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

@@ -1,3 +1,4 @@
# Created by Vercel CLI
NX_DAEMON=""
POSTGRES_DATABASE=""
POSTGRES_HOST=""
@@ -27,3 +28,4 @@ RESEND_KEY=""
RESEND_FROM=""
SECRET_HASH=""
HOME_URL=""
MAINTENANCE_MODE=0

View File

@@ -2,8 +2,7 @@
## To do
- A proper UI
- Email templates
- Polish the UI
- Captcha?
- Tests
@@ -38,3 +37,9 @@ Generate Prisma client
```bash
yarn prisma:generate
```
Reset Prisma database
```bash
yarn db:reset
```

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({
if (validation.success) {
const result = await prisma.news.upsert({
create: {
...sourceNews,
...validation.data,
id
},
update: {
...sourceNews
...validation.data
},
where: {
id
},
select: {
id: true
}
});
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,
@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%;
}
}
@layer base {
* {
@apply border-border;
}
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;
@apply bg-background text-foreground;
@apply bg-custom-background bg-size-cover;
}
.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;
}

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 (
<div>
<CustomLink path='/subscribe' text='Subscribe' />
<CustomLink path='/unsubscribe' text='Unsubscribe' />
<CustomLink path='/privacy' text='Privacy' />
</div>
<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='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()}
/>
);
}

View File

@@ -0,0 +1,41 @@
import { ReactNode } from 'react';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '../../components/ui/card';
import Footer from './footer';
type CustomCardProps = {
title: string;
description?: string;
content: ReactNode;
style?: string;
footer?: boolean;
};
export const CustomCard = ({
title,
description,
content,
style,
footer = true,
}: CustomCardProps) => {
return (
<Card className={style ?? 'w-full sm:w-2/3 md:w-2/5 lg:w-1/3 xl:w-1/4'}>
<CardHeader className="text-center">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>{content}</CardContent>
{footer && (
<CardFooter className="flex justify-center space-x-4">
<Footer />
</CardFooter>
)}
</Card>
);
};

View File

@@ -0,0 +1,5 @@
'use client';
export default function ErrorMessage() {
return 'Oops. Something went wrong. Please try later :(';
}

View File

@@ -0,0 +1,28 @@
import { usePathname } from 'next/navigation';
import { Link } from './link';
const links = [
{ name: 'Subscribe', path: '/' },
{ name: 'Privacy Policy', path: '/privacy' },
];
function Footer() {
const pathname = usePathname();
return (
<ul className="flex justify-center space-x-4">
{links.map(
(link) =>
pathname !== link.path &&
!(pathname === '/confirmation' && link.path === '/subscribe') && (
<Link key={link.path} path={link.path} text={link.name} />
)
)}
{pathname === '/privacy' && (
<Link path="/unsubscribe" text="Unsubscribe" />
)}
</ul>
);
}
export default Footer;

View File

@@ -0,0 +1,15 @@
import NextLink from 'next/link';
import { Button } from '../ui/button';
type LinkProps = {
path: string;
text: string;
};
export function Link({ path, text }: LinkProps) {
return (
<Button asChild>
<NextLink href={path}>{text}</NextLink>
</Button>
);
}

View File

@@ -1,14 +0,0 @@
import Link from 'next/link';
type CustomLinkProps = {
path: string;
text: string;
};
export function CustomLink({ path, text }: CustomLinkProps) {
return (
<Link href={path} className="overflow-hidden rounded-md">
<h1>{text}</h1>
</Link>
);
}

View File

