refactor: improve news and email handling, style, folder structure (#16)

This commit is contained in:
Riccardo Senica
2024-06-04 18:04:54 +08:00
committed by GitHub
parent bc5e0cc195
commit acc10bf5fd
62 changed files with 1737 additions and 1553 deletions

View File

@@ -1,4 +1,13 @@
# Created by Vercel CLI # 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="" NX_DAEMON=""
POSTGRES_DATABASE="" POSTGRES_DATABASE=""
POSTGRES_HOST="" POSTGRES_HOST=""
@@ -7,6 +16,10 @@ POSTGRES_PRISMA_URL=""
POSTGRES_URL="" POSTGRES_URL=""
POSTGRES_URL_NON_POOLING="" POSTGRES_URL_NON_POOLING=""
POSTGRES_USER="" POSTGRES_USER=""
RESEND_AUDIENCE=""
RESEND_FROM=""
RESEND_KEY=""
SECRET_HASH=""
TURBO_REMOTE_ONLY="" TURBO_REMOTE_ONLY=""
TURBO_RUN_SUMMARY="" TURBO_RUN_SUMMARY=""
VERCEL="1" VERCEL="1"
@@ -23,12 +36,3 @@ VERCEL_GIT_REPO_ID=""
VERCEL_GIT_REPO_OWNER="" VERCEL_GIT_REPO_OWNER=""
VERCEL_GIT_REPO_SLUG="" VERCEL_GIT_REPO_SLUG=""
VERCEL_URL="" 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=""

View File

@@ -17,6 +17,8 @@
"plugins": ["@typescript-eslint"], "plugins": ["@typescript-eslint"],
"rules": { "rules": {
"@typescript-eslint/no-unused-vars": "error", "@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"
} }
} }

3
.gitignore vendored
View File

@@ -16,6 +16,9 @@
# production # production
/build /build
# yarn
/.yarn
# misc # misc
.DS_Store .DS_Store
*.pem *.pem

View File

@@ -3,6 +3,6 @@
yarn audit-ci yarn audit-ci
yarn format yarn format
yarn lint yarn lint-staged
yarn typecheck yarn typecheck
yarn build yarn build

View File

@@ -1 +1,5 @@
compressionLevel: mixed
enableGlobalCache: false
nodeLinker: node-modules nodeLinker: node-modules

View File

@@ -1,11 +1,8 @@
# Hackernews newsletter # 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) - 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 ## Some resources used

View File

@@ -1,14 +1,26 @@
import { z } from 'zod'; import prisma from '@prisma/prisma';
import prisma from '../../../prisma/prisma'; import { ApiResponse } from '@utils/apiResponse';
import { ApiResponse } from '../../../utils/apiResponse'; import {
import { ConfirmationSchema, ResponseSchema } from '../../../utils/schemas'; 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 const dynamic = 'force-dynamic'; // defaults to force-static
export async function POST(request: Request) { export async function POST(request: Request) {
try {
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
throw new Error('RESEND_AUDIENCE is not set');
}
const body = await request.json(); const body = await request.json();
const validation = ConfirmationSchema.safeParse(body); const validation = ConfirmationSchema.safeParse(body);
if (!validation.success || !validation.data.code) { if (!validation.success || !validation.data.code) {
return ApiResponse(400, 'Bad request'); return ApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
} }
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@@ -18,6 +30,14 @@ export async function POST(request: Request) {
}); });
if (user) { if (user) {
const resend = new Resend(process.env.RESEND_KEY);
await resend.contacts.update({
id: user.resendId,
audienceId: process.env.RESEND_AUDIENCE,
unsubscribed: false
});
await prisma.user.update({ await prisma.user.update({
where: { where: {
code: validation.data.code code: validation.data.code
@@ -27,18 +47,15 @@ export async function POST(request: Request) {
} }
}); });
const message: z.infer<typeof ResponseSchema> = { const message: ResponseType = {
success: true, success: true,
message: `Thank you for confirming the subscription, ${user.email}!` message: `Thank you for confirming the subscription, ${user.email}!`
}; };
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 message: z.infer<typeof ResponseSchema> = {
success: false,
message: `It was not possible to confirm the subscription.`
};
return ApiResponse(200, JSON.stringify(message));
} }

View File

@@ -1,50 +1,81 @@
import { NextResponse } from 'next/server'; import prisma from '@prisma/prisma';
import prisma from '../../../prisma/prisma'; import { ApiResponse } from '@utils/apiResponse';
import { NewsDatabaseSchema } from '../../../utils/schemas'; import {
import { singleNews, topNews } from '../../../utils/urls'; 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) { export async function GET(request: Request) {
if ( if (
request.headers.get('Authorization') !== `Bearer ${process.env.CRON_SECRET}` request.headers.get('Authorization') !== `Bearer ${process.env.CRON_SECRET}`
) { ) {
return new Response('Unauthorized', { status: 401 }); return ApiResponse(STATUS_UNAUTHORIZED, 'Unauthorized');
} }
try { 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 console.info(`Top stories ids: ${topStories}`);
.splice(0, Number(process.env.NEWS_LIMIT))
.map(async id => { const newsPromises = topStories
const sourceNews = await fetch(singleNews(id)).then(res => res.json()); .slice(0, Number(process.env.NEWS_LIMIT))
const validation = NewsDatabaseSchema.safeParse(sourceNews); .map(id => fetch(singleNews(id)).then(res => res.json()));
const news: NewsDatabaseType[] = await Promise.all(newsPromises);
const upsertPromises = news.map(async singleNews => {
const validation = NewsDatabaseSchema.safeParse(singleNews);
if (validation.success) { if (validation.success) {
console.info(
`Validated news N° ${singleNews.id} - ${singleNews.title}`
);
const result = await prisma.news.upsert({ const result = await prisma.news.upsert({
create: { create: {
...validation.data, ...validation.data,
id id: singleNews.id
}, },
update: { update: {
...validation.data ...validation.data
}, },
where: { where: {
id id: singleNews.id
} }
}); });
console.info(`Imported N° ${singleNews.id} - ${singleNews.title}`);
return result; return result;
} else {
console.error(validation.error);
} }
}); });
await Promise.all(newsPromises); const result = await Promise.all(upsertPromises);
return new NextResponse(`Imported ${newsPromises.length} news.`, { console.info(`Imported ${result.length} news.`);
status: 200
}); if (process.env.ADMIN_EMAIL && process.env.RESEND_FROM) {
} catch { const resend = new Resend(process.env.RESEND_KEY);
return new NextResponse(`Import failed.`, {
status: 500 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.`
}); });
} }
return ApiResponse(STATUS_OK, `Imported ${newsPromises.length} news.`);
} catch (error) {
console.error(error);
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
}
} }

View File

@@ -1,17 +1,26 @@
import { NextResponse } from 'next/server'; import NewsletterTemplate from '@components/email/Newsletter';
import { z } from 'zod'; import prisma from '@prisma/prisma';
import NewsletterTemplate from '../../../components/emails/newsletter'; import { ApiResponse } from '@utils/apiResponse';
import prisma from '../../../prisma/prisma'; import { sender } from '@utils/sender';
import { NewsSchema } from '../../../utils/schemas'; import {
import { sender } from '../../../utils/sender'; 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) { export async function GET(request: Request) {
if ( if (
request.headers.get('Authorization') !== `Bearer ${process.env.CRON_SECRET}` request.headers.get('Authorization') !== `Bearer ${process.env.CRON_SECRET}`
) { ) {
return new Response('Unauthorized', { status: 401 }); return ApiResponse(STATUS_UNAUTHORIZED, 'Unauthorized');
} }
try {
// send newsletter to users who didn't get it in the last 23h 50m, assuming a cron job every 10 minutes // 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 to avoid sending the newsletter to the same users multiple times
// this is not a perfect solution, but it's good enough for now // this is not a perfect solution, but it's good enough for now
@@ -22,7 +31,7 @@ export async function GET(request: Request) {
OR: [ OR: [
{ {
lastMail: { lastMail: {
lt: new Date(Date.now() - 1000 * 60 * 60 * 24 + 1000 * 10 * 60) // 24h - 10m lt: new Date(Date.now() - ONE_DAY_IN_MS + TEN_MINUTES_IN_MS) // 24h - 10m
} }
}, },
{ {
@@ -36,16 +45,16 @@ export async function GET(request: Request) {
} }
}); });
console.info(`Found ${users.length} users to mail to.`);
if (users.length === 0) { if (users.length === 0) {
return new NextResponse('No users.', { return ApiResponse(STATUS_OK, 'No user to mail to.');
status: 200
});
} }
const news = await prisma.news.findMany({ const news = await prisma.news.findMany({
where: { where: {
createdAt: { createdAt: {
gt: new Date(Date.now() - 1000 * 60 * 60 * 24) gt: new Date(Date.now() - ONE_DAY_IN_MS)
} }
}, },
orderBy: { orderBy: {
@@ -54,9 +63,24 @@ export async function GET(request: Request) {
take: 25 take: 25
}); });
const validRankedNews = news console.info(`Found ${news.length} news to include in the newsletter.`);
.filter((item): item is z.infer<typeof NewsSchema> => 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);
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.`
});
}
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( const sent = await sender(
users.map(user => user.email), users.map(user => user.email),
@@ -64,9 +88,7 @@ export async function GET(request: Request) {
); );
if (!sent) { if (!sent) {
return new NextResponse('Internal server error', { return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
status: 500
});
} }
// update users so they don't get the newsletter again // update users so they don't get the newsletter again
@@ -81,7 +103,12 @@ export async function GET(request: Request) {
} }
}); });
return new NextResponse(`Newsletter sent to ${users.length} addresses.`, { return ApiResponse(
status: 200 STATUS_OK,
}); `Newsletter sent to ${users.length} addresses.`
);
} catch (error) {
console.error(error);
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
}
} }

