Skip to content

Commit

Permalink
feat: implement newsletter confirm page
Browse files Browse the repository at this point in the history
  • Loading branch information
CaliCastle committed Jun 1, 2023
1 parent c4ae2ff commit 9e99377
Show file tree
Hide file tree
Showing 15 changed files with 806 additions and 26 deletions.
21 changes: 21 additions & 0 deletions app/(main)/confirm/[token]/SubbedCelebration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use client'

import React from 'react'
import { useReward } from 'react-rewards'

export function SubbedCelebration() {
const { reward } = useReward('subbed-celebration', 'confetti', {
position: 'absolute',
elementCount: 160,
spread: 80,
elementSize: 8,
lifetime: 400,
})

React.useEffect(() => {
setTimeout(() => reward(), 500)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

return <div className="pb-16"></div>
}
43 changes: 43 additions & 0 deletions app/(main)/confirm/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { eq } from 'drizzle-orm'
import { redirect } from 'next/navigation'

import { Container } from '~/components/ui/Container'
import { db } from '~/db'
import { subscribers } from '~/db/schema'

import { SubbedCelebration } from './SubbedCelebration'

export default async function ConfirmPage({
params,
}: {
params: { token: string }
}) {
const [subscriber] = await db
.select()
.from(subscribers)
.where(eq(subscribers.token, params.token))

if (!subscriber || subscriber.subscribedAt) {
redirect('/')
}

await db
.update(subscribers)
.set({ subscribedAt: new Date() })
.where(eq(subscribers.id, subscriber.id))

return (
<Container className="mt-16 sm:mt-32">
<header className="relative mx-auto flex w-full max-w-2xl items-center justify-center">
<h1
className="w-full text-center text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl"
id="subbed-celebration"
>
🥳 感谢你的订阅 🎉
</h1>
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400"></p>
</header>
<SubbedCelebration />
</Container>
)
}
57 changes: 44 additions & 13 deletions app/api/newsletter/route.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,75 @@
import { Ratelimit } from '@upstash/ratelimit'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { Resend } from 'resend'
import { z } from 'zod'

import { emailConfig } from '~/config/email'
import { db } from '~/db'
import { subscribers } from '~/db/schema'
import ConfirmSubscriptionEmail from '~/emails/confirm-subscription'
import { env } from '~/env.mjs'
import { url } from '~/lib'
import { redis } from '~/lib/redis'

const newsletterFormSchema = z.object({
email: z.string().email().nonempty(),
})

export const runtime = 'edge'

const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(1, '10 s'),
analytics: true,
})

const resend = new Resend(env.RESEND_API_KEY)