@@ -1,10 +0,0 @@
import { HomeLink } from './homeLink';
export default function ErrorComponent() {
return (
<div>
<h1>Oops. Something went wrong. Please try later :(</h1>
<HomeLink />
</div>
);
}

View File

@@ -1,5 +0,0 @@
import { CustomLink } from './customLink';
export function HomeLink() {
return <CustomLink path={`/`} text={`Home`} />;
}

View File

@@ -1,11 +0,0 @@
import { HomeLink } from './homeLink';
export function SuccessComponent(message: string) {
return (
<div>
<h1>Success!</h1>
<h3>{message}</h3>
<HomeLink />
</div>
);
}

View File

@@ -2,18 +2,65 @@ import { Container } from '@react-email/container';
import { Html } from '@react-email/html';
import { Section } from '@react-email/section';
import { Text } from '@react-email/text';
import { container, main, paragraph } from './utils/styling';
import { z } from 'zod';
import { NewsSchema } from '../../utils/types';
export default function NewsletterEmail(ids: number[]) {
return (
export default function NewsletterTemplate(
stories: z.infer<typeof NewsSchema>[]
) {
return {
subject: `What's new from Hackernews?`,
template: (
<Html>
<Section style={main}>
<Container style={container}>
<Text style={paragraph}>
These were the ids retrieved: {ids.join(', ')}
{stories.map(story => {
return (
<div
key={story.id}
style={{
padding: '10px',
border: '1px solid #ccc',
boxShadow: '2px 2px 5px rgba(0, 0, 0, 0.1)'
}}
>
<h1>{story.title}</h1>
<p>{story.by}</p>
{story.text && (
<p
dangerouslySetInnerHTML={{
__html:
story.text.length > 500
? story.text.substring(0, 500) + '...'
: story.text
}}
/>
)}
{story.url && <a href={story.url}>Read more</a>}
</div>
);
})}
</Text>
</Container>
</Section>
</Html>
);
)
};
}
const main = {
backgroundColor: '#ffffff'
};
const container = {
margin: '0 auto',
padding: '20px 0 48px',
width: '580px'
};
const paragraph = {
fontSize: '18px',
lineHeight: '1.4',
color: '#484848'
};

View File

@@ -1,23 +1,22 @@
import { Container } from '@react-email/container';
import { Html } from '@react-email/html';
import { Section } from '@react-email/section';
import { Text } from '@react-email/text';
import { container, main, paragraph } from './utils/styling';
import Email from './template';
export default function SubscribeEmail(code: string) {
return (
<Html>
<Section style={main}>
<Container style={container}>
<Text style={paragraph}>
To confirm the subscription, please click{' '}
export default function ConfirmationEmail(code: string) {
return {
subject: 'Welcome!',
template: (
<Email
title={'Welcome!'}
body={
<>
Thank you for subscribing. Please confirm your email address by
clicking{' '}
<a href={`${process.env.HOME_URL}/confirmation?code=${code}`}>
here
</a>
.
</Text>
</Container>
</Section>
</Html>
);
</>
}
/>
)
};
}

View File

@@ -0,0 +1,43 @@
import { Container } from '@react-email/container';
import { Html } from '@react-email/html';
import { Section } from '@react-email/section';
import { Text } from '@react-email/text';
type EmailProps = {
title: string;
body: JSX.Element;
};
export default function Email({ title, body }: EmailProps) {
return (
<Html>
<Section style={main}>
<Container style={container}>
<Text style={heading}>{title}</Text>
<Text style={paragraph}>{body}</Text>
</Container>
</Section>
</Html>
);
}
const main = {
backgroundColor: '#ffffff',
};
const container = {
margin: '0 auto',
padding: '20px 0 48px',
width: '580px',
};
const heading = {
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '16px',
};
const paragraph = {
fontSize: '16px',
marginBottom: '16px',
};

View File

@@ -1,19 +1,13 @@
import { Container } from '@react-email/container';
import { Html } from '@react-email/html';
import { Section } from '@react-email/section';
import { Text } from '@react-email/text';
import { container, main, paragraph } from './utils/styling';
import Email from './template';
export default function UnsubscribeEmail() {
return (
<Html>
<Section style={main}>
<Container style={container}>
<Text style={paragraph}>
You have unsubscribed from the newsletter.
</Text>
</Container>
</Section>
</Html>
);
export default function UnsubscribeTemplate() {
return {
subject: 'Unsubscribe confirmation',
template: (
<Email
title="We're sad you're leaving :("
body={<>You have unsubscribed from the newsletter.</>}
/>
),
};
}

View File

@@ -1,15 +0,0 @@
export const main = {
backgroundColor: '#ffffff'
};
export const container = {
margin: '0 auto',
padding: '20px 0 48px',
width: '580px'
};
export const paragraph = {
fontSize: '18px',
lineHeight: '1.4',
color: '#484848'
};

View File

@@ -1,58 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { z } from 'zod';
import { ResponseSchema } from '../../utils/types';
import { HomeLink } from '../elements/homeLink';
export const 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]);
if (!loading) {
return (
<div>
<h1>{message}</h1>
<HomeLink />
</div>
);
}
return (
<div>
<h1>Verifying...</h1>
<HomeLink />
</div>
);
};

View File

@@ -1,87 +0,0 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { z } from 'zod';
import { ResponseSchema } from '../../utils/types';
import { CustomLink } from '../elements/customLink';
import ErrorComponent from '../elements/error';
import { HomeLink } from '../elements/homeLink';
import { SuccessComponent } from '../elements/success';
export const SubscribeForm = () => {
const [completed, setCompleted] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState(false);
const honeypotRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (honeypotRef.current) {
honeypotRef.current.style.display = 'none';
}
}, []);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
if (data.get('name')) {
return;
}
try {
const response = await fetch('/api/subscribe', {
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();
setMessage(formResponse.message);
setCompleted(true);
} catch (error) {
console.log('Subscribe error', error);
setError(true);
}
}
if (error) {
return ErrorComponent();
}
if (completed) {
return SuccessComponent(message);
}
return (
<div>
<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>
<input type="text" name="name" ref={honeypotRef} />
<div className="button block">
<button type="submit">Subscribe</button>
</div>
</form>
<HomeLink />
<CustomLink path={`/unsubscribe`} text="Unsubscribe" />
</div>
);
};

