11import type { NextRequest } from 'next/server'
22import { NextResponse } from 'next/server'
33import {
4+ renderAbandonedCheckoutEmail ,
45 renderBatchInvitationEmail ,
56 renderCreditPurchaseEmail ,
7+ renderCreditsExhaustedEmail ,
68 renderEnterpriseSubscriptionEmail ,
9+ renderExistingAccountEmail ,
710 renderFreeTierUpgradeEmail ,
811 renderHelpConfirmationEmail ,
912 renderInvitationEmail ,
13+ renderLimitThresholdEmail ,
14+ renderOnboardingFollowupEmail ,
1015 renderOTPEmail ,
1116 renderPasswordResetEmail ,
1217 renderPaymentFailedEmail ,
@@ -15,20 +20,27 @@ import {
1520 renderUsageLimitReachedEmail ,
1621 renderUsageThresholdEmail ,
1722 renderWelcomeEmail ,
23+ renderWorkspaceAddedEmail ,
1824 renderWorkspaceInvitationEmail ,
1925} from '@/components/emails'
26+ import { colors , typography } from '@/components/emails/_styles'
2027import { emailPreviewQuerySchema } from '@/lib/api/contracts/common'
2128import { validationErrorResponse } from '@/lib/api/server'
2229import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2330
2431const emailTemplates = {
2532 // Auth emails
2633 otp : ( ) => renderOTPEmail ( '123456' , 'user@example.com' , 'email-verification' ) ,
34+ 'otp-sign-in' : ( ) => renderOTPEmail ( '123456' , 'user@example.com' , 'sign-in' ) ,
2735 'reset-password' : ( ) => renderPasswordResetEmail ( 'John' , 'https://sim.ai/reset?token=abc123' ) ,
36+ 'existing-account' : ( ) => renderExistingAccountEmail ( 'John' ) ,
2837 welcome : ( ) => renderWelcomeEmail ( 'John' ) ,
38+ 'onboarding-followup' : ( ) => renderOnboardingFollowupEmail ( 'John' ) ,
2939
3040 // Invitation emails
3141 invitation : ( ) => renderInvitationEmail ( 'Jane Doe' , 'Acme Corp' , 'https://sim.ai/invite/abc123' ) ,
42+ 'workspace-added' : ( ) =>
43+ renderWorkspaceAddedEmail ( 'Jane Doe' , 'Engineering' , 'https://sim.ai/workspace/ws_123' ) ,
3244 'batch-invitation' : ( ) =>
3345 renderBatchInvitationEmail (
3446 'Jane Doe' ,
@@ -87,6 +99,43 @@ const emailTemplates = {
8799 amount : 50 ,
88100 newBalance : 75 ,
89101 } ) ,
102+ 'credits-exhausted' : ( ) =>
103+ renderCreditsExhaustedEmail ( {
104+ userName : 'John' ,
105+ limit : 10 ,
106+ upgradeLink : 'https://sim.ai/settings/billing' ,
107+ } ) ,
108+ 'abandoned-checkout' : ( ) => renderAbandonedCheckoutEmail ( 'John' ) ,
109+ 'limit-threshold-storage-warning' : ( ) =>
110+ renderLimitThresholdEmail ( {
111+ kind : 'warning' ,
112+ reason : 'storage' ,
113+ userName : 'John' ,
114+ usageLabel : '4.2 GB' ,
115+ limitLabel : '5 GB' ,
116+ percentUsed : 84 ,
117+ upgradeLink : 'https://sim.ai/settings/billing' ,
118+ } ) ,
119+ 'limit-threshold-tables-reached' : ( ) =>
120+ renderLimitThresholdEmail ( {
121+ kind : 'reached' ,
122+ reason : 'tables' ,
123+ userName : 'John' ,
124+ usageLabel : '50,000 rows' ,
125+ limitLabel : '50,000 rows' ,
126+ percentUsed : 100 ,
127+ upgradeLink : 'https://sim.ai/settings/billing' ,
128+ } ) ,
129+ 'limit-threshold-seats-reached' : ( ) =>
130+ renderLimitThresholdEmail ( {
131+ kind : 'reached' ,
132+ reason : 'seats' ,
133+ userName : 'John' ,
134+ usageLabel : '10 seats' ,
135+ limitLabel : '10 seats' ,
136+ percentUsed : 100 ,
137+ upgradeLink : 'https://sim.ai/settings/billing' ,
138+ } ) ,
90139 'payment-failed' : ( ) =>
91140 renderPaymentFailedEmail ( {
92141 userName : 'John' ,
@@ -138,6 +187,40 @@ function isEmailTemplate(template: string): template is EmailTemplate {
138187 return template in emailTemplates
139188}
140189
190+ const CATEGORIZED = {
191+ Auth : [ 'otp' , 'otp-sign-in' , 'reset-password' , 'existing-account' , 'welcome' ] ,
192+ Invitations : [ 'invitation' , 'batch-invitation' , 'workspace-invitation' , 'workspace-added' ] ,
193+ Support : [ 'help-confirmation' ] ,
194+ Billing : [
195+ 'usage-threshold' ,
196+ 'usage-limit-reached' ,
197+ 'usage-limit-reached-org' ,
198+ 'free-tier-upgrade' ,
199+ 'credits-exhausted' ,
200+ 'limit-threshold-storage-warning' ,
201+ 'limit-threshold-tables-reached' ,
202+ 'limit-threshold-seats-reached' ,
203+ 'payment-failed' ,
204+ 'credit-purchase' ,
205+ 'plan-welcome-pro' ,
206+ 'plan-welcome-team' ,
207+ 'enterprise-subscription' ,
208+ ] ,
209+ Notifications : [ 'schedule-disabled' , 'schedule-disabled-auth' ] ,
210+ 'Plain (unbranded)' : [ 'onboarding-followup' , 'abandoned-checkout' ] ,
211+ } satisfies Record < string , EmailTemplate [ ] >
212+
213+ /**
214+ * Category map for the gallery, with any template missing from {@link CATEGORIZED}
215+ * appended rather than dropped — so a newly registered template always shows up
216+ * even if nobody remembers to file it.
217+ */
218+ const PREVIEW_CATEGORIES : Record < string , EmailTemplate [ ] > = ( ( ) => {
219+ const filed = new Set < string > ( Object . values ( CATEGORIZED ) . flat ( ) )
220+ const unfiled = ( Object . keys ( emailTemplates ) as EmailTemplate [ ] ) . filter ( ( t ) => ! filed . has ( t ) )
221+ return unfiled . length > 0 ? { ...CATEGORIZED , Uncategorized : unfiled } : CATEGORIZED
222+ } ) ( )
223+
141224export const GET = withRouteHandler ( async ( request : NextRequest ) => {
142225 const { searchParams } = new URL ( request . url )
143226 const queryValidation = emailPreviewQuerySchema . safeParse (
@@ -147,48 +230,53 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
147230 const { template } = queryValidation . data
148231
149232 if ( ! template ) {
150- const categories = {
151- Auth : [ 'otp' , 'reset-password' , 'welcome' ] ,
152- Invitations : [ 'invitation' , 'batch-invitation' , 'workspace-invitation' ] ,
153- Support : [ 'help-confirmation' ] ,
154- Billing : [
155- 'usage-threshold' ,
156- 'enterprise-subscription' ,
157- 'free-tier-upgrade' ,
158- 'plan-welcome-pro' ,
159- 'plan-welcome-team' ,
160- 'credit-purchase' ,
161- 'payment-failed' ,
162- 'usage-limit-reached' ,
163- 'usage-limit-reached-org' ,
164- ] ,
165- Notifications : [ 'schedule-disabled' , 'schedule-disabled-auth' ] ,
166- }
167-
168- const categoryHtml = Object . entries ( categories )
233+ const categoryHtml = Object . entries ( PREVIEW_CATEGORIES )
169234 . map (
170235 ( [ category , templates ] ) => `
171- <h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${ category } </h2>
172- <ul style="list-style: none; padding: 0; margin: 0;">
173- ${ templates . map ( ( t ) => `<li style="margin: 8px 0;"><a href="?template=${ t } " style="color: #33C482; text-decoration: none; font-size: 16px;">${ t } </a></li>` ) . join ( '' ) }
174- </ul>
175- `
236+ <section>
237+ <h2>${ category } </h2>
238+ <div class="grid">
239+ ${ templates
240+ . map (
241+ ( t ) => `
242+ <figure>
243+ <figcaption><span>${ t } </span><a href="?template=${ t } " target="_blank" rel="noreferrer">open ↗</a></figcaption>
244+ <iframe src="?template=${ t } " title="${ t } " loading="lazy"></iframe>
245+ </figure>`
246+ )
247+ . join ( '' ) }
248+ </div>
249+ </section>`
176250 )
177251 . join ( '' )
178252
179253 return new NextResponse (
180254 `<!DOCTYPE html>
181255<html>
182256<head>
183- <title>Email Previews</title>
257+ <meta charset="utf-8" />
258+ <meta name="viewport" content="width=device-width, initial-scale=1" />
259+ <title>Email Templates</title>
184260 <style>
185- body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
186- h1 { color: #333; margin-bottom: 32px; }
187- a:hover { text-decoration: underline; }
261+ :root { color-scheme: light; }
262+ body { font-family: ${ typography . systemFontFamily } ; margin: 0; padding: 40px 24px 80px; background: ${ colors . bgCard } ; color: ${ colors . textPrimary } ; }
263+ h1 { font-size: 24px; font-weight: 600; margin: 0 0 4px; }
264+ .count { color: ${ colors . textMuted } ; font-size: 14px; margin: 0 0 40px; }
265+ h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: ${ colors . textMuted } ; margin: 48px 0 16px; padding-bottom: 8px; border-bottom: 1px solid ${ colors . border } ; }
266+ section { max-width: 1400px; margin: 0 auto; }
267+ section > h2:first-child { margin-top: 0; }
268+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(640px, 1fr)); gap: 32px; }
269+ figure { margin: 0 0 32px; }
270+ figcaption { display: flex; justify-content: space-between; align-items: baseline; font-size: 13px; margin-bottom: 8px; }
271+ figcaption span { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: ${ colors . textBody } ; }
272+ figcaption a { color: ${ colors . textMuted } ; text-decoration: none; font-size: 12px; }
273+ figcaption a:hover { color: ${ colors . textPrimary } ; }
274+ iframe { width: 100%; height: 900px; border: 1px solid ${ colors . border } ; border-radius: 8px; background: ${ colors . bgCard } ; display: block; }
188275 </style>
189276</head>
190277<body>
191278 <h1>Email Templates</h1>
279+ <p class="count">Every email Sim sends — ${ Object . keys ( emailTemplates ) . length } previews.</p>
192280 ${ categoryHtml }
193281</body>
194282</html>` ,
0 commit comments