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,6 +20,7 @@ import {
1520 renderUsageLimitReachedEmail ,
1621 renderUsageThresholdEmail ,
1722 renderWelcomeEmail ,
23+ renderWorkspaceAddedEmail ,
1824 renderWorkspaceInvitationEmail ,
1925} from '@/components/emails'
2026import { emailPreviewQuerySchema } from '@/lib/api/contracts/common'
@@ -24,11 +30,16 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2430const emailTemplates = {
2531 // Auth emails
2632 otp : ( ) => renderOTPEmail ( '123456' , 'user@example.com' , 'email-verification' ) ,
33+ 'otp-sign-in' : ( ) => renderOTPEmail ( '123456' , 'user@example.com' , 'sign-in' ) ,
2734 'reset-password' : ( ) => renderPasswordResetEmail ( 'John' , 'https://sim.ai/reset?token=abc123' ) ,
35+ 'existing-account' : ( ) => renderExistingAccountEmail ( 'John' ) ,
2836 welcome : ( ) => renderWelcomeEmail ( 'John' ) ,
37+ 'onboarding-followup' : ( ) => renderOnboardingFollowupEmail ( 'John' ) ,
2938
3039 // Invitation emails
3140 invitation : ( ) => renderInvitationEmail ( 'Jane Doe' , 'Acme Corp' , 'https://sim.ai/invite/abc123' ) ,
41+ 'workspace-added' : ( ) =>
42+ renderWorkspaceAddedEmail ( 'Jane Doe' , 'Engineering' , 'https://sim.ai/workspace/ws_123' ) ,
3243 'batch-invitation' : ( ) =>
3344 renderBatchInvitationEmail (
3445 'Jane Doe' ,
@@ -87,6 +98,43 @@ const emailTemplates = {
8798 amount : 50 ,
8899 newBalance : 75 ,
89100 } ) ,
101+ 'credits-exhausted' : ( ) =>
102+ renderCreditsExhaustedEmail ( {
103+ userName : 'John' ,
104+ limit : 10 ,
105+ upgradeLink : 'https://sim.ai/settings/billing' ,
106+ } ) ,
107+ 'abandoned-checkout' : ( ) => renderAbandonedCheckoutEmail ( 'John' ) ,
108+ 'limit-threshold-storage-warning' : ( ) =>
109+ renderLimitThresholdEmail ( {
110+ kind : 'warning' ,
111+ reason : 'storage' ,
112+ userName : 'John' ,
113+ usageLabel : '4.2 GB' ,
114+ limitLabel : '5 GB' ,
115+ percentUsed : 84 ,
116+ upgradeLink : 'https://sim.ai/settings/billing' ,
117+ } ) ,
118+ 'limit-threshold-tables-reached' : ( ) =>
119+ renderLimitThresholdEmail ( {
120+ kind : 'reached' ,
121+ reason : 'tables' ,
122+ userName : 'John' ,
123+ usageLabel : '50,000 rows' ,
124+ limitLabel : '50,000 rows' ,
125+ percentUsed : 100 ,
126+ upgradeLink : 'https://sim.ai/settings/billing' ,
127+ } ) ,
128+ 'limit-threshold-seats-reached' : ( ) =>
129+ renderLimitThresholdEmail ( {
130+ kind : 'reached' ,
131+ reason : 'seats' ,
132+ userName : 'John' ,
133+ usageLabel : '10 seats' ,
134+ limitLabel : '10 seats' ,
135+ percentUsed : 100 ,
136+ upgradeLink : 'https://sim.ai/settings/billing' ,
137+ } ) ,
90138 'payment-failed' : ( ) =>
91139 renderPaymentFailedEmail ( {
92140 userName : 'John' ,
@@ -138,6 +186,40 @@ function isEmailTemplate(template: string): template is EmailTemplate {
138186 return template in emailTemplates
139187}
140188
189+ const CATEGORIZED = {
190+ Auth : [ 'otp' , 'otp-sign-in' , 'reset-password' , 'existing-account' , 'welcome' ] ,
191+ Invitations : [ 'invitation' , 'batch-invitation' , 'workspace-invitation' , 'workspace-added' ] ,
192+ Support : [ 'help-confirmation' ] ,
193+ Billing : [
194+ 'usage-threshold' ,
195+ 'usage-limit-reached' ,
196+ 'usage-limit-reached-org' ,
197+ 'free-tier-upgrade' ,
198+ 'credits-exhausted' ,
199+ 'limit-threshold-storage-warning' ,
200+ 'limit-threshold-tables-reached' ,
201+ 'limit-threshold-seats-reached' ,
202+ 'payment-failed' ,
203+ 'credit-purchase' ,
204+ 'plan-welcome-pro' ,
205+ 'plan-welcome-team' ,
206+ 'enterprise-subscription' ,
207+ ] ,
208+ Notifications : [ 'schedule-disabled' , 'schedule-disabled-auth' ] ,
209+ 'Plain (unbranded)' : [ 'onboarding-followup' , 'abandoned-checkout' ] ,
210+ } satisfies Record < string , EmailTemplate [ ] >
211+
212+ /**
213+ * Category map for the gallery, with any template missing from {@link CATEGORIZED}
214+ * appended rather than dropped — so a newly registered template always shows up
215+ * even if nobody remembers to file it.
216+ */
217+ const PREVIEW_CATEGORIES : Record < string , EmailTemplate [ ] > = ( ( ) => {
218+ const filed = new Set < string > ( Object . values ( CATEGORIZED ) . flat ( ) )
219+ const unfiled = ( Object . keys ( emailTemplates ) as EmailTemplate [ ] ) . filter ( ( t ) => ! filed . has ( t ) )
220+ return unfiled . length > 0 ? { ...CATEGORIZED , Uncategorized : unfiled } : CATEGORIZED
221+ } ) ( )
222+
141223export const GET = withRouteHandler ( async ( request : NextRequest ) => {
142224 const { searchParams } = new URL ( request . url )
143225 const queryValidation = emailPreviewQuerySchema . safeParse (
@@ -147,48 +229,53 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
147229 const { template } = queryValidation . data
148230
149231 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 )
232+ const categoryHtml = Object . entries ( PREVIEW_CATEGORIES )
169233 . map (
170234 ( [ 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- `
235+ <section>
236+ <h2>${ category } </h2>
237+ <div class="grid">
238+ ${ templates
239+ . map (
240+ ( t ) => `
241+ <figure>
242+ <figcaption><span>${ t } </span><a href="?template=${ t } " target="_blank" rel="noreferrer">open ↗</a></figcaption>
243+ <iframe src="?template=${ t } " title="${ t } " loading="lazy"></iframe>
244+ </figure>`
245+ )
246+ . join ( '' ) }
247+ </div>
248+ </section>`
176249 )
177250 . join ( '' )
178251
179252 return new NextResponse (
180253 `<!DOCTYPE html>
181254<html>
182255<head>
183- <title>Email Previews</title>
256+ <meta charset="utf-8" />
257+ <meta name="viewport" content="width=device-width, initial-scale=1" />
258+ <title>Email Templates</title>
184259 <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; }
260+ :root { color-scheme: light; }
261+ body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 40px 24px 80px; background: #fff; color: #1a1a1a; }
262+ h1 { font-size: 24px; font-weight: 600; margin: 0 0 4px; }
263+ .count { color: #7a7a7a; font-size: 14px; margin: 0 0 40px; }
264+ h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: #7a7a7a; margin: 48px 0 16px; padding-bottom: 8px; border-bottom: 1px solid #d8d8d8; }
265+ section { max-width: 1400px; margin: 0 auto; }
266+ section > h2:first-child { margin-top: 0; }
267+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(640px, 1fr)); gap: 32px; }
268+ figure { margin: 0 0 32px; }
269+ figcaption { display: flex; justify-content: space-between; align-items: baseline; font-size: 13px; margin-bottom: 8px; }
270+ figcaption span { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #434343; }
271+ figcaption a { color: #7a7a7a; text-decoration: none; font-size: 12px; }
272+ figcaption a:hover { color: #1a1a1a; }
273+ iframe { width: 100%; height: 900px; border: 1px solid #d8d8d8; border-radius: 8px; background: #fff; display: block; }
188274 </style>
189275</head>
190276<body>
191277 <h1>Email Templates</h1>
278+ <p class="count">Every email Sim sends — ${ Object . values ( PREVIEW_CATEGORIES ) . flat ( ) . length } previews.</p>
192279 ${ categoryHtml }
193280</body>
194281</html>` ,
0 commit comments