feat: giới thiệu component AuthCard để chuẩn hóa giao diện trang xác thực và cập nhật biến thể nút.

This commit is contained in:
Ho Ngoc Hai
2026-01-05 12:21:30 +07:00
parent dfa045e06c
commit 3998bc5049
17 changed files with 699 additions and 682 deletions

View File

@@ -11,6 +11,7 @@ import { Button } from '@/features/shared/components/ui/button';
import { Input } from '@/features/shared/components/ui/input';
import { useTranslations } from 'next-intl';
import { AuthControls } from '@/features/shared/components/layout/auth-controls';
import { AuthCard } from '@/features/auth/components/auth-card';
/**
* EN: Create login schema with translated messages
@@ -105,170 +106,135 @@ export default function LoginPage() {
};
return (
// EN: Centered login form layout with cosmic background and glassmorphism
// VI: Layout form đăng nhập với nền vũ trụ và hiệu ứng kínhmorphism
<main
role="main"
aria-label={t('auth.login.pageLabel')}
className="min-h-screen flex items-center justify-center relative overflow-hidden bg-bg-primary py-12 px-4 sm:px-6 lg:px-8"
>
{/* EN: X.ai Minimal Design - No cosmic background */}
{/* VI: X.ai Minimal Design - Không có nền vũ trụ */}
<>
<AuthControls />
<div className="w-full max-w-md space-y-8 relative z-10 glass-appear">
<div className="text-center space-y-4">
<div className="flex justify-center">
<div className="p-3 rounded-2xl bg-accent-primary/5 border border-accent-primary/10 shadow-glass-sm">
{/* EN: X.ai Minimal - Static icon (no floating) */}
{/* VI: X.ai Minimal - Icon tĩnh (không float) */}
<AuthCard
title={t('auth.login.title')}
description={t('auth.login.description')}
footer={
<span>
{t('auth.login.noAccount')}{' '}
<Link
href="/register"
className="text-accent-primary hover:text-accent-primary-hover font-medium transition-colors"
>
{t('auth.login.signUp')}
</Link>
</span>
}
>
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
{/* EN: API Error message display / VI: Hiển thị thông báo lỗi API */}
{apiError && (
<div
className="p-3 rounded-lg bg-accent-error/10 border border-accent-error/20 text-accent-error text-sm flex items-center gap-2 animate-in fade-in duration-quick"
role="alert"
>
<svg
width="40"
height="40"
viewBox="0 0 24 24"
className="h-4 w-4 flex-shrink-0"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-text-primary"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
d="M12 2L14.4 9.6H22L15.8 14.2L18.2 21.8L12 17.2L5.8 21.8L8.2 14.2L2 9.6H9.6L12 2Z"
fill="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{apiError}</span>
</div>
</div>
<h1 className="text-4xl font-bold tracking-tight text-text-primary mb-2">
{t('auth.login.title')}
</h1>
<p className="text-text-secondary">{t('auth.login.description')}</p>
</div>
)}
<div className="glass-card p-8 shadow-glass-xl border-border-primary">
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
{/* EN: API Error message display / VI: Hiển thị thông báo lỗi API */}
{apiError && (
<div
className="p-3 rounded-lg bg-accent-error/10 border border-accent-error/20 text-accent-error text-sm flex items-center gap-2 animate-in fade-in duration-quick"
role="alert"
>
<svg
className="h-4 w-4 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{apiError}</span>
</div>
)}
<div className="space-y-4">
{/* EN: Email input field / VI: Trường nhập email */}
<Controller
name="email"
control={control}
render={({ field }) => (
<Input
type="email"
label={t('auth.login.email')}
placeholder="you@example.com"
isInvalid={!!errors.email}
errorMessage={errors.email?.message}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
autoComplete="email"
aria-required="true"
/>
)}
/>
<div className="space-y-4">
{/* EN: Email input field / VI: Trường nhập email */}
{/* EN: Password input field / VI: Trường nhập mật khẩu */}
<div className="space-y-1">
<Controller
name="email"
name="password"
control={control}
render={({ field }) => (
<Input
type="email"
label={t('auth.login.email')}
placeholder="you@example.com"
isInvalid={!!errors.email}
errorMessage={errors.email?.message}
type="password"
label={t('auth.login.password')}
placeholder={t('auth.login.password')}
isInvalid={!!errors.password}
errorMessage={errors.password?.message}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
autoComplete="email"
autoComplete="current-password"
aria-required="true"
/>
)}
/>
{/* EN: Password input field / VI: Trường nhập mật khẩu */}
<div className="space-y-1">
<Controller
name="password"
control={control}
render={({ field }) => (
<Input
type="password"
label={t('auth.login.password')}
placeholder={t('auth.login.password')}
isInvalid={!!errors.password}
errorMessage={errors.password?.message}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
autoComplete="current-password"
aria-required="true"
/>
)}
/>
<div className="flex items-center justify-between pt-2">
<label className="flex items-center gap-2 cursor-pointer group">
<Controller
name="rememberMe"
control={control}
render={({ field }) => (
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className="w-4 h-4 rounded border-glass bg-glass-subtle text-accent-primary focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary cursor-pointer transition-all"
/>
)}
/>
<span className="text-sm text-text-secondary group-hover:text-text-primary transition-colors">
{t('auth.login.rememberMe')}
</span>
</label>
<div className="flex items-center justify-between pt-2">
<label className="flex items-center gap-2 cursor-pointer group">
<Controller
name="rememberMe"
control={control}
render={({ field }) => (
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className="w-4 h-4 rounded border-glass bg-glass-subtle text-accent-primary focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary cursor-pointer transition-all"
/>
)}
/>
<span className="text-sm text-text-secondary group-hover:text-text-primary transition-colors">
{t('auth.login.rememberMe')}
</span>
</label>
<Link
href="/forgot-password"
className="text-sm font-medium text-accent-primary hover:text-accent-primary-hover transition-colors"
>
{t('auth.login.forgotPassword')}
</Link>
</div>
<Link
href="/forgot-password"
className="text-sm font-medium text-accent-primary hover:text-accent-primary-hover transition-colors"
>
{t('auth.login.forgotPassword')}
</Link>
</div>
</div>
</div>
{/* EN: Submit button with loading state / VI: Nút submit với trạng thái loading */}
<Button
type="submit"
variant="brand"
size="lg"
className="w-full mt-4"
isLoading={isLoading || isSubmitting}
isDisabled={isLoading || isSubmitting}
>
{isLoading || isSubmitting
? t('auth.login.signingIn')
: t('auth.login.title')}
</Button>
{/* EN: Sign up link / VI: Link đăng ký */}
<p className="text-sm text-center text-text-tertiary pt-4 border-t border-glass-subtle">
{t('auth.login.noAccount')}{' '}
<Link
href="/register"
className="text-accent-primary hover:text-accent-primary-hover font-medium transition-colors"
>
{t('auth.login.signUp')}
</Link>
</p>
</form>
</div>
</div>
</main>
{/* EN: Submit button with loading state / VI: Nút submit với trạng thái loading */}
<Button
type="submit"
variant="brand"
size="lg"
className="w-full mt-4"
isLoading={isLoading || isSubmitting}
isDisabled={isLoading || isSubmitting}
>
{isLoading || isSubmitting
? t('auth.login.signingIn')
: t('auth.login.title')}
</Button>
</form>
</AuthCard>
</>
);
}