View File

@@ -1,7 +1,13 @@
import prisma from '../../../prisma/prisma'; import prisma from '@prisma/prisma';
import { ApiResponse } from '../../../utils/apiResponse'; import { ApiResponse } from '@utils/apiResponse';
import {
INTERNAL_SERVER_ERROR,
STATUS_INTERNAL_SERVER_ERROR,
STATUS_OK
} from '@utils/statusCodes';
export async function GET() { export async function GET() {
try {
const news = await prisma.news.findMany({ const news = await prisma.news.findMany({
orderBy: { orderBy: {
createdAt: 'desc' createdAt: 'desc'
@@ -15,8 +21,10 @@ export async function GET() {
}); });
if (news) { if (news) {
return ApiResponse(200, JSON.stringify(news)); return ApiResponse(STATUS_OK, news);
}
} catch (error) {
console.error(error);
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
} }
return ApiResponse(500, 'Internal server error');
} }

View File

@@ -1,30 +1,51 @@
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 * as crypto from 'crypto';
import { z } from 'zod'; import { Resend } from 'resend';
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';
export const dynamic = 'force-dynamic'; // defaults to force-static export const dynamic = 'force-dynamic'; // defaults to force-static
export async function POST(request: Request) { export async function POST(request: Request) {
try {
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
throw new Error('RESEND_KEY is not set');
}
const body = await request.json(); const body = await request.json();
const validation = SubscribeFormSchema.safeParse(body); const validation = SubscribeFormSchema.safeParse(body);
if (!validation.success) { if (!validation.success) {
return ApiResponse(400, 'Bad request'); return ApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
} }
const { email } = validation.data; const { email } = validation.data;
const userAlreadyConfirmed = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
email, email
confirmed: true
} }
}); });
if (userAlreadyConfirmed) { const resend = new Resend(process.env.RESEND_KEY);
if (userAlreadyConfirmed.deleted) {
const code = crypto
.createHash('sha256')
.update(`${process.env.SECRET_HASH}${email}}`)
.digest('hex');
if (user && user.confirmed) {
if (user.deleted) {
await prisma.user.update({ await prisma.user.update({
where: { where: {
email email
@@ -33,44 +54,70 @@ export async function POST(request: Request) {
deleted: false 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: z.infer<typeof ResponseSchema> = { const message: ResponseType = {
success: true, success: true,
message: `Thank you for subscribing!` message: `Thank you for subscribing!`
}; };
return ApiResponse(200, JSON.stringify(message)); return ApiResponse(STATUS_OK, message);
} } else if (user && !user.confirmed) {
await prisma.user.update({
const code = crypto
.createHash('sha256')
.update(`${process.env.SECRET_HASH}${email}}`)
.digest('hex');
await prisma.user.upsert({
create: {
email,
code
},
update: {
code
},
where: { where: {
email email
},
data: {
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 sent = await sender([email], ConfirmationTemplate(code)); const sent = await sender([email], ConfirmationTemplate(code));
if (!sent) { if (!sent) {
return ApiResponse(500, 'Internal server error'); return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
} }
const message: z.infer<typeof ResponseSchema> = { const message: ResponseType = {
success: true, success: true,
message: `Thank you! You will now receive an email to ${email} to confirm the subscription.` 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);
}
} }

View File

@@ -1,16 +1,28 @@
import { z } from 'zod'; import UnsubscribeTemplate from '@components/email/Unsubscribe';
import UnsubscribeTemplate from '../../../components/emails/unsubscribe'; import prisma from '@prisma/prisma';
import prisma from '../../../prisma/prisma'; import { ApiResponse } from '@utils/apiResponse';
import { ApiResponse } from '../../../utils/apiResponse'; import { sender } from '@utils/sender';
import { ResponseSchema, UnsubscribeFormSchema } from '../../../utils/schemas'; import {
import { sender } from '../../../utils/sender'; 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 const dynamic = 'force-dynamic'; // defaults to force-static
export async function POST(request: Request) { export async function POST(request: Request) {
try {
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
throw new Error('RESEND_AUDIENCE is not set');
}
const body = await request.json(); const body = await request.json();
const validation = UnsubscribeFormSchema.safeParse(body); const validation = UnsubscribeFormSchema.safeParse(body);
if (!validation.success) { if (!validation.success) {
return ApiResponse(400, 'Bad request'); return ApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
} }
const { email } = validation.data; const { email } = validation.data;
@@ -31,17 +43,32 @@ export async function POST(request: Request) {
} }
}); });
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()); const sent = await sender([email], UnsubscribeTemplate());
if (!sent) { if (!sent) {
return ApiResponse(500, 'Internal server error'); return ApiResponse(
STATUS_INTERNAL_SERVER_ERROR,
'Internal server error'
);
} }
} }
const message: z.infer<typeof ResponseSchema> = { const message: ResponseType = {
success: true, success: true,
message: `${email} unsubscribed.` message: `${email} unsubscribed.`
}; };
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);
}
} }

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import { CardDescription } from '@components/Card';
import CustomCard from '@components/CustomCard';
import { ResponseType } from '@utils/validationSchemas';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react'; import { Suspense, useEffect, useState } from 'react';
import { z } from 'zod';
import { Card } from '../../components/custom/card';
import { ResponseSchema } from '../../utils/schemas';
function ConfirmationPage() { function ConfirmationPage() {
const router = useRouter(); const router = useRouter();
@@ -14,11 +14,14 @@ function ConfirmationPage() {
const code = searchParams.get('code'); const code = searchParams.get('code');
useEffect(() => { useEffect(() => {
const fetchData = async () => {
if (!code) { if (!code) {
router.push('/'); router.push('/');
return;
} }
fetch('/api/confirmation', { try {
const res = await fetch('/api/confirmation', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -26,37 +29,44 @@ function ConfirmationPage() {
body: JSON.stringify({ body: JSON.stringify({
code: code code: code
}) })
}) });
.then(async res => {
if (!res.ok) { if (!res.ok) {
router.push('/'); router.push('/');
return;
} }
const response: z.infer<typeof ResponseSchema> = await res.json(); const response: ResponseType = await res.json();
if (!response.success) { if (!response.success) {
router.push('/'); router.push('/');
return;
} }
return response;
})
.then(response => {
setMessage(response.message); setMessage(response.message);
setLoading(false); setLoading(false);
}); } catch (error) {
console.error(error);
router.push('/');
}
};
fetchData();
}, [code, router]); }, [code, router]);
function render() { function render() {
if (!loading) { if (!loading) {
return message; return (
<CardDescription className='text-center'>{message}</CardDescription>
);
} }
return 'Just a second...'; return 'Just a second...';
} }
return ( return (
<Card <CustomCard
style='text-center' className='max-90vw w-96'
title={loading ? 'Verifying' : 'Confirmed!'} title={loading ? 'Verifying' : 'Confirmed!'}
content={render()} content={render()}
footer={false} footer={false}
@@ -66,7 +76,7 @@ function ConfirmationPage() {
export default function Confirmation() { export default function Confirmation() {
return ( return (
<Suspense> <Suspense fallback={<>Loading...</>}>
<ConfirmationPage /> <ConfirmationPage />
</Suspense> </Suspense>
); );

View File

@@ -35,47 +35,25 @@
--radius: 0.5rem; --radius: 0.5rem;
} }
.dark { h1 {
--background: 0 0% 3.9%; @apply text-3xl font-semibold;
--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%;
}
} }
.styledH2 { h2 {
@apply text-xl font-bold; @apply text-2xl font-semibold;
} }
.styledH3 { h3 {
@apply text-xl font-semibold;
}
h4 {
@apply text-lg font-semibold; @apply text-lg font-semibold;
} }
.styledH4 { h6 {
@apply text-base font-medium; @apply text-lg italic;
}
} }
/* Card border gradient */ /* Card border gradient */
@@ -102,7 +80,6 @@ body {
content: ''; content: '';
top: calc(-1 * var(--radius)); top: calc(-1 * var(--radius));
left: calc(-1 * var(--radius)); left: calc(-1 * var(--radius));
z-index: -1;
width: calc(100% + var(--radius) * 2); width: calc(100% + var(--radius) * 2);
height: calc(100% + var(--radius) * 2); height: calc(100% + var(--radius) * 2);
background: linear-gradient( background: linear-gradient(

View File

@@ -1,8 +1,8 @@
import Tiles from '@components/tiles/Tiles';
import { cn } from '@utils/ui';
import { Analytics } from '@vercel/analytics/react'; import { Analytics } from '@vercel/analytics/react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Inter as FontSans } from 'next/font/google'; import { Inter as FontSans } from 'next/font/google';
import { Tiles } from '../components/custom/tiles/tiles';
import { cn } from '../utils/ui';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -22,16 +22,16 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html lang='en' suppressHydrationWarning> <html lang='en'>
<head /> <head />
<body <body
className={cn( className={cn(
'flex min-h-screen items-center justify-center bg-background font-sans antialiased', 'flex justify-center bg-background font-sans antialiased',
fontSans.variable fontSans.variable
)} )}
> >
<Tiles> <Tiles>
<div style={{ zIndex: 2 }}>{children}</div> <div className='z-10'>{children}</div>
</Tiles> </Tiles>
<Analytics /> <Analytics />
</body> </body>

View File

@@ -1,46 +1,35 @@
'use client'; '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 { 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 { import {
Form, ResponseType,
FormControl, SubscribeFormSchema,
FormField, SubscribeFormType
FormItem, } from '@utils/validationSchemas';
FormMessage import { useState } from 'react';
} from '../components/ui/form'; import { FormProvider, useForm } from 'react-hook-form';
import { Input } from '../components/ui/input';
import { ResponseSchema, SubscribeFormSchema } from '../utils/schemas';
export default function Home() { export default function Home() {
const [completed, setCompleted] = useState(false); const [completed, setCompleted] = useState(false);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [error, setError] = useState(false); const [error, setError] = useState(false);
const honeypotRef = useRef<HTMLInputElement | null>(null);
const form = useForm<z.infer<typeof SubscribeFormSchema>>({ const form = useForm<SubscribeFormType>({
resolver: zodResolver(SubscribeFormSchema), resolver: zodResolver(SubscribeFormSchema),
defaultValues: { defaultValues: {
email: '', email: ''
name: ''
} }
}); });
useEffect(() => { async function handleSubmit(values: SubscribeFormType) {
if (honeypotRef.current) {
honeypotRef.current.style.display = 'none';
}
}, []);
async function handleSubmit(values: z.infer<typeof SubscribeFormSchema>) {
if (values.name) {
return;
}
try { try {
const response = await fetch('/api/subscribe', { const response = await fetch('/api/subscribe', {
method: 'POST', method: 'POST',
@@ -56,8 +45,7 @@ export default function Home() {
throw new Error(`Invalid response: ${response.status}`); throw new Error(`Invalid response: ${response.status}`);
} }
const formResponse: z.infer<typeof ResponseSchema> = const formResponse: ResponseType = await response.json();
await response.json();
if (!formResponse.success) { if (!formResponse.success) {
throw Error(formResponse.message); throw Error(formResponse.message);
@@ -76,12 +64,14 @@ export default function Home() {
} }
if (completed) { if (completed) {
return message; return (
<CardDescription className='text-center'>{message}</CardDescription>
);
} }
return ( return (
<div className='mx-2 h-44'> <div className='mx-2 h-44'>
<Form {...form}> <FormProvider {...form}>
<form <form
onSubmit={form.handleSubmit(handleSubmit)} onSubmit={form.handleSubmit(handleSubmit)}
className='flex flex-col space-y-4' className='flex flex-col space-y-4'
@@ -91,7 +81,7 @@ export default function Home() {
name='email' name='email'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className='h-4'> <div className='h-6'>
<FormMessage className='text-center' /> <FormMessage className='text-center' />
</div> </div>
<FormControl> <FormControl>
@@ -108,7 +98,7 @@ export default function Home() {
<Button type='submit'>Submit</Button> <Button type='submit'>Submit</Button>
</div> </div>
</form> </form>
</Form> </FormProvider>
<p className='py-1 text-center text-xs text-gray-600'> <p className='py-1 text-center text-xs text-gray-600'>
You can rest assured that we will fill your inbox with spam. We You can rest assured that we will fill your inbox with spam. We
don&apos;t like it either! 🙂 don&apos;t like it either! 🙂
@@ -118,8 +108,8 @@ export default function Home() {
} }
return ( return (
<Card <CustomCard
style='text-center max-w-96' className='max-90vw w-96'
title='Interested in keeping up with the latest from the tech world? 👩‍💻' title='Interested in keeping up with the latest from the tech world? 👩‍💻'
description='Subscribe to our newsletter! The top stories from Hackernews for you. Once a day. Every day.' description='Subscribe to our newsletter! The top stories from Hackernews for you. Once a day. Every day.'
content={render()} content={render()}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import CustomCard from '@components/CustomCard';
import Link from 'next/link'; import Link from 'next/link';
import { Card } from '../../components/custom/card';
export default function Privacy() { export default function Privacy() {
const body = ( const body = (
@@ -25,8 +26,8 @@ export default function Privacy() {
. .
</p> </p>
<br /> <br />
<h2 className='styledH2'>Interpretation and Definitions</h2> <h2>Interpretation and Definitions</h2>
<h3 className='styledH3'>Interpretation</h3> <h3>Interpretation</h3>
<p> <p>
The words of which the initial letter is capitalized have meanings The words of which the initial letter is capitalized have meanings
defined under the following conditions. The following definitions shall defined under the following conditions. The following definitions shall
@@ -34,7 +35,7 @@ export default function Privacy() {
in plural. in plural.
</p> </p>
<br /> <br />
<h3 className='styledH3'>Definitions</h3> <h4>Definitions</h4>
<p>For the purposes of this Privacy Policy:</p> <p>For the purposes of this Privacy Policy:</p>
<ul> <ul>
<li> <li>
@@ -115,9 +116,9 @@ export default function Privacy() {
</li> </li>
</ul> </ul>
<br /> <br />
<h2 className='styledH2'>Collecting and Using Your Personal Data</h2> <h3>Collecting and Using Your Personal Data</h3>
<h3 className='styledH3'>Types of Data Collected</h3> <h4>Types of Data Collected</h4>
<h4 className='styledH4'>Personal Data</h4> <h6>Personal Data</h6>
<p> <p>
While using Our Service, We may ask You to provide Us with certain While using Our Service, We may ask You to provide Us with certain
personally identifiable information that can be used to contact or personally identifiable information that can be used to contact or
@@ -133,7 +134,7 @@ export default function Privacy() {
</li> </li>
</ul> </ul>
<br /> <br />
<h4 className='styledH4'>Usage Data</h4> <h6>Usage Data</h6>
<p>Usage Data is collected automatically when using the Service.</p> <p>Usage Data is collected automatically when using the Service.</p>
<p> <p>
Usage Data may include information such as Your Device&aposs Internet Usage Data may include information such as Your Device&aposs Internet
@@ -156,7 +157,7 @@ export default function Privacy() {
device. device.
</p> </p>
<br /> <br />
<h3 className='styledH3'>Use of Your Personal Data</h3> <h4>Use of Your Personal Data</h4>
<p>The Company may use Personal Data for the following purposes:</p> <p>The Company may use Personal Data for the following purposes:</p>
<ul> <ul>
<li> <li>
@@ -265,7 +266,7 @@ export default function Privacy() {
</li> </li>
</ul> </ul>
<br /> <br />
<h3 className='styledH3'>Retention of Your Personal Data</h3> <h4>Retention of Your Personal Data</h4>
<p> <p>
The Company will retain Your Personal Data only for as long as is 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 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. data for longer time periods.
</p> </p>
<br /> <br />
<h3 className='styledH3'>Transfer of Your Personal Data</h3> <h4>Transfer of Your Personal Data</h4>
<p> <p>
Your information, including Personal Data, is processed at the Your information, including Personal Data, is processed at the
Company&aposs operating offices and in any other places where 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. security of Your data and other personal information.
</p> </p>
<br /> <br />
<h3 className='styledH3'>Delete Your Personal Data</h3> <h4>Delete Your Personal Data</h4>
<p> <p>
You have the right to delete or request that We assist in deleting the You have the right to delete or request that We assist in deleting the
Personal Data that We have collected about You. 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. when we have a legal obligation or lawful basis to do so.
</p> </p>
<br /> <br />
<h3 className='styledH3'>Disclosure of Your Personal Data</h3> <h4>Disclosure of Your Personal Data</h4>
<h4 className='styledH4'>Business Transactions</h4> <h6>Business Transactions</h6>
<p> <p>
If the Company is involved in a merger, acquisition or asset sale, Your If the Company is involved in a merger, acquisition or asset sale, Your
Personal Data may be transferred. We will provide notice before Your Personal Data may be transferred. We will provide notice before Your
@@ -334,14 +335,14 @@ export default function Privacy() {
Policy. Policy.
</p> </p>
<br /> <br />
<h4 className='styledH4'>Law enforcement</h4> <h6>Law enforcement</h6>
<p> <p>
Under certain circumstances, the Company may be required to disclose 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 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). requests by public authorities (e.g. a court or a government agency).
</p> </p>
<br /> <br />
<h4 className='styledH4'>Other legal requirements</h4> <h6>Other legal requirements</h6>
<p> <p>
The Company may disclose Your Personal Data in the good faith belief The Company may disclose Your Personal Data in the good faith belief
that such action is necessary to: that such action is necessary to:
@@ -358,9 +359,8 @@ export default function Privacy() {
</li> </li>
<li>Protect against legal liability</li> <li>Protect against legal liability</li>
</ul> </ul>
<br /> <br />
<h3 className='styledH3'>Security of Your Personal Data</h3> <h4>Security of Your Personal Data</h4>
<p> <p>
The security of Your Personal Data is important to Us, but remember that The security of Your Personal Data is important to Us, but remember that
no method of transmission over the Internet, or method of electronic no method of transmission over the Internet, or method of electronic
@@ -369,7 +369,7 @@ export default function Privacy() {
security. security.
</p> </p>
<br /> <br />
<h2 className='styledH2'>{"Children's Privacy"}</h2> <h3>{"Children's Privacy"}</h3>
<p> <p>
Our Service does not address anyone under the age of 13. We do not Our Service does not address anyone under the age of 13. We do not
knowingly collect personally identifiable information from anyone under knowingly collect personally identifiable information from anyone under
@@ -386,7 +386,7 @@ export default function Privacy() {
information. information.
</p> </p>
<br /> <br />
<h2 className='styledH2'>Links to Other Websites</h2> <h3>Links to Other Websites</h3>
<p> <p>
Our Service may contain links to other websites that are not operated by 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 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. privacy policies or practices of any third party sites or services.
</p> </p>
<br /> <br />
<h2 className='styledH2'>Changes to this Privacy Policy</h2> <h3>Changes to this Privacy Policy</h3>
<p> <p>
We may update Our Privacy Policy from time to time. We will notify You 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. of any changes by posting the new Privacy Policy on this page.
@@ -414,7 +414,7 @@ export default function Privacy() {
posted on this page. posted on this page.
</p> </p>
<br /> <br />
<h2 className='styledH2'>Contact Us</h2> <h3>Contact Us</h3>
<p> <p>
If you have any questions about this Privacy Policy, You can contact us If you have any questions about this Privacy Policy, You can contact us
by writing to{' '} by writing to{' '}
@@ -439,8 +439,8 @@ export default function Privacy() {
); );
return ( return (
<Card <CustomCard
style='max-h-[90vh] max-w-[90vw]' className='max-90vh max-90vw'
title='Privacy Policy' title='Privacy Policy'
description='Last updated: December 03, 2023' description='Last updated: December 03, 2023'
content={body} content={body}

View File

@@ -4,7 +4,7 @@ export default function robots(): MetadataRoute.Robots {
return { return {
rules: { rules: {
userAgent: '*', userAgent: '*',
disallow: '/' disallow: ''
}, },
sitemap: `${process.env.HOME_URL!}/sitemap.xml` sitemap: `${process.env.HOME_URL!}/sitemap.xml`
}; };

View File

@@ -1,46 +1,42 @@
'use client'; '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 { 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 { import {
Form, ResponseType,
FormControl, UnsubscribeFormSchema,
FormField, UnsubscribeFormType
FormItem, } from '@utils/validationSchemas';
FormMessage import { useEffect, useRef, useState } from 'react';
} from '../../components/ui/form'; import { FormProvider, useForm } from 'react-hook-form';
import { Input } from '../../components/ui/input';
import { ResponseSchema, UnsubscribeFormSchema } from '../../utils/schemas';
export default function Unsubscribe() { export default function Unsubscribe() {
const [completed, setCompleted] = useState(false); const [completed, setCompleted] = useState(false);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [error, setError] = useState(false); const [error, setError] = useState(false);
const honeypotRef = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const form = useForm<z.infer<typeof UnsubscribeFormSchema>>({ const form = useForm<UnsubscribeFormType>({
resolver: zodResolver(UnsubscribeFormSchema), resolver: zodResolver(UnsubscribeFormSchema),
defaultValues: { defaultValues: {
email: '', email: ''
name: ''
} }
}); });
useEffect(() => { useEffect(() => {
if (honeypotRef.current) { if (ref.current) {
honeypotRef.current.style.display = 'none'; ref.current.style.display = 'none';
} }
}, []); }, []);
async function handleSubmit(values: z.infer<typeof UnsubscribeFormSchema>) { async function handleSubmit(values: UnsubscribeFormType) {
if (values.name) {
return;
}
try { try {
const response = await fetch('/api/unsubscribe', { const response = await fetch('/api/unsubscribe', {
method: 'POST', method: 'POST',
@@ -56,8 +52,7 @@ export default function Unsubscribe() {
throw new Error(`Invalid response: ${response.status}`); throw new Error(`Invalid response: ${response.status}`);
} }
const formResponse: z.infer<typeof ResponseSchema> = const formResponse: ResponseType = await response.json();
await response.json();
if (!formResponse.success) { if (!formResponse.success) {
throw Error(formResponse.message); throw Error(formResponse.message);
@@ -76,12 +71,14 @@ export default function Unsubscribe() {
} }
if (completed) { if (completed) {
return message; return (
<CardDescription className='text-center'>{message}</CardDescription>
);
} }
return ( return (
<div className='mb-5 h-32'> <div className='mb-5 h-32'>
<Form {...form}> <FormProvider {...form}>
<form <form
onSubmit={form.handleSubmit(handleSubmit)} onSubmit={form.handleSubmit(handleSubmit)}
className='flex flex-col space-y-4' className='flex flex-col space-y-4'
@@ -91,11 +88,15 @@ export default function Unsubscribe() {
name='email' name='email'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className='h-4'> <div className='h-6'>
<FormMessage className='text-center' /> <FormMessage className='text-center' />
</div> </div>
<FormControl> <FormControl>
<Input placeholder='example@example.com' {...field} /> <Input
placeholder='example@example.com'
className='text-center'
{...field}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@@ -104,14 +105,14 @@ export default function Unsubscribe() {
<Button type='submit'>Submit</Button> <Button type='submit'>Submit</Button>
</div> </div>
</form> </form>
</Form> </FormProvider>
</div> </div>
); );
} }
return ( return (
<Card <CustomCard
style='text-center max-w-80' className='max-90vw w-96'
title='Unsubscribe' title='Unsubscribe'
description='You sure you want to leave? :(' description='You sure you want to leave? :('
content={render()} content={render()}

View File

@@ -1,6 +1,6 @@
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
import { cn } from '@utils/ui';
import * as React from 'react'; import * as React from 'react';
import { cn } from '../../utils/ui';
export type ButtonProps = { export type ButtonProps = {
asChild?: boolean; asChild?: boolean;

View File

@@ -1,5 +1,5 @@
import { cn } from '@utils/ui';
import * as React from 'react'; import * as React from 'react';
import { cn } from '../../utils/ui';
const Card = React.forwardRef< const Card = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -7,10 +7,7 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn( className={cn('rounded-lg bg-card text-card-foreground', className)}
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props} {...props}
/> />
)); ));
@@ -32,12 +29,9 @@ const CardTitle = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement> React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<h3 <h1
ref={ref} ref={ref}
className={cn( className={cn('leading-none tracking-tight', className)}
'text-3xl font-semibold leading-none tracking-tight',
className
)}
{...props} {...props}
/> />
)); ));

View File

@@ -1,32 +1,30 @@
import { ReactNode, useEffect, useState } from 'react'; import { ReactNode, useEffect, useState } from 'react';
import { useMediaQuery } from 'react-responsive';
import { import {
Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle
Card as CardUI } from './Card';
} from '../../components/ui/card'; import Footer from './Footer';
import Footer from './footer';
type CardProps = { interface CardProps {
title: string; title: string;
description?: string; description?: string;
content: ReactNode; content: ReactNode;
style?: string; className?: string;
footer?: boolean; footer?: boolean;
}; }
export const Card = ({ export default function CustomCard({
title, title,
description, description,
content, content,
style, className,
footer = true footer = true
}: CardProps) => { }: CardProps) {
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const isMobile = useMediaQuery({ query: '(max-width: 767px)' });
useEffect(() => { useEffect(() => {
setIsLoaded(true); setIsLoaded(true);
@@ -37,13 +35,8 @@ export const Card = ({
} }
return ( return (
<div className='gradient-border'> <div className='gradient-border shadow-2xl shadow-black'>
<CardUI <Card className={`z-10 max-w-[90vw] p-8 ${className}`}>
style={{
boxShadow: '0 16px 32px 0 rgba(0, 0, 0, 0.6)'
}}
className={`max-h-[90vh] max-w-[90vw] p-8 ${style}`}
>
<CardHeader> <CardHeader>
<p className='text-xs uppercase text-gray-500'> <p className='text-xs uppercase text-gray-500'>
Hackernews + newsletter Hackernews + newsletter
@@ -51,19 +44,13 @@ export const Card = ({
<CardTitle>{title}</CardTitle> <CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription> <CardDescription>{description}</CardDescription>
</CardHeader> </CardHeader>
{isMobile ? (
<CardContent>{content}</CardContent> <CardContent>{content}</CardContent>
) : (
<CardContent className='flex max-h-[60vh] flex-grow justify-center overflow-auto'>
{content}
</CardContent>
)}
{footer && ( {footer && (
<CardFooter> <CardFooter>
<Footer /> <Footer />
</CardFooter> </CardFooter>
)} )}
</CardUI> </Card>
</div> </div>
); );
}; }

View File

@@ -1,13 +1,13 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '../ui/button'; import { Button } from './Button';
type LinkProps = { interface LinkProps {
path: string; path: string;
text: string; text: string;
}; }
export function CustomLink({ path, text }: LinkProps) { export default function CustomLink({ path, text }: LinkProps) {
return ( return (
<Button asChild> <Button asChild>
<Link href={path}>{text}</Link> <Link href={path}>{text}</Link>

View File

@@ -1,11 +1,11 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { CustomLink } from './customLink'; import CustomLink from './CustomLink';
const links = [{ name: 'Subscribe', path: '/' }]; const links = [{ name: 'Subscribe', path: '/' }];
function Footer() { export default function Footer() {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
@@ -22,7 +22,7 @@ function Footer() {
. .
</p> </p>
) : ( ) : (
<ul className='flex justify-center space-x-4'> <div className='flex justify-center space-x-4'>
{links.map( {links.map(
link => link =>
pathname !== link.path && pathname !== link.path &&
@@ -33,10 +33,8 @@ function Footer() {
{pathname === '/privacy' && ( {pathname === '/privacy' && (
<CustomLink path='/unsubscribe' text='Unsubscribe' /> <CustomLink path='/unsubscribe' text='Unsubscribe' />
)} )}
</ul> </div>
)} )}
</div> </div>
); );
} }
export default Footer;

View File

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

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import * as LabelPrimitive from '@radix-ui/react-label'; import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@utils/ui';
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react'; import * as React from 'react';
import { cn } from '../../utils/ui';
const labelVariants = cva( const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'

View File

@@ -1,48 +0,0 @@
import { useEffect, useState } from 'react';
import { z } from 'zod';
import { NewsTileSchema } from '../../../../utils/schemas';
import { TileContent } from './tileContent';
type CardProps = {
newsA?: z.infer<typeof NewsTileSchema>;
newsB?: z.infer<typeof NewsTileSchema>;
};
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 <div></div>;
return (
<div className={`transform ${switched ? 'animate-rotate' : ''}`}>
<div className='transform-gpu'>
<div className={`absolute left-0 top-0 w-full ${''}`}>
{active
? TileContent({ story: newsA, side: true })
: TileContent({ story: newsB, side: false })}
</div>
</div>
</div>
);
}

View File

@@ -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<typeof NewsTileSchema>;
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 (
<div
className={`h-40 w-40 overflow-hidden rounded-lg p-6 shadow-sm`}
style={{
backgroundColor: `${color}`,
color: '#808080'
}}
>
<h1 className='overflow-auto font-semibold'>{story.title}</h1>
<p className='overflow-auto italic'>by {story.by}</p>
<div
className='rounded-lg'
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '33.33%',
background: `linear-gradient(to bottom, transparent, ${color})`
}}
></div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { Note } from './components/note'; import Note from './components/Note';
import Template from './template'; import Template from './Template';
export default function ConfirmationTemplate(code: string) { export default function ConfirmationTemplate(code: string) {
return { return {

View File

@@ -1,12 +1,9 @@
import { z } from 'zod'; import { sayings } from '@utils/sayings';
import { NewsSchema } from '../../utils/schemas'; import { textTruncate } from '@utils/textTruncate';
import { textTruncate } from '../../utils/textTruncate'; import { NewsType } from '@utils/validationSchemas';
import { sayings } from './helpers/sayings'; import Template from './Template';
import Template from './template';
export default function NewsletterTemplate( export default function NewsletterTemplate(stories: NewsType[]) {
stories: z.infer<typeof NewsSchema>[]
) {
return { return {
subject: `What's new from the Hackernews forum?`, subject: `What's new from the Hackernews forum?`,
template: ( template: (
@@ -40,9 +37,7 @@ export default function NewsletterTemplate(
paddingRight: '1.5rem' paddingRight: '1.5rem'
}} }}
> >
<h2 style={{ fontSize: '1.5rem', fontWeight: '600' }}> <h3>{story.title}</h3>
{story.title}
</h2>
<p style={{ fontSize: '1rem', fontStyle: 'italic' }}> <p style={{ fontSize: '1rem', fontStyle: 'italic' }}>
by {story.by} by {story.by}
</p> </p>

View File

@@ -1,9 +1,9 @@
import { Footer } from './components/footer'; import Footer from './components/Footer';
type TemplateProps = { interface TemplateProps {
title: string; title: string;
body: JSX.Element; body: JSX.Element;
}; }
export default function Template({ title, body }: TemplateProps) { export default function Template({ title, body }: TemplateProps) {
return ( return (
@@ -12,21 +12,19 @@ export default function Template({ title, body }: TemplateProps) {
maxWidth: '720px', maxWidth: '720px',
alignContent: 'center', alignContent: 'center',
alignItems: 'center', alignItems: 'center',
backgroundColor: '#F9FBFB', backgroundColor: '#F9FBFB'
}} }}
> >
<h1 <h2
style={{ style={{
padding: '20px', padding: '20px',
textAlign: 'center', textAlign: 'center',
fontSize: '24px',
fontWeight: 'bold',
color: 'white', color: 'white',
backgroundColor: `#8230CC`, backgroundColor: `#8230CC`
}} }}
> >
{title} {title}
</h1> </h2>
<div style={{ margin: '20px', padding: '20px' }}>{body}</div> <div style={{ margin: '20px', padding: '20px' }}>{body}</div>
<Footer /> <Footer />
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Note } from './components/note'; import Note from './components/Note';
import Template from './template'; import Template from './Template';
export default function UnsubscribeTemplate() { export default function UnsubscribeTemplate() {
return { return {

View File

@@ -1,4 +1,4 @@
export function Footer() { export default function Footer() {
return ( return (
<footer <footer
style={{ style={{

View File

@@ -1,8 +1,8 @@
type NoteProps = { interface NoteProps {
children: React.ReactNode; children: React.ReactNode;
}; }
export function Note({ children }: NoteProps) { export default function Note({ children }: NoteProps) {
return ( return (
<div <div
style={{ style={{

View File

@@ -0,0 +1,26 @@
import { useFormField } from '@hooks/useFormField';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
export 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';

View File

@@ -0,0 +1,20 @@
import { useFormField } from '@hooks/useFormField';
import { cn } from '@utils/ui';
import * as React from 'react';
export const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';

View File

@@ -0,0 +1,22 @@
import { useFormField } from '@hooks/useFormField';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@utils/ui';
import * as React from 'react';
import { Label } from '../Label';
export 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';

View File

@@ -0,0 +1,28 @@
import { useFormField } from '@hooks/useFormField';
import { cn } from '@utils/ui';
import * as React from 'react';
export const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
return (
<>
{!body ? null : (
<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</p>
)}
</>
);
});
FormMessage.displayName = 'FormMessage';

View File

@@ -1,16 +1,15 @@
'use client'; 'use client';
import { NewsTileType } from '@utils/validationSchemas';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { z } from 'zod'; import Tile from './components/Tile';
import { NewsTile, NewsTileSchema } from '../../../utils/schemas';
import { Tile } from './components/tile';
type TilesProps = { interface TilesProps {
children: React.ReactNode; children: React.ReactNode;
}; }
export const Tiles = ({ children }: TilesProps) => { export default function Tiles({ children }: TilesProps) {
const pathname = usePathname(); const pathname = usePathname();
const [windowSize, setWindowSize] = useState<{ const [windowSize, setWindowSize] = useState<{
width: number; width: number;
@@ -19,16 +18,16 @@ export const Tiles = ({ children }: TilesProps) => {
width: 0, width: 0,
height: 0 height: 0
}); });
const [news, setNews] = useState<z.infer<typeof NewsTileSchema>[]>(); const [news, setNews] = useState<NewsTileType[]>();
useEffect(() => { useEffect(() => {
async function getNews() { async function getNews() {
const news: NewsTile[] = await fetch('/api/news').then(res => res.json()); const news: NewsTileType[] = await fetch('/api/news').then(res =>
res.json()
);
if (news) {
setNews(news); setNews(news);
} }
}
if (!news) { if (!news) {
getNews(); getNews();
@@ -50,11 +49,10 @@ export const Tiles = ({ children }: TilesProps) => {
return () => { return () => {
window.removeEventListener('resize', handleResize); window.removeEventListener('resize', handleResize);
}; };
}, [setWindowSize, news]); }, [news]);
if (pathname === '/maintenance') return <div>{children}</div>; const renderTile = useCallback(
(key: number) => {
function renderTile(key: number) {
if (!news) return <div key={key}></div>; if (!news) return <div key={key}></div>;
const randomA = Math.floor(Math.random() * news?.length); const randomA = Math.floor(Math.random() * news?.length);
@@ -67,17 +65,22 @@ export const Tiles = ({ children }: TilesProps) => {
<Tile newsA={news[randomA]} newsB={news[randomB]} /> <Tile newsA={news[randomA]} newsB={news[randomB]} />
</div> </div>
); );
} },
[news]
);
function renderRow(columns: number, key: number) { const renderRow = useCallback(
(columns: number, key: number) => {
return ( return (
<div key={key} className='flex justify-between'> <div key={key} className='flex justify-between'>
{Array.from({ length: columns }).map((_, index) => renderTile(index))} {Array.from({ length: columns }).map((_, index) => renderTile(index))}
</div> </div>
); );
} },
[renderTile]
);
function renderGrid() { const renderGrid = useCallback(() => {
const columns = Math.ceil(windowSize.width / (40 * 4)); const columns = Math.ceil(windowSize.width / (40 * 4));
const rows = Math.ceil(windowSize.height / (40 * 4)); const rows = Math.ceil(windowSize.height / (40 * 4));
@@ -93,7 +96,9 @@ export const Tiles = ({ children }: TilesProps) => {
</div> </div>
</div> </div>
); );
} }, [children, renderRow, windowSize]);
if (pathname === '/maintenance') return <div>{children}</div>;
return <div className='flex h-[100vh] overflow-hidden'>{renderGrid()}</div>; return <div className='flex h-[100vh] overflow-hidden'>{renderGrid()}</div>;
}; }

View File

@@ -0,0 +1,68 @@
import { getRandomGrey } from '@utils/getRandomGrey';
import { NewsTileType } from '@utils/validationSchemas';
import { useEffect, useState } from 'react';
import TileContent from './TileContent';
interface CardProps {
newsA?: NewsTileType;
newsB?: NewsTileType;
}
const TEN_SECONDS = 10000;
const HALF_SECOND = 500;
export default function Tile({ newsA, newsB }: CardProps) {
const [switched, setSwitched] = useState(false);
const [active, setActive] = useState(false);
const [delayed, setDelayed] = useState(true);
const [colorA, setColorA] = useState(getRandomGrey());
const [colorB, setColorB] = useState(getRandomGrey());
useEffect(() => {
const randomDelay = Math.floor(Math.random() * TEN_SECONDS);
const interval = setInterval(
() => {
setSwitched(true);
window.setTimeout(function () {
setActive(!active);
setColorA(getRandomGrey());
setColorB(getRandomGrey());
}, HALF_SECOND / 2);
window.setTimeout(function () {
setSwitched(false);
setDelayed(false);
}, HALF_SECOND);
},
delayed ? randomDelay : randomDelay + TEN_SECONDS
);
return () => clearInterval(interval);
}, [active, delayed]);
if (!newsA || !newsB) return <div></div>;
return (
<div className={`transform ${switched ? 'animate-rotate' : ''}`}>
<div className='transform-gpu'>
<div className={`absolute left-0 top-0 w-full ${''}`}>
{active
? TileContent({
story: newsA,
side: true,
firstColor: colorA,
secondColor: colorB
})
: TileContent({
story: newsB,
side: false,
firstColor: colorB,
secondColor: colorA
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { NewsTileType } from '@utils/validationSchemas';
interface CardContentProps {
story: NewsTileType;
side: boolean;
firstColor: string;
secondColor: string;
}
export default function TileContent({
story,
side,
firstColor,
secondColor
}: CardContentProps) {
const color = side ? firstColor : secondColor;
return (
<div
className={`h-40 w-40 overflow-hidden rounded-lg p-6 shadow-sm`}
style={{
backgroundColor: `${color}`,
color: '#808080'
}}
>
<h4 className='overflow-auto'>{story.title}</h4>
<p className='overflow-auto italic'>by {story.by}</p>
<div
className='rounded-lg'
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '33.33%',
background: `linear-gradient(to bottom, transparent, ${color})`
}}
></div>
</div>
);
}

View File

@@ -1,177 +0,0 @@
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/ui';
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-sm text-muted-foreground', 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;
return (
<>
{!body ? null : (
<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</p>
)}
</>
);
});
FormMessage.displayName = 'FormMessage';
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField
};

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { FieldPath, FieldValues } from 'react-hook-form';
interface FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> {
name: TName;
}
export const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);

View File

@@ -0,0 +1,20 @@
import {
Controller,
ControllerProps,
FieldPath,
FieldValues
} from 'react-hook-form';
import { FormFieldContext } from './FormFieldContext';
export 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>
);
};

View File

@@ -0,0 +1,9 @@
import React from 'react';
interface FormItemContextValue {
id: string;
}
export const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);

View File

@@ -0,0 +1,18 @@
import { cn } from '@utils/ui';
import * as React from 'react';
import { FormItemContext } from './FormItemContext';
export 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';

29
hooks/useFormField.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { FormFieldContext } from '@contexts/FormField/FormFieldContext';
import { FormItemContext } from '@contexts/FormItem/FormItemContext';
import * as React from 'react';
import { useFormContext } from 'react-hook-form';
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
};
};
export { useFormField };

View File

@@ -1,8 +1,8 @@
{ {
"name": "nextjs-hackernews", "name": "nextjs-hackernews",
"version": "0.2.0", "version": "0.3.0",
"description": "Template for NodeJS APIs with TypeScript, with configurations for linting and testing", "description": "Template for NodeJS APIs with TypeScript, with configurations for linting and testing",
"author": "riccardo.s@hey.com", "author": "riccardo@frompixels.com",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "prisma generate && next build", "build": "prisma generate && next build",
@@ -13,16 +13,17 @@
"prepare": "husky install", "prepare": "husky install",
"vercel:link": "vercel link", "vercel:link": "vercel link",
"vercel:env": "vercel env pull .env", "vercel:env": "vercel env pull .env",
"prisma:migrate": "npx prisma migrate dev",
"prisma:push": "npx prisma db push", "prisma:push": "npx prisma db push",
"prisma:generate": "npx prisma generate", "prisma:generate": "npx prisma generate",
"prisma:reset": "npx prisma db push --force-reset" "prisma:reset": "npx prisma db push --force-reset"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.3.2", "@hookform/resolvers": "^3.3.2",
"@next/third-parties": "^14.2.3",
"@prisma/client": "^5.6.0", "@prisma/client": "^5.6.0",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@vercel/analytics": "^1.1.1", "@vercel/analytics": "^1.1.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
@@ -31,7 +32,6 @@
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.48.2", "react-hook-form": "^7.48.2",
"react-responsive": "^9.0.2",
"resend": "^3.1.0", "resend": "^3.1.0",
"tailwind-merge": "^2.1.0", "tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -43,6 +43,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0", "@typescript-eslint/parser": "^6.12.0",
"audit-ci": "^6.6.1", "audit-ci": "^6.6.1",
"autoprefixer": "^10.0.1", "autoprefixer": "^10.0.1",
@@ -59,10 +60,10 @@
"typescript": "^5" "typescript": "^5"
}, },
"lint-staged": { "lint-staged": {
"*.ts": [ "*.{ts,tsx}": [
"eslint --quiet --fix" "eslint --quiet --fix"
], ],
"*.{json,ts}": [ "*.{json,ts,tsx}": [
"prettier --write --ignore-unknown" "prettier --write --ignore-unknown"
] ]
} }

View File

@@ -0,0 +1,37 @@
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"resendId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"code" TEXT NOT NULL,
"confirmed" BOOLEAN NOT NULL DEFAULT false,
"deleted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastMail" TIMESTAMP(3),
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "news" (
"id" DOUBLE PRECISION NOT NULL,
"title" TEXT NOT NULL,
"text" TEXT,
"type" TEXT NOT NULL,
"by" TEXT NOT NULL,
"time" DOUBLE PRECISION NOT NULL,
"url" TEXT,
"score" DOUBLE PRECISION NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "news_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_code_key" ON "users"("code");
-- CreateIndex
CREATE UNIQUE INDEX "news_id_key" ON "news"("id");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -10,6 +10,7 @@ datasource db {
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
resendId String
email String @unique email String @unique
code String @unique code String @unique
confirmed Boolean @default(false) confirmed Boolean @default(false)

View File

@@ -4,8 +4,8 @@
<script> <script>
window.onload = function () { window.onload = function () {
var body = document.getElementsByTagName('body')[0]; var body = document.getElementsByTagName('body')[0];
var size = 160; // size of each square var size = 160;
var margin = 8; // margin between squares var margin = 8;
var squaresPerRow = Math.ceil(window.innerWidth / (size + margin)); var squaresPerRow = Math.ceil(window.innerWidth / (size + margin));
var squaresPerColumn = Math.ceil(window.innerHeight / (size + margin)); var squaresPerColumn = Math.ceil(window.innerHeight / (size + margin));

View File

@@ -54,28 +54,14 @@ module.exports = {
sm: 'calc(var(--radius) - 4px)' sm: 'calc(var(--radius) - 4px)'
}, },
keyframes: { 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 }
},
rotate: { rotate: {
'0%': { transform: 'rotateY(0deg)' }, '0%': { transform: 'rotateY(0deg)' },
'100%': { transform: 'rotateY(180deg)' } '50%': { transform: 'rotateY(90deg)' },
},
'rotate-inverse': {
'0%': { transform: 'rotateY(180deg)' },
'100%': { transform: 'rotateY(0deg)' } '100%': { transform: 'rotateY(0deg)' }
} }
}, },
animation: { animation: {
'accordion-down': 'accordion-down 0.2s ease-out', rotate: 'rotate 0.5s linear both'
'accordion-up': 'accordion-up 0.2s ease-out',
rotate: 'rotate 0.5s linear both',
'rotate-inverse': 'rotate-inverse 0.5s linear both'
}, },
fontFamily: { fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans] sans: ['var(--font-sans)', ...fontFamily.sans]

View File

@@ -19,9 +19,14 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./app/*"] "@app/*": ["./app/*"],
"@components/*": ["./components/*"],
"@contexts/*": ["./contexts/*"],
"@hooks/*": ["./hooks/*"],
"@prisma/*": ["./prisma/*"],
"@utils/*": ["./utils/*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", ".next", ".vercel"] "exclude": ["node_modules", ".yarn", ".next", ".vercel"]
} }

View File

@@ -1,8 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
export function ApiResponse(status: number, message: string) { export function ApiResponse(status: number, message: unknown) {
const response = new NextResponse(message, { status }); const stringMessage = JSON.stringify(message);
response.headers.set('Access-Control-Allow-Origin', process.env.HOME_URL!);
return response; return new NextResponse(stringMessage, { status });
} }

View File

@@ -1,22 +1,40 @@
import { Resend } from 'resend'; import { Resend } from 'resend';
type EmailTemplate = { interface EmailTemplate {
subject: string; subject: string;
template: JSX.Element; template: JSX.Element;
}; }
export async function sender( export async function sender(
to: string[], recipients: string[],
{ subject, template }: EmailTemplate { subject, template }: EmailTemplate
) { ) {
if (recipients.length === 0) {
console.info(`${subject} email skipped for having zero recipients`);
return true;
}
const resend = new Resend(process.env.RESEND_KEY); const resend = new Resend(process.env.RESEND_KEY);
try { try {
const { error } = await resend.batch.send( let response;
to.map(t => {
if (recipients.length == 1) {
response = await resend.emails.send({
from: process.env.RESEND_FROM!,
to: recipients[0],
subject,
react: template,
headers: {
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`
}
});
} else {
response = await resend.batch.send(
recipients.map(recipient => {
return { return {
from: process.env.RESEND_FROM!, from: process.env.RESEND_FROM!,
to: t, to: recipient,
subject, subject,
react: template, react: template,
headers: { headers: {
@@ -25,19 +43,19 @@ export async function sender(
}; };
}) })
); );
}
const { error } = response;
if (error) { if (error) {
console.log(error); console.error(error);
return false;
}
} catch (error) {
console.log(error);
return false; return false;
} }
console.log('Email sent', subject, to.length); console.info(`${subject} email sent to ${recipients.length} recipients`);
return true; return true;
} catch (error) {
console.error(error);
return false;
}
} }

6
utils/statusCodes.ts Normal file
View File

@@ -0,0 +1,6 @@
export const STATUS_OK = 200;
export const STATUS_BAD_REQUEST = 400;
export const BAD_REQUEST = 'Bad request';
export const STATUS_INTERNAL_SERVER_ERROR = 500;
export const INTERNAL_SERVER_ERROR = 'Internal server error';
export const STATUS_UNAUTHORIZED = 401;

View File

@@ -1,3 +1,5 @@
export const topNews = 'https://hacker-news.firebaseio.com/v0/topstories.json'; export const topNews = 'https://hacker-news.firebaseio.com/v0/topstories.json';
export const singleNews = (id: number) =>
`https://hacker-news.firebaseio.com/v0/item/${id}.json`; export function singleNews(id: number) {
return `https://hacker-news.firebaseio.com/v0/item/${id}.json`;
}

View File

@@ -5,20 +5,24 @@ export const ResponseSchema = z.object({
message: z.string() message: z.string()
}); });
export type ResponseType = z.infer<typeof ResponseSchema>;
export const SubscribeFormSchema = z.object({ export const SubscribeFormSchema = z.object({
email: z.string().email(), email: z.string().email()
name: z.string().optional()
}); });
export type SubscribeFormType = z.infer<typeof SubscribeFormSchema>;
export const ConfirmationSchema = z.object({ export const ConfirmationSchema = z.object({
code: z.string() code: z.string()
}); });
export const UnsubscribeFormSchema = z.object({ export const UnsubscribeFormSchema = z.object({
email: z.string().email(), email: z.string().email()
name: z.string().optional()
}); });
export type UnsubscribeFormType = z.infer<typeof UnsubscribeFormSchema>;
export const NewsDatabaseSchema = z.object({ export const NewsDatabaseSchema = z.object({
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
@@ -30,6 +34,8 @@ export const NewsDatabaseSchema = z.object({
score: z.number() score: z.number()
}); });
export type NewsDatabaseType = z.infer<typeof NewsDatabaseSchema>;
export const NewsSchema = z.object({ export const NewsSchema = z.object({
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
@@ -42,10 +48,12 @@ export const NewsSchema = z.object({
createdAt: z.date() createdAt: z.date()
}); });
export type NewsType = z.infer<typeof NewsSchema>;
export const NewsTileSchema = z.object({ export const NewsTileSchema = z.object({
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
by: z.string() by: z.string()
}); });
export type NewsTile = z.infer<typeof NewsTileSchema>; export type NewsTileType = z.infer<typeof NewsTileSchema>;

1323
yarn.lock

File diff suppressed because it is too large Load Diff