anhedral 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1368 @@
1
+ import path from 'node:path';
2
+ import { ANHEDRAL_LOGO_PUBLIC_PATH, writeAnhedralLogo } from '../branding.js';
3
+ import { writeFile } from '../util.js';
4
+ export function writeDefaultWebEnvExample(root) {
5
+ writeFile(path.join(root, '.env.example'), `# App
6
+ NEXT_PUBLIC_APP_URL=http://localhost:3000
7
+
8
+ # Clerk
9
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
10
+ CLERK_SECRET_KEY=sk_test_***
11
+ NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
12
+ NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
13
+ NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
14
+ NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
15
+
16
+ # Neon + Drizzle
17
+ DATABASE_URL=postgresql://user:pass@ep-xxx.us-east-1.aws.neon.tech/dbname?sslmode=require
18
+
19
+ # Cloudflare R2
20
+ R2_ACCOUNT_ID=
21
+ R2_ACCESS_KEY_ID=
22
+ R2_SECRET_ACCESS_KEY=
23
+ R2_BUCKET=
24
+
25
+ # Stripe
26
+ STRIPE_SECRET_KEY=sk_test_***
27
+ STRIPE_WEBHOOK_SECRET=whsec_***
28
+ STRIPE_PRICE_STARTER=price_***
29
+ `);
30
+ }
31
+ export function writeDefaultWebFiles(root, displayName) {
32
+ writeFile(path.join(root, 'lib/db/client.ts'), `import { drizzle } from 'drizzle-orm/neon-http';
33
+ import { neon } from '@neondatabase/serverless';
34
+ import * as schema from './schema';
35
+
36
+ function requireDatabaseUrl() {
37
+ if (!process.env.DATABASE_URL) {
38
+ throw new Error('DATABASE_URL is not defined');
39
+ }
40
+
41
+ return process.env.DATABASE_URL;
42
+ }
43
+
44
+ export function getDb() {
45
+ const sql = neon(requireDatabaseUrl());
46
+ return drizzle(sql, { schema });
47
+ }
48
+ `);
49
+ writeFile(path.join(root, 'lib/db/schema.ts'), `import { integer, index, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
50
+
51
+ export const users = pgTable('users', {
52
+ id: text('id').primaryKey(),
53
+ clerkUserId: text('clerk_user_id').notNull().unique(),
54
+ email: text('email').notNull().unique(),
55
+ displayName: text('display_name'),
56
+ stripeCustomerId: text('stripe_customer_id').unique(),
57
+ subscriptionTier: text('subscription_tier').notNull().default('starter'),
58
+ subscriptionStatus: text('subscription_status').notNull().default('setup_required'),
59
+ creditsBalance: integer('credits_balance').notNull().default(250),
60
+ avatarObjectKey: text('avatar_object_key'),
61
+ avatarMimeType: text('avatar_mime_type'),
62
+ createdAt: timestamp('created_at').defaultNow().notNull(),
63
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
64
+ }, (table) => [
65
+ index('users_clerk_user_id_idx').on(table.clerkUserId),
66
+ index('users_email_idx').on(table.email),
67
+ ]);
68
+
69
+ export const uploads = pgTable('uploads', {
70
+ id: text('id').primaryKey(),
71
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
72
+ objectKey: text('object_key').notNull().unique(),
73
+ bucket: text('bucket').notNull(),
74
+ contentType: text('content_type'),
75
+ createdAt: timestamp('created_at').defaultNow().notNull(),
76
+ });
77
+ `);
78
+ writeFile(path.join(root, 'lib/db/queries/users.ts'), `import { eq } from 'drizzle-orm';
79
+ import { getDb } from '../client';
80
+ import { uploads, users } from '../schema';
81
+
82
+ type SyncUserInput = {
83
+ clerkUserId: string;
84
+ email: string;
85
+ displayName: string | null;
86
+ };
87
+
88
+ export async function findUserByClerkId(clerkUserId: string) {
89
+ const db = getDb();
90
+ return db.query.users.findFirst({
91
+ where: eq(users.clerkUserId, clerkUserId),
92
+ });
93
+ }
94
+
95
+ export async function syncUserProfile(input: SyncUserInput) {
96
+ const db = getDb();
97
+ await db
98
+ .insert(users)
99
+ .values({
100
+ id: input.clerkUserId,
101
+ clerkUserId: input.clerkUserId,
102
+ email: input.email,
103
+ displayName: input.displayName,
104
+ })
105
+ .onConflictDoUpdate({
106
+ target: users.clerkUserId,
107
+ set: {
108
+ email: input.email,
109
+ displayName: input.displayName,
110
+ updatedAt: new Date(),
111
+ },
112
+ });
113
+
114
+ return findUserByClerkId(input.clerkUserId);
115
+ }
116
+
117
+ export async function updateStripeCustomerForUser(clerkUserId: string, stripeCustomerId: string) {
118
+ const db = getDb();
119
+ await db
120
+ .update(users)
121
+ .set({
122
+ stripeCustomerId,
123
+ subscriptionStatus: 'checkout_started',
124
+ updatedAt: new Date(),
125
+ })
126
+ .where(eq(users.clerkUserId, clerkUserId));
127
+ }
128
+
129
+ export async function updateAvatarForUser(
130
+ clerkUserId: string,
131
+ input: { objectKey: string; contentType: string | null },
132
+ ) {
133
+ const db = getDb();
134
+ await db
135
+ .update(users)
136
+ .set({
137
+ avatarObjectKey: input.objectKey,
138
+ avatarMimeType: input.contentType,
139
+ updatedAt: new Date(),
140
+ })
141
+ .where(eq(users.clerkUserId, clerkUserId));
142
+ }
143
+
144
+ export async function createUploadRecord(
145
+ clerkUserId: string,
146
+ input: { objectKey: string; bucket: string; contentType: string | null },
147
+ ) {
148
+ const db = getDb();
149
+ await db.insert(uploads).values({
150
+ id: crypto.randomUUID(),
151
+ userId: clerkUserId,
152
+ objectKey: input.objectKey,
153
+ bucket: input.bucket,
154
+ contentType: input.contentType,
155
+ });
156
+ }
157
+ `);
158
+ writeFile(path.join(root, 'lib/db/queries/uploads.ts'), `import { eq } from 'drizzle-orm';
159
+ import { getDb } from '../client';
160
+ import { uploads } from '../schema';
161
+
162
+ export async function listUploadsForUser(userId: string) {
163
+ const db = getDb();
164
+ return db.query.uploads.findMany({
165
+ where: eq(uploads.userId, userId),
166
+ });
167
+ }
168
+ `);
169
+ writeFile(path.join(root, 'lib/db/queries/index.ts'), `export * from './uploads';
170
+ export * from './users';
171
+ `);
172
+ writeFile(path.join(root, 'lib/db/migrate.ts'), `import { config } from 'dotenv';
173
+ import { migrate } from 'drizzle-orm/neon-http/migrator';
174
+ import { drizzle } from 'drizzle-orm/neon-http';
175
+ import { neon } from '@neondatabase/serverless';
176
+
177
+ config({ path: '.env.local' });
178
+ config();
179
+
180
+ if (!process.env.DATABASE_URL) {
181
+ throw new Error('DATABASE_URL is not defined');
182
+ }
183
+
184
+ const sql = neon(process.env.DATABASE_URL);
185
+ const db = drizzle(sql);
186
+
187
+ await migrate(db, { migrationsFolder: './lib/db/migrations' });
188
+ `);
189
+ writeFile(path.join(root, 'lib/db/migrations/.gitkeep'), '');
190
+ writeFile(path.join(root, 'lib/storage/r2.ts'), `import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
191
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
192
+
193
+ export const r2 = new S3Client({
194
+ region: 'auto',
195
+ endpoint: \`https://\${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com\`,
196
+ credentials: {
197
+ accessKeyId: process.env.R2_ACCESS_KEY_ID ?? '',
198
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? '',
199
+ },
200
+ });
201
+
202
+ export function requireR2Bucket(): string {
203
+ if (!process.env.R2_BUCKET) {
204
+ throw new Error('R2_BUCKET is not configured');
205
+ }
206
+
207
+ return process.env.R2_BUCKET;
208
+ }
209
+
210
+ export async function uploadUserAsset(input: {
211
+ objectKey: string;
212
+ body: Buffer;
213
+ contentType: string | null;
214
+ }) {
215
+ const bucket = requireR2Bucket();
216
+
217
+ await r2.send(new PutObjectCommand({
218
+ Bucket: bucket,
219
+ Key: input.objectKey,
220
+ Body: input.body,
221
+ ContentType: input.contentType ?? undefined,
222
+ }));
223
+
224
+ return {
225
+ bucket,
226
+ objectKey: input.objectKey,
227
+ };
228
+ }
229
+
230
+ export async function createSignedAssetUrl(objectKey: string) {
231
+ return getSignedUrl(
232
+ r2,
233
+ new GetObjectCommand({
234
+ Bucket: requireR2Bucket(),
235
+ Key: objectKey,
236
+ }),
237
+ { expiresIn: 60 * 10 },
238
+ );
239
+ }
240
+ `);
241
+ writeFile(path.join(root, 'lib/payments/stripe.ts'), `import Stripe from 'stripe';
242
+
243
+ export function getStripe() {
244
+ if (!process.env.STRIPE_SECRET_KEY) {
245
+ throw new Error('STRIPE_SECRET_KEY is not configured');
246
+ }
247
+
248
+ return new Stripe(process.env.STRIPE_SECRET_KEY);
249
+ }
250
+
251
+ export function requireStarterPriceId(): string {
252
+ if (!process.env.STRIPE_PRICE_STARTER) {
253
+ throw new Error('STRIPE_PRICE_STARTER is not configured');
254
+ }
255
+
256
+ return process.env.STRIPE_PRICE_STARTER;
257
+ }
258
+ `);
259
+ writeFile(path.join(root, 'lib/auth/clerk.ts'), `type ClerkLikeUser = {
260
+ primaryEmailAddressId: string | null;
261
+ emailAddresses: Array<{
262
+ id: string;
263
+ emailAddress: string;
264
+ }>;
265
+ firstName: string | null;
266
+ lastName: string | null;
267
+ username: string | null;
268
+ } | null;
269
+
270
+ export const clerkNavigation = {
271
+ signIn: '/sign-in',
272
+ signUp: '/sign-up',
273
+ afterAuth: '/dashboard',
274
+ } as const;
275
+
276
+ export function getPrimaryEmail(user: ClerkLikeUser): string | null {
277
+ if (!user) {
278
+ return null;
279
+ }
280
+
281
+ if (user.primaryEmailAddressId) {
282
+ const primaryEmail = user.emailAddresses.find((entry) => entry.id === user.primaryEmailAddressId);
283
+ if (primaryEmail?.emailAddress) {
284
+ return primaryEmail.emailAddress;
285
+ }
286
+ }
287
+
288
+ return user.emailAddresses[0]?.emailAddress ?? null;
289
+ }
290
+
291
+ export function getDisplayName(user: ClerkLikeUser, fallbackEmail: string | null): string {
292
+ if (user) {
293
+ const firstName = user.firstName?.trim() ?? '';
294
+ const lastName = user.lastName?.trim() ?? '';
295
+ const fullName = [firstName, lastName].filter(Boolean).join(' ');
296
+
297
+ if (fullName) {
298
+ return fullName;
299
+ }
300
+
301
+ const username = user.username?.trim();
302
+ if (username) {
303
+ return username;
304
+ }
305
+ }
306
+
307
+ if (fallbackEmail) {
308
+ const emailLocalPart = fallbackEmail.split('@')[0]?.trim();
309
+ if (emailLocalPart) {
310
+ return emailLocalPart;
311
+ }
312
+ }
313
+
314
+ return 'Builder';
315
+ }
316
+
317
+ export function getClerkProfile(user: ClerkLikeUser) {
318
+ const email = getPrimaryEmail(user);
319
+
320
+ return {
321
+ email,
322
+ displayName: getDisplayName(user, email),
323
+ };
324
+ }
325
+ `);
326
+ writeFile(path.join(root, 'lib/app/dashboard.ts'), `import { findUserByClerkId, syncUserProfile } from '@/lib/db/queries';
327
+
328
+ type DashboardStateInput = {
329
+ clerkUserId: string;
330
+ email: string;
331
+ displayName: string;
332
+ };
333
+
334
+ export type DashboardState = {
335
+ email: string;
336
+ displayName: string;
337
+ subscriptionTier: string;
338
+ subscriptionStatus: string;
339
+ creditsBalance: number;
340
+ stripeCustomerId: string | null;
341
+ avatarUrl: string | null;
342
+ databaseReady: boolean;
343
+ };
344
+
345
+ export async function getDashboardState(input: DashboardStateInput): Promise<DashboardState> {
346
+ try {
347
+ if (!process.env.DATABASE_URL) {
348
+ return {
349
+ email: input.email,
350
+ displayName: input.displayName,
351
+ subscriptionTier: 'starter',
352
+ subscriptionStatus: 'setup_required',
353
+ creditsBalance: 250,
354
+ stripeCustomerId: null,
355
+ avatarUrl: null,
356
+ databaseReady: false,
357
+ };
358
+ }
359
+
360
+ await syncUserProfile({
361
+ clerkUserId: input.clerkUserId,
362
+ email: input.email,
363
+ displayName: input.displayName,
364
+ });
365
+
366
+ const user = await findUserByClerkId(input.clerkUserId);
367
+
368
+ if (!user) {
369
+ throw new Error('User could not be loaded after sync');
370
+ }
371
+
372
+ return {
373
+ email: user.email,
374
+ displayName: user.displayName ?? input.displayName,
375
+ subscriptionTier: user.subscriptionTier,
376
+ subscriptionStatus: user.subscriptionStatus,
377
+ creditsBalance: user.creditsBalance,
378
+ stripeCustomerId: user.stripeCustomerId,
379
+ avatarUrl: user.avatarObjectKey
380
+ ? \`/api/account/avatar?v=\${user.updatedAt.getTime()}\`
381
+ : null,
382
+ databaseReady: true,
383
+ };
384
+ } catch {
385
+ return {
386
+ email: input.email,
387
+ displayName: input.displayName,
388
+ subscriptionTier: 'starter',
389
+ subscriptionStatus: 'setup_required',
390
+ creditsBalance: 250,
391
+ stripeCustomerId: null,
392
+ avatarUrl: null,
393
+ databaseReady: false,
394
+ };
395
+ }
396
+ }
397
+ `);
398
+ writeFile(path.join(root, 'lib/ui/button.ts'), `import { cva, type VariantProps } from 'class-variance-authority';
399
+
400
+ export const buttonVariants = cva(
401
+ 'group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4',
402
+ {
403
+ variants: {
404
+ variant: {
405
+ default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
406
+ outline:
407
+ 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
408
+ secondary:
409
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
410
+ ghost:
411
+ 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
412
+ destructive:
413
+ 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
414
+ link: 'text-primary underline-offset-4 hover:underline',
415
+ },
416
+ size: {
417
+ default:
418
+ 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
419
+ xs: 'h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*="size-"])]:size-3',
420
+ sm: 'h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*="size-"])]:size-3.5',
421
+ lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
422
+ icon: 'size-8',
423
+ 'icon-xs':
424
+ 'size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*="size-"])]:size-3',
425
+ 'icon-sm':
426
+ 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
427
+ 'icon-lg': 'size-9',
428
+ },
429
+ },
430
+ defaultVariants: {
431
+ variant: 'default',
432
+ size: 'default',
433
+ },
434
+ },
435
+ );
436
+
437
+ export type ButtonVariantProps = VariantProps<typeof buttonVariants>;
438
+ `);
439
+ writeFile(path.join(root, 'components/ui/button.tsx'), `'use client';
440
+
441
+ import { Button as ButtonPrimitive } from '@base-ui/react/button';
442
+ import { type ButtonVariantProps, buttonVariants } from '@/lib/ui/button';
443
+ import { cn } from '@/lib/utils';
444
+
445
+ function Button({
446
+ className,
447
+ variant = 'default',
448
+ size = 'default',
449
+ ...props
450
+ }: ButtonPrimitive.Props & ButtonVariantProps) {
451
+ return (
452
+ <ButtonPrimitive
453
+ data-slot="button"
454
+ className={cn(buttonVariants({ variant, size, className }))}
455
+ {...props}
456
+ />
457
+ );
458
+ }
459
+
460
+ export { Button, buttonVariants };
461
+ `);
462
+ writeFile(path.join(root, 'components/dashboard/header-user-menu.tsx'), `'use client';
463
+
464
+ import Image from 'next/image';
465
+ import { SignOutButton } from '@clerk/nextjs';
466
+ import { Camera, ChevronDown, Coins, CreditCard, Loader2, LogOut } from 'lucide-react';
467
+ import { useRef, useState } from 'react';
468
+ import { Button } from '@/components/ui/button';
469
+
470
+ type HeaderUserMenuProps = {
471
+ displayName: string;
472
+ email: string;
473
+ subscriptionTier: string;
474
+ subscriptionStatus: string;
475
+ creditsBalance: number;
476
+ avatarUrl: string | null;
477
+ };
478
+
479
+ async function readJsonSafely(response: Response) {
480
+ try {
481
+ return await response.json();
482
+ } catch {
483
+ return null;
484
+ }
485
+ }
486
+
487
+ export function HeaderUserMenu(props: HeaderUserMenuProps) {
488
+ const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl);
489
+ const [notice, setNotice] = useState<string | null>(null);
490
+ const [isPortalPending, setIsPortalPending] = useState(false);
491
+ const [isUploadPending, setIsUploadPending] = useState(false);
492
+ const fileInputRef = useRef<HTMLInputElement>(null);
493
+ const initials = props.displayName
494
+ .split(' ')
495
+ .map((chunk) => chunk[0])
496
+ .join('')
497
+ .slice(0, 2)
498
+ .toUpperCase();
499
+
500
+ const handleManageSubscription = async () => {
501
+ setIsPortalPending(true);
502
+ setNotice(null);
503
+
504
+ try {
505
+ const response = await fetch('/api/stripe/portal', { method: 'POST' });
506
+ const payload = await readJsonSafely(response);
507
+
508
+ if (!response.ok) {
509
+ setNotice(payload?.error ?? 'Stripe portal is not ready yet.');
510
+ return;
511
+ }
512
+
513
+ if (payload?.url) {
514
+ window.location.href = payload.url;
515
+ }
516
+ } finally {
517
+ setIsPortalPending(false);
518
+ }
519
+ };
520
+
521
+ const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
522
+ const input = event.currentTarget;
523
+ const file = input.files?.[0];
524
+ if (!file) return;
525
+
526
+ setIsUploadPending(true);
527
+ setNotice(null);
528
+
529
+ try {
530
+ const formData = new FormData();
531
+ formData.set('file', file);
532
+
533
+ const response = await fetch('/api/account/avatar', {
534
+ method: 'POST',
535
+ body: formData,
536
+ });
537
+
538
+ const payload = await readJsonSafely(response);
539
+
540
+ if (!response.ok) {
541
+ setNotice(payload?.error ?? 'Avatar upload failed.');
542
+ return;
543
+ }
544
+
545
+ setAvatarUrl(payload?.avatarUrl ?? null);
546
+ setNotice('Avatar updated.');
547
+ } finally {
548
+ input.value = '';
549
+ setIsUploadPending(false);
550
+ }
551
+ };
552
+
553
+ const avatarThumbnail = avatarUrl ? (
554
+ <div className="relative size-9 overflow-hidden rounded-full border border-border/70">
555
+ <Image
556
+ alt={props.displayName}
557
+ className="object-cover"
558
+ fill
559
+ sizes="36px"
560
+ src={avatarUrl}
561
+ unoptimized
562
+ />
563
+ </div>
564
+ ) : (
565
+ <div className="flex size-9 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
566
+ {initials}
567
+ </div>
568
+ );
569
+
570
+ const avatarPanel = avatarUrl ? (
571
+ <div className="relative size-14 overflow-hidden rounded-2xl border border-border/70">
572
+ <Image
573
+ alt={props.displayName}
574
+ className="object-cover"
575
+ fill
576
+ sizes="56px"
577
+ src={avatarUrl}
578
+ unoptimized
579
+ />
580
+ </div>
581
+ ) : (
582
+ <div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-lg font-semibold text-primary-foreground">
583
+ {initials}
584
+ </div>
585
+ );
586
+
587
+ return (
588
+ <details className="group relative">
589
+ <summary className="flex cursor-pointer list-none items-center gap-3 rounded-full border border-border/70 bg-background/90 px-3 py-2 shadow-sm transition hover:border-foreground/20 hover:shadow-md [&::-webkit-details-marker]:hidden">
590
+ {avatarThumbnail}
591
+ <div className="hidden text-left sm:block">
592
+ <p className="text-sm font-medium leading-none">{props.displayName}</p>
593
+ <p className="mt-1 text-xs text-muted-foreground">{props.subscriptionTier} plan</p>
594
+ </div>
595
+ <ChevronDown className="size-4 text-muted-foreground transition group-open:rotate-180" />
596
+ </summary>
597
+
598
+ <div className="absolute right-0 z-20 mt-3 w-[22rem] rounded-3xl border border-border/70 bg-background/95 p-5 shadow-2xl backdrop-blur">
599
+ <div className="flex items-center gap-4 border-b border-border/70 pb-4">
600
+ {avatarPanel}
601
+ <div className="min-w-0">
602
+ <p className="truncate text-base font-semibold">{props.displayName}</p>
603
+ <p className="truncate text-sm text-muted-foreground">{props.email}</p>
604
+ </div>
605
+ </div>
606
+
607
+ <div className="mt-4 grid gap-3 sm:grid-cols-2">
608
+ <div className="rounded-2xl border border-border/70 bg-muted/40 p-4">
609
+ <div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-muted-foreground">
610
+ <CreditCard className="size-3.5" />
611
+ Subscription
612
+ </div>
613
+ <p className="mt-3 text-lg font-semibold capitalize">{props.subscriptionTier}</p>
614
+ <p className="text-sm capitalize text-muted-foreground">{props.subscriptionStatus.replaceAll('_', ' ')}</p>
615
+ </div>
616
+
617
+ <div className="rounded-2xl border border-border/70 bg-muted/40 p-4">
618
+ <div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-muted-foreground">
619
+ <Coins className="size-3.5" />
620
+ Credits
621
+ </div>
622
+ <p className="mt-3 text-lg font-semibold">{props.creditsBalance}</p>
623
+ <p className="text-sm text-muted-foreground">Starter balance seeded in the schema.</p>
624
+ </div>
625
+ </div>
626
+
627
+ <input
628
+ ref={fileInputRef}
629
+ accept="image/png,image/jpeg,image/webp,image/gif"
630
+ className="hidden"
631
+ onChange={(event) => {
632
+ void handleUpload(event);
633
+ }}
634
+ type="file"
635
+ />
636
+
637
+ <div className="mt-4 grid gap-2">
638
+ <Button disabled={isPortalPending} onClick={() => void handleManageSubscription()} type="button" variant="outline">
639
+ {isPortalPending ? <Loader2 className="size-4 animate-spin" /> : <CreditCard className="size-4" />}
640
+ Manage subscription
641
+ </Button>
642
+
643
+ <Button disabled={isUploadPending} onClick={() => fileInputRef.current?.click()} type="button" variant="outline">
644
+ {isUploadPending ? <Loader2 className="size-4 animate-spin" /> : <Camera className="size-4" />}
645
+ Upload avatar to R2
646
+ </Button>
647
+
648
+ <SignOutButton redirectUrl="/">
649
+ <button className="inline-flex h-8 items-center justify-center gap-2 rounded-lg px-2.5 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground" type="button">
650
+ <LogOut className="size-4" />
651
+ Sign out
652
+ </button>
653
+ </SignOutButton>
654
+ </div>
655
+
656
+ {notice ? (
657
+ <p className="mt-3 rounded-2xl border border-border/70 bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
658
+ {notice}
659
+ </p>
660
+ ) : null}
661
+ </div>
662
+ </details>
663
+ );
664
+ }
665
+ `);
666
+ writeFile(path.join(root, 'app/api/health/route.ts'), `export async function GET() {
667
+ return Response.json({ ok: true });
668
+ }
669
+ `);
670
+ writeFile(path.join(root, 'app/api/account/avatar/route.ts'), `import { auth, currentUser } from '@clerk/nextjs/server';
671
+ import { getClerkProfile } from '@/lib/auth/clerk';
672
+ import { createUploadRecord, findUserByClerkId, syncUserProfile, updateAvatarForUser } from '@/lib/db/queries';
673
+ import { createSignedAssetUrl, uploadUserAsset } from '@/lib/storage/r2';
674
+
675
+ export const runtime = 'nodejs';
676
+
677
+ function sanitizeFileName(fileName: string) {
678
+ return fileName
679
+ .toLowerCase()
680
+ .replace(/[^a-z0-9.-]+/g, '-')
681
+ .replace(/^-+|-+$/g, '');
682
+ }
683
+
684
+ export async function GET() {
685
+ const { userId } = await auth();
686
+
687
+ if (!userId) {
688
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
689
+ }
690
+
691
+ const user = await findUserByClerkId(userId);
692
+ if (!user?.avatarObjectKey) {
693
+ return Response.json({ error: 'Avatar not found' }, { status: 404 });
694
+ }
695
+
696
+ const signedUrl = await createSignedAssetUrl(user.avatarObjectKey);
697
+ return Response.redirect(signedUrl, 307);
698
+ }
699
+
700
+ export async function POST(request: Request) {
701
+ const { userId } = await auth();
702
+ if (!userId) {
703
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
704
+ }
705
+
706
+ const clerkUser = await currentUser();
707
+ const { email, displayName } = getClerkProfile(clerkUser);
708
+ if (!email) {
709
+ return Response.json({ error: 'No email address found for the signed-in user.' }, { status: 400 });
710
+ }
711
+
712
+ await syncUserProfile({
713
+ clerkUserId: userId,
714
+ email,
715
+ displayName,
716
+ });
717
+
718
+ const formData = await request.formData();
719
+ const file = formData.get('file');
720
+
721
+ if (!(file instanceof File)) {
722
+ return Response.json({ error: 'Select an image before uploading.' }, { status: 400 });
723
+ }
724
+
725
+ if (!file.type.startsWith('image/')) {
726
+ return Response.json({ error: 'Only image uploads are supported.' }, { status: 400 });
727
+ }
728
+
729
+ if (file.size > 5 * 1024 * 1024) {
730
+ return Response.json({ error: 'Avatar uploads are capped at 5MB.' }, { status: 400 });
731
+ }
732
+
733
+ const safeName = sanitizeFileName(file.name || 'avatar');
734
+ const objectKey = \`avatars/\${userId}/\${Date.now()}-\${safeName || 'avatar'}\`;
735
+ const body = Buffer.from(await file.arrayBuffer());
736
+ const upload = await uploadUserAsset({
737
+ objectKey,
738
+ body,
739
+ contentType: file.type || null,
740
+ });
741
+
742
+ await updateAvatarForUser(userId, {
743
+ objectKey,
744
+ contentType: file.type || null,
745
+ });
746
+
747
+ await createUploadRecord(userId, {
748
+ objectKey,
749
+ bucket: upload.bucket,
750
+ contentType: file.type || null,
751
+ });
752
+
753
+ return Response.json({
754
+ ok: true,
755
+ avatarUrl: \`/api/account/avatar?v=\${Date.now()}\`,
756
+ });
757
+ }
758
+ `);
759
+ writeFile(path.join(root, 'app/api/stripe/checkout/route.ts'), `import { auth, currentUser } from '@clerk/nextjs/server';
760
+ import { getClerkProfile } from '@/lib/auth/clerk';
761
+ import { syncUserProfile, updateStripeCustomerForUser } from '@/lib/db/queries';
762
+ import { getStripe, requireStarterPriceId } from '@/lib/payments/stripe';
763
+
764
+ export const runtime = 'nodejs';
765
+
766
+ export async function POST(request: Request) {
767
+ const { userId } = await auth();
768
+
769
+ if (!userId) {
770
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
771
+ }
772
+
773
+ const clerkUser = await currentUser();
774
+ const { email, displayName } = getClerkProfile(clerkUser);
775
+ if (!email) {
776
+ return Response.json({ error: 'No email address found for the signed-in user.' }, { status: 400 });
777
+ }
778
+
779
+ await syncUserProfile({
780
+ clerkUserId: userId,
781
+ email,
782
+ displayName,
783
+ });
784
+
785
+ const origin = new URL(request.url).origin;
786
+ const stripe = getStripe();
787
+ const session = await stripe.checkout.sessions.create({
788
+ mode: 'subscription',
789
+ line_items: [
790
+ {
791
+ price: requireStarterPriceId(),
792
+ quantity: 1,
793
+ },
794
+ ],
795
+ allow_promotion_codes: true,
796
+ customer_email: email,
797
+ client_reference_id: userId,
798
+ success_url: \`\${origin}/dashboard?checkout=success\`,
799
+ cancel_url: \`\${origin}/dashboard?checkout=cancelled\`,
800
+ metadata: {
801
+ clerkUserId: userId,
802
+ },
803
+ });
804
+
805
+ if (typeof session.customer === 'string' && session.customer.length > 0) {
806
+ await updateStripeCustomerForUser(userId, session.customer);
807
+ }
808
+
809
+ return Response.json({ url: session.url });
810
+ }
811
+ `);
812
+ writeFile(path.join(root, 'app/api/stripe/portal/route.ts'), `import { auth } from '@clerk/nextjs/server';
813
+ import { findUserByClerkId } from '@/lib/db/queries';
814
+ import { getStripe } from '@/lib/payments/stripe';
815
+
816
+ export const runtime = 'nodejs';
817
+
818
+ export async function POST(request: Request) {
819
+ const { userId } = await auth();
820
+
821
+ if (!userId) {
822
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
823
+ }
824
+
825
+ const user = await findUserByClerkId(userId);
826
+
827
+ if (!user?.stripeCustomerId) {
828
+ return Response.json({
829
+ error: 'No Stripe customer is attached yet. Run the starter checkout flow once to seed billing.',
830
+ }, { status: 400 });
831
+ }
832
+
833
+ const origin = new URL(request.url).origin;
834
+ const stripe = getStripe();
835
+ const session = await stripe.billingPortal.sessions.create({
836
+ customer: user.stripeCustomerId,
837
+ return_url: \`\${origin}/dashboard\`,
838
+ });
839
+
840
+ return Response.json({ url: session.url });
841
+ }
842
+ `);
843
+ writeFile(path.join(root, 'app/dashboard/page.tsx'), `import Link from 'next/link';
844
+ import { auth, currentUser } from '@clerk/nextjs/server';
845
+ import { redirect } from 'next/navigation';
846
+ import { ArrowRight, CreditCard, Database, HardDriveUpload, Sparkles } from 'lucide-react';
847
+ import { HeaderUserMenu } from '@/components/dashboard/header-user-menu';
848
+ import { getClerkProfile } from '@/lib/auth/clerk';
849
+ import { getDashboardState } from '@/lib/app/dashboard';
850
+ import { buttonVariants } from '@/lib/ui/button';
851
+ import { cn } from '@/lib/utils';
852
+
853
+ export default async function DashboardPage() {
854
+ const { userId } = await auth();
855
+
856
+ if (!userId) {
857
+ redirect('/sign-in');
858
+ }
859
+
860
+ const user = await currentUser();
861
+ const profile = getClerkProfile(user);
862
+ const email = profile.email ?? 'builder@example.com';
863
+ const displayName = profile.displayName;
864
+ const dashboard = await getDashboardState({
865
+ clerkUserId: userId,
866
+ email,
867
+ displayName,
868
+ });
869
+
870
+ return (
871
+ <main className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(29,78,216,0.14),transparent_28%),radial-gradient(circle_at_top_right,rgba(251,191,36,0.14),transparent_24%)]">
872
+ <div className="mx-auto flex min-h-screen max-w-6xl flex-col px-6 py-6 sm:px-8">
873
+ <header className="flex items-center justify-between gap-4 rounded-[2rem] border border-border/70 bg-background/85 px-4 py-3 shadow-sm backdrop-blur">
874
+ <div>
875
+ <p className="text-xs uppercase tracking-[0.26em] text-muted-foreground">anhedral starter</p>
876
+ <h1 className="text-lg font-semibold tracking-tight">{displayName}</h1>
877
+ </div>
878
+ <HeaderUserMenu
879
+ avatarUrl={dashboard.avatarUrl}
880
+ creditsBalance={dashboard.creditsBalance}
881
+ displayName={dashboard.displayName}
882
+ email={dashboard.email}
883
+ subscriptionStatus={dashboard.subscriptionStatus}
884
+ subscriptionTier={dashboard.subscriptionTier}
885
+ />
886
+ </header>
887
+
888
+ <section className="grid flex-1 gap-6 py-10 lg:grid-cols-[1.4fr_0.9fr]">
889
+ <div className="space-y-6">
890
+ <div className="rounded-[2rem] border border-border/70 bg-background/90 p-8 shadow-sm">
891
+ <div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-muted/40 px-3 py-1 text-xs uppercase tracking-[0.24em] text-muted-foreground">
892
+ <Sparkles className="size-3.5" />
893
+ Dashboard starter
894
+ </div>
895
+ <h2 className="mt-5 max-w-3xl text-4xl font-semibold tracking-tight sm:text-5xl">
896
+ Ship the logged-in shell first. Replace the placeholders after your product model is clear.
897
+ </h2>
898
+ <p className="mt-4 max-w-2xl text-base text-muted-foreground">
899
+ The scaffold already wires a protected dashboard, a subscription surface, seeded credits, and R2-backed avatar upload.
900
+ Your team can start inside the app shell instead of building this plumbing from scratch.
901
+ </p>
902
+
903
+ <div className="mt-8 flex flex-wrap gap-3">
904
+ <form action="/api/stripe/checkout" method="post">
905
+ <button className={cn(buttonVariants({ size: 'lg' }))} type="submit">
906
+ Start starter checkout
907
+ <ArrowRight className="size-4" />
908
+ </button>
909
+ </form>
910
+
911
+ <Link className={cn(buttonVariants({ size: 'lg', variant: 'outline' }))} href="/">
912
+ View landing page
913
+ </Link>
914
+ </div>
915
+ </div>
916
+
917
+ <div className="grid gap-4 md:grid-cols-3">
918
+ <article className="rounded-[1.75rem] border border-border/70 bg-background/90 p-5 shadow-sm">
919
+ <div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-muted-foreground">
920
+ <CreditCard className="size-3.5" />
921
+ Billing
922
+ </div>
923
+ <p className="mt-4 text-2xl font-semibold capitalize">{dashboard.subscriptionStatus.replaceAll('_', ' ')}</p>
924
+ <p className="mt-2 text-sm text-muted-foreground">
925
+ Stripe Billing uses Checkout for subscription start and the customer portal for self-serve management.
926
+ </p>
927
+ </article>
928
+
929
+ <article className="rounded-[1.75rem] border border-border/70 bg-background/90 p-5 shadow-sm">
930
+ <div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-muted-foreground">
931
+ <Database className="size-3.5" />
932
+ Credits
933
+ </div>
934
+ <p className="mt-4 text-2xl font-semibold">{dashboard.creditsBalance}</p>
935
+ <p className="mt-2 text-sm text-muted-foreground">
936
+ Seeded in Drizzle so you can attach real entitlement logic without redesigning the menu.
937
+ </p>
938
+ </article>
939
+
940
+ <article className="rounded-[1.75rem] border border-border/70 bg-background/90 p-5 shadow-sm">
941
+ <div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-muted-foreground">
942
+ <HardDriveUpload className="size-3.5" />
943
+ Storage
944
+ </div>
945
+ <p className="mt-4 text-2xl font-semibold">R2 avatar flow</p>
946
+ <p className="mt-2 text-sm text-muted-foreground">
947
+ The header menu uploads profile images into R2 and serves them back through a signed route.
948
+ </p>
949
+ </article>
950
+ </div>
951
+ </div>
952
+
953
+ <aside className="space-y-4 rounded-[2rem] border border-border/70 bg-background/90 p-6 shadow-sm">
954
+ <div>
955
+ <p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Template notes</p>
956
+ <h3 className="mt-3 text-2xl font-semibold tracking-tight">Replace these first</h3>
957
+ </div>
958
+
959
+ <ul className="space-y-3 text-sm text-muted-foreground">
960
+ <li className="rounded-2xl border border-border/70 bg-muted/30 px-4 py-3">
961
+ Swap the starter Stripe price id with your real recurring product and add webhook handlers.
962
+ </li>
963
+ <li className="rounded-2xl border border-border/70 bg-muted/30 px-4 py-3">
964
+ Replace the seeded credits model with your real usage accounting rules.
965
+ </li>
966
+ <li className="rounded-2xl border border-border/70 bg-muted/30 px-4 py-3">
967
+ Expand the dashboard once your primary signed-in workflow is defined.
968
+ </li>
969
+ </ul>
970
+
971
+ {!dashboard.databaseReady ? (
972
+ <div className="rounded-2xl border border-amber-400/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100">
973
+ Database connectivity is not ready yet. The starter falls back to placeholder subscription and credits data until your Neon env and Drizzle migrations are in place.
974
+ </div>
975
+ ) : null}
976
+ </aside>
977
+ </section>
978
+ </div>
979
+ </main>
980
+ );
981
+ }
982
+ `);
983
+ writeFile(path.join(root, 'app/layout.tsx'), `import { shadcn } from '@clerk/ui/themes';
984
+ import { ClerkProvider } from '@clerk/nextjs';
985
+ import type { Metadata } from 'next';
986
+ import './globals.css';
987
+ import { ThemeProvider } from '@/components/theme-provider';
988
+
989
+ export const metadata: Metadata = {
990
+ title: '${displayName}',
991
+ description: 'Anhedral starter with Clerk auth, Stripe billing, Neon + Drizzle, and R2 avatars.',
992
+ };
993
+
994
+ const clerkAppearance = {
995
+ theme: shadcn,
996
+ variables: {
997
+ colorPrimary: 'oklch(0.205 0 0)',
998
+ colorBackground: 'oklch(1 0 0)',
999
+ borderRadius: '1rem',
1000
+ },
1001
+ options: {
1002
+ logoImageUrl: '${ANHEDRAL_LOGO_PUBLIC_PATH}',
1003
+ socialButtonsVariant: 'iconButton' as const,
1004
+ socialButtonsPlacement: 'top' as const,
1005
+ },
1006
+ };
1007
+
1008
+ export default function RootLayout({
1009
+ children,
1010
+ }: Readonly<{
1011
+ children: React.ReactNode;
1012
+ }>) {
1013
+ return (
1014
+ <ClerkProvider appearance={clerkAppearance}>
1015
+ <html
1016
+ lang="en"
1017
+ suppressHydrationWarning
1018
+ className="antialiased font-sans"
1019
+ >
1020
+ <body>
1021
+ <ThemeProvider>{children}</ThemeProvider>
1022
+ </body>
1023
+ </html>
1024
+ </ClerkProvider>
1025
+ );
1026
+ }
1027
+ `);
1028
+ writeFile(path.join(root, 'app/page.tsx'), `import { auth } from '@clerk/nextjs/server';
1029
+ import { ArrowRight, Cloud, CreditCard, Database, ShieldCheck } from 'lucide-react';
1030
+ import Link from 'next/link';
1031
+ import { buttonVariants } from '@/lib/ui/button';
1032
+ import { cn } from '@/lib/utils';
1033
+
1034
+ const pillars = [
1035
+ {
1036
+ title: 'Landing page here',
1037
+ description: 'This placeholder is intentional. Replace the copy, media, and pricing once your narrative is ready.',
1038
+ icon: Cloud,
1039
+ },
1040
+ {
1041
+ title: 'Clerk custom auth',
1042
+ description: 'Custom-branded sign-in and sign-up routes are already mounted at /sign-in and /sign-up.',
1043
+ icon: ShieldCheck,
1044
+ },
1045
+ {
1046
+ title: 'Stripe + credits',
1047
+ description: 'The signed-in shell already reserves space for subscription state, checkout, and credit balances.',
1048
+ icon: CreditCard,
1049
+ },
1050
+ {
1051
+ title: 'Neon + Drizzle + R2',
1052
+ description: 'Core persistence and avatar upload plumbing are scaffolded so your product team starts higher in the stack.',
1053
+ icon: Database,
1054
+ },
1055
+ ];
1056
+
1057
+ export default async function HomePage() {
1058
+ const { userId } = await auth();
1059
+
1060
+ return (
1061
+ <main className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(29,78,216,0.14),transparent_30%),radial-gradient(circle_at_bottom_right,rgba(251,191,36,0.14),transparent_25%)]">
1062
+ <div className="mx-auto flex min-h-screen max-w-6xl flex-col px-6 py-8 sm:px-8">
1063
+ <header className="flex items-center justify-between gap-4 rounded-[2rem] border border-border/70 bg-background/85 px-4 py-3 shadow-sm backdrop-blur">
1064
+ <div>
1065
+ <p className="text-xs uppercase tracking-[0.26em] text-muted-foreground">anhedral starter</p>
1066
+ <p className="text-lg font-semibold tracking-tight">${displayName}</p>
1067
+ </div>
1068
+
1069
+ <div className="flex items-center gap-3">
1070
+ <Link className={cn(buttonVariants({ variant: 'ghost' }))} href="/sign-in">
1071
+ Sign in
1072
+ </Link>
1073
+ <Link className={cn(buttonVariants())} href={userId ? '/dashboard' : '/sign-up'}>
1074
+ {userId ? 'Open dashboard' : 'Start with sign up'}
1075
+ </Link>
1076
+ </div>
1077
+ </header>
1078
+
1079
+ <section className="grid flex-1 items-center gap-8 py-14 lg:grid-cols-[1.3fr_0.9fr]">
1080
+ <div className="rounded-[2.25rem] border border-border/70 bg-background/90 p-8 shadow-sm sm:p-10">
1081
+ <div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-muted/40 px-3 py-1 text-xs uppercase tracking-[0.24em] text-muted-foreground">
1082
+ Replace me with your real story
1083
+ </div>
1084
+ <h1 className="mt-5 max-w-4xl text-5xl font-semibold tracking-tight sm:text-6xl">
1085
+ Landing page here. Keep the shell, then turn it into your actual acquisition motion.
1086
+ </h1>
1087
+ <p className="mt-5 max-w-2xl text-lg text-muted-foreground">
1088
+ The generated project starts with a real auth flow, a signed-in dashboard shell, Stripe billing entry points,
1089
+ and an R2 avatar path so your first sprint is focused on product, not setup.
1090
+ </p>
1091
+
1092
+ <div className="mt-8 flex flex-wrap gap-3">
1093
+ <Link className={cn(buttonVariants({ size: 'lg' }))} href={userId ? '/dashboard' : '/sign-up'}>
1094
+ {userId ? 'Go to dashboard' : 'Create your first account'}
1095
+ <ArrowRight className="size-4" />
1096
+ </Link>
1097
+ <Link className={cn(buttonVariants({ size: 'lg', variant: 'outline' }))} href="/sign-in">
1098
+ See the sign-in flow
1099
+ </Link>
1100
+ </div>
1101
+ </div>
1102
+
1103
+ <div className="grid gap-4">
1104
+ {pillars.map(({ title, description, icon: Icon }) => (
1105
+ <article key={title} className="rounded-[1.75rem] border border-border/70 bg-background/90 p-5 shadow-sm">
1106
+ <div className="flex items-center gap-3">
1107
+ <div className="flex size-10 items-center justify-center rounded-2xl bg-muted text-foreground">
1108
+ <Icon className="size-5" />
1109
+ </div>
1110
+ <h2 className="text-lg font-semibold">{title}</h2>
1111
+ </div>
1112
+ <p className="mt-3 text-sm leading-6 text-muted-foreground">{description}</p>
1113
+ </article>
1114
+ ))}
1115
+ </div>
1116
+ </section>
1117
+ </div>
1118
+ </main>
1119
+ );
1120
+ }
1121
+ `);
1122
+ writeFile(path.join(root, 'app/sign-in/[[...sign-in]]/page.tsx'), `import { SignIn } from '@clerk/nextjs';
1123
+ import Link from 'next/link';
1124
+
1125
+ export default function SignInPage() {
1126
+ return (
1127
+ <main className="grid min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(29,78,216,0.14),transparent_28%),radial-gradient(circle_at_bottom_right,rgba(251,191,36,0.14),transparent_24%)] px-6 py-10 sm:px-8 lg:grid-cols-[1.1fr_0.9fr]">
1128
+ <section className="flex flex-col justify-between rounded-[2rem] border border-border/70 bg-background/90 p-8 shadow-sm">
1129
+ <div>
1130
+ <p className="text-xs uppercase tracking-[0.26em] text-muted-foreground">Clerk custom UI</p>
1131
+ <h1 className="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl">Sign in without rebuilding the basics.</h1>
1132
+ <p className="mt-4 max-w-xl text-base text-muted-foreground">
1133
+ This route is intentionally branded and lives alongside your app shell. Swap the copy and continue shipping.
1134
+ </p>
1135
+ </div>
1136
+
1137
+ <div className="rounded-[1.75rem] border border-border/70 bg-muted/30 p-5 text-sm text-muted-foreground">
1138
+ <p className="font-medium text-foreground">Already wired:</p>
1139
+ <ul className="mt-3 space-y-2">
1140
+ <li>Custom route at /sign-in</li>
1141
+ <li>Clerk shadcn theme through ClerkProvider</li>
1142
+ <li>Dashboard redirect after authentication</li>
1143
+ </ul>
1144
+ <p className="mt-4">
1145
+ Need a different conversion flow? Pair this page with the installed Clerk skill and replace it with a hook-driven form later.
1146
+ </p>
1147
+ </div>
1148
+ </section>
1149
+
1150
+ <section className="flex items-center justify-center py-8 lg:py-0">
1151
+ <div className="w-full max-w-md rounded-[2rem] border border-border/70 bg-background/95 p-4 shadow-xl">
1152
+ <SignIn forceRedirectUrl="/dashboard" path="/sign-in" routing="path" signUpUrl="/sign-up" />
1153
+ <p className="px-4 pb-2 text-center text-sm text-muted-foreground">
1154
+ Need an account?{' '}
1155
+ <Link className="font-medium text-foreground underline-offset-4 hover:underline" href="/sign-up">
1156
+ Create one
1157
+ </Link>
1158
+ </p>
1159
+ </div>
1160
+ </section>
1161
+ </main>
1162
+ );
1163
+ }
1164
+ `);
1165
+ writeFile(path.join(root, 'app/sign-up/[[...sign-up]]/page.tsx'), `import { SignUp } from '@clerk/nextjs';
1166
+ import Link from 'next/link';
1167
+
1168
+ export default function SignUpPage() {
1169
+ return (
1170
+ <main className="grid min-h-screen bg-[radial-gradient(circle_at_top_right,rgba(29,78,216,0.14),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(251,191,36,0.14),transparent_24%)] px-6 py-10 sm:px-8 lg:grid-cols-[0.9fr_1.1fr]">
1171
+ <section className="flex items-center justify-center py-8 lg:order-2 lg:py-0">
1172
+ <div className="w-full max-w-md rounded-[2rem] border border-border/70 bg-background/95 p-4 shadow-xl">
1173
+ <SignUp forceRedirectUrl="/dashboard" path="/sign-up" routing="path" signInUrl="/sign-in" />
1174
+ <p className="px-4 pb-2 text-center text-sm text-muted-foreground">
1175
+ Already have an account?{' '}
1176
+ <Link className="font-medium text-foreground underline-offset-4 hover:underline" href="/sign-in">
1177
+ Sign in
1178
+ </Link>
1179
+ </p>
1180
+ </div>
1181
+ </section>
1182
+
1183
+ <section className="flex flex-col justify-between rounded-[2rem] border border-border/70 bg-background/90 p-8 shadow-sm lg:order-1">
1184
+ <div>
1185
+ <p className="text-xs uppercase tracking-[0.26em] text-muted-foreground">Starter onboarding</p>
1186
+ <h1 className="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl">Get users into the product shell immediately.</h1>
1187
+ <p className="mt-4 max-w-xl text-base text-muted-foreground">
1188
+ The generated starter sends new accounts straight into the dashboard with billing, credits, and avatar plumbing already waiting.
1189
+ </p>
1190
+ </div>
1191
+
1192
+ <div className="rounded-[1.75rem] border border-border/70 bg-muted/30 p-5 text-sm text-muted-foreground">
1193
+ <p className="font-medium text-foreground">Good first replacements:</p>
1194
+ <ul className="mt-3 space-y-2">
1195
+ <li>Swap the starter tier labels and pricing copy</li>
1196
+ <li>Add your real onboarding questionnaire or workspace creation step</li>
1197
+ <li>Connect webhook-driven billing state once Stripe products are finalized</li>
1198
+ </ul>
1199
+ </div>
1200
+ </section>
1201
+ </main>
1202
+ );
1203
+ }
1204
+ `);
1205
+ writeFile(path.join(root, 'app/globals.css'), `@import 'tailwindcss';
1206
+ @import 'tw-animate-css';
1207
+ @import 'shadcn/tailwind.css';
1208
+ @import '@clerk/ui/themes/shadcn.css';
1209
+
1210
+ @custom-variant dark (&:is(.dark *));
1211
+
1212
+ @theme inline {
1213
+ --font-heading: var(--font-app-sans);
1214
+ --font-sans: var(--font-app-sans);
1215
+ --font-mono: var(--font-app-mono);
1216
+ --color-sidebar-ring: var(--sidebar-ring);
1217
+ --color-sidebar-border: var(--sidebar-border);
1218
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
1219
+ --color-sidebar-accent: var(--sidebar-accent);
1220
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
1221
+ --color-sidebar-primary: var(--sidebar-primary);
1222
+ --color-sidebar-foreground: var(--sidebar-foreground);
1223
+ --color-sidebar: var(--sidebar);
1224
+ --color-chart-5: var(--chart-5);
1225
+ --color-chart-4: var(--chart-4);
1226
+ --color-chart-3: var(--chart-3);
1227
+ --color-chart-2: var(--chart-2);
1228
+ --color-chart-1: var(--chart-1);
1229
+ --color-ring: var(--ring);
1230
+ --color-input: var(--input);
1231
+ --color-border: var(--border);
1232
+ --color-destructive: var(--destructive);
1233
+ --color-accent-foreground: var(--accent-foreground);
1234
+ --color-accent: var(--accent);
1235
+ --color-muted-foreground: var(--muted-foreground);
1236
+ --color-muted: var(--muted);
1237
+ --color-secondary-foreground: var(--secondary-foreground);
1238
+ --color-secondary: var(--secondary);
1239
+ --color-primary-foreground: var(--primary-foreground);
1240
+ --color-primary: var(--primary);
1241
+ --color-popover-foreground: var(--popover-foreground);
1242
+ --color-popover: var(--popover);
1243
+ --color-card-foreground: var(--card-foreground);
1244
+ --color-card: var(--card);
1245
+ --color-foreground: var(--foreground);
1246
+ --color-background: var(--background);
1247
+ --radius-sm: calc(var(--radius) * 0.6);
1248
+ --radius-md: calc(var(--radius) * 0.8);
1249
+ --radius-lg: var(--radius);
1250
+ --radius-xl: calc(var(--radius) * 1.4);
1251
+ --radius-2xl: calc(var(--radius) * 1.8);
1252
+ --radius-3xl: calc(var(--radius) * 2.2);
1253
+ --radius-4xl: calc(var(--radius) * 2.6);
1254
+ }
1255
+
1256
+ :root {
1257
+ --font-app-sans: 'Avenir Next', 'Inter', 'Segoe UI', sans-serif;
1258
+ --font-app-mono: 'JetBrains Mono', 'SFMono-Regular', monospace;
1259
+ --background: oklch(1 0 0);
1260
+ --foreground: oklch(0.145 0 0);
1261
+ --card: oklch(1 0 0);
1262
+ --card-foreground: oklch(0.145 0 0);
1263
+ --popover: oklch(1 0 0);
1264
+ --popover-foreground: oklch(0.145 0 0);
1265
+ --primary: oklch(0.205 0 0);
1266
+ --primary-foreground: oklch(0.985 0 0);
1267
+ --secondary: oklch(0.97 0 0);
1268
+ --secondary-foreground: oklch(0.205 0 0);
1269
+ --muted: oklch(0.97 0 0);
1270
+ --muted-foreground: oklch(0.556 0 0);
1271
+ --accent: oklch(0.97 0 0);
1272
+ --accent-foreground: oklch(0.205 0 0);
1273
+ --destructive: oklch(0.577 0.245 27.325);
1274
+ --border: oklch(0.922 0 0);
1275
+ --input: oklch(0.922 0 0);
1276
+ --ring: oklch(0.708 0 0);
1277
+ --chart-1: oklch(0.87 0 0);
1278
+ --chart-2: oklch(0.556 0 0);
1279
+ --chart-3: oklch(0.439 0 0);
1280
+ --chart-4: oklch(0.371 0 0);
1281
+ --chart-5: oklch(0.269 0 0);
1282
+ --radius: 0.875rem;
1283
+ --sidebar: oklch(0.985 0 0);
1284
+ --sidebar-foreground: oklch(0.145 0 0);
1285
+ --sidebar-primary: oklch(0.205 0 0);
1286
+ --sidebar-primary-foreground: oklch(0.985 0 0);
1287
+ --sidebar-accent: oklch(0.97 0 0);
1288
+ --sidebar-accent-foreground: oklch(0.205 0 0);
1289
+ --sidebar-border: oklch(0.922 0 0);
1290
+ --sidebar-ring: oklch(0.708 0 0);
1291
+ }
1292
+
1293
+ .dark {
1294
+ --background: oklch(0.145 0 0);
1295
+ --foreground: oklch(0.985 0 0);
1296
+ --card: oklch(0.205 0 0);
1297
+ --card-foreground: oklch(0.985 0 0);
1298
+ --popover: oklch(0.205 0 0);
1299
+ --popover-foreground: oklch(0.985 0 0);
1300
+ --primary: oklch(0.922 0 0);
1301
+ --primary-foreground: oklch(0.205 0 0);
1302
+ --secondary: oklch(0.269 0 0);
1303
+ --secondary-foreground: oklch(0.985 0 0);
1304
+ --muted: oklch(0.269 0 0);
1305
+ --muted-foreground: oklch(0.708 0 0);
1306
+ --accent: oklch(0.269 0 0);
1307
+ --accent-foreground: oklch(0.985 0 0);
1308
+ --destructive: oklch(0.704 0.191 22.216);
1309
+ --border: oklch(1 0 0 / 10%);
1310
+ --input: oklch(1 0 0 / 15%);
1311
+ --ring: oklch(0.556 0 0);
1312
+ --chart-1: oklch(0.87 0 0);
1313
+ --chart-2: oklch(0.556 0 0);
1314
+ --chart-3: oklch(0.439 0 0);
1315
+ --chart-4: oklch(0.371 0 0);
1316
+ --chart-5: oklch(0.269 0 0);
1317
+ --sidebar: oklch(0.205 0 0);
1318
+ --sidebar-foreground: oklch(0.985 0 0);
1319
+ --sidebar-primary: oklch(0.488 0.243 264.376);
1320
+ --sidebar-primary-foreground: oklch(0.985 0 0);
1321
+ --sidebar-accent: oklch(0.269 0 0);
1322
+ --sidebar-accent-foreground: oklch(0.985 0 0);
1323
+ --sidebar-border: oklch(1 0 0 / 10%);
1324
+ --sidebar-ring: oklch(0.556 0 0);
1325
+ }
1326
+
1327
+ @layer base {
1328
+ * {
1329
+ @apply border-border outline-ring/50;
1330
+ }
1331
+
1332
+ html {
1333
+ @apply font-sans;
1334
+ }
1335
+
1336
+ body {
1337
+ @apply bg-background text-foreground;
1338
+ }
1339
+ }
1340
+ `);
1341
+ writeFile(path.join(root, 'next.config.mjs'), `import path from 'node:path';
1342
+ import { fileURLToPath } from 'node:url';
1343
+
1344
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1345
+
1346
+ /** @type {import('next').NextConfig} */
1347
+ const nextConfig = {
1348
+ reactCompiler: true,
1349
+ turbopack: {
1350
+ root: __dirname,
1351
+ },
1352
+ };
1353
+
1354
+ export default nextConfig;
1355
+ `);
1356
+ writeFile(path.join(root, 'proxy.ts'), `import { clerkMiddleware } from '@clerk/nextjs/server';
1357
+
1358
+ export default clerkMiddleware();
1359
+
1360
+ export const config = {
1361
+ matcher: [
1362
+ '/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
1363
+ '/(api|trpc)(.*)',
1364
+ ],
1365
+ };
1366
+ `);
1367
+ writeAnhedralLogo(root);
1368
+ }