feat(web): adopt shadcn/ui primitives for auth surface
Add shadcn/ui plumbing: - components.json (style: new-york, baseColor: slate, primary: green ~142 71%) - src/lib/utils.ts with cn() helper (clsx + tailwind-merge) - Tailwind config switched to CSS variables theming with primary/destructive/ muted/card etc. tokens, dark-mode class, tailwindcss-animate plugin - src/styles/globals.css declares :root and .dark variable palettes Initial component set under src/components/ui: - Button (cva variants: default/destructive/outline/secondary/ghost/link, asChild) - Input - Label (Radix Label) - Card + CardHeader/Title/Description/Content/Footer Refactor existing auth surface to the new primitives: - sign-in / sign-up pages wrap form in a Card with Title/Description (+ footer for sign-up disclaimer) - sign-in-form / sign-up-form use Input, Label, Button; tokens replace bespoke emerald/slate classes; divider and "Continua con Google" use the same Button - Home page CTAs become Button asChild around Link; SignOutButton uses Button - Hide-Google logic still works: enabledSocialProviders is now ReadonlyArray<SocialProvider> for ergonomic .includes() checks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
646b98cccf
commit
ac2227fdec
17 files changed
+374
-100
No files matched your search
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/styles/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
+10
-1
@@ -11,14 +11,23 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@ketopath/auth": "workspace:*",
|
"@ketopath/auth": "workspace:*",
|
||||||
"@ketopath/shared": "workspace:*",
|
"@ketopath/shared": "workspace:*",
|
||||||
"@ketopath/ui": "workspace:*",
|
"@ketopath/ui": "workspace:*",
|
||||||
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"better-auth": "^1.0.21",
|
"better-auth": "^1.0.21",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.439.0",
|
||||||
"next": "^14.2.5",
|
"next": "^14.2.5",
|
||||||
"next-intl": "^3.17.2",
|
"next-intl": "^3.17.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.53.0",
|
||||||
|
"tailwind-merge": "^2.5.2",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ketopath/eslint-config": "workspace:*",
|
"@ketopath/eslint-config": "workspace:*",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { enabledSocialProviders } from '@ketopath/auth';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
import { SignInForm } from './sign-in-form';
|
import { SignInForm } from './sign-in-form';
|
||||||
|
|
||||||
export default function SignInPage({ params: { locale } }: { params: { locale: string } }) {
|
export default function SignInPage({ params: { locale } }: { params: { locale: string } }) {
|
||||||
@@ -9,12 +11,16 @@ export default function SignInPage({ params: { locale } }: { params: { locale: s
|
|||||||
const t = useTranslations('Auth.SignIn');
|
const t = useTranslations('Auth.SignIn');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-8 px-6 py-16">
|
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-16">
|
||||||
<header className="space-y-2">
|
<Card>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
|
<CardHeader>
|
||||||
<p className="text-sm text-slate-600">{t('subtitle')}</p>
|
<CardTitle>{t('title')}</CardTitle>
|
||||||
</header>
|
<CardDescription>{t('subtitle')}</CardDescription>
|
||||||
<SignInForm googleEnabled={enabledSocialProviders.includes('google')} />
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SignInForm googleEnabled={enabledSocialProviders.includes('google')} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,9 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
|
||||||
export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
|
export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
|
||||||
@@ -46,67 +49,59 @@ export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||||
<label className="block space-y-1">
|
<div className="space-y-1.5">
|
||||||
<span className="text-sm font-medium text-slate-700">{t('emailLabel')}</span>
|
<Label htmlFor="email">{t('emailLabel')}</Label>
|
||||||
<input
|
<Input
|
||||||
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<label className="block space-y-1">
|
<div className="space-y-1.5">
|
||||||
<span className="text-sm font-medium text-slate-700">{t('passwordLabel')}</span>
|
<Label htmlFor="password">{t('passwordLabel')}</Label>
|
||||||
<input
|
<Input
|
||||||
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
minLength={8}
|
minLength={8}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p role="alert" className="text-sm text-red-600">
|
<p role="alert" className="text-destructive text-sm">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<Button type="submit" disabled={pending} className="w-full">
|
||||||
type="submit"
|
|
||||||
disabled={pending}
|
|
||||||
className="w-full rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{pending ? t('submitting') : t('submit')}
|
{pending ? t('submitting') : t('submit')}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{googleEnabled ? (
|
{googleEnabled ? (
|
||||||
<>
|
<>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-slate-200" />
|
<div className="border-border w-full border-t" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center">
|
<div className="relative flex justify-center">
|
||||||
<span className="bg-white px-2 text-xs uppercase tracking-wider text-slate-500">
|
<span className="bg-background text-muted-foreground px-2 text-xs uppercase tracking-wider">
|
||||||
{t('or')}
|
{t('or')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button type="button" variant="outline" onClick={handleGoogle} className="w-full">
|
||||||
type="button"
|
|
||||||
onClick={handleGoogle}
|
|
||||||
className="w-full rounded-md border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
{t('google')}
|
{t('google')}
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-center text-sm text-slate-600">
|
<p className="text-muted-foreground text-center text-sm">
|
||||||
{t.rich('noAccount', {
|
{t.rich('noAccount', {
|
||||||
link: (chunks) => (
|
link: (chunks) => (
|
||||||
<Link href="/sign-up" className="font-medium text-emerald-700 hover:underline">
|
<Link href="/sign-up" className="text-primary font-medium hover:underline">
|
||||||
{chunks}
|
{chunks}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import { enabledSocialProviders } from '@ketopath/auth';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
import { SignUpForm } from './sign-up-form';
|
import { SignUpForm } from './sign-up-form';
|
||||||
|
|
||||||
export default function SignUpPage({ params: { locale } }: { params: { locale: string } }) {
|
export default function SignUpPage({ params: { locale } }: { params: { locale: string } }) {
|
||||||
@@ -9,13 +18,19 @@ export default function SignUpPage({ params: { locale } }: { params: { locale: s
|
|||||||
const t = useTranslations('Auth.SignUp');
|
const t = useTranslations('Auth.SignUp');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-8 px-6 py-16">
|
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-16">
|
||||||
<header className="space-y-2">
|
<Card>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
|
<CardHeader>
|
||||||
<p className="text-sm text-slate-600">{t('subtitle')}</p>
|
<CardTitle>{t('title')}</CardTitle>
|
||||||
</header>
|
<CardDescription>{t('subtitle')}</CardDescription>
|
||||||
<SignUpForm googleEnabled={enabledSocialProviders.includes('google')} />
|
</CardHeader>
|
||||||
<p className="text-xs text-slate-500">{t('disclaimer')}</p>
|
<CardContent>
|
||||||
|
<SignUpForm googleEnabled={enabledSocialProviders.includes('google')} />
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<p className="text-muted-foreground text-xs">{t('disclaimer')}</p>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,9 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
|
||||||
export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
|
export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
|
||||||
@@ -47,79 +50,71 @@ export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||||
<label className="block space-y-1">
|
<div className="space-y-1.5">
|
||||||
<span className="text-sm font-medium text-slate-700">{t('nameLabel')}</span>
|
<Label htmlFor="name">{t('nameLabel')}</Label>
|
||||||
<input
|
<Input
|
||||||
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<label className="block space-y-1">
|
<div className="space-y-1.5">
|
||||||
<span className="text-sm font-medium text-slate-700">{t('emailLabel')}</span>
|
<Label htmlFor="email">{t('emailLabel')}</Label>
|
||||||
<input
|
<Input
|
||||||
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<label className="block space-y-1">
|
<div className="space-y-1.5">
|
||||||
<span className="text-sm font-medium text-slate-700">{t('passwordLabel')}</span>
|
<Label htmlFor="password">{t('passwordLabel')}</Label>
|
||||||
<input
|
<Input
|
||||||
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
minLength={8}
|
minLength={8}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
|
||||||
/>
|
/>
|
||||||
<span className="block text-xs text-slate-500">{t('passwordHint')}</span>
|
<p className="text-muted-foreground text-xs">{t('passwordHint')}</p>
|
||||||
</label>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p role="alert" className="text-sm text-red-600">
|
<p role="alert" className="text-destructive text-sm">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<Button type="submit" disabled={pending} className="w-full">
|
||||||
type="submit"
|
|
||||||
disabled={pending}
|
|
||||||
className="w-full rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{pending ? t('submitting') : t('submit')}
|
{pending ? t('submitting') : t('submit')}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{googleEnabled ? (
|
{googleEnabled ? (
|
||||||
<>
|
<>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-slate-200" />
|
<div className="border-border w-full border-t" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center">
|
<div className="relative flex justify-center">
|
||||||
<span className="bg-white px-2 text-xs uppercase tracking-wider text-slate-500">
|
<span className="bg-background text-muted-foreground px-2 text-xs uppercase tracking-wider">
|
||||||
{t('or')}
|
{t('or')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button type="button" variant="outline" onClick={handleGoogle} className="w-full">
|
||||||
type="button"
|
|
||||||
onClick={handleGoogle}
|
|
||||||
className="w-full rounded-md border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
{t('google')}
|
{t('google')}
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-center text-sm text-slate-600">
|
<p className="text-muted-foreground text-center text-sm">
|
||||||
{t.rich('haveAccount', {
|
{t.rich('haveAccount', {
|
||||||
link: (chunks) => (
|
link: (chunks) => (
|
||||||
<Link href="/sign-in" className="font-medium text-emerald-700 hover:underline">
|
<Link href="/sign-in" className="text-primary font-medium hover:underline">
|
||||||
{chunks}
|
{chunks}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { getServerSession } from '@/lib/auth';
|
import { getServerSession } from '@/lib/auth';
|
||||||
|
|
||||||
import { SignOutButton } from './sign-out-button';
|
import { SignOutButton } from './sign-out-button';
|
||||||
@@ -19,29 +20,23 @@ function HomeContent({ userName }: { userName: string | null }) {
|
|||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen max-w-3xl flex-col items-start justify-center gap-6 px-6 py-16">
|
<main className="mx-auto flex min-h-screen max-w-3xl flex-col items-start justify-center gap-6 px-6 py-16">
|
||||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">{t('title')}</h1>
|
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">{t('title')}</h1>
|
||||||
<p className="text-lg text-slate-600">{t('tagline')}</p>
|
<p className="text-muted-foreground text-lg">{t('tagline')}</p>
|
||||||
{userName ? (
|
{userName ? (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-sm text-slate-700">{t('signedInAs', { name: userName })}</span>
|
<span className="text-foreground text-sm">{t('signedInAs', { name: userName })}</span>
|
||||||
<SignOutButton label={t('signOut')} />
|
<SignOutButton label={t('signOut')} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link
|
<Button asChild>
|
||||||
href="/sign-up"
|
<Link href="/sign-up">{t('ctaPrimary')}</Link>
|
||||||
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
</Button>
|
||||||
>
|
<Button asChild variant="outline">
|
||||||
{t('ctaPrimary')}
|
<Link href="/sign-in">{t('ctaSecondary')}</Link>
|
||||||
</Link>
|
</Button>
|
||||||
<Link
|
|
||||||
href="/sign-in"
|
|
||||||
className="rounded-md border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
|
|
||||||
>
|
|
||||||
{t('ctaSecondary')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="mt-8 text-xs text-slate-500">{t('disclaimer')}</p>
|
<p className="text-muted-foreground mt-8 text-xs">{t('disclaimer')}</p>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
|
||||||
export function SignOutButton({ label }: { label: string }) {
|
export function SignOutButton({ label }: { label: string }) {
|
||||||
@@ -17,13 +18,8 @@ export function SignOutButton({ label }: { label: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button type="button" variant="outline" size="sm" onClick={handleClick} disabled={pending}>
|
||||||
type="button"
|
|
||||||
onClick={handleClick}
|
|
||||||
disabled={pending}
|
|
||||||
className="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-6',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default', size: 'default' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { buttonVariants };
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('bg-card text-card-foreground rounded-lg border shadow-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
export const CardDescription = forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Label = forwardRef<
|
||||||
|
ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -3,7 +3,58 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 142 71% 32%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 210 40% 96%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 142 71% 32%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 142 71% 45%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 142 71% 45%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,62 @@
|
|||||||
import type { Config } from 'tailwindcss';
|
import type { Config } from 'tailwindcss';
|
||||||
|
import animate from 'tailwindcss-animate';
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
|
darkMode: ['class'],
|
||||||
content: ['./src/**/*.{ts,tsx}', '../../packages/ui/src/**/*.{ts,tsx}'],
|
content: ['./src/**/*.{ts,tsx}', '../../packages/ui/src/**/*.{ts,tsx}'],
|
||||||
theme: {
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: '2rem',
|
||||||
|
screens: { '2xl': '1400px' },
|
||||||
|
},
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
|
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
|
||||||
},
|
},
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [animate],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
@@ -8,9 +8,11 @@ const env = readAuthEnv();
|
|||||||
|
|
||||||
const isGoogleConfigured = Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
|
const isGoogleConfigured = Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
|
||||||
|
|
||||||
export const enabledSocialProviders = isGoogleConfigured ? (['google'] as const) : ([] as const);
|
export type SocialProvider = 'google';
|
||||||
|
|
||||||
export type SocialProvider = (typeof enabledSocialProviders)[number];
|
export const enabledSocialProviders: ReadonlyArray<SocialProvider> = isGoogleConfigured
|
||||||
|
? ['google']
|
||||||
|
: [];
|
||||||
|
|
||||||
const googleProvider = isGoogleConfigured
|
const googleProvider = isGoogleConfigured
|
||||||
? {
|
? {
|
||||||
|
|||||||
Generated
BIN
Binary file not shown.
Reference in new issue
Block a user