anhedral 0.1.0 → 0.1.3

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 ? (
@@ -776,7 +785,7 @@ export async function POST(request: Request) {
776
785
  return Response.json({ error: 'No email address found for the signed-in user.' }, { status: 400 });
777
786
  }
778
787
 
779
- await syncUserProfile({
788
+ const user = await syncUserProfile({
780
789
  clerkUserId: userId,
781
790
  email,
782
791
  displayName,
@@ -784,6 +793,21 @@ export async function POST(request: Request) {
784
793
 
785
794
  const origin = new URL(request.url).origin;
786
795
  const stripe = getStripe();
796
+ let stripeCustomerId = user?.stripeCustomerId ?? null;
797
+
798
+ if (!stripeCustomerId) {
799
+ const customer = await stripe.customers.create({
800
+ email,
801
+ name: displayName,
802
+ metadata: {
803
+ clerkUserId: userId,
804
+ },
805
+ });
806
+
807
+ stripeCustomerId = customer.id;
808
+ await updateStripeCustomerForUser(userId, stripeCustomerId);
809
+ }
810
+
787
811
  const session = await stripe.checkout.sessions.create({
788
812
  mode: 'subscription',
789
813
  line_items: [
@@ -793,7 +817,7 @@ export async function POST(request: Request) {
793
817
  },
794
818
  ],
795
819
  allow_promotion_codes: true,
796
- customer_email: email,
820
+ customer: stripeCustomerId,
797
821
  client_reference_id: userId,
798
822
  success_url: \`\${origin}/dashboard?checkout=success\`,
799
823
  cancel_url: \`\${origin}/dashboard?checkout=cancelled\`,
@@ -802,10 +826,6 @@ export async function POST(request: Request) {
802
826
  },
803
827
  });
804
828
 
805
- if (typeof session.customer === 'string' && session.customer.length > 0) {
806
- await updateStripeCustomerForUser(userId, session.customer);
807
- }
808
-
809
829
  return Response.json({ url: session.url });
810
830
  }
811
831
  `);
@@ -841,16 +861,22 @@ export async function POST(request: Request) {
841
861
  }
842
862
  `);
843
863
  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';
864
+ import { auth, currentUser } from '@clerk/nextjs/server';
865
+ import { redirect } from 'next/navigation';
866
+ import { ArrowRight, CreditCard, Database, HardDriveUpload } from 'lucide-react';
867
+ import { ThemeToggle } from '@/components/theme-toggle';
868
+ import { HeaderUserMenu } from '@/components/dashboard/header-user-menu';
869
+ import { getClerkProfile } from '@/lib/auth/clerk';
870
+ import { getDashboardState } from '@/lib/app/dashboard';
871
+ import { clerkEnvReady } from '@/lib/demo-env';
872
+ import { buttonVariants } from '@/lib/ui/button';
873
+ import { cn } from '@/lib/utils';
852
874
 
853
875
  export default async function DashboardPage() {
876
+ if (!clerkEnvReady) {
877
+ redirect('/');
878
+ }
879
+
854
880
  const { userId } = await auth();
855
881
 
856
882
  if (!userId) {
@@ -868,55 +894,59 @@ export default async function DashboardPage() {
868
894
  });
869
895
 
870
896
  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>
897
+ <main className="min-h-screen bg-background">
898
+ <header className="border-b">
899
+ <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">
900
+ <div className="flex items-center gap-3">
901
+ <img alt="Anhedral logo" className="size-7" src="/anhedral.svg" />
902
+ <div>
903
+ <p className="text-base font-semibold tracking-tight">Anhedral</p>
904
+ <p className="text-sm text-muted-foreground">Dashboard</p>
905
+ </div>
877
906
  </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.
907
+ <div className="flex items-center gap-3">
908
+ <ThemeToggle />
909
+ <HeaderUserMenu
910
+ avatarUrl={dashboard.avatarUrl}
911
+ creditsBalance={dashboard.creditsBalance}
912
+ displayName={dashboard.displayName}
913
+ email={dashboard.email}
914
+ subscriptionStatus={dashboard.subscriptionStatus}
915
+ subscriptionTier={dashboard.subscriptionTier}
916
+ />
917
+ </div>
918
+ </div>
919
+ </header>
920
+
921
+ <div className="container mx-auto max-w-5xl px-4 py-8 sm:px-6">
922
+ <section className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,0.9fr)]">
923
+ <div className="min-w-0 space-y-6">
924
+ <section className="min-w-0 rounded-lg border bg-card p-6 text-card-foreground">
925
+ <h2 className="text-3xl font-semibold tracking-tight">
926
+ Dashboard scaffold
897
927
  </h2>
898
- <p className="mt-4 max-w-2xl text-base text-muted-foreground">
928
+ <p className="mt-3 max-w-2xl text-sm text-muted-foreground sm:text-base">
899
929
  The scaffold already wires a protected dashboard, a subscription surface, seeded credits, and R2-backed avatar upload.
900
930
  Your team can start inside the app shell instead of building this plumbing from scratch.
901
931
  </p>
902
932
 
903
- <div className="mt-8 flex flex-wrap gap-3">
933
+ <div className="mt-6 flex flex-wrap gap-3">
904
934
  <form action="/api/stripe/checkout" method="post">
905
- <button className={cn(buttonVariants({ size: 'lg' }))} type="submit">
935
+ <button className={cn(buttonVariants())} type="submit">
906
936
  Start starter checkout
907
- <ArrowRight className="size-4" />
937
+ <ArrowRight className="ml-2 size-4" />
908
938
  </button>
909
939
  </form>
910
940
 
911
- <Link className={cn(buttonVariants({ size: 'lg', variant: 'outline' }))} href="/">
941
+ <Link className={cn(buttonVariants({ variant: 'outline' }))} href="/">
912
942
  View landing page
913
943
  </Link>
914
944
  </div>
915
- </div>
945
+ </section>
916
946
 
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">
947
+ <div className="min-w-0 grid gap-4 md:grid-cols-3">
948
+ <article className="min-w-0 rounded-lg border bg-card p-5 text-card-foreground">
949
+ <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
920
950
  <CreditCard className="size-3.5" />
921
951
  Billing
922
952
  </div>
@@ -926,8 +956,8 @@ export default async function DashboardPage() {
926
956
  </p>
927
957
  </article>
928
958
 
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">
959
+ <article className="min-w-0 rounded-lg border bg-card p-5 text-card-foreground">
960
+ <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
931
961
  <Database className="size-3.5" />
932
962
  Credits
933
963
  </div>
@@ -937,8 +967,8 @@ export default async function DashboardPage() {
937
967
  </p>
938
968
  </article>
939
969
 
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">
970
+ <article className="min-w-0 rounded-lg border bg-card p-5 text-card-foreground">
971
+ <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
942
972
  <HardDriveUpload className="size-3.5" />
943
973
  Storage
944
974
  </div>
@@ -950,26 +980,26 @@ export default async function DashboardPage() {
950
980
  </div>
951
981
  </div>
952
982
 
953
- <aside className="space-y-4 rounded-[2rem] border border-border/70 bg-background/90 p-6 shadow-sm">
983
+ <aside className="min-w-0 space-y-4 rounded-lg border bg-card p-6 text-card-foreground">
954
984
  <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>
985
+ <p className="text-sm text-muted-foreground">Template notes</p>
986
+ <h3 className="mt-2 text-2xl font-semibold tracking-tight">Replace these first</h3>
957
987
  </div>
958
988
 
959
989
  <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">
990
+ <li className="rounded-md border bg-muted/50 px-4 py-3">
961
991
  Swap the starter Stripe price id with your real recurring product and add webhook handlers.
962
992
  </li>
963
- <li className="rounded-2xl border border-border/70 bg-muted/30 px-4 py-3">
993
+ <li className="rounded-md border bg-muted/50 px-4 py-3">
964
994
  Replace the seeded credits model with your real usage accounting rules.
965
995
  </li>
966
- <li className="rounded-2xl border border-border/70 bg-muted/30 px-4 py-3">
996
+ <li className="rounded-md border bg-muted/50 px-4 py-3">
967
997
  Expand the dashboard once your primary signed-in workflow is defined.
968
998
  </li>
969
999
  </ul>
970
1000
 
971
1001
  {!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">
1002
+ <div className="rounded-md border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
973
1003
  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
1004
  </div>
975
1005
  ) : null}
@@ -980,28 +1010,484 @@ export default async function DashboardPage() {
980
1010
  );
981
1011
  }
982
1012
  `);
983
- writeFile(path.join(root, 'app/layout.tsx'), `import { shadcn } from '@clerk/ui/themes';
984
- import { ClerkProvider } from '@clerk/nextjs';
1013
+ writeFile(path.join(root, 'components/auth/sign-in-panel.tsx'), `'use client';
1014
+
1015
+ import Link from 'next/link';
1016
+ import { useRouter } from 'next/navigation';
1017
+ import * as React from 'react';
1018
+ import { useSignIn } from '@clerk/nextjs';
1019
+ import { AlertCircle, ArrowRight, Loader2 } from 'lucide-react';
1020
+ import { Button } from '@/components/ui/button';
1021
+ import { buttonVariants } from '@/lib/ui/button';
1022
+ import { cn } from '@/lib/utils';
1023
+
1024
+ type ClerkHookError = {
1025
+ longMessage?: string;
1026
+ message?: string;
1027
+ };
1028
+
1029
+ type ClerkHookErrorState = {
1030
+ fields?: Record<string, ClerkHookError | undefined>;
1031
+ global?: ClerkHookError[] | null;
1032
+ raw?: ClerkHookError[] | null;
1033
+ };
1034
+
1035
+ 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';
1036
+ const labelClassName = 'text-sm font-medium';
1037
+
1038
+ function getMessage(error?: ClerkHookError | null) {
1039
+ return error?.longMessage ?? error?.message ?? null;
1040
+ }
1041
+
1042
+ function getGlobalError(errors?: ClerkHookErrorState | null, fallback?: string | null) {
1043
+ return (
1044
+ fallback ??
1045
+ getMessage(errors?.global?.[0]) ??
1046
+ getMessage(errors?.raw?.[0]) ??
1047
+ null
1048
+ );
1049
+ }
1050
+
1051
+ async function finalizeSignIn(
1052
+ signIn: Awaited<ReturnType<typeof useSignIn>>['signIn'],
1053
+ router: ReturnType<typeof useRouter>,
1054
+ ) {
1055
+ await signIn.finalize({
1056
+ navigate: async ({ decorateUrl }) => {
1057
+ const url = decorateUrl('/dashboard');
1058
+
1059
+ if (url.startsWith('http')) {
1060
+ window.location.href = url;
1061
+ } else {
1062
+ router.replace(url);
1063
+ }
1064
+ },
1065
+ });
1066
+ }
1067
+
1068
+ export function SignInPanel() {
1069
+ const router = useRouter();
1070
+ const { signIn, errors, fetchStatus } = useSignIn();
1071
+ const [email, setEmail] = React.useState('');
1072
+ const [password, setPassword] = React.useState('');
1073
+ const [unexpectedError, setUnexpectedError] = React.useState<string | null>(null);
1074
+ const isSubmitting = fetchStatus === 'fetching';
1075
+ const typedErrors = errors as unknown as ClerkHookErrorState | null | undefined;
1076
+ const emailError = getMessage(typedErrors?.fields?.identifier);
1077
+ const passwordError = getMessage(typedErrors?.fields?.password);
1078
+ const globalError = getGlobalError(typedErrors, unexpectedError);
1079
+
1080
+ async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
1081
+ event.preventDefault();
1082
+ if (isSubmitting) return;
1083
+
1084
+ setUnexpectedError(null);
1085
+
1086
+ try {
1087
+ const { error } = await signIn.password({
1088
+ identifier: email.trim(),
1089
+ password,
1090
+ });
1091
+
1092
+ if (error) {
1093
+ return;
1094
+ }
1095
+
1096
+ if (signIn.status === 'complete') {
1097
+ await finalizeSignIn(signIn, router);
1098
+ return;
1099
+ }
1100
+
1101
+ setUnexpectedError('This starter currently supports email-and-password sign-in only.');
1102
+ } catch (submissionError) {
1103
+ setUnexpectedError(submissionError instanceof Error ? submissionError.message : 'Something went wrong. Please try again.');
1104
+ }
1105
+ }
1106
+
1107
+ return (
1108
+ <div className="w-full max-w-md rounded-lg border bg-card p-6 text-card-foreground">
1109
+ <div className="mb-6">
1110
+ <h2 className="text-2xl font-semibold tracking-tight">Sign in</h2>
1111
+ <p className="mt-2 text-sm text-muted-foreground">
1112
+ This route is already wired to Clerk hooks and redirects to the dashboard after authentication.
1113
+ </p>
1114
+ </div>
1115
+
1116
+ <form className="space-y-4" onSubmit={onSubmit}>
1117
+ <div className="space-y-2">
1118
+ <label className={labelClassName} htmlFor="email">Email</label>
1119
+ <input
1120
+ autoComplete="email"
1121
+ className={inputClassName}
1122
+ id="email"
1123
+ onChange={(event) => setEmail(event.target.value)}
1124
+ placeholder="you@example.com"
1125
+ type="email"
1126
+ value={email}
1127
+ />
1128
+ {emailError ? <p className="text-sm text-destructive">{emailError}</p> : null}
1129
+ </div>
1130
+
1131
+ <div className="space-y-2">
1132
+ <div className="flex items-center justify-between gap-3">
1133
+ <label className={labelClassName} htmlFor="password">Password</label>
1134
+ <span className="text-xs text-muted-foreground">Email + password</span>
1135
+ </div>
1136
+ <input
1137
+ autoComplete="current-password"
1138
+ className={inputClassName}
1139
+ id="password"
1140
+ onChange={(event) => setPassword(event.target.value)}
1141
+ placeholder="Enter your password"
1142
+ type="password"
1143
+ value={password}
1144
+ />
1145
+ {passwordError ? <p className="text-sm text-destructive">{passwordError}</p> : null}
1146
+ </div>
1147
+
1148
+ {globalError ? (
1149
+ <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">
1150
+ <AlertCircle className="mt-0.5 size-4 shrink-0" />
1151
+ <p>{globalError}</p>
1152
+ </div>
1153
+ ) : null}
1154
+
1155
+ <Button className="w-full" disabled={isSubmitting} type="submit">
1156
+ {isSubmitting ? <Loader2 className="size-4 animate-spin" /> : <ArrowRight className="size-4" />}
1157
+ Continue to dashboard
1158
+ </Button>
1159
+ </form>
1160
+
1161
+ <p className="mt-5 text-center text-sm text-muted-foreground">
1162
+ Need an account?{' '}
1163
+ <Link className={cn(buttonVariants({ variant: 'link', size: 'sm' }), 'h-auto px-0 align-baseline')} href="/sign-up">
1164
+ Create one
1165
+ </Link>
1166
+ </p>
1167
+ </div>
1168
+ );
1169
+ }
1170
+ `);
1171
+ writeFile(path.join(root, 'components/auth/sign-up-panel.tsx'), `'use client';
1172
+
1173
+ import Link from 'next/link';
1174
+ import { useRouter } from 'next/navigation';
1175
+ import * as React from 'react';
1176
+ import { useSignUp } from '@clerk/nextjs';
1177
+ import { AlertCircle, ArrowRight, BadgeCheck, Loader2, MailCheck, RotateCcw } from 'lucide-react';
1178
+ import { Button } from '@/components/ui/button';
1179
+ import { buttonVariants } from '@/lib/ui/button';
1180
+ import { cn } from '@/lib/utils';
1181
+
1182
+ type ClerkHookError = {
1183
+ longMessage?: string;
1184
+ message?: string;
1185
+ };
1186
+
1187
+ type ClerkHookErrorState = {
1188
+ fields?: Record<string, ClerkHookError | undefined>;
1189
+ global?: ClerkHookError[] | null;
1190
+ raw?: ClerkHookError[] | null;
1191
+ };
1192
+
1193
+ 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';
1194
+ const labelClassName = 'text-sm font-medium';
1195
+
1196
+ function getMessage(error?: ClerkHookError | null) {
1197
+ return error?.longMessage ?? error?.message ?? null;
1198
+ }
1199
+
1200
+ function getGlobalError(errors?: ClerkHookErrorState | null, fallback?: string | null) {
1201
+ return (
1202
+ fallback ??
1203
+ getMessage(errors?.global?.[0]) ??
1204
+ getMessage(errors?.raw?.[0]) ??
1205
+ null
1206
+ );
1207
+ }
1208
+
1209
+ async function finalizeSignUp(
1210
+ signUp: Awaited<ReturnType<typeof useSignUp>>['signUp'],
1211
+ router: ReturnType<typeof useRouter>,
1212
+ ) {
1213
+ await signUp.finalize({
1214
+ navigate: async ({ decorateUrl }) => {
1215
+ const url = decorateUrl('/dashboard');
1216
+
1217
+ if (url.startsWith('http')) {
1218
+ window.location.href = url;
1219
+ } else {
1220
+ router.replace(url);
1221
+ }
1222
+ },
1223
+ });
1224
+ }
1225
+
1226
+ export function SignUpPanel() {
1227
+ const router = useRouter();
1228
+ const { signUp, errors, fetchStatus } = useSignUp();
1229
+ const [step, setStep] = React.useState<'details' | 'verify'>('details');
1230
+ const [email, setEmail] = React.useState('');
1231
+ const [password, setPassword] = React.useState('');
1232
+ const [verificationCode, setVerificationCode] = React.useState('');
1233
+ const [unexpectedError, setUnexpectedError] = React.useState<string | null>(null);
1234
+ const isSubmitting = fetchStatus === 'fetching';
1235
+ const typedErrors = errors as unknown as ClerkHookErrorState | null | undefined;
1236
+ const emailError = getMessage(typedErrors?.fields?.emailAddress);
1237
+ const passwordError = getMessage(typedErrors?.fields?.password);
1238
+ const codeError = getMessage(typedErrors?.fields?.code);
1239
+ const globalError = getGlobalError(typedErrors, unexpectedError);
1240
+
1241
+ async function onCreateAccount(event: React.FormEvent<HTMLFormElement>) {
1242
+ event.preventDefault();
1243
+ if (isSubmitting) return;
1244
+
1245
+ setUnexpectedError(null);
1246
+
1247
+ try {
1248
+ const { error } = await signUp.password({
1249
+ emailAddress: email.trim(),
1250
+ password,
1251
+ });
1252
+
1253
+ if (error) {
1254
+ return;
1255
+ }
1256
+
1257
+ if (signUp.status === 'complete') {
1258
+ await finalizeSignUp(signUp, router);
1259
+ return;
1260
+ }
1261
+
1262
+ const { error: sendEmailError } = await signUp.verifications.sendEmailCode();
1263
+ if (sendEmailError) {
1264
+ return;
1265
+ }
1266
+
1267
+ setStep('verify');
1268
+ setVerificationCode('');
1269
+ } catch (submissionError) {
1270
+ setUnexpectedError(submissionError instanceof Error ? submissionError.message : 'Something went wrong. Please try again.');
1271
+ }
1272
+ }
1273
+
1274
+ async function onVerify(event: React.FormEvent<HTMLFormElement>) {
1275
+ event.preventDefault();
1276
+ if (isSubmitting) return;
1277
+
1278
+ setUnexpectedError(null);
1279
+
1280
+ try {
1281
+ const { error } = await signUp.verifications.verifyEmailCode({
1282
+ code: verificationCode.trim(),
1283
+ });
1284
+
1285
+ if (error) {
1286
+ return;
1287
+ }
1288
+
1289
+ if (signUp.status === 'complete') {
1290
+ await finalizeSignUp(signUp, router);
1291
+ return;
1292
+ }
1293
+
1294
+ setUnexpectedError('Verification is still incomplete. Request a fresh code and try again.');
1295
+ } catch (verificationError) {
1296
+ setUnexpectedError(verificationError instanceof Error ? verificationError.message : 'Something went wrong. Please try again.');
1297
+ }
1298
+ }
1299
+
1300
+ async function onResendCode() {
1301
+ if (isSubmitting) return;
1302
+
1303
+ setUnexpectedError(null);
1304
+
1305
+ try {
1306
+ const { error } = await signUp.verifications.sendEmailCode();
1307
+ if (error) {
1308
+ return;
1309
+ }
1310
+ } catch (resendError) {
1311
+ setUnexpectedError(resendError instanceof Error ? resendError.message : 'Unable to send a new code right now.');
1312
+ }
1313
+ }
1314
+
1315
+ return (
1316
+ <div className="w-full max-w-md rounded-lg border bg-card p-6 text-card-foreground">
1317
+ <div className="mb-6">
1318
+ <h2 className="mt-3 text-3xl font-semibold tracking-tight">
1319
+ {step === 'details' ? 'Create your account' : 'Verify your email'}
1320
+ </h2>
1321
+ <p className="mt-2 text-sm leading-6 text-muted-foreground">
1322
+ {step === 'details'
1323
+ ? 'This route is already wired to Clerk hooks and redirects to the dashboard after signup.'
1324
+ : \`Enter the verification code Clerk sent to \${email.trim() || 'your inbox'} to finish account creation.\`}
1325
+ </p>
1326
+ </div>
1327
+
1328
+ {step === 'details' ? (
1329
+ <form className="space-y-4" onSubmit={onCreateAccount}>
1330
+ <div className="space-y-2">
1331
+ <label className={labelClassName} htmlFor="sign-up-email">Email</label>
1332
+ <input
1333
+ autoComplete="email"
1334
+ className={inputClassName}
1335
+ id="sign-up-email"
1336
+ onChange={(event) => setEmail(event.target.value)}
1337
+ placeholder="founder@company.com"
1338
+ type="email"
1339
+ value={email}
1340
+ />
1341
+ {emailError ? <p className="text-sm text-destructive">{emailError}</p> : null}
1342
+ </div>
1343
+
1344
+ <div className="space-y-2">
1345
+ <label className={labelClassName} htmlFor="sign-up-password">Password</label>
1346
+ <input
1347
+ autoComplete="new-password"
1348
+ className={inputClassName}
1349
+ id="sign-up-password"
1350
+ onChange={(event) => setPassword(event.target.value)}
1351
+ placeholder="Choose a strong password"
1352
+ type="password"
1353
+ value={password}
1354
+ />
1355
+ {passwordError ? <p className="text-sm text-destructive">{passwordError}</p> : null}
1356
+ </div>
1357
+
1358
+ {globalError ? (
1359
+ <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">
1360
+ <AlertCircle className="mt-0.5 size-4 shrink-0" />
1361
+ <p>{globalError}</p>
1362
+ </div>
1363
+ ) : null}
1364
+
1365
+ <div id="clerk-captcha" />
1366
+
1367
+ <Button className="w-full" disabled={isSubmitting} type="submit">
1368
+ {isSubmitting ? <Loader2 className="size-4 animate-spin" /> : <ArrowRight className="size-4" />}
1369
+ Create account
1370
+ </Button>
1371
+ </form>
1372
+ ) : (
1373
+ <form className="space-y-4" onSubmit={onVerify}>
1374
+ <div className="rounded-md border bg-muted/50 p-4 text-sm text-muted-foreground">
1375
+ <div className="flex items-center gap-2 font-medium text-foreground">
1376
+ <MailCheck className="size-4" />
1377
+ Email verification
1378
+ </div>
1379
+ <p className="mt-2">
1380
+ Clerk handles the verification challenge while this route stays inside your app.
1381
+ </p>
1382
+ </div>
1383
+
1384
+ <div className="space-y-2">
1385
+ <label className={labelClassName} htmlFor="verification-code">Verification code</label>
1386
+ <input
1387
+ className={inputClassName}
1388
+ id="verification-code"
1389
+ inputMode="numeric"
1390
+ onChange={(event) => setVerificationCode(event.target.value)}
1391
+ placeholder="123456"
1392
+ type="text"
1393
+ value={verificationCode}
1394
+ />
1395
+ {codeError ? <p className="text-sm text-destructive">{codeError}</p> : null}
1396
+ </div>
1397
+
1398
+ {globalError ? (
1399
+ <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">
1400
+ <AlertCircle className="mt-0.5 size-4 shrink-0" />
1401
+ <p>{globalError}</p>
1402
+ </div>
1403
+ ) : null}
1404
+
1405
+ <div className="grid gap-3 md:grid-cols-3">
1406
+ <Button className="md:col-span-2" disabled={isSubmitting} type="submit">
1407
+ {isSubmitting ? <Loader2 className="size-4 animate-spin" /> : <BadgeCheck className="size-4" />}
1408
+ Verify and continue
1409
+ </Button>
1410
+
1411
+ <Button
1412
+ disabled={isSubmitting}
1413
+ onClick={onResendCode}
1414
+ type="button"
1415
+ variant="secondary"
1416
+ >
1417
+ <RotateCcw className="size-4" />
1418
+ Resend code
1419
+ </Button>
1420
+
1421
+ <Button
1422
+ className="md:col-span-3"
1423
+ disabled={isSubmitting}
1424
+ onClick={() => {
1425
+ setStep('details');
1426
+ setVerificationCode('');
1427
+ setUnexpectedError(null);
1428
+ }}
1429
+ type="button"
1430
+ variant="outline"
1431
+ >
1432
+ Use another email
1433
+ </Button>
1434
+ </div>
1435
+ </form>
1436
+ )}
1437
+
1438
+ <p className="mt-5 text-center text-sm text-muted-foreground">
1439
+ Already have an account?{' '}
1440
+ <Link className={cn(buttonVariants({ variant: 'link', size: 'sm' }), 'h-auto px-0 align-baseline')} href="/sign-in">
1441
+ Sign in
1442
+ </Link>
1443
+ </p>
1444
+ </div>
1445
+ );
1446
+ }
1447
+ `);
1448
+ writeFile(path.join(root, 'components/theme-toggle.tsx'), `'use client';
1449
+
1450
+ import * as React from 'react';
1451
+ import { Moon, Sun } from 'lucide-react';
1452
+ import { useTheme } from 'next-themes';
1453
+ import { Button } from '@/components/ui/button';
1454
+
1455
+ export function ThemeToggle() {
1456
+ const { resolvedTheme, setTheme } = useTheme();
1457
+ const [mounted, setMounted] = React.useState(false);
1458
+
1459
+ React.useEffect(() => {
1460
+ setMounted(true);
1461
+ }, []);
1462
+
1463
+ const isDark = mounted && resolvedTheme === 'dark';
1464
+
1465
+ return (
1466
+ <Button
1467
+ aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
1468
+ onClick={() => setTheme(isDark ? 'light' : 'dark')}
1469
+ size="icon"
1470
+ type="button"
1471
+ variant="outline"
1472
+ >
1473
+ {isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
1474
+ </Button>
1475
+ );
1476
+ }
1477
+ `);
1478
+ writeFile(path.join(root, 'app/layout.tsx'), `import { ClerkProvider } from '@clerk/nextjs';
985
1479
  import type { Metadata } from 'next';
986
1480
  import './globals.css';
987
1481
  import { ThemeProvider } from '@/components/theme-provider';
1482
+ import { clerkEnvReady } from '@/lib/demo-env';
988
1483
 
989
1484
  export const metadata: Metadata = {
990
- title: '${displayName}',
1485
+ title: 'Anhedral',
991
1486
  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,
1487
+ icons: {
1488
+ icon: '/favicon.ico',
1489
+ shortcut: '/favicon.ico',
1490
+ apple: '/favicon.ico',
1005
1491
  },
1006
1492
  };
1007
1493
 
@@ -1010,106 +1496,247 @@ export default function RootLayout({
1010
1496
  }: Readonly<{
1011
1497
  children: React.ReactNode;
1012
1498
  }>) {
1499
+ const content = (
1500
+ <html
1501
+ lang="en"
1502
+ suppressHydrationWarning
1503
+ className="antialiased font-sans"
1504
+ >
1505
+ <body>
1506
+ <ThemeProvider>
1507
+ {children}
1508
+ </ThemeProvider>
1509
+ </body>
1510
+ </html>
1511
+ );
1512
+
1513
+ if (!clerkEnvReady) {
1514
+ return content;
1515
+ }
1516
+
1013
1517
  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>
1518
+ <ClerkProvider>
1519
+ {content}
1024
1520
  </ClerkProvider>
1025
1521
  );
1026
1522
  }
1027
1523
  `);
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 = [
1524
+ writeFile(path.join(root, 'lib/demo-env.ts'), `const REQUIRED_ENV_GROUPS = [
1525
+ {
1526
+ title: 'App',
1527
+ fields: ['NEXT_PUBLIC_APP_URL'],
1528
+ },
1035
1529
  {
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,
1530
+ title: 'Clerk',
1531
+ fields: [
1532
+ 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY',
1533
+ 'CLERK_SECRET_KEY',
1534
+ 'NEXT_PUBLIC_CLERK_SIGN_IN_URL',
1535
+ 'NEXT_PUBLIC_CLERK_SIGN_UP_URL',
1536
+ 'NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL',
1537
+ 'NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL',
1538
+ ],
1039
1539
  },
1040
1540
  {
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,
1541
+ title: 'Neon + Drizzle',
1542
+ fields: ['DATABASE_URL'],
1044
1543
  },
1045
1544
  {
1046
- title: 'Stripe + credits',
1047
- description: 'The signed-in shell already reserves space for subscription state, checkout, and credit balances.',
1048
- icon: CreditCard,
1545
+ title: 'Cloudflare R2',
1546
+ fields: ['R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET'],
1049
1547
  },
1050
1548
  {
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,
1549
+ title: 'Stripe',
1550
+ fields: ['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET', 'STRIPE_PRICE_STARTER'],
1054
1551
  },
1055
- ];
1552
+ ] as const;
1553
+
1554
+ function hasRealValue(value: string | undefined) {
1555
+ if (!value) {
1556
+ return false;
1557
+ }
1558
+
1559
+ return !value.includes('***') && !value.includes('demo_placeholder');
1560
+ }
1561
+
1562
+ export const clerkEnvReady =
1563
+ hasRealValue(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) &&
1564
+ hasRealValue(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;
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,37 @@ 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 { redirect } from 'next/navigation';
1750
+ import { SignInPanel } from '@/components/auth/sign-in-panel';
1751
+ import { clerkEnvReady } from '@/lib/demo-env';
1124
1752
 
1125
1753
  export default function SignInPage() {
1126
- return (
1127
- <main className="grid min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(29,78,216,0.14),transparent_28%),radial-gradient(circle_at_bottom_right,rgba(251,191,36,0.14),transparent_24%)] px-6 py-10 sm:px-8 lg:grid-cols-[1.1fr_0.9fr]">
1128
- <section className="flex flex-col justify-between rounded-[2rem] border border-border/70 bg-background/90 p-8 shadow-sm">
1129
- <div>
1130
- <p className="text-xs uppercase tracking-[0.26em] text-muted-foreground">Clerk custom UI</p>
1131
- <h1 className="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl">Sign in without rebuilding the basics.</h1>
1132
- <p className="mt-4 max-w-xl text-base text-muted-foreground">
1133
- This route is intentionally branded and lives alongside your app shell. Swap the copy and continue shipping.
1134
- </p>
1135
- </div>
1136
-
1137
- <div className="rounded-[1.75rem] border border-border/70 bg-muted/30 p-5 text-sm text-muted-foreground">
1138
- <p className="font-medium text-foreground">Already wired:</p>
1139
- <ul className="mt-3 space-y-2">
1140
- <li>Custom route at /sign-in</li>
1141
- <li>Clerk shadcn theme through ClerkProvider</li>
1142
- <li>Dashboard redirect after authentication</li>
1143
- </ul>
1144
- <p className="mt-4">
1145
- Need a different conversion flow? Pair this page with the installed Clerk skill and replace it with a hook-driven form later.
1146
- </p>
1147
- </div>
1148
- </section>
1754
+ if (!clerkEnvReady) {
1755
+ redirect('/');
1756
+ }
1149
1757
 
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>
1758
+ return (
1759
+ <main className="min-h-screen bg-background">
1760
+ <section className="container mx-auto flex min-h-screen max-w-5xl items-center justify-center px-4 py-8 sm:px-6">
1761
+ <SignInPanel />
1160
1762
  </section>
1161
1763
  </main>
1162
1764
  );
1163
1765
  }
1164
1766
  `);
1165
- writeFile(path.join(root, 'app/sign-up/[[...sign-up]]/page.tsx'), `import { SignUp } from '@clerk/nextjs';
1166
- import Link from 'next/link';
1767
+ writeFile(path.join(root, 'app/sign-up/[[...sign-up]]/page.tsx'), `import { redirect } from 'next/navigation';
1768
+ import { SignUpPanel } from '@/components/auth/sign-up-panel';
1769
+ import { clerkEnvReady } from '@/lib/demo-env';
1167
1770
 
1168
1771
  export default function SignUpPage() {
1169
- return (
1170
- <main className="grid min-h-screen bg-[radial-gradient(circle_at_top_right,rgba(29,78,216,0.14),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(251,191,36,0.14),transparent_24%)] px-6 py-10 sm:px-8 lg:grid-cols-[0.9fr_1.1fr]">
1171
- <section className="flex items-center justify-center py-8 lg:order-2 lg:py-0">
1172
- <div className="w-full max-w-md rounded-[2rem] border border-border/70 bg-background/95 p-4 shadow-xl">
1173
- <SignUp forceRedirectUrl="/dashboard" path="/sign-up" routing="path" signInUrl="/sign-in" />
1174
- <p className="px-4 pb-2 text-center text-sm text-muted-foreground">
1175
- Already have an account?{' '}
1176
- <Link className="font-medium text-foreground underline-offset-4 hover:underline" href="/sign-in">
1177
- Sign in
1178
- </Link>
1179
- </p>
1180
- </div>
1181
- </section>
1182
-
1183
- <section className="flex flex-col justify-between rounded-[2rem] border border-border/70 bg-background/90 p-8 shadow-sm lg:order-1">
1184
- <div>
1185
- <p className="text-xs uppercase tracking-[0.26em] text-muted-foreground">Starter onboarding</p>
1186
- <h1 className="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl">Get users into the product shell immediately.</h1>
1187
- <p className="mt-4 max-w-xl text-base text-muted-foreground">
1188
- The generated starter sends new accounts straight into the dashboard with billing, credits, and avatar plumbing already waiting.
1189
- </p>
1190
- </div>
1772
+ if (!clerkEnvReady) {
1773
+ redirect('/');
1774
+ }
1191
1775
 
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>
1776
+ return (
1777
+ <main className="min-h-screen bg-background">
1778
+ <section className="container mx-auto flex min-h-screen max-w-5xl items-center justify-center px-4 py-8 sm:px-6">
1779
+ <SignUpPanel />
1200
1780
  </section>
1201
1781
  </main>
1202
1782
  );
@@ -1205,14 +1785,12 @@ export default function SignUpPage() {
1205
1785
  writeFile(path.join(root, 'app/globals.css'), `@import 'tailwindcss';
1206
1786
  @import 'tw-animate-css';
1207
1787
  @import 'shadcn/tailwind.css';
1208
- @import '@clerk/ui/themes/shadcn.css';
1209
1788
 
1210
1789
  @custom-variant dark (&:is(.dark *));
1211
1790
 
1212
1791
  @theme inline {
1213
- --font-heading: var(--font-app-sans);
1214
- --font-sans: var(--font-app-sans);
1215
- --font-mono: var(--font-app-mono);
1792
+ --font-sans: var(--font-geist-sans);
1793
+ --font-mono: var(--font-geist-mono);
1216
1794
  --color-sidebar-ring: var(--sidebar-ring);
1217
1795
  --color-sidebar-border: var(--sidebar-border);
1218
1796
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -1254,8 +1832,6 @@ export default function SignUpPage() {
1254
1832
  }
1255
1833
 
1256
1834
  :root {
1257
- --font-app-sans: 'Avenir Next', 'Inter', 'Segoe UI', sans-serif;
1258
- --font-app-mono: 'JetBrains Mono', 'SFMono-Regular', monospace;
1259
1835
  --background: oklch(1 0 0);
1260
1836
  --foreground: oklch(0.145 0 0);
1261
1837
  --card: oklch(1 0 0);
@@ -1279,7 +1855,7 @@ export default function SignUpPage() {
1279
1855
  --chart-3: oklch(0.439 0 0);
1280
1856
  --chart-4: oklch(0.371 0 0);
1281
1857
  --chart-5: oklch(0.269 0 0);
1282
- --radius: 0.875rem;
1858
+ --radius: 0.625rem;
1283
1859
  --sidebar: oklch(0.985 0 0);
1284
1860
  --sidebar-foreground: oklch(0.145 0 0);
1285
1861
  --sidebar-primary: oklch(0.205 0 0);
@@ -1342,20 +1918,26 @@ export default function SignUpPage() {
1342
1918
  import { fileURLToPath } from 'node:url';
1343
1919
 
1344
1920
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
1921
+ const monorepoRoot = path.resolve(__dirname, '../..');
1345
1922
 
1346
1923
  /** @type {import('next').NextConfig} */
1347
1924
  const nextConfig = {
1348
1925
  reactCompiler: true,
1926
+ transpilePackages: ['@anhedral/db'],
1349
1927
  turbopack: {
1350
- root: __dirname,
1928
+ root: monorepoRoot,
1351
1929
  },
1352
1930
  };
1353
1931
 
1354
1932
  export default nextConfig;
1355
1933
  `);
1356
1934
  writeFile(path.join(root, 'proxy.ts'), `import { clerkMiddleware } from '@clerk/nextjs/server';
1935
+ import { NextResponse } from 'next/server';
1936
+ import { clerkEnvReady } from './lib/demo-env';
1937
+
1938
+ const proxy = clerkEnvReady ? clerkMiddleware() : () => NextResponse.next();
1357
1939
 
1358
- export default clerkMiddleware();
1940
+ export default proxy;
1359
1941
 
1360
1942
  export const config = {
1361
1943
  matcher: [
@@ -1364,5 +1946,5 @@ export const config = {
1364
1946
  ],
1365
1947
  };
1366
1948
  `);
1367
- writeAnhedralLogo(root);
1949
+ writeAnhedralWebBranding(root);
1368
1950
  }