View File

@@ -9,7 +9,7 @@ import { useState, useMemo } from 'react';
import { useAuthStore } from '@/stores/auth-store';
import { Button } from '@/features/shared/components/ui/button';
import { Input } from '@/features/shared/components/ui/input';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/features/shared/components/ui/card';
import { AuthCard } from '@/features/auth/components/auth-card';
import { useTranslations } from 'next-intl';
import { AuthControls } from '@/features/shared/components/layout/auth-controls';
import { cn } from '@/shared/utils';
@@ -222,178 +222,101 @@ export default function RegisterPage() {
};
return (
// EN: Centered register form layout with cosmic background and glassmorphism
// VI: Layout form đăng ký với nền vũ trụ và hiệu ứng kínhmorphism
<main
role="main"
aria-label={t('auth.register.createAccount')}
className="min-h-screen flex items-center justify-center relative overflow-hidden bg-bg-primary py-12 px-4 sm:px-6 lg:px-8"
>
{/* EN: X.ai Minimal Design - No cosmic background */}
{/* VI: X.ai Minimal Design - Không có nền vũ trụ */}
<>
<AuthControls />
<div className="w-full max-w-md space-y-8 relative z-10 glass-appear">
<div className="text-center space-y-4">
<div className="flex justify-center">
<div className="p-3 rounded-2xl bg-accent-primary/5 border border-accent-primary/10 shadow-glass-sm">
{/* EN: X.ai Minimal - Static icon (no floating) */}
{/* VI: X.ai Minimal - Icon tĩnh (không float) */}
<AuthCard
title={t('auth.register.createAccount')}
description={t('auth.register.signUpToStart')}
footer={
<span>
{t('auth.register.alreadyHaveAccount')}{' '}
<Link
href="/login"
className="text-accent-primary hover:text-accent-primary-hover font-medium transition-colors"
>
{t('auth.register.signIn')}
</Link>
</span>
}
>
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
{/* EN: API Error message display / VI: Hiển thị thông báo lỗi API */}
{apiError && (
<div
className="p-3 rounded-lg bg-accent-error/10 border border-accent-error/20 text-accent-error text-sm flex items-center gap-2 animate-in fade-in duration-quick"
role="alert"
>
<svg
width="40"
height="40"
viewBox="0 0 24 24"
className="h-4 w-4 flex-shrink-0"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-text-primary"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
d="M12 2L14.4 9.6H22L15.8 14.2L18.2 21.8L12 17.2L5.8 21.8L8.2 14.2L2 9.6H9.6L12 2Z"
fill="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{apiError}</span>
</div>
</div>
<h1 className="text-4xl font-bold tracking-tight text-text-primary mb-2">
{t('auth.register.createAccount')}
</h1>
<p className="text-text-secondary">
{t('auth.register.signUpToStart')}
</p>
</div>
)}
<div className="glass-card p-8 shadow-glass-xl border-border-primary">
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
{/* EN: API Error message display / VI: Hiển thị thông báo lỗi API */}
{apiError && (
<div
className="p-3 rounded-lg bg-accent-error/10 border border-accent-error/20 text-accent-error text-sm flex items-center gap-2 animate-in fade-in duration-quick"
role="alert"
>
<svg
className="h-4 w-4 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{apiError}</span>
</div>
)}
<div className="space-y-4">
{/* EN: Full name input field / VI: Trường nhập họ tên */}
<Controller
name="fullName"
control={control}
render={({ field }) => (
<Input
type="text"
label={t('auth.register.fullName')}
placeholder="John Doe"
isInvalid={!!errors.fullName}
errorMessage={errors.fullName?.message}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
autoComplete="name"
aria-required="true"
/>
)}
/>
{/* EN: Email input field / VI: Trường nhập email */}
<Controller
name="email"
control={control}
render={({ field }) => (
<Input
type="email"
label={t('auth.register.email')}
placeholder="you@example.com"
isInvalid={!!errors.email}
errorMessage={errors.email?.message}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
autoComplete="email"
aria-required="true"
/>
)}
/>
{/* EN: Password input field / VI: Trường nhập mật khẩu */}
<div className="space-y-2">
<Controller
name="password"
control={control}
render={({ field }) => (
<Input
type="password"
label={t('auth.register.password')}
placeholder={t('auth.register.createStrongPassword')}
isInvalid={!!errors.password}
errorMessage={errors.password?.message}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
autoComplete="new-password"
aria-required="true"
/>
)}
<div className="space-y-4">
{/* EN: Full name input field / VI: Trường nhập họ tên */}
<Controller
name="fullName"
control={control}
render={({ field }) => (
<Input
type="text"
label={t('auth.register.fullName')}
placeholder="John Doe"
isInvalid={!!errors.fullName}
errorMessage={errors.fullName?.message}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
autoComplete="name"
aria-required="true"
/>
)}
/>
{/* EN: Password strength indicator / VI: Chỉ báo độ mạnh mật khẩu */}
{password && (
<div className="space-y-2 px-1">
<div className="flex gap-1.5">
{[1, 2, 3, 4].map((step) => (
<div
key={step}
className={cn(
'h-1 flex-1 rounded-full transition-all duration-500',
passwordStrength.percentage >= step * 25
? getStrengthColor(passwordStrength.strength)
: 'bg-glass-subtle'
)}
/>
))}
</div>
<p
className={cn(
'text-[10px] uppercase tracking-wider font-semibold',
passwordStrength.strength === 'weak'
? 'text-accent-error'
: passwordStrength.strength === 'strong'
? 'text-accent-success'
: 'text-accent-warning'
)}
>
{t(`auth.register.${passwordStrength.feedback}`)}
</p>
</div>
)}
</div>
{/* EN: Email input field / VI: Trường nhập email */}
<Controller
name="email"
control={control}
render={({ field }) => (
<Input
type="email"
label={t('auth.register.email')}
placeholder="you@example.com"
isInvalid={!!errors.email}
errorMessage={errors.email?.message}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
autoComplete="email"
aria-required="true"
/>
)}
/>
{/* EN: Confirm password input field / VI: Trường xác nhận mật khẩu */}
{/* EN: Password input field / VI: Trường nhập mật khẩu */}
<div className="space-y-2">
<Controller
name="confirmPassword"
name="password"
control={control}
render={({ field }) => (
<Input
type="password"
label={t('auth.register.confirmPassword')}
placeholder={t('auth.register.reEnterPassword')}
isInvalid={!!errors.confirmPassword}
errorMessage={errors.confirmPassword?.message}
label={t('auth.register.password')}
placeholder={t('auth.register.createStrongPassword')}
isInvalid={!!errors.password}
errorMessage={errors.password?.message}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
@@ -403,74 +326,114 @@ export default function RegisterPage() {
)}
/>
{/* EN: Terms and conditions checkbox / VI: Checkbox điều khoản và điều kiện */}
<Controller
name="terms"
control={control}
render={({ field }) => (
<div className="space-y-2 pt-2">
<label className="flex items-start gap-2 cursor-pointer group">
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className="mt-1 w-4 h-4 rounded border-glass bg-glass-subtle text-accent-primary focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary cursor-pointer flex-shrink-0 transition-all"
aria-required="true"
aria-invalid={errors.terms ? 'true' : 'false'}
{/* EN: Password strength indicator / VI: Chỉ báo độ mạnh mật khẩu */}
{password && (
<div className="space-y-2 px-1">
<div className="flex gap-1.5">
{[1, 2, 3, 4].map((step) => (
<div
key={step}
className={cn(
'h-1 flex-1 rounded-full transition-all duration-500',
passwordStrength.percentage >= step * 25
? getStrengthColor(passwordStrength.strength)
: 'bg-glass-subtle'
)}
/>
<span className="text-sm text-text-secondary group-hover:text-text-primary transition-colors">
{t('auth.register.agreeToTerms')}{' '}
<Link
href="/terms"
className="text-accent-primary hover:text-accent-primary-hover font-medium transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{t('auth.register.termsAndConditions')}
</Link>
</span>
</label>
{errors.terms && (
<p
className="text-sm text-accent-error flex items-center gap-1 ml-6 animate-in fade-in duration-quick"
role="alert"
>
<span>{errors.terms.message}</span>
</p>
)}
))}
</div>
)}
/>
<p
className={cn(
'text-[10px] uppercase tracking-wider font-semibold',
passwordStrength.strength === 'weak'
? 'text-accent-error'
: passwordStrength.strength === 'strong'
? 'text-accent-success'
: 'text-accent-warning'
)}
>
{t(`auth.register.${passwordStrength.feedback}`)}
</p>
</div>
)}
</div>
{/* EN: Submit button with loading state / VI: Nút submit với trạng thái loading */}
<Button
type="submit"
variant="brand"
size="lg"
className="w-full mt-4"
isLoading={isLoading || isSubmitting}
isDisabled={isLoading || isSubmitting}
>
{isLoading || isSubmitting
? t('auth.register.creatingAccount')
: t('auth.register.createAccount')}
</Button>
{/* EN: Confirm password input field / VI: Trường xác nhận mật khẩu */}
<Controller
name="confirmPassword"
control={control}
render={({ field }) => (
<Input
type="password"
label={t('auth.register.confirmPassword')}
placeholder={t('auth.register.reEnterPassword')}
isInvalid={!!errors.confirmPassword}
errorMessage={errors.confirmPassword?.message}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
autoComplete="new-password"
aria-required="true"
/>
)}
/>
{/* EN: Sign in link / VI: Link đăng nhập */}
<p className="text-sm text-center text-text-tertiary pt-4 border-t border-glass-subtle">
{t('auth.register.alreadyHaveAccount')}{' '}
<Link
href="/login"
className="text-accent-primary hover:text-accent-primary-hover font-medium transition-colors"
>
{t('auth.register.signIn')}
</Link>
</p>
</form>
</div>
</div>
</main>
{/* EN: Terms and conditions checkbox / VI: Checkbox điều khoản và điều kiện */}
<Controller
name="terms"
control={control}
render={({ field }) => (
<div className="space-y-2 pt-2">
<label className="flex items-start gap-2 cursor-pointer group">
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className="mt-1 w-4 h-4 rounded border-glass bg-glass-subtle text-accent-primary focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary cursor-pointer flex-shrink-0 transition-all"
aria-required="true"
aria-invalid={errors.terms ? 'true' : 'false'}
/>
<span className="text-sm text-text-secondary group-hover:text-text-primary transition-colors">
{t('auth.register.agreeToTerms')}{' '}
<Link
href="/terms"
className="text-accent-primary hover:text-accent-primary-hover font-medium transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{t('auth.register.termsAndConditions')}
</Link>
</span>
</label>
{errors.terms && (
<p
className="text-sm text-accent-error flex items-center gap-1 ml-6 animate-in fade-in duration-quick"
role="alert"
>
<span>{errors.terms.message}</span>
</p>
)}
</div>
)}
/>
</div>
{/* EN: Submit button with loading state / VI: Nút submit với trạng thái loading */}
<Button
type="submit"
variant="brand"
size="lg"
className="w-full mt-4"
isLoading={isLoading || isSubmitting}
isDisabled={isLoading || isSubmitting}
>
{isLoading || isSubmitting
? t('auth.register.creatingAccount')
: t('auth.register.createAccount')}
</Button>
</form>
</AuthCard>
</>
);
}

