Initial commit for project Spoon!
Build and Push Next App / quality (push) Failing after 45s
Build and Push Next App / build-next (push) Has been skipped

This commit is contained in:
Gabriel Brown
2026-06-21 17:52:02 -05:00
commit cf7ff2ee4e
268 changed files with 32981 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@spoon/ui';
const AgentsPage = () => {
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
const requests = useQuery(api.agentRequests.listRecent, { limit: 50 }) ?? [];
const createRequest = useMutation(api.agentRequests.create);
const [spoonId, setSpoonId] = useState('');
const [targetBranch, setTargetBranch] = useState('');
const [prompt, setPrompt] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!spoonId) {
toast.error('Choose a Spoon first.');
return;
}
setSubmitting(true);
try {
await createRequest({
spoonId: spoonId as Id<'spoons'>,
prompt,
targetBranch: targetBranch || undefined,
});
setPrompt('');
setTargetBranch('');
toast.success('Agent request queued.');
} catch (error) {
console.error(error);
toast.error('Could not queue agent request.');
} finally {
setSubmitting(false);
}
};
return (
<main className='space-y-6'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Agents</h1>
<p className='text-muted-foreground mt-2'>
Queue prompt-driven work for future AI merge request automation.
</p>
</div>
<div className='grid gap-6 xl:grid-cols-[0.9fr_1.1fr]'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Request work</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='grid gap-2'>
<Label>Spoon</Label>
<Select value={spoonId} onValueChange={setSpoonId}>
<SelectTrigger className='w-full'>
<SelectValue placeholder='Choose a Spoon' />
</SelectTrigger>
<SelectContent>
{spoons.map((spoon) => (
<SelectItem key={spoon._id} value={spoon._id}>
{spoon.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='targetBranch'>Target branch</Label>
<Input
id='targetBranch'
value={targetBranch}
placeholder='feature/my-change'
onChange={(event) => setTargetBranch(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='prompt'>Prompt</Label>
<Textarea
id='prompt'
value={prompt}
required
onChange={(event) => setPrompt(event.target.value)}
/>
</div>
<Button type='submit' disabled={submitting || !spoons.length}>
{submitting ? 'Queueing...' : 'Queue request'}
</Button>
</form>
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Recent requests</CardTitle>
</CardHeader>
<CardContent>
{requests.length ? (
<div className='space-y-3'>
{requests.map((request) => (
<div key={request._id} className='border-border border p-4'>
<p className='font-medium'>{request.prompt}</p>
<p className='text-muted-foreground mt-1 text-sm'>
{request.status.replaceAll('_', ' ')}
</p>
</div>
))}
</div>
) : (
<p className='text-muted-foreground'>
Agent requests will appear here after you create a Spoon and
queue work.
</p>
)}
</CardContent>
</Card>
</div>
</main>
);
};
export default AgentsPage;
+121
View File
@@ -0,0 +1,121 @@
'use client';
import Link from 'next/link';
import { MetricCard } from '@/components/dashboard/metric-card';
import { SpoonCard } from '@/components/spoons/spoon-card';
import { useQuery } from 'convex/react';
import { Bot, GitBranch, GitPullRequest, RefreshCw } from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
const DashboardPage = () => {
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
const agentRequests =
useQuery(api.agentRequests.listRecent, { limit: 5 }) ?? [];
const activeSpoons = spoons.filter(
(spoon) => spoon.status === 'active',
).length;
const needsReview = syncRuns.filter(
(run) => run.status === 'needs_review',
).length;
return (
<main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Dashboard</h1>
<p className='text-muted-foreground mt-2'>
Monitor managed forks, upstream activity, and queued agent work.
</p>
</div>
<Button asChild>
<Link href='/spoons/new'>Create Spoon</Link>
</Button>
</div>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<MetricCard
label='Total Spoons'
value={spoons.length}
note='Managed forks'
icon={GitBranch}
/>
<MetricCard
label='Active Spoons'
value={activeSpoons}
note='Ready for checks'
icon={GitPullRequest}
/>
<MetricCard
label='Needs review'
value={needsReview}
note='Upstream updates'
icon={RefreshCw}
/>
<MetricCard
label='Agent requests'
value={agentRequests.length}
note='Queued and recent'
icon={Bot}
/>
</div>
<div className='grid gap-6 xl:grid-cols-2'>
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Recent Spoons</h2>
{spoons.length ? (
spoons
.slice(0, 3)
.map((spoon) => <SpoonCard key={spoon._id} spoon={spoon} />)
) : (
<Card className='shadow-none'>
<CardContent className='p-6'>
<p className='font-medium'>No Spoons yet</p>
<p className='text-muted-foreground mt-2 text-sm'>
Create a manual Spoon record to start shaping your fork
maintenance dashboard.
</p>
</CardContent>
</Card>
)}
</section>
<section className='space-y-3'>
<h2 className='text-lg font-semibold'>Recent activity</h2>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Upstream checks</CardTitle>
</CardHeader>
<CardContent>
{syncRuns.length ? (
<div className='space-y-3'>
{syncRuns.map((run) => (
<div
key={run._id}
className='border-border border p-3 text-sm'
>
<p className='font-medium'>
{run.kind.replaceAll('_', ' ')}
</p>
<p className='text-muted-foreground'>
{run.status.replaceAll('_', ' ')}
</p>
</div>
))}
</div>
) : (
<p className='text-muted-foreground text-sm'>
Scheduled upstream checks will appear here once provider
automation is connected.
</p>
)}
</CardContent>
</Card>
</section>
</div>
</main>
);
};
export default DashboardPage;
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from 'react';
import { AppShell } from '@/components/app-shell/app-shell';
const Layout = ({ children }: { children: ReactNode }) => (
<AppShell>{children}</AppShell>
);
export default Layout;
@@ -0,0 +1,16 @@
import { NewSpoonForm } from '@/components/spoons/new-spoon-form';
const NewSpoonPage = () => (
<main className='space-y-6'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>New Spoon</h1>
<p className='text-muted-foreground mt-2 max-w-2xl'>
Create a provider-neutral managed fork record. This does not call a Git
provider yet; it prepares the dashboard surface for future automation.
</p>
</div>
<NewSpoonForm />
</main>
);
export default NewSpoonPage;
+49
View File
@@ -0,0 +1,49 @@
'use client';
import Link from 'next/link';
import { SpoonCard } from '@/components/spoons/spoon-card';
import { useQuery } from 'convex/react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent } from '@spoon/ui';
const SpoonsPage = () => {
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
return (
<main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>My Spoons</h1>
<p className='text-muted-foreground mt-2'>
Managed forks you want to keep close to their upstream projects.
</p>
</div>
<Button asChild>
<Link href='/spoons/new'>New Spoon</Link>
</Button>
</div>
{spoons.length ? (
<div className='grid gap-4 xl:grid-cols-2'>
{spoons.map((spoon) => (
<SpoonCard key={spoon._id} spoon={spoon} />
))}
</div>
) : (
<Card className='shadow-none'>
<CardContent className='p-8'>
<p className='text-lg font-medium'>No managed forks yet</p>
<p className='text-muted-foreground mt-2 max-w-xl'>
Add your first Spoon manually. Provider-backed forking can build
on this same record later.
</p>
<Button className='mt-5' asChild>
<Link href='/spoons/new'>Create Spoon</Link>
</Button>
</CardContent>
</Card>
)}
</main>
);
};
export default SpoonsPage;
+83
View File
@@ -0,0 +1,83 @@
'use client';
import { useQuery } from 'convex/react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@spoon/ui';
const UpdatesPage = () => {
const runs = useQuery(api.syncRuns.listRecent, { limit: 50 }) ?? [];
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
return (
<main className='space-y-6'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>Updates</h1>
<p className='text-muted-foreground mt-2'>
Upstream checks, merge attempts, and AI reviews will appear here.
</p>
</div>
<div className='flex flex-col gap-3 md:flex-row'>
<Select defaultValue='all'>
<SelectTrigger className='w-full md:w-48'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All statuses</SelectItem>
<SelectItem value='needs_review'>Needs review</SelectItem>
<SelectItem value='conflict'>Conflict</SelectItem>
<SelectItem value='clean'>Clean</SelectItem>
</SelectContent>
</Select>
<Select defaultValue='all'>
<SelectTrigger className='w-full md:w-64'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Spoons</SelectItem>
{spoons.map((spoon) => (
<SelectItem key={spoon._id} value={spoon._id}>
{spoon.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Recent sync runs</CardTitle>
</CardHeader>
<CardContent>
{runs.length ? (
<div className='space-y-3'>
{runs.map((run) => (
<div key={run._id} className='border-border border p-4'>
<p className='font-medium'>{run.kind.replaceAll('_', ' ')}</p>
<p className='text-muted-foreground text-sm'>
{run.status.replaceAll('_', ' ')}
</p>
</div>
))}
</div>
) : (
<p className='text-muted-foreground'>
Scheduled upstream checks will appear here once provider
connections and workers are added.
</p>
)}
</CardContent>
</Card>
</main>
);
};
export default UpdatesPage;
@@ -0,0 +1,14 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Forgot Password',
};
};
const ForgotPasswordLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <>{children}</>;
};
export default ForgotPasswordLayout;
@@ -0,0 +1,300 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthActions } from '@convex-dev/auth/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { PASSWORD_MAX, PASSWORD_MIN } from '@spoon/backend/types';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
SubmitButton,
} from '@spoon/ui';
const forgotPasswordSchema = z.object({
email: z.email({ message: 'Invalid email.' }),
});
const resetVerificationSchema = z
.object({
code: z.string({ message: 'Invalid code.' }),
newPassword: z
.string()
.min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
})
.max(PASSWORD_MAX, {
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
})
.regex(/^\S+$/, {
message: 'Password must not contain whitespace.',
})
.regex(/[0-9]/, {
message: 'Password must contain at least one digit.',
})
.regex(/[a-z]/, {
message: 'Password must contain at least one lowercase letter.',
})
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.',
})
.regex(/[\p{P}\p{S}]/u, {
message: 'Password must contain at least one symbol.',
}),
confirmPassword: z.string().min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
}),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
const ForgotPassword = () => {
const { signIn } = useAuthActions();
const [flow, setFlow] = useState<'reset' | 'reset-verification'>('reset');
const [email, setEmail] = useState<string>('');
const [loading, setLoading] = useState(false);
const [code, setCode] = useState<string>('');
const router = useRouter();
const forgotPasswordForm = useForm<z.infer<typeof forgotPasswordSchema>>({
resolver: zodResolver(forgotPasswordSchema),
defaultValues: { email },
});
const resetVerificationForm = useForm<
z.infer<typeof resetVerificationSchema>
>({
resolver: zodResolver(resetVerificationSchema),
defaultValues: { code, newPassword: '', confirmPassword: '' },
});
const handleForgotPasswordSubmit = async (
values: z.infer<typeof forgotPasswordSchema>,
) => {
const formData = new FormData();
formData.append('email', values.email);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData).then(() => {
setEmail(values.email);
setFlow('reset-verification');
});
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
forgotPasswordForm.reset();
setLoading(false);
}
};
const handleResetVerificationSubmit = async (
values: z.infer<typeof resetVerificationSchema>,
) => {
const formData = new FormData();
formData.append('code', code);
formData.append('newPassword', values.newPassword);
formData.append('email', email);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData);
router.push('/');
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
resetVerificationForm.reset();
setLoading(false);
}
};
return (
<div className='flex flex-col items-center'>
<Card className='bg-card/25 min-h-[400px] w-sm p-4 lg:w-md'>
<CardHeader className='flex flex-col items-center gap-4'>
{flow === 'reset' ? (
<>
<CardTitle className='text-2xl font-bold'>
Forgot Password
</CardTitle>
<CardDescription>
Enter your email address and we will send you a link to reset
your password.
</CardDescription>
</>
) : (
<>
<CardTitle className='text-2xl font-bold'>
Reset Password
</CardTitle>
<CardDescription>
Enter your code and new password and we will reset your
password.
</CardDescription>
</>
)}
</CardHeader>
<CardContent>
<Card className='bg-card/50'>
<CardContent>
{flow === 'reset' ? (
<Form {...forgotPasswordForm}>
<form
onSubmit={forgotPasswordForm.handleSubmit(
handleForgotPasswordSubmit,
)}
className='flex flex-col space-y-4'
>
<FormField
control={forgotPasswordForm.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Sending Email...'
className='mx-auto w-2/3 text-xl font-semibold'
>
Send Email
</SubmitButton>
</form>
</Form>
) : (
<Form {...resetVerificationForm}>
<form
onSubmit={resetVerificationForm.handleSubmit(
handleResetVerificationSubmit,
)}
className='flex flex-col space-y-4'
>
<FormField
control={resetVerificationForm.control}
name='code'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Code</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
value={code}
onChange={(value) => setCode(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSeparator />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Please enter the one-time password sent to your
phone.
</FormDescription>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={resetVerificationForm.control}
name='newPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
New Password
</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={resetVerificationForm.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
Confirm Passsword
</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Confirm your password'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Resetting Password...'
className='mx-auto w-2/3 text-xl font-semibold'
>
Reset Password
</SubmitButton>
</form>
</Form>
)}
</CardContent>
</Card>
</CardContent>
</Card>
</div>
);
};
export default ForgotPassword;
@@ -0,0 +1,25 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { isAuthenticatedNextjs } from '@convex-dev/auth/nextjs/server';
export const generateMetadata = (): Metadata => {
return {
title: 'Profile',
robots: {
index: false,
follow: false,
googleBot: {
index: false,
follow: false,
},
},
};
};
const ProfileLayout = async ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
if (!(await isAuthenticatedNextjs())) redirect('/sign-in');
return <>{children}</>;
};
export default ProfileLayout;
+51
View File
@@ -0,0 +1,51 @@
'use server';
import {
AvatarUpload,
ProfileHeader,
ResetPasswordForm,
SignOutForm,
UserInfoForm,
} from '@/components/layout/auth/profile';
import { preloadQuery } from 'convex/nextjs';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Card, Separator } from '@spoon/ui';
const Profile = async () => {
const preloadedUser = await preloadQuery(api.auth.getUser, {});
const preloadedUserProvider = await preloadQuery(
api.auth.getUserProvider,
{},
);
return (
<main className='container mx-auto px-4 py-12 md:py-16'>
<div className='mx-auto max-w-3xl'>
{/* Page Header */}
<div className='mb-8 text-center'>
<h1 className='mb-2 text-3xl font-bold tracking-tight sm:text-4xl'>
Your Profile
</h1>
<p className='text-muted-foreground'>
Manage your personal information and preferences
</p>
</div>
{/* Profile Card */}
<Card className='border-border/40'>
<ProfileHeader />
<AvatarUpload preloadedUser={preloadedUser} />
<Separator className='my-6' />
<UserInfoForm
preloadedUser={preloadedUser}
preloadedProvider={preloadedUserProvider}
/>
<ResetPasswordForm preloadedProvider={preloadedUserProvider} />
<Separator className='my-6' />
<SignOutForm />
</Card>
</div>
</main>
);
};
export default Profile;
@@ -0,0 +1,14 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Sign In',
};
};
const SignInLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <>{children}</>;
};
export default SignInLayout;
+464
View File
@@ -0,0 +1,464 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { AuthentikSignInButton } from '@/components/layout/auth/buttons';
import { useAuthActions } from '@convex-dev/auth/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ConvexError } from 'convex/values';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import {
PASSWORD_MAX,
PASSWORD_MIN,
PASSWORD_REGEX,
} from '@spoon/backend/types';
import {
Card,
CardContent,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
Separator,
SubmitButton,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@spoon/ui';
const signInFormSchema = z.object({
email: z.email({
message: 'Please enter a valid email address.',
}),
password: z.string().regex(PASSWORD_REGEX, {
message: 'Incorrect password. Does not meet requirements.',
}),
});
const signUpFormSchema = z
.object({
name: z.string().min(2, {
message: 'Name must be at least 2 characters.',
}),
email: z.email({
message: 'Please enter a valid email address.',
}),
password: z
.string()
.min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
})
.max(PASSWORD_MAX, {
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
})
.regex(/^\S+$/, {
message: 'Password must not contain whitespace.',
})
.regex(/[0-9]/, {
message: 'Password must contain at least one digit.',
})
.regex(/[a-z]/, {
message: 'Password must contain at least one lowercase letter.',
})
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.',
})
.regex(/[\p{P}\p{S}]/u, {
message: 'Password must contain at least one symbol.',
}),
confirmPassword: z.string().min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
const verifyEmailFormSchema = z.object({
code: z.string({ message: 'Invalid code.' }),
});
const SignIn = () => {
const { signIn } = useAuthActions();
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>(
'signIn',
);
const [email, setEmail] = useState<string>('');
const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const signInForm = useForm<z.infer<typeof signInFormSchema>>({
resolver: zodResolver(signInFormSchema),
defaultValues: { email: '', password: '' },
});
const signUpForm = useForm<z.infer<typeof signUpFormSchema>>({
resolver: zodResolver(signUpFormSchema),
defaultValues: {
name: '',
email,
password: '',
confirmPassword: '',
},
});
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
resolver: zodResolver(verifyEmailFormSchema),
defaultValues: { code },
});
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
const formData = new FormData();
formData.append('email', values.email);
formData.append('password', values.password);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/dashboard'));
} catch (error) {
console.error('Error signing in:', error);
toast.error('Error signing in.');
} finally {
signInForm.reset();
setLoading(false);
}
};
const handleSignUp = async (values: z.infer<typeof signUpFormSchema>) => {
const formData = new FormData();
formData.append('email', values.email);
formData.append('password', values.password);
formData.append('flow', flow);
formData.append('name', values.name);
setLoading(true);
try {
if (values.confirmPassword !== values.password)
throw new ConvexError('Passwords do not match.');
await signIn('password', formData).then(() => {
setEmail(values.email);
setFlow('email-verification');
});
} catch (error) {
console.error('Error signing up:', error);
toast.error('Error signing up.');
} finally {
signUpForm.reset();
setLoading(false);
}
};
const handleVerifyEmail = async (
_values: z.infer<typeof verifyEmailFormSchema>,
) => {
const formData = new FormData();
formData.append('code', code);
formData.append('flow', flow);
formData.append('email', email);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/dashboard'));
} catch (error) {
console.error('Error verifying email:', error);
toast.error('Error verifying email.');
} finally {
verifyEmailForm.reset();
setLoading(false);
}
};
if (flow === 'email-verification') {
return (
<div className='flex flex-col items-center'>
<Card className='bg-card/25 min-h-[720px] w-md p-4'>
<CardContent>
<div className='mb-6 text-center'>
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
<p className='text-muted-foreground'>We sent a code to {email}</p>
</div>
<Form {...verifyEmailForm}>
<form
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
className='flex flex-col space-y-8'
>
<FormField
control={verifyEmailForm.control}
name='code'
render={({ field: _field }) => (
<FormItem>
<FormLabel className='text-xl'>Code</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
value={code}
onChange={(value) => setCode(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSeparator />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Please enter the one-time password sent to your email.
</FormDescription>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Signing Up...'
className='mx-auto w-2/3 text-xl font-semibold'
>
Verify Email
</SubmitButton>
</form>
</Form>
<div className='mt-4 text-center'>
<button
onClick={() => setFlow('signUp')}
className='text-muted-foreground text-sm hover:underline'
>
Back to Sign Up
</button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className='flex flex-col items-center'>
<Card className='bg-card/25 min-h-[720px] w-md p-4'>
<Tabs
defaultValue={flow}
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
className='flex-col items-center'
>
<TabsList>
<TabsTrigger
value='signIn'
className='cursor-pointer px-6 py-2 text-2xl font-bold'
>
Sign In
</TabsTrigger>
<TabsTrigger
value='signUp'
className='cursor-pointer px-6 py-2 text-2xl font-bold'
>
Sign Up
</TabsTrigger>
</TabsList>
<TabsContent
value='signIn'
className='flex min-h-[560px] flex-row items-center'
>
<Card className='bg-card/50 min-w-xs py-10 sm:min-w-sm'>
<CardContent>
<Form {...signInForm}>
<form
onSubmit={signInForm.handleSubmit(handleSignIn)}
className='flex flex-col space-y-8'
>
<FormField
control={signInForm.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signInForm.control}
name='password'
render={({ field }) => (
<FormItem>
<div className='flex justify-between'>
<FormLabel className='text-xl'>Password</FormLabel>
<Link href='/forgot-password'>
Forgot Password?
</Link>
</div>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Signing in...'
className='mx-auto w-2/3 text-xl font-semibold'
>
Sign In
</SubmitButton>
</form>
</Form>
<div className='flex justify-center'>
<div className='mx-auto my-2.5 flex w-1/4 flex-row items-center justify-center'>
<Separator className='mr-3 py-0.5' />
<span className='text-lg font-semibold'>or</span>
<Separator className='ml-3 py-0.5' />
</div>
</div>
<div className='mt-3 flex justify-center'>
<AuthentikSignInButton />
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value='signUp'>
<Card className='bg-card/50 min-w-xs sm:min-w-sm'>
<CardContent>
<Form {...signUpForm}>
<form
onSubmit={signUpForm.handleSubmit(handleSignUp)}
className='flex flex-col space-y-8'
>
<FormField
control={signUpForm.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Name</FormLabel>
<FormControl>
<Input
type='text'
placeholder='Full Name'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signUpForm.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signUpForm.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Password</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signUpForm.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
Confirm Passsword
</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Confirm your password'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Signing Up...'
className='mx-auto w-2/3 text-xl font-semibold'
>
Sign Up
</SubmitButton>
</form>
</Form>
<div className='my-auto flex w-2/3 justify-center'>
<div className='my-2.5 flex w-1/3 flex-row items-center'>
<Separator className='mr-3 py-0.5' />
<span className='text-lg font-semibold'>or</span>
<Separator className='ml-3 py-0.5' />
</div>
</div>
<div className='mt-3 flex justify-center'>
<AuthentikSignInButton type='signUp' />
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</Card>
</div>
);
};
export default SignIn;
+82
View File
@@ -0,0 +1,82 @@
'use client';
import type { Metadata, Viewport } from 'next';
import NextError from 'next/error';
import { Geist, Geist_Mono } from 'next/font/google';
import '@/app/styles.css';
import { useEffect } from 'react';
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
import { ConvexClientProvider } from '@/components/providers';
import { env } from '@/env';
import { generateMetadata } from '@/lib/metadata';
import * as Sentry from '@sentry/nextjs';
import PlausibleProvider from 'next-plausible';
import { Button, ThemeProvider, Toaster } from '@spoon/ui';
export const metadata: Metadata = generateMetadata();
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: 'white' },
{ media: '(prefers-color-scheme: dark)', color: 'black' },
],
};
const geistSans = Geist({
subsets: ['latin'],
variable: '--font-geist-sans',
});
const geistMono = Geist_Mono({
subsets: ['latin'],
variable: '--font-geist-mono',
});
interface GlobalErrorProps {
error: Error & { digest?: string };
reset?: () => void;
}
const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<PlausibleProvider
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<ConvexClientProvider>
<main className='flex min-h-screen flex-col items-center'>
<Header />
<NextError statusCode={0} />
{reset !== undefined && (
<Button onClick={() => reset()}>Try Again</Button>
)}
<Toaster />
<Footer />
</main>
<main className='flex min-h-[90vh] flex-col items-center'>
<Toaster />
</main>
</ConvexClientProvider>
</ThemeProvider>
</body>
</html>
</PlausibleProvider>
);
};
export default GlobalError;
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata, Viewport } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { env } from '@/env';
import '@/app/styles.css';
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
import { ConvexClientProvider } from '@/components/providers';
import { generateMetadata } from '@/lib/metadata';
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
import PlausibleProvider from 'next-plausible';
import { ThemeProvider, Toaster } from '@spoon/ui';
export const metadata: Metadata = generateMetadata();
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: 'white' },
{ media: '(prefers-color-scheme: dark)', color: 'black' },
],
};
const geistSans = Geist({
subsets: ['latin'],
variable: '--font-geist-sans',
});
const geistMono = Geist_Mono({
subsets: ['latin'],
variable: '--font-geist-mono',
});
const RootLayout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return (
<ConvexAuthNextjsServerProvider>
<PlausibleProvider
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<ConvexClientProvider>
<div className='flex min-h-screen flex-col'>
<Header />
{children}
<Footer />
</div>
<Toaster />
</ConvexClientProvider>
</ThemeProvider>
</body>
</html>
</PlausibleProvider>
</ConvexAuthNextjsServerProvider>
);
};
export default RootLayout;
+13
View File
@@ -0,0 +1,13 @@
import { Agents, CTA, Features, Hero, Workflow } from '@/components/landing';
const Home = () => (
<main className='flex min-h-screen flex-col'>
<Hero />
<Workflow />
<Features />
<Agents />
<CTA />
</main>
);
export default Home;
+33
View File
@@ -0,0 +1,33 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import '@spoon/tailwind-config/theme';
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));
@custom-variant auto (&:where(.auto, .auto *));
@utility container {
margin-inline: auto;
padding-inline: 2rem;
@media (width >= --theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >= 1400px) {
max-width: 1400px;
}
}
@layer base {
* {
@apply border-border;
}
html {
@apply bg-background;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}