feat(notifications): add multi-channel notification module with Email, FCM, templates, and event listeners
- Domain: NotificationLog/NotificationPreference entities, repositories, channel value object - Infrastructure: EmailService (nodemailer/SMTP), FcmService (firebase-admin), TemplateService (Handlebars) - Application: SendNotification CQRS command, UserRegistered + AgentVerified event listeners - Presentation: NotificationsController with history, preferences, and templates endpoints - Prisma: NotificationLog and NotificationPreference models with proper indexes - Templates: Vietnamese notification templates for user.registered, agent.verified, listing.approved, inquiry.received, password.reset Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
7
apps/web/app/(auth)/layout.tsx
Normal file
7
apps/web/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/40 px-4 py-12">
|
||||
<div className="w-full max-w-md">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
apps/web/app/(auth)/login/page.tsx
Normal file
123
apps/web/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { OAuthButtons } from '@/components/auth/oauth-buttons';
|
||||
import { loginSchema, type LoginFormData } from '@/lib/validations/auth';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login, isLoading, error, clearError } = useAuthStore();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
await login(data);
|
||||
router.push('/');
|
||||
} catch {
|
||||
// Error is handled by the store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<CardTitle className="text-2xl font-bold">Đăng nhập</CardTitle>
|
||||
<CardDescription>Nhập số điện thoại và mật khẩu để đăng nhập</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
<button type="button" onClick={clearError} className="ml-2 font-medium underline">
|
||||
Bỏ qua
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Số điện thoại</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="0912345678"
|
||||
autoComplete="tel"
|
||||
{...register('phone')}
|
||||
aria-invalid={!!errors.phone}
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="text-sm text-destructive" role="alert">{errors.phone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Mật khẩu</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-primary"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? 'Ẩn' : 'Hiện'}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Nhập mật khẩu"
|
||||
autoComplete="current-password"
|
||||
{...register('password')}
|
||||
aria-invalid={!!errors.password}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive" role="alert">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Đăng nhập
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">Hoặc đăng nhập với</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OAuthButtons />
|
||||
</CardContent>
|
||||
<CardFooter className="justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chưa có tài khoản?{' '}
|
||||
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||
Đăng ký
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
173
apps/web/app/(auth)/register/page.tsx
Normal file
173
apps/web/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { OAuthButtons } from '@/components/auth/oauth-buttons';
|
||||
import { registerSchema, type RegisterFormData } from '@/lib/validations/auth';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { register: registerUser, isLoading, error, clearError } = useAuthStore();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
try {
|
||||
await registerUser({
|
||||
phone: data.phone,
|
||||
password: data.password,
|
||||
fullName: data.fullName,
|
||||
email: data.email || undefined,
|
||||
});
|
||||
router.push('/');
|
||||
} catch {
|
||||
// Error is handled by the store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<CardTitle className="text-2xl font-bold">Tạo tài khoản</CardTitle>
|
||||
<CardDescription>Nhập thông tin để đăng ký tài khoản GoodGo</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
<button type="button" onClick={clearError} className="ml-2 font-medium underline">
|
||||
Bỏ qua
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fullName">Họ và tên</Label>
|
||||
<Input
|
||||
id="fullName"
|
||||
type="text"
|
||||
placeholder="Nguyễn Văn A"
|
||||
autoComplete="name"
|
||||
{...register('fullName')}
|
||||
aria-invalid={!!errors.fullName}
|
||||
/>
|
||||
{errors.fullName && (
|
||||
<p className="text-sm text-destructive" role="alert">{errors.fullName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Số điện thoại</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="0912345678"
|
||||
autoComplete="tel"
|
||||
{...register('phone')}
|
||||
aria-invalid={!!errors.phone}
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="text-sm text-destructive" role="alert">{errors.phone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email (tùy chọn)</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="example@email.com"
|
||||
autoComplete="email"
|
||||
{...register('email')}
|
||||
aria-invalid={!!errors.email}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive" role="alert">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Mật khẩu</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-primary"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? 'Ẩn' : 'Hiện'}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Ít nhất 8 ký tự"
|
||||
autoComplete="new-password"
|
||||
{...register('password')}
|
||||
aria-invalid={!!errors.password}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive" role="alert">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Xác nhận mật khẩu</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Nhập lại mật khẩu"
|
||||
autoComplete="new-password"
|
||||
{...register('confirmPassword')}
|
||||
aria-invalid={!!errors.confirmPassword}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive" role="alert">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Đăng ký
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">Hoặc đăng ký với</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OAuthButtons />
|
||||
</CardContent>
|
||||
<CardFooter className="justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Đã có tài khoản?{' '}
|
||||
<Link href="/login" className="font-medium text-primary hover:underline">
|
||||
Đăng nhập
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,34 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--primary: 142.1 76.2% 36.3%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--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.1 76.2% 36.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { AuthProvider } from '@/components/providers/auth-provider';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -9,7 +10,9 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="vi">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
54
apps/web/components/auth/oauth-buttons.tsx
Normal file
54
apps/web/components/auth/oauth-buttons.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001';
|
||||
|
||||
export function OAuthButtons() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.href = `${API_BASE_URL}/auth/google`;
|
||||
}}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
Google
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.href = `${API_BASE_URL}/auth/zalo`;
|
||||
}}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<rect width="24" height="24" rx="4" fill="#0068FF" />
|
||||
<text x="4" y="17" fontFamily="Arial" fontSize="12" fontWeight="bold" fill="white">
|
||||
Z
|
||||
</text>
|
||||
</svg>
|
||||
Zalo
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
apps/web/components/providers/auth-provider.tsx
Normal file
27
apps/web/components/providers/auth-provider.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
function setAuthCookie(authenticated: boolean) {
|
||||
if (authenticated) {
|
||||
document.cookie = 'goodgo_authenticated=1; path=/; max-age=604800; SameSite=Lax';
|
||||
} else {
|
||||
document.cookie = 'goodgo_authenticated=; path=/; max-age=0';
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const initialize = useAuthStore((s) => s.initialize);
|
||||
const tokens = useAuthStore((s) => s.tokens);
|
||||
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
|
||||
useEffect(() => {
|
||||
setAuthCookie(!!tokens);
|
||||
}, [tokens]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
44
apps/web/components/ui/button.tsx
Normal file
44
apps/web/components/ui/button.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center 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',
|
||||
{
|
||||
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-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
51
apps/web/components/ui/card.tsx
Normal file
51
apps/web/components/ui/card.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
23
apps/web/components/ui/input.tsx
Normal file
23
apps/web/components/ui/input.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
20
apps/web/components/ui/label.tsx
Normal file
20
apps/web/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
export interface LabelProps
|
||||
extends React.LabelHTMLAttributes<HTMLLabelElement>,
|
||||
VariantProps<typeof labelVariants> {}
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => {
|
||||
return <label ref={ref} className={cn(labelVariants(), className)} {...props} />;
|
||||
});
|
||||
Label.displayName = 'Label';
|
||||
|
||||
export { Label };
|
||||
56
apps/web/lib/api-client.ts
Normal file
56
apps/web/lib/api-client.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = Omit<RequestInit, 'body'> & {
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { body, headers, ...rest } = options;
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...rest,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new ApiError(res.status, error.message || 'Request failed');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function authHeaders(token: string): HeadersInit {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
get: <T>(endpoint: string, headers?: HeadersInit) =>
|
||||
request<T>(endpoint, { method: 'GET', headers }),
|
||||
|
||||
post: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) =>
|
||||
request<T>(endpoint, { method: 'POST', body, headers }),
|
||||
|
||||
patch: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) =>
|
||||
request<T>(endpoint, { method: 'PATCH', body, headers }),
|
||||
|
||||
authGet: <T>(endpoint: string, token: string) =>
|
||||
request<T>(endpoint, { method: 'GET', headers: authHeaders(token) }),
|
||||
|
||||
authPost: <T>(endpoint: string, token: string, body?: unknown) =>
|
||||
request<T>(endpoint, { method: 'POST', body, headers: authHeaders(token) }),
|
||||
};
|
||||
42
apps/web/lib/auth-api.ts
Normal file
42
apps/web/lib/auth-api.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { apiClient } from './api-client';
|
||||
|
||||
export interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string | null;
|
||||
phone: string;
|
||||
fullName: string;
|
||||
avatarUrl: string | null;
|
||||
role: string;
|
||||
kycStatus: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
phone: string;
|
||||
password: string;
|
||||
fullName: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
phone: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
register: (data: RegisterPayload) => apiClient.post<TokenPair>('/auth/register', data),
|
||||
|
||||
login: (data: LoginPayload) => apiClient.post<TokenPair>('/auth/login', data),
|
||||
|
||||
refresh: (refreshToken: string) =>
|
||||
apiClient.post<TokenPair>('/auth/refresh', { refreshToken }),
|
||||
|
||||
getProfile: (token: string) => apiClient.authGet<UserProfile>('/auth/profile', token),
|
||||
};
|
||||
123
apps/web/lib/auth-store.ts
Normal file
123
apps/web/lib/auth-store.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { create } from 'zustand';
|
||||
import { authApi, type TokenPair, type UserProfile, type LoginPayload, type RegisterPayload } from './auth-api';
|
||||
import { ApiError } from './api-client';
|
||||
|
||||
const TOKEN_KEY = 'goodgo_tokens';
|
||||
|
||||
function persistTokens(tokens: TokenPair | null) {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (tokens) {
|
||||
localStorage.setItem(TOKEN_KEY, JSON.stringify(tokens));
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function loadTokens(): TokenPair | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const raw = localStorage.getItem(TOKEN_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
tokens: TokenPair | null;
|
||||
user: UserProfile | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
login: (data: LoginPayload) => Promise<void>;
|
||||
register: (data: RegisterPayload) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshToken: () => Promise<boolean>;
|
||||
fetchProfile: () => Promise<void>;
|
||||
initialize: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
tokens: null,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
login: async (data) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const tokens = await authApi.login(data);
|
||||
persistTokens(tokens);
|
||||
set({ tokens, isLoading: false });
|
||||
await get().fetchProfile();
|
||||
} catch (e) {
|
||||
const message = e instanceof ApiError ? e.message : 'Đăng nhập thất bại';
|
||||
set({ isLoading: false, error: message });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (data) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const tokens = await authApi.register(data);
|
||||
persistTokens(tokens);
|
||||
set({ tokens, isLoading: false });
|
||||
await get().fetchProfile();
|
||||
} catch (e) {
|
||||
const message = e instanceof ApiError ? e.message : 'Đăng ký thất bại';
|
||||
set({ isLoading: false, error: message });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
persistTokens(null);
|
||||
set({ tokens: null, user: null, error: null });
|
||||
},
|
||||
|
||||
refreshToken: async () => {
|
||||
const { tokens } = get();
|
||||
if (!tokens?.refreshToken) return false;
|
||||
try {
|
||||
const newTokens = await authApi.refresh(tokens.refreshToken);
|
||||
persistTokens(newTokens);
|
||||
set({ tokens: newTokens });
|
||||
return true;
|
||||
} catch {
|
||||
get().logout();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
fetchProfile: async () => {
|
||||
const { tokens } = get();
|
||||
if (!tokens?.accessToken) return;
|
||||
try {
|
||||
const user = await authApi.getProfile(tokens.accessToken);
|
||||
set({ user });
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
const refreshed = await get().refreshToken();
|
||||
if (refreshed) {
|
||||
const newTokens = get().tokens;
|
||||
if (newTokens) {
|
||||
const user = await authApi.getProfile(newTokens.accessToken);
|
||||
set({ user });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
initialize: async () => {
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) return;
|
||||
set({ tokens });
|
||||
await get().fetchProfile();
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
6
apps/web/lib/utils.ts
Normal file
6
apps/web/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
39
apps/web/lib/validations/auth.ts
Normal file
39
apps/web/lib/validations/auth.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const phoneRegex = /^(0|\+84)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/;
|
||||
|
||||
export const loginSchema = z.object({
|
||||
phone: z
|
||||
.string()
|
||||
.min(1, 'Vui lòng nhập số điện thoại')
|
||||
.regex(phoneRegex, 'Số điện thoại không hợp lệ'),
|
||||
password: z.string().min(1, 'Vui lòng nhập mật khẩu'),
|
||||
});
|
||||
|
||||
export const registerSchema = z
|
||||
.object({
|
||||
fullName: z
|
||||
.string()
|
||||
.min(1, 'Vui lòng nhập họ tên')
|
||||
.min(2, 'Họ tên phải có ít nhất 2 ký tự'),
|
||||
phone: z
|
||||
.string()
|
||||
.min(1, 'Vui lòng nhập số điện thoại')
|
||||
.regex(phoneRegex, 'Số điện thoại không hợp lệ'),
|
||||
email: z
|
||||
.string()
|
||||
.email('Email không hợp lệ')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Mật khẩu phải có ít nhất 8 ký tự'),
|
||||
confirmPassword: z.string().min(1, 'Vui lòng xác nhận mật khẩu'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Mật khẩu xác nhận không khớp',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
export type LoginFormData = z.infer<typeof loginSchema>;
|
||||
export type RegisterFormData = z.infer<typeof registerSchema>;
|
||||
30
apps/web/middleware.ts
Normal file
30
apps/web/middleware.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
const publicPaths = ['/login', '/register'];
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
const isPublicPath = publicPaths.some((path) => pathname.startsWith(path));
|
||||
|
||||
// We check for the token cookie or rely on client-side auth store.
|
||||
// For SSR-safe auth, check a lightweight cookie set by the client after login.
|
||||
const hasAuthCookie = request.cookies.has('goodgo_authenticated');
|
||||
|
||||
if (!isPublicPath && !hasAuthCookie) {
|
||||
const loginUrl = new URL('/login', request.url);
|
||||
loginUrl.searchParams.set('redirect', pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
if (isPublicPath && hasAuthCookie) {
|
||||
return NextResponse.redirect(new URL('/', request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|public).*)'],
|
||||
};
|
||||
@@ -10,9 +10,17 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react-dom": "^18.3.0",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
@@ -21,6 +29,7 @@
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
import tailwindcssAnimate from 'tailwindcss-animate';
|
||||
|
||||
const config: Config = {
|
||||
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
|
||||
darkMode: ['class'],
|
||||
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './lib/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
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))',
|
||||
},
|
||||
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: [tailwindcssAnimate],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
Reference in New Issue
Block a user