feat: base pages and news fetching with cron job
This commit is contained in:
68
app/api/cron/route.ts
Normal file
68
app/api/cron/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import prisma from '../../../prisma/prisma';
|
||||
import { NewsSchema } from '../../utils/types';
|
||||
import { singleNews, topNews } from '../../utils/urls';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (
|
||||
request.headers.get('Authorization') !== `Bearer ${process.env.CRON_SECRET}`
|
||||
) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const response = await hackernewsApi();
|
||||
|
||||
return new NextResponse(JSON.stringify(response), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
async function hackernewsApi() {
|
||||
const topstories: number[] = await fetch(topNews).then((res) => res.json());
|
||||
|
||||
console.log('topstories', topstories);
|
||||
|
||||
const newsPromises = topstories
|
||||
.splice(0, Number(process.env.NEWS_LIMIT))
|
||||
.map(async (id) => {
|
||||
console.log('id', id);
|
||||
const sourceNews: z.infer<typeof NewsSchema> = await fetch(
|
||||
singleNews(id)
|
||||
).then((res) => res.json());
|
||||
|
||||
console.log('sourceNews', sourceNews);
|
||||
|
||||
return await prisma.news.upsert({
|
||||
create: {
|
||||
id,
|
||||
title: sourceNews.title,
|
||||
text: sourceNews.text,
|
||||
type: sourceNews.type,
|
||||
by: sourceNews.by,
|
||||
time: sourceNews.time,
|
||||
url: sourceNews.url,
|
||||
score: sourceNews.score,
|
||||
},
|
||||
update: {
|
||||
title: sourceNews.title,
|
||||
text: sourceNews.text,
|
||||
type: sourceNews.type,
|
||||
by: sourceNews.by,
|
||||
time: sourceNews.time,
|
||||
url: sourceNews.url,
|
||||
score: sourceNews.score,
|
||||
},
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const newsIds = await Promise.all(newsPromises);
|
||||
|
||||
return newsIds.map((news) => news.id);
|
||||
}
|
||||
32
app/api/subscribe/route.ts
Normal file
32
app/api/subscribe/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
import prisma from '../../../prisma/prisma';
|
||||
import { ResponseSchema, SubscribeFormSchema } from '../../utils/types';
|
||||
|
||||
export const dynamic = 'force-dynamic'; // defaults to force-static
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const validation = SubscribeFormSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
return new Response('Bad request', { status: 400 });
|
||||
}
|
||||
|
||||
const { email, targetingAllowed } = validation.data;
|
||||
|
||||
await prisma.user.upsert({
|
||||
create: {
|
||||
email,
|
||||
targetingAllowed,
|
||||
},
|
||||
update: {
|
||||
targetingAllowed,
|
||||
},
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
const message: z.infer<typeof ResponseSchema> = {
|
||||
message: `${email} subscribed!`,
|
||||
};
|
||||
return new Response(JSON.stringify(message), { status: 200 });
|
||||
}
|
||||
29
app/api/unsubscribe/route.ts
Normal file
29
app/api/unsubscribe/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod';
|
||||
import prisma from '../../../prisma/prisma';
|
||||
import { ResponseSchema, UnsubscribeFormSchema } from '../../utils/types';
|
||||
|
||||
export const dynamic = 'force-dynamic'; // defaults to force-static
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const validation = UnsubscribeFormSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
return new Response('Bad request', { status: 400 });
|
||||
}
|
||||
|
||||
const { email } = validation.data;
|
||||
|
||||
try {
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
const message: z.infer<typeof ResponseSchema> = {
|
||||
message: `${email} unsubscribe!`,
|
||||
};
|
||||
return new Response(JSON.stringify(message), { status: 200 });
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
79
app/globals.css
Normal file
79
app/globals.css
Normal file
@@ -0,0 +1,79 @@
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
background: #1e1e1e;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
color: rgb(243, 241, 239);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.container {
|
||||
font-size: 1.3rem;
|
||||
border-radius: 10px;
|
||||
width: 85%;
|
||||
padding: 50px;
|
||||
box-shadow: 0 54px 55px rgb(78 78 78 / 25%), 0 -12px 30px rgb(78 78 78 / 25%),
|
||||
0 4px 6px rgb(78 78 78 / 25%), 0 12px 13px rgb(78 78 78 / 25%),
|
||||
0 -3px 5px rgb(78 78 78 / 25%);
|
||||
}
|
||||
|
||||
.container input {
|
||||
font-size: 1.2rem;
|
||||
margin: 10px 0 10px 0px;
|
||||
border-color: rgb(31, 28, 28);
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
background-color: #e8f0fe;
|
||||
}
|
||||
|
||||
.container textarea {
|
||||
margin: 10px 0 10px 0px;
|
||||
padding: 5px;
|
||||
border-color: rgb(31, 28, 28);
|
||||
border-radius: 5px;
|
||||
background-color: #e8f0fe;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.container h1 {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.name div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.block button {
|
||||
padding: 10px;
|
||||
font-size: 20px;
|
||||
width: 30%;
|
||||
border: 3px solid black;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: none;
|
||||
}
|
||||
22
app/layout.tsx
Normal file
22
app/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Next App',
|
||||
description: 'Generated by create next app',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
16
app/page.tsx
Normal file
16
app/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '../components/Button';
|
||||
import { VerticalLayout } from '../components/VerticalLayout';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<VerticalLayout>
|
||||
<h1>Home</h1>
|
||||
<Button label="Subscribe" onClick={() => router.push('/subscribe')} />
|
||||
<Button label="Unsubscribe" onClick={() => router.push('/unsubscribe')} />
|
||||
</VerticalLayout>
|
||||
);
|
||||
}
|
||||
79
app/subscribe/page.tsx
Normal file
79
app/subscribe/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '../../components/Button';
|
||||
import { VerticalLayout } from '../../components/VerticalLayout';
|
||||
import { ResponseSchema } from './../utils/types';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: data.get('email'),
|
||||
targetingAllowed: isChecked,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response?.ok) {
|
||||
throw new Error(`Invalid response: ${response.status}`);
|
||||
}
|
||||
|
||||
const formResponse: z.infer<typeof ResponseSchema> =
|
||||
await response.json();
|
||||
|
||||
router.push('/success');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
alert('Error');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCheckboxChange() {
|
||||
setIsChecked(!isChecked);
|
||||
}
|
||||
|
||||
return (
|
||||
<VerticalLayout>
|
||||
<form className="container" onSubmit={handleSubmit}>
|
||||
<h1>Subscribe to newsletter</h1>
|
||||
<div className="email block">
|
||||
<label htmlFor="frm-email">Email</label>
|
||||
<input
|
||||
placeholder="example@email.com"
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="checkbox block">
|
||||
<label htmlFor="frm-checkbox">Allow advertising</label>
|
||||
<input
|
||||
id="targetingAllowed"
|
||||
type="checkbox"
|
||||
name="targetingAllowed"
|
||||
checked={isChecked}
|
||||
onChange={handleCheckboxChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="button block">
|
||||
<button type="submit">Subscribe</button>
|
||||
</div>
|
||||
</form>
|
||||
<Button label="Home" onClick={() => router.push('/')} />
|
||||
<Button label="Unsubscribe" onClick={() => router.push('/unsubscribe')} />
|
||||
</VerticalLayout>
|
||||
);
|
||||
}
|
||||
14
app/success/page.tsx
Normal file
14
app/success/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { Button } from '../../components/Button';
|
||||
import { VerticalLayout } from '../../components/VerticalLayout';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<VerticalLayout>
|
||||
<h1>Success!</h1>
|
||||
<Button label="Home" onClick={() => router.push('/')} />
|
||||
</VerticalLayout>
|
||||
);
|
||||
}
|
||||
63
app/unsubscribe/page.tsx
Normal file
63
app/unsubscribe/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '../../components/Button';
|
||||
import { VerticalLayout } from '../../components/VerticalLayout';
|
||||
import { ResponseSchema } from './../utils/types';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: data.get('email'),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response?.ok) {
|
||||
throw new Error(`Invalid response: ${response.status}`);
|
||||
}
|
||||
|
||||
const formResponse: z.infer<typeof ResponseSchema> =
|
||||
await response.json();
|
||||
|
||||
router.push('/success');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
alert('Error');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<VerticalLayout>
|
||||
<form className="container" onSubmit={handleSubmit}>
|
||||
<h1>Unsubscribe newsletter</h1>
|
||||
<div className="email block">
|
||||
<label htmlFor="frm-email">Email</label>
|
||||
<input
|
||||
placeholder="example@email.com"
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="button block">
|
||||
<button type="submit">Unsubscribe</button>
|
||||
</div>
|
||||
</form>
|
||||
<Button label="Home" onClick={() => router.push('/')} />
|
||||
<Button label="Subscribe" onClick={() => router.push('/subscribe')} />
|
||||
</VerticalLayout>
|
||||
);
|
||||
}
|
||||
25
app/utils/types.ts
Normal file
25
app/utils/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export const SubscribeFormSchema = z.object({
|
||||
email: z.string().email(),
|
||||
targetingAllowed: z.boolean(),
|
||||
});
|
||||
|
||||
export const UnsubscribeFormSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const NewsSchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
text: z.string().optional(),
|
||||
type: z.string(),
|
||||
by: z.string(),
|
||||
time: z.number(),
|
||||
url: z.string().optional(),
|
||||
score: z.number(),
|
||||
});
|
||||
3
app/utils/urls.ts
Normal file
3
app/utils/urls.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const topNews = 'https://hacker-news.firebaseio.com/v0/topstories.json';
|
||||
export const singleNews = (id: number) =>
|
||||
`https://hacker-news.firebaseio.com/v0/item/${id}.json`;
|
||||
Reference in New Issue
Block a user