Skip to content

Commit 26693f0

Browse files
bidahclaude
andcommitted
✨ feat: add /space landing page and waitlist with admin email system
React Native Space landing page with dark minimal aesthetic, email waitlist signup, admin panel for sending emails, and launch email template. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d660a20 commit 26693f0

12 files changed

Lines changed: 1222 additions & 0 deletions

File tree

‎apps/web/app/(app)/api/email-preview/route.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
22
import { render } from '@react-email/components'
33
import { WelcomeEmail, NewsletterEmail } from '@/lib/email'
44
import { getTemplate, newsletterTemplates } from '@/lib/email/templates/registry'
5+
import { spaceTemplates } from '@/lib/email/templates/space-registry'
56

67
const PREVIEW_UNSUBSCRIBE_URL = '#unsubscribe-preview'
78

@@ -16,6 +17,11 @@ for (const t of newsletterTemplates) {
1617
templates[t.name] = () => t.component({ unsubscribeUrl: PREVIEW_UNSUBSCRIBE_URL })
1718
}
1819

20+
// Register space templates
21+
for (const t of spaceTemplates) {
22+
templates[t.name] = () => t.component({ unsubscribeUrl: PREVIEW_UNSUBSCRIBE_URL })
23+
}
24+
1925
export async function GET(request: NextRequest) {
2026
// Only allow in development
2127
if (process.env.NODE_ENV !== 'development') {
@@ -47,6 +53,7 @@ export async function GET(request: NextRequest) {
4753
<a href="/api/email-preview?template=welcome">Welcome Email <span>— sent when a user signs up</span></a>
4854
<a href="/api/email-preview?template=newsletter">Newsletter (latest) <span>— weekly updates email</span></a>
4955
${newsletterTemplates.map((t) => `<a href="/api/email-preview?template=${t.name}">${t.name} <span>— ${t.subject} (${t.issueDate})</span></a>`).join('\n ')}
56+
${spaceTemplates.map((t) => `<a href="/api/email-preview?template=${t.name}">${t.name} <span>— ${t.subject}</span></a>`).join('\n ')}
5057
</body>
5158
</html>
5259
`
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { NextResponse } from 'next/server'
2+
import { getServerSession } from '@/lib/auth/index'
3+
import { db, spaceWaitlistSends, desc } from '@/lib/db'
4+
5+
export async function GET() {
6+
const session = await getServerSession()
7+
const adminEmail = process.env.ADMIN_EMAIL
8+
9+
if (!session?.user?.email || !adminEmail || session.user.email !== adminEmail) {
10+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
11+
}
12+
13+
const sends = await db
14+
.select()
15+
.from(spaceWaitlistSends)
16+
.orderBy(desc(spaceWaitlistSends.sentAt))
17+
18+
return NextResponse.json(sends)
19+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { getServerSession } from '@/lib/auth/index'
3+
import { getSpaceTemplate } from '@/lib/email/templates/space-registry'
4+
import { getResend, NEWSLETTER_FROM } from '@/lib/email'
5+
6+
export async function POST(request: NextRequest) {
7+
const session = await getServerSession()
8+
const adminEmail = process.env.ADMIN_EMAIL
9+
10+
if (!session?.user?.email || !adminEmail || session.user.email !== adminEmail) {
11+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
12+
}
13+
14+
const body = await request.json()
15+
const { templateName, testEmail } = body
16+
17+
if (!templateName) {
18+
return NextResponse.json({ error: 'templateName is required' }, { status: 400 })
19+
}
20+
if (!testEmail) {
21+
return NextResponse.json({ error: 'testEmail is required' }, { status: 400 })
22+
}
23+
24+
const template = getSpaceTemplate(templateName)
25+
if (!template) {
26+
return NextResponse.json({ error: `Template "${templateName}" not found` }, { status: 404 })
27+
}
28+
29+
try {
30+
const Component = template.component
31+
const resend = getResend()
32+
33+
const { data, error } = await resend.emails.send({
34+
from: NEWSLETTER_FROM,
35+
to: testEmail,
36+
subject: `[TEST] ${template.subject}`,
37+
react: Component({}),
38+
})
39+
40+
if (error) {
41+
console.error('[Space Admin] Test send error:', JSON.stringify(error))
42+
return NextResponse.json({ error: error.message || 'Failed to send test email' }, { status: 500 })
43+
}
44+
45+
return NextResponse.json({ success: true, testEmail, templateName: template.name })
46+
} catch (error: any) {
47+
console.error('[Space Admin] Test send error:', error?.message)
48+
return NextResponse.json({ error: error?.message || 'Failed to send test email' }, { status: 500 })
49+
}
50+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { getServerSession } from '@/lib/auth/index'
3+
import { db, spaceWaitlist, spaceWaitlistSends, spaceWaitlistRecipients, eq } from '@/lib/db'
4+
import { getResend, NEWSLETTER_FROM } from '@/lib/email'
5+
import { getSpaceTemplate } from '@/lib/email/templates/space-registry'
6+
7+
export async function POST(request: NextRequest) {
8+
const session = await getServerSession()
9+
const adminEmail = process.env.ADMIN_EMAIL
10+
11+
if (!session?.user?.email || !adminEmail || session.user.email !== adminEmail) {
12+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
13+
}
14+
15+
const body = await request.json()
16+
const { templateName } = body
17+
18+
if (!templateName) {
19+
return NextResponse.json({ error: 'templateName is required' }, { status: 400 })
20+
}
21+
22+
const template = getSpaceTemplate(templateName)
23+
if (!template) {
24+
return NextResponse.json({ error: `Template "${templateName}" not found` }, { status: 404 })
25+
}
26+
27+
try {
28+
// Get all waitlist emails
29+
const allWaitlist = await db
30+
.select({ email: spaceWaitlist.email })
31+
.from(spaceWaitlist)
32+
33+
// Get already-sent for this template
34+
const alreadySent = await db
35+
.select({ email: spaceWaitlistRecipients.email })
36+
.from(spaceWaitlistRecipients)
37+
.where(eq(spaceWaitlistRecipients.templateName, templateName))
38+
39+
const sentEmails = new Set(alreadySent.map((r) => r.email))
40+
const pendingEmails = allWaitlist.filter((w) => !sentEmails.has(w.email))
41+
42+
if (pendingEmails.length === 0) {
43+
return NextResponse.json({
44+
error: 'All waitlist subscribers have already received this email',
45+
totalWaitlist: allWaitlist.length,
46+
alreadySent: sentEmails.size,
47+
}, { status: 400 })
48+
}
49+
50+
const Component = template.component
51+
const resend = getResend()
52+
53+
// Send in batches of 100
54+
for (let i = 0; i < pendingEmails.length; i += 100) {
55+
const chunk = pendingEmails.slice(i, i + 100)
56+
const emails = chunk.map((w) => ({
57+
from: NEWSLETTER_FROM,
58+
to: w.email,
59+
subject: template.subject,
60+
react: Component({}),
61+
}))
62+
63+
const { error } = await resend.batch.send(emails)
64+
if (error) {
65+
console.error('[Space Admin] Batch send error:', JSON.stringify(error))
66+
return NextResponse.json({
67+
error: (error as any).message || 'Resend batch send failed',
68+
sentBeforeError: i,
69+
}, { status: 500 })
70+
}
71+
}
72+
73+
// Record recipients
74+
await db.insert(spaceWaitlistRecipients).values(
75+
pendingEmails.map((w) => ({ templateName, email: w.email }))
76+
)
77+
78+
// Record send history
79+
await db.insert(spaceWaitlistSends).values({
80+
templateName: template.name,
81+
subject: template.subject,
82+
recipientCount: pendingEmails.length,
83+
sentBy: session.user.id,
84+
})
85+
86+
return NextResponse.json({
87+
success: true,
88+
sentCount: pendingEmails.length,
89+
totalWaitlist: allWaitlist.length,
90+
})
91+
} catch (error: any) {
92+
console.error('[Space Admin] Send error:', error?.message, error?.stack)
93+
return NextResponse.json(
94+
{ error: error?.message || 'Failed to send' },
95+
{ status: 500 }
96+
)
97+
}
98+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { getServerSession } from '@/lib/auth/index'
3+
import { db, spaceWaitlist, spaceWaitlistRecipients, eq } from '@/lib/db'
4+
5+
export async function GET(request: NextRequest) {
6+
const session = await getServerSession()
7+
const adminEmail = process.env.ADMIN_EMAIL
8+
9+
if (!session?.user?.email || !adminEmail || session.user.email !== adminEmail) {
10+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
11+
}
12+
13+
const { searchParams } = new URL(request.url)
14+
const templateName = searchParams.get('template')
15+
16+
if (!templateName) {
17+
return NextResponse.json({ error: 'template query param required' }, { status: 400 })
18+
}
19+
20+
try {
21+
const allWaitlist = await db
22+
.select({ email: spaceWaitlist.email })
23+
.from(spaceWaitlist)
24+
25+
const sentRecipients = await db
26+
.select({
27+
email: spaceWaitlistRecipients.email,
28+
sentAt: spaceWaitlistRecipients.sentAt,
29+
})
30+
.from(spaceWaitlistRecipients)
31+
.where(eq(spaceWaitlistRecipients.templateName, templateName))
32+
33+
const sentEmails = new Set(sentRecipients.map((r) => r.email))
34+
35+
const sent = sentRecipients.map((r) => ({
36+
email: r.email,
37+
sentAt: r.sentAt,
38+
}))
39+
40+
const pending = allWaitlist
41+
.filter((w) => !sentEmails.has(w.email))
42+
.map((w) => ({ email: w.email }))
43+
44+
return NextResponse.json({
45+
templateName,
46+
totalWaitlist: allWaitlist.length,
47+
sentCount: sent.length,
48+
pendingCount: pending.length,
49+
sent,
50+
pending,
51+
})
52+
} catch (error: any) {
53+
console.error('[Space Admin] Status error:', error?.message)
54+
return NextResponse.json({ error: error?.message || 'Failed to get status' }, { status: 500 })
55+
}
56+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { redirect } from 'next/navigation'
2+
import { getServerSession } from '@/lib/auth/index'
3+
import { SpaceAdminPanel } from './space-admin-panel'
4+
5+
export const dynamic = 'force-dynamic'
6+
7+
export default async function SpaceAdminPage() {
8+
const session = await getServerSession()
9+
const adminEmail = process.env.ADMIN_EMAIL
10+
11+
if (!session?.user?.email || !adminEmail || session.user.email !== adminEmail) {
12+
redirect('/')
13+
}
14+
15+
return (
16+
<main style={{ minHeight: '100vh', background: '#0a0a0a', color: '#e5e5e5' }}>
17+
<div style={{ maxWidth: '900px', margin: '0 auto', padding: '48px 24px' }}>
18+
<SpaceAdminPanel />
19+
</div>
20+
</main>
21+
)
22+
}

0 commit comments

Comments
 (0)