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:
lucianoandClaude Opus 4.7 committed 2026-04-29 14:09:59 +02:00
1 parent 646b98cccf
commit ac2227fdec
17 files changed
+374 -100

No files matched your search

@@ -2,6 +2,8 @@ import { enabledSocialProviders } from '@ketopath/auth';
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { SignInForm } from './sign-in-form';
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');
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-8 px-6 py-16">
<header className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-sm text-slate-600">{t('subtitle')}</p>
</header>
<SignInForm googleEnabled={enabledSocialProviders.includes('google')} />
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-16">
<Card>
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<SignInForm googleEnabled={enabledSocialProviders.includes('google')} />
</CardContent>
</Card>
</main>
);
}
@@ -5,6 +5,9 @@ import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
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';
export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
@@ -46,67 +49,59 @@ export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
return (
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700">{t('emailLabel')}</span>
<input
<div className="space-y-1.5">
<Label htmlFor="email">{t('emailLabel')}</Label>
<Input
id="email"
type="email"
required
autoComplete="email"
value={email}
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>
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700">{t('passwordLabel')}</span>
<input
</div>
<div className="space-y-1.5">
<Label htmlFor="password">{t('passwordLabel')}</Label>
<Input
id="password"
type="password"
required
autoComplete="current-password"
minLength={8}
value={password}
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 ? (
<p role="alert" className="text-sm text-red-600">
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
<button
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"
>
<Button type="submit" disabled={pending} className="w-full">
{pending ? t('submitting') : t('submit')}
</button>
</Button>
</form>
{googleEnabled ? (
<>
<div className="relative">
<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 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')}
</span>
</div>
</div>
<button
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"
>
<Button type="button" variant="outline" onClick={handleGoogle} className="w-full">
{t('google')}
</button>
</Button>
</>
) : null}
<p className="text-center text-sm text-slate-600">
<p className="text-muted-foreground text-center text-sm">
{t.rich('noAccount', {
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}
</Link>
),
@@ -2,6 +2,15 @@ import { enabledSocialProviders } from '@ketopath/auth';
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { SignUpForm } from './sign-up-form';
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');
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-8 px-6 py-16">
<header className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-sm text-slate-600">{t('subtitle')}</p>
</header>
<SignUpForm googleEnabled={enabledSocialProviders.includes('google')} />
<p className="text-xs text-slate-500">{t('disclaimer')}</p>
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-16">
<Card>
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<SignUpForm googleEnabled={enabledSocialProviders.includes('google')} />
</CardContent>
<CardFooter>
<p className="text-muted-foreground text-xs">{t('disclaimer')}</p>
</CardFooter>
</Card>
</main>
);
}
@@ -5,6 +5,9 @@ import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
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';
export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
@@ -47,79 +50,71 @@ export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
return (
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700">{t('nameLabel')}</span>
<input
<div className="space-y-1.5">
<Label htmlFor="name">{t('nameLabel')}</Label>
<Input
id="name"
type="text"
required
autoComplete="name"
value={name}
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>
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700">{t('emailLabel')}</span>
<input
</div>
<div className="space-y-1.5">
<Label htmlFor="email">{t('emailLabel')}</Label>
<Input
id="email"
type="email"
required
autoComplete="email"
value={email}
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>
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700">{t('passwordLabel')}</span>
<input
</div>
<div className="space-y-1.5">
<Label htmlFor="password">{t('passwordLabel')}</Label>
<Input
id="password"
type="password"
required
autoComplete="new-password"
minLength={8}
value={password}
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>
</label>
<p className="text-muted-foreground text-xs">{t('passwordHint')}</p>
</div>
{error ? (
<p role="alert" className="text-sm text-red-600">
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
<button
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"
>
<Button type="submit" disabled={pending} className="w-full">
{pending ? t('submitting') : t('submit')}
</button>
</Button>
</form>
{googleEnabled ? (
<>
<div className="relative">
<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 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')}
</span>
</div>
</div>
<button
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"
>
<Button type="button" variant="outline" onClick={handleGoogle} className="w-full">
{t('google')}
</button>
</Button>
</>
) : null}
<p className="text-center text-sm text-slate-600">
<p className="text-muted-foreground text-center text-sm">
{t.rich('haveAccount', {
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}
</Link>
),
+10 -15
View File
@@ -2,6 +2,7 @@ import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { Button } from '@/components/ui/button';
import { getServerSession } from '@/lib/auth';
import { SignOutButton } from './sign-out-button';
@@ -19,29 +20,23 @@ function HomeContent({ userName }: { userName: string | null }) {
return (
<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>
<p className="text-lg text-slate-600">{t('tagline')}</p>
<p className="text-muted-foreground text-lg">{t('tagline')}</p>
{userName ? (
<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')} />
</div>
) : (
<div className="flex flex-wrap gap-3">
<Link
href="/sign-up"
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"
>
{t('ctaPrimary')}
</Link>
<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>
<Button asChild>
<Link href="/sign-up">{t('ctaPrimary')}</Link>
</Button>
<Button asChild variant="outline">
<Link href="/sign-in">{t('ctaSecondary')}</Link>
</Button>
</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>
);
}
@@ -3,6 +3,7 @@
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { authClient } from '@/lib/auth-client';
export function SignOutButton({ label }: { label: string }) {
@@ -17,13 +18,8 @@ export function SignOutButton({ label }: { label: string }) {
}
return (
<button
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"
>
<Button type="button" variant="outline" size="sm" onClick={handleClick} disabled={pending}>
{label}
</button>
</Button>
);
}