redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
@@ -0,0 +1,26 @@
|
||||
# Navbar Restoration Notes v1.1
|
||||
|
||||
## Correction
|
||||
|
||||
The RentalDriveGo application shell is preserved independently from the modular homepage sections.
|
||||
|
||||
## Navbar behavior
|
||||
|
||||
- Product, Workflow, Modules, Pricing, and FAQ remain visible in the desktop navbar.
|
||||
- The full navbar stays visible through common laptop and tablet-landscape widths, including 1024 px.
|
||||
- Below 900 px, the same five destinations remain available in the mobile navigation drawer.
|
||||
- Language, theme, login, and account-creation controls are preserved.
|
||||
- Homepage section IDs remain aligned with navbar anchors.
|
||||
|
||||
## Regression protection
|
||||
|
||||
Browser specifications now assert that all primary destinations are visible at 1024 px and remain present in the mobile drawer.
|
||||
|
||||
## Validation
|
||||
|
||||
- TypeScript: passed
|
||||
- ESLint: passed
|
||||
- Unit tests: 51 passed
|
||||
- Production build: passed with an explicit test `SITE_ORIGIN`
|
||||
- Server-rendered `/en` markup: confirmed all five navbar links
|
||||
- Browser execution requires Playwright browser binaries in the target CI environment
|
||||
@@ -0,0 +1,322 @@
|
||||
# RentalDriveGo Pricing Section and Homepage Modularization Plan
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** June 26, 2026
|
||||
**Product:** RentalDriveGo
|
||||
**Scope:** Localized public homepage for English, French, and Arabic
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Refactor the homepage into independently maintainable sections and replace the existing quote-only pricing placeholder with a hybrid pricing section that can support transparent starting prices for standard plans and custom pricing for enterprise accounts.
|
||||
|
||||
The implementation must let a developer update one section without editing the primary page composition or accidentally changing unrelated sections.
|
||||
|
||||
## 2. Commercial decision
|
||||
|
||||
RentalDriveGo should use a hybrid model:
|
||||
|
||||
- Public starting prices for standard small and mid-market plans after finance approval.
|
||||
- A fleet-size selector to help visitors self-qualify.
|
||||
- Monthly and annual billing presentation.
|
||||
- Custom pricing for large fleets, franchises, migrations, and complex integrations.
|
||||
- A sales or demonstration CTA for every plan.
|
||||
|
||||
Prices must not be invented in presentation components. All commercial amounts and approval state live in one configuration file.
|
||||
|
||||
## 3. Homepage architecture
|
||||
|
||||
The route component remains a thin composition layer:
|
||||
|
||||
`src/app/[locale]/page.tsx`
|
||||
|
||||
It renders these independent sections in order:
|
||||
|
||||
1. `HeroSection`
|
||||
2. `TrustSection`
|
||||
3. `ComparisonSection`
|
||||
4. `WorkflowSection`
|
||||
5. `RolesSection`
|
||||
6. `ModulesSection`
|
||||
7. `ResultsSection`
|
||||
8. `IntegrationsSection`
|
||||
9. `SecuritySection`
|
||||
10. `PricingSection`
|
||||
11. `FaqSection`
|
||||
12. `FinalCtaSection`
|
||||
|
||||
Each section lives in:
|
||||
|
||||
`src/components/homepage/sections/`
|
||||
|
||||
Section-specific CSS stays beside the section in its own CSS module. Shared tokens and generic components remain in the existing design system.
|
||||
|
||||
## 3.1 Application shell preservation
|
||||
|
||||
Homepage modularization must not remove, replace, or weaken the shared application shell. The following elements remain independent of homepage sections and must be preserved across future section updates:
|
||||
|
||||
- RentalDriveGo brand link
|
||||
- Product, Workflow, Modules, Pricing, and FAQ navigation destinations
|
||||
- English, French, and Arabic locale controls
|
||||
- Light, dark, and system theme controls
|
||||
- Log in and account-creation actions
|
||||
- Mobile navigation drawer containing the same primary destinations
|
||||
- Shared footer navigation
|
||||
|
||||
The full primary navigation remains visible at common laptop widths. At narrower widths, the same destinations move into the mobile drawer rather than being removed.
|
||||
|
||||
## 4. Pricing section structure
|
||||
|
||||
### 4.1 Header
|
||||
|
||||
- Eyebrow: simple, scalable pricing.
|
||||
- Headline: pricing built around the fleet.
|
||||
- Supporting copy: explains that fleet size drives plan recommendations and that public values are controlled centrally.
|
||||
|
||||
### 4.2 Fleet-size selector
|
||||
|
||||
Four radio-button ranges:
|
||||
|
||||
- 1–25 vehicles
|
||||
- 26–75 vehicles
|
||||
- 76–200 vehicles
|
||||
- 200+ vehicles
|
||||
|
||||
The selected range changes the recommended plan and, after commercial approval, the displayed starting price.
|
||||
|
||||
### 4.3 Billing selector
|
||||
|
||||
- Monthly
|
||||
- Annual
|
||||
- Annual savings label only after the discount is financially approved
|
||||
|
||||
Annual pricing is shown as a monthly equivalent and must be calculated from the approved annual discount in the commercial configuration.
|
||||
|
||||
### 4.4 Plan cards
|
||||
|
||||
#### Launch
|
||||
|
||||
For independent rental companies moving away from fragmented manual processes.
|
||||
|
||||
Core capabilities:
|
||||
|
||||
- Reservations and availability
|
||||
- Fleet and vehicle records
|
||||
- Rental agreements
|
||||
- Customer records
|
||||
- Basic operational reporting
|
||||
- Standard support
|
||||
|
||||
#### Growth
|
||||
|
||||
For growing fleets coordinating more vehicles, users, and locations.
|
||||
|
||||
Core capabilities:
|
||||
|
||||
- Everything in Launch
|
||||
- Multiple locations
|
||||
- Online booking workflows
|
||||
- Digital signatures and deposits
|
||||
- Maintenance workflows
|
||||
- Advanced reporting and automation
|
||||
- Priority support
|
||||
|
||||
#### Enterprise
|
||||
|
||||
For large fleets, franchise operations, and complex integration requirements.
|
||||
|
||||
Core capabilities:
|
||||
|
||||
- Everything in Growth
|
||||
- Custom integrations and API access
|
||||
- Advanced permissions
|
||||
- Migration planning
|
||||
- Custom onboarding
|
||||
- Service-level agreement options
|
||||
- Dedicated account support
|
||||
|
||||
### 4.5 Supporting commercial information
|
||||
|
||||
The section also shows:
|
||||
|
||||
- Factors that affect final price
|
||||
- Optional modules
|
||||
- Commercial assurances
|
||||
- A link back to product modules
|
||||
- A configuration note while prices remain unapproved
|
||||
|
||||
## 5. Commercial configuration
|
||||
|
||||
All financial values are stored in:
|
||||
|
||||
`src/content/pricing-config.ts`
|
||||
|
||||
The configuration controls:
|
||||
|
||||
- Currency
|
||||
- Annual discount percentage
|
||||
- Public-price approval state
|
||||
- Monthly prices by plan and fleet band
|
||||
- Recommended plan by fleet band
|
||||
|
||||
### Publishing prices
|
||||
|
||||
Before setting `publicPricesApproved` to `true`, finance and product must approve:
|
||||
|
||||
1. Monthly prices
|
||||
2. Included active vehicles
|
||||
3. Included locations and users
|
||||
4. Additional-vehicle rules
|
||||
5. Annual discount
|
||||
6. Onboarding charges
|
||||
7. Migration charges
|
||||
8. Taxes and currency presentation
|
||||
9. Optional-module pricing
|
||||
10. Enterprise qualification threshold
|
||||
|
||||
Until approval, the UI displays a localized “starting price pending approval” state rather than fabricated numbers.
|
||||
|
||||
## 6. Localization
|
||||
|
||||
Pricing copy is stored in:
|
||||
|
||||
- `src/content/locales/en/homepage.json`
|
||||
- `src/content/locales/fr/homepage.json`
|
||||
- `src/content/locales/ar/homepage.json`
|
||||
|
||||
Rules:
|
||||
|
||||
- All three locales must preserve the same data structure.
|
||||
- Arabic remains true document-level RTL.
|
||||
- Prices use `Intl.NumberFormat` with the active locale.
|
||||
- Monetary values use bidirectional isolation in Arabic.
|
||||
- Plan IDs and fleet-band IDs remain stable, non-translated identifiers.
|
||||
|
||||
## 7. Accessibility requirements
|
||||
|
||||
- Fleet and billing selectors use native radio inputs and fieldsets.
|
||||
- Every control has a visible label.
|
||||
- Keyboard focus is visible.
|
||||
- The recommended plan is not communicated by color alone.
|
||||
- Price updates use an `aria-live` region.
|
||||
- CTA controls retain minimum touch-target sizing.
|
||||
- Layout supports forced colors and reduced motion.
|
||||
- Mobile presentation does not require horizontal scrolling.
|
||||
|
||||
## 8. Responsive behavior
|
||||
|
||||
### Desktop
|
||||
|
||||
- Three plan cards in one row.
|
||||
- Recommended card receives stronger visual emphasis.
|
||||
- Fleet and billing controls share one row.
|
||||
|
||||
### Tablet
|
||||
|
||||
- Two cards on the first row.
|
||||
- Enterprise spans the second row.
|
||||
- Controls collapse when necessary.
|
||||
|
||||
### Mobile
|
||||
|
||||
- Controls stack.
|
||||
- Plan cards stack.
|
||||
- The recommended plan moves first.
|
||||
- CTAs use full width.
|
||||
- No pricing comparison table is forced into horizontal scrolling.
|
||||
|
||||
## 9. Analytics
|
||||
|
||||
The pricing section introduces these events:
|
||||
|
||||
- `pricing_fleet_band_selected`
|
||||
- `pricing_billing_period_changed`
|
||||
- `pricing_plan_cta_clicked`
|
||||
|
||||
Safe event properties:
|
||||
|
||||
- Locale
|
||||
- Direction
|
||||
- Theme
|
||||
- Fleet band
|
||||
- Billing period
|
||||
- Plan ID
|
||||
- Source
|
||||
|
||||
No name, email, company, phone number, or free-text form data is included.
|
||||
|
||||
## 10. Testing
|
||||
|
||||
Required checks:
|
||||
|
||||
- All locales build the same pricing model.
|
||||
- Four fleet bands exist.
|
||||
- Three plans exist.
|
||||
- Public prices remain blocked until approval.
|
||||
- The recommended plan changes with fleet selection.
|
||||
- Annual billing selection works.
|
||||
- RTL layout remains valid.
|
||||
- Homepage still contains exactly twelve sections.
|
||||
- Navigation hashes still resolve.
|
||||
- Pricing controls are keyboard-accessible.
|
||||
|
||||
## 11. Files added or changed
|
||||
|
||||
### Added
|
||||
|
||||
- `src/components/homepage/Homepage.module.css`
|
||||
- `src/components/homepage/README.md`
|
||||
- `src/components/homepage/sections/*.tsx`
|
||||
- Section-specific CSS modules
|
||||
- `src/content/pricing-config.ts`
|
||||
- `tests/unit/pricing.test.ts`
|
||||
|
||||
### Changed
|
||||
|
||||
- `src/app/[locale]/page.tsx`
|
||||
- `src/content/homepage-model.ts`
|
||||
- English, French, and Arabic homepage resources
|
||||
- Analytics event contract
|
||||
- Demo source contract
|
||||
- Homepage unit and browser tests
|
||||
|
||||
### Removed
|
||||
|
||||
- `src/app/[locale]/page.module.css`
|
||||
|
||||
Its rules were moved into section-owned CSS modules so unrelated sections no longer share one page stylesheet.
|
||||
|
||||
## 12. Definition of done
|
||||
|
||||
The work is complete when:
|
||||
|
||||
- The route page only composes section components.
|
||||
- Each section can be edited independently.
|
||||
- Pricing is fully localized and responsive.
|
||||
- Commercial values are centralized and guarded.
|
||||
- No unapproved prices are published.
|
||||
- Type checking, linting, unit tests, and browser tests pass.
|
||||
- The updated repository archive includes this plan and implementation.
|
||||
|
||||
## 13. Implementation status
|
||||
|
||||
Completed in this package:
|
||||
|
||||
- The homepage route is reduced to section composition.
|
||||
- Twelve homepage sections are independently editable.
|
||||
- Section-specific CSS is colocated with each section.
|
||||
- The interactive pricing section is implemented.
|
||||
- English, French, and Arabic pricing content is implemented.
|
||||
- RTL, dark theme, responsive layout, forced colors, and reduced motion are covered.
|
||||
- Pricing analytics events and the pricing demo source are registered.
|
||||
- Commercial amounts and discount values remain blocked pending approval.
|
||||
- The package was made standalone by removing an unused workspace-only dependency and its pre-scripts.
|
||||
- The missing `@axe-core/playwright` test dependency was added.
|
||||
|
||||
Validation completed:
|
||||
|
||||
- TypeScript strict check: passed.
|
||||
- ESLint with zero warnings: passed.
|
||||
- Unit and component tests: 51 passed.
|
||||
- Next.js production build: passed with an explicit local `SITE_ORIGIN`.
|
||||
|
||||
Browser test files were updated, but full Playwright execution could not be completed in the build container. The managed system Chromium blocks loopback navigation, while downloading Playwright Chromium was prevented by unavailable DNS access to the Playwright CDN. This is an environment restriction, not a passing test result. Run the browser suite in the project CI environment before release.
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
import nextTypeScript from 'eslint-config-next/typescript';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
|
||||
export default defineConfig([
|
||||
...nextVitals,
|
||||
...nextTypeScript,
|
||||
prettier,
|
||||
{
|
||||
rules: {
|
||||
'no-console': ['error', { allow: ['log', 'warn', 'error'] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/switch-exhaustiveness-check': 'error',
|
||||
},
|
||||
},
|
||||
globalIgnores([
|
||||
'.next/**',
|
||||
'coverage/**',
|
||||
'playwright-report/**',
|
||||
'test-results/**',
|
||||
'contracts/**',
|
||||
'next-env.d.ts',
|
||||
]),
|
||||
]);
|
||||
@@ -1,17 +1,21 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
|
||||
const securityHeaders = [
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ['192.168.3.3'],
|
||||
compress: true,
|
||||
poweredByHeader: false,
|
||||
reactStrictMode: true,
|
||||
|
||||
transpilePackages: ['@rentaldrivego/types'],
|
||||
|
||||
// The marketplace proxies /dashboard and /admin to their own Next dev servers.
|
||||
// Allow those asset-prefix origins so proxied pages can load chunks and HMR.
|
||||
const dashboardAssetSource = normalizeAssetPrefix(
|
||||
process.env.DASHBOARD_ASSET_PREFIX ?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined),
|
||||
'/dashboard',
|
||||
)
|
||||
const securityHeaders = buildSecurityHeaders({
|
||||
assetSources: [dashboardAssetSource, process.env.ADMIN_ASSET_PREFIX],
|
||||
})
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
@@ -20,15 +24,16 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
transpilePackages: ['@rentaldrivego/types'],
|
||||
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
]
|
||||
];
|
||||
},
|
||||
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
@@ -41,15 +46,16 @@ const nextConfig = {
|
||||
destination: '/dashboard/:path*',
|
||||
permanent: false,
|
||||
},
|
||||
]
|
||||
];
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
const dashboardOrigin = process.env.DASHBOARD_INTERNAL_URL ?? 'http://dashboard:3001'
|
||||
const adminOrigin = process.env.ADMIN_INTERNAL_URL ?? 'http://admin:3002'
|
||||
const dashboardOrigin =
|
||||
process.env.DASHBOARD_INTERNAL_URL ?? 'http://dashboard:3001';
|
||||
const adminOrigin =
|
||||
process.env.ADMIN_INTERNAL_URL ?? 'http://admin:3002';
|
||||
|
||||
return [
|
||||
// Dashboard has basePath '/dashboard'. The proxy preserves the prefix
|
||||
// so the dashboard can strip it and route internally.
|
||||
{
|
||||
source: '/dashboard',
|
||||
destination: `${dashboardOrigin}/dashboard`,
|
||||
@@ -58,7 +64,6 @@ const nextConfig = {
|
||||
source: '/dashboard/:path*',
|
||||
destination: `${dashboardOrigin}/dashboard/:path*`,
|
||||
},
|
||||
// Admin routes (also uses basePath)
|
||||
{
|
||||
source: '/admin',
|
||||
destination: `${adminOrigin}/admin`,
|
||||
@@ -67,8 +72,8 @@ const nextConfig = {
|
||||
source: '/admin/:path*',
|
||||
destination: `${adminOrigin}/admin/:path*`,
|
||||
},
|
||||
]
|
||||
];
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = nextConfig
|
||||
export default nextConfig;
|
||||
@@ -1,34 +1,45 @@
|
||||
{
|
||||
"name": "@rentaldrivego/homepage",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "npm run build --workspace @rentaldrivego/types",
|
||||
"dev": "next dev -H 0.0.0.0 -p 3000",
|
||||
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
||||
"build": "next build",
|
||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||
"pretype-check": "npm run build --workspace @rentaldrivego/types",
|
||||
"start": "next start -H 0.0.0.0 -p 3000",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@rentaldrivego/types": "*",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"lucide-react": "^0.376.0",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/noto-sans-arabic": "^5.2.10",
|
||||
"next": "^16.2.7",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^3.4.3"
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@playwright/test": "^1.61.0",
|
||||
"@testing-library/jest-dom": "^6.9.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.0",
|
||||
"@types/node": "^20.12.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "^16.2.0",
|
||||
"eslint-config-prettier": "^10.1.0",
|
||||
"globals": "^17.0.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"prettier": "^3.8.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^1.6.0"
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3000';
|
||||
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
const serverCommand = process.env.PLAYWRIGHT_SERVER_COMMAND ?? 'pnpm start';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
testMatch: ['browser/**/*.spec.ts', 'a11y/**/*.spec.ts', 'visual/**/*.spec.ts'],
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
...(process.env.CI ? { workers: 2 } : {}),
|
||||
reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
|
||||
expect: {
|
||||
timeout: 5_000,
|
||||
toHaveScreenshot: {
|
||||
animations: 'disabled',
|
||||
caret: 'hide',
|
||||
maxDiffPixelRatio: 0.005,
|
||||
},
|
||||
},
|
||||
use: {
|
||||
baseURL,
|
||||
locale: 'en-US',
|
||||
timezoneId: 'UTC',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video:
|
||||
process.env.PLAYWRIGHT_VIDEO === 'off' ? 'off' : process.env.CI ? 'retain-on-failure' : 'off',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
...(executablePath ? { launchOptions: { executablePath } } : {}),
|
||||
},
|
||||
},
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
|
||||
{ name: 'mobile-safari', use: { ...devices['iPhone 15'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: serverCommand,
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
SITE_ORIGIN: baseURL,
|
||||
PUBLIC_RELEASE_APPROVED: 'false',
|
||||
COMPONENT_FIXTURES_ENABLED: 'true',
|
||||
NEXT_PUBLIC_DEMO_ENABLED: 'true',
|
||||
DEMO_SUBMISSION_MODE: 'local',
|
||||
DEMO_LOCAL_TEST_MODE: 'true',
|
||||
DEMO_TEST_SCENARIOS_ENABLED: 'true',
|
||||
NEXT_PUBLIC_ANALYTICS_MODE: 'disabled',
|
||||
HOSTNAME: '127.0.0.1',
|
||||
PORT: '3000',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import("prettier").Config} */
|
||||
const config = {
|
||||
printWidth: 100,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -1,14 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#0f172a" />
|
||||
<text
|
||||
x="16"
|
||||
y="21"
|
||||
text-anchor="middle"
|
||||
font-family="Inter, Arial, sans-serif"
|
||||
font-size="18"
|
||||
font-weight="700"
|
||||
fill="#ffffff"
|
||||
>
|
||||
R
|
||||
</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 302 B |
|
After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 448 KiB |
@@ -1,274 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { StatsStrip } from '@/components/StatsStrip'
|
||||
import { HowItWorks } from '@/components/HowItWorks'
|
||||
import { TestimonialsSection } from '@/components/TestimonialsSection'
|
||||
import {
|
||||
cloneMarketplaceHomepageContent,
|
||||
resolveMarketplaceHomepageSections,
|
||||
type MarketplaceHomepageConfig,
|
||||
type MarketplaceHomepageSectionType,
|
||||
} from '@rentaldrivego/types'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export default function HomeContent() {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const [content, setContent] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
||||
const dict = content[language]
|
||||
const sections = resolveMarketplaceHomepageSections(dict.sections)
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
const starterSignupHref = `${dashboardUrl}/sign-up?plan=STARTER&billing=MONTHLY¤cy=MAD`
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadHomepageContent() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/site/platform/homepage`, { cache: 'no-store' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok || !json?.data || cancelled) return
|
||||
setContent(json.data as MarketplaceHomepageConfig)
|
||||
} catch {
|
||||
// Fall back to the shared defaults when the API is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
loadHomepageContent()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
function hasSection(section: MarketplaceHomepageSectionType) {
|
||||
return sections.includes(section)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen overflow-hidden bg-transparent">
|
||||
<section className="relative">
|
||||
<div className="site-glow absolute inset-x-0 top-0 h-[38rem]" />
|
||||
<div className="shell relative py-10 sm:py-14 lg:py-20">
|
||||
<div className={`grid gap-8 ${hasSection('hero') && hasSection('surface') ? 'lg:grid-cols-[minmax(0,1.15fr)_24rem] lg:items-stretch xl:grid-cols-[minmax(0,1.2fr)_28rem]' : ''}`}>
|
||||
{hasSection('hero') ? (
|
||||
<div className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/70">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.32em] text-orange-700 dark:text-orange-300">{dict.heroKicker}</p>
|
||||
<h1 className="mt-5 max-w-4xl text-5xl font-black leading-none tracking-[-0.04em] text-blue-950 dark:text-stone-50 sm:text-6xl lg:text-7xl">
|
||||
{dict.heroTitle}
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-stone-600 dark:text-stone-300 sm:text-lg">
|
||||
{dict.heroBody}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<a
|
||||
href={starterSignupHref}
|
||||
className="rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
|
||||
>
|
||||
{dict.startTrial}
|
||||
</a>
|
||||
<a
|
||||
href="/features"
|
||||
className="rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-stone-200 dark:hover:border-stone-200 dark:hover:text-white"
|
||||
>
|
||||
{dict.exploreVehicles}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-3 sm:grid-cols-3">
|
||||
{dict.metrics.map(({ value, label }) => (
|
||||
<div key={value} className="rounded-[1.5rem] border border-stone-200/80 bg-stone-50/90 p-4 dark:border-blue-900 dark:bg-blue-950/50">
|
||||
<p className="text-xs font-bold tracking-[0.28em] text-stone-400 dark:text-stone-500">{value}</p>
|
||||
<p className="mt-3 text-sm font-semibold leading-6 text-stone-900 dark:text-stone-100">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasSection('surface') ? (
|
||||
<div className="relative overflow-hidden rounded-[2rem] border border-blue-900/60 bg-[#06132e] p-6 text-white shadow-[0_30px_80px_rgba(6,19,46,0.40)] dark:border-blue-800">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(249,115,22,0.30),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(96,165,250,0.22),transparent_32%)]" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-orange-200">
|
||||
{dict.surfaceLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
||||
{dict.liveLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-6 text-3xl font-black leading-tight tracking-[-0.03em]">{dict.surfaceTitle}</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-300">{dict.surfaceBody}</p>
|
||||
|
||||
<div className="mt-8 space-y-3">
|
||||
{[
|
||||
['01', dict.trustedFleets],
|
||||
['02', dict.brandedFlows],
|
||||
['03', dict.multiTenant],
|
||||
].map(([step, label]) => (
|
||||
<div key={step} className="flex items-center justify-between rounded-[1.25rem] border border-white/10 bg-white/5 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-xs font-bold text-blue-950">
|
||||
{step}
|
||||
</span>
|
||||
<p className="text-sm font-semibold text-white">{label}</p>
|
||||
</div>
|
||||
<span className="text-lg text-orange-300">+</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasSection('audiences') ? <div className="mt-8 grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<article className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 backdrop-blur dark:border-blue-900 dark:bg-blue-950/60">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.companyKicker}</p>
|
||||
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50 sm:text-3xl">{dict.companyTitle}</h2>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-stone-600 dark:text-stone-300">{dict.companyBody}</p>
|
||||
</article>
|
||||
<article className="rounded-[2rem] border border-stone-200/80 bg-[#f7efe2] p-7 dark:border-blue-900 dark:bg-[#1d1712]">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{dict.renterKicker}</p>
|
||||
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50 sm:text-3xl">{dict.renterTitle}</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-700 dark:text-stone-300">{dict.renterBody}</p>
|
||||
</article>
|
||||
</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{hasSection('pillars') ? <section className="shell pb-10">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{dict.pillars.map(({ title, body }, index) => (
|
||||
<article
|
||||
key={title}
|
||||
className={`rounded-[2rem] border p-7 shadow-sm ${
|
||||
index === 1
|
||||
? 'border-orange-700 bg-orange-600 text-white dark:border-orange-500 dark:bg-orange-500 dark:text-white'
|
||||
: 'border-stone-200 bg-white dark:border-blue-900 dark:bg-blue-950'
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-xs font-bold uppercase tracking-[0.26em] ${
|
||||
index === 1 ? 'text-stone-300 dark:text-stone-700' : 'text-stone-400 dark:text-stone-500'
|
||||
}`}
|
||||
>
|
||||
0{index + 1}
|
||||
</p>
|
||||
<h3 className="mt-4 text-2xl font-black tracking-[-0.03em]">{title}</h3>
|
||||
<p
|
||||
className={`mt-4 text-sm leading-7 ${
|
||||
index === 1 ? 'text-stone-200 dark:text-stone-800' : 'text-stone-600 dark:text-stone-300'
|
||||
}`}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section> : null}
|
||||
|
||||
{hasSection('pillars') ? (
|
||||
<StatsStrip
|
||||
stats={dict.metrics.map((m) => ({
|
||||
label: m.label,
|
||||
value: parseInt(m.value) || 0,
|
||||
}))}
|
||||
kicker="BY THE NUMBERS"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{(hasSection('features') || hasSection('steps')) ? <section className="shell py-10">
|
||||
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
{hasSection('features') ? (
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-[linear-gradient(160deg,#f5f8ff_0%,#edf2ff_100%)] p-8 dark:border-blue-900 dark:bg-[linear-gradient(160deg,#0c1830_0%,#10203e_100%)]">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.featureLabel}</p>
|
||||
<div className="mt-6 space-y-3">
|
||||
{dict.features.map((item, index) => (
|
||||
<div key={item} className="flex items-start gap-4 rounded-[1.25rem] border border-stone-200/80 bg-white/85 px-4 py-4 dark:border-blue-800 dark:bg-blue-950/40">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-orange-600 text-xs font-bold text-white dark:bg-orange-500 dark:text-white">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-sm font-semibold leading-6 text-stone-800 dark:text-stone-100">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : <div />}
|
||||
</div>
|
||||
</section> : null}
|
||||
|
||||
{hasSection('howitworks') ? (
|
||||
<HowItWorks
|
||||
steps={dict.howitworksSteps}
|
||||
kicker={dict.howitworksKicker}
|
||||
title={dict.howitworksTitle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{hasSection('steps') ? <section className="shell py-10">
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm dark:border-blue-900 dark:bg-blue-950">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{dict.readyKicker}</p>
|
||||
<h2 className="mt-4 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">{dict.stepsTitle}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{dict.steps.map(({ step, title, body }) => (
|
||||
<article key={step} className="rounded-[1.5rem] border border-stone-200 bg-stone-50 p-5 dark:border-blue-900 dark:bg-blue-950">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">
|
||||
{dict.stepLabel} {step}
|
||||
</p>
|
||||
<h3 className="mt-3 text-lg font-black text-blue-950 dark:text-stone-50">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600 dark:text-stone-300">{body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section> : null}
|
||||
|
||||
{hasSection('testimonials') && dict.testimonials && dict.testimonials.length > 0 ? (
|
||||
<TestimonialsSection
|
||||
testimonials={dict.testimonials}
|
||||
kicker={dict.testimonialsKicker}
|
||||
title={dict.testimonialsTitle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{hasSection('closing') ? <section className="shell pb-16 pt-6 sm:pb-20">
|
||||
<div className="overflow-hidden rounded-[2rem] border border-blue-900/60 bg-[#06132e] px-8 py-10 text-white dark:border-blue-800">
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.28em] text-orange-300">{dict.readyKicker}</p>
|
||||
<h2 className="mt-4 max-w-3xl text-4xl font-black leading-tight tracking-[-0.04em] sm:text-5xl">{dict.readyTitle}</h2>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-8 text-stone-300 sm:text-base">{dict.readyBody}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 lg:justify-end">
|
||||
<a
|
||||
href="/pricing"
|
||||
className="rounded-full border border-white/20 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white hover:text-blue-900"
|
||||
>
|
||||
{dict.viewPricing}
|
||||
</a>
|
||||
<a
|
||||
href={starterSignupHref}
|
||||
className="rounded-full bg-orange-300 px-6 py-3 text-sm font-semibold text-blue-950 transition hover:bg-orange-200"
|
||||
>
|
||||
{dict.createWorkspace}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section> : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import AppPrivacyEnPage from './app-privacy-en/page'
|
||||
import AppPrivacyFrPage from './app-privacy-fr/page'
|
||||
import AppPrivacyArPage from './app-privacy-ar/page'
|
||||
import AppTermsEnPage from './app-tc-en/page'
|
||||
import AppTermsFrPage from './app-tc-fr/page'
|
||||
import AppTermsArPage from './app-tc-ar/page'
|
||||
|
||||
function expectPolicyPage(element: unknown, slug: string, forcedLanguage: string) {
|
||||
expect(isValidElement(element)).toBe(true)
|
||||
const page = element as React.ReactElement
|
||||
expect(page.type).toBe(FooterContentPage)
|
||||
expect(page.props.slug).toBe(slug)
|
||||
expect(page.props.forcedLanguage).toBe(forcedLanguage)
|
||||
}
|
||||
|
||||
describe('marketplace static app policy pages', () => {
|
||||
it('binds app privacy pages to explicit locales', () => {
|
||||
expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en')
|
||||
expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr')
|
||||
expectPolicyPage(AppPrivacyArPage(), 'privacy-policy', 'ar')
|
||||
})
|
||||
|
||||
it('binds app terms pages to explicit locales and the app terms content slug', () => {
|
||||
expectPolicyPage(AppTermsEnPage(), 'general-conditions', 'en')
|
||||
expectPolicyPage(AppTermsFrPage(), 'general-conditions', 'fr')
|
||||
expectPolicyPage(AppTermsArPage(), 'general-conditions', 'ar')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyArPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyEnPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="en" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyFrPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsArPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsEnPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="en" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsFrPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function CompanyWorkspacePage() {
|
||||
redirect('/')
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart3,
|
||||
Zap,
|
||||
Users,
|
||||
CreditCard,
|
||||
Lock,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { FeatureAlternating } from '@/components/FeatureAlternating'
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
kicker: 'Features',
|
||||
title: 'Operations, marketplace discovery, and branded booking.',
|
||||
body: "RentalDriveGo combines company-only operations with a public marketplace that sends renters to each company's own payment flow.",
|
||||
features: [
|
||||
{
|
||||
title: 'Private dashboard',
|
||||
body: 'Fleet, reservations, CRM, team permissions, billing, and reports stay isolated per company.',
|
||||
icon: 'BarChart3',
|
||||
},
|
||||
{
|
||||
title: 'Marketplace discovery',
|
||||
body: 'Published vehicles and public offers appear on `/explore` for cross-company browsing.',
|
||||
icon: 'Zap',
|
||||
},
|
||||
{
|
||||
title: 'Branded booking site',
|
||||
body: 'Each company gets its own subdomain where renters complete booking and payment directly.',
|
||||
icon: 'Lock',
|
||||
},
|
||||
{
|
||||
title: 'Payment flexibility',
|
||||
body: 'Use AmanPay or PayPal for subscriptions and for company-side renter payments.',
|
||||
icon: 'CreditCard',
|
||||
},
|
||||
{
|
||||
title: 'Advanced rental ops',
|
||||
body: 'Insurance, additional drivers, pricing rules, and structured fuel policies live in dashboard settings.',
|
||||
icon: 'Users',
|
||||
},
|
||||
{
|
||||
title: 'Notification controls',
|
||||
body: 'Both company staff and renters can manage event-by-channel notification preferences.',
|
||||
icon: 'Bell',
|
||||
},
|
||||
],
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Fonctionnalités',
|
||||
title: 'Opérations, découverte marketplace et réservation de marque.',
|
||||
body: "RentalDriveGo combine des opérations privées pour l'entreprise avec une marketplace publique qui redirige les clients vers le parcours de paiement propre à chaque société.",
|
||||
features: [
|
||||
{
|
||||
title: 'Tableau de bord privé',
|
||||
body: "Flotte, réservations, CRM, permissions d'équipe, facturation et rapports restent isolés par entreprise.",
|
||||
icon: 'BarChart3',
|
||||
},
|
||||
{
|
||||
title: 'Découverte marketplace',
|
||||
body: 'Les véhicules publiés et les offres publiques apparaissent sur `/explore` pour une navigation multi-entreprises.',
|
||||
icon: 'Zap',
|
||||
},
|
||||
{
|
||||
title: 'Site de réservation de marque',
|
||||
body: 'Chaque entreprise dispose de son propre sous-domaine où les clients finalisent la réservation et le paiement.',
|
||||
icon: 'Lock',
|
||||
},
|
||||
{
|
||||
title: 'Flexibilité de paiement',
|
||||
body: 'Utilisez AmanPay ou PayPal pour les abonnements et pour les paiements côté entreprise.',
|
||||
icon: 'CreditCard',
|
||||
},
|
||||
{
|
||||
title: 'Opérations avancées',
|
||||
body: 'Assurance, conducteurs supplémentaires, règles tarifaires et politiques carburant structurées sont gérés dans les paramètres du tableau de bord.',
|
||||
icon: 'Users',
|
||||
},
|
||||
{
|
||||
title: 'Contrôle des notifications',
|
||||
body: 'Le personnel comme les clients peuvent gérer leurs préférences de notification par événement et canal.',
|
||||
icon: 'Bell',
|
||||
},
|
||||
],
|
||||
},
|
||||
ar: {
|
||||
kicker: 'المزايا',
|
||||
title: 'العمليات، اكتشاف السوق، والحجز المخصص.',
|
||||
body: 'يجمع RentalDriveGo بين عمليات الشركة الخاصة وسوق عام يوجّه المستأجرين إلى مسار الدفع الخاص بكل شركة.',
|
||||
features: [
|
||||
{
|
||||
title: 'لوحة تحكم خاصة',
|
||||
body: 'الأسطول والحجوزات وCRM وصلاحيات الفريق والفوترة والتقارير تبقى معزولة لكل شركة.',
|
||||
icon: 'BarChart3',
|
||||
},
|
||||
{
|
||||
title: 'اكتشاف عبر السوق',
|
||||
body: 'تظهر السيارات المنشورة والعروض العامة في `/explore` للتصفح بين الشركات.',
|
||||
icon: 'Zap',
|
||||
},
|
||||
{
|
||||
title: 'موقع حجز مخصص',
|
||||
body: 'تحصل كل شركة على نطاق فرعي خاص بها لإتمام الحجز والدفع مباشرة.',
|
||||
icon: 'Lock',
|
||||
},
|
||||
{
|
||||
title: 'مرونة الدفع',
|
||||
body: 'استخدم AmanPay أو PayPal للاشتراكات ومدفوعات المستأجرين من جهة الشركة.',
|
||||
icon: 'CreditCard',
|
||||
},
|
||||
{
|
||||
title: 'عمليات متقدمة',
|
||||
body: 'التأمين والسائقون الإضافيون وقواعد التسعير وسياسات الوقود المنظمة موجودة في إعدادات اللوحة.',
|
||||
icon: 'Users',
|
||||
},
|
||||
{
|
||||
title: 'التحكم في الإشعارات',
|
||||
body: 'يمكن للموظفين والمستأجرين إدارة تفضيلات الإشعارات حسب الحدث والقناة.',
|
||||
icon: 'Bell',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as const
|
||||
|
||||
const iconMap = {
|
||||
BarChart3,
|
||||
Zap,
|
||||
Users,
|
||||
CreditCard,
|
||||
Lock,
|
||||
Bell,
|
||||
}
|
||||
|
||||
export default function FeaturesPage() {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = copy[language]
|
||||
|
||||
const features = dict.features.map((f) => ({
|
||||
...f,
|
||||
icon: iconMap[f.icon as keyof typeof iconMap],
|
||||
}))
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section space-y-16">
|
||||
<div className="max-w-3xl">
|
||||
<p className="site-kicker">{dict.kicker}</p>
|
||||
<h1 className="site-title">{dict.title}</h1>
|
||||
<p className="site-lead">{dict.body}</p>
|
||||
</div>
|
||||
|
||||
<FeatureAlternating features={features} />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error('NEXT_NOT_FOUND')
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => navigation)
|
||||
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import FooterPage, { generateStaticParams } from './page'
|
||||
import { footerPageSlugs } from '@/lib/footerContent'
|
||||
|
||||
describe('marketplace footer route', () => {
|
||||
it('generates one static route per registered footer slug', () => {
|
||||
expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug })))
|
||||
})
|
||||
|
||||
it('passes valid slugs through to FooterContentPage', () => {
|
||||
const element = FooterPage({ params: { slug: 'privacy-policy' } })
|
||||
|
||||
expect(isValidElement(element)).toBe(true)
|
||||
expect(element.type).toBe(FooterContentPage)
|
||||
expect(element.props.slug).toBe('privacy-policy')
|
||||
expect(element.props.forcedLanguage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects unknown slugs instead of rendering arbitrary policy pages', () => {
|
||||
expect(() => FooterPage({ params: { slug: 'definitely-not-a-policy' } })).toThrow('NEXT_NOT_FOUND')
|
||||
expect(navigation.notFound).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import { footerPageSlugs, isFooterPageSlug } from '@/lib/footerContent'
|
||||
|
||||
export function generateStaticParams() {
|
||||
return footerPageSlugs.map((slug) => ({ slug }))
|
||||
}
|
||||
|
||||
export default function FooterPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string }
|
||||
}) {
|
||||
const { slug } = params
|
||||
|
||||
if (!isFooterPageSlug(slug)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <FooterContentPage slug={slug} />
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import MarketplaceHeader from '@/components/MarketplaceHeader'
|
||||
import MarketplaceFooter from '@/components/MarketplaceFooter'
|
||||
import { useMarketplacePreferences, getFooterContent, localeOptions } from '@/components/MarketplaceShell'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const { language, theme, dict, companyName, setLanguage, setTheme } = useMarketplacePreferences()
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
const footerContent = getFooterContent(language)
|
||||
const available = localeOptions.filter((o) => o.value !== language)
|
||||
const current = localeOptions.find((o) => o.value === language) ?? localeOptions[0]
|
||||
|
||||
return (
|
||||
<div className="site-page flex min-h-screen flex-col">
|
||||
<MarketplaceHeader
|
||||
dict={dict}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
companyName={companyName}
|
||||
dashboardUrl={dashboardUrl}
|
||||
currentLanguage={{ value: current.value, flag: current.flag, shortLabel: current.value.toUpperCase() }}
|
||||
localeOptions={available.map((o) => ({ value: o.value, flag: o.flag, shortLabel: o.value.toUpperCase() }))}
|
||||
onSelectLanguage={setLanguage}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
<MarketplaceFooter
|
||||
primaryItems={footerContent.primary}
|
||||
secondaryItems={footerContent.secondary}
|
||||
localeLabel={footerContent.localeLabel}
|
||||
rightsLabel={footerContent.rightsLabel}
|
||||
localeOptions={available}
|
||||
currentLocale={current}
|
||||
onSelectLanguage={setLanguage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import HomeContent from './HomeContent'
|
||||
|
||||
export default function HomePage() {
|
||||
return <HomeContent />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function PlatformOperationsPage() {
|
||||
redirect('/')
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { getCurrencyLabel, PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
type Billing = 'monthly' | 'annual'
|
||||
|
||||
type PricingMatrix = Record<string, Record<string, Record<string, number>>>
|
||||
type PlanFeatureMap = Record<string, string[]>
|
||||
|
||||
type PlatformPricing = {
|
||||
prices: PricingMatrix
|
||||
planFeatures: PlanFeatureMap
|
||||
}
|
||||
|
||||
export const DEFAULT_PRICING: PlatformPricing = {
|
||||
prices: PLAN_PRICES,
|
||||
planFeatures: PLAN_FEATURES,
|
||||
}
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
key: 'STARTER',
|
||||
name: 'Starter',
|
||||
tagline: 'Launch your fleet online',
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
key: 'GROWTH',
|
||||
name: 'Growth',
|
||||
tagline: 'Scale with confidence',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
key: 'PRO',
|
||||
name: 'Pro',
|
||||
tagline: 'Enterprise-grade power',
|
||||
highlight: false,
|
||||
},
|
||||
]
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
monthly: 'Monthly',
|
||||
annual: 'Annual',
|
||||
yearly: '/ year',
|
||||
youSave: 'You save',
|
||||
withAnnual: 'with annual billing — billed as one payment per year.',
|
||||
mostPopular: 'Most popular',
|
||||
perMonth: '/mo',
|
||||
billedAs: 'Billed as',
|
||||
getStarted: 'Get started',
|
||||
footer: 'All plans include a 90-day free trial. No credit card required.',
|
||||
},
|
||||
fr: {
|
||||
monthly: 'Mensuel',
|
||||
annual: 'Annuel',
|
||||
yearly: '/ an',
|
||||
youSave: 'Vous économisez',
|
||||
withAnnual: 'avec la facturation annuelle, prélevée en un seul paiement par an.',
|
||||
mostPopular: 'Le plus populaire',
|
||||
perMonth: '/mois',
|
||||
billedAs: 'Facturé à',
|
||||
getStarted: 'Commencer',
|
||||
footer: "Toutes les formules incluent un essai gratuit de 90 jours. Aucune carte bancaire n'est requise.",
|
||||
},
|
||||
ar: {
|
||||
monthly: 'شهري',
|
||||
annual: 'سنوي',
|
||||
yearly: '/ سنة',
|
||||
youSave: 'توفر',
|
||||
withAnnual: 'مع الفوترة السنوية، تُسدد دفعة واحدة كل سنة.',
|
||||
mostPopular: 'الأكثر شيوعاً',
|
||||
perMonth: '/شهر',
|
||||
billedAs: 'يتم الفوترة بمبلغ',
|
||||
getStarted: 'ابدأ الآن',
|
||||
footer: 'تشمل جميع الخطط تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.',
|
||||
},
|
||||
} as const
|
||||
|
||||
function getPlanPrice(prices: PricingMatrix, plan: string, billingPeriod: 'MONTHLY' | 'ANNUAL') {
|
||||
return (prices[plan]?.[billingPeriod]?.MAD ?? PLAN_PRICES[plan]?.[billingPeriod]?.MAD ?? 0) / 100
|
||||
}
|
||||
|
||||
function annualSavingsPct(prices: PricingMatrix, plan: string): number {
|
||||
const monthly = getPlanPrice(prices, plan, 'MONTHLY')
|
||||
const annual = getPlanPrice(prices, plan, 'ANNUAL')
|
||||
const wouldPay = monthly * 12
|
||||
if (wouldPay <= 0) return 0
|
||||
return Math.max(0, Math.round(((wouldPay - annual) / wouldPay) * 100))
|
||||
}
|
||||
|
||||
export default function PricingClient({ initialPricing = DEFAULT_PRICING }: { initialPricing?: PlatformPricing }) {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const currencyLabel = getCurrencyLabel(language)
|
||||
const dict = copy[language]
|
||||
const [billing, setBilling] = useState<Billing>('monthly')
|
||||
const [pricing, setPricing] = useState<PlatformPricing>(initialPricing)
|
||||
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
|
||||
useEffect(() => {
|
||||
marketplaceFetchOrDefault<PlatformPricing>('/site/platform/pricing', DEFAULT_PRICING)
|
||||
.then(setPricing)
|
||||
}, [])
|
||||
|
||||
const savings = annualSavingsPct(pricing.prices, 'STARTER')
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Billing toggle */}
|
||||
<div className="flex items-center justify-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm dark:border-blue-800 dark:bg-blue-950">
|
||||
{(['monthly', 'annual'] as Billing[]).map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setBilling(b)}
|
||||
className={`relative rounded-full px-5 py-2 text-sm font-semibold transition-colors ${
|
||||
billing === b
|
||||
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
|
||||
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100'
|
||||
}`}
|
||||
>
|
||||
{b === 'monthly' ? dict.monthly : dict.annual}
|
||||
{b === 'annual' && billing !== 'annual' && (
|
||||
<span className="ml-2 rounded-full bg-orange-100 px-2 py-0.5 text-[10px] font-bold text-orange-700 dark:bg-orange-950/50 dark:text-orange-400">
|
||||
-{savings}%
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{billing === 'annual' && (
|
||||
<p className="text-center text-sm font-medium text-orange-700 dark:text-orange-400">
|
||||
{dict.youSave} {savings}% {dict.withAnnual}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Plan cards */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{PLANS.map((plan) => {
|
||||
const monthlyPrice = getPlanPrice(pricing.prices, plan.key, 'MONTHLY')
|
||||
const annualPrice = getPlanPrice(pricing.prices, plan.key, 'ANNUAL')
|
||||
const displayPrice = billing === 'annual'
|
||||
? Math.round(annualPrice / 12)
|
||||
: monthlyPrice
|
||||
const features = pricing.planFeatures[plan.key] ?? PLAN_FEATURES[plan.key] ?? []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.key}
|
||||
className={`card relative flex flex-col overflow-hidden ${
|
||||
plan.highlight
|
||||
? 'border-orange-400 ring-2 ring-orange-400 ring-offset-2 dark:ring-offset-blue-950'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{plan.highlight && (
|
||||
<div className="bg-orange-500 dark:bg-orange-400 py-1.5 text-center text-xs font-bold uppercase tracking-widest text-white dark:text-stone-900">
|
||||
{dict.mostPopular}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-400">
|
||||
{plan.name}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">{plan.tagline}</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-end gap-1">
|
||||
<span className="text-4xl font-black text-stone-900 dark:text-stone-100">
|
||||
{displayPrice}
|
||||
</span>
|
||||
<span className="mb-1 text-lg font-semibold text-stone-500 dark:text-stone-400">{currencyLabel}</span>
|
||||
<span className="mb-1 text-sm text-stone-400 dark:text-stone-500">{dict.perMonth}</span>
|
||||
</div>
|
||||
{billing === 'annual' && (
|
||||
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">
|
||||
{dict.billedAs} {annualPrice} {currencyLabel} {dict.yearly}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 flex-1 space-y-3">
|
||||
{features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2.5 text-sm text-stone-700 dark:text-stone-300">
|
||||
<svg
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-orange-500 dark:text-orange-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href={`${dashboardUrl}/sign-up?plan=${plan.key}&billing=${billing.toUpperCase()}¤cy=MAD`}
|
||||
className={`mt-10 block rounded-full px-6 py-3 text-center text-sm font-semibold transition-colors ${
|
||||
plan.highlight
|
||||
? 'bg-orange-500 text-white hover:bg-orange-600 dark:bg-orange-400 dark:text-stone-900 dark:hover:bg-orange-300'
|
||||
: 'bg-blue-900 text-white hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400'
|
||||
}`}
|
||||
>
|
||||
{dict.getStarted}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<p className="text-center text-sm text-stone-400 dark:text-stone-500">{dict.footer}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import PricingClient from './PricingClient'
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
kicker: 'Pricing',
|
||||
title: 'One platform, every fleet size.',
|
||||
body: 'Start free for 90 days, then choose the plan that grows with your business. Switch plans or cancel at any time.',
|
||||
faq: 'Frequently asked',
|
||||
items: [
|
||||
['Can I change plans later?', 'Yes. Upgrade or downgrade at any time — changes take effect on your next billing cycle.'],
|
||||
['What counts as a vehicle?', 'Any vehicle record added to your fleet dashboard, active or not.'],
|
||||
['Is there a setup fee?', 'No setup fee, ever. You only pay the monthly or annual subscription.'],
|
||||
['How does the marketplace listing work?', 'All paid plans include a public marketplace listing on RentalDriveGo. Renters can discover your fleet and are redirected to your branded booking site.'],
|
||||
],
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Tarifs',
|
||||
title: 'Une seule plateforme pour toutes les tailles de flotte.',
|
||||
body: 'Commencez gratuitement pendant 90 jours, puis choisissez la formule qui accompagne votre activité. Vous pouvez changer de formule ou annuler à tout moment.',
|
||||
faq: 'Questions fréquentes',
|
||||
items: [
|
||||
['Puis-je changer de formule plus tard ?', 'Oui. Vous pouvez passer à une formule supérieure ou inférieure à tout moment. Les changements prennent effet au cycle de facturation suivant.'],
|
||||
["Qu'est-ce qui compte comme véhicule ?", "Tout véhicule ajouté à votre tableau de bord de flotte, qu'il soit actif ou non."],
|
||||
["Y a-t-il des frais d'installation ?", "Aucun frais d'installation. Vous payez uniquement l'abonnement mensuel ou annuel."],
|
||||
['Comment fonctionne la visibilité marketplace ?', 'Toutes les formules payantes incluent une présence publique sur RentalDriveGo. Les clients découvrent votre flotte puis sont redirigés vers votre site de réservation.'],
|
||||
],
|
||||
},
|
||||
ar: {
|
||||
kicker: 'الأسعار',
|
||||
title: 'منصة واحدة لكل أحجام الأساطيل.',
|
||||
body: 'ابدأ مجاناً لمدة 14 يوماً، ثم اختر الخطة التي تناسب نمو نشاطك. يمكنك تغيير الخطة أو إلغاءها في أي وقت.',
|
||||
faq: 'الأسئلة الشائعة',
|
||||
items: [
|
||||
['هل يمكنني تغيير الخطة لاحقاً؟', 'نعم. يمكنك الترقية أو التخفيض في أي وقت — وتدخل التغييرات حيز التنفيذ في دورة الفوترة التالية.'],
|
||||
['ما الذي يُحتسب كسيارة؟', 'أي سجل سيارة تتم إضافته إلى لوحة الأسطول سواء كان نشطاً أم لا.'],
|
||||
['هل توجد رسوم إعداد؟', 'لا توجد أي رسوم إعداد. أنت تدفع فقط قيمة الاشتراك الشهري أو السنوي.'],
|
||||
['كيف تعمل قائمة السوق؟', 'كل الخطط المدفوعة تشمل الظهور العام على RentalDriveGo. يكتشف المستأجرون أسطولك ثم يتم تحويلهم إلى موقع الحجز الخاص بك.'],
|
||||
],
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function PricingPageContent({ initialPricing }: { initialPricing?: React.ComponentProps<typeof PricingClient>['initialPricing'] }) {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = copy[language]
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<p className="site-kicker">{dict.kicker}</p>
|
||||
<h1 className="site-title">{dict.title}</h1>
|
||||
<p className="site-lead">{dict.body}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16">
|
||||
<PricingClient initialPricing={initialPricing} />
|
||||
</div>
|
||||
|
||||
<div className="site-panel mx-auto mt-24 max-w-3xl divide-y divide-stone-200/80 dark:divide-stone-800">
|
||||
<h2 className="pb-8 text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.faq}</h2>
|
||||
{dict.items.map(([q, a]) => (
|
||||
<div key={q} className="py-6">
|
||||
<p className="font-semibold text-stone-900 dark:text-stone-100">{q}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-stone-400">{a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import PricingPageContent from './PricingPageContent'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pricing — RentalDriveGo',
|
||||
description: 'Simple, transparent pricing for every fleet size.',
|
||||
}
|
||||
|
||||
const DEFAULT_PRICING = {
|
||||
prices: PLAN_PRICES,
|
||||
planFeatures: PLAN_FEATURES,
|
||||
}
|
||||
|
||||
export default async function PricingPage() {
|
||||
const initialPricing = await marketplaceFetchOrDefault('/site/platform/pricing', DEFAULT_PRICING)
|
||||
return <PricingPageContent initialPricing={initialPricing} />
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Star } from 'lucide-react'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
interface ReviewInfo {
|
||||
reservationId: string
|
||||
companyName: string
|
||||
companyLogoUrl: string | null
|
||||
vehicle: string
|
||||
vehiclePhoto: string | null
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange, label }: { value: number; onChange: (v: number) => void; label: string }) {
|
||||
const [hovered, setHovered] = useState(0)
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1.5 text-sm font-medium text-stone-700 dark:text-stone-300">{label}</p>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => onChange(n)}
|
||||
onMouseEnter={() => setHovered(n)}
|
||||
onMouseLeave={() => setHovered(0)}
|
||||
className="p-0.5 transition-transform hover:scale-110"
|
||||
aria-label={`${n} star${n > 1 ? 's' : ''}`}
|
||||
>
|
||||
<Star
|
||||
className={`h-8 w-8 transition-colors ${
|
||||
n <= (hovered || value)
|
||||
? 'fill-orange-400 text-orange-400'
|
||||
: 'fill-stone-200 text-stone-200 dark:fill-stone-700 dark:text-stone-700'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewPageContent() {
|
||||
const params = useSearchParams()
|
||||
const token = params.get('token') ?? ''
|
||||
|
||||
const [info, setInfo] = useState<ReviewInfo | null>(null)
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'already_reviewed' | 'invalid'>('loading')
|
||||
|
||||
const [overall, setOverall] = useState(0)
|
||||
const [vehicle, setVehicle] = useState(0)
|
||||
const [service, setService] = useState(0)
|
||||
const [comment, setComment] = useState('')
|
||||
const [submitState, setSubmitState] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) { setLoadState('invalid'); return }
|
||||
fetch(`${API_BASE}/marketplace/review/${token}`)
|
||||
.then(async (r) => {
|
||||
if (r.status === 409) { setLoadState('already_reviewed'); return }
|
||||
if (!r.ok) { setLoadState('invalid'); return }
|
||||
const json = await r.json()
|
||||
setInfo(json.data)
|
||||
setLoadState('ready')
|
||||
})
|
||||
.catch(() => setLoadState('invalid'))
|
||||
}, [token])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (overall === 0) { setErrorMsg('Please select an overall rating.'); return }
|
||||
setSubmitState('submitting')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/marketplace/review/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
overallRating: overall,
|
||||
vehicleRating: vehicle || undefined,
|
||||
serviceRating: service || undefined,
|
||||
comment: comment.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
if (res.status === 409) { setLoadState('already_reviewed'); return }
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
throw new Error(json.message ?? 'Something went wrong.')
|
||||
}
|
||||
setSubmitState('done')
|
||||
} catch (err: any) {
|
||||
setSubmitState('error')
|
||||
setErrorMsg(err.message ?? 'Something went wrong. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
if (loadState === 'loading') {
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">Loading…</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadState === 'invalid') {
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<div className="card w-full max-w-md space-y-3 p-8 text-center">
|
||||
<p className="text-2xl font-black text-stone-900 dark:text-stone-100">Link not found</p>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">This review link is invalid or has already been used.</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadState === 'already_reviewed') {
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<div className="card w-full max-w-md space-y-3 p-8 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
|
||||
<svg className="h-7 w-7 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-stone-900 dark:text-stone-100">Already reviewed</p>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">You have already submitted a review for this rental. Thank you!</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (submitState === 'done') {
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<div className="card w-full max-w-md space-y-4 p-8 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
|
||||
<svg className="h-8 w-8 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-black text-stone-900 dark:text-stone-100">Thank you!</h1>
|
||||
<p className="text-stone-500 dark:text-stone-400">Your review has been submitted and will be visible on {info?.companyName}'s profile.</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<div className="card w-full max-w-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 border-b border-stone-200 dark:border-blue-900 p-6">
|
||||
{info?.vehiclePhoto ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={info.vehiclePhoto} alt={info.vehicle} className="h-16 w-24 flex-shrink-0 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div className="h-16 w-24 flex-shrink-0 rounded-xl bg-stone-100 dark:bg-blue-900/30" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs uppercase tracking-widest text-stone-400 dark:text-stone-500">{info?.companyName}</p>
|
||||
<h1 className="mt-0.5 truncate text-lg font-bold text-stone-900 dark:text-stone-100">{info?.vehicle}</h1>
|
||||
<p className="mt-0.5 text-sm text-stone-500 dark:text-stone-400">How was your experience?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 p-6">
|
||||
<StarRating value={overall} onChange={setOverall} label="Overall experience *" />
|
||||
<StarRating value={vehicle} onChange={setVehicle} label="Vehicle condition (optional)" />
|
||||
<StarRating value={service} onChange={setService} label="Customer service (optional)" />
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">Comments (optional)</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Tell us about your rental experience…"
|
||||
className="w-full resize-none rounded-xl border border-stone-200 dark:border-blue-800 bg-white dark:bg-blue-950 px-4 py-3 text-sm text-stone-900 dark:text-stone-100 placeholder:text-stone-400 dark:placeholder:text-stone-500 focus:outline-none focus:ring-2 focus:ring-stone-900 dark:focus:ring-stone-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<p className="rounded-xl bg-rose-50 dark:bg-rose-950/40 px-4 py-3 text-sm text-rose-700 dark:text-rose-400">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitState === 'submitting'}
|
||||
className="w-full rounded-full bg-orange-600 py-3 text-sm font-semibold text-white transition-colors hover:bg-orange-700 disabled:opacity-60 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
|
||||
>
|
||||
{submitState === 'submitting' ? 'Submitting…' : 'Submit review'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReviewPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">Loading…</p>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<ReviewPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { StateAction, StateActions, StateShell } from '@/components/app-shell/StateShell';
|
||||
import { SignInForm } from '@/components/auth/SignInForm';
|
||||
import { ForgotPasswordForm } from '@/components/auth/ForgotPasswordForm';
|
||||
import { ResetPasswordForm } from '@/components/auth/ResetPasswordForm';
|
||||
import {
|
||||
isLocale,
|
||||
localizedPath,
|
||||
routeIdFromSlug,
|
||||
type Locale,
|
||||
type RouteId,
|
||||
} from '@/lib/localization/config';
|
||||
import { getMessages, type ShellMessages } from '@/lib/localization/messages';
|
||||
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface PendingRouteProps {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}
|
||||
|
||||
function routeLabel(messages: ShellMessages, routeId: RouteId): string {
|
||||
const labels = messages.footer.links;
|
||||
switch (routeId) {
|
||||
case 'sign-in':
|
||||
return labels.login;
|
||||
case 'forgot-password':
|
||||
return 'Forgot password';
|
||||
case 'reset-password':
|
||||
return 'Reset password';
|
||||
case 'privacy':
|
||||
return labels.privacy;
|
||||
case 'terms':
|
||||
return labels.terms;
|
||||
case 'accessibility':
|
||||
return labels.accessibility;
|
||||
case 'home':
|
||||
return 'RentalDriveGo';
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveParams(params: PendingRouteProps['params']) {
|
||||
const { locale: localeValue, slug } = await params;
|
||||
if (!isLocale(localeValue)) notFound();
|
||||
const routeId = routeIdFromSlug(localeValue, slug);
|
||||
if (!routeId || routeId === 'home') notFound();
|
||||
return { locale: localeValue as Locale, routeId };
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PendingRouteProps): Promise<Metadata> {
|
||||
const { locale, routeId } = await resolveParams(params);
|
||||
const messages = getMessages(locale).shell;
|
||||
const label = routeLabel(messages, routeId);
|
||||
return buildLocalizedMetadata({
|
||||
locale,
|
||||
routeId,
|
||||
title: `${label} | ${messages.metadata.pendingTitle}`,
|
||||
description: messages.metadata.pendingDescription,
|
||||
indexable: false,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function PendingRoute({ params }: PendingRouteProps) {
|
||||
const { locale, routeId } = await resolveParams(params);
|
||||
const messages = getMessages(locale).shell;
|
||||
const label = routeLabel(messages, routeId);
|
||||
|
||||
if (routeId === 'sign-in') {
|
||||
return <SignInForm locale={locale} />;
|
||||
}
|
||||
|
||||
if (routeId === 'forgot-password') {
|
||||
return <ForgotPasswordForm locale={locale} />;
|
||||
}
|
||||
|
||||
if (routeId === 'reset-password') {
|
||||
return <ResetPasswordForm locale={locale} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<StateShell
|
||||
title={`${label}: ${messages.states.pendingTitle}`}
|
||||
body={messages.states.pendingBody}
|
||||
>
|
||||
<StateActions>
|
||||
<StateAction href={localizedPath('home', locale)}>{messages.states.backHome}</StateAction>
|
||||
</StateActions>
|
||||
</StateShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/actions/Button';
|
||||
import { Accordion } from '@/components/controls/Accordion';
|
||||
import { SegmentedControl } from '@/components/controls/SegmentedControl';
|
||||
import { Tabs } from '@/components/controls/Tabs';
|
||||
import { Alert } from '@/components/feedback/Alert';
|
||||
import { Checkbox, Switch } from '@/components/forms/ChoiceControls';
|
||||
import { FormField } from '@/components/forms/FormField';
|
||||
import { TextInput } from '@/components/forms/TextInput';
|
||||
import { DropdownMenu } from '@/components/overlays/DropdownMenu';
|
||||
import { Dialog } from '@/components/overlays/Dialog';
|
||||
import type { ComponentLabCopy } from '@/content/development/component-lab';
|
||||
import { useRef, useState } from 'react';
|
||||
import styles from './page.module.css';
|
||||
|
||||
export function ComponentLabInteractive({ copy }: { copy: ComponentLabCopy }) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [segment, setSegment] = useState('comfortable');
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
return (
|
||||
<div className={styles.interactiveGrid}>
|
||||
<section className={styles.group} aria-labelledby="lab-actions">
|
||||
<h2 id="lab-actions">Actions</h2>
|
||||
<div className={styles.cluster}>
|
||||
<Button>{copy.actions.primary}</Button>
|
||||
<Button intent="conversion">{copy.actions.conversion}</Button>
|
||||
<Button loading>{copy.actions.loading}</Button>
|
||||
<Button disabled>{copy.actions.disabled}</Button>
|
||||
<Button ref={triggerRef} intent="secondary" onClick={() => setDialogOpen(true)}>
|
||||
{copy.dialog.open}
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
label={copy.menu.label}
|
||||
items={[
|
||||
{ id: 'review', label: copy.menu.first, onSelect: () => undefined },
|
||||
{
|
||||
id: 'archive',
|
||||
label: copy.menu.second,
|
||||
onSelect: () => undefined,
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.group} aria-labelledby="lab-form">
|
||||
<h2 id="lab-form">Form controls</h2>
|
||||
<FormField
|
||||
id="fixture-email"
|
||||
label={copy.form.label}
|
||||
supportingText={copy.form.supporting}
|
||||
error={copy.form.error}
|
||||
required
|
||||
>
|
||||
<TextInput
|
||||
id="fixture-email"
|
||||
type="email"
|
||||
placeholder={copy.form.placeholder}
|
||||
invalid
|
||||
describedBy="fixture-email-help fixture-email-error"
|
||||
/>
|
||||
</FormField>
|
||||
<Checkbox label={copy.form.checkbox} />
|
||||
<Switch label={copy.form.switch} />
|
||||
<SegmentedControl
|
||||
label="Density"
|
||||
value={segment}
|
||||
onChange={setSegment}
|
||||
options={[
|
||||
{ value: 'comfortable', label: 'Comfortable' },
|
||||
{ value: 'compact', label: 'Compact' },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
<section className={styles.group} aria-labelledby="lab-controls">
|
||||
<h2 id="lab-controls">Selection and disclosure</h2>
|
||||
<Tabs label={copy.tabs.label} items={copy.tabs.items} />
|
||||
<Accordion items={copy.accordion.items} />
|
||||
</section>
|
||||
<Alert tone="warning" title={copy.alert.title}>
|
||||
{copy.alert.body}
|
||||
</Alert>
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={copy.dialog.title}
|
||||
description={copy.dialog.description}
|
||||
closeLabel={copy.dialog.close}
|
||||
returnFocusRef={triggerRef}
|
||||
>
|
||||
<p>{copy.dialog.content}</p>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.main {
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
.interactiveGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.group {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--space-4);
|
||||
min-inline-size: 0;
|
||||
padding: var(--space-6);
|
||||
border: 1px solid var(--border-standard);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
.group h2 {
|
||||
margin: 0;
|
||||
font-size: var(--type-heading-3-size);
|
||||
}
|
||||
.cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.interactiveGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
|
||||
import { Comparison } from '@/components/marketing/Comparison';
|
||||
import { CTA } from '@/components/marketing/CTA';
|
||||
import { Metric } from '@/components/marketing/Evidence';
|
||||
import { ProductPreview } from '@/components/marketing/ProductPreview';
|
||||
import { SectionHeader } from '@/components/marketing/SectionHeader';
|
||||
import { Workflow } from '@/components/marketing/Workflow';
|
||||
import { getComponentLabCopy } from '@/content/development/component-lab';
|
||||
import { isLocale, type Locale } from '@/lib/localization/config';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ComponentLabInteractive } from './ComponentLabInteractive';
|
||||
import styles from './page.module.css';
|
||||
|
||||
export const metadata = { robots: { index: false, follow: false } };
|
||||
|
||||
export default async function ComponentLabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
if (process.env.COMPONENT_FIXTURES_ENABLED !== 'true') notFound();
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
const resolvedLocale: Locale = locale;
|
||||
const copy = getComponentLabCopy(resolvedLocale);
|
||||
return (
|
||||
<main id="main-content" className={styles.main}>
|
||||
<Section>
|
||||
<Container>
|
||||
<Stack gap="large">
|
||||
<SectionHeader
|
||||
eyebrow={copy.developmentLabel}
|
||||
title={copy.title}
|
||||
body={copy.body}
|
||||
headingLevel={1}
|
||||
/>
|
||||
<ComponentLabInteractive copy={copy} />
|
||||
<Workflow label={copy.title} items={copy.workflow} />
|
||||
<Comparison
|
||||
columns={[
|
||||
{
|
||||
title: copy.comparison.beforeTitle,
|
||||
items: copy.comparison.before,
|
||||
tone: 'problem',
|
||||
},
|
||||
{
|
||||
title: copy.comparison.afterTitle,
|
||||
items: copy.comparison.after,
|
||||
tone: 'solution',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProductPreview
|
||||
title={copy.preview.title}
|
||||
caption={copy.preview.caption}
|
||||
illustrativeLabel={copy.preview.illustrative}
|
||||
rows={copy.preview.rows.map((row, index) => ({
|
||||
...row,
|
||||
statusTone: index === 0 ? 'success' : 'warning',
|
||||
}))}
|
||||
/>
|
||||
<Metric
|
||||
content={{
|
||||
value: copy.metric.value,
|
||||
label: copy.metric.label,
|
||||
qualification: copy.metric.qualification,
|
||||
status: 'research-only',
|
||||
}}
|
||||
statusLabel={copy.metric.status}
|
||||
/>
|
||||
<CTA
|
||||
title={copy.cta.title}
|
||||
body={copy.cta.body}
|
||||
primary={{ label: copy.cta.primary, disabledReason: copy.cta.unavailable }}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { StateButton, StateActions, StateShell } from '@/components/app-shell/StateShell';
|
||||
import { getClientShellMessages } from '@/lib/localization/client-messages';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
export default function ErrorPage({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const params = useParams<{ locale?: string }>();
|
||||
const messages = getClientShellMessages(params?.locale);
|
||||
|
||||
return (
|
||||
<StateShell title={messages.states.errorTitle} body={messages.states.errorBody}>
|
||||
<StateActions>
|
||||
<StateButton onClick={reset}>{messages.states.retry}</StateButton>
|
||||
</StateActions>
|
||||
</StateShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import '@fontsource-variable/inter/index.css';
|
||||
import '@fontsource-variable/noto-sans-arabic/index.css';
|
||||
import '@/styles/tokens.css';
|
||||
import '@/styles/component-tokens.css';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
import { SiteFooter } from '@/components/app-shell/SiteFooter';
|
||||
import { DemoDialogHost } from '@/components/integrations/DemoDialogHost';
|
||||
import { SiteHeader } from '@/components/app-shell/SiteHeader';
|
||||
import { ThemeController } from '@/components/app-shell/ThemeController';
|
||||
import { getPublicIntegrationConfig } from '@/lib/integrations/environment';
|
||||
import { getDirection, isLocale, type Locale } from '@/lib/localization/config';
|
||||
import { getMessages } from '@/lib/localization/messages';
|
||||
import { themeBootstrapScript } from '@/lib/theme/bootstrap-script';
|
||||
import {
|
||||
isThemePreference,
|
||||
serverResolvedTheme,
|
||||
themeCookie,
|
||||
type ThemePreference,
|
||||
} from '@/lib/theme/config';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
icons: {
|
||||
icon: '/rentaldrivego.jpeg',
|
||||
apple: '/rentaldrivego.jpeg',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface LocaleLayoutProps {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
|
||||
const { locale: localeValue } = await params;
|
||||
if (!isLocale(localeValue)) notFound();
|
||||
const locale: Locale = localeValue;
|
||||
const messages = getMessages(locale);
|
||||
const integrationConfig = getPublicIntegrationConfig();
|
||||
const requestHeaders = await headers();
|
||||
const nonce = requestHeaders.get('x-nonce') ?? undefined;
|
||||
const cookieStore = await cookies();
|
||||
const cookiePreference = cookieStore.get(themeCookie)?.value;
|
||||
const preference: ThemePreference = isThemePreference(cookiePreference)
|
||||
? cookiePreference
|
||||
: 'system';
|
||||
const resolvedTheme = serverResolvedTheme(preference);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
dir={getDirection(locale)}
|
||||
data-theme-preference={preference}
|
||||
data-theme={resolvedTheme}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
id="theme-bootstrap"
|
||||
nonce={nonce}
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: themeBootstrapScript }}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<ThemeController />
|
||||
<a className="skip-link" href="#main-content">
|
||||
{messages.shell.skipToContent}
|
||||
</a>
|
||||
<SiteHeader
|
||||
locale={locale}
|
||||
messages={messages.shell}
|
||||
themePreference={preference}
|
||||
demoEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
{children}
|
||||
<DemoDialogHost
|
||||
locale={locale}
|
||||
messages={messages.homepage.form}
|
||||
enabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
<SiteFooter
|
||||
locale={locale}
|
||||
messages={messages.shell}
|
||||
demoEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LocalizedLoading } from '@/components/app-shell/LocalizedLoading';
|
||||
|
||||
export default function Loading() {
|
||||
return <LocalizedLoading />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LocalizedNotFound } from '@/components/app-shell/LocalizedNotFound';
|
||||
|
||||
export default function NotFound() {
|
||||
return <LocalizedNotFound />;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import styles from '@/components/homepage/Homepage.module.css';
|
||||
import {
|
||||
ComparisonSection,
|
||||
FaqSection,
|
||||
FinalCtaSection,
|
||||
HeroSection,
|
||||
IntegrationsSection,
|
||||
ModulesSection,
|
||||
PricingSection,
|
||||
ResultsSection,
|
||||
RolesSection,
|
||||
SecuritySection,
|
||||
TrustSection,
|
||||
WorkflowSection,
|
||||
} from '@/components/homepage/sections';
|
||||
import { buildHomepageContent } from '@/content/homepage-model';
|
||||
import { getPublicIntegrationConfig } from '@/lib/integrations/environment';
|
||||
import { isLocale, localizedPath, type Locale } from '@/lib/localization/config';
|
||||
import { getMessages } from '@/lib/localization/messages';
|
||||
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface LocalePageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: LocalePageProps): Promise<Metadata> {
|
||||
const { locale: localeValue } = await params;
|
||||
if (!isLocale(localeValue)) notFound();
|
||||
const messages = getMessages(localeValue);
|
||||
return buildLocalizedMetadata({
|
||||
locale: localeValue,
|
||||
routeId: 'home',
|
||||
title: messages.homepage.meta.title,
|
||||
description: messages.homepage.meta.description,
|
||||
indexable: true,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function LocaleHomePage({ params }: LocalePageProps) {
|
||||
const { locale: localeValue } = await params;
|
||||
if (!isLocale(localeValue)) notFound();
|
||||
|
||||
const locale: Locale = localeValue;
|
||||
const messages = getMessages(locale);
|
||||
const content = buildHomepageContent(messages.homepage, messages.shell);
|
||||
const integrationConfig = getPublicIntegrationConfig();
|
||||
const homePath = localizedPath('home', locale);
|
||||
|
||||
return (
|
||||
<main id="main-content" className={styles.main} tabIndex={-1}>
|
||||
<HeroSection
|
||||
content={content.hero}
|
||||
homePath={homePath}
|
||||
demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
<TrustSection content={content.trust} />
|
||||
<ComparisonSection content={content.comparison} />
|
||||
<WorkflowSection content={content.workflow} />
|
||||
<RolesSection content={content.roles} />
|
||||
<ModulesSection content={content.modules} />
|
||||
<ResultsSection content={content.results} />
|
||||
<IntegrationsSection content={content.integrations} />
|
||||
<SecuritySection content={content.security} />
|
||||
<PricingSection
|
||||
content={content.pricing}
|
||||
locale={locale}
|
||||
homePath={homePath}
|
||||
demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
<FaqSection content={content.faq} />
|
||||
<FinalCtaSection
|
||||
content={content.final}
|
||||
demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
demoSubmissionEnvelopeSchema,
|
||||
zodFieldErrors,
|
||||
type DemoSubmissionResult,
|
||||
} from '@/lib/demo/schema';
|
||||
import { submitDemoLead } from '@/lib/demo/service';
|
||||
import { getIntegrationEnvironment } from '@/lib/integrations/environment';
|
||||
import { isApplicationJson } from '@/lib/security/content-type';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const maximumBodyBytes = 16_384;
|
||||
const responseHeaders = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
|
||||
|
||||
function failure(
|
||||
status: number,
|
||||
result: Extract<DemoSubmissionResult, { ok: false }>,
|
||||
): NextResponse<DemoSubmissionResult> {
|
||||
return NextResponse.json(result, { status, headers: responseHeaders });
|
||||
}
|
||||
|
||||
function isAllowedOrigin(
|
||||
request: Request,
|
||||
approvedOrigin: string | undefined,
|
||||
production: boolean,
|
||||
) {
|
||||
const origin = request.headers.get('origin');
|
||||
if (!origin) return !production;
|
||||
const requestOrigin = new URL(request.url).origin;
|
||||
if (origin === requestOrigin) return true;
|
||||
return approvedOrigin ? origin === new URL(approvedOrigin).origin : false;
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse<DemoSubmissionResult>> {
|
||||
const correlationId = randomUUID();
|
||||
let environment;
|
||||
try {
|
||||
environment = getIntegrationEnvironment();
|
||||
} catch {
|
||||
return failure(503, {
|
||||
ok: false,
|
||||
category: 'configuration',
|
||||
formErrorCode: 'integration_environment_invalid',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (request.headers.get('x-rdg-form') !== 'demo-v1') {
|
||||
return failure(403, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'invalid_request_marker',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isAllowedOrigin(request, environment.SITE_ORIGIN, environment.NODE_ENV === 'production')) {
|
||||
return failure(403, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'invalid_origin',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = request.headers.get('content-type');
|
||||
if (!isApplicationJson(contentType)) {
|
||||
return failure(415, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'unsupported_content_type',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const declaredLength = Number(request.headers.get('content-length') ?? '0');
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maximumBodyBytes) {
|
||||
return failure(413, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'request_too_large',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
if (new TextEncoder().encode(raw).byteLength > maximumBodyBytes) {
|
||||
return failure(413, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'request_too_large',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
return failure(400, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'malformed_json',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = demoSubmissionEnvelopeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return failure(400, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
fieldErrors: zodFieldErrors(parsed.error),
|
||||
formErrorCode: 'validation_failed',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.guard.honeypot.length > 0) {
|
||||
return failure(400, {
|
||||
ok: false,
|
||||
category: 'abuse',
|
||||
formErrorCode: 'request_rejected',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const scenarioHeader = request.headers.get('x-rdg-test-scenario');
|
||||
const testScenario =
|
||||
environment.NODE_ENV !== 'production' &&
|
||||
environment.DEMO_TEST_SCENARIOS_ENABLED &&
|
||||
['success', 'duplicate', 'timeout', 'integration-error'].includes(scenarioHeader ?? '')
|
||||
? (scenarioHeader as 'success' | 'duplicate' | 'timeout' | 'integration-error')
|
||||
: undefined;
|
||||
|
||||
const result = await submitDemoLead(parsed.data.lead, {
|
||||
environment,
|
||||
...(testScenario ? { testScenario } : {}),
|
||||
});
|
||||
|
||||
const status = result.ok
|
||||
? 202
|
||||
: result.category === 'timeout'
|
||||
? 504
|
||||
: result.category === 'configuration'
|
||||
? 503
|
||||
: result.category === 'duplicate'
|
||||
? 409
|
||||
: 502;
|
||||
return NextResponse.json(result, { status, headers: responseHeaders });
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-white text-blue-950 antialiased transition-colors dark:text-slate-100;
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
background-image:
|
||||
linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-[2rem] border shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:shadow-[0_30px_80px_rgba(0,0,0,0.36)];
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .card {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 0.72);
|
||||
}
|
||||
|
||||
.site-page {
|
||||
@apply min-h-screen overflow-hidden text-blue-950;
|
||||
background-image:
|
||||
linear-gradient(180deg, #ffffff 0%, #f5f8ff 28%, #eef4ff 58%, #ffffff 100%);
|
||||
}
|
||||
|
||||
html.dark .site-page {
|
||||
@apply text-slate-100;
|
||||
background-image:
|
||||
linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 100%);
|
||||
}
|
||||
|
||||
.site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(234, 88, 12, 0.24), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
html.dark .site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(251, 146, 60, 0.22), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(96, 165, 250, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
.site-section {
|
||||
@apply shell py-10 sm:py-14 lg:py-16;
|
||||
}
|
||||
|
||||
.site-panel {
|
||||
@apply rounded-[2rem] border p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .site-panel {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 0.72);
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.site-panel-muted {
|
||||
@apply rounded-[2rem] border p-7 transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-image: linear-gradient(160deg, #f5f8ff 0%, #edf2ff 100%);
|
||||
}
|
||||
|
||||
html.dark .site-panel-muted {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-image: linear-gradient(160deg, #0c1830 0%, #10203e 100%);
|
||||
}
|
||||
|
||||
.site-panel-contrast {
|
||||
@apply rounded-[2rem] border p-7 text-white shadow-[0_30px_80px_rgba(28,25,23,0.18)] transition-colors sm:p-8;
|
||||
border-color: rgb(14 40 90 / 0.8);
|
||||
background-color: rgb(10 25 75);
|
||||
}
|
||||
|
||||
html.dark .site-panel-contrast {
|
||||
border-color: rgb(30 64 175);
|
||||
}
|
||||
|
||||
.site-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.28em] text-orange-600 dark:text-orange-400;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
@apply mt-4 text-4xl font-black tracking-[-0.04em] text-blue-950 dark:text-white sm:text-5xl;
|
||||
}
|
||||
|
||||
.site-lead {
|
||||
@apply mt-5 text-base leading-8 text-stone-600 dark:text-slate-300 sm:text-lg;
|
||||
}
|
||||
|
||||
.site-link-primary {
|
||||
@apply rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400;
|
||||
}
|
||||
|
||||
.site-link-secondary {
|
||||
@apply rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-100 dark:hover:border-blue-400 dark:hover:text-white;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import MarketplaceShell from '@/components/MarketplaceShell'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FleetOS Marketplace',
|
||||
description: 'Discover vehicles from trusted rental companies.',
|
||||
icons: {
|
||||
icon: '/rentalcardrive.png',
|
||||
shortcut: '/favicon.ico',
|
||||
apple: '/rentalcardrive.png',
|
||||
},
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const language = await getMarketplaceLanguage()
|
||||
const cookieStore = await cookies()
|
||||
const rawTheme =
|
||||
cookieStore.get('rentaldrivego-theme')?.value ??
|
||||
cookieStore.get('marketplace-theme')?.value
|
||||
const theme = rawTheme === 'dark' ? 'dark' : 'light'
|
||||
|
||||
return (
|
||||
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Runs before hydration to prevent flash of wrong theme */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('marketplace-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<MarketplaceShell initialLanguage={language} initialTheme={theme}>{children}</MarketplaceShell>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { getSiteOrigin } from '@/lib/localization/site-origin';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const origin = getSiteOrigin();
|
||||
const approved = process.env.PUBLIC_RELEASE_APPROVED === 'true';
|
||||
return {
|
||||
rules: approved ? { userAgent: '*', allow: '/' } : { userAgent: '*', disallow: '/' },
|
||||
host: origin.href,
|
||||
};
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface Feature {
|
||||
title: string
|
||||
body: string
|
||||
icon?: LucideIcon
|
||||
}
|
||||
|
||||
interface FeatureAlternatingProps {
|
||||
features: Feature[]
|
||||
}
|
||||
|
||||
export function FeatureAlternating({ features }: FeatureAlternatingProps) {
|
||||
return (
|
||||
<div className="space-y-16 lg:space-y-20">
|
||||
{features.map((feature, index) => {
|
||||
const Icon = feature.icon
|
||||
const isEven = index % 2 === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.title}
|
||||
className={`grid gap-12 items-center ${isEven ? 'lg:grid-cols-[1.1fr_0.9fr]' : 'lg:grid-cols-[0.9fr_1.1fr]'}`}
|
||||
>
|
||||
<div className={isEven ? 'order-1 lg:order-none' : 'order-2 lg:order-none'}>
|
||||
<div className="site-panel">
|
||||
<div className="flex items-start gap-3">
|
||||
{Icon && <Icon className="mt-1 h-6 w-6 shrink-0 text-orange-600 dark:text-orange-500" />}
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-blue-950 dark:text-white sm:text-3xl">
|
||||
{feature.title}
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-8 text-stone-600 dark:text-stone-300 sm:text-lg">
|
||||
{feature.body}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`order-2 lg:order-none ${isEven ? 'order-2 lg:order-none' : 'order-1 lg:order-none'}`}
|
||||
>
|
||||
<div className="site-panel-muted flex h-48 items-center justify-center sm:h-56 lg:h-80">
|
||||
{Icon ? (
|
||||
<Icon className="h-24 w-24 text-orange-600/20 dark:text-orange-500/20" />
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-semibold text-stone-400 dark:text-stone-600">
|
||||
{feature.title}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const preferenceState = vi.hoisted(() => ({ language: 'en' as 'en' | 'fr' | 'ar' }))
|
||||
|
||||
vi.mock('@/components/MarketplaceShell', () => ({
|
||||
useMarketplacePreferences: () => ({ language: preferenceState.language, theme: 'light' }),
|
||||
}))
|
||||
|
||||
import FooterContentPage from './FooterContentPage'
|
||||
|
||||
function collectText(node: unknown): string[] {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return []
|
||||
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
|
||||
if (Array.isArray(node)) return node.flatMap(collectText)
|
||||
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
|
||||
return []
|
||||
}
|
||||
|
||||
function collectElements(node: unknown): React.ReactElement[] {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return []
|
||||
if (Array.isArray(node)) return node.flatMap(collectElements)
|
||||
if (!isValidElement<{ children?: React.ReactNode }>(node)) return []
|
||||
return [node, ...collectElements(node.props.children)]
|
||||
}
|
||||
|
||||
describe('FooterContentPage', () => {
|
||||
beforeEach(() => {
|
||||
preferenceState.language = 'en'
|
||||
})
|
||||
|
||||
it('uses the current marketplace language when no forced language is provided', () => {
|
||||
preferenceState.language = 'fr'
|
||||
const page = FooterContentPage({ slug: 'contact-sales' })
|
||||
const text = collectText(page).join(' ')
|
||||
|
||||
expect(text).toContain('Informations du pied de page')
|
||||
expect(text).toContain('Besoin de plus de détails')
|
||||
})
|
||||
|
||||
it('lets static app policy pages force their own locale', () => {
|
||||
preferenceState.language = 'en'
|
||||
const page = FooterContentPage({ slug: 'terms-of-service', forcedLanguage: 'fr' })
|
||||
const text = collectText(page).join(' ')
|
||||
|
||||
expect(text).toContain('Informations du pied de page')
|
||||
expect(text).not.toContain('Footer information')
|
||||
})
|
||||
|
||||
it('marks Arabic content as right-aligned', () => {
|
||||
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'ar' })
|
||||
const elements = collectElements(page)
|
||||
|
||||
expect(elements.some((element) => String(element.props.className ?? '').includes('text-right'))).toBe(true)
|
||||
})
|
||||
|
||||
it('turns plain URLs embedded in policy paragraphs into safe anchors', () => {
|
||||
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'en' })
|
||||
const links = collectElements(page).filter((element) => element.type === 'a')
|
||||
|
||||
expect(links.some((link) => String(link.props.href).startsWith('https://'))).toBe(true)
|
||||
expect(links.every((link) => link.props.href === collectText(link).join(''))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent'
|
||||
import { type MarketplaceLanguage } from '@/lib/i18n'
|
||||
|
||||
const pageMeta = {
|
||||
en: {
|
||||
kicker: 'Footer information',
|
||||
cta: 'Need more details?',
|
||||
support: 'Contact the RentalDriveGo team through our official channels for business, support, or partnership requests.',
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Informations du pied de page',
|
||||
cta: 'Besoin de plus de détails ?',
|
||||
support: "Contactez l'équipe RentalDriveGo via nos canaux officiels pour toute demande commerciale, support ou partenariat.",
|
||||
},
|
||||
ar: {
|
||||
kicker: 'معلومات التذييل',
|
||||
cta: 'هل تحتاج إلى مزيد من التفاصيل؟',
|
||||
support: 'تواصل مع فريق RentalDriveGo عبر القنوات الرسمية للاستفسارات التجارية أو الدعم أو الشراكات.',
|
||||
},
|
||||
} as const
|
||||
|
||||
const urlPattern = /(https?:\/\/[^\s]+)/g
|
||||
|
||||
function renderParagraphText(paragraph: string) {
|
||||
const parts = paragraph.split(urlPattern)
|
||||
return parts.map((part, index) => {
|
||||
if (urlPattern.test(part)) {
|
||||
urlPattern.lastIndex = 0
|
||||
const match = part.match(/^(https?:\/\/[^\s]+?)([.,!?;:]*)$/)
|
||||
const href = match?.[1] ?? part
|
||||
const trailing = match?.[2] ?? ''
|
||||
|
||||
return (
|
||||
<span key={`${part}-${index}`}>
|
||||
<a
|
||||
href={href}
|
||||
className="text-orange-700 underline decoration-orange-300 underline-offset-4 transition hover:text-blue-900 dark:text-orange-300 dark:hover:text-stone-100"
|
||||
>
|
||||
{href}
|
||||
</a>
|
||||
{trailing}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
urlPattern.lastIndex = 0
|
||||
return <span key={`${part}-${index}`}>{part}</span>
|
||||
})
|
||||
}
|
||||
|
||||
export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: MarketplaceLanguage }) {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const activeLanguage = forcedLanguage ?? language
|
||||
const content = getFooterPageContent(activeLanguage, slug)
|
||||
const meta = pageMeta[activeLanguage]
|
||||
const isArabic = activeLanguage === 'ar'
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section mx-auto max-w-4xl">
|
||||
<section className="site-panel overflow-hidden p-0">
|
||||
<div className="site-panel-muted rounded-none border-0 border-b border-stone-100/80 px-6 py-10 sm:px-10 sm:py-14 dark:border-blue-900">
|
||||
<p className="site-kicker">
|
||||
{meta.kicker}
|
||||
</p>
|
||||
<h1 className="site-title">
|
||||
{content.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-6 px-6 py-10 sm:px-10 sm:py-12 ${isArabic ? 'text-right' : 'text-left'}`}>
|
||||
{content.paragraphs.map((paragraph) => (
|
||||
<p key={paragraph} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
|
||||
{renderParagraphText(paragraph)}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{content.sections?.map((section) => (
|
||||
<section key={section.heading} className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-stone-900 dark:text-stone-100">
|
||||
{section.heading}
|
||||
</h2>
|
||||
{section.paragraphs.map((paragraph) => (
|
||||
<p key={`${section.heading}-${paragraph}`} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
|
||||
{renderParagraphText(paragraph)}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className="rounded-3xl border border-orange-200 bg-orange-50 px-6 py-5 dark:border-orange-800 dark:bg-orange-950/30">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-orange-800 dark:text-orange-400">
|
||||
{meta.cta}
|
||||
</p>
|
||||
<p className="mt-2 text-base leading-7 text-stone-700 dark:text-stone-300">{meta.support}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface Step {
|
||||
number: string
|
||||
title: string
|
||||
description: string
|
||||
icon?: LucideIcon
|
||||
}
|
||||
|
||||
interface HowItWorksProps {
|
||||
steps: Step[]
|
||||
kicker?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function HowItWorks({ steps, kicker = 'GETTING STARTED', title = 'How It Works' }: HowItWorksProps) {
|
||||
const [activeStep, setActiveStep] = useState(0)
|
||||
|
||||
return (
|
||||
<section className="site-section">
|
||||
<div className="site-panel">
|
||||
<p className="site-kicker">{kicker}</p>
|
||||
<h2 className="site-title">{title}</h2>
|
||||
|
||||
<div className="mt-12 space-y-4 lg:space-y-6">
|
||||
{steps.map((step, index) => {
|
||||
const Icon = step.icon
|
||||
const isActive = activeStep === index
|
||||
return (
|
||||
<button
|
||||
key={step.number}
|
||||
onClick={() => setActiveStep(index)}
|
||||
className={`w-full flex gap-6 rounded-[1.5rem] border p-6 transition-all sm:p-8 ${
|
||||
isActive
|
||||
? 'border-orange-600 bg-orange-50 shadow-lg dark:border-orange-500 dark:bg-orange-950/30'
|
||||
: 'border-stone-200/80 bg-white/85 hover:-translate-y-1 dark:border-blue-800 dark:bg-blue-950/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex shrink-0 flex-col items-center">
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full font-bold text-white transition-all sm:h-14 sm:w-14 ${
|
||||
isActive
|
||||
? 'scale-110 bg-orange-600 shadow-lg dark:bg-orange-500'
|
||||
: 'bg-orange-600 dark:bg-orange-500'
|
||||
}`}
|
||||
>
|
||||
{step.number}
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className="mt-4 h-8 w-1 bg-gradient-to-b from-orange-600 to-transparent dark:from-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pt-1 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
{Icon && (
|
||||
<Icon className="mt-1 h-5 w-5 shrink-0 text-orange-600 dark:text-orange-400" />
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-blue-950 dark:text-white">{step.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-stone-300">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
type LocaleOption = {
|
||||
value: 'en' | 'fr' | 'ar'
|
||||
label: string
|
||||
flag: string
|
||||
}
|
||||
|
||||
type FooterItem = {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
export default function MarketplaceFooter({
|
||||
primaryItems,
|
||||
secondaryItems,
|
||||
localeLabel,
|
||||
rightsLabel,
|
||||
localeOptions,
|
||||
currentLocale,
|
||||
onSelectLanguage,
|
||||
}: {
|
||||
primaryItems: FooterItem[]
|
||||
secondaryItems: FooterItem[]
|
||||
localeLabel: string
|
||||
rightsLabel: string
|
||||
localeOptions: LocaleOption[]
|
||||
currentLocale: LocaleOption
|
||||
onSelectLanguage: (language: LocaleOption['value']) => void
|
||||
}) {
|
||||
const localeMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (!localeMenuRef.current?.contains(event.target as Node)) {
|
||||
setLocaleMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<footer className="border-t border-stone-200/80 bg-white/72 px-4 py-8 text-stone-600 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-blue-950/72 dark:text-stone-300">
|
||||
<div className="shell flex flex-col items-center gap-5 text-center">
|
||||
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
|
||||
{primaryItems.map((item, index) => (
|
||||
<div key={item.label} className="flex items-center">
|
||||
<FooterNavItem item={item} />
|
||||
{index < primaryItems.length - 1 ? (
|
||||
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
|
||||
{secondaryItems.map((item) => (
|
||||
<div key={item.label} className="flex items-center">
|
||||
<FooterNavItem item={item} />
|
||||
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={localeMenuRef} className="relative px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLocaleMenuOpen((open) => !open)}
|
||||
className="inline-flex items-center gap-2 text-sky-600 transition hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
|
||||
aria-expanded={localeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span aria-hidden="true" className="text-base leading-none">{currentLocale.flag}</span>
|
||||
<span>{localeLabel}</span>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{localeMenuOpen ? (
|
||||
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
{localeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectLanguage(option.value)
|
||||
setLocaleMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
|
||||
>
|
||||
<span aria-hidden="true" className="text-base leading-none">{option.flag}</span>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">
|
||||
© {new Date().getFullYear()} FleetOS. {rightsLabel}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterNavItem({ item }: { item: FooterItem }) {
|
||||
const className = 'px-3 text-stone-600 transition hover:text-blue-900 dark:text-stone-300 dark:hover:text-white'
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<Link href={item.href} className={className}>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className={className}>{item.label}</span>
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildDashboardSignInHref,
|
||||
companyInitial,
|
||||
localeMenuPositionClass,
|
||||
ownerWorkspaceHref,
|
||||
} from './MarketplaceHeader'
|
||||
|
||||
describe('MarketplaceHeader helpers', () => {
|
||||
it('builds dashboard sign-in links with language and theme query parameters', () => {
|
||||
expect(buildDashboardSignInHref('https://app.example.com/dashboard', 'fr', 'dark')).toBe(
|
||||
'https://app.example.com/dashboard/sign-in?lang=fr&theme=dark'
|
||||
)
|
||||
expect(buildDashboardSignInHref('/dashboard', 'ar', 'light')).toBe('/dashboard/sign-in?lang=ar&theme=light')
|
||||
})
|
||||
|
||||
it('keeps the locale dropdown anchored to the readable side for RTL and LTR languages', () => {
|
||||
expect(localeMenuPositionClass('ar')).toBe('right-0 sm:right-0')
|
||||
expect(localeMenuPositionClass('en')).toBe('left-0 sm:left-auto sm:right-0')
|
||||
expect(localeMenuPositionClass('fr')).toBe('left-0 sm:left-auto sm:right-0')
|
||||
})
|
||||
|
||||
it('routes existing companies to their workspace and new owners to sign-up', () => {
|
||||
expect(ownerWorkspaceHref('https://dashboard.example.com', 'Atlas Cars')).toBe('https://dashboard.example.com')
|
||||
expect(ownerWorkspaceHref('https://dashboard.example.com', null)).toBe('https://dashboard.example.com/sign-up')
|
||||
})
|
||||
|
||||
it('normalizes company initials for the owner workspace pill', () => {
|
||||
expect(companyInitial('atlas cars')).toBe('A')
|
||||
expect(companyInitial(' زاكورة كار ')).toBe('ز')
|
||||
})
|
||||
})
|
||||
@@ -1,237 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type Theme = 'light' | 'dark'
|
||||
export type Language = 'en' | 'fr' | 'ar'
|
||||
|
||||
type Dictionary = {
|
||||
home: string
|
||||
features: string
|
||||
pricing: string
|
||||
signIn: string
|
||||
ownerSignIn: string
|
||||
theme: string
|
||||
light: string
|
||||
dark: string
|
||||
}
|
||||
|
||||
type LanguageMeta = {
|
||||
value: Language
|
||||
flag: string
|
||||
shortLabel: string
|
||||
}
|
||||
|
||||
|
||||
export function buildDashboardSignInHref(dashboardUrl: string, language: Language, theme: Theme): string {
|
||||
const signInParams = new URLSearchParams()
|
||||
signInParams.set('lang', language)
|
||||
signInParams.set('theme', theme)
|
||||
return `${dashboardUrl}/sign-in?${signInParams.toString()}`
|
||||
}
|
||||
|
||||
export function localeMenuPositionClass(language: Language): string {
|
||||
return language === 'ar' ? 'right-0 sm:right-0' : 'left-0 sm:left-auto sm:right-0'
|
||||
}
|
||||
|
||||
export function ownerWorkspaceHref(dashboardUrl: string, companyName: string | null): string {
|
||||
return companyName ? dashboardUrl : `${dashboardUrl}/sign-up`
|
||||
}
|
||||
|
||||
export function companyInitial(companyName: string): string {
|
||||
return companyName.trim().charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
export default function MarketplaceHeader({
|
||||
dict,
|
||||
theme,
|
||||
setTheme,
|
||||
companyName,
|
||||
dashboardUrl,
|
||||
currentLanguage,
|
||||
localeOptions,
|
||||
onSelectLanguage,
|
||||
}: {
|
||||
dict: Dictionary
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
companyName: string | null
|
||||
dashboardUrl: string
|
||||
currentLanguage: LanguageMeta
|
||||
localeOptions: LanguageMeta[]
|
||||
onSelectLanguage: (language: Language) => void
|
||||
}) {
|
||||
const localeMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
|
||||
const themeMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
|
||||
const themeOptions = [
|
||||
{ value: 'light' as const, label: dict.light },
|
||||
{ value: 'dark' as const, label: dict.dark },
|
||||
]
|
||||
const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0]
|
||||
const menuPositionClass = localeMenuPositionClass(currentLanguage.value)
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (!localeMenuRef.current?.contains(event.target as Node)) {
|
||||
setLocaleMenuOpen(false)
|
||||
}
|
||||
if (!themeMenuRef.current?.contains(event.target as Node)) {
|
||||
setThemeMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [])
|
||||
|
||||
function toggleLocaleMenu() {
|
||||
setThemeMenuOpen(false)
|
||||
setLocaleMenuOpen((open) => !open)
|
||||
}
|
||||
|
||||
function toggleThemeMenu() {
|
||||
setLocaleMenuOpen(false)
|
||||
setThemeMenuOpen((open) => !open)
|
||||
}
|
||||
|
||||
const signInHref = buildDashboardSignInHref(dashboardUrl, currentLanguage.value, theme)
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.32)]">
|
||||
<div className="shell flex min-h-14 flex-col gap-1.5 py-1.5 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link href="/" className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 dark:text-stone-100 sm:text-sm sm:tracking-[0.2em]">
|
||||
<Image
|
||||
src="/rentalcardrive.png"
|
||||
alt="FleetOS"
|
||||
width={36}
|
||||
height={36}
|
||||
priority
|
||||
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
|
||||
/>
|
||||
<span>FleetOS</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
|
||||
<nav className="flex items-center gap-0.5 overflow-x-auto">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{dict.home}
|
||||
</Link>
|
||||
<Link
|
||||
href="/features"
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{dict.features}
|
||||
</Link>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{dict.pricing}
|
||||
</Link>
|
||||
<a
|
||||
href={signInHref}
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{dict.signIn}
|
||||
</a>
|
||||
{companyName ? (
|
||||
<a
|
||||
href={dashboardUrl}
|
||||
className="ml-1 flex items-center gap-1.5 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:gap-2 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-blue-950/20">
|
||||
{companyInitial(companyName)}
|
||||
</span>
|
||||
{companyName}
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={ownerWorkspaceHref(dashboardUrl, companyName)}
|
||||
className="ml-1 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{dict.ownerSignIn}
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/85 sm:self-auto sm:p-1">
|
||||
<div ref={localeMenuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLocaleMenu}
|
||||
className="inline-flex min-w-[4.75rem] items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-semibold text-stone-700 transition hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-blue-900/40 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs"
|
||||
aria-expanded={localeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span aria-hidden="true">{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.shortLabel}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{localeMenuOpen ? (
|
||||
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
|
||||
{localeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectLanguage(option.value)
|
||||
setLocaleMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
|
||||
>
|
||||
<span aria-hidden="true">{option.flag}</span>
|
||||
<span>{option.shortLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="mx-1 h-6 w-px bg-stone-200 dark:bg-stone-700" aria-hidden="true" />
|
||||
<div ref={themeMenuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleThemeMenu}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2 py-1 transition hover:bg-stone-100 dark:hover:bg-blue-900/40 sm:px-2.5 sm:py-1.5"
|
||||
aria-expanded={themeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span className="rounded-full bg-blue-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-orange-400 dark:text-white sm:px-3.5 sm:py-1.5 sm:text-xs">
|
||||
{currentTheme.label}
|
||||
</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-stone-500 transition-transform dark:text-stone-400 ${themeMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{themeMenuOpen ? (
|
||||
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
|
||||
{themeOptions
|
||||
.filter((option) => option.value !== theme)
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTheme(option.value)
|
||||
setThemeMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './MarketplaceShell'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('MarketplaceShell auth helpers', () => {
|
||||
it('does not report an employee profile when the browser cache is empty', () => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
getItem: vi.fn(() => null),
|
||||
},
|
||||
})
|
||||
|
||||
expect(hasCachedEmployeeProfile()).toBe(false)
|
||||
})
|
||||
|
||||
it('detects the cached employee profile marker written by dashboard sign-in', () => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
getItem: vi.fn((key: string) => (key === 'employee_profile' ? '{"id":"emp_1"}' : null)),
|
||||
},
|
||||
})
|
||||
|
||||
expect(hasCachedEmployeeProfile()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears the cached employee profile marker', () => {
|
||||
const removeItem = vi.fn()
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
removeItem,
|
||||
},
|
||||
})
|
||||
|
||||
clearCachedEmployeeProfile()
|
||||
|
||||
expect(removeItem).toHaveBeenCalledWith('employee_profile')
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getFooterContent, localeOptions } from './MarketplaceShell'
|
||||
|
||||
describe('MarketplaceShell footer content registry', () => {
|
||||
it('exposes exactly the supported marketplace locales in selector order', () => {
|
||||
expect(localeOptions.map((option) => option.value)).toEqual(['en', 'ar', 'fr'])
|
||||
expect(localeOptions.every((option) => option.label.length > 0 && option.flag.length > 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns English footer links with app privacy pointing at the English mobile policy', () => {
|
||||
const content = getFooterContent('en')
|
||||
|
||||
expect(content.localeLabel).toBe('Global (English)')
|
||||
expect(content.rightsLabel).toBe('All rights reserved.')
|
||||
expect(content.primary).toContainEqual({ label: 'Privacy Policy', href: '/app-privacy-en' })
|
||||
expect(content.secondary).toContainEqual({ label: 'Contact Sales', href: '/footer/contact-sales' })
|
||||
})
|
||||
|
||||
it('returns French labels while preserving canonical footer slugs', () => {
|
||||
const content = getFooterContent('fr')
|
||||
|
||||
expect(content.localeLabel).toBe('Europe (French)')
|
||||
expect(content.primary).toContainEqual({ label: "Conditions d'utilisation", href: '/footer/terms-of-service' })
|
||||
expect(content.secondary).toContainEqual({ label: 'Conditions générales', href: '/footer/general-conditions' })
|
||||
})
|
||||
|
||||
it('returns Arabic labels and app privacy href without falling back to English copy', () => {
|
||||
const content = getFooterContent('ar')
|
||||
|
||||
expect(content.localeLabel).toBe('North Africa (Arabic)')
|
||||
expect(content.rightsLabel).toBe('جميع الحقوق محفوظة.')
|
||||
expect(content.primary).toContainEqual({ label: 'سياسة الخصوصية', href: '/app-privacy-ar' })
|
||||
expect(content.primary.map((item) => item.label)).not.toContain('Privacy Policy')
|
||||
})
|
||||
})
|
||||
@@ -1,368 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { appPrivacyHref, footerPageHref } from '@/lib/footerContent'
|
||||
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
|
||||
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
type Dictionary = {
|
||||
home: string
|
||||
features: string
|
||||
pricing: string
|
||||
signIn: string
|
||||
ownerSignIn: string
|
||||
language: string
|
||||
theme: string
|
||||
light: string
|
||||
dark: string
|
||||
preferences: string
|
||||
}
|
||||
|
||||
const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
|
||||
en: {
|
||||
home: 'Home',
|
||||
features: 'Features',
|
||||
pricing: 'Pricing',
|
||||
signIn: 'Sign in',
|
||||
ownerSignIn: 'Get Started',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
preferences: 'Preferences',
|
||||
},
|
||||
fr: {
|
||||
home: 'Accueil',
|
||||
features: 'Fonctionnalités',
|
||||
pricing: 'Tarifs',
|
||||
signIn: 'Connexion',
|
||||
ownerSignIn: 'Démarrer',
|
||||
language: 'Langue',
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
dark: 'Sombre',
|
||||
preferences: 'Préférences',
|
||||
},
|
||||
ar: {
|
||||
home: 'الرئيسية',
|
||||
features: 'الميزات',
|
||||
pricing: 'الأسعار',
|
||||
signIn: 'تسجيل الدخول',
|
||||
ownerSignIn: 'ابدأ الآن',
|
||||
language: 'اللغة',
|
||||
theme: 'الوضع',
|
||||
light: 'فاتح',
|
||||
dark: 'داكن',
|
||||
preferences: 'التفضيلات',
|
||||
},
|
||||
}
|
||||
|
||||
const EMPLOYEE_PROFILE_KEY = 'employee_profile'
|
||||
|
||||
export const localeOptions: Array<{ value: MarketplaceLanguage; label: string; flag: string }> = [
|
||||
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
|
||||
{ value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' },
|
||||
{ value: 'fr', label: 'Europe (French)', flag: '🇫🇷' },
|
||||
]
|
||||
|
||||
export function getFooterContent(language: MarketplaceLanguage): {
|
||||
primary: Array<{ label: string; href?: string }>
|
||||
secondary: Array<{ label: string; href?: string }>
|
||||
localeLabel: string
|
||||
rightsLabel: string
|
||||
} {
|
||||
switch (language) {
|
||||
case 'fr':
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'À propos de nous', href: footerPageHref['about-us'] },
|
||||
{ label: "Conditions d'utilisation", href: footerPageHref['terms-of-service'] },
|
||||
{ label: 'Sécurité', href: footerPageHref.security },
|
||||
{ label: 'Conformité', href: footerPageHref.compliance },
|
||||
{ label: 'Politique de confidentialité', href: appPrivacyHref.fr },
|
||||
{ label: 'Politique relative aux cookies', href: footerPageHref['cookie-policy'] },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'Newsletter', href: footerPageHref.newsletter },
|
||||
{ label: 'Contacter les ventes', href: footerPageHref['contact-sales'] },
|
||||
{ label: 'Conditions générales', href: footerPageHref['general-conditions'] },
|
||||
],
|
||||
localeLabel: 'Europe (French)',
|
||||
rightsLabel: 'Tous droits réservés.',
|
||||
}
|
||||
case 'ar':
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'من نحن', href: footerPageHref['about-us'] },
|
||||
{ label: 'شروط الاستخدام', href: footerPageHref['terms-of-service'] },
|
||||
{ label: 'الأمان', href: footerPageHref.security },
|
||||
{ label: 'الامتثال', href: footerPageHref.compliance },
|
||||
{ label: 'سياسة الخصوصية', href: appPrivacyHref.ar },
|
||||
{ label: 'سياسة ملفات تعريف الارتباط', href: footerPageHref['cookie-policy'] },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'النشرة الإخبارية', href: footerPageHref.newsletter },
|
||||
{ label: 'تواصل مع المبيعات', href: footerPageHref['contact-sales'] },
|
||||
{ label: 'الشروط العامة', href: footerPageHref['general-conditions'] },
|
||||
],
|
||||
localeLabel: 'North Africa (Arabic)',
|
||||
rightsLabel: 'جميع الحقوق محفوظة.',
|
||||
}
|
||||
case 'en':
|
||||
default:
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'About Us', href: footerPageHref['about-us'] },
|
||||
{ label: 'Terms of Service', href: footerPageHref['terms-of-service'] },
|
||||
{ label: 'Security', href: footerPageHref.security },
|
||||
{ label: 'Compliance', href: footerPageHref.compliance },
|
||||
{ label: 'Privacy Policy', href: appPrivacyHref.en },
|
||||
{ label: 'Cookie Policy', href: footerPageHref['cookie-policy'] },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'Newsletter', href: footerPageHref.newsletter },
|
||||
{ label: 'Contact Sales', href: footerPageHref['contact-sales'] },
|
||||
{ label: 'General Conditions', href: footerPageHref['general-conditions'] },
|
||||
],
|
||||
localeLabel: 'Global (English)',
|
||||
rightsLabel: 'All rights reserved.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCachedEmployeeProfile(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
|
||||
try {
|
||||
return Boolean(window.localStorage.getItem(EMPLOYEE_PROFILE_KEY))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCachedEmployeeProfile() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function detectBrowserLanguage(): string | null {
|
||||
if (typeof navigator === 'undefined') return null
|
||||
const langs = Array.from(navigator.languages ?? [navigator.language])
|
||||
for (const lang of langs) {
|
||||
const code = lang.split('-')[0].toLowerCase()
|
||||
if (code === 'fr' || code === 'ar' || code === 'en') return code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type PreferencesContextValue = {
|
||||
language: MarketplaceLanguage
|
||||
theme: Theme
|
||||
dict: Dictionary
|
||||
companyName: string | null
|
||||
setLanguage: (language: MarketplaceLanguage) => void
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
|
||||
|
||||
export function useMarketplacePreferences() {
|
||||
const context = useContext(PreferencesContext)
|
||||
if (!context) throw new Error('useMarketplacePreferences must be used within MarketplaceShell')
|
||||
return context
|
||||
}
|
||||
|
||||
export default function MarketplaceShell({
|
||||
children,
|
||||
initialLanguage = 'en',
|
||||
initialTheme = 'light',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
initialLanguage?: MarketplaceLanguage
|
||||
initialTheme?: Theme
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
const [language, setLanguageState] = useState<MarketplaceLanguage>(initialLanguage)
|
||||
const [theme, setThemeState] = useState<Theme>(initialTheme)
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const previousLanguage = useRef<MarketplaceLanguage>(initialLanguage)
|
||||
const [companyName, setCompanyName] = useState<string | null>(null)
|
||||
|
||||
function applyLanguage(nextLanguage: MarketplaceLanguage) {
|
||||
if (nextLanguage === language) return
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
document.documentElement.lang = nextLanguage
|
||||
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
|
||||
try {
|
||||
sessionStorage.setItem('marketplace-language', nextLanguage)
|
||||
} catch {}
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['marketplace-language'])
|
||||
}
|
||||
|
||||
setLanguageState(nextLanguage)
|
||||
}
|
||||
|
||||
function applyTheme(nextTheme: Theme) {
|
||||
if (nextTheme === theme) return
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
document.documentElement.classList.toggle('dark', nextTheme === 'dark')
|
||||
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
|
||||
document.body.dataset.theme = nextTheme
|
||||
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['marketplace-theme'])
|
||||
}
|
||||
|
||||
setThemeState(nextTheme)
|
||||
}
|
||||
|
||||
async function syncCompanyBrand() {
|
||||
if (!hasCachedEmployeeProfile()) {
|
||||
setCompanyName(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/companies/me/brand`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearCachedEmployeeProfile()
|
||||
}
|
||||
setCompanyName(null)
|
||||
return
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
const name = json?.data?.displayName ?? json?.displayName ?? null
|
||||
setCompanyName(name)
|
||||
} catch {
|
||||
setCompanyName(null)
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionLanguage(): MarketplaceLanguage | null {
|
||||
try {
|
||||
const val = sessionStorage.getItem('marketplace-language')
|
||||
return isMarketplaceLanguage(val) ? val : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function syncScopedPreferencesForSession() {
|
||||
const sessionLang = readSessionLanguage()
|
||||
if (sessionLang) {
|
||||
if (sessionLang !== language) setLanguageState(sessionLang)
|
||||
} else {
|
||||
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
|
||||
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
|
||||
setLanguageState(scopedLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
|
||||
if ((scopedTheme === 'light' || scopedTheme === 'dark') && scopedTheme !== theme) {
|
||||
setThemeState(scopedTheme)
|
||||
} else {
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const sessionLang = readSessionLanguage()
|
||||
if (sessionLang) {
|
||||
setLanguageState(sessionLang)
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['marketplace-language'])
|
||||
} else {
|
||||
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language'])
|
||||
if (isMarketplaceLanguage(storedLanguage)) {
|
||||
setLanguageState(storedLanguage)
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['marketplace-language'])
|
||||
} else {
|
||||
const detected = detectBrowserLanguage()
|
||||
if (isMarketplaceLanguage(detected)) {
|
||||
setLanguageState(detected)
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['marketplace-language'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-theme']) as Theme | null
|
||||
if (storedTheme === 'light' || storedTheme === 'dark') {
|
||||
setThemeState(storedTheme)
|
||||
}
|
||||
|
||||
setHydrated(true)
|
||||
void syncCompanyBrand()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleWindowFocus() {
|
||||
syncScopedPreferencesForSession()
|
||||
void syncCompanyBrand()
|
||||
}
|
||||
|
||||
function handleAuthMessage(event: MessageEvent) {
|
||||
if (!event.data || typeof event.data !== 'object') return
|
||||
|
||||
const message = event.data as { type?: string }
|
||||
|
||||
if (message.type === 'rentaldrivego:employee-login' || message.type === 'rentaldrivego:employee-logout') {
|
||||
syncScopedPreferencesForSession()
|
||||
void syncCompanyBrand()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
window.addEventListener('message', handleAuthMessage)
|
||||
document.addEventListener('visibilitychange', handleWindowFocus)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
window.removeEventListener('message', handleAuthMessage)
|
||||
document.removeEventListener('visibilitychange', handleWindowFocus)
|
||||
}
|
||||
}, [language, theme])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
|
||||
if (!hydrated) return
|
||||
|
||||
try { sessionStorage.setItem('marketplace-language', language) } catch {}
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
|
||||
|
||||
if (previousLanguage.current !== language && pathname !== '/sign-in') {
|
||||
router.refresh()
|
||||
}
|
||||
previousLanguage.current = language
|
||||
}, [hydrated, language, pathname, router])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light'
|
||||
document.body.dataset.theme = theme
|
||||
if (hydrated) {
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
|
||||
}
|
||||
}, [theme, hydrated])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ language, theme, dict: dictionaries[language], companyName, setLanguage: applyLanguage, setTheme: applyTheme }),
|
||||
[language, theme, companyName],
|
||||
)
|
||||
|
||||
return <PreferencesContext.Provider value={value}>{children}</PreferencesContext.Provider>
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function PricingToggle({
|
||||
onToggle,
|
||||
}: {
|
||||
onToggle: (isAnnual: boolean) => void
|
||||
}) {
|
||||
const [isAnnual, setIsAnnual] = useState(false)
|
||||
|
||||
const handleToggle = () => {
|
||||
const newValue = !isAnnual
|
||||
setIsAnnual(newValue)
|
||||
onToggle(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="inline-flex items-center gap-4 rounded-full border border-stone-200 bg-white/80 p-1 dark:border-blue-800 dark:bg-blue-950/40">
|
||||
<button
|
||||
onClick={() => !isAnnual && handleToggle()}
|
||||
className={`rounded-full px-6 py-2 text-sm font-semibold transition ${
|
||||
!isAnnual
|
||||
? 'bg-orange-600 text-white dark:bg-orange-500'
|
||||
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => isAnnual && handleToggle()}
|
||||
className={`rounded-full px-6 py-2 text-sm font-semibold transition ${
|
||||
isAnnual
|
||||
? 'bg-orange-600 text-white dark:bg-orange-500'
|
||||
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
Annual
|
||||
<span className="ml-2 text-xs font-bold text-orange-200 dark:text-orange-300">-20%</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface Stat {
|
||||
label: string
|
||||
value: number
|
||||
suffix?: string
|
||||
}
|
||||
|
||||
interface StatsStripProps {
|
||||
stats: Stat[]
|
||||
kicker?: string
|
||||
}
|
||||
|
||||
function Counter({ value, suffix = '' }: { value: number; suffix?: string }) {
|
||||
const [count, setCount] = useState(0)
|
||||
const ref = useRef<HTMLParagraphElement>(null)
|
||||
const hasAnimated = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && !hasAnimated.current) {
|
||||
hasAnimated.current = true
|
||||
const duration = 2000
|
||||
const steps = 60
|
||||
const increment = value / steps
|
||||
let current = 0
|
||||
const startTime = Date.now()
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
current = Math.floor(value * progress)
|
||||
setCount(current)
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
animate()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ref.current) {
|
||||
observer.unobserve(ref.current)
|
||||
}
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<p ref={ref} className="text-3xl font-black text-blue-950 dark:text-white sm:text-4xl">
|
||||
{count.toLocaleString()}
|
||||
{suffix}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatsStrip({ stats, kicker = 'BY THE NUMBERS' }: StatsStripProps) {
|
||||
return (
|
||||
<section className="site-section">
|
||||
<div className="site-panel">
|
||||
<p className="site-kicker">{kicker}</p>
|
||||
<h2 className="site-title">Our Impact</h2>
|
||||
|
||||
<div className="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="flex flex-col">
|
||||
<Counter value={stat.value} suffix={stat.suffix} />
|
||||
<p className="mt-4 text-sm font-semibold text-stone-600 dark:text-stone-300">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface Testimonial {
|
||||
quote: string
|
||||
author: string
|
||||
role: string
|
||||
company?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
interface TestimonialsSectionProps {
|
||||
testimonials: Testimonial[]
|
||||
kicker?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function TestimonialsSection({
|
||||
testimonials,
|
||||
kicker = 'WHAT CUSTOMERS SAY',
|
||||
title = 'Testimonials',
|
||||
}: TestimonialsSectionProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const autoScrollRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
autoScrollRef.current = setInterval(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % testimonials.length)
|
||||
}, 5000)
|
||||
|
||||
return () => {
|
||||
if (autoScrollRef.current) clearInterval(autoScrollRef.current)
|
||||
}
|
||||
}, [testimonials.length])
|
||||
|
||||
const handlePrev = () => {
|
||||
setActiveIndex((prev) => (prev - 1 + testimonials.length) % testimonials.length)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
setActiveIndex((prev) => (prev + 1) % testimonials.length)
|
||||
}
|
||||
|
||||
if (!testimonials || testimonials.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="site-section">
|
||||
<div className="flex flex-col">
|
||||
<p className="site-kicker">{kicker}</p>
|
||||
<h2 className="site-title">{title}</h2>
|
||||
|
||||
<div className="mt-12 relative">
|
||||
<div className="site-panel">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-lg leading-8 text-blue-950 dark:text-stone-200 sm:text-xl">
|
||||
"{testimonials[activeIndex].quote}"
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-2">
|
||||
<p className="font-bold text-blue-950 dark:text-white">{testimonials[activeIndex].author}</p>
|
||||
<p className="text-sm text-stone-600 dark:text-stone-400">
|
||||
{testimonials[activeIndex].role}
|
||||
{testimonials[activeIndex].company && ` at ${testimonials[activeIndex].company}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testimonials[activeIndex].image && (
|
||||
<img
|
||||
src={testimonials[activeIndex].image}
|
||||
alt={testimonials[activeIndex].author}
|
||||
className="h-16 w-16 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testimonials.length > 1 && (
|
||||
<div className="mt-6 flex justify-center gap-4">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
className="rounded-full border border-stone-300 bg-white px-4 py-2 font-semibold text-stone-700 transition hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/30 dark:text-stone-200 dark:hover:bg-blue-950"
|
||||
aria-label="Previous testimonial"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{testimonials.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
className={`h-2 w-2 rounded-full transition ${
|
||||
index === activeIndex ? 'bg-orange-600 dark:bg-orange-500' : 'bg-stone-300 dark:bg-stone-600'
|
||||
}`}
|
||||
aria-label={`Go to testimonial ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="rounded-full border border-stone-300 bg-white px-4 py-2 font-semibold text-stone-700 transition hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/30 dark:text-stone-200 dark:hover:bg-blue-950"
|
||||
aria-label="Next testimonial"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function WorkspaceTabs() {
|
||||
const { language, theme } = useMarketplacePreferences()
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
badge: 'Unified Workspace',
|
||||
title: 'One launch point on port 3000 for marketplace discovery, company operations, and platform admin work.',
|
||||
intro:
|
||||
'The apps still run as separate Next.js surfaces internally, but the navbar now takes you straight to each workspace area without hunting for ports.',
|
||||
sectionBadge: 'RentalDriveGo Marketplace',
|
||||
sectionTitle: 'Browse local rental fleets without losing the company behind the wheel.',
|
||||
sectionBodyA: 'Discovery happens here. Booking and payment happen on each rental company\'s own branded site.',
|
||||
sectionBodyB: 'Customers only reach that branded booking site after selecting a vehicle from a company\'s fleet.',
|
||||
exploreVehicles: 'Explore vehicles',
|
||||
viewPricing: 'View pricing',
|
||||
nowServing: 'Now serving',
|
||||
promoTitle: 'Discovery, pricing, owner sign-in, and booking entry all stay anchored on the marketplace surface.',
|
||||
promoBody:
|
||||
'Company Workspace and Platform Operations stay available from the navbar, while branded booking starts after a renter picks a vehicle.',
|
||||
customerPaths: 'Customer Paths',
|
||||
ownerSignIn: 'Owner sign in',
|
||||
pricing: 'Pricing',
|
||||
workspaceAreas: 'Workspace Areas',
|
||||
workspaceA: 'Company Workspace for fleet operations, team, billing, and analytics.',
|
||||
workspaceB: 'Platform Operations for platform-level support and tenant oversight.',
|
||||
workspaceC: 'Branded booking handoff begins only after a renter chooses a specific vehicle.',
|
||||
},
|
||||
fr: {
|
||||
badge: 'Espace unifié',
|
||||
title: 'Un point d’entrée sur le port 3000 pour la découverte marketplace, les opérations de l’entreprise et l’administration de la plateforme.',
|
||||
intro:
|
||||
'Les applications restent des interfaces Next.js séparées en interne, mais la barre de navigation mène directement à chaque espace sans avoir à changer de port manuellement.',
|
||||
sectionBadge: 'Marketplace RentalDriveGo',
|
||||
sectionTitle: 'Parcourez les flottes locales sans perdre l’identité de l’entreprise qui gère la location.',
|
||||
sectionBodyA: 'La découverte commence ici. La réservation et le paiement se font sur le site de réservation propre à chaque entreprise.',
|
||||
sectionBodyB: 'Les clients accèdent à ce site de réservation seulement après avoir choisi un véhicule dans la flotte d’une entreprise.',
|
||||
exploreVehicles: 'Explorer les véhicules',
|
||||
viewPricing: 'Voir les tarifs',
|
||||
nowServing: 'Disponible',
|
||||
promoTitle: 'La découverte, les tarifs, la connexion propriétaire et l’accès à la réservation restent ancrés dans la marketplace.',
|
||||
promoBody:
|
||||
'L’espace entreprise et les opérations de la plateforme restent accessibles depuis la navigation, tandis que la réservation de marque commence après le choix d’un véhicule.',
|
||||
customerPaths: 'Parcours client',
|
||||
ownerSignIn: 'Connexion propriétaire',
|
||||
pricing: 'Tarifs',
|
||||
workspaceAreas: 'Espaces de travail',
|
||||
workspaceA: 'Espace entreprise pour la flotte, l’équipe, la facturation et l’analyse.',
|
||||
workspaceB: 'Opérations plateforme pour le support global et la supervision multi-entreprises.',
|
||||
workspaceC: 'Le transfert vers la réservation de marque commence seulement après le choix d’un véhicule précis.',
|
||||
},
|
||||
ar: {
|
||||
badge: 'مساحة موحدة',
|
||||
title: 'نقطة دخول واحدة على المنفذ 3000 لاكتشاف السوق وعمليات الشركات وإدارة المنصة.',
|
||||
intro:
|
||||
'ما زالت التطبيقات تعمل كواجهات Next.js منفصلة داخلياً، لكن شريط التنقل ينقلك مباشرة إلى كل مساحة عمل من دون الحاجة إلى البحث عن المنافذ.',
|
||||
sectionBadge: 'سوق RentalDriveGo',
|
||||
sectionTitle: 'تصفح أساطيل التأجير المحلية مع الحفاظ على هوية الشركة التي تدير الحجز.',
|
||||
sectionBodyA: 'الاكتشاف يبدأ هنا. الحجز والدفع يتمان على موقع الحجز الخاص بكل شركة.',
|
||||
sectionBodyB: 'لا يصل العميل إلى موقع الحجز الخاص بالشركة إلا بعد اختيار سيارة من أسطول تلك الشركة.',
|
||||
exploreVehicles: 'استكشف السيارات',
|
||||
viewPricing: 'عرض الأسعار',
|
||||
nowServing: 'المتاح الآن',
|
||||
promoTitle: 'الاكتشاف والأسعار وتسجيل دخول المالك وبداية الحجز تبقى كلها داخل واجهة السوق.',
|
||||
promoBody:
|
||||
'تبقى مساحة الشركة وعمليات المنصة متاحتين من شريط التنقل، بينما يبدأ الحجز المرتبط بالعلامة التجارية بعد اختيار المستأجر لسيارة.',
|
||||
customerPaths: 'مسارات العملاء',
|
||||
ownerSignIn: 'دخول المالك',
|
||||
pricing: 'الأسعار',
|
||||
workspaceAreas: 'مساحات العمل',
|
||||
workspaceA: 'مساحة الشركة لإدارة الأسطول والفريق والفوترة والتحليلات.',
|
||||
workspaceB: 'عمليات المنصة للدعم والإشراف على الشركات على مستوى المنصة.',
|
||||
workspaceC: 'الانتقال إلى موقع الحجز الخاص بالشركة يبدأ فقط بعد اختيار سيارة محددة.',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<main className={`min-h-screen transition-colors ${
|
||||
theme === 'dark'
|
||||
? 'bg-[radial-gradient(circle_at_top,rgba(234,88,12,0.20),transparent_28%),linear-gradient(180deg,#0f1f4a,#112d6e)]'
|
||||
: 'bg-[radial-gradient(circle_at_top,rgba(234,88,12,0.08),transparent_28%),linear-gradient(180deg,#f5f8ff,white)]'
|
||||
}`}>
|
||||
<div className="shell py-16 sm:py-20">
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/" aria-label="RentalDriveGo home">
|
||||
<Image
|
||||
src="/rentalcardrive.png"
|
||||
alt="RentalDriveGo"
|
||||
width={72}
|
||||
height={72}
|
||||
className={`h-16 w-16 rounded-2xl object-contain p-1 shadow-sm ${
|
||||
theme === 'dark' ? 'border border-orange-500/40 bg-blue-950' : 'border border-orange-200 bg-white'
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
<p className={`text-sm font-semibold uppercase tracking-[0.24em] ${
|
||||
theme === 'dark' ? 'text-orange-300' : 'text-orange-700'
|
||||
}`}>
|
||||
{copy.badge}
|
||||
</p>
|
||||
</div>
|
||||
<h1 className={`mt-5 text-4xl font-black tracking-tight sm:text-6xl ${
|
||||
theme === 'dark' ? 'text-slate-100' : 'text-blue-900'
|
||||
}`}>
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p className={`mt-6 max-w-3xl text-lg leading-8 ${
|
||||
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
|
||||
}`}>
|
||||
{copy.intro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className={`mt-10 overflow-hidden rounded-[2rem] border shadow-xl transition-colors ${
|
||||
theme === 'dark'
|
||||
? 'border-blue-900 bg-blue-950 shadow-black/20'
|
||||
: 'border-stone-200 bg-white shadow-stone-200/40'
|
||||
}`}>
|
||||
<div className={`px-6 py-6 ${theme === 'dark' ? 'border-b border-stone-800' : 'border-b border-stone-200'}`}>
|
||||
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
|
||||
theme === 'dark' ? 'text-orange-300' : 'text-orange-700'
|
||||
}`}>
|
||||
{copy.sectionBadge}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<h2 className={`text-2xl font-black tracking-tight ${
|
||||
theme === 'dark' ? 'text-slate-100' : 'text-blue-900'
|
||||
}`}>
|
||||
{copy.sectionTitle}
|
||||
</h2>
|
||||
<p className={`mt-3 text-sm leading-7 ${
|
||||
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
|
||||
}`}>
|
||||
{copy.sectionBodyA}
|
||||
</p>
|
||||
<p className={`mt-3 text-sm leading-7 ${
|
||||
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
|
||||
}`}>
|
||||
{copy.sectionBodyB}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/features"
|
||||
className={`rounded-full px-5 py-2.5 text-sm font-semibold transition ${
|
||||
theme === 'dark'
|
||||
? 'bg-orange-400 text-blue-950 hover:bg-orange-300'
|
||||
: 'bg-blue-950 text-white hover:bg-blue-900/40'
|
||||
}`}
|
||||
>
|
||||
{copy.exploreVehicles}
|
||||
</Link>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className={`rounded-full border px-5 py-2.5 text-sm font-semibold transition ${
|
||||
theme === 'dark'
|
||||
? 'border-blue-800 text-slate-200 hover:border-blue-600 hover:bg-blue-900/40'
|
||||
: 'border-stone-300 text-stone-700 hover:border-stone-400 hover:bg-stone-50'
|
||||
}`}
|
||||
>
|
||||
{copy.viewPricing}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-6 px-6 py-8 lg:grid-cols-[1.4fr_0.9fr] ${
|
||||
theme === 'dark' ? 'bg-blue-950' : 'bg-stone-50'
|
||||
}`}>
|
||||
<div className="rounded-[1.5rem] bg-[linear-gradient(135deg,#06132e,#0d1b38)] p-8 text-white">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-300">{copy.nowServing}</p>
|
||||
<h3 className="mt-4 text-3xl font-black tracking-tight">
|
||||
{copy.promoTitle}
|
||||
</h3>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-stone-200">
|
||||
{copy.promoBody}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className={`rounded-[1.5rem] border p-5 ${
|
||||
theme === 'dark' ? 'border-blue-900 bg-blue-950' : 'border-stone-200 bg-white'
|
||||
}`}>
|
||||
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
|
||||
theme === 'dark' ? 'text-stone-400' : 'text-stone-500'
|
||||
}`}>{copy.customerPaths}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href="/features" className="rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white">
|
||||
{copy.exploreVehicles}
|
||||
</Link>
|
||||
<a className={`rounded-full border px-4 py-2 text-sm font-semibold ${
|
||||
theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
|
||||
}`} href={`${dashboardUrl}/sign-in`}>
|
||||
{copy.ownerSignIn}
|
||||
</a>
|
||||
<Link className={`rounded-full border px-4 py-2 text-sm font-semibold ${
|
||||
theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
|
||||
}`} href="/pricing">
|
||||
{copy.pricing}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`rounded-[1.5rem] border p-5 ${
|
||||
theme === 'dark' ? 'border-blue-900 bg-blue-950' : 'border-stone-200 bg-white'
|
||||
}`}>
|
||||
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
|
||||
theme === 'dark' ? 'text-stone-400' : 'text-stone-500'
|
||||
}`}>{copy.workspaceAreas}</p>
|
||||
<ul className={`mt-4 space-y-3 text-sm ${
|
||||
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
|
||||
}`}>
|
||||
<li>{copy.workspaceA}</li>
|
||||
<li>{copy.workspaceB}</li>
|
||||
<li>{copy.workspaceC}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
.link {
|
||||
display: inline-flex;
|
||||
min-block-size: 2.75rem;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-weight: 650;
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
.inline {
|
||||
min-block-size: auto;
|
||||
font-weight: inherit;
|
||||
text-decoration-thickness: 0.08em;
|
||||
}
|
||||
.navigation {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.navigation:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.standalone {
|
||||
text-decoration: none;
|
||||
}
|
||||
.standalone:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.button-primary,
|
||||
.button-conversion {
|
||||
justify-content: center;
|
||||
padding-block: var(--space-3);
|
||||
padding-inline: var(--space-5);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-inverse);
|
||||
text-decoration: none;
|
||||
}
|
||||
.button-primary {
|
||||
background: var(--interactive-primary);
|
||||
}
|
||||
.button-primary:hover {
|
||||
background: var(--interactive-primary-hover);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
.button-conversion {
|
||||
background: var(--interactive-conversion);
|
||||
}
|
||||
.button-conversion:hover {
|
||||
background: var(--interactive-conversion-hover);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--type-label-size);
|
||||
}
|
||||
.disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--control-disabled-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
|
||||
import { classNames } from '@/lib/components/classNames';
|
||||
import type { AnchorHTMLAttributes, ReactNode } from 'react';
|
||||
import styles from './ActionLink.module.css';
|
||||
|
||||
type LinkVariant =
|
||||
| 'inline'
|
||||
| 'navigation'
|
||||
| 'standalone'
|
||||
| 'button-primary'
|
||||
| 'button-conversion'
|
||||
| 'muted';
|
||||
|
||||
interface ActionLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
variant?: LinkVariant;
|
||||
icon?: ApprovedIconName;
|
||||
external?: boolean;
|
||||
newTab?: boolean;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export function ActionLink({
|
||||
href,
|
||||
children,
|
||||
variant = 'inline',
|
||||
icon,
|
||||
external = false,
|
||||
newTab = false,
|
||||
disabledReason,
|
||||
className,
|
||||
...props
|
||||
}: ActionLinkProps) {
|
||||
if (disabledReason) {
|
||||
return (
|
||||
<span
|
||||
className={classNames(styles.link, styles[variant], styles.disabled, className)}
|
||||
aria-disabled="true"
|
||||
title={disabledReason}
|
||||
>
|
||||
<span>{children}</span>
|
||||
<span className="visually-hidden">: {disabledReason}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const isExternal = external || /^https?:\/\//.test(href);
|
||||
const trailingIcon = icon ?? (isExternal ? 'external' : undefined);
|
||||
const content = (
|
||||
<>
|
||||
<span>{children}</span>
|
||||
{trailingIcon ? (
|
||||
<Icon
|
||||
name={trailingIcon}
|
||||
size="sm"
|
||||
directionBehavior={
|
||||
trailingIcon === 'chevron-forward' || trailingIcon === 'arrow-forward'
|
||||
? 'mirror'
|
||||
: 'fixed'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{newTab ? <span className="visually-hidden"> (opens in a new tab)</span> : null}
|
||||
</>
|
||||
);
|
||||
const shared = {
|
||||
className: classNames(styles.link, styles[variant], className),
|
||||
...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}),
|
||||
...props,
|
||||
};
|
||||
return (
|
||||
<a href={href} {...shared}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
min-block-size: 2.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color var(--duration-fast) var(--easing-productive),
|
||||
border-color var(--duration-fast) var(--easing-productive),
|
||||
color var(--duration-fast) var(--easing-productive),
|
||||
transform var(--duration-fast) var(--easing-productive);
|
||||
}
|
||||
.button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: var(--opacity-disabled);
|
||||
}
|
||||
.small {
|
||||
min-block-size: 2.5rem;
|
||||
padding-block: var(--space-2);
|
||||
padding-inline: var(--space-3);
|
||||
font-size: var(--type-label-size);
|
||||
}
|
||||
.medium {
|
||||
padding-block: var(--space-3);
|
||||
padding-inline: var(--space-5);
|
||||
}
|
||||
.large {
|
||||
min-block-size: 3.25rem;
|
||||
padding-block: var(--space-3);
|
||||
padding-inline: var(--space-6);
|
||||
font-size: var(--type-body-large-size);
|
||||
}
|
||||
.primary {
|
||||
background: var(--interactive-primary);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--interactive-primary-hover);
|
||||
}
|
||||
.conversion {
|
||||
background: var(--interactive-conversion);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
.conversion:hover:not(:disabled) {
|
||||
background: var(--interactive-conversion-hover);
|
||||
}
|
||||
.secondary {
|
||||
border-color: var(--interactive-primary);
|
||||
background: var(--surface-primary);
|
||||
color: var(--interactive-primary);
|
||||
}
|
||||
.secondary:hover:not(:disabled) {
|
||||
background: var(--status-info-surface);
|
||||
}
|
||||
.tertiary {
|
||||
border-color: var(--border-standard);
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.tertiary:hover:not(:disabled) {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ghost:hover:not(:disabled) {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.destructive {
|
||||
background: var(--interactive-danger);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
.destructive:hover:not(:disabled) {
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
.link {
|
||||
min-block-size: 2.75rem;
|
||||
padding-inline: 0;
|
||||
background: transparent;
|
||||
color: var(--link-default);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
.link:hover:not(:disabled) {
|
||||
color: var(--link-hover);
|
||||
}
|
||||
.iconOnly {
|
||||
aspect-ratio: 1;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.fullWidth {
|
||||
inline-size: 100%;
|
||||
}
|
||||
.loading {
|
||||
position: relative;
|
||||
}
|
||||
.spinner {
|
||||
position: absolute;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
.loadingContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
opacity: 0;
|
||||
}
|
||||
.label {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.button {
|
||||
transition: none;
|
||||
}
|
||||
.spinner {
|
||||
animation-duration: 1.8s;
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.button {
|
||||
border-color: ButtonText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
|
||||
import { classNames } from '@/lib/components/classNames';
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
import styles from './Button.module.css';
|
||||
|
||||
export type ButtonIntent =
|
||||
| 'primary'
|
||||
| 'conversion'
|
||||
| 'secondary'
|
||||
| 'tertiary'
|
||||
| 'ghost'
|
||||
| 'destructive'
|
||||
| 'link';
|
||||
export type ButtonSize = 'small' | 'medium' | 'large';
|
||||
|
||||
interface CommonButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
intent?: ButtonIntent;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
leadingIcon?: ApprovedIconName;
|
||||
trailingIcon?: ApprovedIconName;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface IconOnlyButtonProps extends Omit<
|
||||
CommonButtonProps,
|
||||
'children' | 'leadingIcon' | 'trailingIcon'
|
||||
> {
|
||||
icon: ApprovedIconName;
|
||||
'aria-label': string;
|
||||
children?: never;
|
||||
}
|
||||
|
||||
export type ButtonProps = CommonButtonProps | IconOnlyButtonProps;
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) {
|
||||
const {
|
||||
intent = 'primary',
|
||||
size = 'medium',
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
disabled = false,
|
||||
className,
|
||||
type = 'button',
|
||||
...rest
|
||||
} = props;
|
||||
const iconOnly = 'icon' in props;
|
||||
const content = iconOnly ? (
|
||||
<Icon name={props.icon} />
|
||||
) : (
|
||||
<>
|
||||
{props.leadingIcon ? <Icon name={props.leadingIcon} /> : null}
|
||||
<span className={styles.label}>{props.children}</span>
|
||||
{props.trailingIcon ? (
|
||||
<Icon
|
||||
name={props.trailingIcon}
|
||||
directionBehavior={
|
||||
props.trailingIcon === 'arrow-forward' || props.trailingIcon === 'chevron-forward'
|
||||
? 'mirror'
|
||||
: 'fixed'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const nativeProps = { ...rest } as ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
delete (nativeProps as Partial<CommonButtonProps>).leadingIcon;
|
||||
delete (nativeProps as Partial<CommonButtonProps>).trailingIcon;
|
||||
delete (nativeProps as Partial<IconOnlyButtonProps>).icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={classNames(
|
||||
styles.button,
|
||||
styles[intent],
|
||||
styles[size],
|
||||
iconOnly && styles.iconOnly,
|
||||
fullWidth && styles.fullWidth,
|
||||
loading && styles.loading,
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
data-loading={loading || undefined}
|
||||
{...nativeProps}
|
||||
>
|
||||
{loading ? <Icon name="spinner" className={styles.spinner} /> : null}
|
||||
<span className={loading ? styles.loadingContent : undefined}>{content}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { localizedPath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import styles from './SiteHeader.module.css';
|
||||
|
||||
export function BrandLink({ locale, label }: { locale: Locale; label: string }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<a
|
||||
className={styles.brand}
|
||||
href={localizedPath('home', locale)}
|
||||
aria-label={label}
|
||||
aria-current={routeIdFromPathname(pathname) === 'home' ? 'page' : undefined}
|
||||
>
|
||||
<Image
|
||||
src="/rentaldrivego.jpeg"
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
className={styles.mark}
|
||||
priority
|
||||
/>
|
||||
<span className={styles.brandName}>RentalDriveGo</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.field {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--type-caption-size);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.select {
|
||||
min-block-size: var(--size-touch-min);
|
||||
min-inline-size: 7.25rem;
|
||||
max-inline-size: 100%;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding-block: var(--space-2);
|
||||
padding-inline: var(--space-3) var(--space-8);
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.select:hover {
|
||||
border-color: var(--interactive-primary);
|
||||
}
|
||||
|
||||
.compactLabel {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { StatusBadge } from '@/components/foundation/StatusBadge';
|
||||
import styles from './SiteHeader.module.css';
|
||||
|
||||
interface DisabledActionProps {
|
||||
label: string;
|
||||
reason: string;
|
||||
pendingLabel?: string;
|
||||
conversion?: boolean;
|
||||
}
|
||||
|
||||
export function DisabledAction({
|
||||
label,
|
||||
reason,
|
||||
pendingLabel,
|
||||
conversion = false,
|
||||
}: DisabledActionProps) {
|
||||
return (
|
||||
<span
|
||||
className={conversion ? styles.conversionDisabled : styles.actionDisabled}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-label={`${label}. ${reason}`}
|
||||
title={reason}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{pendingLabel ? <StatusBadge>{pendingLabel}</StatusBadge> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { localizedPath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from './SiteHeader.module.css';
|
||||
|
||||
export type HeaderNavId = 'product' | 'workflow' | 'modules' | 'pricing' | 'faq';
|
||||
|
||||
interface HeaderNavigationProps {
|
||||
locale: Locale;
|
||||
label: string;
|
||||
labels: Record<HeaderNavId, string>;
|
||||
onNavigate?: () => void;
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
const navigationIds: HeaderNavId[] = ['product', 'workflow', 'modules', 'pricing', 'faq'];
|
||||
|
||||
export function HeaderNavigation({
|
||||
locale,
|
||||
label,
|
||||
labels,
|
||||
onNavigate,
|
||||
mobile = false,
|
||||
}: HeaderNavigationProps) {
|
||||
const pathname = usePathname();
|
||||
const currentRoute = routeIdFromPathname(pathname);
|
||||
const [currentHash, setCurrentHash] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const updateHash = () => setCurrentHash(window.location.hash.replace(/^#/, ''));
|
||||
updateHash();
|
||||
window.addEventListener('hashchange', updateHash);
|
||||
return () => window.removeEventListener('hashchange', updateHash);
|
||||
}, []);
|
||||
|
||||
const home = localizedPath('home', locale);
|
||||
|
||||
return (
|
||||
<nav aria-label={label} className={mobile ? styles.drawerNavigation : styles.navigation}>
|
||||
<ul>
|
||||
{navigationIds.map((id) => (
|
||||
<li key={id}>
|
||||
<a
|
||||
href={`${home}#${id}`}
|
||||
aria-current={currentRoute === 'home' && currentHash === id ? 'location' : undefined}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{labels[id]}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { persistLocale } from '@/lib/localization/client';
|
||||
import {
|
||||
localeSwitchUrl,
|
||||
locales,
|
||||
routeIdFromPathname,
|
||||
type Locale,
|
||||
} from '@/lib/localization/config';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import styles from './Controls.module.css';
|
||||
|
||||
interface LocaleSelectorProps {
|
||||
currentLocale: Locale;
|
||||
label: string;
|
||||
localeNames: Record<Locale, string>;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function LocaleSelector({
|
||||
currentLocale,
|
||||
label,
|
||||
localeNames,
|
||||
compact = false,
|
||||
}: LocaleSelectorProps) {
|
||||
const pathname = usePathname();
|
||||
const routeId = routeIdFromPathname(pathname) ?? 'home';
|
||||
|
||||
return (
|
||||
<label className={styles.field}>
|
||||
<span className={compact ? styles.compactLabel : styles.label}>{label}</span>
|
||||
<select
|
||||
className={styles.select}
|
||||
aria-label={label}
|
||||
value={currentLocale}
|
||||
onChange={(event) => {
|
||||
const targetLocale = event.target.value as Locale;
|
||||
persistLocale(targetLocale);
|
||||
window.location.assign(
|
||||
localeSwitchUrl(new URL(window.location.href), targetLocale, routeId),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{locales.map((locale) => (
|
||||
<option key={locale} value={locale}>
|
||||
{localeNames[locale]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { getClientShellMessages } from '@/lib/localization/client-messages';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { LoadingSkeleton, StateShell } from './StateShell';
|
||||
|
||||
export function LocalizedLoading() {
|
||||
const params = useParams<{ locale?: string }>();
|
||||
const messages = getClientShellMessages(params?.locale);
|
||||
return (
|
||||
<StateShell title={messages.states.loadingTitle} body={messages.states.loadingBody} status>
|
||||
<LoadingSkeleton />
|
||||
</StateShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { getClientShellMessages } from '@/lib/localization/client-messages';
|
||||
import { isLocale, localizedPath } from '@/lib/localization/config';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { StateAction, StateActions, StateShell } from './StateShell';
|
||||
|
||||
export function LocalizedNotFound() {
|
||||
const pathname = usePathname();
|
||||
const localeValue = pathname.split('/').filter(Boolean)[0];
|
||||
const locale = isLocale(localeValue) ? localeValue : 'en';
|
||||
const messages = getClientShellMessages(locale);
|
||||
return (
|
||||
<StateShell title={messages.states.notFoundTitle} body={messages.states.notFoundBody}>
|
||||
<StateActions>
|
||||
<StateAction href={localizedPath('home', locale)}>{messages.states.backHome}</StateAction>
|
||||
</StateActions>
|
||||
</StateShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { ActionLink } from '@/components/actions/ActionLink';
|
||||
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
|
||||
import type { ShellMessages } from '@/lib/localization/messages';
|
||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
||||
import type { ThemePreference } from '@/lib/theme/config';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { HeaderNavigation } from './HeaderNavigation';
|
||||
import { LocaleSelector } from './LocaleSelector';
|
||||
import styles from './SiteHeader.module.css';
|
||||
import { ThemeSelector } from './ThemeSelector';
|
||||
|
||||
function signInUrl(locale: Locale): string {
|
||||
return localizedPath('sign-in', locale);
|
||||
}
|
||||
|
||||
interface MobileNavigationProps {
|
||||
locale: Locale;
|
||||
messages: ShellMessages;
|
||||
themePreference: ThemePreference;
|
||||
demoEnabled: boolean;
|
||||
}
|
||||
|
||||
export function MobileNavigation({
|
||||
locale,
|
||||
messages,
|
||||
themePreference,
|
||||
demoEnabled,
|
||||
}: MobileNavigationProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
document.body.dataset.navigationOpen = 'true';
|
||||
closeRef.current?.focus();
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
|
||||
return () => {
|
||||
delete document.body.dataset.navigationOpen;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const close = () => {
|
||||
dialogRef.current?.close();
|
||||
setOpen(false);
|
||||
delete document.body.dataset.navigationOpen;
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.mobileNavigation}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={styles.menuButton}
|
||||
aria-label={messages.header.menuOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span aria-hidden="true" className={styles.menuIcon}>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={styles.drawer}
|
||||
aria-label={messages.header.navigationLabel}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
delete document.body.dataset.navigationOpen;
|
||||
}}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<div className={styles.drawerPanel}>
|
||||
<div className={styles.drawerHeader}>
|
||||
<strong>RentalDriveGo</strong>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
className={styles.closeButton}
|
||||
aria-label={messages.header.menuClose}
|
||||
onClick={close}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<HeaderNavigation
|
||||
locale={locale}
|
||||
label={messages.header.navigationLabel}
|
||||
labels={messages.header.nav}
|
||||
mobile
|
||||
onNavigate={close}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={styles.drawerControls}
|
||||
role="group"
|
||||
aria-label={messages.header.controlsLabel}
|
||||
>
|
||||
<LocaleSelector
|
||||
currentLocale={locale}
|
||||
label={messages.controls.language}
|
||||
localeNames={messages.controls.localeNames}
|
||||
/>
|
||||
<ThemeSelector
|
||||
initialPreference={themePreference}
|
||||
label={messages.controls.theme}
|
||||
statusTemplate={messages.controls.themeStatus}
|
||||
optionLabels={messages.controls.themes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.drawerActions}>
|
||||
<ActionLink href={signInUrl(locale)} variant="button-primary">
|
||||
{messages.header.login}
|
||||
</ActionLink>
|
||||
<DemoTrigger
|
||||
label={messages.header.demo}
|
||||
source="mobile-header"
|
||||
fullWidth
|
||||
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
.footer {
|
||||
border-block-start: 1px solid var(--border-standard);
|
||||
background: var(--surface-primary);
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: grid;
|
||||
inline-size: min(100%, var(--container-wide));
|
||||
margin-inline: auto;
|
||||
padding-block: var(--space-12) var(--space-6);
|
||||
padding-inline: var(--layout-gutter);
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.brandColumn {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--space-4);
|
||||
max-inline-size: 32rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
color: var(--text-primary);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mark {
|
||||
display: inline-grid;
|
||||
min-inline-size: var(--size-touch-min);
|
||||
min-block-size: var(--size-touch-min);
|
||||
padding-inline: var(--space-2);
|
||||
place-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--interactive-primary);
|
||||
color: var(--text-inverse);
|
||||
font-size: var(--type-caption-size);
|
||||
}
|
||||
|
||||
.tagline,
|
||||
.releaseNotice {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.group h2 {
|
||||
margin-block-end: var(--space-3);
|
||||
font-size: var(--type-label-size);
|
||||
letter-spacing: var(--tracking-label);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
html[dir='rtl'] .group h2 {
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.group ul {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: var(--space-2);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.group a,
|
||||
.pendingItem {
|
||||
display: inline-flex;
|
||||
min-block-size: var(--size-touch-min);
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.group a:hover {
|
||||
color: var(--interactive-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pendingItem {
|
||||
flex-wrap: wrap;
|
||||
opacity: var(--opacity-disabled);
|
||||
}
|
||||
|
||||
.legalLink {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block-start: var(--space-6);
|
||||
border-block-start: 1px solid var(--border-standard);
|
||||
color: var(--text-subdued);
|
||||
font-size: var(--type-caption-size);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.inner {
|
||||
grid-template-columns: minmax(14rem, 0.8fr) minmax(0, 2fr);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { StatusBadge } from '@/components/foundation/StatusBadge';
|
||||
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
|
||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
||||
import type { ShellMessages } from '@/lib/localization/messages';
|
||||
import styles from './SiteFooter.module.css';
|
||||
|
||||
function signInUrl(locale: Locale): string {
|
||||
return localizedPath('sign-in', locale);
|
||||
}
|
||||
|
||||
interface SiteFooterProps {
|
||||
locale: Locale;
|
||||
messages: ShellMessages;
|
||||
demoEnabled: boolean;
|
||||
}
|
||||
|
||||
function HashLink({ locale, hash, children }: { locale: Locale; hash: string; children: string }) {
|
||||
return <a href={`${localizedPath('home', locale)}#${hash}`}>{children}</a>;
|
||||
}
|
||||
|
||||
function PendingRouteLink({
|
||||
locale,
|
||||
routeId,
|
||||
label,
|
||||
pending,
|
||||
}: {
|
||||
locale: Locale;
|
||||
routeId: 'sign-in' | 'forgot-password' | 'privacy' | 'terms' | 'accessibility';
|
||||
label: string;
|
||||
pending: string;
|
||||
}) {
|
||||
return (
|
||||
<a className={styles.legalLink} href={localizedPath(routeId, locale)}>
|
||||
<span>{label}</span>
|
||||
<StatusBadge>{pending}</StatusBadge>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingItem({ label, pending }: { label: string; pending: string }) {
|
||||
return (
|
||||
<span className={styles.pendingItem} aria-disabled="true">
|
||||
<span>{label}</span>
|
||||
<StatusBadge>{pending}</StatusBadge>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteFooter({ locale, messages, demoEnabled }: SiteFooterProps) {
|
||||
const footer = messages.footer;
|
||||
const year = new Date().getUTCFullYear();
|
||||
|
||||
return (
|
||||
<footer className={styles.footer} aria-label={footer.landmarkLabel}>
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.brandColumn}>
|
||||
<div className={styles.brand}>
|
||||
<span aria-hidden="true" className={styles.mark}>
|
||||
RDG
|
||||
</span>
|
||||
<span>RentalDriveGo</span>
|
||||
</div>
|
||||
<p className={styles.tagline}>{footer.tagline}</p>
|
||||
<p className={styles.releaseNotice}>{footer.releaseNotice}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.groups}>
|
||||
<section className={styles.group} aria-labelledby="footer-product">
|
||||
<h2 id="footer-product">{footer.groups.product}</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<HashLink locale={locale} hash="workflow">
|
||||
{footer.links.workflow}
|
||||
</HashLink>
|
||||
</li>
|
||||
<li>
|
||||
<HashLink locale={locale} hash="modules">
|
||||
{footer.links.modules}
|
||||
</HashLink>
|
||||
</li>
|
||||
<li>
|
||||
<HashLink locale={locale} hash="pricing">
|
||||
{footer.links.pricing}
|
||||
</HashLink>
|
||||
</li>
|
||||
<li>
|
||||
<HashLink locale={locale} hash="faq">
|
||||
{footer.links.faq}
|
||||
</HashLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className={styles.group} aria-labelledby="footer-company">
|
||||
<h2 id="footer-company">{footer.groups.company}</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<PendingItem label={footer.links.contact} pending={footer.pending} />
|
||||
</li>
|
||||
<li>
|
||||
<DemoTrigger
|
||||
label={footer.links.demo}
|
||||
source="footer"
|
||||
size="small"
|
||||
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className={styles.group} aria-labelledby="footer-account">
|
||||
<h2 id="footer-account">{footer.groups.account}</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<a href={signInUrl(locale)}>
|
||||
{footer.links.login}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className={styles.group} aria-labelledby="footer-legal">
|
||||
<h2 id="footer-legal">{footer.groups.legal}</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<PendingRouteLink
|
||||
locale={locale}
|
||||
routeId="privacy"
|
||||
label={footer.links.privacy}
|
||||
pending={footer.pending}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<PendingRouteLink
|
||||
locale={locale}
|
||||
routeId="terms"
|
||||
label={footer.links.terms}
|
||||
pending={footer.pending}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<PendingRouteLink
|
||||
locale={locale}
|
||||
routeId="accessibility"
|
||||
label={footer.links.accessibility}
|
||||
pending={footer.pending}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className={styles.bottom}>
|
||||
<span>
|
||||
{footer.copyrightPrefix} {year} RentalDriveGo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
.header {
|
||||
position: sticky;
|
||||
z-index: var(--layer-sticky);
|
||||
inset-block-start: 0;
|
||||
border-block-end: 1px solid var(--border-standard);
|
||||
background: color-mix(in srgb, var(--surface-primary) 94%, transparent);
|
||||
box-shadow: var(--shadow-subtle);
|
||||
backdrop-filter: blur(var(--space-3));
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: grid;
|
||||
min-block-size: var(--header-min-block-size);
|
||||
inline-size: min(100%, var(--container-wide));
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--layout-gutter);
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
min-block-size: var(--size-touch-min);
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
color: var(--text-primary);
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mark {
|
||||
inline-size: 36px;
|
||||
block-size: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.navigation ul,
|
||||
.drawerNavigation ul {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.navigation ul {
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.navigation a,
|
||||
.drawerNavigation a {
|
||||
display: inline-flex;
|
||||
min-block-size: var(--size-touch-min);
|
||||
align-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navigation a {
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
|
||||
.navigation a:hover,
|
||||
.navigation a[aria-current],
|
||||
.drawerNavigation a:hover,
|
||||
.drawerNavigation a[aria-current] {
|
||||
background: var(--surface-muted);
|
||||
color: var(--interactive-primary);
|
||||
}
|
||||
|
||||
.desktopControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.actionDisabled,
|
||||
.conversionDisabled {
|
||||
display: inline-flex;
|
||||
min-block-size: var(--size-touch-min);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding-block: var(--space-2);
|
||||
padding-inline: var(--space-3);
|
||||
background: var(--control-disabled-surface);
|
||||
color: var(--control-disabled-text);
|
||||
font-weight: 700;
|
||||
opacity: var(--opacity-disabled);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actionLink {
|
||||
display: inline-flex;
|
||||
min-block-size: var(--size-touch-min);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding-block: var(--space-2);
|
||||
padding-inline: var(--space-3);
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.actionLink:hover {
|
||||
background: var(--interactive-primary);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--interactive-primary);
|
||||
}
|
||||
|
||||
.conversionDisabled {
|
||||
border-color: var(--interactive-conversion);
|
||||
}
|
||||
|
||||
.mobileActions,
|
||||
.mobileNavigation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menuButton,
|
||||
.closeButton {
|
||||
display: inline-grid;
|
||||
min-inline-size: var(--size-touch-min);
|
||||
min-block-size: var(--size-touch-min);
|
||||
place-items: center;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
display: grid;
|
||||
inline-size: var(--space-5);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.menuIcon span {
|
||||
display: block;
|
||||
block-size: 2px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
inline-size: min(88vw, 384px);
|
||||
max-inline-size: none;
|
||||
block-size: 100dvh;
|
||||
max-block-size: none;
|
||||
margin-block: 0;
|
||||
margin-inline: 0 auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
html[dir='rtl'] .drawer {
|
||||
margin-inline: auto 0;
|
||||
}
|
||||
|
||||
.drawer::backdrop {
|
||||
background: var(--overlay-scrim);
|
||||
}
|
||||
|
||||
.drawerPanel {
|
||||
display: flex;
|
||||
block-size: 100%;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
overflow-y: auto;
|
||||
padding: var(--space-5);
|
||||
background: var(--surface-elevated);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
|
||||
.drawerHeader {
|
||||
display: flex;
|
||||
min-block-size: var(--size-touch-min);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
font-size: var(--type-heading-3-size);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.drawerNavigation ul {
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.drawerNavigation a {
|
||||
inline-size: 100%;
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
|
||||
.drawerControls,
|
||||
.drawerActions {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.drawerActions {
|
||||
margin-block-start: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* Preserve the full navigation on laptop and tablet landscape widths.
|
||||
* Only the secondary controls collapse into the menu until the viewport
|
||||
* becomes too narrow for the five primary destinations.
|
||||
*/
|
||||
@media (min-width: 900px) and (max-width: 1279px) {
|
||||
.brandName {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desktopControls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileActions,
|
||||
.mobileNavigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.inner {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.desktopNavigation,
|
||||
.desktopControls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileActions,
|
||||
.mobileNavigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 559px) {
|
||||
.brandName {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobileActions > .conversionDisabled {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.header {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.mark,
|
||||
.actionDisabled,
|
||||
.conversionDisabled,
|
||||
.menuButton,
|
||||
.closeButton {
|
||||
border: 1px solid CanvasText;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 559px) {
|
||||
.mobileDemoTrigger {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
||||
import type { ShellMessages } from '@/lib/localization/messages';
|
||||
import type { ThemePreference } from '@/lib/theme/config';
|
||||
import { ActionLink } from '@/components/actions/ActionLink';
|
||||
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
|
||||
import { BrandLink } from './BrandLink';
|
||||
import { HeaderNavigation } from './HeaderNavigation';
|
||||
import { LocaleSelector } from './LocaleSelector';
|
||||
import { MobileNavigation } from './MobileNavigation';
|
||||
import styles from './SiteHeader.module.css';
|
||||
import { ThemeSelector } from './ThemeSelector';
|
||||
|
||||
function signInUrl(locale: Locale): string {
|
||||
return localizedPath('sign-in', locale);
|
||||
}
|
||||
|
||||
interface SiteHeaderProps {
|
||||
locale: Locale;
|
||||
messages: ShellMessages;
|
||||
themePreference: ThemePreference;
|
||||
demoEnabled: boolean;
|
||||
}
|
||||
|
||||
export function SiteHeader({ locale, messages, themePreference, demoEnabled }: SiteHeaderProps) {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.inner}>
|
||||
<BrandLink locale={locale} label={messages.brandLabel} />
|
||||
|
||||
<div className={styles.desktopNavigation}>
|
||||
<HeaderNavigation
|
||||
locale={locale}
|
||||
label={messages.header.navigationLabel}
|
||||
labels={messages.header.nav}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.desktopControls}
|
||||
role="group"
|
||||
aria-label={messages.header.controlsLabel}
|
||||
>
|
||||
<LocaleSelector
|
||||
currentLocale={locale}
|
||||
label={messages.controls.language}
|
||||
localeNames={messages.controls.localeNames}
|
||||
compact
|
||||
/>
|
||||
<ThemeSelector
|
||||
initialPreference={themePreference}
|
||||
label={messages.controls.theme}
|
||||
statusTemplate={messages.controls.themeStatus}
|
||||
optionLabels={messages.controls.themes}
|
||||
compact
|
||||
/>
|
||||
<ActionLink href={signInUrl(locale)} variant="button-primary">
|
||||
{messages.header.login}
|
||||
</ActionLink>
|
||||
<DemoTrigger
|
||||
label={messages.header.demo}
|
||||
source="header"
|
||||
size="small"
|
||||
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.mobileActions}>
|
||||
<DemoTrigger
|
||||
label={messages.header.demo}
|
||||
source="mobile-header"
|
||||
size="small"
|
||||
className={styles.mobileDemoTrigger}
|
||||
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
|
||||
/>
|
||||
<MobileNavigation
|
||||
locale={locale}
|
||||
messages={messages}
|
||||
themePreference={themePreference}
|
||||
demoEnabled={demoEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
.main {
|
||||
display: grid;
|
||||
min-block-size: calc(100dvh - var(--header-min-block-size));
|
||||
place-items: center;
|
||||
padding-block: var(--section-space);
|
||||
padding-inline: var(--layout-gutter);
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: grid;
|
||||
inline-size: min(100%, var(--container-reading));
|
||||
gap: var(--space-5);
|
||||
padding: clamp(var(--space-6), 6vw, var(--space-12));
|
||||
border: 1px solid var(--border-standard);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-elevated);
|
||||
box-shadow: var(--shadow-card);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.panel h1 {
|
||||
margin: 0;
|
||||
font-size: var(--type-heading-1-size);
|
||||
}
|
||||
|
||||
.panel p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--type-body-large-size);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
min-block-size: var(--size-touch-min);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--interactive-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding-block: var(--space-2);
|
||||
padding-inline: var(--space-4);
|
||||
background: var(--interactive-primary);
|
||||
color: var(--text-inverse);
|
||||
font-weight: 750;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--interactive-primary-hover);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.skeleton span {
|
||||
display: block;
|
||||
block-size: var(--space-4);
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--skeleton-base),
|
||||
var(--skeleton-highlight),
|
||||
var(--skeleton-base)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shift 1.4s var(--easing-standard) infinite;
|
||||
}
|
||||
|
||||
.skeleton span:first-child {
|
||||
inline-size: 64%;
|
||||
}
|
||||
|
||||
.skeleton span:last-child {
|
||||
inline-size: 82%;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shift {
|
||||
from {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||
import styles from './StateShell.module.css';
|
||||
|
||||
interface StateShellProps {
|
||||
title: string;
|
||||
body: string;
|
||||
children?: ReactNode;
|
||||
status?: boolean;
|
||||
}
|
||||
|
||||
export function StateShell({ title, body, children, status = false }: StateShellProps) {
|
||||
return (
|
||||
<main id="main-content" className={styles.main} tabIndex={-1}>
|
||||
<section
|
||||
className={styles.panel}
|
||||
role={status ? 'status' : undefined}
|
||||
aria-live={status ? 'polite' : undefined}
|
||||
>
|
||||
<h1>{title}</h1>
|
||||
<p>{body}</p>
|
||||
{children ? <div className={styles.content}>{children}</div> : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function StateActions({ children }: { children: ReactNode }) {
|
||||
return <div className={styles.actions}>{children}</div>;
|
||||
}
|
||||
|
||||
export function StateAction({ children, ...props }: ComponentPropsWithoutRef<'a'>) {
|
||||
return (
|
||||
<a className={styles.action} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function StateButton({ children, ...props }: ComponentPropsWithoutRef<'button'>) {
|
||||
return (
|
||||
<button className={styles.action} type="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingSkeleton() {
|
||||
return (
|
||||
<div className={styles.skeleton} aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { applyTheme, currentThemePreference } from '@/lib/theme/client';
|
||||
import { themeStorageKey } from '@/lib/theme/config';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function ThemeController() {
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleSystemChange = () => {
|
||||
if (currentThemePreference() === 'system') applyTheme('system');
|
||||
};
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === themeStorageKey && event.newValue) {
|
||||
const preference = event.newValue;
|
||||
if (preference === 'light' || preference === 'dark' || preference === 'system') {
|
||||
applyTheme(preference);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
media.addEventListener('change', handleSystemChange);
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => {
|
||||
media.removeEventListener('change', handleSystemChange);
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { persistTheme, themePreferenceEvent } from '@/lib/theme/client';
|
||||
import { themePreferences, themeStorageKey, type ThemePreference } from '@/lib/theme/config';
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
import styles from './Controls.module.css';
|
||||
|
||||
interface ThemeSelectorProps {
|
||||
initialPreference: ThemePreference;
|
||||
label: string;
|
||||
statusTemplate: string;
|
||||
optionLabels: Record<ThemePreference, string>;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ThemeSelector({
|
||||
initialPreference,
|
||||
label,
|
||||
statusTemplate,
|
||||
optionLabels,
|
||||
compact = false,
|
||||
}: ThemeSelectorProps) {
|
||||
const [preference, setPreference] = useState<ThemePreference>(initialPreference);
|
||||
const statusId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (
|
||||
event.key === themeStorageKey &&
|
||||
(event.newValue === 'light' || event.newValue === 'dark' || event.newValue === 'system')
|
||||
) {
|
||||
setPreference(event.newValue);
|
||||
}
|
||||
};
|
||||
const handlePreference = (event: Event) => {
|
||||
const preferenceEvent = event as CustomEvent<ThemePreference>;
|
||||
setPreference(preferenceEvent.detail);
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
window.addEventListener(themePreferenceEvent, handlePreference);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
window.removeEventListener(themePreferenceEvent, handlePreference);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const status = statusTemplate.replace('{value}', optionLabels[preference]);
|
||||
|
||||
return (
|
||||
<label className={styles.field}>
|
||||
<span className={compact ? styles.compactLabel : styles.label}>{label}</span>
|
||||
<select
|
||||
className={styles.select}
|
||||
aria-label={label}
|
||||
aria-describedby={statusId}
|
||||
value={preference}
|
||||
onChange={(event) => {
|
||||
const nextPreference = event.target.value as ThemePreference;
|
||||
setPreference(nextPreference);
|
||||
persistTheme(nextPreference);
|
||||
}}
|
||||
>
|
||||
{themePreferences.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{optionLabels[option]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span id={statusId} className="visually-hidden" aria-live="polite">
|
||||
{status}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { localizedPath, type Locale } from '@/lib/localization/config'
|
||||
import Image from 'next/image'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface AuthShellProps {
|
||||
locale: Locale
|
||||
title: string
|
||||
subtitle?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AuthShell({ locale, title, subtitle, children }: AuthShellProps) {
|
||||
return (
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem 1rem',
|
||||
minHeight: 'calc(100vh - 80px)',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%', maxWidth: '28rem' }}>
|
||||
<div style={{ marginBottom: '2rem', textAlign: 'center' }}>
|
||||
<a href={localizedPath('home', locale)}>
|
||||
<Image
|
||||
src="/rentaldrivego.jpeg"
|
||||
alt="RentalDriveGo"
|
||||
width={96}
|
||||
height={96}
|
||||
priority
|
||||
style={{
|
||||
borderRadius: '1.5rem',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
padding: '0.375rem',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
<p
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.28em',
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
RentalDriveGo
|
||||
</p>
|
||||
<h1
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 800,
|
||||
letterSpacing: '-0.03em',
|
||||
color: 'var(--color-heading)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle ? (
|
||||
<p
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section
|
||||
style={{
|
||||
borderRadius: '2rem',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
padding: '2rem',
|
||||
boxShadow: '0 30px 80px rgba(28,25,23,0.06)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/actions/Button'
|
||||
import { TextInput } from '@/components/forms/TextInput'
|
||||
import { FormField } from '@/components/forms/FormField'
|
||||
import { AuthShell } from '@/components/auth/AuthShell'
|
||||
import { API_BASE } from '@/lib/api'
|
||||
import { localizedPath, type Locale } from '@/lib/localization/config'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Dict {
|
||||
title: string
|
||||
subtitle: string
|
||||
email: string
|
||||
emailPlaceholder: string
|
||||
submit: string
|
||||
submitting: string
|
||||
backToLogin: string
|
||||
successTitle: string
|
||||
successBody: string
|
||||
error: string
|
||||
}
|
||||
|
||||
const dicts: Record<string, Dict> = {
|
||||
en: {
|
||||
title: 'Forgot your password?',
|
||||
subtitle: "Enter your work email and we'll send you a reset link.",
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'owner@company.com',
|
||||
submit: 'Send reset link',
|
||||
submitting: 'Sending…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Check your email',
|
||||
successBody: 'If that email is registered, a reset link has been sent. It expires in 60 minutes.',
|
||||
error: 'Something went wrong. Please try again.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Mot de passe oublié ?',
|
||||
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'owner@company.com',
|
||||
submit: 'Envoyer le lien',
|
||||
submitting: 'Envoi…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Vérifiez votre email',
|
||||
successBody: "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Il expire dans 60 minutes.",
|
||||
error: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
},
|
||||
ar: {
|
||||
title: 'نسيت كلمة المرور؟',
|
||||
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
|
||||
email: 'البريد الإلكتروني',
|
||||
emailPlaceholder: 'owner@company.com',
|
||||
submit: 'إرسال رابط إعادة التعيين',
|
||||
submitting: 'جارٍ الإرسال…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تحقق من بريدك الإلكتروني',
|
||||
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
|
||||
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
},
|
||||
}
|
||||
|
||||
export function ForgotPasswordForm({ locale }: { locale: Locale }) {
|
||||
const dict = (dicts[locale] ?? dicts.en) as Dict
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [empRes, adminRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/auth/employee/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
fetch(`${API_BASE}/admin/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
])
|
||||
if (!empRes.ok && !adminRes.ok) throw new Error()
|
||||
setSent(true)
|
||||
} catch {
|
||||
setError(dict.error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signInHref = localizedPath('sign-in', locale)
|
||||
|
||||
return (
|
||||
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
|
||||
{sent ? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
margin: '0 auto 1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '3.5rem',
|
||||
height: '3.5rem',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--color-success-bg, #ecfdf5)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
style={{ width: '1.75rem', height: '1.75rem', color: 'var(--color-success-text, #059669)' }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-heading)' }}>
|
||||
{dict.successTitle}
|
||||
</h2>
|
||||
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
|
||||
{dict.successBody}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
{error ? (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '1.5rem',
|
||||
border: '1px solid var(--color-error-border, #fecaca)',
|
||||
background: 'var(--color-error-bg, #fef2f2)',
|
||||
padding: '0.75rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-error-text, #b91c1c)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FormField id="forgot-email" label={dict.email} required>
|
||||
<TextInput
|
||||
id="forgot-email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={dict.emailPlaceholder}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '1.5rem',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
paddingTop: '1.5rem',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={signInHref}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-heading)',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: '4px',
|
||||
}}
|
||||
>
|
||||
{dict.backToLogin}
|
||||
</a>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/actions/Button';
|
||||
import { FormField } from '@/components/forms/FormField';
|
||||
import { AuthShell } from '@/components/auth/AuthShell';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useState } from 'react';
|
||||
|
||||
interface Dict {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
submit: string;
|
||||
submitting: string;
|
||||
backToLogin: string;
|
||||
successTitle: string;
|
||||
successBody: string;
|
||||
signIn: string;
|
||||
errorMismatch: string;
|
||||
errorShort: string;
|
||||
errorInvalidToken: string;
|
||||
errorGeneric: string;
|
||||
noToken: string;
|
||||
requestNew: string;
|
||||
}
|
||||
|
||||
const dicts: Record<string, Dict> = {
|
||||
en: {
|
||||
title: 'Set new password',
|
||||
subtitle: 'Choose a strong password for your account.',
|
||||
newPassword: 'New password',
|
||||
confirmPassword: 'Confirm password',
|
||||
submit: 'Reset password',
|
||||
submitting: 'Resetting…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Password updated',
|
||||
successBody: 'Your password has been reset. You can now sign in with your new password.',
|
||||
signIn: 'Sign in',
|
||||
errorMismatch: 'Passwords do not match.',
|
||||
errorShort: 'Password must be at least 8 characters.',
|
||||
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
|
||||
errorGeneric: 'Something went wrong. Please try again.',
|
||||
noToken: 'No reset token found. Please request a new password reset.',
|
||||
requestNew: 'Request new reset link',
|
||||
},
|
||||
fr: {
|
||||
title: 'Nouveau mot de passe',
|
||||
subtitle: 'Choisissez un mot de passe fort pour votre compte.',
|
||||
newPassword: 'Nouveau mot de passe',
|
||||
confirmPassword: 'Confirmer le mot de passe',
|
||||
submit: 'Réinitialiser',
|
||||
submitting: 'Traitement…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Mot de passe mis à jour',
|
||||
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
|
||||
signIn: 'Se connecter',
|
||||
errorMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
|
||||
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
|
||||
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
noToken: 'Aucun jeton de réinitialisation trouvé. Veuillez demander un nouveau lien.',
|
||||
requestNew: 'Demander un nouveau lien',
|
||||
},
|
||||
ar: {
|
||||
title: 'تعيين كلمة مرور جديدة',
|
||||
subtitle: 'اختر كلمة مرور قوية لحسابك.',
|
||||
newPassword: 'كلمة المرور الجديدة',
|
||||
confirmPassword: 'تأكيد كلمة المرور',
|
||||
submit: 'إعادة تعيين',
|
||||
submitting: 'جارٍ المعالجة…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تم تحديث كلمة المرور',
|
||||
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
|
||||
signIn: 'تسجيل الدخول',
|
||||
errorMismatch: 'كلمتا المرور غير متطابقتين.',
|
||||
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
|
||||
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
|
||||
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
noToken: 'لم يتم العثور على رمز إعادة التعيين. يرجى طلب رابط جديد.',
|
||||
requestNew: 'طلب رابط جديد',
|
||||
},
|
||||
};
|
||||
|
||||
function ResetPasswordContent({ locale }: { locale: Locale }) {
|
||||
const dict = (dicts[locale] ?? dicts.en) as Dict;
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (password.length < 8) {
|
||||
setError(dict.errorShort);
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError(dict.errorMismatch);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Try employee endpoint first, fall back to admin
|
||||
let res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
res = await fetch(`${API_BASE}/admin/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken);
|
||||
throw new Error(dict.errorGeneric);
|
||||
}
|
||||
setDone(true);
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : dict.errorGeneric);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const signInHref = localizedPath('sign-in', locale);
|
||||
const forgotHref = localizedPath('forgot-password', locale);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<AuthShell locale={locale} title={dict.title}>
|
||||
<p
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{dict.noToken}
|
||||
</p>
|
||||
<div style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<a href={forgotHref}>
|
||||
<Button intent="primary">{dict.requestNew}</Button>
|
||||
</a>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<AuthShell locale={locale} title={dict.title}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
margin: '0 auto 1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '3.5rem',
|
||||
height: '3.5rem',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--color-success-bg, #ecfdf5)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
style={{
|
||||
width: '1.75rem',
|
||||
height: '1.75rem',
|
||||
color: 'var(--color-success-text, #059669)',
|
||||
}}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-heading)' }}>
|
||||
{dict.successTitle}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{dict.successBody}
|
||||
</p>
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<a href={signInHref}>
|
||||
<Button intent="primary" fullWidth>
|
||||
{dict.signIn}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}
|
||||
>
|
||||
{error ? (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '1.5rem',
|
||||
border: '1px solid var(--color-error-border, #fecaca)',
|
||||
background: 'var(--color-error-bg, #fef2f2)',
|
||||
padding: '0.75rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-error-text, #b91c1c)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FormField id="reset-password" label={dict.newPassword} required>
|
||||
<input
|
||||
id="reset-password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '2.875rem',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
border: '1px solid var(--border-strong)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--surface-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="reset-confirm" label={dict.confirmPassword} required>
|
||||
<input
|
||||
id="reset-confirm"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '2.875rem',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
border: '1px solid var(--border-strong)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--surface-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '1.5rem',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
paddingTop: '1.5rem',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={signInHref}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-heading)',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: '4px',
|
||||
}}
|
||||
>
|
||||
{dict.backToLogin}
|
||||
</a>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResetPasswordForm({ locale }: { locale: Locale }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResetPasswordContent locale={locale} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/actions/Button';
|
||||
import { TextInput } from '@/components/forms/TextInput';
|
||||
import { FormField } from '@/components/forms/FormField';
|
||||
import { AuthShell } from '@/components/auth/AuthShell';
|
||||
import { API_BASE, EMPLOYEE_PROFILE_KEY } from '@/lib/api';
|
||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Dict {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
email: string;
|
||||
emailPlaceholder: string;
|
||||
password: string;
|
||||
signIn: string;
|
||||
signingIn: string;
|
||||
verify: string;
|
||||
verifying: string;
|
||||
authCode: string;
|
||||
enterCode: string;
|
||||
totpPlaceholder: string;
|
||||
back: string;
|
||||
forgotPassword: string;
|
||||
invalidCredentials: string;
|
||||
passwordNotSet: string;
|
||||
tooManyRequests: string;
|
||||
emailNotVerified: string;
|
||||
unexpectedError: string;
|
||||
}
|
||||
|
||||
const dicts: Record<string, Dict> = {
|
||||
en: {
|
||||
title: 'Sign in',
|
||||
subtitle: 'Enter your credentials to access your account.',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'owner@company.com',
|
||||
password: 'Password',
|
||||
signIn: 'Sign in',
|
||||
signingIn: 'Signing in…',
|
||||
verify: 'Verify code',
|
||||
verifying: 'Verifying…',
|
||||
authCode: 'Authentication code',
|
||||
enterCode: 'Enter the 6-digit code from your authenticator app.',
|
||||
totpPlaceholder: '000000 or XXXX-XXXX-XXXX',
|
||||
back: 'Back to credentials',
|
||||
forgotPassword: 'Forgot your password?',
|
||||
invalidCredentials: 'Invalid email or password.',
|
||||
passwordNotSet:
|
||||
'No password set yet. Check your invitation email or use "Forgot your password?"',
|
||||
tooManyRequests: 'Too many attempts. Please try again later.',
|
||||
emailNotVerified: 'Please verify your email first. Check your inbox for the verification link.',
|
||||
unexpectedError: 'Something went wrong. Please try again.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Connexion',
|
||||
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'owner@company.com',
|
||||
password: 'Mot de passe',
|
||||
signIn: 'Connexion',
|
||||
signingIn: 'Connexion…',
|
||||
verify: 'Vérifier le code',
|
||||
verifying: 'Vérification…',
|
||||
authCode: "Code d'authentification",
|
||||
enterCode: "Entrez le code à 6 chiffres de votre application d'authentification.",
|
||||
totpPlaceholder: '000000 ou XXXX-XXXX-XXXX',
|
||||
back: 'Retour aux identifiants',
|
||||
forgotPassword: 'Mot de passe oublié ?',
|
||||
invalidCredentials: 'Adresse e-mail ou mot de passe invalide.',
|
||||
passwordNotSet:
|
||||
"Aucun mot de passe défini. Vérifiez votre e-mail d'invitation ou utilisez « Mot de passe oublié ? »",
|
||||
tooManyRequests: 'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||
emailNotVerified: 'Veuillez vérifier votre adresse email. Consultez votre boîte de réception.',
|
||||
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
},
|
||||
ar: {
|
||||
title: 'تسجيل الدخول',
|
||||
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
|
||||
email: 'البريد الإلكتروني',
|
||||
emailPlaceholder: 'owner@company.com',
|
||||
password: 'كلمة المرور',
|
||||
signIn: 'تسجيل الدخول',
|
||||
signingIn: 'جارٍ تسجيل الدخول…',
|
||||
verify: 'تحقق من الرمز',
|
||||
verifying: 'جارٍ التحقق…',
|
||||
authCode: 'رمز المصادقة',
|
||||
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
||||
totpPlaceholder: '000000 أو XXXX-XXXX-XXXX',
|
||||
back: 'العودة إلى بيانات الدخول',
|
||||
forgotPassword: 'نسيت كلمة المرور؟',
|
||||
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
|
||||
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
|
||||
tooManyRequests: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||
emailNotVerified: 'يرجى التحقق من بريدك الإلكتروني أولاً. تحقق من صندوق الوارد.',
|
||||
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
},
|
||||
};
|
||||
|
||||
export function SignInForm({ locale }: { locale: Locale }) {
|
||||
const dict = (dicts[locale] ?? dicts.en) as Dict;
|
||||
const searchParams = useSearchParams();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [step, setStep] = useState<'credentials' | 'totp'>('credentials');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const requestedPortal = searchParams.get('portal');
|
||||
const employeeRedirect = searchParams.get('redirect') || '/dashboard';
|
||||
const preferAdminAuth = requestedPortal === 'admin';
|
||||
|
||||
async function handleCredentials(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const tryAdminLogin = async () => {
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const adminJson = await adminRes.json();
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.admin) {
|
||||
window.location.href = `${window.location.origin}/admin/dashboard`;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
|
||||
setStep('totp');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (adminRes.status === 429) {
|
||||
setError(dict.tooManyRequests);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const tryEmployeeLogin = async () => {
|
||||
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const empJson = await empRes.json();
|
||||
|
||||
if (empRes.ok && empJson?.data?.employee) {
|
||||
if (empJson?.data?.employee) {
|
||||
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee));
|
||||
const prefLang = empJson.data.employee?.preferredLanguage;
|
||||
if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') {
|
||||
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`;
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'));
|
||||
window.location.replace(employeeRedirect);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (empJson?.error === 'password_not_set') {
|
||||
setError(dict.passwordNotSet);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (empJson?.error === 'email_not_verified') {
|
||||
setError(dict.emailNotVerified);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (empRes.status === 429) {
|
||||
setError(dict.tooManyRequests);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
if (preferAdminAuth) {
|
||||
if (await tryAdminLogin()) return;
|
||||
setError(dict.invalidCredentials);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await tryEmployeeLogin()) return;
|
||||
|
||||
setError(dict.invalidCredentials);
|
||||
} catch {
|
||||
setError(dict.unexpectedError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotp(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const normalizedCode = totpCode.trim().toUpperCase();
|
||||
const secondFactor = /^\d{6}$/.test(normalizedCode)
|
||||
? { totpCode: normalizedCode }
|
||||
: { recoveryCode: normalizedCode };
|
||||
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password, ...secondFactor }),
|
||||
});
|
||||
const adminJson = await adminRes.json();
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.admin) {
|
||||
window.location.href = `${window.location.origin}/admin/dashboard`;
|
||||
return;
|
||||
}
|
||||
|
||||
setError(dict.invalidCredentials);
|
||||
} catch {
|
||||
setError(dict.unexpectedError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hrefBase = localizedPath('forgot-password', locale);
|
||||
|
||||
return (
|
||||
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
|
||||
{error ? (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '1.25rem',
|
||||
borderRadius: '1.5rem',
|
||||
border: '1px solid var(--color-error-border, #fecaca)',
|
||||
background: 'var(--color-error-bg, #fef2f2)',
|
||||
padding: '0.75rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-error-text, #b91c1c)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'credentials' ? (
|
||||
<form
|
||||
onSubmit={handleCredentials}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}
|
||||
>
|
||||
<FormField id="signin-email" label={dict.email} required>
|
||||
<TextInput
|
||||
id="signin-email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={dict.emailPlaceholder}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="signin-password" label={dict.password} required>
|
||||
<input
|
||||
id="signin-password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '2.875rem',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
border: '1px solid var(--border-strong)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--surface-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
|
||||
{loading ? dict.signingIn : dict.signIn}
|
||||
</Button>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<a
|
||||
href={hrefBase}
|
||||
style={{
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: '4px',
|
||||
}}
|
||||
>
|
||||
{dict.forgotPassword}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleTotp}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '1.5rem',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface-secondary)',
|
||||
padding: '1rem',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{dict.enterCode}
|
||||
</div>
|
||||
|
||||
<FormField id="signin-totp" label={dict.authCode} required>
|
||||
<TextInput
|
||||
id="signin-totp"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
required
|
||||
maxLength={14}
|
||||
value={totpCode}
|
||||
onChange={(e) =>
|
||||
setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())
|
||||
}
|
||||
placeholder={dict.totpPlaceholder}
|
||||
style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.45em' }}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
|
||||
{loading ? dict.verifying : dict.verify}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep('credentials');
|
||||
setTotpCode('');
|
||||
setError(null);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{dict.back}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
.accordion {
|
||||
border-block-start: 1px solid var(--border-standard);
|
||||
}
|
||||
.item {
|
||||
border-block-end: 1px solid var(--border-standard);
|
||||
}
|
||||
.summary {
|
||||
display: flex;
|
||||
min-block-size: 3.5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-4);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.heading {
|
||||
margin: 0;
|
||||
font-size: var(--type-body-large-size);
|
||||
}
|
||||
.icon {
|
||||
transition: transform var(--duration-fast) var(--easing-productive);
|
||||
}
|
||||
.item[open] .icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.content {
|
||||
padding-block-end: var(--space-5);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.content > :last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.icon {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Icon } from '@/components/foundation/Icon';
|
||||
import type { ReactNode } from 'react';
|
||||
import styles from './Accordion.module.css';
|
||||
|
||||
export interface AccordionItem {
|
||||
id: string;
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
export function Accordion({
|
||||
items,
|
||||
headingLevel = 3,
|
||||
}: {
|
||||
items: AccordionItem[];
|
||||
headingLevel?: 2 | 3 | 4;
|
||||
}) {
|
||||
const Heading = `h${headingLevel}` as const;
|
||||
return (
|
||||
<div className={styles.accordion}>
|
||||
{items.map((item) => (
|
||||
<details key={item.id} className={styles.item} open={item.open}>
|
||||
<summary className={styles.summary}>
|
||||
<Heading className={styles.heading}>{item.title}</Heading>
|
||||
<Icon name="chevron-down" className={styles.icon} />
|
||||
</summary>
|
||||
<div className={styles.content}>{item.content}</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.pagination {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.pagination > :last-child {
|
||||
justify-self: end;
|
||||
}
|
||||
.pages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.page {
|
||||
min-inline-size: 2.75rem;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.page[aria-current='page'] {
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--interactive-primary);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
@media (max-width: 479px) {
|
||||
.pages {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ActionLink } from '@/components/actions/ActionLink';
|
||||
import { Icon } from '@/components/foundation/Icon';
|
||||
import styles from './Pagination.module.css';
|
||||
|
||||
export interface PaginationPage {
|
||||
number: number;
|
||||
href: string;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
interface PaginationProps {
|
||||
label: string;
|
||||
pages: PaginationPage[];
|
||||
previous?: { href: string; label: string };
|
||||
next?: { href: string; label: string };
|
||||
}
|
||||
|
||||
export function Pagination({ label, pages, previous, next }: PaginationProps) {
|
||||
return (
|
||||
<nav aria-label={label} className={styles.pagination}>
|
||||
{previous ? (
|
||||
<ActionLink href={previous.href} variant="navigation">
|
||||
<Icon name="arrow-back" directionBehavior="mirror" /> {previous.label}
|
||||
</ActionLink>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<ol className={styles.pages}>
|
||||
{pages.map((page) => (
|
||||
<li key={page.number}>
|
||||
<ActionLink
|
||||
href={page.href}
|
||||
variant="navigation"
|
||||
aria-current={page.current ? 'page' : undefined}
|
||||
className={styles.page}
|
||||
>
|
||||
{page.number}
|
||||
</ActionLink>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{next ? (
|
||||
<ActionLink href={next.href} variant="navigation">
|
||||
{next.label} <Icon name="arrow-forward" directionBehavior="mirror" />
|
||||
</ActionLink>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.control {
|
||||
display: inline-flex;
|
||||
max-inline-size: 100%;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
border: 1px solid var(--border-standard);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.option {
|
||||
flex: none;
|
||||
min-block-size: 2.5rem;
|
||||
padding-block: var(--space-2);
|
||||
padding-inline: var(--space-4);
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.option[aria-checked='true'] {
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-subtle);
|
||||
}
|
||||
.option:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: var(--opacity-disabled);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { classNames } from '@/lib/components/classNames';
|
||||
import styles from './SegmentedControl.module.css';
|
||||
|
||||
export interface SegmentOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps {
|
||||
label: string;
|
||||
value: string;
|
||||
options: SegmentOption[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SegmentedControl({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
}: SegmentedControlProps) {
|
||||
return (
|
||||
<div className={classNames(styles.control, className)} role="radiogroup" aria-label={label}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={option.value === value}
|
||||
disabled={option.disabled}
|
||||
className={styles.option}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.tabs {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.list {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
overflow-x: auto;
|
||||
border-block-end: 1px solid var(--border-standard);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.tab {
|
||||
flex: none;
|
||||
min-block-size: 2.75rem;
|
||||
padding-block: var(--space-3);
|
||||
padding-inline: var(--space-4);
|
||||
border: 0;
|
||||
border-block-end: 3px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tab[aria-selected='true'] {
|
||||
border-block-end-color: var(--interactive-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.tab:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: var(--opacity-disabled);
|
||||
}
|
||||
.panel {
|
||||
padding-block: var(--space-5);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { classNames } from '@/lib/components/classNames';
|
||||
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import styles from './Tabs.module.css';
|
||||
|
||||
export interface TabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
content: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
items: TabItem[];
|
||||
label: string;
|
||||
defaultActiveId?: string;
|
||||
activation?: 'automatic' | 'manual';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
items,
|
||||
label,
|
||||
defaultActiveId,
|
||||
activation = 'automatic',
|
||||
className,
|
||||
}: TabsProps) {
|
||||
const available = items.filter((item) => !item.disabled);
|
||||
const initial =
|
||||
defaultActiveId && available.some((item) => item.id === defaultActiveId)
|
||||
? defaultActiveId
|
||||
: (available[0]?.id ?? '');
|
||||
const [activeId, setActiveId] = useState(initial);
|
||||
const [focusedId, setFocusedId] = useState(initial);
|
||||
const refs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const prefix = useId().replaceAll(':', '');
|
||||
|
||||
const focusByOffset = (currentId: string, offset: number) => {
|
||||
const currentIndex = available.findIndex((item) => item.id === currentId);
|
||||
if (currentIndex < 0 || available.length === 0) return;
|
||||
const next = available[(currentIndex + offset + available.length) % available.length];
|
||||
if (!next) return;
|
||||
setFocusedId(next.id);
|
||||
if (activation === 'automatic') setActiveId(next.id);
|
||||
refs.current.get(next.id)?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
focusByOffset(id, document.documentElement.dir === 'rtl' ? -1 : 1);
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
focusByOffset(id, document.documentElement.dir === 'rtl' ? 1 : -1);
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
const first = available[0];
|
||||
if (first) focusByOffset(first.id, 0);
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
const last = available.at(-1);
|
||||
if (last) focusByOffset(last.id, 0);
|
||||
} else if ((event.key === 'Enter' || event.key === ' ') && activation === 'manual') {
|
||||
event.preventDefault();
|
||||
setActiveId(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.tabs, className)}>
|
||||
<div className={styles.list} role="tablist" aria-label={label}>
|
||||
{items.map((item) => {
|
||||
const selected = item.id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={(node) => {
|
||||
if (node) refs.current.set(item.id, node);
|
||||
else refs.current.delete(item.id);
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${prefix}-tab-${item.id}`}
|
||||
aria-controls={`${prefix}-panel-${item.id}`}
|
||||
aria-selected={selected}
|
||||
tabIndex={item.id === focusedId ? 0 : -1}
|
||||
disabled={item.disabled}
|
||||
className={styles.tab}
|
||||
onClick={() => {
|
||||
setFocusedId(item.id);
|
||||
setActiveId(item.id);
|
||||
}}
|
||||
onKeyDown={(event) => handleKeyDown(event, item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
role="tabpanel"
|
||||
id={`${prefix}-panel-${item.id}`}
|
||||
aria-labelledby={`${prefix}-tab-${item.id}`}
|
||||
hidden={item.id !== activeId}
|
||||
tabIndex={0}
|
||||
className={styles.panel}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.alert {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.inline {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.info {
|
||||
background: var(--status-info-surface);
|
||||
color: var(--status-info-text);
|
||||
}
|
||||
.success {
|
||||
background: var(--status-success-surface);
|
||||
color: var(--status-success-text);
|
||||
}
|
||||
.warning {
|
||||
background: var(--status-warning-surface);
|
||||
color: var(--status-warning-text);
|
||||
}
|
||||
.error {
|
||||
background: var(--status-error-surface);
|
||||
color: var(--status-error-text);
|
||||
}
|
||||
.title {
|
||||
display: block;
|
||||
margin-block-end: var(--space-1);
|
||||
}
|
||||
.content > :last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
|
||||
import { classNames } from '@/lib/components/classNames';
|
||||
import type { ReactNode } from 'react';
|
||||
import styles from './Alert.module.css';
|
||||
|
||||
export type AlertTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
const toneIcon: Record<AlertTone, ApprovedIconName> = {
|
||||
info: 'info',
|
||||
success: 'check',
|
||||
warning: 'warning',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
interface AlertProps {
|
||||
tone?: AlertTone;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
live?: 'off' | 'polite' | 'assertive';
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Alert({
|
||||
tone = 'info',
|
||||
title,
|
||||
children,
|
||||
live = 'off',
|
||||
inline = false,
|
||||
className,
|
||||
}: AlertProps) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.alert, styles[tone], inline && styles.inline, className)}
|
||||
role={live === 'assertive' ? 'alert' : live === 'polite' ? 'status' : undefined}
|
||||
aria-live={live === 'off' ? undefined : live}
|
||||
>
|
||||
<Icon name={toneIcon[tone]} />
|
||||
<div>
|
||||
{title ? <strong className={styles.title}>{title}</strong> : null}
|
||||
<div className={styles.content}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
.spinnerGroup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.spinner {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
.skeleton {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.skeleton span {
|
||||
block-size: 0.9rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--skeleton-base),
|
||||
var(--skeleton-highlight),
|
||||
var(--skeleton-base)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes shimmer {
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner {
|
||||
animation-duration: 1.8s;
|
||||
}
|
||||
.skeleton span {
|
||||
animation: none;
|
||||
background: var(--skeleton-base);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Icon } from '@/components/foundation/Icon';
|
||||
import { classNames } from '@/lib/components/classNames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import styles from './Loading.module.css';
|
||||
|
||||
export function Spinner({ label, className }: { label: string; className?: string }) {
|
||||
return (
|
||||
<span className={classNames(styles.spinnerGroup, className)} role="status">
|
||||
<Icon name="spinner" className={styles.spinner} />
|
||||
<span className="visually-hidden">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonProps extends HTMLAttributes<HTMLDivElement> {
|
||||
lines?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
lines = 3,
|
||||
label = 'Loading content',
|
||||
className,
|
||||
...props
|
||||
}: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.skeleton, className)}
|
||||
aria-label={label}
|
||||
aria-busy="true"
|
||||
{...props}
|
||||
>
|
||||
{Array.from({ length: lines }, (_, index) => (
|
||||
<span key={index} style={{ inlineSize: index === lines - 1 ? '68%' : '100%' }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||