From acc10bf5fdd8487ce411591efcbf9dcffcfa3161 Mon Sep 17 00:00:00 2001 From: Riccardo Senica <46839416+RiccardoSenica@users.noreply.github.com> Date: Tue, 4 Jun 2024 18:04:54 +0800 Subject: [PATCH] refactor: improve news and email handling, style, folder structure (#16) --- .env.example | 22 +- .eslintrc.json | 4 +- .gitignore | 3 + .husky/pre-commit | 2 +- .yarnrc.yml | 4 + README.md | 5 +- app/api/confirmation/route.ts | 79 +- app/api/import/route.ts | 101 +- app/api/mailing/route.ts | 169 ++- app/api/news/route.ts | 42 +- app/api/subscribe/route.ts | 159 +- app/api/unsubscribe/route.ts | 93 +- app/confirmation/page.tsx | 60 +- app/globals.css | 53 +- app/layout.tsx | 10 +- app/page.tsx | 64 +- app/privacy/page.tsx | 48 +- app/robots.ts | 2 +- app/unsubscribe/page.tsx | 69 +- components/{ui/button.tsx => Button.tsx} | 2 +- components/{ui/card.tsx => Card.tsx} | 14 +- .../{custom/card.tsx => CustomCard.tsx} | 43 +- .../{custom/customLink.tsx => CustomLink.tsx} | 8 +- .../{custom/error.tsx => ErrorMessage.tsx} | 0 components/{custom/footer.tsx => Footer.tsx} | 10 +- components/{ui/input.tsx => Input.tsx} | 4 +- components/{ui/label.tsx => Label.tsx} | 2 +- components/custom/tiles/components/tile.tsx | 48 - .../custom/tiles/components/tileContent.tsx | 48 - .../Confirmation.tsx} | 4 +- .../newsletter.tsx => email/Newsletter.tsx} | 17 +- .../template.tsx => email/Template.tsx} | 16 +- .../unsubscribe.tsx => email/Unsubscribe.tsx} | 4 +- .../components/Footer.tsx} | 2 +- .../note.tsx => email/components/Note.tsx} | 6 +- components/form/FormControl.tsx | 26 + components/form/FormDescription.tsx | 20 + components/form/FormLabel.tsx | 22 + components/form/FormMessage.tsx | 28 + .../tiles/tiles.tsx => tiles/Tiles.tsx} | 79 +- components/tiles/components/Tile.tsx | 68 + components/tiles/components/TileContent.tsx | 41 + components/ui/form.tsx | 177 --- contexts/FormField/FormFieldContext.ts | 13 + contexts/FormField/FormFieldProvider.tsx | 20 + contexts/FormItem/FormItemContext.ts | 9 + contexts/FormItem/FormItemProvider.tsx | 18 + hooks/useFormField.tsx | 29 + package.json | 13 +- .../migration.sql | 37 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 13 +- public/maintenance.html | 4 +- tailwind.config.ts | 18 +- tsconfig.json | 9 +- utils/apiResponse.ts | 7 +- .../emails/helpers => utils}/sayings.ts | 0 utils/sender.ts | 66 +- utils/statusCodes.ts | 6 + utils/urls.ts | 6 +- utils/{schemas.ts => validationSchemas.ts} | 18 +- yarn.lock | 1323 ++++++++--------- 62 files changed, 1737 insertions(+), 1553 deletions(-) rename components/{ui/button.tsx => Button.tsx} (92%) rename components/{ui/card.tsx => Card.tsx} (86%) rename components/{custom/card.tsx => CustomCard.tsx} (50%) rename components/{custom/customLink.tsx => CustomLink.tsx} (57%) rename components/{custom/error.tsx => ErrorMessage.tsx} (100%) rename components/{custom/footer.tsx => Footer.tsx} (85%) rename components/{ui/input.tsx => Input.tsx} (52%) rename components/{ui/label.tsx => Label.tsx} (94%) delete mode 100644 components/custom/tiles/components/tile.tsx delete mode 100644 components/custom/tiles/components/tileContent.tsx rename components/{emails/confirmation.tsx => email/Confirmation.tsx} (92%) rename components/{emails/newsletter.tsx => email/Newsletter.tsx} (85%) rename components/{emails/template.tsx => email/Template.tsx} (68%) rename components/{emails/unsubscribe.tsx => email/Unsubscribe.tsx} (93%) rename components/{emails/components/footer.tsx => email/components/Footer.tsx} (95%) rename components/{emails/components/note.tsx => email/components/Note.tsx} (77%) create mode 100644 components/form/FormControl.tsx create mode 100644 components/form/FormDescription.tsx create mode 100644 components/form/FormLabel.tsx create mode 100644 components/form/FormMessage.tsx rename components/{custom/tiles/tiles.tsx => tiles/Tiles.tsx} (52%) create mode 100644 components/tiles/components/Tile.tsx create mode 100644 components/tiles/components/TileContent.tsx delete mode 100644 components/ui/form.tsx create mode 100644 contexts/FormField/FormFieldContext.ts create mode 100644 contexts/FormField/FormFieldProvider.tsx create mode 100644 contexts/FormItem/FormItemContext.ts create mode 100644 contexts/FormItem/FormItemProvider.tsx create mode 100644 hooks/useFormField.tsx create mode 100644 prisma/migrations/20240530015352_add_user_resend_id/migration.sql create mode 100644 prisma/migrations/migration_lock.toml rename {components/emails/helpers => utils}/sayings.ts (100%) create mode 100644 utils/statusCodes.ts rename utils/{schemas.ts => validationSchemas.ts} (66%) diff --git a/.env.example b/.env.example index bb50bb8..65d88e4 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,13 @@ # Created by Vercel CLI +ADMIN_EMAIL="" +CRON_SECRET="" +HOME_URL="" +MAINTENANCE_MODE="" +GOOGLE_ANALYTICS="" +NEWS_LIMIT="" +NEXT_PUBLIC_BRAND_COUNTRY="" +NEXT_PUBLIC_BRAND_EMAIL="" +NEXT_PUBLIC_BRAND_NAME="" NX_DAEMON="" POSTGRES_DATABASE="" POSTGRES_HOST="" @@ -7,6 +16,10 @@ POSTGRES_PRISMA_URL="" POSTGRES_URL="" POSTGRES_URL_NON_POOLING="" POSTGRES_USER="" +RESEND_AUDIENCE="" +RESEND_FROM="" +RESEND_KEY="" +SECRET_HASH="" TURBO_REMOTE_ONLY="" TURBO_RUN_SUMMARY="" VERCEL="1" @@ -23,12 +36,3 @@ VERCEL_GIT_REPO_ID="" VERCEL_GIT_REPO_OWNER="" VERCEL_GIT_REPO_SLUG="" VERCEL_URL="" -NEWS_LIMIT="" -RESEND_KEY="" -RESEND_FROM="" -SECRET_HASH="" -HOME_URL="" -MAINTENANCE_MODE=0 -NEXT_PUBLIC_BRAND_NAME="" -NEXT_PUBLIC_BRAND_EMAIL="" -NEXT_PUBLIC_BRAND_COUNTRY="" \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 3d533f7..f4c762f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -17,6 +17,8 @@ "plugins": ["@typescript-eslint"], "rules": { "@typescript-eslint/no-unused-vars": "error", - "@typescript-eslint/consistent-type-definitions": ["error", "type"] + "@typescript-eslint/consistent-type-definitions": "error", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error" } } diff --git a/.gitignore b/.gitignore index b9f8d23..5247af1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ # production /build +# yarn +/.yarn + # misc .DS_Store *.pem diff --git a/.husky/pre-commit b/.husky/pre-commit index ea3ec41..4800cb9 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -3,6 +3,6 @@ yarn audit-ci yarn format -yarn lint +yarn lint-staged yarn typecheck yarn build \ No newline at end of file diff --git a/.yarnrc.yml b/.yarnrc.yml index 3186f3f..91b1101 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1 +1,5 @@ +compressionLevel: mixed + +enableGlobalCache: false + nodeLinker: node-modules diff --git a/README.md b/README.md index 74b8e11..46e5388 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,8 @@ # Hackernews newsletter -## Next up +## Future improvements -- Adjust card size -- Tweak email templates - Cron every 10 minutes: people are more likely to open the newsletter if delivered around the time when they subscribed (if cron becomes not enough, then the cost of sending all the emails might be a bigger issue) -- Move to GCP? ## Some resources used diff --git a/app/api/confirmation/route.ts b/app/api/confirmation/route.ts index cc0a5e6..fabfefd 100644 --- a/app/api/confirmation/route.ts +++ b/app/api/confirmation/route.ts @@ -1,44 +1,61 @@ -import { z } from 'zod'; -import prisma from '../../../prisma/prisma'; -import { ApiResponse } from '../../../utils/apiResponse'; -import { ConfirmationSchema, ResponseSchema } from '../../../utils/schemas'; +import prisma from '@prisma/prisma'; +import { ApiResponse } from '@utils/apiResponse'; +import { + BAD_REQUEST, + INTERNAL_SERVER_ERROR, + STATUS_BAD_REQUEST, + STATUS_INTERNAL_SERVER_ERROR, + STATUS_OK +} from '@utils/statusCodes'; +import { ConfirmationSchema, ResponseType } from '@utils/validationSchemas'; +import { Resend } from 'resend'; export const dynamic = 'force-dynamic'; // defaults to force-static + export async function POST(request: Request) { - const body = await request.json(); - const validation = ConfirmationSchema.safeParse(body); - if (!validation.success || !validation.data.code) { - return ApiResponse(400, 'Bad request'); - } - - const user = await prisma.user.findUnique({ - where: { - code: validation.data.code + try { + if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) { + throw new Error('RESEND_AUDIENCE is not set'); + } + const body = await request.json(); + const validation = ConfirmationSchema.safeParse(body); + if (!validation.success || !validation.data.code) { + return ApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST); } - }); - if (user) { - await prisma.user.update({ + const user = await prisma.user.findUnique({ where: { code: validation.data.code - }, - data: { - confirmed: true } }); - const message: z.infer = { - success: true, - message: `Thank you for confirming the subscription, ${user.email}!` - }; + if (user) { + const resend = new Resend(process.env.RESEND_KEY); - return ApiResponse(200, JSON.stringify(message)); + await resend.contacts.update({ + id: user.resendId, + audienceId: process.env.RESEND_AUDIENCE, + unsubscribed: false + }); + + await prisma.user.update({ + where: { + code: validation.data.code + }, + data: { + confirmed: true + } + }); + + const message: ResponseType = { + success: true, + message: `Thank you for confirming the subscription, ${user.email}!` + }; + + return ApiResponse(STATUS_OK, message); + } + } catch (error) { + console.error(error); + return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR); } - - const message: z.infer = { - success: false, - message: `It was not possible to confirm the subscription.` - }; - - return ApiResponse(200, JSON.stringify(message)); } diff --git a/app/api/import/route.ts b/app/api/import/route.ts index 67d8d47..4a32bd5 100644 --- a/app/api/import/route.ts +++ b/app/api/import/route.ts @@ -1,50 +1,81 @@ -import { NextResponse } from 'next/server'; -import prisma from '../../../prisma/prisma'; -import { NewsDatabaseSchema } from '../../../utils/schemas'; -import { singleNews, topNews } from '../../../utils/urls'; +import prisma from '@prisma/prisma'; +import { ApiResponse } from '@utils/apiResponse'; +import { + INTERNAL_SERVER_ERROR, + STATUS_INTERNAL_SERVER_ERROR, + STATUS_OK, + STATUS_UNAUTHORIZED +} from '@utils/statusCodes'; +import { singleNews, topNews } from '@utils/urls'; +import { NewsDatabaseSchema, NewsDatabaseType } from '@utils/validationSchemas'; +import { Resend } from 'resend'; export async function GET(request: Request) { if ( request.headers.get('Authorization') !== `Bearer ${process.env.CRON_SECRET}` ) { - return new Response('Unauthorized', { status: 401 }); + return ApiResponse(STATUS_UNAUTHORIZED, 'Unauthorized'); } try { - const topstories: number[] = await fetch(topNews).then(res => res.json()); + const topStories: number[] = await fetch(topNews, { + cache: 'no-store' + }).then(res => res.json()); - const newsPromises = topstories - .splice(0, Number(process.env.NEWS_LIMIT)) - .map(async id => { - const sourceNews = await fetch(singleNews(id)).then(res => res.json()); - const validation = NewsDatabaseSchema.safeParse(sourceNews); + console.info(`Top stories ids: ${topStories}`); - if (validation.success) { - const result = await prisma.news.upsert({ - create: { - ...validation.data, - id - }, - update: { - ...validation.data - }, - where: { - id - } - }); + const newsPromises = topStories + .slice(0, Number(process.env.NEWS_LIMIT)) + .map(id => fetch(singleNews(id)).then(res => res.json())); - return result; - } + const news: NewsDatabaseType[] = await Promise.all(newsPromises); + + const upsertPromises = news.map(async singleNews => { + const validation = NewsDatabaseSchema.safeParse(singleNews); + + if (validation.success) { + console.info( + `Validated news N° ${singleNews.id} - ${singleNews.title}` + ); + const result = await prisma.news.upsert({ + create: { + ...validation.data, + id: singleNews.id + }, + update: { + ...validation.data + }, + where: { + id: singleNews.id + } + }); + + console.info(`Imported N° ${singleNews.id} - ${singleNews.title}`); + + return result; + } else { + console.error(validation.error); + } + }); + + const result = await Promise.all(upsertPromises); + + console.info(`Imported ${result.length} news.`); + + if (process.env.ADMIN_EMAIL && process.env.RESEND_FROM) { + const resend = new Resend(process.env.RESEND_KEY); + + await resend.emails.send({ + from: process.env.RESEND_FROM, + to: [process.env.ADMIN_EMAIL], + subject: 'Newsletter: import cron job', + text: `Found these ids ${topStories.join(', ')} and imported ${result.length} of them.` }); + } - await Promise.all(newsPromises); - - return new NextResponse(`Imported ${newsPromises.length} news.`, { - status: 200 - }); - } catch { - return new NextResponse(`Import failed.`, { - status: 500 - }); + return ApiResponse(STATUS_OK, `Imported ${newsPromises.length} news.`); + } catch (error) { + console.error(error); + return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR); } } diff --git a/app/api/mailing/route.ts b/app/api/mailing/route.ts index 3569145..872871e 100644 --- a/app/api/mailing/route.ts +++ b/app/api/mailing/route.ts @@ -1,87 +1,114 @@ -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import NewsletterTemplate from '../../../components/emails/newsletter'; -import prisma from '../../../prisma/prisma'; -import { NewsSchema } from '../../../utils/schemas'; -import { sender } from '../../../utils/sender'; +import NewsletterTemplate from '@components/email/Newsletter'; +import prisma from '@prisma/prisma'; +import { ApiResponse } from '@utils/apiResponse'; +import { sender } from '@utils/sender'; +import { + INTERNAL_SERVER_ERROR, + STATUS_INTERNAL_SERVER_ERROR, + STATUS_OK, + STATUS_UNAUTHORIZED +} from '@utils/statusCodes'; +import { Resend } from 'resend'; + +const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24; +const TEN_MINUTES_IN_MS = 1000 * 10 * 60; export async function GET(request: Request) { if ( request.headers.get('Authorization') !== `Bearer ${process.env.CRON_SECRET}` ) { - return new Response('Unauthorized', { status: 401 }); + return ApiResponse(STATUS_UNAUTHORIZED, 'Unauthorized'); } - // send newsletter to users who didn't get it in the last 23h 50m, assuming a cron job every 10 minutes - // this is to avoid sending the newsletter to the same users multiple times - // this is not a perfect solution, but it's good enough for now - const users = await prisma.user.findMany({ - where: { - confirmed: true, - deleted: false, - OR: [ - { - lastMail: { - lt: new Date(Date.now() - 1000 * 60 * 60 * 24 + 1000 * 10 * 60) // 24h - 10m + try { + // send newsletter to users who didn't get it in the last 23h 50m, assuming a cron job every 10 minutes + // this is to avoid sending the newsletter to the same users multiple times + // this is not a perfect solution, but it's good enough for now + const users = await prisma.user.findMany({ + where: { + confirmed: true, + deleted: false, + OR: [ + { + lastMail: { + lt: new Date(Date.now() - ONE_DAY_IN_MS + TEN_MINUTES_IN_MS) // 24h - 10m + } + }, + { + lastMail: null } - }, - { - lastMail: null + ] + }, + select: { + id: true, + email: true + } + }); + + console.info(`Found ${users.length} users to mail to.`); + + if (users.length === 0) { + return ApiResponse(STATUS_OK, 'No user to mail to.'); + } + + const news = await prisma.news.findMany({ + where: { + createdAt: { + gt: new Date(Date.now() - ONE_DAY_IN_MS) } - ] - }, - select: { - id: true, - email: true - } - }); - - if (users.length === 0) { - return new NextResponse('No users.', { - status: 200 + }, + orderBy: { + score: 'desc' + }, + take: 25 }); - } - const news = await prisma.news.findMany({ - where: { - createdAt: { - gt: new Date(Date.now() - 1000 * 60 * 60 * 24) - } - }, - orderBy: { - score: 'desc' - }, - take: 25 - }); + console.info(`Found ${news.length} news to include in the newsletter.`); - const validRankedNews = news - .filter((item): item is z.infer => item !== undefined) - .sort((a, b) => b.score - a.score); + if (process.env.ADMIN_EMAIL && process.env.RESEND_FROM) { + const resend = new Resend(process.env.RESEND_KEY); - const sent = await sender( - users.map(user => user.email), - NewsletterTemplate(validRankedNews) - ); - - if (!sent) { - return new NextResponse('Internal server error', { - status: 500 - }); - } - - // update users so they don't get the newsletter again - await prisma.user.updateMany({ - where: { - id: { - in: users.map(user => user.id) - } - }, - data: { - lastMail: new Date() + await resend.emails.send({ + from: process.env.RESEND_FROM, + to: [process.env.ADMIN_EMAIL], + subject: 'Newsletter: mailing cron job', + text: `Found ${users.length} users and ${news.length} news to send.` + }); } - }); - return new NextResponse(`Newsletter sent to ${users.length} addresses.`, { - status: 200 - }); + if (news.length === 0) { + return ApiResponse(STATUS_OK, 'No news to include in newsletter.'); + } + + const validRankedNews = news.sort((a, b) => b.score - a.score); + + const sent = await sender( + users.map(user => user.email), + NewsletterTemplate(validRankedNews) + ); + + if (!sent) { + return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR); + } + + // update users so they don't get the newsletter again + await prisma.user.updateMany({ + where: { + id: { + in: users.map(user => user.id) + } + }, + data: { + lastMail: new Date() + } + }); + + return ApiResponse( + STATUS_OK, + `Newsletter sent to ${users.length} addresses.` + ); + } catch (error) { + console.error(error); + return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR); + } } diff --git a/app/api/news/route.ts b/app/api/news/route.ts index f269f91..875b475 100644 --- a/app/api/news/route.ts +++ b/app/api/news/route.ts @@ -1,22 +1,30 @@ -import prisma from '../../../prisma/prisma'; -import { ApiResponse } from '../../../utils/apiResponse'; +import prisma from '@prisma/prisma'; +import { ApiResponse } from '@utils/apiResponse'; +import { + INTERNAL_SERVER_ERROR, + STATUS_INTERNAL_SERVER_ERROR, + STATUS_OK +} from '@utils/statusCodes'; export async function GET() { - const news = await prisma.news.findMany({ - orderBy: { - createdAt: 'desc' - }, - take: 50, - select: { - id: true, - title: true, - by: true + try { + const news = await prisma.news.findMany({ + orderBy: { + createdAt: 'desc' + }, + take: 50, + select: { + id: true, + title: true, + by: true + } + }); + + if (news) { + return ApiResponse(STATUS_OK, news); } - }); - - if (news) { - return ApiResponse(200, JSON.stringify(news)); + } catch (error) { + console.error(error); + return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR); } - - return ApiResponse(500, 'Internal server error'); } diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts index eab4c1f..05102fe 100644 --- a/app/api/subscribe/route.ts +++ b/app/api/subscribe/route.ts @@ -1,76 +1,123 @@ +import ConfirmationTemplate from '@components/email/Confirmation'; +import prisma from '@prisma/prisma'; +import { ApiResponse } from '@utils/apiResponse'; +import { sender } from '@utils/sender'; +import { + BAD_REQUEST, + INTERNAL_SERVER_ERROR, + STATUS_BAD_REQUEST, + STATUS_INTERNAL_SERVER_ERROR, + STATUS_OK +} from '@utils/statusCodes'; +import { ResponseType, SubscribeFormSchema } from '@utils/validationSchemas'; import * as crypto from 'crypto'; -import { z } from 'zod'; -import ConfirmationTemplate from '../../../components/emails/confirmation'; -import prisma from '../../../prisma/prisma'; -import { ApiResponse } from '../../../utils/apiResponse'; -import { ResponseSchema, SubscribeFormSchema } from '../../../utils/schemas'; -import { sender } from '../../../utils/sender'; +import { Resend } from 'resend'; export const dynamic = 'force-dynamic'; // defaults to force-static + export async function POST(request: Request) { - const body = await request.json(); - const validation = SubscribeFormSchema.safeParse(body); - if (!validation.success) { - return ApiResponse(400, 'Bad request'); - } - - const { email } = validation.data; - - const userAlreadyConfirmed = await prisma.user.findUnique({ - where: { - email, - confirmed: true + try { + if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) { + throw new Error('RESEND_KEY is not set'); } - }); - if (userAlreadyConfirmed) { - if (userAlreadyConfirmed.deleted) { + const body = await request.json(); + + const validation = SubscribeFormSchema.safeParse(body); + + if (!validation.success) { + return ApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST); + } + + const { email } = validation.data; + + const user = await prisma.user.findUnique({ + where: { + email + } + }); + + const resend = new Resend(process.env.RESEND_KEY); + + const code = crypto + .createHash('sha256') + .update(`${process.env.SECRET_HASH}${email}}`) + .digest('hex'); + + if (user && user.confirmed) { + if (user.deleted) { + await prisma.user.update({ + where: { + email + }, + data: { + deleted: false + } + }); + + const contact = await resend.contacts.get({ + id: user.resendId, + audienceId: process.env.RESEND_AUDIENCE + }); + + if (!contact) { + await resend.contacts.update({ + id: user.resendId, + audienceId: process.env.RESEND_AUDIENCE, + unsubscribed: true + }); + } + } + + const message: ResponseType = { + success: true, + message: `Thank you for subscribing!` + }; + + return ApiResponse(STATUS_OK, message); + } else if (user && !user.confirmed) { await prisma.user.update({ where: { email }, data: { - deleted: false + code + } + }); + } else { + const contact = await resend.contacts.create({ + email: email, + audienceId: process.env.RESEND_AUDIENCE, + unsubscribed: true + }); + + if (!contact.data?.id) { + throw new Error('Failed to create Resend contact'); + } + + await prisma.user.create({ + data: { + email, + code, + resendId: contact.data.id } }); } - const message: z.infer = { + const sent = await sender([email], ConfirmationTemplate(code)); + + if (!sent) { + return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR); + } + + const message: ResponseType = { success: true, - message: `Thank you for subscribing!` + message: `Thank you! You will now receive an email to ${email} to confirm the subscription.` }; - return ApiResponse(200, JSON.stringify(message)); + return ApiResponse(STATUS_OK, message); + } catch (error) { + console.error(error); + return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR); } - - const code = crypto - .createHash('sha256') - .update(`${process.env.SECRET_HASH}${email}}`) - .digest('hex'); - - await prisma.user.upsert({ - create: { - email, - code - }, - update: { - code - }, - where: { - email - } - }); - - const sent = await sender([email], ConfirmationTemplate(code)); - - if (!sent) { - return ApiResponse(500, 'Internal server error'); - } - - const message: z.infer = { - success: true, - message: `Thank you! You will now receive an email to ${email} to confirm the subscription.` - }; - - return ApiResponse(200, JSON.stringify(message)); } diff --git a/app/api/unsubscribe/route.ts b/app/api/unsubscribe/route.ts index 13dc3bf..85a193d 100644 --- a/app/api/unsubscribe/route.ts +++ b/app/api/unsubscribe/route.ts @@ -1,47 +1,74 @@ -import { z } from 'zod'; -import UnsubscribeTemplate from '../../../components/emails/unsubscribe'; -import prisma from '../../../prisma/prisma'; -import { ApiResponse } from '../../../utils/apiResponse'; -import { ResponseSchema, UnsubscribeFormSchema } from '../../../utils/schemas'; -import { sender } from '../../../utils/sender'; +import UnsubscribeTemplate from '@components/email/Unsubscribe'; +import prisma from '@prisma/prisma'; +import { ApiResponse } from '@utils/apiResponse'; +import { sender } from '@utils/sender'; +import { + BAD_REQUEST, + INTERNAL_SERVER_ERROR, + STATUS_BAD_REQUEST, + STATUS_INTERNAL_SERVER_ERROR, + STATUS_OK +} from '@utils/statusCodes'; +import { ResponseType, UnsubscribeFormSchema } from '@utils/validationSchemas'; +import { Resend } from 'resend'; export const dynamic = 'force-dynamic'; // defaults to force-static + export async function POST(request: Request) { - const body = await request.json(); - const validation = UnsubscribeFormSchema.safeParse(body); - if (!validation.success) { - return ApiResponse(400, 'Bad request'); - } - - const { email } = validation.data; - - const user = await prisma.user.findUnique({ - where: { - email + try { + if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) { + throw new Error('RESEND_AUDIENCE is not set'); + } + const body = await request.json(); + const validation = UnsubscribeFormSchema.safeParse(body); + if (!validation.success) { + return ApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST); } - }); - if (user && !user.deleted) { - await prisma.user.update({ + const { email } = validation.data; + + const user = await prisma.user.findUnique({ where: { email - }, - data: { - deleted: true } }); - const sent = await sender([email], UnsubscribeTemplate()); + if (user && !user.deleted) { + await prisma.user.update({ + where: { + email + }, + data: { + deleted: true + } + }); - if (!sent) { - return ApiResponse(500, 'Internal server error'); + const resend = new Resend(process.env.RESEND_KEY); + + await resend.contacts.update({ + id: user.resendId, + audienceId: process.env.RESEND_AUDIENCE, + unsubscribed: true + }); + + const sent = await sender([email], UnsubscribeTemplate()); + + if (!sent) { + return ApiResponse( + STATUS_INTERNAL_SERVER_ERROR, + 'Internal server error' + ); + } } + + const message: ResponseType = { + success: true, + message: `${email} unsubscribed.` + }; + + return ApiResponse(STATUS_OK, message); + } catch (error) { + console.error(error); + return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR); } - - const message: z.infer = { - success: true, - message: `${email} unsubscribed.` - }; - - return ApiResponse(200, JSON.stringify(message)); } diff --git a/app/confirmation/page.tsx b/app/confirmation/page.tsx index 9dc3f05..d014d44 100644 --- a/app/confirmation/page.tsx +++ b/app/confirmation/page.tsx @@ -1,9 +1,9 @@ 'use client'; +import { CardDescription } from '@components/Card'; +import CustomCard from '@components/CustomCard'; +import { ResponseType } from '@utils/validationSchemas'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useState } from 'react'; -import { z } from 'zod'; -import { Card } from '../../components/custom/card'; -import { ResponseSchema } from '../../utils/schemas'; function ConfirmationPage() { const router = useRouter(); @@ -14,49 +14,59 @@ function ConfirmationPage() { const code = searchParams.get('code'); useEffect(() => { - if (!code) { - router.push('/'); - } + const fetchData = async () => { + if (!code) { + router.push('/'); + return; + } + + try { + const res = await fetch('/api/confirmation', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + code: code + }) + }); - fetch('/api/confirmation', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - code: code - }) - }) - .then(async res => { if (!res.ok) { router.push('/'); + return; } - const response: z.infer = await res.json(); + const response: ResponseType = await res.json(); if (!response.success) { router.push('/'); + return; } - return response; - }) - .then(response => { setMessage(response.message); setLoading(false); - }); + } catch (error) { + console.error(error); + router.push('/'); + } + }; + + fetchData(); }, [code, router]); function render() { if (!loading) { - return message; + return ( + {message} + ); } return 'Just a second...'; } return ( - + Loading...}> ); diff --git a/app/globals.css b/app/globals.css index 38314ba..b0dce60 100644 --- a/app/globals.css +++ b/app/globals.css @@ -35,47 +35,25 @@ --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%; + h1 { + @apply text-3xl font-semibold; } -} -.styledH2 { - @apply text-xl font-bold; -} + h2 { + @apply text-2xl font-semibold; + } -.styledH3 { - @apply text-lg font-semibold; -} + h3 { + @apply text-xl font-semibold; + } -.styledH4 { - @apply text-base font-medium; + h4 { + @apply text-lg font-semibold; + } + + h6 { + @apply text-lg italic; + } } /* Card border gradient */ @@ -102,7 +80,6 @@ body { content: ''; top: calc(-1 * var(--radius)); left: calc(-1 * var(--radius)); - z-index: -1; width: calc(100% + var(--radius) * 2); height: calc(100% + var(--radius) * 2); background: linear-gradient( diff --git a/app/layout.tsx b/app/layout.tsx index 0bb9177..f4ac316 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,8 +1,8 @@ +import Tiles from '@components/tiles/Tiles'; +import { cn } from '@utils/ui'; import { Analytics } from '@vercel/analytics/react'; import type { Metadata } from 'next'; import { Inter as FontSans } from 'next/font/google'; -import { Tiles } from '../components/custom/tiles/tiles'; -import { cn } from '../utils/ui'; import './globals.css'; export const metadata: Metadata = { @@ -22,16 +22,16 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + -
{children}
+
{children}
diff --git a/app/page.tsx b/app/page.tsx index 73b0edd..7644a30 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,46 +1,35 @@ 'use client'; +import { Button } from '@components/Button'; +import { CardDescription } from '@components/Card'; +import CustomCard from '@components/CustomCard'; +import ErrorMessage from '@components/ErrorMessage'; +import { FormControl } from '@components/form/FormControl'; +import { FormMessage } from '@components/form/FormMessage'; +import { Input } from '@components/Input'; +import { FormField } from '@contexts/FormField/FormFieldProvider'; +import { FormItem } from '@contexts/FormItem/FormItemProvider'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useEffect, useRef, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; -import { Card } 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/schemas'; + ResponseType, + SubscribeFormSchema, + SubscribeFormType +} from '@utils/validationSchemas'; +import { useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; 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>({ + const form = useForm({ resolver: zodResolver(SubscribeFormSchema), defaultValues: { - email: '', - name: '' + email: '' } }); - useEffect(() => { - if (honeypotRef.current) { - honeypotRef.current.style.display = 'none'; - } - }, []); - - async function handleSubmit(values: z.infer) { - if (values.name) { - return; - } - + async function handleSubmit(values: SubscribeFormType) { try { const response = await fetch('/api/subscribe', { method: 'POST', @@ -56,8 +45,7 @@ export default function Home() { throw new Error(`Invalid response: ${response.status}`); } - const formResponse: z.infer = - await response.json(); + const formResponse: ResponseType = await response.json(); if (!formResponse.success) { throw Error(formResponse.message); @@ -76,12 +64,14 @@ export default function Home() { } if (completed) { - return message; + return ( + {message} + ); } return (
-
+ ( -
+
@@ -108,7 +98,7 @@ export default function Home() {
- +

You can rest assured that we will fill your inbox with spam. We don't like it either! šŸ™‚ @@ -118,8 +108,8 @@ export default function Home() { } return ( -
-

Interpretation and Definitions

-

Interpretation

+

Interpretation and Definitions

+

Interpretation

The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall @@ -34,7 +35,7 @@ export default function Privacy() { in plural.


-

Definitions

+

Definitions

For the purposes of this Privacy Policy:

  • @@ -115,9 +116,9 @@ export default function Privacy() {

-

Collecting and Using Your Personal Data

-

Types of Data Collected

-

Personal Data

+

Collecting and Using Your Personal Data

+

Types of Data Collected

+
Personal Data

While using Our Service, We may ask You to provide Us with certain personally identifiable information that can be used to contact or @@ -133,7 +134,7 @@ export default function Privacy() {
-

Usage Data

+
Usage Data

Usage Data is collected automatically when using the Service.

Usage Data may include information such as Your Device&aposs Internet @@ -156,7 +157,7 @@ export default function Privacy() { device.


-

Use of Your Personal Data

+

Use of Your Personal Data

The Company may use Personal Data for the following purposes:

  • @@ -265,7 +266,7 @@ export default function Privacy() {

-

Retention of Your Personal Data

+

Retention of Your Personal Data

The Company will retain Your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will @@ -282,7 +283,7 @@ export default function Privacy() { data for longer time periods.


-

Transfer of Your Personal Data

+

Transfer of Your Personal Data

Your information, including Personal Data, is processed at the Company&aposs operating offices and in any other places where the @@ -304,7 +305,7 @@ export default function Privacy() { security of Your data and other personal information.


-

Delete Your Personal Data

+

Delete Your Personal Data

You have the right to delete or request that We assist in deleting the Personal Data that We have collected about You. @@ -325,8 +326,8 @@ export default function Privacy() { when we have a legal obligation or lawful basis to do so.


-

Disclosure of Your Personal Data

-

Business Transactions

+

Disclosure of Your Personal Data

+
Business Transactions

If the Company is involved in a merger, acquisition or asset sale, Your Personal Data may be transferred. We will provide notice before Your @@ -334,14 +335,14 @@ export default function Privacy() { Policy.


-

Law enforcement

+
Law enforcement

Under certain circumstances, the Company may be required to disclose Your Personal Data if required to do so by law or in response to valid requests by public authorities (e.g. a court or a government agency).


-

Other legal requirements

+
Other legal requirements

The Company may disclose Your Personal Data in the good faith belief that such action is necessary to: @@ -358,9 +359,8 @@ export default function Privacy() {

  • Protect against legal liability
  • -
    -

    Security of Your Personal Data

    +

    Security of Your Personal Data

    The security of Your Personal Data is important to Us, but remember that no method of transmission over the Internet, or method of electronic @@ -369,7 +369,7 @@ export default function Privacy() { security.


    -

    {"Children's Privacy"}

    +

    {"Children's Privacy"}

    Our Service does not address anyone under the age of 13. We do not knowingly collect personally identifiable information from anyone under @@ -386,7 +386,7 @@ export default function Privacy() { information.


    -

    Links to Other Websites

    +

    Links to Other Websites

    Our Service may contain links to other websites that are not operated by Us. If You click on a third party link, You will be directed to that @@ -398,7 +398,7 @@ export default function Privacy() { privacy policies or practices of any third party sites or services.


    -

    Changes to this Privacy Policy

    +

    Changes to this Privacy Policy

    We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the new Privacy Policy on this page. @@ -414,7 +414,7 @@ export default function Privacy() { posted on this page.


    -

    Contact Us

    +

    Contact Us

    If you have any questions about this Privacy Policy, You can contact us by writing to{' '} @@ -439,8 +439,8 @@ export default function Privacy() { ); return ( - (null); + const ref = useRef(null); - const form = useForm>({ + const form = useForm({ resolver: zodResolver(UnsubscribeFormSchema), defaultValues: { - email: '', - name: '' + email: '' } }); useEffect(() => { - if (honeypotRef.current) { - honeypotRef.current.style.display = 'none'; + if (ref.current) { + ref.current.style.display = 'none'; } }, []); - async function handleSubmit(values: z.infer) { - if (values.name) { - return; - } - + async function handleSubmit(values: UnsubscribeFormType) { try { const response = await fetch('/api/unsubscribe', { method: 'POST', @@ -56,8 +52,7 @@ export default function Unsubscribe() { throw new Error(`Invalid response: ${response.status}`); } - const formResponse: z.infer = - await response.json(); + const formResponse: ResponseType = await response.json(); if (!formResponse.success) { throw Error(formResponse.message); @@ -76,12 +71,14 @@ export default function Unsubscribe() { } if (completed) { - return message; + return ( + {message} + ); } return (

    -
    + ( -
    +
    - + )} @@ -104,14 +105,14 @@ export default function Unsubscribe() {
    - +
    ); } return ( - (({ className, ...props }, ref) => (
    )); @@ -32,12 +29,9 @@ const CardTitle = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -

    )); diff --git a/components/custom/card.tsx b/components/CustomCard.tsx similarity index 50% rename from components/custom/card.tsx rename to components/CustomCard.tsx index 19543d0..014a307 100644 --- a/components/custom/card.tsx +++ b/components/CustomCard.tsx @@ -1,32 +1,30 @@ import { ReactNode, useEffect, useState } from 'react'; -import { useMediaQuery } from 'react-responsive'; import { + Card, CardContent, CardDescription, CardFooter, CardHeader, - CardTitle, - Card as CardUI -} from '../../components/ui/card'; -import Footer from './footer'; + CardTitle +} from './Card'; +import Footer from './Footer'; -type CardProps = { +interface CardProps { title: string; description?: string; content: ReactNode; - style?: string; + className?: string; footer?: boolean; -}; +} -export const Card = ({ +export default function CustomCard({ title, description, content, - style, + className, footer = true -}: CardProps) => { +}: CardProps) { const [isLoaded, setIsLoaded] = useState(false); - const isMobile = useMediaQuery({ query: '(max-width: 767px)' }); useEffect(() => { setIsLoaded(true); @@ -37,13 +35,8 @@ export const Card = ({ } return ( -
    - +
    +

    Hackernews + newsletter @@ -51,19 +44,13 @@ export const Card = ({ {title} {description} - {isMobile ? ( - {content} - ) : ( - - {content} - - )} + {content} {footer && (

    )} - +
    ); -}; +} diff --git a/components/custom/customLink.tsx b/components/CustomLink.tsx similarity index 57% rename from components/custom/customLink.tsx rename to components/CustomLink.tsx index 38297fb..e752cfb 100644 --- a/components/custom/customLink.tsx +++ b/components/CustomLink.tsx @@ -1,13 +1,13 @@ 'use client'; import Link from 'next/link'; -import { Button } from '../ui/button'; +import { Button } from './Button'; -type LinkProps = { +interface LinkProps { path: string; text: string; -}; +} -export function CustomLink({ path, text }: LinkProps) { +export default function CustomLink({ path, text }: LinkProps) { return (
    )}

    ); } - -export default Footer; diff --git a/components/ui/input.tsx b/components/Input.tsx similarity index 52% rename from components/ui/input.tsx rename to components/Input.tsx index 289e0a8..fff1fec 100644 --- a/components/ui/input.tsx +++ b/components/Input.tsx @@ -1,5 +1,5 @@ +import { cn } from '@utils/ui'; import * as React from 'react'; -import { cn } from '../../utils/ui'; const Input = React.forwardRef< HTMLInputElement, @@ -9,7 +9,7 @@ const Input = React.forwardRef< ; - newsB?: z.infer; -}; - -export function Tile({ newsA, newsB }: CardProps) { - const [switched, setSwitched] = useState(false); - const [active, setActive] = useState(Math.random() < 0.5); - const [delayed, setDelayed] = useState(true); - - useEffect(() => { - const randomDelay = Math.floor(Math.random() * 10000); - - const interval = setInterval( - () => { - setSwitched(true); - - window.setTimeout(function () { - setSwitched(false); - setActive(!active); - setDelayed(false); - }, 500 / 2); - }, - delayed ? randomDelay : randomDelay + 10000 - ); - - return () => clearInterval(interval); - }, [active, delayed]); - - if (!newsA || !newsB) return
    ; - - return ( -
    -
    -
    - {active - ? TileContent({ story: newsA, side: true }) - : TileContent({ story: newsB, side: false })} -
    -
    -
    - ); -} diff --git a/components/custom/tiles/components/tileContent.tsx b/components/custom/tiles/components/tileContent.tsx deleted file mode 100644 index 2641266..0000000 --- a/components/custom/tiles/components/tileContent.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useState } from 'react'; -import { z } from 'zod'; -import { getRandomGrey } from '../../../../utils/getRandomGrey'; -import { NewsTileSchema } from '../../../../utils/schemas'; - -type CardContentProps = { - story: z.infer; - side: boolean; -}; - -export function TileContent({ story, side }: CardContentProps) { - const [firstColor, setFirstColor] = useState(getRandomGrey()); - const [secondColor, setSecondColor] = useState(getRandomGrey()); - const [switched, setSwitched] = useState(true); - - if (switched !== side) { - setFirstColor(getRandomGrey()); - setSecondColor(getRandomGrey()); - - setSwitched(side); - } - - const color = side ? firstColor : secondColor; - - return ( -
    -

    {story.title}

    -

    by {story.by}

    -
    -
    - ); -} diff --git a/components/emails/confirmation.tsx b/components/email/Confirmation.tsx similarity index 92% rename from components/emails/confirmation.tsx rename to components/email/Confirmation.tsx index deaaa33..6bdccd7 100644 --- a/components/emails/confirmation.tsx +++ b/components/email/Confirmation.tsx @@ -1,5 +1,5 @@ -import { Note } from './components/note'; -import Template from './template'; +import Note from './components/Note'; +import Template from './Template'; export default function ConfirmationTemplate(code: string) { return { diff --git a/components/emails/newsletter.tsx b/components/email/Newsletter.tsx similarity index 85% rename from components/emails/newsletter.tsx rename to components/email/Newsletter.tsx index 1e84cdf..1358a81 100644 --- a/components/emails/newsletter.tsx +++ b/components/email/Newsletter.tsx @@ -1,12 +1,9 @@ -import { z } from 'zod'; -import { NewsSchema } from '../../utils/schemas'; -import { textTruncate } from '../../utils/textTruncate'; -import { sayings } from './helpers/sayings'; -import Template from './template'; +import { sayings } from '@utils/sayings'; +import { textTruncate } from '@utils/textTruncate'; +import { NewsType } from '@utils/validationSchemas'; +import Template from './Template'; -export default function NewsletterTemplate( - stories: z.infer[] -) { +export default function NewsletterTemplate(stories: NewsType[]) { return { subject: `What's new from the Hackernews forum?`, template: ( @@ -40,9 +37,7 @@ export default function NewsletterTemplate( paddingRight: '1.5rem' }} > -

    - {story.title} -

    +

    {story.title}

    by {story.by}

    diff --git a/components/emails/template.tsx b/components/email/Template.tsx similarity index 68% rename from components/emails/template.tsx rename to components/email/Template.tsx index acbff12..fcfcced 100644 --- a/components/emails/template.tsx +++ b/components/email/Template.tsx @@ -1,9 +1,9 @@ -import { Footer } from './components/footer'; +import Footer from './components/Footer'; -type TemplateProps = { +interface TemplateProps { title: string; body: JSX.Element; -}; +} export default function Template({ title, body }: TemplateProps) { return ( @@ -12,21 +12,19 @@ export default function Template({ title, body }: TemplateProps) { maxWidth: '720px', alignContent: 'center', alignItems: 'center', - backgroundColor: '#F9FBFB', + backgroundColor: '#F9FBFB' }} > -

    {title} -

    +
    {body}
    diff --git a/components/emails/unsubscribe.tsx b/components/email/Unsubscribe.tsx similarity index 93% rename from components/emails/unsubscribe.tsx rename to components/email/Unsubscribe.tsx index bbf00ec..8070877 100644 --- a/components/emails/unsubscribe.tsx +++ b/components/email/Unsubscribe.tsx @@ -1,5 +1,5 @@ -import { Note } from './components/note'; -import Template from './template'; +import Note from './components/Note'; +import Template from './Template'; export default function UnsubscribeTemplate() { return { diff --git a/components/emails/components/footer.tsx b/components/email/components/Footer.tsx similarity index 95% rename from components/emails/components/footer.tsx rename to components/email/components/Footer.tsx index df8f21a..9d22281 100644 --- a/components/emails/components/footer.tsx +++ b/components/email/components/Footer.tsx @@ -1,4 +1,4 @@ -export function Footer() { +export default function Footer() { return (