diff --git a/.env.example b/.env.example index 23aa94e..ca96795 100644 --- a/.env.example +++ b/.env.example @@ -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="" \ No newline at end of file +HOME_URL="" +MAINTENANCE_MODE=0 \ No newline at end of file diff --git a/README.md b/README.md index 21dbfcd..9b3c730 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/app/api/confirmation/route.ts b/app/api/confirmation/route.ts index 446d771..242b5cf 100644 --- a/app/api/confirmation/route.ts +++ b/app/api/confirmation/route.ts @@ -28,7 +28,7 @@ export async function POST(request: Request) { }); const message: z.infer = { - message: `Thank you for confirming the subscripion!` + message: `Thank you for confirming the subscription, ${user.email}!` }; return ApiResponse(200, JSON.stringify(message)); diff --git a/app/api/cron/route.ts b/app/api/cron/route.ts index 0e35159..6e63b64 100644 --- a/app/api/cron/route.ts +++ b/app/api/cron/route.ts @@ -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 = 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 => 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.`, { diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts index e23f749..ce14910 100644 --- a/app/api/subscribe/route.ts +++ b/app/api/subscribe/route.ts @@ -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 = { message: `Thank you! You will now receive an email to ${email} to confirm the subscription.` diff --git a/app/api/unsubscribe/route.ts b/app/api/unsubscribe/route.ts index b94048e..caad05e 100644 --- a/app/api/unsubscribe/route.ts +++ b/app/api/unsubscribe/route.ts @@ -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 = { - message: `${email} unsubscribed!` + message: `${email} unsubscribed.` }; return ApiResponse(200, JSON.stringify(message)); diff --git a/app/confirmation/page.tsx b/app/confirmation/page.tsx index cada941..7022498 100644 --- a/app/confirmation/page.tsx +++ b/app/confirmation/page.tsx @@ -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 ; + 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 = 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 ( + + ); } diff --git a/app/globals.css b/app/globals.css index b3195ae..9eae9e9 100644 --- a/app/globals.css +++ b/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; + } } diff --git a/app/layout.tsx b/app/layout.tsx index 0cc5d58..9c8b015 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - - + + + {children} diff --git a/app/page.tsx b/app/page.tsx index a5db071..036699b 100644 --- a/app/page.tsx +++ b/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(null); + + const form = useForm>({ + resolver: zodResolver(SubscribeFormSchema), + defaultValues: { + email: '', + name: '' + } + }); + + useEffect(() => { + if (honeypotRef.current) { + honeypotRef.current.style.display = 'none'; + } + }, []); + + async function handleSubmit(values: z.infer) { + 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 = + 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 ( +
+ + ( + + + + + + + )} + /> + + + + ); + } + return ( -
- - - -
+ ); } diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 3d88d4e..501d423 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -1,8 +1,9 @@ +'use client'; +import { CustomCard } from '../../components/custom/card'; + export default function Privacy() { - return ( + const body = (
-

Privacy Policy

-

Last updated: December 03, 2023

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() {

); + + return ( + + ); } diff --git a/app/sitemap.ts b/app/sitemap.ts index 659442e..017c8bf 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -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 } ]; } diff --git a/app/subscribe/page.tsx b/app/subscribe/page.tsx deleted file mode 100644 index c0f282d..0000000 --- a/app/subscribe/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SubscribeForm } from '../../components/pages/subscribe'; - -export default function Subscribe() { - return ; -} diff --git a/app/unsubscribe/page.tsx b/app/unsubscribe/page.tsx index 12c744d..47c21ea 100644 --- a/app/unsubscribe/page.tsx +++ b/app/unsubscribe/page.tsx @@ -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 ; + const [completed, setCompleted] = useState(false); + const [message, setMessage] = useState(''); + const [error, setError] = useState(false); + const honeypotRef = useRef(null); + + const form = useForm>({ + resolver: zodResolver(UnsubscribeFormSchema), + defaultValues: { + email: '', + name: '' + } + }); + + useEffect(() => { + if (honeypotRef.current) { + honeypotRef.current.style.display = 'none'; + } + }, []); + + async function handleSubmit(values: z.infer) { + 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 = + 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 ( +
+ + ( + + + + + + + )} + /> + + + + ); + } + + return ( + + ); } diff --git a/components/custom/card.tsx b/components/custom/card.tsx new file mode 100644 index 0000000..76ae130 --- /dev/null +++ b/components/custom/card.tsx @@ -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 ( + + + {title} + {description} + + {content} + {footer && ( + +