export async function POST(req: NextRequest) {
const { success } = await ratelimit.limit('subscribe_' + (req.ip ?? ''))
if (!success) {
return NextResponse.error()
if (env.NODE_ENV === 'production') {
const { success } = await ratelimit.limit('subscribe_' + (req.ip ?? ''))
if (!success) {
return NextResponse.error()
}
}

try {
const { data } = await req.json()
const parsed = newsletterFormSchema.parse(data)

// generate a random one-time token
const token = Math.random().toString(36).slice(2)

await new Resend(env.RESEND_API_KEY).sendEmail({
from: emailConfig.from,
to: parsed.email,
subject: '确认订阅 Cali 的动态吗?',
react: ConfirmSubscriptionEmail({ token }),
})
const token = crypto.randomUUID()

const [subscriber] = await db
.select()
.from(subscribers)
.where(eq(subscribers.email, parsed.email))

if (subscriber && subscriber.subscribedAt) {
return NextResponse.json({ status: 'success' })
}

if (env.NODE_ENV === 'production') {
await resend.sendEmail({
from: emailConfig.from,
to: parsed.email,
subject: '来自 Cali 的订阅确认',
react: ConfirmSubscriptionEmail({
link: url(`confirm/${token}`).href,
}),
})

if (!subscriber) {
await db.insert(subscribers).values({
email: parsed.email,
token,
})
} else {
await db
.update(subscribers)
.set({ token })
.where(eq(subscribers.email, parsed.email))
}
}

return NextResponse.json({ status: 'success' })
} catch (error) {
Expand Down
13 changes: 13 additions & 0 deletions db/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { connect } from '@planetscale/database'
import { drizzle } from 'drizzle-orm/planetscale-serverless'

import { env } from '~/env.mjs'

// create the connection
const connection = connect({
host: env.DATABASE_HOST,
username: env.DATABASE_USERNAME,
password: env.DATABASE_PASSWORD,
})

export const db = drizzle(connection)
5 changes: 5 additions & 0 deletions db/migrations/0000_sweet_king_cobra.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE `subscribers` (
`cuid` text PRIMARY KEY NOT NULL,
`email` varchar(120),
`token` varchar(50),
`subscribed_at` datetime);
2 changes: 2 additions & 0 deletions db/migrations/0001_worried_crystal.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE `subscribers` RENAME COLUMN `cuid` TO `id`;
ALTER TABLE `subscribers` MODIFY COLUMN `id` serial AUTO_INCREMENT NOT NULL;
50 changes: 50 additions & 0 deletions db/migrations/meta/0000_snapshot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"version": "5",
"dialect": "mysql",
"id": "3758b424-2adb-4fc1-aff6-8409ceea1931",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"subscribers": {
"name": "subscribers",
"columns": {
"cuid": {
"name": "cuid",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(120)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token": {
"name": "token",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subscribed_at": {
"name": "subscribed_at",
"type": "datetime",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
}
},
"schemas": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}
52 changes: 52 additions & 0 deletions db/migrations/meta/0001_snapshot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"version": "5",
"dialect": "mysql",
"id": "11f232f1-7994-4e6a-b58c-7b4587981bdc",
"prevId": "3758b424-2adb-4fc1-aff6-8409ceea1931",
"tables": {
"subscribers": {
"name": "subscribers",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"email": {
"name": "email",
"type": "varchar(120)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token": {
"name": "token",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subscribed_at": {
"name": "subscribed_at",
"type": "datetime",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
}
},
"schemas": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"subscribers\".\"cuid\"": "\"subscribers\".\"id\""
}
}
}
20 changes: 20 additions & 0 deletions db/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"version": "5",
"dialect": "mysql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1685604945558,
"tag": "0000_sweet_king_cobra",
"breakpoints": false
},
{
"idx": 1,
"version": "5",
"when": 1685606673974,
"tag": "0001_worried_crystal",
"breakpoints": false
}
]
}
8 changes: 8 additions & 0 deletions db/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { datetime, mysqlTable, serial, varchar } from 'drizzle-orm/mysql-core'

export const subscribers = mysqlTable('subscribers', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 120 }),
token: varchar('token', { length: 50 }),
subscribedAt: datetime('subscribed_at'),
})
9 changes: 9 additions & 0 deletions drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as dotenv from 'dotenv'
import type { Config } from 'drizzle-kit'
dotenv.config()

export default {
schema: './db/schema.ts',
out: './db/migrations',
connectionString: process.env.DATABASE_URL,
} satisfies Config
8 changes: 2 additions & 6 deletions emails/confirm-subscription.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,15 @@ import {
Text,
} from './_components'

const confirmLink = new URL('/confirm', emailConfig.baseUrl)

const ConfirmSubscriptionEmail = ({ token = 'fake-token' }) => {
const ConfirmSubscriptionEmail = ({ link = 'link.com/confirm?fake-token' }) => {
const previewText = `确认订阅 Cali 的动态吗?`
confirmLink.searchParams.set('token', token)
const link = confirmLink.toString()

return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto mt-[32px] bg-zinc-50 font-sans">
<Body className="mx-auto my-auto bg-zinc-50 pt-[32px] font-sans">
<Container className="mx-auto my-[40px] w-[465px] rounded-2xl border border-solid border-zinc-100 bg-white p-[20px]">
<Section className="mt-[24px]">
<Img
Expand Down
8 changes: 6 additions & 2 deletions env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { z } from 'zod'
*/
const server = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
CONVERTKIT_API_KEY: z.string().min(1),
DATABASE_HOST: z.string().min(1),
DATABASE_USERNAME: z.string().min(1),
DATABASE_PASSWORD: z.string().min(1),
RESEND_API_KEY: z.string().min(1),
VERCEL_ENV: z.enum(['development', 'preview', 'production']),
UPSTASH_REDIS_REST_URL: z.string().min(1),
Expand All @@ -27,7 +29,9 @@ const client = z.object({
*/
const processEnv = {
NODE_ENV: process.env.NODE_ENV,
CONVERTKIT_API_KEY: process.env.CONVERTKIT_API_KEY,
DATABASE_HOST: process.env.DATABASE_HOST,
DATABASE_USERNAME: process.env.DATABASE_USERNAME,
DATABASE_PASSWORD: process.env.DATABASE_PASSWORD,
RESEND_API_KEY: process.env.RESEND_API_KEY,
VERCEL_ENV: process.env.VERCEL_ENV,
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
Expand Down
Loading

0 comments on commit 9e99377

Please sign in to comment.