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")
|
||||
# Nullable: NULL means "not set yet", will use Accept-Language header fallback
|
||||
# Indexed: For analytics queries and filtering by locale
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("locale", sa.String(length=10), nullable=True)
|
||||
)
|
||||
op.add_column("users", sa.Column("locale", sa.String(length=10), nullable=True))
|
||||
|
||||
# Create index on locale column for performance
|
||||
op.create_index(
|
||||
|
||||
@@ -117,8 +117,9 @@ async def get_locale(
|
||||
if current_user and current_user.locale:
|
||||
# Validate that saved locale is still supported
|
||||
# (in case SUPPORTED_LOCALES changed after user set preference)
|
||||
if current_user.locale in SUPPORTED_LOCALES:
|
||||
return current_user.locale
|
||||
locale_value = str(current_user.locale)
|
||||
if locale_value in SUPPORTED_LOCALES:
|
||||
return locale_value
|
||||
|
||||
# Priority 2: Accept-Language header
|
||||
accept_language = request.headers.get("accept-language", "")
|
||||
|
||||
@@ -40,9 +40,9 @@ class UserUpdate(BaseModel):
|
||||
locale: str | None = Field(
|
||||
None,
|
||||
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)",
|
||||
examples=["en", "it", "en-US", "it-IT"]
|
||||
examples=["en", "it", "en-US", "it-IT"],
|
||||
)
|
||||
is_active: bool | None = (
|
||||
None # Changed default from True to None to avoid unintended updates
|
||||
@@ -70,12 +70,12 @@ class UserUpdate(BaseModel):
|
||||
return v
|
||||
# Only support English and Italian for template showcase
|
||||
# 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
|
||||
v_lower = v.lower()
|
||||
if v_lower not in SUPPORTED_LOCALES:
|
||||
if v_lower not in supported_locales:
|
||||
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 v_lower
|
||||
|
||||
@@ -67,9 +67,7 @@ class TestParseAcceptLanguage:
|
||||
|
||||
def test_parse_complex_header(self):
|
||||
"""Test complex Accept-Language header with multiple locales"""
|
||||
result = parse_accept_language(
|
||||
"it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7,fr;q=0.6"
|
||||
)
|
||||
result = parse_accept_language("it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7,fr;q=0.6")
|
||||
assert result == "it-it"
|
||||
|
||||
def test_parse_whitespace_handling(self):
|
||||
@@ -199,9 +197,7 @@ class TestGetLocale:
|
||||
assert result == "en"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locale_from_accept_language_header(
|
||||
self, async_user_without_locale
|
||||
):
|
||||
async def test_locale_from_accept_language_header(self, async_user_without_locale):
|
||||
"""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 = MagicMock()
|
||||
|
||||
@@ -334,11 +334,7 @@ class TestLocaleValidation:
|
||||
def test_locale_in_user_update_with_other_fields(self):
|
||||
"""Test locale validation works when combined with other fields"""
|
||||
# Valid locale with other fields
|
||||
user = UserUpdate(
|
||||
first_name="Mario",
|
||||
last_name="Rossi",
|
||||
locale="it"
|
||||
)
|
||||
user = UserUpdate(first_name="Mario", last_name="Rossi", locale="it")
|
||||
assert user.locale == "it"
|
||||
assert user.first_name == "Mario"
|
||||
|
||||
@@ -347,7 +343,7 @@ class TestLocaleValidation:
|
||||
UserUpdate(
|
||||
first_name="Pierre",
|
||||
last_name="Dupont",
|
||||
locale="fr" # Unsupported
|
||||
locale="fr", # Unsupported
|
||||
)
|
||||
|
||||
def test_supported_locales_list(self):
|
||||
@@ -357,7 +353,9 @@ class TestLocaleValidation:
|
||||
# Expected output (normalized to lowercase)
|
||||
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)
|
||||
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)
|
||||
|
||||
// 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")');
|
||||
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
await page.waitForURL(/\/(auth\/login|$)/);
|
||||
await page.waitForURL(/\/en(\/login)?$/);
|
||||
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
|
||||
// (AuthGuard fetches user on protected pages)
|
||||
await page.goto('/settings');
|
||||
await page.goto('/en/settings');
|
||||
await page.waitForSelector('h1:has-text("Settings")');
|
||||
|
||||
// Should see admin link in header navigation bar
|
||||
@@ -52,7 +52,7 @@ test.describe('Admin Access Control', () => {
|
||||
.locator('header nav')
|
||||
.getByRole('link', { name: 'Admin', exact: true });
|
||||
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 }) => {
|
||||
@@ -61,10 +61,10 @@ test.describe('Admin Access Control', () => {
|
||||
// Auth already cached in storage state (loginViaUI removed for performance)
|
||||
|
||||
// Navigate to admin page
|
||||
await page.goto('/admin');
|
||||
await page.goto('/en/admin');
|
||||
|
||||
// Should see admin dashboard
|
||||
await expect(page).toHaveURL('/admin');
|
||||
await expect(page).toHaveURL('/en/admin');
|
||||
await expect(page.locator('h1')).toContainText('Admin Dashboard');
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,7 @@ test.describe('Admin Dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
@@ -120,7 +120,7 @@ test.describe('Admin Navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
@@ -143,9 +143,9 @@ test.describe('Admin Navigation', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
// Breadcrumbs should show Admin > Users
|
||||
@@ -158,9 +158,9 @@ test.describe('Admin Navigation', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
// Breadcrumbs should show Admin > Organizations
|
||||
@@ -173,9 +173,9 @@ test.describe('Admin Navigation', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
// Breadcrumbs should show Admin > Settings
|
||||
@@ -208,14 +208,14 @@ test.describe('Admin Navigation', () => {
|
||||
});
|
||||
|
||||
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
|
||||
const dashboardLink = page.getByTestId('nav-dashboard');
|
||||
await dashboardLink.click();
|
||||
|
||||
await page.waitForURL('/admin');
|
||||
await expect(page).toHaveURL('/admin');
|
||||
await page.waitForURL('/en/admin');
|
||||
await expect(page).toHaveURL('/en/admin');
|
||||
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 }) => {
|
||||
await page.goto('/admin');
|
||||
await page.goto('/en/admin');
|
||||
|
||||
const breadcrumbs = page.getByTestId('breadcrumbs');
|
||||
await expect(breadcrumbs).toBeVisible();
|
||||
@@ -239,12 +239,12 @@ test.describe('Admin Breadcrumbs', () => {
|
||||
});
|
||||
|
||||
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)
|
||||
const adminBreadcrumb = page.getByTestId('breadcrumb-admin');
|
||||
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)
|
||||
const usersBreadcrumb = page.getByTestId('breadcrumb-users');
|
||||
@@ -253,13 +253,13 @@ test.describe('Admin Breadcrumbs', () => {
|
||||
});
|
||||
|
||||
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
|
||||
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 }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
await expect(page).toHaveURL('/admin');
|
||||
await expect(page).toHaveURL('/en/admin');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Admin Dashboard' })).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 }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
@@ -62,7 +62,7 @@ test.describe('Admin Dashboard - Quick Actions', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
@@ -86,9 +86,9 @@ test.describe('Admin Dashboard - Quick Actions', () => {
|
||||
test('should navigate to users page when clicking user management', async ({ page }) => {
|
||||
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 }) => {
|
||||
@@ -96,9 +96,9 @@ test.describe('Admin Dashboard - Quick Actions', () => {
|
||||
const quickActionsSection = page.locator('h2:has-text("Quick Actions")').locator('..');
|
||||
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 }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
@@ -151,7 +151,7 @@ test.describe('Admin Dashboard - Accessibility', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ test.describe('Admin Organization Members - Navigation from Organizations List',
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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');
|
||||
});
|
||||
|
||||
@@ -24,12 +24,12 @@ test.describe('Admin Organization Members - Navigation from Organizations List',
|
||||
|
||||
// Click "View Members"
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
||||
page.waitForURL(/\/en\/admin\/organizations\/[^/]+\/members/),
|
||||
page.getByText('View Members').click(),
|
||||
]);
|
||||
|
||||
// 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 }) => {
|
||||
@@ -39,12 +39,12 @@ test.describe('Admin Organization Members - Navigation from Organizations List',
|
||||
|
||||
// Click on member count
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
||||
page.waitForURL(/\/en\/admin\/organizations\/[^/]+\/members/),
|
||||
memberButton.click(),
|
||||
]);
|
||||
|
||||
// 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 }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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');
|
||||
|
||||
// Navigate to members page
|
||||
@@ -60,13 +60,13 @@ test.describe('Admin Organization Members - Page Structure', () => {
|
||||
await actionButton.click();
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
||||
page.waitForURL(/\/en\/admin\/organizations\/[^/]+\/members/),
|
||||
page.getByText('View Members').click(),
|
||||
]);
|
||||
});
|
||||
|
||||
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
|
||||
await page.waitForSelector('table');
|
||||
@@ -123,7 +123,7 @@ test.describe('Admin Organization Members - AddMemberDialog E2E Tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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');
|
||||
|
||||
// Navigate to members page
|
||||
@@ -131,7 +131,7 @@ test.describe('Admin Organization Members - AddMemberDialog E2E Tests', () => {
|
||||
await actionButton.click();
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
||||
page.waitForURL(/\/en\/admin\/organizations\/[^/]+\/members/),
|
||||
page.getByText('View Members').click(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ test.describe('Admin Organization Management - Page Load', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
await expect(page).toHaveURL('/admin/organizations');
|
||||
await expect(page).toHaveURL('/en/admin/organizations');
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForSelector('table');
|
||||
@@ -41,7 +41,7 @@ test.describe('Admin Organization Management - Organization List Table', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
@@ -107,7 +107,7 @@ test.describe('Admin Organization Management - Pagination', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
@@ -127,7 +127,7 @@ test.describe('Admin Organization Management - Create Organization Button', () =
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
@@ -140,7 +140,7 @@ test.describe('Admin Organization Management - Action Menu', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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');
|
||||
});
|
||||
|
||||
@@ -192,12 +192,12 @@ test.describe('Admin Organization Management - Action Menu', () => {
|
||||
|
||||
// Click view members - use Promise.all for Next.js Link navigation
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
||||
page.waitForURL(/\/en\/admin\/organizations\/[^/]+\/members/),
|
||||
page.getByText('View Members').click(),
|
||||
]);
|
||||
|
||||
// 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 }) => {
|
||||
@@ -248,7 +248,7 @@ test.describe('Admin Organization Management - Edit Organization Dialog', () =>
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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');
|
||||
});
|
||||
|
||||
@@ -297,7 +297,7 @@ test.describe('Admin Organization Management - Member Count Interaction', () =>
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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');
|
||||
});
|
||||
|
||||
@@ -308,12 +308,12 @@ test.describe('Admin Organization Management - Member Count Interaction', () =>
|
||||
|
||||
// Click on member count - use Promise.all for Next.js Link navigation
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
||||
page.waitForURL(/\/en\/admin\/organizations\/[^/]+\/members/),
|
||||
memberButton.click(),
|
||||
]);
|
||||
|
||||
// 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 }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
|
||||
@@ -10,11 +10,11 @@ test.describe('Admin User Management - Page Load', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
await expect(page).toHaveURL('/admin/users');
|
||||
await expect(page).toHaveURL('/en/admin/users');
|
||||
await expect(page.locator('h1')).toContainText('User Management');
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ test.describe('Admin User Management - User List Table', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
@@ -101,7 +101,7 @@ test.describe('Admin User Management - Search and Filters', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
@@ -244,7 +244,7 @@ test.describe('Admin User Management - Pagination', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
@@ -262,7 +262,7 @@ test.describe('Admin User Management - Row Selection', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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');
|
||||
});
|
||||
|
||||
@@ -325,7 +325,7 @@ test.describe('Admin User Management - Create User Dialog', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
@@ -449,7 +449,7 @@ test.describe('Admin User Management - Action Menu', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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');
|
||||
});
|
||||
|
||||
@@ -502,7 +502,7 @@ test.describe('Admin User Management - Edit User Dialog', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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');
|
||||
});
|
||||
|
||||
@@ -561,7 +561,7 @@ test.describe('Admin User Management - Bulk Actions', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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');
|
||||
});
|
||||
|
||||
@@ -631,7 +631,7 @@ test.describe('Admin User Management - Accessibility', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
// 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 }) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ test.describe('AuthGuard - Route Protection', () => {
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
// Clear storage before each test to ensure clean state
|
||||
await context.clearCookies();
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
@@ -15,37 +15,37 @@ test.describe('AuthGuard - Route Protection', () => {
|
||||
// Try to access a protected route (if you have one)
|
||||
// For now, we'll test the root if it's protected
|
||||
// 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
|
||||
// Wait for page to stabilize
|
||||
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();
|
||||
expect(url).toMatch(/\/(login)?$/);
|
||||
expect(url).toMatch(/\/en(\/login)?$/);
|
||||
});
|
||||
|
||||
test('should allow access to public routes without auth', async ({ page }) => {
|
||||
// Test login page
|
||||
await page.goto('/login');
|
||||
await expect(page).toHaveURL('/login');
|
||||
await page.goto('/en/login');
|
||||
await expect(page).toHaveURL('/en/login');
|
||||
await expect(page.locator('h2')).toContainText('Sign in to your account');
|
||||
|
||||
// Test register page
|
||||
await page.goto('/register');
|
||||
await expect(page).toHaveURL('/register');
|
||||
await page.goto('/en/register');
|
||||
await expect(page).toHaveURL('/en/register');
|
||||
await expect(page.locator('h2')).toContainText('Create your account');
|
||||
|
||||
// Test password reset page
|
||||
await page.goto('/password-reset');
|
||||
await expect(page).toHaveURL('/password-reset');
|
||||
await page.goto('/en/password-reset');
|
||||
await expect(page).toHaveURL('/en/password-reset');
|
||||
await expect(page.locator('h2')).toContainText('Reset your password');
|
||||
});
|
||||
|
||||
test('should persist authentication across page reloads', async ({ page }) => {
|
||||
// Manually set a mock token in localStorage for testing
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
await page.evaluate(() => {
|
||||
const mockToken = {
|
||||
access_token: 'mock-access-token',
|
||||
@@ -73,7 +73,7 @@ test.describe('AuthGuard - Route Protection', () => {
|
||||
|
||||
test('should clear authentication on logout', async ({ page }) => {
|
||||
// Set up authenticated state
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
await page.evaluate(() => {
|
||||
const mockToken = {
|
||||
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 }) => {
|
||||
// Set up authenticated state
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
await page.evaluate(() => {
|
||||
const mockToken = {
|
||||
access_token: 'mock-access-token',
|
||||
@@ -127,7 +127,7 @@ test.describe('AuthGuard - Route Protection', () => {
|
||||
});
|
||||
|
||||
// Try to access login page
|
||||
await page.goto('/login');
|
||||
await page.goto('/en/login');
|
||||
|
||||
// Wait a bit for potential redirect
|
||||
await page.waitForTimeout(2000);
|
||||
@@ -141,7 +141,7 @@ test.describe('AuthGuard - Route Protection', () => {
|
||||
|
||||
test('should handle expired tokens gracefully', async ({ page }) => {
|
||||
// Set up authenticated state with expired token
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
await page.evaluate(() => {
|
||||
const expiredToken = {
|
||||
access_token: 'expired-access-token',
|
||||
@@ -171,7 +171,7 @@ test.describe('AuthGuard - Route Protection', () => {
|
||||
test('should preserve intended destination after login', async ({ page }) => {
|
||||
// This is a nice-to-have feature that requires protected routes
|
||||
// For now, just verify the test doesn't crash
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
|
||||
// Login (via localStorage for testing)
|
||||
await page.evaluate(() => {
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe('Login Flow', () => {
|
||||
});
|
||||
|
||||
// Navigate to login page before each test
|
||||
await page.goto('/login');
|
||||
await page.goto('/en/login');
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
@@ -128,7 +128,7 @@ test.describe('Login Flow', () => {
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 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 }) => {
|
||||
@@ -162,10 +162,10 @@ test.describe('Login Flow', () => {
|
||||
// Click forgot password link - use Promise.all to wait for navigation
|
||||
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
|
||||
await expect(page).toHaveURL('/password-reset');
|
||||
await expect(page).toHaveURL('/en/password-reset');
|
||||
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
|
||||
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
|
||||
await expect(page).toHaveURL('/register');
|
||||
await expect(page).toHaveURL('/en/register');
|
||||
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.beforeEach(async ({ 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 }) => {
|
||||
@@ -37,7 +37,7 @@ test.describe('Password Reset Request Flow', () => {
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 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 }) => {
|
||||
@@ -55,10 +55,10 @@ test.describe('Password Reset Request Flow', () => {
|
||||
// Click back to login link - use Promise.all to wait for navigation
|
||||
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
|
||||
await expect(page).toHaveURL('/login');
|
||||
await expect(page).toHaveURL('/en/login');
|
||||
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('should display error for missing token', async ({ page }) => {
|
||||
// Navigate without token
|
||||
await page.goto('/password-reset/confirm');
|
||||
await page.goto('/en/password-reset/confirm');
|
||||
|
||||
// Should show error message
|
||||
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 }) => {
|
||||
// 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
|
||||
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 }) => {
|
||||
// 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
|
||||
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 }) => {
|
||||
// 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
|
||||
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 }) => {
|
||||
// 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
|
||||
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 }) => {
|
||||
// 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
|
||||
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
|
||||
|
||||
// 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
|
||||
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 }) => {
|
||||
// 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
|
||||
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
|
||||
await expect(page).toHaveURL('/password-reset');
|
||||
await expect(page).toHaveURL('/en/password-reset');
|
||||
await expect(page.locator('h2')).toContainText('Reset your password');
|
||||
});
|
||||
|
||||
test('should toggle password visibility', async ({ page }) => {
|
||||
// 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 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 }) => {
|
||||
// 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
|
||||
await page.locator('input[name="new_password"]').fill('NewPassword123!');
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe('Registration Flow', () => {
|
||||
});
|
||||
|
||||
// Navigate to register page before each test
|
||||
await page.goto('/register');
|
||||
await page.goto('/en/register');
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
@@ -222,10 +222,10 @@ test.describe('Registration Flow', () => {
|
||||
const loginLink = page.getByRole('link', { name: 'Sign in' });
|
||||
|
||||
// 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
|
||||
await expect(page).toHaveURL('/login');
|
||||
await expect(page).toHaveURL('/en/login');
|
||||
await expect(page.locator('h2')).toContainText('Sign in to your account');
|
||||
});
|
||||
|
||||
|
||||
@@ -100,15 +100,15 @@ export async function loginViaUI(
|
||||
email = 'test@example.com',
|
||||
password = 'Password123!'
|
||||
): Promise<void> {
|
||||
// Navigate to login page
|
||||
await page.goto('/login');
|
||||
// Navigate to login page (with locale prefix)
|
||||
await page.goto('/en/login');
|
||||
|
||||
// Fill login form
|
||||
await page.locator('input[name="email"]').fill(email);
|
||||
await page.locator('input[name="password"]').fill(password);
|
||||
|
||||
// Submit and wait for navigation to home
|
||||
await Promise.all([page.waitForURL('/'), page.locator('button[type="submit"]').click()]);
|
||||
// Submit and wait for navigation to home (with locale prefix)
|
||||
await Promise.all([page.waitForURL('/en'), page.locator('button[type="submit"]').click()]);
|
||||
|
||||
// Wait for auth to settle
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Homepage - Desktop Navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
// 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)
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -29,22 +29,22 @@ test.describe('Homepage - Desktop Navigation', () => {
|
||||
await expect(githubLink).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
test('should navigate to components page via header link', async ({ page }) => {
|
||||
// Click the exact Components link in header navigation
|
||||
test('should navigate to design system page via header link', async ({ page }) => {
|
||||
// Click the exact Design System link in header navigation
|
||||
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
|
||||
await expect(componentsLink).toBeVisible();
|
||||
await expect(componentsLink).toHaveAttribute('href', '/dev');
|
||||
await expect(designSystemLink).toBeVisible();
|
||||
await expect(designSystemLink).toHaveAttribute('href', '/en/dev');
|
||||
|
||||
// Click and wait for navigation
|
||||
await componentsLink.click();
|
||||
await page.waitForURL('/dev', { timeout: 10000 }).catch(() => {});
|
||||
await designSystemLink.click();
|
||||
await page.waitForURL('/en/dev', { timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Verify URL (might not navigate if /dev page has issues, that's ok for this test)
|
||||
const currentUrl = page.url();
|
||||
expect(currentUrl).toMatch(/\/(dev)?$/);
|
||||
expect(currentUrl).toMatch(/\/en(\/dev)?$/);
|
||||
});
|
||||
|
||||
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
|
||||
await expect(adminLink).toBeVisible();
|
||||
await expect(adminLink).toHaveAttribute('href', '/admin');
|
||||
await expect(adminLink).toHaveAttribute('href', '/en/admin');
|
||||
|
||||
// Click and wait for navigation
|
||||
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)
|
||||
const currentUrl = page.url();
|
||||
expect(currentUrl).toMatch(/\/(admin)?$/);
|
||||
expect(currentUrl).toMatch(/\/en(\/admin)?$/);
|
||||
});
|
||||
|
||||
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 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 }) => {
|
||||
@@ -113,7 +113,7 @@ test.describe('Homepage - Mobile Menu Interactions', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Set mobile viewport
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
});
|
||||
|
||||
@@ -146,15 +146,15 @@ test.describe('Homepage - Mobile Menu Interactions', () => {
|
||||
const componentsLink = mobileMenu.getByRole('link', { name: 'Components' });
|
||||
|
||||
// Verify link has correct href
|
||||
await expect(componentsLink).toHaveAttribute('href', '/dev');
|
||||
await expect(componentsLink).toHaveAttribute('href', '/en/dev');
|
||||
|
||||
// Click and wait for navigation
|
||||
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)
|
||||
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 }) => {
|
||||
@@ -164,15 +164,15 @@ test.describe('Homepage - Mobile Menu Interactions', () => {
|
||||
const adminLink = mobileMenu.getByRole('link', { name: 'Admin Demo' });
|
||||
|
||||
// Verify link has correct href
|
||||
await expect(adminLink).toHaveAttribute('href', '/admin');
|
||||
await expect(adminLink).toHaveAttribute('href', '/en/admin');
|
||||
|
||||
// Click and wait for navigation
|
||||
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)
|
||||
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 }) => {
|
||||
@@ -204,9 +204,9 @@ test.describe('Homepage - Mobile Menu Interactions', () => {
|
||||
const loginLink = mobileMenu.getByRole('link', { name: /Login/i });
|
||||
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 }) => {
|
||||
@@ -223,7 +223,7 @@ test.describe('Homepage - Mobile Menu Interactions', () => {
|
||||
|
||||
test.describe('Homepage - Hero Section', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
// Verify link has correct href
|
||||
await expect(exploreLink).toHaveAttribute('href', '/dev');
|
||||
await expect(exploreLink).toHaveAttribute('href', '/en/dev');
|
||||
|
||||
// Click and try to navigate
|
||||
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)
|
||||
const currentUrl = page.url();
|
||||
expect(currentUrl).toMatch(/\/(dev)?$/);
|
||||
expect(currentUrl).toMatch(/\/en(\/dev)?$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Homepage - Demo Credentials Modal', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
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 }) => {
|
||||
@@ -344,7 +344,7 @@ test.describe('Homepage - Demo Credentials Modal', () => {
|
||||
|
||||
test.describe('Homepage - Animated Terminal', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
// Verify link has correct href
|
||||
await expect(terminalDemoLink).toHaveAttribute('href', '/login');
|
||||
await expect(terminalDemoLink).toHaveAttribute('href', '/en/login');
|
||||
|
||||
// Click and try to navigate
|
||||
await terminalDemoLink.click();
|
||||
await page.waitForURL('/login', { timeout: 10000 }).catch(() => {});
|
||||
await page.waitForURL('/en/login', { timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Verify URL (flexible to handle redirects)
|
||||
const currentUrl = page.url();
|
||||
expect(currentUrl).toMatch(/\/(login)?$/);
|
||||
expect(currentUrl).toMatch(/\/en(\/login)?$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Homepage - Feature Sections', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
// Verify link has correct href
|
||||
await expect(authLink).toHaveAttribute('href', '/login');
|
||||
await expect(authLink).toHaveAttribute('href', '/en/login');
|
||||
|
||||
// Click and try to navigate
|
||||
await authLink.click();
|
||||
await page.waitForURL('/login', { timeout: 10000 }).catch(() => {});
|
||||
await page.waitForURL('/en/login', { timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Verify URL (flexible to handle redirects)
|
||||
const currentUrl = page.url();
|
||||
expect(currentUrl).toMatch(/\/(login)?$/);
|
||||
expect(currentUrl).toMatch(/\/en(\/login)?$/);
|
||||
});
|
||||
|
||||
test('should navigate to admin from admin panel CTA', async ({ page }) => {
|
||||
const adminLink = page.getByRole('link', { name: /Try Admin Panel/i });
|
||||
|
||||
// Verify link has correct href
|
||||
await expect(adminLink).toHaveAttribute('href', '/admin');
|
||||
await expect(adminLink).toHaveAttribute('href', '/en/admin');
|
||||
|
||||
// Click and try to navigate
|
||||
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)
|
||||
const currentUrl = page.url();
|
||||
expect(currentUrl).toMatch(/\/(admin)?$/);
|
||||
expect(currentUrl).toMatch(/\/en(\/admin)?$/);
|
||||
});
|
||||
|
||||
test('should display tech stack section', async ({ page }) => {
|
||||
@@ -462,7 +462,7 @@ test.describe('Homepage - Feature Sections', () => {
|
||||
|
||||
test.describe('Homepage - Footer', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
});
|
||||
|
||||
test('should display footer with copyright', async ({ page }) => {
|
||||
@@ -475,7 +475,7 @@ test.describe('Homepage - Footer', () => {
|
||||
|
||||
test.describe('Homepage - Accessibility', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto('/en');
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
// Start at home page (auth already cached in storage state)
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL('/');
|
||||
await page.goto('/en');
|
||||
await expect(page).toHaveURL('/en');
|
||||
|
||||
// Navigate to settings/profile
|
||||
await page.goto('/settings/profile');
|
||||
await page.goto('/en/settings/profile');
|
||||
|
||||
// Verify navigation successful
|
||||
await expect(page).toHaveURL('/settings/profile');
|
||||
await expect(page).toHaveURL('/en/settings/profile');
|
||||
|
||||
// Verify page loaded - use specific heading selector
|
||||
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 }) => {
|
||||
// Start at home page (auth already cached in storage state)
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL('/');
|
||||
await page.goto('/en');
|
||||
await expect(page).toHaveURL('/en');
|
||||
|
||||
// Navigate to settings/password
|
||||
await page.goto('/settings/password');
|
||||
await page.goto('/en/settings/password');
|
||||
|
||||
// Verify navigation successful
|
||||
await expect(page).toHaveURL('/settings/password');
|
||||
await expect(page).toHaveURL('/en/settings/password');
|
||||
|
||||
// Verify page loaded - use specific heading selector
|
||||
await expect(page.getByRole('heading', { name: 'Password' })).toBeVisible();
|
||||
@@ -47,24 +47,24 @@ test.describe('Settings Navigation', () => {
|
||||
|
||||
test('should navigate between settings pages', async ({ page }) => {
|
||||
// Start at profile page
|
||||
await page.goto('/settings/profile');
|
||||
await page.goto('/en/settings/profile');
|
||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
|
||||
|
||||
// Navigate to password page
|
||||
await page.goto('/settings/password');
|
||||
await page.goto('/en/settings/password');
|
||||
await expect(page.getByRole('heading', { name: 'Password' })).toBeVisible();
|
||||
|
||||
// Navigate back to profile page
|
||||
await page.goto('/settings/profile');
|
||||
await page.goto('/en/settings/profile');
|
||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should redirect from /settings to /settings/profile', async ({ page }) => {
|
||||
// Navigate to base settings page
|
||||
await page.goto('/settings');
|
||||
await page.goto('/en/settings');
|
||||
|
||||
// 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
|
||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
|
||||
@@ -72,10 +72,10 @@ test.describe('Settings Navigation', () => {
|
||||
|
||||
test('should display preferences page placeholder', async ({ page }) => {
|
||||
// Navigate to preferences page
|
||||
await page.goto('/settings/preferences');
|
||||
await page.goto('/en/settings/preferences');
|
||||
|
||||
// Verify navigation successful
|
||||
await expect(page).toHaveURL('/settings/preferences');
|
||||
await expect(page).toHaveURL('/en/settings/preferences');
|
||||
|
||||
// Verify page loaded with placeholder content
|
||||
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)
|
||||
|
||||
// Navigate to password page
|
||||
await page.goto('/settings/password');
|
||||
await page.goto('/en/settings/password');
|
||||
|
||||
// Wait for form to be 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)
|
||||
|
||||
// Navigate to profile page
|
||||
await page.goto('/settings/profile');
|
||||
await page.goto('/en/settings/profile');
|
||||
|
||||
// Wait for page to render
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
@@ -8,12 +8,12 @@ import { test, expect } from '@playwright/test';
|
||||
test.describe('Theme Toggle on Public Pages', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear localStorage before each test
|
||||
await page.goto('/login');
|
||||
await page.goto('/en/login');
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
});
|
||||
|
||||
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
|
||||
await page.waitForTimeout(500);
|
||||
@@ -27,7 +27,7 @@ test.describe('Theme Toggle on Public Pages', () => {
|
||||
});
|
||||
|
||||
test('theme persists across page navigation', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.goto('/en/login');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Set theme to dark via localStorage
|
||||
@@ -43,14 +43,14 @@ test.describe('Theme Toggle on Public Pages', () => {
|
||||
await expect(page.locator('html')).toHaveClass(/dark/);
|
||||
|
||||
// Navigate to register page
|
||||
await page.goto('/register');
|
||||
await page.goto('/en/register');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Theme should still be dark
|
||||
await expect(page.locator('html')).toHaveClass(/dark/);
|
||||
|
||||
// Navigate to password reset
|
||||
await page.goto('/password-reset');
|
||||
await page.goto('/en/password-reset');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Theme should still be dark
|
||||
@@ -58,7 +58,7 @@ test.describe('Theme Toggle on Public Pages', () => {
|
||||
});
|
||||
|
||||
test('can switch theme programmatically', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.goto('/en/login');
|
||||
|
||||
// Set to light theme
|
||||
await page.evaluate(() => {
|
||||
|
||||
@@ -10,11 +10,15 @@ const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
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',
|
||||
},
|
||||
testMatch: ['<rootDir>/tests/**/*.test.ts', '<rootDir>/tests/**/*.test.tsx'],
|
||||
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: [
|
||||
'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 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 = {
|
||||
output: 'standalone',
|
||||
@@ -21,4 +25,5 @@ const nextConfig: NextConfig = {
|
||||
// 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",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "^15.5.6",
|
||||
"next-intl": "^4.5.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -1022,7 +1023,6 @@
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
|
||||
"integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "2.2.7",
|
||||
@@ -1035,7 +1035,6 @@
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
|
||||
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
@@ -1045,7 +1044,6 @@
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",
|
||||
"integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/ecma402-abstract": "2.3.6",
|
||||
@@ -1057,7 +1055,6 @@
|
||||
"version": "1.8.16",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",
|
||||
"integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/ecma402-abstract": "2.3.6",
|
||||
@@ -1068,7 +1065,6 @@
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
|
||||
"integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
@@ -4260,6 +4256,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "9.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.46.0.tgz",
|
||||
@@ -4420,6 +4422,172 @@
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"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": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -4429,6 +4597,15 @@
|
||||
"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": {
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
||||
@@ -7461,7 +7638,6 @@
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
@@ -9891,7 +10067,6 @@
|
||||
"version": "10.7.18",
|
||||
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz",
|
||||
"integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@formatjs/ecma402-abstract": "2.3.6",
|
||||
@@ -13390,6 +13565,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.6.2",
|
||||
"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": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||
@@ -16536,7 +16806,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"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": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "^15.5.6",
|
||||
"next-intl": "^4.5.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^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';
|
||||
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useRouter } from '@/lib/i18n/routing';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Alert } from '@/components/ui/alert';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
|
||||
// Code-split PasswordResetConfirmForm (319 lines)
|
||||
const PasswordResetConfirmForm = dynamic(
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from '@/lib/i18n/routing';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
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 */
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OrganizationMembersContent } from '@/components/admin/organizations/OrganizationMembersContent';
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
/* istanbul ignore next - Next.js type import for metadata */
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OrganizationManagementContent } from '@/components/admin/organizations/OrganizationManagementContent';
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
/* istanbul ignore next - Next.js type import for metadata */
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { DashboardStats } from '@/components/admin';
|
||||
import {
|
||||
UserGrowthChart,
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
/* istanbul ignore next - Next.js type import for metadata */
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
/* istanbul ignore next - Next.js type import for metadata */
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { UserManagementContent } from '@/components/admin/users/UserManagementContent';
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import {
|
||||
Palette,
|
||||
ShieldCheck,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
LogIn,
|
||||
Settings,
|
||||
Users,
|
||||
Lock,
|
||||
Activity,
|
||||
UserCog,
|
||||
BarChart3,
|
||||
@@ -28,7 +27,8 @@ import { Separator } from '@/components/ui/separator';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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 = [
|
||||
@@ -37,7 +37,12 @@ const demoCategories = [
|
||||
title: 'Design System Hub',
|
||||
description: 'Browse components, layouts, spacing, and forms with live examples',
|
||||
href: '/dev',
|
||||
features: ['All UI components', 'Layout patterns', 'Spacing philosophy', 'Form implementations'],
|
||||
features: [
|
||||
'All UI components',
|
||||
'Layout patterns',
|
||||
'Spacing philosophy',
|
||||
'Form implementations',
|
||||
],
|
||||
credentials: null,
|
||||
},
|
||||
{
|
||||
@@ -261,7 +266,8 @@ export default function DemoTourPage() {
|
||||
{/* CTA */}
|
||||
<Button asChild className="w-full gap-2">
|
||||
<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>
|
||||
</Button>
|
||||
</CardContent>
|
||||
@@ -4,7 +4,7 @@
|
||||
* Access: /dev/docs
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import {
|
||||
BookOpen,
|
||||
Sparkles,
|
||||
@@ -7,7 +7,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { Grid3x3 } from 'lucide-react';
|
||||
import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -14,7 +14,8 @@ import { Separator } from '@/components/ui/separator';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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 = [
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { Ruler } from 'lucide-react';
|
||||
import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
/* istanbul ignore next - Next.js type import for metadata */
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
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';
|
||||
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',
|
||||
};
|
||||
/**
|
||||
* Root Layout
|
||||
*
|
||||
* Minimal root layout that passes through to locale-specific layouts.
|
||||
* The actual HTML structure and providers are in [locale]/layout.tsx
|
||||
* to properly handle locale-specific rendering.
|
||||
*/
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return children;
|
||||
}
|
||||
|
||||
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
|
||||
* Main landing page for the FastNext Template project
|
||||
* Showcases features, tech stack, and provides demos for developers
|
||||
* Root page - redirects to default locale
|
||||
*/
|
||||
|
||||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
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>
|
||||
);
|
||||
export default function RootPage() {
|
||||
// Redirect to default locale (en)
|
||||
redirect('/en');
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { usePathname } from '@/lib/i18n/routing';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { usePathname } from '@/lib/i18n/routing';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
@@ -25,6 +25,7 @@ export function Breadcrumbs() {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Generate breadcrumb items from pathname
|
||||
// Note: usePathname() from next-intl returns path WITHOUT locale prefix
|
||||
const generateBreadcrumbs = (): BreadcrumbItem[] => {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const breadcrumbs: BreadcrumbItem[] = [];
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
'use client';
|
||||
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
'use client';
|
||||
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
'use client';
|
||||
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
'use client';
|
||||
|
||||
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 { useMe } from '@/lib/api/hooks/useAuth';
|
||||
import { AuthLoadingSkeleton } from '@/components/layout';
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -23,17 +24,18 @@ import config from '@/config/app.config';
|
||||
// Validation Schema
|
||||
// ============================================================================
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Password is required')
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter'),
|
||||
});
|
||||
const createLoginSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
email: z.string().min(1, t('validation.required')).email(t('validation.email')),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, t('validation.required'))
|
||||
.min(8, t('validation.minLength').replace('{count}', '8'))
|
||||
.regex(/[0-9]/, t('errors.validation.passwordWeak'))
|
||||
.regex(/[A-Z]/, t('errors.validation.passwordWeak')),
|
||||
});
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
type LoginFormData = z.infer<ReturnType<typeof createLoginSchema>>;
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
@@ -74,9 +76,22 @@ export function LoginForm({
|
||||
showPasswordResetLink = true,
|
||||
className,
|
||||
}: LoginFormProps) {
|
||||
const t = useTranslations('auth.login');
|
||||
const tValidation = useTranslations('validation');
|
||||
const tErrors = useTranslations('errors.validation');
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
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>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
mode: 'onBlur',
|
||||
@@ -116,7 +131,7 @@ export function LoginForm({
|
||||
});
|
||||
} else {
|
||||
// Unexpected error format
|
||||
setServerError('An unexpected error occurred. Please try again.');
|
||||
setServerError(t('unexpectedError'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -135,11 +150,11 @@ export function LoginForm({
|
||||
|
||||
{/* Email Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">{t('emailLabel')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
autoComplete="email"
|
||||
disabled={isSubmitting}
|
||||
{...form.register('email')}
|
||||
@@ -156,20 +171,20 @@ export function LoginForm({
|
||||
{/* Password Field */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">{t('passwordLabel')}</Label>
|
||||
{showPasswordResetLink && (
|
||||
<Link
|
||||
href="/password-reset"
|
||||
className="text-sm text-muted-foreground hover:text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
{t('forgotPassword')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
autoComplete="current-password"
|
||||
disabled={isSubmitting}
|
||||
{...form.register('password')}
|
||||
@@ -185,18 +200,18 @@ export function LoginForm({
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign in'}
|
||||
{isSubmitting ? t('loginButtonLoading') : t('loginButton')}
|
||||
</Button>
|
||||
|
||||
{/* Registration Link */}
|
||||
{showRegisterLink && config.features.enableRegistration && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
{t('noAccount')}{' '}
|
||||
<Link
|
||||
href={config.routes.register}
|
||||
className="text-primary underline-offset-4 hover:underline font-medium"
|
||||
>
|
||||
Sign up
|
||||
{t('registerLink')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -22,23 +23,24 @@ import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/erro
|
||||
// Validation Schema
|
||||
// ============================================================================
|
||||
|
||||
const resetConfirmSchema = z
|
||||
.object({
|
||||
token: z.string().min(1, 'Reset token is required'),
|
||||
new_password: z
|
||||
.string()
|
||||
.min(1, 'New password is required')
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter'),
|
||||
confirm_password: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.new_password === data.confirm_password, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirm_password'],
|
||||
});
|
||||
const createResetConfirmSchema = (t: (key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
token: z.string().min(1, t('tokenRequired')),
|
||||
new_password: z
|
||||
.string()
|
||||
.min(1, t('passwordRequired'))
|
||||
.min(8, t('passwordMinLength'))
|
||||
.regex(/[0-9]/, t('passwordNumber'))
|
||||
.regex(/[A-Z]/, t('passwordUppercase')),
|
||||
confirm_password: z.string().min(1, t('confirmPasswordRequired')),
|
||||
})
|
||||
.refine((data) => data.new_password === data.confirm_password, {
|
||||
message: t('passwordMismatch'),
|
||||
path: ['confirm_password'],
|
||||
});
|
||||
|
||||
type ResetConfirmFormData = z.infer<typeof resetConfirmSchema>;
|
||||
type ResetConfirmFormData = z.infer<ReturnType<typeof createResetConfirmSchema>>;
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
@@ -104,10 +106,13 @@ export function PasswordResetConfirmForm({
|
||||
showLoginLink = true,
|
||||
className,
|
||||
}: PasswordResetConfirmFormProps) {
|
||||
const t = useTranslations('auth.passwordResetConfirm');
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const resetMutation = usePasswordResetConfirm();
|
||||
|
||||
const resetConfirmSchema = createResetConfirmSchema((key: string) => t(key));
|
||||
|
||||
const form = useForm<ResetConfirmFormData>({
|
||||
resolver: zodResolver(resetConfirmSchema),
|
||||
defaultValues: {
|
||||
@@ -134,9 +139,7 @@ export function PasswordResetConfirmForm({
|
||||
});
|
||||
|
||||
// Show success message
|
||||
setSuccessMessage(
|
||||
'Your password has been successfully reset. You can now log in with your new password.'
|
||||
);
|
||||
setSuccessMessage(t('success'));
|
||||
|
||||
// Reset form
|
||||
form.reset({ token, new_password: '', confirm_password: '' });
|
||||
@@ -161,7 +164,7 @@ export function PasswordResetConfirmForm({
|
||||
});
|
||||
} else {
|
||||
// Unexpected error format
|
||||
setServerError('An unexpected error occurred. Please try again.');
|
||||
setServerError(t('unexpectedError'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -186,9 +189,7 @@ export function PasswordResetConfirmForm({
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your new password below. Make sure it meets all security requirements.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('instructions')}</p>
|
||||
|
||||
{/* Hidden Token Field (for form submission) */}
|
||||
<input type="hidden" {...form.register('token')} />
|
||||
@@ -196,12 +197,12 @@ export function PasswordResetConfirmForm({
|
||||
{/* New Password Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new_password">
|
||||
New Password <span className="text-destructive">*</span>
|
||||
{t('newPasswordLabel')} <span className="text-destructive">{t('required')}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="new_password"
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
placeholder={t('newPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
disabled={isSubmitting}
|
||||
{...form.register('new_password')}
|
||||
@@ -240,7 +241,7 @@ export function PasswordResetConfirmForm({
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{passwordStrength.hasMinLength ? '✓' : '○'} At least 8 characters
|
||||
{passwordStrength.hasMinLength ? '✓' : '○'} {t('passwordRequirements.minLength')}
|
||||
</li>
|
||||
<li
|
||||
className={
|
||||
@@ -249,7 +250,7 @@ export function PasswordResetConfirmForm({
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{passwordStrength.hasNumber ? '✓' : '○'} Contains a number
|
||||
{passwordStrength.hasNumber ? '✓' : '○'} {t('passwordRequirements.hasNumber')}
|
||||
</li>
|
||||
<li
|
||||
className={
|
||||
@@ -258,7 +259,8 @@ export function PasswordResetConfirmForm({
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{passwordStrength.hasUppercase ? '✓' : '○'} Contains an uppercase letter
|
||||
{passwordStrength.hasUppercase ? '✓' : '○'}{' '}
|
||||
{t('passwordRequirements.hasUppercase')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -268,12 +270,12 @@ export function PasswordResetConfirmForm({
|
||||
{/* Confirm Password Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm_password">
|
||||
Confirm Password <span className="text-destructive">*</span>
|
||||
{t('confirmPasswordLabel')} <span className="text-destructive">{t('required')}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type="password"
|
||||
placeholder="Re-enter new password"
|
||||
placeholder={t('confirmPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
disabled={isSubmitting}
|
||||
{...form.register('confirm_password')}
|
||||
@@ -292,18 +294,18 @@ export function PasswordResetConfirmForm({
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Resetting Password...' : 'Reset Password'}
|
||||
{isSubmitting ? t('resetButtonLoading') : t('resetButton')}
|
||||
</Button>
|
||||
|
||||
{/* Login Link */}
|
||||
{showLoginLink && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Remember your password?{' '}
|
||||
{t('rememberPassword')}{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary underline-offset-4 hover:underline font-medium"
|
||||
>
|
||||
Back to login
|
||||
{t('backToLogin')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -22,11 +23,12 @@ import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/erro
|
||||
// Validation Schema
|
||||
// ============================================================================
|
||||
|
||||
const resetRequestSchema = z.object({
|
||||
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
|
||||
});
|
||||
const createResetRequestSchema = (t: (key: string) => string) =>
|
||||
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
|
||||
@@ -64,10 +66,19 @@ export function PasswordResetRequestForm({
|
||||
showLoginLink = true,
|
||||
className,
|
||||
}: PasswordResetRequestFormProps) {
|
||||
const t = useTranslations('auth.passwordReset');
|
||||
const tValidation = useTranslations('validation');
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const resetMutation = usePasswordResetRequest();
|
||||
|
||||
const resetRequestSchema = createResetRequestSchema((key: string) => {
|
||||
if (key.startsWith('validation.')) {
|
||||
return tValidation(key.replace('validation.', ''));
|
||||
}
|
||||
return t(key);
|
||||
});
|
||||
|
||||
const form = useForm<ResetRequestFormData>({
|
||||
resolver: zodResolver(resetRequestSchema),
|
||||
defaultValues: {
|
||||
@@ -86,9 +97,7 @@ export function PasswordResetRequestForm({
|
||||
await resetMutation.mutateAsync({ email: data.email });
|
||||
|
||||
// Show success message
|
||||
setSuccessMessage(
|
||||
'Password reset instructions have been sent to your email address. Please check your inbox.'
|
||||
);
|
||||
setSuccessMessage(t('success'));
|
||||
|
||||
// Reset form
|
||||
form.reset();
|
||||
@@ -113,7 +122,7 @@ export function PasswordResetRequestForm({
|
||||
});
|
||||
} else {
|
||||
// Unexpected error format
|
||||
setServerError('An unexpected error occurred. Please try again.');
|
||||
setServerError(t('unexpectedError'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -138,19 +147,17 @@ export function PasswordResetRequestForm({
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your email address and we'll send you instructions to reset your password.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('instructions')}</p>
|
||||
|
||||
{/* Email Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
Email <span className="text-destructive">*</span>
|
||||
{t('emailLabel')} <span className="text-destructive">{t('required')}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
autoComplete="email"
|
||||
disabled={isSubmitting}
|
||||
{...form.register('email')}
|
||||
@@ -167,18 +174,18 @@ export function PasswordResetRequestForm({
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Sending...' : 'Send Reset Instructions'}
|
||||
{isSubmitting ? t('sendButtonLoading') : t('sendButton')}
|
||||
</Button>
|
||||
|
||||
{/* Login Link */}
|
||||
{showLoginLink && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Remember your password?{' '}
|
||||
{t('rememberPassword')}{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary underline-offset-4 hover:underline font-medium"
|
||||
>
|
||||
Back to login
|
||||
{t('backToLogin')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -23,33 +24,34 @@ import config from '@/config/app.config';
|
||||
// Validation Schema
|
||||
// ============================================================================
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
|
||||
first_name: z
|
||||
.string()
|
||||
.min(1, 'First name is required')
|
||||
.min(2, 'First name must be at least 2 characters')
|
||||
.max(50, 'First name must not exceed 50 characters'),
|
||||
last_name: z
|
||||
.string()
|
||||
.max(50, 'Last name must not exceed 50 characters')
|
||||
.optional()
|
||||
.or(z.literal('')), // Allow empty string
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Password is required')
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
const createRegisterSchema = (t: (key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
email: z.string().min(1, t('validation.required')).email(t('validation.email')),
|
||||
first_name: z
|
||||
.string()
|
||||
.min(1, t('firstNameRequired'))
|
||||
.min(2, t('firstNameMinLength'))
|
||||
.max(50, t('firstNameMaxLength')),
|
||||
last_name: z
|
||||
.string()
|
||||
.max(50, t('lastNameMaxLength'))
|
||||
.optional()
|
||||
.or(z.literal('')), // Allow empty string
|
||||
password: z
|
||||
.string()
|
||||
.min(1, t('passwordRequired'))
|
||||
.min(8, t('passwordMinLength'))
|
||||
.regex(/[0-9]/, t('passwordNumber'))
|
||||
.regex(/[A-Z]/, t('passwordUppercase')),
|
||||
confirmPassword: z.string().min(1, t('confirmPasswordRequired')),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: t('passwordMismatch'),
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type RegisterFormData = z.infer<typeof registerSchema>;
|
||||
type RegisterFormData = z.infer<ReturnType<typeof createRegisterSchema>>;
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
@@ -84,9 +86,18 @@ interface 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 registerMutation = useRegister();
|
||||
|
||||
const registerSchema = createRegisterSchema((key: string) => {
|
||||
if (key.startsWith('validation.')) {
|
||||
return tValidation(key.replace('validation.', ''));
|
||||
}
|
||||
return t(key);
|
||||
});
|
||||
|
||||
const form = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
mode: 'onBlur',
|
||||
@@ -133,7 +144,7 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
||||
});
|
||||
} else {
|
||||
// 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 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">
|
||||
First Name <span className="text-destructive">*</span>
|
||||
{t('firstNameLabel')} <span className="text-destructive">{t('required')}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
type="text"
|
||||
placeholder="John"
|
||||
placeholder={t('firstNamePlaceholder')}
|
||||
autoComplete="given-name"
|
||||
disabled={isSubmitting}
|
||||
{...form.register('first_name')}
|
||||
@@ -180,11 +191,11 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
||||
|
||||
{/* Last Name Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">Last Name</Label>
|
||||
<Label htmlFor="last_name">{t('lastNameLabel')}</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
type="text"
|
||||
placeholder="Doe (optional)"
|
||||
placeholder={t('lastNamePlaceholder')}
|
||||
autoComplete="family-name"
|
||||
disabled={isSubmitting}
|
||||
{...form.register('last_name')}
|
||||
@@ -201,12 +212,12 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
||||
{/* Email Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
Email <span className="text-destructive">*</span>
|
||||
{t('emailLabel')} <span className="text-destructive">{t('required')}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
autoComplete="email"
|
||||
disabled={isSubmitting}
|
||||
{...form.register('email')}
|
||||
@@ -223,12 +234,12 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
||||
{/* Password Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Password <span className="text-destructive">*</span>
|
||||
{t('passwordLabel')} <span className="text-destructive">{t('required')}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Create a strong password"
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
disabled={isSubmitting}
|
||||
{...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 ? '✓' : '○'} At least 8 characters
|
||||
{hasMinLength ? '✓' : '○'} {t('passwordRequirements.minLength')}
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
hasNumber ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{hasNumber ? '✓' : '○'} Contains a number
|
||||
{hasNumber ? '✓' : '○'} {t('passwordRequirements.hasNumber')}
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
hasUppercase ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{hasUppercase ? '✓' : '○'} Contains an uppercase letter
|
||||
{hasUppercase ? '✓' : '○'} {t('passwordRequirements.hasUppercase')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -276,12 +287,12 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
||||
{/* Confirm Password Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
Confirm Password <span className="text-destructive">*</span>
|
||||
{t('confirmPasswordLabel')} <span className="text-destructive">{t('required')}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm your password"
|
||||
placeholder={t('confirmPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
disabled={isSubmitting}
|
||||
{...form.register('confirmPassword')}
|
||||
@@ -299,18 +310,18 @@ export function RegisterForm({ onSuccess, showLoginLink = true, className }: Reg
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating account...' : 'Create account'}
|
||||
{isSubmitting ? t('registerButtonLoading') : t('registerButton')}
|
||||
</Button>
|
||||
|
||||
{/* Login Link */}
|
||||
{showLoginLink && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
{t('hasAccount')}{' '}
|
||||
<Link
|
||||
href={config.routes.login}
|
||||
className="text-primary underline-offset-4 hover:underline font-medium"
|
||||
>
|
||||
Sign in
|
||||
{t('loginLink')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -8,9 +8,19 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Code2, Palette, LayoutDashboard, Box, FileText, BookOpen, Home, ArrowLeft, Rocket } from 'lucide-react';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { usePathname } from '@/lib/i18n/routing';
|
||||
import {
|
||||
Code2,
|
||||
Palette,
|
||||
LayoutDashboard,
|
||||
Box,
|
||||
FileText,
|
||||
BookOpen,
|
||||
Home,
|
||||
ArrowLeft,
|
||||
Rocket,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ThemeToggle } from '@/components/theme';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useEffect, useState, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Terminal, Play } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
|
||||
const commands = [
|
||||
{ text: '# Clone the repository', delay: 0 },
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Github, Star, Play, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Play, Layers, ShieldCheck, UserCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -120,9 +120,7 @@ export function DemoSection() {
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
>
|
||||
<Button asChild variant="outline" size="lg" className="gap-2">
|
||||
<Link href="/demos">
|
||||
View Complete Demo Tour →
|
||||
</Link>
|
||||
<Link href="/demos">View Complete Demo Tour →</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight, LucideIcon } from 'lucide-react';
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { Menu, X, Github, Star } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { LocaleSwitcher } from '@/components/i18n';
|
||||
|
||||
interface HeaderProps {
|
||||
onOpenDemoModal: () => void;
|
||||
@@ -63,6 +64,9 @@ export function Header({ onOpenDemoModal }: HeaderProps) {
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Locale Switcher */}
|
||||
<LocaleSwitcher />
|
||||
|
||||
{/* CTAs */}
|
||||
<Button onClick={onOpenDemoModal} variant="default" size="sm">
|
||||
Try Demo
|
||||
@@ -113,6 +117,11 @@ export function Header({ onOpenDemoModal }: HeaderProps) {
|
||||
</a>
|
||||
|
||||
<div className="border-t pt-4 mt-4 space-y-3">
|
||||
{/* Locale Switcher */}
|
||||
<div className="flex justify-center">
|
||||
<LocaleSwitcher />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight, Github, Play } from 'lucide-react';
|
||||
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';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
|
||||
export function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { usePathname } from '@/lib/i18n/routing';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useLogout } from '@/lib/api/hooks/useAuth';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -23,6 +24,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Settings, LogOut, User, Shield } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ThemeToggle } from '@/components/theme';
|
||||
import { LocaleSwitcher } from '@/components/i18n';
|
||||
|
||||
/**
|
||||
* Get user initials for avatar
|
||||
@@ -67,6 +69,7 @@ function NavLink({
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const t = useTranslations('navigation');
|
||||
const { user } = useAuth();
|
||||
const { mutate: logout, isPending: isLoggingOut } = useLogout();
|
||||
|
||||
@@ -86,15 +89,16 @@ export function Header() {
|
||||
{/* Navigation Links */}
|
||||
<nav className="hidden md:flex items-center space-x-1">
|
||||
<NavLink href="/" exact>
|
||||
Home
|
||||
{t('home')}
|
||||
</NavLink>
|
||||
{user?.is_superuser && <NavLink href="/admin">Admin</NavLink>}
|
||||
{user?.is_superuser && <NavLink href="/admin">{t('admin')}</NavLink>}
|
||||
</nav>
|
||||
</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">
|
||||
<ThemeToggle />
|
||||
<LocaleSwitcher />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||
@@ -118,20 +122,20 @@ export function Header() {
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/profile" className="cursor-pointer">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
{t('profile')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/password" className="cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
{t('settings')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{user?.is_superuser && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
Admin Panel
|
||||
{t('adminPanel')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
@@ -142,7 +146,7 @@ export function Header() {
|
||||
disabled={isLoggingOut}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{isLoggingOut ? 'Logging out...' : 'Log out'}
|
||||
{isLoggingOut ? t('loggingOut') : t('logout')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert } from '@/components/ui/alert';
|
||||
@@ -22,25 +23,26 @@ import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/erro
|
||||
// Validation Schema
|
||||
// ============================================================================
|
||||
|
||||
const passwordChangeSchema = z
|
||||
.object({
|
||||
current_password: z.string().min(1, 'Current password is required'),
|
||||
new_password: z
|
||||
.string()
|
||||
.min(1, 'New password is required')
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
|
||||
confirm_password: z.string().min(1, 'Please confirm your new password'),
|
||||
})
|
||||
.refine((data) => data.new_password === data.confirm_password, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirm_password'],
|
||||
});
|
||||
const createPasswordChangeSchema = (t: (key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
current_password: z.string().min(1, t('currentPasswordRequired')),
|
||||
new_password: z
|
||||
.string()
|
||||
.min(1, t('newPasswordRequired'))
|
||||
.min(8, t('newPasswordMinLength'))
|
||||
.regex(/[0-9]/, t('newPasswordNumber'))
|
||||
.regex(/[A-Z]/, t('newPasswordUppercase'))
|
||||
.regex(/[a-z]/, t('newPasswordLowercase'))
|
||||
.regex(/[^A-Za-z0-9]/, t('newPasswordSpecial')),
|
||||
confirm_password: z.string().min(1, t('confirmPasswordRequired')),
|
||||
})
|
||||
.refine((data) => data.new_password === data.confirm_password, {
|
||||
message: t('passwordMismatch'),
|
||||
path: ['confirm_password'],
|
||||
});
|
||||
|
||||
type PasswordChangeFormData = z.infer<typeof passwordChangeSchema>;
|
||||
type PasswordChangeFormData = z.infer<ReturnType<typeof createPasswordChangeSchema>>;
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
@@ -72,6 +74,7 @@ interface PasswordChangeFormProps {
|
||||
* ```
|
||||
*/
|
||||
export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormProps) {
|
||||
const t = useTranslations('settings.password');
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const passwordChangeMutation = usePasswordChange((message) => {
|
||||
toast.success(message);
|
||||
@@ -79,6 +82,8 @@ export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormP
|
||||
onSuccess?.();
|
||||
});
|
||||
|
||||
const passwordChangeSchema = createPasswordChangeSchema((key: string) => t(key));
|
||||
|
||||
const form = useForm<PasswordChangeFormData>({
|
||||
resolver: zodResolver(passwordChangeSchema),
|
||||
defaultValues: {
|
||||
@@ -122,7 +127,7 @@ export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormP
|
||||
});
|
||||
} else {
|
||||
// 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 (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Change Password</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure. Make sure it's strong and unique.
|
||||
</CardDescription>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
@@ -149,9 +152,9 @@ export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormP
|
||||
|
||||
{/* Current Password Field */}
|
||||
<FormField
|
||||
label="Current Password"
|
||||
label={t('currentPasswordLabel')}
|
||||
type="password"
|
||||
placeholder="Enter your current password"
|
||||
placeholder={t('currentPasswordPlaceholder')}
|
||||
autoComplete="current-password"
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
@@ -161,22 +164,22 @@ export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormP
|
||||
|
||||
{/* New Password Field */}
|
||||
<FormField
|
||||
label="New Password"
|
||||
label={t('newPasswordLabel')}
|
||||
type="password"
|
||||
placeholder="Enter your new password"
|
||||
placeholder={t('newPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
description="At least 8 characters with uppercase, lowercase, number, and special character"
|
||||
description={t('newPasswordDescription')}
|
||||
error={form.formState.errors.new_password}
|
||||
{...form.register('new_password')}
|
||||
/>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<FormField
|
||||
label="Confirm New Password"
|
||||
label={t('confirmPasswordLabel')}
|
||||
type="password"
|
||||
placeholder="Confirm your new password"
|
||||
placeholder={t('confirmPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
@@ -187,12 +190,12 @@ export function PasswordChangeForm({ onSuccess, className }: PasswordChangeFormP
|
||||
{/* Submit Button */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="submit" disabled={isSubmitting || !isDirty}>
|
||||
{isSubmitting ? 'Changing Password...' : 'Change Password'}
|
||||
{isSubmitting ? t('updateButtonLoading') : t('updateButton')}
|
||||
</Button>
|
||||
{/* istanbul ignore next - Cancel button requires isDirty state, tested in E2E */}
|
||||
{isDirty && !isSubmitting && (
|
||||
<Button type="button" variant="outline" onClick={() => form.reset()}>
|
||||
Cancel
|
||||
{t('cancelButton')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert } from '@/components/ui/alert';
|
||||
@@ -23,21 +24,18 @@ import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/erro
|
||||
// Validation Schema
|
||||
// ============================================================================
|
||||
|
||||
const profileSchema = z.object({
|
||||
first_name: z
|
||||
.string()
|
||||
.min(1, 'First name is required')
|
||||
.min(2, 'First name must be at least 2 characters')
|
||||
.max(50, 'First name must not exceed 50 characters'),
|
||||
last_name: z
|
||||
.string()
|
||||
.max(50, 'Last name must not exceed 50 characters')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
email: z.string().email('Invalid email address'),
|
||||
});
|
||||
const createProfileSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
first_name: z
|
||||
.string()
|
||||
.min(1, t('firstNameRequired'))
|
||||
.min(2, t('firstNameMinLength'))
|
||||
.max(50, t('firstNameMaxLength')),
|
||||
last_name: z.string().max(50, t('lastNameMaxLength')).optional().or(z.literal('')),
|
||||
email: z.string().email(t('emailInvalid')),
|
||||
});
|
||||
|
||||
type ProfileFormData = z.infer<typeof profileSchema>;
|
||||
type ProfileFormData = z.infer<ReturnType<typeof createProfileSchema>>;
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
@@ -67,6 +65,7 @@ interface ProfileSettingsFormProps {
|
||||
* ```
|
||||
*/
|
||||
export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFormProps) {
|
||||
const t = useTranslations('settings.profile');
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const currentUser = useCurrentUser();
|
||||
const updateProfileMutation = useUpdateProfile((message) => {
|
||||
@@ -74,6 +73,8 @@ export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFor
|
||||
onSuccess?.();
|
||||
});
|
||||
|
||||
const profileSchema = createProfileSchema((key: string) => t(key));
|
||||
|
||||
const form = useForm<ProfileFormData>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
@@ -135,7 +136,7 @@ export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFor
|
||||
});
|
||||
} else {
|
||||
// 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 (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Information</CardTitle>
|
||||
<CardDescription>
|
||||
Update your personal information. Your email address is read-only.
|
||||
</CardDescription>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
@@ -162,9 +161,9 @@ export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFor
|
||||
|
||||
{/* First Name Field */}
|
||||
<FormField
|
||||
label="First Name"
|
||||
label={t('firstNameLabel')}
|
||||
type="text"
|
||||
placeholder="John"
|
||||
placeholder={t('firstNamePlaceholder')}
|
||||
autoComplete="given-name"
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
@@ -174,9 +173,9 @@ export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFor
|
||||
|
||||
{/* Last Name Field */}
|
||||
<FormField
|
||||
label="Last Name"
|
||||
label={t('lastNameLabel')}
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
placeholder={t('lastNamePlaceholder')}
|
||||
autoComplete="family-name"
|
||||
disabled={isSubmitting}
|
||||
error={form.formState.errors.last_name}
|
||||
@@ -185,11 +184,11 @@ export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFor
|
||||
|
||||
{/* Email Field (Read-only) */}
|
||||
<FormField
|
||||
label="Email"
|
||||
label={t('emailLabel')}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
disabled
|
||||
description="Your email address cannot be changed from this form"
|
||||
description={t('emailDescription')}
|
||||
error={form.formState.errors.email}
|
||||
{...form.register('email')}
|
||||
/>
|
||||
@@ -197,12 +196,12 @@ export function ProfileSettingsForm({ onSuccess, className }: ProfileSettingsFor
|
||||
{/* Submit Button */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="submit" disabled={isSubmitting || !isDirty}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
{isSubmitting ? t('updateButtonLoading') : t('updateButton')}
|
||||
</Button>
|
||||
{/* istanbul ignore next - Reset button requires isDirty state, tested in E2E */}
|
||||
{isDirty && !isSubmitting && (
|
||||
<Button type="button" variant="outline" onClick={() => form.reset()}>
|
||||
Reset
|
||||
{t('resetButton')}
|
||||
</Button>
|
||||
)}
|
||||
</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 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) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Block access to /dev routes in production
|
||||
if (pathname.startsWith('/dev')) {
|
||||
// Block access to /dev routes in production (handles both /dev and /[locale]/dev)
|
||||
// Match: /dev, /en/dev, /it/dev, etc.
|
||||
if (pathname === '/dev' || pathname.match(/^\/[a-z]{2}\/dev($|\/)/)) {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
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 = {
|
||||
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 LoginPage from '@/app/(auth)/login/page';
|
||||
import LoginPage from '@/app/[locale]/(auth)/login/page';
|
||||
|
||||
// Mock dynamic import
|
||||
jest.mock('next/dynamic', () => ({
|
||||
|
||||
@@ -4,20 +4,20 @@
|
||||
*/
|
||||
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import PasswordResetConfirmContent from '@/app/(auth)/password-reset/confirm/PasswordResetConfirmContent';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useRouter } from '@/lib/i18n/routing';
|
||||
import PasswordResetConfirmContent from '@/app/[locale]/(auth)/password-reset/confirm/PasswordResetConfirmContent';
|
||||
|
||||
// Mock Next.js navigation
|
||||
jest.mock('next/navigation', () => ({
|
||||
useSearchParams: jest.fn(),
|
||||
useRouter: jest.fn(),
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock Next.js Link
|
||||
jest.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
// Mock i18n routing
|
||||
jest.mock('@/lib/i18n/routing', () => ({
|
||||
useRouter: jest.fn(),
|
||||
Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
*/
|
||||
|
||||
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
|
||||
jest.mock('@/app/(auth)/password-reset/confirm/PasswordResetConfirmContent', () => ({
|
||||
jest.mock('@/app/[locale]/(auth)/password-reset/confirm/PasswordResetConfirmContent', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="password-reset-confirm-content">Content</div>,
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
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
|
||||
jest.mock('next/dynamic', () => ({
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
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
|
||||
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