anhedral 0.1.0 → 0.1.1

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,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { ANHEDRAL_LOGO_PUBLIC_PATH, writeAnhedralLogo } from '../branding.js';
2
+ import { writeAnhedralWebBranding } from '../branding.js';
3
3
  import { writeFile } from '../util.js';
4
4
  export function writeDefaultWebEnvExample(root) {
5
5
  writeFile(path.join(root, '.env.example'), `# App
@@ -398,7 +398,7 @@ export async function getDashboardState(input: DashboardStateInput): Promise<Das
398
398
  writeFile(path.join(root, 'lib/ui/button.ts'), `import { cva, type VariantProps } from 'class-variance-authority';
399
399
 
400
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',
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
402
  {
403
403
  variants: {
404
404
  variant: {
@@ -416,14 +416,14 @@ export const buttonVariants = cva(
416
416
  size: {
417
417
  default:
418
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',
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
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
422
  icon: 'size-8',
423
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',
424
+ 'size-6 rounded-md in-data-[slot=button-group]:rounded-md [&_svg:not([class*="size-"])]:size-3',
425
425
  'icon-sm':
426
- 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
426
+ 'size-7 rounded-md in-data-[slot=button-group]:rounded-md',
427
427
  'icon-lg': 'size-9',
428
428
  },
429
429
  },
@@ -462,7 +462,7 @@ export { Button, buttonVariants };
462
462
  writeFile(path.join(root, 'components/dashboard/header-user-menu.tsx'), `'use client';
463
463
 
464
464
  import Image from 'next/image';
465
- import { SignOutButton } from '@clerk/nextjs';
465
+ import { useClerk } from '@clerk/nextjs';
466
466
  import { Camera, ChevronDown, Coins, CreditCard, Loader2, LogOut } from 'lucide-react';
467
467
  import { useRef, useState } from 'react';
468
468
  import { Button } from '@/components/ui/button';
@@ -485,10 +485,12 @@ async function readJsonSafely(response: Response) {
485
485
  }
486
486
 
487
487
  export function HeaderUserMenu(props: HeaderUserMenuProps) {
488
+ const { signOut } = useClerk();
488
489
  const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl);
489
490
  const [notice, setNotice] = useState<string | null>(null);
490
491
  const [isPortalPending, setIsPortalPending] = useState(false);
491
492
  const [isUploadPending, setIsUploadPending] = useState(false);
493
+ const [isSignOutPending, setIsSignOutPending] = useState(false);
492
494
  const fileInputRef = useRef<HTMLInputElement>(null);
493
495
  const initials = props.displayName
494
496
  .split(' ')
@@ -518,6 +520,15 @@ export function HeaderUserMenu(props: HeaderUserMenuProps) {
518
520
  }
519
521
  };
520
522
 
523
+ const handleSignOut = async () => {
524
+ setIsSignOutPending(true);
525
+ try {
526
+ await signOut({ redirectUrl: '/' });
527
+ } finally {
528
+ setIsSignOutPending(false);
529
+ }
530
+ };
531
+
521
532
  const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
522
533
  const input = event.currentTarget;
523
534
  const file = input.files?.[0];
@@ -645,12 +656,10 @@ export function HeaderUserMenu(props: HeaderUserMenuProps) {
645
656
  Upload avatar to R2
646
657
  </Button>
647
658
 
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>
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>
654
663
  </div>
655
664
 
656
665
  {notice ? (
@@ -841,14 +850,15 @@ export async function POST(request: Request) {
841
850
  }
842
851
  `);
843
852
  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';
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';
852
862
 
853
863
  export default async function DashboardPage() {
854
864
  const { userId } = await auth();
@@ -868,55 +878,59 @@ export default async function DashboardPage() {
868
878
  });
869
879
 
870
880
  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>
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>
877
890
  </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.
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
897
911
  </h2>
898
- <p className="mt-4 max-w-2xl text-base text-muted-foreground">
912
+ <p className="mt-3 max-w-2xl text-sm text-muted-foreground sm:text-base">
899
913
  The scaffold already wires a protected dashboard, a subscription surface, seeded credits, and R2-backed avatar upload.
900
914
  Your team can start inside the app shell instead of building this plumbing from scratch.
901
915
  </p>
902
916
 
903
- <div className="mt-8 flex flex-wrap gap-3">
917
+ <div className="mt-6 flex flex-wrap gap-3">
904
918
  <form action="/api/stripe/checkout" method="post">
905
- <button className={cn(buttonVariants({ size: 'lg' }))} type="submit">
919
+ <button className={cn(buttonVariants())} type="submit">
906
920
  Start starter checkout
907
- <ArrowRight className="size-4" />
921
+ <ArrowRight className="ml-2 size-4" />
908
922
  </button>
909
923
  </form>
910
924
 
911
- <Link className={cn(buttonVariants({ size: 'lg', variant: 'outline' }))} href="/">
925
+ <Link className={cn(buttonVariants({ variant: 'outline' }))} href="/">
912
926
  View landing page
913
927
  </Link>
914
928
  </div>
915
- </div>
929
+ </section>
916
930
 
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">
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">
920
934
  <CreditCard className="size-3.5" />
921
935
  Billing
922
936
  </div>
@@ -926,8 +940,8 @@ export default async function DashboardPage() {
926
940
  </p>
927
941
  </article>
928
942
 
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">
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">
931
945
  <Database className="size-3.5" />
932
946
  Credits
933
947
  </div>
@@ -937,8 +951,8 @@ export default async function DashboardPage() {
937
951
  </p>
938
952
  </article>
939
953
 
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">
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">
942
956
  <HardDriveUpload className="size-3.5" />
943
957
  Storage
944
958
  </div>
@@ -950,26 +964,26 @@ export default async function DashboardPage() {
950
964
  </div>
951
965
  </div>
952
966
 
953
- <aside className="space-y-4 rounded-[2rem] border border-border/70 bg-background/90 p-6 shadow-sm">
967
+ <aside className="min-w-0 space-y-4 rounded-lg border bg-card p-6 text-card-foreground">
954
968
  <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>
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>
957
971
  </div>
958
972
 
959
973
  <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">
974
+ <li className="rounded-md border bg-muted/50 px-4 py-3">
961
975
  Swap the starter Stripe price id with your real recurring product and add webhook handlers.
962
976
  </li>
963
- <li className="rounded-2xl border border-border/70 bg-muted/30 px-4 py-3">
977
+ <li className="rounded-md border bg-muted/50 px-4 py-3">
964
978
  Replace the seeded credits model with your real usage accounting rules.
965
979
  </li>
966
- <li className="rounded-2xl border border-border/70 bg-muted/30 px-4 py-3">
980
+ <li className="rounded-md border bg-muted/50 px-4 py-3">
967
981
  Expand the dashboard once your primary signed-in workflow is defined.
968
982
  </li>
969
983
  </ul>
970
984
 
971
985
  {!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">
986
+ <div className="rounded-md border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
973
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.
974
988
  </div>
975
989
  ) : null}
@@ -980,28 +994,484 @@ export default async function DashboardPage() {
980
994
  );
981
995
  }
982
996
  `);
983
- writeFile(path.join(root, 'app/layout.tsx'), `import { shadcn } from '@clerk/ui/themes';
984
- import { ClerkProvider } from '@clerk/nextjs';
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';
985
1463
  import type { Metadata } from 'next';
986
1464
  import './globals.css';
987
1465
  import { ThemeProvider } from '@/components/theme-provider';
1466
+ import { clerkEnvReady } from '@/lib/demo-env';
988
1467
 
989
1468
  export const metadata: Metadata = {
990
- title: '${displayName}',
1469
+ title: 'Anhedral',
991
1470
  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,
1471
+ icons: {
1472
+ icon: '/favicon.ico',
1473
+ shortcut: '/favicon.ico',
1474
+ apple: '/favicon.ico',
1005
1475
  },
1006
1476
  };
1007
1477
 
@@ -1010,106 +1480,263 @@ export default function RootLayout({
1010
1480
  }: Readonly<{
1011
1481
  children: React.ReactNode;
1012
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
+
1013
1501
  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>
1502
+ <ClerkProvider>
1503
+ {content}
1024
1504
  </ClerkProvider>
1025
1505
  );
1026
1506
  }
1027
1507
  `);
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 = [
1508
+ writeFile(path.join(root, 'lib/demo-env.ts'), `const REQUIRED_ENV_GROUPS = [
1035
1509
  {
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,
1510
+ title: 'App',
1511
+ fields: ['NEXT_PUBLIC_APP_URL'],
1039
1512
  },
1040
1513
  {
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,
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
+ ],
1044
1523
  },
1045
1524
  {
1046
- title: 'Stripe + credits',
1047
- description: 'The signed-in shell already reserves space for subscription state, checkout, and credit balances.',
1048
- icon: CreditCard,
1525
+ title: 'Neon + Drizzle',
1526
+ fields: ['DATABASE_URL'],
1049
1527
  },
1050
1528
  {
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,
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',
1054
1585
  },
1055
- ];
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;
1056
1631
 
1057
1632
  export default async function HomePage() {
1058
- const { userId } = await auth();
1633
+ const { userId } = clerkEnvReady ? await auth() : { userId: null };
1059
1634
 
1060
1635
  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>
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>
1067
1642
  </div>
1068
1643
 
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>
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
+ )}
1076
1660
  </div>
1077
- </header>
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>
1078
1674
 
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>
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>
1091
1695
 
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>
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
+ )}
1102
1725
 
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">
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">
1106
1729
  <div className="flex items-center gap-3">
1107
- <div className="flex size-10 items-center justify-center rounded-2xl bg-muted text-foreground">
1730
+ <div className="flex size-9 items-center justify-center rounded-md bg-muted text-foreground">
1108
1731
  <Icon className="size-5" />
1109
1732
  </div>
1110
- <h2 className="text-lg font-semibold">{title}</h2>
1733
+ <h2 className="text-base font-semibold">{title}</h2>
1111
1734
  </div>
1112
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>
1113
1740
  </article>
1114
1741
  ))}
