chore: Remove the web-client application, add a local database initialization script, and update service Dockerfiles.
@@ -29,8 +29,7 @@ COPY . .
|
||||
# VI: Build và publish
|
||||
RUN dotnet publish src/WebClientTpos.Server/WebClientTpos.Server.csproj \
|
||||
-c Release \
|
||||
-o /app/publish \
|
||||
--no-restore
|
||||
-o /app/publish
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Stage 2: Runtime
|
||||
@@ -38,6 +37,11 @@ RUN dotnet publish src/WebClientTpos.Server/WebClientTpos.Server.csproj \
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# EN: Install ICU libs for globalization support (Alpine doesn't include them)
|
||||
# VI: Cài đặt ICU libs cho hỗ trợ globalization (Alpine không bao gồm sẵn)
|
||||
RUN apk add --no-cache icu-libs
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
|
||||
# EN: Create non-root user for security
|
||||
# VI: Tạo user không phải root để bảo mật
|
||||
RUN adduser -D -u 1000 appuser && chown -R appuser /app
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Bypass Traefik due to Docker provider error
|
||||
NEXT_PUBLIC_API_URL=http://localhost/api/v1
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"@goodgo/eslint-config"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"es2020": true
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"**/*.stories.ts",
|
||||
"**/*.stories.tsx",
|
||||
"**/test/**",
|
||||
"**/__tests__/**",
|
||||
"**/e2e/**",
|
||||
"playwright.config.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
# Storybook Configuration
|
||||
|
||||
## Overview / Tổng quan
|
||||
|
||||
This Storybook setup is configured for the GoodGo web-client application with support for:
|
||||
|
||||
- **Next.js 14+** with App Router
|
||||
- **TypeScript** with strict type checking
|
||||
- **Tailwind CSS** with custom theme variables
|
||||
- **Theme switching** (dark/light mode) with Context API
|
||||
- **Accessibility testing** with @storybook/addon-a11y
|
||||
- **Documentation** with @storybook/addon-docs
|
||||
|
||||
## Getting Started / Bắt đầu
|
||||
|
||||
### Running Storybook / Chạy Storybook
|
||||
|
||||
```bash
|
||||
# From the web-client directory / Từ thư mục web-client
|
||||
pnpm storybook
|
||||
|
||||
# Or from the root / Hoặc từ thư mục gốc
|
||||
cd apps/web-client && pnpm storybook
|
||||
```
|
||||
|
||||
Storybook will start on `http://localhost:6006`
|
||||
|
||||
### Building Storybook / Build Storybook
|
||||
|
||||
```bash
|
||||
pnpm build-storybook
|
||||
```
|
||||
|
||||
This creates a static build in the `storybook-static` directory.
|
||||
|
||||
## Configuration / Cấu hình
|
||||
|
||||
### Main Configuration (`main.ts`)
|
||||
|
||||
- **Stories location**: `../src/**/*.stories.@(js|jsx|mjs|ts|tsx)`
|
||||
- **Framework**: `@storybook/nextjs-vite`
|
||||
- **Path aliases**: `@/*` maps to `./src/*`
|
||||
|
||||
### Preview Configuration (`preview.ts`)
|
||||
|
||||
- **Theme Provider**: All stories are wrapped with `ThemeProvider` for theme support
|
||||
- **Global Styles**: Imports `globals.css` for Tailwind CSS and theme variables
|
||||
- **Accessibility**: Configured with a11y addon for accessibility testing
|
||||
- **Backgrounds**: Pre-configured dark and light backgrounds
|
||||
|
||||
## Writing Stories / Viết Stories
|
||||
|
||||
### Basic Story Example / Ví dụ Story cơ bản
|
||||
|
||||
```tsx
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Button } from './button';
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'UI/Button',
|
||||
component: Button,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
children: 'Button',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Story with Theme Context / Story với Theme Context
|
||||
|
||||
All stories automatically have access to the `ThemeProvider`, so theme-dependent components work out of the box.
|
||||
|
||||
```tsx
|
||||
export const Themed: Story = {
|
||||
render: () => (
|
||||
<div className="bg-secondary p-4 rounded-lg">
|
||||
<Button>Theme-aware Button</Button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
```
|
||||
|
||||
## Addons / Tiện ích
|
||||
|
||||
### Available Addons / Các addon có sẵn
|
||||
|
||||
1. **@storybook/addon-docs**: Automatic documentation generation
|
||||
2. **@storybook/addon-a11y**: Accessibility testing
|
||||
3. **@storybook/addon-vitest**: Test integration
|
||||
4. **@chromatic-com/storybook**: Visual testing (optional)
|
||||
|
||||
### Using Accessibility Addon / Sử dụng Addon Accessibility
|
||||
|
||||
The a11y addon is automatically configured. Use the "Accessibility" tab in the Storybook UI to check for accessibility issues.
|
||||
|
||||
## Best Practices / Thực hành tốt nhất
|
||||
|
||||
1. **Bilingual Comments**: Always include EN/VI comments in stories
|
||||
2. **Accessibility**: Test all components with the a11y addon
|
||||
3. **Theme Testing**: Test components in both light and dark modes
|
||||
4. **Documentation**: Use the `docs` parameter to add component descriptions
|
||||
5. **Story Organization**: Organize stories by feature/component type
|
||||
|
||||
## Troubleshooting / Khắc phục sự cố
|
||||
|
||||
### Path Alias Issues / Vấn đề Path Alias
|
||||
|
||||
If you encounter import errors with `@/*` paths, ensure:
|
||||
- The `viteFinal` configuration in `main.ts` is correct
|
||||
- TypeScript path mapping in `tsconfig.json` matches
|
||||
|
||||
### Theme Not Working / Theme không hoạt động
|
||||
|
||||
Ensure:
|
||||
- `ThemeProvider` is imported in `preview.ts`
|
||||
- `globals.css` is imported in `preview.ts`
|
||||
- Components use CSS variables from the theme
|
||||
|
||||
### Tailwind Classes Not Working / Tailwind Classes không hoạt động
|
||||
|
||||
Check:
|
||||
- `tailwind.config.js` includes Storybook paths
|
||||
- `globals.css is imported in `preview.ts`
|
||||
- PostCSS is configured correctly
|
||||
|
||||
## Resources / Tài nguyên
|
||||
|
||||
- [Storybook Documentation](https://storybook.js.org/docs)
|
||||
- [Next.js + Storybook Guide](https://storybook.js.org/docs/get-started/nextjs)
|
||||
- [Accessibility Testing](https://storybook.js.org/docs/writing-tests/accessibility-testing)
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { StorybookConfig } from '@storybook/nextjs-vite';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* EN: This function is used to resolve the absolute path of a package.
|
||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||
* VI: Hàm này được sử dụng để resolve đường dẫn tuyệt đối của một package.
|
||||
* Cần thiết trong các dự án sử dụng Yarn PnP hoặc được thiết lập trong monorepo.
|
||||
*/
|
||||
function getAbsolutePath(value: string): any {
|
||||
return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`)));
|
||||
}
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
getAbsolutePath('@chromatic-com/storybook'),
|
||||
getAbsolutePath('@storybook/addon-vitest'),
|
||||
getAbsolutePath('@storybook/addon-a11y'),
|
||||
getAbsolutePath('@storybook/addon-docs'),
|
||||
getAbsolutePath('@storybook/addon-onboarding'),
|
||||
],
|
||||
framework: getAbsolutePath('@storybook/nextjs-vite'),
|
||||
staticDirs: ['../public'],
|
||||
// EN: Configure path aliases for Storybook
|
||||
// VI: Cấu hình path aliases cho Storybook
|
||||
viteFinal: async (config) => {
|
||||
if (config.resolve) {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'@': join(__dirname, '../src'),
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { Preview } from '@storybook/nextjs-vite';
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from '../src/contexts/theme-context';
|
||||
import '../src/app/globals.css';
|
||||
|
||||
/**
|
||||
* EN: Storybook preview configuration with theme support
|
||||
* VI: Cấu hình preview Storybook với hỗ trợ theme
|
||||
*/
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{
|
||||
name: 'dark',
|
||||
value: '#0A0A0A',
|
||||
},
|
||||
{
|
||||
name: 'light',
|
||||
value: '#FFFFFF',
|
||||
},
|
||||
],
|
||||
},
|
||||
// EN: Accessibility addon configuration
|
||||
// VI: Cấu hình addon accessibility
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [
|
||||
{
|
||||
id: 'color-contrast',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
// EN: Decorator to wrap all stories with ThemeProvider
|
||||
// VI: Decorator để bọc tất cả stories với ThemeProvider
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ThemeProvider>
|
||||
<div style={{ padding: '1rem' }}>
|
||||
<Story />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -1,50 +0,0 @@
|
||||
FROM node:25-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Builder stage
|
||||
FROM base AS builder
|
||||
RUN corepack enable pnpm
|
||||
# Copy workspace configuration
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
# Create directory structure and copy all package.json files
|
||||
RUN mkdir -p packages apps services
|
||||
COPY packages/auth-sdk/package.json ./packages/auth-sdk/
|
||||
COPY packages/http-client/package.json ./packages/http-client/
|
||||
COPY packages/logger/package.json ./packages/logger/
|
||||
COPY packages/tracing/package.json ./packages/tracing/
|
||||
COPY packages/types/package.json ./packages/types/
|
||||
COPY packages/config/eslint-config/package.json ./packages/config/eslint-config/
|
||||
COPY packages/config/prettier-config/package.json ./packages/config/prettier-config/
|
||||
COPY packages/config/tsconfig/package.json ./packages/config/tsconfig/
|
||||
COPY apps/web-client/package.json ./apps/web-client/
|
||||
COPY apps/web-admin/package.json ./apps/web-admin/
|
||||
COPY services/iam-service/package.json ./services/iam-service/
|
||||
# Install all dependencies for entire monorepo
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
# Copy all source code
|
||||
COPY packages ./packages
|
||||
COPY apps/web-client ./apps/web-client
|
||||
COPY turbo.json ./
|
||||
# Build using turbo from root (handles dependency order automatically)
|
||||
RUN pnpm turbo build --filter=web-client
|
||||
|
||||
# Production stage
|
||||
FROM base AS runner
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy the entire workspace to preserve pnpm structure
|
||||
COPY --from=builder --chown=nextjs:nodejs /app /app
|
||||
|
||||
WORKDIR /app/apps/web-client
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", ".next/standalone/apps/web-client/server.js"]
|
||||
@@ -1,185 +0,0 @@
|
||||
# GoodGo Platform - Web Client
|
||||
|
||||
> **EN**: Enterprise-grade web client for GoodGo microservices platform
|
||||
> **VI**: Web client cấp doanh nghiệp cho nền tảng microservices GoodGo
|
||||
|
||||
## Features / Tính Năng
|
||||
|
||||
- ✅ **Brand Identity System** - Complete logo suite, favicons, and illustrations
|
||||
- ✅ **Design System** - Professional design tokens with brand colors, gradients, and glassmorphism
|
||||
- ✅ **UI Component Library** - Enhanced components with brand variants (Button, EmptyState, Loading States, Logo)
|
||||
- ✅ **Dark/Light Theme** - Automatic theme switching with system preference
|
||||
- ✅ **Internationalization** - Multi-language support (i18n ready)
|
||||
- ✅ **TypeScript** - Full type safety
|
||||
- ✅ **Tailwind CSS 4** - Modern utility-first styling
|
||||
- ✅ **Next.js 14** - App Router with RSC
|
||||
- ✅ **Accessibility** - WCAG 2.1 AA compliant
|
||||
- ✅ **PWA Ready** - Progressive Web App support
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 14 (App Router)
|
||||
- **Language**: TypeScript 5+
|
||||
- **Styling**: Tailwind CSS 4 (CSS-first)
|
||||
- **State Management**: Zustand
|
||||
- **API Client**: `@goodgo/http-client`
|
||||
- **Testing**: Vitest + Playwright
|
||||
- **Component Development**: Storybook
|
||||
|
||||
##Development / Phát Triển
|
||||
|
||||
```bash
|
||||
# Install dependencies / Cài đặt dependencies
|
||||
pnpm install
|
||||
|
||||
# Start dev server / Khởi động dev server
|
||||
pnpm dev
|
||||
# → http://localhost:3000
|
||||
|
||||
# Start Storybook / Khởi động Storybook
|
||||
pnpm storybook
|
||||
# → http://localhost:6006
|
||||
|
||||
# Build for production / Build cho production
|
||||
pnpm build
|
||||
|
||||
# Type checking / Kiểm tra kiểu
|
||||
pnpm typecheck
|
||||
|
||||
# Lint / Kiểm tra lỗi
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## Environment Variables / Biến Môi Trường
|
||||
|
||||
Create `.env.local` file:
|
||||
|
||||
```bash
|
||||
# API URL
|
||||
NEXT_PUBLIC_API_URL=http://localhost/api/v1
|
||||
```
|
||||
|
||||
## Brand Assets / Tài Sản Thương Hiệu
|
||||
|
||||
Brand assets are located in `/public/brand-assets/`:
|
||||
|
||||
- **Logos**: `/brand-assets/logo/` (full, icon, wordmark variants)
|
||||
- **Icons**: `/brand-assets/icons/` (favicon)
|
||||
- **Illustrations**: `/brand-assets/illustrations/` (empty state, error state)
|
||||
|
||||
Usage in components:
|
||||
|
||||
```tsx
|
||||
import { BrandLogo } from '@/components/ui/brand-logo';
|
||||
import { BRAND } from '@/lib/brand-constants';
|
||||
|
||||
// Logo component
|
||||
<BrandLogo variant="full" size="lg" />
|
||||
|
||||
// Brand constants
|
||||
const primaryColor = BRAND.colors.primary.hex; // #3B82F6
|
||||
```
|
||||
|
||||
## UI Components / Components Giao Diện
|
||||
|
||||
### BrandLogo
|
||||
```tsx
|
||||
import { BrandLogo, BrandLogoLink } from '@/components/ui/brand-logo';
|
||||
|
||||
<BrandLogo variant="full" size="xl" />
|
||||
<BrandLogoLink variant="icon" size="md" href="/" />
|
||||
```
|
||||
|
||||
### Button with Brand Variants
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
<Button variant="brand">Get Started</Button>
|
||||
<Button variant="glass">Learn More</Button>
|
||||
```
|
||||
|
||||
### Empty State
|
||||
```tsx
|
||||
import { EmptyState, ErrorState } from '@/components/ui/empty-state';
|
||||
|
||||
<EmptyState
|
||||
title="No items found"
|
||||
description="Try adding some items"
|
||||
action={{ label: 'Add Item', onClick: handleAdd }}
|
||||
/>
|
||||
```
|
||||
|
||||
### Loading States
|
||||
```tsx
|
||||
import {
|
||||
BrandSpinner,
|
||||
Skeleton,
|
||||
SkeletonCard,
|
||||
LoadingOverlay,
|
||||
ProgressBar
|
||||
} from '@/components/ui/loading-states';
|
||||
|
||||
<BrandSpinner size="lg" color="brand" />
|
||||
<SkeletonCard />
|
||||
<LoadingOverlay show={isLoading} message="Loading..." />
|
||||
<ProgressBar value={progress} showLabel />
|
||||
```
|
||||
|
||||
## Design System / Hệ Thống Thiết Kế
|
||||
|
||||
### Brand Colors
|
||||
|
||||
```css
|
||||
/* Primary (Blue) - Tech & Trust */
|
||||
--brand-primary: #3B82F6;
|
||||
|
||||
/* Secondary (Purple) - Innovation */
|
||||
--brand-secondary: #8B5CF6;
|
||||
|
||||
/* Accent (Cyan) - Energy */
|
||||
--brand-accent: #06B6D4;
|
||||
```
|
||||
|
||||
### Tailwind Utilities
|
||||
|
||||
```tsx
|
||||
// Brand colors
|
||||
<div className="bg-brand-primary text-white" />
|
||||
<div className="bg-brand-gradient" />
|
||||
|
||||
// Glassmorphism
|
||||
<div className="bg-glass-bg backdrop-blur-glass border-glass-border" />
|
||||
|
||||
// Brand shadows
|
||||
<div className="shadow-brand hover:shadow-brand-lg" />
|
||||
```
|
||||
|
||||
## Project Structure / Cấu Trúc Dự Án
|
||||
|
||||
```
|
||||
apps/web-client/
|
||||
├── public/
|
||||
│ └── brand-assets/ # Brand logos, icons, illustrations
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router pages
|
||||
│ ├── components/
|
||||
│ │ └── ui/ # Reusable UI components
|
||||
│ ├── lib/
|
||||
│ │ └── brand-constants.ts # Brand helper functions
|
||||
│ ├── styles/
|
||||
│ │ └── theme.css # Design system tokens
|
||||
│ ├── contexts/ # React contexts (Theme, etc.)
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── providers/ # Provider components
|
||||
│ └── stores/ # Zustand stores
|
||||
└── tailwind.config.js # Tailwind configuration
|
||||
```
|
||||
|
||||
## Contributing / Đóng Góp
|
||||
|
||||
Please read [CONTRIBUTING.md](../../CONTRIBUTING.md) for details.
|
||||
|
||||
## License / Giấy Phép
|
||||
|
||||
Proprietary - GoodGo Platform
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* EN: E2E tests for authentication flows
|
||||
* VI: E2E tests cho các luồng xác thực
|
||||
*/
|
||||
test.describe('Authentication', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// EN: Navigate to login page / VI: Điều hướng đến trang login
|
||||
await page.goto('/login');
|
||||
});
|
||||
|
||||
test('should display login page', async ({ page }) => {
|
||||
// EN: Check for heading "Sign In" / VI: Kiểm tra heading "Sign In"
|
||||
await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible();
|
||||
// EN: Check for email input with exact placeholder / VI: Kiểm tra input email với placeholder chính xác
|
||||
await expect(page.getByPlaceholder('you@example.com')).toBeVisible();
|
||||
// EN: Check for password input with placeholder / VI: Kiểm tra input password với placeholder
|
||||
await expect(page.getByPlaceholder('Password')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show validation errors for empty form', async ({ page }) => {
|
||||
const submitButton = page.getByRole('button', { name: 'Sign In' });
|
||||
await submitButton.click();
|
||||
// EN: Wait for validation to complete / VI: Đợi validation hoàn thành
|
||||
await page.waitForTimeout(500);
|
||||
// EN: Check for validation errors (email or password required) / VI: Kiểm tra lỗi validation (email hoặc password required)
|
||||
const emailError = page.getByText('Email is required');
|
||||
const passwordError = page.getByText('Password is required');
|
||||
// EN: At least one error should be visible / VI: Ít nhất một lỗi phải hiển thị
|
||||
const hasError = await emailError.isVisible().catch(() => false) ||
|
||||
await passwordError.isVisible().catch(() => false);
|
||||
expect(hasError).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should navigate to register page', async ({ page }) => {
|
||||
await page.getByRole('link', { name: 'Sign up' }).click();
|
||||
await expect(page).toHaveURL(/.*\/register/);
|
||||
});
|
||||
|
||||
test('should navigate to forgot password page', async ({ page }) => {
|
||||
await page.getByRole('link', { name: 'Forgot password?' }).click();
|
||||
await expect(page).toHaveURL(/.*\/forgot-password/);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* EN: E2E tests for chat functionality
|
||||
* VI: E2E tests cho chức năng chat
|
||||
*/
|
||||
test.describe('Chat', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// EN: Navigate to chat page (assuming authenticated) / VI: Điều hướng đến trang chat (giả sử đã authenticated)
|
||||
await page.goto('/chat');
|
||||
});
|
||||
|
||||
test('should display chat interface', async ({ page }) => {
|
||||
// EN: Check for chat input with exact placeholder / VI: Kiểm tra chat input với placeholder chính xác
|
||||
await expect(page.getByPlaceholder('Type your message...')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should send message', async ({ page }) => {
|
||||
const input = page.getByPlaceholder('Type your message...');
|
||||
// EN: Type into the textarea (controlled component) / VI: Nhập vào textarea (controlled component)
|
||||
await input.type('Test message');
|
||||
// EN: Wait for send button to be enabled / VI: Đợi nút send được kích hoạt
|
||||
const sendButton = page.getByRole('button', { name: 'Send message' });
|
||||
await expect(sendButton).toBeEnabled();
|
||||
await sendButton.click();
|
||||
// EN: Check if message appears / VI: Kiểm tra nếu tin nhắn xuất hiện
|
||||
// Note: This would require WebSocket mocking in actual implementation
|
||||
// Lưu ý: Điều này sẽ cần mock WebSocket trong implementation thực tế
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* EN: E2E tests for settings pages.
|
||||
* VI: E2E tests cho các trang settings.
|
||||
*/
|
||||
test.describe('Settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// EN: Seed persisted auth state before app hydration.
|
||||
// VI: Seed trạng thái auth đã persist trước khi app hydrate.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
'auth-storage',
|
||||
JSON.stringify({
|
||||
state: {
|
||||
user: {
|
||||
id: '9d7bb2f8-4a68-4aa4-9b73-530a06039ee3',
|
||||
email: 'tester@goodgo.dev',
|
||||
},
|
||||
isAuthenticated: true,
|
||||
},
|
||||
version: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should render profile settings page', async ({ page }) => {
|
||||
await page.goto('/settings/profile');
|
||||
await expect(page.getByRole('heading', { level: 2, name: 'Profile' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should render api keys settings page', async ({ page }) => {
|
||||
await page.goto('/settings/api-keys');
|
||||
await expect(page.getByRole('heading', { level: 2, name: 'API Keys' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Create API Key/i }).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
// EN: Minimal ESLint flat config for web-client package.
|
||||
// VI: Cấu hình ESLint flat tối thiểu cho package web-client.
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'.next/**',
|
||||
'playwright-report/**',
|
||||
'test-results/**',
|
||||
'coverage/**',
|
||||
'dist/**',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.tsx',
|
||||
'**/*.spec.ts',
|
||||
'**/*.spec.tsx',
|
||||
'**/*.stories.ts',
|
||||
'**/*.stories.tsx',
|
||||
'e2e/**',
|
||||
'.storybook/**',
|
||||
'src/test/**',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooksPlugin,
|
||||
},
|
||||
rules: {
|
||||
'no-debugger': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
6
apps/web-client/next-env.d.ts
vendored
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -1,81 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// EN: Enable React strict mode for development warnings
|
||||
// VI: Bật React strict mode để hiển thị warnings trong development
|
||||
reactStrictMode: true,
|
||||
|
||||
// EN: Output standalone build for container deployment
|
||||
// VI: Output build standalone để deploy trong container
|
||||
output: 'standalone',
|
||||
|
||||
// EN: Image optimization configuration
|
||||
// VI: Cấu hình tối ưu hình ảnh
|
||||
images: {
|
||||
// EN: Enable image optimization with WebP/AVIF formats
|
||||
// VI: Bật tối ưu hình ảnh với định dạng WebP/AVIF
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
// EN: Remote image domains (if needed)
|
||||
// VI: Các domain hình ảnh từ xa (nếu cần)
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
// EN: Device sizes for responsive images
|
||||
// VI: Kích thước thiết bị cho hình ảnh responsive
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||
// EN: Image sizes for different breakpoints
|
||||
// VI: Kích thước hình ảnh cho các breakpoint khác nhau
|
||||
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||
},
|
||||
|
||||
// EN: Environment variables exposed to the browser
|
||||
// VI: Biến môi trường được expose cho browser
|
||||
env: {
|
||||
// EN: Public API URL for client-side API calls
|
||||
// VI: URL API public để gọi API từ client-side
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost/api/v1',
|
||||
},
|
||||
|
||||
// EN: Headers for caching static assets (1 year) - Performance optimization
|
||||
// VI: Headers cho caching static assets (1 năm) - Tối ưu hiệu suất
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/_next/static/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=31536000, immutable',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/images/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=31536000, immutable',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// EN: Compress responses - Performance optimization
|
||||
// VI: Nén responses - Tối ưu hiệu suất
|
||||
compress: true,
|
||||
|
||||
// EN: Remove console.log in production
|
||||
// VI: Xóa console.log trong production
|
||||
...(process.env.NODE_ENV === 'production' && {
|
||||
compiler: {
|
||||
removeConsole: {
|
||||
exclude: ['error', 'warn'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -1,89 +0,0 @@
|
||||
{
|
||||
"name": "@goodgo/web-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@goodgo/http-client": "workspace:*",
|
||||
"@goodgo/types": "workspace:*",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-stately/list": "^3.13.2",
|
||||
"@react-stately/menu": "^3.9.9",
|
||||
"@react-stately/overlays": "^3.6.21",
|
||||
"@react-stately/select": "^3.9.0",
|
||||
"@react-stately/toggle": "^3.9.3",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.24.8",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "^16.1.1",
|
||||
"next-intl": "^4.7.0",
|
||||
"react": "^19.2.3",
|
||||
"react-aria": "^3.45.0",
|
||||
"react-aria-components": "^1.14.0",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"react-stately": "^3.43.0",
|
||||
"zod": "^4.3.5",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/react": "^4.11.0",
|
||||
"@chromatic-com/storybook": "^4.1.3",
|
||||
"@goodgo/eslint-config": "workspace:*",
|
||||
"@goodgo/prettier-config": "workspace:*",
|
||||
"@goodgo/tsconfig": "workspace:*",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@storybook/addon-a11y": "^10.1.11",
|
||||
"@storybook/addon-docs": "^10.1.11",
|
||||
"@storybook/addon-onboarding": "^10.1.11",
|
||||
"@storybook/addon-vitest": "^10.1.11",
|
||||
"@storybook/nextjs-vite": "^10.1.11",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"@vitest/browser-playwright": "^4.0.16",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "^16.1.1",
|
||||
"eslint-plugin-storybook": "^10.1.11",
|
||||
"jsdom": "^28.1.0",
|
||||
"playwright": "^1.57.0",
|
||||
"postcss": "^8.5.6",
|
||||
"storybook": "^10.1.11",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"plugin:storybook/recommended"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* EN: Playwright configuration for E2E tests
|
||||
* VI: Cấu hình Playwright cho E2E tests
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* EN: PostCSS configuration for Tailwind CSS 4
|
||||
* VI: Cấu hình PostCSS cho Tailwind CSS 4
|
||||
*/
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
# Public assets
|
||||
@@ -1,19 +0,0 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!--
|
||||
EN: Favicon - Simplified icon optimized for small sizes
|
||||
VI: Favicon - Icon đơn giản hóa tối ưu cho kích thước nhỏ
|
||||
-->
|
||||
<defs>
|
||||
<linearGradient id="faviconGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8B5CF6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Simplified hexagon -->
|
||||
<path d="M16 4 L25 10 L25 22 L16 28 L7 22 L7 10 Z"
|
||||
fill="url(#faviconGradient)"/>
|
||||
|
||||
<!-- Center dot -->
|
||||
<circle cx="16" cy="16" r="3" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 712 B |
@@ -1,50 +0,0 @@
|
||||
<svg width="400" height="300" viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!--
|
||||
EN: Empty State Illustration - Minimalist design with brand colors
|
||||
VI: Minh họa Empty State - Thiết kế tối giản với màu thương hiệu
|
||||
-->
|
||||
<defs>
|
||||
<linearGradient id="emptyGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:0.2" />
|
||||
<stop offset="100%" style="stop-color:#8B5CF6;stop-opacity:0.2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background circle -->
|
||||
<circle cx="200" cy="150" r="120" fill="url(#emptyGradient)"/>
|
||||
|
||||
<!-- Document icon -->
|
||||
<g transform="translate(150, 100)">
|
||||
<!-- Main rectangle -->
|
||||
<rect x="0" y="0" width="100" height="120" rx="8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
opacity="0.3"/>
|
||||
|
||||
<!-- Lines representing text -->
|
||||
<line x1="15" y1="25" x2="85" y2="25"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
opacity="0.2"/>
|
||||
<line x1="15" y1="45" x2="70" y2="45"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
opacity="0.2"/>
|
||||
<line x1="15" y1="65" x2="85" y2="65"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
opacity="0.2"/>
|
||||
<line x1="15" y1="85" x2="60" y2="85"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
opacity="0.2"/>
|
||||
</g>
|
||||
|
||||
<!-- Plus icon overlay -->
|
||||
<g transform="translate(200, 150)">
|
||||
<circle cx="35" cy="35" r="25" fill="#3B82F6"/>
|
||||
<line x1="35" y1="25" x2="35" y2="45" stroke="#FFFFFF" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="25" y1="35" x2="45" y2="35" stroke="#FFFFFF" stroke-width="3" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,33 +0,0 @@
|
||||
<svg width="400" height="300" viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!--
|
||||
EN: Error State Illustration - Alert triangle with brand styling
|
||||
VI: Minh họa Error State - Tam giác cảnh báo với style thương hiệu
|
||||
-->
|
||||
|
||||
<!-- Background circle with error color -->
|
||||
<circle cx="200" cy="150" r="120"
|
||||
fill="#EF4444"
|
||||
opacity="0.1"/>
|
||||
|
||||
<!-- Alert triangle -->
|
||||
<g transform="translate(200, 150)">
|
||||
<!-- Triangle outline -->
|
||||
<path d="M 0,-70 L 60,50 L -60,50 Z"
|
||||
fill="none"
|
||||
stroke="#EF4444"
|
||||
stroke-width="4"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Exclamation mark -->
|
||||
<line x1="0" y1="-30" x2="0" y2="10"
|
||||
stroke="#EF4444"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"/>
|
||||
<circle cx="0" cy="30" r="4" fill="#EF4444"/>
|
||||
</g>
|
||||
|
||||
<!-- Decorative elements -->
|
||||
<circle cx="120" cy="80" r="8" fill="#EF4444" opacity="0.2"/>
|
||||
<circle cx="280" cy="220" r="12" fill="#EF4444" opacity="0.2"/>
|
||||
<circle cx="290" cy="90" r="6" fill="#EF4444" opacity="0.2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,54 +0,0 @@
|
||||
<svg width="280" height="48" viewBox="0 0 280 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!--
|
||||
EN: GoodGo Platform Full Logo - Icon + Wordmark
|
||||
VI: Logo đầy đủ GoodGo Platform - Icon + Wordmark
|
||||
-->
|
||||
<defs>
|
||||
<linearGradient id="iconGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8B5CF6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="textGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8B5CF6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Icon (scaled to 48x48) -->
|
||||
<g transform="translate(0, 0)">
|
||||
<!-- Main hexagon -->
|
||||
<path d="M24 6 L37.5 15 L37.5 33 L24 42 L10.5 33 L10.5 15 Z"
|
||||
fill="url(#iconGradient)"
|
||||
opacity="0.9"/>
|
||||
|
||||
<!-- Inner hexagon -->
|
||||
<path d="M24 13.5 L31.5 18.75 L31.5 29.25 L24 34.5 L16.5 29.25 L16.5 18.75 Z"
|
||||
fill="#FFFFFF"
|
||||
opacity="0.2"/>
|
||||
|
||||
<!-- Center dot -->
|
||||
<circle cx="24" cy="24" r="3" fill="#FFFFFF"/>
|
||||
</g>
|
||||
|
||||
<!-- Wordmark -->
|
||||
<g transform="translate(60, 0)">
|
||||
<!-- GoodGo text -->
|
||||
<text x="0" y="32"
|
||||
font-family="Inter, sans-serif"
|
||||
font-size="28"
|
||||
font-weight="700"
|
||||
fill="url(#textGradient)">
|
||||
GoodGo
|
||||
</text>
|
||||
|
||||
<!-- Platform text -->
|
||||
<text x="115" y="32"
|
||||
font-family="Inter, sans-serif"
|
||||
font-size="18"
|
||||
font-weight="400"
|
||||
fill="currentColor"
|
||||
opacity="0.6">
|
||||
Platform
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,28 +0,0 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!--
|
||||
EN: GoodGo Platform Logo Icon - Geometric minimalist design
|
||||
VI: Logo Icon GoodGo Platform - Thiết kế hình học tối giản
|
||||
|
||||
Design concept: Overlapping hexagons representing microservices architecture
|
||||
Màu sắc: Brand gradient (Blue to Purple)
|
||||
-->
|
||||
<defs>
|
||||
<linearGradient id="brandGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8B5CF6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main hexagon -->
|
||||
<path d="M32 8 L50 20 L50 44 L32 56 L14 44 L14 20 Z"
|
||||
fill="url(#brandGradient)"
|
||||
opacity="0.9"/>
|
||||
|
||||
<!-- Inner hexagon -->
|
||||
<path d="M32 18 L42 25 L42 39 L32 46 L22 39 L22 25 Z"
|
||||
fill="#FFFFFF"
|
||||
opacity="0.2"/>
|
||||
|
||||
<!-- Center dot -->
|
||||
<circle cx="32" cy="32" r="4" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 996 B |
@@ -1,31 +0,0 @@
|
||||
<svg width="200" height="40" viewBox="0 0 200 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!--
|
||||
EN: GoodGo Platform Wordmark - Clean typography
|
||||
VI: Wordmark GoodGo Platform - Typography sạch sẽ
|
||||
-->
|
||||
<defs>
|
||||
<linearGradient id="textGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8B5CF6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- GoodGo text -->
|
||||
<text x="0" y="28"
|
||||
font-family="Inter, sans-serif"
|
||||
font-size="24"
|
||||
font-weight="700"
|
||||
fill="url(#textGradient)">
|
||||
GoodGo
|
||||
</text>
|
||||
|
||||
<!-- Platform text -->
|
||||
<text x="100" y="28"
|
||||
font-family="Inter, sans-serif"
|
||||
font-size="16"
|
||||
font-weight="400"
|
||||
fill="currentColor"
|
||||
opacity="0.6">
|
||||
Platform
|
||||
</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 896 B |
|
Before Width: | Height: | Size: 722 KiB |
|
Before Width: | Height: | Size: 550 KiB |
|
Before Width: | Height: | Size: 804 KiB |
|
Before Width: | Height: | Size: 598 KiB |
|
Before Width: | Height: | Size: 602 KiB |
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "GoodGo Platform",
|
||||
"short_name": "GoodGo",
|
||||
"description": "Enterprise Microservices Platform - Build, deploy, and scale with confidence",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#3B82F6",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/brand-assets/icons/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"developer tools",
|
||||
"productivity"
|
||||
],
|
||||
"lang": "en-US"
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import LoginPage from '../app/(auth)/login/page';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useTranslation
|
||||
vi.mock('../shared/hooks/use-translation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock auth store
|
||||
vi.mock('../stores/auth-store', () => ({
|
||||
useAuthStore: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Auth Flow Integration', () => {
|
||||
let queryClient: QueryClient;
|
||||
let mockAuthStore: any;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
mockAuthStore = {
|
||||
login: vi.fn(),
|
||||
isLoading: false,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
};
|
||||
|
||||
(useAuthStore as any).mockReturnValue(mockAuthStore);
|
||||
});
|
||||
|
||||
const renderLoginPage = () => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LoginPage />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Login Page', () => {
|
||||
it('renders login form with required fields', () => {
|
||||
renderLoginPage();
|
||||
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/remember me/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation errors for empty fields', async () => {
|
||||
renderLoginPage();
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/validation\.emailRequired/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/validation\.password/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error for invalid email', async () => {
|
||||
renderLoginPage();
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/validation\.email/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls login function on valid form submission', async () => {
|
||||
mockAuthStore.login.mockResolvedValue(undefined);
|
||||
|
||||
renderLoginPage();
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAuthStore.login).toHaveBeenCalledWith('test@example.com', 'password123');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state during login', async () => {
|
||||
mockAuthStore.isLoading = true;
|
||||
mockAuthStore.login.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
renderLoginPage();
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(screen.getByText(/auth\.login\.loginButtonLoading/i)).toBeInTheDocument();
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows error message on login failure', async () => {
|
||||
const errorMessage = 'Invalid credentials';
|
||||
mockAuthStore.login.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
renderLoginPage();
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { UsersTable } from '../features/shared/components/users/UsersTable';
|
||||
import { UserCard } from '../features/shared/components/users/UserCard';
|
||||
import { UserForm } from '../features/shared/components/users/UserForm';
|
||||
|
||||
/**
|
||||
* EN: Smoke tests for users components
|
||||
* VI: Smoke tests cho users components
|
||||
*
|
||||
* These tests ensure components render without crashing and have basic functionality.
|
||||
*/
|
||||
describe('Users Components - Smoke Tests', () => {
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
role: 'USER',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockUsers = [mockUser];
|
||||
|
||||
describe('UsersTable', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<UsersTable users={mockUsers} />);
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
render(<UsersTable users={[]} loading={true} />);
|
||||
expect(screen.getByText('Loading users...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state', () => {
|
||||
render(<UsersTable users={[]} />);
|
||||
expect(screen.getByText('No users found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bulk actions when users selected', () => {
|
||||
// This would require more complex setup with user interactions
|
||||
// For smoke test, just ensure it renders
|
||||
render(<UsersTable users={mockUsers} showBulkActions />);
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserCard', () => {
|
||||
it('renders user information', () => {
|
||||
render(<UserCard user={mockUser} />);
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('USER')).toBeInTheDocument();
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders compact mode', () => {
|
||||
render(<UserCard user={mockUser} compact />);
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows admin actions when enabled', () => {
|
||||
render(<UserCard user={mockUser} showAdminActions />);
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserForm', () => {
|
||||
it('renders create form', () => {
|
||||
render(
|
||||
<UserForm
|
||||
isCreate
|
||||
onSubmit={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Create New User')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders edit form', () => {
|
||||
render(
|
||||
<UserForm
|
||||
user={mockUser}
|
||||
onSubmit={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Edit User')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('test@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation errors', async () => {
|
||||
render(
|
||||
<UserForm
|
||||
isCreate
|
||||
onSubmit={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /create user/i });
|
||||
submitButton.click();
|
||||
|
||||
// Note: Form validation requires react-hook-form setup
|
||||
// This is just a basic smoke test
|
||||
expect(submitButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,299 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { authApi } from '@/services/api/auth.api';
|
||||
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';
|
||||
|
||||
/**
|
||||
* EN: Create forgot password schema with translated messages
|
||||
* VI: Tạo forgot password schema với thông báo đã dịch
|
||||
*/
|
||||
function createForgotPasswordSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, t('validation.emailRequired'))
|
||||
.email(t('validation.email')),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* EN: Forgot Password page component - allows users to request password reset link
|
||||
* VI: Component trang quên mật khẩu - cho phép người dùng yêu cầu link đặt lại mật khẩu
|
||||
*
|
||||
* Features:
|
||||
* - Email input with real-time validation
|
||||
* - Error messages below inputs
|
||||
* - Loading state on button
|
||||
* - Success state with confirmation message
|
||||
* - Link to check email
|
||||
* - Back to login link
|
||||
*
|
||||
* Flow:
|
||||
* 1. Enter email → Send reset link
|
||||
* 2. Check email → Click link
|
||||
* 3. Enter new password → Confirm
|
||||
* 4. Success → Redirect to login
|
||||
*/
|
||||
export default function ForgotPasswordPage() {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
|
||||
// EN: Success state - shows confirmation after email is sent
|
||||
// VI: Trạng thái thành công - hiển thị xác nhận sau khi email được gửi
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [submittedEmail, setSubmittedEmail] = useState('');
|
||||
|
||||
// EN: General error state for API errors
|
||||
// VI: Trạng thái lỗi chung cho lỗi API
|
||||
const [apiError, setApiError] = useState<string>('');
|
||||
|
||||
// EN: Create schema with translations / VI: Tạo schema với translations
|
||||
const forgotPasswordSchema = createForgotPasswordSchema(t);
|
||||
type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
|
||||
|
||||
// EN: React Hook Form setup with Zod resolver
|
||||
// VI: Setup React Hook Form với Zod resolver
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ForgotPasswordFormData>({
|
||||
resolver: zodResolver(forgotPasswordSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* EN: Handle form submission for forgot password
|
||||
* VI: Xử lý submit form để quên mật khẩu
|
||||
*
|
||||
* @param data - Form data validated by Zod / Dữ liệu form đã được validate bởi Zod
|
||||
*/
|
||||
const onSubmit = async (data: ForgotPasswordFormData) => {
|
||||
setApiError('');
|
||||
try {
|
||||
// EN: Request password reset link via API
|
||||
// VI: Yêu cầu link đặt lại mật khẩu qua API
|
||||
const response = await authApi.forgotPassword(data.email);
|
||||
|
||||
if (response.success) {
|
||||
// EN: Show success message and store email for display
|
||||
// VI: Hiển thị thông báo thành công và lưu email để hiển thị
|
||||
setIsSuccess(true);
|
||||
setSubmittedEmail(data.email);
|
||||
} else {
|
||||
setApiError(
|
||||
response.error?.message || t('auth.forgotPassword.failedToSend')
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// EN: Set error message from API response
|
||||
// VI: Đặt thông báo lỗi từ phản hồi API
|
||||
setApiError(err.message || t('errors.generic'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// EN: Centered forgot password form layout with cosmic background and glassmorphism
|
||||
// VI: Layout form quên mật khẩu với nền vũ trụ và hiệu ứng kínhmorphism
|
||||
<main
|
||||
role="main"
|
||||
aria-label={t('auth.forgotPassword.title')}
|
||||
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 md:max-w-[400px] 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) */}
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-text-primary"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-text-primary mb-2">
|
||||
{t('auth.forgotPassword.title')}
|
||||
</h1>
|
||||
<p className="text-text-secondary">
|
||||
{isSuccess
|
||||
? t('auth.forgotPassword.checkEmail')
|
||||
: t('auth.forgotPassword.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-card p-8 shadow-glass-xl border-border-primary">
|
||||
{isSuccess ? (
|
||||
// EN: Success state - show confirmation message
|
||||
// VI: Trạng thái thành công - hiển thị thông báo xác nhận
|
||||
<div className="space-y-6 animate-in fade-in zoom-in duration-500">
|
||||
<div
|
||||
className="p-4 rounded-xl bg-accent-success/10 border border-accent-success/20 text-accent-success flex items-center gap-3"
|
||||
role="alert"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-semibold">
|
||||
{t('auth.forgotPassword.resetLinkSent')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-sm text-text-secondary leading-relaxed">
|
||||
<p>
|
||||
{t('auth.forgotPassword.resetLinkSentDetail', {
|
||||
email: submittedEmail,
|
||||
})}
|
||||
</p>
|
||||
<div className="pt-4 border-t border-glass-subtle">
|
||||
<p className="text-text-tertiary">
|
||||
{t('auth.forgotPassword.checkInbox')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="brand"
|
||||
size="lg"
|
||||
className="w-full mt-4"
|
||||
onPress={() => (window.location.href = '/login')}
|
||||
>
|
||||
{t('auth.forgotPassword.backToLogin')}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-sm text-text-tertiary hover:text-text-primary transition-colors"
|
||||
onClick={() => {
|
||||
setIsSuccess(false);
|
||||
setSubmittedEmail('');
|
||||
}}
|
||||
>
|
||||
{t('auth.forgotPassword.sendToAnotherEmail')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// EN: Form state - email input and submit
|
||||
// VI: Trạng thái form - input email và submit
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
variant="solid"
|
||||
label={t('auth.forgotPassword.email')}
|
||||
placeholder="you@example.com"
|
||||
isInvalid={!!errors.email}
|
||||
errorMessage={errors.email?.message}
|
||||
name={register('email').name}
|
||||
onBlur={register('email').onBlur}
|
||||
onChange={(value) => {
|
||||
// EN: React Aria onChange receives value string, not event
|
||||
// VI: React Aria onChange nhận value string, không phải event
|
||||
setValue('email', value);
|
||||
}}
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
aria-required="true"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="brand"
|
||||
size="lg"
|
||||
className="w-full mt-4"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? t('auth.forgotPassword.sending')
|
||||
: t('auth.forgotPassword.sendResetLink')}
|
||||
</Button>
|
||||
|
||||
<div className="text-center pt-4 border-t border-border-primary/50">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center gap-2 px-6 py-2.5 rounded-xl border border-border-primary bg-bg-secondary text-sm font-semibold text-text-primary hover:bg-bg-tertiary hover:border-border-secondary transition-all group"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 transform group-hover:-translate-x-1 transition-transform"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{t('auth.forgotPassword.backToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useState } 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 { 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
|
||||
* VI: Tạo login schema với thông báo đã dịch
|
||||
*/
|
||||
function createLoginSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, t('validation.emailRequired'))
|
||||
.email(t('validation.email')),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, t('validation.password'))
|
||||
.min(8, t('validation.passwordMin')),
|
||||
rememberMe: z.boolean().optional(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: LoginFormData type - will be inferred from schema in component
|
||||
* VI: Kiểu LoginFormData - sẽ được suy luận từ schema trong component
|
||||
*/
|
||||
|
||||
/**
|
||||
* EN: Login page component for user authentication
|
||||
* VI: Component trang đăng nhập để xác thực người dùng
|
||||
*
|
||||
* Features:
|
||||
* - Email/password inputs with validation
|
||||
* - Real-time error messages
|
||||
* - Remember me checkbox
|
||||
* - Forgot password link
|
||||
* - Loading state on button
|
||||
* - Error handling
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
|
||||
// EN: Next.js router for navigation
|
||||
// VI: Next.js router để điều hướng
|
||||
const router = useRouter();
|
||||
|
||||
// EN: Auth store hooks for login functionality
|
||||
// VI: Auth store hooks cho chức năng đăng nhập
|
||||
const { login, isLoading } = useAuthStore();
|
||||
|
||||
// EN: General error state for API errors
|
||||
// VI: Trạng thái lỗi chung cho lỗi API
|
||||
const [apiError, setApiError] = useState<string>('');
|
||||
|
||||
// EN: Create schema with translations / VI: Tạo schema với translations
|
||||
const loginSchema = createLoginSchema(t);
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
// EN: React Hook Form setup with Zod resolver
|
||||
// VI: Setup React Hook Form với Zod resolver
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* EN: Handle form submission for login
|
||||
* VI: Xử lý submit form để đăng nhập
|
||||
*
|
||||
* @param data - Form data validated by Zod / Dữ liệu form đã được validate bởi Zod
|
||||
*/
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
setApiError('');
|
||||
try {
|
||||
// EN: Attempt login through auth store
|
||||
// VI: Thử đăng nhập thông qua auth store
|
||||
await login(data.email, data.password);
|
||||
// EN: Redirect to dashboard on successful login
|
||||
// VI: Chuyển hướng về dashboard khi đăng nhập thành công
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
// EN: Set error message from API response
|
||||
// VI: Đặt thông báo lỗi từ phản hồi API
|
||||
setApiError(err.message || t('auth.login.loginFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthControls />
|
||||
<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
|
||||
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"
|
||||
variant="solid"
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 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"
|
||||
variant="solid"
|
||||
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-border-primary bg-bg-secondary 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
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 { 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';
|
||||
|
||||
/**
|
||||
* EN: Create register schema with translated messages
|
||||
* VI: Tạo register schema với thông báo đã dịch
|
||||
*/
|
||||
function createRegisterSchema(t: (key: string) => string) {
|
||||
return z
|
||||
.object({
|
||||
fullName: z
|
||||
.string()
|
||||
.min(1, t('validation.fullNameRequired'))
|
||||
.min(2, t('validation.fullNameMin'))
|
||||
.max(100, t('validation.fullNameMax')),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, t('validation.emailRequired'))
|
||||
.email(t('validation.email')),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, t('validation.password'))
|
||||
.min(8, t('validation.passwordMin'))
|
||||
.regex(/[A-Z]/, t('validation.passwordUppercase'))
|
||||
.regex(/[a-z]/, t('validation.passwordLowercase'))
|
||||
.regex(/[0-9]/, t('validation.passwordNumber'))
|
||||
.regex(/[^A-Za-z0-9]/, t('validation.passwordSpecial')),
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(1, t('validation.passwordConfirmRequired')),
|
||||
terms: z.boolean().refine((val) => val === true, {
|
||||
message: t('validation.termsRequired'),
|
||||
}),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: t('validation.passwordConfirm'),
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* EN: Password strength levels
|
||||
* VI: Các mức độ mạnh của mật khẩu
|
||||
*/
|
||||
type PasswordStrength = 'weak' | 'fair' | 'good' | 'strong';
|
||||
|
||||
/**
|
||||
* EN: Calculate password strength based on criteria
|
||||
* VI: Tính toán độ mạnh mật khẩu dựa trên các tiêu chí
|
||||
*
|
||||
* @param password - Password to evaluate / Mật khẩu cần đánh giá
|
||||
* @returns Password strength level / Mức độ mạnh mật khẩu
|
||||
*/
|
||||
function calculatePasswordStrength(password: string): {
|
||||
strength: PasswordStrength;
|
||||
percentage: number;
|
||||
feedback: string;
|
||||
} {
|
||||
if (!password) {
|
||||
return { strength: 'weak', percentage: 0, feedback: '' };
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
const checks = {
|
||||
length: password.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
number: /[0-9]/.test(password),
|
||||
special: /[^A-Za-z0-9]/.test(password),
|
||||
};
|
||||
|
||||
// EN: Calculate score based on criteria / VI: Tính điểm dựa trên tiêu chí
|
||||
if (checks.length) score += 20;
|
||||
if (checks.uppercase) score += 20;
|
||||
if (checks.lowercase) score += 20;
|
||||
if (checks.number) score += 20;
|
||||
if (checks.special) score += 20;
|
||||
|
||||
// EN: Additional points for length / VI: Điểm thêm cho độ dài
|
||||
if (password.length >= 12) score += 10;
|
||||
if (password.length >= 16) score += 10;
|
||||
|
||||
// EN: Cap at 100% / VI: Giới hạn ở 100%
|
||||
score = Math.min(score, 100);
|
||||
|
||||
let strength: PasswordStrength;
|
||||
let feedback: string;
|
||||
|
||||
// EN: This will be translated in the component / VI: Sẽ được dịch trong component
|
||||
if (score < 25) {
|
||||
strength = 'weak';
|
||||
feedback = 'weak';
|
||||
} else if (score < 50) {
|
||||
strength = 'fair';
|
||||
feedback = 'fair';
|
||||
} else if (score < 75) {
|
||||
strength = 'good';
|
||||
feedback = 'good';
|
||||
} else {
|
||||
strength = 'strong';
|
||||
feedback = 'strong';
|
||||
}
|
||||
|
||||
return { strength, percentage: score, feedback };
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Register page component for user registration
|
||||
* VI: Component trang đăng ký để đăng ký người dùng
|
||||
*
|
||||
* Features:
|
||||
* - Full name, email, password, confirm password inputs
|
||||
* - Password strength indicator with visual feedback
|
||||
* - Terms & conditions checkbox
|
||||
* - Real-time validation
|
||||
* - Error handling
|
||||
*/
|
||||
export default function RegisterPage() {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
|
||||
// EN: Next.js router for navigation
|
||||
// VI: Next.js router để điều hướng
|
||||
const router = useRouter();
|
||||
|
||||
// EN: Auth store hooks for registration functionality
|
||||
// VI: Auth store hooks cho chức năng đăng ký
|
||||
const { register: registerUser, isLoading } = useAuthStore();
|
||||
|
||||
// EN: General error state for API errors
|
||||
// VI: Trạng thái lỗi chung cho lỗi API
|
||||
const [apiError, setApiError] = useState<string>('');
|
||||
|
||||
// EN: Create schema with translations / VI: Tạo schema với translations
|
||||
const registerSchema = createRegisterSchema(t);
|
||||
type RegisterFormData = z.infer<typeof registerSchema>;
|
||||
|
||||
// EN: React Hook Form setup with Zod resolver
|
||||
// VI: Setup React Hook Form với Zod resolver
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: {
|
||||
fullName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
terms: false,
|
||||
},
|
||||
});
|
||||
|
||||
// EN: Watch password field for strength calculation
|
||||
// VI: Theo dõi trường password để tính độ mạnh
|
||||
const password = watch('password');
|
||||
|
||||
// EN: Calculate password strength
|
||||
// VI: Tính toán độ mạnh mật khẩu
|
||||
const passwordStrength = useMemo(
|
||||
() => calculatePasswordStrength(password || ''),
|
||||
[password]
|
||||
);
|
||||
|
||||
/**
|
||||
* EN: Handle form submission for registration
|
||||
* VI: Xử lý submit form để đăng ký
|
||||
*
|
||||
* @param data - Form data validated by Zod / Dữ liệu form đã được validate bởi Zod
|
||||
* Note: fullName is collected for UX but not sent to API (backend generates username from email)
|
||||
* Ghi chú: fullName được thu thập cho UX nhưng không gửi đến API (backend tạo username từ email)
|
||||
*/
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
setApiError('');
|
||||
try {
|
||||
// EN: Attempt registration through auth store
|
||||
// VI: Thử đăng ký thông qua auth store
|
||||
// Note: RegisterDto only accepts email, password, confirmPassword
|
||||
// Ghi chú: RegisterDto chỉ chấp nhận email, password, confirmPassword
|
||||
await registerUser(data.email, data.password, data.confirmPassword);
|
||||
// EN: Redirect to home page on successful registration
|
||||
// VI: Chuyển hướng về trang chủ khi đăng ký thành công
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
// EN: Set error message from API response
|
||||
// VI: Đặt thông báo lỗi từ phản hồi API
|
||||
setApiError(err.message || t('auth.register.registrationFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Get color for password strength indicator
|
||||
// VI: Lấy màu cho chỉ báo độ mạnh mật khẩu
|
||||
const getStrengthColor = (strength: PasswordStrength) => {
|
||||
switch (strength) {
|
||||
case 'weak':
|
||||
return 'bg-accent-error'; // Red
|
||||
case 'fair':
|
||||
return 'bg-accent-warning'; // Amber
|
||||
case 'good':
|
||||
return 'bg-accent-warning'; // Yellow (using warning for good)
|
||||
case 'strong':
|
||||
return 'bg-accent-success'; // Green
|
||||
default:
|
||||
return 'bg-border-primary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthControls />
|
||||
<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
|
||||
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"
|
||||
variant="solid"
|
||||
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"
|
||||
variant="solid"
|
||||
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"
|
||||
variant="solid"
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 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-bg-tertiary'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</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: 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"
|
||||
variant="solid"
|
||||
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: 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-border-primary bg-bg-secondary 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"
|
||||
fullWidth
|
||||
className="mt-2"
|
||||
isLoading={isLoading || isSubmitting}
|
||||
isDisabled={isLoading || isSubmitting}
|
||||
>
|
||||
{isLoading || isSubmitting
|
||||
? t('auth.register.signingUp')
|
||||
: t('auth.register.title')}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,601 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* EN: Account Mockup Component
|
||||
* VI: Component Mockup Tài khoản
|
||||
*
|
||||
* This is a visual mockup/demo component for the client account page.
|
||||
* It showcases the account overview, profile information, statistics, and quick actions.
|
||||
*
|
||||
* Đây là component mockup/demo cho trang tài khoản client.
|
||||
* Nó minh họa tổng quan tài khoản, thông tin profile, thống kê, và các hành động nhanh.
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from '@/features/shared/components/ui/card';
|
||||
import { Button } from '@/features/shared/components/ui/button';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
} from '@/features/shared/components/ui/avatar';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Calendar,
|
||||
Shield,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
CreditCard,
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
BarChart3,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
/**
|
||||
* EN: Account Statistics Card
|
||||
* VI: Card Thống kê Tài khoản
|
||||
*/
|
||||
function AccountStatsCard() {
|
||||
const t = useTranslations();
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: t('account.stats.totalChats', { defaultValue: 'Total Chats' }),
|
||||
value: '1,234',
|
||||
change: '+12%',
|
||||
trend: 'up',
|
||||
icon: MessageSquare,
|
||||
color: 'text-accent-primary',
|
||||
},
|
||||
{
|
||||
label: t('account.stats.activeSessions', { defaultValue: 'Active Sessions' }),
|
||||
value: '5',
|
||||
change: '+2',
|
||||
trend: 'up',
|
||||
icon: Activity,
|
||||
color: 'text-accent-success',
|
||||
},
|
||||
{
|
||||
label: t('account.stats.apiCalls', { defaultValue: 'API Calls (This Month)' }),
|
||||
value: '45.2K',
|
||||
change: '+8%',
|
||||
trend: 'up',
|
||||
icon: TrendingUp,
|
||||
color: 'text-accent-info',
|
||||
},
|
||||
{
|
||||
label: t('account.stats.uptime', { defaultValue: 'Uptime' }),
|
||||
value: '99.9%',
|
||||
change: 'Stable',
|
||||
trend: 'stable',
|
||||
icon: BarChart3,
|
||||
color: 'text-accent-success',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Card key={index} hover bordered>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-text-secondary">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-bold text-text-primary">
|
||||
{stat.value}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
{stat.trend === 'up' && (
|
||||
<span className="text-xs font-medium text-accent-success">
|
||||
{stat.change}
|
||||
</span>
|
||||
)}
|
||||
{stat.trend === 'stable' && (
|
||||
<span className="text-xs font-medium text-text-tertiary">
|
||||
{stat.change}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{t('account.stats.vsLastMonth', { defaultValue: 'vs last month' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`rounded-lg bg-bg-tertiary p-3 ${stat.color}`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Quick Actions Card
|
||||
* VI: Card Hành động Nhanh
|
||||
*/
|
||||
function QuickActionsCard() {
|
||||
const t = useTranslations();
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: t('account.actions.updateProfile', { defaultValue: 'Update Profile' }),
|
||||
icon: User,
|
||||
href: '/settings/profile',
|
||||
color: 'text-accent-primary',
|
||||
},
|
||||
{
|
||||
label: t('account.actions.manageApiKeys', { defaultValue: 'Manage API Keys' }),
|
||||
icon: Shield,
|
||||
href: '/settings/api-keys',
|
||||
color: 'text-accent-warning',
|
||||
},
|
||||
{
|
||||
label: t('account.actions.billing', { defaultValue: 'Billing & Subscription' }),
|
||||
icon: CreditCard,
|
||||
href: '/settings/billing',
|
||||
color: 'text-accent-info',
|
||||
},
|
||||
{
|
||||
label: t('account.actions.notifications', { defaultValue: 'Notification Settings' }),
|
||||
icon: Bell,
|
||||
href: '/settings/notifications',
|
||||
color: 'text-accent-secondary',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card hover>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-accent-primary" />
|
||||
{t('account.quickActions', { defaultValue: 'Quick Actions' })}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('account.quickActionsDescription', {
|
||||
defaultValue: 'Common account management tasks',
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{actions.map((action, index) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
href={action.href}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-start gap-3 rounded-md font-medium transition-all duration-[150ms] ease-out h-auto py-3 px-4',
|
||||
'bg-chat-ai-bubble text-chat-ai-text hover:bg-bg-tertiary hover:scale-[1.02] active:scale-[0.98]',
|
||||
'border border-border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-accent-primary'
|
||||
)}
|
||||
>
|
||||
<Icon className={`h-5 w-5 ${action.color}`} />
|
||||
<span>{action.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Account Information Card
|
||||
* VI: Card Thông tin Tài khoản
|
||||
*/
|
||||
function AccountInfoCard() {
|
||||
const t = useTranslations();
|
||||
|
||||
// EN: Mock user data / VI: Dữ liệu user mẫu
|
||||
const userData = {
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
username: 'johndoe',
|
||||
memberSince: 'January 2024',
|
||||
emailVerified: true,
|
||||
phoneVerified: false,
|
||||
plan: 'Pro',
|
||||
avatarUrl: null,
|
||||
};
|
||||
|
||||
const infoItems = [
|
||||
{
|
||||
label: t('account.info.email', { defaultValue: 'Email' }),
|
||||
value: userData.email,
|
||||
verified: userData.emailVerified,
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
label: t('account.info.username', { defaultValue: 'Username' }),
|
||||
value: userData.username,
|
||||
verified: false,
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
label: t('account.info.memberSince', { defaultValue: 'Member Since' }),
|
||||
value: userData.memberSince,
|
||||
verified: false,
|
||||
icon: Calendar,
|
||||
},
|
||||
{
|
||||
label: t('account.info.plan', { defaultValue: 'Plan' }),
|
||||
value: userData.plan,
|
||||
verified: false,
|
||||
icon: Shield,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card hover>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('account.info.title', { defaultValue: 'Account Information' })}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('account.info.description', {
|
||||
defaultValue: 'Your account details and verification status',
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* EN: Avatar and Name Section / VI: Phần Avatar và Tên */}
|
||||
<div className="mb-6 flex items-center gap-4 border-b border-border-primary pb-6">
|
||||
<Avatar size="xl" className="h-20 w-20">
|
||||
{userData.avatarUrl && (
|
||||
<AvatarImage src={userData.avatarUrl} alt={userData.name} />
|
||||
)}
|
||||
<AvatarFallback>
|
||||
{userData.name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-text-primary">{userData.name}</h3>
|
||||
<p className="text-sm text-text-secondary">{userData.email}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{userData.emailVerified && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent-success/10 px-2 py-1 text-xs font-medium text-accent-success">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t('account.verified', { defaultValue: 'Verified' })}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent-primary/10 px-2 py-1 text-xs font-medium text-accent-primary">
|
||||
{userData.plan}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/settings/profile"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md font-medium transition-all duration-[150ms] ease-out',
|
||||
'bg-chat-ai-bubble text-chat-ai-text hover:bg-bg-tertiary hover:scale-[1.02] active:scale-[0.98]',
|
||||
'border border-border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-accent-primary',
|
||||
'h-10 px-4 text-base'
|
||||
)}
|
||||
>
|
||||
{t('account.editProfile', { defaultValue: 'Edit Profile' })}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* EN: Information Grid / VI: Lưới Thông tin */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{infoItems.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-3 rounded-lg bg-bg-tertiary p-4"
|
||||
>
|
||||
<div className="rounded-lg bg-bg-elevated p-2 text-text-tertiary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-text-secondary">{item.label}</p>
|
||||
<p className="mt-1 text-base text-text-primary">{item.value}</p>
|
||||
{item.verified && (
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-accent-success">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
<span>{t('account.verified', { defaultValue: 'Verified' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Recent Activity Card
|
||||
* VI: Card Hoạt động Gần đây
|
||||
*/
|
||||
function RecentActivityCard() {
|
||||
const t = useTranslations();
|
||||
|
||||
// EN: Mock activity data / VI: Dữ liệu hoạt động mẫu
|
||||
const activities = [
|
||||
{
|
||||
type: 'api_key_created',
|
||||
message: t('account.activity.apiKeyCreated', {
|
||||
defaultValue: 'Created new API key',
|
||||
}),
|
||||
time: '2 hours ago',
|
||||
icon: Shield,
|
||||
color: 'text-accent-success',
|
||||
},
|
||||
{
|
||||
type: 'profile_updated',
|
||||
message: t('account.activity.profileUpdated', {
|
||||
defaultValue: 'Updated profile information',
|
||||
}),
|
||||
time: '1 day ago',
|
||||
icon: User,
|
||||
color: 'text-accent-primary',
|
||||
},
|
||||
{
|
||||
type: 'chat_created',
|
||||
message: t('account.activity.chatCreated', {
|
||||
defaultValue: 'Started new conversation',
|
||||
}),
|
||||
time: '2 days ago',
|
||||
icon: MessageSquare,
|
||||
color: 'text-accent-info',
|
||||
},
|
||||
{
|
||||
type: 'settings_changed',
|
||||
message: t('account.activity.settingsChanged', {
|
||||
defaultValue: 'Changed notification preferences',
|
||||
}),
|
||||
time: '3 days ago',
|
||||
icon: Settings,
|
||||
color: 'text-accent-warning',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card hover>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-accent-primary" />
|
||||
{t('account.recentActivity', { defaultValue: 'Recent Activity' })}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('account.recentActivityDescription', {
|
||||
defaultValue: 'Your recent account activities and changes',
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity, index) => {
|
||||
const Icon = activity.icon;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-4 rounded-lg border border-border-primary bg-bg-tertiary p-4 transition-all hover:border-border-secondary"
|
||||
>
|
||||
<div className={`rounded-lg bg-bg-elevated p-2 ${activity.color}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{activity.message}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-text-tertiary">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{activity.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
{t('account.viewAllActivity', { defaultValue: 'View All Activity' })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Account Security Status Card
|
||||
* VI: Card Trạng thái Bảo mật Tài khoản
|
||||
*/
|
||||
function SecurityStatusCard() {
|
||||
const t = useTranslations();
|
||||
|
||||
const securityItems = [
|
||||
{
|
||||
label: t('account.security.emailVerified', { defaultValue: 'Email Verified' }),
|
||||
status: true,
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
label: t('account.security.twoFactorEnabled', { defaultValue: 'Two-Factor Authentication' }),
|
||||
status: false,
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
label: t('account.security.phoneVerified', { defaultValue: 'Phone Verified' }),
|
||||
status: false,
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
label: t('account.security.recoveryCodes', {
|
||||
defaultValue: 'Recovery Codes Generated',
|
||||
}),
|
||||
status: true,
|
||||
icon: Shield,
|
||||
},
|
||||
];
|
||||
|
||||
const completedCount = securityItems.filter((item) => item.status).length;
|
||||
const totalCount = securityItems.length;
|
||||
const percentage = (completedCount / totalCount) * 100;
|
||||
|
||||
return (
|
||||
<Card hover>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-accent-primary" />
|
||||
{t('account.security.title', { defaultValue: 'Security Status' })}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('account.security.description', {
|
||||
defaultValue: 'Your account security settings and recommendations',
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* EN: Security Score / VI: Điểm Bảo mật */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-text-secondary">
|
||||
{t('account.security.score', { defaultValue: 'Security Score' })}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-text-primary">
|
||||
{completedCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-bg-tertiary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent-primary transition-all duration-500"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-text-tertiary">
|
||||
{percentage === 100
|
||||
? t('account.security.excellent', {
|
||||
defaultValue: 'Excellent! Your account is well secured.',
|
||||
})
|
||||
: t('account.security.improve', {
|
||||
defaultValue: 'Complete remaining items to improve security.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* EN: Security Items List / VI: Danh sách Mục Bảo mật */}
|
||||
<div className="space-y-3">
|
||||
{securityItems.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between rounded-lg border border-border-primary bg-bg-tertiary p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon
|
||||
className={`h-5 w-5 ${
|
||||
item.status ? 'text-accent-success' : 'text-text-tertiary'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
{item.status ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent-success/10 px-2 py-1 text-xs font-medium text-accent-success">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t('account.security.enabled', { defaultValue: 'Enabled' })}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href="/settings/security"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md font-medium transition-all duration-[150ms] ease-out',
|
||||
'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary active:bg-bg-elevated',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-accent-primary',
|
||||
'h-8 px-3 text-sm'
|
||||
)}
|
||||
>
|
||||
{t('account.security.enable', { defaultValue: 'Enable' })}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Main Account Mockup Page
|
||||
* VI: Trang Mockup Tài khoản Chính
|
||||
*/
|
||||
export default function AccountMockupPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-primary">
|
||||
{/* EN: Page Header / VI: Header Trang */}
|
||||
<div className="border-b border-border-primary bg-bg-secondary">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="py-8">
|
||||
<h1 className="text-3xl font-bold text-text-primary">
|
||||
{t('account.title', { defaultValue: 'My Account' })}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-text-tertiary">
|
||||
{t('account.description', {
|
||||
defaultValue: 'Manage your account settings, view statistics, and monitor activity',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: Main Content / VI: Nội dung Chính */}
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="space-y-8">
|
||||
{/* EN: Statistics Cards / VI: Cards Thống kê */}
|
||||
<AccountStatsCard />
|
||||
|
||||
{/* EN: Main Grid Layout / VI: Bố cục Lưới Chính */}
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
{/* EN: Left Column - Account Info / VI: Cột Trái - Thông tin Tài khoản */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
<AccountInfoCard />
|
||||
<RecentActivityCard />
|
||||
</div>
|
||||
|
||||
{/* EN: Right Column - Quick Actions & Security / VI: Cột Phải - Hành động Nhanh & Bảo mật */}
|
||||
<div className="space-y-8">
|
||||
<QuickActionsCard />
|
||||
<SecurityStatusCard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import AccountMockupPage from './mockup';
|
||||
|
||||
/**
|
||||
* EN: Account Page - Displays account overview and management
|
||||
* VI: Trang Tài khoản - Hiển thị tổng quan và quản lý tài khoản
|
||||
*
|
||||
* This page shows:
|
||||
* - Account statistics (chats, sessions, API calls, uptime)
|
||||
* - Account information (profile, email, username, plan)
|
||||
* - Quick actions (update profile, manage API keys, billing, notifications)
|
||||
* - Recent activity feed
|
||||
* - Security status and recommendations
|
||||
*
|
||||
* Trang này hiển thị:
|
||||
* - Thống kê tài khoản (chats, sessions, API calls, uptime)
|
||||
* - Thông tin tài khoản (profile, email, username, plan)
|
||||
* - Hành động nhanh (cập nhật profile, quản lý API keys, billing, notifications)
|
||||
* - Feed hoạt động gần đây
|
||||
* - Trạng thái bảo mật và khuyến nghị
|
||||
*/
|
||||
export default function AccountPage() {
|
||||
return <AccountMockupPage />;
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChatLayout } from '@/features/chat/chat-layout';
|
||||
import { ConversationSidebar } from '@/features/chat/conversation-sidebar';
|
||||
import { MessageBubble } from '@/features/chat/message-bubble';
|
||||
import { ChatInput } from '@/features/chat/chat-input';
|
||||
// EN: Lazy load typing indicator / VI: Lazy load typing indicator
|
||||
const TypingIndicator = React.lazy(() => import('@/features/chat/typing-indicator').then(m => ({ default: m.TypingIndicator })));
|
||||
import { LiveRegion } from '@/features/shared/components/accessibility/live-region';
|
||||
import { useKeyboardShortcuts, CHAT_SHORTCUTS } from '@/shared/hooks/use-keyboard-shortcuts';
|
||||
import { useChatStore, MessageSender } from '@/stores/chat-store';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* EN: Chat page component - Main chat interface
|
||||
* VI: Component trang chat - Giao diện chat chính
|
||||
*
|
||||
* Features:
|
||||
* - Chat layout with sidebar and main area
|
||||
* - Message display with user/AI variants
|
||||
* - Chat input with auto-resize
|
||||
* - Typing indicator
|
||||
* - Keyboard shortcuts
|
||||
* - Screen reader announcements
|
||||
*
|
||||
* Tính năng:
|
||||
* - Layout chat với sidebar và khu vực chính
|
||||
* - Hiển thị tin nhắn với các biến thể user/AI
|
||||
* - Input chat với auto-resize
|
||||
* - Typing indicator
|
||||
* - Phím tắt bàn phím
|
||||
* - Thông báo screen reader
|
||||
*/
|
||||
export default function ChatPage() {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
|
||||
const [sidebarVisible, setSidebarVisible] = React.useState(true);
|
||||
const [announcement, setAnnouncement] = React.useState<string>('');
|
||||
|
||||
// EN: Get chat state from store / VI: Lấy state chat từ store
|
||||
const {
|
||||
messages,
|
||||
conversations,
|
||||
currentConversationId,
|
||||
sendMessage,
|
||||
selectConversation,
|
||||
createConversation,
|
||||
} = useChatStore();
|
||||
|
||||
// EN: Get current conversation messages / VI: Lấy tin nhắn của conversation hiện tại
|
||||
const currentMessages = React.useMemo(() => {
|
||||
if (!currentConversationId) return [];
|
||||
return messages[currentConversationId] || [];
|
||||
}, [messages, currentConversationId]);
|
||||
|
||||
// EN: Handle send message / VI: Xử lý gửi tin nhắn
|
||||
const handleSend = async (content: string) => {
|
||||
let conversationId = currentConversationId;
|
||||
if (!conversationId) {
|
||||
// EN: Create new conversation if none selected / VI: Tạo conversation mới nếu chưa chọn
|
||||
conversationId = createConversation();
|
||||
}
|
||||
try {
|
||||
await sendMessage(conversationId, content);
|
||||
setAnnouncement(t('chat.messageSent'));
|
||||
} catch (error) {
|
||||
setAnnouncement(t('chat.messageSendFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Handle new chat / VI: Xử lý chat mới
|
||||
const handleNewChat = () => {
|
||||
createConversation();
|
||||
setAnnouncement(t('chat.newConversationCreated'));
|
||||
};
|
||||
|
||||
// EN: Handle select conversation / VI: Xử lý chọn conversation
|
||||
const handleSelectConversation = (conversationId: string) => {
|
||||
selectConversation(conversationId);
|
||||
setAnnouncement(t('chat.switchedToConversation'));
|
||||
};
|
||||
|
||||
// EN: Keyboard shortcuts / VI: Phím tắt bàn phím
|
||||
useKeyboardShortcuts([
|
||||
{
|
||||
key: CHAT_SHORTCUTS.NEW_CHAT,
|
||||
handler: () => handleNewChat(),
|
||||
description: t('chat.newChat'),
|
||||
preventDefault: true,
|
||||
},
|
||||
{
|
||||
key: CHAT_SHORTCUTS.SEARCH,
|
||||
handler: () => {
|
||||
// EN: Focus search input / VI: Focus input tìm kiếm
|
||||
const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement;
|
||||
searchInput?.focus();
|
||||
},
|
||||
description: t('chat.openSearch'),
|
||||
preventDefault: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LiveRegion message={announcement} priority="polite" />
|
||||
<ChatLayout
|
||||
sidebar={
|
||||
<ConversationSidebar
|
||||
conversations={conversations.map((conv) => ({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
lastMessage: conv.lastMessage?.content,
|
||||
lastMessageAt: conv.lastMessage ? new Date(conv.lastMessage.createdAt) : undefined,
|
||||
isSelected: conv.id === currentConversationId,
|
||||
}))}
|
||||
selectedConversationId={currentConversationId || undefined}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onNewChat={handleNewChat}
|
||||
/>
|
||||
}
|
||||
sidebarVisible={sidebarVisible}
|
||||
onSidebarToggle={setSidebarVisible}
|
||||
>
|
||||
{/* EN: Messages container / VI: Container tin nhắn */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4" role="log" aria-label={t('chat.messages')}>
|
||||
{currentMessages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<p className="text-text-tertiary text-lg mb-2">
|
||||
{t('chat.startConversation')}
|
||||
</p>
|
||||
<p className="text-text-tertiary text-sm">
|
||||
{t('chat.startConversationDesc')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
currentMessages.map((message) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
sender={message.sender === MessageSender.USER ? 'user' : message.sender === MessageSender.ASSISTANT ? 'ai' : 'system'}
|
||||
content={message.content}
|
||||
timestamp={new Date(message.createdAt)}
|
||||
showActions
|
||||
onCopy={() => {
|
||||
navigator.clipboard.writeText(message.content);
|
||||
setAnnouncement(t('chat.messageCopied'));
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{/* EN: Typing indicator / VI: Typing indicator */}
|
||||
{(() => {
|
||||
const { typingUsers } = useChatStore.getState();
|
||||
return Object.values(typingUsers).some((typing) => typing) ? (
|
||||
<React.Suspense fallback={<div className="px-4 py-3" aria-label={t('chat.loadingTypingIndicator')} />}>
|
||||
<TypingIndicator />
|
||||
</React.Suspense>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* EN: Chat input / VI: Chat input */}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
placeholder={t('chat.typeMessage')}
|
||||
/>
|
||||
</ChatLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,586 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/features/shared/components/ui/button';
|
||||
import { Input } from '@/features/shared/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from '@/features/shared/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/features/shared/components/ui/dialog';
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useI18n } from '@/features/theme';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiKeysApi, type ApiKey } from '@/services/api/api-keys.api';
|
||||
|
||||
/**
|
||||
* EN: Create API key schema with translated messages
|
||||
* VI: Tạo API key schema với thông báo đã dịch
|
||||
*/
|
||||
function createApiKeySchema(
|
||||
t: (key: string, values?: Record<string, any>) => string
|
||||
) {
|
||||
return z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, t('settings.apiKeys.nameRequired'))
|
||||
.max(100, t('validation.maxLength', { max: 100 })),
|
||||
description: z
|
||||
.string()
|
||||
.max(500, t('validation.maxLength', { max: 500 }))
|
||||
.optional(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Format timestamp to relative time string
|
||||
* VI: Format timestamp thành chuỗi thời gian tương đối
|
||||
*/
|
||||
function formatRelativeTime(date: string | null, t: (key: string, values?: any) => string, locale: string): string {
|
||||
if (!date) return t('settings.security.never');
|
||||
const dateObj = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - dateObj.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return t('chat.justNow');
|
||||
if (diffMins < 60) return t('chat.minutesAgo', { minutes: diffMins });
|
||||
if (diffHours < 24) return t('chat.hoursAgo', { hours: diffHours });
|
||||
if (diffDays < 7) return t('chat.daysAgo', { days: diffDays });
|
||||
return dateObj.toLocaleDateString(locale === 'vi' ? 'vi-VN' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: dateObj.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Format date to readable string
|
||||
* VI: Format ngày thành chuỗi dễ đọc
|
||||
*/
|
||||
function formatDate(date: string | null, t: (key: string) => string, locale: string): string {
|
||||
if (!date) return t('settings.security.never');
|
||||
return new Date(date).toLocaleDateString(locale === 'vi' ? 'vi-VN' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: API Keys management page component
|
||||
* VI: Component trang quản lý API Keys
|
||||
*
|
||||
* Features:
|
||||
* - List all API keys
|
||||
* - Create new API keys with name and description
|
||||
* - Delete API keys
|
||||
* - Show/hide API key values
|
||||
* - Copy API key to clipboard
|
||||
* - Display creation date, last used date, expiration date
|
||||
*/
|
||||
export default function ApiKeysPage() {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
const { locale } = useI18n();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
// EN: Create schema with translations / VI: Tạo schema với translations
|
||||
const apiKeySchema = createApiKeySchema(t);
|
||||
type CreateApiKeyFormData = z.infer<typeof apiKeySchema>;
|
||||
|
||||
// EN: State management / VI: Quản lý state
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false);
|
||||
const [newApiKey, setNewApiKey] = useState<string>('');
|
||||
const [newApiKeyName, setNewApiKeyName] = useState<string>('');
|
||||
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
|
||||
const [copiedKeyId, setCopiedKeyId] = useState<string | null>(null);
|
||||
const [deletingKeyId, setDeletingKeyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [success, setSuccess] = useState<string>('');
|
||||
|
||||
// EN: React Hook Form setup for creating API key / VI: Setup React Hook Form cho tạo API key
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
} = useForm<CreateApiKeyFormData>({
|
||||
resolver: zodResolver(apiKeySchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
},
|
||||
});
|
||||
|
||||
// EN: Load API keys on mount / VI: Tải API keys khi mount
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
loadApiKeys();
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
/**
|
||||
* EN: Load API keys from API
|
||||
* VI: Tải API keys từ API
|
||||
*/
|
||||
const loadApiKeys = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await apiKeysApi.list(user.id);
|
||||
if (response.success && response.data) {
|
||||
setApiKeys(response.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('settings.apiKeys.failedToLoad'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Handle create API key form submission
|
||||
* VI: Xử lý submit form tạo API key
|
||||
*/
|
||||
const onCreateSubmit = async (data: CreateApiKeyFormData) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const response = await apiKeysApi.create(user.id, {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(t('settings.apiKeys.failedToCreate'));
|
||||
}
|
||||
|
||||
setNewApiKey(response.data.key);
|
||||
setNewApiKeyName(data.name);
|
||||
setShowCreateDialog(false);
|
||||
setShowNewKeyDialog(true);
|
||||
reset();
|
||||
setApiKeys((prev) => [response.data!.apiKey, ...prev]);
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('settings.apiKeys.failedToCreate'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Handle delete API key
|
||||
* VI: Xử lý xóa API key
|
||||
*/
|
||||
const handleDeleteKey = async (keyId: string, keyName: string) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
if (!confirm(t('settings.apiKeys.confirmDelete', { name: keyName }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingKeyId(keyId);
|
||||
setError('');
|
||||
try {
|
||||
await apiKeysApi.delete(user.id, keyId);
|
||||
setApiKeys((prev) => prev.filter((key) => key.id !== keyId));
|
||||
setSuccess(t('settings.apiKeys.deletedSuccessfully'));
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('settings.apiKeys.failedToDelete'));
|
||||
} finally {
|
||||
setDeletingKeyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Handle copy API key to clipboard
|
||||
* VI: Xử lý sao chép API key vào clipboard
|
||||
*/
|
||||
const handleCopyKey = async (keyId: string, fullKey?: string) => {
|
||||
try {
|
||||
// EN: If full key is provided (from new key dialog), copy it / VI: Nếu có full key (từ dialog key mới), copy nó
|
||||
if (fullKey) {
|
||||
await navigator.clipboard.writeText(fullKey);
|
||||
} else {
|
||||
// EN: Existing keys are stored masked for security; copy masked value.
|
||||
// VI: Các key đã lưu được che để bảo mật; sao chép giá trị đã che.
|
||||
const key = apiKeys.find((k) => k.id === keyId);
|
||||
if (key) {
|
||||
await navigator.clipboard.writeText(`${key.keyPrefix}...${key.keySuffix}`);
|
||||
}
|
||||
}
|
||||
|
||||
setCopiedKeyId(keyId);
|
||||
setTimeout(() => setCopiedKeyId(null), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Toggle visibility of API key
|
||||
* VI: Bật/tắt hiển thị API key
|
||||
*/
|
||||
const toggleKeyVisibility = (keyId: string) => {
|
||||
setVisibleKeys((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(keyId)) {
|
||||
newSet.delete(keyId);
|
||||
} else {
|
||||
newSet.add(keyId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* EN: Page header / VI: Header trang */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-text-primary">
|
||||
{t('settings.apiKeys.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
{t('settings.apiKeys.manageKeys')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* EN: Success message / VI: Thông báo thành công */}
|
||||
{success && (
|
||||
<div className="rounded-lg bg-accent-success/10 border border-accent-success p-3 flex items-center gap-2 text-sm text-accent-success">
|
||||
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EN: Error message / VI: Thông báo lỗi */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-accent-error/10 border border-accent-error p-3 flex items-center gap-2 text-sm text-accent-error">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EN: API Keys list card / VI: Card danh sách API Keys */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-accent-primary" />
|
||||
{t('settings.apiKeys.yourApiKeys')}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{t('settings.apiKeys.createAndManage')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('settings.apiKeys.createApiKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-text-tertiary">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent mb-4"></div>
|
||||
<p>{t('common.loading')}</p>
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Key className="h-12 w-12 text-text-tertiary mx-auto mb-4" />
|
||||
<p className="text-text-secondary font-medium mb-2">
|
||||
{t('settings.apiKeys.noApiKeys')}
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary mb-6">
|
||||
{t('settings.apiKeys.createFirstKey')}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('settings.apiKeys.createApiKey')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{apiKeys.map((apiKey) => (
|
||||
<div
|
||||
key={apiKey.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg bg-bg-tertiary border border-border-primary hover:border-border-secondary transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-sm font-semibold text-text-primary">
|
||||
{apiKey.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{/* EN: API Key display / VI: Hiển thị API Key */}
|
||||
<code className="text-sm font-mono bg-bg-primary px-2 py-1 rounded border border-border-primary text-text-secondary">
|
||||
{visibleKeys.has(apiKey.id)
|
||||
? `${apiKey.keyPrefix}...${apiKey.keySuffix}`
|
||||
: `${apiKey.keyPrefix}${'•'.repeat(20)}${apiKey.keySuffix}`}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleKeyVisibility(apiKey.id)}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label={visibleKeys.has(apiKey.id) ? t('settings.apiKeys.hide') : t('settings.apiKeys.show')}
|
||||
>
|
||||
{visibleKeys.has(apiKey.id) ? (
|
||||
<EyeOff className="h-4 w-4 text-text-tertiary" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-text-tertiary" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyKey(apiKey.id)}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label={t('settings.apiKeys.copy')}
|
||||
>
|
||||
{copiedKeyId === apiKey.id ? (
|
||||
<Check className="h-4 w-4 text-accent-success" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-text-tertiary" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-text-tertiary">
|
||||
<span>
|
||||
{t('settings.apiKeys.created')}: {formatDate(apiKey.createdAt, t, locale)}
|
||||
</span>
|
||||
{apiKey.lastUsedAt && (
|
||||
<span>
|
||||
{t('settings.apiKeys.lastUsed')}: {formatRelativeTime(apiKey.lastUsedAt, t, locale)}
|
||||
</span>
|
||||
)}
|
||||
{apiKey.expiresAt && (
|
||||
<span className={new Date(apiKey.expiresAt) < new Date() ? 'text-accent-error' : ''}>
|
||||
{t('settings.apiKeys.expires')}: {formatDate(apiKey.expiresAt, t, locale)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteKey(apiKey.id, apiKey.name)}
|
||||
isLoading={deletingKeyId === apiKey.id}
|
||||
className="text-accent-error hover:brightness-110 hover:bg-accent-error/10 ml-4"
|
||||
aria-label={`${t('settings.apiKeys.delete')} ${apiKey.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* EN: Security notice card / VI: Card thông báo bảo mật */}
|
||||
<Card hover={false} bordered>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertCircle className="h-5 w-5 text-accent-warning" />
|
||||
{t('settings.apiKeys.securityBestPractices', { defaultValue: 'Security Best Practices' })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-text-secondary">
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>{t('settings.apiKeys.practice1', { defaultValue: 'Keep your API keys secure and never share them publicly' })}</li>
|
||||
<li>{t('settings.apiKeys.practice2', { defaultValue: 'Use environment variables or secure secret management tools' })}</li>
|
||||
<li>{t('settings.apiKeys.practice3', { defaultValue: 'Rotate your API keys regularly' })}</li>
|
||||
<li>{t('settings.apiKeys.practice4', { defaultValue: 'Delete unused API keys immediately' })}</li>
|
||||
<li>{t('settings.apiKeys.practice5', { defaultValue: 'If a key is compromised, revoke it immediately and create a new one' })}</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* EN: Create API Key Dialog / VI: Dialog tạo API Key */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.apiKeys.createApiKey')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.apiKeys.createForAccess', { defaultValue: 'Create a new API key for programmatic access' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onCreateSubmit)} className="space-y-4">
|
||||
{/* EN: Error message / VI: Thông báo lỗi */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-bg-tertiary border border-accent-error text-accent-error text-sm flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label={t('settings.apiKeys.name')}
|
||||
placeholder={t('settings.apiKeys.namePlaceholder', { defaultValue: 'e.g., Production Key, Development Key' })}
|
||||
name="name"
|
||||
value={watch('name')}
|
||||
onChange={(value) => setValue('name', value)}
|
||||
onBlur={register('name').onBlur}
|
||||
errorMessage={errors.name?.message}
|
||||
isInvalid={!!errors.name}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="block text-sm font-medium text-text-secondary mb-2"
|
||||
>
|
||||
{t('settings.apiKeys.description')} ({t('common.optional', { defaultValue: 'Optional' })})
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows={3}
|
||||
placeholder={t('settings.apiKeys.descriptionPlaceholder', { defaultValue: 'Optional description for this API key' })}
|
||||
{...register('description')}
|
||||
className="flex w-full rounded-md border border-border-primary bg-bg-secondary px-3 py-2 text-base text-text-primary placeholder:text-text-tertiary transition-all duration-[150ms] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:border-accent-primary focus-visible:ring-accent-primary focus-visible:shadow-[0_0_20px_rgba(59,130,246,0.3)] disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-bg-tertiary"
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="mt-1.5 text-sm text-accent-error flex items-center gap-1">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{errors.description.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowCreateDialog(false);
|
||||
reset();
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
{t('settings.apiKeys.createApiKey')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* EN: New API Key Dialog (shown once after creation) / VI: Dialog API Key mới (hiển thị một lần sau khi tạo) */}
|
||||
<Dialog open={showNewKeyDialog} onOpenChange={setShowNewKeyDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-accent-success" />
|
||||
{t('settings.apiKeys.newApiKeyCreated')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.apiKeys.newKeyFor', { name: newApiKeyName, defaultValue: `Your new API key for "${newApiKeyName}"` })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* EN: Warning message / VI: Thông báo cảnh báo */}
|
||||
<div className="p-3 rounded-md bg-accent-warning/10 border border-accent-warning text-accent-warning text-sm">
|
||||
<p className="font-medium mb-1">
|
||||
{t('settings.apiKeys.important', { defaultValue: 'Important' })}
|
||||
</p>
|
||||
<p>
|
||||
{t('settings.apiKeys.saveKeySecurely')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* EN: API Key display / VI: Hiển thị API Key */}
|
||||
<div className="p-4 rounded-lg bg-bg-primary border border-border-primary">
|
||||
<code className="text-sm font-mono text-text-primary break-all select-all">
|
||||
{newApiKey}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* EN: Copy button / VI: Nút sao chép */}
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleCopyKey('new', newApiKey)}
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{copiedKeyId === 'new' ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Copied! / Đã sao chép!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
{t('settings.apiKeys.copyApiKey', { defaultValue: 'Copy API Key' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setShowNewKeyDialog(false);
|
||||
setNewApiKey('');
|
||||
setNewApiKeyName('');
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{t('settings.apiKeys.iveCopied', { defaultValue: 'I\'ve copied the key' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import {
|
||||
User,
|
||||
Settings,
|
||||
Shield,
|
||||
Bell,
|
||||
CreditCard,
|
||||
Key,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* EN: Settings navigation tabs configuration
|
||||
* VI: Cấu hình các tab điều hướng Settings
|
||||
*/
|
||||
function getSettingsTabs(t: (key: string) => string) {
|
||||
return [
|
||||
{
|
||||
id: 'profile',
|
||||
label: t('settings.profile.label'),
|
||||
href: '/settings/profile',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
id: 'preferences',
|
||||
label: t('settings.preferences.label'),
|
||||
href: '/settings/preferences',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
label: t('settings.security.label'),
|
||||
href: '/settings/security',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
label: t('settings.notifications.label'),
|
||||
href: '/settings/notifications',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
id: 'billing',
|
||||
label: t('settings.billing.label'),
|
||||
href: '/settings/billing',
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
id: 'api-keys',
|
||||
label: t('settings.apiKeys.label'),
|
||||
href: '/settings/api-keys',
|
||||
icon: Key,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Settings layout component with tab navigation
|
||||
* VI: Component layout Settings với điều hướng tab
|
||||
*
|
||||
* Features:
|
||||
* - Tab navigation for different settings sections
|
||||
* - Active tab highlighting
|
||||
* - Responsive design
|
||||
* - Icon support for each tab
|
||||
*/
|
||||
export default function SettingsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
const pathname = usePathname();
|
||||
const settingsTabs = getSettingsTabs(t);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-primary">
|
||||
{/* EN: Settings header / VI: Header Settings */}
|
||||
<div className="border-b border-border-primary bg-bg-secondary">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="py-6">
|
||||
<h1 className="text-3xl font-bold text-text-primary">
|
||||
{t('settings.title')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
{t('settings.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: Settings content with tabs / VI: Nội dung Settings với tabs */}
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="py-8">
|
||||
<div className="lg:grid lg:grid-cols-12 lg:gap-8">
|
||||
{/* EN: Sidebar navigation / VI: Điều hướng sidebar */}
|
||||
<aside className="lg:col-span-3">
|
||||
<nav
|
||||
className="space-y-1"
|
||||
aria-label={t('settings.navigation')}
|
||||
>
|
||||
{settingsTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = pathname === tab.href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
// EN: Base styles / VI: Styles cơ bản
|
||||
'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-[150ms]',
|
||||
// EN: Active state / VI: Trạng thái active
|
||||
isActive
|
||||
? 'bg-accent-primary text-white shadow-md'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary',
|
||||
)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-5 w-5 flex-shrink-0',
|
||||
isActive ? 'text-white' : 'text-text-tertiary group-hover:text-text-primary',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{tab.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* EN: Main content area / VI: Khu vực nội dung chính */}
|
||||
<div className="mt-8 lg:col-span-9 lg:mt-0">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTheme, type ThemeMode } from '@/features/theme';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/features/shared/components/ui/card';
|
||||
import { Select } from '@/features/shared/components/ui/select';
|
||||
import { Switch } from '@/features/shared/components/ui/switch';
|
||||
import { Button } from '@/features/shared/components/ui/button';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useI18n } from '@/features/theme';
|
||||
import { type Locale } from '@/features/theme/i18n-config';
|
||||
|
||||
/**
|
||||
* EN: Language options for preferences
|
||||
* VI: Các tùy chọn ngôn ngữ cho preferences
|
||||
*/
|
||||
type Language = 'en' | 'vi';
|
||||
|
||||
/**
|
||||
* EN: Font size options for chat
|
||||
* VI: Các tùy chọn kích thước font cho chat
|
||||
*/
|
||||
type FontSize = 'small' | 'medium' | 'large' | 'xlarge';
|
||||
|
||||
/**
|
||||
* EN: Message grouping options
|
||||
* VI: Các tùy chọn nhóm tin nhắn
|
||||
*/
|
||||
type MessageGrouping = 'none' | 'by-author' | 'by-time';
|
||||
|
||||
/**
|
||||
* EN: Preferences interface
|
||||
* VI: Interface cho preferences
|
||||
*/
|
||||
interface Preferences {
|
||||
language: Language;
|
||||
theme: ThemeMode;
|
||||
chatAutoScroll: boolean;
|
||||
chatShowTimestamps: boolean;
|
||||
chatMessageGrouping: MessageGrouping;
|
||||
chatFontSize: FontSize;
|
||||
accessibilityHighContrast: boolean;
|
||||
accessibilityScreenReader: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Default preferences
|
||||
* VI: Preferences mặc định
|
||||
*/
|
||||
const defaultPreferences: Preferences = {
|
||||
language: 'en',
|
||||
theme: 'system',
|
||||
chatAutoScroll: true,
|
||||
chatShowTimestamps: true,
|
||||
chatMessageGrouping: 'by-author',
|
||||
chatFontSize: 'medium',
|
||||
accessibilityHighContrast: false,
|
||||
accessibilityScreenReader: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Preferences page component
|
||||
* VI: Component trang Preferences
|
||||
*
|
||||
* Features:
|
||||
* - Language selection
|
||||
* - Theme selection (Dark/Light/Auto)
|
||||
* - Chat settings (Auto-scroll, Show timestamps, Message grouping, Font size)
|
||||
* - Accessibility options (High contrast mode, Screen reader optimizations)
|
||||
*/
|
||||
export default function PreferencesPage() {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
const { locale, setLocale } = useI18n();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [preferences, setPreferences] = React.useState<Preferences>(defaultPreferences);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
||||
|
||||
// EN: Load preferences from localStorage on mount / VI: Load preferences từ localStorage khi mount
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const stored = localStorage.getItem('preferences');
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as Partial<Preferences>;
|
||||
const loaded = { ...defaultPreferences, ...parsed, theme, language: locale as Language };
|
||||
setPreferences(loaded);
|
||||
} catch {
|
||||
// EN: Invalid stored data, use defaults / VI: Dữ liệu lưu không hợp lệ, dùng mặc định
|
||||
setPreferences({ ...defaultPreferences, theme, language: locale as Language });
|
||||
}
|
||||
} else {
|
||||
setPreferences({ ...defaultPreferences, theme, language: locale as Language });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // EN: Only run on mount / VI: Chỉ chạy khi mount
|
||||
|
||||
// EN: Sync locale with preferences / VI: Đồng bộ locale với preferences
|
||||
React.useEffect(() => {
|
||||
if (preferences.language !== locale) {
|
||||
setPreferences((prev) => ({ ...prev, language: locale as Language }));
|
||||
}
|
||||
}, [locale, preferences.language]);
|
||||
|
||||
// EN: Update preferences state / VI: Cập nhật state preferences
|
||||
const updatePreference = <K extends keyof Preferences>(
|
||||
key: K,
|
||||
value: Preferences[K]
|
||||
) => {
|
||||
setPreferences((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// EN: Handle save preferences / VI: Xử lý lưu preferences
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
setSaveSuccess(false);
|
||||
|
||||
try {
|
||||
// EN: Save to localStorage / VI: Lưu vào localStorage
|
||||
localStorage.setItem('preferences', JSON.stringify(preferences));
|
||||
|
||||
// EN: Show success message / VI: Hiển thị thông báo thành công
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to save preferences:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Handle theme change / VI: Xử lý thay đổi theme
|
||||
const handleThemeChange = (newTheme: ThemeMode) => {
|
||||
setTheme(newTheme);
|
||||
updatePreference('theme', newTheme);
|
||||
};
|
||||
|
||||
// EN: Handle language change / VI: Xử lý thay đổi ngôn ngữ
|
||||
const handleLanguageChange = (newLanguage: string) => {
|
||||
const lang = newLanguage as Locale;
|
||||
setLocale(lang);
|
||||
updatePreference('language', lang as Language);
|
||||
// EN: Trigger a re-render by updating preferences / VI: Kích hoạt re-render bằng cách cập nhật preferences
|
||||
setPreferences((prev) => ({ ...prev, language: lang as Language }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* EN: Page header / VI: Header trang */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-text-primary">
|
||||
{t('settings.preferences.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
{t('settings.preferences.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* EN: Language & Theme Section / VI: Phần Ngôn ngữ & Theme */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.preferences.languageAndTheme')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('settings.preferences.languageAndThemeDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* EN: Language selection / VI: Chọn ngôn ngữ */}
|
||||
<div>
|
||||
<Select
|
||||
label={t('settings.preferences.language')}
|
||||
value={preferences.language}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
helperText={t('settings.preferences.languageHelper')}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="vi">Tiếng Việt</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* EN: Theme selection / VI: Chọn theme */}
|
||||
<div>
|
||||
<Select
|
||||
label={t('settings.preferences.theme')}
|
||||
value={preferences.theme}
|
||||
onChange={(e) => handleThemeChange(e.target.value as ThemeMode)}
|
||||
helperText={t('settings.preferences.themeHelper')}
|
||||
>
|
||||
<option value="light">{t('settings.preferences.light')}</option>
|
||||
<option value="dark">{t('settings.preferences.dark')}</option>
|
||||
<option value="system">{t('settings.preferences.system')}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* EN: Chat Settings Section / VI: Phần Cài đặt Chat */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.preferences.chatSettings')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('settings.preferences.chatSettingsDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* EN: Auto-scroll toggle / VI: Toggle tự động cuộn */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="chat-auto-scroll"
|
||||
className="text-sm font-medium text-text-primary cursor-pointer"
|
||||
>
|
||||
{t('settings.preferences.autoScroll')}
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
{t('settings.preferences.autoScrollDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="chat-auto-scroll"
|
||||
checked={preferences.chatAutoScroll}
|
||||
onCheckedChange={(checked) => updatePreference('chatAutoScroll', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* EN: Show timestamps toggle / VI: Toggle hiển thị thời gian */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="chat-show-timestamps"
|
||||
className="text-sm font-medium text-text-primary cursor-pointer"
|
||||
>
|
||||
{t('settings.preferences.showTimestamps')}
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
{t('settings.preferences.showTimestampsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="chat-show-timestamps"
|
||||
checked={preferences.chatShowTimestamps}
|
||||
onCheckedChange={(checked) => updatePreference('chatShowTimestamps', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* EN: Message grouping / VI: Nhóm tin nhắn */}
|
||||
<div>
|
||||
<Select
|
||||
label={t('settings.preferences.messageGrouping')}
|
||||
value={preferences.chatMessageGrouping}
|
||||
onChange={(e) => updatePreference('chatMessageGrouping', e.target.value as MessageGrouping)}
|
||||
helperText={t('settings.preferences.messageGroupingHelper')}
|
||||
>
|
||||
<option value="none">{t('settings.preferences.none')}</option>
|
||||
<option value="by-author">{t('settings.preferences.byAuthor')}</option>
|
||||
<option value="by-time">{t('settings.preferences.byTime')}</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* EN: Font size / VI: Kích thước font */}
|
||||
<div>
|
||||
<Select
|
||||
label={t('settings.preferences.fontSize')}
|
||||
value={preferences.chatFontSize}
|
||||
onChange={(e) => updatePreference('chatFontSize', e.target.value as FontSize)}
|
||||
helperText={t('settings.preferences.fontSizeHelper')}
|
||||
>
|
||||
<option value="small">{t('settings.preferences.small')}</option>
|
||||
<option value="medium">{t('settings.preferences.medium')}</option>
|
||||
<option value="large">{t('settings.preferences.large')}</option>
|
||||
<option value="xlarge">{t('settings.preferences.extraLarge')}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* EN: Accessibility Section / VI: Phần Khả năng truy cập */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.preferences.accessibility')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('settings.preferences.accessibilityDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* EN: High contrast mode toggle / VI: Toggle chế độ tương phản cao */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="accessibility-high-contrast"
|
||||
className="text-sm font-medium text-text-primary cursor-pointer"
|
||||
>
|
||||
{t('settings.preferences.highContrast')}
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
{t('settings.preferences.highContrastDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="accessibility-high-contrast"
|
||||
checked={preferences.accessibilityHighContrast}
|
||||
onCheckedChange={(checked) => updatePreference('accessibilityHighContrast', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* EN: Screen reader optimizations toggle / VI: Toggle tối ưu screen reader */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="accessibility-screen-reader"
|
||||
className="text-sm font-medium text-[#FAFAFA] cursor-pointer"
|
||||
>
|
||||
{t('settings.preferences.screenReader')}
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-[#A0A0A0]">
|
||||
{t('settings.preferences.screenReaderDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="accessibility-screen-reader"
|
||||
checked={preferences.accessibilityScreenReader}
|
||||
onCheckedChange={(checked) => updatePreference('accessibilityScreenReader', checked)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* EN: Save button / VI: Nút lưu */}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
{saveSuccess && (
|
||||
<p className="text-sm text-accent-success flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{t('settings.preferences.preferencesSaved')}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isLoading={isSaving}
|
||||
isDisabled={isSaving}
|
||||
>
|
||||
{t('settings.preferences.savePreferences')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Upload,
|
||||
UserCircle2,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { userApi, type UserProfile, type UpdateUserProfileDto } from '@/services/api/user.api';
|
||||
import { storageApi } from '@/services/api/storage.api';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/features/shared/components/ui/button';
|
||||
import { Input } from '@/features/shared/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/features/shared/components/ui/card';
|
||||
|
||||
/**
|
||||
* EN: Create profile schema with translated messages
|
||||
* VI: Tạo profile schema với thông báo đã dịch
|
||||
*/
|
||||
function createProfileSchema(
|
||||
t: (key: string, values?: Record<string, any>) => string
|
||||
) {
|
||||
return z.object({
|
||||
firstName: z
|
||||
.string()
|
||||
.max(255, t('validation.maxLength', { max: 255 }))
|
||||
.optional(),
|
||||
lastName: z
|
||||
.string()
|
||||
.max(255, t('validation.maxLength', { max: 255 }))
|
||||
.optional(),
|
||||
phone: z
|
||||
.string()
|
||||
.max(20, t('validation.maxLength', { max: 20 }))
|
||||
.optional(),
|
||||
bio: z
|
||||
.string()
|
||||
.max(500, t('validation.maxLength', { max: 500 }))
|
||||
.optional(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: User Profile page component
|
||||
* VI: Component trang Profile người dùng
|
||||
*
|
||||
* Features:
|
||||
* - Avatar upload with preview
|
||||
* - Profile fields (First Name, Last Name, Phone, Bio)
|
||||
* - Email display with verified status
|
||||
* - Username display
|
||||
* - Save changes button
|
||||
* - Loading states
|
||||
* - Error handling
|
||||
*/
|
||||
export default function ProfilePage() {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
const { user } = useAuthStore();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// EN: Create schema with translations / VI: Tạo schema với translations
|
||||
const profileSchema = createProfileSchema(t);
|
||||
type ProfileFormData = z.infer<typeof profileSchema>;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<ProfileFormData>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: '',
|
||||
bio: '',
|
||||
},
|
||||
});
|
||||
|
||||
// EN: Fetch user profile on mount / VI: Lấy profile người dùng khi mount
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
const response = await userApi.getProfile(user.id);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setProfile(response.data);
|
||||
const attributes = response.data.attributes ?? [];
|
||||
const firstName = attributes.find((attr) => attr.key === 'firstName')?.value ?? '';
|
||||
const lastName = attributes.find((attr) => attr.key === 'lastName')?.value ?? '';
|
||||
const phone = attributes.find((attr) => attr.key === 'phone')?.value ?? '';
|
||||
|
||||
setValue('firstName', firstName, { shouldDirty: false });
|
||||
setValue('lastName', lastName, { shouldDirty: false });
|
||||
setValue('phone', phone, { shouldDirty: false });
|
||||
setValue('bio', response.data.bio ?? '', { shouldDirty: false });
|
||||
|
||||
if (response.data.avatarUrl) {
|
||||
setAvatarPreview(response.data.avatarUrl);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch profile:', error);
|
||||
setErrorMessage(t('settings.profile.failedToFetch'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProfile();
|
||||
}, [user?.id, setValue]);
|
||||
|
||||
// EN: Handle avatar file selection / VI: Xử lý chọn file avatar
|
||||
const handleAvatarChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// EN: Validate file type / VI: Kiểm tra loại file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert(t('settings.profile.selectImageFile'));
|
||||
return;
|
||||
}
|
||||
|
||||
// EN: Validate file size (max 5MB) / VI: Kiểm tra kích thước file (tối đa 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert(t('settings.profile.imageSizeLimit'));
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarFile(file);
|
||||
|
||||
// EN: Create preview URL / VI: Tạo URL preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
// EN: Handle avatar upload / VI: Xử lý upload avatar
|
||||
const handleAvatarUpload = async () => {
|
||||
if (!user?.id || !avatarFile) return;
|
||||
|
||||
try {
|
||||
setIsUploadingAvatar(true);
|
||||
setSaveStatus('idle');
|
||||
setErrorMessage('');
|
||||
|
||||
const uploadResponse = await storageApi.uploadAvatar(avatarFile);
|
||||
const avatarUrl =
|
||||
uploadResponse.data?.url ||
|
||||
`/files/${uploadResponse.data?.fileId}/cdn-url`;
|
||||
|
||||
const response = await userApi.uploadAvatar(user.id, avatarUrl);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(t('settings.profile.failedToUpload'));
|
||||
}
|
||||
|
||||
setProfile(response.data);
|
||||
setAvatarPreview(response.data.avatarUrl ?? avatarPreview);
|
||||
setAvatarFile(null);
|
||||
setSaveStatus('success');
|
||||
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload avatar:', error);
|
||||
setSaveStatus('error');
|
||||
setErrorMessage(t('settings.profile.failedToUpload'));
|
||||
} finally {
|
||||
setIsUploadingAvatar(false);
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Handle form submission / VI: Xử lý submit form
|
||||
const onSubmit = async (data: ProfileFormData) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setSaveStatus('idle');
|
||||
setErrorMessage('');
|
||||
|
||||
const updateData: UpdateUserProfileDto = {
|
||||
bio: data.bio || undefined,
|
||||
};
|
||||
|
||||
const response = await userApi.updateProfile(user.id, updateData);
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(t('settings.profile.failedToUpdate'));
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
userApi.setProfileAttribute(user.id, 'firstName', {
|
||||
value: data.firstName ?? '',
|
||||
valueType: 'String',
|
||||
}),
|
||||
userApi.setProfileAttribute(user.id, 'lastName', {
|
||||
value: data.lastName ?? '',
|
||||
valueType: 'String',
|
||||
}),
|
||||
userApi.setProfileAttribute(user.id, 'phone', {
|
||||
value: data.phone ?? '',
|
||||
valueType: 'String',
|
||||
}),
|
||||
]);
|
||||
|
||||
setProfile(response.data);
|
||||
reset(data);
|
||||
setSaveStatus('success');
|
||||
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
setSaveStatus('error');
|
||||
setErrorMessage(t('settings.profile.failedToUpdate'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Get user initials for avatar fallback / VI: Lấy chữ cái đầu cho avatar fallback
|
||||
const getUserInitials = () => {
|
||||
if (!user) return 'U';
|
||||
const attributes = profile?.attributes ?? [];
|
||||
const firstName = attributes.find((attr) => attr.key === 'firstName')?.value || user.email.split('@')[0];
|
||||
const lastName = attributes.find((attr) => attr.key === 'lastName')?.value || '';
|
||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase() || 'U';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent"></div>
|
||||
<p className="mt-4 text-sm text-text-tertiary">
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const username = user?.email?.split('@')[0] ?? t('settings.profile.notSet');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-text-primary">
|
||||
{t('settings.profile.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
{t('settings.profile.updateInfo')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{saveStatus === 'success' && (
|
||||
<div className="rounded-lg bg-accent-success/10 border border-accent-success p-3 flex items-center gap-2 text-sm text-accent-success">
|
||||
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t('settings.profile.changesSaved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(saveStatus === 'error' || errorMessage) && (
|
||||
<div className="rounded-lg bg-accent-error/10 border border-accent-error p-3 flex items-center gap-2 text-sm text-accent-error">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{errorMessage || t('settings.profile.failedToUpdate')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card bordered>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.profile.changeAvatar')}</CardTitle>
|
||||
<CardDescription>{t('settings.profile.updateInfo')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="h-24 w-24 rounded-full bg-bg-primary border border-border-primary flex items-center justify-center overflow-hidden">
|
||||
{avatarPreview ? (
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt={t('settings.profile.changeAvatar')}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center text-lg font-semibold text-text-secondary">
|
||||
{getUserInitials()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<UserCircle2 className="h-4 w-4" />
|
||||
{t('settings.profile.changeAvatar')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleAvatarUpload}
|
||||
isLoading={isUploadingAvatar}
|
||||
isDisabled={!avatarFile}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{t('settings.profile.uploadAvatar')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{avatarFile && (
|
||||
<p className="text-xs text-text-tertiary">{avatarFile.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card bordered>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.profile.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.profile.updateInfo')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={t('settings.profile.firstName')}
|
||||
placeholder={t('settings.profile.enterFirstName')}
|
||||
value={watch('firstName') ?? ''}
|
||||
onChange={(value) => setValue('firstName', value, { shouldDirty: true })}
|
||||
onBlur={register('firstName').onBlur}
|
||||
errorMessage={errors.firstName?.message}
|
||||
isInvalid={!!errors.firstName}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('settings.profile.lastName')}
|
||||
placeholder={t('settings.profile.enterLastName')}
|
||||
value={watch('lastName') ?? ''}
|
||||
onChange={(value) => setValue('lastName', value, { shouldDirty: true })}
|
||||
onBlur={register('lastName').onBlur}
|
||||
errorMessage={errors.lastName?.message}
|
||||
isInvalid={!!errors.lastName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label={t('settings.profile.phone')}
|
||||
placeholder={t('settings.profile.enterPhone')}
|
||||
value={watch('phone') ?? ''}
|
||||
onChange={(value) => setValue('phone', value, { shouldDirty: true })}
|
||||
onBlur={register('phone').onBlur}
|
||||
errorMessage={errors.phone?.message}
|
||||
isInvalid={!!errors.phone}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="bio"
|
||||
className="block text-sm font-medium text-text-secondary mb-2"
|
||||
>
|
||||
{t('settings.profile.bio')}
|
||||
</label>
|
||||
<textarea
|
||||
id="bio"
|
||||
rows={4}
|
||||
value={watch('bio') ?? ''}
|
||||
onChange={(event) =>
|
||||
setValue('bio', event.target.value, { shouldDirty: true })
|
||||
}
|
||||
onBlur={register('bio').onBlur}
|
||||
placeholder={t('settings.profile.bioPlaceholder')}
|
||||
className="flex w-full rounded-md border border-border-primary bg-bg-secondary px-3 py-2 text-base text-text-primary placeholder:text-text-tertiary transition-all duration-[150ms] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:border-accent-primary focus-visible:ring-accent-primary"
|
||||
/>
|
||||
{errors.bio && (
|
||||
<p className="mt-1.5 text-sm text-accent-error">
|
||||
{errors.bio.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={t('settings.profile.email')}
|
||||
value={user?.email ?? ''}
|
||||
isDisabled
|
||||
description={t('settings.profile.emailCannotChange')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('settings.profile.username')}
|
||||
value={username}
|
||||
isDisabled
|
||||
description={t('settings.profile.usernameCannotChange')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isLoading={isSaving}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
{t('settings.profile.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
import { Card } from '../../../../features/shared/components/ui/card';
|
||||
import { AuthGuard } from '../../../../features/shared/middleware/auth-guard';
|
||||
import { useAuthStore } from '../../../../stores/auth-store';
|
||||
|
||||
/**
|
||||
* EN: Admin User Detail/Edit Page
|
||||
* VI: Trang chi tiết/edit User cho Admin
|
||||
*
|
||||
* Features:
|
||||
* - Display user details
|
||||
* - Edit user information
|
||||
* - Role management
|
||||
* - Activity logs (placeholder)
|
||||
* - Breadcrumb navigation
|
||||
*/
|
||||
export default function AdminUserDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const userId = params.id as string;
|
||||
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const {
|
||||
currentUser: user,
|
||||
isLoadingUser,
|
||||
error,
|
||||
fetchUser,
|
||||
updateUser,
|
||||
clearCurrentUser,
|
||||
clearError,
|
||||
} = useUsersStore();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Check permissions
|
||||
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(() => {
|
||||
if (isAdmin && userId) {
|
||||
fetchUser(userId);
|
||||
}
|
||||
}, [isAdmin, userId, fetchUser]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearCurrentUser();
|
||||
};
|
||||
}, [clearCurrentUser]);
|
||||
|
||||
const handleEditSubmit = async (userData: any) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
await updateUser(user.id, userData);
|
||||
setIsEditing(false);
|
||||
clearError();
|
||||
} catch (error) {
|
||||
// Error handled in store
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingUser) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Card className="p-8 text-center max-w-md">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">Error</h2>
|
||||
<p className="text-gray-600 mb-6">{error}</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Button onClick={() => fetchUser(userId)}>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => router.back()}>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Card className="p-8 text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">User Not Found</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
The user you're looking for doesn't exist.
|
||||
</p>
|
||||
<Button onClick={() => router.push('/admin/users')}>
|
||||
Back to Users
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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="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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
import { Input } from '../../../features/shared/components/ui/input';
|
||||
import { Card } from '../../../features/shared/components/ui/card';
|
||||
import { ResponsiveLayout } from '../../../features/shared/components/layout/responsive-layout';
|
||||
import { AuthGuard } from '../../../features/shared/middleware/auth-guard';
|
||||
import { useAuthStore } from '../../../stores/auth-store';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* EN: Admin Users List Page
|
||||
* VI: Trang danh sách Users cho Admin
|
||||
*
|
||||
* Features:
|
||||
* - List all users with pagination
|
||||
* - Search and filter functionality
|
||||
* - Bulk actions (activate/deactivate/delete)
|
||||
* - Create new user modal
|
||||
* - Edit user modal
|
||||
* - Role-based access control
|
||||
*/
|
||||
export default function AdminUsersPage() {
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const {
|
||||
users,
|
||||
pagination,
|
||||
isLoading,
|
||||
error,
|
||||
fetchUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
bulkDeleteUsers,
|
||||
bulkUpdateUserRoles,
|
||||
clearError,
|
||||
} = useUsersStore();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedRole, setSelectedRole] = useState<string>('all');
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>('all');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<any>(null);
|
||||
|
||||
// Check if current user has admin permissions
|
||||
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 as Role) : undefined,
|
||||
isActive: selectedStatus !== 'all' ? selectedStatus === 'active' : undefined,
|
||||
limit: 20,
|
||||
};
|
||||
fetchUsers(params as any);
|
||||
}
|
||||
}, [isAdmin, searchQuery, selectedRole, selectedStatus, fetchUsers]);
|
||||
|
||||
const handleCreateUser = async (userData: any) => {
|
||||
try {
|
||||
await createUser(userData);
|
||||
setShowCreateModal(false);
|
||||
clearError();
|
||||
} catch (error) {
|
||||
// Error is handled in the store
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditUser = async (userData: any) => {
|
||||
if (!editingUser) return;
|
||||
|
||||
try {
|
||||
await updateUser(editingUser.id, userData);
|
||||
setEditingUser(null);
|
||||
clearError();
|
||||
} catch (error) {
|
||||
// Error is handled in the store
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (user: any) => {
|
||||
if (confirm(`Are you sure you want to delete ${user.email}?`)) {
|
||||
try {
|
||||
await deleteUser(user.id);
|
||||
clearError();
|
||||
} catch (error) {
|
||||
// Error is handled in the store
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleUserStatus = async (user: any) => {
|
||||
try {
|
||||
await updateUser(user.id, { isActive: !user.isActive });
|
||||
clearError();
|
||||
} catch (error) {
|
||||
// Error is handled in the store
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAction = async (action: string, userIds: string[]) => {
|
||||
const actionMessages = {
|
||||
delete: `Are you sure you want to delete ${userIds.length} user(s)?`,
|
||||
activate: `Are you sure you want to activate ${userIds.length} user(s)?`,
|
||||
deactivate: `Are you sure you want to deactivate ${userIds.length} user(s)?`,
|
||||
};
|
||||
|
||||
if (!confirm(actionMessages[action as keyof typeof actionMessages])) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'delete') {
|
||||
await bulkDeleteUsers(userIds);
|
||||
} else if (action === 'activate' || action === 'deactivate') {
|
||||
const updates = userIds.map(id => ({
|
||||
id,
|
||||
role: users.find(u => u.id === id)?.role || 'USER',
|
||||
}));
|
||||
// Note: For status changes, we'd need a bulk update status method
|
||||
// For now, we'll handle individual updates
|
||||
for (const userId of userIds) {
|
||||
const user = users.find(u => u.id === userId);
|
||||
if (user) {
|
||||
await updateUser(userId, { isActive: action === 'activate' });
|
||||
}
|
||||
}
|
||||
}
|
||||
clearError();
|
||||
} catch (error) {
|
||||
// Error is handled in the store
|
||||
}
|
||||
};
|
||||
|
||||
// Desktop Header
|
||||
const desktopHeader = (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back</span>
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-white">Users Management</h1>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add User</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Mobile Header
|
||||
const mobileHeader = (
|
||||
<div className="flex items-center justify-between w-full px-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.back()}
|
||||
className="p-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-semibold text-white">Users</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="p-2"
|
||||
>
|
||||
<Plus className="w-5 h-5 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const pageContent = (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Page Title (hidden on mobile, shown in header) */}
|
||||
<div className="hidden md:block mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Users Management</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Manage user accounts, roles, and permissions
|
||||
</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={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={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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Create User Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<UserForm
|
||||
isCreate
|
||||
loading={isLoading}
|
||||
onSubmit={handleCreateUser}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit User Modal */}
|
||||
{editingUser && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<UserForm
|
||||
user={editingUser}
|
||||
loading={isLoading}
|
||||
onSubmit={handleEditUser}
|
||||
onCancel={() => setEditingUser(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthGuard requiredRoles={[Role.ADMIN, Role.SUPER_ADMIN]}>
|
||||
<ResponsiveLayout
|
||||
header={mobileHeader}
|
||||
showHeader={true}
|
||||
enablePullToRefresh={true}
|
||||
onRefresh={async () => {
|
||||
await fetchUsers();
|
||||
}}
|
||||
>
|
||||
{pageContent}
|
||||
</ResponsiveLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { getStoredRedirectUrl } from '@/features/auth/lib/oauth';
|
||||
|
||||
/**
|
||||
* EN: OAuth callback content component
|
||||
* VI: Component nội dung OAuth callback
|
||||
*/
|
||||
function OAuthCallbackContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { oauthLogin } = useAuthStore();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* EN: Handle OAuth callback with token from URL
|
||||
* VI: Xử lý OAuth callback với token từ URL
|
||||
*/
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// EN: Get token from URL query parameters
|
||||
// VI: Lấy token từ query parameters của URL
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No token provided / Không có token được cung cấp');
|
||||
}
|
||||
|
||||
// EN: Authenticate with OAuth token
|
||||
// VI: Xác thực với OAuth token
|
||||
await oauthLogin(token);
|
||||
|
||||
// EN: Get stored redirect URL or default to home
|
||||
// VI: Lấy redirect URL đã lưu hoặc mặc định về trang chủ
|
||||
const redirectUrl = getStoredRedirectUrl() || '/';
|
||||
|
||||
// EN: Redirect to intended destination
|
||||
// VI: Chuyển hướng đến đích dự định
|
||||
router.push(redirectUrl);
|
||||
} catch (err: any) {
|
||||
console.error('OAuth callback error:', err);
|
||||
setError(err.message || 'Authentication failed / Xác thực thất bại');
|
||||
// EN: Redirect to error page after 3 seconds
|
||||
// VI: Chuyển hướng đến trang lỗi sau 3 giây
|
||||
setTimeout(() => {
|
||||
router.push(`/auth/error?message=${encodeURIComponent(err.message || 'Authentication failed')}`);
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [searchParams, router, oauthLogin]);
|
||||
|
||||
// EN: Loading state while processing callback
|
||||
// VI: Trạng thái loading trong khi xử lý callback
|
||||
if (!error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-lg">Completing authentication... / Đang hoàn tất xác thực...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// EN: Error state
|
||||
// VI: Trạng thái lỗi
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-4">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg text-red-500 mb-2">Error / Lỗi</p>
|
||||
<p className="text-gray-600">{error}</p>
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
Redirecting... / Đang chuyển hướng...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: OAuth callback page to handle authentication after OAuth redirect
|
||||
* VI: Trang callback OAuth để xử lý xác thực sau khi redirect OAuth
|
||||
*/
|
||||
export default function OAuthCallbackPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-lg">Loading... / Đang tải...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<OAuthCallbackContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Button } from '@/features/shared/components/ui/button';
|
||||
import { handleOAuthError } from '@/features/auth/lib/oauth';
|
||||
|
||||
/**
|
||||
* EN: OAuth error content component
|
||||
* VI: Component nội dung lỗi OAuth
|
||||
*/
|
||||
function OAuthErrorContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// EN: Get error message from URL
|
||||
// VI: Lấy thông báo lỗi từ URL
|
||||
const errorMessage = searchParams.get('message') || 'Unknown error / Lỗi không xác định';
|
||||
const decodedError = handleOAuthError(errorMessage);
|
||||
|
||||
/**
|
||||
* EN: Handle retry by redirecting to login page
|
||||
* VI: Xử lý thử lại bằng cách chuyển hướng đến trang đăng nhập
|
||||
*/
|
||||
const handleRetry = () => {
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Handle go home redirect
|
||||
* VI: Xử lý chuyển hướng về trang chủ
|
||||
*/
|
||||
const handleGoHome = () => {
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 text-center">
|
||||
{/* EN: Error icon / VI: Icon lỗi */}
|
||||
<div className="text-red-500 mb-4">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* EN: Error title / VI: Tiêu đề lỗi */}
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Authentication Error / Lỗi Xác Thực
|
||||
</h1>
|
||||
|
||||
{/* EN: Error message / VI: Thông báo lỗi */}
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
{decodedError}
|
||||
</p>
|
||||
|
||||
{/* EN: Action buttons / VI: Các nút hành động */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button
|
||||
onClick={handleRetry}
|
||||
variant="primary"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Try Again / Thử Lại
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGoHome}
|
||||
variant="secondary"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Go Home / Về Trang Chủ
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: OAuth error page to display authentication errors
|
||||
* VI: Trang lỗi OAuth để hiển thị lỗi xác thực
|
||||
*/
|
||||
export default function OAuthErrorPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-lg">Loading... / Đang tải...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<OAuthErrorContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* EN: Redirect /auth/login to /login
|
||||
* VI: Chuyển hướng /auth/login sang /login
|
||||
*/
|
||||
export default function AuthLoginRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace('/login');
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-text-secondary">Redirecting...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { MessageCircle, User, Settings, Home, Search, LogOut, Menu } from 'lucide-react';
|
||||
import { useAuthStore } from '../../stores/auth-store';
|
||||
import { ResponsiveLayout } from '../../features/shared/components/layout/responsive-layout';
|
||||
import { MobileBottomNav, useBottomNav } from '../../features/shared/components/layout/mobile-layout';
|
||||
import { Button } from '../../features/shared/components/ui/button';
|
||||
import { Card } from '../../features/shared/components/ui/card';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* EN: Dashboard Page - Main authenticated user interface
|
||||
* VI: Trang Dashboard - Giao diện chính cho người dùng đã xác thực
|
||||
*
|
||||
* Features:
|
||||
* - Responsive layout (desktop sidebar + mobile bottom nav)
|
||||
* - Quick actions and status overview
|
||||
* - Navigation to different sections
|
||||
* - User greeting and avatar
|
||||
*/
|
||||
export default function DashboardPage() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { activeItem, handleNavPress } = useBottomNav('home');
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
// Desktop Header
|
||||
const desktopHeader = (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="p-2"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-white">Dashboard</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-semibold text-sm">
|
||||
{user.email[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Mobile Header
|
||||
const mobileHeader = (
|
||||
<div className="flex items-center justify-between w-full px-4">
|
||||
<h1 className="text-lg font-semibold text-white">Dashboard</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-semibold text-sm">
|
||||
{user.email[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Desktop Sidebar
|
||||
const desktopSidebar = (
|
||||
<div className="p-4">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-semibold">
|
||||
{user.email[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{user.email}</p>
|
||||
<p className="text-sm text-gray-500 capitalize">{user.role.toLowerCase()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!sidebarCollapsed && (
|
||||
<nav className="space-y-2">
|
||||
{[
|
||||
{ id: 'home', label: 'Home', icon: Home },
|
||||
{ id: 'chat', label: 'Chat', icon: MessageCircle },
|
||||
{ id: 'search', label: 'Search', icon: Search },
|
||||
{ id: 'profile', label: 'Profile', icon: User },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
].map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
if (item.id === 'chat') router.push('/chat');
|
||||
else if (item.id === 'profile') router.push('/profile');
|
||||
else if (item.id === 'settings') router.push('/settings');
|
||||
}}
|
||||
className="w-full flex items-center space-x-3 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Icon className="w-5 h-5 text-gray-600" />
|
||||
<span className="text-gray-700">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!sidebarCollapsed && (
|
||||
<div className="border-t pt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center justify-center"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Mobile Bottom Navigation Items
|
||||
const mobileNavItems = [
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
icon: <Home className="w-6 h-6" />,
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
label: 'Search',
|
||||
icon: <Search className="w-6 h-6" />,
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
label: 'Chat',
|
||||
icon: <MessageCircle className="w-6 h-6" />,
|
||||
badge: 2,
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profile',
|
||||
icon: <User className="w-6 h-6" />,
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: <Settings className="w-6 h-6" />,
|
||||
},
|
||||
];
|
||||
|
||||
const handleMobileNavPress = (itemId: string) => {
|
||||
handleNavPress(itemId);
|
||||
if (itemId === 'chat') router.push('/chat');
|
||||
else if (itemId === 'profile') router.push('/profile');
|
||||
else if (itemId === 'settings') router.push('/settings');
|
||||
};
|
||||
|
||||
// Dashboard Content
|
||||
const dashboardContent = (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* Welcome Section */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Welcome back, {user.email.split('@')[0]}!
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Here's what's happening with your account today.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<MessageCircle className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Active Chats</p>
|
||||
<p className="text-2xl font-bold text-gray-900">3</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<User className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Profile Views</p>
|
||||
<p className="text-2xl font-bold text-gray-900">12</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<Settings className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Settings Updated</p>
|
||||
<p className="text-2xl font-bold text-gray-900">2</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quick Actions</h3>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() => router.push('/chat')}
|
||||
className="w-full flex items-center justify-start"
|
||||
variant="secondary"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-3" />
|
||||
Start New Chat
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push('/profile')}
|
||||
className="w-full flex items-center justify-start"
|
||||
variant="secondary"
|
||||
>
|
||||
<User className="w-4 h-4 mr-3" />
|
||||
Update Profile
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push('/settings')}
|
||||
className="w-full flex items-center justify-start"
|
||||
variant="secondary"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-3" />
|
||||
Account Settings
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<p className="text-sm text-gray-600">Started a new conversation</p>
|
||||
<span className="text-xs text-gray-400">2h ago</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<p className="text-sm text-gray-600">Updated profile information</p>
|
||||
<span className="text-xs text-gray-400">1d ago</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
|
||||
<p className="text-sm text-gray-600">Changed notification settings</p>
|
||||
<span className="text-xs text-gray-400">3d ago</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Account Status */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Account Status</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Account Active</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Your account is in good standing. Member since {new Date(user.createdAt).getFullYear()}.
|
||||
</p>
|
||||
</div>
|
||||
<div className={`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'}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveLayout
|
||||
// Desktop props
|
||||
header={desktopHeader}
|
||||
sidebar={desktopSidebar}
|
||||
showSidebar={true}
|
||||
sidebarWidth={sidebarCollapsed ? 64 : 280}
|
||||
sidebarCollapsible={true}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
|
||||
// Mobile props
|
||||
showBottomNav={true}
|
||||
bottomNavItems={mobileNavItems}
|
||||
activeNavItem={activeItem}
|
||||
onNavItemPress={handleMobileNavPress}
|
||||
enablePullToRefresh={true}
|
||||
onRefresh={async () => {
|
||||
// Simulate refresh
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}}
|
||||
>
|
||||
{dashboardContent}
|
||||
</ResponsiveLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
/**
|
||||
* EN: Global Styles with Tailwind CSS 4
|
||||
* VI: Styles toàn cục với Tailwind CSS 4
|
||||
*
|
||||
* Import theme variables first, then glassmorphism utilities, then Tailwind CSS 4
|
||||
* Import các biến theme trước, sau đó là glassmorphism utilities, rồi đến Tailwind CSS 4
|
||||
*/
|
||||
@import "../styles/theme.css";
|
||||
@import "../styles/glass.css";
|
||||
@import "tailwindcss";
|
||||
|
||||
/**
|
||||
* EN: Base Styles
|
||||
* VI: Styles cơ bản
|
||||
*/
|
||||
@layer base {
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Disable smooth scroll for users who prefer reduced motion
|
||||
* VI: Tắt smooth scroll cho người dùng ưa thích giảm chuyển động
|
||||
*/
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
/* Also disable all animations */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Custom text selection (X.ai blue)
|
||||
* VI: Tùy chỉnh text selection (X.ai blue)
|
||||
*/
|
||||
::selection {
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Custom scrollbar (X.ai minimal)
|
||||
* VI: Tùy chỉnh scrollbar (X.ai minimal)
|
||||
*/
|
||||
/* Webkit browsers (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--glass-border-default);
|
||||
border-radius: var(--radius-full);
|
||||
transition: background var(--duration-fast) var(--ease-snap);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--glass-border-hover);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--glass-border-default) var(--bg-secondary);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-light);
|
||||
line-height: var(--leading-relaxed);
|
||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
||||
color var(--duration-normal) var(--ease-in-out);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Smooth transitions for theme switching
|
||||
* VI: Chuyển đổi mượt mà cho việc chuyển theme
|
||||
*/
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
||||
color var(--duration-normal) var(--ease-in-out),
|
||||
border-color var(--duration-normal) var(--ease-in-out);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Focus indicators for keyboard navigation (WCAG 2.1 AA)
|
||||
* VI: Chỉ báo focus cho điều hướng bàn phím (WCAG 2.1 AA)
|
||||
*/
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Skip link styles (screen reader only until focused)
|
||||
* VI: Styles cho skip link (chỉ screen reader cho đến khi focus)
|
||||
*/
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.sr-only:focus,
|
||||
.sr-only:focus-visible {
|
||||
position: fixed;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: inherit;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Ensure minimum font size for accessibility (16px) - WCAG 2.1 AA
|
||||
* VI: Đảm bảo kích thước font tối thiểu cho accessibility (16px) - WCAG 2.1 AA
|
||||
*/
|
||||
body {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Message bubble fade-in animation
|
||||
* VI: Animation fade-in cho message bubble
|
||||
*/
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Floating Animation - X.ai Minimalist (Simplified)
|
||||
* VI: Animation lơ lửng - X.ai Minimalist (Đơn giản hóa)
|
||||
*/
|
||||
@keyframes float {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Skeleton Loading Animation
|
||||
* VI: Animation loading skeleton
|
||||
*/
|
||||
@keyframes skeleton {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EN: Removed - Mesh Gradient Animation (X.ai Minimalist)
|
||||
VI: Đã xóa - Mesh Gradient Animation (X.ai Minimalist)
|
||||
============================================ */
|
||||
/* Removed for cleaner, minimalist aesthetic */
|
||||
|
||||
/* ============================================
|
||||
EN: Removed - Shimmer Animation (X.ai Minimalist)
|
||||
VI: Đã xóa - Shimmer Animation (X.ai Minimalist)
|
||||
============================================ */
|
||||
/* Removed for cleaner, minimalist aesthetic */
|
||||
|
||||
/**
|
||||
* EN: Utilities Layer
|
||||
* VI: Layer Utilities
|
||||
*/
|
||||
@layer utilities {
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 1s var(--ease-spring) forwards;
|
||||
}
|
||||
|
||||
/* EN: Simplified float animation - X.ai style */
|
||||
.animate-float {
|
||||
animation: float 4s var(--ease-smooth) infinite;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Skeleton Loading Utilities
|
||||
* VI: Utilities loading skeleton
|
||||
*/
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg,
|
||||
var(--glass-bg-subtle) 0%,
|
||||
var(--glass-bg-default) 50%,
|
||||
var(--glass-bg-subtle) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
height: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 2em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 200px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Button Press Micro-interaction
|
||||
* VI: Micro-interaction cho button press
|
||||
*/
|
||||
.btn-press {
|
||||
transition: transform var(--duration-fast) var(--ease-snap);
|
||||
}
|
||||
|
||||
.btn-press:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EN: Removed - Cosmic Effects (X.ai Minimalist)
|
||||
VI: Đã xóa - Cosmic Effects (X.ai Minimalist)
|
||||
============================================ */
|
||||
/* Removed .mesh-gradient, .mesh-spot, .text-brand-glow, .animate-shimmer */
|
||||
/* Use simple backgrounds and minimal effects instead */
|
||||
|
||||
.container-responsive {
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container-responsive {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container-responsive {
|
||||
max-width: 768px;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container-responsive {
|
||||
max-width: 1024px;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.container-responsive {
|
||||
max-width: 1280px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-responsive-hero {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.text-responsive-hero {
|
||||
font-size: 3.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-touch {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Print Styles
|
||||
* VI: Styles cho in ấn
|
||||
*/
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Hide non-essential elements */
|
||||
nav,
|
||||
footer,
|
||||
.no-print,
|
||||
button,
|
||||
.auth-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Ensure readable text */
|
||||
* {
|
||||
color: black !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Show link URLs after text */
|
||||
a[href]:after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { ThemeProvider } from '../features/theme';
|
||||
import { QueryProvider } from '../providers/query-provider';
|
||||
import { I18nProvider } from '../features/theme/i18n-provider';
|
||||
|
||||
// EN: Configure Inter font with subsets and variable name
|
||||
// VI: Cấu hình font Inter với các subsets và tên biến CSS
|
||||
const inter = Inter({
|
||||
subsets: ['latin', 'vietnamese'],
|
||||
display: 'swap',
|
||||
variable: '--font-inter',
|
||||
weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
||||
});
|
||||
import { SkipToContent } from '../features/shared/components/accessibility/skip-to-content';
|
||||
|
||||
/**
|
||||
* EN: Metadata for the application
|
||||
* VI: Metadata cho ứng dụng
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'GoodGo Platform - Enterprise Microservices',
|
||||
template: '%s | GoodGo Platform',
|
||||
},
|
||||
description: 'Build, deploy, and scale microservices with confidence. Enterprise-grade microservices platform for modern development teams.',
|
||||
keywords: ['microservices', 'enterprise', 'platform', 'cloud', 'kubernetes', 'devops'],
|
||||
|
||||
// EN: Brand icons for all platforms / VI: Brand icons cho tất cả platforms
|
||||
icons: {
|
||||
icon: '/brand-assets/icons/favicon.svg',
|
||||
apple: '/brand-assets/icons/favicon.svg',
|
||||
},
|
||||
|
||||
// EN: Open Graph for social media sharing / VI: Open Graph cho chia sẻ mạng xã hội
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
url: 'https://goodgo.com',
|
||||
siteName: 'GoodGo Platform',
|
||||
title: 'GoodGo Platform - Enterprise Microservices',
|
||||
description: 'Build, deploy, and scale microservices with confidence',
|
||||
},
|
||||
|
||||
// EN: Twitter Card metadata / VI: Twitter Card metadata
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'GoodGo Platform',
|
||||
description: 'Enterprise Microservices Platform',
|
||||
},
|
||||
|
||||
// EN: PWA manifest / VI: PWA manifest
|
||||
manifest: '/manifest.json',
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Viewport configuration (Next.js 13+ requires separate export)
|
||||
* VI: Cấu hình viewport (Next.js 13+ yêu cầu export riêng)
|
||||
*/
|
||||
export const viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Root layout component for the entire application
|
||||
* VI: Component layout gốc cho toàn bộ ứng dụng
|
||||
*
|
||||
* @param children - Child components to render / Components con để render
|
||||
*/
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
// EN: Root HTML structure with dynamic language (will be updated by I18nProvider)
|
||||
// VI: Cấu trúc HTML gốc với ngôn ngữ động (sẽ được cập nhật bởi I18nProvider)
|
||||
<html lang="en" className={inter.variable} suppressHydrationWarning>
|
||||
<body>
|
||||
<I18nProvider>
|
||||
<SkipToContent />
|
||||
<QueryProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</QueryProvider>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { MobileAppDemo } from '@/features/shared/components/layout/mobile-layout';
|
||||
|
||||
/**
|
||||
* EN: Mobile App Demo Page
|
||||
* VI: Trang Demo Mobile App
|
||||
*
|
||||
* Showcases the native app-style mobile layout components.
|
||||
* Trình diễn các component mobile layout theo phong cách native app.
|
||||
*/
|
||||
export default function MobileDemoPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Mobile App Layout Demo
|
||||
</h1>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Experience native app-style mobile layouts with pull-to-refresh,
|
||||
bottom navigation, and touch-optimized interactions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||
<MobileAppDemo />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-sm text-gray-500">
|
||||
<p>Resize your browser window to mobile size to see the full experience!</p>
|
||||
<p className="mt-2">Try pulling down on the Home screen to test pull-to-refresh.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* EN: Mood Board - Design System Showcase
|
||||
* VI: Mood Board - Showcase Design System
|
||||
*
|
||||
* A comprehensive showcase of all UI components and design tokens
|
||||
* Một trang showcase toàn diện cho tất cả UI components và design tokens
|
||||
*/
|
||||
import { Metadata } from 'next';
|
||||
import { ThemeToggle } from '@/features/theme/components/theme-toggle-enhanced';
|
||||
import TypographySection from './sections/TypographySection';
|
||||
import ColorPaletteSection from './sections/ColorPaletteSection';
|
||||
import SpacingSection from './sections/SpacingSection';
|
||||
import GlassEffectsSection from './sections/GlassEffectsSection';
|
||||
import ButtonsSection from './sections/ButtonsSection';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Mood Board | Design System Showcase',
|
||||
description: 'Comprehensive showcase of all UI components and design tokens',
|
||||
};
|
||||
|
||||
export default function MoodBoardPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-primary">
|
||||
{/* EN: Header with sticky navigation / VI: Header với navigation sticky */}
|
||||
<header className="glass-nav sticky top-0 z-50 p-6">
|
||||
<div className="container-responsive flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-text-primary">
|
||||
Mood Board
|
||||
</h1>
|
||||
<p className="text-text-secondary mt-2">
|
||||
Design System Showcase - All Components & Tokens
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* EN: Theme Toggle / VI: Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* EN: Main content with all sections / VI: Nội dung chính với tất cả sections */}
|
||||
<main className="container-responsive py-12 space-y-16">
|
||||
<TypographySection />
|
||||
<ColorPaletteSection />
|
||||
<SpacingSection />
|
||||
<GlassEffectsSection />
|
||||
<ButtonsSection />
|
||||
</main>
|
||||
|
||||
{/* EN: Footer / VI: Footer */}
|
||||
<footer className="border-t border-border-primary p-6 text-center text-text-tertiary">
|
||||
<p>Design System v1.0.0 - X.ai Minimalist Theme</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* EN: Buttons Section - All button variants
|
||||
* VI: Buttons Section - Tất cả button variants
|
||||
*/
|
||||
import SectionWrapper from '@/features/mood-board/components/SectionWrapper';
|
||||
import ComponentShowcase from '@/features/mood-board/components/ComponentShowcase';
|
||||
|
||||
export default function ButtonsSection() {
|
||||
return (
|
||||
<SectionWrapper
|
||||
id="buttons"
|
||||
title="Buttons"
|
||||
description="All button styles and states"
|
||||
>
|
||||
{/* EN: Glass Buttons / VI: Glass Buttons */}
|
||||
<ComponentShowcase
|
||||
title="Glass Buttons"
|
||||
description="Glassmorphism button styles"
|
||||
code={`<button className="glass-button">Default Glass</button>
|
||||
<button className="glass-button" disabled>Disabled</button>`}
|
||||
darkBg
|
||||
>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<button className="glass-button">Default Glass</button>
|
||||
<button className="glass-button hover:bg-glass-bg-hover">
|
||||
Hover Me
|
||||
</button>
|
||||
<button className="glass-button" disabled>
|
||||
Disabled
|
||||
</button>
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
|
||||
{/* EN: Primary Buttons / VI: Primary Buttons */}
|
||||
<ComponentShowcase
|
||||
title="Primary Buttons (Accent)"
|
||||
description="Brand primary button with X.ai blue"
|
||||
code={`<button className="bg-accent-primary text-white px-6 py-3 rounded-md font-medium hover:bg-accent-primary-hover transition-all">
|
||||
Primary Action
|
||||
</button>`}
|
||||
>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<button className="bg-accent-primary text-white px-6 py-3 rounded-md font-medium hover:bg-accent-primary-hover transition-all btn-press">
|
||||
Primary Action
|
||||
</button>
|
||||
<button className="bg-accent-primary text-white px-6 py-3 rounded-md font-medium opacity-50 cursor-not-allowed">
|
||||
Disabled Primary
|
||||
</button>
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
|
||||
{/* EN: Secondary Buttons / VI: Secondary Buttons */}
|
||||
<ComponentShowcase
|
||||
title="Secondary Buttons"
|
||||
description="Secondary button style with border"
|
||||
code={`<button className="border border-border-primary text-text-primary px-6 py-3 rounded-md font-medium hover:border-border-secondary transition-all">
|
||||
Secondary Action
|
||||
</button>`}
|
||||
>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<button className="border border-border-primary text-text-primary px-6 py-3 rounded-md font-medium hover:border-border-secondary hover:bg-bg-secondary transition-all btn-press">
|
||||
Secondary Action
|
||||
</button>
|
||||
<button className="border border-border-primary text-text-primary px-6 py-3 rounded-md font-medium opacity-50 cursor-not-allowed">
|
||||
Disabled Secondary
|
||||
</button>
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
|
||||
{/* EN: Button Sizes / VI: Button Sizes */}
|
||||
<ComponentShowcase
|
||||
title="Button Sizes"
|
||||
description="Different button size variants"
|
||||
code={`<button className="glass-button text-xs px-3 py-1.5">Small</button>
|
||||
<button className="glass-button text-sm px-4 py-2">Medium</button>
|
||||
<button className="glass-button text-base px-6 py-3">Large</button>`}
|
||||
darkBg
|
||||
>
|
||||
<div className="flex gap-4 items-center flex-wrap">
|
||||
<button className="glass-button text-xs px-3 py-1.5">Small</button>
|
||||
<button className="glass-button text-sm px-4 py-2">Medium</button>
|
||||
<button className="glass-button text-base px-6 py-3">Large</button>
|
||||
<button className="glass-button text-lg px-8 py-4">Extra Large</button>
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
|
||||
{/* EN: Icon Buttons / VI: Icon Buttons */}
|
||||
<ComponentShowcase
|
||||
title="Icon Buttons"
|
||||
description="Square icon buttons for actions"
|
||||
code={`<button className="glass-button w-10 h-10 flex items-center justify-center">
|
||||
×
|
||||
</button>`}
|
||||
darkBg
|
||||
>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<button className="glass-button w-10 h-10 flex items-center justify-center">
|
||||
×
|
||||
</button>
|
||||
<button className="glass-button w-10 h-10 flex items-center justify-center">
|
||||
✓
|
||||
</button>
|
||||
<button className="glass-button w-10 h-10 flex items-center justify-center">
|
||||
⋮
|
||||
</button>
|
||||
<button className="glass-button w-12 h-12 flex items-center justify-center rounded-full">
|
||||
✎
|
||||
</button>
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* EN: Color Palette Section - All design token colors
|
||||
* VI: Color Palette Section - Tất cả color tokens từ design system
|
||||
*/
|
||||
import SectionWrapper from '@/features/mood-board/components/SectionWrapper';
|
||||
import ColorSwatch from '@/features/mood-board/components/ColorSwatch';
|
||||
|
||||
export default function ColorPaletteSection() {
|
||||
return (
|
||||
<SectionWrapper
|
||||
id="colors"
|
||||
title="Color Palette"
|
||||
description="All color tokens - backgrounds, text, borders, accents (Dark/Light mode)"
|
||||
>
|
||||
{/* EN: Background Colors / VI: Màu nền */}
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4 text-text-primary">
|
||||
Background Colors
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<ColorSwatch name="--bg-primary" var="var(--bg-primary)" />
|
||||
<ColorSwatch name="--bg-secondary" var="var(--bg-secondary)" />
|
||||
<ColorSwatch name="--bg-tertiary" var="var(--bg-tertiary)" />
|
||||
<ColorSwatch name="--bg-elevated" var="var(--bg-elevated)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: Text Colors / VI: Màu chữ */}
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4 text-text-primary">
|
||||
Text Colors (WCAG Compliant)
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<ColorSwatch name="--text-primary" var="var(--text-primary)" />
|
||||
<ColorSwatch name="--text-secondary" var="var(--text-secondary)" />
|
||||
<ColorSwatch name="--text-tertiary" var="var(--text-tertiary)" />
|
||||
<ColorSwatch name="--text-muted" var="var(--text-muted)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: Accent Colors / VI: Màu accent */}
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4 text-text-primary">
|
||||
Accent Colors (X.ai Brand)
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<ColorSwatch name="--accent-primary" var="var(--accent-primary)" />
|
||||
<ColorSwatch name="--accent-success" var="var(--accent-success)" />
|
||||
<ColorSwatch name="--accent-warning" var="var(--accent-warning)" />
|
||||
<ColorSwatch name="--accent-error" var="var(--accent-error)" />
|
||||
<ColorSwatch name="--accent-info" var="var(--accent-info)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: Border Colors / VI: Màu viền */}
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4 text-text-primary">
|
||||
Border Colors
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<ColorSwatch name="--border-primary" var="var(--border-primary)" />
|
||||
<ColorSwatch
|
||||
name="--border-secondary"
|
||||
var="var(--border-secondary)"
|
||||
/>
|
||||
<ColorSwatch name="--border-focus" var="var(--border-focus)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: Chat Colors / VI: Màu chat */}
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4 text-text-primary">
|
||||
Chat Specific Colors
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<ColorSwatch name="--chat-user-bubble" var="var(--chat-user-bubble)" />
|
||||
<ColorSwatch name="--chat-user-text" var="var(--chat-user-text)" />
|
||||
<ColorSwatch name="--chat-ai-text" var="var(--chat-ai-text)" />
|
||||
</div>
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* EN: Glass Effects Section - All glassmorphism utilities
|
||||
* VI: Glass Effects Section - Tất cả glassmorphism utilities
|
||||
*/
|
||||
import SectionWrapper from '@/features/mood-board/components/SectionWrapper';
|
||||
import ComponentShowcase from '@/features/mood-board/components/ComponentShowcase';
|
||||
|
||||
export default function GlassEffectsSection() {
|
||||
return (
|
||||
<SectionWrapper
|
||||
id="glass-effects"
|
||||
title="Glassmorphism Effects"
|
||||
description="All glass utilities from glass.css (X.ai Minimal Style)"
|
||||
>
|
||||
{/* EN: Glass Card / VI: Glass Card */}
|
||||
<ComponentShowcase
|
||||
title="Glass Card"
|
||||
description="Default glass card with subtle blur and border"
|
||||
code={`<div className="glass-card p-6">
|
||||
<h3 className="text-xl font-semibold">Glass Card</h3>
|
||||
<p>Ultra-minimal glassmorphism effect</p>
|
||||
</div>`}
|
||||
darkBg
|
||||
>
|
||||
<div className="glass-card p-6 max-w-md">
|
||||
<h3 className="text-xl font-semibold text-text-primary mb-2">
|
||||
Glass Card
|
||||
</h3>
|
||||
<p className="text-text-secondary">
|
||||
Ultra-minimal glassmorphism effect with 4% opacity background and 8px
|
||||
blur.
|
||||
</p>
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
|
||||
{/* EN: Glass Variants / VI: Glass Variants */}
|
||||
<ComponentShowcase
|
||||
title="Glass Variants"
|
||||
description="Different glass effect intensities"
|
||||
code={`<div className="glass-subtle">Subtle (1% bg, 4px blur)</div>
|
||||
<div className="glass-card">Default (4% bg, 8px blur)</div>
|
||||
<div className="glass-strong">Strong (5% bg, 12px blur)</div>`}
|
||||
darkBg
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="glass-subtle p-6 rounded-lg border border-glass-border-subtle">
|
||||
<h4 className="font-semibold text-text-primary mb-2">
|
||||
Glass Subtle
|
||||
</h4>
|
||||
<p className="text-sm text-text-secondary">
|
||||
1% bg, 4px blur
|
||||
</p>
|
||||
</div>
|
||||
<div className="glass-card p-6">
|
||||
<h4 className="font-semibold text-text-primary mb-2">
|
||||
Glass Card (Default)
|
||||
</h4>
|
||||
<p className="text-sm text-text-secondary">
|
||||
4% bg, 8px blur
|
||||
</p>
|
||||
</div>
|
||||
<div className="glass-strong p-6 rounded-lg">
|
||||
<h4 className="font-semibold text-text-primary mb-2">
|
||||
Glass Strong
|
||||
</h4>
|
||||
<p className="text-sm text-text-secondary">
|
||||
5% bg, 12px blur
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
|
||||
{/* EN: Glass Button / VI: Glass Button */}
|
||||
<ComponentShowcase
|
||||
title="Glass Button"
|
||||
description="Interactive glass button with hover/active states"
|
||||
code={`<button className="glass-button">
|
||||
Click Me
|
||||
</button>`}
|
||||
darkBg
|
||||
>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<button className="glass-button">Default Button</button>
|
||||
<button className="glass-button" disabled>
|
||||
Disabled Button
|
||||
</button>
|
||||
<button className="glass-button px-6 py-3">Larger Button</button>
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
|
||||
{/* EN: Glass Input / VI: Glass Input */}
|
||||
<ComponentShowcase
|
||||
title="Glass Input"
|
||||
description="Glass input with focus states"
|
||||
code={`<input
|
||||
type="text"
|
||||
placeholder="Enter text..."
|
||||
className="glass-input w-full rounded-md px-4 py-2"
|
||||
/>`}
|
||||
darkBg
|
||||
>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Glass input..."
|
||||
className="glass-input w-full rounded-md px-4 py-2 text-text-primary"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email address..."
|
||||
className="glass-input w-full rounded-md px-4 py-2 text-text-primary"
|
||||
/>
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
|
||||
{/* EN: Glass Badge / VI: Glass Badge */}
|
||||
<ComponentShowcase
|
||||
title="Glass Badge"
|
||||
description="Small glass badges for tags and labels"
|
||||
code={`<span className="glass-badge">Design</span>
|
||||
<span className="glass-badge">Development</span>`}
|
||||
darkBg
|
||||
>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<span className="glass-badge">Design</span>
|
||||
<span className="glass-badge">Development</span>
|
||||
<span className="glass-badge">X.ai Minimal</span>
|
||||
<span className="glass-badge">Glassmorphism</span>
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* EN: Spacing Section - Spacing scale and layout tokens
|
||||
* VI: Spacing Section - Spacing scale và layout tokens
|
||||
*/
|
||||
import SectionWrapper from '@/features/mood-board/components/SectionWrapper';
|
||||
import ComponentShowcase from '@/features/mood-board/components/ComponentShowcase';
|
||||
|
||||
export default function SpacingSection() {
|
||||
return (
|
||||
<SectionWrapper
|
||||
id="spacing"
|
||||
title="Spacing & Layout"
|
||||
description="Spacing scale (4px base unit) and layout tokens"
|
||||
>
|
||||
{/* EN: Spacing Scale / VI: Spacing Scale */}
|
||||
<ComponentShowcase
|
||||
title="Spacing Scale"
|
||||
description="8-point grid system from --space-0 to --space-20"
|
||||
code={`<div className="space-y-1">0.25rem (4px)</div>
|
||||
<div className="space-y-2">0.5rem (8px)</div>
|
||||
<div className="space-y-4">1rem (16px)</div>
|
||||
<div className="space-y-8">2rem (32px)</div>`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ name: '--space-0', value: '0', px: '0px' },
|
||||
{ name: '--space-1', value: '0.25rem', px: '4px' },
|
||||
{ name: '--space-2', value: '0.5rem', px: '8px' },
|
||||
{ name: '--space-3', value: '0.75rem', px: '12px' },
|
||||
{ name: '--space-4', value: '1rem', px: '16px' },
|
||||
{ name: '--space-5', value: '1.25rem', px: '20px' },
|
||||
{ name: '--space-6', value: '1.5rem', px: '24px' },
|
||||
{ name: '--space-8', value: '2rem', px: '32px' },
|
||||
{ name: '--space-10', value: '2.5rem', px: '40px' },
|
||||
{ name: '--space-12', value: '3rem', px: '48px' },
|
||||
{ name: '--space-16', value: '4rem', px: '64px' },
|
||||
{ name: '--space-20', value: '5rem', px: '80px' },
|
||||
].map((token) => (
|
||||
<div key={token.name} className="flex items-center gap-4">
|
||||
<div
|
||||
className="bg-accent-primary rounded"
|
||||
style={{
|
||||
width: token.value,
|
||||
height: '24px',
|
||||
minWidth: '4px',
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<code className="text-sm font-mono text-text-primary">
|
||||
{token.name}
|
||||
</code>
|
||||
<span className="text-text-tertiary text-sm ml-2">
|
||||
{token.value} ({token.px})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
|
||||
{/* EN: Border Radius / VI: Border Radius */}
|
||||
<ComponentShowcase
|
||||
title="Border Radius"
|
||||
description="Minimal roundness for X.ai aesthetic"
|
||||
code={`<div className="rounded-sm">2px - Sharp</div>
|
||||
<div className="rounded-md">4px - Buttons</div>
|
||||
<div className="rounded-lg">8px - Cards</div>
|
||||
<div className="rounded-full">9999px - Avatars</div>`}
|
||||
>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ name: '--radius-sm', value: '2px', class: 'rounded-sm' },
|
||||
{ name: '--radius-md', value: '4px', class: 'rounded-md' },
|
||||
{ name: '--radius-lg', value: '8px', class: 'rounded-lg' },
|
||||
{ name: '--radius-xl', value: '12px', class: 'rounded-xl' },
|
||||
{ name: '--radius-2xl', value: '16px', class: 'rounded-2xl' },
|
||||
{ name: '--radius-full', value: '9999px', class: 'rounded-full' },
|
||||
].map((radius) => (
|
||||
<div key={radius.name} className="glass-card p-4">
|
||||
<div
|
||||
className={`bg-accent-primary w-full h-20 ${radius.class} mb-2`}
|
||||
/>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{radius.name}
|
||||
</p>
|
||||
<code className="text-xs text-text-tertiary font-mono">
|
||||
{radius.value}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* EN: Typography Section - Font scales, weights, letter spacing showcase
|
||||
* VI: Typography Section - Showcase font scales, weights, letter spacing
|
||||
*/
|
||||
import SectionWrapper from '@/features/mood-board/components/SectionWrapper';
|
||||
import ComponentShowcase from '@/features/mood-board/components/ComponentShowcase';
|
||||
|
||||
export default function TypographySection() {
|
||||
return (
|
||||
<SectionWrapper
|
||||
id="typography"
|
||||
title="Typography"
|
||||
description="Font scales, weights, line heights, and letter spacing from Design System"
|
||||
>
|
||||
{/* EN: Font Sizes / VI: Font Sizes */}
|
||||
<ComponentShowcase
|
||||
title="Type Scale"
|
||||
description="All font sizes from --text-xs (12px) to --text-6xl (56px)"
|
||||
code={`<h1 className="text-6xl font-extrabold">Hero Title (56px)</h1>
|
||||
<h2 className="text-5xl font-bold">Page Title (44px)</h2>
|
||||
<h3 className="text-4xl font-semibold">Section Header (36px)</h3>
|
||||
<p className="text-base">Body Text (16px)</p>
|
||||
<span className="text-xs">Caption (12px)</span>`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-6xl font-extrabold text-text-primary">
|
||||
Hero Title (--text-6xl)
|
||||
</p>
|
||||
<p className="text-5xl font-bold text-text-primary">
|
||||
Page Title (--text-5xl)
|
||||
</p>
|
||||
<p className="text-4xl font-semibold text-text-primary">
|
||||
Section Header (--text-4xl)
|
||||
</p>
|
||||
<p className="text-3xl font-semibold text-text-primary">
|
||||
Card Header (--text-3xl)
|
||||
</p>
|
||||
<p className="text-2xl text-text-primary">Large Body (--text-2xl)</p>
|
||||
<p className="text-xl text-text-primary">Emphasized (--text-xl)</p>
|
||||
<p className="text-lg text-text-primary">Large Body (--text-lg)</p>
|
||||
<p className="text-base text-text-primary">
|
||||
Default Body (--text-base)
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary">Small Text (--text-sm)</p>
|
||||
<p className="text-xs text-text-tertiary">Caption (--text-xs)</p>
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
|
||||
{/* EN: Font Weights / VI: Font Weights */}
|
||||
<ComponentShowcase
|
||||
title="Font Weights"
|
||||
description="All weight variants for typography hierarchy (100-900)"
|
||||
code={`<p className="font-thin">Thin (100)</p>
|
||||
<p className="font-light">Light (300)</p>
|
||||
<p className="font-normal">Normal (400)</p>
|
||||
<p className="font-medium">Medium (500)</p>
|
||||
<p className="font-semibold">Semibold (600)</p>
|
||||
<p className="font-bold">Bold (700)</p>
|
||||
<p className="font-extrabold">Extra Bold (800)</p>
|
||||
<p className="font-black">Black (900)</p>`}
|
||||
>
|
||||
<div className="space-y-2 text-2xl text-text-primary">
|
||||
<p className="font-thin">Thin (--font-thin: 100)</p>
|
||||
<p className="font-extralight">
|
||||
Extra Light (--font-extralight: 200)
|
||||
</p>
|
||||
<p className="font-light">Light (--font-light: 300)</p>
|
||||
<p className="font-normal">Normal (--font-normal: 400)</p>
|
||||
<p className="font-medium">Medium (--font-medium: 500)</p>
|
||||
<p className="font-semibold">Semibold (--font-semibold: 600)</p>
|
||||
<p className="font-bold">Bold (--font-bold: 700)</p>
|
||||
<p className="font-extrabold">Extra Bold (--font-extrabold: 800)</p>
|
||||
<p className="font-black">Black (--font-black: 900)</p>
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
|
||||
{/* EN: Letter Spacing / VI: Letter Spacing */}
|
||||
<ComponentShowcase
|
||||
title="Letter Spacing (Tracking)"
|
||||
description="Tracking variants for clean, minimal aesthetics"
|
||||
code={`<p className="tracking-tighter">Very Tight (-0.04em)</p>
|
||||
<p className="tracking-tight">Tight (-0.02em)</p>
|
||||
<p className="tracking-normal">Normal (0)</p>
|
||||
<p className="tracking-wide">Wide (0.02em)</p>
|
||||
<p className="tracking-wider">Wider (0.04em)</p>`}
|
||||
>
|
||||
<div className="space-y-2 text-2xl text-text-primary">
|
||||
<p className="tracking-tighter">
|
||||
Very Tight (--tracking-tighter: -0.04em)
|
||||
</p>
|
||||
<p className="tracking-tight">
|
||||
Tight (--tracking-tight: -0.02em)
|
||||
</p>
|
||||
<p className="tracking-normal">Normal (--tracking-normal: 0)</p>
|
||||
<p className="tracking-wide">Wide (--tracking-wide: 0.02em)</p>
|
||||
<p className="tracking-wider">Wider (--tracking-wider: 0.04em)</p>
|
||||
</div>
|
||||
</ComponentShowcase>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import NextImage from 'next/image';
|
||||
import { Button } from '@/ui';
|
||||
import { Footer } from '@/shared/components/layout/footer';
|
||||
import { NavigationHeader } from '@/shared/components/layout/header';
|
||||
import { AnnouncementBanner } from '@/shared/components/layout/announcement-banner';
|
||||
import {
|
||||
ArrowRight,
|
||||
Target, // Marketing/Ads
|
||||
Gift, // Reward Apps
|
||||
Store, // POS System
|
||||
Cpu, // AI/Blockchain
|
||||
BarChart3,
|
||||
Smartphone,
|
||||
CreditCard,
|
||||
Blocks
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* EN: Home page component - GoodGo Platform Landing Page
|
||||
* VI: Component trang chủ - Trang đích GoodGo Platform
|
||||
*/
|
||||
export default function Home() {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
|
||||
// EN: Get authentication state from store
|
||||
// VI: Lấy trạng thái xác thực từ store
|
||||
const { user, isAuthenticated, isLoading, fetchUser } = useAuthStore();
|
||||
|
||||
// EN: State to track if component is mounted on client
|
||||
// VI: State để theo dõi xem component đã mount trên client chưa
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// EN: Set mounted state to true on client side
|
||||
// VI: Đặt trạng thái mounted thành true ở phía client
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// EN: Fetch user data on component mount if not authenticated
|
||||
// VI: Fetch dữ liệu user khi component mount nếu chưa xác thực
|
||||
useEffect(() => {
|
||||
if (isMounted && !isAuthenticated) {
|
||||
fetchUser();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMounted]);
|
||||
|
||||
// EN: Prevent hydration mismatch
|
||||
// VI: Ngăn chặn hydration mismatch
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// EN: Loading state
|
||||
// VI: Trạng thái loading
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-8 bg-bg-primary text-text-primary">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavigationHeader />
|
||||
|
||||
{/* Announcement Banner */}
|
||||
<AnnouncementBanner
|
||||
title="GoodGo Enterprise"
|
||||
description="Empowering businesses with comprehensive digital solutions"
|
||||
linkText="Explore Now"
|
||||
linkUrl="/services"
|
||||
imageSrc="/images/home/announcement-bg.png"
|
||||
/>
|
||||
|
||||
<main className="min-h-screen bg-bg-primary text-text-primary overflow-hidden relative">
|
||||
{/* Ambient Background Effects - Removed for Monochrome */}
|
||||
|
||||
<div className="container mx-auto px-6 relative z-10">
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="flex flex-col items-center justify-center min-h-[80vh] text-center space-y-10 pt-20">
|
||||
<div className="max-w-5xl space-y-6">
|
||||
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full bg-glass-subtle border border-glass-border-subtle mb-4">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-text-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-text-primary"></span>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-secondary">Ecosystem v2.0 Live</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-7xl lg:text-8xl font-bold leading-tight tracking-tight">
|
||||
<span className="text-text-primary block">
|
||||
Powering the Next
|
||||
</span>
|
||||
<span className="text-text-primary block">
|
||||
Generation of Business
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl md:text-2xl text-text-secondary max-w-3xl mx-auto leading-relaxed">
|
||||
Comprehensive solutions for Marketing, Loyalty, Retail, and Web3 technologies.
|
||||
Everything you need to grow your digital presence.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-8">
|
||||
<Button
|
||||
size="lg"
|
||||
className="rounded-full px-8 py-6 text-lg bg-text-primary text-bg-primary hover:bg-text-secondary w-full sm:w-auto transition-all shadow-glow"
|
||||
onPress={() => window.location.href = '/contact'}
|
||||
>
|
||||
Start Building
|
||||
</Button>
|
||||
<Button
|
||||
variant="glass"
|
||||
size="lg"
|
||||
className="rounded-full px-8 py-6 text-lg border-border-primary hover:bg-glass-hover w-full sm:w-auto backdrop-blur-md text-text-primary"
|
||||
onPress={() => window.location.href = '/solutions'}
|
||||
>
|
||||
View Solutions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Solutions Section (4 Pillars) */}
|
||||
<section className="py-24" id="solutions">
|
||||
<div className="text-center mb-20 animate-fade-in-up">
|
||||
<span className="text-text-tertiary font-bold tracking-wider uppercase text-sm mb-2 block">Our Ecosystem</span>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-4">Core Business Pillars</h2>
|
||||
<p className="text-text-secondary max-w-2xl mx-auto text-lg">
|
||||
Integrated technological pillars designed to scale your business operations from end to end.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
|
||||
{/* Pillar 1: Marketing/Ads */}
|
||||
<div className="group relative">
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-br from-text-secondary to-text-tertiary rounded-2xl opacity-10 group-hover:opacity-30 blur transition duration-500"></div>
|
||||
<div className="relative h-full bg-bg-secondary/40 backdrop-blur-xl rounded-2xl border border-glass-border overflow-hidden hover:bg-bg-elevated/90 transition-all duration-300 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="relative h-48 w-full overflow-hidden">
|
||||
<NextImage
|
||||
src="/images/home/marketing-ads.png"
|
||||
alt="Marketing & Ads"
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-110 grayscale"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-bg-secondary via-transparent to-transparent"></div>
|
||||
</div>
|
||||
<div className="p-8 pt-6">
|
||||
<div className="w-12 h-12 bg-glass-bg-hover rounded-xl flex items-center justify-center mb-4 text-text-primary">
|
||||
<Target className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold mb-3 group-hover:text-text-primary transition-colors">Marketing & Ads</h3>
|
||||
<p className="text-text-tertiary leading-relaxed mb-6">
|
||||
Advanced AdTech solutions, Real-Time Bidding (RTB), and cross-platform campaign management.
|
||||
</p>
|
||||
<ul className="space-y-2 mb-6">
|
||||
<li className="flex items-center text-sm text-text-secondary">
|
||||
<BarChart3 className="h-4 w-4 mr-2 text-text-primary" /> Analytics
|
||||
</li>
|
||||
<li className="flex items-center text-sm text-text-secondary">
|
||||
<Target className="h-4 w-4 mr-2 text-text-primary" /> Precise Targeting
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 pb-8">
|
||||
<Button variant="ghost" className="w-full justify-between group-hover:bg-glass-bg-hover group-hover:text-text-primary">
|
||||
Learn more <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pillar 2: Reward Apps */}
|
||||
<div className="group relative">
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-br from-text-secondary to-text-tertiary rounded-2xl opacity-10 group-hover:opacity-30 blur transition duration-500"></div>
|
||||
<div className="relative h-full bg-bg-secondary/40 backdrop-blur-xl rounded-2xl border border-glass-border overflow-hidden hover:bg-bg-elevated/90 transition-all duration-300 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="relative h-48 w-full overflow-hidden">
|
||||
<NextImage
|
||||
src="/images/home/reward-apps.png"
|
||||
alt="Reward Apps"
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-110 grayscale"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-bg-secondary via-transparent to-transparent"></div>
|
||||
</div>
|
||||
<div className="p-8 pt-6">
|
||||
<div className="w-12 h-12 bg-glass-bg-hover rounded-xl flex items-center justify-center mb-4 text-text-primary">
|
||||
<Gift className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold mb-3 group-hover:text-text-primary transition-colors">Reward Apps</h3>
|
||||
<p className="text-text-tertiary leading-relaxed mb-6">
|
||||
Customer loyalty programs, gamification layers, and engagement ecosystems.
|
||||
</p>
|
||||
<ul className="space-y-2 mb-6">
|
||||
<li className="flex items-center text-sm text-text-secondary">
|
||||
<Smartphone className="h-4 w-4 mr-2 text-text-primary" /> Mobile First
|
||||
</li>
|
||||
<li className="flex items-center text-sm text-text-secondary">
|
||||
<Gift className="h-4 w-4 mr-2 text-text-primary" /> Loyalty Points
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 pb-8">
|
||||
<Button variant="ghost" className="w-full justify-between group-hover:bg-glass-bg-hover group-hover:text-text-primary">
|
||||
Learn more <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pillar 3: POS System */}
|
||||
<div className="group relative">
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-br from-text-secondary to-text-tertiary rounded-2xl opacity-10 group-hover:opacity-30 blur transition duration-500"></div>
|
||||
<div className="relative h-full bg-bg-secondary/40 backdrop-blur-xl rounded-2xl border border-glass-border overflow-hidden hover:bg-bg-elevated/90 transition-all duration-300 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="relative h-48 w-full overflow-hidden">
|
||||
<NextImage
|
||||
src="/images/home/pos-system.png"
|
||||
alt="POS System"
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-110 grayscale"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-bg-secondary via-transparent to-transparent"></div>
|
||||
</div>
|
||||
<div className="p-8 pt-6">
|
||||
<div className="w-12 h-12 bg-glass-bg-hover rounded-xl flex items-center justify-center mb-4 text-text-primary">
|
||||
<Store className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold mb-3 group-hover:text-text-primary transition-colors">POS System</h3>
|
||||
<p className="text-text-tertiary leading-relaxed mb-6">
|
||||
Multi-industry Point of Sale solutions for Retail, F&B, and Service sectors.
|
||||
</p>
|
||||
<ul className="space-y-2 mb-6">
|
||||
<li className="flex items-center text-sm text-text-secondary">
|
||||
<CreditCard className="h-4 w-4 mr-2 text-text-primary" /> Payments
|
||||
</li>
|
||||
<li className="flex items-center text-sm text-text-secondary">
|
||||
<Store className="h-4 w-4 mr-2 text-text-primary" /> Inventory
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 pb-8">
|
||||
<Button variant="ghost" className="w-full justify-between group-hover:bg-glass-bg-hover group-hover:text-text-primary">
|
||||
Learn more <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pillar 4: AI/Blockchain */}
|
||||
<div className="group relative">
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-br from-text-secondary to-text-tertiary rounded-2xl opacity-10 group-hover:opacity-30 blur transition duration-500"></div>
|
||||
<div className="relative h-full bg-bg-secondary/40 backdrop-blur-xl rounded-2xl border border-glass-border overflow-hidden hover:bg-bg-elevated/90 transition-all duration-300 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="relative h-48 w-full overflow-hidden">
|
||||
<NextImage
|
||||
src="/images/home/ai-blockchain.png"
|
||||
alt="AI & Blockchain"
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-110 grayscale"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-bg-secondary via-transparent to-transparent"></div>
|
||||
</div>
|
||||
<div className="p-8 pt-6">
|
||||
<div className="w-12 h-12 bg-glass-bg-hover rounded-xl flex items-center justify-center mb-4 text-text-primary">
|
||||
<Cpu className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold mb-3 group-hover:text-text-primary transition-colors">AI & Blockchain</h3>
|
||||
<p className="text-text-tertiary leading-relaxed mb-6">
|
||||
Next-gen infrastructure including Wallet, Mining, Mission, and AI-driven analytics.
|
||||
</p>
|
||||
<ul className="space-y-2 mb-6">
|
||||
<li className="flex items-center text-sm text-text-secondary">
|
||||
<Blocks className="h-4 w-4 mr-2 text-text-primary" /> Web3 Native
|
||||
</li>
|
||||
<li className="flex items-center text-sm text-text-secondary">
|
||||
<Cpu className="h-4 w-4 mr-2 text-text-primary" /> AI Insights
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 pb-8">
|
||||
<Button variant="ghost" className="w-full justify-between group-hover:bg-glass-bg-hover group-hover:text-text-primary">
|
||||
Learn more <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Authenticated User Welcome - Kept from original but styled */}
|
||||
{isAuthenticated && user && (
|
||||
<section className="py-12 border-t border-border-primary">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h2 className="text-2xl font-bold mb-6">Ready to continue your work?</h2>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="solid"
|
||||
className="bg-text-primary text-bg-primary hover:bg-text-secondary px-8"
|
||||
onPress={() => window.location.href = '/dashboard'}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { User, Mail, Lock, Bell, Palette, Globe } from 'lucide-react';
|
||||
import { useAuthStore } from '../../stores/auth-store';
|
||||
import { UserForm } from '../../features/shared/components/users';
|
||||
import { Button } from '../../features/shared/components/ui/button';
|
||||
import { Card } from '../../features/shared/components/ui/card';
|
||||
import { Switch } from '../../features/shared/components/ui/switch';
|
||||
|
||||
/**
|
||||
* EN: User Profile Page
|
||||
* VI: Trang Profile của User
|
||||
*
|
||||
* Features:
|
||||
* - View and edit personal information
|
||||
* - Change password
|
||||
* - Account settings
|
||||
* - Preferences management
|
||||
*/
|
||||
export default function ProfilePage() {
|
||||
const { user, isAuthenticated, updateProfile } = useAuthStore();
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'security' | 'preferences'>('profile');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Mock preferences (in real app, this would come from a preferences store)
|
||||
const [preferences, setPreferences] = useState({
|
||||
emailNotifications: true,
|
||||
pushNotifications: false,
|
||||
darkMode: false,
|
||||
language: 'en',
|
||||
});
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Card className="p-8 text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Please sign in</h2>
|
||||
<p className="text-gray-600">
|
||||
You need to be signed in to access your profile.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleProfileUpdate = async (data: any) => {
|
||||
try {
|
||||
// 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);
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreferenceChange = (key: string, value: any) => {
|
||||
setPreferences(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile', label: 'Profile', icon: User },
|
||||
{ id: 'security', label: 'Security', icon: Lock },
|
||||
{ id: 'preferences', label: 'Preferences', icon: Bell },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">My Profile</h1>
|
||||
<p className="text-gray-600">Manage your account settings and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Overview Card */}
|
||||
<Card className="p-6 mb-8">
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-bold text-2xl">
|
||||
{user.email[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<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'
|
||||
}`}>
|
||||
{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'
|
||||
}`}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex space-x-1 mb-6 bg-white p-1 rounded-lg border">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<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
|
||||
? '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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="space-y-6">
|
||||
{/* Profile Tab */}
|
||||
{activeTab === 'profile' && (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Personal Information</h3>
|
||||
<p className="text-gray-600">Update your personal details and contact information</p>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Button onClick={() => setIsEditing(true)}>
|
||||
Edit Profile
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<UserForm
|
||||
user={user}
|
||||
onSubmit={handleProfileUpdate}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<Mail className="w-5 h-5 text-gray-400" />
|
||||
<span className="text-gray-900">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Account Role
|
||||
</label>
|
||||
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<User className="w-5 h-5 text-gray-400" />
|
||||
<span className="text-gray-900 capitalize">{user.role.toLowerCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
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="text-gray-900">
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Member Since
|
||||
</label>
|
||||
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-gray-900">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Security Tab */}
|
||||
{activeTab === 'security' && (
|
||||
<Card className="p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Security Settings</h3>
|
||||
<p className="text-gray-600">Manage your password and security preferences</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Change Password</h4>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</div>
|
||||
<Button>Update Password</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Preferences Tab */}
|
||||
{activeTab === 'preferences' && (
|
||||
<Card className="p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Preferences</h3>
|
||||
<p className="text-gray-600">Customize your experience and notification settings</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4 flex items-center">
|
||||
<Bell className="w-5 h-5 mr-2" />
|
||||
Notifications
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Email Notifications</p>
|
||||
<p className="text-sm text-gray-600">Receive notifications via email</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.emailNotifications}
|
||||
onCheckedChange={(checked) => handlePreferenceChange('emailNotifications', checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Push Notifications</p>
|
||||
<p className="text-sm text-gray-600">Receive push notifications in your browser</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.pushNotifications}
|
||||
onCheckedChange={(checked) => handlePreferenceChange('pushNotifications', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4 flex items-center">
|
||||
<Palette className="w-5 h-5 mr-2" />
|
||||
Appearance
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Dark Mode</p>
|
||||
<p className="text-sm text-gray-600">Use dark theme for the interface</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.darkMode}
|
||||
onCheckedChange={(checked) => handlePreferenceChange('darkMode', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4 flex items-center">
|
||||
<Globe className="w-5 h-5 mr-2" />
|
||||
Language & Region
|
||||
</h4>
|
||||
<div className="max-w-xs">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Language
|
||||
</label>
|
||||
<select
|
||||
value={preferences.language}
|
||||
onChange={(e) => handlePreferenceChange('language', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="vi">Tiếng Việt</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Settings,
|
||||
Bell,
|
||||
Shield,
|
||||
Palette,
|
||||
Globe,
|
||||
User,
|
||||
Moon,
|
||||
Sun,
|
||||
Monitor
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../../stores/auth-store';
|
||||
import { Button } from '../../features/shared/components/ui/button';
|
||||
import { Card } from '../../features/shared/components/ui/card';
|
||||
import { Switch } from '../../features/shared/components/ui/switch';
|
||||
|
||||
/**
|
||||
* EN: Settings Page
|
||||
* VI: Trang Settings
|
||||
*
|
||||
* Features:
|
||||
* - Theme preferences (light/dark/auto)
|
||||
* - Language settings
|
||||
* - Notification preferences
|
||||
* - Privacy settings
|
||||
* - Account management
|
||||
*/
|
||||
export default function SettingsPage() {
|
||||
const { user, isAuthenticated, logout } = useAuthStore();
|
||||
const [settings, setSettings] = useState({
|
||||
theme: 'system', // 'light', 'dark', 'system'
|
||||
language: 'en',
|
||||
emailNotifications: true,
|
||||
pushNotifications: false,
|
||||
marketingEmails: false,
|
||||
profileVisibility: 'private', // 'public', 'private'
|
||||
dataSharing: false,
|
||||
});
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Card className="p-8 text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Please sign in</h2>
|
||||
<p className="text-gray-600">
|
||||
You need to be signed in to access settings.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSettingChange = (key: string, value: any) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSaveSettings = () => {
|
||||
// In a real app, this would save to backend
|
||||
console.log('Saving settings:', settings);
|
||||
// Show success message
|
||||
};
|
||||
|
||||
const handleExportData = () => {
|
||||
// Mock data export
|
||||
const data = {
|
||||
user: user,
|
||||
settings: settings,
|
||||
exportDate: new Date().toISOString(),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `user-data-${user.id}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
if (confirm('Are you sure you want to delete your account? This action cannot be undone.')) {
|
||||
// In a real app, this would call delete API
|
||||
console.log('Deleting account...');
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Settings</h1>
|
||||
<p className="text-gray-600">Manage your account preferences and privacy settings</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Main Settings */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Appearance */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<Palette className="w-6 h-6 text-blue-600" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Appearance</h3>
|
||||
<p className="text-gray-600">Customize how the app looks and feels</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Theme
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ value: 'light', label: 'Light', icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', icon: Moon },
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
].map((theme) => {
|
||||
const Icon = theme.icon;
|
||||
return (
|
||||
<button
|
||||
key={theme.value}
|
||||
onClick={() => handleSettingChange('theme', theme.value)}
|
||||
className={`p-4 border rounded-lg text-center transition-all ${
|
||||
settings.theme === theme.value
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-6 h-6 mx-auto mb-2" />
|
||||
<span className="text-sm font-medium">{theme.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Language
|
||||
</label>
|
||||
<select
|
||||
value={settings.language}
|
||||
onChange={(e) => handleSettingChange('language', e.target.value)}
|
||||
className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="vi">Tiếng Việt</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Notifications */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<Bell className="w-6 h-6 text-green-600" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
|
||||
<p className="text-gray-600">Choose what notifications you want to receive</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Email Notifications</p>
|
||||
<p className="text-sm text-gray-600">Receive important updates via email</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.emailNotifications}
|
||||
onCheckedChange={(checked) => handleSettingChange('emailNotifications', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Push Notifications</p>
|
||||
<p className="text-sm text-gray-600">Receive notifications in your browser</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.pushNotifications}
|
||||
onCheckedChange={(checked) => handleSettingChange('pushNotifications', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Marketing Emails</p>
|
||||
<p className="text-sm text-gray-600">Receive promotional content and updates</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.marketingEmails}
|
||||
onCheckedChange={(checked) => handleSettingChange('marketingEmails', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Privacy */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Privacy</h3>
|
||||
<p className="text-gray-600">Control your privacy and data sharing preferences</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Profile Visibility
|
||||
</label>
|
||||
<select
|
||||
value={settings.profileVisibility}
|
||||
onChange={(e) => handleSettingChange('profileVisibility', e.target.value)}
|
||||
className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="public">Public - Anyone can see my profile</option>
|
||||
<option value="private">Private - Only I can see my profile</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Data Sharing</p>
|
||||
<p className="text-sm text-gray-600">Allow anonymous usage data to help improve the service</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.dataSharing}
|
||||
onCheckedChange={(checked) => handleSettingChange('dataSharing', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Account Info */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<User className="w-6 h-6 text-gray-600" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Account</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Email</p>
|
||||
<p className="font-medium text-gray-900">{user.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Role</p>
|
||||
<p className="font-medium text-gray-900 capitalize">{user.role.toLowerCase()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Status</p>
|
||||
<span className={`inline-flex items-center px-2 py-1 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>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Account Actions</h3>
|
||||
<div className="space-y-3">
|
||||
<Button onClick={handleSaveSettings} className="w-full">
|
||||
Save Settings
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleExportData} className="w-full">
|
||||
Export My Data
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => window.location.href = '/profile'} className="w-full">
|
||||
Edit Profile
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="p-6 border-red-200 bg-red-50">
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-4">Danger Zone</h3>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDeleteAccount}
|
||||
className="w-full border-red-300 text-red-700 hover:bg-red-50"
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
<p className="text-xs text-red-600">
|
||||
This action cannot be undone. All your data will be permanently deleted.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,613 +0,0 @@
|
||||
# Design System Documentation
|
||||
|
||||
> **Hệ thống thiết kế toàn diện cho GoodGo Web Client**
|
||||
>
|
||||
> Tài liệu này mô tả đầy đủ các design deliverables chuyên nghiệp, từ UX research đến UI implementation, giúp team phát triển và thiết kế làm việc hiệu quả.
|
||||
|
||||
## 📚 Mục lục
|
||||
|
||||
- [1. UX Foundation - Nền tảng trải nghiệm người dùng](#1-ux-foundation)
|
||||
- [2. UI Visual Design - Thiết kế giao diện](#2-ui-visual-design)
|
||||
- [3. Design System - Hệ thống thiết kế](#3-design-system)
|
||||
- [4. Component Library - Thư viện thành phần](#4-component-library)
|
||||
- [5. Developer Handoff - Bàn giao cho Dev](#5-developer-handoff)
|
||||
|
||||
---
|
||||
|
||||
## 1. UX Foundation - Nền tảng trải nghiệm người dùng
|
||||
|
||||
### 1.1 Information Architecture
|
||||
|
||||
#### Sitemap
|
||||
**Vị trí**: `src/docs/ux/sitemap.md`
|
||||
|
||||
Cấu trúc tổng quan của ứng dụng:
|
||||
|
||||
```
|
||||
GoodGo Web Client
|
||||
├── Authentication
|
||||
│ ├── Login (/login)
|
||||
│ ├── Register (/register)
|
||||
│ └── Forgot Password (/forgot-password)
|
||||
├── Dashboard (/)
|
||||
│ ├── Overview
|
||||
│ ├── Analytics
|
||||
│ └── Quick Actions
|
||||
├── Profile (/profile)
|
||||
│ ├── Settings
|
||||
│ └── Preferences
|
||||
└── [Add more sections...]
|
||||
```
|
||||
|
||||
**Cách sử dụng**:
|
||||
- Cập nhật file `ux/sitemap.md` khi thêm/bớt trang
|
||||
- Dùng cho planning và onboarding team mới
|
||||
- Tham khảo khi thiết kế navigation
|
||||
|
||||
---
|
||||
|
||||
### 1.2 User Flows
|
||||
|
||||
**Vị trí**: `src/docs/ux/flows/`
|
||||
|
||||
Các luồng người dùng quan trọng:
|
||||
|
||||
#### Authentication Flow
|
||||
```
|
||||
[Landing] → [Login Page] → [Email Input] → [Password Input]
|
||||
↓
|
||||
[Validation] → [Success: Dashboard]
|
||||
↓
|
||||
[Error: Show Message]
|
||||
|
||||
Alternative: Forgot Password
|
||||
[Login Page] → [Forgot Password Link] → [Email Input]
|
||||
→ [Send Reset Email] → [Success Message]
|
||||
```
|
||||
|
||||
**Files**:
|
||||
- `flows/auth-login.md` - Luồng đăng nhập
|
||||
- `flows/auth-register.md` - Luồng đăng ký
|
||||
- `flows/auth-password-reset.md` - Luồng reset mật khẩu
|
||||
|
||||
**Template cho mỗi flow**:
|
||||
```markdown
|
||||
# [Flow Name]
|
||||
|
||||
## Mục tiêu
|
||||
Người dùng cần đạt được điều gì?
|
||||
|
||||
## Entry Points
|
||||
Từ đâu người dùng bắt đầu?
|
||||
|
||||
## Steps
|
||||
1. Step 1: Action → Result
|
||||
2. Step 2: Action → Result
|
||||
...
|
||||
|
||||
## Success Criteria
|
||||
Làm thế nào để biết flow thành công?
|
||||
|
||||
## Error States
|
||||
Các lỗi có thể xảy ra và cách xử lý?
|
||||
|
||||
## Figma Link
|
||||
[Link to prototype]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Wireframes
|
||||
|
||||
**Vị trí**: `src/docs/ux/wireframes/`
|
||||
|
||||
#### Low-Fidelity (Concept)
|
||||
- Sketch thủ công hoặc basic blocks
|
||||
- Focus vào layout và content hierarchy
|
||||
- Không có color, chỉ grayscale
|
||||
|
||||
#### Mid-Fidelity (Structure)
|
||||
- Chi tiết hơn về components
|
||||
- Content placeholders rõ ràng
|
||||
- Grid system được áp dụng
|
||||
- Chưa có branding/style
|
||||
|
||||
**Files**:
|
||||
- `wireframes/auth-pages-low.md` - Low-fi wireframes
|
||||
- `wireframes/auth-pages-mid.md` - Mid-fi wireframes
|
||||
|
||||
**Lưu wireframes**:
|
||||
- Export từ Figma/Sketch dạng PNG
|
||||
- Đặt trong `public/design/wireframes/`
|
||||
- Link trong markdown files
|
||||
|
||||
---
|
||||
|
||||
## 2. UI Visual Design - Thiết kế giao diện
|
||||
|
||||
### 2.1 Moodboard
|
||||
|
||||
**Vị trí**: `src/docs/ui/moodboard.md`
|
||||
|
||||
Định hướng phong cách visual cho project:
|
||||
|
||||
#### X.ai Minimal Style (Current Direction)
|
||||
- **Keywords**: Clean, Minimal, Sophisticated, Tech-forward
|
||||
- **Inspiration**: X.ai, Linear, Vercel
|
||||
- **Color Direction**: Dark backgrounds, vibrant accents
|
||||
- **Typography**: Geometric sans-serif (Inter, Geist)
|
||||
- **Visual Treatment**: Subtle glassmorphism, minimal shadows
|
||||
|
||||
**References**:
|
||||
- X.ai authentication pages
|
||||
- Linear app interface
|
||||
- Vercel dashboard
|
||||
|
||||
---
|
||||
|
||||
### 2.2 High-Fidelity Mockups
|
||||
|
||||
**Vị trí**: `src/docs/ui/mockups/`
|
||||
|
||||
Thiết kế hoàn chỉnh của tất cả screens:
|
||||
|
||||
#### States Required for Each Screen
|
||||
- ✅ **Default State** - Trạng thái bình thường
|
||||
- ✅ **Hover State** - Khi di chuột qua interactive elements
|
||||
- ✅ **Active State** - Khi click/focus
|
||||
- ✅ **Error State** - Khi có lỗi validation
|
||||
- ✅ **Success State** - Khi thao tác thành công
|
||||
- ✅ **Loading State** - Khi đang xử lý
|
||||
- ✅ **Empty State** - Khi không có data
|
||||
- ✅ **Disabled State** - Khi element bị vô hiệu hóa
|
||||
|
||||
**Files**:
|
||||
- `mockups/auth-login-states.md` - Tất cả states của login page
|
||||
- `mockups/auth-register-states.md` - Tất cả states của register page
|
||||
- `mockups/auth-forgot-password-states.md` - States của forgot password
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Responsive Design
|
||||
|
||||
**Breakpoints** (Tailwind CSS):
|
||||
- Mobile: `< 640px` (sm)
|
||||
- Tablet: `640px - 1024px` (md, lg)
|
||||
- Desktop: `> 1024px` (xl, 2xl)
|
||||
|
||||
**Files**:
|
||||
- `ui/responsive/mobile-specs.md`
|
||||
- `ui/responsive/tablet-specs.md`
|
||||
- `ui/responsive/desktop-specs.md`
|
||||
|
||||
**Quy tắc responsive**:
|
||||
- Mobile-first approach
|
||||
- Stack elements vertically on mobile
|
||||
- 2-column layout on tablet
|
||||
- 3+ column layout on desktop
|
||||
- Touch targets minimum 44x44px on mobile
|
||||
|
||||
---
|
||||
|
||||
## 3. Design System - Hệ thống thiết kế
|
||||
|
||||
### 3.1 Color Palette
|
||||
|
||||
**Vị trí**: `src/styles/theme.css` (Source of Truth)
|
||||
|
||||
#### X.ai Minimal Color Palette
|
||||
|
||||
**Dark Theme (Default)**:
|
||||
```css
|
||||
/* Backgrounds */
|
||||
--bg-primary: #15202b; /* Warm dark gray (not pure black) */
|
||||
--bg-secondary: #1a2734; /* Lighter variant */
|
||||
--bg-tertiary: #1f2f3d; /* Even lighter */
|
||||
--bg-elevated: #243442; /* Elevated surfaces */
|
||||
|
||||
/* Brand/Accent */
|
||||
--accent-primary: #1D9BF0; /* X.ai blue */
|
||||
--accent-primary-hover: #1a8cd8;
|
||||
--accent-secondary: #657786;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #FFFFFF; /* Pure white */
|
||||
--text-secondary: #8899A6; /* Light gray */
|
||||
--text-tertiary: #657786; /* Mid gray */
|
||||
|
||||
/* Status Colors */
|
||||
--accent-success: #10B981; /* Green */
|
||||
--accent-error: #EF4444; /* Red */
|
||||
--accent-warning: #F59E0B; /* Amber */
|
||||
--accent-info: #1D9BF0; /* Blue */
|
||||
|
||||
/* Borders */
|
||||
--border-primary: #38444d;
|
||||
--border-secondary: #4a5966;
|
||||
--border-focus: #1D9BF0; /* X.ai blue */
|
||||
```
|
||||
|
||||
**Light Theme**:
|
||||
```css
|
||||
--bg-primary: #FFFFFF;
|
||||
--accent-primary: #1D9BF0;
|
||||
--text-primary: #1D1D1F;
|
||||
```
|
||||
|
||||
**Color Usage Guidelines**:
|
||||
- Backgrounds: Use `--bg-*` variables
|
||||
- Interactive elements: Use `--accent-primary` for primary actions
|
||||
- Text: Use `--text-*` with proper hierarchy
|
||||
- Never use hardcoded hex values in components
|
||||
|
||||
**Accessibility**:
|
||||
- All color combinations meet WCAG 2.1 AA (4.5:1 contrast ratio)
|
||||
- Test with WebAIM Contrast Checker
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Typography
|
||||
|
||||
**Font Family**:
|
||||
```css
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
```
|
||||
|
||||
**Type Scale**:
|
||||
```css
|
||||
/* Headings */
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
--text-4xl: 2.25rem; /* 36px */
|
||||
--text-5xl: 3rem; /* 48px */
|
||||
|
||||
/* Line Heights */
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.75;
|
||||
|
||||
/* Font Weights */
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
--font-extrabold: 800;
|
||||
```
|
||||
|
||||
**Typography Usage**:
|
||||
- H1: `text-4xl font-extrabold` - Page titles
|
||||
- H2: `text-3xl font-bold` - Section headers
|
||||
- H3: `text-2xl font-semibold` - Subsection headers
|
||||
- Body: `text-base font-normal` - Default text
|
||||
- Small: `text-sm` - Secondary info
|
||||
- Caption: `text-xs` - Captions, labels
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Spacing System
|
||||
|
||||
**8-Point Grid System**:
|
||||
```css
|
||||
--spacing-0: 0;
|
||||
--spacing-1: 0.25rem; /* 4px */
|
||||
--spacing-2: 0.5rem; /* 8px */
|
||||
--spacing-3: 0.75rem; /* 12px */
|
||||
--spacing-4: 1rem; /* 16px */
|
||||
--spacing-5: 1.25rem; /* 20px */
|
||||
--spacing-6: 1.5rem; /* 24px */
|
||||
--spacing-8: 2rem; /* 32px */
|
||||
--spacing-10: 2.5rem; /* 40px */
|
||||
--spacing-12: 3rem; /* 48px */
|
||||
--spacing-16: 4rem; /* 64px */
|
||||
--spacing-20: 5rem; /* 80px */
|
||||
```
|
||||
|
||||
**Spacing Guidelines**:
|
||||
- Form fields: `space-y-4` (16px)
|
||||
- Card padding: `p-8` (32px)
|
||||
- Section spacing: `space-y-12` (48px)
|
||||
- Container max-width: `max-w-md` (448px) for auth forms
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Border Radius
|
||||
|
||||
```css
|
||||
--radius-sm: 0.375rem; /* 6px */
|
||||
--radius-md: 0.5rem; /* 8px */
|
||||
--radius-lg: 0.75rem; /* 12px */
|
||||
--radius-xl: 1rem; /* 16px */
|
||||
--radius-2xl: 1.5rem; /* 24px */
|
||||
--radius-full: 9999px; /* Fully rounded */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Shadows
|
||||
|
||||
```css
|
||||
/* Subtle shadows for minimal design */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
--shadow-xl: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
|
||||
/* Glass shadows */
|
||||
--shadow-glass-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-glass-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Animations & Transitions
|
||||
|
||||
**Duration**:
|
||||
```css
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 300ms;
|
||||
--duration-slow: 500ms;
|
||||
```
|
||||
|
||||
**Easing**:
|
||||
```css
|
||||
--ease-snap: cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
--ease-smooth: cubic-bezier(0.4, 0.0, 0.6, 1);
|
||||
```
|
||||
|
||||
**Common Animations**:
|
||||
- Hover scale: `hover:scale-[1.02] transition-transform duration-150`
|
||||
- Fade in: `animate-in fade-in duration-300`
|
||||
- Float: `animate-float` (custom keyframe in theme.css)
|
||||
|
||||
---
|
||||
|
||||
## 4. Component Library - Thư viện thành phần
|
||||
|
||||
### 4.1 Components Catalog
|
||||
|
||||
**Vị trí**: Storybook - `npm run storybook`
|
||||
|
||||
Tất cả components đã được document trong Storybook với:
|
||||
- Visual examples
|
||||
- Props API
|
||||
- Usage guidelines
|
||||
- Accessibility notes
|
||||
- Code snippets
|
||||
|
||||
### 4.2 Core Components
|
||||
|
||||
#### Button
|
||||
**Path**: `src/features/shared/components/ui/button/`
|
||||
|
||||
**Variants**:
|
||||
- `brand` - Primary CTA (X.ai blue)
|
||||
- `secondary` - Secondary actions
|
||||
- `ghost` - Tertiary actions
|
||||
- `danger` - Destructive actions
|
||||
|
||||
**Sizes**: `sm`, `md`, `lg`
|
||||
|
||||
**States**: default, hover, active, loading, disabled
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
<Button variant="brand" size="lg">
|
||||
Sign In
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Input
|
||||
**Path**: `src/features/shared/components/ui/input/`
|
||||
|
||||
**Features**:
|
||||
- Label support
|
||||
- Error messages
|
||||
- Description text
|
||||
- Password visibility toggle
|
||||
- Focus states with X.ai blue
|
||||
|
||||
**States**: default, focus, error, disabled
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
error="Invalid email"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Card
|
||||
**Path**: `src/features/shared/components/ui/card/`
|
||||
|
||||
**Features**:
|
||||
- Glass effect variants
|
||||
- Hover states
|
||||
- Border options
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
<Card className="glass-card p-8">
|
||||
<CardHeader>
|
||||
<CardTitle>Title</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Content here
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Glass Effects
|
||||
|
||||
**Path**: `src/styles/glass.css`
|
||||
|
||||
**Classes**:
|
||||
- `.glass-card` - Card containers
|
||||
- `.glass-input` - Input fields
|
||||
- `.glass-bg` - Generic glass background
|
||||
|
||||
**X.ai Minimal Glass**:
|
||||
- Ultra-subtle transparency (1-5%)
|
||||
- Thin borders (3-10% white)
|
||||
- Minimal blur (4-12px)
|
||||
- Works on #15202b background
|
||||
|
||||
---
|
||||
|
||||
## 5. Developer Handoff - Bàn giao cho Dev
|
||||
|
||||
### 5.1 Design Specs
|
||||
|
||||
**Tools**:
|
||||
- Figma Dev Mode (recommended)
|
||||
- Zeplin
|
||||
- Measure Chrome Extension
|
||||
|
||||
**Specs to Include**:
|
||||
- Exact pixel measurements
|
||||
- Spacing between elements
|
||||
- Font sizes and weights
|
||||
- Color values (reference CSS variables)
|
||||
- Border radius
|
||||
- Shadow values
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Assets Export
|
||||
|
||||
**Location**: `public/design/assets/`
|
||||
|
||||
**Format Guidelines**:
|
||||
- **Icons**: SVG (preferred), PNG @2x, @3x
|
||||
- **Images**: WebP (preferred), PNG, JPEG
|
||||
- **Illustrations**: SVG for vector graphics
|
||||
- **Logo**: SVG + PNG variants
|
||||
|
||||
**Export Checklist**:
|
||||
- [ ] Optimize SVGs (remove unnecessary metadata)
|
||||
- [ ] Export @1x, @2x, @3x for raster images
|
||||
- [ ] Name files descriptively: `icon-close.svg`, `logo-light.svg`
|
||||
- [ ] Organize in folders by category
|
||||
|
||||
---
|
||||
|
||||
### 5.3 Implementation Notes
|
||||
|
||||
**Vị trí**: `src/docs/implementation/`
|
||||
|
||||
**Files**:
|
||||
- `implementation/auth-pages-notes.md` - Ghi chú cho auth pages
|
||||
- `implementation/components-notes.md` - Component implementation details
|
||||
|
||||
**Template**:
|
||||
```markdown
|
||||
# [Component/Page Name] Implementation Notes
|
||||
|
||||
## Design Intent
|
||||
Mục đích thiết kế là gì?
|
||||
|
||||
## Technical Requirements
|
||||
- Framework: Next.js 14
|
||||
- Styling: Tailwind CSS
|
||||
- Animations: Framer Motion (if needed)
|
||||
|
||||
## Accessibility Requirements
|
||||
- ARIA attributes needed
|
||||
- Keyboard navigation
|
||||
- Screen reader considerations
|
||||
|
||||
## Edge Cases
|
||||
- What happens when...?
|
||||
- How to handle...?
|
||||
|
||||
## Dependencies
|
||||
- Required components
|
||||
- Third-party libraries
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Visual regression
|
||||
- [ ] Accessibility (a11y)
|
||||
- [ ] Responsive on all breakpoints
|
||||
- [ ] Cross-browser compatibility
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.4 Component API Documentation
|
||||
|
||||
Mỗi component cần có:
|
||||
|
||||
**Props Table**:
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `variant` | `'brand' \| 'secondary'` | `'brand'` | Visual style variant |
|
||||
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Size of the button |
|
||||
| `disabled` | `boolean` | `false` | Disable interaction |
|
||||
|
||||
**Example Usage**:
|
||||
```tsx
|
||||
// Basic usage
|
||||
<Button>Click me</Button>
|
||||
|
||||
// With all props
|
||||
<Button
|
||||
variant="brand"
|
||||
size="lg"
|
||||
disabled={false}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Maintenance & Updates
|
||||
|
||||
### When to Update This Documentation
|
||||
|
||||
1. **New Feature Added** → Update sitemap, user flows, components
|
||||
2. **Visual Redesign** → Update moodboard, mockups, color palette
|
||||
3. **Component Changes** → Update component specs in Storybook
|
||||
4. **New Page** → Add to sitemap, create wireframes/mockups
|
||||
5. **Accessibility Improvements** → Update WCAG compliance docs
|
||||
|
||||
### Version Control
|
||||
|
||||
- Use semantic versioning for design system updates
|
||||
- Document breaking changes
|
||||
- Keep changelog in `DESIGN_SYSTEM_CHANGELOG.md`
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Quick Links
|
||||
|
||||
- [Storybook](http://localhost:6006) - Component library
|
||||
- [WCAG Compliance](./WCAG_COMPLIANCE.md) - Accessibility guidelines
|
||||
- [Performance](./PERFORMANCE.md) - Performance best practices
|
||||
- [Figma Design Files](#) - (Add your Figma link here)
|
||||
|
||||
---
|
||||
|
||||
## 📞 Contact
|
||||
|
||||
**Design Team**: design@goodgo.com
|
||||
**Dev Team**: dev@goodgo.com
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-01-04
|
||||
**Version**: 2.0.0 (X.ai Minimal Redesign)
|
||||
@@ -1,504 +0,0 @@
|
||||
# ✅ Documentation Complete - Summary
|
||||
|
||||
> **Professional UX/UI Design Deliverables**
|
||||
>
|
||||
> Hệ thống documentation hoàn chỉnh cho GoodGo Web Client
|
||||
|
||||
**Created**: 2026-01-04
|
||||
**Total Files**: 12 markdown documents
|
||||
**Total Size**: ~106 KB
|
||||
**Status**: ✅ Ready for Use
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Tổng quan
|
||||
|
||||
Bạn đã yêu cầu tạo **bộ hồ sơ thiết kế UX/UI chuyên nghiệp** theo chuẩn industry standard, không chỉ là "hình ảnh đẹp" mà là tài liệu toàn diện sẵn sàng cho team sử dụng.
|
||||
|
||||
**✅ ĐÃ HOÀN THÀNH TẤT CẢ!**
|
||||
|
||||
---
|
||||
|
||||
## 📊 Documentation Coverage
|
||||
|
||||
### ✅ 1. UX Foundation - Nền tảng trải nghiệm
|
||||
|
||||
| Document | Size | Status | Description |
|
||||
|----------|------|--------|-------------|
|
||||
| [ux/sitemap.md](./ux/sitemap.md) | 2.7K | ✅ | Information architecture, cấu trúc app |
|
||||
| [ux/flows/auth-login.md](./ux/flows/auth-login.md) | 9.2K | ✅ | Chi tiết login user flow với 8 steps |
|
||||
| [ux/wireframes/auth-pages-wireframes.md](./ux/wireframes/auth-pages-wireframes.md) | 23K | ✅ | Lo-Fi & Mid-Fi wireframes cho 3 auth pages |
|
||||
|
||||
**Coverage**: 3/3 files ✅
|
||||
|
||||
**Bao gồm**:
|
||||
- ✅ Sitemap với navigation patterns
|
||||
- ✅ Login user flow (entry/exit points, success/error paths)
|
||||
- ✅ Wireframes (low-fi + mid-fi) cho login, register, forgot password
|
||||
- ✅ Grid system & spacing specifications
|
||||
- ✅ Responsive wireframe variants
|
||||
|
||||
---
|
||||
|
||||
### ✅ 2. UI Visual Design - Thiết kế giao diện
|
||||
|
||||
| Document | Size | Status | Description |
|
||||
|----------|------|--------|-------------|
|
||||
| [ui/mockups/auth-login-states.md](./ui/mockups/auth-login-states.md) | 9.9K | ✅ | 8 states của login page (default, hover, focus, error, loading, etc.) |
|
||||
| [ui/responsive/mobile-specs.md](./ui/responsive/mobile-specs.md) | 9.6K | ✅ | Mobile responsive specs (< 640px) |
|
||||
|
||||
**Coverage**: 2 files core ✅
|
||||
|
||||
**Bao gồm**:
|
||||
- ✅ All UI states documented (default, hover, focus, active, error, loading, success, disabled)
|
||||
- ✅ High-fidelity mockup specs với measurements chính xác
|
||||
- ✅ Mobile-first responsive specifications
|
||||
- ✅ Typography scale, color palette, spacing
|
||||
- ✅ Touch targets, virtual keyboard handling
|
||||
- ✅ Performance targets cho mobile
|
||||
|
||||
**TODO (có thể tạo sau)**:
|
||||
- [ ] Register & Forgot Password mockups (dùng template tương tự)
|
||||
- [ ] Tablet specs (640-1024px)
|
||||
- [ ] Desktop specs (> 1024px)
|
||||
|
||||
---
|
||||
|
||||
### ✅ 3. Design System - Hệ thống thiết kế
|
||||
|
||||
| Document | Size | Status | Description |
|
||||
|----------|------|--------|-------------|
|
||||
| [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) | 14K | ✅ | Complete design system documentation |
|
||||
|
||||
**Coverage**: 100% ✅
|
||||
|
||||
**Bao gồm**:
|
||||
- ✅ **UX Foundation**: Sitemap structure, User flows framework, Wireframes guidelines
|
||||
- ✅ **UI Visual Design**: Moodboard guidelines, Mockups structure, Responsive specs
|
||||
- ✅ **Design System**:
|
||||
- Color palette (X.ai blue #1D9BF0, dark #15202b)
|
||||
- Typography (Inter, geometric sans-serif)
|
||||
- Spacing (8-point grid)
|
||||
- Shadows, Border radius, Animations
|
||||
- ✅ **Component Library**: Button, Input, Card specs
|
||||
- ✅ **Developer Handoff**: Specs, assets export, implementation notes
|
||||
|
||||
**X.ai Minimal Design**:
|
||||
- ✅ Warm dark gray background (#15202b thay vì pure black)
|
||||
- ✅ X.ai blue accent (#1D9BF0)
|
||||
- ✅ Removed cosmic background effects
|
||||
- ✅ Neo-minimalism 2026 style
|
||||
|
||||
---
|
||||
|
||||
### ✅ 4. Component Library - Thư viện thành phần
|
||||
|
||||
**Integration**: ✅ Storybook (đã có sẵn)
|
||||
|
||||
**Components Documented**:
|
||||
- ✅ Button (brand variant with X.ai blue)
|
||||
- ✅ Input (with focus states, password visibility)
|
||||
- ✅ Card (glass effects)
|
||||
|
||||
**Component Specs Template**: ✅
|
||||
- [TEMPLATE_COMPONENT_SPEC.md](./TEMPLATE_COMPONENT_SPEC.md) (7.5K)
|
||||
- Dùng để document thêm components sau
|
||||
|
||||
**TODO** (dùng template):
|
||||
- [ ] Modal spec
|
||||
- [ ] Dropdown spec
|
||||
- [ ] Tooltip spec
|
||||
- [ ] Toast/Alert spec
|
||||
|
||||
---
|
||||
|
||||
### ✅ 5. Developer Handoff - Bàn giao cho Dev
|
||||
|
||||
| Document | Size | Status | Description |
|
||||
|----------|------|--------|-------------|
|
||||
| [implementation/auth-pages-implementation.md](./implementation/auth-pages-implementation.md) | 13K | ✅ | Chi tiết implementation guide cho auth redesign |
|
||||
|
||||
**Coverage**: 100% ✅
|
||||
|
||||
**Bao gồm**:
|
||||
- ✅ Design intent & rationale
|
||||
- ✅ Step-by-step technical implementation (7 files to modify)
|
||||
- ✅ Code examples chi tiết với line numbers
|
||||
- ✅ Testing requirements (Visual, A11y, Responsive, Functional, Cross-browser)
|
||||
- ✅ Design specifications (measurements, colors, animations)
|
||||
- ✅ Common issues & solutions
|
||||
- ✅ Migration checklist (5 phases)
|
||||
- ✅ Assets checklist
|
||||
|
||||
**Ready for Implementation**: ✅ Developers có thể bắt đầu ngay!
|
||||
|
||||
---
|
||||
|
||||
### ✅ 6. Templates - Reusable Documentation
|
||||
|
||||
| Template | Size | Purpose |
|
||||
|----------|------|---------|
|
||||
| [TEMPLATE_USER_FLOW.md](./TEMPLATE_USER_FLOW.md) | 3.9K | Tạo user flows cho features mới |
|
||||
| [TEMPLATE_COMPONENT_SPEC.md](./TEMPLATE_COMPONENT_SPEC.md) | 7.5K | Document components mới |
|
||||
|
||||
**Coverage**: 2 core templates ✅
|
||||
|
||||
**Cách dùng**:
|
||||
```bash
|
||||
# Copy template
|
||||
cp src/docs/TEMPLATE_USER_FLOW.md src/docs/ux/flows/checkout-flow.md
|
||||
|
||||
# Edit file
|
||||
# Điền các section theo hướng dẫn
|
||||
# Link vào README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ✅ 7. Supporting Documentation
|
||||
|
||||
| Document | Size | Status | Description |
|
||||
|----------|------|--------|-------------|
|
||||
| [README.md](./README.md) | 9.6K | ✅ | Central hub, navigation, how-to guides |
|
||||
| [WCAG_COMPLIANCE.md](./WCAG_COMPLIANCE.md) | 1.7K | ✅ | Accessibility guidelines |
|
||||
| [PERFORMANCE.md](./PERFORMANCE.md) | 2.5K | ✅ | Performance best practices |
|
||||
|
||||
**Coverage**: 100% ✅
|
||||
|
||||
---
|
||||
|
||||
## 📁 File Structure (Complete)
|
||||
|
||||
```
|
||||
src/docs/
|
||||
├── 📖 README.md (9.6K) ─────────────────── START HERE!
|
||||
├── 🎨 DESIGN_SYSTEM.md (14K) ────────────── Complete design system
|
||||
├── ♿ WCAG_COMPLIANCE.md (1.7K) ──────────── Accessibility
|
||||
├── ⚡ PERFORMANCE.md (2.5K) ──────────────── Performance
|
||||
├── ✅ DOCUMENTATION_COMPLETE.md ──────────── This file
|
||||
│
|
||||
├── 📝 Templates (Reusable)
|
||||
│ ├── TEMPLATE_USER_FLOW.md (3.9K) ────── Copy cho user flows mới
|
||||
│ └── TEMPLATE_COMPONENT_SPEC.md (7.5K) ── Copy cho component specs
|
||||
│
|
||||
├── ux/ ──────────────────────────────────── UX Deliverables
|
||||
│ ├── sitemap.md (2.7K) ───────────────── ✅ App structure
|
||||
│ ├── flows/
|
||||
│ │ └── auth-login.md (9.2K) ────────── ✅ Login flow complete
|
||||
│ └── wireframes/
|
||||
│ └── auth-pages-wireframes.md (23K) ─ ✅ Lo-Fi + Mid-Fi
|
||||
│
|
||||
├── ui/ ──────────────────────────────────── UI Design Specs
|
||||
│ ├── mockups/
|
||||
│ │ └── auth-login-states.md (9.9K) ─── ✅ 8 UI states
|
||||
│ └── responsive/
|
||||
│ └── mobile-specs.md (9.6K) ──────── ✅ Mobile responsive
|
||||
│
|
||||
└── implementation/ ───────────────────────── Developer Handoff
|
||||
└── auth-pages-implementation.md (13K) ── ✅ Implementation guide
|
||||
|
||||
public/design/ ────────────────────────────── Design Assets (folders created)
|
||||
├── assets/ ──────────────────────────────── Icons, images
|
||||
└── wireframes/ ──────────────────────────── Exported wireframes
|
||||
```
|
||||
|
||||
**Total**: 12 documentation files, ~106 KB
|
||||
|
||||
---
|
||||
|
||||
## 🎯 What You Have Now
|
||||
|
||||
### ✅ Professional vs Amateur
|
||||
|
||||
#### ❌ Design Nghiệp Dư
|
||||
- Vài file PNG/JPG của UI
|
||||
- Không có user flows
|
||||
- Không có design system
|
||||
- Dev phải "đoán" implementation
|
||||
- Không có wireframes
|
||||
- Không có testing guidelines
|
||||
|
||||
#### ✅ Design Chuyên Nghiệp (BẠN ĐÃ CÓ!)
|
||||
|
||||
**1. UX Foundation** ✅
|
||||
- ✅ Sitemap (information architecture)
|
||||
- ✅ User flows (chi tiết từng bước)
|
||||
- ✅ Wireframes (lo-fi + mid-fi)
|
||||
|
||||
**2. UI Visual Design** ✅
|
||||
- ✅ Moodboard guidelines (X.ai minimal)
|
||||
- ✅ High-fidelity mockups (8 states)
|
||||
- ✅ Responsive specifications (mobile)
|
||||
|
||||
**3. Design System** ✅
|
||||
- ✅ Color palette (X.ai blue, warm dark gray)
|
||||
- ✅ Typography (Inter, scales)
|
||||
- ✅ Spacing (8-point grid)
|
||||
- ✅ Components (Button, Input, Card)
|
||||
- ✅ Shadows, Borders, Animations
|
||||
|
||||
**4. Component Library** ✅
|
||||
- ✅ Storybook integration
|
||||
- ✅ Component specs template
|
||||
|
||||
**5. Developer Handoff** ✅
|
||||
- ✅ Implementation guide (step-by-step)
|
||||
- ✅ Code examples (exact line numbers)
|
||||
- ✅ Testing checklists
|
||||
- ✅ Assets export guidelines
|
||||
|
||||
**6. Reusable Templates** ✅
|
||||
- ✅ User flow template
|
||||
- ✅ Component spec template
|
||||
|
||||
---
|
||||
|
||||
## 🚀 How to Use
|
||||
|
||||
### For Designers
|
||||
|
||||
1. **Start Here**: [README.md](./README.md)
|
||||
2. **Understand System**: [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md)
|
||||
3. **See Examples**:
|
||||
- [Login Flow](./ux/flows/auth-login.md)
|
||||
- [Login Mockups](./ui/mockups/auth-login-states.md)
|
||||
- [Wireframes](./ux/wireframes/auth-pages-wireframes.md)
|
||||
4. **Create New**:
|
||||
- Copy [TEMPLATE_USER_FLOW.md](./TEMPLATE_USER_FLOW.md)
|
||||
- Copy [TEMPLATE_COMPONENT_SPEC.md](./TEMPLATE_COMPONENT_SPEC.md)
|
||||
|
||||
---
|
||||
|
||||
### For Developers
|
||||
|
||||
1. **Implementation Guide**: [auth-pages-implementation.md](./implementation/auth-pages-implementation.md)
|
||||
2. **Design Tokens**: `src/styles/theme.css`
|
||||
3. **Component Library**: `npm run storybook`
|
||||
4. **Follow Steps**:
|
||||
```bash
|
||||
# Step 1: Update theme.css
|
||||
# Step 2: Update glass.css
|
||||
# Step 3: Update auth pages
|
||||
# Step 4: Update components
|
||||
# Step 5: Test (4 types)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### For Product Managers
|
||||
|
||||
1. **See Structure**: [Sitemap](./ux/sitemap.md)
|
||||
2. **Understand Flows**: [Login Flow](./ux/flows/auth-login.md)
|
||||
3. **Review Designs**: [Mockups](./ui/mockups/auth-login-states.md)
|
||||
4. **Plan Features**: Use templates for new flows
|
||||
|
||||
---
|
||||
|
||||
### For QA/Testing
|
||||
|
||||
1. **Test Checklists**: In [implementation guide](./implementation/auth-pages-implementation.md)
|
||||
2. **Accessibility**: [WCAG_COMPLIANCE.md](./WCAG_COMPLIANCE.md)
|
||||
3. **Responsive**: [Mobile Specs](./ui/responsive/mobile-specs.md)
|
||||
4. **User Flows**: [Login Flow](./ux/flows/auth-login.md)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Documentation Metrics
|
||||
|
||||
### Completion Status
|
||||
|
||||
**Core Documentation**: ✅ 100%
|
||||
- Design System: ✅
|
||||
- Implementation Guide: ✅
|
||||
- User Flows: ✅ (1/3 - login done, template available)
|
||||
- Wireframes: ✅
|
||||
- Mockups: ✅ (1/3 - login done, template available)
|
||||
- Responsive Specs: ✅ (1/3 - mobile done)
|
||||
- Templates: ✅
|
||||
|
||||
**Optional Enhancements**: 📋 Available via Templates
|
||||
- Register flow: Use TEMPLATE_USER_FLOW.md
|
||||
- Forgot password flow: Use TEMPLATE_USER_FLOW.md
|
||||
- Register mockups: Use auth-login-states.md as reference
|
||||
- Tablet specs: Use mobile-specs.md as reference
|
||||
- Desktop specs: Use mobile-specs.md as reference
|
||||
- More component specs: Use TEMPLATE_COMPONENT_SPEC.md
|
||||
|
||||
### Files Created
|
||||
|
||||
| Category | Files | Size | Status |
|
||||
|----------|-------|------|--------|
|
||||
| **Core Docs** | 4 | 28K | ✅ Complete |
|
||||
| **UX** | 3 | 35K | ✅ Complete |
|
||||
| **UI** | 2 | 19.5K | ✅ Complete |
|
||||
| **Implementation** | 1 | 13K | ✅ Complete |
|
||||
| **Templates** | 2 | 11.4K | ✅ Complete |
|
||||
| **Total** | **12** | **~106K** | **✅ Complete** |
|
||||
|
||||
---
|
||||
|
||||
## 🎨 X.ai Minimal Redesign - Ready to Implement
|
||||
|
||||
**Design Approved**: ✅
|
||||
**Documentation Complete**: ✅
|
||||
**Implementation Guide**: ✅
|
||||
**Testing Checklist**: ✅
|
||||
|
||||
**Developers can start now!**
|
||||
|
||||
### Files to Modify (from implementation guide)
|
||||
|
||||
1. ✅ `src/styles/theme.css` - Color palette
|
||||
2. ✅ `src/styles/glass.css` - Glass effects
|
||||
3. ✅ `src/app/(auth)/login/page.tsx` - Login page
|
||||
4. ✅ `src/app/(auth)/register/page.tsx` - Register page
|
||||
5. ✅ `src/app/(auth)/forgot-password/page.tsx` - Forgot password
|
||||
6. ✅ `src/features/shared/components/ui/input/input.tsx` - Input component
|
||||
7. ✅ `src/features/shared/components/ui/button/button.tsx` - Button component
|
||||
|
||||
**All specs documented in**: [auth-pages-implementation.md](./implementation/auth-pages-implementation.md)
|
||||
|
||||
---
|
||||
|
||||
## 💡 Benefits of This Documentation System
|
||||
|
||||
### 1. Faster Development ⚡
|
||||
- Dev có guide chi tiết, không cần đoán
|
||||
- Code examples với line numbers chính xác
|
||||
- Step-by-step implementation
|
||||
|
||||
### 2. Design Consistency 🎨
|
||||
- Design system rõ ràng, dễ follow
|
||||
- Color palette, typography, spacing defined
|
||||
- Components reusable
|
||||
|
||||
### 3. Better Collaboration 🤝
|
||||
- Docs làm cầu nối Design-Dev-QA-PM
|
||||
- Mọi người cùng reference một nguồn
|
||||
- Giảm miscommunication
|
||||
|
||||
### 4. Scalability 📈
|
||||
- Templates sẵn sàng cho feature mới
|
||||
- Copy & customize, không phải làm lại
|
||||
- Maintainable long-term
|
||||
|
||||
### 5. Accessibility ♿
|
||||
- Built-in WCAG compliance guidelines
|
||||
- Color contrast validated
|
||||
- Keyboard navigation documented
|
||||
|
||||
### 6. Professional Quality 🏆
|
||||
- Industry standard deliverables
|
||||
- Ready for stakeholder presentation
|
||||
- Ready for developer handoff
|
||||
|
||||
---
|
||||
|
||||
## 📋 Next Steps
|
||||
|
||||
### Immediate (Now)
|
||||
|
||||
**For Developers**:
|
||||
1. ✅ Read [Implementation Guide](./implementation/auth-pages-implementation.md)
|
||||
2. ✅ Start implementing (follow 5 steps)
|
||||
3. ✅ Test according to checklists
|
||||
|
||||
**For Designers**:
|
||||
1. ✅ Review all docs
|
||||
2. ✅ Create Figma prototypes (link in docs)
|
||||
3. ✅ Add screenshots to `public/design/`
|
||||
|
||||
---
|
||||
|
||||
### Short-term (This Week)
|
||||
|
||||
**Optional Enhancements**:
|
||||
- [ ] Create Register & Forgot Password flows (use template)
|
||||
- [ ] Create Register & Forgot Password mockups (use login as reference)
|
||||
- [ ] Create Tablet & Desktop responsive specs (use mobile as reference)
|
||||
- [ ] Add Figma links to all docs
|
||||
- [ ] Export and add screenshots
|
||||
|
||||
---
|
||||
|
||||
### Long-term (This Month)
|
||||
|
||||
**Scale the System**:
|
||||
- [ ] Document 10 core components (use template)
|
||||
- [ ] Create more user flows (checkout, profile, etc.)
|
||||
- [ ] Setup Chromatic for visual regression
|
||||
- [ ] Accessibility audit all pages
|
||||
- [ ] Performance benchmarks
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Quick Reference
|
||||
|
||||
| Need | Go To |
|
||||
|------|-------|
|
||||
| **Overview** | [README.md](./README.md) |
|
||||
| **Design System** | [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) |
|
||||
| **Implement Auth** | [auth-pages-implementation.md](./implementation/auth-pages-implementation.md) |
|
||||
| **See User Flow** | [auth-login.md](./ux/flows/auth-login.md) |
|
||||
| **See Wireframes** | [auth-pages-wireframes.md](./ux/wireframes/auth-pages-wireframes.md) |
|
||||
| **See Mockups** | [auth-login-states.md](./ui/mockups/auth-login-states.md) |
|
||||
| **Mobile Specs** | [mobile-specs.md](./ui/responsive/mobile-specs.md) |
|
||||
| **Create Flow** | [TEMPLATE_USER_FLOW.md](./TEMPLATE_USER_FLOW.md) |
|
||||
| **Document Component** | [TEMPLATE_COMPONENT_SPEC.md](./TEMPLATE_COMPONENT_SPEC.md) |
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Congratulations!
|
||||
|
||||
Bạn đã có một **hệ thống documentation UX/UI chuyên nghiệp** hoàn chỉnh!
|
||||
|
||||
**✅ Đây KHÔNG PHẢI là vài file hình ảnh**
|
||||
**✅ Đây LÀ bộ hồ sơ thiết kế toàn diện theo chuẩn industry**
|
||||
|
||||
### So sánh
|
||||
|
||||
**Before (Amateur)**:
|
||||
- 3 file PNG của UI screens
|
||||
- Không có context
|
||||
- Dev không biết làm thế nào
|
||||
|
||||
**After (Professional - BẠN ĐÃ CÓ!)**:
|
||||
- 12 markdown documents
|
||||
- ~106 KB documentation
|
||||
- UX Foundation (sitemap, flows, wireframes)
|
||||
- UI Design (mockups, responsive specs)
|
||||
- Design System (colors, typography, spacing, components)
|
||||
- Developer Handoff (implementation guide, testing)
|
||||
- Reusable Templates (scale cho tương lai)
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
**Questions about**:
|
||||
- Documentation structure → [README.md](./README.md)
|
||||
- Design system → [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md)
|
||||
- Implementation → [auth-pages-implementation.md](./implementation/auth-pages-implementation.md)
|
||||
|
||||
**Team Contacts**:
|
||||
- Design Team: design@goodgo.com
|
||||
- Dev Team: dev@goodgo.com
|
||||
- Product Team: product@goodgo.com
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2026-01-04
|
||||
**Last Updated**: 2026-01-04
|
||||
**Version**: 1.0.0
|
||||
**Status**: ✅ Complete & Ready for Use
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Ready to Build!
|
||||
|
||||
Documentation: ✅
|
||||
Design: ✅
|
||||
Specs: ✅
|
||||
Testing: ✅
|
||||
|
||||
**Let's ship it! 🎉**
|
||||
@@ -1,77 +0,0 @@
|
||||
# Performance Optimization Documentation
|
||||
|
||||
## Code Splitting
|
||||
|
||||
### Route-based Splitting
|
||||
- Chat page: Lazy loaded TypingIndicator
|
||||
- Admin dashboard: Lazy loaded chart components (UserGrowthChart, RevenueChart)
|
||||
- Admin settings: Lazy loaded settings forms (GeneralSettings, EmailSettings, SecuritySettings)
|
||||
- Admin users: Lazy loaded UserDetailsModal
|
||||
|
||||
### Component-based Splitting
|
||||
- Heavy components wrapped with `React.lazy()` and `React.Suspense`
|
||||
- Fallback loading states provided for better UX
|
||||
|
||||
## Image Optimization
|
||||
|
||||
### Next.js Image Configuration
|
||||
- **Formats**: AVIF and WebP (automatic format selection)
|
||||
- **Device sizes**: Responsive breakpoints (640px to 3840px)
|
||||
- **Image sizes**: Optimized sizes for different use cases
|
||||
- **Lazy loading**: All images use `loading="lazy"` attribute
|
||||
|
||||
### Implementation
|
||||
- Avatar images: Using Radix UI AvatarImage (supports lazy loading)
|
||||
- QR codes: Using `<img>` with `loading="lazy"` (data URLs not supported by Next.js Image)
|
||||
|
||||
## Lazy Loading
|
||||
|
||||
### Components
|
||||
- TypingIndicator: Lazy loaded in chat page
|
||||
- Chart components: Lazy loaded in admin dashboard
|
||||
- Modal components: Lazy loaded when needed
|
||||
- Settings forms: Lazy loaded per tab
|
||||
|
||||
### Images
|
||||
- All images use `loading="lazy"` attribute
|
||||
- Avatar images support lazy loading through Radix UI
|
||||
|
||||
### Routes
|
||||
- Dynamic imports for route-based code splitting (Next.js App Router handles this automatically)
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
### Browser Caching
|
||||
- **Static assets**: 1 year cache (`Cache-Control: public, max-age=31536000, immutable`)
|
||||
- Configured in `next.config.js` headers
|
||||
|
||||
### API Response Caching
|
||||
- **React Query**: 5 minutes stale time, 10 minutes cache time
|
||||
- Automatic refetching on window focus (disabled for better UX)
|
||||
- Retry failed requests once
|
||||
|
||||
### Session Storage
|
||||
- User data: Stored in Zustand stores with persistence
|
||||
- Chat conversations: Persisted to localStorage
|
||||
- Theme preferences: Persisted to localStorage
|
||||
|
||||
## Performance Targets
|
||||
|
||||
### Lighthouse Scores
|
||||
- **Performance**: 90+
|
||||
- **Accessibility**: 95+
|
||||
- **Best Practices**: 95+
|
||||
- **SEO**: 100
|
||||
|
||||
### Core Web Vitals
|
||||
- **LCP** (Largest Contentful Paint): < 2.5s
|
||||
- **FID** (First Input Delay): < 100ms
|
||||
- **CLS** (Cumulative Layout Shift): < 0.1
|
||||
|
||||
## Optimization Techniques
|
||||
|
||||
1. **Code Splitting**: Route and component-based splitting
|
||||
2. **Image Optimization**: WebP/AVIF formats, responsive images, lazy loading
|
||||
3. **Caching**: Browser caching for static assets, React Query for API responses
|
||||
4. **Compression**: Gzip/Brotli compression enabled
|
||||
5. **Console Removal**: Production builds remove console.log (except errors/warnings)
|
||||
@@ -1,344 +0,0 @@
|
||||
# Documentation Hub
|
||||
|
||||
> **Central documentation for GoodGo Web Client**
|
||||
>
|
||||
> Professional design deliverables, technical specs, and guidelines
|
||||
|
||||
## 📚 Documentation Structure
|
||||
|
||||
```
|
||||
src/docs/
|
||||
├── 📖 README.md (this file)
|
||||
├── 🎨 DESIGN_SYSTEM.md - Complete design system documentation ✅
|
||||
├── ♿ WCAG_COMPLIANCE.md - Accessibility guidelines ✅
|
||||
├── ⚡ PERFORMANCE.md - Performance best practices ✅
|
||||
├── 📝 TEMPLATE_USER_FLOW.md - Template for creating user flows ✅
|
||||
├── 📝 TEMPLATE_COMPONENT_SPEC.md - Template for component specs ✅
|
||||
├── ✅ DOCUMENTATION_COMPLETE.md - Summary of all docs ✅
|
||||
│
|
||||
├── ux/ - UX Research & Deliverables
|
||||
│ ├── sitemap.md - Information architecture ✅
|
||||
│ ├── flows/
|
||||
│ │ ├── auth-login.md - Login flow ✅
|
||||
│ │ ├── auth-register.md - Registration flow (use template)
|
||||
│ │ └── auth-password-reset.md - Password reset flow (use template)
|
||||
│ └── wireframes/
|
||||
│ └── auth-pages-wireframes.md - Lo-Fi + Mid-Fi wireframes ✅
|
||||
│
|
||||
├── ui/ - UI Design Documentation
|
||||
│ ├── MOODBOARD.md - Visual direction & X.ai design philosophy ✅
|
||||
│ ├── mockups/
|
||||
│ │ ├── auth-login-states.md - All 8 states of login page ✅
|
||||
│ │ ├── auth-register-states.md - (use login as template)
|
||||
│ │ └── auth-forgot-password-states.md - (use login as template)
|
||||
│ ├── responsive/
|
||||
│ │ ├── mobile-specs.md - Mobile breakpoint specs ✅
|
||||
│ │ ├── tablet-specs.md - (use mobile as template)
|
||||
│ │ └── desktop-specs.md - (use mobile as template)
|
||||
│ └── components/ - (use TEMPLATE_COMPONENT_SPEC.md)
|
||||
│
|
||||
└── implementation/
|
||||
└── auth-pages-implementation.md - Auth pages guide ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Links
|
||||
|
||||
### For Designers
|
||||
- **Start here**: [Design System](./DESIGN_SYSTEM.md)
|
||||
- **Visual direction**: [Moodboard](./ui/MOODBOARD.md) - X.ai design philosophy
|
||||
- **Wireframes**: [Auth Wireframes](./ux/wireframes/auth-pages-wireframes.md)
|
||||
- **Mockups**: [Login States](./ui/mockups/auth-login-states.md)
|
||||
- **Create new flow**: Use [User Flow Template](./TEMPLATE_USER_FLOW.md)
|
||||
- **Document component**: Use [Component Spec Template](./TEMPLATE_COMPONENT_SPEC.md)
|
||||
- **See structure**: [Sitemap](./ux/sitemap.md)
|
||||
|
||||
### For Developers
|
||||
- **Implementation guide**: [Auth Pages Implementation](./implementation/auth-pages-implementation.md)
|
||||
- **Design tokens**: [Theme CSS](../styles/theme.css)
|
||||
- **Components**: Run `npm run storybook`
|
||||
- **Accessibility**: [WCAG Compliance](./WCAG_COMPLIANCE.md)
|
||||
|
||||
### For Product Managers
|
||||
- **User flows**: [ux/flows/](./ux/flows/)
|
||||
- **Feature specs**: [implementation/](./implementation/)
|
||||
- **Analytics**: See individual flow documents
|
||||
|
||||
### For QA/Testing
|
||||
- **Test checklists**: See implementation docs
|
||||
- **Accessibility**: [WCAG Compliance](./WCAG_COMPLIANCE.md)
|
||||
- **Performance**: [Performance Guide](./PERFORMANCE.md)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Current Project: X.ai Minimal Redesign
|
||||
|
||||
**Status**: In Progress ⏳
|
||||
|
||||
**Objective**: Redesign 3 authentication pages to match X.ai's 2026 minimal aesthetic
|
||||
|
||||
### Completed ✅
|
||||
- [x] Design system documentation structure
|
||||
- [x] UX sitemap
|
||||
- [x] Login user flow
|
||||
- [x] Implementation guide for auth pages
|
||||
- [x] Templates for reusable documentation
|
||||
|
||||
### In Progress 🏗️
|
||||
- [ ] High-fidelity mockups for all auth states
|
||||
- [ ] Component specifications
|
||||
- [ ] Responsive design specifications
|
||||
|
||||
### Pending 📋
|
||||
- [ ] User flows for register & forgot password
|
||||
- [ ] Wireframes documentation
|
||||
- [ ] Moodboard creation
|
||||
- [ ] Visual regression tests
|
||||
- [ ] Accessibility audit
|
||||
|
||||
---
|
||||
|
||||
## 📝 How to Use This Documentation
|
||||
|
||||
### Creating a New User Flow
|
||||
|
||||
1. Copy [TEMPLATE_USER_FLOW.md](./TEMPLATE_USER_FLOW.md)
|
||||
2. Save to `ux/flows/[feature-name]-flow.md`
|
||||
3. Fill in all sections
|
||||
4. Link Figma prototype
|
||||
5. Add to sitemap if new page
|
||||
6. Update this README
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
cp src/docs/TEMPLATE_USER_FLOW.md src/docs/ux/flows/checkout-flow.md
|
||||
# Edit checkout-flow.md
|
||||
# Add link in sitemap.md
|
||||
# Update README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Creating a New Component Spec
|
||||
|
||||
1. Copy [TEMPLATE_COMPONENT_SPEC.md](./TEMPLATE_COMPONENT_SPEC.md)
|
||||
2. Save to `ui/components/[component-name]-spec.md`
|
||||
3. Fill in all sections
|
||||
4. Add screenshots
|
||||
5. Link Figma design
|
||||
6. Create Storybook story
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
cp src/docs/TEMPLATE_COMPONENT_SPEC.md src/docs/ui/components/modal-spec.md
|
||||
# Edit modal-spec.md
|
||||
# Add screenshots to public/design/
|
||||
# Create Storybook story
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Creating Implementation Documentation
|
||||
|
||||
1. Create file in `implementation/[feature-name]-implementation.md`
|
||||
2. Include:
|
||||
- Design intent
|
||||
- Technical requirements
|
||||
- Step-by-step guide
|
||||
- Code examples
|
||||
- Testing checklist
|
||||
3. Link related docs (flows, specs)
|
||||
4. Add to this README
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design System Quick Reference
|
||||
|
||||
### Colors
|
||||
```css
|
||||
/* X.ai Minimal Palette */
|
||||
--bg-primary: #15202b; /* Warm dark gray */
|
||||
--accent-primary: #1D9BF0; /* X.ai blue */
|
||||
--text-primary: #FFFFFF; /* Pure white */
|
||||
```
|
||||
|
||||
### Typography
|
||||
```css
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--text-4xl: 2.25rem; /* Headings */
|
||||
--text-base: 1rem; /* Body */
|
||||
```
|
||||
|
||||
### Spacing (8-point grid)
|
||||
```css
|
||||
--spacing-4: 1rem; /* 16px */
|
||||
--spacing-8: 2rem; /* 32px */
|
||||
```
|
||||
|
||||
### Breakpoints
|
||||
```css
|
||||
Mobile: < 640px
|
||||
Tablet: 640px - 1024px
|
||||
Desktop: > 1024px
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ♿ Accessibility Requirements
|
||||
|
||||
All components and pages must meet:
|
||||
- **WCAG 2.1 Level AA** compliance
|
||||
- **Contrast ratio** ≥ 4.5:1 for text
|
||||
- **Keyboard navigation** fully supported
|
||||
- **Screen reader** compatible
|
||||
- **Focus indicators** clearly visible (X.ai blue ring)
|
||||
|
||||
See [WCAG_COMPLIANCE.md](./WCAG_COMPLIANCE.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance Standards
|
||||
|
||||
- **Page load**: < 1s
|
||||
- **Time to Interactive**: < 2s
|
||||
- **Lighthouse Performance**: ≥ 90
|
||||
- **Lighthouse Accessibility**: ≥ 95
|
||||
|
||||
See [PERFORMANCE.md](./PERFORMANCE.md) for optimization guide.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Documentation Maintenance
|
||||
|
||||
### Review Schedule
|
||||
- **Weekly**: Update in-progress features
|
||||
- **Bi-weekly**: Review completed sections
|
||||
- **Monthly**: Full documentation audit
|
||||
- **Quarterly**: Archive outdated docs
|
||||
|
||||
### When to Update
|
||||
- ✏️ New feature added → Add sitemap, flow, specs
|
||||
- 🎨 Visual redesign → Update mockups, components
|
||||
- 🔧 Component changes → Update specs, Storybook
|
||||
- 🐛 Bug fix → Update known issues
|
||||
- 🚀 New page → Add to sitemap, create flow
|
||||
|
||||
### Version Control
|
||||
- Use semantic versioning (v1.0.0)
|
||||
- Track in individual doc files
|
||||
- Major changes → Update version in this README
|
||||
- Keep changelog in relevant docs
|
||||
|
||||
---
|
||||
|
||||
## 📞 Contact & Support
|
||||
|
||||
**Design Team**: design@goodgo.com
|
||||
**Dev Team**: dev@goodgo.com
|
||||
**Product Team**: product@goodgo.com
|
||||
|
||||
**Slack Channels**:
|
||||
- #design - Design discussions
|
||||
- #dev-frontend - Development questions
|
||||
- #accessibility - A11y topics
|
||||
|
||||
---
|
||||
|
||||
## 🔗 External Resources
|
||||
|
||||
### Design Tools
|
||||
- [Figma Project](#) - Main design files
|
||||
- [Storybook](http://localhost:6006) - Component library
|
||||
- [Chromatic](#) - Visual regression testing
|
||||
|
||||
### Development Tools
|
||||
- [GitHub Repo](#) - Source code
|
||||
- [Vercel Dashboard](#) - Deployments
|
||||
- [Sentry](#) - Error monitoring
|
||||
|
||||
### Learning Resources
|
||||
- [X.ai Brand Guidelines](https://x.ai/legal/brand-guidelines)
|
||||
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
|
||||
- [React Aria Docs](https://react-spectrum.adobe.com/react-aria/)
|
||||
|
||||
---
|
||||
|
||||
## 📈 Metrics & Analytics
|
||||
|
||||
### Design System Adoption
|
||||
- Components documented: 3/20 (15%)
|
||||
- User flows documented: 1/10 (10%)
|
||||
- Pages with specs: 3/3 (100% - Auth pages)
|
||||
|
||||
### Goals for Q1 2026
|
||||
- [ ] 100% of auth flow documented
|
||||
- [ ] All shared components have specs
|
||||
- [ ] Storybook coverage > 80%
|
||||
- [ ] Accessibility score > 95 on all pages
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Onboarding
|
||||
|
||||
### New Designers
|
||||
1. Read [Design System](./DESIGN_SYSTEM.md)
|
||||
2. Review [Sitemap](./ux/sitemap.md)
|
||||
3. Explore Figma files
|
||||
4. Check existing [User Flows](./ux/flows/)
|
||||
5. Review component specs in Storybook
|
||||
|
||||
### New Developers
|
||||
1. Read [Auth Pages Implementation](./implementation/auth-pages-implementation.md)
|
||||
2. Run `npm run storybook` to see components
|
||||
3. Review [Theme CSS](../styles/theme.css)
|
||||
4. Check [WCAG Compliance](./WCAG_COMPLIANCE.md)
|
||||
5. Run tests: `npm test`
|
||||
|
||||
### New QA Engineers
|
||||
1. Review all user flows in [ux/flows/](./ux/flows/)
|
||||
2. Check testing checklists in implementation docs
|
||||
3. Review [WCAG Compliance](./WCAG_COMPLIANCE.md)
|
||||
4. Familiarize with Storybook for component testing
|
||||
|
||||
---
|
||||
|
||||
## 📋 Templates Available
|
||||
|
||||
| Template | Purpose | Location |
|
||||
|----------|---------|----------|
|
||||
| User Flow | Document user journeys | [TEMPLATE_USER_FLOW.md](./TEMPLATE_USER_FLOW.md) |
|
||||
| Component Spec | Document UI components | [TEMPLATE_COMPONENT_SPEC.md](./TEMPLATE_COMPONENT_SPEC.md) |
|
||||
|
||||
---
|
||||
|
||||
## 🏆 Best Practices
|
||||
|
||||
### For Documentation
|
||||
- ✅ Write clear, concise descriptions
|
||||
- ✅ Include visual examples (screenshots, diagrams)
|
||||
- ✅ Link related documents
|
||||
- ✅ Keep it up-to-date
|
||||
- ✅ Use consistent formatting
|
||||
- ❌ Don't duplicate information
|
||||
- ❌ Don't use jargon without explanation
|
||||
|
||||
### For Design
|
||||
- ✅ Follow design system tokens
|
||||
- ✅ Maintain accessibility standards
|
||||
- ✅ Test on all breakpoints
|
||||
- ✅ Document all states
|
||||
- ✅ Get feedback early and often
|
||||
|
||||
### For Development
|
||||
- ✅ Reference design specs
|
||||
- ✅ Use CSS variables, not hardcoded values
|
||||
- ✅ Test accessibility
|
||||
- ✅ Write Storybook stories
|
||||
- ✅ Keep components reusable
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-01-04
|
||||
**Version**: 2.0.0 (X.ai Minimal Redesign)
|
||||
**Maintained by**: Design Team + Dev Team
|
||||
@@ -1,422 +0,0 @@
|
||||
# Component Specification Template
|
||||
|
||||
> **Copy this template for new component documentation**
|
||||
>
|
||||
> Location: `src/docs/ui/components/[component-name]-spec.md`
|
||||
|
||||
## 📦 Component: [Component Name]
|
||||
|
||||
**Path**: `src/features/[feature]/components/[component-name]/`
|
||||
|
||||
**Purpose**: [What does this component do? One-line description]
|
||||
|
||||
**Category**: [Form Input | Button | Layout | Navigation | Data Display | Feedback]
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Visual Design
|
||||
|
||||
### Desktop View
|
||||

|
||||
|
||||
### Mobile View
|
||||

|
||||
|
||||
### Figma Link
|
||||
[Link to Figma component]
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Component API
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Default | Required | Description |
|
||||
|------|------|---------|----------|-------------|
|
||||
| `variant` | `'primary' \| 'secondary'` | `'primary'` | No | Visual style variant |
|
||||
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | No | Size of the component |
|
||||
| `disabled` | `boolean` | `false` | No | Disable interaction |
|
||||
| `children` | `ReactNode` | - | Yes | Content to display |
|
||||
| `className` | `string` | - | No | Additional CSS classes |
|
||||
| `onClick` | `() => void` | - | No | Click handler |
|
||||
|
||||
### Variants
|
||||
|
||||
#### Primary Variant
|
||||
- **Use when**: [When to use this variant]
|
||||
- **Visual**: [Description of appearance]
|
||||
- **Example**:
|
||||
```tsx
|
||||
<Component variant="primary">Primary</Component>
|
||||
```
|
||||
|
||||
#### Secondary Variant
|
||||
- **Use when**: [When to use this variant]
|
||||
- **Visual**: [Description of appearance]
|
||||
- **Example**:
|
||||
```tsx
|
||||
<Component variant="secondary">Secondary</Component>
|
||||
```
|
||||
|
||||
### Sizes
|
||||
|
||||
#### Small (`sm`)
|
||||
- Height: [px/rem]
|
||||
- Padding: [px/rem]
|
||||
- Font size: [px/rem]
|
||||
- Use case: [When to use]
|
||||
|
||||
#### Medium (`md`)
|
||||
- Height: [px/rem]
|
||||
- Padding: [px/rem]
|
||||
- Font size: [px/rem]
|
||||
- Use case: [When to use]
|
||||
|
||||
#### Large (`lg`)
|
||||
- Height: [px/rem]
|
||||
- Padding: [px/rem]
|
||||
- Font size: [px/rem]
|
||||
- Use case: [When to use]
|
||||
|
||||
---
|
||||
|
||||
## 📱 States
|
||||
|
||||
### Default State
|
||||
**Description**: Normal, inactive state
|
||||
|
||||
**Visual**:
|
||||
- Background: [color/variable]
|
||||
- Border: [color/variable]
|
||||
- Text: [color/variable]
|
||||
|
||||
**Code**:
|
||||
```tsx
|
||||
<Component>Default</Component>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Hover State
|
||||
**Description**: Mouse over the component
|
||||
|
||||
**Visual**:
|
||||
- Background: [color/variable]
|
||||
- Border: [color/variable]
|
||||
- Text: [color/variable]
|
||||
- Transform: [scale/translate if any]
|
||||
|
||||
**Code**:
|
||||
```tsx
|
||||
<Component className="hover:...">Hover me</Component>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Active State
|
||||
**Description**: Component is being clicked/pressed
|
||||
|
||||
**Visual**:
|
||||
- Background: [color/variable]
|
||||
- Border: [color/variable]
|
||||
- Text: [color/variable]
|
||||
|
||||
---
|
||||
|
||||
### Focus State
|
||||
**Description**: Component is focused (keyboard navigation)
|
||||
|
||||
**Visual**:
|
||||
- Outline/Ring: [color/variable]
|
||||
- Background: [color/variable]
|
||||
|
||||
**Accessibility**: Must be clearly visible for keyboard users
|
||||
|
||||
---
|
||||
|
||||
### Disabled State
|
||||
**Description**: Component cannot be interacted with
|
||||
|
||||
**Visual**:
|
||||
- Opacity: [value]
|
||||
- Cursor: not-allowed
|
||||
- Background: [color/variable]
|
||||
|
||||
**Code**:
|
||||
```tsx
|
||||
<Component disabled>Disabled</Component>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Loading State (if applicable)
|
||||
**Description**: Component is processing an action
|
||||
|
||||
**Visual**:
|
||||
- Spinner/loading indicator
|
||||
- Disabled interaction
|
||||
- Optional text change
|
||||
|
||||
**Code**:
|
||||
```tsx
|
||||
<Component loading>Loading...</Component>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Error State (if applicable)
|
||||
**Description**: Component has validation error
|
||||
|
||||
**Visual**:
|
||||
- Border: error color
|
||||
- Background: error color with low opacity
|
||||
- Error icon (optional)
|
||||
- Error message below
|
||||
|
||||
**Code**:
|
||||
```tsx
|
||||
<Component error="Error message">Value</Component>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Usage Examples
|
||||
|
||||
### Basic Usage
|
||||
```tsx
|
||||
import { Component } from '@/features/shared/components/ui/component'
|
||||
|
||||
function Example() {
|
||||
return (
|
||||
<Component>
|
||||
Basic example
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### With All Props
|
||||
```tsx
|
||||
<Component
|
||||
variant="primary"
|
||||
size="lg"
|
||||
disabled={false}
|
||||
onClick={handleClick}
|
||||
className="custom-class"
|
||||
>
|
||||
Full example
|
||||
</Component>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Complex Example
|
||||
```tsx
|
||||
function ComplexExample() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
setLoading(true)
|
||||
await someAction()
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
variant="primary"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{loading ? 'Processing...' : 'Click me'}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design Tokens Used
|
||||
|
||||
### Colors
|
||||
- `--accent-primary`: Primary action color
|
||||
- `--bg-secondary`: Background color
|
||||
- `--text-primary`: Text color
|
||||
- `--border-primary`: Border color
|
||||
|
||||
### Typography
|
||||
- Font: `var(--font-sans)`
|
||||
- Size: `var(--text-base)`
|
||||
- Weight: `var(--font-medium)`
|
||||
|
||||
### Spacing
|
||||
- Padding: `var(--spacing-4)`
|
||||
- Margin: `var(--spacing-2)`
|
||||
|
||||
### Borders
|
||||
- Radius: `var(--radius-md)`
|
||||
- Width: `1px`
|
||||
|
||||
### Shadows
|
||||
- Default: `var(--shadow-sm)`
|
||||
- Hover: `var(--shadow-md)`
|
||||
|
||||
---
|
||||
|
||||
## ♿ Accessibility
|
||||
|
||||
### ARIA Attributes
|
||||
```tsx
|
||||
<Component
|
||||
role="button"
|
||||
aria-label="Descriptive label"
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
Content
|
||||
</Component>
|
||||
```
|
||||
|
||||
### Keyboard Navigation
|
||||
- **Tab**: Focus the component
|
||||
- **Enter/Space**: Activate the component
|
||||
- **Escape**: Cancel (if applicable)
|
||||
|
||||
### Screen Reader Support
|
||||
- Component announces its purpose
|
||||
- State changes are announced
|
||||
- Error messages are announced with `role="alert"`
|
||||
|
||||
### Focus Management
|
||||
- Visible focus indicator (X.ai blue ring)
|
||||
- Focus trap (if modal/dialog)
|
||||
- Focus returns to trigger element after close
|
||||
|
||||
### Color Contrast
|
||||
- Text to background: ≥ 4.5:1 (WCAG AA)
|
||||
- Focus indicator: ≥ 3:1 (WCAG AA)
|
||||
|
||||
---
|
||||
|
||||
## 📱 Responsive Behavior
|
||||
|
||||
### Mobile (< 640px)
|
||||
- [How does it adapt?]
|
||||
- [Touch target size: min 44x44px]
|
||||
- [Full width or stacked?]
|
||||
|
||||
### Tablet (640-1024px)
|
||||
- [How does it adapt?]
|
||||
|
||||
### Desktop (> 1024px)
|
||||
- [How does it adapt?]
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Unit Tests
|
||||
```tsx
|
||||
describe('Component', () => {
|
||||
it('renders correctly', () => {
|
||||
// Test case
|
||||
})
|
||||
|
||||
it('handles click events', () => {
|
||||
// Test case
|
||||
})
|
||||
|
||||
it('shows disabled state', () => {
|
||||
// Test case
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Visual Regression Tests
|
||||
- Default state
|
||||
- All variants
|
||||
- All sizes
|
||||
- All states (hover, active, focus, disabled)
|
||||
- Dark + Light theme
|
||||
|
||||
### Accessibility Tests
|
||||
- axe-core tests pass
|
||||
- Keyboard navigation works
|
||||
- Screen reader announces correctly
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Known Issues
|
||||
|
||||
### Issue 1: [Issue Description]
|
||||
**Browsers affected**: [Chrome, Firefox, Safari, etc.]
|
||||
**Workaround**: [How to work around it]
|
||||
**Tracking**: [Link to GitHub issue]
|
||||
|
||||
---
|
||||
|
||||
## 📝 Development Notes
|
||||
|
||||
### Dependencies
|
||||
- `react`: ^18.2.0
|
||||
- `react-aria`: ^3.45.0 (if applicable)
|
||||
- [Other dependencies]
|
||||
|
||||
### File Structure
|
||||
```
|
||||
component-name/
|
||||
├── index.ts # Barrel export
|
||||
├── component-name.tsx # Main component
|
||||
├── component-name.test.tsx
|
||||
├── component-name.stories.tsx
|
||||
└── component-name.module.css (if needed)
|
||||
```
|
||||
|
||||
### CSS Classes
|
||||
```tsx
|
||||
// Tailwind classes used
|
||||
className="
|
||||
bg-accent-primary
|
||||
text-white
|
||||
px-4 py-2
|
||||
rounded-md
|
||||
hover:bg-accent-primary-hover
|
||||
focus:ring-2 focus:ring-accent-primary/30
|
||||
disabled:opacity-50
|
||||
transition-colors duration-150
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Changelog
|
||||
|
||||
### v2.0.0 (2026-01-04)
|
||||
- Updated to X.ai blue accent color
|
||||
- Removed gradient, now solid color
|
||||
- Updated focus states
|
||||
|
||||
### v1.0.0 (2025-12-01)
|
||||
- Initial release
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Components
|
||||
|
||||
- [Related Component 1](./related-component-1-spec.md)
|
||||
- [Related Component 2](./related-component-2-spec.md)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- [Storybook](http://localhost:6006/?path=/story/component)
|
||||
- [Figma Design](#)
|
||||
- [GitHub Source](path/to/component)
|
||||
|
||||
---
|
||||
|
||||
**Maintained by**: [Team Name]
|
||||
**Last Updated**: [Date]
|
||||
**Version**: [Version Number]
|
||||
@@ -1,189 +0,0 @@
|
||||
# User Flow Template
|
||||
|
||||
> **Copy this template to create new user flows**
|
||||
>
|
||||
> Location: `src/docs/ux/flows/[feature-name]-flow.md`
|
||||
|
||||
## 📋 Flow Overview
|
||||
|
||||
**Goal**: [What does the user want to accomplish?]
|
||||
|
||||
**Entry Points**:
|
||||
- [Where can users start this flow?]
|
||||
- [List all entry points]
|
||||
|
||||
**Exit Points**:
|
||||
- Success → [Where do they go on success?]
|
||||
- Cancel → [What happens if they cancel?]
|
||||
- Error → [What happens on error?]
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Entry Point │
|
||||
└──────┬──────┘
|
||||
│
|
||||
v
|
||||
┌──────────────────────────────┐
|
||||
│ Step 1: [Action] │
|
||||
│ - Detail 1 │
|
||||
│ - Detail 2 │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
v
|
||||
┌──────────────────────────────┐
|
||||
│ Step 2: [Action] │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
v
|
||||
┌──────────────────────────────┐
|
||||
│ Step 3: [Decision Point] │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
├─────────────┬──────────────┐
|
||||
v Path A v Path B v Path C
|
||||
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Outcome A │ │ Outcome B │ │ Outcome C │
|
||||
└─────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Detailed Steps
|
||||
|
||||
### Step 1: [Step Name]
|
||||
**Action**: [What does the user do?]
|
||||
|
||||
**UI State**:
|
||||
- [What's visible on screen?]
|
||||
- [What's the initial state?]
|
||||
|
||||
**Validation** (if applicable):
|
||||
- ✅ [Validation rule 1]
|
||||
- ✅ [Validation rule 2]
|
||||
|
||||
**Error Messages**:
|
||||
- [Error condition]: "[Error message]"
|
||||
|
||||
**Technical Notes**:
|
||||
- [API calls, state changes, etc.]
|
||||
|
||||
---
|
||||
|
||||
### Step 2: [Step Name]
|
||||
**Action**: [What does the user do?]
|
||||
|
||||
**UI State**:
|
||||
- [What changes?]
|
||||
|
||||
**Behavior**:
|
||||
- [How does it behave?]
|
||||
|
||||
---
|
||||
|
||||
### Step 3: [Decision Point]
|
||||
**Action**: [What triggers the decision?]
|
||||
|
||||
**Possible Outcomes**:
|
||||
|
||||
#### ✅ Success Path
|
||||
**Condition**: [When does this happen?]
|
||||
|
||||
**Actions**:
|
||||
1. [What happens first?]
|
||||
2. [What happens next?]
|
||||
3. [Final outcome]
|
||||
|
||||
---
|
||||
|
||||
#### ❌ Error Path
|
||||
**Condition**: [When does this happen?]
|
||||
|
||||
**Actions**:
|
||||
1. [How is error shown?]
|
||||
2. [How can user recover?]
|
||||
|
||||
---
|
||||
|
||||
## 🔀 Alternative Paths
|
||||
|
||||
### Path A: [Alternative Name]
|
||||
**Trigger**: [What triggers this path?]
|
||||
|
||||
**Action**: [What happens?]
|
||||
|
||||
---
|
||||
|
||||
### Path B: [Alternative Name]
|
||||
**Trigger**: [What triggers this path?]
|
||||
|
||||
**Action**: [What happens?]
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
Flow is successful when:
|
||||
- ✅ [Criterion 1]
|
||||
- ✅ [Criterion 2]
|
||||
- ✅ [Criterion 3]
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
**Functional Tests**:
|
||||
- [ ] [Test case 1]
|
||||
- [ ] [Test case 2]
|
||||
- [ ] [Test case 3]
|
||||
|
||||
**Edge Cases**:
|
||||
- [ ] [Edge case 1]
|
||||
- [ ] [Edge case 2]
|
||||
|
||||
**Accessibility**:
|
||||
- [ ] Keyboard navigation works
|
||||
- [ ] Screen reader announces correctly
|
||||
- [ ] Focus management is correct
|
||||
|
||||
**Performance**:
|
||||
- [ ] Page loads < [X]s
|
||||
- [ ] API response < [X]s
|
||||
- [ ] No layout shift
|
||||
|
||||
---
|
||||
|
||||
## 📱 Responsive Behavior
|
||||
|
||||
**Mobile (< 640px)**:
|
||||
- [How does it adapt?]
|
||||
|
||||
**Tablet (640-1024px)**:
|
||||
- [How does it adapt?]
|
||||
|
||||
**Desktop (> 1024px)**:
|
||||
- [How does it adapt?]
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Figma Prototype
|
||||
|
||||
[Link to Figma prototype]
|
||||
|
||||
---
|
||||
|
||||
## 📊 Analytics Events
|
||||
|
||||
Track these events:
|
||||
- `[event_name_1]`
|
||||
- `[event_name_2]`
|
||||
- `[event_name_3]`
|
||||
|
||||
---
|
||||
|
||||
**Maintained by**: [Team Name]
|
||||
**Last Updated**: [Date]
|
||||
**Version**: [Version Number]
|
||||
@@ -1,53 +0,0 @@
|
||||
# WCAG 2.1 AA Compliance Documentation
|
||||
|
||||
## Color Contrast
|
||||
|
||||
### Text Colors (WCAG 2.1 AA Compliant)
|
||||
|
||||
**Normal Text (< 18px): Minimum 4.5:1 contrast ratio**
|
||||
|
||||
- `--text-primary: #FAFAFA` on `--bg-primary: #0A0A0A` = **15.8:1** ✓
|
||||
- `--text-secondary: #E0E0E0` on `--bg-primary: #0A0A0A` = **12.6:1** ✓
|
||||
- `--text-tertiary: #A0A0A0` on `--bg-primary: #0A0A0A` = **6.4:1** ✓
|
||||
|
||||
**Large Text (≥ 18px): Minimum 3:1 contrast ratio**
|
||||
|
||||
- All text colors meet this requirement ✓
|
||||
|
||||
### UI Components: Minimum 3:1 contrast ratio
|
||||
|
||||
- `--accent-primary: #3B82F6` on `--bg-primary: #0A0A0A` = **4.2:1** ✓
|
||||
- `--accent-success: #10B981` on `--bg-primary: #0A0A0A` = **5.1:1** ✓
|
||||
- `--accent-error: #EF4444` on `--bg-primary: #0A0A0A` = **5.8:1** ✓
|
||||
|
||||
## Font Size
|
||||
|
||||
- **Minimum base font size: 16px** (set in `globals.css`)
|
||||
- All text uses relative units (rem) for proper scaling
|
||||
- Supports zoom up to 200% without breaking layout
|
||||
|
||||
## Keyboard Navigation
|
||||
|
||||
- **Focus indicators**: 2px solid outline with 2px offset
|
||||
- **Tab order**: Logical and consistent
|
||||
- **Custom shortcuts**: Ctrl+K (search), Ctrl+N (new chat), Ctrl+/ (help)
|
||||
|
||||
## Screen Reader Support
|
||||
|
||||
- **Semantic HTML**: Proper use of `<header>`, `<nav>`, `<main>`, `<aside>`, `<footer>`
|
||||
- **ARIA labels**: All interactive elements have descriptive labels
|
||||
- **Live regions**: Announcements with `role="status" aria-live="polite"`
|
||||
- **Skip link**: "Skip to main content" link for keyboard navigation
|
||||
|
||||
## Alternative Text
|
||||
|
||||
- All images have descriptive `alt` attributes
|
||||
- Decorative images use `alt=""`
|
||||
- Avatar images include user names in alt text
|
||||
|
||||
## Testing Tools
|
||||
|
||||
- WebAIM Contrast Checker
|
||||
- Chrome DevTools Accessibility tab
|
||||
- WAVE browser extension
|
||||
- axe DevTools
|
||||
@@ -1,477 +0,0 @@
|
||||
# Báo Cáo Tình Trạng Implementation - Authentication Pages
|
||||
|
||||
**Ngày kiểm tra**: 2026-01-05
|
||||
**Người kiểm tra**: AI Assistant
|
||||
|
||||
---
|
||||
|
||||
## 📊 Tổng Quan
|
||||
|
||||
| Tiêu chí | Trạng thái | Ghi chú |
|
||||
|----------|-----------|---------|
|
||||
| **Login Page** | ✅ Hoàn thành 100% | Đã implement đầy đủ theo design guide |
|
||||
| **Register Page** | ✅ Hoàn thành 100% | Đã implement đầy đủ theo design guide |
|
||||
| **Forgot Password** | ✅ Hoàn thành 100% | Đã implement đầy đủ theo design guide |
|
||||
| **Theme CSS** | ✅ Hoàn thành 100% | Đã cập nhật X.ai minimal colors |
|
||||
| **Glass CSS** | ✅ Hoàn thành 100% | Đã fix shadow values và X.ai blue glow |
|
||||
| **Input Component** | ✅ Hoàn thành 100% | X.ai blue focus ring đúng 100% |
|
||||
| **Button Component** | ✅ Hoàn thành 100% | Solid X.ai blue (no gradient) |
|
||||
|
||||
**🎉 HOÀN THÀNH 100%** - Tất cả components đã implement đúng theo X.ai minimal design guide!
|
||||
|
||||
---
|
||||
|
||||
## ✅ Đã Hoàn Thành
|
||||
|
||||
### 1. Login Page (`/login`)
|
||||
|
||||
**File**: `src/app/(auth)/login/page.tsx`
|
||||
|
||||
✅ **Background**:
|
||||
- Line 113: Đã đổi từ `bg-black` → `bg-bg-primary` (#15202b)
|
||||
- Đã xóa cosmic background (lines 115-116 có comment)
|
||||
|
||||
✅ **Icon Container**:
|
||||
- Line 123: Đã cập nhật `bg-accent-primary/5 border border-accent-primary/10`
|
||||
- Đã xóa `animate-float` class (icon tĩnh)
|
||||
|
||||
✅ **Links & Buttons**:
|
||||
- Line 237: "Forgot password" link dùng `text-accent-primary hover:text-accent-primary-hover`
|
||||
- Line 264: "Sign up" link dùng `text-accent-primary hover:text-accent-primary-hover`
|
||||
- Line 246-257: Button dùng `variant="brand"`
|
||||
|
||||
✅ **Form Elements**:
|
||||
- Line 226: Checkbox dùng `text-accent-primary focus:ring-accent-primary`
|
||||
- Inputs sử dụng Input component với validation
|
||||
|
||||
---
|
||||
|
||||
### 2. Register Page (`/register`)
|
||||
|
||||
**File**: `src/app/(auth)/register/page.tsx`
|
||||
|
||||
✅ **Background**:
|
||||
- Line 230: Đã đổi từ `bg-black` → `bg-bg-primary` (#15202b)
|
||||
- Đã xóa cosmic background (lines 232-233 có comment)
|
||||
|
||||
✅ **Icon Container**:
|
||||
- Line 240: Đã cập nhật `bg-accent-primary/5 border border-accent-primary/10`
|
||||
- Đã xóa `animate-float` class (icon tĩnh)
|
||||
|
||||
✅ **Password Strength Indicator**:
|
||||
- Lines 355-383: Đã implement đầy đủ với 4 bars
|
||||
- Sử dụng màu X.ai: error (red), warning (amber), success (green)
|
||||
- Real-time calculation với `useMemo`
|
||||
|
||||
✅ **Links & Buttons**:
|
||||
- Line 426: Terms link dùng `text-accent-primary hover:text-accent-primary-hover`
|
||||
- Line 466: "Sign in" link dùng `text-accent-primary hover:text-accent-primary-hover`
|
||||
- Line 448-459: Button dùng `variant="brand"`
|
||||
|
||||
✅ **Form Elements**:
|
||||
- Line 418: Terms checkbox dùng `text-accent-primary focus:ring-accent-primary`
|
||||
- Inputs sử dụng Input component với validation đầy đủ
|
||||
|
||||
---
|
||||
|
||||
### 3. Forgot Password Page (`/forgot-password`)
|
||||
|
||||
**File**: `src/app/(auth)/forgot-password/page.tsx`
|
||||
|
||||
✅ **Background**:
|
||||
- Line 113: Đã đổi từ `bg-black` → `bg-bg-primary` (#15202b)
|
||||
- Đã xóa cosmic background (lines 115-116 có comment)
|
||||
|
||||
✅ **Icon Container**:
|
||||
- Line 123: Đã cập nhật `bg-accent-primary/5 border border-accent-primary/10`
|
||||
- Đã xóa `animate-float` class (icon tĩnh)
|
||||
|
||||
✅ **Success State**:
|
||||
- Lines 152-211: Đã implement đầy đủ success state
|
||||
- Success icon với green color
|
||||
- "Back to login" button
|
||||
- "Send to another email" option
|
||||
|
||||
✅ **Links & Buttons**:
|
||||
- Line 274: "Back to login" link dùng `text-accent-primary hover:text-accent-primary-hover`
|
||||
- Line 192-199: Button dùng `variant="brand"`
|
||||
|
||||
✅ **Form Elements**:
|
||||
- Line 240-256: Input sử dụng Input component với validation
|
||||
|
||||
---
|
||||
|
||||
### 4. Theme CSS
|
||||
|
||||
**File**: `src/styles/theme.css`
|
||||
|
||||
✅ **Background Colors** (Lines 22-30):
|
||||
```css
|
||||
--bg-primary: #15202b; /* ✅ Warm dark gray */
|
||||
--bg-secondary: #1a2734; /* ✅ Lighter variant */
|
||||
--bg-tertiary: #1f2f3d; /* ✅ Medium variant */
|
||||
--bg-elevated: #243442; /* ✅ Elevated variant */
|
||||
```
|
||||
|
||||
✅ **Accent Colors** (Lines 44-50):
|
||||
```css
|
||||
--accent-primary: #1D9BF0; /* ✅ X.ai blue */
|
||||
--accent-primary-hover: #1a8cd8; /* ✅ X.ai blue hover */
|
||||
--accent-primary-light: #8ecdf7; /* ✅ X.ai blue light */
|
||||
```
|
||||
|
||||
✅ **Brand Colors** (Lines 89-97):
|
||||
```css
|
||||
--brand-primary: #1D9BF0; /* ✅ X.ai blue */
|
||||
--brand-primary-light: #8ecdf7; /* ✅ Light variant */
|
||||
--brand-primary-dark: #1a8cd8; /* ✅ Dark variant */
|
||||
```
|
||||
|
||||
✅ **Border Colors** (Lines 76-82):
|
||||
```css
|
||||
--border-focus: #1D9BF0; /* ✅ X.ai blue for focus */
|
||||
```
|
||||
|
||||
✅ **Glass Effects** (Lines 113-143):
|
||||
```css
|
||||
--glass-bg-default: rgba(255, 255, 255, 0.04); /* ✅ 4% opacity */
|
||||
--glass-border-default: rgba(255, 255, 255, 0.08); /* ✅ 8% opacity */
|
||||
--glass-border-focus: rgba(29, 155, 240, 0.5); /* ✅ X.ai blue 50% */
|
||||
--glass-blur-md: 8px; /* ✅ Reduced blur */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Đã Kiểm Tra - Components
|
||||
|
||||
### 1. Glass CSS File ⚠️ CẦN CẬP NHẬT
|
||||
|
||||
**File**: `src/styles/glass.css`
|
||||
|
||||
**Tình trạng**: ❌ **Chưa đúng theo design guide**
|
||||
|
||||
**Hiện tại** (Lines 45-59):
|
||||
```css
|
||||
.glass-card {
|
||||
background: var(--glass-bg-default);
|
||||
backdrop-filter: blur(var(--glass-blur-sm)); /* ✅ Đúng */
|
||||
border: 1px solid var(--glass-border-default); /* ✅ Đúng */
|
||||
box-shadow: var(--shadow); /* ⚠️ Cần update */
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--duration-fast) var(--ease-snap);
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
transform: translateY(-1px); /* ⚠️ Không cần transform */
|
||||
border-color: var(--glass-border-hover); /* ✅ Đúng */
|
||||
/* No shadow change - cleaner look */ /* ❌ Cần thêm shadow */
|
||||
}
|
||||
```
|
||||
|
||||
**Cần sửa thành** (theo design guide):
|
||||
```css
|
||||
.glass-card {
|
||||
background: var(--glass-bg-default);
|
||||
backdrop-filter: blur(var(--glass-blur-sm));
|
||||
border: 1px solid var(--glass-border-default);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); /* Softer shadow */
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
border-color: var(--glass-border-hover);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); /* Softer hover */
|
||||
}
|
||||
```
|
||||
|
||||
**Glass Input** (Lines 108-125):
|
||||
```css
|
||||
.glass-input:focus {
|
||||
background: var(--glass-bg-default);
|
||||
border-color: var(--glass-border-focus); /* ✅ Đúng (X.ai blue) */
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.03); /* ❌ Cần X.ai blue glow */
|
||||
outline: none;
|
||||
}
|
||||
```
|
||||
|
||||
**Cần sửa thành**:
|
||||
```css
|
||||
.glass-input:focus {
|
||||
border-color: var(--accent-primary); /* X.ai blue */
|
||||
box-shadow: 0 0 0 3px rgba(29, 155, 240, 0.1); /* X.ai blue glow */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Input Component ✅ HOÀN THÀNH
|
||||
|
||||
**File**: `src/features/shared/components/ui/input/input.tsx`
|
||||
|
||||
**Tình trạng**: ✅ **Đã đúng 100% theo design guide**
|
||||
|
||||
**Focus States** (Lines 203-206):
|
||||
```tsx
|
||||
// ✅ Đã implement đúng X.ai blue focus ring
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
'focus:ring-accent-primary/30 focus:ring-offset-bg-primary',
|
||||
'focus:bg-glass focus:border-accent-primary',
|
||||
```
|
||||
|
||||
**Khớp với design guide**:
|
||||
- ✅ `focus:ring-accent-primary/30` - X.ai blue ring 30% opacity
|
||||
- ✅ `focus:ring-offset-bg-primary` - Ring offset với background
|
||||
- ✅ `focus:border-accent-primary` - Border X.ai blue khi focus
|
||||
|
||||
**Validation States** (Lines 208-210):
|
||||
```tsx
|
||||
// ✅ Invalid state với accent-error
|
||||
'data-[invalid]:border-accent-error',
|
||||
'data-[invalid]:focus:ring-accent-error/50',
|
||||
```
|
||||
|
||||
**Kết luận**: ✅ Input component đã hoàn thành đúng theo X.ai minimal design
|
||||
|
||||
---
|
||||
|
||||
### 3. Button Component ✅ HOÀN THÀNH
|
||||
|
||||
**File**: `src/features/shared/components/ui/button/button.tsx`
|
||||
|
||||
**Tình trạng**: ✅ **Đã đúng 100% theo design guide**
|
||||
|
||||
**Brand Variant** (Lines 82-90):
|
||||
```tsx
|
||||
// ✅ Đã implement đúng X.ai blue solid color
|
||||
brand: [
|
||||
'bg-accent-primary text-white', // ✅ Solid X.ai blue (không có gradient)
|
||||
'shadow-md hover:shadow-lg', // ✅ Simplified shadow
|
||||
'hover:bg-accent-primary-hover', // ✅ X.ai blue hover
|
||||
'transition-colors duration-quick', // ✅ Fast transition
|
||||
'focus-visible:ring-2 focus-visible:ring-accent-primary/30', // ✅ X.ai blue focus ring
|
||||
'focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary',
|
||||
],
|
||||
```
|
||||
|
||||
**Khớp với design guide**:
|
||||
- ✅ Gradient → Solid X.ai blue (`bg-accent-primary`)
|
||||
- ✅ Simplified hover (no scale transform) - chỉ có `hover:bg-accent-primary-hover`
|
||||
- ✅ X.ai blue focus ring (`focus-visible:ring-accent-primary/30`)
|
||||
- ✅ Fast transition (`duration-quick`)
|
||||
|
||||
**Active State** (Line 35):
|
||||
```tsx
|
||||
'active:scale-[0.98]', // ✅ Minimal press feedback
|
||||
```
|
||||
|
||||
**Kết luận**: ✅ Button component đã hoàn thành đúng theo X.ai minimal design
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
### Visual Testing (Dark Theme)
|
||||
|
||||
- [ ] Background là #15202b (warm dark gray)
|
||||
- [ ] Không có cosmic blur orbs
|
||||
- [ ] Icon container có X.ai blue tint
|
||||
- [ ] Primary button là X.ai blue
|
||||
- [ ] Links là X.ai blue
|
||||
- [ ] Input focus ring là X.ai blue
|
||||
- [ ] Glass effects subtle nhưng visible
|
||||
- [ ] Text readable (high contrast)
|
||||
|
||||
### Visual Testing (Light Theme)
|
||||
|
||||
- [ ] Background là white
|
||||
- [ ] X.ai blue vẫn prominent
|
||||
- [ ] Text là dark
|
||||
- [ ] Tất cả interactive elements visible
|
||||
|
||||
### Accessibility Testing
|
||||
|
||||
- [ ] Lighthouse Accessibility score ≥ 95
|
||||
- [ ] Contrast ratio ≥ 4.5:1 cho tất cả text
|
||||
- [ ] Focus indicators visible (X.ai blue ring)
|
||||
- [ ] Keyboard navigation works
|
||||
- [ ] Screen reader announces form fields correctly
|
||||
- [ ] Error messages có `role="alert"`
|
||||
- [ ] Form labels properly associated
|
||||
|
||||
### Responsive Testing
|
||||
|
||||
- [ ] Form centered trên tất cả screen sizes
|
||||
- [ ] Text readable without zoom trên mobile
|
||||
- [ ] Buttons touch-friendly (min 44x44px)
|
||||
- [ ] Không có horizontal scroll
|
||||
- [ ] AuthControls accessible trên mobile
|
||||
- [ ] Virtual keyboard không hide submit button
|
||||
|
||||
### Functional Testing
|
||||
|
||||
#### Login Page
|
||||
- [ ] Submit với valid email + password
|
||||
- [ ] Email validation shows error
|
||||
- [ ] Password validation shows error
|
||||
- [ ] "Remember me" checkbox works
|
||||
- [ ] "Forgot password" link navigates
|
||||
- [ ] "Sign up" link navigates
|
||||
- [ ] Loading state shows spinner
|
||||
- [ ] API error displays message
|
||||
|
||||
#### Register Page
|
||||
- [ ] Password strength indicator updates real-time
|
||||
- [ ] Confirm password validates match
|
||||
- [ ] Terms checkbox required
|
||||
- [ ] All validation messages display
|
||||
|
||||
#### Forgot Password
|
||||
- [ ] Email validation works
|
||||
- [ ] Success state shows confirmation
|
||||
- [ ] "Back to login" link works
|
||||
- [ ] "Send to another email" works
|
||||
|
||||
### Cross-Browser Testing
|
||||
|
||||
- [ ] Chrome (latest)
|
||||
- [ ] Firefox (latest)
|
||||
- [ ] Safari (latest)
|
||||
- [ ] Edge (latest)
|
||||
|
||||
## 📝 Kết Luận
|
||||
|
||||
### Tổng Kết
|
||||
|
||||
**🎉 HOÀN THÀNH 100%** ✅
|
||||
|
||||
**Đã làm xong** (100%):
|
||||
1. ✅ **Login Page** - 100% hoàn thành
|
||||
2. ✅ **Register Page** - 100% hoàn thành
|
||||
3. ✅ **Forgot Password Page** - 100% hoàn thành
|
||||
4. ✅ **Theme CSS** - 100% hoàn thành (X.ai minimal colors)
|
||||
5. ✅ **Glass CSS** - 100% hoàn thành (đã fix shadow values và X.ai blue glow)
|
||||
6. ✅ **Input Component** - 100% hoàn thành (X.ai blue focus ring)
|
||||
7. ✅ **Button Component** - 100% hoàn thành (solid X.ai blue, no gradient)
|
||||
|
||||
### Chi Tiết Đã Fix
|
||||
|
||||
#### File: `src/styles/glass.css` ✅
|
||||
|
||||
**1. Glass Card Shadow (Line 50)**
|
||||
|
||||
✅ Đã sửa từ:
|
||||
```css
|
||||
box-shadow: var(--shadow);
|
||||
```
|
||||
|
||||
Thành:
|
||||
```css
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); /* Softer shadow */
|
||||
```
|
||||
|
||||
**2. Glass Card Hover (Lines 55-59)**
|
||||
|
||||
✅ Đã xóa:
|
||||
```css
|
||||
transform: translateY(-1px); /* Removed */
|
||||
```
|
||||
|
||||
✅ Đã thêm:
|
||||
```css
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); /* Softer hover */
|
||||
```
|
||||
|
||||
**3. Glass Input Focus (Line 123)**
|
||||
|
||||
✅ Đã sửa từ:
|
||||
```css
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.03); /* White glow */
|
||||
```
|
||||
|
||||
Thành:
|
||||
```css
|
||||
box-shadow: 0 0 0 3px rgba(29, 155, 240, 0.1); /* X.ai blue glow */
|
||||
```
|
||||
|
||||
### Next Steps - Testing
|
||||
|
||||
#### 1. Visual Testing (10 phút)
|
||||
|
||||
```bash
|
||||
# Dev server đang chạy tại http://localhost:3000
|
||||
# Navigate to:
|
||||
# - http://localhost:3000/login
|
||||
# - http://localhost:3000/register
|
||||
# - http://localhost:3000/forgot-password
|
||||
```
|
||||
|
||||
**Kiểm tra**:
|
||||
- [ ] Glass card có shadow mềm hơn (0 2px 8px)
|
||||
- [ ] Glass card hover có shadow tăng nhẹ (0 4px 12px)
|
||||
- [ ] Input focus có X.ai blue glow (không phải white)
|
||||
- [ ] Tất cả buttons là X.ai blue solid
|
||||
- [ ] Links là X.ai blue
|
||||
- [ ] Background là #15202b (warm dark gray)
|
||||
- [ ] Không có cosmic blur orbs
|
||||
|
||||
#### 2. Accessibility Testing (10 phút)
|
||||
|
||||
**Tools**:
|
||||
- Chrome DevTools Lighthouse
|
||||
- axe DevTools extension
|
||||
|
||||
**Targets**:
|
||||
- [ ] Lighthouse Accessibility score ≥ 95
|
||||
- [ ] Contrast ratio ≥ 4.5:1 (X.ai blue #1D9BF0 on #15202b = 5.2:1 ✅)
|
||||
- [ ] Focus indicators visible
|
||||
- [ ] Keyboard navigation works
|
||||
|
||||
#### 3. Responsive Testing (10 phút)
|
||||
|
||||
**Breakpoints**:
|
||||
- [ ] Mobile: 375px (iPhone)
|
||||
- [ ] Tablet: 768px (iPad)
|
||||
- [ ] Desktop: 1440px
|
||||
|
||||
### Đánh Giá Chất Lượng
|
||||
|
||||
| Tiêu chí | Điểm | Ghi chú |
|
||||
|----------|------|---------|
|
||||
| **Code Quality** | 10/10 | ✅ Perfect - Clean, maintainable code |
|
||||
| **Design Compliance** | 10/10 | ✅ 100% khớp với X.ai minimal design |
|
||||
| **Accessibility** | 10/10 | ✅ Đầy đủ ARIA, keyboard nav, focus states |
|
||||
| **Performance** | 10/10 | ✅ Minimal blur, fast transitions |
|
||||
| **Maintainability** | 10/10 | ✅ Clean code, good comments |
|
||||
|
||||
### Kết Luận Cuối
|
||||
|
||||
**🎉 Implementation đã hoàn thành 100%** với chất lượng cao!
|
||||
|
||||
**Điểm mạnh**:
|
||||
- ✅ Tất cả 3 trang auth đã implement đúng design guide
|
||||
- ✅ Theme CSS hoàn hảo với X.ai minimal colors (#15202b, #1D9BF0)
|
||||
- ✅ Glass CSS đã fix đúng shadow values và X.ai blue glow
|
||||
- ✅ Input và Button components đã đúng 100%
|
||||
- ✅ Code quality cao, maintainable
|
||||
- ✅ Accessibility đầy đủ
|
||||
- ✅ Performance tối ưu (minimal blur, fast transitions)
|
||||
|
||||
**Thay đổi chính**:
|
||||
1. Background: `#000000` → `#15202b` (warm dark gray)
|
||||
2. Accent: `#FFFFFF` → `#1D9BF0` (X.ai blue)
|
||||
3. Cosmic effects: Removed (minimal design)
|
||||
4. Glass shadows: Softer values
|
||||
5. Focus glow: X.ai blue (not white)
|
||||
|
||||
**Khuyến nghị tiếp theo**:
|
||||
1. ✅ Chạy visual testing (10 phút)
|
||||
2. ✅ Chạy accessibility testing (10 phút)
|
||||
3. ✅ Chạy responsive testing (10 phút)
|
||||
4. ✅ Deploy to staging để QA test
|
||||
|
||||
---
|
||||
|
||||
**Tác giả**: AI Assistant
|
||||
**Ngày tạo**: 2026-01-05
|
||||
**Ngày hoàn thành**: 2026-01-05 10:16
|
||||
**Version**: 3.0 (100% Complete)
|
||||
@@ -1,594 +0,0 @@
|
||||
# Authentication Pages - Implementation Guide
|
||||
|
||||
> **Developer Handoff Documentation**
|
||||
>
|
||||
> Hướng dẫn chi tiết để implement 3 trang xác thực theo X.ai minimal design
|
||||
|
||||
## 🎯 Design Intent
|
||||
|
||||
**Vision**: Tạo trải nghiệm xác thực minimal, sophisticated, và user-friendly theo phong cách X.ai 2026
|
||||
|
||||
**Key Principles**:
|
||||
- **Neo-minimalism**: "Less is enough" - loại bỏ yếu tố thừa
|
||||
- **High contrast**: White text on warm dark background
|
||||
- **Accessibility-first**: WCAG 2.1 AA compliance
|
||||
- **Responsive**: Mobile-first approach
|
||||
- **Performance**: Fast load, smooth animations
|
||||
|
||||
---
|
||||
|
||||
## 📋 Pages Overview
|
||||
|
||||
### 1. Login Page (`/login`)
|
||||
- Email + Password form
|
||||
- Remember me checkbox
|
||||
- Forgot password link
|
||||
- Sign up link
|
||||
|
||||
### 2. Register Page (`/register`)
|
||||
- Email + Password + Confirm Password
|
||||
- Password strength indicator
|
||||
- Terms & conditions checkbox
|
||||
- Sign in link
|
||||
|
||||
### 3. Forgot Password Page (`/forgot-password`)
|
||||
- Email input
|
||||
- Success state with confirmation
|
||||
- Back to login link
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design Changes (X.ai Minimal Redesign)
|
||||
|
||||
### Background
|
||||
**Before**: Pure black (#000000) with 2 floating blur orbs
|
||||
**After**: Warm dark gray (#15202b), clean solid background
|
||||
|
||||
**Rationale**:
|
||||
- #15202b reduces eye strain
|
||||
- Softer, more inviting feel
|
||||
- Aligns with X.ai's 2026 neo-minimalism
|
||||
- Better for OLED displays
|
||||
|
||||
---
|
||||
|
||||
### Accent Color
|
||||
**Before**: White (#FFFFFF)
|
||||
**After**: X.ai blue (#1D9BF0)
|
||||
|
||||
**Usage**:
|
||||
- Primary buttons
|
||||
- Links
|
||||
- Focus states
|
||||
- Interactive elements
|
||||
|
||||
**Rationale**:
|
||||
- Creates strong brand identity
|
||||
- Better visual hierarchy
|
||||
- Meets WCAG contrast requirements on #15202b
|
||||
|
||||
---
|
||||
|
||||
### Cosmic Background Removal
|
||||
**Removed**:
|
||||
```tsx
|
||||
// DELETE THIS:
|
||||
<div className="absolute inset-0 z-0 pointer-events-none">
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-accent-primary/5 blur-[120px] rounded-full animate-float opacity-50" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-accent-primary/5 blur-[120px] rounded-full animate-float opacity-50" />
|
||||
</div>
|
||||
```
|
||||
|
||||
**Rationale**:
|
||||
- Reduce visual noise
|
||||
- Focus user attention on form
|
||||
- Faster rendering (no blur filters)
|
||||
- Aligns with minimal aesthetic
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Technical Implementation
|
||||
|
||||
### Step 1: Update Theme Variables
|
||||
|
||||
**File**: `src/styles/theme.css`
|
||||
|
||||
**Changes**:
|
||||
```css
|
||||
/* Dark Theme Colors */
|
||||
:root {
|
||||
/* Backgrounds */
|
||||
--bg-primary: #15202b; /* Changed from #000000 */
|
||||
--bg-secondary: #1a2734; /* Changed from #0A0A0A */
|
||||
--bg-tertiary: #1f2f3d; /* Changed from #141414 */
|
||||
--bg-elevated: #243442; /* Changed from #1A1A1A */
|
||||
|
||||
/* Accent Colors */
|
||||
--accent-primary: #1D9BF0; /* Changed from #FFFFFF */
|
||||
--accent-primary-hover: #1a8cd8;
|
||||
|
||||
/* Brand Colors */
|
||||
--brand-primary: #1D9BF0; /* Changed from #FFFFFF */
|
||||
--brand-primary-light: #8ecdf7;
|
||||
--brand-primary-dark: #1a8cd8;
|
||||
|
||||
/* Borders */
|
||||
--border-focus: #1D9BF0; /* Changed from #FFFFFF */
|
||||
|
||||
/* Glass Effects */
|
||||
--glass-border-focus: rgba(29, 155, 240, 0.5);
|
||||
}
|
||||
|
||||
/* Light Theme */
|
||||
[data-theme="light"], .light {
|
||||
--bg-primary: #FFFFFF;
|
||||
--accent-primary: #1D9BF0;
|
||||
--border-focus: #1D9BF0;
|
||||
}
|
||||
```
|
||||
|
||||
**Impact**: All components using CSS variables automatically update
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Update Glass Effects
|
||||
|
||||
**File**: `src/styles/glass.css`
|
||||
|
||||
**Changes**:
|
||||
```css
|
||||
.glass-card {
|
||||
background: var(--glass-bg-default);
|
||||
backdrop-filter: blur(var(--glass-blur-sm));
|
||||
border: 1px solid var(--glass-border-default);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); /* Softer shadow */
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
border-color: var(--glass-border-hover);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); /* Softer hover */
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
border-color: var(--accent-primary); /* X.ai blue */
|
||||
box-shadow: 0 0 0 3px rgba(29, 155, 240, 0.1); /* X.ai blue glow */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Update Auth Pages
|
||||
|
||||
#### Login Page
|
||||
**File**: `src/app/(auth)/login/page.tsx`
|
||||
|
||||
**Line 116** - Update background:
|
||||
```tsx
|
||||
// BEFORE:
|
||||
className="min-h-screen flex items-center justify-center relative overflow-hidden bg-black py-12 px-4 sm:px-6 lg:px-8"
|
||||
|
||||
// AFTER:
|
||||
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"
|
||||
```
|
||||
|
||||
**Lines 118-126** - Remove cosmic background:
|
||||
```tsx
|
||||
// DELETE ENTIRE BLOCK
|
||||
```
|
||||
|
||||
**Lines 133-147** - Update icon container:
|
||||
```tsx
|
||||
// BEFORE:
|
||||
<div className="p-3 rounded-2xl bg-white/5 border border-white/10 shadow-glass-sm animate-float">
|
||||
|
||||
// AFTER:
|
||||
<div className="p-3 rounded-2xl bg-accent-primary/5 border border-accent-primary/10 shadow-glass-sm">
|
||||
```
|
||||
|
||||
**Note**: Remove `animate-float` class for static icon
|
||||
|
||||
---
|
||||
|
||||
#### Register Page
|
||||
**File**: `src/app/(auth)/register/page.tsx`
|
||||
|
||||
**Same changes as Login**:
|
||||
1. Line 230: `bg-black` → `bg-bg-primary`
|
||||
2. Lines 232-240: Delete cosmic background
|
||||
3. Lines 247-261: Update icon container
|
||||
|
||||
---
|
||||
|
||||
#### Forgot Password Page
|
||||
**File**: `src/app/(auth)/forgot-password/page.tsx`
|
||||
|
||||
**Same changes as Login**:
|
||||
1. Line 115: `bg-black` → `bg-bg-primary`
|
||||
2. Lines 118-125: Delete cosmic background
|
||||
3. Lines 132-146: Update icon container
|
||||
|
||||
**Optional** - Line 278, update "Back to login" link:
|
||||
```tsx
|
||||
// BEFORE:
|
||||
className="text-sm font-medium text-text-secondary hover:text-white transition-colors inline-flex items-center gap-2 group"
|
||||
|
||||
// AFTER:
|
||||
className="text-sm font-medium text-accent-primary hover:text-accent-primary-hover transition-colors inline-flex items-center gap-2 group"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Update Input Component
|
||||
|
||||
**File**: `src/features/shared/components/ui/input/input.tsx`
|
||||
|
||||
**Lines 203-206** - Update focus states:
|
||||
```tsx
|
||||
// BEFORE:
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
'focus:ring-glass-focus focus:ring-offset-bg-primary',
|
||||
'focus:bg-glass focus:border-glass-hover',
|
||||
|
||||
// AFTER:
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
'focus:ring-accent-primary/30 focus:ring-offset-bg-primary',
|
||||
'focus:bg-glass focus:border-accent-primary',
|
||||
```
|
||||
|
||||
**Visual Result**: X.ai blue focus ring around inputs
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Update Button Component
|
||||
|
||||
**File**: `src/features/shared/components/ui/button/button.tsx`
|
||||
|
||||
**Lines 83-88** - Update brand variant:
|
||||
```tsx
|
||||
// BEFORE:
|
||||
brand: [
|
||||
'bg-brand-gradient text-white',
|
||||
'shadow-brand hover:shadow-brand-lg',
|
||||
'hover:scale-[1.02]',
|
||||
'focus-visible:ring-brand-primary focus-visible:shadow-colored',
|
||||
],
|
||||
|
||||
// AFTER:
|
||||
brand: [
|
||||
'bg-accent-primary text-white',
|
||||
'shadow-md hover:shadow-lg',
|
||||
'hover:bg-accent-primary-hover',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent-primary/30 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary',
|
||||
],
|
||||
```
|
||||
|
||||
**Changes**:
|
||||
- Gradient → Solid X.ai blue
|
||||
- Simplified hover (no scale transform)
|
||||
- X.ai blue focus ring
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Requirements
|
||||
|
||||
### Visual Testing
|
||||
|
||||
**Dark Theme** (Default):
|
||||
```bash
|
||||
# Start dev server
|
||||
npm run dev
|
||||
|
||||
# Navigate to:
|
||||
http://localhost:3000/login
|
||||
http://localhost:3000/register
|
||||
http://localhost:3000/forgot-password
|
||||
```
|
||||
|
||||
**Checklist**:
|
||||
- [ ] Background is #15202b (warm dark gray)
|
||||
- [ ] No cosmic blur orbs visible
|
||||
- [ ] Icon container has X.ai blue tint
|
||||
- [ ] Primary button is X.ai blue
|
||||
- [ ] Links are X.ai blue
|
||||
- [ ] Input focus ring is X.ai blue
|
||||
- [ ] Glass effects subtle but visible
|
||||
- [ ] Text readable (high contrast)
|
||||
|
||||
---
|
||||
|
||||
**Light Theme**:
|
||||
```bash
|
||||
# Click theme toggle in top-right
|
||||
# Or manually:
|
||||
localStorage.setItem('theme', 'light')
|
||||
```
|
||||
|
||||
**Checklist**:
|
||||
- [ ] Background is white
|
||||
- [ ] X.ai blue still prominent
|
||||
- [ ] Text is dark
|
||||
- [ ] All interactive elements visible
|
||||
|
||||
---
|
||||
|
||||
### Accessibility Testing
|
||||
|
||||
**Tools**:
|
||||
- Chrome DevTools Lighthouse
|
||||
- axe DevTools extension
|
||||
- WebAIM Contrast Checker
|
||||
|
||||
**Requirements**:
|
||||
- [ ] Lighthouse Accessibility score ≥ 95
|
||||
- [ ] Contrast ratio ≥ 4.5:1 for all text
|
||||
- [ ] Focus indicators visible (X.ai blue ring)
|
||||
- [ ] Keyboard navigation works (Tab, Shift+Tab, Enter)
|
||||
- [ ] Screen reader announces form fields correctly
|
||||
- [ ] Error messages have `role="alert"`
|
||||
- [ ] Form labels properly associated
|
||||
|
||||
**Contrast Check**:
|
||||
```
|
||||
X.ai blue (#1D9BF0) on dark bg (#15202b):
|
||||
✅ Contrast ratio: 5.2:1 (Passes WCAG AA)
|
||||
|
||||
White (#FFFFFF) on dark bg (#15202b):
|
||||
✅ Contrast ratio: 12.8:1 (Passes WCAG AAA)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Responsive Testing
|
||||
|
||||
**Breakpoints**:
|
||||
```bash
|
||||
# Mobile
|
||||
Resize browser to 375px width (iPhone)
|
||||
|
||||
# Tablet
|
||||
Resize to 768px width (iPad)
|
||||
|
||||
# Desktop
|
||||
Resize to 1440px width
|
||||
```
|
||||
|
||||
**Checklist**:
|
||||
- [ ] Form centered on all screen sizes
|
||||
- [ ] Text readable without zoom on mobile
|
||||
- [ ] Buttons touch-friendly (min 44x44px)
|
||||
- [ ] No horizontal scroll
|
||||
- [ ] AuthControls (theme/lang) accessible on mobile
|
||||
- [ ] Virtual keyboard doesn't hide submit button
|
||||
|
||||
---
|
||||
|
||||
### Functional Testing
|
||||
|
||||
**Login Page**:
|
||||
- [ ] Can submit with valid email + password
|
||||
- [ ] Email validation shows error for invalid format
|
||||
- [ ] Password validation shows error if < 8 chars
|
||||
- [ ] "Remember me" checkbox works
|
||||
- [ ] "Forgot password" link navigates correctly
|
||||
- [ ] "Sign up" link navigates correctly
|
||||
- [ ] Loading state shows spinner
|
||||
- [ ] API error displays message
|
||||
|
||||
**Register Page**:
|
||||
- [ ] Password strength indicator updates in real-time
|
||||
- [ ] Confirm password validates match
|
||||
- [ ] Terms checkbox required to submit
|
||||
- [ ] All validation messages display correctly
|
||||
|
||||
**Forgot Password**:
|
||||
- [ ] Email validation works
|
||||
- [ ] Success state shows confirmation
|
||||
- [ ] "Back to login" link works
|
||||
- [ ] "Send to another email" option works
|
||||
|
||||
---
|
||||
|
||||
### Cross-Browser Testing
|
||||
|
||||
**Required Browsers**:
|
||||
- [ ] Chrome (latest)
|
||||
- [ ] Firefox (latest)
|
||||
- [ ] Safari (latest)
|
||||
- [ ] Edge (latest)
|
||||
|
||||
**Known Issues**:
|
||||
- Safari: backdrop-filter may have slight differences
|
||||
- Solution: Fallback to solid background if needed
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design Specifications
|
||||
|
||||
### Layout Measurements
|
||||
|
||||
**Form Container**:
|
||||
- Max-width: 448px (`max-w-md`)
|
||||
- Padding: 32px (`p-8`)
|
||||
- Border-radius: 12px (`rounded-xl`)
|
||||
|
||||
**Spacing**:
|
||||
- Form fields: 16px gap (`space-y-4`)
|
||||
- Sections: 24px gap (`space-y-6`)
|
||||
- Button to form: 24px (`mt-6`)
|
||||
|
||||
**Typography**:
|
||||
- Heading: 36px / 2.25rem (`text-4xl`)
|
||||
- Body: 16px / 1rem (`text-base`)
|
||||
- Small: 14px / 0.875rem (`text-sm`)
|
||||
|
||||
---
|
||||
|
||||
### Color Specifications
|
||||
|
||||
**Backgrounds**:
|
||||
```
|
||||
Dark Primary: #15202b
|
||||
Dark Secondary: #1a2734
|
||||
Light Primary: #FFFFFF
|
||||
```
|
||||
|
||||
**Accent**:
|
||||
```
|
||||
X.ai Blue: #1D9BF0
|
||||
X.ai Blue Hover: #1a8cd8
|
||||
X.ai Blue Light: #8ecdf7
|
||||
```
|
||||
|
||||
**Text**:
|
||||
```
|
||||
Primary: #FFFFFF (dark theme), #1D1D1F (light theme)
|
||||
Secondary: #8899A6
|
||||
Tertiary: #657786
|
||||
```
|
||||
|
||||
**Status**:
|
||||
```
|
||||
Success: #10B981
|
||||
Error: #EF4444
|
||||
Warning: #F59E0B
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Animation Specifications
|
||||
|
||||
**Transitions**:
|
||||
```css
|
||||
Fast: 150ms cubic-bezier(0.4, 0.0, 0.2, 1)
|
||||
Normal: 300ms cubic-bezier(0.4, 0.0, 0.2, 1)
|
||||
```
|
||||
|
||||
**Hover Effects**:
|
||||
- Button: `hover:bg-accent-primary-hover`
|
||||
- Link: `hover:text-accent-primary-hover`
|
||||
- Card: `hover:border-glass-hover`
|
||||
|
||||
**Focus Effects**:
|
||||
- Ring: 2px solid, 30% opacity X.ai blue
|
||||
- Offset: 2px from element
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Common Issues & Solutions
|
||||
|
||||
### Issue 1: Glass effect not visible
|
||||
**Cause**: Backdrop-filter not supported or disabled
|
||||
**Solution**:
|
||||
```css
|
||||
/* Add fallback */
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.05); /* Fallback */
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
@supports not (backdrop-filter: blur(8px)) {
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.1); /* More opaque */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue 2: Focus ring not showing
|
||||
**Cause**: Browser default outline removed
|
||||
**Solution**: Always replace with custom focus styles:
|
||||
```tsx
|
||||
className="focus:outline-none focus:ring-2 focus:ring-accent-primary/30"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue 3: Color contrast failure
|
||||
**Cause**: Insufficient contrast ratio
|
||||
**Solution**: Use CSS variables that are pre-validated:
|
||||
```tsx
|
||||
// ✅ Good
|
||||
text-text-primary
|
||||
|
||||
// ❌ Bad
|
||||
text-gray-400 // May not meet contrast requirements
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Assets Needed
|
||||
|
||||
### Icons
|
||||
- [ ] Logo/Brand mark (SVG) - Already in codebase
|
||||
- [ ] Social login icons (future)
|
||||
|
||||
### Images
|
||||
- None required for auth pages (minimal design)
|
||||
|
||||
### Fonts
|
||||
- [x] Inter (already loaded via Tailwind)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Migration Checklist
|
||||
|
||||
For developers implementing this redesign:
|
||||
|
||||
### Phase 1: Preparation
|
||||
- [ ] Read this document fully
|
||||
- [ ] Review Figma designs (if available)
|
||||
- [ ] Backup current code (git branch)
|
||||
- [ ] Run existing tests to ensure baseline
|
||||
|
||||
### Phase 2: Implementation
|
||||
- [ ] Update `src/styles/theme.css`
|
||||
- [ ] Update `src/styles/glass.css`
|
||||
- [ ] Update `src/features/shared/components/ui/input/input.tsx`
|
||||
- [ ] Update `src/features/shared/components/ui/button/button.tsx`
|
||||
- [ ] Update `src/app/(auth)/login/page.tsx`
|
||||
- [ ] Update `src/app/(auth)/register/page.tsx`
|
||||
- [ ] Update `src/app/(auth)/forgot-password/page.tsx`
|
||||
|
||||
### Phase 3: Testing
|
||||
- [ ] Visual testing (dark + light theme)
|
||||
- [ ] Accessibility testing (Lighthouse)
|
||||
- [ ] Responsive testing (mobile, tablet, desktop)
|
||||
- [ ] Functional testing (all forms)
|
||||
- [ ] Cross-browser testing
|
||||
|
||||
### Phase 4: Review
|
||||
- [ ] Code review with team
|
||||
- [ ] Design review with designer
|
||||
- [ ] QA testing
|
||||
- [ ] Performance benchmarks
|
||||
|
||||
### Phase 5: Deployment
|
||||
- [ ] Merge to main branch
|
||||
- [ ] Deploy to staging
|
||||
- [ ] Final QA on staging
|
||||
- [ ] Deploy to production
|
||||
- [ ] Monitor for issues
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
**Questions about design**: Contact Design Team
|
||||
**Questions about implementation**: Contact Dev Lead
|
||||
**Bug reports**: Create issue in GitHub
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
- [Design System](../DESIGN_SYSTEM.md) - Full design system docs
|
||||
- [WCAG Compliance](../WCAG_COMPLIANCE.md) - Accessibility guidelines
|
||||
- [Login User Flow](../ux/flows/auth-login.md) - Detailed UX flow
|
||||
|
||||
---
|
||||
|
||||
**Author**: Design Team + Dev Team
|
||||
**Last Updated**: 2026-01-04
|
||||
**Version**: 2.0 (X.ai Minimal Redesign)
|
||||
@@ -1,397 +0,0 @@
|
||||
# Theme Completeness Report - X.ai Minimal Design
|
||||
|
||||
**Ngày kiểm tra**: 2026-01-05
|
||||
**Phiên bản**: 1.0
|
||||
|
||||
---
|
||||
|
||||
## 📊 Tổng Quan
|
||||
|
||||
**Kết quả**: ✅ Theme đã hoàn chỉnh 98% - Chỉ cần vài improvements nhỏ
|
||||
|
||||
| Component | Status | Ghi chú |
|
||||
|-----------|--------|---------|
|
||||
| **Theme CSS** | ✅ 100% | Hoàn hảo - X.ai minimal colors |
|
||||
| **Glass CSS** | ✅ 100% | Đã fix shadow values |
|
||||
| **Tailwind Config** | ✅ 100% | Đầy đủ utilities |
|
||||
| **Global CSS** | ✅ 100% | Base styles tốt |
|
||||
| **Light Mode** | ⚠️ 90% | Cần test kỹ hơn |
|
||||
| **Animations** | ⚠️ 95% | Có thể thêm vài animations |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Điểm Mạnh
|
||||
|
||||
### 1. Theme Variables (theme.css)
|
||||
|
||||
**Hoàn hảo** - Đã có đầy đủ:
|
||||
- ✅ X.ai minimal colors (#15202b, #1D9BF0)
|
||||
- ✅ Glass effects với opacity phù hợp (4-8%)
|
||||
- ✅ Typography scale đầy đủ
|
||||
- ✅ Spacing system nhất quán
|
||||
- ✅ Animation timing (fast, snappy)
|
||||
- ✅ Dark/Light theme support
|
||||
|
||||
### 2. Tailwind Config
|
||||
|
||||
**Hoàn hảo** - Đã map tất cả CSS variables:
|
||||
- ✅ Colors (bg, text, accent, brand, glass)
|
||||
- ✅ Typography (font sizes, weights, line heights)
|
||||
- ✅ Spacing & layout
|
||||
- ✅ Shadows (glass shadows)
|
||||
- ✅ Backdrop blur levels
|
||||
- ✅ Animation utilities
|
||||
|
||||
### 3. Glass Effects
|
||||
|
||||
**Hoàn hảo** - Đã có đầy đủ glass utilities:
|
||||
- ✅ `.glass-card` - với softer shadows
|
||||
- ✅ `.glass-input` - với X.ai blue focus
|
||||
- ✅ `.glass-button`
|
||||
- ✅ `.glass-modal`
|
||||
- ✅ `.glass-nav`
|
||||
- ✅ `.glass-dropdown`
|
||||
|
||||
### 4. Accessibility
|
||||
|
||||
**Tốt** - Đã có:
|
||||
- ✅ Focus indicators (X.ai blue outline)
|
||||
- ✅ Skip links (.sr-only)
|
||||
- ✅ Minimum font size (16px)
|
||||
- ✅ WCAG 2.1 AA compliant colors
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Cần Bổ Sung (Optional Improvements)
|
||||
|
||||
### 1. Light Mode Testing ⚠️
|
||||
|
||||
**Hiện tại**: Đã có light mode variables nhưng chưa test kỹ
|
||||
|
||||
**Cần làm**:
|
||||
```css
|
||||
/* theme.css - Light mode đã có nhưng cần verify */
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #FFFFFF;
|
||||
--accent-primary: #1D9BF0;
|
||||
--glass-bg-default: rgba(0, 0, 0, 0.04); /* Inverted for light */
|
||||
}
|
||||
```
|
||||
|
||||
**Action**: Test light mode trên auth pages để đảm bảo:
|
||||
- [ ] Background trắng
|
||||
- [ ] Text đen
|
||||
- [ ] X.ai blue vẫn prominent
|
||||
- [ ] Glass effects visible
|
||||
|
||||
---
|
||||
|
||||
### 2. Smooth Scroll Behavior 💡
|
||||
|
||||
**Thiếu**: Smooth scroll cho anchor links
|
||||
|
||||
**Đề xuất thêm vào `globals.css`**:
|
||||
```css
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Disable for users who prefer reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Lý do**: Cải thiện UX khi click anchor links
|
||||
|
||||
---
|
||||
|
||||
### 3. Selection Color 💡
|
||||
|
||||
**Thiếu**: Custom text selection color
|
||||
|
||||
**Đề xuất thêm vào `globals.css`**:
|
||||
```css
|
||||
@layer base {
|
||||
::selection {
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Lý do**: Brand consistency khi user select text
|
||||
|
||||
---
|
||||
|
||||
### 4. Loading States 💡
|
||||
|
||||
**Thiếu**: Skeleton loading animation
|
||||
|
||||
**Đề xuất thêm vào `globals.css`**:
|
||||
```css
|
||||
@keyframes skeleton {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--glass-bg-subtle) 0%,
|
||||
var(--glass-bg-default) 50%,
|
||||
var(--glass-bg-subtle) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Lý do**: Better UX khi loading data
|
||||
|
||||
---
|
||||
|
||||
### 5. Scrollbar Styling 💡
|
||||
|
||||
**Thiếu**: Custom scrollbar theo X.ai theme
|
||||
|
||||
**Đề xuất thêm vào `globals.css`**:
|
||||
```css
|
||||
@layer base {
|
||||
/* Webkit browsers (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--glass-border-default);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--glass-border-hover);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--glass-border-default) var(--bg-secondary);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Lý do**: Consistent với X.ai minimal design
|
||||
|
||||
---
|
||||
|
||||
### 6. Print Styles 💡
|
||||
|
||||
**Thiếu**: Print-friendly styles
|
||||
|
||||
**Đề xuất thêm vào `globals.css`**:
|
||||
```css
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Hide non-essential elements */
|
||||
nav, footer, .no-print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Lý do**: Better UX khi user print pages
|
||||
|
||||
---
|
||||
|
||||
### 7. Focus-Within States 💡
|
||||
|
||||
**Thiếu**: Focus-within for form groups
|
||||
|
||||
**Đề xuất thêm vào `glass.css`**:
|
||||
```css
|
||||
.glass-card:focus-within {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 1px var(--accent-primary);
|
||||
}
|
||||
```
|
||||
|
||||
**Lý do**: Visual feedback khi focus vào form trong card
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Đề Xuất Bổ Sung (Nice to Have)
|
||||
|
||||
### 1. Gradient Utilities (Minimal)
|
||||
|
||||
Mặc dù X.ai minimal không dùng nhiều gradient, nhưng có thể thêm vài gradients tinh tế:
|
||||
|
||||
```css
|
||||
/* theme.css */
|
||||
:root {
|
||||
/* Subtle gradients for special cases */
|
||||
--gradient-subtle: linear-gradient(
|
||||
180deg,
|
||||
var(--bg-primary) 0%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
|
||||
--gradient-accent: linear-gradient(
|
||||
135deg,
|
||||
var(--accent-primary) 0%,
|
||||
var(--accent-primary-hover) 100%
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Micro-interactions
|
||||
|
||||
Thêm vài micro-animations cho buttons:
|
||||
|
||||
```css
|
||||
@layer utilities {
|
||||
.btn-press {
|
||||
transition: transform var(--duration-fast) var(--ease-snap);
|
||||
}
|
||||
|
||||
.btn-press:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Toast/Notification Styles
|
||||
|
||||
Thêm styles cho toast notifications:
|
||||
|
||||
```css
|
||||
.toast {
|
||||
background: var(--glass-bg-medium);
|
||||
backdrop-filter: blur(var(--glass-blur-md));
|
||||
border: 1px solid var(--glass-border-default);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-left: 4px solid var(--accent-success);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-left: 4px solid var(--accent-error);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Checklist Bổ Sung
|
||||
|
||||
### High Priority (Nên làm)
|
||||
|
||||
- [ ] **Test Light Mode** - Test kỹ light mode trên auth pages
|
||||
- [ ] **Smooth Scroll** - Thêm smooth scroll behavior
|
||||
- [ ] **Selection Color** - Custom text selection với X.ai blue
|
||||
|
||||
### Medium Priority (Tốt nếu có)
|
||||
|
||||
- [ ] **Scrollbar Styling** - Custom scrollbar theo theme
|
||||
- [ ] **Loading States** - Skeleton loading animation
|
||||
- [ ] **Focus-Within** - Focus states cho form groups
|
||||
|
||||
### Low Priority (Nice to have)
|
||||
|
||||
- [ ] **Print Styles** - Print-friendly CSS
|
||||
- [ ] **Gradient Utilities** - Subtle gradients
|
||||
- [ ] **Micro-interactions** - Button press animations
|
||||
- [ ] **Toast Styles** - Notification components
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Kết Luận
|
||||
|
||||
### Tổng Kết
|
||||
|
||||
**Theme hiện tại: 98% hoàn chỉnh** ✅
|
||||
|
||||
**Điểm mạnh**:
|
||||
- ✅ X.ai minimal design đã implement đúng 100%
|
||||
- ✅ Theme variables đầy đủ và nhất quán
|
||||
- ✅ Glass effects hoàn hảo
|
||||
- ✅ Accessibility tốt (WCAG 2.1 AA)
|
||||
- ✅ Dark mode hoàn hảo
|
||||
|
||||
**Cần bổ sung** (Optional):
|
||||
- ⚠️ Test light mode kỹ hơn
|
||||
- 💡 Smooth scroll behavior
|
||||
- 💡 Custom text selection
|
||||
- 💡 Scrollbar styling
|
||||
- 💡 Loading states
|
||||
|
||||
### Khuyến Nghị
|
||||
|
||||
**Ưu tiên cao** (15 phút):
|
||||
1. Test light mode trên auth pages
|
||||
2. Thêm smooth scroll behavior
|
||||
3. Thêm custom text selection color
|
||||
|
||||
**Ưu tiên trung bình** (30 phút):
|
||||
4. Custom scrollbar styling
|
||||
5. Skeleton loading animation
|
||||
6. Focus-within states
|
||||
|
||||
**Ưu tiên thấp** (khi có thời gian):
|
||||
7. Print styles
|
||||
8. Gradient utilities
|
||||
9. Toast/notification styles
|
||||
|
||||
### Đánh Giá Cuối
|
||||
|
||||
| Tiêu chí | Điểm | Ghi chú |
|
||||
|----------|------|---------|
|
||||
| **Completeness** | 9.8/10 | Gần như hoàn hảo |
|
||||
| **Consistency** | 10/10 | Rất nhất quán |
|
||||
| **Accessibility** | 10/10 | WCAG 2.1 AA compliant |
|
||||
| **Performance** | 10/10 | Minimal, fast |
|
||||
| **Maintainability** | 10/10 | Clean, organized |
|
||||
|
||||
**Kết luận**: Theme đã rất tốt, chỉ cần vài improvements nhỏ để đạt 100% hoàn hảo!
|
||||
|
||||
---
|
||||
|
||||
**Tác giả**: AI Assistant
|
||||
**Ngày tạo**: 2026-01-05
|
||||
**Version**: 1.0
|
||||
@@ -1,370 +0,0 @@
|
||||
# Theme Improvements - Applied Successfully ✅
|
||||
|
||||
**Ngày apply**: 2026-01-05
|
||||
**Files modified**: 2 files
|
||||
|
||||
---
|
||||
|
||||
## 📊 Tổng Quan
|
||||
|
||||
**🎉 Đã apply thành công 100%** - Tất cả 7 improvements đã được thêm vào theme!
|
||||
|
||||
| Improvement | File | Status |
|
||||
|-------------|------|--------|
|
||||
| **Smooth Scroll** | globals.css | ✅ Applied |
|
||||
| **Custom Selection** | globals.css | ✅ Applied |
|
||||
| **Scrollbar Styling** | globals.css | ✅ Applied |
|
||||
| **Skeleton Loading** | globals.css | ✅ Applied |
|
||||
| **Button Press** | globals.css | ✅ Applied |
|
||||
| **Print Styles** | globals.css | ✅ Applied |
|
||||
| **Focus-Within** | glass.css | ✅ Applied |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Chi Tiết Đã Apply
|
||||
|
||||
### 1. Smooth Scroll Behavior ✅
|
||||
|
||||
**File**: `src/app/globals.css` (Lines 23-48)
|
||||
|
||||
**Thêm vào**:
|
||||
```css
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
/* Disable animations for accessibility */
|
||||
}
|
||||
```
|
||||
|
||||
**Lợi ích**:
|
||||
- ✅ Smooth scroll khi click anchor links
|
||||
- ✅ Respect user preferences (reduced motion)
|
||||
- ✅ Better UX
|
||||
|
||||
---
|
||||
|
||||
### 2. Custom Text Selection (X.ai Blue) ✅
|
||||
|
||||
**File**: `src/app/globals.css` (Lines 50-62)
|
||||
|
||||
**Thêm vào**:
|
||||
```css
|
||||
::selection {
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
```
|
||||
|
||||
**Lợi ích**:
|
||||
- ✅ Brand consistency khi select text
|
||||
- ✅ X.ai blue highlight
|
||||
- ✅ Better visual feedback
|
||||
|
||||
---
|
||||
|
||||
### 3. Custom Scrollbar (X.ai Minimal) ✅
|
||||
|
||||
**File**: `src/app/globals.css` (Lines 64-91)
|
||||
|
||||
**Thêm vào**:
|
||||
```css
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--glass-border-default);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--glass-border-hover);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--glass-border-default) var(--bg-secondary);
|
||||
}
|
||||
```
|
||||
|
||||
**Lợi ích**:
|
||||
- ✅ Consistent với X.ai minimal design
|
||||
- ✅ Subtle, không gây mất tập trung
|
||||
- ✅ Cross-browser support (Chrome, Firefox, Safari)
|
||||
|
||||
---
|
||||
|
||||
### 4. Skeleton Loading Animation ✅
|
||||
|
||||
**File**: `src/app/globals.css`
|
||||
|
||||
**Thêm animation** (Lines 195-207):
|
||||
```css
|
||||
@keyframes skeleton {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Thêm utilities** (Lines 235-272):
|
||||
```css
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--glass-bg-subtle) 0%,
|
||||
var(--glass-bg-default) 50%,
|
||||
var(--glass-bg-subtle) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.skeleton-text { height: 1em; }
|
||||
.skeleton-title { height: 2em; }
|
||||
.skeleton-avatar { width: 48px; height: 48px; }
|
||||
.skeleton-card { height: 200px; }
|
||||
```
|
||||
|
||||
**Lợi ích**:
|
||||
- ✅ Better loading UX
|
||||
- ✅ Consistent với glass theme
|
||||
- ✅ Ready-to-use utilities
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
<div className="skeleton skeleton-text" />
|
||||
<div className="skeleton skeleton-card" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Button Press Micro-interaction ✅
|
||||
|
||||
**File**: `src/app/globals.css` (Lines 274-282)
|
||||
|
||||
**Thêm vào**:
|
||||
```css
|
||||
.btn-press {
|
||||
transition: transform var(--duration-fast) var(--ease-snap);
|
||||
}
|
||||
|
||||
.btn-press:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
```
|
||||
|
||||
**Lợi ích**:
|
||||
- ✅ Tactile feedback
|
||||
- ✅ Better UX
|
||||
- ✅ Minimal, subtle
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
<button className="btn-press">Click me</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Print Styles ✅
|
||||
|
||||
**File**: `src/app/globals.css` (Lines 295-337)
|
||||
|
||||
**Thêm vào**:
|
||||
```css
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Hide non-essential elements */
|
||||
nav, footer, .no-print, button, .auth-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Show link URLs */
|
||||
a[href]:after {
|
||||
content: " (" attr(href) ")";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Lợi ích**:
|
||||
- ✅ Print-friendly pages
|
||||
- ✅ Hide unnecessary elements
|
||||
- ✅ Show link URLs
|
||||
|
||||
---
|
||||
|
||||
### 7. Focus-Within States ✅
|
||||
|
||||
**File**: `src/styles/glass.css` (Lines 60-70)
|
||||
|
||||
**Thêm vào**:
|
||||
```css
|
||||
.glass-card:focus-within {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 1px rgba(29, 155, 240, 0.2);
|
||||
transition: all var(--duration-fast) var(--ease-snap);
|
||||
}
|
||||
```
|
||||
|
||||
**Lợi ích**:
|
||||
- ✅ Visual feedback khi focus vào form trong card
|
||||
- ✅ Better accessibility
|
||||
- ✅ X.ai blue highlight
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Kết Quả
|
||||
|
||||
### Files Modified
|
||||
|
||||
1. **`src/app/globals.css`**:
|
||||
- ✅ Smooth scroll behavior
|
||||
- ✅ Custom text selection
|
||||
- ✅ Custom scrollbar
|
||||
- ✅ Skeleton loading animation + utilities
|
||||
- ✅ Button press micro-interaction
|
||||
- ✅ Print styles
|
||||
|
||||
2. **`src/styles/glass.css`**:
|
||||
- ✅ Focus-within states
|
||||
|
||||
### New Utilities Available
|
||||
|
||||
```css
|
||||
/* Skeleton Loading */
|
||||
.skeleton
|
||||
.skeleton-text
|
||||
.skeleton-title
|
||||
.skeleton-avatar
|
||||
.skeleton-card
|
||||
|
||||
/* Button Press */
|
||||
.btn-press
|
||||
```
|
||||
|
||||
### Accessibility Improvements
|
||||
|
||||
- ✅ Respect `prefers-reduced-motion`
|
||||
- ✅ Focus-within visual feedback
|
||||
- ✅ Print-friendly styles
|
||||
- ✅ Smooth scroll (can be disabled)
|
||||
|
||||
### UX Improvements
|
||||
|
||||
- ✅ Better loading states (skeleton)
|
||||
- ✅ Brand consistency (X.ai blue selection)
|
||||
- ✅ Tactile feedback (button press)
|
||||
- ✅ Smooth navigation (scroll behavior)
|
||||
- ✅ Custom scrollbar (minimal design)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Testing Checklist
|
||||
|
||||
### Visual Testing
|
||||
|
||||
- [ ] Test smooth scroll với anchor links
|
||||
- [ ] Select text để xem X.ai blue highlight
|
||||
- [ ] Scroll page để xem custom scrollbar
|
||||
- [ ] Test skeleton loading utilities
|
||||
- [ ] Click buttons để xem press animation
|
||||
- [ ] Focus vào form trong glass-card
|
||||
|
||||
### Accessibility Testing
|
||||
|
||||
- [ ] Test với `prefers-reduced-motion` enabled
|
||||
- [ ] Test keyboard navigation với focus-within
|
||||
- [ ] Test print preview (Ctrl/Cmd + P)
|
||||
|
||||
### Cross-Browser Testing
|
||||
|
||||
- [ ] Chrome - Scrollbar styling
|
||||
- [ ] Firefox - Scrollbar styling
|
||||
- [ ] Safari - Scrollbar styling
|
||||
- [ ] All browsers - Selection color
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Usage Examples
|
||||
|
||||
### Skeleton Loading
|
||||
|
||||
```tsx
|
||||
// Loading user profile
|
||||
<div className="glass-card p-6">
|
||||
<div className="skeleton skeleton-avatar mb-4" />
|
||||
<div className="skeleton skeleton-title" />
|
||||
<div className="skeleton skeleton-text" />
|
||||
<div className="skeleton skeleton-text" />
|
||||
</div>
|
||||
|
||||
// Loading card
|
||||
<div className="skeleton skeleton-card" />
|
||||
```
|
||||
|
||||
### Button Press
|
||||
|
||||
```tsx
|
||||
<Button className="btn-press" variant="brand">
|
||||
Click me
|
||||
</Button>
|
||||
```
|
||||
|
||||
### Print-Friendly
|
||||
|
||||
```tsx
|
||||
// Hide element when printing
|
||||
<div className="no-print">
|
||||
<AuthControls />
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Đánh Giá Cuối
|
||||
|
||||
| Tiêu chí | Before | After |
|
||||
|----------|--------|-------|
|
||||
| **Theme Completeness** | 98% | 100% ✅ |
|
||||
| **UX Features** | Good | Excellent ✅ |
|
||||
| **Accessibility** | Good | Excellent ✅ |
|
||||
| **Loading States** | None | Skeleton ✅ |
|
||||
| **Print Support** | None | Full ✅ |
|
||||
|
||||
**Kết luận**: Theme đã hoàn hảo 100% với tất cả improvements được apply thành công! 🎉
|
||||
|
||||
---
|
||||
|
||||
**Tác giả**: AI Assistant
|
||||
**Ngày apply**: 2026-01-05
|
||||
**Version**: 1.0 (Complete)
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* EN: High Priority Theme Improvements
|
||||
* VI: Cải tiến theme ưu tiên cao
|
||||
*
|
||||
* Add these improvements to globals.css for better UX
|
||||
* Thêm các cải tiến này vào globals.css để UX tốt hơn
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
1. Smooth Scroll Behavior
|
||||
============================================ */
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Disable for users who prefer reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
/* Also disable all animations */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
2. Custom Text Selection (X.ai Blue)
|
||||
============================================ */
|
||||
@layer base {
|
||||
::selection {
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
3. Custom Scrollbar (X.ai Minimal)
|
||||
============================================ */
|
||||
@layer base {
|
||||
|
||||
/* Webkit browsers (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--glass-border-default);
|
||||
border-radius: var(--radius-full);
|
||||
transition: background var(--duration-fast) var(--ease-snap);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--glass-border-hover);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--glass-border-default) var(--bg-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
4. Skeleton Loading Animation
|
||||
============================================ */
|
||||
@keyframes skeleton {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg,
|
||||
var(--glass-bg-subtle) 0%,
|
||||
var(--glass-bg-default) 50%,
|
||||
var(--glass-bg-subtle) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Skeleton variants */
|
||||
.skeleton-text {
|
||||
height: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 2em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 200px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
5. Focus-Within States for Form Groups
|
||||
============================================ */
|
||||
@layer components {
|
||||
.glass-card:focus-within {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 1px rgba(29, 155, 240, 0.2);
|
||||
transition: all var(--duration-fast) var(--ease-snap);
|
||||
}
|
||||
|
||||
.form-group:focus-within label {
|
||||
color: var(--accent-primary);
|
||||
transition: color var(--duration-fast) var(--ease-snap);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
6. Print Styles
|
||||
============================================ */
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Hide non-essential elements */
|
||||
nav,
|
||||
footer,
|
||||
.no-print,
|
||||
button,
|
||||
.auth-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Ensure readable text */
|
||||
* {
|
||||
color: black !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Show link URLs after text */
|
||||
a[href]:after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
7. Button Press Micro-interaction
|
||||
============================================ */
|
||||
@layer utilities {
|
||||
.btn-press {
|
||||
transition: transform var(--duration-fast) var(--ease-snap);
|
||||
}
|
||||
|
||||
.btn-press:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
@@ -1,597 +0,0 @@
|
||||
# Moodboard - Visual Direction Guide
|
||||
|
||||
> **X.ai Minimal Design Philosophy**
|
||||
>
|
||||
> Hướng dẫn visual direction cho GoodGo Web Client theo phong cách Neo-minimalism 2026
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
**Design Style**: X.ai Minimal / Neo-minimalism 2026
|
||||
**Target Audience**: Tech-savvy professionals
|
||||
**Brand Personality**: Sophisticated, Trustworthy, Innovative, Clean
|
||||
**Last Updated**: 2026-01-04
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Design Philosophy
|
||||
|
||||
### Core Principle: "Less is Enough"
|
||||
|
||||
> **Neo-minimalism** is the evolution of minimalism - moving away from cold, sterile designs toward something warmer, more human, and inviting. It's about removing the unnecessary while retaining warmth and personality.
|
||||
|
||||
### Key Characteristics
|
||||
|
||||
1. **Intentional Simplicity**
|
||||
- Every element has a purpose
|
||||
- Remove visual clutter
|
||||
- White space is a feature, not empty space
|
||||
|
||||
2. **Warm Darkness**
|
||||
- Dark backgrounds that feel inviting, not harsh
|
||||
- `#15202b` instead of pure black `#000000`
|
||||
- Reduces eye strain, feels sophisticated
|
||||
|
||||
3. **Vibrant Accents**
|
||||
- Single accent color for focus: X.ai Blue `#1D9BF0`
|
||||
- High contrast for accessibility
|
||||
- Blue = trust, technology, reliability
|
||||
|
||||
4. **Subtle Depth**
|
||||
- Minimal shadows, not flat
|
||||
- Glass effects (glassmorphism) but very subtle
|
||||
- Layering through transparency, not heavy shadows
|
||||
|
||||
5. **Geometric Typography**
|
||||
- Clean, sans-serif fonts (Inter)
|
||||
- Strong hierarchy
|
||||
- Generous line-height for readability
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Visual Inspiration
|
||||
|
||||
### Primary Inspiration: X.ai / Grok
|
||||
|
||||
**Why X.ai?**
|
||||
- Leader in AI interface design
|
||||
- Perfect balance of minimal + functional
|
||||
- Warm dark theme with vibrant blue accent
|
||||
- Clean, distraction-free interfaces
|
||||
|
||||
**Key Elements from X.ai**:
|
||||
- Warm dark gray backgrounds (#15202b family)
|
||||
- Signature blue accent (#1D9BF0)
|
||||
- Minimal UI chrome
|
||||
- Focus on content, not decoration
|
||||
- Subtle glassmorphism
|
||||
|
||||
---
|
||||
|
||||
### Secondary Inspirations
|
||||
|
||||
#### 1. Linear.app
|
||||
**What to Learn**:
|
||||
- Ultra-clean interfaces
|
||||
- Smooth micro-interactions
|
||||
- Purple/violet accents on dark backgrounds
|
||||
- Keyboard-first design
|
||||
|
||||
**Visual Elements**:
|
||||
- Subtle gradients
|
||||
- Crisp typography
|
||||
- Minimal shadows
|
||||
- Fast, snappy animations
|
||||
|
||||
---
|
||||
|
||||
#### 2. Vercel Dashboard
|
||||
**What to Learn**:
|
||||
- Developer-friendly aesthetics
|
||||
- High contrast for readability
|
||||
- Clean data visualization
|
||||
- Monospace typography for code
|
||||
|
||||
**Visual Elements**:
|
||||
- Pure black backgrounds (but we use warmer)
|
||||
- White/gray text hierarchy
|
||||
- Accent colors for status
|
||||
- Edge-to-edge layouts
|
||||
|
||||
---
|
||||
|
||||
#### 3. Stripe Dashboard
|
||||
**What to Learn**:
|
||||
- Trust through design
|
||||
- Clear visual hierarchy
|
||||
- Excellent form design
|
||||
- Error handling patterns
|
||||
|
||||
**Visual Elements**:
|
||||
- Clean form layouts
|
||||
- Clear labels and instructions
|
||||
- Inline validation
|
||||
- Accessible color choices
|
||||
|
||||
---
|
||||
|
||||
#### 4. Notion
|
||||
**What to Learn**:
|
||||
- Content-first approach
|
||||
- Flexible layouts
|
||||
- Light + dark themes
|
||||
- Collaborative feel
|
||||
|
||||
**Visual Elements**:
|
||||
- Generous whitespace
|
||||
- Subtle hover states
|
||||
- Friendly, approachable feel
|
||||
- Emoji as visual elements (optional)
|
||||
|
||||
---
|
||||
|
||||
## 🌈 Color Direction
|
||||
|
||||
### Primary Palette: X.ai Minimal
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ BACKGROUNDS │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ #15202b │ │ #1a2734 │ │ #1f2f3d │ │ #243442 │ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ Primary Secondary Tertiary Elevated │
|
||||
│ │
|
||||
│ ACCENT │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ #1D9BF0 │ │ #1a8cd8 │ │ #8ecdf7 │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ X.ai Blue Hover Light │
|
||||
│ │
|
||||
│ TEXT │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ #FFFFFF │ │ #8899A6 │ │ #657786 │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ Primary Secondary Tertiary │
|
||||
│ │
|
||||
│ STATUS │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ #10B981 │ │ #EF4444 │ │ #F59E0B │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ Success Error Warning │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Color Psychology
|
||||
|
||||
| Color | Hex | Emotion | Usage |
|
||||
|-------|-----|---------|-------|
|
||||
| **X.ai Blue** | #1D9BF0 | Trust, Technology, Calm | Primary actions, links, focus |
|
||||
| **Warm Dark** | #15202b | Sophisticated, Professional | Backgrounds |
|
||||
| **White** | #FFFFFF | Clean, Clear | Primary text |
|
||||
| **Green** | #10B981 | Success, Positive | Success states |
|
||||
| **Red** | #EF4444 | Alert, Important | Errors, destructive |
|
||||
| **Amber** | #F59E0B | Caution, Attention | Warnings |
|
||||
|
||||
---
|
||||
|
||||
## 🔤 Typography Direction
|
||||
|
||||
### Font Family: Inter
|
||||
|
||||
**Why Inter?**
|
||||
- Designed for screens
|
||||
- Excellent readability at all sizes
|
||||
- Geometric but warm
|
||||
- Open-source, widely available
|
||||
- Similar to SF Pro (Apple's system font)
|
||||
|
||||
### Typography Scale
|
||||
|
||||
```
|
||||
Heading 1: 36px / 2.25rem / font-extrabold / #FFFFFF
|
||||
"Sign in to your account"
|
||||
|
||||
Heading 2: 30px / 1.875rem / font-bold / #FFFFFF
|
||||
"Section Title"
|
||||
|
||||
Heading 3: 24px / 1.5rem / font-semibold / #FFFFFF
|
||||
"Subsection"
|
||||
|
||||
Body: 16px / 1rem / font-normal / #FFFFFF or #8899A6
|
||||
"Regular paragraph text goes here."
|
||||
|
||||
Small: 14px / 0.875rem / font-normal / #8899A6
|
||||
"Secondary information or labels"
|
||||
|
||||
Tiny: 12px / 0.75rem / font-normal / #657786
|
||||
"Captions, timestamps, footnotes"
|
||||
```
|
||||
|
||||
### Typography Principles
|
||||
|
||||
1. **Hierarchy is Key**
|
||||
- 3 levels max per screen
|
||||
- Size + Weight + Color = Hierarchy
|
||||
- Don't rely on color alone
|
||||
|
||||
2. **Generous Spacing**
|
||||
- Line-height: 1.5 for body
|
||||
- Letter-spacing: -0.02em for headings
|
||||
- Paragraph spacing: 1.5x line-height
|
||||
|
||||
3. **Readability First**
|
||||
- Max line length: 65-75 characters
|
||||
- 16px minimum for body (prevents iOS zoom)
|
||||
- High contrast (WCAG AA minimum)
|
||||
|
||||
---
|
||||
|
||||
## 🎭 UI Patterns & Elements
|
||||
|
||||
### Glassmorphism (Subtle)
|
||||
|
||||
**Our Approach**: Ultra-subtle glass effects
|
||||
|
||||
```css
|
||||
/* X.ai Minimal Glass */
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
```
|
||||
|
||||
**DO**:
|
||||
- ✅ Very low opacity (2-5%)
|
||||
- ✅ Subtle blur (4-12px)
|
||||
- ✅ Thin, barely visible borders
|
||||
- ✅ Use sparingly on cards, modals
|
||||
|
||||
**DON'T**:
|
||||
- ❌ Heavy blur (>20px)
|
||||
- ❌ High opacity (>10%)
|
||||
- ❌ Thick, obvious borders
|
||||
- ❌ Glass on glass (double layer)
|
||||
|
||||
---
|
||||
|
||||
### Shadows
|
||||
|
||||
**Our Approach**: Minimal, subtle shadows
|
||||
|
||||
```css
|
||||
/* Light shadow */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Medium shadow */
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* Glow effect (for focus) */
|
||||
box-shadow: 0 0 0 3px rgba(29, 155, 240, 0.1);
|
||||
```
|
||||
|
||||
**DO**:
|
||||
- ✅ Soft, diffused shadows
|
||||
- ✅ Dark shadows on dark backgrounds
|
||||
- ✅ Blue glow for focus states
|
||||
- ✅ Minimal elevation differences
|
||||
|
||||
**DON'T**:
|
||||
- ❌ Sharp, hard shadows
|
||||
- ❌ Light shadows on dark backgrounds
|
||||
- ❌ Multiple shadow layers
|
||||
- ❌ Colored shadows (except focus)
|
||||
|
||||
---
|
||||
|
||||
### Animations & Transitions
|
||||
|
||||
**Our Approach**: Fast, snappy, purposeful
|
||||
|
||||
```css
|
||||
/* Fast (hover, focus) */
|
||||
transition: all 150ms cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
|
||||
/* Normal (page transitions) */
|
||||
transition: all 300ms cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
|
||||
/* Easing: Material Design "Emphasized" */
|
||||
cubic-bezier(0.4, 0.0, 0.2, 1)
|
||||
```
|
||||
|
||||
**DO**:
|
||||
- ✅ Fast transitions (150-300ms)
|
||||
- ✅ Subtle hover effects (opacity, color change)
|
||||
- ✅ Smooth easing curves
|
||||
- ✅ Respect `prefers-reduced-motion`
|
||||
|
||||
**DON'T**:
|
||||
- ❌ Slow animations (>500ms)
|
||||
- ❌ Bouncy/elastic effects
|
||||
- ❌ Rotation or complex transforms
|
||||
- ❌ Animations that block interaction
|
||||
|
||||
---
|
||||
|
||||
### Buttons
|
||||
|
||||
**Primary (X.ai Blue)**:
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ │
|
||||
│ Sign in │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
Background: #1D9BF0
|
||||
Text: #FFFFFF
|
||||
Height: 48px
|
||||
Border-radius: 8px
|
||||
```
|
||||
|
||||
**Secondary (Ghost)**:
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ │
|
||||
│ Cancel │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
Background: transparent
|
||||
Text: #8899A6
|
||||
Border: 1px solid rgba(255,255,255,0.1)
|
||||
Height: 48px
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Form Inputs
|
||||
|
||||
**Default State**:
|
||||
```
|
||||
Email address
|
||||
┌─────────────────────────────────────┐
|
||||
│ you@example.com │
|
||||
└─────────────────────────────────────┘
|
||||
Background: rgba(255,255,255,0.02)
|
||||
Border: 1px solid rgba(255,255,255,0.08)
|
||||
```
|
||||
|
||||
**Focus State**:
|
||||
```
|
||||
Email address
|
||||
┌─────────────────────────────────────┐
|
||||
│ you@example.com | │
|
||||
└─────────────────────────────────────┘
|
||||
Border: 1px solid #1D9BF0
|
||||
Glow: 0 0 0 3px rgba(29,155,240,0.1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Cards
|
||||
|
||||
**Glass Card**:
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Card Title │
|
||||
│ │
|
||||
│ Card content goes here. Keep it │
|
||||
│ simple and focused on one thing. │
|
||||
│ │
|
||||
│ [Action Button] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
Background: rgba(255,255,255,0.04)
|
||||
Border: 1px solid rgba(255,255,255,0.08)
|
||||
Padding: 24-32px
|
||||
Border-radius: 12px
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Do's and Don'ts
|
||||
|
||||
### Colors
|
||||
|
||||
| ✅ DO | ❌ DON'T |
|
||||
|-------|----------|
|
||||
| Use CSS variables (`--accent-primary`) | Hardcode hex values |
|
||||
| Warm dark gray (#15202b) | Pure black (#000000) |
|
||||
| Single accent color (X.ai blue) | Multiple bright colors |
|
||||
| Test contrast ratios (WCAG AA) | Assume colors are accessible |
|
||||
| Consistent color meanings | Random color choices |
|
||||
|
||||
---
|
||||
|
||||
### Typography
|
||||
|
||||
| ✅ DO | ❌ DON'T |
|
||||
|-------|----------|
|
||||
| Use Inter font family | Mix multiple font families |
|
||||
| Clear hierarchy (3 levels max) | Too many font sizes |
|
||||
| 16px minimum for body | Small text (<14px for body) |
|
||||
| High contrast (white on dark) | Low contrast text |
|
||||
| Generous line-height (1.5) | Cramped text |
|
||||
|
||||
---
|
||||
|
||||
### Layout & Spacing
|
||||
|
||||
| ✅ DO | ❌ DON'T |
|
||||
|-------|----------|
|
||||
| 8-point grid system | Random spacing values |
|
||||
| Generous whitespace | Cramped layouts |
|
||||
| Single-column forms | Multi-column forms (mobile) |
|
||||
| Consistent padding | Variable padding |
|
||||
| Max-width for content (448px forms) | Full-width everything |
|
||||
|
||||
---
|
||||
|
||||
### Effects & Decorations
|
||||
|
||||
| ✅ DO | ❌ DON'T |
|
||||
|-------|----------|
|
||||
| Subtle glass effects (2-5% opacity) | Heavy glassmorphism (>10%) |
|
||||
| Minimal shadows | Multiple shadow layers |
|
||||
| Fast transitions (150-300ms) | Slow animations (>500ms) |
|
||||
| Blue glow for focus | Rainbow/gradient glows |
|
||||
| ~~Cosmic backgrounds~~ Solid backgrounds | Complex background patterns |
|
||||
|
||||
---
|
||||
|
||||
### Components
|
||||
|
||||
| ✅ DO | ❌ DON'T |
|
||||
|-------|----------|
|
||||
| Touch-friendly sizes (44px+) | Small touch targets |
|
||||
| Clear hover/focus states | Missing interaction states |
|
||||
| Loading states | No feedback during actions |
|
||||
| Error messages with icons | Error messages with color only |
|
||||
| Disabled states clearly visible | Subtle disabled states |
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Visual Reference Board
|
||||
|
||||
### Approved Styles ✅
|
||||
|
||||
**Authentication Pages**:
|
||||
- Centered form layout
|
||||
- Glass card container
|
||||
- X.ai blue primary button
|
||||
- Warm dark background (#15202b)
|
||||
- Subtle icon container with blue tint
|
||||
- No cosmic/floating background effects
|
||||
|
||||
**Form Elements**:
|
||||
- Top-aligned labels
|
||||
- Subtle input backgrounds
|
||||
- Blue focus rings
|
||||
- Inline validation messages
|
||||
- Password visibility toggle
|
||||
|
||||
**Buttons**:
|
||||
- Solid X.ai blue (not gradient)
|
||||
- Subtle hover darkening
|
||||
- Loading spinner inline
|
||||
- Full-width on mobile
|
||||
|
||||
---
|
||||
|
||||
### Rejected Styles ❌
|
||||
|
||||
**Avoid**:
|
||||
- Pure black backgrounds (#000000)
|
||||
- White accent colors (low visibility)
|
||||
- Floating blur orbs/cosmic effects
|
||||
- Gradient buttons
|
||||
- Heavy shadows
|
||||
- Bouncy animations
|
||||
- Decorative illustrations (for auth)
|
||||
- Multiple accent colors
|
||||
|
||||
---
|
||||
|
||||
## 📱 Responsive Considerations
|
||||
|
||||
### Mobile-First Approach
|
||||
|
||||
1. **Start with mobile** (375px)
|
||||
2. **Enhance for tablet** (768px)
|
||||
3. **Optimize for desktop** (1280px+)
|
||||
|
||||
### Key Mobile Adjustments
|
||||
|
||||
- Full-width containers
|
||||
- Larger touch targets (48px)
|
||||
- Reduced spacing (75% of desktop)
|
||||
- Stacked layouts
|
||||
- Simplified navigation
|
||||
- 16px font for inputs (prevent zoom)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Design Validation Checklist
|
||||
|
||||
Before finalizing any design:
|
||||
|
||||
**Visual**:
|
||||
- [ ] Uses approved color palette
|
||||
- [ ] Typography follows scale
|
||||
- [ ] Spacing follows 8-point grid
|
||||
- [ ] Glass effects are subtle
|
||||
- [ ] Shadows are minimal
|
||||
- [ ] Animations are fast (<300ms)
|
||||
|
||||
**Accessibility**:
|
||||
- [ ] Contrast ratio ≥ 4.5:1
|
||||
- [ ] Focus states visible
|
||||
- [ ] Touch targets ≥ 44px
|
||||
- [ ] Error messages clear
|
||||
- [ ] Color not sole indicator
|
||||
|
||||
**Consistency**:
|
||||
- [ ] Matches existing components
|
||||
- [ ] Uses design tokens (CSS variables)
|
||||
- [ ] Follows established patterns
|
||||
- [ ] Works in dark + light themes
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Resources & References
|
||||
|
||||
### Design Tools
|
||||
- [Figma](https://figma.com) - Primary design tool
|
||||
- [Storybook](http://localhost:6006) - Component library
|
||||
|
||||
### Inspiration Sources
|
||||
- [X.ai](https://x.ai) - Primary inspiration
|
||||
- [Linear.app](https://linear.app) - UI patterns
|
||||
- [Vercel](https://vercel.com) - Developer aesthetics
|
||||
- [Stripe](https://stripe.com) - Form patterns
|
||||
|
||||
### Guidelines
|
||||
- [X.ai Brand Guidelines](https://x.ai/legal/brand-guidelines)
|
||||
- [Material Design 3](https://m3.material.io)
|
||||
- [Apple Human Interface Guidelines](https://developer.apple.com/design/)
|
||||
|
||||
### Learning
|
||||
- [Refactoring UI](https://refactoringui.com)
|
||||
- [UI Design Daily](https://uidesigndaily.com)
|
||||
- [Mobbin](https://mobbin.com) - UI patterns library
|
||||
|
||||
---
|
||||
|
||||
## 📝 Version History
|
||||
|
||||
### v2.0.0 (2026-01-04) - X.ai Minimal Redesign
|
||||
- Adopted X.ai blue accent (#1D9BF0)
|
||||
- Changed from pure black to warm dark (#15202b)
|
||||
- Removed cosmic background effects
|
||||
- Simplified glassmorphism
|
||||
- Updated animation guidelines
|
||||
|
||||
### v1.0.0 (2025-12-01) - Initial Design
|
||||
- White accent on black background
|
||||
- Cosmic floating effects
|
||||
- Gradient buttons
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
- [Design System](../DESIGN_SYSTEM.md)
|
||||
- [Login Mockups](./mockups/auth-login-states.md)
|
||||
- [Mobile Specs](./responsive/mobile-specs.md)
|
||||
- [Implementation Guide](../implementation/auth-pages-implementation.md)
|
||||
|
||||
---
|
||||
|
||||
**Created by**: Design Team
|
||||
**Last Updated**: 2026-01-04
|
||||
**Version**: 2.0 (X.ai Minimal)
|
||||
**Status**: ✅ Approved & Active
|
||||
@@ -1,447 +0,0 @@
|
||||
# Login Page - UI Mockups & States
|
||||
|
||||
> **High-Fidelity Design Specifications**
|
||||
>
|
||||
> Tất cả states của trang đăng nhập theo X.ai minimal design
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
**Page**: Login (`/login`)
|
||||
**Figma**: [Add Figma link here]
|
||||
**Last Updated**: 2026-01-04
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design States
|
||||
|
||||
### 1. Default State (Initial Load)
|
||||
|
||||
**Description**: Trạng thái ban đầu khi user vào trang
|
||||
|
||||
#### Visual Specs
|
||||
|
||||
**Background**:
|
||||
- Color: `#15202b` (warm dark gray)
|
||||
- Pattern: Solid, no cosmic effects
|
||||
|
||||
**Form Container**:
|
||||
- Width: `448px` (max-w-md)
|
||||
- Padding: `32px` (p-8)
|
||||
- Background: `rgba(255, 255, 255, 0.04)` (glass-card)
|
||||
- Border: `1px solid rgba(255, 255, 255, 0.08)`
|
||||
- Border-radius: `12px` (rounded-xl)
|
||||
- Backdrop-filter: `blur(8px)`
|
||||
- Shadow: `0 2px 8px rgba(0, 0, 0, 0.3)`
|
||||
|
||||
**Logo/Icon Container**:
|
||||
- Size: `56px x 56px`
|
||||
- Padding: `12px` (p-3)
|
||||
- Background: `rgba(29, 155, 240, 0.05)` (X.ai blue 5%)
|
||||
- Border: `1px solid rgba(29, 155, 240, 0.1)`
|
||||
- Border-radius: `16px` (rounded-2xl)
|
||||
- Icon: Star (white, 40x40px)
|
||||
|
||||
**Heading**:
|
||||
- Text: "Sign in to your account"
|
||||
- Font: Inter, 36px (text-4xl)
|
||||
- Weight: 800 (font-extrabold)
|
||||
- Color: `#FFFFFF`
|
||||
- Line-height: `1.25` (tracking-tight)
|
||||
|
||||
**Subheading**:
|
||||
- Text: "Enter your credentials to access your account"
|
||||
- Font: Inter, 14px (text-sm)
|
||||
- Color: `#8899A6` (text-secondary)
|
||||
|
||||
**Form Fields**:
|
||||
|
||||
**Email Input**:
|
||||
- Label: "Email address"
|
||||
- Placeholder: "you@example.com"
|
||||
- Background: `rgba(255, 255, 255, 0.02)`
|
||||
- Border: `1px solid rgba(255, 255, 255, 0.08)`
|
||||
- Height: `44px`
|
||||
- Border-radius: `8px`
|
||||
- Font: 16px
|
||||
- Color: `#FFFFFF`
|
||||
|
||||
**Password Input**:
|
||||
- Label: "Password"
|
||||
- Placeholder: "••••••••"
|
||||
- Same styling as email
|
||||
- Eye icon: `24x24px`, color `#8899A6`
|
||||
|
||||
**Remember Me Checkbox**:
|
||||
- Size: `20x20px`
|
||||
- Border: `1px solid rgba(255, 255, 255, 0.2)`
|
||||
- Border-radius: `4px`
|
||||
- Label: "Remember me", 14px, `#8899A6`
|
||||
|
||||
**Forgot Password Link**:
|
||||
- Text: "Forgot password?"
|
||||
- Font: 14px (text-sm)
|
||||
- Color: `#1D9BF0` (X.ai blue)
|
||||
- Hover: `#1a8cd8`
|
||||
|
||||
**Sign In Button**:
|
||||
- Text: "Sign in"
|
||||
- Width: `100%`
|
||||
- Height: `48px`
|
||||
- Background: `#1D9BF0` (X.ai blue)
|
||||
- Color: `#FFFFFF`
|
||||
- Border-radius: `8px`
|
||||
- Font: 16px, weight 600
|
||||
- Shadow: `0 2px 8px rgba(0, 0, 0, 0.1)`
|
||||
|
||||
**Sign Up Link**:
|
||||
- Text: "Don't have an account? Sign up"
|
||||
- Font: 14px
|
||||
- "Sign up" part: `#1D9BF0`
|
||||
|
||||
**Theme/Language Controls** (top-right):
|
||||
- Position: `fixed top-6 right-6`
|
||||
- Gap: `12px`
|
||||
- Each button: `40x40px`, glass effect
|
||||
|
||||
#### Screenshot
|
||||

|
||||
*TODO: Add screenshot*
|
||||
|
||||
---
|
||||
|
||||
### 2. Hover States
|
||||
|
||||
#### Email Input Hover
|
||||
**Changes**:
|
||||
- Border: `1px solid rgba(255, 255, 255, 0.12)`
|
||||
- Transition: `150ms ease`
|
||||
|
||||
#### Password Input Hover
|
||||
**Changes**: Same as email
|
||||
|
||||
#### Sign In Button Hover
|
||||
**Changes**:
|
||||
- Background: `#1a8cd8` (darker blue)
|
||||
- Shadow: `0 4px 12px rgba(0, 0, 0, 0.15)`
|
||||
- Transition: `150ms ease`
|
||||
|
||||
#### Link Hover
|
||||
**Changes**:
|
||||
- Color: `#1a8cd8`
|
||||
- Transition: `150ms ease`
|
||||
|
||||
#### Screenshot
|
||||

|
||||
*TODO: Add screenshot*
|
||||
|
||||
---
|
||||
|
||||
### 3. Focus States
|
||||
|
||||
#### Email Input Focus
|
||||
**Changes**:
|
||||
- Border: `1px solid #1D9BF0` (X.ai blue)
|
||||
- Background: `rgba(255, 255, 255, 0.04)`
|
||||
- Ring: `2px solid rgba(29, 155, 240, 0.3)`
|
||||
- Ring offset: `2px`
|
||||
- Outline: `none`
|
||||
|
||||
#### Password Input Focus
|
||||
**Changes**: Same as email
|
||||
|
||||
#### Button Focus
|
||||
**Changes**:
|
||||
- Ring: `2px solid rgba(29, 155, 240, 0.3)`
|
||||
- Ring offset: `2px`
|
||||
|
||||
#### Screenshot
|
||||

|
||||
*TODO: Add screenshot with focused input*
|
||||
|
||||
---
|
||||
|
||||
### 4. Active/Typing State
|
||||
|
||||
#### Email Input with Text
|
||||
**Changes**:
|
||||
- Value: "user@example.com"
|
||||
- Text color: `#FFFFFF`
|
||||
- Cursor: blinking white line
|
||||
|
||||
#### Password Input with Text
|
||||
**Changes**:
|
||||
- Value: "••••••••" (masked)
|
||||
- Eye icon clickable
|
||||
|
||||
#### Screenshot
|
||||

|
||||
*TODO: Add screenshot*
|
||||
|
||||
---
|
||||
|
||||
### 5. Validation Error State
|
||||
|
||||
#### Email Error
|
||||
**Trigger**: Invalid email format
|
||||
|
||||
**Visual Changes**:
|
||||
- Border: `1px solid #EF4444` (error red)
|
||||
- Background: `rgba(239, 68, 68, 0.1)`
|
||||
- Error message below:
|
||||
- Text: "Please enter a valid email address"
|
||||
- Color: `#EF4444`
|
||||
- Icon: Alert circle (16x16px)
|
||||
- Font: 14px (text-sm)
|
||||
- Margin-top: `4px`
|
||||
|
||||
#### Password Error
|
||||
**Trigger**: Password < 8 characters
|
||||
|
||||
**Visual Changes**:
|
||||
- Border: `1px solid #EF4444`
|
||||
- Background: `rgba(239, 68, 68, 0.1)`
|
||||
- Error message: "Password must be at least 8 characters"
|
||||
|
||||
#### Form-level Error
|
||||
**Trigger**: API returns error (invalid credentials)
|
||||
|
||||
**Visual Changes**:
|
||||
- Alert box above form:
|
||||
- Background: `rgba(239, 68, 68, 0.1)`
|
||||
- Border: `1px solid rgba(239, 68, 68, 0.2)`
|
||||
- Border-radius: `8px`
|
||||
- Padding: `12px 16px`
|
||||
- Icon: X circle (20x20px, red)
|
||||
- Text: "Invalid email or password. Please try again."
|
||||
- Color: `#EF4444`
|
||||
- Font: 14px
|
||||
|
||||
#### Screenshot
|
||||

|
||||
*TODO: Add screenshot*
|
||||
|
||||
---
|
||||
|
||||
### 6. Loading State
|
||||
|
||||
#### Button Loading
|
||||
**Trigger**: Form submitted, waiting for API
|
||||
|
||||
**Visual Changes**:
|
||||
- Button disabled
|
||||
- Text: "Signing in..."
|
||||
- Spinner:
|
||||
- Size: `20x20px`
|
||||
- Color: `#FFFFFF`
|
||||
- Position: Left of text
|
||||
- Animation: Spin (1s linear infinite)
|
||||
- Opacity: `0.7`
|
||||
- Cursor: `not-allowed`
|
||||
|
||||
**Form Fields**:
|
||||
- All inputs disabled
|
||||
- Opacity: `0.5`
|
||||
|
||||
#### Screenshot
|
||||

|
||||
*TODO: Add screenshot*
|
||||
|
||||
---
|
||||
|
||||
### 7. Success State (Redirect)
|
||||
|
||||
**Note**: Very brief state before redirect to dashboard
|
||||
|
||||
**Visual Changes**:
|
||||
- Success toast (optional):
|
||||
- Background: `rgba(16, 185, 129, 0.1)`
|
||||
- Border: `1px solid rgba(16, 185, 129, 0.2)`
|
||||
- Icon: Checkmark circle (green)
|
||||
- Text: "Login successful! Redirecting..."
|
||||
- Position: Top-center
|
||||
- Duration: 1s before redirect
|
||||
|
||||
#### Screenshot
|
||||

|
||||
*TODO: Add screenshot*
|
||||
|
||||
---
|
||||
|
||||
### 8. Disabled State
|
||||
|
||||
**Trigger**: Network error or maintenance mode
|
||||
|
||||
**Visual Changes**:
|
||||
- All inputs disabled
|
||||
- Button disabled:
|
||||
- Background: `rgba(29, 155, 240, 0.3)`
|
||||
- Cursor: `not-allowed`
|
||||
- Opacity: `0.5`
|
||||
- Message: "Login is temporarily unavailable"
|
||||
|
||||
#### Screenshot
|
||||

|
||||
*TODO: Add screenshot*
|
||||
|
||||
---
|
||||
|
||||
## 📱 Responsive Variations
|
||||
|
||||
### Mobile (< 640px)
|
||||
|
||||
**Changes**:
|
||||
- Container: Full width with `16px` padding
|
||||
- Form: Full width
|
||||
- Stack vertically
|
||||
- Touch targets: Min `44x44px`
|
||||
- Font sizes may adjust slightly
|
||||
|
||||
**Screenshot**:
|
||||

|
||||
*TODO: Add screenshot*
|
||||
|
||||
---
|
||||
|
||||
### Tablet (640px - 1024px)
|
||||
|
||||
**Changes**:
|
||||
- Container: Centered, same max-width (448px)
|
||||
- Similar to desktop
|
||||
|
||||
**Screenshot**:
|
||||

|
||||
*TODO: Add screenshot*
|
||||
|
||||
---
|
||||
|
||||
### Desktop (> 1024px)
|
||||
|
||||
**Changes**: Default state as described above
|
||||
|
||||
**Screenshot**:
|
||||

|
||||
*TODO: Add screenshot*
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Dark vs Light Theme
|
||||
|
||||
### Dark Theme (Default)
|
||||
- Background: `#15202b`
|
||||
- Text: `#FFFFFF`
|
||||
- As described above
|
||||
|
||||
### Light Theme
|
||||
**Changes**:
|
||||
- Background: `#FFFFFF`
|
||||
- Text: `#1D1D1F` (dark)
|
||||
- Glass effect: `rgba(0, 0, 0, 0.04)`
|
||||
- Borders: `rgba(0, 0, 0, 0.1)`
|
||||
- X.ai blue stays: `#1D9BF0`
|
||||
|
||||
**Screenshot**:
|
||||

|
||||
*TODO: Add screenshot*
|
||||
|
||||
---
|
||||
|
||||
## 🔤 Typography Specs
|
||||
|
||||
| Element | Font | Size | Weight | Color | Line Height |
|
||||
|---------|------|------|--------|-------|-------------|
|
||||
| H1 Heading | Inter | 36px | 800 | #FFFFFF | 1.25 |
|
||||
| Subheading | Inter | 14px | 400 | #8899A6 | 1.5 |
|
||||
| Input Label | Inter | 14px | 500 | #8899A6 | 1.5 |
|
||||
| Input Text | Inter | 16px | 400 | #FFFFFF | 1.5 |
|
||||
| Button Text | Inter | 16px | 600 | #FFFFFF | 1.5 |
|
||||
| Link Text | Inter | 14px | 500 | #1D9BF0 | 1.5 |
|
||||
| Error Text | Inter | 14px | 400 | #EF4444 | 1.5 |
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Color Palette
|
||||
|
||||
| Purpose | Color | Hex | Usage |
|
||||
|---------|-------|-----|-------|
|
||||
| Background | Warm Dark Gray | #15202b | Main background |
|
||||
| X.ai Blue | Primary Accent | #1D9BF0 | Buttons, links, focus |
|
||||
| X.ai Blue Hover | Darker Blue | #1a8cd8 | Hover states |
|
||||
| White | Primary Text | #FFFFFF | Headings, input text |
|
||||
| Light Gray | Secondary Text | #8899A6 | Labels, descriptions |
|
||||
| Red | Error | #EF4444 | Error messages, borders |
|
||||
| Green | Success | #10B981 | Success messages |
|
||||
|
||||
---
|
||||
|
||||
## 📐 Spacing & Layout
|
||||
|
||||
**Form Container**:
|
||||
- Max-width: `448px`
|
||||
- Padding: `32px`
|
||||
- Gap between elements: `24px`
|
||||
|
||||
**Form Fields**:
|
||||
- Label to input: `8px`
|
||||
- Input to input: `16px`
|
||||
- Input height: `44px`
|
||||
|
||||
**Buttons**:
|
||||
- Height: `48px`
|
||||
- Margin-top: `24px`
|
||||
|
||||
**Mobile**:
|
||||
- Side padding: `16px`
|
||||
- All gaps reduced by 25%
|
||||
|
||||
---
|
||||
|
||||
## ♿ Accessibility Notes
|
||||
|
||||
**Contrast Ratios**:
|
||||
- White on #15202b: 12.8:1 (WCAG AAA ✅)
|
||||
- X.ai blue on #15202b: 5.2:1 (WCAG AA ✅)
|
||||
- Error red on white bg: 4.5:1 (WCAG AA ✅)
|
||||
|
||||
**Focus Indicators**:
|
||||
- All interactive elements have visible focus ring
|
||||
- X.ai blue ring with 30% opacity
|
||||
- 2px thick, 2px offset
|
||||
|
||||
**Keyboard Navigation**:
|
||||
- Tab order: Email → Password → Remember me → Button → Forgot password → Sign up link
|
||||
- Enter key submits form
|
||||
|
||||
**Screen Reader**:
|
||||
- Form has proper labels
|
||||
- Error messages announced with `role="alert"`
|
||||
- Loading state announced
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Design Validation Checklist
|
||||
|
||||
- [ ] All states designed (8 states)
|
||||
- [ ] Responsive variants (mobile, tablet, desktop)
|
||||
- [ ] Dark + Light theme variants
|
||||
- [ ] Typography specs documented
|
||||
- [ ] Color palette defined
|
||||
- [ ] Spacing consistent with 8-point grid
|
||||
- [ ] Accessibility requirements met
|
||||
- [ ] Screenshots added to Figma/public folder
|
||||
- [ ] Interactive prototype created
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- [Login User Flow](../../ux/flows/auth-login.md)
|
||||
- [Implementation Guide](../../implementation/auth-pages-implementation.md)
|
||||
- [Design System](../../DESIGN_SYSTEM.md)
|
||||
- [Responsive Specs](../responsive/mobile-specs.md)
|
||||
|
||||
---
|
||||
|
||||
**Designer**: [Name]
|
||||
**Reviewer**: [Name]
|
||||
**Last Updated**: 2026-01-04
|
||||
**Version**: 2.0 (X.ai Minimal Redesign)
|
||||
@@ -1,554 +0,0 @@
|
||||
# Mobile Responsive Design Specifications
|
||||
|
||||
> **Mobile-First Design Guide**
|
||||
>
|
||||
> Breakpoint: `< 640px` (sm in Tailwind)
|
||||
|
||||
## 📱 Overview
|
||||
|
||||
**Target Devices**:
|
||||
- iPhone 12/13/14: 390px width
|
||||
- iPhone SE: 375px width
|
||||
- Android phones: 360px - 414px width
|
||||
|
||||
**Orientation**: Portrait (primary), Landscape (secondary)
|
||||
|
||||
**Last Updated**: 2026-01-04
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Mobile Design Principles
|
||||
|
||||
### 1. Touch-First Interaction
|
||||
- Minimum touch target: `44x44px` (Apple HIG)
|
||||
- Recommended: `48x48px` (Material Design)
|
||||
- Spacing between touchable elements: `8px` minimum
|
||||
|
||||
### 2. Thumb Zone Optimization
|
||||
- Primary actions: Bottom center (easy reach)
|
||||
- Secondary actions: Top corners
|
||||
- Avoid: Middle edges (hard to reach)
|
||||
|
||||
### 3. Content Priority
|
||||
- Show essential content only
|
||||
- Progressive disclosure for details
|
||||
- Reduce visual clutter
|
||||
|
||||
### 4. Performance
|
||||
- Optimize images for mobile
|
||||
- Minimize animations
|
||||
- Fast load times (< 2s)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Auth Pages - Mobile Specifications
|
||||
|
||||
### Login Page (`/login`)
|
||||
|
||||
#### Layout
|
||||
|
||||
**Container**:
|
||||
```css
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
padding: 16px; /* Reduced from 32px */
|
||||
background: #15202b;
|
||||
```
|
||||
|
||||
**Form Container**:
|
||||
```css
|
||||
width: 100%;
|
||||
max-width: 100%; /* Remove 448px limit */
|
||||
padding: 24px; /* Reduced from 32px */
|
||||
margin: auto;
|
||||
```
|
||||
|
||||
**Vertical Spacing**:
|
||||
- Logo to heading: `16px` (reduced from 24px)
|
||||
- Heading to form: `20px` (reduced from 24px)
|
||||
- Between inputs: `12px` (reduced from 16px)
|
||||
- Form to button: `20px` (reduced from 24px)
|
||||
|
||||
#### Typography
|
||||
|
||||
**Heading**:
|
||||
```css
|
||||
font-size: 28px; /* Reduced from 36px */
|
||||
line-height: 1.2;
|
||||
margin-bottom: 8px;
|
||||
```
|
||||
|
||||
**Subheading**:
|
||||
```css
|
||||
font-size: 14px; /* Same */
|
||||
line-height: 1.4;
|
||||
```
|
||||
|
||||
**Input Labels**:
|
||||
```css
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
```
|
||||
|
||||
**Input Text**:
|
||||
```css
|
||||
font-size: 16px; /* Must be 16px to prevent zoom on iOS */
|
||||
```
|
||||
|
||||
#### Form Elements
|
||||
|
||||
**Input Fields**:
|
||||
```css
|
||||
height: 48px; /* Increased from 44px for better touch */
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px; /* Prevent iOS zoom */
|
||||
```
|
||||
|
||||
**Submit Button**:
|
||||
```css
|
||||
height: 52px; /* Increased from 48px */
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
```
|
||||
|
||||
**Links**:
|
||||
```css
|
||||
min-height: 44px; /* Touch target */
|
||||
padding: 8px 0;
|
||||
display: inline-block;
|
||||
```
|
||||
|
||||
**Checkbox**:
|
||||
```css
|
||||
width: 24px; /* Increased from 20px */
|
||||
height: 24px;
|
||||
margin-right: 8px;
|
||||
```
|
||||
|
||||
#### Theme/Language Controls
|
||||
|
||||
**Position**:
|
||||
```css
|
||||
position: fixed;
|
||||
top: 16px; /* Reduced from 24px */
|
||||
right: 16px;
|
||||
z-index: 50;
|
||||
```
|
||||
|
||||
**Size**:
|
||||
```css
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
gap: 8px;
|
||||
```
|
||||
|
||||
#### Screenshot Layout
|
||||

|
||||
*TODO: Add annotated screenshot*
|
||||
|
||||
---
|
||||
|
||||
### Register Page (`/register`)
|
||||
|
||||
**Same as Login with additions**:
|
||||
|
||||
#### Password Strength Indicator
|
||||
|
||||
**Bars**:
|
||||
```css
|
||||
height: 4px; /* Reduced from 6px */
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
```
|
||||
|
||||
**Text**:
|
||||
```css
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
```
|
||||
|
||||
#### Terms Checkbox
|
||||
|
||||
**Hit Area**:
|
||||
```css
|
||||
min-height: 44px;
|
||||
padding: 8px 0;
|
||||
```
|
||||
|
||||
**Text**:
|
||||
```css
|
||||
font-size: 13px; /* Slightly smaller */
|
||||
line-height: 1.5;
|
||||
```
|
||||
|
||||
#### Screenshot
|
||||

|
||||
*TODO: Add screenshot*
|
||||
|
||||
---
|
||||
|
||||
### Forgot Password Page (`/forgot-password`)
|
||||
|
||||
**Same mobile specs as Login**
|
||||
|
||||
#### Success State
|
||||
|
||||
**Message Box**:
|
||||
```css
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
```
|
||||
|
||||
#### Screenshot
|
||||

|
||||
*TODO: Add screenshot*
|
||||
|
||||
---
|
||||
|
||||
## 📐 Grid & Spacing
|
||||
|
||||
### 8-Point Grid (Mobile Adjusted)
|
||||
|
||||
**Base unit**: 4px
|
||||
|
||||
**Common spacing**:
|
||||
- Extra small: `4px` (spacing-1)
|
||||
- Small: `8px` (spacing-2)
|
||||
- Medium: `12px` (spacing-3)
|
||||
- Default: `16px` (spacing-4)
|
||||
- Large: `20px` (spacing-5)
|
||||
- Extra large: `24px` (spacing-6)
|
||||
|
||||
**Container Padding**:
|
||||
- Side padding: `16px` (px-4)
|
||||
- Top/bottom: `16px` (py-4)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Typography Scale (Mobile)
|
||||
|
||||
| Element | Size | Weight | Line Height | Letter Spacing |
|
||||
|---------|------|--------|-------------|----------------|
|
||||
| H1 | 28px | 800 | 1.2 | -0.02em |
|
||||
| H2 | 24px | 700 | 1.25 | -0.01em |
|
||||
| H3 | 20px | 600 | 1.3 | 0 |
|
||||
| Body | 16px | 400 | 1.5 | 0 |
|
||||
| Small | 14px | 400 | 1.5 | 0 |
|
||||
| Tiny | 12px | 400 | 1.4 | 0 |
|
||||
|
||||
**Important**: All input fields MUST use `font-size: 16px` to prevent iOS auto-zoom.
|
||||
|
||||
---
|
||||
|
||||
## 🔘 Interactive Elements
|
||||
|
||||
### Touch Targets
|
||||
|
||||
**Minimum Sizes**:
|
||||
- Buttons: `48x48px` or `100% width x 52px`
|
||||
- Links: `44px` min-height
|
||||
- Checkboxes: `24x24px`
|
||||
- Radio buttons: `24x24px`
|
||||
- Toggle switches: `52x32px`
|
||||
|
||||
**Spacing**:
|
||||
- Between buttons: `12px`
|
||||
- Button to text: `8px`
|
||||
- Icon to text: `8px`
|
||||
|
||||
### Button Variations
|
||||
|
||||
**Primary Button (Sign In)**:
|
||||
```css
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
```
|
||||
|
||||
**Secondary Button** (if needed):
|
||||
```css
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
```
|
||||
|
||||
**Link Button**:
|
||||
```css
|
||||
min-height: 44px;
|
||||
padding: 10px 0;
|
||||
display: inline-block;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ Virtual Keyboard Handling
|
||||
|
||||
### iOS Safari
|
||||
|
||||
**Issue**: Keyboard covers bottom of viewport
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Input Focus**:
|
||||
```javascript
|
||||
// Scroll input into view
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
```
|
||||
|
||||
2. **Form Position**:
|
||||
```css
|
||||
/* Keep submit button visible */
|
||||
.form-container {
|
||||
margin-bottom: 300px; /* Space for keyboard */
|
||||
}
|
||||
```
|
||||
|
||||
3. **Viewport Units**:
|
||||
```css
|
||||
/* Avoid 100vh on mobile */
|
||||
min-height: 100dvh; /* Dynamic viewport height */
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
**Input Types**:
|
||||
```html
|
||||
<!-- Show correct keyboard -->
|
||||
<input type="email" inputmode="email" />
|
||||
<input type="tel" inputmode="tel" />
|
||||
<input type="number" inputmode="numeric" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Images & Assets
|
||||
|
||||
### Icon Sizes (Mobile)
|
||||
|
||||
| Element | Size | Format |
|
||||
|---------|------|--------|
|
||||
| Logo | 40x40px | SVG |
|
||||
| Form icons | 20x20px | SVG |
|
||||
| Success/Error icons | 20x20px | SVG |
|
||||
| Menu icons | 24x24px | SVG |
|
||||
|
||||
### Image Export
|
||||
|
||||
**Density**:
|
||||
- Standard: @1x
|
||||
- Retina: @2x
|
||||
- Super Retina: @3x
|
||||
|
||||
**Format**:
|
||||
- Icons: SVG (preferred)
|
||||
- Photos: WebP or JPEG
|
||||
- Transparency: PNG
|
||||
|
||||
---
|
||||
|
||||
## 🎭 Animations (Mobile)
|
||||
|
||||
### Reduced Motion
|
||||
|
||||
**Respect User Preference**:
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Performance
|
||||
|
||||
**Avoid**:
|
||||
- Heavy blur effects (< 8px ok)
|
||||
- Too many simultaneous animations
|
||||
- Animating `width`, `height`, `top`, `left`
|
||||
|
||||
**Use**:
|
||||
- `transform` (translateY, scale)
|
||||
- `opacity`
|
||||
- Hardware acceleration: `will-change: transform`
|
||||
|
||||
### Transitions
|
||||
|
||||
**Fast**:
|
||||
```css
|
||||
transition: all 150ms cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
```
|
||||
|
||||
**Normal**:
|
||||
```css
|
||||
transition: all 300ms cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance Targets (Mobile)
|
||||
|
||||
### Load Times
|
||||
- First Contentful Paint: < 1.5s
|
||||
- Time to Interactive: < 2.5s
|
||||
- Total page weight: < 500KB
|
||||
|
||||
### Lighthouse Scores
|
||||
- Performance: ≥ 85
|
||||
- Accessibility: ≥ 95
|
||||
- Best Practices: ≥ 90
|
||||
|
||||
---
|
||||
|
||||
## ♿ Mobile Accessibility
|
||||
|
||||
### Touch Targets
|
||||
- Minimum: `44x44px`
|
||||
- Ideal: `48x48px`
|
||||
- Spacing: `8px` between targets
|
||||
|
||||
### Text Sizing
|
||||
- All text scalable (no `font-size: 16px !important`)
|
||||
- Test with iOS text size settings
|
||||
- Test with Android display size settings
|
||||
|
||||
### Screen Readers
|
||||
- iOS VoiceOver compatible
|
||||
- Android TalkBack compatible
|
||||
- Proper heading hierarchy
|
||||
- Form labels associated
|
||||
|
||||
### Focus Management
|
||||
- Visible focus indicators
|
||||
- Focus not trapped unintentionally
|
||||
- Logical tab order
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
### Devices to Test
|
||||
|
||||
**iOS**:
|
||||
- [ ] iPhone 14 Pro (393x852)
|
||||
- [ ] iPhone 14 (390x844)
|
||||
- [ ] iPhone SE (375x667)
|
||||
|
||||
**Android**:
|
||||
- [ ] Samsung Galaxy S21 (360x800)
|
||||
- [ ] Google Pixel 7 (412x915)
|
||||
|
||||
### Orientations
|
||||
- [ ] Portrait (primary)
|
||||
- [ ] Landscape (secondary)
|
||||
|
||||
### Actions
|
||||
- [ ] Form filling with virtual keyboard
|
||||
- [ ] Submit form
|
||||
- [ ] Navigate between fields (Tab)
|
||||
- [ ] Tap all interactive elements
|
||||
- [ ] Zoom in/out (pinch)
|
||||
- [ ] Scroll form
|
||||
- [ ] Theme toggle
|
||||
- [ ] Language switcher
|
||||
|
||||
### Edge Cases
|
||||
- [ ] Long email addresses
|
||||
- [ ] Small screen (320px width)
|
||||
- [ ] Large text size (iOS settings)
|
||||
- [ ] Slow network (3G simulation)
|
||||
- [ ] Offline mode
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Common Mobile Issues & Solutions
|
||||
|
||||
### Issue 1: Input Zoom on iOS
|
||||
**Problem**: Safari zooms in when focusing input < 16px
|
||||
**Solution**: Always use `font-size: 16px` for inputs
|
||||
|
||||
### Issue 2: Keyboard Covers Submit Button
|
||||
**Problem**: Virtual keyboard hides button
|
||||
**Solution**: Add bottom margin or use `scrollIntoView()`
|
||||
|
||||
### Issue 3: Touch Targets Too Small
|
||||
**Problem**: Users miss buttons
|
||||
**Solution**: Minimum `44x44px`, ideally `48x48px`
|
||||
|
||||
### Issue 4: Viewport Height Issues
|
||||
**Problem**: `100vh` includes browser chrome
|
||||
**Solution**: Use `100dvh` (dynamic viewport height)
|
||||
|
||||
### Issue 5: Click Delay
|
||||
**Problem**: 300ms delay on iOS
|
||||
**Solution**: Add `<meta name="viewport" content="width=device-width">`
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Mobile-Specific Patterns
|
||||
|
||||
### Form Layout
|
||||
|
||||
**Stack Vertically**:
|
||||
```
|
||||
[Logo]
|
||||
[Heading]
|
||||
[Subheading]
|
||||
|
||||
[Email Label]
|
||||
[Email Input]
|
||||
|
||||
[Password Label]
|
||||
[Password Input]
|
||||
|
||||
[Remember Me] [Forgot Password]
|
||||
|
||||
[Submit Button]
|
||||
|
||||
[Sign Up Link]
|
||||
```
|
||||
|
||||
### Error Messages
|
||||
|
||||
**Position**: Below field (not inline)
|
||||
**Icon**: Left of text
|
||||
**Color**: Red with light background
|
||||
**Dismissible**: Tap X to close
|
||||
|
||||
### Loading States
|
||||
|
||||
**Full Screen Overlay** (for page transitions):
|
||||
```css
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(21, 32, 43, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 9999;
|
||||
```
|
||||
|
||||
**Inline** (for button):
|
||||
- Show spinner in button
|
||||
- Disable form
|
||||
- Keep button in place (no layout shift)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- [Tablet Specs](./tablet-specs.md)
|
||||
- [Desktop Specs](./desktop-specs.md)
|
||||
- [Design System](../../DESIGN_SYSTEM.md)
|
||||
- [Auth Mockups](../mockups/auth-login-states.md)
|
||||
|
||||
---
|
||||
|
||||
**Designer**: [Name]
|
||||
**Last Updated**: 2026-01-04
|
||||
**Version**: 2.0 (X.ai Minimal Redesign)
|
||||
@@ -1,393 +0,0 @@
|
||||
# User Flow: Login
|
||||
|
||||
> **Goal**: Người dùng đăng nhập thành công vào hệ thống
|
||||
|
||||
## 📋 Flow Overview
|
||||
|
||||
**Entry Points**:
|
||||
- Direct URL: `/login`
|
||||
- From landing page
|
||||
- After logout
|
||||
- After session expiry
|
||||
- From protected route redirect
|
||||
|
||||
**Exit Points**:
|
||||
- Success → Dashboard (`/`)
|
||||
- Forgot Password → `/forgot-password`
|
||||
- Sign Up → `/register`
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Entry Point │
|
||||
└──────┬──────┘
|
||||
│
|
||||
v
|
||||
┌──────────────────────────────┐
|
||||
│ Login Page Loads │
|
||||
│ - Email input (empty) │
|
||||
│ - Password input (empty) │
|
||||
│ - Remember me (unchecked) │
|
||||
│ - Theme/Language controls │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
v
|
||||
┌──────────────────────────────┐
|
||||
│ User Enters Email │
|
||||
│ - Validation on blur │
|
||||
│ - Error if invalid format │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
v
|
||||
┌──────────────────────────────┐
|
||||
│ User Enters Password │
|
||||
│ - Show/hide toggle │
|
||||
│ - Validation on blur │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
v
|
||||
┌──────────────────────────────┐
|
||||
│ User Clicks "Sign In" │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
v
|
||||
┌──────────────────────────────┐
|
||||
│ Client-side Validation │
|
||||
│ - Check all fields filled │
|
||||
│ - Check valid email format │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
├─ Invalid → Show errors inline
|
||||
│
|
||||
v Valid
|
||||
┌──────────────────────────────┐
|
||||
│ Show Loading State │
|
||||
│ - Disable button │
|
||||
│ - Show spinner │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
v
|
||||
┌──────────────────────────────┐
|
||||
│ Send API Request │
|
||||
│ POST /api/auth/login │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
├─────────────────┬──────────────────┐
|
||||
│ │ │
|
||||
v Success v Invalid Creds v Server Error
|
||||
┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ Save Token │ │ Show Error: │ │ Show Error: │
|
||||
│ Set Cookie │ │ "Invalid email │ │ "Something went │
|
||||
│ Update Auth │ │ or password" │ │ wrong" │
|
||||
│ State │ │ - Clear password │ │ - Keep form data │
|
||||
└──────┬──────┘ │ - Focus email │ │ - Enable retry │
|
||||
│ └──────────────────┘ └──────────────────┘
|
||||
v
|
||||
┌─────────────┐
|
||||
│ Redirect to │
|
||||
│ Dashboard │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Detailed Steps
|
||||
|
||||
### Step 1: Page Load
|
||||
**Action**: User lands on `/login`
|
||||
|
||||
**UI State**:
|
||||
- Clean form with empty fields
|
||||
- "Sign In" button enabled
|
||||
- Theme toggle (top-right)
|
||||
- Language switcher (top-right)
|
||||
|
||||
**Technical**:
|
||||
- Check if already authenticated → redirect to `/`
|
||||
- Load theme preference from localStorage
|
||||
- Load language preference from localStorage
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Email Input
|
||||
**Action**: User types email address
|
||||
|
||||
**Validation** (on blur):
|
||||
- ✅ Required field
|
||||
- ✅ Valid email format (`/^[^\s@]+@[^\s@]+\.[^\s@]+$/`)
|
||||
|
||||
**Error Messages**:
|
||||
- Empty: "Email is required"
|
||||
- Invalid: "Please enter a valid email address"
|
||||
|
||||
**UI Behavior**:
|
||||
- Focus ring: X.ai blue (#1D9BF0)
|
||||
- Error: Red border + error text below
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Password Input
|
||||
**Action**: User types password
|
||||
|
||||
**Features**:
|
||||
- Password visibility toggle (eye icon)
|
||||
- Masked by default
|
||||
|
||||
**Validation** (on blur):
|
||||
- ✅ Required field
|
||||
- ✅ Minimum length (8 characters)
|
||||
|
||||
**Error Messages**:
|
||||
- Empty: "Password is required"
|
||||
- Too short: "Password must be at least 8 characters"
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Remember Me (Optional)
|
||||
**Action**: User checks "Remember me"
|
||||
|
||||
**Behavior**:
|
||||
- If checked: Session persists 30 days
|
||||
- If unchecked: Session expires on browser close
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Form Submission
|
||||
**Action**: User clicks "Sign In" button
|
||||
|
||||
**Client-side Validation**:
|
||||
```typescript
|
||||
// Validation rules
|
||||
{
|
||||
email: {
|
||||
required: true,
|
||||
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
},
|
||||
password: {
|
||||
required: true,
|
||||
minLength: 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**If Invalid**:
|
||||
- Show all errors
|
||||
- Focus first invalid field
|
||||
- Don't send API request
|
||||
|
||||
**If Valid**:
|
||||
- Proceed to API call
|
||||
|
||||
---
|
||||
|
||||
### Step 6: Loading State
|
||||
**UI Changes**:
|
||||
- Button shows spinner
|
||||
- Button text: "Signing in..."
|
||||
- Button disabled
|
||||
- Form fields disabled
|
||||
|
||||
**Duration**: Until API responds (typically 500ms-2s)
|
||||
|
||||
---
|
||||
|
||||
### Step 7: API Response Handling
|
||||
|
||||
#### ✅ Success (200 OK)
|
||||
**API Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"token": "jwt_token_here",
|
||||
"user": {
|
||||
"id": "123",
|
||||
"email": "user@example.com",
|
||||
"name": "John Doe"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Actions**:
|
||||
1. Save token to localStorage/cookie
|
||||
2. Update auth state (Zustand)
|
||||
3. Show success toast (optional)
|
||||
4. Redirect to `/` (dashboard)
|
||||
|
||||
---
|
||||
|
||||
#### ❌ Invalid Credentials (401 Unauthorized)
|
||||
**API Response**:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "INVALID_CREDENTIALS",
|
||||
"message": "Invalid email or password"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Actions**:
|
||||
1. Show error message below form
|
||||
2. Clear password field
|
||||
3. Focus email field
|
||||
4. Keep email value
|
||||
5. Enable retry
|
||||
|
||||
**Error Display**:
|
||||
```
|
||||
⚠️ Invalid email or password. Please try again.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### ⚠️ Account Locked (403 Forbidden)
|
||||
**API Response**:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ACCOUNT_LOCKED",
|
||||
"message": "Too many failed attempts. Try again in 15 minutes."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Actions**:
|
||||
1. Show specific error
|
||||
2. Disable form for 15 minutes (optional)
|
||||
3. Show countdown timer
|
||||
|
||||
---
|
||||
|
||||
#### 💥 Server Error (500)
|
||||
**API Response**:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "SERVER_ERROR",
|
||||
"message": "Something went wrong. Please try again."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Actions**:
|
||||
1. Show generic error message
|
||||
2. Keep form data
|
||||
3. Enable retry button
|
||||
|
||||
---
|
||||
|
||||
## 🔀 Alternative Paths
|
||||
|
||||
### Path A: Forgot Password
|
||||
**Trigger**: User clicks "Forgot password?" link
|
||||
|
||||
**Action**: Navigate to `/forgot-password`
|
||||
|
||||
---
|
||||
|
||||
### Path B: Sign Up
|
||||
**Trigger**: User clicks "Don't have an account? Sign up" link
|
||||
|
||||
**Action**: Navigate to `/register`
|
||||
|
||||
---
|
||||
|
||||
### Path C: Social Login (Future)
|
||||
**Trigger**: User clicks "Continue with Google" button
|
||||
|
||||
**Action**:
|
||||
1. Open OAuth popup
|
||||
2. Handle callback
|
||||
3. Same success flow as email/password
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
Flow is successful when:
|
||||
- ✅ User is authenticated
|
||||
- ✅ Token is stored
|
||||
- ✅ User is redirected to dashboard
|
||||
- ✅ Dashboard loads with user data
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
**Functional Tests**:
|
||||
- [ ] Can submit with valid credentials
|
||||
- [ ] Cannot submit with empty fields
|
||||
- [ ] Cannot submit with invalid email
|
||||
- [ ] Error shown for wrong password
|
||||
- [ ] Loading state appears during API call
|
||||
- [ ] Remember me persists session
|
||||
- [ ] Forgot password link works
|
||||
- [ ] Sign up link works
|
||||
|
||||
**Edge Cases**:
|
||||
- [ ] Already authenticated user redirected
|
||||
- [ ] Network error handled gracefully
|
||||
- [ ] API timeout handled (> 10s)
|
||||
- [ ] Special characters in password work
|
||||
- [ ] Copy-paste email works
|
||||
- [ ] Browser autofill works
|
||||
|
||||
**Accessibility**:
|
||||
- [ ] Keyboard navigation works (Tab, Enter)
|
||||
- [ ] Screen reader announces errors
|
||||
- [ ] Focus management correct
|
||||
- [ ] Error messages have role="alert"
|
||||
|
||||
**Performance**:
|
||||
- [ ] Page loads < 1s
|
||||
- [ ] API response < 2s
|
||||
- [ ] No layout shift during loading
|
||||
|
||||
---
|
||||
|
||||
## 📱 Responsive Behavior
|
||||
|
||||
**Mobile (< 640px)**:
|
||||
- Stack form vertically
|
||||
- Full-width button
|
||||
- Touch-friendly input size (min 44px height)
|
||||
- Virtual keyboard doesn't hide submit button
|
||||
|
||||
**Tablet (640-1024px)**:
|
||||
- Same as mobile, centered
|
||||
|
||||
**Desktop (> 1024px)**:
|
||||
- Centered form (max-width: 448px)
|
||||
- Ample padding around form
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Figma Prototype
|
||||
|
||||
[Add link to Figma prototype here]
|
||||
|
||||
---
|
||||
|
||||
## 📊 Analytics Events
|
||||
|
||||
Track these events:
|
||||
- `login_page_viewed`
|
||||
- `login_form_submitted`
|
||||
- `login_success`
|
||||
- `login_failed` (with error code)
|
||||
- `forgot_password_clicked`
|
||||
- `sign_up_clicked`
|
||||
|
||||
---
|
||||
|
||||
**Maintained by**: UX Team
|
||||
**Last Updated**: 2026-01-04
|
||||
**Version**: 2.0 (X.ai Minimal Redesign)
|
||||
@@ -1,109 +0,0 @@
|
||||
# GoodGo Web Client - Sitemap
|
||||
|
||||
> **Information Architecture** - Cấu trúc tổng quan của ứng dụng
|
||||
>
|
||||
> Last Updated: 2026-01-04
|
||||
|
||||
## 🗺️ Application Structure
|
||||
|
||||
```
|
||||
GoodGo Web Client
|
||||
│
|
||||
├── 🔐 Authentication (Unauthenticated)
|
||||
│ ├── /login - Login Page
|
||||
│ │ ├── Email input
|
||||
│ │ ├── Password input
|
||||
│ │ ├── Remember me checkbox
|
||||
│ │ ├── Forgot password link → /forgot-password
|
||||
│ │ └── Sign up link → /register
|
||||
│ │
|
||||
│ ├── /register - Registration Page
|
||||
│ │ ├── Email input
|
||||
│ │ ├── Password input (with strength indicator)
|
||||
│ │ ├── Confirm password input
|
||||
│ │ ├── Terms & conditions checkbox
|
||||
│ │ └── Sign in link → /login
|
||||
│ │
|
||||
│ └── /forgot-password - Password Reset
|
||||
│ ├── Email input
|
||||
│ ├── Success state (email sent)
|
||||
│ └── Back to login link → /login
|
||||
│
|
||||
├── 🏠 Dashboard (Authenticated) - /
|
||||
│ ├── Overview section
|
||||
│ ├── Analytics widgets
|
||||
│ ├── Quick actions
|
||||
│ └── Navigation to other sections
|
||||
│
|
||||
├── 👤 Profile - /profile
|
||||
│ ├── User information
|
||||
│ ├── Settings
|
||||
│ │ ├── Theme toggle (Light/Dark)
|
||||
│ │ ├── Language switcher (EN/VI)
|
||||
│ │ └── Other preferences
|
||||
│ └── Account security
|
||||
│
|
||||
└── [Add more sections as they're developed]
|
||||
|
||||
```
|
||||
|
||||
## 📱 Mobile Navigation
|
||||
|
||||
Mobile navigation should include:
|
||||
- Hamburger menu icon (top-left or top-right)
|
||||
- Collapsible sidebar
|
||||
- Bottom navigation bar (optional, for key actions)
|
||||
|
||||
## 🎨 Visual Hierarchy
|
||||
|
||||
1. **Level 1**: Authentication pages (public)
|
||||
2. **Level 2**: Main dashboard (authenticated)
|
||||
3. **Level 3**: Feature-specific pages
|
||||
4. **Level 4**: Settings & profile
|
||||
|
||||
## 🔗 External Links
|
||||
|
||||
- Help Center (future)
|
||||
- Privacy Policy (future)
|
||||
- Terms of Service (future)
|
||||
|
||||
## 📝 Navigation Patterns
|
||||
|
||||
### Primary Navigation
|
||||
- Logo (top-left) → Dashboard
|
||||
- Main menu items (horizontal or sidebar)
|
||||
- User menu (top-right)
|
||||
- Profile
|
||||
- Settings
|
||||
- Logout
|
||||
|
||||
### Secondary Navigation
|
||||
- Breadcrumbs (for deep pages)
|
||||
- Tabs (for section switching)
|
||||
- Back buttons (for flows)
|
||||
|
||||
## 🚀 Future Additions
|
||||
|
||||
Document new pages/sections here as they're added:
|
||||
|
||||
- [ ] [Feature Name] - /route
|
||||
- [ ] [Feature Name] - /route
|
||||
|
||||
## 🔍 SEO & Meta
|
||||
|
||||
Each page should have:
|
||||
- Unique title tag
|
||||
- Meta description
|
||||
- Open Graph tags (for social sharing)
|
||||
|
||||
## 📊 Analytics Tracking
|
||||
|
||||
Pages to track:
|
||||
- All auth pages (login, register, forgot-password)
|
||||
- Dashboard landing
|
||||
- Conversion funnels (signup → onboarding)
|
||||
|
||||
---
|
||||
|
||||
**Maintained by**: Design Team
|
||||
**Review frequency**: Monthly or when major features added
|
||||
@@ -1,610 +0,0 @@
|
||||
# Authentication Pages - Wireframes
|
||||
|
||||
> **UX Wireframe Documentation**
|
||||
>
|
||||
> Low và Mid-fidelity wireframes cho 3 trang xác thực
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
**Pages**: Login, Register, Forgot Password
|
||||
**Purpose**: Visual structure trước khi design chi tiết
|
||||
**Tools**: Figma, Sketch, or hand-drawn
|
||||
**Last Updated**: 2026-01-04
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Wireframe Levels
|
||||
|
||||
### Low-Fidelity (Lo-Fi)
|
||||
**Purpose**: Validate layout và information hierarchy
|
||||
**Details**: Boxes, lines, placeholder text
|
||||
**Colors**: Grayscale only (no brand colors)
|
||||
**Time**: Quick sketches (15-30 mins per page)
|
||||
|
||||
### Mid-Fidelity (Mid-Fi)
|
||||
**Purpose**: Define spacing, sizing, content
|
||||
**Details**: Actual text, proper sizing, grid system
|
||||
**Colors**: Still grayscale, but more refined
|
||||
**Time**: Detailed mockups (1-2 hours per page)
|
||||
|
||||
---
|
||||
|
||||
## 📄 Page 1: Login
|
||||
|
||||
### Low-Fidelity Wireframe
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ │ Theme/Lang
|
||||
│ [App Icon/Logo] │ ◐ EN▼
|
||||
│ │
|
||||
│ Sign In │
|
||||
│ Subtitle text here │
|
||||
│ │
|
||||
│ Email address │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ your@email.com │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ Password │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ •••••••• 👁 │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ☐ Remember me Forgot password│
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Sign In │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ Don't have an account? Sign up │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
Annotations:
|
||||
- Container: 448px max-width, centered
|
||||
- Vertical spacing: 24px between sections
|
||||
- Input height: 44px
|
||||
- Button height: 48px
|
||||
```
|
||||
|
||||
**Figma Link**: [Add lo-fi wireframe link]
|
||||
|
||||
**Screenshot**:
|
||||

|
||||
*TODO: Add wireframe screenshot*
|
||||
|
||||
---
|
||||
|
||||
### Mid-Fidelity Wireframe
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ │ [Theme] [Lang]
|
||||
│ ┌────────┐ │
|
||||
│ │ [★] │ App Icon │
|
||||
│ └────────┘ 56x56px │
|
||||
│ │
|
||||
│ Sign in to your account │ 36px, Bold
|
||||
│ Enter your credentials to access... │ 14px, Gray
|
||||
│ │
|
||||
│ │
|
||||
│ Email address │ 14px label
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ you@example.com │ │ 16px text
|
||||
│ └──────────────────────────────────┘ │ 44px height
|
||||
│ │
|
||||
│ │ 16px gap
|
||||
│ Password │ 14px label
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ •••••••• 👁 │ │ 16px text
|
||||
│ └──────────────────────────────────┘ │ 44px height
|
||||
│ │
|
||||
│ │ 12px gap
|
||||
│ ☐ Remember me Forgot password?│ 14px
|
||||
│ │
|
||||
│ │ 24px gap
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ Sign in │ │ 16px, 600 weight
|
||||
│ └──────────────────────────────────┘ │ 48px height
|
||||
│ │
|
||||
│ │ 16px gap
|
||||
│ Don't have an account? Sign up │ 14px
|
||||
│ ──────────────────── ─────── │ Blue underline
|
||||
│ │
|
||||
└────────────────────────────────────────────┘
|
||||
|
||||
Measurements:
|
||||
- Container: max-width 448px, padding 32px
|
||||
- Form to edges: 32px padding
|
||||
- Label to input: 8px
|
||||
- Input to input: 16px
|
||||
- Checkbox to link: space-between
|
||||
- Button margin-top: 24px
|
||||
|
||||
Grid:
|
||||
- 8-point grid system
|
||||
- Vertical rhythm: 8px base
|
||||
|
||||
Typography:
|
||||
- H1: 36px, extrabold
|
||||
- Subtitle: 14px, regular
|
||||
- Labels: 14px, medium
|
||||
- Inputs: 16px, regular
|
||||
- Button: 16px, semibold
|
||||
- Links: 14px, medium
|
||||
```
|
||||
|
||||
**Figma Link**: [Add mid-fi wireframe link]
|
||||
|
||||
**Screenshot**:
|
||||

|
||||
*TODO: Add wireframe screenshot*
|
||||
|
||||
---
|
||||
|
||||
## 📄 Page 2: Register
|
||||
|
||||
### Low-Fidelity Wireframe
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ │ Theme/Lang
|
||||
│ [App Icon/Logo] │ ◐ EN▼
|
||||
│ │
|
||||
│ Create Account │
|
||||
│ Subtitle text here │
|
||||
│ │
|
||||
│ Email address │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ your@email.com │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ Password │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ •••••••• 👁 │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ Strength: [▓▓▓▓░] Strong │
|
||||
│ │
|
||||
│ Confirm Password │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ •••••••• 👁 │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ☐ I agree to Terms & Conditions │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Sign Up │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ Already have an account? Sign in│
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
Annotations:
|
||||
- Password strength: 4-bar indicator
|
||||
- Confirm password: validates match
|
||||
- Terms checkbox: required
|
||||
- Same spacing as login
|
||||
```
|
||||
|
||||
**Figma Link**: [Add lo-fi wireframe link]
|
||||
|
||||
**Screenshot**:
|
||||

|
||||
*TODO: Add wireframe screenshot*
|
||||
|
||||
---
|
||||
|
||||
### Mid-Fidelity Wireframe
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ │ [Theme] [Lang]
|
||||
│ ┌────────┐ │
|
||||
│ │ [★] │ App Icon │
|
||||
│ └────────┘ 56x56px │
|
||||
│ │
|
||||
│ Create your account │ 36px, Bold
|
||||
│ Get started with your free account │ 14px, Gray
|
||||
│ │
|
||||
│ │
|
||||
│ Email address │ 14px label
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ you@example.com │ │ 16px text
|
||||
│ └──────────────────────────────────┘ │ 44px height
|
||||
│ │
|
||||
│ │ 16px gap
|
||||
│ Password │ 14px label
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ •••••••• 👁 │ │ 16px text
|
||||
│ └──────────────────────────────────┘ │ 44px height
|
||||
│ │
|
||||
│ Password strength │ 12px gray
|
||||
│ ┌──┬──┬──┬──┐ │ 4px height bars
|
||||
│ │▓▓│▓▓│▓▓│░░│ Strong │ Green text
|
||||
│ └──┴──┴──┴──┘ │
|
||||
│ │ 12px gap
|
||||
│ Confirm Password │ 14px label
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ •••••••• 👁 │ │ 16px text
|
||||
│ └──────────────────────────────────┘ │ 44px height
|
||||
│ │
|
||||
│ │ 16px gap
|
||||
│ ☐ I agree to Terms of Service and │ 14px
|
||||
│ Privacy Policy │ Blue links
|
||||
│ │
|
||||
│ │ 24px gap
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ Create Account │ │ 16px, 600 weight
|
||||
│ └──────────────────────────────────┘ │ 48px height
|
||||
│ │
|
||||
│ │ 16px gap
|
||||
│ Already have an account? Sign in │ 14px
|
||||
│ ────────────────────────── ─────── │ Blue underline
|
||||
│ │
|
||||
└────────────────────────────────────────────┘
|
||||
|
||||
Password Strength States:
|
||||
- Weak: 1 bar (Red)
|
||||
- Fair: 2 bars (Orange)
|
||||
- Good: 3 bars (Amber)
|
||||
- Strong: 4 bars (Green)
|
||||
|
||||
Validation:
|
||||
- Email: valid format
|
||||
- Password: min 8 chars, 1 uppercase, 1 number
|
||||
- Confirm: matches password
|
||||
- Terms: must be checked
|
||||
```
|
||||
|
||||
**Figma Link**: [Add mid-fi wireframe link]
|
||||
|
||||
**Screenshot**:
|
||||

|
||||
*TODO: Add wireframe screenshot*
|
||||
|
||||
---
|
||||
|
||||
## 📄 Page 3: Forgot Password
|
||||
|
||||
### Low-Fidelity Wireframe
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ │ Theme/Lang
|
||||
│ [App Icon/Logo] │ ◐ EN▼
|
||||
│ │
|
||||
│ Reset Password │
|
||||
│ Subtitle text here │
|
||||
│ │
|
||||
│ Email address │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ your@email.com │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Send Reset Link │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ← Back to Sign in │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
Success State:
|
||||
┌─────────────────────────────────────┐
|
||||
│ [✓ Icon] │
|
||||
│ │
|
||||
│ Check your email │
|
||||
│ We sent reset link to... │
|
||||
│ │
|
||||
│ [Open Email App] │
|
||||
│ │
|
||||
│ Send to another email │
|
||||
│ ← Back to Sign in │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Figma Link**: [Add lo-fi wireframe link]
|
||||
|
||||
**Screenshot**:
|
||||

|
||||
*TODO: Add wireframe screenshot*
|
||||
|
||||
---
|
||||
|
||||
### Mid-Fidelity Wireframe
|
||||
|
||||
**Initial State**:
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ │ [Theme] [Lang]
|
||||
│ ┌────────┐ │
|
||||
│ │ [★] │ App Icon │
|
||||
│ └────────┘ 56x56px │
|
||||
│ │
|
||||
│ Reset your password │ 36px, Bold
|
||||
│ Enter your email to receive a... │ 14px, Gray
|
||||
│ │
|
||||
│ │
|
||||
│ Email address │ 14px label
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ you@example.com │ │ 16px text
|
||||
│ └──────────────────────────────────┘ │ 44px height
|
||||
│ │
|
||||
│ │ 24px gap
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ Send Reset Link │ │ 16px, 600 weight
|
||||
│ └──────────────────────────────────┘ │ 48px height
|
||||
│ │
|
||||
│ │ 16px gap
|
||||
│ ← Back to Sign in │ 14px, blue
|
||||
│ │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Success State**:
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ │ [Theme] [Lang]
|
||||
│ │
|
||||
│ ┌────────┐ │
|
||||
│ │ [✓] │ Success Icon │
|
||||
│ └────────┘ 56x56px, Green │
|
||||
│ │
|
||||
│ Check your email │ 36px, Bold
|
||||
│ We've sent a password reset link │ 14px, Gray
|
||||
│ to your@example.com │
|
||||
│ │
|
||||
│ │ 24px gap
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ Open Email App │ │ 16px, Blue button
|
||||
│ └──────────────────────────────────┘ │ 48px height
|
||||
│ │
|
||||
│ │ 12px gap
|
||||
│ Didn't receive email? Resend │ 14px
|
||||
│ │
|
||||
│ │ 16px gap
|
||||
│ ← Back to Sign in │ 14px, blue
|
||||
│ │
|
||||
└────────────────────────────────────────────┘
|
||||
|
||||
Notes:
|
||||
- Success icon: Green checkmark circle
|
||||
- Email shown: User's entered email
|
||||
- Open Email App: Deep link to mail client
|
||||
- Resend link: Available after 60 seconds
|
||||
```
|
||||
|
||||
**Figma Link**: [Add mid-fi wireframe link]
|
||||
|
||||
**Screenshot**:
|
||||

|
||||
*TODO: Add wireframe screenshot*
|
||||
|
||||
---
|
||||
|
||||
## 📐 Design Grid & Spacing
|
||||
|
||||
### 8-Point Grid System
|
||||
|
||||
**Base Unit**: 8px
|
||||
|
||||
**Vertical Rhythm**:
|
||||
- Extra small: 4px (0.5 unit)
|
||||
- Small: 8px (1 unit)
|
||||
- Medium: 16px (2 units)
|
||||
- Large: 24px (3 units)
|
||||
- Extra large: 32px (4 units)
|
||||
- Section: 48px (6 units)
|
||||
|
||||
**Horizontal Spacing**:
|
||||
- Container padding: 32px (4 units)
|
||||
- Mobile padding: 16px (2 units)
|
||||
- Element spacing: 8px, 12px, 16px, 24px
|
||||
|
||||
### Layout Grid
|
||||
|
||||
**Desktop (> 1024px)**:
|
||||
- Max-width: 448px
|
||||
- Centered in viewport
|
||||
- Padding: 32px
|
||||
|
||||
**Tablet (640-1024px)**:
|
||||
- Max-width: 448px
|
||||
- Centered
|
||||
- Padding: 24px
|
||||
|
||||
**Mobile (< 640px)**:
|
||||
- Full width
|
||||
- Padding: 16px
|
||||
- Adjust spacing (reduce by 25%)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Wireframe Conventions
|
||||
|
||||
### Visual Elements
|
||||
|
||||
**Boxes**:
|
||||
- Rectangle: Input field, button, container
|
||||
- Rounded rectangle: Button (8px radius)
|
||||
- Circle: Icon, avatar
|
||||
- Line: Separator
|
||||
|
||||
**Text**:
|
||||
- UPPERCASE: Labels, headings
|
||||
- Mixed case: Body text, placeholder
|
||||
- Lorem ipsum: Placeholder content
|
||||
- Actual copy: Mid-fi onwards
|
||||
|
||||
**Icons**:
|
||||
- [X]: Close/remove
|
||||
- [?]: Help/info
|
||||
- [⚙]: Settings
|
||||
- [★]: App icon placeholder
|
||||
- [👁]: Show/hide password
|
||||
- [✓]: Success, checkmark
|
||||
- [⚠]: Warning
|
||||
- [ⓘ]: Information
|
||||
|
||||
**Interaction States**:
|
||||
- Dotted line: Focus state
|
||||
- Gray background: Disabled state
|
||||
- Bold outline: Active/selected state
|
||||
|
||||
---
|
||||
|
||||
## 📱 Responsive Wireframe Variants
|
||||
|
||||
### Mobile Wireframe (< 640px)
|
||||
|
||||
**Changes from Desktop**:
|
||||
- Full-width layout
|
||||
- Reduced padding (16px)
|
||||
- Smaller heading (28px)
|
||||
- Adjusted spacing (reduce 25%)
|
||||
- Stack all elements vertically
|
||||
- Theme/Lang controls: Smaller (36px)
|
||||
|
||||
**Screenshot**:
|
||||

|
||||
*TODO: Add mobile wireframe*
|
||||
|
||||
---
|
||||
|
||||
### Tablet Wireframe (640-1024px)
|
||||
|
||||
**Changes from Desktop**:
|
||||
- Same max-width (448px)
|
||||
- Centered layout
|
||||
- Slightly adjusted padding (24px)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 User Flow Integration
|
||||
|
||||
### Wireframe Flow Diagram
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ Login │────────────┐
|
||||
│ Page │ │
|
||||
└────┬─────┘ │
|
||||
│ ▼
|
||||
│ ┌───────────┐
|
||||
│ │ Forgot │
|
||||
│ │ Password │
|
||||
│ └───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ Register │
|
||||
│ Page │
|
||||
└──────────┘
|
||||
|
||||
Transitions:
|
||||
- Login → Forgot: "Forgot password?" link
|
||||
- Login → Register: "Sign up" link
|
||||
- Forgot → Login: "Back to sign in" link
|
||||
- Register → Login: "Sign in" link
|
||||
```
|
||||
|
||||
### Page States
|
||||
|
||||
**Login**:
|
||||
1. Default (empty form)
|
||||
2. Typing (with values)
|
||||
3. Error (validation failed)
|
||||
4. Loading (submitting)
|
||||
5. Success (redirect)
|
||||
|
||||
**Register**:
|
||||
1. Default (empty form)
|
||||
2. Typing (with password strength)
|
||||
3. Error (validation failed)
|
||||
4. Loading (submitting)
|
||||
5. Success (redirect or confirm email)
|
||||
|
||||
**Forgot Password**:
|
||||
1. Default (empty form)
|
||||
2. Typing (email entered)
|
||||
3. Loading (sending)
|
||||
4. Success (email sent confirmation)
|
||||
5. Error (email not found)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Wireframe Review Checklist
|
||||
|
||||
### Content
|
||||
- [ ] All necessary fields included
|
||||
- [ ] Labels clear and descriptive
|
||||
- [ ] CTAs (Call-to-Actions) obvious
|
||||
- [ ] Error states considered
|
||||
- [ ] Success states designed
|
||||
- [ ] Loading states indicated
|
||||
|
||||
### Layout
|
||||
- [ ] Visual hierarchy clear
|
||||
- [ ] Important elements prominent
|
||||
- [ ] Spacing consistent
|
||||
- [ ] Alignment proper
|
||||
- [ ] Grid system applied
|
||||
- [ ] Responsive variants created
|
||||
|
||||
### Interaction
|
||||
- [ ] Touch targets adequate (44px+)
|
||||
- [ ] Links distinguishable
|
||||
- [ ] Form flow logical
|
||||
- [ ] Error messages positioned well
|
||||
- [ ] Focus states indicated
|
||||
|
||||
### Accessibility
|
||||
- [ ] Heading hierarchy correct (H1, H2, H3)
|
||||
- [ ] Form labels associated with inputs
|
||||
- [ ] Focus order logical
|
||||
- [ ] Color not sole indicator
|
||||
- [ ] Alt text for images/icons (noted)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Next Steps
|
||||
|
||||
After wireframes approved:
|
||||
|
||||
1. **Create High-Fidelity Mockups**:
|
||||
- Apply X.ai color palette
|
||||
- Add glassmorphism effects
|
||||
- Finalize typography
|
||||
- Design all states
|
||||
- See: [Auth Mockups](../mockups/auth-login-states.md)
|
||||
|
||||
2. **Build Interactive Prototype**:
|
||||
- Link pages in Figma
|
||||
- Add transitions
|
||||
- Test user flow
|
||||
- Get stakeholder feedback
|
||||
|
||||
3. **Handoff to Development**:
|
||||
- Export assets
|
||||
- Create specs
|
||||
- Write implementation guide
|
||||
- See: [Implementation Guide](../../implementation/auth-pages-implementation.md)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
- [Login User Flow](../flows/auth-login.md)
|
||||
- [Sitemap](../sitemap.md)
|
||||
- [High-Fi Mockups](../../ui/mockups/auth-login-states.md)
|
||||
- [Mobile Specs](../../ui/responsive/mobile-specs.md)
|
||||
- [Design System](../../DESIGN_SYSTEM.md)
|
||||
|
||||
---
|
||||
|
||||
**Created by**: UX Team
|
||||
**Last Updated**: 2026-01-04
|
||||
**Version**: 2.0 (X.ai Minimal Redesign)
|
||||
**Status**: Ready for High-Fi Design
|
||||
@@ -1,59 +0,0 @@
|
||||
'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 md:max-w-[400px] 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="text-center text-sm text-text-secondary mt-8">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* EN: Authentication Feature Public API
|
||||
* VI: Public API của Feature Authentication
|
||||
*
|
||||
* This file exports the public API of the authentication feature.
|
||||
* File này export public API của feature authentication.
|
||||
*/
|
||||
|
||||
// Components
|
||||
// export * from './components/login-form';
|
||||
// export * from './components/register-form';
|
||||
// export * from './components/forgot-password-form';
|
||||
|
||||
// Hooks
|
||||
// export * from './hooks/use-auth';
|
||||
|
||||
// Stores
|
||||
// export { useAuthStore } from './stores/auth.store';
|
||||
|
||||
// Types
|
||||
// export type * from './types/auth.types';
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* EN: Auth feature library exports
|
||||
* VI: Xuất thư viện của feature auth
|
||||
*/
|
||||
|
||||
export * from './oauth';
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* EN: OAuth utility functions for social authentication
|
||||
* VI: Các hàm tiện ích OAuth cho xác thực mạng xã hội
|
||||
*/
|
||||
|
||||
/**
|
||||
* EN: Get API base URL from environment
|
||||
* VI: Lấy API base URL từ environment
|
||||
*/
|
||||
const getApiBaseUrl = (): string => {
|
||||
return process.env.NEXT_PUBLIC_API_URL || 'http://localhost/api/v1';
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: OAuth provider types
|
||||
* VI: Các loại provider OAuth
|
||||
*/
|
||||
export type OAuthProvider = 'google' | 'facebook' | 'github';
|
||||
|
||||
/**
|
||||
* EN: Initiate OAuth flow by redirecting to backend OAuth endpoint
|
||||
* VI: Bắt đầu luồng OAuth bằng cách chuyển hướng đến endpoint OAuth của backend
|
||||
*
|
||||
* @param provider - OAuth provider (google, facebook, github)
|
||||
* @param redirectUrl - Optional redirect URL after successful authentication
|
||||
*/
|
||||
export const initiateOAuth = (provider: OAuthProvider, redirectUrl?: string): void => {
|
||||
// EN: Store redirect URL in sessionStorage for use after callback
|
||||
// VI: Lưu redirect URL vào sessionStorage để sử dụng sau callback
|
||||
if (redirectUrl && typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('oauth_redirect', redirectUrl);
|
||||
}
|
||||
|
||||
// EN: Build OAuth initiation URL
|
||||
// VI: Xây dựng URL bắt đầu OAuth
|
||||
const apiBaseUrl = getApiBaseUrl();
|
||||
const oauthUrl = `${apiBaseUrl}/auth/social/${provider}`;
|
||||
|
||||
// EN: Redirect to backend OAuth endpoint
|
||||
// VI: Chuyển hướng đến endpoint OAuth của backend
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = oauthUrl;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Handle OAuth callback by extracting token from URL
|
||||
* VI: Xử lý OAuth callback bằng cách trích xuất token từ URL
|
||||
*
|
||||
* @param token - Access token from OAuth callback
|
||||
* @returns Access token string
|
||||
*/
|
||||
export const handleOAuthCallback = (token: string): string => {
|
||||
return token;
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Handle OAuth error by extracting error message from URL
|
||||
* VI: Xử lý lỗi OAuth bằng cách trích xuất thông báo lỗi từ URL
|
||||
*
|
||||
* @param errorMessage - Error message from OAuth callback
|
||||
* @returns Error message string
|
||||
*/
|
||||
export const handleOAuthError = (errorMessage: string): string => {
|
||||
return decodeURIComponent(errorMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Get stored redirect URL from sessionStorage
|
||||
* VI: Lấy redirect URL đã lưu từ sessionStorage
|
||||
*
|
||||
* @returns Redirect URL or null
|
||||
*/
|
||||
export const getStoredRedirectUrl = (): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const redirectUrl = sessionStorage.getItem('oauth_redirect');
|
||||
if (redirectUrl) {
|
||||
sessionStorage.removeItem('oauth_redirect');
|
||||
}
|
||||
return redirectUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Google OAuth initiation helper
|
||||
* VI: Helper để bắt đầu OAuth Google
|
||||
*
|
||||
* @param redirectUrl - Optional redirect URL after successful authentication
|
||||
*/
|
||||
export const signInWithGoogle = (redirectUrl?: string): void => {
|
||||
initiateOAuth('google', redirectUrl);
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Facebook OAuth initiation helper
|
||||
* VI: Helper để bắt đầu OAuth Facebook
|
||||
*
|
||||
* @param redirectUrl - Optional redirect URL after successful authentication
|
||||
*/
|
||||
export const signInWithFacebook = (redirectUrl?: string): void => {
|
||||
initiateOAuth('facebook', redirectUrl);
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: GitHub OAuth initiation helper
|
||||
* VI: Helper để bắt đầu OAuth GitHub
|
||||
*
|
||||
* @param redirectUrl - Optional redirect URL after successful authentication
|
||||
*/
|
||||
export const signInWithGitHub = (redirectUrl?: string): void => {
|
||||
initiateOAuth('github', redirectUrl);
|
||||
};
|
||||
@@ -1,300 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { Button } from '@/features/shared/components/ui/button';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* EN: ChatInput component props interface
|
||||
* VI: Interface cho props của component ChatInput
|
||||
*/
|
||||
export interface ChatInputProps {
|
||||
/**
|
||||
* EN: Current input value / VI: Giá trị input hiện tại
|
||||
*/
|
||||
value?: string;
|
||||
/**
|
||||
* EN: Callback when input value changes / VI: Callback khi giá trị input thay đổi
|
||||
*/
|
||||
onChange?: (value: string) => void;
|
||||
/**
|
||||
* EN: Callback when send button is clicked or Enter is pressed / VI: Callback khi nút send được click hoặc Enter được nhấn
|
||||
*/
|
||||
onSend?: (message: string) => void;
|
||||
/**
|
||||
* EN: Callback when attach file button is clicked / VI: Callback khi nút attach file được click
|
||||
*/
|
||||
onAttachFile?: () => void;
|
||||
/**
|
||||
* EN: Placeholder text / VI: Text placeholder
|
||||
*/
|
||||
placeholder?: string;
|
||||
/**
|
||||
* EN: Disabled state / VI: Trạng thái vô hiệu hóa
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* EN: Loading state - shows spinner on send button / VI: Trạng thái loading - hiển thị spinner trên nút send
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* EN: Maximum height for textarea before scrolling / VI: Chiều cao tối đa cho textarea trước khi scroll
|
||||
*/
|
||||
maxHeight?: number;
|
||||
/**
|
||||
* EN: Minimum height for textarea / VI: Chiều cao tối thiểu cho textarea
|
||||
*/
|
||||
minHeight?: number;
|
||||
/**
|
||||
* EN: Additional CSS classes / VI: Các class CSS bổ sung
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: ChatInput component - Textarea input with auto-resize, attach file button, and send button
|
||||
* VI: Component ChatInput - Textarea input với auto-resize, nút attach file và nút send
|
||||
*
|
||||
* Features:
|
||||
* - Auto-resize textarea based on content
|
||||
* - Keyboard shortcuts: Enter to send, Shift+Enter for new line
|
||||
* - Attach file button (optional)
|
||||
* - Send button with loading state
|
||||
* - Follows design system theme colors
|
||||
*
|
||||
* Tính năng:
|
||||
* - Textarea tự động thay đổi kích thước dựa trên nội dung
|
||||
* - Phím tắt: Enter để gửi, Shift+Enter để xuống dòng
|
||||
* - Nút attach file (tùy chọn)
|
||||
* - Nút send với trạng thái loading
|
||||
* - Tuân theo màu sắc của design system
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ChatInput
|
||||
* value={message}
|
||||
* onChange={setMessage}
|
||||
* onSend={handleSend}
|
||||
* onAttachFile={handleAttachFile}
|
||||
* placeholder="Type your message..."
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ChatInput({
|
||||
value: externalValue,
|
||||
onChange,
|
||||
onSend,
|
||||
onAttachFile,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
maxHeight = 200,
|
||||
minHeight = 44,
|
||||
className,
|
||||
}: ChatInputProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
const defaultPlaceholder = placeholder || t('chat.typeMessage');
|
||||
// EN: Reference to textarea element for auto-resize / VI: Reference đến element textarea cho auto-resize
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// EN: Internal state for uncontrolled mode / VI: State nội bộ cho chế độ uncontrolled
|
||||
const [internalValue, setInternalValue] = React.useState('');
|
||||
|
||||
// EN: Use external value if provided, otherwise use internal state / VI: Sử dụng external value nếu có, nếu không dùng internal state
|
||||
const value = externalValue !== undefined ? externalValue : internalValue;
|
||||
|
||||
// EN: Handle textarea input change / VI: Xử lý thay đổi input textarea
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
// EN: Update internal state if uncontrolled / VI: Cập nhật internal state nếu uncontrolled
|
||||
if (externalValue === undefined) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
|
||||
onChange?.(newValue);
|
||||
|
||||
// EN: Auto-resize textarea / VI: Tự động thay đổi kích thước textarea
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
const scrollHeight = textareaRef.current.scrollHeight;
|
||||
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
|
||||
textareaRef.current.style.height = `${newHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Handle keyboard shortcuts / VI: Xử lý phím tắt
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// EN: Enter to send (unless Shift is pressed for new line) / VI: Enter để gửi (trừ khi nhấn Shift để xuống dòng)
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Handle send message / VI: Xử lý gửi tin nhắn
|
||||
const handleSend = () => {
|
||||
const trimmedValue = value.trim();
|
||||
if (trimmedValue && !disabled && !loading && onSend) {
|
||||
onSend(trimmedValue);
|
||||
// EN: Reset textarea height after sending / VI: Đặt lại chiều cao textarea sau khi gửi
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = `${minHeight}px`;
|
||||
}
|
||||
// EN: Clear internal state if uncontrolled / VI: Xóa internal state nếu uncontrolled
|
||||
if (externalValue === undefined) {
|
||||
setInternalValue('');
|
||||
}
|
||||
// EN: Clear input value (expecting parent to handle this via onChange) / VI: Xóa giá trị input (kỳ vọng parent xử lý qua onChange)
|
||||
onChange?.('');
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Adjust textarea height on mount and when value changes / VI: Điều chỉnh chiều cao textarea khi mount và khi value thay đổi
|
||||
React.useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
// EN: Reset height to auto to get accurate scrollHeight / VI: Đặt lại chiều cao về auto để lấy scrollHeight chính xác
|
||||
textareaRef.current.style.height = 'auto';
|
||||
const scrollHeight = textareaRef.current.scrollHeight;
|
||||
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
|
||||
textareaRef.current.style.height = `${newHeight}px`;
|
||||
}
|
||||
}, [value, minHeight, maxHeight]);
|
||||
|
||||
// EN: Check if send button should be enabled / VI: Kiểm tra xem nút send có nên được kích hoạt không
|
||||
const canSend = value.trim().length > 0 && !disabled && !loading;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-end gap-2 w-full',
|
||||
'border-t border-border-primary bg-bg-secondary',
|
||||
'p-4',
|
||||
// EN: Mobile: Sticky bottom / VI: Mobile: Sticky bottom
|
||||
'max-md:sticky max-md:bottom-0 max-md:z-10',
|
||||
// EN: Mobile: Safe area padding for iOS / VI: Mobile: Safe area padding cho iOS
|
||||
'max-md:pb-[env(safe-area-inset-bottom)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* EN: Attach file button / VI: Nút attach file */}
|
||||
{onAttachFile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAttachFile}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex-shrink-0',
|
||||
// EN: Mobile: Minimum 44px touch target / VI: Mobile: Touch target tối thiểu 44px
|
||||
'w-10 h-10 md:w-10 md:h-10',
|
||||
'min-w-[44px] min-h-[44px]',
|
||||
'flex items-center justify-center',
|
||||
'rounded-md',
|
||||
'text-text-tertiary hover:text-text-secondary',
|
||||
'hover:bg-bg-tertiary active:bg-bg-elevated',
|
||||
'transition-colors duration-[150ms]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
aria-label={t('chat.attachFile')}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.122 2.122l7.81-7.81"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* EN: Textarea input / VI: Textarea input */}
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder={defaultPlaceholder}
|
||||
rows={1}
|
||||
className={cn(
|
||||
'w-full',
|
||||
'px-4 py-3',
|
||||
'bg-bg-tertiary',
|
||||
'border border-border-primary rounded-lg',
|
||||
'text-text-primary placeholder:text-text-tertiary',
|
||||
'resize-none',
|
||||
'overflow-y-auto',
|
||||
'focus-visible:outline-none',
|
||||
'focus-visible:border-accent-primary',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2',
|
||||
'focus-visible:shadow-[0_0_20px_rgba(59,130,246,0.3)]',
|
||||
'transition-all duration-[150ms]',
|
||||
'text-base',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
// EN: Custom scrollbar styling / VI: Styling scrollbar tùy chỉnh
|
||||
'scrollbar-thin scrollbar-thumb-border-secondary scrollbar-track-transparent',
|
||||
'hover:scrollbar-thumb-border-secondary'
|
||||
)}
|
||||
style={{
|
||||
minHeight: `${minHeight}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
}}
|
||||
aria-label={t('chat.messageInput')}
|
||||
aria-describedby="chat-input-help"
|
||||
/>
|
||||
{/* EN: Hidden helper text for screen readers / VI: Text hướng dẫn ẩn cho screen readers */}
|
||||
<span id="chat-input-help" className="sr-only">
|
||||
{t('chat.sendHelp')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* EN: Send button / VI: Nút send */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
isDisabled={!canSend}
|
||||
isLoading={loading}
|
||||
variant="primary"
|
||||
size="md"
|
||||
className={cn(
|
||||
'flex-shrink-0',
|
||||
'w-10 h-10 p-0',
|
||||
// EN: Mobile: Minimum 44px touch target / VI: Mobile: Touch target tối thiểu 44px
|
||||
'min-w-[44px] min-h-[44px]',
|
||||
'rounded-lg',
|
||||
// EN: Adjust button styles for square shape / VI: Điều chỉnh styles cho button hình vuông
|
||||
!canSend && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
aria-label={t('chat.send')}
|
||||
>
|
||||
{!loading && (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* EN: Chat layout component props interface
|
||||
* VI: Interface cho props của component Chat layout
|
||||
*/
|
||||
export interface ChatLayoutProps {
|
||||
/**
|
||||
* EN: Left sidebar content (conversation list, search, etc.)
|
||||
* VI: Nội dung sidebar trái (danh sách cuộc trò chuyện, tìm kiếm, etc.)
|
||||
*/
|
||||
sidebar?: React.ReactNode;
|
||||
/**
|
||||
* EN: Main chat area content (messages, header, input)
|
||||
* VI: Nội dung khu vực chat chính (tin nhắn, header, input)
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* EN: Right panel content (settings, participants, etc.) - optional
|
||||
* VI: Nội dung panel bên phải (cài đặt, người tham gia, etc.) - tùy chọn
|
||||
*/
|
||||
rightPanel?: React.ReactNode;
|
||||
/**
|
||||
* EN: Whether the sidebar is visible (for mobile responsiveness)
|
||||
* VI: Sidebar có hiển thị hay không (cho responsive mobile)
|
||||
*/
|
||||
sidebarVisible?: boolean;
|
||||
/**
|
||||
* EN: Whether the right panel is visible
|
||||
* VI: Panel bên phải có hiển thị hay không
|
||||
*/
|
||||
rightPanelVisible?: boolean;
|
||||
/**
|
||||
* EN: Callback when sidebar visibility changes
|
||||
* VI: Callback khi trạng thái hiển thị sidebar thay đổi
|
||||
*/
|
||||
onSidebarToggle?: (visible: boolean) => void;
|
||||
/**
|
||||
* EN: Callback when right panel visibility changes
|
||||
* VI: Callback khi trạng thái hiển thị panel bên phải thay đổi
|
||||
*/
|
||||
onRightPanelToggle?: (visible: boolean) => void;
|
||||
/**
|
||||
* EN: Additional CSS classes
|
||||
* VI: Các class CSS bổ sung
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Chat layout component - Main layout structure for chat interface
|
||||
* VI: Component Chat layout - Cấu trúc layout chính cho giao diện chat
|
||||
*
|
||||
* Layout structure:
|
||||
* - Left Sidebar (280px): Conversation list, search, user profile
|
||||
* - Main Chat Area (flex-1, max 768px centered): Messages, header, input
|
||||
* - Right Panel (320px, optional): Settings, participants, shared files
|
||||
*
|
||||
* Responsive behavior:
|
||||
* - Mobile (< 768px): Hide sidebar by default, full-width messages
|
||||
* - Tablet (768px - 1024px): Sidebar + Main (two columns)
|
||||
* - Desktop (> 1024px): Sidebar + Main + Right Panel (three columns)
|
||||
*
|
||||
* Cấu trúc layout:
|
||||
* - Sidebar trái (280px): Danh sách cuộc trò chuyện, tìm kiếm, profile người dùng
|
||||
* - Khu vực chat chính (flex-1, tối đa 768px căn giữa): Tin nhắn, header, input
|
||||
* - Panel bên phải (320px, tùy chọn): Cài đặt, người tham gia, file đã chia sẻ
|
||||
*
|
||||
* Hành vi responsive:
|
||||
* - Mobile (< 768px): Ẩn sidebar mặc định, tin nhắn full-width
|
||||
* - Tablet (768px - 1024px): Sidebar + Main (hai cột)
|
||||
* - Desktop (> 1024px): Sidebar + Main + Right Panel (ba cột)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ChatLayout
|
||||
* sidebar={<ConversationSidebar />}
|
||||
* rightPanel={<ConversationSettings />}
|
||||
* >
|
||||
* <ChatMessages />
|
||||
* </ChatLayout>
|
||||
* ```
|
||||
*/
|
||||
export function ChatLayout({
|
||||
sidebar,
|
||||
children,
|
||||
rightPanel,
|
||||
sidebarVisible = true,
|
||||
rightPanelVisible = false,
|
||||
onSidebarToggle,
|
||||
onRightPanelToggle: _onRightPanelToggle,
|
||||
className,
|
||||
}: ChatLayoutProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
|
||||
// EN: Mobile: Hide sidebar by default / VI: Mobile: Ẩn sidebar mặc định
|
||||
const [mobileSidebarVisible, setMobileSidebarVisible] = React.useState(false);
|
||||
const [touchStart, setTouchStart] = React.useState<number | null>(null);
|
||||
const [touchEnd, setTouchEnd] = React.useState<number | null>(null);
|
||||
|
||||
// EN: Minimum swipe distance (px) / VI: Khoảng cách swipe tối thiểu (px)
|
||||
const minSwipeDistance = 50;
|
||||
|
||||
// EN: Handle touch start / VI: Xử lý bắt đầu chạm
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
setTouchEnd(null);
|
||||
setTouchStart(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
// EN: Handle touch move / VI: Xử lý di chuyển chạm
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
setTouchEnd(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
// EN: Handle touch end / VI: Xử lý kết thúc chạm
|
||||
const onTouchEnd = () => {
|
||||
if (!touchStart || !touchEnd) return;
|
||||
const distance = touchStart - touchEnd;
|
||||
const isLeftSwipe = distance > minSwipeDistance;
|
||||
const isRightSwipe = distance < -minSwipeDistance;
|
||||
|
||||
// EN: Swipe right to open sidebar / VI: Vuốt phải để mở sidebar
|
||||
if (isRightSwipe && !mobileSidebarVisible) {
|
||||
setMobileSidebarVisible(true);
|
||||
onSidebarToggle?.(true);
|
||||
}
|
||||
// EN: Swipe left to close sidebar / VI: Vuốt trái để đóng sidebar
|
||||
if (isLeftSwipe && mobileSidebarVisible) {
|
||||
setMobileSidebarVisible(false);
|
||||
onSidebarToggle?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Sync mobile sidebar state with prop / VI: Đồng bộ state sidebar mobile với prop
|
||||
React.useEffect(() => {
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
setMobileSidebarVisible(sidebarVisible);
|
||||
}
|
||||
}, [sidebarVisible]);
|
||||
|
||||
// EN: Tablet: Show sidebar by default, but toggleable / VI: Tablet: Hiện sidebar mặc định, nhưng có thể toggle
|
||||
const [tabletSidebarVisible, setTabletSidebarVisible] = React.useState(true);
|
||||
|
||||
// EN: Determine if sidebar should be visible / VI: Xác định sidebar có nên hiển thị không
|
||||
const getSidebarVisibility = () => {
|
||||
if (typeof window === 'undefined') return sidebarVisible;
|
||||
const width = window.innerWidth;
|
||||
if (width < 768) {
|
||||
// EN: Mobile: Use mobile state / VI: Mobile: Dùng state mobile
|
||||
return mobileSidebarVisible;
|
||||
} else if (width >= 768 && width < 1024) {
|
||||
// EN: Tablet: Use tablet state / VI: Tablet: Dùng state tablet
|
||||
return tabletSidebarVisible;
|
||||
}
|
||||
// EN: Desktop: Use prop / VI: Desktop: Dùng prop
|
||||
return sidebarVisible;
|
||||
};
|
||||
|
||||
const isSidebarVisible = getSidebarVisibility();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// EN: Base layout container / VI: Container layout cơ bản
|
||||
'flex h-screen w-full overflow-hidden bg-bg-primary',
|
||||
className
|
||||
)}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* EN: Mobile/Tablet menu button / VI: Nút menu mobile/tablet */}
|
||||
{sidebar && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const newVisible = !isSidebarVisible;
|
||||
if (typeof window !== 'undefined') {
|
||||
const width = window.innerWidth;
|
||||
if (width < 768) {
|
||||
setMobileSidebarVisible(newVisible);
|
||||
} else if (width >= 768 && width < 1024) {
|
||||
setTabletSidebarVisible(newVisible);
|
||||
}
|
||||
}
|
||||
onSidebarToggle?.(newVisible);
|
||||
}}
|
||||
className={cn(
|
||||
'lg:hidden',
|
||||
'fixed top-4 left-4 z-50',
|
||||
'p-2 rounded-lg',
|
||||
'bg-bg-elevated border border-border-primary',
|
||||
'text-text-primary hover:bg-bg-tertiary',
|
||||
'transition-colors duration-[150ms]',
|
||||
'shadow-lg',
|
||||
'min-w-[44px] min-h-[44px]'
|
||||
)}
|
||||
aria-label={isSidebarVisible ? t('chat.closeSidebar', { defaultValue: 'Close sidebar' }) : t('chat.openSidebar', { defaultValue: 'Open sidebar' })}
|
||||
>
|
||||
{isSidebarVisible ? (
|
||||
<X className="h-5 w-5" />
|
||||
) : (
|
||||
<Menu className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* EN: Left Sidebar / VI: Sidebar trái */}
|
||||
{sidebar && (
|
||||
<aside
|
||||
className={cn(
|
||||
// EN: Base sidebar styles / VI: Style sidebar cơ bản
|
||||
'flex flex-col bg-bg-secondary border-r border-border-primary transition-all duration-[250ms] ease-out',
|
||||
// EN: Desktop: Fixed width 280px / VI: Desktop: Chiều rộng cố định 280px
|
||||
'w-sidebar flex-shrink-0',
|
||||
// EN: Mobile: Hide by default, show when sidebarVisible / VI: Mobile: Ẩn mặc định, hiện khi sidebarVisible
|
||||
'max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-40',
|
||||
'max-md:transform max-md:transition-transform',
|
||||
isSidebarVisible ? 'max-md:translate-x-0' : 'max-md:-translate-x-full',
|
||||
// EN: Tablet: Show by default, toggleable / VI: Tablet: Hiện mặc định, có thể toggle
|
||||
'md:block lg:block',
|
||||
!isSidebarVisible && 'md:hidden'
|
||||
)}
|
||||
aria-label={t('chat.conversationSidebar', { defaultValue: 'Conversation sidebar' })}
|
||||
>
|
||||
{sidebar}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* EN: Main Chat Area / VI: Khu vực chat chính */}
|
||||
<main
|
||||
id="main-content"
|
||||
className={cn(
|
||||
// EN: Base main area styles / VI: Style khu vực chính cơ bản
|
||||
'flex flex-col flex-1 overflow-hidden',
|
||||
// EN: Center content with max-width constraint on desktop / VI: Căn giữa nội dung với giới hạn chiều rộng tối đa trên desktop
|
||||
'md:items-center',
|
||||
// EN: Mobile: Full width / VI: Mobile: Full width
|
||||
'w-full'
|
||||
)}
|
||||
role="main"
|
||||
aria-label={t('chat.mainChatArea', { defaultValue: 'Main chat area' })}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
// EN: Content container with max-width on desktop / VI: Container nội dung với chiều rộng tối đa trên desktop
|
||||
'w-full flex flex-col h-full',
|
||||
// EN: Mobile: Full width / VI: Mobile: Full width
|
||||
'max-md:max-w-none',
|
||||
// EN: Tablet: Medium width (60%) / VI: Tablet: Chiều rộng trung bình (60%)
|
||||
'md:max-w-[60%] md:mx-auto',
|
||||
// EN: Desktop: Max width 768px centered / VI: Desktop: Chiều rộng tối đa 768px căn giữa
|
||||
'lg:max-w-chat-max lg:mx-auto'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* EN: Right Panel / VI: Panel bên phải */}
|
||||
{rightPanel && (
|
||||
<aside
|
||||
className={cn(
|
||||
// EN: Base right panel styles / VI: Style panel bên phải cơ bản
|
||||
'flex flex-col bg-bg-secondary border-l border-border-primary transition-all duration-[250ms] ease-out',
|
||||
// EN: Desktop: Fixed width 320px, only show on large screens / VI: Desktop: Chiều rộng cố định 320px, chỉ hiện trên màn hình lớn
|
||||
'w-80 flex-shrink-0',
|
||||
// EN: Hide on small/medium screens / VI: Ẩn trên màn hình nhỏ/trung bình
|
||||
'max-lg:hidden',
|
||||
// EN: Show/hide based on rightPanelVisible prop / VI: Hiện/ẩn dựa trên prop rightPanelVisible
|
||||
rightPanelVisible ? 'lg:flex' : 'lg:hidden'
|
||||
)}
|
||||
aria-label={t('chat.conversationSettingsPanel', { defaultValue: 'Conversation settings panel' })}
|
||||
>
|
||||
{rightPanel}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* EN: Mobile overlay when sidebar is visible / VI: Overlay mobile khi sidebar hiển thị */}
|
||||
{sidebar && isSidebarVisible && (
|
||||
<div
|
||||
className={cn(
|
||||
// EN: Overlay for mobile sidebar / VI: Overlay cho sidebar mobile
|
||||
'fixed inset-0 bg-black/50 z-30',
|
||||
// EN: Only show on mobile, not tablet / VI: Chỉ hiện trên mobile, không phải tablet
|
||||
'md:hidden'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
setMobileSidebarVisible(false);
|
||||
onSidebarToggle?.(false);
|
||||
}
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { Button } from '@/features/shared/components/ui/button';
|
||||
import { Input } from '@/features/shared/components/ui/input';
|
||||
import { Avatar, AvatarFallback } from '@/features/shared/components/ui/avatar';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useI18n } from '@/features/theme';
|
||||
|
||||
/**
|
||||
* EN: Conversation interface
|
||||
* VI: Interface cho Conversation
|
||||
*/
|
||||
export interface Conversation {
|
||||
/** EN: Unique conversation ID / VI: ID duy nhất của conversation */
|
||||
id: string;
|
||||
/** EN: Conversation title / VI: Tiêu đề conversation */
|
||||
title: string;
|
||||
/** EN: Last message preview / VI: Xem trước tin nhắn cuối */
|
||||
lastMessage?: string;
|
||||
/** EN: Last message timestamp / VI: Timestamp tin nhắn cuối */
|
||||
lastMessageAt?: Date;
|
||||
/** EN: Whether conversation is selected / VI: Conversation có được chọn không */
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: ConversationSidebar component props
|
||||
* VI: Props của component ConversationSidebar
|
||||
*/
|
||||
export interface ConversationSidebarProps {
|
||||
/** EN: List of conversations / VI: Danh sách conversations */
|
||||
conversations?: Conversation[];
|
||||
/** EN: Selected conversation ID / VI: ID conversation được chọn */
|
||||
selectedConversationId?: string;
|
||||
/** EN: Callback when conversation is selected / VI: Callback khi conversation được chọn */
|
||||
onSelectConversation?: (conversationId: string) => void;
|
||||
/** EN: Callback when new chat button is clicked / VI: Callback khi nút new chat được click */
|
||||
onNewChat?: () => void;
|
||||
/** EN: Additional CSS classes / VI: Các class CSS bổ sung */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: ConversationSidebar component - Sidebar with conversation list, search, and user profile
|
||||
* VI: Component ConversationSidebar - Sidebar với danh sách conversation, tìm kiếm, và profile người dùng
|
||||
*
|
||||
* Features:
|
||||
* - New Chat button
|
||||
* - Search conversations input
|
||||
* - Scrollable conversation list
|
||||
* - User profile section (sticky bottom)
|
||||
*
|
||||
* Tính năng:
|
||||
* - Nút New Chat
|
||||
* - Input tìm kiếm conversations
|
||||
* - Danh sách conversation có thể scroll
|
||||
* - Phần profile người dùng (sticky bottom)
|
||||
*/
|
||||
export function ConversationSidebar({
|
||||
conversations = [],
|
||||
selectedConversationId,
|
||||
onSelectConversation,
|
||||
onNewChat,
|
||||
className,
|
||||
}: ConversationSidebarProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
const { locale } = useI18n();
|
||||
// EN: Get current user from auth store / VI: Lấy user hiện tại từ auth store
|
||||
const { user } = useAuthStore();
|
||||
|
||||
// EN: Search state / VI: State tìm kiếm
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
|
||||
// EN: Filter conversations based on search query / VI: Lọc conversations dựa trên search query
|
||||
const filteredConversations = React.useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return conversations;
|
||||
}
|
||||
const query = searchQuery.toLowerCase();
|
||||
return conversations.filter(
|
||||
(conv) =>
|
||||
conv.title.toLowerCase().includes(query) ||
|
||||
conv.lastMessage?.toLowerCase().includes(query)
|
||||
);
|
||||
}, [conversations, searchQuery]);
|
||||
|
||||
// EN: Format timestamp to relative time / VI: Format timestamp thành thời gian tương đối
|
||||
const formatRelativeTime = (date?: Date): string => {
|
||||
if (!date) return '';
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return t('chat.justNow');
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
return date.toLocaleDateString(locale === 'vi' ? 'vi-VN' : 'en-US', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
// EN: Get user initials for avatar fallback / VI: Lấy initials của user cho avatar fallback
|
||||
const getUserInitials = (email?: string): string => {
|
||||
if (!email) return 'U';
|
||||
const parts = email.split('@')[0].split(/[._-]/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return email.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// EN: Base styles - fixed width sidebar / VI: Styles cơ bản - sidebar cố định chiều rộng
|
||||
'flex flex-col h-full w-sidebar bg-bg-secondary border-r border-border-primary',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* EN: Header section with New Chat button / VI: Phần header với nút New Chat */}
|
||||
<header className="p-4 border-b border-border-primary" role="banner">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="w-full"
|
||||
onClick={onNewChat}
|
||||
aria-label={t('chat.newChat')}
|
||||
>
|
||||
{/* EN: Plus icon / VI: Icon dấu cộng */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
{t('chat.newChat')}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* EN: Search section / VI: Phần tìm kiếm */}
|
||||
<nav className="p-4 border-b border-border-primary" role="search" aria-label={t('chat.searchConversations')}>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t('chat.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
className="w-full"
|
||||
aria-label={t('chat.searchConversations')}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
{/* EN: Conversations list (scrollable) / VI: Danh sách conversations (có thể scroll) */}
|
||||
<nav className="flex-1 overflow-y-auto" role="navigation" aria-label={t('chat.conversationList')}>
|
||||
{filteredConversations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<p className="text-text-tertiary text-sm">
|
||||
{searchQuery
|
||||
? t('chat.noConversationsFound')
|
||||
: t('chat.noConversations')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{filteredConversations.map((conversation) => (
|
||||
<button
|
||||
key={conversation.id}
|
||||
onClick={() => onSelectConversation?.(conversation.id)}
|
||||
className={cn(
|
||||
// EN: Base conversation item styles / VI: Styles cơ bản cho item conversation
|
||||
'w-full text-left p-3 rounded-lg mb-2 transition-all duration-[150ms]',
|
||||
// EN: Touch-friendly: Minimum 44px height, 8px spacing / VI: Thân thiện với chạm: Chiều cao tối thiểu 44px, khoảng cách 8px
|
||||
'min-h-[44px]',
|
||||
'hover:bg-bg-tertiary active:scale-[0.98] active:bg-bg-elevated',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-secondary',
|
||||
// EN: Selected state / VI: Trạng thái được chọn
|
||||
conversation.id === selectedConversationId || conversation.isSelected
|
||||
? 'bg-bg-tertiary border-l-3 border-accent-primary'
|
||||
: 'bg-transparent'
|
||||
)}
|
||||
aria-label={`${t('chat.conversation')}: ${conversation.title}`}
|
||||
>
|
||||
{/* EN: Conversation title / VI: Tiêu đề conversation */}
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<h3 className="text-sm font-medium text-text-primary truncate flex-1">
|
||||
{conversation.title}
|
||||
</h3>
|
||||
{conversation.lastMessageAt && (
|
||||
<span className="text-xs text-chat-timestamp whitespace-nowrap">
|
||||
{formatRelativeTime(conversation.lastMessageAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EN: Last message preview / VI: Xem trước tin nhắn cuối */}
|
||||
{conversation.lastMessage && (
|
||||
<p className="text-xs text-text-tertiary truncate">
|
||||
{conversation.lastMessage}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* EN: User profile section (sticky bottom) / VI: Phần profile người dùng (sticky bottom) */}
|
||||
{user && (
|
||||
<div className="p-4 border-t border-border-primary bg-bg-secondary">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback>{getUserInitials(user.email)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary truncate">
|
||||
{user.role}
|
||||
</p>
|
||||
</div>
|
||||
{/* EN: Settings icon / VI: Icon cài đặt */}
|
||||
<button
|
||||
className="p-2 rounded-md hover:bg-bg-tertiary transition-colors"
|
||||
aria-label={t('settings.title')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4 text-text-tertiary"
|
||||
>
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* EN: Chat feature exports
|
||||
* VI: Export các thành phần chat
|
||||
*/
|
||||
|
||||
export * from './chat-input';
|
||||
export * from './chat-layout';
|
||||
export * from './conversation-sidebar';
|
||||
export * from './message-actions-menu';
|
||||
export * from './message-bubble';
|
||||
export * from './typing-indicator';
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* EN: Chat feature library exports
|
||||
* VI: Xuất thư viện của feature chat
|
||||
*/
|
||||
|
||||
export * from './websocket';
|
||||
@@ -1,432 +0,0 @@
|
||||
/**
|
||||
* EN: WebSocket client for real-time messaging with automatic reconnection
|
||||
* VI: WebSocket client cho real-time messaging với tự động kết nối lại
|
||||
*/
|
||||
|
||||
/**
|
||||
* EN: WebSocket connection state
|
||||
* VI: Trạng thái kết nối WebSocket
|
||||
*/
|
||||
export enum WebSocketState {
|
||||
/** EN: WebSocket is connecting / VI: WebSocket đang kết nối */
|
||||
CONNECTING = 'CONNECTING',
|
||||
/** EN: WebSocket is connected and ready / VI: WebSocket đã kết nối và sẵn sàng */
|
||||
CONNECTED = 'CONNECTED',
|
||||
/** EN: WebSocket connection is closed / VI: Kết nối WebSocket đã đóng */
|
||||
CLOSED = 'CLOSED',
|
||||
/** EN: WebSocket connection error / VI: Lỗi kết nối WebSocket */
|
||||
ERROR = 'ERROR',
|
||||
/** EN: WebSocket is reconnecting / VI: WebSocket đang kết nối lại */
|
||||
RECONNECTING = 'RECONNECTING',
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: WebSocket message event types
|
||||
* VI: Các loại sự kiện message WebSocket
|
||||
*/
|
||||
export enum WebSocketMessageType {
|
||||
/** EN: Text message / VI: Tin nhắn văn bản */
|
||||
MESSAGE = 'message',
|
||||
/** EN: Typing indicator / VI: Chỉ báo đang gõ */
|
||||
TYPING = 'typing',
|
||||
/** EN: Message read receipt / VI: Xác nhận đã đọc */
|
||||
READ = 'read',
|
||||
/** EN: User joined conversation / VI: Người dùng đã tham gia cuộc trò chuyện */
|
||||
USER_JOINED = 'user_joined',
|
||||
/** EN: User left conversation / VI: Người dùng đã rời cuộc trò chuyện */
|
||||
USER_LEFT = 'user_left',
|
||||
/** EN: Error message / VI: Tin nhắn lỗi */
|
||||
ERROR = 'error',
|
||||
/** EN: Connection acknowledgment / VI: Xác nhận kết nối */
|
||||
ACK = 'ack',
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: WebSocket message payload interface
|
||||
* VI: Interface payload tin nhắn WebSocket
|
||||
*/
|
||||
export interface WebSocketMessage {
|
||||
/** EN: Message type / VI: Loại tin nhắn */
|
||||
type: WebSocketMessageType;
|
||||
/** EN: Message payload / VI: Payload tin nhắn */
|
||||
data?: Record<string, unknown>;
|
||||
/** EN: Conversation ID / VI: ID cuộc trò chuyện */
|
||||
conversationId?: string;
|
||||
/** EN: Message ID / VI: ID tin nhắn */
|
||||
messageId?: string;
|
||||
/** EN: Timestamp / VI: Timestamp */
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: WebSocket event callbacks
|
||||
* VI: Callbacks sự kiện WebSocket
|
||||
*/
|
||||
export interface WebSocketCallbacks {
|
||||
/** EN: Called when WebSocket connection opens / VI: Được gọi khi kết nối WebSocket mở */
|
||||
onOpen?: () => void;
|
||||
/** EN: Called when WebSocket connection closes / VI: Được gọi khi kết nối WebSocket đóng */
|
||||
onClose?: () => void;
|
||||
/** EN: Called when WebSocket error occurs / VI: Được gọi khi xảy ra lỗi WebSocket */
|
||||
onError?: (error: Event) => void;
|
||||
/** EN: Called when message is received / VI: Được gọi khi nhận được tin nhắn */
|
||||
onMessage?: (message: WebSocketMessage) => void;
|
||||
/** EN: Called when connection state changes / VI: Được gọi khi trạng thái kết nối thay đổi */
|
||||
onStateChange?: (state: WebSocketState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: WebSocket client configuration options
|
||||
* VI: Tùy chọn cấu hình WebSocket client
|
||||
*/
|
||||
export interface WebSocketClientConfig {
|
||||
/** EN: WebSocket server URL / VI: URL server WebSocket */
|
||||
url: string;
|
||||
/** EN: Enable automatic reconnection / VI: Bật tự động kết nối lại */
|
||||
autoReconnect?: boolean;
|
||||
/** EN: Maximum reconnection attempts (0 = unlimited) / VI: Số lần thử kết nối lại tối đa (0 = không giới hạn) */
|
||||
maxReconnectAttempts?: number;
|
||||
/** EN: Initial reconnection delay in milliseconds / VI: Độ trễ kết nối lại ban đầu tính bằng milliseconds */
|
||||
reconnectDelay?: number;
|
||||
/** EN: Maximum reconnection delay in milliseconds / VI: Độ trễ kết nối lại tối đa tính bằng milliseconds */
|
||||
maxReconnectDelay?: number;
|
||||
/** EN: Reconnection delay multiplier (exponential backoff) / VI: Hệ số nhân độ trễ kết nối lại (exponential backoff) */
|
||||
reconnectDelayMultiplier?: number;
|
||||
/** EN: Event callbacks / VI: Callbacks sự kiện */
|
||||
callbacks?: WebSocketCallbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: WebSocket client for real-time messaging
|
||||
* VI: WebSocket client cho real-time messaging
|
||||
*
|
||||
* Features:
|
||||
* - Automatic reconnection with exponential backoff
|
||||
* - Authentication via JWT token
|
||||
* - Event-based message handling
|
||||
* - Connection state management
|
||||
* - Heartbeat/ping-pong support (optional)
|
||||
*/
|
||||
export class WebSocketClient {
|
||||
/** EN: WebSocket instance / VI: Instance WebSocket */
|
||||
private ws: WebSocket | null = null;
|
||||
/** EN: Current connection state / VI: Trạng thái kết nối hiện tại */
|
||||
private state: WebSocketState = WebSocketState.CLOSED;
|
||||
/** EN: Configuration options / VI: Tùy chọn cấu hình */
|
||||
private config: Required<Omit<WebSocketClientConfig, 'callbacks'>> & { callbacks?: WebSocketCallbacks };
|
||||
/** EN: Reconnection attempt counter / VI: Bộ đếm số lần thử kết nối lại */
|
||||
private reconnectAttempts = 0;
|
||||
/** EN: Current reconnection delay / VI: Độ trễ kết nối lại hiện tại */
|
||||
private reconnectDelay = 0;
|
||||
/** EN: Reconnection timeout ID / VI: ID timeout kết nối lại */
|
||||
private reconnectTimeoutId: NodeJS.Timeout | null = null;
|
||||
/** EN: Ping interval ID for heartbeat / VI: ID interval ping cho heartbeat */
|
||||
private pingIntervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* EN: Initialize WebSocket client with configuration
|
||||
* VI: Khởi tạo WebSocket client với cấu hình
|
||||
*
|
||||
* @param config - WebSocket client configuration / Cấu hình WebSocket client
|
||||
*/
|
||||
constructor(config: WebSocketClientConfig) {
|
||||
this.config = {
|
||||
url: config.url,
|
||||
autoReconnect: config.autoReconnect ?? true,
|
||||
maxReconnectAttempts: config.maxReconnectAttempts ?? 10,
|
||||
reconnectDelay: config.reconnectDelay ?? 1000,
|
||||
maxReconnectDelay: config.maxReconnectDelay ?? 30000,
|
||||
reconnectDelayMultiplier: config.reconnectDelayMultiplier ?? 1.5,
|
||||
callbacks: config.callbacks,
|
||||
};
|
||||
this.reconnectDelay = this.config.reconnectDelay;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Get current connection state
|
||||
* VI: Lấy trạng thái kết nối hiện tại
|
||||
*
|
||||
* @returns Current WebSocket state / Trạng thái WebSocket hiện tại
|
||||
*/
|
||||
getState(): WebSocketState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Check if WebSocket is connected
|
||||
* VI: Kiểm tra WebSocket đã kết nối chưa
|
||||
*
|
||||
* @returns True if connected / True nếu đã kết nối
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.state === WebSocketState.CONNECTED && this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Connect to WebSocket server
|
||||
* VI: Kết nối tới server WebSocket
|
||||
*
|
||||
* @throws Error if connection fails / Ném lỗi nếu kết nối thất bại
|
||||
*/
|
||||
connect(): void {
|
||||
// EN: Return if already connected or connecting
|
||||
// VI: Trả về nếu đã kết nối hoặc đang kết nối
|
||||
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
|
||||
return;
|
||||
}
|
||||
|
||||
// EN: Get authentication token from localStorage
|
||||
// VI: Lấy authentication token từ localStorage
|
||||
const token = this.getAuthToken();
|
||||
if (!token) {
|
||||
throw new Error('No authentication token found. Please login first. / Không tìm thấy token xác thực. Vui lòng đăng nhập trước.');
|
||||
}
|
||||
|
||||
// EN: Build WebSocket URL with authentication token
|
||||
// VI: Tạo URL WebSocket với authentication token
|
||||
const url = this.buildWebSocketUrl(this.config.url, token);
|
||||
|
||||
try {
|
||||
this.setState(WebSocketState.CONNECTING);
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = this.handleOpen.bind(this);
|
||||
this.ws.onclose = this.handleClose.bind(this);
|
||||
this.ws.onerror = this.handleError.bind(this);
|
||||
this.ws.onmessage = this.handleMessage.bind(this);
|
||||
} catch (error) {
|
||||
this.setState(WebSocketState.ERROR);
|
||||
this.config.callbacks?.onError?.(error as Event);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Disconnect from WebSocket server
|
||||
* VI: Ngắt kết nối khỏi server WebSocket
|
||||
*/
|
||||
disconnect(): void {
|
||||
// EN: Disable auto-reconnect when manually disconnecting
|
||||
// VI: Tắt tự động kết nối lại khi ngắt kết nối thủ công
|
||||
this.config.autoReconnect = false;
|
||||
|
||||
// EN: Clear reconnection timeout
|
||||
// VI: Xóa timeout kết nối lại
|
||||
if (this.reconnectTimeoutId) {
|
||||
clearTimeout(this.reconnectTimeoutId);
|
||||
this.reconnectTimeoutId = null;
|
||||
}
|
||||
|
||||
// EN: Clear ping interval
|
||||
// VI: Xóa ping interval
|
||||
if (this.pingIntervalId) {
|
||||
clearInterval(this.pingIntervalId);
|
||||
this.pingIntervalId = null;
|
||||
}
|
||||
|
||||
// EN: Close WebSocket connection
|
||||
// VI: Đóng kết nối WebSocket
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Client disconnect / Ngắt kết nối từ client');
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.setState(WebSocketState.CLOSED);
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectDelay = this.config.reconnectDelay;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Send message through WebSocket
|
||||
* VI: Gửi tin nhắn qua WebSocket
|
||||
*
|
||||
* @param message - Message to send / Tin nhắn cần gửi
|
||||
* @throws Error if not connected / Ném lỗi nếu chưa kết nối
|
||||
*/
|
||||
send(message: WebSocketMessage): void {
|
||||
if (!this.isConnected() || !this.ws) {
|
||||
throw new Error('WebSocket is not connected / WebSocket chưa kết nối');
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
this.config.callbacks?.onError?.(error as Event);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Update event callbacks
|
||||
* VI: Cập nhật event callbacks
|
||||
*
|
||||
* @param callbacks - New callbacks / Callbacks mới
|
||||
*/
|
||||
updateCallbacks(callbacks: WebSocketCallbacks): void {
|
||||
this.config.callbacks = { ...this.config.callbacks, ...callbacks };
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Handle WebSocket open event
|
||||
* VI: Xử lý sự kiện WebSocket mở
|
||||
*/
|
||||
private handleOpen(): void {
|
||||
this.setState(WebSocketState.CONNECTED);
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectDelay = this.config.reconnectDelay;
|
||||
this.config.callbacks?.onOpen?.();
|
||||
|
||||
// EN: Start heartbeat/ping (optional, uncomment if server supports)
|
||||
// VI: Bắt đầu heartbeat/ping (tùy chọn, bỏ comment nếu server hỗ trợ)
|
||||
// this._startPing(); // EN: Unused for now / VI: Chưa sử dụng
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Handle WebSocket close event
|
||||
* VI: Xử lý sự kiện WebSocket đóng
|
||||
*/
|
||||
private handleClose(event: CloseEvent): void {
|
||||
this.setState(WebSocketState.CLOSED);
|
||||
this.config.callbacks?.onClose?.();
|
||||
|
||||
// EN: Clear ping interval
|
||||
// VI: Xóa ping interval
|
||||
if (this.pingIntervalId) {
|
||||
clearInterval(this.pingIntervalId);
|
||||
this.pingIntervalId = null;
|
||||
}
|
||||
|
||||
// EN: Attempt reconnection if not a normal closure and auto-reconnect is enabled
|
||||
// VI: Thử kết nối lại nếu không phải đóng bình thường và auto-reconnect được bật
|
||||
if (event.code !== 1000 && this.config.autoReconnect) {
|
||||
this.attemptReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Handle WebSocket error event
|
||||
* VI: Xử lý sự kiện lỗi WebSocket
|
||||
*/
|
||||
private handleError(error: Event): void {
|
||||
this.setState(WebSocketState.ERROR);
|
||||
this.config.callbacks?.onError?.(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Handle WebSocket message event
|
||||
* VI: Xử lý sự kiện tin nhắn WebSocket
|
||||
*/
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
this.config.callbacks?.onMessage?.(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message / Không thể parse tin nhắn WebSocket:', error);
|
||||
this.config.callbacks?.onError?.(error as Event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Attempt to reconnect to WebSocket server with exponential backoff
|
||||
* VI: Thử kết nối lại tới server WebSocket với exponential backoff
|
||||
*/
|
||||
private attemptReconnect(): void {
|
||||
// EN: Check if max reconnection attempts reached
|
||||
// VI: Kiểm tra nếu đã đạt số lần thử kết nối lại tối đa
|
||||
if (this.config.maxReconnectAttempts > 0 && this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
||||
console.error(
|
||||
`Max reconnection attempts reached (${this.config.maxReconnectAttempts}) / Đã đạt số lần thử kết nối lại tối đa (${this.config.maxReconnectAttempts})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
this.setState(WebSocketState.RECONNECTING);
|
||||
|
||||
// EN: Calculate exponential backoff delay
|
||||
// VI: Tính toán độ trễ exponential backoff
|
||||
const delay = Math.min(this.reconnectDelay, this.config.maxReconnectDelay);
|
||||
|
||||
console.log(
|
||||
`Attempting to reconnect (${this.reconnectAttempts}/${this.config.maxReconnectAttempts || '∞'}) in ${delay}ms / Đang thử kết nối lại (${this.reconnectAttempts}/${this.config.maxReconnectAttempts || '∞'}) sau ${delay}ms`
|
||||
);
|
||||
|
||||
// EN: Schedule reconnection attempt
|
||||
// VI: Lên lịch thử kết nối lại
|
||||
this.reconnectTimeoutId = setTimeout(() => {
|
||||
this.reconnectDelay *= this.config.reconnectDelayMultiplier;
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Update connection state and trigger callback
|
||||
* VI: Cập nhật trạng thái kết nối và trigger callback
|
||||
*/
|
||||
private setState(state: WebSocketState): void {
|
||||
if (this.state !== state) {
|
||||
this.state = state;
|
||||
this.config.callbacks?.onStateChange?.(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Get authentication token from localStorage
|
||||
* VI: Lấy authentication token từ localStorage
|
||||
*
|
||||
* @returns Access token or null / Access token hoặc null
|
||||
*/
|
||||
private getAuthToken(): string | null {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('accessToken');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Build WebSocket URL with authentication token
|
||||
* VI: Tạo URL WebSocket với authentication token
|
||||
*
|
||||
* @param baseUrl - Base WebSocket URL / URL WebSocket cơ sở
|
||||
* @param token - Authentication token / Token xác thực
|
||||
* @returns WebSocket URL with token / URL WebSocket với token
|
||||
*/
|
||||
private buildWebSocketUrl(baseUrl: string, token: string): string {
|
||||
// EN: Convert HTTP/HTTPS URL to WS/WSS URL
|
||||
// VI: Chuyển đổi URL HTTP/HTTPS sang WS/WSS URL
|
||||
const wsUrl = baseUrl.replace(/^http/, 'ws');
|
||||
const url = new URL(wsUrl);
|
||||
|
||||
// EN: Add token as query parameter (common pattern)
|
||||
// VI: Thêm token như query parameter (pattern phổ biến)
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Start ping interval for heartbeat (optional)
|
||||
* VI: Bắt đầu ping interval cho heartbeat (tùy chọn)
|
||||
* @internal - Currently unused, available for future use
|
||||
*/
|
||||
private _startPing(): void {
|
||||
// EN: Send ping every 30 seconds to keep connection alive
|
||||
// VI: Gửi ping mỗi 30 giây để giữ kết nối sống
|
||||
this.pingIntervalId = setInterval(() => {
|
||||
if (this.isConnected() && this.ws) {
|
||||
this.ws.send(JSON.stringify({ type: WebSocketMessageType.ACK, data: { type: 'ping' } }));
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Create WebSocket client instance
|
||||
* VI: Tạo instance WebSocket client
|
||||
*
|
||||
* @param config - WebSocket client configuration / Cấu hình WebSocket client
|
||||
* @returns WebSocket client instance / Instance WebSocket client
|
||||
*/
|
||||
export const createWebSocketClient = (config: WebSocketClientConfig): WebSocketClient => {
|
||||
return new WebSocketClient(config);
|
||||
};
|
||||
@@ -1,345 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/features/shared/components/ui/dropdown-menu';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* EN: Message role type
|
||||
* VI: Kiểu vai trò của message
|
||||
*/
|
||||
export type MessageRole = 'user' | 'ai' | 'system';
|
||||
|
||||
/**
|
||||
* EN: Message feedback type
|
||||
* VI: Kiểu feedback của message
|
||||
*/
|
||||
export type MessageFeedback = 'like' | 'dislike' | null;
|
||||
|
||||
/**
|
||||
* EN: MessageActionsMenu component props
|
||||
* VI: Props của component MessageActionsMenu
|
||||
*/
|
||||
export interface MessageActionsMenuProps {
|
||||
/** EN: Message role (user/ai/system) / VI: Vai trò của message (user/ai/system) */
|
||||
role: MessageRole;
|
||||
/** EN: Message content to copy / VI: Nội dung message để copy */
|
||||
messageContent: string;
|
||||
/** EN: Current feedback state / VI: Trạng thái feedback hiện tại */
|
||||
feedback?: MessageFeedback;
|
||||
/** EN: Callback when copy action is triggered / VI: Callback khi action copy được kích hoạt */
|
||||
onCopy?: () => void;
|
||||
/** EN: Callback when edit action is triggered / VI: Callback khi action edit được kích hoạt */
|
||||
onEdit?: () => void;
|
||||
/** EN: Callback when delete action is triggered / VI: Callback khi action delete được kích hoạt */
|
||||
onDelete?: () => void;
|
||||
/** EN: Callback when regenerate action is triggered / VI: Callback khi action regenerate được kích hoạt */
|
||||
onRegenerate?: () => void;
|
||||
/** EN: Callback when like action is triggered / VI: Callback khi action like được kích hoạt */
|
||||
onLike?: () => void;
|
||||
/** EN: Callback when dislike action is triggered / VI: Callback khi action dislike được kích hoạt */
|
||||
onDislike?: () => void;
|
||||
/** EN: Callback when share action is triggered / VI: Callback khi action share được kích hoạt */
|
||||
onShare?: () => void;
|
||||
/** EN: Additional CSS classes / VI: Các class CSS bổ sung */
|
||||
className?: string;
|
||||
/** EN: Trigger element / VI: Element trigger */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: MessageActionsMenu component - Dropdown menu with message actions (copy, edit, delete, regenerate, like/dislike, share)
|
||||
* VI: Component MessageActionsMenu - Dropdown menu với các action cho message (copy, edit, delete, regenerate, like/dislike, share)
|
||||
*
|
||||
* Features:
|
||||
* - Copy message (all messages)
|
||||
* - Edit (user messages only)
|
||||
* - Delete (user messages only)
|
||||
* - Regenerate (AI messages only)
|
||||
* - Like/Dislike (feedback - all messages)
|
||||
* - Share (all messages)
|
||||
*
|
||||
* Tính năng:
|
||||
* - Copy message (tất cả messages)
|
||||
* - Edit (chỉ user messages)
|
||||
* - Delete (chỉ user messages)
|
||||
* - Regenerate (chỉ AI messages)
|
||||
* - Like/Dislike (feedback - tất cả messages)
|
||||
* - Share (tất cả messages)
|
||||
*/
|
||||
export function MessageActionsMenu({
|
||||
role,
|
||||
messageContent,
|
||||
feedback,
|
||||
onCopy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
onLike,
|
||||
onDislike,
|
||||
onShare,
|
||||
className,
|
||||
children,
|
||||
}: MessageActionsMenuProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
|
||||
// EN: Copy to clipboard handler / VI: Handler copy vào clipboard
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(messageContent);
|
||||
onCopy?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to copy message:', error);
|
||||
}
|
||||
}, [messageContent, onCopy]);
|
||||
|
||||
// EN: Share handler using Web Share API / VI: Handler share sử dụng Web Share API
|
||||
const handleShare = React.useCallback(async () => {
|
||||
try {
|
||||
if (navigator.share) {
|
||||
await navigator.share({
|
||||
text: messageContent,
|
||||
title: t('chat.shareMessage'),
|
||||
});
|
||||
onShare?.();
|
||||
} else {
|
||||
// EN: Fallback to copy if share API is not available / VI: Fallback về copy nếu Share API không khả dụng
|
||||
await handleCopy();
|
||||
onShare?.();
|
||||
}
|
||||
} catch (error) {
|
||||
// EN: User cancelled share or error occurred / VI: User hủy share hoặc có lỗi
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
console.error('Failed to share message:', error);
|
||||
}
|
||||
}
|
||||
}, [messageContent, handleCopy, onShare]);
|
||||
|
||||
// EN: Determine if message is from user / VI: Xác định message có phải từ user không
|
||||
const isUserMessage = role === 'user';
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className={className}>
|
||||
{children || (
|
||||
<button
|
||||
className={cn(
|
||||
// EN: Base button styles - visible on hover / VI: Styles button cơ bản - hiển thị khi hover
|
||||
'opacity-0 group-hover:opacity-100 transition-opacity duration-[150ms]',
|
||||
'p-1.5 rounded-md hover:bg-bg-tertiary',
|
||||
'text-text-tertiary hover:text-text-primary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2'
|
||||
)}
|
||||
aria-label={t('chat.messageActions', { defaultValue: 'Message actions' })}
|
||||
>
|
||||
{/* EN: More options icon (three dots) / VI: Icon thêm tùy chọn (ba chấm) */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{/* EN: Copy action - available for all messages / VI: Action Copy - có sẵn cho tất cả messages */}
|
||||
<DropdownMenuItem onClick={handleCopy}>
|
||||
{/* EN: Copy icon / VI: Icon copy */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2h8c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
{t('chat.copy')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* EN: Edit action - only for user messages / VI: Action Edit - chỉ cho user messages */}
|
||||
{isUserMessage && onEdit && (
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
{/* EN: Edit icon / VI: Icon edit */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
{t('chat.edit')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* EN: Regenerate action - only for AI messages / VI: Action Regenerate - chỉ cho AI messages */}
|
||||
{!isUserMessage && onRegenerate && (
|
||||
<DropdownMenuItem onClick={onRegenerate}>
|
||||
{/* EN: Refresh/Regenerate icon / VI: Icon refresh/regenerate */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
</svg>
|
||||
{t('chat.regenerate')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* EN: Like/Dislike actions - available for all messages / VI: Actions Like/Dislike - có sẵn cho tất cả messages */}
|
||||
{onLike && (
|
||||
<DropdownMenuItem
|
||||
onClick={onLike}
|
||||
className={cn(feedback === 'like' && 'bg-bg-tertiary')}
|
||||
>
|
||||
{/* EN: Like icon (thumbs up) / VI: Icon like (thumbs up) */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill={feedback === 'like' ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
>
|
||||
<path d="M7 10v12" />
|
||||
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2h0a3.13 3.13 0 0 1 3 3.88Z" />
|
||||
</svg>
|
||||
{t('chat.like')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{onDislike && (
|
||||
<DropdownMenuItem
|
||||
onClick={onDislike}
|
||||
className={cn(feedback === 'dislike' && 'bg-bg-tertiary')}
|
||||
>
|
||||
{/* EN: Dislike icon (thumbs down) / VI: Icon dislike (thumbs down) */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill={feedback === 'dislike' ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
>
|
||||
<path d="M17 14V2" />
|
||||
<path d="M9 18.12 10 14H4.17a2 2 0 0 0-1.92 2.56l2.33 8A2 2 0 0 0 6.5 22H20a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-2.76a2 2 0 0 0-1.79-1.11L12 2h0a3.13 3.13 0 0 0-3 3.88Z" />
|
||||
</svg>
|
||||
{t('chat.dislike')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* EN: Share action - available for all messages / VI: Action Share - có sẵn cho tất cả messages */}
|
||||
{onShare && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleShare}>
|
||||
{/* EN: Share icon / VI: Icon share */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
>
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" x2="12" y1="2" y2="15" />
|
||||
</svg>
|
||||
{t('chat.share')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* EN: Delete action - only for user messages / VI: Action Delete - chỉ cho user messages */}
|
||||
{isUserMessage && onDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={onDelete}
|
||||
className="text-accent-error focus:text-accent-error focus:bg-bg-tertiary"
|
||||
>
|
||||
{/* EN: Delete icon / VI: Icon delete */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
{t('chat.delete')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,564 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/features/shared/components/ui/avatar';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useI18n } from '@/features/theme';
|
||||
|
||||
/**
|
||||
* EN: Message sender type
|
||||
* VI: Loại người gửi tin nhắn
|
||||
*/
|
||||
export type MessageSender = 'user' | 'ai' | 'system';
|
||||
|
||||
/**
|
||||
* EN: Message bubble props interface
|
||||
* VI: Interface cho props của component Message bubble
|
||||
*/
|
||||
export interface MessageBubbleProps {
|
||||
/**
|
||||
* EN: Message sender type (user, ai, or system)
|
||||
* VI: Loại người gửi tin nhắn (user, ai, hoặc system)
|
||||
*/
|
||||
sender: MessageSender;
|
||||
/**
|
||||
* EN: Message content / VI: Nội dung tin nhắn
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* EN: Message timestamp / VI: Thời gian tin nhắn
|
||||
*/
|
||||
timestamp?: Date | string;
|
||||
/**
|
||||
* EN: Author name / VI: Tên tác giả
|
||||
*/
|
||||
authorName?: string;
|
||||
/**
|
||||
* EN: Author avatar URL / VI: URL avatar tác giả
|
||||
*/
|
||||
authorAvatar?: string;
|
||||
/**
|
||||
* EN: Whether to show message actions on hover
|
||||
* VI: Có hiển thị các hành động tin nhắn khi hover không
|
||||
*/
|
||||
showActions?: boolean;
|
||||
/**
|
||||
* EN: Callback when copy action is clicked
|
||||
* VI: Callback khi hành động copy được click
|
||||
*/
|
||||
onCopy?: () => void;
|
||||
/**
|
||||
* EN: Callback when edit action is clicked (user messages only)
|
||||
* VI: Callback khi hành động edit được click (chỉ tin nhắn user)
|
||||
*/
|
||||
onEdit?: () => void;
|
||||
/**
|
||||
* EN: Callback when delete action is clicked (user messages only)
|
||||
* VI: Callback khi hành động delete được click (chỉ tin nhắn user)
|
||||
*/
|
||||
onDelete?: () => void;
|
||||
/**
|
||||
* EN: Callback when regenerate action is clicked (AI messages only)
|
||||
* VI: Callback khi hành động regenerate được click (chỉ tin nhắn AI)
|
||||
*/
|
||||
onRegenerate?: () => void;
|
||||
/**
|
||||
* EN: Callback when like action is clicked
|
||||
* VI: Callback khi hành động like được click
|
||||
*/
|
||||
onLike?: () => void;
|
||||
/**
|
||||
* EN: Callback when dislike action is clicked
|
||||
* VI: Callback khi hành động dislike được click
|
||||
*/
|
||||
onDislike?: () => void;
|
||||
/**
|
||||
* EN: Callback when share action is clicked
|
||||
* VI: Callback khi hành động share được click
|
||||
*/
|
||||
onShare?: () => void;
|
||||
/**
|
||||
* EN: Whether the message is liked
|
||||
* VI: Tin nhắn có được like không
|
||||
*/
|
||||
isLiked?: boolean;
|
||||
/**
|
||||
* EN: Whether the message is disliked
|
||||
* VI: Tin nhắn có được dislike không
|
||||
*/
|
||||
isDisliked?: boolean;
|
||||
/**
|
||||
* EN: Additional CSS classes
|
||||
* VI: Các class CSS bổ sung
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Format timestamp to readable string
|
||||
* VI: Format timestamp thành chuỗi dễ đọc
|
||||
*/
|
||||
function formatTimestamp(timestamp: Date | string, t: (key: string, values?: any) => string, locale: string): string {
|
||||
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) {
|
||||
return t('chat.justNow');
|
||||
} else if (diffMins < 60) {
|
||||
return t('chat.minutesAgo', { minutes: diffMins });
|
||||
} else if (diffHours < 24) {
|
||||
return t('chat.hoursAgo', { hours: diffHours });
|
||||
} else if (diffDays < 7) {
|
||||
return t('chat.daysAgo', { days: diffDays });
|
||||
} else {
|
||||
return date.toLocaleDateString(locale === 'vi' ? 'vi-VN' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Copy icon SVG
|
||||
* VI: Icon SVG copy
|
||||
*/
|
||||
const CopyIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2h8c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* EN: Edit icon SVG
|
||||
* VI: Icon SVG edit
|
||||
*/
|
||||
const EditIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* EN: Delete icon SVG
|
||||
* VI: Icon SVG delete
|
||||
*/
|
||||
const DeleteIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* EN: Regenerate icon SVG
|
||||
* VI: Icon SVG regenerate
|
||||
*/
|
||||
const RegenerateIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M3 21v-5h5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* EN: Like icon SVG
|
||||
* VI: Icon SVG like
|
||||
*/
|
||||
const LikeIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M7 10v12" />
|
||||
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.67 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2h0a3.13 3.13 0 0 1 3 3.88Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* EN: Dislike icon SVG
|
||||
* VI: Icon SVG dislike
|
||||
*/
|
||||
const DislikeIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M17 14V2" />
|
||||
<path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.67 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22h0a3.13 3.13 0 0 1-3-3.88Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* EN: Share icon SVG
|
||||
* VI: Icon SVG share
|
||||
*/
|
||||
const ShareIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" x2="12" y1="2" y2="15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* EN: Message bubble component - Displays chat messages with user/AI variants and actions
|
||||
* VI: Component Message bubble - Hiển thị tin nhắn chat với các biến thể user/AI và các hành động
|
||||
*
|
||||
* Features:
|
||||
* - User messages: Right aligned, blue bubble
|
||||
* - AI messages: Left aligned, grey bubble
|
||||
* - System messages: Centered, grey text
|
||||
* - Message actions on hover: Copy, Edit, Delete, Regenerate, Like/Dislike, Share
|
||||
* - Animation: Slide up + fade in
|
||||
*
|
||||
* Tính năng:
|
||||
* - Tin nhắn user: Căn phải, bubble xanh
|
||||
* - Tin nhắn AI: Căn trái, bubble xám
|
||||
* - Tin nhắn hệ thống: Căn giữa, text xám
|
||||
* - Hành động tin nhắn khi hover: Copy, Edit, Delete, Regenerate, Like/Dislike, Share
|
||||
* - Animation: Trượt lên + fade in
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MessageBubble
|
||||
* sender="user"
|
||||
* content="Hello, AI!"
|
||||
* timestamp={new Date()}
|
||||
* authorName="John Doe"
|
||||
* showActions
|
||||
* onCopy={() => navigator.clipboard.writeText("Hello, AI!")}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function MessageBubble({
|
||||
sender,
|
||||
content,
|
||||
timestamp,
|
||||
authorName,
|
||||
authorAvatar,
|
||||
showActions = true,
|
||||
onCopy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
onLike,
|
||||
onDislike,
|
||||
onShare,
|
||||
isLiked = false,
|
||||
isDisliked = false,
|
||||
className,
|
||||
}: MessageBubbleProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
const { locale } = useI18n();
|
||||
|
||||
// EN: System messages - centered, simple text / VI: Tin nhắn hệ thống - căn giữa, text đơn giản
|
||||
if (sender === 'system') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex justify-center items-center py-3 px-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="text-sm text-chat-timestamp">{content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUser = sender === 'user';
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// EN: Base message container / VI: Container tin nhắn cơ bản
|
||||
'flex gap-3 px-4 py-3 group',
|
||||
// EN: User messages: Right aligned / VI: Tin nhắn user: Căn phải
|
||||
isUser && 'flex-row-reverse',
|
||||
// EN: AI messages: Left aligned / VI: Tin nhắn AI: Căn trái
|
||||
!isUser && 'flex-row',
|
||||
// EN: Animation: Fade in / VI: Animation: Fade in
|
||||
'opacity-0 animate-fadeIn',
|
||||
className
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* EN: Avatar / VI: Avatar */}
|
||||
{!isUser && (
|
||||
<Avatar size="sm" className="flex-shrink-0">
|
||||
{authorAvatar && (
|
||||
<AvatarImage
|
||||
src={authorAvatar}
|
||||
alt={authorName ? t('chat.avatarOf', { name: authorName }) : t('chat.aiAssistantAvatar')}
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback aria-label={t('chat.aiAssistant')}>AI</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
{/* EN: Message content container / VI: Container nội dung tin nhắn */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col',
|
||||
// EN: Mobile: Full width (95% for padding) / VI: Mobile: Full width (95% cho padding)
|
||||
'max-md:max-w-[95%]',
|
||||
// EN: Tablet: Medium width (60%) / VI: Tablet: Chiều rộng trung bình (60%)
|
||||
'md:max-w-[60%]',
|
||||
// EN: Desktop: Max width 80% / VI: Desktop: Chiều rộng tối đa 80%
|
||||
'lg:max-w-[80%]',
|
||||
// EN: User messages: Align to right / VI: Tin nhắn user: Căn về phải
|
||||
isUser && 'items-end',
|
||||
// EN: AI messages: Align to left / VI: Tin nhắn AI: Căn về trái
|
||||
!isUser && 'items-start'
|
||||
)}
|
||||
>
|
||||
{/* EN: Message header (author, timestamp) / VI: Header tin nhắn (tác giả, thời gian) */}
|
||||
{(authorName || timestamp) && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 mb-1 px-1',
|
||||
isUser && 'flex-row-reverse'
|
||||
)}
|
||||
>
|
||||
{authorName && (
|
||||
<span className="text-xs font-medium text-text-secondary">
|
||||
{authorName}
|
||||
</span>
|
||||
)}
|
||||
{timestamp && (
|
||||
<span className="text-xs text-chat-timestamp">
|
||||
{formatTimestamp(timestamp, t, locale)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EN: Message bubble / VI: Bubble tin nhắn */}
|
||||
<div
|
||||
className={cn(
|
||||
// EN: Base bubble styles / VI: Style bubble cơ bản
|
||||
'relative rounded-lg px-4 py-2.5 break-words',
|
||||
// EN: User bubble: Blue background / VI: Bubble user: Nền xanh
|
||||
isUser &&
|
||||
'bg-chat-user-bubble text-chat-user-text rounded-tr-none',
|
||||
// EN: AI bubble: Grey background / VI: Bubble AI: Nền xám
|
||||
!isUser &&
|
||||
'bg-chat-ai-bubble text-chat-ai-text rounded-tl-none'
|
||||
)}
|
||||
>
|
||||
{/* EN: Message text / VI: Text tin nhắn */}
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{content}</p>
|
||||
|
||||
{/* EN: Message actions (shown on hover) / VI: Hành động tin nhắn (hiện khi hover) */}
|
||||
{showActions && isHovered && (
|
||||
<div
|
||||
className={cn(
|
||||
// EN: Actions container / VI: Container hành động
|
||||
'absolute flex items-center gap-1 rounded-md bg-bg-elevated border border-border-primary p-1 shadow-lg',
|
||||
// EN: User messages: Position to top-right / VI: Tin nhắn user: Vị trí trên-phải
|
||||
isUser && 'top-0 right-full mr-2 flex-row-reverse',
|
||||
// EN: AI messages: Position to top-left / VI: Tin nhắn AI: Vị trí trên-trái
|
||||
!isUser && 'top-0 left-full ml-2'
|
||||
)}
|
||||
>
|
||||
{/* EN: Copy action / VI: Hành động copy */}
|
||||
{onCopy && (
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label={t('chat.copyMessage')}
|
||||
title={t('chat.copy')}
|
||||
>
|
||||
<CopyIcon className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* EN: Edit action (user messages only) / VI: Hành động edit (chỉ tin nhắn user) */}
|
||||
{isUser && onEdit && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label={t('chat.editMessage')}
|
||||
title={t('chat.edit')}
|
||||
>
|
||||
<EditIcon className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* EN: Delete action (user messages only) / VI: Hành động delete (chỉ tin nhắn user) */}
|
||||
{isUser && onDelete && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label={t('chat.deleteMessage')}
|
||||
title={t('chat.delete')}
|
||||
>
|
||||
<DeleteIcon className="w-4 h-4 text-accent-error" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* EN: Regenerate action (AI messages only) / VI: Hành động regenerate (chỉ tin nhắn AI) */}
|
||||
{!isUser && onRegenerate && (
|
||||
<button
|
||||
onClick={onRegenerate}
|
||||
className="p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label={t('chat.regenerateResponse')}
|
||||
title={t('chat.regenerate')}
|
||||
>
|
||||
<RegenerateIcon className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* EN: Like action / VI: Hành động like */}
|
||||
{onLike && (
|
||||
<button
|
||||
onClick={onLike}
|
||||
className={cn(
|
||||
'p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center',
|
||||
isLiked && 'text-accent-success'
|
||||
)}
|
||||
aria-label={t('chat.likeMessage')}
|
||||
title={t('chat.like')}
|
||||
>
|
||||
<LikeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* EN: Dislike action / VI: Hành động dislike */}
|
||||
{onDislike && (
|
||||
<button
|
||||
onClick={onDislike}
|
||||
className={cn(
|
||||
'p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center',
|
||||
isDisliked && 'text-accent-error'
|
||||
)}
|
||||
aria-label={t('chat.dislikeMessage')}
|
||||
title={t('chat.dislike')}
|
||||
>
|
||||
<DislikeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* EN: Share action / VI: Hành động share */}
|
||||
{onShare && (
|
||||
<button
|
||||
onClick={onShare}
|
||||
className="p-1.5 rounded hover:bg-bg-tertiary active:scale-[0.95] active:bg-bg-elevated transition-all duration-[150ms] min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label={t('chat.shareMessage')}
|
||||
title={t('chat.share')}
|
||||
>
|
||||
<ShareIcon className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: User avatar (right side) / VI: Avatar user (bên phải) */}
|
||||
{isUser && (
|
||||
<Avatar size="sm" className="flex-shrink-0">
|
||||
{authorAvatar && (
|
||||
<AvatarImage
|
||||
src={authorAvatar}
|
||||
alt={authorName ? t('chat.avatarOf', { name: authorName }) : t('chat.userAvatar')}
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback aria-label={authorName ? t('chat.avatarOf', { name: authorName }) : t('chat.userAvatar')}>
|
||||
{authorName
|
||||
? authorName
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
: 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* EN: TypingIndicator component props interface
|
||||
* VI: Interface cho props của component TypingIndicator
|
||||
*/
|
||||
export interface TypingIndicatorProps {
|
||||
/**
|
||||
* EN: Number of dots to display (default: 3) / VI: Số lượng chấm hiển thị (mặc định: 3)
|
||||
*/
|
||||
dotCount?: number;
|
||||
/**
|
||||
* EN: Animation duration in milliseconds (default: 1400) / VI: Thời lượng animation tính bằng milliseconds (mặc định: 1400)
|
||||
*/
|
||||
duration?: number;
|
||||
/**
|
||||
* EN: Size of each dot in pixels (default: 8) / VI: Kích thước mỗi chấm tính bằng pixels (mặc định: 8)
|
||||
*/
|
||||
dotSize?: number;
|
||||
/**
|
||||
* EN: Color of the dots (default: uses theme tertiary text color) / VI: Màu của các chấm (mặc định: sử dụng màu text tertiary của theme)
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* EN: Additional CSS classes / VI: Các class CSS bổ sung
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* EN: Label for accessibility (default: "AI is typing...") / VI: Nhãn cho accessibility (mặc định: "AI đang nhập...")
|
||||
*/
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: TypingIndicator component - Animated pulsing dots to indicate typing status
|
||||
* VI: Component TypingIndicator - Các chấm nhấp nháy để hiển thị trạng thái đang nhập
|
||||
*
|
||||
* Features:
|
||||
* - Pulsing dots animation with staggered delays
|
||||
* - Configurable dot count, size, and color
|
||||
* - Smooth animation using CSS keyframes
|
||||
* - Accessibility support with aria-label
|
||||
* - Follows design system theme colors
|
||||
*
|
||||
* Tính năng:
|
||||
* - Animation các chấm nhấp nháy với độ trễ lệch nhau
|
||||
* - Có thể cấu hình số lượng chấm, kích thước và màu sắc
|
||||
* - Animation mượt mà sử dụng CSS keyframes
|
||||
* - Hỗ trợ accessibility với aria-label
|
||||
* - Tuân theo màu sắc của design system
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <TypingIndicator />
|
||||
* <TypingIndicator dotCount={4} dotSize={10} />
|
||||
* <TypingIndicator color="#3B82F6" aria-label="User is typing..." />
|
||||
* ```
|
||||
*/
|
||||
export function TypingIndicator({
|
||||
dotCount = 3,
|
||||
duration = 1400,
|
||||
dotSize = 8,
|
||||
color,
|
||||
className,
|
||||
'aria-label': ariaLabel,
|
||||
}: TypingIndicatorProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const t = useTranslations();
|
||||
const defaultAriaLabel = ariaLabel || t('chat.typing', { defaultValue: 'AI is typing...' });
|
||||
// EN: Generate array of dot indices for rendering / VI: Tạo mảng các chỉ số chấm để render
|
||||
const dots = React.useMemo(() => {
|
||||
return Array.from({ length: dotCount }, (_, i) => i);
|
||||
}, [dotCount]);
|
||||
|
||||
// EN: Calculate animation delay for each dot (staggered) / VI: Tính toán độ trễ animation cho mỗi chấm (lệch nhau)
|
||||
const getDelay = (index: number) => {
|
||||
// EN: Stagger delay by 200ms per dot / VI: Lệch độ trễ 200ms cho mỗi chấm
|
||||
return (index * 200) / duration;
|
||||
};
|
||||
|
||||
// EN: Generate inline styles for animation / VI: Tạo inline styles cho animation
|
||||
const getDotStyle = (index: number): React.CSSProperties => {
|
||||
return {
|
||||
width: `${dotSize}px`,
|
||||
height: `${dotSize}px`,
|
||||
animationDelay: `${getDelay(index)}s`,
|
||||
animationDuration: `${duration}ms`,
|
||||
backgroundColor: color || 'var(--text-tertiary)', // EN: Default to theme tertiary text color / VI: Mặc định là màu text tertiary của theme
|
||||
animationTimingFunction: 'ease-in-out',
|
||||
animationIterationCount: 'infinite',
|
||||
animationName: 'typing-pulse',
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2',
|
||||
'px-4 py-3',
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
aria-label={defaultAriaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
{dots.map((index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="typing-dot rounded-full"
|
||||
style={getDotStyle(index)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* EN: Code block with syntax highlighting and copy-to-clipboard
|
||||
* VI: Code block với syntax highlighting và copy-to-clipboard
|
||||
*
|
||||
* Displays formatted code with copy functionality
|
||||
* Hiển thị code đã format với tính năng copy
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export default function CodeBlock({ code, language }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* EN: Copy Button / VI: Nút copy */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 glass-button text-xs px-3 py-1.5"
|
||||
>
|
||||
{copied ? '✓ Copied!' : 'Copy'}
|
||||
</button>
|
||||
|
||||
{/* EN: Code Display / VI: Hiển thị code */}
|
||||
<pre className="bg-bg-tertiary rounded-lg p-4 overflow-x-auto">
|
||||
<code className="text-sm text-text-primary font-mono">{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* EN: Color swatch component to display CSS variable colors
|
||||
* VI: Component color swatch để hiển thị màu từ CSS variable
|
||||
*
|
||||
* Shows color preview with variable name and value
|
||||
* Hiển thị preview màu với tên biến và giá trị
|
||||
*/
|
||||
'use client';
|
||||
|
||||
interface ColorSwatchProps {
|
||||
name: string;
|
||||
var: string;
|
||||
}
|
||||
|
||||
export default function ColorSwatch({ name, var: cssVar }: ColorSwatchProps) {
|
||||
return (
|
||||
<div className="glass-card p-4">
|
||||
{/* EN: Color Preview Box / VI: Hộp preview màu */}
|
||||
<div
|
||||
className="w-full h-24 rounded-lg mb-3 border border-border-primary"
|
||||
style={{ backgroundColor: cssVar }}
|
||||
/>
|
||||
|
||||
{/* EN: Color Variable Name / VI: Tên biến màu */}
|
||||
<p className="text-sm font-medium text-text-primary">{name}</p>
|
||||
|
||||
{/* EN: CSS Variable / VI: CSS Variable */}
|
||||
<code className="text-xs text-text-tertiary font-mono">{cssVar}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* EN: Component showcase wrapper with code preview
|
||||
* VI: Wrapper showcase component kèm code preview
|
||||
*
|
||||
* Displays component demo with optional code snippet
|
||||
* Hiển thị demo component với tùy chọn xem code snippet
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import CodeBlock from './CodeBlock';
|
||||
|
||||
interface ComponentShowcaseProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
code: string;
|
||||
children: React.ReactNode;
|
||||
darkBg?: boolean; // EN: For light components on dark background / VI: Cho light components trên dark background
|
||||
}
|
||||
|
||||
export default function ComponentShowcase({
|
||||
title,
|
||||
description,
|
||||
code,
|
||||
children,
|
||||
darkBg = false,
|
||||
}: ComponentShowcaseProps) {
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="glass-card p-6">
|
||||
{/* EN: Title and description / VI: Tiêu đề và mô tả */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xl font-semibold text-text-primary">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-text-tertiary text-sm mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EN: Component Preview Area / VI: Khu vực preview component */}
|
||||
<div
|
||||
className={`rounded-lg border border-border-primary p-8 mb-4 ${darkBg ? 'bg-bg-primary' : 'bg-bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* EN: Toggle Code Button / VI: Nút toggle code */}
|
||||
<button
|
||||
onClick={() => setShowCode(!showCode)}
|
||||
className="glass-button text-sm"
|
||||
>
|
||||
{showCode ? 'Hide Code' : 'Show Code'}
|
||||
</button>
|
||||
|
||||
{/* EN: Code Block / VI: Code block */}
|
||||
{showCode && (
|
||||
<div className="mt-4">
|
||||
<CodeBlock code={code} language="tsx" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* EN: Section wrapper component for Mood Board sections
|
||||
* VI: Component wrapper cho các sections của Mood Board
|
||||
*
|
||||
* Provides consistent layout and styling for each section
|
||||
* Cung cấp layout và styling nhất quán cho mỗi section
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
interface SectionWrapperProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function SectionWrapper({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: SectionWrapperProps) {
|
||||
return (
|
||||
<section id={id} className="scroll-mt-24">
|
||||
{/* EN: Section Header / VI: Header của section */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-text-primary">
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="text-text-secondary mt-2">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EN: Section Content / VI: Nội dung section */}
|
||||
<div className="space-y-6">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
/**
|
||||
* EN: LiveRegion component props
|
||||
* VI: Props của component LiveRegion
|
||||
*/
|
||||
export interface LiveRegionProps {
|
||||
/**
|
||||
* EN: Announcement message / VI: Thông báo
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* EN: Priority level (polite or assertive) / VI: Mức độ ưu tiên (polite hoặc assertive)
|
||||
*/
|
||||
priority?: 'polite' | 'assertive';
|
||||
/**
|
||||
* EN: Additional CSS classes / VI: Các class CSS bổ sung
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: LiveRegion component - Announces changes to screen readers
|
||||
* VI: Component LiveRegion - Thông báo thay đổi cho screen readers
|
||||
*
|
||||
* Features:
|
||||
* - ARIA live region for announcements
|
||||
* - Polite or assertive priority
|
||||
* - Hidden visually but accessible to screen readers
|
||||
*
|
||||
* Tính năng:
|
||||
* - Vùng live ARIA cho thông báo
|
||||
* - Mức độ ưu tiên polite hoặc assertive
|
||||
* - Ẩn về mặt thị giác nhưng có thể truy cập bởi screen readers
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LiveRegion message="New message received" priority="polite" />
|
||||
* ```
|
||||
*/
|
||||
export function LiveRegion({ message, priority = 'polite', className }: LiveRegionProps) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live={priority}
|
||||
aria-atomic="true"
|
||||
className={cn('sr-only', className)}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
/**
|
||||
* EN: SkipToContent component - Skip link for keyboard navigation
|
||||
* VI: Component SkipToContent - Link bỏ qua cho điều hướng bàn phím
|
||||
*
|
||||
* Features:
|
||||
* - Appears on focus (keyboard navigation)
|
||||
* - Links to main content area
|
||||
* - WCAG 2.1 AA compliant
|
||||
*
|
||||
* Tính năng:
|
||||
* - Xuất hiện khi focus (điều hướng bàn phím)
|
||||
* - Link đến khu vực nội dung chính
|
||||
* - Tuân thủ WCAG 2.1 AA
|
||||
*/
|
||||
export function SkipToContent() {
|
||||
return (
|
||||
<a
|
||||
href="#main-content"
|
||||
className={cn(
|
||||
// EN: Hidden by default, visible on focus / VI: Ẩn mặc định, hiện khi focus
|
||||
'sr-only focus:not-sr-only',
|
||||
'fixed top-4 left-4 z-[100]',
|
||||
'px-4 py-2',
|
||||
'bg-accent-primary text-white',
|
||||
'rounded-md',
|
||||
'font-medium text-sm',
|
||||
'shadow-lg',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2',
|
||||
'transition-all duration-[150ms]'
|
||||
)}
|
||||
aria-label="Skip to main content / Bỏ qua đến nội dung chính"
|
||||
>
|
||||
Skip to content / Bỏ qua đến nội dung
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useTheme } from '@/features/theme';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { BRAND, getBrandLogo } from '@/shared/lib/brand-constants';
|
||||
|
||||
/**
|
||||
* EN: Brand logo variant types
|
||||
* VI: Các kiểu biến thể logo thương hiệu
|
||||
*/
|
||||
export type LogoVariant = 'full' | 'icon' | 'wordmark';
|
||||
|
||||
/**
|
||||
* EN: Brand logo size presets
|
||||
* VI: Kích thước logo định sẵn
|
||||
*/
|
||||
export type LogoSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
|
||||
/**
|
||||
* EN: Brand logo component props
|
||||
* VI: Props cho component logo thương hiệu
|
||||
*/
|
||||
export interface BrandLogoProps {
|
||||
/** Logo variant / Biến thể logo */
|
||||
variant?: LogoVariant;
|
||||
/** Logo size / Kích thước logo */
|
||||
size?: LogoSize;
|
||||
/** Additional CSS classes / CSS classes bổ sung */
|
||||
className?: string;
|
||||
/** Whether to use Next.js Image optimization / Có sử dụng tối ưu Image của Next.js không */
|
||||
priority?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Size mappings for different logo sizes
|
||||
* VI: Ánh xạ kích thước cho các size logo khác nhau
|
||||
*/
|
||||
const sizeClasses: Record<LogoSize, { width: string; height: string }> = {
|
||||
xs: { width: 'w-16', height: 'h-4' },
|
||||
sm: { width: 'w-24', height: 'h-6' },
|
||||
md: { width: 'w-32', height: 'h-8' },
|
||||
lg: { width: 'w-48', height: 'h-12' },
|
||||
xl: { width: 'w-64', height: 'h-16' },
|
||||
'2xl': { width: 'w-80', height: 'h-20' },
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Size mappings for icon variant
|
||||
* VI: Ánh xạ kích thước cho biến thể icon
|
||||
*/
|
||||
const iconSizeClasses: Record<LogoSize, { width: string; height: string }> = {
|
||||
xs: { width: 'w-4', height: 'h-4' },
|
||||
sm: { width: 'w-6', height: 'h-6' },
|
||||
md: { width: 'w-8', height: 'h-8' },
|
||||
lg: { width: 'w-12', height: 'h-12' },
|
||||
xl: { width: 'w-16', height: 'h-16' },
|
||||
'2xl': { width: 'w-20', height: 'h-20' },
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Brand Logo Component - Displays brand logo with automatic theme switching
|
||||
* VI: Component Logo Thương Hiệu - Hiển thị logo với tự động chuyển theme
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <BrandLogo variant="full" size="lg" />
|
||||
* <BrandLogo variant="icon" size="md" />
|
||||
* <BrandLogo variant="wordmark" size="sm" />
|
||||
* ```
|
||||
*/
|
||||
export function BrandLogo({
|
||||
variant = 'full',
|
||||
size = 'md',
|
||||
className,
|
||||
priority = false,
|
||||
}: BrandLogoProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
// EN: Get logo path based on variant
|
||||
// VI: Lấy đường dẫn logo dựa trên biến thể
|
||||
const logoSrc = getBrandLogo(variant);
|
||||
|
||||
// EN: Select size classes based on variant
|
||||
// VI: Chọn class kích thước dựa trên biến thể
|
||||
const sizes = variant === 'icon' ? iconSizeClasses[size] : sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center',
|
||||
sizes.width,
|
||||
sizes.height,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={BRAND.name}
|
||||
fill
|
||||
priority={priority}
|
||||
className="object-contain"
|
||||
style={{
|
||||
// EN: Apply theme-aware color for SVG text elements
|
||||
// VI: Áp dụng màu nhận biết theme cho SVG text elements
|
||||
color: resolvedTheme === 'dark' ? '#FFFFFF' : '#000000',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Brand Logo Link - Clickable logo that links to home
|
||||
* VI: Logo Link - Logo có thể nhấp dẫn về trang chủ
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <BrandLogoLink variant="full" size="lg" href="/" />
|
||||
* ```
|
||||
*/
|
||||
export interface BrandLogoLinkProps extends BrandLogoProps {
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export function BrandLogoLink({
|
||||
href = '/',
|
||||
...logoProps
|
||||
}: BrandLogoLinkProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="inline-block transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2 rounded-sm"
|
||||
aria-label={`${BRAND.name} - Go to homepage`}
|
||||
>
|
||||
<BrandLogo {...logoProps} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* EN: Brand components exports
|
||||
* VI: Xuất các component thương hiệu
|
||||
*/
|
||||
|
||||
export * from './brand-logo';
|
||||