anhedral 0.1.1 → 0.1.4

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.
@@ -1,1936 +0,0 @@
1
- import path from 'node:path';
2
- import { writeAnhedralWebBranding } 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-md 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-md px-2 text-xs in-data-[slot=button-group]:rounded-md 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-md px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-md 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-md in-data-[slot=button-group]:rounded-md [&_svg:not([class*="size-"])]:size-3',
425
- 'icon-sm':
426
- 'size-7 rounded-md in-data-[slot=button-group]:rounded-md',
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 { useClerk } 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 { signOut } = useClerk();
489
- const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl);
490
- const [notice, setNotice] = useState<string | null>(null);
491
- const [isPortalPending, setIsPortalPending] = useState(false);
492
- const [isUploadPending, setIsUploadPending] = useState(false);
493
- const [isSignOutPending, setIsSignOutPending] = useState(false);
494
- const fileInputRef = useRef<HTMLInputElement>(null);
495
- const initials = props.displayName
496
- .split(' ')
497
- .map((chunk) => chunk[0])
498
- .join('')
499
- .slice(0, 2)
500
- .toUpperCase();
501
-
502
- const handleManageSubscription = async () => {
503
- setIsPortalPending(true);
504
- setNotice(null);
505
-
506
- try {
507
- const response = await fetch('/api/stripe/portal', { method: 'POST' });
508
- const payload = await readJsonSafely(response);
509
-
510
- if (!response.ok) {
511
- setNotice(payload?.error ?? 'Stripe portal is not ready yet.');
512
- return;
513
- }
514
-
515
- if (payload?.url) {
516
- window.location.href = payload.url;
517
- }
518
- } finally {
519
- setIsPortalPending(false);
520
- }
521
- };
522
-
523
- const handleSignOut = async () => {
524
- setIsSignOutPending(true);
525
- try {
526
- await signOut({ redirectUrl: '/' });
527
- } finally {
528
- setIsSignOutPending(false);
529
- }
530
- };
531
-
532
- const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
533
- const input = event.currentTarget;
534
- const file = input.files?.[0];
535
- if (!file) return;
536
-
537
- setIsUploadPending(true);
538
- setNotice(null);
539
-
540
- try {
541
- const formData = new FormData();
542
- formData.set('file', file);
543
-
544
- const response = await fetch('/api/account/avatar', {
545
- method: 'POST',
546
- body: formData,
547
- });
548
-
549
- const payload = await readJsonSafely(response);
550
-
551
- if (!response.ok) {
552
- setNotice(payload?.error ?? 'Avatar upload failed.');
553
- return;
554
- }
555
-
556
- setAvatarUrl(payload?.avatarUrl ?? null);
557
- setNotice('Avatar updated.');
558
- } finally {
559
- input.value = '';
560
- setIsUploadPending(false);
561
- }
562
- };
563
-
564
- const avatarThumbnail = avatarUrl ? (
565
- <div className="relative size-9 overflow-hidden rounded-full border border-border/70">
566
- <Image
567
- alt={props.displayName}
568
- className="object-cover"
569
- fill
570
- sizes="36px"
571
- src={avatarUrl}
572
- unoptimized
573
- />
574
- </div>
575
- ) : (
576
- <div className="flex size-9 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
577
- {initials}
578
- </div>
579
- );
580
-
581
- const avatarPanel = avatarUrl ? (
582
- <div className="relative size-14 overflow-hidden rounded-2xl border border-border/70">
583
- <Image
584
- alt={props.displayName}
585
- className="object-cover"
586
- fill
587
- sizes="56px"
588
- src={avatarUrl}
589
- unoptimized
590
- />
591
- </div>
592
- ) : (
593
- <div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-lg font-semibold text-primary-foreground">
594
- {initials}
595
- </div>
596
- );
597
-
598
- return (
599
- <details className="group relative">
600
- <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">
601
- {avatarThumbnail}
602
- <div className="hidden text-left sm:block">
603
- <p className="text-sm font-medium leading-none">{props.displayName}</p>
604
- <p className="mt-1 text-xs text-muted-foreground">{props.subscriptionTier} plan</p>
605
- </div>
606
- <ChevronDown className="size-4 text-muted-foreground transition group-open:rotate-180" />
607
- </summary>
608
-
609
- <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">
610
- <div className="flex items-center gap-4 border-b border-border/70 pb-4">
611
- {avatarPanel}
612
- <div className="min-w-0">
613
- <p className="truncate text-base font-semibold">{props.displayName}</p>
614
- <p className="truncate text-sm text-muted-foreground">{props.email}</p>
615
- </div>
616
- </div>
617
-
618
- <div className="mt-4 grid gap-3 sm:grid-cols-2">
619
- <div className="rounded-2xl border border-border/70 bg-muted/40 p-4">
620
- <div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-muted-foreground">
621
- <CreditCard className="size-3.5" />
622
- Subscription
623
- </div>
624
- <p className="mt-3 text-lg font-semibold capitalize">{props.subscriptionTier}</p>
625
- <p className="text-sm capitalize text-muted-foreground">{props.subscriptionStatus.replaceAll('_', ' ')}</p>
626
- </div>
627
-
628
- <div className="rounded-2xl border border-border/70 bg-muted/40 p-4">
629
- <div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-muted-foreground">
630
- <Coins className="size-3.5" />
631
- Credits
632
- </div>
633
- <p className="mt-3 text-lg font-semibold">{props.creditsBalance}</p>
634
- <p className="text-sm text-muted-foreground">Starter balance seeded in the schema.</p>
635
- </div>
636
- </div>
637
-
638
- <input
639
- ref={fileInputRef}
640
- accept="image/png,image/jpeg,image/webp,image/gif"
641
- className="hidden"
642
- onChange={(event) => {
643
- void handleUpload(event);
644
- }}
645
- type="file"
646
- />
647
-
648
- <div className="mt-4 grid gap-2">
649
- <Button disabled={isPortalPending} onClick={() => void handleManageSubscription()} type="button" variant="outline">
650
- {isPortalPending ? <Loader2 className="size-4 animate-spin" /> : <CreditCard className="size-4" />}
651
- Manage subscription
652
- </Button>
653
-
654
- <Button disabled={isUploadPending} onClick={() => fileInputRef.current?.click()} type="button" variant="outline">
655
- {isUploadPending ? <Loader2 className="size-4 animate-spin" /> : <Camera className="size-4" />}
656
- Upload avatar to R2
657
- </Button>
658
-
659
- <Button disabled={isSignOutPending} onClick={() => void handleSignOut()} type="button" variant="ghost">
660
- {isSignOutPending ? <Loader2 className="size-4 animate-spin" /> : <LogOut className="size-4" />}
661
- Sign out
662
- </Button>
663
- </div>
664
-
665
- {notice ? (
666
- <p className="mt-3 rounded-2xl border border-border/70 bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
667
- {notice}
668
- </p>
669
- ) : null}
670
- </div>
671
- </details>
672
- );
673
- }
674
- `);
675
- writeFile(path.join(root, 'app/api/health/route.ts'), `export async function GET() {
676
- return Response.json({ ok: true });
677
- }
678
- `);
679
- writeFile(path.join(root, 'app/api/account/avatar/route.ts'), `import { auth, currentUser } from '@clerk/nextjs/server';
680
- import { getClerkProfile } from '@/lib/auth/clerk';
681
- import { createUploadRecord, findUserByClerkId, syncUserProfile, updateAvatarForUser } from '@/lib/db/queries';
682
- import { createSignedAssetUrl, uploadUserAsset } from '@/lib/storage/r2';
683
-
684
- export const runtime = 'nodejs';
685
-
686
- function sanitizeFileName(fileName: string) {
687
- return fileName
688
- .toLowerCase()
689
- .replace(/[^a-z0-9.-]+/g, '-')
690
- .replace(/^-+|-+$/g, '');
691
- }
692
-
693
- export async function GET() {
694
- const { userId } = await auth();
695
-
696
- if (!userId) {
697
- return Response.json({ error: 'Unauthorized' }, { status: 401 });
698
- }
699
-
700
- const user = await findUserByClerkId(userId);
701
- if (!user?.avatarObjectKey) {
702
- return Response.json({ error: 'Avatar not found' }, { status: 404 });
703
- }
704
-
705
- const signedUrl = await createSignedAssetUrl(user.avatarObjectKey);
706
- return Response.redirect(signedUrl, 307);
707
- }
708
-
709
- export async function POST(request: Request) {
710
- const { userId } = await auth();
711
- if (!userId) {
712
- return Response.json({ error: 'Unauthorized' }, { status: 401 });
713
- }
714
-
715
- const clerkUser = await currentUser();
716
- const { email, displayName } = getClerkProfile(clerkUser);
717
- if (!email) {
718
- return Response.json({ error: 'No email address found for the signed-in user.' }, { status: 400 });
719
- }
720
-
721
- await syncUserProfile({
722
- clerkUserId: userId,
723
- email,
724
- displayName,
725
- });
726
-
727
- const formData = await request.formData();
728
- const file = formData.get('file');
729
-
730
- if (!(file instanceof File)) {
731
- return Response.json({ error: 'Select an image before uploading.' }, { status: 400 });
732
- }
733
-
734
- if (!file.type.startsWith('image/')) {
735
- return Response.json({ error: 'Only image uploads are supported.' }, { status: 400 });
736
- }
737
-
738
- if (file.size > 5 * 1024 * 1024) {
739
- return Response.json({ error: 'Avatar uploads are capped at 5MB.' }, { status: 400 });
740
- }
741
-
742
- const safeName = sanitizeFileName(file.name || 'avatar');
743
- const objectKey = \`avatars/\${userId}/\${Date.now()}-\${safeName || 'avatar'}\`;
744
- const body = Buffer.from(await file.arrayBuffer());
745
- const upload = await uploadUserAsset({
746
- objectKey,
747
- body,
748
- contentType: file.type || null,
749
- });
750
-
751
- await updateAvatarForUser(userId, {
752
- objectKey,
753
- contentType: file.type || null,
754
- });
755
-
756
- await createUploadRecord(userId, {
757
- objectKey,
758
- bucket: upload.bucket,
759
- contentType: file.type || null,
760
- });
761
-
762
- return Response.json({
763
- ok: true,
764
- avatarUrl: \`/api/account/avatar?v=\${Date.now()}\`,
765
- });
766
- }
767
- `);
768
- writeFile(path.join(root, 'app/api/stripe/checkout/route.ts'), `import { auth, currentUser } from '@clerk/nextjs/server';
769
- import { getClerkProfile } from '@/lib/auth/clerk';
770
- import { syncUserProfile, updateStripeCustomerForUser } from '@/lib/db/queries';
771
- import { getStripe, requireStarterPriceId } from '@/lib/payments/stripe';
772
-
773
- export const runtime = 'nodejs';
774
-
775
- export async function POST(request: Request) {
776
- const { userId } = await auth();
777
-
778
- if (!userId) {
779
- return Response.json({ error: 'Unauthorized' }, { status: 401 });
780
- }
781
-
782
- const clerkUser = await currentUser();
783
- const { email, displayName } = getClerkProfile(clerkUser);
784
- if (!email) {
785
- return Response.json({ error: 'No email address found for the signed-in user.' }, { status: 400 });
786
- }
787
-
788
- await syncUserProfile({
789
- clerkUserId: userId,
790
- email,
791
- displayName,
792
- });
793
-
794
- const origin = new URL(request.url).origin;
795
- const stripe = getStripe();
796
- const session = await stripe.checkout.sessions.create({
797
- mode: 'subscription',
798
- line_items: [
799
- {
800
- price: requireStarterPriceId(),
801
- quantity: 1,
802
- },
803
- ],
804
- allow_promotion_codes: true,
805
- customer_email: email,
806
- client_reference_id: userId,
807
- success_url: \`\${origin}/dashboard?checkout=success\`,
808
- cancel_url: \`\${origin}/dashboard?checkout=cancelled\`,
809
- metadata: {
810
- clerkUserId: userId,
811
- },
812
- });
813
-
814
- if (typeof session.customer === 'string' && session.customer.length > 0) {
815
- await updateStripeCustomerForUser(userId, session.customer);
816
- }
817
-
818
- return Response.json({ url: session.url });
819
- }
820
- `);
821
- writeFile(path.join(root, 'app/api/stripe/portal/route.ts'), `import { auth } from '@clerk/nextjs/server';
822
- import { findUserByClerkId } from '@/lib/db/queries';
823
- import { getStripe } from '@/lib/payments/stripe';
824
-
825
- export const runtime = 'nodejs';
826
-
827
- export async function POST(request: Request) {
828
- const { userId } = await auth();
829
-
830
- if (!userId) {
831
- return Response.json({ error: 'Unauthorized' }, { status: 401 });
832
- }
833
-
834
- const user = await findUserByClerkId(userId);
835
-
836
- if (!user?.stripeCustomerId) {
837
- return Response.json({
838
- error: 'No Stripe customer is attached yet. Run the starter checkout flow once to seed billing.',
839
- }, { status: 400 });
840
- }
841
-
842
- const origin = new URL(request.url).origin;
843
- const stripe = getStripe();
844
- const session = await stripe.billingPortal.sessions.create({
845
- customer: user.stripeCustomerId,
846
- return_url: \`\${origin}/dashboard\`,
847
- });
848
-
849
- return Response.json({ url: session.url });
850
- }
851
- `);
852
- writeFile(path.join(root, 'app/dashboard/page.tsx'), `import Link from 'next/link';
853
- import { auth, currentUser } from '@clerk/nextjs/server';
854
- import { redirect } from 'next/navigation';
855
- import { ArrowRight, CreditCard, Database, HardDriveUpload } from 'lucide-react';
856
- import { ThemeToggle } from '@/components/theme-toggle';
857
- import { HeaderUserMenu } from '@/components/dashboard/header-user-menu';
858
- import { getClerkProfile } from '@/lib/auth/clerk';
859
- import { getDashboardState } from '@/lib/app/dashboard';
860
- import { buttonVariants } from '@/lib/ui/button';
861
- import { cn } from '@/lib/utils';
862
-
863
- export default async function DashboardPage() {
864
- const { userId } = await auth();
865
-
866
- if (!userId) {
867
- redirect('/sign-in');
868
- }
869
-
870
- const user = await currentUser();
871
- const profile = getClerkProfile(user);
872
- const email = profile.email ?? 'builder@example.com';
873
- const displayName = profile.displayName;
874
- const dashboard = await getDashboardState({
875
- clerkUserId: userId,
876
- email,
877
- displayName,
878
- });
879
-
880
- return (
881
- <main className="min-h-screen bg-background">
882
- <header className="border-b">
883
- <div className="container mx-auto flex w-full max-w-5xl flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
884
- <div className="flex items-center gap-3">
885
- <img alt="Anhedral logo" className="size-7" src="/anhedral.svg" />
886
- <div>
887
- <p className="text-base font-semibold tracking-tight">Anhedral</p>
888
- <p className="text-sm text-muted-foreground">Dashboard</p>
889
- </div>
890
- </div>
891
- <div className="flex items-center gap-3">
892
- <ThemeToggle />
893
- <HeaderUserMenu
894
- avatarUrl={dashboard.avatarUrl}
895
- creditsBalance={dashboard.creditsBalance}
896
- displayName={dashboard.displayName}
897
- email={dashboard.email}
898
- subscriptionStatus={dashboard.subscriptionStatus}
899
- subscriptionTier={dashboard.subscriptionTier}
900
- />
901
- </div>
902
- </div>
903
- </header>
904
-
905
- <div className="container mx-auto max-w-5xl px-4 py-8 sm:px-6">
906
- <section className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,0.9fr)]">
907
- <div className="min-w-0 space-y-6">
908
- <section className="min-w-0 rounded-lg border bg-card p-6 text-card-foreground">
909
- <h2 className="text-3xl font-semibold tracking-tight">
910
- Dashboard scaffold
911
- </h2>
912
- <p className="mt-3 max-w-2xl text-sm text-muted-foreground sm:text-base">
913
- The scaffold already wires a protected dashboard, a subscription surface, seeded credits, and R2-backed avatar upload.
914
- Your team can start inside the app shell instead of building this plumbing from scratch.
915
- </p>
916
-
917
- <div className="mt-6 flex flex-wrap gap-3">
918
- <form action="/api/stripe/checkout" method="post">
919
- <button className={cn(buttonVariants())} type="submit">
920
- Start starter checkout
921
- <ArrowRight className="ml-2 size-4" />
922
- </button>
923
- </form>
924
-
925
- <Link className={cn(buttonVariants({ variant: 'outline' }))} href="/">
926
- View landing page
927
- </Link>
928
- </div>
929
- </section>
930
-
931
- <div className="min-w-0 grid gap-4 md:grid-cols-3">
932
- <article className="min-w-0 rounded-lg border bg-card p-5 text-card-foreground">
933
- <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
934
- <CreditCard className="size-3.5" />
935
- Billing
936
- </div>
937
- <p className="mt-4 text-2xl font-semibold capitalize">{dashboard.subscriptionStatus.replaceAll('_', ' ')}</p>
938
- <p className="mt-2 text-sm text-muted-foreground">
939
- Stripe Billing uses Checkout for subscription start and the customer portal for self-serve management.
940
- </p>
941
- </article>
942
-
943
- <article className="min-w-0 rounded-lg border bg-card p-5 text-card-foreground">
944
- <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
945
- <Database className="size-3.5" />
946
- Credits
947
- </div>
948
- <p className="mt-4 text-2xl font-semibold">{dashboard.creditsBalance}</p>
949
- <p className="mt-2 text-sm text-muted-foreground">
950
- Seeded in Drizzle so you can attach real entitlement logic without redesigning the menu.
951
- </p>
952
- </article>
953
-
954
- <article className="min-w-0 rounded-lg border bg-card p-5 text-card-foreground">
955
- <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
956
- <HardDriveUpload className="size-3.5" />
957
- Storage
958
- </div>
959
- <p className="mt-4 text-2xl font-semibold">R2 avatar flow</p>
960
- <p className="mt-2 text-sm text-muted-foreground">
961
- The header menu uploads profile images into R2 and serves them back through a signed route.
962
- </p>
963
- </article>
964
- </div>
965
- </div>
966
-
967
- <aside className="min-w-0 space-y-4 rounded-lg border bg-card p-6 text-card-foreground">
968
- <div>
969
- <p className="text-sm text-muted-foreground">Template notes</p>
970
- <h3 className="mt-2 text-2xl font-semibold tracking-tight">Replace these first</h3>
971
- </div>
972
-
973
- <ul className="space-y-3 text-sm text-muted-foreground">
974
- <li className="rounded-md border bg-muted/50 px-4 py-3">
975
- Swap the starter Stripe price id with your real recurring product and add webhook handlers.
976
- </li>
977
- <li className="rounded-md border bg-muted/50 px-4 py-3">
978
- Replace the seeded credits model with your real usage accounting rules.
979
- </li>
980
- <li className="rounded-md border bg-muted/50 px-4 py-3">
981
- Expand the dashboard once your primary signed-in workflow is defined.
982
- </li>
983
- </ul>
984
-
985
- {!dashboard.databaseReady ? (
986
- <div className="rounded-md border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
987
- 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.
988
- </div>
989
- ) : null}
990
- </aside>
991
- </section>
992
- </div>
993
- </main>
994
- );
995
- }
996
- `);
997
- writeFile(path.join(root, 'components/auth/sign-in-panel.tsx'), `'use client';
998
-
999
- import Link from 'next/link';
1000
- import { useRouter } from 'next/navigation';
1001
- import * as React from 'react';
1002
- import { useSignIn } from '@clerk/nextjs';
1003
- import { AlertCircle, ArrowRight, Loader2 } from 'lucide-react';
1004
- import { Button } from '@/components/ui/button';
1005
- import { buttonVariants } from '@/lib/ui/button';
1006
- import { cn } from '@/lib/utils';
1007
-
1008
- type ClerkHookError = {
1009
- longMessage?: string;
1010
- message?: string;
1011
- };
1012
-
1013
- type ClerkHookErrorState = {
1014
- fields?: Record<string, ClerkHookError | undefined>;
1015
- global?: ClerkHookError[] | null;
1016
- raw?: ClerkHookError[] | null;
1017
- };
1018
-
1019
- const inputClassName = 'flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none transition placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50';
1020
- const labelClassName = 'text-sm font-medium';
1021
-
1022
- function getMessage(error?: ClerkHookError | null) {
1023
- return error?.longMessage ?? error?.message ?? null;
1024
- }
1025
-
1026
- function getGlobalError(errors?: ClerkHookErrorState | null, fallback?: string | null) {
1027
- return (
1028
- fallback ??
1029
- getMessage(errors?.global?.[0]) ??
1030
- getMessage(errors?.raw?.[0]) ??
1031
- null
1032
- );
1033
- }
1034
-
1035
- async function finalizeSignIn(
1036
- signIn: Awaited<ReturnType<typeof useSignIn>>['signIn'],
1037
- router: ReturnType<typeof useRouter>,
1038
- ) {
1039
- await signIn.finalize({
1040
- navigate: async ({ decorateUrl }) => {
1041
- const url = decorateUrl('/dashboard');
1042
-
1043
- if (url.startsWith('http')) {
1044
- window.location.href = url;
1045
- } else {
1046
- router.replace(url);
1047
- }
1048
- },
1049
- });
1050
- }
1051
-
1052
- export function SignInPanel() {
1053
- const router = useRouter();
1054
- const { signIn, errors, fetchStatus } = useSignIn();
1055
- const [email, setEmail] = React.useState('');
1056
- const [password, setPassword] = React.useState('');
1057
- const [unexpectedError, setUnexpectedError] = React.useState<string | null>(null);
1058
- const isSubmitting = fetchStatus === 'fetching';
1059
- const typedErrors = errors as unknown as ClerkHookErrorState | null | undefined;
1060
- const emailError = getMessage(typedErrors?.fields?.identifier);
1061
- const passwordError = getMessage(typedErrors?.fields?.password);
1062
- const globalError = getGlobalError(typedErrors, unexpectedError);
1063
-
1064
- async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
1065
- event.preventDefault();
1066
- if (isSubmitting) return;
1067
-
1068
- setUnexpectedError(null);
1069
-
1070
- try {
1071
- const { error } = await signIn.password({
1072
- identifier: email.trim(),
1073
- password,
1074
- });
1075
-
1076
- if (error) {
1077
- return;
1078
- }
1079
-
1080
- if (signIn.status === 'complete') {
1081
- await finalizeSignIn(signIn, router);
1082
- return;
1083
- }
1084
-
1085
- setUnexpectedError('This starter currently supports email-and-password sign-in only.');
1086
- } catch (submissionError) {
1087
- setUnexpectedError(submissionError instanceof Error ? submissionError.message : 'Something went wrong. Please try again.');
1088
- }
1089
- }
1090
-
1091
- return (
1092
- <div className="w-full max-w-md rounded-lg border bg-card p-6 text-card-foreground">
1093
- <div className="mb-6">
1094
- <h2 className="text-2xl font-semibold tracking-tight">Sign in</h2>
1095
- <p className="mt-2 text-sm text-muted-foreground">
1096
- This route is already wired to Clerk hooks and redirects to the dashboard after authentication.
1097
- </p>
1098
- </div>
1099
-
1100
- <form className="space-y-4" onSubmit={onSubmit}>
1101
- <div className="space-y-2">
1102
- <label className={labelClassName} htmlFor="email">Email</label>
1103
- <input
1104
- autoComplete="email"
1105
- className={inputClassName}
1106
- id="email"
1107
- onChange={(event) => setEmail(event.target.value)}
1108
- placeholder="you@example.com"
1109
- type="email"
1110
- value={email}
1111
- />
1112
- {emailError ? <p className="text-sm text-destructive">{emailError}</p> : null}
1113
- </div>
1114
-
1115
- <div className="space-y-2">
1116
- <div className="flex items-center justify-between gap-3">
1117
- <label className={labelClassName} htmlFor="password">Password</label>
1118
- <span className="text-xs text-muted-foreground">Email + password</span>
1119
- </div>
1120
- <input
1121
- autoComplete="current-password"
1122
- className={inputClassName}
1123
- id="password"
1124
- onChange={(event) => setPassword(event.target.value)}
1125
- placeholder="Enter your password"
1126
- type="password"
1127
- value={password}
1128
- />
1129
- {passwordError ? <p className="text-sm text-destructive">{passwordError}</p> : null}
1130
- </div>
1131
-
1132
- {globalError ? (
1133
- <div className="flex items-start gap-3 rounded-md border border-destructive/25 bg-destructive/10 px-4 py-3 text-sm text-destructive" role="alert">
1134
- <AlertCircle className="mt-0.5 size-4 shrink-0" />
1135
- <p>{globalError}</p>
1136
- </div>
1137
- ) : null}
1138
-
1139
- <Button className="w-full" disabled={isSubmitting} type="submit">
1140
- {isSubmitting ? <Loader2 className="size-4 animate-spin" /> : <ArrowRight className="size-4" />}
1141
- Continue to dashboard
1142
- </Button>
1143
- </form>
1144
-
1145
- <p className="mt-5 text-center text-sm text-muted-foreground">
1146
- Need an account?{' '}
1147
- <Link className={cn(buttonVariants({ variant: 'link', size: 'sm' }), 'h-auto px-0 align-baseline')} href="/sign-up">
1148
- Create one
1149
- </Link>
1150
- </p>
1151
- </div>
1152
- );
1153
- }
1154
- `);
1155
- writeFile(path.join(root, 'components/auth/sign-up-panel.tsx'), `'use client';
1156
-
1157
- import Link from 'next/link';
1158
- import { useRouter } from 'next/navigation';
1159
- import * as React from 'react';
1160
- import { useSignUp } from '@clerk/nextjs';
1161
- import { AlertCircle, ArrowRight, BadgeCheck, Loader2, MailCheck, RotateCcw } from 'lucide-react';
1162
- import { Button } from '@/components/ui/button';
1163
- import { buttonVariants } from '@/lib/ui/button';
1164
- import { cn } from '@/lib/utils';
1165
-
1166
- type ClerkHookError = {
1167
- longMessage?: string;
1168
- message?: string;
1169
- };
1170
-
1171
- type ClerkHookErrorState = {
1172
- fields?: Record<string, ClerkHookError | undefined>;
1173
- global?: ClerkHookError[] | null;
1174
- raw?: ClerkHookError[] | null;
1175
- };
1176
-
1177
- const inputClassName = 'flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none transition placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50';
1178
- const labelClassName = 'text-sm font-medium';
1179
-
1180
- function getMessage(error?: ClerkHookError | null) {
1181
- return error?.longMessage ?? error?.message ?? null;
1182
- }
1183
-
1184
- function getGlobalError(errors?: ClerkHookErrorState | null, fallback?: string | null) {
1185
- return (
1186
- fallback ??
1187
- getMessage(errors?.global?.[0]) ??
1188
- getMessage(errors?.raw?.[0]) ??
1189
- null
1190
- );
1191
- }
1192
-
1193
- async function finalizeSignUp(
1194
- signUp: Awaited<ReturnType<typeof useSignUp>>['signUp'],
1195
- router: ReturnType<typeof useRouter>,
1196
- ) {
1197
- await signUp.finalize({
1198
- navigate: async ({ decorateUrl }) => {
1199
- const url = decorateUrl('/dashboard');
1200
-
1201
- if (url.startsWith('http')) {
1202
- window.location.href = url;
1203
- } else {
1204
- router.replace(url);
1205
- }
1206
- },
1207
- });
1208
- }
1209
-
1210
- export function SignUpPanel() {
1211
- const router = useRouter();
1212
- const { signUp, errors, fetchStatus } = useSignUp();
1213
- const [step, setStep] = React.useState<'details' | 'verify'>('details');
1214
- const [email, setEmail] = React.useState('');
1215
- const [password, setPassword] = React.useState('');
1216
- const [verificationCode, setVerificationCode] = React.useState('');
1217
- const [unexpectedError, setUnexpectedError] = React.useState<string | null>(null);
1218
- const isSubmitting = fetchStatus === 'fetching';
1219
- const typedErrors = errors as unknown as ClerkHookErrorState | null | undefined;
1220
- const emailError = getMessage(typedErrors?.fields?.emailAddress);
1221
- const passwordError = getMessage(typedErrors?.fields?.password);
1222
- const codeError = getMessage(typedErrors?.fields?.code);
1223
- const globalError = getGlobalError(typedErrors, unexpectedError);
1224
-
1225
- async function onCreateAccount(event: React.FormEvent<HTMLFormElement>) {
1226
- event.preventDefault();
1227
- if (isSubmitting) return;
1228
-
1229
- setUnexpectedError(null);
1230
-
1231
- try {
1232
- const { error } = await signUp.password({
1233
- emailAddress: email.trim(),
1234
- password,
1235
- });
1236
-
1237
- if (error) {
1238
- return;
1239
- }
1240
-
1241
- if (signUp.status === 'complete') {
1242
- await finalizeSignUp(signUp, router);
1243
- return;
1244
- }
1245
-
1246
- const { error: sendEmailError } = await signUp.verifications.sendEmailCode();
1247
- if (sendEmailError) {
1248
- return;
1249
- }
1250
-
1251
- setStep('verify');
1252
- setVerificationCode('');
1253
- } catch (submissionError) {
1254
- setUnexpectedError(submissionError instanceof Error ? submissionError.message : 'Something went wrong. Please try again.');
1255
- }
1256
- }
1257
-
1258
- async function onVerify(event: React.FormEvent<HTMLFormElement>) {
1259
- event.preventDefault();
1260
- if (isSubmitting) return;
1261
-
1262
- setUnexpectedError(null);
1263
-
1264
- try {
1265
- const { error } = await signUp.verifications.verifyEmailCode({
1266
- code: verificationCode.trim(),
1267
- });
1268
-
1269
- if (error) {
1270
- return;
1271
- }
1272
-
1273
- if (signUp.status === 'complete') {
1274
- await finalizeSignUp(signUp, router);
1275
- return;
1276
- }
1277
-
1278
- setUnexpectedError('Verification is still incomplete. Request a fresh code and try again.');
1279
- } catch (verificationError) {
1280
- setUnexpectedError(verificationError instanceof Error ? verificationError.message : 'Something went wrong. Please try again.');
1281
- }
1282
- }
1283
-
1284
- async function onResendCode() {
1285
- if (isSubmitting) return;
1286
-
1287
- setUnexpectedError(null);
1288
-
1289
- try {
1290
- const { error } = await signUp.verifications.sendEmailCode();
1291
- if (error) {
1292
- return;
1293
- }
1294
- } catch (resendError) {
1295
- setUnexpectedError(resendError instanceof Error ? resendError.message : 'Unable to send a new code right now.');
1296
- }
1297
- }
1298
-
1299
- return (
1300
- <div className="w-full max-w-md rounded-lg border bg-card p-6 text-card-foreground">
1301
- <div className="mb-6">
1302
- <h2 className="mt-3 text-3xl font-semibold tracking-tight">
1303
- {step === 'details' ? 'Create your account' : 'Verify your email'}
1304
- </h2>
1305
- <p className="mt-2 text-sm leading-6 text-muted-foreground">
1306
- {step === 'details'
1307
- ? 'This route is already wired to Clerk hooks and redirects to the dashboard after signup.'
1308
- : \`Enter the verification code Clerk sent to \${email.trim() || 'your inbox'} to finish account creation.\`}
1309
- </p>
1310
- </div>
1311
-
1312
- {step === 'details' ? (
1313
- <form className="space-y-4" onSubmit={onCreateAccount}>
1314
- <div className="space-y-2">
1315
- <label className={labelClassName} htmlFor="sign-up-email">Email</label>
1316
- <input
1317
- autoComplete="email"
1318
- className={inputClassName}
1319
- id="sign-up-email"
1320
- onChange={(event) => setEmail(event.target.value)}
1321
- placeholder="founder@company.com"
1322
- type="email"
1323
- value={email}
1324
- />
1325
- {emailError ? <p className="text-sm text-destructive">{emailError}</p> : null}
1326
- </div>
1327
-
1328
- <div className="space-y-2">
1329
- <label className={labelClassName} htmlFor="sign-up-password">Password</label>
1330
- <input
1331
- autoComplete="new-password"
1332
- className={inputClassName}
1333
- id="sign-up-password"
1334
- onChange={(event) => setPassword(event.target.value)}
1335
- placeholder="Choose a strong password"
1336
- type="password"
1337
- value={password}
1338
- />
1339
- {passwordError ? <p className="text-sm text-destructive">{passwordError}</p> : null}
1340
- </div>
1341
-
1342
- {globalError ? (
1343
- <div className="flex items-start gap-3 rounded-md border border-destructive/25 bg-destructive/10 px-4 py-3 text-sm text-destructive" role="alert">
1344
- <AlertCircle className="mt-0.5 size-4 shrink-0" />
1345
- <p>{globalError}</p>
1346
- </div>
1347
- ) : null}
1348
-
1349
- <div id="clerk-captcha" />
1350
-
1351
- <Button className="w-full" disabled={isSubmitting} type="submit">
1352
- {isSubmitting ? <Loader2 className="size-4 animate-spin" /> : <ArrowRight className="size-4" />}
1353
- Create account
1354
- </Button>
1355
- </form>
1356
- ) : (
1357
- <form className="space-y-4" onSubmit={onVerify}>
1358
- <div className="rounded-md border bg-muted/50 p-4 text-sm text-muted-foreground">
1359
- <div className="flex items-center gap-2 font-medium text-foreground">
1360
- <MailCheck className="size-4" />
1361
- Email verification
1362
- </div>
1363
- <p className="mt-2">
1364
- Clerk handles the verification challenge while this route stays inside your app.
1365
- </p>
1366
- </div>
1367
-
1368
- <div className="space-y-2">
1369
- <label className={labelClassName} htmlFor="verification-code">Verification code</label>
1370
- <input
1371
- className={inputClassName}
1372
- id="verification-code"
1373
- inputMode="numeric"
1374
- onChange={(event) => setVerificationCode(event.target.value)}
1375
- placeholder="123456"
1376
- type="text"
1377
- value={verificationCode}
1378
- />
1379
- {codeError ? <p className="text-sm text-destructive">{codeError}</p> : null}
1380
- </div>
1381
-
1382
- {globalError ? (
1383
- <div className="flex items-start gap-3 rounded-md border border-destructive/25 bg-destructive/10 px-4 py-3 text-sm text-destructive" role="alert">
1384
- <AlertCircle className="mt-0.5 size-4 shrink-0" />
1385
- <p>{globalError}</p>
1386
- </div>
1387
- ) : null}
1388
-
1389
- <div className="grid gap-3 md:grid-cols-3">
1390
- <Button className="md:col-span-2" disabled={isSubmitting} type="submit">
1391
- {isSubmitting ? <Loader2 className="size-4 animate-spin" /> : <BadgeCheck className="size-4" />}
1392
- Verify and continue
1393
- </Button>
1394
-
1395
- <Button
1396
- disabled={isSubmitting}
1397
- onClick={onResendCode}
1398
- type="button"
1399
- variant="secondary"
1400
- >
1401
- <RotateCcw className="size-4" />
1402
- Resend code
1403
- </Button>
1404
-
1405
- <Button
1406
- className="md:col-span-3"
1407
- disabled={isSubmitting}
1408
- onClick={() => {
1409
- setStep('details');
1410
- setVerificationCode('');
1411
- setUnexpectedError(null);
1412
- }}
1413
- type="button"
1414
- variant="outline"
1415
- >
1416
- Use another email
1417
- </Button>
1418
- </div>
1419
- </form>
1420
- )}
1421
-
1422
- <p className="mt-5 text-center text-sm text-muted-foreground">
1423
- Already have an account?{' '}
1424
- <Link className={cn(buttonVariants({ variant: 'link', size: 'sm' }), 'h-auto px-0 align-baseline')} href="/sign-in">
1425
- Sign in
1426
- </Link>
1427
- </p>
1428
- </div>
1429
- );
1430
- }
1431
- `);
1432
- writeFile(path.join(root, 'components/theme-toggle.tsx'), `'use client';
1433
-
1434
- import * as React from 'react';
1435
- import { Moon, Sun } from 'lucide-react';
1436
- import { useTheme } from 'next-themes';
1437
- import { Button } from '@/components/ui/button';
1438
-
1439
- export function ThemeToggle() {
1440
- const { resolvedTheme, setTheme } = useTheme();
1441
- const [mounted, setMounted] = React.useState(false);
1442
-
1443
- React.useEffect(() => {
1444
- setMounted(true);
1445
- }, []);
1446
-
1447
- const isDark = mounted && resolvedTheme === 'dark';
1448
-
1449
- return (
1450
- <Button
1451
- aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
1452
- onClick={() => setTheme(isDark ? 'light' : 'dark')}
1453
- size="icon"
1454
- type="button"
1455
- variant="outline"
1456
- >
1457
- {isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
1458
- </Button>
1459
- );
1460
- }
1461
- `);
1462
- writeFile(path.join(root, 'app/layout.tsx'), `import { ClerkProvider } from '@clerk/nextjs';
1463
- import type { Metadata } from 'next';
1464
- import './globals.css';
1465
- import { ThemeProvider } from '@/components/theme-provider';
1466
- import { clerkEnvReady } from '@/lib/demo-env';
1467
-
1468
- export const metadata: Metadata = {
1469
- title: 'Anhedral',
1470
- description: 'Anhedral starter with Clerk auth, Stripe billing, Neon + Drizzle, and R2 avatars.',
1471
- icons: {
1472
- icon: '/favicon.ico',
1473
- shortcut: '/favicon.ico',
1474
- apple: '/favicon.ico',
1475
- },
1476
- };
1477
-
1478
- export default function RootLayout({
1479
- children,
1480
- }: Readonly<{
1481
- children: React.ReactNode;
1482
- }>) {
1483
- const content = (
1484
- <html
1485
- lang="en"
1486
- suppressHydrationWarning
1487
- className="antialiased font-sans"
1488
- >
1489
- <body>
1490
- <ThemeProvider>
1491
- {children}
1492
- </ThemeProvider>
1493
- </body>
1494
- </html>
1495
- );
1496
-
1497
- if (!clerkEnvReady) {
1498
- return content;
1499
- }
1500
-
1501
- return (
1502
- <ClerkProvider>
1503
- {content}
1504
- </ClerkProvider>
1505
- );
1506
- }
1507
- `);
1508
- writeFile(path.join(root, 'lib/demo-env.ts'), `const REQUIRED_ENV_GROUPS = [
1509
- {
1510
- title: 'App',
1511
- fields: ['NEXT_PUBLIC_APP_URL'],
1512
- },
1513
- {
1514
- title: 'Clerk',
1515
- fields: [
1516
- 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY',
1517
- 'CLERK_SECRET_KEY',
1518
- 'NEXT_PUBLIC_CLERK_SIGN_IN_URL',
1519
- 'NEXT_PUBLIC_CLERK_SIGN_UP_URL',
1520
- 'NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL',
1521
- 'NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL',
1522
- ],
1523
- },
1524
- {
1525
- title: 'Neon + Drizzle',
1526
- fields: ['DATABASE_URL'],
1527
- },
1528
- {
1529
- title: 'Cloudflare R2',
1530
- fields: ['R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET'],
1531
- },
1532
- {
1533
- title: 'Stripe',
1534
- fields: ['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET', 'STRIPE_PRICE_STARTER'],
1535
- },
1536
- ] as const;
1537
-
1538
- function hasRealValue(value: string | undefined) {
1539
- if (!value) {
1540
- return false;
1541
- }
1542
-
1543
- return !value.includes('***') && !value.includes('demo_placeholder');
1544
- }
1545
-
1546
- function isValidPublishableKey(value: string | undefined) {
1547
- if (!hasRealValue(value)) {
1548
- return false;
1549
- }
1550
-
1551
- return /^pk_(test|live)_[A-Za-z0-9]+$/.test(value ?? '');
1552
- }
1553
-
1554
- function isValidSecretKey(value: string | undefined) {
1555
- if (!hasRealValue(value)) {
1556
- return false;
1557
- }
1558
-
1559
- return /^sk_(test|live)_[A-Za-z0-9]+$/.test(value ?? '');
1560
- }
1561
-
1562
- export const clerkEnvReady =
1563
- isValidPublishableKey(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) &&
1564
- isValidSecretKey(process.env.CLERK_SECRET_KEY);
1565
-
1566
- export const requiredEnvGroups = REQUIRED_ENV_GROUPS;
1567
- `);
1568
- writeFile(path.join(root, 'app/page.tsx'), `import { auth } from '@clerk/nextjs/server';
1569
- import { AlertCircle, ArrowRight, Cloud, CreditCard, Database, ShieldCheck } from 'lucide-react';
1570
- import Link from 'next/link';
1571
- import { ThemeToggle } from '@/components/theme-toggle';
1572
- import { clerkEnvReady, requiredEnvGroups } from '@/lib/demo-env';
1573
- import { buttonVariants } from '@/lib/ui/button';
1574
- import { cn } from '@/lib/utils';
1575
-
1576
- // Edit the content in this file to turn the starter into your own app.
1577
- const homePageContent = {
1578
- hero: {
1579
- title: 'A simple starter, ready to customize.',
1580
- description:
1581
- 'This template keeps auth, billing, data, and storage wired up without adding extra visual noise. Replace the placeholder sections with your product-specific content and workflows.',
1582
- primaryLabel: 'Create your first account',
1583
- signedInPrimaryLabel: 'Go to dashboard',
1584
- secondaryLabel: 'Open sign-in',
1585
- },
1586
- setup: {
1587
- title: 'Add your env vars',
1588
- description:
1589
- 'Copy .env.example to .env, replace every placeholder below, then restart the dev server.',
1590
- steps: [
1591
- 'Copy .env.example to .env.',
1592
- 'Fill in every App, Clerk, database, storage, and Stripe value listed below.',
1593
- 'Restart pnpm dev after saving the file.',
1594
- ],
1595
- },
1596
- sections: [
1597
- {
1598
- title: 'Landing content',
1599
- description:
1600
- 'Replace this placeholder with your headline, screenshots, pricing, and any product proof you want on the homepage.',
1601
- href: '#hero',
1602
- linkLabel: 'Jump to hero copy',
1603
- icon: Cloud,
1604
- },
1605
- {
1606
- title: 'Authentication',
1607
- description:
1608
- 'Sign-in and sign-up routes are already mounted, so you can focus on your app-specific onboarding and copy.',
1609
- href: '/sign-in',
1610
- linkLabel: 'Open sign-in',
1611
- icon: ShieldCheck,
1612
- },
1613
- {
1614
- title: 'Billing',
1615
- description:
1616
- 'Checkout, subscription state, and starter credit surfaces are already scaffolded into the signed-in experience.',
1617
- href: '/dashboard',
1618
- linkLabel: 'Open billing shell',
1619
- icon: CreditCard,
1620
- },
1621
- {
1622
- title: 'Data and storage',
1623
- description:
1624
- 'Neon, Drizzle, and R2 hooks are in place for account data, uploads, and the rest of your product-specific models.',
1625
- href: '/dashboard',
1626
- linkLabel: 'Open app shell',
1627
- icon: Database,
1628
- },
1629
- ],
1630
- } as const;
1631
-
1632
- export default async function HomePage() {
1633
- const { userId } = clerkEnvReady ? await auth() : { userId: null };
1634
-
1635
- return (
1636
- <main className="min-h-screen bg-background">
1637
- <header className="border-b">
1638
- <div className="container mx-auto flex w-full max-w-5xl flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
1639
- <div className="flex items-center gap-3">
1640
- <img alt="Anhedral logo" className="size-7" src="/anhedral.svg" />
1641
- <p className="text-base font-semibold tracking-tight">Anhedral</p>
1642
- </div>
1643
-
1644
- <div className="flex items-center gap-2 sm:gap-3">
1645
- <ThemeToggle />
1646
- {clerkEnvReady ? (
1647
- <>
1648
- <Link className={cn(buttonVariants({ variant: 'ghost' }))} href="/sign-in">
1649
- Sign in
1650
- </Link>
1651
- <Link className={cn(buttonVariants())} href={userId ? '/dashboard' : '/sign-up'}>
1652
- {userId ? 'Open dashboard' : 'Start with sign up'}
1653
- </Link>
1654
- </>
1655
- ) : (
1656
- <div className="inline-flex h-8 items-center rounded-md border bg-muted/50 px-3 text-sm text-muted-foreground">
1657
- Setup mode
1658
- </div>
1659
- )}
1660
- </div>
1661
- </div>
1662
- </header>
1663
-
1664
- <div className="container mx-auto max-w-5xl px-4 py-8 sm:px-6">
1665
- <section className="grid gap-6 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
1666
- {clerkEnvReady ? (
1667
- <section id="hero" className="min-w-0 rounded-lg border bg-card p-6 text-card-foreground">
1668
- <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
1669
- {homePageContent.hero.title}
1670
- </h1>
1671
- <p className="mt-3 max-w-2xl text-sm text-muted-foreground sm:text-base">
1672
- {homePageContent.hero.description}
1673
- </p>
1674
-
1675
- <div className="mt-6 flex flex-wrap gap-3">
1676
- <Link className={cn(buttonVariants())} href={userId ? '/dashboard' : '/sign-up'}>
1677
- {userId ? homePageContent.hero.signedInPrimaryLabel : homePageContent.hero.primaryLabel}
1678
- <ArrowRight className="ml-2 size-4" />
1679
- </Link>
1680
- <Link className={cn(buttonVariants({ variant: 'outline' }))} href="/sign-in">
1681
- {homePageContent.hero.secondaryLabel}
1682
- </Link>
1683
- </div>
1684
- </section>
1685
- ) : (
1686
- <section className="rounded-lg border bg-card p-6 text-card-foreground sm:p-8">
1687
- <div className="inline-flex items-center gap-2 rounded-md border bg-muted px-3 py-1 text-sm text-muted-foreground">
1688
- <AlertCircle className="size-3.5" />
1689
- Setup mode
1690
- </div>
1691
- <h2 className="mt-4 text-xl font-semibold tracking-tight">{homePageContent.setup.title}</h2>
1692
- <p className="mt-2 text-sm text-muted-foreground">
1693
- {homePageContent.setup.description}
1694
- </p>
1695
-
1696
- <div className="mt-4 space-y-4">
1697
- <div className="rounded-md border bg-muted/50 p-5">
1698
- <p className="text-sm font-medium text-muted-foreground">Environment checklist</p>
1699
- <ol className="mt-3 space-y-2 text-sm text-muted-foreground">
1700
- {homePageContent.setup.steps.map((step, index) => (
1701
- <li key={step}>
1702
- {index + 1}. {step}
1703
- </li>
1704
- ))}
1705
- </ol>
1706
- </div>
1707
-
1708
- <div className="space-y-4">
1709
- {requiredEnvGroups.map((group) => (
1710
- <article key={group.title} className="min-w-0 rounded-md border bg-card p-4">
1711
- <h3 className="text-sm font-medium text-muted-foreground">{group.title}</h3>
1712
- <ul className="mt-3 space-y-2 text-foreground/90">
1713
- {group.fields.map((field) => (
1714
- <li key={field} className="min-w-0">
1715
- <code className="block break-all font-mono text-xs">{field}</code>
1716
- </li>
1717
- ))}
1718
- </ul>
1719
- </article>
1720
- ))}
1721
- </div>
1722
- </div>
1723
- </section>
1724
- )}
1725
-
1726
- <div className="min-w-0 grid gap-4">
1727
- {homePageContent.sections.map(({ title, description, href, linkLabel, icon: Icon }) => (
1728
- <article key={title} className="min-w-0 rounded-lg border bg-card p-5 text-card-foreground">
1729
- <div className="flex items-center gap-3">
1730
- <div className="flex size-9 items-center justify-center rounded-md bg-muted text-foreground">
1731
- <Icon className="size-5" />
1732
- </div>
1733
- <h2 className="text-base font-semibold">{title}</h2>
1734
- </div>
1735
- <p className="mt-3 text-sm leading-6 text-muted-foreground">{description}</p>
1736
- <Link className={cn(buttonVariants({ size: 'sm', variant: 'link' }), 'mt-4 h-auto px-0')} href={href}>
1737
- {linkLabel}
1738
- <ArrowRight className="ml-1 size-4" />
1739
- </Link>
1740
- </article>
1741
- ))}
1742
- </div>
1743
- </section>
1744
- </div>
1745
- </main>
1746
- );
1747
- }
1748
- `);
1749
- writeFile(path.join(root, 'app/sign-in/[[...sign-in]]/page.tsx'), `import { SignInPanel } from '@/components/auth/sign-in-panel';
1750
-
1751
- export default function SignInPage() {
1752
- return (
1753
- <main className="min-h-screen bg-background">
1754
- <section className="container mx-auto flex min-h-screen max-w-5xl items-center justify-center px-4 py-8 sm:px-6">
1755
- <SignInPanel />
1756
- </section>
1757
- </main>
1758
- );
1759
- }
1760
- `);
1761
- writeFile(path.join(root, 'app/sign-up/[[...sign-up]]/page.tsx'), `import { SignUpPanel } from '@/components/auth/sign-up-panel';
1762
-
1763
- export default function SignUpPage() {
1764
- return (
1765
- <main className="min-h-screen bg-background">
1766
- <section className="container mx-auto flex min-h-screen max-w-5xl items-center justify-center px-4 py-8 sm:px-6">
1767
- <SignUpPanel />
1768
- </section>
1769
- </main>
1770
- );
1771
- }
1772
- `);
1773
- writeFile(path.join(root, 'app/globals.css'), `@import 'tailwindcss';
1774
- @import 'tw-animate-css';
1775
- @import 'shadcn/tailwind.css';
1776
-
1777
- @custom-variant dark (&:is(.dark *));
1778
-
1779
- @theme inline {
1780
- --font-sans: var(--font-geist-sans);
1781
- --font-mono: var(--font-geist-mono);
1782
- --color-sidebar-ring: var(--sidebar-ring);
1783
- --color-sidebar-border: var(--sidebar-border);
1784
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
1785
- --color-sidebar-accent: var(--sidebar-accent);
1786
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
1787
- --color-sidebar-primary: var(--sidebar-primary);
1788
- --color-sidebar-foreground: var(--sidebar-foreground);
1789
- --color-sidebar: var(--sidebar);
1790
- --color-chart-5: var(--chart-5);
1791
- --color-chart-4: var(--chart-4);
1792
- --color-chart-3: var(--chart-3);
1793
- --color-chart-2: var(--chart-2);
1794
- --color-chart-1: var(--chart-1);
1795
- --color-ring: var(--ring);
1796
- --color-input: var(--input);
1797
- --color-border: var(--border);
1798
- --color-destructive: var(--destructive);
1799
- --color-accent-foreground: var(--accent-foreground);
1800
- --color-accent: var(--accent);
1801
- --color-muted-foreground: var(--muted-foreground);
1802
- --color-muted: var(--muted);
1803
- --color-secondary-foreground: var(--secondary-foreground);
1804
- --color-secondary: var(--secondary);
1805
- --color-primary-foreground: var(--primary-foreground);
1806
- --color-primary: var(--primary);
1807
- --color-popover-foreground: var(--popover-foreground);
1808
- --color-popover: var(--popover);
1809
- --color-card-foreground: var(--card-foreground);
1810
- --color-card: var(--card);
1811
- --color-foreground: var(--foreground);
1812
- --color-background: var(--background);
1813
- --radius-sm: calc(var(--radius) * 0.6);
1814
- --radius-md: calc(var(--radius) * 0.8);
1815
- --radius-lg: var(--radius);
1816
- --radius-xl: calc(var(--radius) * 1.4);
1817
- --radius-2xl: calc(var(--radius) * 1.8);
1818
- --radius-3xl: calc(var(--radius) * 2.2);
1819
- --radius-4xl: calc(var(--radius) * 2.6);
1820
- }
1821
-
1822
- :root {
1823
- --background: oklch(1 0 0);
1824
- --foreground: oklch(0.145 0 0);
1825
- --card: oklch(1 0 0);
1826
- --card-foreground: oklch(0.145 0 0);
1827
- --popover: oklch(1 0 0);
1828
- --popover-foreground: oklch(0.145 0 0);
1829
- --primary: oklch(0.205 0 0);
1830
- --primary-foreground: oklch(0.985 0 0);
1831
- --secondary: oklch(0.97 0 0);
1832
- --secondary-foreground: oklch(0.205 0 0);
1833
- --muted: oklch(0.97 0 0);
1834
- --muted-foreground: oklch(0.556 0 0);
1835
- --accent: oklch(0.97 0 0);
1836
- --accent-foreground: oklch(0.205 0 0);
1837
- --destructive: oklch(0.577 0.245 27.325);
1838
- --border: oklch(0.922 0 0);
1839
- --input: oklch(0.922 0 0);
1840
- --ring: oklch(0.708 0 0);
1841
- --chart-1: oklch(0.87 0 0);
1842
- --chart-2: oklch(0.556 0 0);
1843
- --chart-3: oklch(0.439 0 0);
1844
- --chart-4: oklch(0.371 0 0);
1845
- --chart-5: oklch(0.269 0 0);
1846
- --radius: 0.625rem;
1847
- --sidebar: oklch(0.985 0 0);
1848
- --sidebar-foreground: oklch(0.145 0 0);
1849
- --sidebar-primary: oklch(0.205 0 0);
1850
- --sidebar-primary-foreground: oklch(0.985 0 0);
1851
- --sidebar-accent: oklch(0.97 0 0);
1852
- --sidebar-accent-foreground: oklch(0.205 0 0);
1853
- --sidebar-border: oklch(0.922 0 0);
1854
- --sidebar-ring: oklch(0.708 0 0);
1855
- }
1856
-
1857
- .dark {
1858
- --background: oklch(0.145 0 0);
1859
- --foreground: oklch(0.985 0 0);
1860
- --card: oklch(0.205 0 0);
1861
- --card-foreground: oklch(0.985 0 0);
1862
- --popover: oklch(0.205 0 0);
1863
- --popover-foreground: oklch(0.985 0 0);
1864
- --primary: oklch(0.922 0 0);
1865
- --primary-foreground: oklch(0.205 0 0);
1866
- --secondary: oklch(0.269 0 0);
1867
- --secondary-foreground: oklch(0.985 0 0);
1868
- --muted: oklch(0.269 0 0);
1869
- --muted-foreground: oklch(0.708 0 0);
1870
- --accent: oklch(0.269 0 0);
1871
- --accent-foreground: oklch(0.985 0 0);
1872
- --destructive: oklch(0.704 0.191 22.216);
1873
- --border: oklch(1 0 0 / 10%);
1874
- --input: oklch(1 0 0 / 15%);
1875
- --ring: oklch(0.556 0 0);
1876
- --chart-1: oklch(0.87 0 0);
1877
- --chart-2: oklch(0.556 0 0);
1878
- --chart-3: oklch(0.439 0 0);
1879
- --chart-4: oklch(0.371 0 0);
1880
- --chart-5: oklch(0.269 0 0);
1881
- --sidebar: oklch(0.205 0 0);
1882
- --sidebar-foreground: oklch(0.985 0 0);
1883
- --sidebar-primary: oklch(0.488 0.243 264.376);
1884
- --sidebar-primary-foreground: oklch(0.985 0 0);
1885
- --sidebar-accent: oklch(0.269 0 0);
1886
- --sidebar-accent-foreground: oklch(0.985 0 0);
1887
- --sidebar-border: oklch(1 0 0 / 10%);
1888
- --sidebar-ring: oklch(0.556 0 0);
1889
- }
1890
-
1891
- @layer base {
1892
- * {
1893
- @apply border-border outline-ring/50;
1894
- }
1895
-
1896
- html {
1897
- @apply font-sans;
1898
- }
1899
-
1900
- body {
1901
- @apply bg-background text-foreground;
1902
- }
1903
- }
1904
- `);
1905
- writeFile(path.join(root, 'next.config.mjs'), `import path from 'node:path';
1906
- import { fileURLToPath } from 'node:url';
1907
-
1908
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
1909
-
1910
- /** @type {import('next').NextConfig} */
1911
- const nextConfig = {
1912
- reactCompiler: true,
1913
- turbopack: {
1914
- root: __dirname,
1915
- },
1916
- };
1917
-
1918
- export default nextConfig;
1919
- `);
1920
- writeFile(path.join(root, 'proxy.ts'), `import { clerkMiddleware } from '@clerk/nextjs/server';
1921
- import { NextResponse } from 'next/server';
1922
- import { clerkEnvReady } from './lib/demo-env';
1923
-
1924
- const proxy = clerkEnvReady ? clerkMiddleware() : () => NextResponse.next();
1925
-
1926
- export default proxy;
1927
-
1928
- export const config = {
1929
- matcher: [
1930
- '/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
1931
- '/(api|trpc)(.*)',
1932
- ],
1933
- };
1934
- `);
1935
- writeAnhedralWebBranding(root);
1936
- }