View File

@@ -1,87 +0,0 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { z } from 'zod';
import { ResponseSchema } from '../../utils/types';
import { CustomLink } from '../elements/customLink';
import ErrorComponent from '../elements/error';
import { HomeLink } from '../elements/homeLink';
import { SuccessComponent } from '../elements/success';
export const UnsubscribeForm = () => {
const [completed, setCompleted] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState(false);
const honeypotRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (honeypotRef.current) {
honeypotRef.current.style.display = 'none';
}
}, []);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
if (data.get('name')) {
return;
}
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();
setMessage(formResponse.message);
setCompleted(true);
} catch (error) {
console.log('Unsubscribe error', error);
setError(true);
}
}
if (error) {
return ErrorComponent();
}
if (completed) {
return SuccessComponent(message);
}
return (
<div>
<form className="container" onSubmit={handleSubmit}>
<h1>Unsubscribe from 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>
<input type="text" name="name" ref={honeypotRef} />
<div className="button block">
<button type="submit">Unsubscribe</button>
</div>
</form>
<HomeLink />
<CustomLink path={`/subscribe`} text="Subscribe" />
</div>
);
};

54
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '../../utils/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
export type ButtonProps = {
asChild?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

85
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,85 @@
import * as React from 'react';
import { cn } from '../../utils/utils';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'bg-card text-card-foreground rounded-lg border shadow-sm',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
};

176
components/ui/form.tsx Normal file
View File

@@ -0,0 +1,176 @@
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext
} from 'react-hook-form';
import { Label } from '../../components/ui/label';
import { cn } from '../../utils/utils';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-destructive text-sm font-medium', className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = 'FormMessage';
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField
};

22
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '../../utils/utils';
const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = 'Input';
export { Input };

