Compare commits

...

9 Commits

Author SHA1 Message Date
Felipe Cardoso
7aa63d79df Implement extensive localization improvements across forms and components
- Refactored `it.json` translations with added keys for authentication, admin panel, and settings.
- Updated authentication forms (`LoginForm`, `RegisterForm`, `PasswordResetConfirmForm`) to use localized strings via `next-intl`.
- Enhanced password validation schemas with dynamic translations and refined error messages.
- Adjusted `Header` and related components to include localized navigation and status elements.
- Improved placeholder hints, button labels, and inline validation messages for seamless localization.
2025-11-19 03:02:13 +01:00
Felipe Cardoso
333c9c40af Add locale switcher component and integrate internationalization improvements
- Introduced `LocaleSwitcher` component for language selection with support for locale-aware dropdown and ARIA accessibility.
- Updated layouts (`Header`, `Breadcrumbs`, `Home`) to include the new locale switcher.
- Expanded localization files (`en.json`, `it.json`) with new keys for language switching.
- Adjusted i18n configuration to enhance routing and message imports.
- Updated Jest module mappings to mock new i18n components and utilities.
2025-11-19 01:31:51 +01:00
Felipe Cardoso
0b192ce030 Update e2e tests and mocks for locale-based routing
- Adjusted assertions and navigation tests to include `/en` locale prefix for consistency.
- Updated next-intl and components-i18n mocks to support locale handling in tests.
- Renamed "Components" link and related references to "Design System" in homepage tests.
- Disabled typing delay in debounce test for improved test reliability.
2025-11-19 01:31:35 +01:00
Felipe Cardoso
da021d0640 Update tests and e2e files to support locale-based routing
- Replaced static paths with dynamic locale subpaths (`/[locale]/*`) in imports, URLs, and assertions across tests.
- Updated `next-intl` mocks for improved compatibility with `locale`-aware components.
- Standardized `page.goto` and navigation tests with `/en` as the base locale for consistency.
2025-11-18 23:26:10 +01:00
Felipe Cardoso
d1b47006f4 Remove all obsolete authentication, settings, admin, and demo-related components and pages
- Eliminated redundant components, pages, and layouts related to authentication (`login`, `register`, `password-reset`, etc.), user settings, admin, and demos.
- Simplified the frontend structure by removing unused dynamic imports, forms, and test code.
- Refactored configurations and metadata imports to exclude references to removed features.
- Streamlined the project for future development and improved maintainability by discarding legacy and unused code.
2025-11-18 12:41:57 +01:00
Felipe Cardoso
a73d3c7d3e Refactor multiline formatting, link definitions, and code consistency across components and tests
- Improved readability by updating multiline statements and object definitions.
- Applied consistent link and button wrapping in `DemoSection` and other components.
- Enhanced test assertions and helper functions with uniform formatting and parentheses usage.
2025-11-18 07:25:23 +01:00
Felipe Cardoso
55ae92c460 Refactor i18n setup and improve structure for maintainability
- Relocated `i18n` configuration files to `src/lib/i18n` for better organization.
- Removed obsolete `request.ts` and `routing.ts` files, simplifying `i18n` setup within the project.
- Added extensive tests for `i18n/utils` to validate locale-related utilities, including locale name, native name, and flag retrieval.
- Introduced a detailed `I18N_IMPLEMENTATION_PLAN.md` to document implementation phases, decisions, and recommendations for future extensions.
- Enhanced TypeScript definitions and modularity across i18n utilities for improved developer experience.
2025-11-18 07:23:54 +01:00
Felipe Cardoso
fe6a98c379 Add internationalization (i18n) with next-intl and Italian translations
- Integrated `next-intl` for server-side and client-side i18n support.
- Added English (`en.json`) and Italian (`it.json`) localization files.
- Configured routing with locale-based subdirectories (`/[locale]/path`) using `next-intl`.
- Introduced type-safe i18n utilities and TypeScript definitions for translation keys.
- Updated middleware to handle locale detection and routing.
- Implemented dynamic translation loading to reduce bundle size.
- Enhanced developer experience with auto-complete and compile-time validation for i18n keys.
2025-11-17 20:27:09 +01:00
Felipe Cardoso
b7c1191335 Refactor locale validation and update style consistency across schemas, tests, and migrations
- Replaced `SUPPORTED_LOCALES` with `supported_locales` for naming consistency.
- Applied formatting improvements to multiline statements for better readability.
- Cleaned up redundant comments and streamlined test assertions.
2025-11-17 20:04:03 +01:00
122 changed files with 2790 additions and 751 deletions

View File

@@ -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(

View File

@@ -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", "")

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View 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.

View File

@@ -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');
});
});

View File

@@ -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 }) => {

View File

@@ -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(),
]);

View File

@@ -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 }) => {

View File

@@ -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 }) => {

View File

@@ -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(() => {

View File

@@ -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');
});

View File

@@ -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!');

View File

@@ -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');
});

View File

@@ -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);

View File

@@ -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 }) => {

View File

@@ -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();

View File

@@ -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' });

View File

@@ -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);

View File

@@ -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(() => {

View File

@@ -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
View 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
View 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"
}
}

View File

@@ -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);

View File

@@ -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",

View File

@@ -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",

View File

@@ -1,10 +0,0 @@
/**
* Settings Index Page
* Redirects to /settings/profile
*/
import { redirect } from 'next/navigation';
export default function SettingsPage() {
redirect('/settings/profile');
}

View File

@@ -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(

View File

@@ -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';

View 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`);
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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,

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>

View File

@@ -4,7 +4,7 @@
* Access: /dev/docs
*/
import Link from 'next/link';
import { Link } from '@/lib/i18n/routing';
import {
BookOpen,
Sparkles,

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 = [

View File

@@ -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';

View File

@@ -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';

View 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>
);
}

View 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>
);
}

View File

@@ -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
View 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');
}

View File

@@ -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,

View File

@@ -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[] = [];

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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&apos;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>
)}

View File

@@ -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>
)}

View File

@@ -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&apos;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>
)}

View File

@@ -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>
)}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 },

View File

@@ -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';

View File

@@ -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,

View File

@@ -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>

View File

@@ -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';

View File

@@ -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);

View File

@@ -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';

View 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>
);
}

View File

@@ -0,0 +1,6 @@
/**
* i18n Components
* Exports internationalization-related components
*/
export { LocaleSwitcher } from './LocaleSwitcher';

View File

@@ -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();

View File

@@ -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>

View File

@@ -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&apos;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>

View File

@@ -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>

View 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(),
};
});

View 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];

View 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`;
}
}

View File

@@ -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
View 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 {}
}

View File

@@ -0,0 +1,5 @@
/**
* Mock for @/components/i18n
*/
export const LocaleSwitcher = () => <div data-testid="locale-switcher">EN</div>;

View 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,
});

View 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(),
}),
});

View 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');

View File

@@ -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', () => ({

View File

@@ -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>
),
}));

View File

@@ -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>,
}));

View File

@@ -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', () => ({

View File

@@ -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