style: added shadcn-ui
This commit is contained in:
@@ -28,7 +28,7 @@ export async function POST(request: Request) {
|
||||
});
|
||||
|
||||
const message: z.infer<typeof ResponseSchema> = {
|
||||
message: `Thank you for confirming the subscripion!`
|
||||
message: `Thank you for confirming the subscription, ${user.email}!`
|
||||
};
|
||||
|
||||
return ApiResponse(200, JSON.stringify(message));
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import NewsletterEmail from '../../../components/emails/newsletter';
|
||||
import NewsletterTemplate from '../../../components/emails/newsletter';
|
||||
import prisma from '../../../prisma/prisma';
|
||||
import { sendEmail } from '../../../utils/sender';
|
||||
import { NewsSchema } from '../../../utils/types';
|
||||
import { NewsDatabaseSchema, NewsSchema } from '../../../utils/types';
|
||||
import { singleNews, topNews } from '../../../utils/urls';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@@ -18,28 +18,28 @@ export async function GET(request: Request) {
|
||||
const newsPromises = topstories
|
||||
.splice(0, Number(process.env.NEWS_LIMIT))
|
||||
.map(async id => {
|
||||
const sourceNews: z.infer<typeof NewsSchema> = await fetch(
|
||||
singleNews(id)
|
||||
).then(res => res.json());
|
||||
const sourceNews = await fetch(singleNews(id)).then(res => res.json());
|
||||
const validation = NewsDatabaseSchema.safeParse(sourceNews);
|
||||
|
||||
return await prisma.news.upsert({
|
||||
create: {
|
||||
...sourceNews,
|
||||
id
|
||||
},
|
||||
update: {
|
||||
...sourceNews
|
||||
},
|
||||
where: {
|
||||
id
|
||||
},
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
});
|
||||
if (validation.success) {
|
||||
const result = await prisma.news.upsert({
|
||||
create: {
|
||||
...validation.data,
|
||||
id
|
||||
},
|
||||
update: {
|
||||
...validation.data
|
||||
},
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
const newsIds = await Promise.all(newsPromises);
|
||||
const news = await Promise.all(newsPromises);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
@@ -58,10 +58,13 @@ export async function GET(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const validRankedNews = news
|
||||
.filter((item): item is z.infer<typeof NewsSchema> => item !== undefined)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
await sendEmail(
|
||||
users.map(user => user.email),
|
||||
`What's new from Hackernews?`,
|
||||
NewsletterEmail(newsIds.map(news => news.id))
|
||||
NewsletterTemplate(validRankedNews)
|
||||
);
|
||||
|
||||
return new NextResponse(`Newsletter sent to ${users.length} addresses.`, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import SubscribeEmail from '../../../components/emails/subscribe';
|
||||
import SubscribeTemplate from '../../../components/emails/subscribe';
|
||||
import prisma from '../../../prisma/prisma';
|
||||
import { ApiResponse } from '../../../utils/apiResponse';
|
||||
import { sendEmail } from '../../../utils/sender';
|
||||
@@ -60,7 +60,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
});
|
||||
|
||||
await sendEmail([email], 'Welcome!', SubscribeEmail(code));
|
||||
await sendEmail([email], SubscribeTemplate(code));
|
||||
|
||||
const message: z.infer<typeof ResponseSchema> = {
|
||||
message: `Thank you! You will now receive an email to ${email} to confirm the subscription.`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import UnsubscribeEmail from '../../../components/emails/unsubscribe';
|
||||
import UnsubscribeTemplate from '../../../components/emails/unsubscribe';
|
||||
import prisma from '../../../prisma/prisma';
|
||||
import { ApiResponse } from '../../../utils/apiResponse';
|
||||
import { sendEmail } from '../../../utils/sender';
|
||||
@@ -31,11 +31,11 @@ export async function POST(request: Request) {
|
||||
}
|
||||
});
|
||||
|
||||
sendEmail([email], 'Unsubscribe confirmation', UnsubscribeEmail());
|
||||
await sendEmail([email], UnsubscribeTemplate());
|
||||
}
|
||||
|
||||
const message: z.infer<typeof ResponseSchema> = {
|
||||
message: `${email} unsubscribed!`
|
||||
message: `${email} unsubscribed.`
|
||||
};
|
||||
|
||||
return ApiResponse(200, JSON.stringify(message));
|
||||
|
||||
@@ -1,5 +1,59 @@
|
||||
import { ConfirmationPage } from '../../components/pages/confirmation';
|
||||
'use client';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { CustomCard } from '../../components/custom/card';
|
||||
import { ResponseSchema } from '../../utils/types';
|
||||
|
||||
export default function Confirmation() {
|
||||
return <ConfirmationPage />;
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const code = searchParams.get('code');
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) {
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
fetch('/api/confirmation', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: code
|
||||
})
|
||||
})
|
||||
.then(async res => {
|
||||
if (!res.ok) {
|
||||
router.push('/');
|
||||
}
|
||||
const response: z.infer<typeof ResponseSchema> = await res.json();
|
||||
return response;
|
||||
})
|
||||
.then(response => {
|
||||
setMessage(response.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [code, router]);
|
||||
|
||||
function render() {
|
||||
if (!loading) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return 'Just a second...';
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomCard
|
||||
style='text-center'
|
||||
title={loading ? 'Verifying' : 'Confirmed!'}
|
||||
content={render()}
|
||||
footer={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
163
app/globals.css
163
app/globals.css
@@ -1,92 +1,77 @@
|
||||
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;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-custom-background bg-size-cover;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import { Analytics } from '@vercel/analytics/react';
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { Inter as FontSans } from 'next/font/google';
|
||||
import { cn } from '../utils/utils';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Hacker News newsletter',
|
||||
description: 'Newsletter delivering the best posts from Hacker News',
|
||||
keywords: 'newsletter, hackernews, technology, coding, programming, news'
|
||||
};
|
||||
|
||||
export const fontSans = FontSans({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans'
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang='en'>
|
||||
<body className={inter.className}>
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<head />
|
||||
<body
|
||||
className={cn(
|
||||
'flex min-h-screen items-center justify-center bg-background font-sans antialiased',
|
||||
fontSans.variable
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<Analytics />
|
||||
</body>
|
||||
|
||||
112
app/page.tsx
112
app/page.tsx
@@ -1,11 +1,111 @@
|
||||
import { CustomLink } from '../components/elements/customLink';
|
||||
'use client';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { CustomCard } from '../components/custom/card';
|
||||
import ErrorMessage from '../components/custom/error';
|
||||
import { Button } from '../components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage
|
||||
} from '../components/ui/form';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { ResponseSchema, SubscribeFormSchema } from '../utils/types';
|
||||
|
||||
export default function Home() {
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const honeypotRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const form = useForm<z.infer<typeof SubscribeFormSchema>>({
|
||||
resolver: zodResolver(SubscribeFormSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
name: ''
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (honeypotRef.current) {
|
||||
honeypotRef.current.style.display = 'none';
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(values: z.infer<typeof SubscribeFormSchema>) {
|
||||
if (values.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: values.email
|
||||
})
|
||||
});
|
||||
|
||||
if (!response?.ok) {
|
||||
throw new Error(`Invalid response: ${response.status}`);
|
||||
}
|
||||
|
||||
const formResponse: z.infer<typeof ResponseSchema> =
|
||||
await response.json();
|
||||
|
||||
setMessage(formResponse.message);
|
||||
setCompleted(true);
|
||||
} catch (error) {
|
||||
console.log('Subscribe error', error);
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (error) {
|
||||
return ErrorMessage();
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='flex items-center justify-center space-x-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder='example@example.com' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type='submit'>Submit</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CustomLink path='/subscribe' text='Subscribe' />
|
||||
<CustomLink path='/unsubscribe' text='Unsubscribe' />
|
||||
<CustomLink path='/privacy' text='Privacy' />
|
||||
</div>
|
||||
<CustomCard
|
||||
style='text-center'
|
||||
title='Hackernews + newsletter'
|
||||
description='Top stories from Hackernews. Once a day. Every day.'
|
||||
content={render()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
import { CustomCard } from '../../components/custom/card';
|
||||
|
||||
export default function Privacy() {
|
||||
return (
|
||||
const body = (
|
||||
<div>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>Last updated: December 03, 2023</p>
|
||||
<p>
|
||||
This Privacy Policy describes Our policies and procedures on the
|
||||
collection, use and disclosure of Your information when You use the
|
||||
@@ -411,4 +412,13 @@ export default function Privacy() {
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomCard
|
||||
title='Privacy Policy'
|
||||
description='Last updated: December 03, 2023'
|
||||
content={body}
|
||||
style='w-2/3'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,16 +9,16 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
priority: 1
|
||||
},
|
||||
{
|
||||
url: `${process.env.HOME_URL!}/subscribe`,
|
||||
url: `${process.env.HOME_URL!}/privacy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.8
|
||||
priority: 0.5
|
||||
},
|
||||
{
|
||||
url: `${process.env.HOME_URL!}/unsubscribe`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.5
|
||||
priority: 0.2
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SubscribeForm } from '../../components/pages/subscribe';
|
||||
|
||||
export default function Subscribe() {
|
||||
return <SubscribeForm />;
|
||||
}
|
||||
@@ -1,5 +1,111 @@
|
||||
import { UnsubscribeForm } from '../../components/pages/unsubscribe';
|
||||
'use client';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { CustomCard } from '../../components/custom/card';
|
||||
import ErrorMessage from '../../components/custom/error';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage
|
||||
} from '../../components/ui/form';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { ResponseSchema, UnsubscribeFormSchema } from '../../utils/types';
|
||||
|
||||
export default function Unsubscribe() {
|
||||
return <UnsubscribeForm />;
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const honeypotRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const form = useForm<z.infer<typeof UnsubscribeFormSchema>>({
|
||||
resolver: zodResolver(UnsubscribeFormSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
name: ''
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (honeypotRef.current) {
|
||||
honeypotRef.current.style.display = 'none';
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(values: z.infer<typeof UnsubscribeFormSchema>) {
|
||||
if (values.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: values.email
|
||||
})
|
||||
});
|
||||
|
||||
if (!response?.ok) {
|
||||
throw new Error(`Invalid response: ${response.status}`);
|
||||
}
|
||||
|
||||
const formResponse: z.infer<typeof ResponseSchema> =
|
||||
await response.json();
|
||||
|
||||
setMessage(formResponse.message);
|
||||
setCompleted(true);
|
||||
} catch (error) {
|
||||
console.log('Unsubscribe error', error);
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (error) {
|
||||
return ErrorMessage();
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='flex items-center justify-center space-x-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder='example@example.com' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type='submit'>Submit</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomCard
|
||||
style='text-center'
|
||||
title='Unsubscribe'
|
||||
description='You sure you want to leave? :('
|
||||
content={render()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user