refactor: improve news and email handling, style, folder structure (#16)
This commit is contained in:
22
.env.example
22
.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=""
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,6 +16,9 @@
|
||||
# production
|
||||
/build
|
||||
|
||||
# yarn
|
||||
/.yarn
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
|
||||
yarn audit-ci
|
||||
yarn format
|
||||
yarn lint
|
||||
yarn lint-staged
|
||||
yarn typecheck
|
||||
yarn build
|
||||
@@ -1 +1,5 @@
|
||||
compressionLevel: mixed
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
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) {
|
||||
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(400, 'Bad request');
|
||||
return ApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -18,6 +30,14 @@ export async function POST(request: Request) {
|
||||
});
|
||||
|
||||
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({
|
||||
where: {
|
||||
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,
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
const newsPromises = topStories
|
||||
.slice(0, Number(process.env.NEWS_LIMIT))
|
||||
.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) {
|
||||
console.info(
|
||||
`Validated news N° ${singleNews.id} - ${singleNews.title}`
|
||||
);
|
||||
const result = await prisma.news.upsert({
|
||||
create: {
|
||||
...validation.data,
|
||||
id
|
||||
id: singleNews.id
|
||||
},
|
||||
update: {
|
||||
...validation.data
|
||||
},
|
||||
where: {
|
||||
id
|
||||
id: singleNews.id
|
||||
}
|
||||
});
|
||||
|
||||
console.info(`Imported N° ${singleNews.id} - ${singleNews.title}`);
|
||||
|
||||
return result;
|
||||
} else {
|
||||
console.error(validation.error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(newsPromises);
|
||||
const result = await Promise.all(upsertPromises);
|
||||
|
||||
return new NextResponse(`Imported ${newsPromises.length} news.`, {
|
||||
status: 200
|
||||
});
|
||||
} catch {
|
||||
return new NextResponse(`Import failed.`, {
|
||||
status: 500
|
||||
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.`
|
||||
});
|
||||
}
|
||||
|
||||
return ApiResponse(STATUS_OK, `Imported ${newsPromises.length} news.`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
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');
|
||||
}
|
||||
|
||||
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
|
||||
@@ -22,7 +31,7 @@ export async function GET(request: Request) {
|
||||
OR: [
|
||||
{
|
||||
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) {
|
||||
return new NextResponse('No users.', {
|
||||
status: 200
|
||||
});
|
||||
return ApiResponse(STATUS_OK, 'No user to mail to.');
|
||||
}
|
||||
|
||||
const news = await prisma.news.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gt: new Date(Date.now() - 1000 * 60 * 60 * 24)
|
||||
gt: new Date(Date.now() - ONE_DAY_IN_MS)
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
@@ -54,9 +63,24 @@ export async function GET(request: Request) {
|
||||
take: 25
|
||||
});
|
||||
|
||||
const validRankedNews = news
|
||||
.filter((item): item is z.infer<typeof NewsSchema> => item !== undefined)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
console.info(`Found ${news.length} news to include in the newsletter.`);
|
||||
|
||||
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(
|
||||
users.map(user => user.email),
|
||||
@@ -64,9 +88,7 @@ export async function GET(request: Request) {
|
||||
);
|
||||
|
||||
if (!sent) {
|
||||
return new NextResponse('Internal server error', {
|
||||
status: 500
|
||||
});
|
||||
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// 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.`, {
|
||||
status: 200
|
||||
});
|
||||
return ApiResponse(
|
||||
STATUS_OK,
|
||||
`Newsletter sent to ${users.length} addresses.`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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() {
|
||||
try {
|
||||
const news = await prisma.news.findMany({
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
@@ -15,8 +21,10 @@ export async function GET() {
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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 { 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) {
|
||||
try {
|
||||
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
|
||||
throw new Error('RESEND_KEY is not set');
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const validation = SubscribeFormSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return ApiResponse(400, 'Bad request');
|
||||
return ApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
|
||||
}
|
||||
|
||||
const { email } = validation.data;
|
||||
|
||||
const userAlreadyConfirmed = await prisma.user.findUnique({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
confirmed: true
|
||||
email
|
||||
}
|
||||
});
|
||||
|
||||
if (userAlreadyConfirmed) {
|
||||
if (userAlreadyConfirmed.deleted) {
|
||||
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
|
||||
@@ -33,44 +54,70 @@ export async function POST(request: Request) {
|
||||
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,
|
||||
message: `Thank you for subscribing!`
|
||||
};
|
||||
|
||||
return ApiResponse(200, JSON.stringify(message));
|
||||
}
|
||||
|
||||
const code = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${process.env.SECRET_HASH}${email}}`)
|
||||
.digest('hex');
|
||||
|
||||
await prisma.user.upsert({
|
||||
create: {
|
||||
email,
|
||||
code
|
||||
},
|
||||
update: {
|
||||
code
|
||||
},
|
||||
return ApiResponse(STATUS_OK, message);
|
||||
} else if (user && !user.confirmed) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
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));
|
||||
|
||||
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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
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) {
|
||||
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(400, 'Bad request');
|
||||
return ApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 +14,14 @@ function ConfirmationPage() {
|
||||
const code = searchParams.get('code');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!code) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/confirmation', {
|
||||
try {
|
||||
const res = await fetch('/api/confirmation', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -26,37 +29,44 @@ function ConfirmationPage() {
|
||||
body: JSON.stringify({
|
||||
code: code
|
||||
})
|
||||
})
|
||||
.then(async res => {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
const response: z.infer<typeof ResponseSchema> = 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 (
|
||||
<CardDescription className='text-center'>{message}</CardDescription>
|
||||
);
|
||||
}
|
||||
|
||||
return 'Just a second...';
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
style='text-center'
|
||||
<CustomCard
|
||||
className='max-90vw w-96'
|
||||
title={loading ? 'Verifying' : 'Confirmed!'}
|
||||
content={render()}
|
||||
footer={false}
|
||||
@@ -66,7 +76,7 @@ function ConfirmationPage() {
|
||||
|
||||
export default function Confirmation() {
|
||||
return (
|
||||
<Suspense>
|
||||
<Suspense fallback={<>Loading...</>}>
|
||||
<ConfirmationPage />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
h3 {
|
||||
@apply text-xl font-semibold;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-lg font-semibold;
|
||||
}
|
||||
|
||||
.styledH4 {
|
||||
@apply text-base font-medium;
|
||||
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(
|
||||
|
||||
@@ -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 (
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<html lang='en'>
|
||||
<head />
|
||||
<body
|
||||
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
|
||||
)}
|
||||
>
|
||||
<Tiles>
|
||||
<div style={{ zIndex: 2 }}>{children}</div>
|
||||
<div className='z-10'>{children}</div>
|
||||
</Tiles>
|
||||
<Analytics />
|
||||
</body>
|
||||
|
||||
64
app/page.tsx
64
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<HTMLInputElement | null>(null);
|
||||
|
||||
const form = useForm<z.infer<typeof SubscribeFormSchema>>({
|
||||
const form = useForm<SubscribeFormType>({
|
||||
resolver: zodResolver(SubscribeFormSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
name: ''
|
||||
email: ''
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (honeypotRef.current) {
|
||||
honeypotRef.current.style.display = 'none';
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(values: z.infer<typeof SubscribeFormSchema>) {
|
||||
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<typeof ResponseSchema> =
|
||||
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 (
|
||||
<CardDescription className='text-center'>{message}</CardDescription>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mx-2 h-44'>
|
||||
<Form {...form}>
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='flex flex-col space-y-4'
|
||||
@@ -91,7 +81,7 @@ export default function Home() {
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className='h-4'>
|
||||
<div className='h-6'>
|
||||
<FormMessage className='text-center' />
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -108,7 +98,7 @@ export default function Home() {
|
||||
<Button type='submit'>Submit</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
<p className='py-1 text-center text-xs text-gray-600'>
|
||||
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 (
|
||||
<Card
|
||||
style='text-center max-w-96'
|
||||
<CustomCard
|
||||
className='max-90vw w-96'
|
||||
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.'
|
||||
content={render()}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import CustomCard from '@components/CustomCard';
|
||||
import Link from 'next/link';
|
||||
import { Card } from '../../components/custom/card';
|
||||
|
||||
export default function Privacy() {
|
||||
const body = (
|
||||
@@ -25,8 +26,8 @@ export default function Privacy() {
|
||||
.
|
||||
</p>
|
||||
<br />
|
||||
<h2 className='styledH2'>Interpretation and Definitions</h2>
|
||||
<h3 className='styledH3'>Interpretation</h3>
|
||||
<h2>Interpretation and Definitions</h2>
|
||||
<h3>Interpretation</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<br />
|
||||
<h3 className='styledH3'>Definitions</h3>
|
||||
<h4>Definitions</h4>
|
||||
<p>For the purposes of this Privacy Policy:</p>
|
||||
<ul>
|
||||
<li>
|
||||
@@ -115,9 +116,9 @@ export default function Privacy() {
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
<h2 className='styledH2'>Collecting and Using Your Personal Data</h2>
|
||||
<h3 className='styledH3'>Types of Data Collected</h3>
|
||||
<h4 className='styledH4'>Personal Data</h4>
|
||||
<h3>Collecting and Using Your Personal Data</h3>
|
||||
<h4>Types of Data Collected</h4>
|
||||
<h6>Personal Data</h6>
|
||||
<p>
|
||||
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() {
|
||||
</li>
|
||||
</ul>
|
||||
<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 may include information such as Your Device&aposs Internet
|
||||
@@ -156,7 +157,7 @@ export default function Privacy() {
|
||||
device.
|
||||
</p>
|
||||
<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>
|
||||
<ul>
|
||||
<li>
|
||||
@@ -265,7 +266,7 @@ export default function Privacy() {
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
<h3 className='styledH3'>Retention of Your Personal Data</h3>
|
||||
<h4>Retention of Your Personal Data</h4>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<br />
|
||||
<h3 className='styledH3'>Transfer of Your Personal Data</h3>
|
||||
<h4>Transfer of Your Personal Data</h4>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<br />
|
||||
<h3 className='styledH3'>Delete Your Personal Data</h3>
|
||||
<h4>Delete Your Personal Data</h4>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<br />
|
||||
<h3 className='styledH3'>Disclosure of Your Personal Data</h3>
|
||||
<h4 className='styledH4'>Business Transactions</h4>
|
||||
<h4>Disclosure of Your Personal Data</h4>
|
||||
<h6>Business Transactions</h6>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<br />
|
||||
<h4 className='styledH4'>Law enforcement</h4>
|
||||
<h6>Law enforcement</h6>
|
||||
<p>
|
||||
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).
|
||||
</p>
|
||||
<br />
|
||||
<h4 className='styledH4'>Other legal requirements</h4>
|
||||
<h6>Other legal requirements</h6>
|
||||
<p>
|
||||
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() {
|
||||
</li>
|
||||
<li>Protect against legal liability</li>
|
||||
</ul>
|
||||
|
||||
<br />
|
||||
<h3 className='styledH3'>Security of Your Personal Data</h3>
|
||||
<h4>Security of Your Personal Data</h4>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<br />
|
||||
<h2 className='styledH2'>{"Children's Privacy"}</h2>
|
||||
<h3>{"Children's Privacy"}</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<br />
|
||||
<h2 className='styledH2'>Links to Other Websites</h2>
|
||||
<h3>Links to Other Websites</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<br />
|
||||
<h2 className='styledH2'>Changes to this Privacy Policy</h2>
|
||||
<h3>Changes to this Privacy Policy</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<br />
|
||||
<h2 className='styledH2'>Contact Us</h2>
|
||||
<h3>Contact Us</h3>
|
||||
<p>
|
||||
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 (
|
||||
<Card
|
||||
style='max-h-[90vh] max-w-[90vw]'
|
||||
<CustomCard
|
||||
className='max-90vh max-90vw'
|
||||
title='Privacy Policy'
|
||||
description='Last updated: December 03, 2023'
|
||||
content={body}
|
||||
|
||||
@@ -4,7 +4,7 @@ export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
disallow: '/'
|
||||
disallow: ''
|
||||
},
|
||||
sitemap: `${process.env.HOME_URL!}/sitemap.xml`
|
||||
};
|
||||
|
||||
@@ -1,46 +1,42 @@
|
||||
'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, UnsubscribeFormSchema } from '../../utils/schemas';
|
||||
ResponseType,
|
||||
UnsubscribeFormSchema,
|
||||
UnsubscribeFormType
|
||||
} from '@utils/validationSchemas';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
export default function Unsubscribe() {
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
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),
|
||||
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<typeof UnsubscribeFormSchema>) {
|
||||
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<typeof ResponseSchema> =
|
||||
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 (
|
||||
<CardDescription className='text-center'>{message}</CardDescription>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mb-5 h-32'>
|
||||
<Form {...form}>
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='flex flex-col space-y-4'
|
||||
@@ -91,11 +88,15 @@ export default function Unsubscribe() {
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className='h-4'>
|
||||
<div className='h-6'>
|
||||
<FormMessage className='text-center' />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input placeholder='example@example.com' {...field} />
|
||||
<Input
|
||||
placeholder='example@example.com'
|
||||
className='text-center'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -104,14 +105,14 @@ export default function Unsubscribe() {
|
||||
<Button type='submit'>Submit</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
style='text-center max-w-80'
|
||||
<CustomCard
|
||||
className='max-90vw w-96'
|
||||
title='Unsubscribe'
|
||||
description='You sure you want to leave? :('
|
||||
content={render()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cn } from '@utils/ui';
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../utils/ui';
|
||||
|
||||
export type ButtonProps = {
|
||||
asChild?: boolean;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from '@utils/ui';
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../utils/ui';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -7,10 +7,7 @@ const Card = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
className={cn('rounded-lg bg-card text-card-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -32,12 +29,9 @@ const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
<h1
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-3xl font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
className={cn('leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -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 (
|
||||
<div className='gradient-border'>
|
||||
<CardUI
|
||||
style={{
|
||||
boxShadow: '0 16px 32px 0 rgba(0, 0, 0, 0.6)'
|
||||
}}
|
||||
className={`max-h-[90vh] max-w-[90vw] p-8 ${style}`}
|
||||
>
|
||||
<div className='gradient-border shadow-2xl shadow-black'>
|
||||
<Card className={`z-10 max-w-[90vw] p-8 ${className}`}>
|
||||
<CardHeader>
|
||||
<p className='text-xs uppercase text-gray-500'>
|
||||
Hackernews + newsletter
|
||||
@@ -51,19 +44,13 @@ export const Card = ({
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
{isMobile ? (
|
||||
<CardContent>{content}</CardContent>
|
||||
) : (
|
||||
<CardContent className='flex max-h-[60vh] flex-grow justify-center overflow-auto'>
|
||||
{content}
|
||||
</CardContent>
|
||||
)}
|
||||
{footer && (
|
||||
<CardFooter>
|
||||
<Footer />
|
||||
</CardFooter>
|
||||
)}
|
||||
</CardUI>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<Button asChild>
|
||||
<Link href={path}>{text}</Link>
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { CustomLink } from './customLink';
|
||||
import CustomLink from './CustomLink';
|
||||
|
||||
const links = [{ name: 'Subscribe', path: '/' }];
|
||||
|
||||
function Footer() {
|
||||
export default function Footer() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
@@ -22,7 +22,7 @@ function Footer() {
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<ul className='flex justify-center space-x-4'>
|
||||
<div className='flex justify-center space-x-4'>
|
||||
{links.map(
|
||||
link =>
|
||||
pathname !== link.path &&
|
||||
@@ -33,10 +33,8 @@ function Footer() {
|
||||
{pathname === '/privacy' && (
|
||||
<CustomLink path='/unsubscribe' text='Unsubscribe' />
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
@@ -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<
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'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
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cn } from '@utils/ui';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../utils/ui';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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<typeof NewsSchema>[]
|
||||
) {
|
||||
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'
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: '600' }}>
|
||||
{story.title}
|
||||
</h2>
|
||||
<h3>{story.title}</h3>
|
||||
<p style={{ fontSize: '1rem', fontStyle: 'italic' }}>
|
||||
by {story.by}
|
||||
</p>
|
||||
@@ -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'
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
<h2
|
||||
style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundColor: `#8230CC`,
|
||||
backgroundColor: `#8230CC`
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
</h2>
|
||||
<div style={{ margin: '20px', padding: '20px' }}>{body}</div>
|
||||
<Footer />
|
||||
</div>
|
||||
@@ -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 {
|
||||
@@ -1,4 +1,4 @@
|
||||
export function Footer() {
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer
|
||||
style={{
|
||||
@@ -1,8 +1,8 @@
|
||||
type NoteProps = {
|
||||
interface NoteProps {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export function Note({ children }: NoteProps) {
|
||||
export default function Note({ children }: NoteProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
26
components/form/FormControl.tsx
Normal file
26
components/form/FormControl.tsx
Normal 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';
|
||||
20
components/form/FormDescription.tsx
Normal file
20
components/form/FormDescription.tsx
Normal 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';
|
||||
22
components/form/FormLabel.tsx
Normal file
22
components/form/FormLabel.tsx
Normal 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';
|
||||
28
components/form/FormMessage.tsx
Normal file
28
components/form/FormMessage.tsx
Normal 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';
|
||||
@@ -1,16 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { NewsTileType } from '@utils/validationSchemas';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { NewsTile, NewsTileSchema } from '../../../utils/schemas';
|
||||
import { Tile } from './components/tile';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Tile from './components/Tile';
|
||||
|
||||
type TilesProps = {
|
||||
interface TilesProps {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export const Tiles = ({ children }: TilesProps) => {
|
||||
export default function Tiles({ children }: TilesProps) {
|
||||
const pathname = usePathname();
|
||||
const [windowSize, setWindowSize] = useState<{
|
||||
width: number;
|
||||
@@ -19,16 +18,16 @@ export const Tiles = ({ children }: TilesProps) => {
|
||||
width: 0,
|
||||
height: 0
|
||||
});
|
||||
const [news, setNews] = useState<z.infer<typeof NewsTileSchema>[]>();
|
||||
const [news, setNews] = useState<NewsTileType[]>();
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (!news) {
|
||||
getNews();
|
||||
@@ -50,11 +49,10 @@ export const Tiles = ({ children }: TilesProps) => {
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [setWindowSize, news]);
|
||||
}, [news]);
|
||||
|
||||
if (pathname === '/maintenance') return <div>{children}</div>;
|
||||
|
||||
function renderTile(key: number) {
|
||||
const renderTile = useCallback(
|
||||
(key: number) => {
|
||||
if (!news) return <div key={key}></div>;
|
||||
|
||||
const randomA = Math.floor(Math.random() * news?.length);
|
||||
@@ -67,17 +65,22 @@ export const Tiles = ({ children }: TilesProps) => {
|
||||
<Tile newsA={news[randomA]} newsB={news[randomB]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
[news]
|
||||
);
|
||||
|
||||
function renderRow(columns: number, key: number) {
|
||||
const renderRow = useCallback(
|
||||
(columns: number, key: number) => {
|
||||
return (
|
||||
<div key={key} className='flex justify-between'>
|
||||
{Array.from({ length: columns }).map((_, index) => renderTile(index))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
[renderTile]
|
||||
);
|
||||
|
||||
function renderGrid() {
|
||||
const renderGrid = useCallback(() => {
|
||||
const columns = Math.ceil(windowSize.width / (40 * 4));
|
||||
const rows = Math.ceil(windowSize.height / (40 * 4));
|
||||
|
||||
@@ -93,7 +96,9 @@ export const Tiles = ({ children }: TilesProps) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [children, renderRow, windowSize]);
|
||||
|
||||
if (pathname === '/maintenance') return <div>{children}</div>;
|
||||
|
||||
return <div className='flex h-[100vh] overflow-hidden'>{renderGrid()}</div>;
|
||||
};
|
||||
}
|
||||
68
components/tiles/components/Tile.tsx
Normal file
68
components/tiles/components/Tile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
components/tiles/components/TileContent.tsx
Normal file
41
components/tiles/components/TileContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
13
contexts/FormField/FormFieldContext.ts
Normal file
13
contexts/FormField/FormFieldContext.ts
Normal 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
|
||||
);
|
||||
20
contexts/FormField/FormFieldProvider.tsx
Normal file
20
contexts/FormField/FormFieldProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
9
contexts/FormItem/FormItemContext.ts
Normal file
9
contexts/FormItem/FormItemContext.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FormItemContextValue {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
);
|
||||
18
contexts/FormItem/FormItemProvider.tsx
Normal file
18
contexts/FormItem/FormItemProvider.tsx
Normal 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
29
hooks/useFormField.tsx
Normal 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 };
|
||||
13
package.json
13
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "nextjs-hackernews",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Template for NodeJS APIs with TypeScript, with configurations for linting and testing",
|
||||
"author": "riccardo.s@hey.com",
|
||||
"author": "riccardo@frompixels.com",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate && next build",
|
||||
@@ -13,16 +13,17 @@
|
||||
"prepare": "husky install",
|
||||
"vercel:link": "vercel link",
|
||||
"vercel:env": "vercel env pull .env",
|
||||
"prisma:migrate": "npx prisma migrate dev",
|
||||
"prisma:push": "npx prisma db push",
|
||||
"prisma:generate": "npx prisma generate",
|
||||
"prisma:reset": "npx prisma db push --force-reset"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@next/third-parties": "^14.2.3",
|
||||
"@prisma/client": "^5.6.0",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||
"@vercel/analytics": "^1.1.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
@@ -31,7 +32,6 @@
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-responsive": "^9.0.2",
|
||||
"resend": "^3.1.0",
|
||||
"tailwind-merge": "^2.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -43,6 +43,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||
"@typescript-eslint/parser": "^6.12.0",
|
||||
"audit-ci": "^6.6.1",
|
||||
"autoprefixer": "^10.0.1",
|
||||
@@ -59,10 +60,10 @@
|
||||
"typescript": "^5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.ts": [
|
||||
"*.{ts,tsx}": [
|
||||
"eslint --quiet --fix"
|
||||
],
|
||||
"*.{json,ts}": [
|
||||
"*.{json,ts,tsx}": [
|
||||
"prettier --write --ignore-unknown"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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"
|
||||
@@ -10,6 +10,7 @@ datasource db {
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
resendId String
|
||||
email String @unique
|
||||
code String @unique
|
||||
confirmed Boolean @default(false)
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<script>
|
||||
window.onload = function () {
|
||||
var body = document.getElementsByTagName('body')[0];
|
||||
var size = 160; // size of each square
|
||||
var margin = 8; // margin between squares
|
||||
var size = 160;
|
||||
var margin = 8;
|
||||
var squaresPerRow = Math.ceil(window.innerWidth / (size + margin));
|
||||
var squaresPerColumn = Math.ceil(window.innerHeight / (size + margin));
|
||||
|
||||
|
||||
@@ -54,28 +54,14 @@ module.exports = {
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: 0 },
|
||||
to: { height: 'var(--radix-accordion-content-height)' }
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: 0 }
|
||||
},
|
||||
rotate: {
|
||||
'0%': { transform: 'rotateY(0deg)' },
|
||||
'100%': { transform: 'rotateY(180deg)' }
|
||||
},
|
||||
'rotate-inverse': {
|
||||
'0%': { transform: 'rotateY(180deg)' },
|
||||
'50%': { transform: 'rotateY(90deg)' },
|
||||
'100%': { transform: 'rotateY(0deg)' }
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
rotate: 'rotate 0.5s linear both',
|
||||
'rotate-inverse': 'rotate-inverse 0.5s linear both'
|
||||
rotate: 'rotate 0.5s linear both'
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['var(--font-sans)', ...fontFamily.sans]
|
||||
|
||||
@@ -19,9 +19,14 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./app/*"]
|
||||
"@app/*": ["./app/*"],
|
||||
"@components/*": ["./components/*"],
|
||||
"@contexts/*": ["./contexts/*"],
|
||||
"@hooks/*": ["./hooks/*"],
|
||||
"@prisma/*": ["./prisma/*"],
|
||||
"@utils/*": ["./utils/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", ".next", ".vercel"]
|
||||
"exclude": ["node_modules", ".yarn", ".next", ".vercel"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export function ApiResponse(status: number, message: string) {
|
||||
const response = new NextResponse(message, { status });
|
||||
response.headers.set('Access-Control-Allow-Origin', process.env.HOME_URL!);
|
||||
export function ApiResponse(status: number, message: unknown) {
|
||||
const stringMessage = JSON.stringify(message);
|
||||
|
||||
return response;
|
||||
return new NextResponse(stringMessage, { status });
|
||||
}
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
import { Resend } from 'resend';
|
||||
|
||||
type EmailTemplate = {
|
||||
interface EmailTemplate {
|
||||
subject: string;
|
||||
template: JSX.Element;
|
||||
};
|
||||
}
|
||||
|
||||
export async function sender(
|
||||
to: string[],
|
||||
recipients: string[],
|
||||
{ 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);
|
||||
|
||||
try {
|
||||
const { error } = await resend.batch.send(
|
||||
to.map(t => {
|
||||
let response;
|
||||
|
||||
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 {
|
||||
from: process.env.RESEND_FROM!,
|
||||
to: t,
|
||||
to: recipient,
|
||||
subject,
|
||||
react: template,
|
||||
headers: {
|
||||
@@ -25,19 +43,19 @@ export async function sender(
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = response;
|
||||
|
||||
if (error) {
|
||||
console.log(error);
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('Email sent', subject, to.length);
|
||||
|
||||
console.info(`${subject} email sent to ${recipients.length} recipients`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
6
utils/statusCodes.ts
Normal file
6
utils/statusCodes.ts
Normal 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;
|
||||
@@ -1,3 +1,5 @@
|
||||
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`;
|
||||
}
|
||||
|
||||
@@ -5,20 +5,24 @@ export const ResponseSchema = z.object({
|
||||
message: z.string()
|
||||
});
|
||||
|
||||
export type ResponseType = z.infer<typeof ResponseSchema>;
|
||||
|
||||
export const SubscribeFormSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional()
|
||||
email: z.string().email()
|
||||
});
|
||||
|
||||
export type SubscribeFormType = z.infer<typeof SubscribeFormSchema>;
|
||||
|
||||
export const ConfirmationSchema = z.object({
|
||||
code: z.string()
|
||||
});
|
||||
|
||||
export const UnsubscribeFormSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional()
|
||||
email: z.string().email()
|
||||
});
|
||||
|
||||
export type UnsubscribeFormType = z.infer<typeof UnsubscribeFormSchema>;
|
||||
|
||||
export const NewsDatabaseSchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
@@ -30,6 +34,8 @@ export const NewsDatabaseSchema = z.object({
|
||||
score: z.number()
|
||||
});
|
||||
|
||||
export type NewsDatabaseType = z.infer<typeof NewsDatabaseSchema>;
|
||||
|
||||
export const NewsSchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
@@ -42,10 +48,12 @@ export const NewsSchema = z.object({
|
||||
createdAt: z.date()
|
||||
});
|
||||
|
||||
export type NewsType = z.infer<typeof NewsSchema>;
|
||||
|
||||
export const NewsTileSchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
by: z.string()
|
||||
});
|
||||
|
||||
export type NewsTile = z.infer<typeof NewsTileSchema>;
|
||||
export type NewsTileType = z.infer<typeof NewsTileSchema>;
|
||||
Reference in New Issue
Block a user