1115
1742
  </div>
@@ -1119,84 +1746,25 @@ export default async function HomePage() {
1119
1746
  );
1120
1747
  }
1121
1748
  `);
1122
- writeFile(path.join(root, 'app/sign-in/[[...sign-in]]/page.tsx'), `import { SignIn } from '@clerk/nextjs';
1123
- import Link from 'next/link';
1749
+ writeFile(path.join(root, 'app/sign-in/[[...sign-in]]/page.tsx'), `import { SignInPanel } from '@/components/auth/sign-in-panel';
1124
1750
 
1125
1751
  export default function SignInPage() {
1126
1752
  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>
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 />
1160
1756
  </section>
1161
1757
  </main>
1162
1758
  );
1163
1759
  }
1164
1760
  `);
1165
- writeFile(path.join(root, 'app/sign-up/[[...sign-up]]/page.tsx'), `import { SignUp } from '@clerk/nextjs';
1166
- import Link from 'next/link';
1761
+ writeFile(path.join(root, 'app/sign-up/[[...sign-up]]/page.tsx'), `import { SignUpPanel } from '@/components/auth/sign-up-panel';
1167
1762
 
1168
1763
  export default function SignUpPage() {
1169
1764
  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>
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 />
1200
1768
  </section>
1201
1769
  </main>
1202
1770
  );
@@ -1205,14 +1773,12 @@ export default function SignUpPage() {
1205
1773
  writeFile(path.join(root, 'app/globals.css'), `@import 'tailwindcss';
1206
1774
  @import 'tw-animate-css';
1207
1775
  @import 'shadcn/tailwind.css';
1208
- @import '@clerk/ui/themes/shadcn.css';
1209
1776
 
1210
1777
  @custom-variant dark (&:is(.dark *));
1211
1778
 
1212
1779
  @theme inline {
1213
- --font-heading: var(--font-app-sans);
1214
- --font-sans: var(--font-app-sans);
1215
- --font-mono: var(--font-app-mono);
1780
+ --font-sans: var(--font-geist-sans);
1781
+ --font-mono: var(--font-geist-mono);
1216
1782
  --color-sidebar-ring: var(--sidebar-ring);
1217
1783
  --color-sidebar-border: var(--sidebar-border);
1218
1784
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -1254,8 +1820,6 @@ export default function SignUpPage() {
1254
1820
  }
1255
1821
 
1256
1822
  :root {
1257
- --font-app-sans: 'Avenir Next', 'Inter', 'Segoe UI', sans-serif;
1258
- --font-app-mono: 'JetBrains Mono', 'SFMono-Regular', monospace;
1259
1823
  --background: oklch(1 0 0);
1260
1824
  --foreground: oklch(0.145 0 0);
1261
1825
  --card: oklch(1 0 0);
@@ -1279,7 +1843,7 @@ export default function SignUpPage() {
1279
1843
  --chart-3: oklch(0.439 0 0);
1280
1844
  --chart-4: oklch(0.371 0 0);
1281
1845
  --chart-5: oklch(0.269 0 0);
1282
- --radius: 0.875rem;
1846
+ --radius: 0.625rem;
1283
1847
  --sidebar: oklch(0.985 0 0);
1284
1848
  --sidebar-foreground: oklch(0.145 0 0);
1285
1849
  --sidebar-primary: oklch(0.205 0 0);
@@ -1354,8 +1918,12 @@ const nextConfig = {
1354
1918
  export default nextConfig;
1355
1919
  `);
1356
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();
1357
1925
 
1358
- export default clerkMiddleware();
1926
+ export default proxy;
1359
1927
 
1360
1928
  export const config = {
1361
1929
  matcher: [
@@ -1364,5 +1932,5 @@ export const config = {
1364
1932
  ],
1365
1933
  };
1366
1934
  `);
1367
- writeAnhedralLogo(root);
1935
+ writeAnhedralWebBranding(root);
1368
1936
  }