25
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,25 @@
'use client';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '../../utils/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -1,6 +1,17 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true
reactStrictMode: true,
redirects() {
return [
process.env.MAINTENANCE_MODE === '1'
? {
source: '/((?!maintenance).*)',
destination: '/maintenance.html',
permanent: false
}
: null
].filter(Boolean);
}
};
module.exports = nextConfig;

View File

@@ -14,7 +14,8 @@
"vercel:link": "vercel link",
"vercel:env": "vercel env pull .env",
"prisma:push": "npx prisma db push",
"prisma:generate": "npx prisma generate"
"prisma:generate": "npx prisma generate",
"prisma:reset": "npx prisma db push --force-reset"
},
"dependencies": {
"@prisma/client": "^5.6.0",
@@ -24,14 +25,19 @@
"react": "^18",
"react-dom": "^18",
"zod": "^3.22.4",
"zod-validation-error": "^2.1.0",
"@react-email/container": "^0.0.10",
"@react-email/html": "^0.0.6",
"@react-email/section": "^0.0.10",
"@react-email/text": "^0.0.6",
"crypto": "^1.0.1",
"react-email": "^1.9.5",
"resend": "^2.0.0"
"resend": "^2.0.0",
"@hookform/resolvers": "^3.3.2",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"react-hook-form": "^7.48.2",
"tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@commitlint/cli": "^18.4.3",

BIN
public/background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

45
public/maintenance.html Normal file
View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Maintenance</title>
<style>
body {
background: url('/background.jpg') no-repeat center center fixed;
-webkit-background-size: cover;
-moz-background-size: cover;
-o-background-size: cover;
background-size: cover;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.card {
width: 100%;
max-width: 600px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
font-family: Arial, sans-serif;
background-color: rgba(255, 255, 255, 0.8);
}
.card h1 {
font-size: 2em;
color: #333;
}
.card p {
font-size: 1em;
color: #666;
}
</style>
</head>
<body>
<div class="card">
<h1>Maintenance</h1>
<p>We are doing stuff. Please come back later...</p>
</div>
</body>
</html>

View File

@@ -1,20 +1,87 @@
import type { Config } from "tailwindcss";
import { fontFamily } from 'tailwindcss/defaultTheme';
const config: Config = {
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}'
],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
'custom-background': "url('/background.jpg')"
},
backgroundSize: {
'size-cover': '100% auto'
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
plugins: [],
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' }
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 }
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
},
fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans]
}
}
},
plugins: [require('tailwindcss-animate')]
};
export default config;

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "ES5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,

View File

@@ -1,9 +1,13 @@
import { Resend } from 'resend';
type EmailTemplate = {
subject: string;
template: JSX.Element;
};
export async function sendEmail(
to: string[],
subject: string,
template: JSX.Element
{ subject, template }: EmailTemplate
) {
const resend = new Resend(process.env.RESEND_KEY);
@@ -21,4 +25,6 @@ export async function sendEmail(
} catch (error) {
console.log(error);
}
console.log('Email sent', subject, to.length);
}

View File

@@ -6,6 +6,7 @@ export const ResponseSchema = z.object({
export const SubscribeFormSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
});
export const ConfirmationSchema = z.object({
@@ -14,9 +15,10 @@ export const ConfirmationSchema = z.object({
export const UnsubscribeFormSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
});
export const NewsSchema = z.object({
export const NewsDatabaseSchema = z.object({
id: z.number(),
title: z.string(),
text: z.string().optional(),
@@ -26,3 +28,15 @@ export const NewsSchema = z.object({
url: z.string().optional(),
score: z.number(),
});
export const NewsSchema = z.object({
id: z.number(),
title: z.string(),
text: z.string().nullable(),
type: z.string(),
by: z.string(),
time: z.number(),
url: z.string().nullable(),
score: z.number(),
createdAt: z.date(),
});

6
utils/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

1127
yarn.lock

File diff suppressed because it is too large Load Diff