Compare commits
9 Commits
68e04a911a
...
7aa63d79df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa63d79df | ||
|
|
333c9c40af | ||
|
|
0b192ce030 | ||
|
|
da021d0640 | ||
|
|
d1b47006f4 | ||
|
|
a73d3c7d3e | ||
|
|
55ae92c460 | ||
|
|
fe6a98c379 | ||
|
|
b7c1191335 |
@@ -23,10 +23,7 @@ def upgrade() -> None:
|
|||||||
# VARCHAR(10) supports BCP 47 format (e.g., "en", "it", "en-US", "it-IT")
|
# VARCHAR(10) supports BCP 47 format (e.g., "en", "it", "en-US", "it-IT")
|
||||||
# Nullable: NULL means "not set yet", will use Accept-Language header fallback
|
# Nullable: NULL means "not set yet", will use Accept-Language header fallback
|
||||||
# Indexed: For analytics queries and filtering by locale
|
# Indexed: For analytics queries and filtering by locale
|
||||||
op.add_column(
|
op.add_column("users", sa.Column("locale", sa.String(length=10), nullable=True))
|
||||||
"users",
|
|
||||||
sa.Column("locale", sa.String(length=10), nullable=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create index on locale column for performance
|
# Create index on locale column for performance
|
||||||
op.create_index(
|
op.create_index(
|
||||||
|
|||||||
@@ -117,8 +117,9 @@ async def get_locale(
|
|||||||
if current_user and current_user.locale:
|
if current_user and current_user.locale:
|
||||||
# Validate that saved locale is still supported
|
# Validate that saved locale is still supported
|
||||||
# (in case SUPPORTED_LOCALES changed after user set preference)
|
# (in case SUPPORTED_LOCALES changed after user set preference)
|
||||||
if current_user.locale in SUPPORTED_LOCALES:
|
locale_value = str(current_user.locale)
|
||||||
return current_user.locale
|
if locale_value in SUPPORTED_LOCALES:
|
||||||
|
return locale_value
|
||||||
|
|
||||||
# Priority 2: Accept-Language header
|
# Priority 2: Accept-Language header
|
||||||
accept_language = request.headers.get("accept-language", "")
|
accept_language = request.headers.get("accept-language", "")
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ class UserUpdate(BaseModel):
|
|||||||
locale: str | None = Field(
|
locale: str | None = Field(
|
||||||
None,
|
None,
|
||||||
max_length=10,
|
max_length=10,
|
||||||
pattern=r'^[a-z]{2}(-[A-Z]{2})?$',
|
pattern=r"^[a-z]{2}(-[A-Z]{2})?$",
|
||||||
description="User's preferred locale (BCP 47 format: en, it, en-US, it-IT)",
|
description="User's preferred locale (BCP 47 format: en, it, en-US, it-IT)",
|
||||||
examples=["en", "it", "en-US", "it-IT"]
|
examples=["en", "it", "en-US", "it-IT"],
|
||||||
)
|
)
|
||||||
is_active: bool | None = (
|
is_active: bool | None = (
|
||||||
None # Changed default from True to None to avoid unintended updates
|
None # Changed default from True to None to avoid unintended updates
|
||||||
@@ -70,12 +70,12 @@ class UserUpdate(BaseModel):
|
|||||||
return v
|
return v
|
||||||
# Only support English and Italian for template showcase
|
# Only support English and Italian for template showcase
|
||||||
# Note: Locales stored in lowercase for case-insensitive matching
|
# Note: Locales stored in lowercase for case-insensitive matching
|
||||||
SUPPORTED_LOCALES = {"en", "it", "en-us", "en-gb", "it-it"}
|
supported_locales = {"en", "it", "en-us", "en-gb", "it-it"}
|
||||||
# Normalize to lowercase for comparison and storage
|
# Normalize to lowercase for comparison and storage
|
||||||
v_lower = v.lower()
|
v_lower = v.lower()
|
||||||
if v_lower not in SUPPORTED_LOCALES:
|
if v_lower not in supported_locales:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unsupported locale '{v}'. Supported locales: {sorted(SUPPORTED_LOCALES)}"
|
f"Unsupported locale '{v}'. Supported locales: {sorted(supported_locales)}"
|
||||||
)
|
)
|
||||||
# Return normalized lowercase version for consistency
|
# Return normalized lowercase version for consistency
|
||||||
return v_lower
|
return v_lower
|
||||||
|
|||||||
@@ -67,9 +67,7 @@ class TestParseAcceptLanguage:
|
|||||||
|
|
||||||
def test_parse_complex_header(self):
|
def test_parse_complex_header(self):
|
||||||
"""Test complex Accept-Language header with multiple locales"""
|
"""Test complex Accept-Language header with multiple locales"""
|
||||||
result = parse_accept_language(
|
result = parse_accept_language("it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7,fr;q=0.6")
|
||||||
"it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7,fr;q=0.6"
|
|
||||||
)
|
|
||||||
assert result == "it-it"
|
assert result == "it-it"
|
||||||
|
|
||||||
def test_parse_whitespace_handling(self):
|
def test_parse_whitespace_handling(self):
|
||||||
@@ -199,9 +197,7 @@ class TestGetLocale:
|
|||||||
assert result == "en"
|
assert result == "en"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_locale_from_accept_language_header(
|
async def test_locale_from_accept_language_header(self, async_user_without_locale):
|
||||||
self, async_user_without_locale
|
|
||||||
):
|
|
||||||
"""Test locale detection from Accept-Language header when user has no preference"""
|
"""Test locale detection from Accept-Language header when user has no preference"""
|
||||||
# Mock request with Italian Accept-Language (it-IT has highest priority)
|
# Mock request with Italian Accept-Language (it-IT has highest priority)
|
||||||
mock_request = MagicMock()
|
mock_request = MagicMock()
|
||||||
|
|||||||
@@ -334,11 +334,7 @@ class TestLocaleValidation:
|
|||||||
def test_locale_in_user_update_with_other_fields(self):
|
def test_locale_in_user_update_with_other_fields(self):
|
||||||
"""Test locale validation works when combined with other fields"""
|
"""Test locale validation works when combined with other fields"""
|
||||||
# Valid locale with other fields
|
# Valid locale with other fields
|
||||||
user = UserUpdate(
|
user = UserUpdate(first_name="Mario", last_name="Rossi", locale="it")
|
||||||
first_name="Mario",
|
|
||||||
last_name="Rossi",
|
|
||||||
locale="it"
|
|
||||||
)
|
|
||||||
assert user.locale == "it"
|
assert user.locale == "it"
|
||||||
assert user.first_name == "Mario"
|
assert user.first_name == "Mario"
|
||||||
|
|
||||||
@@ -347,7 +343,7 @@ class TestLocaleValidation:
|
|||||||
UserUpdate(
|
UserUpdate(
|
||||||
first_name="Pierre",
|
first_name="Pierre",
|
||||||
last_name="Dupont",
|
last_name="Dupont",
|
||||||
locale="fr" # Unsupported
|
locale="fr", # Unsupported
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_supported_locales_list(self):
|
def test_supported_locales_list(self):
|
||||||
@@ -357,7 +353,9 @@ class TestLocaleValidation:
|
|||||||
# Expected output (normalized to lowercase)
|
# Expected output (normalized to lowercase)
|
||||||
expected_outputs = ["en", "it", "en-us", "en-gb", "it-it"]
|
expected_outputs = ["en", "it", "en-us", "en-gb", "it-it"]
|
||||||
|
|
||||||
for input_locale, expected_output in zip(input_locales, expected_outputs):
|
for input_locale, expected_output in zip(
|
||||||
|
input_locales, expected_outputs, strict=True
|
||||||
|
):
|
||||||
user = UserUpdate(locale=input_locale)
|
user = UserUpdate(locale=input_locale)
|
||||||
assert user.locale == expected_output
|
assert user.locale == expected_output
|
||||||
|
|
||||||
|
|||||||
528
docs/I18N_IMPLEMENTATION_PLAN.md
Normal file
528
docs/I18N_IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
# Internationalization (i18n) Implementation Plan
|
||||||
|
## State-of-the-Art Next.js 15 + FastAPI i18n System (2025)
|
||||||
|
|
||||||
|
**Last Updated**: 2025-11-17
|
||||||
|
**Status**: ✅ Approved - Ready for Implementation
|
||||||
|
**Languages**: English (en) - Default, Italian (it) - Showcase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 EXECUTIVE SUMMARY
|
||||||
|
|
||||||
|
This document provides a comprehensive plan for implementing production-grade internationalization (i18n) in the FastNext Template. The implementation showcases best practices with 2 languages (English and Italian), making it easy for users to extend with additional languages.
|
||||||
|
|
||||||
|
### Technology Stack (2025 Best Practices)
|
||||||
|
|
||||||
|
- **Frontend**: next-intl 4.0 (ESM, TypeScript-first, App Router native)
|
||||||
|
- **Backend**: FastAPI with BCP 47 locale storage in PostgreSQL
|
||||||
|
- **Testing**: Playwright (parameterized locale tests) + Jest (i18n test utils)
|
||||||
|
- **SEO**: Automatic hreflang tags, sitemap generation, metadata per locale
|
||||||
|
- **Validation**: Automated translation key validation in CI/CD
|
||||||
|
|
||||||
|
### Why Only 2 Languages?
|
||||||
|
|
||||||
|
This is a **template showcase**, not a production deployment:
|
||||||
|
|
||||||
|
✅ Clean example of i18n implementation
|
||||||
|
✅ Easy to understand patterns
|
||||||
|
✅ Users can add languages by copying the Italian example
|
||||||
|
✅ Faster testing and implementation
|
||||||
|
✅ Smaller bundle size for demonstration
|
||||||
|
|
||||||
|
### Quality Standards
|
||||||
|
|
||||||
|
- ✅ **Test Coverage**: Backend ≥97%, comprehensive E2E tests
|
||||||
|
- ✅ **Zero Breaking Changes**: All existing 743 backend + 56 frontend tests must pass
|
||||||
|
- ✅ **Type Safety**: Full autocomplete for translation keys
|
||||||
|
- ✅ **Performance**: Core Web Vitals maintained (LCP < 2.5s, INP < 200ms, CLS < 0.1)
|
||||||
|
- ✅ **SEO**: Lighthouse SEO score 100 for both locales
|
||||||
|
- ✅ **GDPR Compliant**: User locale preferences handled appropriately
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 IMPLEMENTATION PHASES
|
||||||
|
|
||||||
|
### Phase 0: Documentation & Planning (2 hours)
|
||||||
|
- Create this implementation plan document
|
||||||
|
- Document architecture decisions
|
||||||
|
|
||||||
|
### Phase 1: Backend Foundation (4 hours)
|
||||||
|
- Add `locale` column to User model
|
||||||
|
- Create database migration
|
||||||
|
- Update Pydantic schemas
|
||||||
|
- Create locale detection dependency
|
||||||
|
- Add backend tests
|
||||||
|
|
||||||
|
### Phase 2: Frontend Setup (4 hours)
|
||||||
|
- Install and configure next-intl
|
||||||
|
- Create translation files (EN, IT)
|
||||||
|
- Configure TypeScript autocomplete
|
||||||
|
- Restructure App Router for [locale] pattern
|
||||||
|
- Fix tests
|
||||||
|
|
||||||
|
### Phase 3: Component Translation (4 hours)
|
||||||
|
- Create LocaleSwitcher component
|
||||||
|
- Translate auth components
|
||||||
|
- Translate navigation components
|
||||||
|
- Translate settings components
|
||||||
|
- Review and test
|
||||||
|
|
||||||
|
### Phase 4: SEO & Metadata (3 hours)
|
||||||
|
- Implement locale-aware metadata
|
||||||
|
- Generate multilingual sitemap
|
||||||
|
- Configure robots.txt
|
||||||
|
- SEO validation
|
||||||
|
|
||||||
|
### Phase 5: Performance Optimization (3 hours)
|
||||||
|
- Measure Core Web Vitals baseline
|
||||||
|
- Optimize translation loading
|
||||||
|
- Prevent CLS with font loading
|
||||||
|
- Performance validation
|
||||||
|
|
||||||
|
### Phase 6: Comprehensive Testing (4 hours)
|
||||||
|
- Backend integration tests
|
||||||
|
- Frontend E2E locale tests
|
||||||
|
- Frontend unit tests
|
||||||
|
- Translation validation automation
|
||||||
|
|
||||||
|
### Phase 7: Documentation & Polish (2 hours)
|
||||||
|
- Update technical documentation
|
||||||
|
- Create migration guide
|
||||||
|
- Final SEO and performance validation
|
||||||
|
|
||||||
|
### Phase 8: Deployment Prep (2 hours)
|
||||||
|
- Update README
|
||||||
|
- Create release notes
|
||||||
|
- Deployment checklist
|
||||||
|
|
||||||
|
**Total Time**: ~28 hours (~3.5 days) + 20% buffer = **4 days**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ ARCHITECTURE DECISIONS
|
||||||
|
|
||||||
|
### 1. Locale Format: BCP 47
|
||||||
|
|
||||||
|
**Decision**: Use BCP 47 language tags (e.g., "en", "it", "en-US", "it-IT")
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Industry standard (used by HTTP Accept-Language, HTML lang attribute, ICU libraries)
|
||||||
|
- Based on ISO 639-1 (language) + ISO 3166-1 (region)
|
||||||
|
- Flexible: start simple with 2-letter codes, add region qualifiers when needed
|
||||||
|
- Future-proof for dialects and scripts (e.g., "zh-Hans" for Simplified Chinese)
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
# Backend validation
|
||||||
|
SUPPORTED_LOCALES = {"en", "it", "en-US", "en-GB", "it-IT"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. URL Structure: Subdirectory
|
||||||
|
|
||||||
|
**Decision**: `/[locale]/[path]` format (e.g., `/en/about`, `/it/about`)
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- ❌ Subdomain (`en.example.com`) - Too complex for template
|
||||||
|
- ❌ Country-code TLD (`example.it`) - Too expensive, not needed for template
|
||||||
|
- ❌ URL parameters (`?lang=en`) - Poor SEO, harder to crawl
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- ✅ **Best SEO**: Google explicitly recommends subdirectories for most sites
|
||||||
|
- ✅ **Simple Infrastructure**: Single domain, single deployment
|
||||||
|
- ✅ **Low Cost**: No multiple domain purchases
|
||||||
|
- ✅ **Easy to Maintain**: Centralized analytics and tooling
|
||||||
|
- ✅ **Clear URLs**: Users can easily switch locales by changing URL
|
||||||
|
|
||||||
|
**SEO Benefits**:
|
||||||
|
- Domain authority consolidates to one domain
|
||||||
|
- Backlinks benefit all language versions
|
||||||
|
- Easier to build authority than multiple domains
|
||||||
|
- Works seamlessly with hreflang tags
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Database Schema: Dedicated `locale` Column
|
||||||
|
|
||||||
|
**Decision**: Add `locale VARCHAR(10)` column to `users` table, NOT JSONB
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- ❌ Store in `preferences` JSONB field - 2-10x slower queries, no optimizer statistics
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- ✅ **Performance**: B-tree index vs GIN index (smaller, faster)
|
||||||
|
- ✅ **Query Optimization**: PostgreSQL can maintain statistics on column values
|
||||||
|
- ✅ **Disk Space**: JSONB stores keys repeatedly (inefficient for common fields)
|
||||||
|
- ✅ **Update Performance**: Updating JSONB requires rewriting entire field + indexes
|
||||||
|
|
||||||
|
**Schema**:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE users ADD COLUMN locale VARCHAR(10) DEFAULT NULL;
|
||||||
|
CREATE INDEX ix_users_locale ON users(locale);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Nullable?**
|
||||||
|
- Distinguish "never set" (NULL) from "explicitly set to English"
|
||||||
|
- Allows lazy loading on first request (more accurate than backfilling)
|
||||||
|
|
||||||
|
**Why No Database DEFAULT?**
|
||||||
|
- Application-level default provides more flexibility
|
||||||
|
- Can implement locale detection logic (Accept-Language header)
|
||||||
|
- Easier to change default without migration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Locale Detection Priority
|
||||||
|
|
||||||
|
**Decision**: Three-tier fallback system
|
||||||
|
|
||||||
|
1. **User's Saved Preference** (highest priority)
|
||||||
|
- If authenticated and `user.locale` is set, use it
|
||||||
|
- Persists across sessions and devices
|
||||||
|
|
||||||
|
2. **Accept-Language Header** (second priority)
|
||||||
|
- For unauthenticated users
|
||||||
|
- Parse `Accept-Language: it-IT,it;q=0.9,en;q=0.8` → "it-IT"
|
||||||
|
- Validate against supported locales
|
||||||
|
|
||||||
|
3. **Default to English** (fallback)
|
||||||
|
- If no user preference and no header match
|
||||||
|
|
||||||
|
**Implementation** (Backend):
|
||||||
|
```python
|
||||||
|
async def get_locale(
|
||||||
|
request: Request,
|
||||||
|
current_user: User | None = Depends(get_optional_current_user)
|
||||||
|
) -> str:
|
||||||
|
if current_user and current_user.locale:
|
||||||
|
return current_user.locale
|
||||||
|
|
||||||
|
accept_language = request.headers.get("accept-language", "")
|
||||||
|
if accept_language:
|
||||||
|
locale = accept_language.split(',')[0].split(';')[0].strip()
|
||||||
|
lang_code = locale.split('-')[0].lower()
|
||||||
|
if lang_code in {"en", "it"}:
|
||||||
|
return locale.lower()
|
||||||
|
|
||||||
|
return "en"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Translation Storage: Server-Side Only (next-intl)
|
||||||
|
|
||||||
|
**Decision**: Use next-intl's server-component pattern, NOT client-side translation loading
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- ✅ **Zero Client Bundle Overhead**: Translations never sent to browser
|
||||||
|
- ✅ **Instant Page Loads**: No translation parsing on client
|
||||||
|
- ✅ **Better SEO**: Fully rendered HTML for search engines
|
||||||
|
- ✅ **Reduced Bandwidth**: Especially important for mobile users
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```typescript
|
||||||
|
// Server Component (preferred 95% of the time)
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const t = await getTranslations('HomePage');
|
||||||
|
return <h1>{t('title')}</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client Component (only when needed for interactivity)
|
||||||
|
'use client';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const t = useTranslations('auth.Login');
|
||||||
|
return <form>{/* ... */}</form>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Performance Impact**:
|
||||||
|
- next-intl core: ~9.2kb gzipped
|
||||||
|
- Translation files: 0kb on client (server-side only)
|
||||||
|
- Tree-shaking: Automatic with ESM build
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Translation File Structure: Nested Namespaces
|
||||||
|
|
||||||
|
**Decision**: Use nested JSON structure with namespaces, not flat keys
|
||||||
|
|
||||||
|
**Bad (Flat)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth_login_title": "Sign in",
|
||||||
|
"auth_login_email_label": "Email",
|
||||||
|
"auth_register_title": "Sign up"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Good (Nested)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"Login": {
|
||||||
|
"title": "Sign in",
|
||||||
|
"emailLabel": "Email"
|
||||||
|
},
|
||||||
|
"Register": {
|
||||||
|
"title": "Sign up"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- ✅ **Better Organization**: Logical grouping by feature
|
||||||
|
- ✅ **Easier Maintenance**: Find related translations quickly
|
||||||
|
- ✅ **Type Safety**: Full autocomplete with TypeScript integration
|
||||||
|
- ✅ **Scalability**: Easy to split into separate files later
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```typescript
|
||||||
|
const t = useTranslations('auth.Login');
|
||||||
|
t('title'); // "Sign in"
|
||||||
|
t('emailLabel'); // "Email"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. SEO Strategy: Hreflang + Sitemap + Metadata
|
||||||
|
|
||||||
|
**Decision**: Dual implementation for comprehensive SEO
|
||||||
|
|
||||||
|
**1. HTML `<link>` Tags** (via `generateMetadata`):
|
||||||
|
```typescript
|
||||||
|
export async function generateMetadata({ params: { locale } }) {
|
||||||
|
return {
|
||||||
|
alternates: {
|
||||||
|
canonical: `/${locale}`,
|
||||||
|
languages: {
|
||||||
|
'x-default': '/en',
|
||||||
|
'en': '/en',
|
||||||
|
'it': '/it',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. XML Sitemap** (with hreflang):
|
||||||
|
```typescript
|
||||||
|
export default function sitemap() {
|
||||||
|
return routes.flatMap(route =>
|
||||||
|
locales.map(locale => ({
|
||||||
|
url: `${baseUrl}/${locale}${route.path}`,
|
||||||
|
alternates: {
|
||||||
|
languages: { en: `/en${route.path}`, it: `/it${route.path}` }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Both?**
|
||||||
|
- HTML tags: Google's preferred method for page-level precision
|
||||||
|
- Sitemap: Helps discovery, provides backup if HTML tags malfunction
|
||||||
|
- **Never use HTTP headers** - avoid confusion with mixed methods
|
||||||
|
|
||||||
|
**x-default Locale**:
|
||||||
|
- Points to English (`/en`) as fallback for unsupported locales
|
||||||
|
- Used when user's language doesn't match any hreflang tags
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Testing Strategy: Smoke Tests + Parameterization
|
||||||
|
|
||||||
|
**Decision**: Don't test all translations, test the i18n mechanism works
|
||||||
|
|
||||||
|
**Backend Tests**:
|
||||||
|
- 10 new integration tests covering locale CRUD, validation, detection
|
||||||
|
- Test both EN and IT locale values
|
||||||
|
- Test Accept-Language header parsing
|
||||||
|
|
||||||
|
**Frontend E2E Tests**:
|
||||||
|
- 12 new parameterized tests for locale switching
|
||||||
|
- Test critical flows in both EN and IT (login, register)
|
||||||
|
- **NOT** duplicating all 56 existing tests per locale
|
||||||
|
- Use parameterization: `for (const locale of ['en', 'it']) { test(...) }`
|
||||||
|
|
||||||
|
**Frontend Unit Tests**:
|
||||||
|
- 8 new component tests with i18n wrappers
|
||||||
|
- Test LocaleSwitcher functionality
|
||||||
|
- Test translated components render correctly
|
||||||
|
|
||||||
|
**Translation Validation**:
|
||||||
|
- Automated CI check for missing keys
|
||||||
|
- Validate Italian has all keys that English has
|
||||||
|
- Detect unused keys
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- ✅ **Efficient**: Test mechanism, not content
|
||||||
|
- ✅ **Maintainable**: Adding Italian tests doesn't double test time
|
||||||
|
- ✅ **Comprehensive**: Critical paths tested in both locales
|
||||||
|
- ✅ **Fast CI**: ~13-18 minutes total (vs 60+ if we duplicate everything)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Performance Budget
|
||||||
|
|
||||||
|
**Core Web Vitals Targets** (both EN and IT):
|
||||||
|
- **LCP** (Largest Contentful Paint): < 2.5s
|
||||||
|
- **INP** (Interaction to Next Paint): < 200ms
|
||||||
|
- **CLS** (Cumulative Layout Shift): < 0.1
|
||||||
|
|
||||||
|
**Bundle Size Impact**:
|
||||||
|
- next-intl: ~9.2kb gzipped (acceptable)
|
||||||
|
- Translation files: 0kb on client (server-side)
|
||||||
|
- Total increase: < 15kb
|
||||||
|
|
||||||
|
**Lighthouse Scores**:
|
||||||
|
- Performance: ≥ 90
|
||||||
|
- Accessibility: ≥ 95
|
||||||
|
- SEO: 100
|
||||||
|
|
||||||
|
**Font Loading Strategy**:
|
||||||
|
- Use `display: swap` to prevent CLS
|
||||||
|
- Preload Inter font with Latin + Latin-ext subsets (for Italian accents: à, è, ì, ò, ù)
|
||||||
|
- Fallback to system fonts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. GDPR Compliance
|
||||||
|
|
||||||
|
**Classification**: User locale preference IS personal data (GDPR Article 4)
|
||||||
|
|
||||||
|
**Lawful Basis**: Legitimate interest (service improvement) ✅
|
||||||
|
|
||||||
|
**User Rights**:
|
||||||
|
- ✅ **Access**: User can view locale in profile (GET /users/me)
|
||||||
|
- ✅ **Rectification**: User can update locale (PATCH /users/me)
|
||||||
|
- ✅ **Erasure**: Locale deleted when user deleted (CASCADE)
|
||||||
|
- ✅ **Portability**: Included in user data export
|
||||||
|
|
||||||
|
**Privacy Policy Requirements**:
|
||||||
|
- "We store your language preference to personalize your experience"
|
||||||
|
- "You can change this in Settings > Profile at any time"
|
||||||
|
|
||||||
|
**Data Minimization**: ✅ PASS
|
||||||
|
- Locale is necessary for service personalization
|
||||||
|
- No excessive data collection (not storing geolocation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 GETTING STARTED
|
||||||
|
|
||||||
|
### For Developers Implementing This Plan
|
||||||
|
|
||||||
|
1. **Read This Document**: Understand the architecture decisions and rationale
|
||||||
|
2. **Follow Phases Sequentially**: Each phase builds on the previous
|
||||||
|
3. **Run Tests After Each Phase**: Ensure no regressions
|
||||||
|
4. **Use Parallel Agents**: Where indicated in the plan for efficiency
|
||||||
|
5. **Document Decisions**: Update this file if you deviate from the plan
|
||||||
|
|
||||||
|
### For Users of the Template
|
||||||
|
|
||||||
|
1. **See `/docs/I18N.md`**: User-facing guide on using i18n
|
||||||
|
2. **See `/docs/I18N_MIGRATION_GUIDE.md`**: Deploying to existing projects
|
||||||
|
3. **Adding Languages**: Copy the Italian example, follow `/docs/I18N.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 REFERENCES
|
||||||
|
|
||||||
|
### Official Documentation
|
||||||
|
- [next-intl](https://next-intl.dev) - Next.js 15 i18n library
|
||||||
|
- [BCP 47 Language Tags](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) - Locale format standard
|
||||||
|
- [Google Multilingual SEO](https://developers.google.com/search/docs/specialty/international) - SEO guidelines
|
||||||
|
|
||||||
|
### Research Sources
|
||||||
|
- next-intl 4.0 release notes (2025)
|
||||||
|
- Next.js 15 App Router i18n patterns
|
||||||
|
- PostgreSQL performance: JSONB vs columns
|
||||||
|
- Lighthouse CI best practices
|
||||||
|
- Playwright i18n testing patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ COMPLETION CHECKLIST
|
||||||
|
|
||||||
|
Use this checklist to verify implementation is complete:
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [ ] `locale` column added to `users` table
|
||||||
|
- [ ] Database migration created and tested
|
||||||
|
- [ ] Pydantic schemas updated (UserUpdate, UserResponse)
|
||||||
|
- [ ] Locale detection dependency created
|
||||||
|
- [ ] 10+ backend tests added
|
||||||
|
- [ ] All existing 743 tests still pass
|
||||||
|
- [ ] Coverage ≥97% maintained
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [ ] next-intl installed and configured
|
||||||
|
- [ ] Translation files created (en.json, it.json)
|
||||||
|
- [ ] TypeScript autocomplete working
|
||||||
|
- [ ] App Router restructured to `[locale]` pattern
|
||||||
|
- [ ] LocaleSwitcher component created
|
||||||
|
- [ ] All components translated (auth, navigation, settings)
|
||||||
|
- [ ] All existing 56 E2E tests still pass
|
||||||
|
- [ ] 12+ new E2E locale tests added
|
||||||
|
- [ ] 8+ new unit tests added
|
||||||
|
|
||||||
|
### SEO
|
||||||
|
- [ ] Metadata implemented per locale
|
||||||
|
- [ ] Sitemap generated with hreflang
|
||||||
|
- [ ] robots.txt configured
|
||||||
|
- [ ] Lighthouse SEO = 100 (both EN and IT)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- [ ] Core Web Vitals measured (both locales)
|
||||||
|
- [ ] LCP < 2.5s
|
||||||
|
- [ ] INP < 200ms
|
||||||
|
- [ ] CLS < 0.1
|
||||||
|
- [ ] Bundle analysis shows minimal impact
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- [ ] This implementation plan complete
|
||||||
|
- [ ] `/docs/I18N.md` created (user guide)
|
||||||
|
- [ ] `/docs/I18N_MIGRATION_GUIDE.md` created
|
||||||
|
- [ ] `CLAUDE.md` updated with i18n patterns
|
||||||
|
- [ ] README.md updated with i18n feature
|
||||||
|
- [ ] CHANGELOG.md updated
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- [ ] Translation validation in CI
|
||||||
|
- [ ] All tests passing
|
||||||
|
- [ ] No flaky tests
|
||||||
|
- [ ] Coverage targets met
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 NEXT STEPS
|
||||||
|
|
||||||
|
After completing this implementation:
|
||||||
|
|
||||||
|
1. **Deploy to Staging**: Test in production-like environment
|
||||||
|
2. **Gather Feedback**: From team and early users
|
||||||
|
3. **Optimize Further**: Based on real-world usage data
|
||||||
|
4. **Add Languages**: If needed, follow the Italian example
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 CHANGE LOG
|
||||||
|
|
||||||
|
| Date | Author | Change |
|
||||||
|
|------|--------|--------|
|
||||||
|
| 2025-11-17 | Claude | Initial plan created based on 2025 research |
|
||||||
|
| 2025-11-17 | Claude | Updated to 2 languages (EN, IT) per user request |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of Implementation Plan**
|
||||||
|
|
||||||
|
This plan represents state-of-the-art i18n implementation for 2025. It balances best practices, performance, SEO, and developer experience while remaining simple enough for a template showcase.
|
||||||
|
|
||||||
|
For questions or clarifications, refer to the detailed task descriptions in each phase.
|
||||||
@@ -13,7 +13,7 @@ test.describe('Admin Access Control', () => {
|
|||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
|
|
||||||
// Navigate to authenticated page to test authenticated header (not homepage)
|
// Navigate to authenticated page to test authenticated header (not homepage)
|
||||||
await page.goto('/settings');
|
await page.goto('/en/settings');
|
||||||
await page.waitForSelector('h1:has-text("Settings")');
|
await page.waitForSelector('h1:has-text("Settings")');
|
||||||
|
|
||||||
// Should not see admin link in authenticated header navigation
|
// Should not see admin link in authenticated header navigation
|
||||||
@@ -28,10 +28,10 @@ test.describe('Admin Access Control', () => {
|
|||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
|
|
||||||
// Try to access admin page directly
|
// Try to access admin page directly
|
||||||
await page.goto('/admin');
|
await page.goto('/en/admin');
|
||||||
|
|
||||||
// Should be redirected away from admin (to login or home)
|
// Should be redirected away from admin (to login or home)
|
||||||
await page.waitForURL(/\/(auth\/login|$)/);
|
await page.waitForURL(/\/en(\/login)?$/);
|
||||||
expect(page.url()).not.toContain('/admin');
|
expect(page.url()).not.toContain('/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ test.describe('Admin Access Control', () => {
|
|||||||
|
|
||||||
// Navigate to settings page to ensure user state is loaded
|
// Navigate to settings page to ensure user state is loaded
|
||||||
// (AuthGuard fetches user on protected pages)
|
// (AuthGuard fetches user on protected pages)
|
||||||
await page.goto('/settings');
|
await page.goto('/en/settings');
|
||||||
await page.waitForSelector('h1:has-text("Settings")');
|
await page.waitForSelector('h1:has-text("Settings")');
|
||||||
|
|
||||||
// Should see admin link in header navigation bar
|
// Should see admin link in header navigation bar
|
||||||
@@ -52,7 +52,7 @@ test.describe('Admin Access Control', () => {
|
|||||||
.locator('header nav')
|
.locator('header nav')
|
||||||
.getByRole('link', { name: 'Admin', exact: true });
|
.getByRole('link', { name: 'Admin', exact: true });
|
||||||
await expect(headerAdminLink).toBeVisible();
|
await expect(headerAdminLink).toBeVisible();
|
||||||
await expect(headerAdminLink).toHaveAttribute('href', '/admin');
|
await expect(headerAdminLink).toHaveAttribute('href', '/en/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('superuser should be able to access admin dashboard', async ({ page }) => {
|
test('superuser should be able to access admin dashboard', async ({ page }) => {
|
||||||
@@ -61,10 +61,10 @@ test.describe('Admin Access Control', () => {
|
|||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
|
|
||||||
// Navigate to admin page
|
// Navigate to admin page
|
||||||
await page.goto('/admin');
|
await page.goto('/en/admin');
|
||||||
|
|
||||||
// Should see admin dashboard
|
// Should see admin dashboard
|
||||||
await expect(page).toHaveURL('/admin');
|
await expect(page).toHaveURL('/en/admin');
|
||||||
await expect(page.locator('h1')).toContainText('Admin Dashboard');
|
await expect(page.locator('h1')).toContainText('Admin Dashboard');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -73,7 +73,7 @@ test.describe('Admin Dashboard', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin');
|
await page.goto('/en/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display page title and description', async ({ page }) => {
|
test('should display page title and description', async ({ page }) => {
|
||||||
@@ -120,7 +120,7 @@ test.describe('Admin Navigation', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin');
|
await page.goto('/en/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display admin sidebar', async ({ page }) => {
|
test('should display admin sidebar', async ({ page }) => {
|
||||||
@@ -143,9 +143,9 @@ test.describe('Admin Navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to users page', async ({ page }) => {
|
test('should navigate to users page', async ({ page }) => {
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
|
|
||||||
await expect(page).toHaveURL('/admin/users');
|
await expect(page).toHaveURL('/en/admin/users');
|
||||||
await expect(page.locator('h1')).toContainText('User Management');
|
await expect(page.locator('h1')).toContainText('User Management');
|
||||||
|
|
||||||
// Breadcrumbs should show Admin > Users
|
// Breadcrumbs should show Admin > Users
|
||||||
@@ -158,9 +158,9 @@ test.describe('Admin Navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to organizations page', async ({ page }) => {
|
test('should navigate to organizations page', async ({ page }) => {
|
||||||
await page.goto('/admin/organizations');
|
await page.goto('/en/admin/organizations');
|
||||||
|
|
||||||
await expect(page).toHaveURL('/admin/organizations');
|
await expect(page).toHaveURL('/en/admin/organizations');
|
||||||
await expect(page.getByRole('heading', { name: 'All Organizations' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'All Organizations' })).toBeVisible();
|
||||||
|
|
||||||
// Breadcrumbs should show Admin > Organizations
|
// Breadcrumbs should show Admin > Organizations
|
||||||
@@ -173,9 +173,9 @@ test.describe('Admin Navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to settings page', async ({ page }) => {
|
test('should navigate to settings page', async ({ page }) => {
|
||||||
await page.goto('/admin/settings');
|
await page.goto('/en/admin/settings');
|
||||||
|
|
||||||
await expect(page).toHaveURL('/admin/settings');
|
await expect(page).toHaveURL('/en/admin/settings');
|
||||||
await expect(page.locator('h1')).toContainText('System Settings');
|
await expect(page.locator('h1')).toContainText('System Settings');
|
||||||
|
|
||||||
// Breadcrumbs should show Admin > Settings
|
// Breadcrumbs should show Admin > Settings
|
||||||
@@ -208,14 +208,14 @@ test.describe('Admin Navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate back to dashboard from users page', async ({ page }) => {
|
test('should navigate back to dashboard from users page', async ({ page }) => {
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
|
|
||||||
// Click dashboard link in sidebar
|
// Click dashboard link in sidebar
|
||||||
const dashboardLink = page.getByTestId('nav-dashboard');
|
const dashboardLink = page.getByTestId('nav-dashboard');
|
||||||
await dashboardLink.click();
|
await dashboardLink.click();
|
||||||
|
|
||||||
await page.waitForURL('/admin');
|
await page.waitForURL('/en/admin');
|
||||||
await expect(page).toHaveURL('/admin');
|
await expect(page).toHaveURL('/en/admin');
|
||||||
await expect(page.locator('h1')).toContainText('Admin Dashboard');
|
await expect(page.locator('h1')).toContainText('Admin Dashboard');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -227,7 +227,7 @@ test.describe('Admin Breadcrumbs', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should show single breadcrumb on dashboard', async ({ page }) => {
|
test('should show single breadcrumb on dashboard', async ({ page }) => {
|
||||||
await page.goto('/admin');
|
await page.goto('/en/admin');
|
||||||
|
|
||||||
const breadcrumbs = page.getByTestId('breadcrumbs');
|
const breadcrumbs = page.getByTestId('breadcrumbs');
|
||||||
await expect(breadcrumbs).toBeVisible();
|
await expect(breadcrumbs).toBeVisible();
|
||||||
@@ -239,12 +239,12 @@ test.describe('Admin Breadcrumbs', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should show clickable parent breadcrumb', async ({ page }) => {
|
test('should show clickable parent breadcrumb', async ({ page }) => {
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
|
|
||||||
// 'Admin' should be a clickable link (test ID is on the Link element itself)
|
// 'Admin' should be a clickable link (test ID is on the Link element itself)
|
||||||
const adminBreadcrumb = page.getByTestId('breadcrumb-admin');
|
const adminBreadcrumb = page.getByTestId('breadcrumb-admin');
|
||||||
await expect(adminBreadcrumb).toBeVisible();
|
await expect(adminBreadcrumb).toBeVisible();
|
||||||
await expect(adminBreadcrumb).toHaveAttribute('href', '/admin');
|
await expect(adminBreadcrumb).toHaveAttribute('href', '/en/admin');
|
||||||
|
|
||||||
// 'Users' should be current page (not a link, so it's a span)
|
// 'Users' should be current page (not a link, so it's a span)
|
||||||
const usersBreadcrumb = page.getByTestId('breadcrumb-users');
|
const usersBreadcrumb = page.getByTestId('breadcrumb-users');
|
||||||
@@ -253,13 +253,13 @@ test.describe('Admin Breadcrumbs', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate via breadcrumb link', async ({ page }) => {
|
test('should navigate via breadcrumb link', async ({ page }) => {
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
|
|
||||||
// Click 'Admin' breadcrumb to go back to dashboard
|
// Click 'Admin' breadcrumb to go back to dashboard
|
||||||
const adminBreadcrumb = page.getByTestId('breadcrumb-admin');
|
const adminBreadcrumb = page.getByTestId('breadcrumb-admin');
|
||||||
|
|
||||||
await Promise.all([page.waitForURL('/admin'), adminBreadcrumb.click()]);
|
await Promise.all([page.waitForURL('/en/admin'), adminBreadcrumb.click()]);
|
||||||
|
|
||||||
await expect(page).toHaveURL('/admin');
|
await expect(page).toHaveURL('/en/admin');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ test.describe('Admin Dashboard - Page Load', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin');
|
await page.goto('/en/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display admin dashboard page', async ({ page }) => {
|
test('should display admin dashboard page', async ({ page }) => {
|
||||||
await expect(page).toHaveURL('/admin');
|
await expect(page).toHaveURL('/en/admin');
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Admin Dashboard' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Admin Dashboard' })).toBeVisible();
|
||||||
await expect(page.getByText('Manage users, organizations, and system settings')).toBeVisible();
|
await expect(page.getByText('Manage users, organizations, and system settings')).toBeVisible();
|
||||||
@@ -29,7 +29,7 @@ test.describe('Admin Dashboard - Statistics Cards', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin');
|
await page.goto('/en/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display all stat cards', async ({ page }) => {
|
test('should display all stat cards', async ({ page }) => {
|
||||||
@@ -62,7 +62,7 @@ test.describe('Admin Dashboard - Quick Actions', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin');
|
await page.goto('/en/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display quick actions section', async ({ page }) => {
|
test('should display quick actions section', async ({ page }) => {
|
||||||
@@ -86,9 +86,9 @@ test.describe('Admin Dashboard - Quick Actions', () => {
|
|||||||
test('should navigate to users page when clicking user management', async ({ page }) => {
|
test('should navigate to users page when clicking user management', async ({ page }) => {
|
||||||
const userManagementLink = page.getByRole('link', { name: /User Management/i });
|
const userManagementLink = page.getByRole('link', { name: /User Management/i });
|
||||||
|
|
||||||
await Promise.all([page.waitForURL('/admin/users'), userManagementLink.click()]);
|
await Promise.all([page.waitForURL('/en/admin/users'), userManagementLink.click()]);
|
||||||
|
|
||||||
await expect(page).toHaveURL('/admin/users');
|
await expect(page).toHaveURL('/en/admin/users');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to organizations page when clicking organizations', async ({ page }) => {
|
test('should navigate to organizations page when clicking organizations', async ({ page }) => {
|
||||||
@@ -96,9 +96,9 @@ test.describe('Admin Dashboard - Quick Actions', () => {
|
|||||||
const quickActionsSection = page.locator('h2:has-text("Quick Actions")').locator('..');
|
const quickActionsSection = page.locator('h2:has-text("Quick Actions")').locator('..');
|
||||||
const organizationsLink = quickActionsSection.getByRole('link', { name: /Organizations/i });
|
const organizationsLink = quickActionsSection.getByRole('link', { name: /Organizations/i });
|
||||||
|
|
||||||
await Promise.all([page.waitForURL('/admin/organizations'), organizationsLink.click()]);
|
await Promise.all([page.waitForURL('/en/admin/organizations'), organizationsLink.click()]);
|
||||||
|
|
||||||
await expect(page).toHaveURL('/admin/organizations');
|
await expect(page).toHaveURL('/en/admin/organizations');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ test.describe('Admin Dashboard - Analytics Charts', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin');
|
await page.goto('/en/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display analytics overview section', async ({ page }) => {
|
test('should display analytics overview section', async ({ page }) => {
|
||||||
@@ -151,7 +151,7 @@ test.describe('Admin Dashboard - Accessibility', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin');
|
await page.goto('/en/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should have proper heading hierarchy', async ({ page }) => {
|
test('should have proper heading hierarchy', async ({ page }) => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ test.describe('Admin Organization Members - Navigation from Organizations List',
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/organizations');
|
await page.goto('/en/admin/organizations');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,12 +24,12 @@ test.describe('Admin Organization Members - Navigation from Organizations List',
|
|||||||
|
|
||||||
// Click "View Members"
|
// Click "View Members"
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
page.waitForURL(/\/en\/admin\/organizations\/[^/]+\/members/),
|
||||||
page.getByText('View Members').click(),
|
page.getByText('View Members').click(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Should be on members page
|
// Should be on members page
|
||||||
await expect(page).toHaveURL(/\/admin\/organizations\/[^/]+\/members/);
|
await expect(page).toHaveURL(/\/en\/admin\/organizations\/[^/]+\/members/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to members page when clicking member count', async ({ page }) => {
|
test('should navigate to members page when clicking member count', async ({ page }) => {
|
||||||
@@ -39,12 +39,12 @@ test.describe('Admin Organization Members - Navigation from Organizations List',
|
|||||||
|
|
||||||
// Click on member count
|
// Click on member count
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
page.waitForURL(/\/en\/admin\/organizations\/[^/]+\/members/),
|
||||||
memberButton.click(),
|
memberButton.click(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Should be on members page
|
// Should be on members page
|
||||||
await expect(page).toHaveURL(/\/admin\/organizations\/[^/]+\/members/);
|
await expect(page).toHaveURL(/\/en\/admin\/organizations\/[^/]+\/members/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ test.describe('Admin Organization Members - Page Structure', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/organizations');
|
await page.goto('/en/admin/organizations');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
|
|
||||||
// Navigate to members page
|
// Navigate to members page
|
||||||
@@ -60,13 +60,13 @@ test.describe('Admin Organization Members - Page Structure', () => {
|
|||||||
await actionButton.click();
|
await actionButton.click();
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
page.waitForURL(/\/en\/admin\/organizations\/[^/]+\/members/),
|
||||||
page.getByText('View Members').click(),
|
page.getByText('View Members').click(),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display organization members page', async ({ page }) => {
|
test('should display organization members page', async ({ page }) => {
|
||||||
await expect(page).toHaveURL(/\/admin\/organizations\/[^/]+\/members/);
|
await expect(page).toHaveURL(/\/en\/admin\/organizations\/[^/]+\/members/);
|
||||||
|
|
||||||
// Wait for page to load
|
// Wait for page to load
|
||||||
await page.waitForSelector('table');
|
await page.waitForSelector('table');
|
||||||
@@ -123,7 +123,7 @@ test.describe('Admin Organization Members - AddMemberDialog E2E Tests', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/organizations');
|
await page.goto('/en/admin/organizations');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
|
|
||||||
// Navigate to members page
|
// Navigate to members page
|
||||||
@@ -131,7 +131,7 @@ test.describe('Admin Organization Members - AddMemberDialog E2E Tests', () => {
|
|||||||
await actionButton.click();
|
await actionButton.click();
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
page.waitForURL(/\/en\/admin\/organizations\/[^/]+\/members/),
|
||||||
page.getByText('View Members').click(),
|
page.getByText('View Members').click(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ test.describe('Admin Organization Management - Page Load', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/organizations');
|
await page.goto('/en/admin/organizations');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display organization management page', async ({ page }) => {
|
test('should display organization management page', async ({ page }) => {
|
||||||
await expect(page).toHaveURL('/admin/organizations');
|
await expect(page).toHaveURL('/en/admin/organizations');
|
||||||
|
|
||||||
// Wait for page to load
|
// Wait for page to load
|
||||||
await page.waitForSelector('table');
|
await page.waitForSelector('table');
|
||||||
@@ -41,7 +41,7 @@ test.describe('Admin Organization Management - Organization List Table', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/organizations');
|
await page.goto('/en/admin/organizations');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display organization list table with headers', async ({ page }) => {
|
test('should display organization list table with headers', async ({ page }) => {
|
||||||
@@ -107,7 +107,7 @@ test.describe('Admin Organization Management - Pagination', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/organizations');
|
await page.goto('/en/admin/organizations');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display pagination info', async ({ page }) => {
|
test('should display pagination info', async ({ page }) => {
|
||||||
@@ -127,7 +127,7 @@ test.describe('Admin Organization Management - Create Organization Button', () =
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/organizations');
|
await page.goto('/en/admin/organizations');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display create organization button', async ({ page }) => {
|
test('should display create organization button', async ({ page }) => {
|
||||||
@@ -140,7 +140,7 @@ test.describe('Admin Organization Management - Action Menu', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/organizations');
|
await page.goto('/en/admin/organizations');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,12 +192,12 @@ test.describe('Admin Organization Management - Action Menu', () => {
|
|||||||
|
|
||||||
// Click view members - use Promise.all for Next.js Link navigation
|
// Click view members - use Promise.all for Next.js Link navigation
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
page.waitForURL(/\/en\/admin\/organizations\/[^/]+\/members/),
|
||||||
page.getByText('View Members').click(),
|
page.getByText('View Members').click(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Should navigate to members page
|
// Should navigate to members page
|
||||||
await expect(page).toHaveURL(/\/admin\/organizations\/[^/]+\/members/);
|
await expect(page).toHaveURL(/\/en\/admin\/organizations\/[^/]+\/members/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show delete confirmation dialog when clicking delete', async ({ page }) => {
|
test('should show delete confirmation dialog when clicking delete', async ({ page }) => {
|
||||||
@@ -248,7 +248,7 @@ test.describe('Admin Organization Management - Edit Organization Dialog', () =>
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/organizations');
|
await page.goto('/en/admin/organizations');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ test.describe('Admin Organization Management - Member Count Interaction', () =>
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/organizations');
|
await page.goto('/en/admin/organizations');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -308,12 +308,12 @@ test.describe('Admin Organization Management - Member Count Interaction', () =>
|
|||||||
|
|
||||||
// Click on member count - use Promise.all for Next.js Link navigation
|
// Click on member count - use Promise.all for Next.js Link navigation
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
page.waitForURL(/\/en\/admin\/organizations\/[^/]+\/members/),
|
||||||
memberButton.click(),
|
memberButton.click(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Should navigate to members page
|
// Should navigate to members page
|
||||||
await expect(page).toHaveURL(/\/admin\/organizations\/[^/]+\/members/);
|
await expect(page).toHaveURL(/\/en\/admin\/organizations\/[^/]+\/members/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ test.describe('Admin Organization Management - Accessibility', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/organizations');
|
await page.goto('/en/admin/organizations');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should have proper heading hierarchy', async ({ page }) => {
|
test('should have proper heading hierarchy', async ({ page }) => {
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ test.describe('Admin User Management - Page Load', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display user management page', async ({ page }) => {
|
test('should display user management page', async ({ page }) => {
|
||||||
await expect(page).toHaveURL('/admin/users');
|
await expect(page).toHaveURL('/en/admin/users');
|
||||||
await expect(page.locator('h1')).toContainText('User Management');
|
await expect(page.locator('h1')).toContainText('User Management');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ test.describe('Admin User Management - User List Table', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display user list table with headers', async ({ page }) => {
|
test('should display user list table with headers', async ({ page }) => {
|
||||||
@@ -101,7 +101,7 @@ test.describe('Admin User Management - Search and Filters', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display search input', async ({ page }) => {
|
test('should display search input', async ({ page }) => {
|
||||||
@@ -244,7 +244,7 @@ test.describe('Admin User Management - Pagination', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display pagination info', async ({ page }) => {
|
test('should display pagination info', async ({ page }) => {
|
||||||
@@ -262,7 +262,7 @@ test.describe('Admin User Management - Row Selection', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -325,7 +325,7 @@ test.describe('Admin User Management - Create User Dialog', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should open create user dialog', async ({ page }) => {
|
test('should open create user dialog', async ({ page }) => {
|
||||||
@@ -449,7 +449,7 @@ test.describe('Admin User Management - Action Menu', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -502,7 +502,7 @@ test.describe('Admin User Management - Edit User Dialog', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -561,7 +561,7 @@ test.describe('Admin User Management - Bulk Actions', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -631,7 +631,7 @@ test.describe('Admin User Management - Accessibility', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupSuperuserMocks(page);
|
await setupSuperuserMocks(page);
|
||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
await page.goto('/admin/users');
|
await page.goto('/en/admin/users');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should have proper heading hierarchy', async ({ page }) => {
|
test('should have proper heading hierarchy', async ({ page }) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ test.describe('AuthGuard - Route Protection', () => {
|
|||||||
test.beforeEach(async ({ page, context }) => {
|
test.beforeEach(async ({ page, context }) => {
|
||||||
// Clear storage before each test to ensure clean state
|
// Clear storage before each test to ensure clean state
|
||||||
await context.clearCookies();
|
await context.clearCookies();
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
@@ -15,37 +15,37 @@ test.describe('AuthGuard - Route Protection', () => {
|
|||||||
// Try to access a protected route (if you have one)
|
// Try to access a protected route (if you have one)
|
||||||
// For now, we'll test the root if it's protected
|
// For now, we'll test the root if it's protected
|
||||||
// Adjust the route based on your actual protected routes
|
// Adjust the route based on your actual protected routes
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
|
|
||||||
// If root is protected, should redirect to login or show homepage
|
// If root is protected, should redirect to login or show homepage
|
||||||
// Wait for page to stabilize
|
// Wait for page to stabilize
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// Should either be on login or homepage (not crashing)
|
// Should either be on login or homepage (not crashing) - with locale prefix
|
||||||
const url = page.url();
|
const url = page.url();
|
||||||
expect(url).toMatch(/\/(login)?$/);
|
expect(url).toMatch(/\/en(\/login)?$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow access to public routes without auth', async ({ page }) => {
|
test('should allow access to public routes without auth', async ({ page }) => {
|
||||||
// Test login page
|
// Test login page
|
||||||
await page.goto('/login');
|
await page.goto('/en/login');
|
||||||
await expect(page).toHaveURL('/login');
|
await expect(page).toHaveURL('/en/login');
|
||||||
await expect(page.locator('h2')).toContainText('Sign in to your account');
|
await expect(page.locator('h2')).toContainText('Sign in to your account');
|
||||||
|
|
||||||
// Test register page
|
// Test register page
|
||||||
await page.goto('/register');
|
await page.goto('/en/register');
|
||||||
await expect(page).toHaveURL('/register');
|
await expect(page).toHaveURL('/en/register');
|
||||||
await expect(page.locator('h2')).toContainText('Create your account');
|
await expect(page.locator('h2')).toContainText('Create your account');
|
||||||
|
|
||||||
// Test password reset page
|
// Test password reset page
|
||||||
await page.goto('/password-reset');
|
await page.goto('/en/password-reset');
|
||||||
await expect(page).toHaveURL('/password-reset');
|
await expect(page).toHaveURL('/en/password-reset');
|
||||||
await expect(page.locator('h2')).toContainText('Reset your password');
|
await expect(page.locator('h2')).toContainText('Reset your password');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should persist authentication across page reloads', async ({ page }) => {
|
test('should persist authentication across page reloads', async ({ page }) => {
|
||||||
// Manually set a mock token in localStorage for testing
|
// Manually set a mock token in localStorage for testing
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const mockToken = {
|
const mockToken = {
|
||||||
access_token: 'mock-access-token',
|
access_token: 'mock-access-token',
|
||||||
@@ -73,7 +73,7 @@ test.describe('AuthGuard - Route Protection', () => {
|
|||||||
|
|
||||||
test('should clear authentication on logout', async ({ page }) => {
|
test('should clear authentication on logout', async ({ page }) => {
|
||||||
// Set up authenticated state
|
// Set up authenticated state
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const mockToken = {
|
const mockToken = {
|
||||||
access_token: 'mock-access-token',
|
access_token: 'mock-access-token',
|
||||||
@@ -110,7 +110,7 @@ test.describe('AuthGuard - Route Protection', () => {
|
|||||||
|
|
||||||
test('should not allow access to auth pages when already logged in', async ({ page }) => {
|
test('should not allow access to auth pages when already logged in', async ({ page }) => {
|
||||||
// Set up authenticated state
|
// Set up authenticated state
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const mockToken = {
|
const mockToken = {
|
||||||
access_token: 'mock-access-token',
|
access_token: 'mock-access-token',
|
||||||
@@ -127,7 +127,7 @@ test.describe('AuthGuard - Route Protection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Try to access login page
|
// Try to access login page
|
||||||
await page.goto('/login');
|
await page.goto('/en/login');
|
||||||
|
|
||||||
// Wait a bit for potential redirect
|
// Wait a bit for potential redirect
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
@@ -141,7 +141,7 @@ test.describe('AuthGuard - Route Protection', () => {
|
|||||||
|
|
||||||
test('should handle expired tokens gracefully', async ({ page }) => {
|
test('should handle expired tokens gracefully', async ({ page }) => {
|
||||||
// Set up authenticated state with expired token
|
// Set up authenticated state with expired token
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const expiredToken = {
|
const expiredToken = {
|
||||||
access_token: 'expired-access-token',
|
access_token: 'expired-access-token',
|
||||||
@@ -171,7 +171,7 @@ test.describe('AuthGuard - Route Protection', () => {
|
|||||||
test('should preserve intended destination after login', async ({ page }) => {
|
test('should preserve intended destination after login', async ({ page }) => {
|
||||||
// This is a nice-to-have feature that requires protected routes
|
// This is a nice-to-have feature that requires protected routes
|
||||||
// For now, just verify the test doesn't crash
|
// For now, just verify the test doesn't crash
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
|
|
||||||
// Login (via localStorage for testing)
|
// Login (via localStorage for testing)
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ test.describe('Login Flow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to login page before each test
|
// Navigate to login page before each test
|
||||||
await page.goto('/login');
|
await page.goto('/en/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(async ({ page }, testInfo) => {
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
@@ -128,7 +128,7 @@ test.describe('Login Flow', () => {
|
|||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// Should stay on login page (validation failed)
|
// Should stay on login page (validation failed)
|
||||||
await expect(page).toHaveURL('/login');
|
await expect(page).toHaveURL('/en/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show error for invalid credentials', async ({ page }) => {
|
test('should show error for invalid credentials', async ({ page }) => {
|
||||||
@@ -162,10 +162,10 @@ test.describe('Login Flow', () => {
|
|||||||
// Click forgot password link - use Promise.all to wait for navigation
|
// Click forgot password link - use Promise.all to wait for navigation
|
||||||
const forgotLink = page.getByRole('link', { name: 'Forgot password?' });
|
const forgotLink = page.getByRole('link', { name: 'Forgot password?' });
|
||||||
|
|
||||||
await Promise.all([page.waitForURL('/password-reset'), forgotLink.click()]);
|
await Promise.all([page.waitForURL('/en/password-reset'), forgotLink.click()]);
|
||||||
|
|
||||||
// Should be on password reset page
|
// Should be on password reset page
|
||||||
await expect(page).toHaveURL('/password-reset');
|
await expect(page).toHaveURL('/en/password-reset');
|
||||||
await expect(page.locator('h2')).toContainText('Reset your password');
|
await expect(page.locator('h2')).toContainText('Reset your password');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,10 +173,10 @@ test.describe('Login Flow', () => {
|
|||||||
// Click sign up link - use Promise.all to wait for navigation
|
// Click sign up link - use Promise.all to wait for navigation
|
||||||
const signupLink = page.getByRole('link', { name: 'Sign up' });
|
const signupLink = page.getByRole('link', { name: 'Sign up' });
|
||||||
|
|
||||||
await Promise.all([page.waitForURL('/register'), signupLink.click()]);
|
await Promise.all([page.waitForURL('/en/register'), signupLink.click()]);
|
||||||
|
|
||||||
// Should be on register page
|
// Should be on register page
|
||||||
await expect(page).toHaveURL('/register');
|
await expect(page).toHaveURL('/en/register');
|
||||||
await expect(page.locator('h2')).toContainText('Create your account');
|
await expect(page.locator('h2')).toContainText('Create your account');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test';
|
|||||||
test.describe('Password Reset Request Flow', () => {
|
test.describe('Password Reset Request Flow', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
// Navigate to password reset page
|
// Navigate to password reset page
|
||||||
await page.goto('/password-reset');
|
await page.goto('/en/password-reset');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display password reset request form', async ({ page }) => {
|
test('should display password reset request form', async ({ page }) => {
|
||||||
@@ -37,7 +37,7 @@ test.describe('Password Reset Request Flow', () => {
|
|||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// Should stay on password reset page (validation failed)
|
// Should stay on password reset page (validation failed)
|
||||||
await expect(page).toHaveURL('/password-reset');
|
await expect(page).toHaveURL('/en/password-reset');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should successfully submit password reset request', async ({ page }) => {
|
test('should successfully submit password reset request', async ({ page }) => {
|
||||||
@@ -55,10 +55,10 @@ test.describe('Password Reset Request Flow', () => {
|
|||||||
// Click back to login link - use Promise.all to wait for navigation
|
// Click back to login link - use Promise.all to wait for navigation
|
||||||
const loginLink = page.getByRole('link', { name: 'Back to login' });
|
const loginLink = page.getByRole('link', { name: 'Back to login' });
|
||||||
|
|
||||||
await Promise.all([page.waitForURL('/login'), loginLink.click()]);
|
await Promise.all([page.waitForURL('/en/login'), loginLink.click()]);
|
||||||
|
|
||||||
// Should be on login page
|
// Should be on login page
|
||||||
await expect(page).toHaveURL('/login');
|
await expect(page).toHaveURL('/en/login');
|
||||||
await expect(page.locator('h2')).toContainText('Sign in to your account');
|
await expect(page.locator('h2')).toContainText('Sign in to your account');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ test.describe('Password Reset Request Flow', () => {
|
|||||||
test.describe('Password Reset Confirm Flow', () => {
|
test.describe('Password Reset Confirm Flow', () => {
|
||||||
test('should display error for missing token', async ({ page }) => {
|
test('should display error for missing token', async ({ page }) => {
|
||||||
// Navigate without token
|
// Navigate without token
|
||||||
await page.goto('/password-reset/confirm');
|
await page.goto('/en/password-reset/confirm');
|
||||||
|
|
||||||
// Should show error message
|
// Should show error message
|
||||||
await expect(page.locator('h2')).toContainText(/Invalid/i);
|
await expect(page.locator('h2')).toContainText(/Invalid/i);
|
||||||
@@ -95,7 +95,7 @@ test.describe('Password Reset Confirm Flow', () => {
|
|||||||
|
|
||||||
test('should display password reset confirm form with valid token', async ({ page }) => {
|
test('should display password reset confirm form with valid token', async ({ page }) => {
|
||||||
// Navigate with token (using a dummy token for UI testing)
|
// Navigate with token (using a dummy token for UI testing)
|
||||||
await page.goto('/password-reset/confirm?token=dummy-test-token-123');
|
await page.goto('/en/password-reset/confirm?token=dummy-test-token-123');
|
||||||
|
|
||||||
// Check page title
|
// Check page title
|
||||||
await expect(page.locator('h2')).toContainText('Set new password');
|
await expect(page.locator('h2')).toContainText('Set new password');
|
||||||
@@ -108,7 +108,7 @@ test.describe('Password Reset Confirm Flow', () => {
|
|||||||
|
|
||||||
test('should show validation errors for empty form', async ({ page }) => {
|
test('should show validation errors for empty form', async ({ page }) => {
|
||||||
// Navigate with token
|
// Navigate with token
|
||||||
await page.goto('/password-reset/confirm?token=dummy-test-token-123');
|
await page.goto('/en/password-reset/confirm?token=dummy-test-token-123');
|
||||||
|
|
||||||
// Click submit without filling form
|
// Click submit without filling form
|
||||||
await page.locator('button[type="submit"]').click();
|
await page.locator('button[type="submit"]').click();
|
||||||
@@ -120,7 +120,7 @@ test.describe('Password Reset Confirm Flow', () => {
|
|||||||
|
|
||||||
test('should show validation error for weak password', async ({ page }) => {
|
test('should show validation error for weak password', async ({ page }) => {
|
||||||
// Navigate with token
|
// Navigate with token
|
||||||
await page.goto('/password-reset/confirm?token=dummy-test-token-123');
|
await page.goto('/en/password-reset/confirm?token=dummy-test-token-123');
|
||||||
|
|
||||||
// Fill with weak password
|
// Fill with weak password
|
||||||
await page.locator('input[name="new_password"]').fill('weak');
|
await page.locator('input[name="new_password"]').fill('weak');
|
||||||
@@ -136,7 +136,7 @@ test.describe('Password Reset Confirm Flow', () => {
|
|||||||
|
|
||||||
test('should show validation error for mismatched passwords', async ({ page }) => {
|
test('should show validation error for mismatched passwords', async ({ page }) => {
|
||||||
// Navigate with token
|
// Navigate with token
|
||||||
await page.goto('/password-reset/confirm?token=dummy-test-token-123');
|
await page.goto('/en/password-reset/confirm?token=dummy-test-token-123');
|
||||||
|
|
||||||
// Fill with mismatched passwords
|
// Fill with mismatched passwords
|
||||||
await page.locator('input[name="new_password"]').fill('Password123!');
|
await page.locator('input[name="new_password"]').fill('Password123!');
|
||||||
@@ -152,7 +152,7 @@ test.describe('Password Reset Confirm Flow', () => {
|
|||||||
|
|
||||||
test('should show error for invalid token', async ({ page }) => {
|
test('should show error for invalid token', async ({ page }) => {
|
||||||
// Navigate with invalid token
|
// Navigate with invalid token
|
||||||
await page.goto('/password-reset/confirm?token=invalid-token');
|
await page.goto('/en/password-reset/confirm?token=invalid-token');
|
||||||
|
|
||||||
// Fill form with valid passwords
|
// Fill form with valid passwords
|
||||||
await page.locator('input[name="new_password"]').fill('NewPassword123!');
|
await page.locator('input[name="new_password"]').fill('NewPassword123!');
|
||||||
@@ -172,7 +172,7 @@ test.describe('Password Reset Confirm Flow', () => {
|
|||||||
// In real scenario, you'd generate a token via API or use a test fixture
|
// In real scenario, you'd generate a token via API or use a test fixture
|
||||||
|
|
||||||
// For UI testing, we use a dummy token - backend will reject it
|
// For UI testing, we use a dummy token - backend will reject it
|
||||||
await page.goto('/password-reset/confirm?token=valid-test-token-from-backend');
|
await page.goto('/en/password-reset/confirm?token=valid-test-token-from-backend');
|
||||||
|
|
||||||
// Fill form with valid passwords
|
// Fill form with valid passwords
|
||||||
await page.locator('input[name="new_password"]').fill('NewPassword123!');
|
await page.locator('input[name="new_password"]').fill('NewPassword123!');
|
||||||
@@ -188,21 +188,21 @@ test.describe('Password Reset Confirm Flow', () => {
|
|||||||
|
|
||||||
test('should navigate to request new reset link', async ({ page }) => {
|
test('should navigate to request new reset link', async ({ page }) => {
|
||||||
// Navigate without token to trigger error state
|
// Navigate without token to trigger error state
|
||||||
await page.goto('/password-reset/confirm');
|
await page.goto('/en/password-reset/confirm');
|
||||||
|
|
||||||
// Click request new reset link - use Promise.all to wait for navigation
|
// Click request new reset link - use Promise.all to wait for navigation
|
||||||
const resetLink = page.getByRole('link', { name: 'Request new reset link' });
|
const resetLink = page.getByRole('link', { name: 'Request new reset link' });
|
||||||
|
|
||||||
await Promise.all([page.waitForURL('/password-reset'), resetLink.click()]);
|
await Promise.all([page.waitForURL('/en/password-reset'), resetLink.click()]);
|
||||||
|
|
||||||
// Should be on password reset request page
|
// Should be on password reset request page
|
||||||
await expect(page).toHaveURL('/password-reset');
|
await expect(page).toHaveURL('/en/password-reset');
|
||||||
await expect(page.locator('h2')).toContainText('Reset your password');
|
await expect(page.locator('h2')).toContainText('Reset your password');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should toggle password visibility', async ({ page }) => {
|
test('should toggle password visibility', async ({ page }) => {
|
||||||
// Navigate with token
|
// Navigate with token
|
||||||
await page.goto('/password-reset/confirm?token=dummy-test-token-123');
|
await page.goto('/en/password-reset/confirm?token=dummy-test-token-123');
|
||||||
|
|
||||||
const passwordInput = page.locator('input[name="new_password"]');
|
const passwordInput = page.locator('input[name="new_password"]');
|
||||||
const confirmPasswordInput = page.locator('input[name="confirm_password"]');
|
const confirmPasswordInput = page.locator('input[name="confirm_password"]');
|
||||||
@@ -216,7 +216,7 @@ test.describe('Password Reset Confirm Flow', () => {
|
|||||||
|
|
||||||
test('should disable submit button while loading', async ({ page }) => {
|
test('should disable submit button while loading', async ({ page }) => {
|
||||||
// Navigate with token
|
// Navigate with token
|
||||||
await page.goto('/password-reset/confirm?token=dummy-test-token-123');
|
await page.goto('/en/password-reset/confirm?token=dummy-test-token-123');
|
||||||
|
|
||||||
// Fill form
|
// Fill form
|
||||||
await page.locator('input[name="new_password"]').fill('NewPassword123!');
|
await page.locator('input[name="new_password"]').fill('NewPassword123!');
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ test.describe('Registration Flow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to register page before each test
|
// Navigate to register page before each test
|
||||||
await page.goto('/register');
|
await page.goto('/en/register');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(async ({ page }, testInfo) => {
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
@@ -222,10 +222,10 @@ test.describe('Registration Flow', () => {
|
|||||||
const loginLink = page.getByRole('link', { name: 'Sign in' });
|
const loginLink = page.getByRole('link', { name: 'Sign in' });
|
||||||
|
|
||||||
// Use Promise.all to wait for navigation
|
// Use Promise.all to wait for navigation
|
||||||
await Promise.all([page.waitForURL('/login'), loginLink.click()]);
|
await Promise.all([page.waitForURL('/en/login'), loginLink.click()]);
|
||||||
|
|
||||||
// Should be on login page
|
// Should be on login page
|
||||||
await expect(page).toHaveURL('/login');
|
await expect(page).toHaveURL('/en/login');
|
||||||
await expect(page.locator('h2')).toContainText('Sign in to your account');
|
await expect(page.locator('h2')).toContainText('Sign in to your account');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -100,15 +100,15 @@ export async function loginViaUI(
|
|||||||
email = 'test@example.com',
|
email = 'test@example.com',
|
||||||
password = 'Password123!'
|
password = 'Password123!'
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Navigate to login page
|
// Navigate to login page (with locale prefix)
|
||||||
await page.goto('/login');
|
await page.goto('/en/login');
|
||||||
|
|
||||||
// Fill login form
|
// Fill login form
|
||||||
await page.locator('input[name="email"]').fill(email);
|
await page.locator('input[name="email"]').fill(email);
|
||||||
await page.locator('input[name="password"]').fill(password);
|
await page.locator('input[name="password"]').fill(password);
|
||||||
|
|
||||||
// Submit and wait for navigation to home
|
// Submit and wait for navigation to home (with locale prefix)
|
||||||
await Promise.all([page.waitForURL('/'), page.locator('button[type="submit"]').click()]);
|
await Promise.all([page.waitForURL('/en'), page.locator('button[type="submit"]').click()]);
|
||||||
|
|
||||||
// Wait for auth to settle
|
// Wait for auth to settle
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { test, expect } from '@playwright/test';
|
|||||||
|
|
||||||
test.describe('Homepage - Desktop Navigation', () => {
|
test.describe('Homepage - Desktop Navigation', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
// Wait for page to be fully loaded
|
// Wait for page to be fully loaded
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ test.describe('Homepage - Desktop Navigation', () => {
|
|||||||
|
|
||||||
// Desktop navigation links should be visible (use locator to find within header)
|
// Desktop navigation links should be visible (use locator to find within header)
|
||||||
const header = page.locator('header').first();
|
const header = page.locator('header').first();
|
||||||
await expect(header.getByRole('link', { name: 'Components', exact: true })).toBeVisible();
|
await expect(header.getByRole('link', { name: 'Design System', exact: true })).toBeVisible();
|
||||||
await expect(header.getByRole('link', { name: 'Admin Demo', exact: true })).toBeVisible();
|
await expect(header.getByRole('link', { name: 'Admin Demo', exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,22 +29,22 @@ test.describe('Homepage - Desktop Navigation', () => {
|
|||||||
await expect(githubLink).toHaveAttribute('target', '_blank');
|
await expect(githubLink).toHaveAttribute('target', '_blank');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to components page via header link', async ({ page }) => {
|
test('should navigate to design system page via header link', async ({ page }) => {
|
||||||
// Click the exact Components link in header navigation
|
// Click the exact Design System link in header navigation
|
||||||
const header = page.locator('header').first();
|
const header = page.locator('header').first();
|
||||||
const componentsLink = header.getByRole('link', { name: 'Components', exact: true });
|
const designSystemLink = header.getByRole('link', { name: 'Design System', exact: true });
|
||||||
|
|
||||||
// Verify link exists and has correct href
|
// Verify link exists and has correct href
|
||||||
await expect(componentsLink).toBeVisible();
|
await expect(designSystemLink).toBeVisible();
|
||||||
await expect(componentsLink).toHaveAttribute('href', '/dev');
|
await expect(designSystemLink).toHaveAttribute('href', '/en/dev');
|
||||||
|
|
||||||
// Click and wait for navigation
|
// Click and wait for navigation
|
||||||
await componentsLink.click();
|
await designSystemLink.click();
|
||||||
await page.waitForURL('/dev', { timeout: 10000 }).catch(() => {});
|
await page.waitForURL('/en/dev', { timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Verify URL (might not navigate if /dev page has issues, that's ok for this test)
|
// Verify URL (might not navigate if /dev page has issues, that's ok for this test)
|
||||||
const currentUrl = page.url();
|
const currentUrl = page.url();
|
||||||
expect(currentUrl).toMatch(/\/(dev)?$/);
|
expect(currentUrl).toMatch(/\/en(\/dev)?$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to admin demo via header link', async ({ page }) => {
|
test('should navigate to admin demo via header link', async ({ page }) => {
|
||||||
@@ -54,15 +54,15 @@ test.describe('Homepage - Desktop Navigation', () => {
|
|||||||
|
|
||||||
// Verify link exists and has correct href
|
// Verify link exists and has correct href
|
||||||
await expect(adminLink).toBeVisible();
|
await expect(adminLink).toBeVisible();
|
||||||
await expect(adminLink).toHaveAttribute('href', '/admin');
|
await expect(adminLink).toHaveAttribute('href', '/en/admin');
|
||||||
|
|
||||||
// Click and wait for navigation
|
// Click and wait for navigation
|
||||||
await adminLink.click();
|
await adminLink.click();
|
||||||
await page.waitForURL('/admin', { timeout: 10000 }).catch(() => {});
|
await page.waitForURL('/en/admin', { timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Verify URL (might not navigate if /admin requires auth, that's ok for this test)
|
// Verify URL (might not navigate if /admin requires auth, that's ok for this test)
|
||||||
const currentUrl = page.url();
|
const currentUrl = page.url();
|
||||||
expect(currentUrl).toMatch(/\/(admin)?$/);
|
expect(currentUrl).toMatch(/\/en(\/admin)?$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to login page via header button', async ({ page }) => {
|
test('should navigate to login page via header button', async ({ page }) => {
|
||||||
@@ -70,9 +70,9 @@ test.describe('Homepage - Desktop Navigation', () => {
|
|||||||
const header = page.locator('header').first();
|
const header = page.locator('header').first();
|
||||||
const headerLoginLink = header.getByRole('link', { name: /^Login$/i });
|
const headerLoginLink = header.getByRole('link', { name: /^Login$/i });
|
||||||
|
|
||||||
await Promise.all([page.waitForURL('/login'), headerLoginLink.click()]);
|
await Promise.all([page.waitForURL('/en/login'), headerLoginLink.click()]);
|
||||||
|
|
||||||
await expect(page).toHaveURL('/login');
|
await expect(page).toHaveURL('/en/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip('should open demo credentials modal when clicking Try Demo', async ({ page }) => {
|
test.skip('should open demo credentials modal when clicking Try Demo', async ({ page }) => {
|
||||||
@@ -113,7 +113,7 @@ test.describe('Homepage - Mobile Menu Interactions', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
// Set mobile viewport
|
// Set mobile viewport
|
||||||
await page.setViewportSize({ width: 375, height: 667 });
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,15 +146,15 @@ test.describe('Homepage - Mobile Menu Interactions', () => {
|
|||||||
const componentsLink = mobileMenu.getByRole('link', { name: 'Components' });
|
const componentsLink = mobileMenu.getByRole('link', { name: 'Components' });
|
||||||
|
|
||||||
// Verify link has correct href
|
// Verify link has correct href
|
||||||
await expect(componentsLink).toHaveAttribute('href', '/dev');
|
await expect(componentsLink).toHaveAttribute('href', '/en/dev');
|
||||||
|
|
||||||
// Click and wait for navigation
|
// Click and wait for navigation
|
||||||
await componentsLink.click();
|
await componentsLink.click();
|
||||||
await page.waitForURL('/dev', { timeout: 10000 }).catch(() => {});
|
await page.waitForURL('/en/dev', { timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Verify URL (might not navigate if /dev page has issues, that's ok)
|
// Verify URL (might not navigate if /dev page has issues, that's ok)
|
||||||
const currentUrl = page.url();
|
const currentUrl = page.url();
|
||||||
expect(currentUrl).toMatch(/\/(dev)?$/);
|
expect(currentUrl).toMatch(/\/en(\/dev)?$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip('should navigate to admin demo from mobile menu', async ({ page }) => {
|
test.skip('should navigate to admin demo from mobile menu', async ({ page }) => {
|
||||||
@@ -164,15 +164,15 @@ test.describe('Homepage - Mobile Menu Interactions', () => {
|
|||||||
const adminLink = mobileMenu.getByRole('link', { name: 'Admin Demo' });
|
const adminLink = mobileMenu.getByRole('link', { name: 'Admin Demo' });
|
||||||
|
|
||||||
// Verify link has correct href
|
// Verify link has correct href
|
||||||
await expect(adminLink).toHaveAttribute('href', '/admin');
|
await expect(adminLink).toHaveAttribute('href', '/en/admin');
|
||||||
|
|
||||||
// Click and wait for navigation
|
// Click and wait for navigation
|
||||||
await adminLink.click();
|
await adminLink.click();
|
||||||
await page.waitForURL('/admin', { timeout: 10000 }).catch(() => {});
|
await page.waitForURL('/en/admin', { timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Verify URL (might not navigate if /admin requires auth, that's ok)
|
// Verify URL (might not navigate if /admin requires auth, that's ok)
|
||||||
const currentUrl = page.url();
|
const currentUrl = page.url();
|
||||||
expect(currentUrl).toMatch(/\/(admin)?$/);
|
expect(currentUrl).toMatch(/\/en(\/admin)?$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip('should display Try Demo button in mobile menu', async ({ page }) => {
|
test.skip('should display Try Demo button in mobile menu', async ({ page }) => {
|
||||||
@@ -204,9 +204,9 @@ test.describe('Homepage - Mobile Menu Interactions', () => {
|
|||||||
const loginLink = mobileMenu.getByRole('link', { name: /Login/i });
|
const loginLink = mobileMenu.getByRole('link', { name: /Login/i });
|
||||||
await loginLink.waitFor({ state: 'visible' });
|
await loginLink.waitFor({ state: 'visible' });
|
||||||
|
|
||||||
await Promise.all([page.waitForURL('/login'), loginLink.click()]);
|
await Promise.all([page.waitForURL('/en/login'), loginLink.click()]);
|
||||||
|
|
||||||
await expect(page).toHaveURL('/login');
|
await expect(page).toHaveURL('/en/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip('should close mobile menu when clicking outside', async ({ page }) => {
|
test.skip('should close mobile menu when clicking outside', async ({ page }) => {
|
||||||
@@ -223,7 +223,7 @@ test.describe('Homepage - Mobile Menu Interactions', () => {
|
|||||||
|
|
||||||
test.describe('Homepage - Hero Section', () => {
|
test.describe('Homepage - Hero Section', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display main headline', async ({ page }) => {
|
test('should display main headline', async ({ page }) => {
|
||||||
@@ -255,21 +255,21 @@ test.describe('Homepage - Hero Section', () => {
|
|||||||
const exploreLink = page.getByRole('link', { name: /Explore Components/i }).first();
|
const exploreLink = page.getByRole('link', { name: /Explore Components/i }).first();
|
||||||
|
|
||||||
// Verify link has correct href
|
// Verify link has correct href
|
||||||
await expect(exploreLink).toHaveAttribute('href', '/dev');
|
await expect(exploreLink).toHaveAttribute('href', '/en/dev');
|
||||||
|
|
||||||
// Click and try to navigate
|
// Click and try to navigate
|
||||||
await exploreLink.click();
|
await exploreLink.click();
|
||||||
await page.waitForURL('/dev', { timeout: 10000 }).catch(() => {});
|
await page.waitForURL('/en/dev', { timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Verify URL (flexible to handle auth redirects)
|
// Verify URL (flexible to handle auth redirects)
|
||||||
const currentUrl = page.url();
|
const currentUrl = page.url();
|
||||||
expect(currentUrl).toMatch(/\/(dev)?$/);
|
expect(currentUrl).toMatch(/\/en(\/dev)?$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Homepage - Demo Credentials Modal', () => {
|
test.describe('Homepage - Demo Credentials Modal', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip('should display regular and admin credentials', async ({ page }) => {
|
test.skip('should display regular and admin credentials', async ({ page }) => {
|
||||||
@@ -321,9 +321,9 @@ test.describe('Homepage - Demo Credentials Modal', () => {
|
|||||||
|
|
||||||
const loginLink = dialog.getByRole('link', { name: /Go to Login/i });
|
const loginLink = dialog.getByRole('link', { name: /Go to Login/i });
|
||||||
|
|
||||||
await Promise.all([page.waitForURL('/login'), loginLink.click()]);
|
await Promise.all([page.waitForURL('/en/login'), loginLink.click()]);
|
||||||
|
|
||||||
await expect(page).toHaveURL('/login');
|
await expect(page).toHaveURL('/en/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip('should close modal when clicking close button', async ({ page }) => {
|
test.skip('should close modal when clicking close button', async ({ page }) => {
|
||||||
@@ -344,7 +344,7 @@ test.describe('Homepage - Demo Credentials Modal', () => {
|
|||||||
|
|
||||||
test.describe('Homepage - Animated Terminal', () => {
|
test.describe('Homepage - Animated Terminal', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display terminal section', async ({ page }) => {
|
test('should display terminal section', async ({ page }) => {
|
||||||
@@ -387,21 +387,21 @@ test.describe('Homepage - Animated Terminal', () => {
|
|||||||
const terminalDemoLink = demoLinks.last(); // Last one should be from terminal section
|
const terminalDemoLink = demoLinks.last(); // Last one should be from terminal section
|
||||||
|
|
||||||
// Verify link has correct href
|
// Verify link has correct href
|
||||||
await expect(terminalDemoLink).toHaveAttribute('href', '/login');
|
await expect(terminalDemoLink).toHaveAttribute('href', '/en/login');
|
||||||
|
|
||||||
// Click and try to navigate
|
// Click and try to navigate
|
||||||
await terminalDemoLink.click();
|
await terminalDemoLink.click();
|
||||||
await page.waitForURL('/login', { timeout: 10000 }).catch(() => {});
|
await page.waitForURL('/en/login', { timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Verify URL (flexible to handle redirects)
|
// Verify URL (flexible to handle redirects)
|
||||||
const currentUrl = page.url();
|
const currentUrl = page.url();
|
||||||
expect(currentUrl).toMatch(/\/(login)?$/);
|
expect(currentUrl).toMatch(/\/en(\/login)?$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Homepage - Feature Sections', () => {
|
test.describe('Homepage - Feature Sections', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display feature grid section', async ({ page }) => {
|
test('should display feature grid section', async ({ page }) => {
|
||||||
@@ -417,30 +417,30 @@ test.describe('Homepage - Feature Sections', () => {
|
|||||||
const authLink = page.getByRole('link', { name: /View Auth Flow/i });
|
const authLink = page.getByRole('link', { name: /View Auth Flow/i });
|
||||||
|
|
||||||
// Verify link has correct href
|
// Verify link has correct href
|
||||||
await expect(authLink).toHaveAttribute('href', '/login');
|
await expect(authLink).toHaveAttribute('href', '/en/login');
|
||||||
|
|
||||||
// Click and try to navigate
|
// Click and try to navigate
|
||||||
await authLink.click();
|
await authLink.click();
|
||||||
await page.waitForURL('/login', { timeout: 10000 }).catch(() => {});
|
await page.waitForURL('/en/login', { timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Verify URL (flexible to handle redirects)
|
// Verify URL (flexible to handle redirects)
|
||||||
const currentUrl = page.url();
|
const currentUrl = page.url();
|
||||||
expect(currentUrl).toMatch(/\/(login)?$/);
|
expect(currentUrl).toMatch(/\/en(\/login)?$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to admin from admin panel CTA', async ({ page }) => {
|
test('should navigate to admin from admin panel CTA', async ({ page }) => {
|
||||||
const adminLink = page.getByRole('link', { name: /Try Admin Panel/i });
|
const adminLink = page.getByRole('link', { name: /Try Admin Panel/i });
|
||||||
|
|
||||||
// Verify link has correct href
|
// Verify link has correct href
|
||||||
await expect(adminLink).toHaveAttribute('href', '/admin');
|
await expect(adminLink).toHaveAttribute('href', '/en/admin');
|
||||||
|
|
||||||
// Click and try to navigate
|
// Click and try to navigate
|
||||||
await adminLink.click();
|
await adminLink.click();
|
||||||
await page.waitForURL('/admin', { timeout: 10000 }).catch(() => {});
|
await page.waitForURL('/en/admin', { timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Verify URL (flexible to handle auth redirects)
|
// Verify URL (flexible to handle auth redirects)
|
||||||
const currentUrl = page.url();
|
const currentUrl = page.url();
|
||||||
expect(currentUrl).toMatch(/\/(admin)?$/);
|
expect(currentUrl).toMatch(/\/en(\/admin)?$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display tech stack section', async ({ page }) => {
|
test('should display tech stack section', async ({ page }) => {
|
||||||
@@ -462,7 +462,7 @@ test.describe('Homepage - Feature Sections', () => {
|
|||||||
|
|
||||||
test.describe('Homepage - Footer', () => {
|
test.describe('Homepage - Footer', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display footer with copyright', async ({ page }) => {
|
test('should display footer with copyright', async ({ page }) => {
|
||||||
@@ -475,7 +475,7 @@ test.describe('Homepage - Footer', () => {
|
|||||||
|
|
||||||
test.describe('Homepage - Accessibility', () => {
|
test.describe('Homepage - Accessibility', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should have proper heading hierarchy', async ({ page }) => {
|
test('should have proper heading hierarchy', async ({ page }) => {
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ test.describe('Settings Navigation', () => {
|
|||||||
|
|
||||||
test('should navigate from home to settings profile', async ({ page }) => {
|
test('should navigate from home to settings profile', async ({ page }) => {
|
||||||
// Start at home page (auth already cached in storage state)
|
// Start at home page (auth already cached in storage state)
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
await expect(page).toHaveURL('/');
|
await expect(page).toHaveURL('/en');
|
||||||
|
|
||||||
// Navigate to settings/profile
|
// Navigate to settings/profile
|
||||||
await page.goto('/settings/profile');
|
await page.goto('/en/settings/profile');
|
||||||
|
|
||||||
// Verify navigation successful
|
// Verify navigation successful
|
||||||
await expect(page).toHaveURL('/settings/profile');
|
await expect(page).toHaveURL('/en/settings/profile');
|
||||||
|
|
||||||
// Verify page loaded - use specific heading selector
|
// Verify page loaded - use specific heading selector
|
||||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
|
||||||
@@ -32,14 +32,14 @@ test.describe('Settings Navigation', () => {
|
|||||||
|
|
||||||
test('should navigate from home to settings password', async ({ page }) => {
|
test('should navigate from home to settings password', async ({ page }) => {
|
||||||
// Start at home page (auth already cached in storage state)
|
// Start at home page (auth already cached in storage state)
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
await expect(page).toHaveURL('/');
|
await expect(page).toHaveURL('/en');
|
||||||
|
|
||||||
// Navigate to settings/password
|
// Navigate to settings/password
|
||||||
await page.goto('/settings/password');
|
await page.goto('/en/settings/password');
|
||||||
|
|
||||||
// Verify navigation successful
|
// Verify navigation successful
|
||||||
await expect(page).toHaveURL('/settings/password');
|
await expect(page).toHaveURL('/en/settings/password');
|
||||||
|
|
||||||
// Verify page loaded - use specific heading selector
|
// Verify page loaded - use specific heading selector
|
||||||
await expect(page.getByRole('heading', { name: 'Password' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Password' })).toBeVisible();
|
||||||
@@ -47,24 +47,24 @@ test.describe('Settings Navigation', () => {
|
|||||||
|
|
||||||
test('should navigate between settings pages', async ({ page }) => {
|
test('should navigate between settings pages', async ({ page }) => {
|
||||||
// Start at profile page
|
// Start at profile page
|
||||||
await page.goto('/settings/profile');
|
await page.goto('/en/settings/profile');
|
||||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
|
||||||
|
|
||||||
// Navigate to password page
|
// Navigate to password page
|
||||||
await page.goto('/settings/password');
|
await page.goto('/en/settings/password');
|
||||||
await expect(page.getByRole('heading', { name: 'Password' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Password' })).toBeVisible();
|
||||||
|
|
||||||
// Navigate back to profile page
|
// Navigate back to profile page
|
||||||
await page.goto('/settings/profile');
|
await page.goto('/en/settings/profile');
|
||||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should redirect from /settings to /settings/profile', async ({ page }) => {
|
test('should redirect from /settings to /settings/profile', async ({ page }) => {
|
||||||
// Navigate to base settings page
|
// Navigate to base settings page
|
||||||
await page.goto('/settings');
|
await page.goto('/en/settings');
|
||||||
|
|
||||||
// Should redirect to profile page
|
// Should redirect to profile page
|
||||||
await expect(page).toHaveURL('/settings/profile');
|
await expect(page).toHaveURL('/en/settings/profile');
|
||||||
|
|
||||||
// Verify profile page loaded - use specific heading selector
|
// Verify profile page loaded - use specific heading selector
|
||||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
|
||||||
@@ -72,10 +72,10 @@ test.describe('Settings Navigation', () => {
|
|||||||
|
|
||||||
test('should display preferences page placeholder', async ({ page }) => {
|
test('should display preferences page placeholder', async ({ page }) => {
|
||||||
// Navigate to preferences page
|
// Navigate to preferences page
|
||||||
await page.goto('/settings/preferences');
|
await page.goto('/en/settings/preferences');
|
||||||
|
|
||||||
// Verify navigation successful
|
// Verify navigation successful
|
||||||
await expect(page).toHaveURL('/settings/preferences');
|
await expect(page).toHaveURL('/en/settings/preferences');
|
||||||
|
|
||||||
// Verify page loaded with placeholder content
|
// Verify page loaded with placeholder content
|
||||||
await expect(page.getByRole('heading', { name: 'Preferences' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Preferences' })).toBeVisible();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ test.describe('Password Change', () => {
|
|||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
|
|
||||||
// Navigate to password page
|
// Navigate to password page
|
||||||
await page.goto('/settings/password');
|
await page.goto('/en/settings/password');
|
||||||
|
|
||||||
// Wait for form to be visible
|
// Wait for form to be visible
|
||||||
await page.getByLabel(/current password/i).waitFor({ state: 'visible' });
|
await page.getByLabel(/current password/i).waitFor({ state: 'visible' });
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ test.describe('Profile Settings', () => {
|
|||||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||||
|
|
||||||
// Navigate to profile page
|
// Navigate to profile page
|
||||||
await page.goto('/settings/profile');
|
await page.goto('/en/settings/profile');
|
||||||
|
|
||||||
// Wait for page to render
|
// Wait for page to render
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import { test, expect } from '@playwright/test';
|
|||||||
test.describe('Theme Toggle on Public Pages', () => {
|
test.describe('Theme Toggle on Public Pages', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
// Clear localStorage before each test
|
// Clear localStorage before each test
|
||||||
await page.goto('/login');
|
await page.goto('/en/login');
|
||||||
await page.evaluate(() => localStorage.clear());
|
await page.evaluate(() => localStorage.clear());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('theme is applied on login page', async ({ page }) => {
|
test('theme is applied on login page', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/en/login');
|
||||||
|
|
||||||
// Wait for page to load and theme to be applied
|
// Wait for page to load and theme to be applied
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
@@ -27,7 +27,7 @@ test.describe('Theme Toggle on Public Pages', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('theme persists across page navigation', async ({ page }) => {
|
test('theme persists across page navigation', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/en/login');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Set theme to dark via localStorage
|
// Set theme to dark via localStorage
|
||||||
@@ -43,14 +43,14 @@ test.describe('Theme Toggle on Public Pages', () => {
|
|||||||
await expect(page.locator('html')).toHaveClass(/dark/);
|
await expect(page.locator('html')).toHaveClass(/dark/);
|
||||||
|
|
||||||
// Navigate to register page
|
// Navigate to register page
|
||||||
await page.goto('/register');
|
await page.goto('/en/register');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Theme should still be dark
|
// Theme should still be dark
|
||||||
await expect(page.locator('html')).toHaveClass(/dark/);
|
await expect(page.locator('html')).toHaveClass(/dark/);
|
||||||
|
|
||||||
// Navigate to password reset
|
// Navigate to password reset
|
||||||
await page.goto('/password-reset');
|
await page.goto('/en/password-reset');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Theme should still be dark
|
// Theme should still be dark
|
||||||
@@ -58,7 +58,7 @@ test.describe('Theme Toggle on Public Pages', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('can switch theme programmatically', async ({ page }) => {
|
test('can switch theme programmatically', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/en/login');
|
||||||
|
|
||||||
// Set to light theme
|
// Set to light theme
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ const customJestConfig = {
|
|||||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
testEnvironment: 'jest-environment-jsdom',
|
testEnvironment: 'jest-environment-jsdom',
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
|
'^next-intl$': '<rootDir>/tests/__mocks__/next-intl.tsx',
|
||||||
|
'^next-intl/routing$': '<rootDir>/tests/__mocks__/next-intl-routing.tsx',
|
||||||
|
'^next-intl/navigation$': '<rootDir>/tests/__mocks__/next-intl-navigation.tsx',
|
||||||
|
'^@/components/i18n$': '<rootDir>/tests/__mocks__/components-i18n.tsx',
|
||||||
'^@/(.*)$': '<rootDir>/src/$1',
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
},
|
},
|
||||||
testMatch: ['<rootDir>/tests/**/*.test.ts', '<rootDir>/tests/**/*.test.tsx'],
|
testMatch: ['<rootDir>/tests/**/*.test.ts', '<rootDir>/tests/**/*.test.tsx'],
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!(react-syntax-highlighter|refractor|hastscript|hast-.*|unist-.*|property-information|space-separated-tokens|comma-separated-tokens|web-namespaces)/)',
|
'node_modules/(?!(react-syntax-highlighter|refractor|hastscript|hast-.*|unist-.*|property-information|space-separated-tokens|comma-separated-tokens|web-namespaces|next-intl|use-intl)/)',
|
||||||
],
|
],
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'src/**/*.{js,jsx,ts,tsx}',
|
'src/**/*.{js,jsx,ts,tsx}',
|
||||||
|
|||||||
255
frontend/messages/en.json
Normal file
255
frontend/messages/en.json
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Success",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"close": "Close",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"submit": "Submit",
|
||||||
|
"search": "Search",
|
||||||
|
"filter": "Filter",
|
||||||
|
"clear": "Clear",
|
||||||
|
"required": "Required",
|
||||||
|
"optional": "Optional",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"home": "Home",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"settings": "Settings",
|
||||||
|
"profile": "Profile",
|
||||||
|
"logout": "Log out",
|
||||||
|
"loggingOut": "Logging out...",
|
||||||
|
"login": "Login",
|
||||||
|
"register": "Register",
|
||||||
|
"demos": "Demos",
|
||||||
|
"design": "Design System",
|
||||||
|
"admin": "Admin",
|
||||||
|
"adminPanel": "Admin Panel"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to your account",
|
||||||
|
"subtitle": "Enter your email below to sign in to your account",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "name@example.com",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "Enter your password",
|
||||||
|
"rememberMe": "Remember me",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"loginButton": "Sign in",
|
||||||
|
"loginButtonLoading": "Signing in...",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"registerLink": "Sign up",
|
||||||
|
"success": "Successfully logged in",
|
||||||
|
"error": "Invalid email or password",
|
||||||
|
"unexpectedError": "An unexpected error occurred. Please try again."
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Create an account",
|
||||||
|
"subtitle": "Enter your information to create an account",
|
||||||
|
"firstNameLabel": "First Name",
|
||||||
|
"firstNamePlaceholder": "John",
|
||||||
|
"lastNameLabel": "Last Name",
|
||||||
|
"lastNamePlaceholder": "Doe (optional)",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "Create a strong password",
|
||||||
|
"confirmPasswordLabel": "Confirm Password",
|
||||||
|
"confirmPasswordPlaceholder": "Confirm your password",
|
||||||
|
"phoneLabel": "Phone Number",
|
||||||
|
"phonePlaceholder": "+1 (555) 000-0000",
|
||||||
|
"registerButton": "Create account",
|
||||||
|
"registerButtonLoading": "Creating account...",
|
||||||
|
"hasAccount": "Already have an account?",
|
||||||
|
"loginLink": "Sign in",
|
||||||
|
"success": "Account created successfully",
|
||||||
|
"error": "Failed to create account",
|
||||||
|
"unexpectedError": "An unexpected error occurred. Please try again.",
|
||||||
|
"passwordRequirements": {
|
||||||
|
"minLength": "At least 8 characters",
|
||||||
|
"hasNumber": "Contains a number",
|
||||||
|
"hasUppercase": "Contains an uppercase letter"
|
||||||
|
},
|
||||||
|
"required": "*",
|
||||||
|
"firstNameRequired": "First name is required",
|
||||||
|
"firstNameMinLength": "First name must be at least 2 characters",
|
||||||
|
"firstNameMaxLength": "First name must not exceed 50 characters",
|
||||||
|
"lastNameMaxLength": "Last name must not exceed 50 characters",
|
||||||
|
"passwordRequired": "Password is required",
|
||||||
|
"passwordMinLength": "Password must be at least 8 characters",
|
||||||
|
"passwordNumber": "Password must contain at least one number",
|
||||||
|
"passwordUppercase": "Password must contain at least one uppercase letter",
|
||||||
|
"confirmPasswordRequired": "Please confirm your password",
|
||||||
|
"passwordMismatch": "Passwords do not match"
|
||||||
|
},
|
||||||
|
"passwordReset": {
|
||||||
|
"title": "Reset your password",
|
||||||
|
"subtitle": "Enter your email address and we'll send you a reset link",
|
||||||
|
"instructions": "Enter your email address and we'll send you instructions to reset your password.",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"sendButton": "Send Reset Instructions",
|
||||||
|
"sendButtonLoading": "Sending...",
|
||||||
|
"backToLogin": "Back to login",
|
||||||
|
"rememberPassword": "Remember your password?",
|
||||||
|
"success": "Password reset instructions have been sent to your email address. Please check your inbox.",
|
||||||
|
"error": "Failed to send reset link",
|
||||||
|
"unexpectedError": "An unexpected error occurred. Please try again.",
|
||||||
|
"required": "*"
|
||||||
|
},
|
||||||
|
"passwordChange": {
|
||||||
|
"title": "Change Password",
|
||||||
|
"currentPasswordLabel": "Current Password",
|
||||||
|
"newPasswordLabel": "New Password",
|
||||||
|
"newPasswordPlaceholder": "Enter new password",
|
||||||
|
"confirmPasswordLabel": "Confirm New Password",
|
||||||
|
"confirmPasswordPlaceholder": "Re-enter new password",
|
||||||
|
"changeButton": "Change Password",
|
||||||
|
"success": "Password changed successfully",
|
||||||
|
"error": "Failed to change password"
|
||||||
|
},
|
||||||
|
"passwordResetConfirm": {
|
||||||
|
"title": "Reset Password",
|
||||||
|
"instructions": "Enter your new password below. Make sure it meets all security requirements.",
|
||||||
|
"newPasswordLabel": "New Password",
|
||||||
|
"newPasswordPlaceholder": "Enter new password",
|
||||||
|
"confirmPasswordLabel": "Confirm Password",
|
||||||
|
"confirmPasswordPlaceholder": "Re-enter new password",
|
||||||
|
"resetButton": "Reset Password",
|
||||||
|
"resetButtonLoading": "Resetting Password...",
|
||||||
|
"rememberPassword": "Remember your password?",
|
||||||
|
"backToLogin": "Back to login",
|
||||||
|
"success": "Your password has been successfully reset. You can now log in with your new password.",
|
||||||
|
"unexpectedError": "An unexpected error occurred. Please try again.",
|
||||||
|
"required": "*",
|
||||||
|
"tokenRequired": "Reset token is required",
|
||||||
|
"passwordRequired": "New password is required",
|
||||||
|
"passwordMinLength": "Password must be at least 8 characters",
|
||||||
|
"passwordNumber": "Password must contain at least one number",
|
||||||
|
"passwordUppercase": "Password must contain at least one uppercase letter",
|
||||||
|
"confirmPasswordRequired": "Please confirm your password",
|
||||||
|
"passwordMismatch": "Passwords do not match",
|
||||||
|
"passwordRequirements": {
|
||||||
|
"minLength": "At least 8 characters",
|
||||||
|
"hasNumber": "Contains a number",
|
||||||
|
"hasUppercase": "Contains an uppercase letter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"profile": {
|
||||||
|
"title": "Profile Information",
|
||||||
|
"subtitle": "Update your personal information. Your email address is read-only.",
|
||||||
|
"firstNameLabel": "First Name",
|
||||||
|
"firstNamePlaceholder": "John",
|
||||||
|
"lastNameLabel": "Last Name",
|
||||||
|
"lastNamePlaceholder": "Doe",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailDescription": "Your email address cannot be changed from this form",
|
||||||
|
"phoneLabel": "Phone Number",
|
||||||
|
"updateButton": "Save Changes",
|
||||||
|
"updateButtonLoading": "Saving...",
|
||||||
|
"resetButton": "Reset",
|
||||||
|
"success": "Profile updated successfully",
|
||||||
|
"error": "Failed to update profile",
|
||||||
|
"unexpectedError": "An unexpected error occurred. Please try again.",
|
||||||
|
"firstNameRequired": "First name is required",
|
||||||
|
"firstNameMinLength": "First name must be at least 2 characters",
|
||||||
|
"firstNameMaxLength": "First name must not exceed 50 characters",
|
||||||
|
"lastNameMaxLength": "Last name must not exceed 50 characters",
|
||||||
|
"emailInvalid": "Invalid email address"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"title": "Change Password",
|
||||||
|
"subtitle": "Update your password to keep your account secure. Make sure it's strong and unique.",
|
||||||
|
"currentPasswordLabel": "Current Password",
|
||||||
|
"currentPasswordPlaceholder": "Enter your current password",
|
||||||
|
"newPasswordLabel": "New Password",
|
||||||
|
"newPasswordPlaceholder": "Enter your new password",
|
||||||
|
"newPasswordDescription": "At least 8 characters with uppercase, lowercase, number, and special character",
|
||||||
|
"confirmPasswordLabel": "Confirm New Password",
|
||||||
|
"confirmPasswordPlaceholder": "Confirm your new password",
|
||||||
|
"updateButton": "Change Password",
|
||||||
|
"updateButtonLoading": "Changing Password...",
|
||||||
|
"cancelButton": "Cancel",
|
||||||
|
"success": "Password updated successfully",
|
||||||
|
"error": "Failed to update password",
|
||||||
|
"unexpectedError": "An unexpected error occurred. Please try again.",
|
||||||
|
"currentPasswordRequired": "Current password is required",
|
||||||
|
"newPasswordRequired": "New password is required",
|
||||||
|
"newPasswordMinLength": "Password must be at least 8 characters",
|
||||||
|
"newPasswordNumber": "Password must contain at least one number",
|
||||||
|
"newPasswordUppercase": "Password must contain at least one uppercase letter",
|
||||||
|
"newPasswordLowercase": "Password must contain at least one lowercase letter",
|
||||||
|
"newPasswordSpecial": "Password must contain at least one special character",
|
||||||
|
"confirmPasswordRequired": "Please confirm your new password",
|
||||||
|
"passwordMismatch": "Passwords do not match"
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "Sessions",
|
||||||
|
"subtitle": "Manage your active sessions",
|
||||||
|
"currentSession": "Current Session",
|
||||||
|
"activeSession": "Active",
|
||||||
|
"lastActive": "Last active",
|
||||||
|
"device": "Device",
|
||||||
|
"location": "Location",
|
||||||
|
"revokeButton": "Revoke",
|
||||||
|
"revokeAll": "Revoke All Other Sessions",
|
||||||
|
"success": "Session revoked successfully",
|
||||||
|
"error": "Failed to revoke session"
|
||||||
|
},
|
||||||
|
"preferences": {
|
||||||
|
"title": "Preferences",
|
||||||
|
"subtitle": "Customize your experience",
|
||||||
|
"language": "Language",
|
||||||
|
"languageDescription": "Select your preferred language",
|
||||||
|
"theme": "Theme",
|
||||||
|
"themeDescription": "Choose your color scheme",
|
||||||
|
"themeLight": "Light",
|
||||||
|
"themeDark": "Dark",
|
||||||
|
"themeSystem": "System"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"notFound": "Page not found",
|
||||||
|
"notFoundDescription": "The page you're looking for doesn't exist.",
|
||||||
|
"unauthorized": "Unauthorized",
|
||||||
|
"unauthorizedDescription": "You don't have permission to access this page.",
|
||||||
|
"serverError": "Server error",
|
||||||
|
"serverErrorDescription": "Something went wrong on our end.",
|
||||||
|
"networkError": "Network error",
|
||||||
|
"networkErrorDescription": "Please check your internet connection.",
|
||||||
|
"validation": {
|
||||||
|
"required": "This field is required",
|
||||||
|
"email": "Please enter a valid email address",
|
||||||
|
"minLength": "Must be at least {min} characters",
|
||||||
|
"maxLength": "Must be at most {max} characters",
|
||||||
|
"passwordMismatch": "Passwords do not match",
|
||||||
|
"passwordWeak": "Password is too weak"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "This field is required",
|
||||||
|
"email": "Invalid email address",
|
||||||
|
"minLength": "Minimum {count} characters required",
|
||||||
|
"maxLength": "Maximum {count} characters allowed",
|
||||||
|
"pattern": "Invalid format",
|
||||||
|
"passwordMismatch": "Passwords do not match"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"en": "English",
|
||||||
|
"it": "Italian",
|
||||||
|
"switchLanguage": "Switch language",
|
||||||
|
"currentLanguage": "Current language"
|
||||||
|
}
|
||||||
|
}
|
||||||
255
frontend/messages/it.json
Normal file
255
frontend/messages/it.json
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Caricamento...",
|
||||||
|
"error": "Errore",
|
||||||
|
"success": "Successo",
|
||||||
|
"cancel": "Annulla",
|
||||||
|
"save": "Salva",
|
||||||
|
"delete": "Elimina",
|
||||||
|
"edit": "Modifica",
|
||||||
|
"close": "Chiudi",
|
||||||
|
"confirm": "Conferma",
|
||||||
|
"back": "Indietro",
|
||||||
|
"next": "Avanti",
|
||||||
|
"submit": "Invia",
|
||||||
|
"search": "Cerca",
|
||||||
|
"filter": "Filtra",
|
||||||
|
"clear": "Cancella",
|
||||||
|
"required": "Obbligatorio",
|
||||||
|
"optional": "Facoltativo",
|
||||||
|
"yes": "Sì",
|
||||||
|
"no": "No"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"home": "Home",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"settings": "Impostazioni",
|
||||||
|
"profile": "Profilo",
|
||||||
|
"logout": "Disconnetti",
|
||||||
|
"loggingOut": "Disconnessione...",
|
||||||
|
"login": "Accedi",
|
||||||
|
"register": "Registrati",
|
||||||
|
"demos": "Demo",
|
||||||
|
"design": "Design System",
|
||||||
|
"admin": "Admin",
|
||||||
|
"adminPanel": "Pannello Admin"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": {
|
||||||
|
"title": "Accedi al tuo account",
|
||||||
|
"subtitle": "Inserisci la tua email per accedere al tuo account",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "nome@esempio.com",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "Inserisci la tua password",
|
||||||
|
"rememberMe": "Ricordami",
|
||||||
|
"forgotPassword": "Password dimenticata?",
|
||||||
|
"loginButton": "Accedi",
|
||||||
|
"loginButtonLoading": "Accesso in corso...",
|
||||||
|
"noAccount": "Non hai un account?",
|
||||||
|
"registerLink": "Registrati",
|
||||||
|
"success": "Accesso effettuato con successo",
|
||||||
|
"error": "Email o password non validi",
|
||||||
|
"unexpectedError": "Si è verificato un errore imprevisto. Riprova."
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Crea un account",
|
||||||
|
"subtitle": "Inserisci le tue informazioni per creare un account",
|
||||||
|
"firstNameLabel": "Nome",
|
||||||
|
"firstNamePlaceholder": "Mario",
|
||||||
|
"lastNameLabel": "Cognome",
|
||||||
|
"lastNamePlaceholder": "Rossi (facoltativo)",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "nome@esempio.com",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "Crea una password sicura",
|
||||||
|
"confirmPasswordLabel": "Conferma Password",
|
||||||
|
"confirmPasswordPlaceholder": "Conferma la tua password",
|
||||||
|
"phoneLabel": "Numero di Telefono",
|
||||||
|
"phonePlaceholder": "+39 123 456 7890",
|
||||||
|
"registerButton": "Crea account",
|
||||||
|
"registerButtonLoading": "Creazione account...",
|
||||||
|
"hasAccount": "Hai già un account?",
|
||||||
|
"loginLink": "Accedi",
|
||||||
|
"success": "Account creato con successo",
|
||||||
|
"error": "Impossibile creare l'account",
|
||||||
|
"unexpectedError": "Si è verificato un errore imprevisto. Riprova.",
|
||||||
|
"passwordRequirements": {
|
||||||
|
"minLength": "Almeno 8 caratteri",
|
||||||
|
"hasNumber": "Contiene un numero",
|
||||||
|
"hasUppercase": "Contiene una lettera maiuscola"
|
||||||
|
},
|
||||||
|
"required": "*",
|
||||||
|
"firstNameRequired": "Il nome è obbligatorio",
|
||||||
|
"firstNameMinLength": "Il nome deve essere di almeno 2 caratteri",
|
||||||
|
"firstNameMaxLength": "Il nome non deve superare i 50 caratteri",
|
||||||
|
"lastNameMaxLength": "Il cognome non deve superare i 50 caratteri",
|
||||||
|
"passwordRequired": "La password è obbligatoria",
|
||||||
|
"passwordMinLength": "La password deve essere di almeno 8 caratteri",
|
||||||
|
"passwordNumber": "La password deve contenere almeno un numero",
|
||||||
|
"passwordUppercase": "La password deve contenere almeno una lettera maiuscola",
|
||||||
|
"confirmPasswordRequired": "Conferma la tua password",
|
||||||
|
"passwordMismatch": "Le password non corrispondono"
|
||||||
|
},
|
||||||
|
"passwordReset": {
|
||||||
|
"title": "Reimposta la tua password",
|
||||||
|
"subtitle": "Inserisci il tuo indirizzo email e ti invieremo un link per reimpostare la password",
|
||||||
|
"instructions": "Inserisci il tuo indirizzo email e ti invieremo le istruzioni per reimpostare la password.",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "nome@esempio.com",
|
||||||
|
"sendButton": "Invia Istruzioni di Reset",
|
||||||
|
"sendButtonLoading": "Invio in corso...",
|
||||||
|
"backToLogin": "Torna al login",
|
||||||
|
"rememberPassword": "Ricordi la tua password?",
|
||||||
|
"success": "Le istruzioni per il reset della password sono state inviate al tuo indirizzo email. Controlla la tua casella di posta.",
|
||||||
|
"error": "Impossibile inviare il link di reset",
|
||||||
|
"unexpectedError": "Si è verificato un errore imprevisto. Riprova.",
|
||||||
|
"required": "*"
|
||||||
|
},
|
||||||
|
"passwordChange": {
|
||||||
|
"title": "Cambia Password",
|
||||||
|
"currentPasswordLabel": "Password Attuale",
|
||||||
|
"newPasswordLabel": "Nuova Password",
|
||||||
|
"newPasswordPlaceholder": "Inserisci nuova password",
|
||||||
|
"confirmPasswordLabel": "Conferma Nuova Password",
|
||||||
|
"confirmPasswordPlaceholder": "Reinserisci nuova password",
|
||||||
|
"changeButton": "Cambia Password",
|
||||||
|
"success": "Password cambiata con successo",
|
||||||
|
"error": "Impossibile cambiare la password"
|
||||||
|
},
|
||||||
|
"passwordResetConfirm": {
|
||||||
|
"title": "Reimposta Password",
|
||||||
|
"instructions": "Inserisci la tua nuova password qui sotto. Assicurati che soddisfi tutti i requisiti di sicurezza.",
|
||||||
|
"newPasswordLabel": "Nuova Password",
|
||||||
|
"newPasswordPlaceholder": "Inserisci nuova password",
|
||||||
|
"confirmPasswordLabel": "Conferma Password",
|
||||||
|
"confirmPasswordPlaceholder": "Reinserisci nuova password",
|
||||||
|
"resetButton": "Reimposta Password",
|
||||||
|
"resetButtonLoading": "Reimpostazione Password...",
|
||||||
|
"rememberPassword": "Ricordi la tua password?",
|
||||||
|
"backToLogin": "Torna al login",
|
||||||
|
"success": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password.",
|
||||||
|
"unexpectedError": "Si è verificato un errore imprevisto. Riprova.",
|
||||||
|
"required": "*",
|
||||||
|
"tokenRequired": "Token di reset è obbligatorio",
|
||||||
|
"passwordRequired": "La nuova password è obbligatoria",
|
||||||
|
"passwordMinLength": "La password deve essere di almeno 8 caratteri",
|
||||||
|
"passwordNumber": "La password deve contenere almeno un numero",
|
||||||
|
"passwordUppercase": "La password deve contenere almeno una lettera maiuscola",
|
||||||
|
"confirmPasswordRequired": "Conferma la tua password",
|
||||||
|
"passwordMismatch": "Le password non corrispondono",
|
||||||
|
"passwordRequirements": {
|
||||||
|
"minLength": "Almeno 8 caratteri",
|
||||||
|
"hasNumber": "Contiene un numero",
|
||||||
|
"hasUppercase": "Contiene una lettera maiuscola"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Impostazioni",
|
||||||
|
"profile": {
|
||||||
|
"title": "Informazioni Profilo",
|
||||||
|
"subtitle": "Aggiorna le tue informazioni personali. Il tuo indirizzo email è di sola lettura.",
|
||||||
|
"firstNameLabel": "Nome",
|
||||||
|
"firstNamePlaceholder": "Mario",
|
||||||
|
"lastNameLabel": "Cognome",
|
||||||
|
"lastNamePlaceholder": "Rossi",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailDescription": "Il tuo indirizzo email non può essere modificato da questo modulo",
|
||||||
|
"phoneLabel": "Numero di Telefono",
|
||||||
|
"updateButton": "Salva Modifiche",
|
||||||
|
"updateButtonLoading": "Salvataggio...",
|
||||||
|
"resetButton": "Ripristina",
|
||||||
|
"success": "Profilo aggiornato con successo",
|
||||||
|
"error": "Impossibile aggiornare il profilo",
|
||||||
|
"unexpectedError": "Si è verificato un errore imprevisto. Riprova.",
|
||||||
|
"firstNameRequired": "Il nome è obbligatorio",
|
||||||
|
"firstNameMinLength": "Il nome deve essere di almeno 2 caratteri",
|
||||||
|
"firstNameMaxLength": "Il nome non deve superare i 50 caratteri",
|
||||||
|
"lastNameMaxLength": "Il cognome non deve superare i 50 caratteri",
|
||||||
|
"emailInvalid": "Indirizzo email non valido"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"title": "Cambia Password",
|
||||||
|
"subtitle": "Aggiorna la tua password per mantenere il tuo account sicuro. Assicurati che sia forte e univoca.",
|
||||||
|
"currentPasswordLabel": "Password Attuale",
|
||||||
|
"currentPasswordPlaceholder": "Inserisci la tua password attuale",
|
||||||
|
"newPasswordLabel": "Nuova Password",
|
||||||
|
"newPasswordPlaceholder": "Inserisci la tua nuova password",
|
||||||
|
"newPasswordDescription": "Almeno 8 caratteri con maiuscole, minuscole, numeri e caratteri speciali",
|
||||||
|
"confirmPasswordLabel": "Conferma Nuova Password",
|
||||||
|
"confirmPasswordPlaceholder": "Conferma la tua nuova password",
|
||||||
|
"updateButton": "Cambia Password",
|
||||||
|
"updateButtonLoading": "Cambio Password...",
|
||||||
|
"cancelButton": "Annulla",
|
||||||
|
"success": "Password aggiornata con successo",
|
||||||
|
"error": "Impossibile aggiornare la password",
|
||||||
|
"unexpectedError": "Si è verificato un errore imprevisto. Riprova.",
|
||||||
|
"currentPasswordRequired": "La password attuale è obbligatoria",
|
||||||
|
"newPasswordRequired": "La nuova password è obbligatoria",
|
||||||
|
"newPasswordMinLength": "La password deve essere di almeno 8 caratteri",
|
||||||
|
"newPasswordNumber": "La password deve contenere almeno un numero",
|
||||||
|
"newPasswordUppercase": "La password deve contenere almeno una lettera maiuscola",
|
||||||
|
"newPasswordLowercase": "La password deve contenere almeno una lettera minuscola",
|
||||||
|
"newPasswordSpecial": "La password deve contenere almeno un carattere speciale",
|
||||||
|
"confirmPasswordRequired": "Conferma la tua nuova password",
|
||||||
|
"passwordMismatch": "Le password non corrispondono"
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "Sessioni",
|
||||||
|
"subtitle": "Gestisci le tue sessioni attive",
|
||||||
|
"currentSession": "Sessione Corrente",
|
||||||
|
"activeSession": "Attiva",
|
||||||
|
"lastActive": "Ultima attività",
|
||||||
|
"device": "Dispositivo",
|
||||||
|
"location": "Posizione",
|
||||||
|
"revokeButton": "Revoca",
|
||||||
|
"revokeAll": "Revoca Tutte le Altre Sessioni",
|
||||||
|
"success": "Sessione revocata con successo",
|
||||||
|
"error": "Impossibile revocare la sessione"
|
||||||
|
},
|
||||||
|
"preferences": {
|
||||||
|
"title": "Preferenze",
|
||||||
|
"subtitle": "Personalizza la tua esperienza",
|
||||||
|
"language": "Lingua",
|
||||||
|
"languageDescription": "Seleziona la tua lingua preferita",
|
||||||
|
"theme": "Tema",
|
||||||
|
"themeDescription": "Scegli la tua combinazione di colori",
|
||||||
|
"themeLight": "Chiaro",
|
||||||
|
"themeDark": "Scuro",
|
||||||
|
"themeSystem": "Sistema"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"notFound": "Pagina non trovata",
|
||||||
|
"notFoundDescription": "La pagina che stai cercando non esiste.",
|
||||||
|
"unauthorized": "Non autorizzato",
|
||||||
|
"unauthorizedDescription": "Non hai i permessi per accedere a questa pagina.",
|
||||||
|
"serverError": "Errore del server",
|
||||||
|
"serverErrorDescription": "Qualcosa è andato storto dal nostro lato.",
|
||||||
|
"networkError": "Errore di rete",
|
||||||
|
"networkErrorDescription": "Controlla la tua connessione internet.",
|
||||||
|
"validation": {
|
||||||
|
"required": "Questo campo è obbligatorio",
|
||||||
|
"email": "Inserisci un indirizzo email valido",
|
||||||
|
"minLength": "Deve essere di almeno {min} caratteri",
|
||||||
|
"maxLength": "Deve essere al massimo {max} caratteri",
|
||||||
|
"passwordMismatch": "Le password non corrispondono",
|
||||||
|
"passwordWeak": "La password è troppo debole"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "Questo campo è obbligatorio",
|
||||||
|
"email": "Indirizzo email non valido",
|
||||||
|
"minLength": "Minimo {count} caratteri richiesti",
|
||||||
|
"maxLength": "Massimo {count} caratteri consentiti",
|
||||||
|
"pattern": "Formato non valido",
|
||||||
|
"passwordMismatch": "Le password non corrispondono"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"en": "Inglese",
|
||||||
|
"it": "Italiano",
|
||||||
|
"switchLanguage": "Cambia lingua",
|
||||||
|
"currentLanguage": "Lingua corrente"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { NextConfig } from 'next';
|
import type { NextConfig } from 'next';
|
||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
|
|
||||||
|
// Initialize next-intl plugin with i18n request config path
|
||||||
|
const withNextIntl = createNextIntlPlugin('./src/lib/i18n/request.ts');
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
@@ -21,4 +25,5 @@ const nextConfig: NextConfig = {
|
|||||||
// Note: swcMinify is default in Next.js 15
|
// Note: swcMinify is default in Next.js 15
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
// Wrap config with next-intl plugin
|
||||||
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
300
frontend/package-lock.json
generated
300
frontend/package-lock.json
generated
@@ -31,6 +31,7 @@
|
|||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lucide-react": "^0.552.0",
|
"lucide-react": "^0.552.0",
|
||||||
"next": "^15.5.6",
|
"next": "^15.5.6",
|
||||||
|
"next-intl": "^4.5.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -1022,7 +1023,6 @@
|
|||||||
"version": "2.3.6",
|
"version": "2.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
|
||||||
"integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
|
"integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/fast-memoize": "2.2.7",
|
"@formatjs/fast-memoize": "2.2.7",
|
||||||
@@ -1035,7 +1035,6 @@
|
|||||||
"version": "2.2.7",
|
"version": "2.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
|
||||||
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
|
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
@@ -1045,7 +1044,6 @@
|
|||||||
"version": "2.11.4",
|
"version": "2.11.4",
|
||||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",
|
||||||
"integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",
|
"integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/ecma402-abstract": "2.3.6",
|
"@formatjs/ecma402-abstract": "2.3.6",
|
||||||
@@ -1057,7 +1055,6 @@
|
|||||||
"version": "1.8.16",
|
"version": "1.8.16",
|
||||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",
|
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",
|
||||||
"integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",
|
"integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/ecma402-abstract": "2.3.6",
|
"@formatjs/ecma402-abstract": "2.3.6",
|
||||||
@@ -1068,7 +1065,6 @@
|
|||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
|
||||||
"integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
|
"integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
@@ -4260,6 +4256,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@schummar/icu-type-parser": {
|
||||||
|
"version": "1.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
|
||||||
|
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@sentry/core": {
|
"node_modules/@sentry/core": {
|
||||||
"version": "9.46.0",
|
"version": "9.46.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.46.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.46.0.tgz",
|
||||||
@@ -4420,6 +4422,172 @@
|
|||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/core-darwin-arm64": {
|
||||||
|
"version": "1.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.2.tgz",
|
||||||
|
"integrity": "sha512-Ghyz4RJv4zyXzrUC1B2MLQBbppIB5c4jMZJybX2ebdEQAvryEKp3gq1kBksCNsatKGmEgXul88SETU19sMWcrw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-darwin-x64": {
|
||||||
|
"version": "1.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.2.tgz",
|
||||||
|
"integrity": "sha512-7n/PGJOcL2QoptzL42L5xFFfXY5rFxLHnuz1foU+4ruUTG8x2IebGhtwVTpaDN8ShEv2UZObBlT1rrXTba15Zw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.2.tgz",
|
||||||
|
"integrity": "sha512-ZUQVCfRJ9wimuxkStRSlLwqX4TEDmv6/J+E6FicGkQ6ssLMWoKDy0cAo93HiWt/TWEee5vFhFaSQYzCuBEGO6A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||||
|
"version": "1.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.2.tgz",
|
||||||
|
"integrity": "sha512-GZh3pYBmfnpQ+JIg+TqLuz+pM+Mjsk5VOzi8nwKn/m+GvQBsxD5ectRtxuWUxMGNG8h0lMy4SnHRqdK3/iJl7A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-musl": {
|
||||||
|
"version": "1.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.2.tgz",
|
||||||
|
"integrity": "sha512-5av6VYZZeneiYIodwzGMlnyVakpuYZryGzFIbgu1XP8wVylZxduEzup4eP8atiMDFmIm+s4wn8GySJmYqeJC0A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-gnu": {
|
||||||
|
"version": "1.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.2.tgz",
|
||||||
|
"integrity": "sha512-1nO/UfdCLuT/uE/7oB3EZgTeZDCIa6nL72cFEpdegnqpJVNDI6Qb8U4g/4lfVPkmHq2lvxQ0L+n+JdgaZLhrRA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-musl": {
|
||||||
|
"version": "1.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.2.tgz",
|
||||||
|
"integrity": "sha512-Ksfrb0Tx310kr+TLiUOvB/I80lyZ3lSOp6cM18zmNRT/92NB4mW8oX2Jo7K4eVEI2JWyaQUAFubDSha2Q+439A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||||
|
"version": "1.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.2.tgz",
|
||||||
|
"integrity": "sha512-IzUb5RlMUY0r1A9IuJrQ7Tbts1wWb73/zXVXT8VhewbHGoNlBKE0qUhKMED6Tv4wDF+pmbtUJmKXDthytAvLmg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||||
|
"version": "1.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.2.tgz",
|
||||||
|
"integrity": "sha512-kCATEzuY2LP9AlbU2uScjcVhgnCAkRdu62vbce17Ro5kxEHxYWcugkveyBRS3AqZGtwAKYbMAuNloer9LS/hpw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-x64-msvc": {
|
||||||
|
"version": "1.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.2.tgz",
|
||||||
|
"integrity": "sha512-iJaHeYCF4jTn7OEKSa3KRiuVFIVYts8jYjNmCdyz1u5g8HRyTDISD76r8+ljEOgm36oviRQvcXaw6LFp1m0yyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/counter": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -4429,6 +4597,15 @@
|
|||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/types": {
|
||||||
|
"version": "0.1.25",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz",
|
||||||
|
"integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.1.16",
|
"version": "4.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
||||||
@@ -7461,7 +7638,6 @@
|
|||||||
"version": "10.6.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/decimal.js-light": {
|
"node_modules/decimal.js-light": {
|
||||||
@@ -9891,7 +10067,6 @@
|
|||||||
"version": "10.7.18",
|
"version": "10.7.18",
|
||||||
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz",
|
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz",
|
||||||
"integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==",
|
"integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/ecma402-abstract": "2.3.6",
|
"@formatjs/ecma402-abstract": "2.3.6",
|
||||||
@@ -13390,6 +13565,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/neo-async": {
|
"node_modules/neo-async": {
|
||||||
"version": "2.6.2",
|
"version": "2.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||||
@@ -13459,6 +13643,92 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-intl": {
|
||||||
|
"version": "4.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.5.3.tgz",
|
||||||
|
"integrity": "sha512-/omQgD0JyewIwJa0F5/HPRe5LYAVBNcGDgZvnv6hul8lI1KMcCcxErMXUiNjyc5kuQqLQeWUa2e4ICx09uL8FA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/amannn"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/intl-localematcher": "^0.5.4",
|
||||||
|
"@swc/core": "^1.13.19",
|
||||||
|
"negotiator": "^1.0.0",
|
||||||
|
"use-intl": "^4.5.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-intl/node_modules/@formatjs/intl-localematcher": {
|
||||||
|
"version": "0.5.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz",
|
||||||
|
"integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-intl/node_modules/@swc/core": {
|
||||||
|
"version": "1.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.2.tgz",
|
||||||
|
"integrity": "sha512-OQm+yJdXxvSjqGeaWhP6Ia264ogifwAO7Q12uTDVYj/Ks4jBTI4JknlcjDRAXtRhqbWsfbZyK/5RtuIPyptk3w==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3",
|
||||||
|
"@swc/types": "^0.1.25"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/swc"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@swc/core-darwin-arm64": "1.15.2",
|
||||||
|
"@swc/core-darwin-x64": "1.15.2",
|
||||||
|
"@swc/core-linux-arm-gnueabihf": "1.15.2",
|
||||||
|
"@swc/core-linux-arm64-gnu": "1.15.2",
|
||||||
|
"@swc/core-linux-arm64-musl": "1.15.2",
|
||||||
|
"@swc/core-linux-x64-gnu": "1.15.2",
|
||||||
|
"@swc/core-linux-x64-musl": "1.15.2",
|
||||||
|
"@swc/core-win32-arm64-msvc": "1.15.2",
|
||||||
|
"@swc/core-win32-ia32-msvc": "1.15.2",
|
||||||
|
"@swc/core-win32-x64-msvc": "1.15.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@swc/helpers": ">=0.5.17"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@swc/helpers": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-intl/node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||||
|
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next-themes": {
|
"node_modules/next-themes": {
|
||||||
"version": "0.4.6",
|
"version": "0.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||||
@@ -16536,7 +16806,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -16784,6 +17054,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-intl": {
|
||||||
|
"version": "4.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.5.3.tgz",
|
||||||
|
"integrity": "sha512-vO2csOEc+xpi5PdvjTKORR4ZZQE6mz2jheefOszLOjppWx8SATC2XkmxUYwSHz1HIrcW6alUsj9qfPa6ZFhTNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/fast-memoize": "^2.2.0",
|
||||||
|
"@schummar/icu-type-parser": "1.21.5",
|
||||||
|
"intl-messageformat": "^10.5.14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-sidecar": {
|
"node_modules/use-sidecar": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lucide-react": "^0.552.0",
|
"lucide-react": "^0.552.0",
|
||||||
"next": "^15.5.6",
|
"next": "^15.5.6",
|
||||||
|
"next-intl": "^4.5.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* Settings Index Page
|
|
||||||
* Redirects to /settings/profile
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
redirect('/settings/profile');
|
|
||||||
}
|
|
||||||
@@ -5,11 +5,12 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useRouter } from '@/lib/i18n/routing';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { Alert } from '@/components/ui/alert';
|
import { Alert } from '@/components/ui/alert';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
|
|
||||||
// Code-split PasswordResetConfirmForm (319 lines)
|
// Code-split PasswordResetConfirmForm (319 lines)
|
||||||
const PasswordResetConfirmForm = dynamic(
|
const PasswordResetConfirmForm = dynamic(
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from '@/lib/i18n/routing';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { User, Lock, Monitor, Settings as SettingsIcon } from 'lucide-react';
|
import { User, Lock, Monitor, Settings as SettingsIcon } from 'lucide-react';
|
||||||
|
|
||||||
11
frontend/src/app/[locale]/(authenticated)/settings/page.tsx
Normal file
11
frontend/src/app/[locale]/(authenticated)/settings/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Settings Index Page
|
||||||
|
* Redirects to /settings/profile
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default async function SettingsPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
redirect(`/${locale}/settings/profile`);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
/* istanbul ignore next - Next.js type import for metadata */
|
/* istanbul ignore next - Next.js type import for metadata */
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { OrganizationMembersContent } from '@/components/admin/organizations/OrganizationMembersContent';
|
import { OrganizationMembersContent } from '@/components/admin/organizations/OrganizationMembersContent';
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
/* istanbul ignore next - Next.js type import for metadata */
|
/* istanbul ignore next - Next.js type import for metadata */
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { OrganizationManagementContent } from '@/components/admin/organizations/OrganizationManagementContent';
|
import { OrganizationManagementContent } from '@/components/admin/organizations/OrganizationManagementContent';
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
/* istanbul ignore next - Next.js type import for metadata */
|
/* istanbul ignore next - Next.js type import for metadata */
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { DashboardStats } from '@/components/admin';
|
import { DashboardStats } from '@/components/admin';
|
||||||
import {
|
import {
|
||||||
UserGrowthChart,
|
UserGrowthChart,
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
/* istanbul ignore next - Next.js type import for metadata */
|
/* istanbul ignore next - Next.js type import for metadata */
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
/* istanbul ignore next - Next.js type import for metadata */
|
/* istanbul ignore next - Next.js type import for metadata */
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { UserManagementContent } from '@/components/admin/users/UserManagementContent';
|
import { UserManagementContent } from '@/components/admin/users/UserManagementContent';
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import {
|
import {
|
||||||
Palette,
|
Palette,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
LogIn,
|
LogIn,
|
||||||
Settings,
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
Lock,
|
|
||||||
Activity,
|
Activity,
|
||||||
UserCog,
|
UserCog,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -28,7 +27,8 @@ import { Separator } from '@/components/ui/separator';
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Demo Tour | FastNext Template',
|
title: 'Demo Tour | FastNext Template',
|
||||||
description: 'Try all features with demo credentials - comprehensive guide to the FastNext template',
|
description:
|
||||||
|
'Try all features with demo credentials - comprehensive guide to the FastNext template',
|
||||||
};
|
};
|
||||||
|
|
||||||
const demoCategories = [
|
const demoCategories = [
|
||||||
@@ -37,7 +37,12 @@ const demoCategories = [
|
|||||||
title: 'Design System Hub',
|
title: 'Design System Hub',
|
||||||
description: 'Browse components, layouts, spacing, and forms with live examples',
|
description: 'Browse components, layouts, spacing, and forms with live examples',
|
||||||
href: '/dev',
|
href: '/dev',
|
||||||
features: ['All UI components', 'Layout patterns', 'Spacing philosophy', 'Form implementations'],
|
features: [
|
||||||
|
'All UI components',
|
||||||
|
'Layout patterns',
|
||||||
|
'Spacing philosophy',
|
||||||
|
'Form implementations',
|
||||||
|
],
|
||||||
credentials: null,
|
credentials: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -261,7 +266,8 @@ export default function DemoTourPage() {
|
|||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<Button asChild className="w-full gap-2">
|
<Button asChild className="w-full gap-2">
|
||||||
<Link href={category.href}>
|
<Link href={category.href}>
|
||||||
{category.credentials ? 'Try Now' : 'Explore'} <ArrowRight className="h-4 w-4" />
|
{category.credentials ? 'Try Now' : 'Explore'}{' '}
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* Access: /dev/docs
|
* Access: /dev/docs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { Grid3x3 } from 'lucide-react';
|
import { Grid3x3 } from 'lucide-react';
|
||||||
import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs';
|
import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { Palette, Layout, Ruler, FileText, BookOpen, ArrowRight, Sparkles } from 'lucide-react';
|
import { Palette, Layout, Ruler, FileText, BookOpen, ArrowRight, Sparkles } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -14,7 +14,8 @@ import { Separator } from '@/components/ui/separator';
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Design System Hub | FastNext Template',
|
title: 'Design System Hub | FastNext Template',
|
||||||
description: 'Interactive design system demonstrations with live examples - explore components, layouts, spacing, and forms built with shadcn/ui and Tailwind CSS',
|
description:
|
||||||
|
'Interactive design system demonstrations with live examples - explore components, layouts, spacing, and forms built with shadcn/ui and Tailwind CSS',
|
||||||
};
|
};
|
||||||
|
|
||||||
const demoPages = [
|
const demoPages = [
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { Ruler } from 'lucide-react';
|
import { Ruler } from 'lucide-react';
|
||||||
import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs';
|
import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
/* istanbul ignore next - Next.js type import for metadata */
|
/* istanbul ignore next - Next.js type import for metadata */
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { ShieldAlert } from 'lucide-react';
|
import { ShieldAlert } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
88
frontend/src/app/[locale]/layout.tsx
Normal file
88
frontend/src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { routing } from '@/lib/i18n/routing';
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import { getMessages } from 'next-intl/server';
|
||||||
|
import '../globals.css';
|
||||||
|
import { Providers } from '../providers';
|
||||||
|
import { AuthProvider } from '@/lib/auth/AuthContext';
|
||||||
|
import { AuthInitializer } from '@/components/auth';
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: '--font-geist-sans',
|
||||||
|
subsets: ['latin'],
|
||||||
|
display: 'swap', // Prevent font from blocking render
|
||||||
|
preload: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: '--font-geist-mono',
|
||||||
|
subsets: ['latin'],
|
||||||
|
display: 'swap', // Prevent font from blocking render
|
||||||
|
preload: false, // Only preload primary font
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'FastNext Template',
|
||||||
|
description: 'FastAPI + Next.js Template',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}>) {
|
||||||
|
// Await params in Next.js 15
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
// Ensure that the incoming `locale` is valid
|
||||||
|
if (!routing.locales.includes(locale as 'en' | 'it')) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providing all messages to the client
|
||||||
|
// side is the easiest way to get started
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale} suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
{/* Theme initialization script - runs before React hydrates to prevent FOUC */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
const theme = localStorage.getItem('theme') || 'system';
|
||||||
|
let resolved;
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
} else {
|
||||||
|
resolved = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(resolved);
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail - theme will be set by ThemeProvider
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthInitializer />
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</AuthProvider>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
frontend/src/app/[locale]/page.tsx
Executable file
99
frontend/src/app/[locale]/page.tsx
Executable file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Homepage / Landing Page
|
||||||
|
* Main landing page for the FastNext Template project
|
||||||
|
* Showcases features, tech stack, and provides demos for developers
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from '@/lib/i18n/routing';
|
||||||
|
import { Header } from '@/components/home/Header';
|
||||||
|
import { HeroSection } from '@/components/home/HeroSection';
|
||||||
|
import { ContextSection } from '@/components/home/ContextSection';
|
||||||
|
import { AnimatedTerminal } from '@/components/home/AnimatedTerminal';
|
||||||
|
import { FeatureGrid } from '@/components/home/FeatureGrid';
|
||||||
|
import { DemoSection } from '@/components/home/DemoSection';
|
||||||
|
import { StatsSection } from '@/components/home/StatsSection';
|
||||||
|
import { TechStackSection } from '@/components/home/TechStackSection';
|
||||||
|
import { PhilosophySection } from '@/components/home/PhilosophySection';
|
||||||
|
import { QuickStartCode } from '@/components/home/QuickStartCode';
|
||||||
|
import { CTASection } from '@/components/home/CTASection';
|
||||||
|
import { DemoCredentialsModal } from '@/components/home/DemoCredentialsModal';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [demoModalOpen, setDemoModalOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
{/* Header Navigation */}
|
||||||
|
<Header onOpenDemoModal={() => setDemoModalOpen(true)} />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main>
|
||||||
|
{/* Hero Section with CTAs */}
|
||||||
|
<HeroSection onOpenDemoModal={() => setDemoModalOpen(true)} />
|
||||||
|
|
||||||
|
{/* What is this template? */}
|
||||||
|
<ContextSection />
|
||||||
|
|
||||||
|
{/* Animated Terminal with Quick Start */}
|
||||||
|
<AnimatedTerminal />
|
||||||
|
|
||||||
|
{/* 6 Feature Cards Grid */}
|
||||||
|
<FeatureGrid />
|
||||||
|
|
||||||
|
{/* Interactive Demo Cards */}
|
||||||
|
<DemoSection />
|
||||||
|
|
||||||
|
{/* Statistics with Animated Counters */}
|
||||||
|
<StatsSection />
|
||||||
|
|
||||||
|
{/* Tech Stack Grid */}
|
||||||
|
<TechStackSection />
|
||||||
|
|
||||||
|
{/* For Developers, By Developers */}
|
||||||
|
<PhilosophySection />
|
||||||
|
|
||||||
|
{/* Quick Start Code Block */}
|
||||||
|
<QuickStartCode />
|
||||||
|
|
||||||
|
{/* Final CTA Section */}
|
||||||
|
<CTASection onOpenDemoModal={() => setDemoModalOpen(true)} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t bg-muted/30">
|
||||||
|
<div className="container mx-auto px-6 py-8">
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
© {new Date().getFullYear()} FastNext Template. MIT Licensed.
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6 text-sm text-muted-foreground">
|
||||||
|
<Link href="/demos" className="hover:text-foreground transition-colors">
|
||||||
|
Demo Tour
|
||||||
|
</Link>
|
||||||
|
<Link href="/dev" className="hover:text-foreground transition-colors">
|
||||||
|
Design System
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href="https://github.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
<Link href="/dev/docs" className="hover:text-foreground transition-colors">
|
||||||
|
Documentation
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Shared Demo Credentials Modal */}
|
||||||
|
<DemoCredentialsModal open={demoModalOpen} onClose={() => setDemoModalOpen(false)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,68 +1,15 @@
|
|||||||
import type { Metadata } from 'next';
|
/**
|
||||||
import { Geist, Geist_Mono } from 'next/font/google';
|
* Root Layout
|
||||||
import './globals.css';
|
*
|
||||||
import { Providers } from './providers';
|
* Minimal root layout that passes through to locale-specific layouts.
|
||||||
import { AuthProvider } from '@/lib/auth/AuthContext';
|
* The actual HTML structure and providers are in [locale]/layout.tsx
|
||||||
import { AuthInitializer } from '@/components/auth';
|
* to properly handle locale-specific rendering.
|
||||||
|
*/
|
||||||
const geistSans = Geist({
|
|
||||||
variable: '--font-geist-sans',
|
|
||||||
subsets: ['latin'],
|
|
||||||
display: 'swap', // Prevent font from blocking render
|
|
||||||
preload: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: '--font-geist-mono',
|
|
||||||
subsets: ['latin'],
|
|
||||||
display: 'swap', // Prevent font from blocking render
|
|
||||||
preload: false, // Only preload primary font
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'FastNext Template',
|
|
||||||
description: 'FastAPI + Next.js Template',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return children;
|
||||||
<html lang="en" suppressHydrationWarning>
|
|
||||||
<head>
|
|
||||||
{/* Theme initialization script - runs before React hydrates to prevent FOUC */}
|
|
||||||
<script
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
(function() {
|
|
||||||
try {
|
|
||||||
const theme = localStorage.getItem('theme') || 'system';
|
|
||||||
let resolved;
|
|
||||||
|
|
||||||
if (theme === 'system') {
|
|
||||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
||||||
} else {
|
|
||||||
resolved = theme;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.documentElement.classList.remove('light', 'dark');
|
|
||||||
document.documentElement.classList.add(resolved);
|
|
||||||
} catch (e) {
|
|
||||||
// Silently fail - theme will be set by ThemeProvider
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
|
||||||
<AuthProvider>
|
|
||||||
<AuthInitializer />
|
|
||||||
<Providers>{children}</Providers>
|
|
||||||
</AuthProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
99
frontend/src/app/page.tsx
Executable file → Normal file
99
frontend/src/app/page.tsx
Executable file → Normal file
@@ -1,99 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Homepage / Landing Page
|
* Root page - redirects to default locale
|
||||||
* Main landing page for the FastNext Template project
|
|
||||||
* Showcases features, tech stack, and provides demos for developers
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useState } from 'react';
|
export default function RootPage() {
|
||||||
import Link from 'next/link';
|
// Redirect to default locale (en)
|
||||||
import { Header } from '@/components/home/Header';
|
redirect('/en');
|
||||||
import { HeroSection } from '@/components/home/HeroSection';
|
|
||||||
import { ContextSection } from '@/components/home/ContextSection';
|
|
||||||
import { AnimatedTerminal } from '@/components/home/AnimatedTerminal';
|
|
||||||
import { FeatureGrid } from '@/components/home/FeatureGrid';
|
|
||||||
import { DemoSection } from '@/components/home/DemoSection';
|
|
||||||
import { StatsSection } from '@/components/home/StatsSection';
|
|
||||||
import { TechStackSection } from '@/components/home/TechStackSection';
|
|
||||||
import { PhilosophySection } from '@/components/home/PhilosophySection';
|
|
||||||
import { QuickStartCode } from '@/components/home/QuickStartCode';
|
|
||||||
import { CTASection } from '@/components/home/CTASection';
|
|
||||||
import { DemoCredentialsModal } from '@/components/home/DemoCredentialsModal';
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const [demoModalOpen, setDemoModalOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
{/* Header Navigation */}
|
|
||||||
<Header onOpenDemoModal={() => setDemoModalOpen(true)} />
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main>
|
|
||||||
{/* Hero Section with CTAs */}
|
|
||||||
<HeroSection onOpenDemoModal={() => setDemoModalOpen(true)} />
|
|
||||||
|
|
||||||
{/* What is this template? */}
|
|
||||||
<ContextSection />
|
|
||||||
|
|
||||||
{/* Animated Terminal with Quick Start */}
|
|
||||||
<AnimatedTerminal />
|
|
||||||
|
|
||||||
{/* 6 Feature Cards Grid */}
|
|
||||||
<FeatureGrid />
|
|
||||||
|
|
||||||
{/* Interactive Demo Cards */}
|
|
||||||
<DemoSection />
|
|
||||||
|
|
||||||
{/* Statistics with Animated Counters */}
|
|
||||||
<StatsSection />
|
|
||||||
|
|
||||||
{/* Tech Stack Grid */}
|
|
||||||
<TechStackSection />
|
|
||||||
|
|
||||||
{/* For Developers, By Developers */}
|
|
||||||
<PhilosophySection />
|
|
||||||
|
|
||||||
{/* Quick Start Code Block */}
|
|
||||||
<QuickStartCode />
|
|
||||||
|
|
||||||
{/* Final CTA Section */}
|
|
||||||
<CTASection onOpenDemoModal={() => setDemoModalOpen(true)} />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="border-t bg-muted/30">
|
|
||||||
<div className="container mx-auto px-6 py-8">
|
|
||||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
© {new Date().getFullYear()} FastNext Template. MIT Licensed.
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-6 text-sm text-muted-foreground">
|
|
||||||
<Link href="/demos" className="hover:text-foreground transition-colors">
|
|
||||||
Demo Tour
|
|
||||||
</Link>
|
|
||||||
<Link href="/dev" className="hover:text-foreground transition-colors">
|
|
||||||
Design System
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href="https://github.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
<Link href="/dev/docs" className="hover:text-foreground transition-colors">
|
|
||||||
Documentation
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
{/* Shared Demo Credentials Modal */}
|
|
||||||
<DemoCredentialsModal open={demoModalOpen} onClose={() => setDemoModalOpen(false)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from '@/lib/i18n/routing';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from '@/lib/i18n/routing';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
interface BreadcrumbItem {
|
interface BreadcrumbItem {
|
||||||
@@ -25,6 +25,7 @@ export function Breadcrumbs() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// Generate breadcrumb items from pathname
|
// Generate breadcrumb items from pathname
|
||||||
|
// Note: usePathname() from next-intl returns path WITHOUT locale prefix
|
||||||
const generateBreadcrumbs = (): BreadcrumbItem[] => {
|
const generateBreadcrumbs = (): BreadcrumbItem[] => {
|
||||||
const segments = pathname.split('/').filter(Boolean);
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
const breadcrumbs: BreadcrumbItem[] = [];
|
const breadcrumbs: BreadcrumbItem[] = [];
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useRouter } from '@/lib/i18n/routing';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useRouter } from '@/lib/i18n/routing';
|
||||||
import { UserPlus } from 'lucide-react';
|
import { UserPlus } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useRouter } from '@/lib/i18n/routing';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from '@/lib/i18n/routing';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import { useMe } from '@/lib/api/hooks/useAuth';
|
import { useMe } from '@/lib/api/hooks/useAuth';
|
||||||
import { AuthLoadingSkeleton } from '@/components/layout';
|
import { AuthLoadingSkeleton } from '@/components/layout';
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -23,17 +24,18 @@ import config from '@/config/app.config';
|
|||||||
// Validation Schema
|
// Validation Schema
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const createLoginSchema = (t: (key: string) => string) =>
|
||||||
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
|
z.object({
|
||||||
|
email: z.string().min(1, t('validation.required')).email(t('validation.email')),
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'Password is required')
|
.min(1, t('validation.required'))
|
||||||
.min(8, 'Password must be at least 8 characters')
|
.min(8, t('validation.minLength').replace('{count}', '8'))
|
||||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
.regex(/[0-9]/, t('errors.validation.passwordWeak'))
|
||||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter'),
|
.regex(/[A-Z]/, t('errors.validation.passwordWeak')),
|
||||||
});
|
});
|
||||||
|
|
||||||
type LoginFormData = z.infer<typeof loginSchema>;
|
type LoginFormData = z.infer<ReturnType<typeof createLoginSchema>>;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Component
|
// Component
|
||||||
@@ -74,9 +76,22 @@ export function LoginForm({
|
|||||||
showPasswordResetLink = true,
|
showPasswordResetLink = true,
|
||||||
className,
|
className,
|
||||||
}: LoginFormProps) {
|
}: LoginFormProps) {
|
||||||
|
const t = useTranslations('auth.login');
|
||||||
|
const tValidation = useTranslations('validation');
|
||||||
|
const tErrors = useTranslations('errors.validation');
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
const loginMutation = useLogin();
|
const loginMutation = useLogin();
|
||||||
|
|
||||||
|
const loginSchema = createLoginSchema((key: string) => {
|
||||||
|
if (key.startsWith('validation.')) {
|
||||||
|
return tValidation(key.replace('validation.', ''));
|
||||||
|
}
|
||||||
|
if (key.startsWith('errors.validation.')) {
|
||||||
|
return tErrors(key.replace('errors.validation.', ''));
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
|
||||||
const form = useForm<LoginFormData>({
|
const form = useForm<LoginFormData>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
mode: 'onBlur',
|
mode: 'onBlur',
|
||||||
@@ -116,7 +131,7 @@ export function LoginForm({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Unexpected error format
|
// Unexpected error format
|
||||||
setServerError('An unexpected error occurred. Please try again.');
|
setServerError(t('unexpectedError'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -135,11 +150,11 @@ export function LoginForm({
|
|||||||
|
|
||||||
{/* Email Field */}
|
{/* Email Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">{t('emailLabel')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="you@example.com"
|
placeholder={t('emailPlaceholder')}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...form.register('email')}
|
{...form.register('email')}
|
||||||
@@ -156,20 +171,20 @@ export function LoginForm({
|
|||||||
{/* Password Field */}
|
{/* Password Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">{t('passwordLabel')}</Label>
|
||||||
{showPasswordResetLink && (
|
{showPasswordResetLink && (
|
||||||
<Link
|
<Link
|
||||||
href="/password-reset"
|
href="/password-reset"
|
||||||
className="text-sm text-muted-foreground hover:text-primary underline-offset-4 hover:underline"
|
className="text-sm text-muted-foreground hover:text-primary underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
Forgot password?
|
{t('forgotPassword')}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter your password"
|
placeholder={t('passwordPlaceholder')}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...form.register('password')}
|
{...form.register('password')}
|
||||||
@@ -185,18 +200,18 @@ export function LoginForm({
|
|||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||||
{isSubmitting ? 'Signing in...' : 'Sign in'}
|
{isSubmitting ? t('loginButtonLoading') : t('loginButton')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Registration Link */}
|
{/* Registration Link */}
|
||||||
{showRegisterLink && config.features.enableRegistration && (
|
{showRegisterLink && config.features.enableRegistration && (
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Don't have an account?{' '}
|
{t('noAccount')}{' '}
|
||||||
<Link
|
<Link
|
||||||
href={config.routes.register}
|
href={config.routes.register}
|
||||||
className="text-primary underline-offset-4 hover:underline font-medium"
|
className="text-primary underline-offset-4 hover:underline font-medium"
|
||||||
>
|
>
|
||||||
Sign up
|
{t('registerLink')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -22,23 +23,24 @@ import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/erro
|
|||||||
// Validation Schema
|
// Validation Schema
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const resetConfirmSchema = z
|
const createResetConfirmSchema = (t: (key: string) => string) =>
|
||||||
|
z
|
||||||
.object({
|
.object({
|
||||||
token: z.string().min(1, 'Reset token is required'),
|
token: z.string().min(1, t('tokenRequired')),
|
||||||
new_password: z
|
new_password: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'New password is required')
|
.min(1, t('passwordRequired'))
|
||||||
.min(8, 'Password must be at least 8 characters')
|
.min(8, t('passwordMinLength'))
|
||||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
.regex(/[0-9]/, t('passwordNumber'))
|
||||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter'),
|
.regex(/[A-Z]/, t('passwordUppercase')),
|
||||||
confirm_password: z.string().min(1, 'Please confirm your password'),
|
confirm_password: z.string().min(1, t('confirmPasswordRequired')),
|
||||||
})
|
})
|
||||||
.refine((data) => data.new_password === data.confirm_password, {
|
.refine((data) => data.new_password === data.confirm_password, {
|
||||||
message: 'Passwords do not match',
|
message: t('passwordMismatch'),
|
||||||
path: ['confirm_password'],
|
path: ['confirm_password'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type ResetConfirmFormData = z.infer<typeof resetConfirmSchema>;
|
type ResetConfirmFormData = z.infer<ReturnType<typeof createResetConfirmSchema>>;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
@@ -104,10 +106,13 @@ export function PasswordResetConfirmForm({
|
|||||||
showLoginLink = true,
|
showLoginLink = true,
|
||||||
className,
|
className,
|
||||||
}: PasswordResetConfirmFormProps) {
|
}: PasswordResetConfirmFormProps) {
|
||||||
|
const t = useTranslations('auth.passwordResetConfirm');
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const resetMutation = usePasswordResetConfirm();
|
const resetMutation = usePasswordResetConfirm();
|
||||||
|
|
||||||
|
const resetConfirmSchema = createResetConfirmSchema((key: string) => t(key));
|
||||||
|
|
||||||
const form = useForm<ResetConfirmFormData>({
|
const form = useForm<ResetConfirmFormData>({
|
||||||
resolver: zodResolver(resetConfirmSchema),
|
resolver: zodResolver(resetConfirmSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -134,9 +139,7 @@ export function PasswordResetConfirmForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
setSuccessMessage(
|
setSuccessMessage(t('success'));
|
||||||
'Your password has been successfully reset. You can now log in with your new password.'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
form.reset({ token, new_password: '', confirm_password: '' });
|
form.reset({ token, new_password: '', confirm_password: '' });
|
||||||
@@ -161,7 +164,7 @@ export function PasswordResetConfirmForm({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Unexpected error format
|
// Unexpected error format
|
||||||
setServerError('An unexpected error occurred. Please try again.');
|
setServerError(t('unexpectedError'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -186,9 +189,7 @@ export function PasswordResetConfirmForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Instructions */}
|
{/* Instructions */}
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('instructions')}</p>
|
||||||
Enter your new password below. Make sure it meets all security requirements.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Hidden Token Field (for form submission) */}
|
{/* Hidden Token Field (for form submission) */}
|
||||||
<input type="hidden" {...form.register('token')} />
|
<input type="hidden" {...form.register('token')} />
|
||||||
@@ -196,12 +197,12 @@ export function PasswordResetConfirmForm({
|
|||||||
{/* New Password Field */}
|
{/* New Password Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="new_password">
|
<Label htmlFor="new_password">
|
||||||
New Password <span className="text-destructive">*</span>
|
{t('newPasswordLabel')} <span className="text-destructive">{t('required')}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="new_password"
|
id="new_password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter new password"
|
placeholder={t('newPasswordPlaceholder')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...form.register('new_password')}
|
{...form.register('new_password')}
|
||||||
@@ -240,7 +241,7 @@ export function PasswordResetConfirmForm({
|
|||||||
: 'text-muted-foreground'
|
: 'text-muted-foreground'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{passwordStrength.hasMinLength ? '✓' : '○'} At least 8 characters
|
{passwordStrength.hasMinLength ? '✓' : '○'} {t('passwordRequirements.minLength')}
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
className={
|
className={
|
||||||
@@ -249,7 +250,7 @@ export function PasswordResetConfirmForm({
|
|||||||
: 'text-muted-foreground'
|
: 'text-muted-foreground'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{passwordStrength.hasNumber ? '✓' : '○'} Contains a number
|
{passwordStrength.hasNumber ? '✓' : '○'} {t('passwordRequirements.hasNumber')}
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
className={
|
className={
|
||||||
@@ -258,7 +259,8 @@ export function PasswordResetConfirmForm({
|
|||||||
: 'text-muted-foreground'
|
: 'text-muted-foreground'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{passwordStrength.hasUppercase ? '✓' : '○'} Contains an uppercase letter
|
{passwordStrength.hasUppercase ? '✓' : '○'}{' '}
|
||||||
|
{t('passwordRequirements.hasUppercase')}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,12 +270,12 @@ export function PasswordResetConfirmForm({
|
|||||||
{/* Confirm Password Field */}
|
{/* Confirm Password Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirm_password">
|
<Label htmlFor="confirm_password">
|
||||||
Confirm Password <span className="text-destructive">*</span>
|
{t('confirmPasswordLabel')} <span className="text-destructive">{t('required')}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirm_password"
|
id="confirm_password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Re-enter new password"
|
placeholder={t('confirmPasswordPlaceholder')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...form.register('confirm_password')}
|
{...form.register('confirm_password')}
|
||||||
@@ -292,18 +294,18 @@ export function PasswordResetConfirmForm({
|
|||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||||
{isSubmitting ? 'Resetting Password...' : 'Reset Password'}
|
{isSubmitting ? t('resetButtonLoading') : t('resetButton')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Login Link */}
|
{/* Login Link */}
|
||||||
{showLoginLink && (
|
{showLoginLink && (
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Remember your password?{' '}
|
{t('rememberPassword')}{' '}
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="text-primary underline-offset-4 hover:underline font-medium"
|
className="text-primary underline-offset-4 hover:underline font-medium"
|
||||||
>
|
>
|
||||||
Back to login
|
{t('backToLogin')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -22,11 +23,12 @@ import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/erro
|
|||||||
// Validation Schema
|
// Validation Schema
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const resetRequestSchema = z.object({
|
const createResetRequestSchema = (t: (key: string) => string) =>
|
||||||
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
|
z.object({
|
||||||
|
email: z.string().min(1, t('validation.required')).email(t('validation.email')),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ResetRequestFormData = z.infer<typeof resetRequestSchema>;
|
type ResetRequestFormData = z.infer<ReturnType<typeof createResetRequestSchema>>;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Component
|
// Component
|
||||||
@@ -64,10 +66,19 @@ export function PasswordResetRequestForm({
|
|||||||
showLoginLink = true,
|
showLoginLink = true,
|
||||||
className,
|
className,
|
||||||
}: PasswordResetRequestFormProps) {
|
}: PasswordResetRequestFormProps) {
|
||||||
|
const t = useTranslations('auth.passwordReset');
|
||||||
|
const tValidation = useTranslations('validation');
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const resetMutation = usePasswordResetRequest();
|
const resetMutation = usePasswordResetRequest();
|
||||||
|
|
||||||
|
const resetRequestSchema = createResetRequestSchema((key: string) => {
|
||||||
|
if (key.startsWith('validation.')) {
|
||||||
|
return tValidation(key.replace('validation.', ''));
|
||||||
|
}
|
||||||
|
return t(key);
|
||||||
|
});
|
||||||
|
|
||||||
const form = useForm<ResetRequestFormData>({
|
const form = useForm<ResetRequestFormData>({
|
||||||
resolver: zodResolver(resetRequestSchema),
|
resolver: zodResolver(resetRequestSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -86,9 +97,7 @@ export function PasswordResetRequestForm({
|
|||||||
await resetMutation.mutateAsync({ email: data.email });
|
await resetMutation.mutateAsync({ email: data.email });
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
setSuccessMessage(
|
setSuccessMessage(t('success'));
|
||||||
'Password reset instructions have been sent to your email address. Please check your inbox.'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -113,7 +122,7 @@ export function PasswordResetRequestForm({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Unexpected error format
|
// Unexpected error format
|
||||||
setServerError('An unexpected error occurred. Please try again.');
|
setServerError(t('unexpectedError'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -138,19 +147,17 @@ export function PasswordResetRequestForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Instructions */}
|
{/* Instructions */}
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('instructions')}</p>
|
||||||
Enter your email address and we'll send you instructions to reset your password.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Email Field */}
|
{/* Email Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">
|
<Label htmlFor="email">
|
||||||
Email <span className="text-destructive">*</span>
|
{t('emailLabel')} <span className="text-destructive">{t('required')}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="you@example.com"
|
placeholder={t('emailPlaceholder')}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...form.register('email')}
|
{...form.register('email')}
|
||||||
@@ -167,18 +174,18 @@ export function PasswordResetRequestForm({
|
|||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||||
{isSubmitting ? 'Sending...' : 'Send Reset Instructions'}
|
{isSubmitting ? t('sendButtonLoading') : t('sendButton')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Login Link */}
|
{/* Login Link */}
|
||||||
{showLoginLink && (
|
{showLoginLink && (
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Remember your password?{' '}
|
{t('rememberPassword')}{' '}
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="text-primary underline-offset-4 hover:underline font-medium"
|
className="text-primary underline-offset-4 hover:underline font-medium"
|
||||||
>
|
>
|
||||||
Back to login
|
{t('backToLogin')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -23,33 +24,34 @@ import config from '@/config/app.config';
|
|||||||
// Validation Schema
|
// Validation Schema
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const registerSchema = z
|
const createRegisterSchema = (t: (key: string) => string) =>
|
||||||
|
z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
|
email: z.string().min(1, t('validation.required')).email(t('validation.email')),
|
||||||
first_name: z
|
first_name: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'First name is required')
|
.min(1, t('firstNameRequired'))
|
||||||
.min(2, 'First name must be at least 2 characters')
|
.min(2, t('firstNameMinLength'))
|
||||||
.max(50, 'First name must not exceed 50 characters'),
|
.max(50, t('firstNameMaxLength')),
|
||||||
last_name: z
|
last_name: z
|
||||||
.string()
|
.string()
|
||||||
.max(50, 'Last name must not exceed 50 characters')
|
.max(50, t('lastNameMaxLength'))
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal('')), // Allow empty string
|
.or(z.literal('')), // Allow empty string
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'Password is required')
|
.min(1, t('passwordRequired'))
|
||||||
.min(8, 'Password must be at least 8 characters')
|
.min(8, t('passwordMinLength'))
|
||||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
.regex(/[0-9]/, t('passwordNumber'))
|
||||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter'),
|
.regex(/[A-Z]/, t('passwordUppercase')),
|
||||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
confirmPassword: z.string().min(1, t('confirmPasswordRequired')),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
message: 'Passwords do not match',
|
message: t('passwordMismatch'),
|
||||||
path: ['confirmPassword'],
|
path: ['confirmPassword'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type RegisterFormData = z.infer<typeof registerSchema>;
|
type RegisterFormData = z.infer<ReturnType<typeof createRegisterSchema>>;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Component
|
// Component
|
||||||
@@ -84,9 +86,18 @@ interface RegisterFormProps {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function RegisterForm({ onSuccess, showLoginLink = true, className }: RegisterFormProps) {
|
export function RegisterForm({ onSuccess, showLoginLink = true, className }: RegisterFormProps) {
|
||||||
|
const t = useTranslations('auth.register');
|
||||||
|
const tValidation = useTranslations('validation');
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
const registerMutation = useRegister();
|
const registerMutation = useRegister();
|
||||||
|
|
||||||
|
const registerSchema = createRegisterSchema((key: string) => {
|
||||||
|
if (key.startsWith('validation.')) {
|
||||||
|
return tValidation(key.replace('validation.', ''));
|
||||||
|
}
|
||||||
|
return t(key);
|
||||||
|
});
|
||||||
|
|
||||||
const form = useForm<RegisterFormData>({
|
const form = useForm<RegisterFormData>({
|
||||||
resolver: zodResolver(registerSchema),
|
resolver: zodResolver(registerSchema),
|
||||||
mode: 'onBlur',
|
mode: 'onBlur',
|
||||||
@@ -133,7 +144,7 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Unexpected error format
|
// Unexpected error format
|
||||||
setServerError('An unexpected error occurred. Please try again.');
|
setServerError(t('unexpectedError'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -159,12 +170,12 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
|||||||
{/* First Name Field */}
|
{/* First Name Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="first_name">
|
<Label htmlFor="first_name">
|
||||||
First Name <span className="text-destructive">*</span>
|
{t('firstNameLabel')} <span className="text-destructive">{t('required')}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="first_name"
|
id="first_name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="John"
|
placeholder={t('firstNamePlaceholder')}
|
||||||
autoComplete="given-name"
|
autoComplete="given-name"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...form.register('first_name')}
|
{...form.register('first_name')}
|
||||||
@@ -180,11 +191,11 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
|||||||
|
|
||||||
{/* Last Name Field */}
|
{/* Last Name Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="last_name">Last Name</Label>
|
<Label htmlFor="last_name">{t('lastNameLabel')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="last_name"
|
id="last_name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Doe (optional)"
|
placeholder={t('lastNamePlaceholder')}
|
||||||
autoComplete="family-name"
|
autoComplete="family-name"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...form.register('last_name')}
|
{...form.register('last_name')}
|
||||||
@@ -201,12 +212,12 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
|||||||
{/* Email Field */}
|
{/* Email Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">
|
<Label htmlFor="email">
|
||||||
Email <span className="text-destructive">*</span>
|
{t('emailLabel')} <span className="text-destructive">{t('required')}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="you@example.com"
|
placeholder={t('emailPlaceholder')}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...form.register('email')}
|
{...form.register('email')}
|
||||||
@@ -223,12 +234,12 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
|||||||
{/* Password Field */}
|
{/* Password Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">
|
<Label htmlFor="password">
|
||||||
Password <span className="text-destructive">*</span>
|
{t('passwordLabel')} <span className="text-destructive">{t('required')}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Create a strong password"
|
placeholder={t('passwordPlaceholder')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...form.register('password')}
|
{...form.register('password')}
|
||||||
@@ -253,21 +264,21 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
|||||||
hasMinLength ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
|
hasMinLength ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{hasMinLength ? '✓' : '○'} At least 8 characters
|
{hasMinLength ? '✓' : '○'} {t('passwordRequirements.minLength')}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className={
|
className={
|
||||||
hasNumber ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
|
hasNumber ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{hasNumber ? '✓' : '○'} Contains a number
|
{hasNumber ? '✓' : '○'} {t('passwordRequirements.hasNumber')}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className={
|
className={
|
||||||
hasUppercase ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
|
hasUppercase ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{hasUppercase ? '✓' : '○'} Contains an uppercase letter
|
{hasUppercase ? '✓' : '○'} {t('passwordRequirements.hasUppercase')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -276,12 +287,12 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
|||||||
{/* Confirm Password Field */}
|
{/* Confirm Password Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirmPassword">
|
<Label htmlFor="confirmPassword">
|
||||||
Confirm Password <span className="text-destructive">*</span>
|
{t('confirmPasswordLabel')} <span className="text-destructive">{t('required')}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Confirm your password"
|
placeholder={t('confirmPasswordPlaceholder')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...form.register('confirmPassword')}
|
{...form.register('confirmPassword')}
|
||||||
@@ -299,18 +310,18 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
|||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||||
{isSubmitting ? 'Creating account...' : 'Create account'}
|
{isSubmitting ? t('registerButtonLoading') : t('registerButton')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Login Link */}
|
{/* Login Link */}
|
||||||
{showLoginLink && (
|
{showLoginLink && (
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Already have an account?{' '}
|
{t('hasAccount')}{' '}
|
||||||
<Link
|
<Link
|
||||||
href={config.routes.login}
|
href={config.routes.login}
|
||||||
className="text-primary underline-offset-4 hover:underline font-medium"
|
className="text-primary underline-offset-4 hover:underline font-medium"
|
||||||
>
|
>
|
||||||
Sign in
|
{t('loginLink')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { ChevronRight, Home } from 'lucide-react';
|
import { ChevronRight, Home } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,19 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from '@/lib/i18n/routing';
|
||||||
import { Code2, Palette, LayoutDashboard, Box, FileText, BookOpen, Home, ArrowLeft, Rocket } from 'lucide-react';
|
import {
|
||||||
|
Code2,
|
||||||
|
Palette,
|
||||||
|
LayoutDashboard,
|
||||||
|
Box,
|
||||||
|
FileText,
|
||||||
|
BookOpen,
|
||||||
|
Home,
|
||||||
|
ArrowLeft,
|
||||||
|
Rocket,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ThemeToggle } from '@/components/theme';
|
import { ThemeToggle } from '@/components/theme';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Terminal, Play } from 'lucide-react';
|
import { Terminal, Play } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
|
|
||||||
const commands = [
|
const commands = [
|
||||||
{ text: '# Clone the repository', delay: 0 },
|
{ text: '# Clone the repository', delay: 0 },
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Github, Star, Play, ArrowRight } from 'lucide-react';
|
import { Github, Star, Play, ArrowRight } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { Copy, Check } from 'lucide-react';
|
import { Copy, Check } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Play, Layers, ShieldCheck, UserCircle } from 'lucide-react';
|
import { Play, Layers, ShieldCheck, UserCircle } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -120,9 +120,7 @@ export function DemoSection() {
|
|||||||
transition={{ duration: 0.6, delay: 0.4 }}
|
transition={{ duration: 0.6, delay: 0.4 }}
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" size="lg" className="gap-2">
|
<Button asChild variant="outline" size="lg" className="gap-2">
|
||||||
<Link href="/demos">
|
<Link href="/demos">View Complete Demo Tour →</Link>
|
||||||
View Complete Demo Tour →
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowRight, LucideIcon } from 'lucide-react';
|
import { ArrowRight, LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { Menu, X, Github, Star } from 'lucide-react';
|
import { Menu, X, Github, Star } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
|
import { LocaleSwitcher } from '@/components/i18n';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onOpenDemoModal: () => void;
|
onOpenDemoModal: () => void;
|
||||||
@@ -63,6 +64,9 @@ export function Header({ onOpenDemoModal }: HeaderProps) {
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{/* Locale Switcher */}
|
||||||
|
<LocaleSwitcher />
|
||||||
|
|
||||||
{/* CTAs */}
|
{/* CTAs */}
|
||||||
<Button onClick={onOpenDemoModal} variant="default" size="sm">
|
<Button onClick={onOpenDemoModal} variant="default" size="sm">
|
||||||
Try Demo
|
Try Demo
|
||||||
@@ -113,6 +117,11 @@ export function Header({ onOpenDemoModal }: HeaderProps) {
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div className="border-t pt-4 mt-4 space-y-3">
|
<div className="border-t pt-4 mt-4 space-y-3">
|
||||||
|
{/* Locale Switcher */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<LocaleSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMobileMenuOpen(false);
|
setMobileMenuOpen(false);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowRight, Github, Play } from 'lucide-react';
|
import { ArrowRight, Github, Play } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
76
frontend/src/components/i18n/LocaleSwitcher.tsx
Normal file
76
frontend/src/components/i18n/LocaleSwitcher.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* LocaleSwitcher Component
|
||||||
|
*
|
||||||
|
* Allows users to switch between available locales (EN, IT).
|
||||||
|
* Maintains the current pathname when switching languages.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Dropdown menu with available locales
|
||||||
|
* - Shows current locale with visual indicator
|
||||||
|
* - Preserves pathname when switching
|
||||||
|
* - Accessible with proper ARIA labels
|
||||||
|
* - Translated labels using next-intl
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { usePathname, useRouter, type Locale } from '@/lib/i18n/routing';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Check, Languages } from 'lucide-react';
|
||||||
|
import { routing } from '@/lib/i18n/routing';
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
export function LocaleSwitcher() {
|
||||||
|
const t = useTranslations('locale');
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleLocaleChange = (newLocale: Locale) => {
|
||||||
|
startTransition(() => {
|
||||||
|
// Navigate to the same pathname with the new locale
|
||||||
|
router.replace(pathname, { locale: newLocale });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label={t('switchLanguage')}
|
||||||
|
>
|
||||||
|
<Languages className="h-4 w-4" aria-hidden="true" />
|
||||||
|
<span className="uppercase text-xs font-semibold">{locale}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{routing.locales.map((loc) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={loc}
|
||||||
|
onClick={() => handleLocaleChange(loc)}
|
||||||
|
className="cursor-pointer gap-2"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`h-4 w-4 ${locale === loc ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{t(loc)}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground uppercase">{loc}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
frontend/src/components/i18n/index.ts
Normal file
6
frontend/src/components/i18n/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* i18n Components
|
||||||
|
* Exports internationalization-related components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { LocaleSwitcher } from './LocaleSwitcher';
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/i18n/routing';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from '@/lib/i18n/routing';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import { useLogout } from '@/lib/api/hooks/useAuth';
|
import { useLogout } from '@/lib/api/hooks/useAuth';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -23,6 +24,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|||||||
import { Settings, LogOut, User, Shield } from 'lucide-react';
|
import { Settings, LogOut, User, Shield } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ThemeToggle } from '@/components/theme';
|
import { ThemeToggle } from '@/components/theme';
|
||||||
|
import { LocaleSwitcher } from '@/components/i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user initials for avatar
|
* Get user initials for avatar
|
||||||
@@ -67,6 +69,7 @@ function NavLink({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
|
const t = useTranslations('navigation');
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { mutate: logout, isPending: isLoggingOut } = useLogout();
|
const { mutate: logout, isPending: isLoggingOut } = useLogout();
|
||||||
|
|
||||||
@@ -86,15 +89,16 @@ export function Header() {
|
|||||||
{/* Navigation Links */}
|
{/* Navigation Links */}
|
||||||
<nav className="hidden md:flex items-center space-x-1">
|
<nav className="hidden md:flex items-center space-x-1">
|
||||||
<NavLink href="/" exact>
|
<NavLink href="/" exact>
|
||||||
Home
|
{t('home')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
{user?.is_superuser && <NavLink href="/admin">Admin</NavLink>}
|
{user?.is_superuser && <NavLink href="/admin">{t('admin')}</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Theme toggle and user menu */}
|
{/* Right side - Theme toggle, locale switcher, and user menu */}
|
||||||
<div className="ml-auto flex items-center space-x-2">
|
<div className="ml-auto flex items-center space-x-2">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
<LocaleSwitcher />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||||
@@ -118,20 +122,20 @@ export function Header() {
|
|||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/profile" className="cursor-pointer">
|
<Link href="/settings/profile" className="cursor-pointer">
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
Profile
|
{t('profile')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/password" className="cursor-pointer">
|
<Link href="/settings/password" className="cursor-pointer">
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
Settings
|
{t('settings')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{user?.is_superuser && (
|
{user?.is_superuser && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/admin" className="cursor-pointer">
|
<Link href="/admin" className="cursor-pointer">
|
||||||
<Shield className="mr-2 h-4 w-4" />
|
<Shield className="mr-2 h-4 w-4" />
|
||||||
Admin Panel
|
{t('adminPanel')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
@@ -142,7 +146,7 @@ export function Header() {
|
|||||||
disabled={isLoggingOut}
|
disabled={isLoggingOut}
|
||||||
>
|
>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
{isLoggingOut ? 'Logging out...' : 'Log out'}
|
{isLoggingOut ? t('loggingOut') : t('logout')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Alert } from '@/components/ui/alert';
|
import { Alert } from '@/components/ui/alert';
|
||||||
@@ -22,25 +23,26 @@ import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/erro
|
|||||||
// Validation Schema
|
// Validation Schema
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const passwordChangeSchema = z
|
const createPasswordChangeSchema = (t: (key: string) => string) =>
|
||||||
|
z
|
||||||
.object({
|
.object({
|
||||||
current_password: z.string().min(1, 'Current password is required'),
|
current_password: z.string().min(1, t('currentPasswordRequired')),
|
||||||
new_password: z
|
new_password: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'New password is required')
|
.min(1, t('newPasswordRequired'))
|
||||||
.min(8, 'Password must be at least 8 characters')
|
.min(8, t('newPasswordMinLength'))
|
||||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
.regex(/[0-9]/, t('newPasswordNumber'))
|
||||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
.regex(/[A-Z]/, t('newPasswordUppercase'))
|
||||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
.regex(/[a-z]/, t('newPasswordLowercase'))
|
||||||
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
|
.regex(/[^A-Za-z0-9]/, t('newPasswordSpecial')),
|
||||||
confirm_password: z.string().min(1, 'Please confirm your new password'),
|
confirm_password: z.string().min(1, t('confirmPasswordRequired')),
|
||||||
})
|
})
|
||||||
.refine((data) => data.new_password === data.confirm_password, {
|
.refine((data) => data.new_password === data.confirm_password, {
|
||||||
message: 'Passwords do not match',
|
message: t('passwordMismatch'),
|
||||||
path: ['confirm_password'],
|
path: ['confirm_password'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type PasswordChangeFormData = z.infer<typeof passwordChangeSchema>;
|
type PasswordChangeFormData = z.infer<ReturnType<typeof createPasswordChangeSchema>>;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Component
|
// Component
|
||||||
@@ -72,6 +74,7 @@ interface PasswordChangeFormProps {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormProps) {
|
export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormProps) {
|
||||||
|
const t = useTranslations('settings.password');
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
const passwordChangeMutation = usePasswordChange((message) => {
|
const passwordChangeMutation = usePasswordChange((message) => {
|
||||||
toast.success(message);
|
toast.success(message);
|
||||||
@@ -79,6 +82,8 @@ export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormP
|
|||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const passwordChangeSchema = createPasswordChangeSchema((key: string) => t(key));
|
||||||
|
|
||||||
const form = useForm<PasswordChangeFormData>({
|
const form = useForm<PasswordChangeFormData>({
|
||||||
resolver: zodResolver(passwordChangeSchema),
|
resolver: zodResolver(passwordChangeSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -122,7 +127,7 @@ export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormP
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Unexpected error format
|
// Unexpected error format
|
||||||
setServerError('An unexpected error occurred. Please try again.');
|
setServerError(t('unexpectedError'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -133,10 +138,8 @@ export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormP
|
|||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Change Password</CardTitle>
|
<CardTitle>{t('title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t('subtitle')}</CardDescription>
|
||||||
Update your password to keep your account secure. Make sure it's strong and unique.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
@@ -149,9 +152,9 @@ export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormP
|
|||||||
|
|
||||||
{/* Current Password Field */}
|
{/* Current Password Field */}
|
||||||
<FormField
|
<FormField
|
||||||
label="Current Password"
|
label={t('currentPasswordLabel')}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter your current password"
|
placeholder={t('currentPasswordPlaceholder')}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
required
|
required
|
||||||
@@ -161,22 +164,22 @@ export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormP
|
|||||||
|
|
||||||
{/* New Password Field */}
|
{/* New Password Field */}
|
||||||
<FormField
|
<FormField
|
||||||
label="New Password"
|
label={t('newPasswordLabel')}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter your new password"
|
placeholder={t('newPasswordPlaceholder')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
required
|
required
|
||||||
description="At least 8 characters with uppercase, lowercase, number, and special character"
|
description={t('newPasswordDescription')}
|
||||||
error={form.formState.errors.new_password}
|
error={form.formState.errors.new_password}
|
||||||
{...form.register('new_password')}
|
{...form.register('new_password')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Confirm Password Field */}
|
{/* Confirm Password Field */}
|
||||||
<FormField
|
<FormField
|
||||||
label="Confirm New Password"
|
label={t('confirmPasswordLabel')}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Confirm your new password"
|
placeholder={t('confirmPasswordPlaceholder')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
required
|
required
|
||||||
@@ -187,12 +190,12 @@ export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormP
|
|||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button type="submit" disabled={isSubmitting || !isDirty}>
|
<Button type="submit" disabled={isSubmitting || !isDirty}>
|
||||||
{isSubmitting ? 'Changing Password...' : 'Change Password'}
|
{isSubmitting ? t('updateButtonLoading') : t('updateButton')}
|
||||||
</Button>
|
</Button>
|
||||||
{/* istanbul ignore next - Cancel button requires isDirty state, tested in E2E */}
|
{/* istanbul ignore next - Cancel button requires isDirty state, tested in E2E */}
|
||||||
{isDirty && !isSubmitting && (
|
{isDirty && !isSubmitting && (
|
||||||
<Button type="button" variant="outline" onClick={() => form.reset()}>
|
<Button type="button" variant="outline" onClick={() => form.reset()}>
|
||||||
Cancel
|
{t('cancelButton')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Alert } from '@/components/ui/alert';
|
import { Alert } from '@/components/ui/alert';
|
||||||
@@ -23,21 +24,18 @@ import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/erro
|
|||||||
// Validation Schema
|
// Validation Schema
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const profileSchema = z.object({
|
const createProfileSchema = (t: (key: string) => string) =>
|
||||||
|
z.object({
|
||||||
first_name: z
|
first_name: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'First name is required')
|
.min(1, t('firstNameRequired'))
|
||||||
.min(2, 'First name must be at least 2 characters')
|
.min(2, t('firstNameMinLength'))
|
||||||
.max(50, 'First name must not exceed 50 characters'),
|
.max(50, t('firstNameMaxLength')),
|
||||||
last_name: z
|
last_name: z.string().max(50, t('lastNameMaxLength')).optional().or(z.literal('')),
|
||||||
.string()
|
email: z.string().email(t('emailInvalid')),
|
||||||
.max(50, 'Last name must not exceed 50 characters')
|
|
||||||
.optional()
|
|
||||||
.or(z.literal('')),
|
|
||||||
email: z.string().email('Invalid email address'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type ProfileFormData = z.infer<typeof profileSchema>;
|
type ProfileFormData = z.infer<ReturnType<typeof createProfileSchema>>;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Component
|
// Component
|
||||||
@@ -67,6 +65,7 @@ interface ProfileSettingsFormProps {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFormProps) {
|
export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFormProps) {
|
||||||
|
const t = useTranslations('settings.profile');
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const updateProfileMutation = useUpdateProfile((message) => {
|
const updateProfileMutation = useUpdateProfile((message) => {
|
||||||
@@ -74,6 +73,8 @@ export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFor
|
|||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const profileSchema = createProfileSchema((key: string) => t(key));
|
||||||
|
|
||||||
const form = useForm<ProfileFormData>({
|
const form = useForm<ProfileFormData>({
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -135,7 +136,7 @@ export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFor
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Unexpected error format
|
// Unexpected error format
|
||||||
setServerError('An unexpected error occurred. Please try again.');
|
setServerError(t('unexpectedError'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -146,10 +147,8 @@ export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFor
|
|||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Profile Information</CardTitle>
|
<CardTitle>{t('title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t('subtitle')}</CardDescription>
|
||||||
Update your personal information. Your email address is read-only.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
@@ -162,9 +161,9 @@ export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFor
|
|||||||
|
|
||||||
{/* First Name Field */}
|
{/* First Name Field */}
|
||||||
<FormField
|
<FormField
|
||||||
label="First Name"
|
label={t('firstNameLabel')}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="John"
|
placeholder={t('firstNamePlaceholder')}
|
||||||
autoComplete="given-name"
|
autoComplete="given-name"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
required
|
required
|
||||||
@@ -174,9 +173,9 @@ export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFor
|
|||||||
|
|
||||||
{/* Last Name Field */}
|
{/* Last Name Field */}
|
||||||
<FormField
|
<FormField
|
||||||
label="Last Name"
|
label={t('lastNameLabel')}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Doe"
|
placeholder={t('lastNamePlaceholder')}
|
||||||
autoComplete="family-name"
|
autoComplete="family-name"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
error={form.formState.errors.last_name}
|
error={form.formState.errors.last_name}
|
||||||
@@ -185,11 +184,11 @@ export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFor
|
|||||||
|
|
||||||
{/* Email Field (Read-only) */}
|
{/* Email Field (Read-only) */}
|
||||||
<FormField
|
<FormField
|
||||||
label="Email"
|
label={t('emailLabel')}
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled
|
disabled
|
||||||
description="Your email address cannot be changed from this form"
|
description={t('emailDescription')}
|
||||||
error={form.formState.errors.email}
|
error={form.formState.errors.email}
|
||||||
{...form.register('email')}
|
{...form.register('email')}
|
||||||
/>
|
/>
|
||||||
@@ -197,12 +196,12 @@ export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFor
|
|||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button type="submit" disabled={isSubmitting || !isDirty}>
|
<Button type="submit" disabled={isSubmitting || !isDirty}>
|
||||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
{isSubmitting ? t('updateButtonLoading') : t('updateButton')}
|
||||||
</Button>
|
</Button>
|
||||||
{/* istanbul ignore next - Reset button requires isDirty state, tested in E2E */}
|
{/* istanbul ignore next - Reset button requires isDirty state, tested in E2E */}
|
||||||
{isDirty && !isSubmitting && (
|
{isDirty && !isSubmitting && (
|
||||||
<Button type="button" variant="outline" onClick={() => form.reset()}>
|
<Button type="button" variant="outline" onClick={() => form.reset()}>
|
||||||
Reset
|
{t('resetButton')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
44
frontend/src/lib/i18n/request.ts
Normal file
44
frontend/src/lib/i18n/request.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// src/i18n/request.ts
|
||||||
|
/**
|
||||||
|
* Server-side i18n request configuration for next-intl.
|
||||||
|
*
|
||||||
|
* This file handles:
|
||||||
|
* - Loading translation messages for the requested locale
|
||||||
|
* - Server-side locale detection
|
||||||
|
* - Time zone configuration
|
||||||
|
*
|
||||||
|
* Important:
|
||||||
|
* - This runs on the server only (Next.js App Router)
|
||||||
|
* - Translation files are NOT sent to the client (zero bundle overhead)
|
||||||
|
* - Messages are loaded on-demand per request
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getRequestConfig } from 'next-intl/server';
|
||||||
|
import { routing } from '@/lib/i18n/routing';
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({ locale }) => {
|
||||||
|
// Validate that the incoming `locale` parameter is valid
|
||||||
|
// Type assertion: we know locale will be a string from the URL parameter
|
||||||
|
const requestedLocale = locale as 'en' | 'it';
|
||||||
|
|
||||||
|
// Check if the requested locale is supported, otherwise use default
|
||||||
|
const validLocale = routing.locales.includes(requestedLocale)
|
||||||
|
? requestedLocale
|
||||||
|
: routing.defaultLocale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Return the validated locale
|
||||||
|
locale: validLocale,
|
||||||
|
|
||||||
|
// Load messages for the requested locale
|
||||||
|
// Dynamic import ensures only the requested locale is loaded
|
||||||
|
messages: (await import(`../../../messages/${validLocale}.json`)).default,
|
||||||
|
|
||||||
|
// Optional: Configure time zone
|
||||||
|
// This will be used for date/time formatting
|
||||||
|
// timeZone: 'Europe/Rome', // Example for Italian users
|
||||||
|
|
||||||
|
// Optional: Configure now (for relative time formatting)
|
||||||
|
// now: new Date(),
|
||||||
|
};
|
||||||
|
});
|
||||||
47
frontend/src/lib/i18n/routing.ts
Normal file
47
frontend/src/lib/i18n/routing.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// src/i18n/routing.ts
|
||||||
|
/**
|
||||||
|
* Internationalization routing configuration for next-intl.
|
||||||
|
*
|
||||||
|
* This file defines:
|
||||||
|
* - Supported locales (en, it)
|
||||||
|
* - Default locale (en)
|
||||||
|
* - Routing strategy (subdirectory pattern: /[locale]/path)
|
||||||
|
*
|
||||||
|
* Architecture Decision:
|
||||||
|
* - Using subdirectory pattern (/en/about, /it/about) for best SEO
|
||||||
|
* - Only 2 languages (EN, IT) as template showcase
|
||||||
|
* - Users can extend by adding more locales to this configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineRouting } from 'next-intl/routing';
|
||||||
|
import { createNavigation } from 'next-intl/navigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routing configuration for next-intl.
|
||||||
|
*
|
||||||
|
* Pattern: /[locale]/[pathname]
|
||||||
|
* Examples:
|
||||||
|
* - /en/about
|
||||||
|
* - /it/about
|
||||||
|
* - /en/auth/login
|
||||||
|
* - /it/auth/login
|
||||||
|
*/
|
||||||
|
export const routing = defineRouting({
|
||||||
|
// A list of all locales that are supported
|
||||||
|
locales: ['en', 'it'],
|
||||||
|
|
||||||
|
// Used when no locale matches
|
||||||
|
defaultLocale: 'en',
|
||||||
|
|
||||||
|
// Locale prefix strategy
|
||||||
|
// - "always": Always show locale in URL (/en/about, /it/about)
|
||||||
|
// - "as-needed": Only show non-default locales (/about for en, /it/about for it)
|
||||||
|
// We use "always" for clarity and consistency
|
||||||
|
localePrefix: 'always',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lightweight wrappers around Next.js' navigation APIs
|
||||||
|
// that will consider the routing configuration
|
||||||
|
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);
|
||||||
|
|
||||||
|
export type Locale = (typeof routing.locales)[number];
|
||||||
86
frontend/src/lib/i18n/utils.ts
Normal file
86
frontend/src/lib/i18n/utils.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// src/lib/i18n/utils.ts
|
||||||
|
/**
|
||||||
|
* Utility functions for internationalization.
|
||||||
|
*
|
||||||
|
* This file provides pure utility functions for i18n without React dependencies.
|
||||||
|
* For React hooks, see hooks.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display name for a locale code.
|
||||||
|
*
|
||||||
|
* @param locale - The locale code ('en' or 'it')
|
||||||
|
* @returns The human-readable locale name
|
||||||
|
*/
|
||||||
|
export function getLocaleName(locale: string): string {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
en: 'English',
|
||||||
|
it: 'Italiano',
|
||||||
|
};
|
||||||
|
|
||||||
|
return names[locale] || names.en;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the native display name for a locale code.
|
||||||
|
* This shows the language name in its own language.
|
||||||
|
*
|
||||||
|
* @param locale - The locale code ('en' or 'it')
|
||||||
|
* @returns The native language name
|
||||||
|
*/
|
||||||
|
export function getLocaleNativeName(locale: string): string {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
en: 'English',
|
||||||
|
it: 'Italiano',
|
||||||
|
};
|
||||||
|
|
||||||
|
return names[locale] || names.en;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the flag emoji for a locale.
|
||||||
|
*
|
||||||
|
* @param locale - The locale code ('en' or 'it')
|
||||||
|
* @returns The flag emoji
|
||||||
|
*/
|
||||||
|
export function getLocaleFlag(locale: string): string {
|
||||||
|
// Map to country flags (note: 'en' uses US flag, could be GB)
|
||||||
|
const flags: Record<string, string> = {
|
||||||
|
en: '🇺🇸', // or '🇬🇧' for British English
|
||||||
|
it: '🇮🇹',
|
||||||
|
};
|
||||||
|
|
||||||
|
return flags[locale] || flags.en;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a relative time string (e.g., "2 hours ago").
|
||||||
|
* This is a placeholder for future implementation with next-intl's date/time formatting.
|
||||||
|
*
|
||||||
|
* @param date - The date to format
|
||||||
|
* @param locale - The locale to use for formatting
|
||||||
|
* @returns Formatted relative time string
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(date: Date, locale: string = 'en'): string {
|
||||||
|
const now = new Date();
|
||||||
|
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||||
|
|
||||||
|
if (diffInSeconds < 60) {
|
||||||
|
return locale === 'it' ? 'proprio ora' : 'just now';
|
||||||
|
} else if (diffInSeconds < 3600) {
|
||||||
|
const minutes = Math.floor(diffInSeconds / 60);
|
||||||
|
return locale === 'it'
|
||||||
|
? `${minutes} ${minutes === 1 ? 'minuto' : 'minuti'} fa`
|
||||||
|
: `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`;
|
||||||
|
} else if (diffInSeconds < 86400) {
|
||||||
|
const hours = Math.floor(diffInSeconds / 3600);
|
||||||
|
return locale === 'it'
|
||||||
|
? `${hours} ${hours === 1 ? 'ora' : 'ore'} fa`
|
||||||
|
: `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
|
||||||
|
} else {
|
||||||
|
const days = Math.floor(diffInSeconds / 86400);
|
||||||
|
return locale === 'it'
|
||||||
|
? `${days} ${days === 1 ? 'giorno' : 'giorni'} fa`
|
||||||
|
: `${days} ${days === 1 ? 'day' : 'days'} ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
import createMiddleware from 'next-intl/middleware';
|
||||||
|
import { routing } from './lib/i18n/routing';
|
||||||
|
|
||||||
|
// Create next-intl middleware for locale handling
|
||||||
|
const intlMiddleware = createMiddleware(routing);
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
// Block access to /dev routes in production
|
// Block access to /dev routes in production (handles both /dev and /[locale]/dev)
|
||||||
if (pathname.startsWith('/dev')) {
|
// Match: /dev, /en/dev, /it/dev, etc.
|
||||||
|
if (pathname === '/dev' || pathname.match(/^\/[a-z]{2}\/dev($|\/)/)) {
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
@@ -14,9 +20,20 @@ export function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
// Handle locale routing with next-intl
|
||||||
|
return intlMiddleware(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: '/dev/:path*',
|
// Match all pathnames except for:
|
||||||
|
// - API routes (/api/*)
|
||||||
|
// - Static files (/_next/*, /favicon.ico, etc.)
|
||||||
|
// - Files in public folder (images, fonts, etc.)
|
||||||
|
matcher: [
|
||||||
|
// Match all pathnames except for
|
||||||
|
'/((?!api|_next|_vercel|.*\\..*).*)',
|
||||||
|
// However, match all pathnames within /api/
|
||||||
|
// that don't end with a file extension
|
||||||
|
'/api/(.*)',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
25
frontend/src/types/i18n.d.ts
vendored
Normal file
25
frontend/src/types/i18n.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// src/types/i18n.d.ts
|
||||||
|
/**
|
||||||
|
* TypeScript type definitions for i18n with next-intl.
|
||||||
|
*
|
||||||
|
* This file configures TypeScript autocomplete for translation keys.
|
||||||
|
* By importing the English messages as the reference type, we get:
|
||||||
|
* - Full autocomplete for all translation keys
|
||||||
|
* - Type safety when using t() function
|
||||||
|
* - Compile-time errors for missing or incorrect keys
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```tsx
|
||||||
|
* const t = useTranslations('auth.login');
|
||||||
|
* t('title'); // ✅ Autocomplete shows available keys
|
||||||
|
* t('invalid'); // ❌ TypeScript error
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Messages = typeof import('../../messages/en.json');
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// Use type safe message keys with `next-intl`
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
interface IntlMessages extends Messages {}
|
||||||
|
}
|
||||||
5
frontend/tests/__mocks__/components-i18n.tsx
Normal file
5
frontend/tests/__mocks__/components-i18n.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Mock for @/components/i18n
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const LocaleSwitcher = () => <div data-testid="locale-switcher">EN</div>;
|
||||||
29
frontend/tests/__mocks__/next-intl-navigation.tsx
Normal file
29
frontend/tests/__mocks__/next-intl-navigation.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Mock for next-intl/navigation
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Create shared mock instances that tests can manipulate
|
||||||
|
// Note: next-intl's usePathname returns paths WITHOUT locale prefix
|
||||||
|
export const mockUsePathname = jest.fn(() => '/');
|
||||||
|
export const mockPush = jest.fn();
|
||||||
|
export const mockReplace = jest.fn();
|
||||||
|
export const mockUseRouter = jest.fn(() => ({
|
||||||
|
push: mockPush,
|
||||||
|
replace: mockReplace,
|
||||||
|
prefetch: jest.fn(),
|
||||||
|
back: jest.fn(),
|
||||||
|
forward: jest.fn(),
|
||||||
|
refresh: jest.fn(),
|
||||||
|
}));
|
||||||
|
export const mockRedirect = jest.fn();
|
||||||
|
|
||||||
|
export const createNavigation = (_routing: any) => ({
|
||||||
|
Link: ({ children, href, ...props }: any) => (
|
||||||
|
<a href={href} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
redirect: mockRedirect,
|
||||||
|
usePathname: mockUsePathname,
|
||||||
|
useRouter: mockUseRouter,
|
||||||
|
});
|
||||||
23
frontend/tests/__mocks__/next-intl-routing.tsx
Normal file
23
frontend/tests/__mocks__/next-intl-routing.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Mock for next-intl/routing
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const defineRouting = (config: any) => config;
|
||||||
|
|
||||||
|
export const createNavigation = (_routing: any) => ({
|
||||||
|
Link: ({ children, href, ...props }: any) => (
|
||||||
|
<a href={href} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
redirect: jest.fn(),
|
||||||
|
usePathname: () => '/en/test',
|
||||||
|
useRouter: () => ({
|
||||||
|
push: jest.fn(),
|
||||||
|
replace: jest.fn(),
|
||||||
|
prefetch: jest.fn(),
|
||||||
|
back: jest.fn(),
|
||||||
|
forward: jest.fn(),
|
||||||
|
refresh: jest.fn(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
11
frontend/tests/__mocks__/next-intl.tsx
Normal file
11
frontend/tests/__mocks__/next-intl.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Mock for next-intl
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const useLocale = jest.fn(() => 'en');
|
||||||
|
export const useTranslations = jest.fn(() => (key: string) => key);
|
||||||
|
export const NextIntlClientProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||||
|
export const useFormatter = jest.fn(() => ({}));
|
||||||
|
export const useMessages = jest.fn(() => ({}));
|
||||||
|
export const useNow = jest.fn(() => new Date());
|
||||||
|
export const useTimeZone = jest.fn(() => 'UTC');
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import LoginPage from '@/app/(auth)/login/page';
|
import LoginPage from '@/app/[locale]/(auth)/login/page';
|
||||||
|
|
||||||
// Mock dynamic import
|
// Mock dynamic import
|
||||||
jest.mock('next/dynamic', () => ({
|
jest.mock('next/dynamic', () => ({
|
||||||
|
|||||||
@@ -4,20 +4,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen, act } from '@testing-library/react';
|
import { render, screen, act } from '@testing-library/react';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import PasswordResetConfirmContent from '@/app/(auth)/password-reset/confirm/PasswordResetConfirmContent';
|
import { useRouter } from '@/lib/i18n/routing';
|
||||||
|
import PasswordResetConfirmContent from '@/app/[locale]/(auth)/password-reset/confirm/PasswordResetConfirmContent';
|
||||||
|
|
||||||
// Mock Next.js navigation
|
// Mock Next.js navigation
|
||||||
jest.mock('next/navigation', () => ({
|
jest.mock('next/navigation', () => ({
|
||||||
useSearchParams: jest.fn(),
|
useSearchParams: jest.fn(),
|
||||||
useRouter: jest.fn(),
|
|
||||||
default: jest.fn(),
|
default: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock Next.js Link
|
// Mock i18n routing
|
||||||
jest.mock('next/link', () => ({
|
jest.mock('@/lib/i18n/routing', () => ({
|
||||||
__esModule: true,
|
useRouter: jest.fn(),
|
||||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||||
<a href={href}>{children}</a>
|
<a href={href}>{children}</a>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import PasswordResetConfirmPage from '@/app/(auth)/password-reset/confirm/page';
|
import PasswordResetConfirmPage from '@/app/[locale]/(auth)/password-reset/confirm/page';
|
||||||
|
|
||||||
// Mock the content component
|
// Mock the content component
|
||||||
jest.mock('@/app/(auth)/password-reset/confirm/PasswordResetConfirmContent', () => ({
|
jest.mock('@/app/[locale]/(auth)/password-reset/confirm/PasswordResetConfirmContent', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: () => <div data-testid="password-reset-confirm-content">Content</div>,
|
default: () => <div data-testid="password-reset-confirm-content">Content</div>,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import PasswordResetPage from '@/app/(auth)/password-reset/page';
|
import PasswordResetPage from '@/app/[locale]/(auth)/password-reset/page';
|
||||||
|
|
||||||
// Mock dynamic import
|
// Mock dynamic import
|
||||||
jest.mock('next/dynamic', () => ({
|
jest.mock('next/dynamic', () => ({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import RegisterPage from '@/app/(auth)/register/page';
|
import RegisterPage from '@/app/[locale]/(auth)/register/page';
|
||||||
|
|
||||||
// Mock dynamic import
|
// Mock dynamic import
|
||||||
jest.mock('next/dynamic', () => ({
|
jest.mock('next/dynamic', () => ({
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user