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.
- package/README.md +69 -11
- package/dist/branding.d.ts +2 -0
- package/dist/branding.js +9 -1
- package/dist/commands.js +1 -1
- package/dist/templates/default-web.js +798 -230
- package/dist/templates/frontend.js +1 -1
- package/dist/toolchain.js +6 -0
- package/dist/util.d.ts +1 -1
- package/dist/util.js +62 -38
- package/favicon.ico +0 -0
- package/package.json +12 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
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-
|
|
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-
|
|
420
|
-
sm: 'h-7 gap-1 rounded-
|
|
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-
|
|
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-
|
|
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 {
|
|
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
|
-
<
|
|
649
|
-
<
|
|
650
|
-
|
|
651
|
-
|
|
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
|
|
847
|
-
import {
|
|
848
|
-
import {
|
|
849
|
-
import {
|
|
850
|
-
import {
|
|
851
|
-
import {
|
|
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-
|
|
872
|
-
<
|
|
873
|
-
<
|
|
874
|
-
<div>
|
|
875
|
-
<
|
|
876
|
-
<
|
|
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
|
-
<
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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-
|
|
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-
|
|
917
|
+
<div className="mt-6 flex flex-wrap gap-3">
|
|
904
918
|
<form action="/api/stripe/checkout" method="post">
|
|
905
|
-
<button className={cn(buttonVariants(
|
|
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({
|
|
925
|
+
<Link className={cn(buttonVariants({ variant: 'outline' }))} href="/">
|
|
912
926
|
View landing page
|
|
913
927
|
</Link>
|
|
914
928
|
</div>
|
|
915
|
-
</
|
|
929
|
+
</section>
|
|
916
930
|
|
|
917
|
-
<div className="grid gap-4 md:grid-cols-3">
|
|
918
|
-
<article className="rounded-
|
|
919
|
-
<div className="flex items-center gap-2 text-
|
|
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-
|
|
930
|
-
<div className="flex items-center gap-2 text-
|
|
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-
|
|
941
|
-
<div className="flex items-center gap-2 text-
|
|
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-
|
|
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-
|
|
956
|
-
<h3 className="mt-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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, '
|
|
984
|
-
|
|
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: '
|
|
1469
|
+
title: 'Anhedral',
|
|
991
1470
|
description: 'Anhedral starter with Clerk auth, Stripe billing, Neon + Drizzle, and R2 avatars.',
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
|
1015
|
-
|
|
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, '
|
|
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: '
|
|
1037
|
-
|
|
1038
|
-
icon: Cloud,
|
|
1510
|
+
title: 'App',
|
|
1511
|
+
fields: ['NEXT_PUBLIC_APP_URL'],
|
|
1039
1512
|
},
|
|
1040
1513
|
{
|
|
1041
|
-
title: 'Clerk
|
|
1042
|
-
|
|
1043
|
-
|
|
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: '
|
|
1047
|
-
|
|
1048
|
-
icon: CreditCard,
|
|
1525
|
+
title: 'Neon + Drizzle',
|
|
1526
|
+
fields: ['DATABASE_URL'],
|
|
1049
1527
|
},
|
|
1050
1528
|
{
|
|
1051
|
-
title: '
|
|
1052
|
-
|
|
1053
|
-
|
|
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-
|
|
1062
|
-
<
|
|
1063
|
-
<
|
|
1064
|
-
<div>
|
|
1065
|
-
<
|
|
1066
|
-
<p className="text-
|
|
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
|
-
<
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
-
{
|
|
1105
|
-
<article key={title} className="rounded-
|
|
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-
|
|
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-
|
|
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 {
|
|
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="
|
|
1128
|
-
<section className="flex
|
|
1129
|
-
<
|
|
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 {
|
|
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="
|
|
1171
|
-
<section className="flex items-center justify-center py-8
|
|
1172
|
-
<
|
|
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-
|
|
1214
|
-
--font-
|
|
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.
|
|
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
|
|
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
|
-
|
|
1935
|
+
writeAnhedralWebBranding(root);
|
|
1368
1936
|
}
|