View File

@@ -3,6 +3,7 @@
import React, { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, User, Mail, Calendar, Shield, Edit } from 'lucide-react';
import { Role } from '@goodgo/types';
import { useUsersStore } from '../../../../stores/users-store';
import { UserCard, UserForm } from '../../../../features/shared/components/users';
import { Button } from '../../../../features/shared/components/ui/button';
@@ -40,9 +41,9 @@ export default function AdminUserDetailPage() {
const [isEditing, setIsEditing] = useState(false);
// Check permissions
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const canEdit = currentUser?.role === 'SUPER_ADMIN' ||
(currentUser?.role === 'ADMIN' && user?.role !== 'SUPER_ADMIN');
const isAdmin = currentUser?.role === Role.ADMIN || currentUser?.role === Role.SUPER_ADMIN;
const canEdit = currentUser?.role === Role.SUPER_ADMIN ||
(currentUser?.role === Role.ADMIN && user?.role !== Role.SUPER_ADMIN);
// Load user data
useEffect(() => {
@@ -88,7 +89,7 @@ export default function AdminUserDetailPage() {
<Button onClick={() => fetchUser(userId)}>
Try Again
</Button>
<Button variant="outline" onClick={() => router.back()}>
<Button variant="secondary" onClick={() => router.back()}>
Go Back
</Button>
</div>
@@ -114,160 +115,158 @@ export default function AdminUserDetailPage() {
}
return (
<AuthGuard requiredRoles={['ADMIN', 'SUPER_ADMIN']}>
<AuthGuard requiredRoles={[Role.ADMIN, Role.SUPER_ADMIN]}>
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto p-6">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<Button
variant="outline"
size="sm"
onClick={() => router.back()}
className="flex items-center space-x-2"
>
<ArrowLeft className="w-4 h-4" />
<span>Back</span>
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">User Details</h1>
<p className="text-gray-600">{user.email}</p>
<div className="max-w-4xl mx-auto p-6">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<Button
variant="secondary"
size="sm"
onClick={() => router.back()}
className="flex items-center space-x-2"
>
<ArrowLeft className="w-4 h-4" />
<span>Back</span>
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">User Details</h1>
<p className="text-gray-600">{user.email}</p>
</div>
</div>
{canEdit && !isEditing && (
<Button
onClick={() => setIsEditing(true)}
className="flex items-center space-x-2"
>
<Edit className="w-4 h-4" />
<span>Edit User</span>
</Button>
)}
</div>
{canEdit && !isEditing && (
<Button
onClick={() => setIsEditing(true)}
className="flex items-center space-x-2"
>
<Edit className="w-4 h-4" />
<span>Edit User</span>
</Button>
)}
</div>
{/* Error Display */}
{error && (
<Card className="p-4 mb-6 bg-red-50 border-red-200">
<div className="flex items-center justify-between">
<p className="text-red-800">{error}</p>
<Button variant="outline" size="sm" onClick={clearError}>
Dismiss
</Button>
</div>
</Card>
)}
{/* Content */}
{isEditing ? (
<UserForm
user={user}
loading={isLoadingUser}
onSubmit={handleEditSubmit}
onCancel={() => setIsEditing(false)}
/>
) : (
<div className="space-y-6">
{/* User Card */}
<UserCard
user={user}
showAdminActions={false}
className="mb-6"
/>
{/* User Details Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Account Information */}
<Card className="p-6">
<div className="flex items-center space-x-3 mb-4">
<User className="w-6 h-6 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900">Account Information</h3>
</div>
<dl className="space-y-3">
<div>
<dt className="text-sm font-medium text-gray-500">User ID</dt>
<dd className="text-sm text-gray-900 font-mono">{user.id}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="text-sm text-gray-900">{user.email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Role</dt>
<dd className="text-sm text-gray-900">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.role === 'SUPER_ADMIN' ? 'bg-red-100 text-red-800' :
user.role === 'ADMIN' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{user.role}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Status</dt>
<dd className="text-sm text-gray-900">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</dd>
</div>
</dl>
</Card>
{/* Account Timeline */}
<Card className="p-6">
<div className="flex items-center space-x-3 mb-4">
<Calendar className="w-6 h-6 text-green-600" />
<h3 className="text-lg font-semibold text-gray-900">Account Timeline</h3>
</div>
<dl className="space-y-3">
<div>
<dt className="text-sm font-medium text-gray-500">Created</dt>
<dd className="text-sm text-gray-900">
{new Date(user.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Last Updated</dt>
<dd className="text-sm text-gray-900">
{new Date(user.updatedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</dd>
</div>
</dl>
</Card>
</div>
{/* Activity Logs (Placeholder) */}
<Card className="p-6">
<div className="flex items-center space-x-3 mb-4">
<Shield className="w-6 h-6 text-purple-600" />
<h3 className="text-lg font-semibold text-gray-900">Recent Activity</h3>
</div>
<div className="text-center py-8">
<p className="text-gray-500">
Activity logs will be displayed here in a future update.
</p>
{/* Error Display */}
{error && (
<Card className="p-4 mb-6 bg-red-50 border-red-200">
<div className="flex items-center justify-between">
<p className="text-red-800">{error}</p>
<Button variant="secondary" size="sm" onClick={clearError}>
Dismiss
</Button>
</div>
</Card>
</div>
)}
)}
{/* Content */}
{isEditing ? (
<UserForm
user={user}
loading={isLoadingUser}
onSubmit={handleEditSubmit}
onCancel={() => setIsEditing(false)}
/>
) : (
<div className="space-y-6">
{/* User Card */}
<UserCard
user={user}
showAdminActions={false}
className="mb-6"
/>
{/* User Details Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Account Information */}
<Card className="p-6">
<div className="flex items-center space-x-3 mb-4">
<User className="w-6 h-6 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900">Account Information</h3>
</div>
<dl className="space-y-3">
<div>
<dt className="text-sm font-medium text-gray-500">User ID</dt>
<dd className="text-sm text-gray-900 font-mono">{user.id}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="text-sm text-gray-900">{user.email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Role</dt>
<dd className="text-sm text-gray-900">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.role === 'SUPER_ADMIN' ? 'bg-red-100 text-red-800' :
user.role === 'ADMIN' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{user.role}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Status</dt>
<dd className="text-sm text-gray-900">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</dd>
</div>
</dl>
</Card>
{/* Account Timeline */}
<Card className="p-6">
<div className="flex items-center space-x-3 mb-4">
<Calendar className="w-6 h-6 text-green-600" />
<h3 className="text-lg font-semibold text-gray-900">Account Timeline</h3>
</div>
<dl className="space-y-3">
<div>
<dt className="text-sm font-medium text-gray-500">Created</dt>
<dd className="text-sm text-gray-900">
{new Date(user.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Last Updated</dt>
<dd className="text-sm text-gray-900">
{new Date(user.updatedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</dd>
</div>
</dl>
</Card>
</div>
{/* Activity Logs (Placeholder) */}
<Card className="p-6">
<div className="flex items-center space-x-3 mb-4">
<Shield className="w-6 h-6 text-purple-600" />
<h3 className="text-lg font-semibold text-gray-900">Recent Activity</h3>
</div>
<div className="text-center py-8">
<p className="text-gray-500">
Activity logs will be displayed here in a future update.
</p>
</div>
</Card>
</div>
)}
</div>
</div>
</div>
</AuthGuard>
);
}

View File

@@ -2,6 +2,7 @@
import React, { useState, useEffect } from 'react';
import { Plus, Search, Filter, ArrowLeft } from 'lucide-react';
import { Role } from '@goodgo/types';
import { useUsersStore } from '../../../stores/users-store';
import { UsersTable, UserForm } from '../../../features/shared/components/users';
import { Button } from '../../../features/shared/components/ui/button';
@@ -48,19 +49,19 @@ export default function AdminUsersPage() {
const [editingUser, setEditingUser] = useState<any>(null);
// Check if current user has admin permissions
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN';
const isAdmin = currentUser?.role === Role.ADMIN || currentUser?.role === Role.SUPER_ADMIN;
const isSuperAdmin = currentUser?.role === Role.SUPER_ADMIN;
// Load users on mount and when filters change
useEffect(() => {
if (isAdmin) {
const params = {
search: searchQuery || undefined,
role: selectedRole !== 'all' ? selectedRole : undefined,
role: selectedRole !== 'all' ? (selectedRole as Role) : undefined,
isActive: selectedStatus !== 'all' ? selectedStatus === 'active' : undefined,
limit: 20,
};
fetchUsers(params);
fetchUsers(params as any);
}
}, [isAdmin, searchQuery, selectedRole, selectedStatus, fetchUsers]);
@@ -200,74 +201,74 @@ export default function AdminUsersPage() {
</p>
</div>
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
placeholder="Search users..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
placeholder="Search users..."
value={searchQuery}
onChange={setSearchQuery}
className="pl-10"
/>
</div>
</div>
<select
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Roles</option>
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
{isSuperAdmin && <option value="SUPER_ADMIN">Super Admin</option>}
</select>
<select
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Roles</option>
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
{isSuperAdmin && <option value="SUPER_ADMIN">Super Admin</option>}
</select>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</Card>
{/* Error Display */}
{error && (
<Card className="p-4 mb-6 bg-red-50 border-red-200">
<div className="flex items-center justify-between">
<p className="text-red-800">{error}</p>
<Button variant="secondary" size="sm" onClick={clearError}>
Dismiss
</Button>
</div>
</Card>
)}
{/* Error Display */}
{error && (
<Card className="p-4 mb-6 bg-red-50 border-red-200">
<div className="flex items-center justify-between">
<p className="text-red-800">{error}</p>
<Button variant="outline" size="sm" onClick={clearError}>
Dismiss
</Button>
</div>
</Card>
)}
{/* Users Table */}
<UsersTable
users={users}
loading={isLoading}
onEditUser={setEditingUser}
onDeleteUser={handleDeleteUser}
onToggleUserStatus={handleToggleUserStatus}
onBulkAction={handleBulkAction}
showBulkActions={isSuperAdmin}
/>
{/* Users Table */}
<UsersTable
users={users}
loading={isLoading}
onEditUser={setEditingUser}
onDeleteUser={handleDeleteUser}
onToggleUserStatus={handleToggleUserStatus}
onBulkAction={handleBulkAction}
showBulkActions={isSuperAdmin}
/>
{/* Pagination Info */}
{pagination && (
<div className="mt-6 text-center text-gray-600">
Showing {users.length} of {pagination.total} users
(Page {pagination.page} of {pagination.totalPages})
</div>
)}
{/* Pagination Info */}
{pagination && (
<div className="mt-6 text-center text-gray-600">
Showing {users.length} of {pagination.total} users
(Page {pagination.page} of {pagination.totalPages})
</div>
)}
{/* Create User Modal */}
{showCreateModal && (
@@ -300,17 +301,17 @@ export default function AdminUsersPage() {
);
return (
<AuthGuard requiredRoles={['ADMIN', 'SUPER_ADMIN']}>
<AuthGuard requiredRoles={[Role.ADMIN, Role.SUPER_ADMIN]}>
<ResponsiveLayout
header={mobileHeader}
showHeader={true}
enablePullToRefresh={true}
onRefresh={async () => {
await fetchUsers();
}}
>
{pageContent}
</ResponsiveLayout>
header={mobileHeader}
showHeader={true}
enablePullToRefresh={true}
onRefresh={async () => {
await fetchUsers();
}}
>
{pageContent}
</ResponsiveLayout>
</AuthGuard>
);
}

View File

@@ -127,7 +127,7 @@ export default function DashboardPage() {
{!sidebarCollapsed && (
<div className="border-t pt-4">
<Button
variant="outline"
variant="secondary"
size="sm"
onClick={handleLogout}
className="w-full flex items-center justify-center"
@@ -237,7 +237,7 @@ export default function DashboardPage() {
<Button
onClick={() => router.push('/chat')}
className="w-full flex items-center justify-start"
variant="outline"
variant="secondary"
>
<MessageCircle className="w-4 h-4 mr-3" />
Start New Chat
@@ -245,7 +245,7 @@ export default function DashboardPage() {
<Button
onClick={() => router.push('/profile')}
className="w-full flex items-center justify-start"
variant="outline"
variant="secondary"
>
<User className="w-4 h-4 mr-3" />
Update Profile
@@ -253,7 +253,7 @@ export default function DashboardPage() {
<Button
onClick={() => router.push('/settings')}
className="w-full flex items-center justify-start"
variant="outline"
variant="secondary"
>
<Settings className="w-4 h-4 mr-3" />
Account Settings

View File

@@ -46,10 +46,11 @@ export default function ProfilePage() {
const handleProfileUpdate = async (data: any) => {
try {
// In a real app, this would call an API to update the user profile
// EN: In a real app, this would call an API, then update the store
// VI: Trong app thực tế, phần này sẽ gọi API, sau đó cập nhật store
console.log('Updating profile:', data);
updateProfile(data);
setIsEditing(false);
// Mock success - in real app you'd update the auth store
} catch (error) {
console.error('Failed to update profile:', error);
}
@@ -86,16 +87,14 @@ export default function ProfilePage() {
<h2 className="text-xl font-semibold text-gray-900">{user.email}</h2>
<p className="text-gray-600">Member since {new Date(user.createdAt).getFullYear()}</p>
<div className="flex items-center mt-2">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
user.role === 'SUPER_ADMIN' ? 'bg-red-100 text-red-800' :
user.role === 'ADMIN' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${user.role === 'SUPER_ADMIN' ? 'bg-red-100 text-red-800' :
user.role === 'ADMIN' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{user.role}
</span>
<span className={`ml-3 inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
<span className={`ml-3 inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</div>
@@ -111,11 +110,10 @@ export default function ProfilePage() {
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center space-x-2 px-4 py-2 rounded-md text-sm font-medium transition-all ${
activeTab === tab.id
className={`flex items-center space-x-2 px-4 py-2 rounded-md text-sm font-medium transition-all ${activeTab === tab.id
? 'bg-blue-100 text-blue-700 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
}`}
>
<Icon className="w-4 h-4" />
<span>{tab.label}</span>
@@ -175,9 +173,8 @@ export default function ProfilePage() {
Account Status
</label>
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
<span className={`w-3 h-3 rounded-full ${
user.isActive ? 'bg-green-500' : 'bg-red-500'
}`} />
<span className={`w-3 h-3 rounded-full ${user.isActive ? 'bg-green-500' : 'bg-red-500'
}`} />
<span className="text-gray-900">
{user.isActive ? 'Active' : 'Inactive'}
</span>

View File

@@ -282,10 +282,10 @@ export default function SettingsPage() {
<Button onClick={handleSaveSettings} className="w-full">
Save Settings
</Button>
<Button variant="outline" onClick={handleExportData} className="w-full">
<Button variant="secondary" onClick={handleExportData} className="w-full">
Export My Data
</Button>
<Button variant="outline" onClick={() => window.location.href = '/profile'} className="w-full">
<Button variant="secondary" onClick={() => window.location.href = '/profile'} className="w-full">
Edit Profile
</Button>
</div>
@@ -296,7 +296,7 @@ export default function SettingsPage() {
<h3 className="text-lg font-semibold text-red-900 mb-4">Danger Zone</h3>
<div className="space-y-3">
<Button
variant="outline"
variant="secondary"
onClick={handleDeleteAccount}
className="w-full border-red-300 text-red-700 hover:bg-red-50"
>

View File

@@ -0,0 +1,66 @@
'use client';
import React from 'react';
import { cn } from '@/shared/lib/utils';
import { BrandLogo } from '@/features/shared/components/brand/brand-logo';
interface AuthCardProps {
children: React.ReactNode;
title: string;
description?: string;
className?: string;
footer?: React.ReactNode;
}
/**
* EN: Shared component for consistent Auth page layouts
* VI: Component dùng chung cho layout nhất quán giữa các trang Auth
*/
export function AuthCard({
children,
title,
description,
className,
footer
}: AuthCardProps) {
return (
<div className="flex min-h-[calc(100vh-3.5rem)] flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-md space-y-8 flex flex-col items-center">
{/* EN: Logo & Header / VI: Logo & Tiêu đề */}
<div className="flex flex-col items-center">
<BrandLogo variant="icon" size="lg" className="mb-6" />
<h1 className="text-3xl font-bold text-text-primary tracking-tight">
{title}
</h1>
{description && (
<p className="mt-2 text-sm text-text-secondary">
{description}
</p>
)}
</div>
{/* EN: Main Content / VI: Nội dung chính */}
<div className={cn(
'w-full glass-card p-8 rounded-3xl shadow-glass-lg border border-glass-medium',
'animate-in fade-in zoom-in-95 duration-normal',
className
)}>
{children}
{footer && (
<>
<div className="relative my-8">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-border-primary"></div>
</div>
</div>
<div className="text-center text-sm text-text-secondary">
{footer}
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@
import React from 'react';
import { MessageCircle, User, Settings, Home, Search } from 'lucide-react';
import { cn } from '@/shared/lib/utils';
import { MobileLayout } from './mobile-layout';
import type { BottomNavItem } from './mobile-layout';
/**
* EN: Native-style Bottom Navigation Component
@@ -30,7 +30,7 @@ export interface MobileBottomNavProps {
/** Show badges on items */
showBadges?: Record<string, number>;
/** Custom navigation items */
customItems?: MobileLayout['bottomNavItems'];
customItems?: BottomNavItem[];
/** Hide labels on inactive items */
hideLabels?: boolean;
}
@@ -38,7 +38,7 @@ export interface MobileBottomNavProps {
/**
* Pre-configured bottom navigation items for common apps
*/
const DEFAULT_NAV_ITEMS: MobileLayout['bottomNavItems'] = [
const DEFAULT_NAV_ITEMS: BottomNavItem[] = [
{
id: 'home',
label: 'Home',

View File

@@ -23,7 +23,7 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { cn } from '@/shared/utils';
import { cn } from '@/shared/lib/utils';
export interface MobileLayoutProps {
/**

View File

@@ -27,7 +27,7 @@ const buttonVariants = cva(
// Base styles - common to all variants
[
'inline-flex items-center justify-center',
'rounded-lg font-medium',
'rounded-xl font-medium',
'transition-all duration-quick ease-glide',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'focus-visible:ring-glass-focus focus-visible:ring-offset-bg-primary',
@@ -39,8 +39,8 @@ const buttonVariants = cva(
variant: {
// Primary glass button (subtle x.ai style)
primary: [
'bg-glass text-text-primary',
'border border-glass hover:border-glass-hover',
'bg-glass-medium text-text-primary',
'border border-glass-medium hover:border-glass-hover',
'backdrop-blur-glass',
'shadow-glass hover:shadow-glass-lg',
'hover:bg-glass-hover',

View File

@@ -196,7 +196,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
className={cn(
// Base glass input styles
'glass-input',
'w-full rounded-lg px-3 py-2',
'w-full rounded-xl px-3 py-2',
'text-base text-text-primary placeholder:text-text-tertiary',
'transition-all duration-quick ease-smooth',

View File

@@ -2,10 +2,15 @@
import React from 'react';
import { UserResponse, Role } from '@goodgo/types';
import { Mail, Calendar, Shield, Edit, MoreHorizontal } from 'lucide-react';
import { Mail, Calendar, Shield, Edit, MoreHorizontal, Trash2 } from 'lucide-react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { DropdownMenu } from '../ui/dropdown-menu';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem
} from '../ui/dropdown-menu';
import { Switch } from '../ui/switch';
import { cn } from '@/shared/lib/utils';
@@ -114,26 +119,26 @@ export function UserCard({
{/* Actions */}
{showAdminActions && (
<DropdownMenu
trigger={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
}
items={[
{
label: 'Edit',
icon: <Edit className="w-4 h-4" />,
onClick: () => onEdit?.(user),
},
{
label: 'Delete',
icon: <div className="w-4 h-4 bg-red-500 rounded-full" />,
onClick: () => onDelete?.(user),
destructive: true,
},
]}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => onEdit?.(user)}>
<Edit className="w-4 h-4 mr-2" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete?.(user)}
className="text-red-600 focus:text-red-700 focus:bg-red-50"
>
<Trash2 className="w-4 h-4 mr-2" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</Card>
@@ -201,28 +206,29 @@ export function UserCard({
{showAdminActions && (
<div className="flex space-x-2">
<Button
variant="outline"
variant="secondary"
size="sm"
onClick={() => onEdit?.(user)}
>
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
<DropdownMenu
trigger={
<Button variant="outline" size="sm">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
}
items={[
{
label: 'Delete',
icon: <div className="w-4 h-4 bg-red-500 rounded-full" />,
onClick: () => onDelete?.(user),
destructive: true,
},
]}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => onDelete?.(user)}
className="text-red-600 focus:text-red-700 focus:bg-red-50"
>
<Trash2 className="w-4 h-4 mr-2" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>

View File

@@ -177,13 +177,13 @@ export function UserForm({
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
onChange={(value) => handleInputChange('email', value)}
className={cn(
'pl-10',
errors.email && 'border-red-300 focus:border-red-500 focus:ring-red-500'
)}
placeholder="user@example.com"
disabled={loading}
isDisabled={loading}
/>
</div>
{errors.email && (
@@ -202,12 +202,12 @@ export function UserForm({
id="password"
type="password"
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
onChange={(value) => handleInputChange('password', value)}
className={cn(
errors.password && 'border-red-300 focus:border-red-500 focus:ring-red-500'
)}
placeholder="Enter password"
disabled={loading}
isDisabled={loading}
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
@@ -222,12 +222,12 @@ export function UserForm({
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
onChange={(value) => handleInputChange('confirmPassword', value)}
className={cn(
errors.confirmPassword && 'border-red-300 focus:border-red-500 focus:ring-red-500'
)}
placeholder="Confirm password"
disabled={loading}
isDisabled={loading}
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
@@ -245,7 +245,7 @@ export function UserForm({
<Shield className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 z-10" />
<Select
value={formData.role}
onValueChange={(value) => handleInputChange('role', value as Role)}
onChange={(e) => handleInputChange('role', e.target.value as Role)}
disabled={loading}
>
{roleOptions.map((option) => (
@@ -279,9 +279,9 @@ export function UserForm({
{onCancel && (
<Button
type="button"
variant="outline"
variant="secondary"
onClick={onCancel}
disabled={loading}
isDisabled={loading}
>
<X className="w-4 h-4 mr-2" />
Cancel
@@ -289,7 +289,7 @@ export function UserForm({
)}
<Button
type="submit"
disabled={loading}
isDisabled={loading}
className="min-w-[120px]"
>
{loading ? (

View File

@@ -5,7 +5,12 @@ import { UserResponse, Role } from '@goodgo/types';
import { MoreHorizontal, Edit, Trash2, UserCheck, UserX } from 'lucide-react';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { DropdownMenu } from '../ui/dropdown-menu';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem
} from '../ui/dropdown-menu';
import { Switch } from '../ui/switch';
import { cn } from '@/shared/lib/utils';
@@ -63,7 +68,7 @@ export function UsersTable({
let aValue: any = a[sortField];
let bValue: any = b[sortField];
if (sortField === 'createdAt' || sortField === 'updatedAt') {
if (sortField === 'createdAt') {
aValue = new Date(aValue).getTime();
bValue = new Date(bValue).getTime();
}
@@ -143,7 +148,7 @@ export function UsersTable({
</span>
<div className="flex gap-2">
<Button
variant="outline"
variant="secondary"
size="sm"
onClick={() => handleBulkAction('activate')}
className="text-green-700 border-green-300 hover:bg-green-50"
@@ -152,7 +157,7 @@ export function UsersTable({
Activate
</Button>
<Button
variant="outline"
variant="secondary"
size="sm"
onClick={() => handleBulkAction('deactivate')}
className="text-orange-700 border-orange-300 hover:bg-orange-50"
@@ -161,7 +166,7 @@ export function UsersTable({
Deactivate
</Button>
<Button
variant="outline"
variant="secondary"
size="sm"
onClick={() => handleBulkAction('delete')}
className="text-red-700 border-red-300 hover:bg-red-50"
@@ -262,26 +267,26 @@ export function UsersTable({
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<DropdownMenu
trigger={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
}
items={[
{
label: 'Edit',
icon: <Edit className="w-4 h-4" />,
onClick: () => onEditUser?.(user),
},
{
label: 'Delete',
icon: <Trash2 className="w-4 h-4" />,
onClick: () => onDeleteUser?.(user),
destructive: true,
},
]}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => onEditUser?.(user)}>
<Edit className="w-4 h-4 mr-2" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDeleteUser?.(user)}
className="text-red-600 focus:text-red-700 focus:bg-red-50"
>
<Trash2 className="w-4 h-4 mr-2" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}

View File

@@ -10,7 +10,7 @@
// Các hooks sẽ được thêm vào khi được implement
export * from './use-device-type';
export * from './use-translation';
// export * from './use-translation';
export * from './use-keyboard-shortcuts';
// export * from './use-media-query';
// export * from './use-breakpoint';

View File

@@ -25,6 +25,8 @@ interface AuthState {
fetchUser: () => Promise<void>;
/** EN: OAuth login method / VI: Method đăng nhập OAuth */
oauthLogin: (accessToken: string) => Promise<void>;
/** EN: Update profile method / VI: Method cập nhật profile */
updateProfile: (data: Partial<UserResponse>) => void;
}
/**
@@ -188,6 +190,18 @@ export const useAuthStore = create<AuthState>()(
throw error;
}
},
/**
* EN: Update user profile in store
* VI: Cập nhật hồ sơ người dùng trong store
*
* @param data - Updated user data / Dữ liệu người dùng đã cập nhật
*/
updateProfile: (data: Partial<UserResponse>) => {
set((state) => ({
user: state.user ? { ...state.user, ...data } : null,
}));
},
}),
{
// EN: Persist auth state to localStorage