style: added shadcn-ui

This commit is contained in:
Riccardo
2023-12-05 20:13:29 +01:00
parent 1b0919a460
commit 78de374cba
45 changed files with 1463 additions and 1340 deletions

View File

@@ -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));

View File

@@ -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.`, {

View File

@@ -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.`

View File

@@ -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));

View File

@@ -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}
/>
);
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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()}
/>
);
}

View File

@@ -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'
/>
);
}

View File

@@ -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
}
];
}

View File

@@ -1,5 +0,0 @@
import { SubscribeForm } from '../../components/pages/subscribe';
export default function Subscribe() {
return <SubscribeForm />;
}

View File

@@ -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()}
/>
);
}