style: added shadcn-ui
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
# Created by Vercel CLI
|
||||
NX_DAEMON=""
|
||||
POSTGRES_DATABASE=""
|
||||
POSTGRES_HOST=""
|
||||
@@ -26,4 +27,5 @@ NEWS_LIMIT=""
|
||||
RESEND_KEY=""
|
||||
RESEND_FROM=""
|
||||
SECRET_HASH=""
|
||||
HOME_URL=""
|
||||
HOME_URL=""
|
||||
MAINTENANCE_MODE=0
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
41
components/custom/card.tsx
Normal file
41
components/custom/card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
5
components/custom/error.tsx
Normal file
5
components/custom/error.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
'use client';
|
||||
|
||||
export default function ErrorMessage() {
|
||||
return 'Oops. Something went wrong. Please try later :(';
|
||||
}
|
||||
28
components/custom/footer.tsx
Normal file
28
components/custom/footer.tsx
Normal 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;
|
||||
15
components/custom/link.tsx
Normal file
15
components/custom/link.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { CustomLink } from './customLink';
|
||||
|
||||
export function HomeLink() {
|
||||
return <CustomLink path={`/`} text={`Home`} />;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { HomeLink } from './homeLink';
|
||||
|
||||
export function SuccessComponent(message: string) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Success!</h1>
|
||||
<h3>{message}</h3>
|
||||
<HomeLink />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Html>
|
||||
<Section style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={paragraph}>
|
||||
These were the ids retrieved: {ids.join(', ')}
|
||||
</Text>
|
||||
</Container>
|
||||
</Section>
|
||||
</Html>
|
||||
);
|
||||
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}>
|
||||
{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'
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
43
components/emails/template.tsx
Normal file
43
components/emails/template.tsx
Normal 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',
|
||||
};
|
||||
@@ -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.</>}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
54
components/ui/button.tsx
Normal 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
85
components/ui/card.tsx
Normal 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
176
components/ui/form.tsx
Normal 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
22
components/ui/input.tsx
Normal 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
25
components/ui/label.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
16
package.json
16
package.json
@@ -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
BIN
public/background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 MiB |
45
public/maintenance.html
Normal file
45
public/maintenance.html
Normal 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>
|
||||
@@ -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))'
|
||||
},
|
||||
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: [],
|
||||
plugins: [require('tailwindcss-animate')]
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "ES5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
6
utils/utils.ts
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user