feat: Enhance Public VPN Plans view with marketing content and new components
- Added detailed service highlights, how it works steps, and FAQs to the Public VPN Plans view. - Introduced new components: CtaButton, FeaturedServiceCard, ProcessStep, ServiceCard, ServiceShowcaseCard, TrustBadge, TrustIndicators, HowItWorks, ServiceCTA, and ServiceFAQ for improved layout and functionality. - Implemented a new design for the landing page with enhanced visuals and user engagement elements. - Updated the VPN plans section to include a more informative and visually appealing layout.
This commit is contained in:
parent
bde9f706ce
commit
dc32e7aa07
264
CLAUDE.md
264
CLAUDE.md
@ -1,196 +1,212 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Instructions for Claude Code working in this repository.
|
||||
|
||||
## Project Overview
|
||||
---
|
||||
|
||||
Customer portal with BFF (Backend for Frontend) architecture. Users can self-register, manage subscriptions, view/pay invoices, and manage support cases.
|
||||
## Agent Behavior
|
||||
|
||||
**Systems of Record:**
|
||||
**Do NOT** run long-running processes without explicit permission:
|
||||
|
||||
- **WHMCS**: Billing, subscriptions, invoices, authoritative address storage
|
||||
- **Salesforce**: CRM (Accounts, Contacts, Cases), order address snapshots
|
||||
- **Portal**: Next.js UI + NestJS BFF
|
||||
- `pnpm dev`, `pnpm dev:start`, `npm start`, `npm run dev`
|
||||
- Any command that starts servers, watchers, or blocking processes
|
||||
|
||||
## Development Commands
|
||||
**Always ask first** before:
|
||||
|
||||
- Starting development servers
|
||||
- Running Docker containers
|
||||
- Executing build watchers
|
||||
- Any process that won't terminate on its own
|
||||
|
||||
**Before coding**: Read relevant docs. Never guess endpoint behavior or payload shapes.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Start development environment (PostgreSQL + Redis via Docker)
|
||||
pnpm dev:start
|
||||
|
||||
# Start both frontend and backend with hot reload
|
||||
pnpm dev
|
||||
|
||||
# Build domain package (required before running apps if domain changed)
|
||||
# Build domain (required after domain changes)
|
||||
pnpm domain:build
|
||||
|
||||
# Type checking
|
||||
# Type check & lint
|
||||
pnpm type-check
|
||||
|
||||
# Linting
|
||||
pnpm lint
|
||||
pnpm lint:fix
|
||||
|
||||
# Database commands
|
||||
pnpm db:migrate # Run migrations
|
||||
pnpm db:studio # Open Prisma Studio GUI
|
||||
pnpm db:generate # Generate Prisma client
|
||||
# Database
|
||||
pnpm db:migrate
|
||||
pnpm db:generate
|
||||
|
||||
# Run tests
|
||||
pnpm test # All packages
|
||||
pnpm --filter @customer-portal/bff test # BFF only
|
||||
|
||||
# Stop services
|
||||
pnpm dev:stop
|
||||
# Tests
|
||||
pnpm test
|
||||
pnpm --filter @customer-portal/bff test
|
||||
```
|
||||
|
||||
**Access points:**
|
||||
**Ports**: Frontend :3000 | Backend :4000/api | Prisma Studio :5555
|
||||
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend API: http://localhost:4000/api
|
||||
- Prisma Studio: http://localhost:5555
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
### Monorepo
|
||||
|
||||
```
|
||||
apps/
|
||||
├── portal/ # Next.js 15 frontend (React 19, Tailwind, shadcn/ui)
|
||||
└── bff/ # NestJS 11 backend (Prisma, BullMQ, Zod validation)
|
||||
├── portal/ # Next.js 15 (React 19, Tailwind, shadcn/ui)
|
||||
└── bff/ # NestJS 11 (Prisma, BullMQ, Zod)
|
||||
|
||||
packages/
|
||||
└── domain/ # Unified domain layer (contracts, schemas, provider mappers)
|
||||
└── domain/ # Shared contracts, schemas, provider mappers
|
||||
```
|
||||
|
||||
### Three-Layer Boundary (Non-Negotiable)
|
||||
### Three-Layer Boundary
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
| ------ | ------------------ | ------------------------------------------------------------------------------ |
|
||||
| Domain | `packages/domain/` | Shared contracts, Zod validation, provider mappers. Framework-agnostic. |
|
||||
| BFF | `apps/bff/` | HTTP boundary, orchestration, external integrations (Salesforce/WHMCS/Freebit) |
|
||||
| Portal | `apps/portal/` | UI layer. Pages are thin wrappers over feature modules. |
|
||||
| Layer | Location | Purpose |
|
||||
| ------ | ------------------ | -------------------------------------------------- |
|
||||
| Domain | `packages/domain/` | Contracts, Zod schemas, provider mappers |
|
||||
| BFF | `apps/bff/` | HTTP boundary, orchestration, integrations |
|
||||
| Portal | `apps/portal/` | UI layer, thin route wrappers over feature modules |
|
||||
|
||||
### Domain Package Structure
|
||||
### Systems of Record
|
||||
|
||||
Each domain module follows this pattern:
|
||||
- **WHMCS**: Billing, subscriptions, invoices, authoritative addresses
|
||||
- **Salesforce**: CRM (Accounts, Contacts, Cases), order snapshots
|
||||
- **Portal**: UI + BFF orchestration
|
||||
|
||||
---
|
||||
|
||||
## Import Rules (ESLint Enforced)
|
||||
|
||||
```typescript
|
||||
// Allowed (Portal + BFF)
|
||||
import type { Invoice } from "@customer-portal/domain/billing";
|
||||
import { invoiceSchema } from "@customer-portal/domain/billing";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
// Allowed (BFF only)
|
||||
import { Whmcs } from "@customer-portal/domain/billing/providers";
|
||||
|
||||
// Forbidden everywhere
|
||||
import { Billing } from "@customer-portal/domain"; // root import
|
||||
import { Invoice } from "@customer-portal/domain/billing/contract"; // deep import
|
||||
|
||||
// Forbidden in Portal
|
||||
import { Whmcs } from "@customer-portal/domain/billing/providers"; // provider adapters
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Package
|
||||
|
||||
Each module follows:
|
||||
|
||||
```
|
||||
packages/domain/<module>/
|
||||
├── contract.ts # Normalized types (provider-agnostic)
|
||||
├── schema.ts # Zod validation schemas
|
||||
├── schema.ts # Zod validation
|
||||
├── index.ts # Public exports
|
||||
└── providers/ # Provider-specific adapters (BFF-only)
|
||||
└── providers/ # Provider-specific (BFF-only)
|
||||
└── whmcs/
|
||||
├── raw.types.ts # Raw API response types
|
||||
└── mapper.ts # Transform raw → domain
|
||||
```
|
||||
|
||||
### Import Rules (ESLint Enforced)
|
||||
**Key principle**: Map once in domain mappers, use domain types everywhere.
|
||||
|
||||
**Allowed (Portal + BFF):**
|
||||
---
|
||||
|
||||
```typescript
|
||||
import type { Invoice } from "@customer-portal/domain/billing";
|
||||
import { invoiceSchema } from "@customer-portal/domain/billing";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
```
|
||||
|
||||
**Allowed (BFF only):**
|
||||
|
||||
```typescript
|
||||
import { Whmcs } from "@customer-portal/domain/billing/providers";
|
||||
```
|
||||
|
||||
**Forbidden everywhere:**
|
||||
|
||||
```typescript
|
||||
// Root import
|
||||
import { Billing } from "@customer-portal/domain";
|
||||
// Deep imports beyond entrypoints
|
||||
import { Invoice } from "@customer-portal/domain/billing/contract";
|
||||
import { transformWhmcsInvoice } from "@customer-portal/domain/billing/providers/whmcs/mapper";
|
||||
```
|
||||
|
||||
**Forbidden in Portal:**
|
||||
|
||||
```typescript
|
||||
// Portal must NEVER import provider adapters
|
||||
import { Whmcs } from "@customer-portal/domain/billing/providers";
|
||||
```
|
||||
|
||||
### Portal Feature Architecture
|
||||
## Portal Structure
|
||||
|
||||
```
|
||||
apps/portal/src/
|
||||
├── app/ # Next.js App Router (thin route shells, no API calls)
|
||||
├── components/ # Atomic design: atoms/, molecules/, organisms/, templates/
|
||||
├── core/ # App infrastructure: api/, logger/, providers/
|
||||
├── features/ # Feature modules with: api/, stores/, components/, hooks/, views/
|
||||
└── shared/ # Cross-feature: hooks/, utils/, constants/
|
||||
├── app/ # Next.js App Router (thin shells, NO API calls)
|
||||
├── components/ # Atomic: atoms/ molecules/ organisms/ templates/
|
||||
├── core/ # Infrastructure: api/, logger/, providers/
|
||||
├── features/ # Feature modules (api/, stores/, hooks/, views/)
|
||||
└── shared/ # Cross-feature: hooks/, utils/, constants/
|
||||
```
|
||||
|
||||
**Feature module pattern:**
|
||||
### Feature Module Pattern
|
||||
|
||||
- `api/`: Data fetching layer (built on shared apiClient)
|
||||
- `stores/`: Zustand state management
|
||||
- `hooks/`: React Query hooks wrapping API services
|
||||
- `components/`: Feature-specific UI
|
||||
- `views/`: Page-level view components
|
||||
- `index.ts`: Feature public API (barrel exports)
|
||||
```
|
||||
features/<name>/
|
||||
├── api/ # Data fetching (uses core/api/apiClient)
|
||||
├── stores/ # Zustand state
|
||||
├── hooks/ # React Query hooks
|
||||
├── components/ # Feature UI
|
||||
├── views/ # Page-level views
|
||||
└── index.ts # Public exports (barrel)
|
||||
```
|
||||
|
||||
### BFF Integration Pattern
|
||||
**Rules**:
|
||||
|
||||
**Map Once, Use Everywhere:**
|
||||
- Pages are thin wrappers importing views from features
|
||||
- No API calls in `app/` directory
|
||||
- No business logic in frontend; use services and APIs
|
||||
|
||||
---
|
||||
|
||||
## BFF Patterns
|
||||
|
||||
### Integration Layer
|
||||
|
||||
```
|
||||
apps/bff/src/integrations/{provider}/
|
||||
├── services/
|
||||
│ ├── {provider}-connection.service.ts
|
||||
│ └── {provider}-{entity}.service.ts
|
||||
└── utils/
|
||||
└── {entity}-query-builder.ts
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
External API → Integration Service → Domain Mapper → Domain Type → Use Directly
|
||||
(fetch + query) (transform once) (return)
|
||||
```
|
||||
|
||||
Integration services live in `apps/bff/src/integrations/{provider}/`:
|
||||
**Integration services**:
|
||||
|
||||
- `services/`: Connection services, entity-specific services
|
||||
- `utils/`: Query builders (SOQL, etc.)
|
||||
1. Build queries (SOQL, API params)
|
||||
2. Execute API calls
|
||||
3. Use domain mappers to transform
|
||||
4. Return domain types
|
||||
5. NO additional mapping or business logic
|
||||
|
||||
Domain mappers live in `packages/domain/{module}/providers/{provider}/`:
|
||||
### Controllers
|
||||
|
||||
- Integration services fetch data and call domain mappers
|
||||
- No business logic in integration layer
|
||||
- No double transformation
|
||||
- Thin: no business logic, no Zod imports
|
||||
- Use `createZodDto(schema)` + global `ZodValidationPipe`
|
||||
|
||||
## Key Patterns
|
||||
---
|
||||
|
||||
### Validation (Zod-First)
|
||||
## Validation (Zod-First)
|
||||
|
||||
- Schemas live in domain: `packages/domain/<module>/schema.ts`
|
||||
- Derive types from schemas: `export type X = z.infer<typeof xSchema>`
|
||||
- Schemas in domain: `packages/domain/<module>/schema.ts`
|
||||
- Derive types: `export type X = z.infer<typeof xSchema>`
|
||||
- Query params: use `z.coerce.*` for URL strings
|
||||
|
||||
### BFF Controllers
|
||||
---
|
||||
|
||||
- Controllers are thin: no business logic, no Zod imports
|
||||
- Use `createZodDto(schema)` with global `ZodValidationPipe`
|
||||
- Integrations: build queries in utils, fetch data, transform via domain mappers
|
||||
|
||||
### Logging
|
||||
|
||||
- BFF: Use `nestjs-pino` Logger, inject via constructor
|
||||
- Portal: Use `@/core/logger`
|
||||
- No `console.log` in production code (ESLint enforced)
|
||||
|
||||
### Naming
|
||||
## Code Standards
|
||||
|
||||
- No `any` in public APIs
|
||||
- No `console.log` (use logger)
|
||||
- No `console.log` (use logger: `nestjs-pino` for BFF, `@/core/logger` for Portal)
|
||||
- Avoid `V2` suffix in service names
|
||||
- No unsafe assertions
|
||||
- Reuse existing types and functions; extend when needed
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Read before coding:
|
||||
Read before implementing:
|
||||
|
||||
- `docs/README.md` (entrypoint)
|
||||
- `docs/development/` (BFF/Portal/Domain patterns)
|
||||
- `docs/architecture/` (boundaries)
|
||||
- `docs/integrations/` (external API details)
|
||||
|
||||
**Rule: Never guess endpoint behavior or payload shapes. Find docs or existing implementation first.**
|
||||
| Topic | Location |
|
||||
| ------------------- | ---------------------------------------------- |
|
||||
| Overview | `docs/README.md` |
|
||||
| BFF patterns | `docs/development/bff/integration-patterns.md` |
|
||||
| Portal architecture | `docs/development/portal/architecture.md` |
|
||||
| Domain structure | `docs/development/domain/structure.md` |
|
||||
| Salesforce | `docs/integrations/salesforce/` |
|
||||
| WHMCS | `docs/integrations/whmcs/` |
|
||||
|
||||
@ -42,6 +42,7 @@ import { SupportModule } from "@bff/modules/support/support.module.js";
|
||||
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
|
||||
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
|
||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||
import { AddressModule } from "@bff/modules/address/address.module.js";
|
||||
|
||||
// System Modules
|
||||
import { HealthModule } from "@bff/modules/health/health.module.js";
|
||||
@ -99,6 +100,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
|
||||
RealtimeApiModule,
|
||||
VerificationModule,
|
||||
NotificationsModule,
|
||||
AddressModule,
|
||||
|
||||
// === SYSTEM MODULES ===
|
||||
HealthModule,
|
||||
|
||||
@ -2,10 +2,11 @@ import { Module } from "@nestjs/common";
|
||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
|
||||
import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
|
||||
import { JapanPostModule } from "@bff/integrations/japanpost/japanpost.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [WhmcsModule, SalesforceModule, FreebitModule],
|
||||
imports: [WhmcsModule, SalesforceModule, FreebitModule, JapanPostModule],
|
||||
providers: [],
|
||||
exports: [WhmcsModule, SalesforceModule, FreebitModule],
|
||||
exports: [WhmcsModule, SalesforceModule, FreebitModule, JapanPostModule],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
|
||||
@ -7,6 +7,7 @@ import { SalesforceConnection } from "./services/salesforce-connection.service.j
|
||||
import {
|
||||
SalesforceAccountService,
|
||||
type SalesforceAccountPortalUpdate,
|
||||
type UpdateSalesforceContactAddressRequest,
|
||||
} from "./services/salesforce-account.service.js";
|
||||
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
|
||||
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers";
|
||||
@ -81,6 +82,17 @@ export class SalesforceService implements OnModuleInit {
|
||||
await this.accountService.updatePortalFields(accountId, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update PersonContact address fields for dual-write sync
|
||||
* Stores Japanese address data in Salesforce
|
||||
*/
|
||||
async updateContactAddress(
|
||||
accountId: string,
|
||||
address: UpdateSalesforceContactAddressRequest
|
||||
): Promise<void> {
|
||||
await this.accountService.updateContactAddress(accountId, address);
|
||||
}
|
||||
|
||||
// === ORDER METHODS (For Order Provisioning) ===
|
||||
|
||||
async updateOrder(orderData: Partial<SalesforceOrderRecord> & { Id: string }): Promise<void> {
|
||||
|
||||
@ -372,6 +372,83 @@ export class SalesforceAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Contact Address Update Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Update PersonContact address fields for a Person Account
|
||||
* Used for storing Japanese address data in Salesforce
|
||||
*
|
||||
* @param accountId - Salesforce Account ID
|
||||
* @param address - Japanese address data
|
||||
*/
|
||||
async updateContactAddress(
|
||||
accountId: string,
|
||||
address: UpdateSalesforceContactAddressRequest
|
||||
): Promise<void> {
|
||||
const validAccountId = salesforceIdSchema.parse(accountId);
|
||||
|
||||
this.logger.debug("Updating PersonContact address", {
|
||||
accountId: validAccountId,
|
||||
hasMailingStreet: Boolean(address.mailingStreet),
|
||||
hasMailingCity: Boolean(address.mailingCity),
|
||||
});
|
||||
|
||||
try {
|
||||
// For Person Accounts, we need to get the PersonContactId from the Account
|
||||
const accountRecord = (await this.connection.query(
|
||||
`SELECT PersonContactId FROM Account WHERE Id = '${this.safeSoql(validAccountId)}'`,
|
||||
{ label: "address:getPersonContactId" }
|
||||
)) as SalesforceResponse<{ PersonContactId: string }>;
|
||||
|
||||
const personContactId = accountRecord.records[0]?.PersonContactId;
|
||||
if (!personContactId) {
|
||||
this.logger.warn("PersonContactId not found for Person Account", {
|
||||
accountId: validAccountId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build contact update payload with Japanese address fields
|
||||
const contactPayload: Record<string, unknown> = {
|
||||
Id: personContactId,
|
||||
MailingStreet: address.mailingStreet,
|
||||
MailingCity: address.mailingCity,
|
||||
MailingState: address.mailingState,
|
||||
MailingPostalCode: address.mailingPostalCode,
|
||||
MailingCountry: address.mailingCountry || "Japan",
|
||||
};
|
||||
|
||||
// Add custom fields if provided
|
||||
if (address.buildingName !== undefined) {
|
||||
contactPayload["BuildingName__c"] = address.buildingName;
|
||||
}
|
||||
if (address.roomNumber !== undefined) {
|
||||
contactPayload["RoomNumber__c"] = address.roomNumber;
|
||||
}
|
||||
|
||||
const updateMethod = this.connection.sobject("Contact").update;
|
||||
if (!updateMethod) {
|
||||
this.logger.warn("Salesforce update method not available");
|
||||
return;
|
||||
}
|
||||
|
||||
await updateMethod(contactPayload as Record<string, unknown> & { Id: string });
|
||||
|
||||
this.logger.log("PersonContact address updated successfully", {
|
||||
accountId: validAccountId,
|
||||
contactId: personContactId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Log but don't throw - WHMCS is source of truth, SF is secondary
|
||||
this.logger.error("Failed to update PersonContact address", {
|
||||
accountId: validAccountId,
|
||||
error: extractErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Portal Field Update Methods
|
||||
// ============================================================================
|
||||
@ -456,3 +533,24 @@ export interface CreateSalesforceContactRequest {
|
||||
gender: "male" | "female" | "other";
|
||||
dateOfBirth: string; // YYYY-MM-DD format
|
||||
}
|
||||
|
||||
/**
|
||||
* Request type for updating Salesforce Contact address
|
||||
* Used for dual-write: English to WHMCS, Japanese to Salesforce
|
||||
*/
|
||||
export interface UpdateSalesforceContactAddressRequest {
|
||||
/** Japanese street (町名/番地) */
|
||||
mailingStreet: string;
|
||||
/** Japanese city */
|
||||
mailingCity: string;
|
||||
/** Japanese prefecture */
|
||||
mailingState: string;
|
||||
/** ZIP code (postal code) */
|
||||
mailingPostalCode: string;
|
||||
/** Country (always Japan) */
|
||||
mailingCountry?: string;
|
||||
/** Building name (English, same for both systems) */
|
||||
buildingName?: string | null;
|
||||
/** Room number */
|
||||
roomNumber?: string | null;
|
||||
}
|
||||
|
||||
@ -239,18 +239,20 @@ export class InternetEligibilityService {
|
||||
const requestedAtRaw = record[requestedAtField];
|
||||
const checkedAtRaw = record[checkedAtField];
|
||||
|
||||
const requestedAt =
|
||||
typeof requestedAtRaw === "string"
|
||||
? requestedAtRaw
|
||||
: requestedAtRaw instanceof Date
|
||||
? requestedAtRaw.toISOString()
|
||||
: null;
|
||||
const checkedAt =
|
||||
typeof checkedAtRaw === "string"
|
||||
? checkedAtRaw
|
||||
: checkedAtRaw instanceof Date
|
||||
? checkedAtRaw.toISOString()
|
||||
: null;
|
||||
// Normalize datetime strings to ISO 8601 with Z suffix (Salesforce may return +0000 offset)
|
||||
const normalizeDateTime = (value: unknown): string | null => {
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const requestedAt = normalizeDateTime(requestedAtRaw);
|
||||
const checkedAt = normalizeDateTime(checkedAtRaw);
|
||||
|
||||
return internetEligibilityDetailsSchema.parse({
|
||||
status,
|
||||
|
||||
@ -3,6 +3,7 @@ import { Logger } from "nestjs-pino";
|
||||
import type { User as PrismaUser } from "@prisma/client";
|
||||
import type { User } from "@customer-portal/domain/customer";
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
import type { BilingualAddress } from "@customer-portal/domain/address";
|
||||
import type { DashboardSummary } from "@customer-portal/domain/dashboard";
|
||||
import type { UpdateCustomerProfileRequest } from "@customer-portal/domain/auth";
|
||||
import { UserAuthRepository } from "../infra/user-auth.repository.js";
|
||||
@ -63,6 +64,10 @@ export class UsersFacade {
|
||||
return this.profileService.updateAddress(userId, update);
|
||||
}
|
||||
|
||||
async updateBilingualAddress(userId: string, address: BilingualAddress): Promise<Address> {
|
||||
return this.profileService.updateBilingualAddress(userId, address);
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise<User> {
|
||||
return this.profileService.updateProfile(userId, update);
|
||||
}
|
||||
|
||||
@ -15,6 +15,11 @@ import {
|
||||
type Address,
|
||||
type User,
|
||||
} from "@customer-portal/domain/customer";
|
||||
import {
|
||||
type BilingualAddress,
|
||||
prepareWhmcsAddressFields,
|
||||
prepareSalesforceContactAddressFields,
|
||||
} from "@customer-portal/domain/address";
|
||||
import {
|
||||
getCustomFieldValue,
|
||||
mapPrismaUserToUserAuth,
|
||||
@ -109,6 +114,88 @@ export class UserProfileService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update address with bilingual data (Japanese + English)
|
||||
* Dual-write: English to WHMCS, Japanese to Salesforce
|
||||
*
|
||||
* @param userId - User ID
|
||||
* @param bilingualAddress - Address data with both Japanese and English fields
|
||||
* @returns Updated address (from WHMCS, source of truth)
|
||||
*/
|
||||
async updateBilingualAddress(
|
||||
userId: string,
|
||||
bilingualAddress: BilingualAddress
|
||||
): Promise<Address> {
|
||||
const validId = parseUuidOrThrow(userId, "Invalid user ID format");
|
||||
|
||||
return withErrorHandling(
|
||||
async () => {
|
||||
const mapping = await this.mappingsService.findByUserId(validId);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new NotFoundException("User mapping not found");
|
||||
}
|
||||
|
||||
// 1. Update WHMCS with English address (source of truth)
|
||||
const whmcsFields = prepareWhmcsAddressFields(bilingualAddress);
|
||||
await this.whmcsClientService.updateClientAddress(mapping.whmcsClientId, whmcsFields);
|
||||
await this.whmcsClientService.invalidateUserCache(validId);
|
||||
|
||||
this.logger.log("Successfully updated customer address in WHMCS", {
|
||||
userId: validId,
|
||||
whmcsClientId: mapping.whmcsClientId,
|
||||
});
|
||||
|
||||
// 2. Update Salesforce with Japanese address (secondary, non-blocking)
|
||||
if (mapping.sfAccountId) {
|
||||
const sfFields = prepareSalesforceContactAddressFields(bilingualAddress);
|
||||
try {
|
||||
await this.salesforceService.updateContactAddress(mapping.sfAccountId, {
|
||||
mailingStreet: sfFields.MailingStreet,
|
||||
mailingCity: sfFields.MailingCity,
|
||||
mailingState: sfFields.MailingState,
|
||||
mailingPostalCode: sfFields.MailingPostalCode,
|
||||
mailingCountry: sfFields.MailingCountry,
|
||||
buildingName: sfFields.BuildingName__c,
|
||||
roomNumber: sfFields.RoomNumber__c,
|
||||
});
|
||||
|
||||
this.logger.log("Successfully updated Japanese address in Salesforce", {
|
||||
userId: validId,
|
||||
sfAccountId: mapping.sfAccountId,
|
||||
});
|
||||
} catch (sfError) {
|
||||
// Log but don't fail - WHMCS is source of truth
|
||||
this.logger.warn("Failed to update Salesforce address (non-blocking)", {
|
||||
userId: validId,
|
||||
sfAccountId: mapping.sfAccountId,
|
||||
error: extractErrorMessage(sfError),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.logger.debug("No Salesforce mapping found, skipping Japanese address sync", {
|
||||
userId: validId,
|
||||
});
|
||||
}
|
||||
|
||||
// Return refreshed address from WHMCS
|
||||
const refreshedProfile = await this.getProfile(validId);
|
||||
if (refreshedProfile.address) {
|
||||
return refreshedProfile.address;
|
||||
}
|
||||
|
||||
const refreshedAddress = await this.whmcsClientService.getClientAddress(
|
||||
mapping.whmcsClientId
|
||||
);
|
||||
return addressSchema.parse(refreshedAddress ?? {});
|
||||
},
|
||||
this.logger,
|
||||
{
|
||||
context: `Update bilingual address for user ${validId}`,
|
||||
fallbackMessage: "Unable to update address",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise<User> {
|
||||
const validId = parseUuidOrThrow(userId, "Invalid user ID format");
|
||||
const parsed = updateCustomerProfileRequestSchema.parse(update);
|
||||
|
||||
@ -13,12 +13,14 @@ import { createZodDto, ZodResponse, ZodSerializerDto } from "nestjs-zod";
|
||||
import { updateCustomerProfileRequestSchema } from "@customer-portal/domain/auth";
|
||||
import { dashboardSummarySchema } from "@customer-portal/domain/dashboard";
|
||||
import { addressSchema, userSchema } from "@customer-portal/domain/customer";
|
||||
import { bilingualAddressSchema } from "@customer-portal/domain/address";
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
import type { User } from "@customer-portal/domain/customer";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||
|
||||
class UpdateAddressDto extends createZodDto(addressSchema.partial()) {}
|
||||
class UpdateBilingualAddressDto extends createZodDto(bilingualAddressSchema) {}
|
||||
class UpdateCustomerProfileRequestDto extends createZodDto(updateCustomerProfileRequestSchema) {}
|
||||
class AddressDto extends createZodDto(addressSchema) {}
|
||||
class UserDto extends createZodDto(userSchema) {}
|
||||
@ -73,6 +75,37 @@ export class UsersController {
|
||||
return this.usersFacade.updateAddress(req.user.id, address);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /me/address/bilingual - Update address with Japanese + English fields
|
||||
*
|
||||
* Dual-write: English to WHMCS (source of truth), Japanese to Salesforce
|
||||
*
|
||||
* Use this endpoint when you have both Japanese and English address data
|
||||
* (e.g., from Japan Post ZIP code lookup).
|
||||
*
|
||||
* @example
|
||||
* {
|
||||
* "postcode": "1000001",
|
||||
* "prefecture": "Tokyo",
|
||||
* "prefectureJa": "東京都",
|
||||
* "city": "Chiyoda-ku",
|
||||
* "cityJa": "千代田区",
|
||||
* "town": "Chiyoda",
|
||||
* "townJa": "千代田",
|
||||
* "buildingName": "Example Building",
|
||||
* "roomNumber": "101",
|
||||
* "residenceType": "apartment"
|
||||
* }
|
||||
*/
|
||||
@Patch("address/bilingual")
|
||||
@ZodResponse({ description: "Update bilingual address", type: AddressDto })
|
||||
async updateBilingualAddress(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() address: UpdateBilingualAddressDto
|
||||
): Promise<Address> {
|
||||
return this.usersFacade.updateBilingualAddress(req.user.id, address);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /me - Update customer profile (can update profile fields and/or address)
|
||||
* All fields optional - only send what needs to be updated
|
||||
|
||||
@ -1,24 +1,206 @@
|
||||
import { ServicesGrid } from "@/features/services/components/common/ServicesGrid";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Wifi,
|
||||
Smartphone,
|
||||
ShieldCheck,
|
||||
ArrowRight,
|
||||
Phone,
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
Headphones,
|
||||
Building2,
|
||||
Wrench,
|
||||
Tv,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
interface ServicesPageProps {
|
||||
basePath?: string;
|
||||
interface ServiceCardProps {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
price?: string;
|
||||
badge?: string;
|
||||
accentColor?: "blue" | "green" | "purple" | "orange" | "cyan" | "pink";
|
||||
}
|
||||
|
||||
export default function ServicesPage({ basePath = "/services" }: ServicesPageProps) {
|
||||
function ServiceCard({
|
||||
href,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
price,
|
||||
badge,
|
||||
accentColor = "blue",
|
||||
}: ServiceCardProps) {
|
||||
const accentStyles = {
|
||||
blue: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
||||
green: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
|
||||
purple: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
|
||||
orange: "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
|
||||
cyan: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20",
|
||||
pink: "bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/20",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-16 pt-8">
|
||||
<h1 className="text-4xl sm:text-5xl font-extrabold text-foreground mb-6 tracking-tight">
|
||||
<Link href={href} className="group block">
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full flex flex-col rounded-2xl border bg-card p-6",
|
||||
"transition-all duration-200",
|
||||
"hover:-translate-y-1 hover:shadow-lg hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
{badge && (
|
||||
<span className="absolute -top-2.5 right-4 rounded-full bg-success px-2.5 py-0.5 text-xs font-medium text-success-foreground">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 items-center justify-center rounded-xl border",
|
||||
accentStyles[accentColor]
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-foreground font-display">{title}</h3>
|
||||
{price && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
From <span className="font-medium text-foreground">{price}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-relaxed flex-grow">{description}</p>
|
||||
|
||||
<div className="mt-4 flex items-center gap-1 text-sm font-medium text-primary">
|
||||
Learn more
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServicesPage() {
|
||||
return (
|
||||
<div className="space-y-12 pb-16">
|
||||
{/* Hero */}
|
||||
<section className="text-center pt-8">
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-primary/8 border border-primary/15 px-4 py-2 text-sm text-primary font-medium mb-6">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Full English Support
|
||||
</span>
|
||||
|
||||
<h1 className="text-display-lg font-display font-bold text-foreground mb-4">
|
||||
Our Services
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
|
||||
From high-speed internet to onsite support, we provide comprehensive solutions for your
|
||||
home and business.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ServicesGrid basePath={basePath} />
|
||||
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
|
||||
Connectivity and support solutions for Japan's international community.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Value Props - Compact */}
|
||||
<section className="flex flex-wrap justify-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Globe className="h-4 w-4 text-primary" />
|
||||
<span>One provider, all services</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Headphones className="h-4 w-4 text-success" />
|
||||
<span>English support</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-info" />
|
||||
<span>No hidden fees</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* All Services - Clean Grid */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<ServiceCard
|
||||
href="/services/internet"
|
||||
icon={<Wifi className="h-6 w-6" />}
|
||||
title="Internet"
|
||||
description="NTT Optical Fiber for homes and apartments. Speeds up to 10Gbps with professional installation."
|
||||
price="¥3,200/mo"
|
||||
accentColor="blue"
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
href="/services/sim"
|
||||
icon={<Smartphone className="h-6 w-6" />}
|
||||
title="SIM & eSIM"
|
||||
description="Data, voice & SMS on NTT Docomo network. Physical SIM or instant eSIM activation."
|
||||
price="¥1,100/mo"
|
||||
badge="1st month free"
|
||||
accentColor="green"
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
href="/services/vpn"
|
||||
icon={<ShieldCheck className="h-6 w-6" />}
|
||||
title="VPN Router"
|
||||
description="Access US & UK streaming content with a pre-configured router. Simple plug-and-play."
|
||||
price="¥2,500/mo"
|
||||
accentColor="purple"
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
href="/services/business"
|
||||
icon={<Building2 className="h-6 w-6" />}
|
||||
title="Business"
|
||||
description="Enterprise solutions for offices and commercial spaces. Dedicated support and SLAs."
|
||||
accentColor="orange"
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
href="/services/onsite"
|
||||
icon={<Wrench className="h-6 w-6" />}
|
||||
title="Onsite Support"
|
||||
description="Professional technicians visit your location for setup, troubleshooting, and maintenance."
|
||||
accentColor="cyan"
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
href="/services/tv"
|
||||
icon={<Tv className="h-6 w-6" />}
|
||||
title="TV"
|
||||
description="Streaming TV packages with international channels. Watch content from home countries."
|
||||
accentColor="pink"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="rounded-2xl bg-gradient-to-br from-muted/50 to-muted/80 p-8 text-center">
|
||||
<h2 className="text-xl font-bold text-foreground font-display mb-3">Need help choosing?</h2>
|
||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||
Our bilingual team can help you find the right solution.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 font-medium text-primary-foreground hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
Contact Us
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<a
|
||||
href="tel:0120660470"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
0120-660-470 (Toll Free)
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -21,27 +21,27 @@
|
||||
--font-display: var(--font-plus-jakarta-sans, var(--font-sans));
|
||||
|
||||
/* Core Surfaces */
|
||||
--background: oklch(0.995 0 0);
|
||||
--foreground: oklch(0.13 0.02 265);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.16 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: var(--foreground);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: var(--foreground);
|
||||
--muted: oklch(0.97 0.006 265);
|
||||
--muted-foreground: oklch(0.45 0.02 265);
|
||||
--muted: oklch(0.96 0.008 234.4);
|
||||
--muted-foreground: oklch(0.5 0 0);
|
||||
|
||||
/* Brand - Premium Blue (shifted slightly purple for richness) */
|
||||
--primary: oklch(0.55 0.18 260);
|
||||
--primary-hover: oklch(0.48 0.19 260);
|
||||
--primary-soft: oklch(0.95 0.03 260);
|
||||
/* Brand - Clean Blue (matches logo) */
|
||||
--primary: oklch(0.6884 0.1342 234.4);
|
||||
--primary-hover: oklch(0.6 0.14 234.4);
|
||||
--primary-soft: oklch(0.95 0.03 234.4);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
|
||||
/* Gradient Accent - Purple for gradient mixing */
|
||||
--accent-gradient: oklch(0.58 0.2 290);
|
||||
/* Gradient Accent - Slightly deeper blue for gradients */
|
||||
--accent-gradient: oklch(0.55 0.15 234.4);
|
||||
|
||||
--secondary: oklch(0.96 0.01 265);
|
||||
--secondary-foreground: oklch(0.25 0.02 265);
|
||||
--accent: oklch(0.95 0.04 260);
|
||||
--secondary: oklch(0.95 0.015 234.4);
|
||||
--secondary-foreground: oklch(0.29 0 0);
|
||||
--accent: oklch(0.95 0.04 234.4);
|
||||
--accent-foreground: var(--foreground);
|
||||
|
||||
/* 5 Semantic Colors (each: base, foreground, bg, border) */
|
||||
@ -71,14 +71,14 @@
|
||||
--neutral-border: oklch(0.87 0.02 272.34);
|
||||
|
||||
/* Chrome */
|
||||
--border: oklch(0.92 0.005 265);
|
||||
--input: oklch(0.96 0.004 265);
|
||||
--ring: oklch(0.55 0.18 260 / 0.5);
|
||||
--border: oklch(0.92 0.005 234.4);
|
||||
--input: oklch(0.96 0.004 234.4);
|
||||
--ring: oklch(0.6884 0.1342 234.4 / 0.5);
|
||||
|
||||
/* Sidebar - Deep rich purple-blue */
|
||||
--sidebar: oklch(0.18 0.08 280);
|
||||
--sidebar-foreground: oklch(0.98 0 0);
|
||||
--sidebar-border: oklch(0.28 0.06 280);
|
||||
/* Sidebar - Deep purple/indigo */
|
||||
--sidebar: oklch(0.2754 0.1199 272.34);
|
||||
--sidebar-foreground: oklch(1 0 0);
|
||||
--sidebar-border: oklch(0.36 0.1 272.34);
|
||||
--sidebar-active: oklch(0.99 0 0 / 0.15);
|
||||
|
||||
/* Header */
|
||||
@ -93,6 +93,16 @@
|
||||
--chart-4: var(--danger);
|
||||
--chart-5: var(--neutral);
|
||||
|
||||
/* Amber/Gold CTA Accent (for conversion-focused buttons) */
|
||||
--cta: oklch(0.65 0.16 55);
|
||||
--cta-hover: oklch(0.58 0.17 55);
|
||||
--cta-foreground: oklch(0.15 0.02 55);
|
||||
--cta-soft: oklch(0.97 0.03 55);
|
||||
|
||||
/* Deep Navy for trust headers */
|
||||
--navy: oklch(0.22 0.04 265);
|
||||
--navy-foreground: oklch(0.98 0 0);
|
||||
|
||||
/* Glass Morphism Tokens */
|
||||
--glass-bg: oklch(1 0 0 / 0.7);
|
||||
--glass-bg-strong: oklch(1 0 0 / 0.85);
|
||||
@ -104,38 +114,46 @@
|
||||
--gradient-primary: linear-gradient(135deg, var(--primary) 0%, var(--accent-gradient) 100%);
|
||||
--gradient-premium: linear-gradient(
|
||||
135deg,
|
||||
oklch(0.55 0.18 260),
|
||||
oklch(0.52 0.2 290),
|
||||
oklch(0.55 0.15 320)
|
||||
oklch(0.6884 0.1342 234.4),
|
||||
oklch(0.55 0.15 234.4),
|
||||
oklch(0.5 0.12 234.4)
|
||||
);
|
||||
--gradient-subtle: linear-gradient(
|
||||
180deg,
|
||||
oklch(0.99 0.005 234.4) 0%,
|
||||
oklch(0.97 0.008 234.4) 100%
|
||||
);
|
||||
--gradient-glow: radial-gradient(
|
||||
circle at 50% 0%,
|
||||
oklch(0.6884 0.1342 234.4 / 0.15),
|
||||
transparent 50%
|
||||
);
|
||||
--gradient-subtle: linear-gradient(180deg, oklch(0.99 0.005 260) 0%, oklch(0.97 0.008 290) 100%);
|
||||
--gradient-glow: radial-gradient(circle at 50% 0%, oklch(0.55 0.18 260 / 0.15), transparent 50%);
|
||||
|
||||
/* Premium Shadows with Color */
|
||||
--shadow-primary-sm: 0 2px 8px -2px oklch(0.55 0.18 260 / 0.2);
|
||||
--shadow-primary-md: 0 4px 16px -4px oklch(0.55 0.18 260 / 0.25);
|
||||
--shadow-primary-lg: 0 8px 32px -8px oklch(0.55 0.18 260 / 0.3);
|
||||
--shadow-primary-sm: 0 2px 8px -2px oklch(0.6884 0.1342 234.4 / 0.2);
|
||||
--shadow-primary-md: 0 4px 16px -4px oklch(0.6884 0.1342 234.4 / 0.25);
|
||||
--shadow-primary-lg: 0 8px 32px -8px oklch(0.6884 0.1342 234.4 / 0.3);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Surfaces - Rich dark with blue undertone */
|
||||
--background: oklch(0.12 0.015 280);
|
||||
--background: oklch(0.12 0.015 234.4);
|
||||
--foreground: oklch(0.95 0 0);
|
||||
--card: oklch(0.15 0.015 280);
|
||||
--card: oklch(0.15 0.015 234.4);
|
||||
--card-foreground: var(--foreground);
|
||||
--popover: oklch(0.15 0.015 280);
|
||||
--popover: oklch(0.15 0.015 234.4);
|
||||
--popover-foreground: var(--foreground);
|
||||
--muted: oklch(0.25 0.01 280);
|
||||
--muted: oklch(0.25 0.01 234.4);
|
||||
--muted-foreground: oklch(0.74 0 0);
|
||||
|
||||
/* Brand - Brighter for dark mode contrast */
|
||||
--primary: oklch(0.68 0.16 260);
|
||||
--primary-hover: oklch(0.72 0.14 260);
|
||||
--primary-soft: oklch(0.22 0.04 260);
|
||||
--primary: oklch(0.75 0.12 234.4);
|
||||
--primary-hover: oklch(0.8 0.1 234.4);
|
||||
--primary-soft: oklch(0.22 0.04 234.4);
|
||||
|
||||
--secondary: oklch(0.22 0.01 280);
|
||||
--secondary: oklch(0.22 0.01 234.4);
|
||||
--secondary-foreground: oklch(0.9 0 0);
|
||||
--accent: oklch(0.24 0.03 260);
|
||||
--accent: oklch(0.24 0.03 234.4);
|
||||
--accent-foreground: oklch(0.92 0 0);
|
||||
|
||||
--success: oklch(0.72 0.1 145);
|
||||
@ -163,26 +181,30 @@
|
||||
--neutral-bg: oklch(0.24 0.02 272.34);
|
||||
--neutral-border: oklch(0.38 0.03 272.34);
|
||||
|
||||
--border: oklch(0.32 0.02 280);
|
||||
--input: oklch(0.35 0.02 280);
|
||||
--ring: oklch(0.68 0.16 260 / 0.5);
|
||||
--border: oklch(0.32 0.02 234.4);
|
||||
--input: oklch(0.35 0.02 234.4);
|
||||
--ring: oklch(0.75 0.12 234.4 / 0.5);
|
||||
|
||||
/* Sidebar - Slightly lighter than background */
|
||||
--sidebar: oklch(0.14 0.02 280);
|
||||
--sidebar-border: oklch(0.22 0.03 280);
|
||||
/* Sidebar - Purple/indigo theme for dark mode */
|
||||
--sidebar: oklch(0.2 0.08 272.34);
|
||||
--sidebar-border: oklch(0.28 0.08 272.34);
|
||||
|
||||
--header: oklch(0.15 0.015 280 / 0.95);
|
||||
--header: oklch(0.15 0.015 234.4 / 0.95);
|
||||
--header-foreground: var(--foreground);
|
||||
|
||||
--chart-3: oklch(0.82 0.14 85);
|
||||
|
||||
/* Glass for dark mode */
|
||||
--glass-bg: oklch(0.15 0.02 280 / 0.6);
|
||||
--glass-bg-strong: oklch(0.18 0.02 280 / 0.8);
|
||||
--glass-bg: oklch(0.15 0.02 234.4 / 0.6);
|
||||
--glass-bg-strong: oklch(0.18 0.02 234.4 / 0.8);
|
||||
--glass-border: oklch(1 0 0 / 0.1);
|
||||
|
||||
/* Gradients adjusted for dark */
|
||||
--gradient-subtle: linear-gradient(180deg, oklch(0.15 0.02 260) 0%, oklch(0.13 0.025 290) 100%);
|
||||
--gradient-subtle: linear-gradient(
|
||||
180deg,
|
||||
oklch(0.15 0.02 234.4) 0%,
|
||||
oklch(0.13 0.025 234.4) 100%
|
||||
);
|
||||
|
||||
/* Shadows for dark mode */
|
||||
--shadow-primary-sm: 0 2px 8px -2px oklch(0 0 0 / 0.4);
|
||||
@ -270,6 +292,16 @@
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* CTA/Amber tokens */
|
||||
--color-cta: var(--cta);
|
||||
--color-cta-hover: var(--cta-hover);
|
||||
--color-cta-foreground: var(--cta-foreground);
|
||||
--color-cta-soft: var(--cta-soft);
|
||||
|
||||
/* Navy tokens */
|
||||
--color-navy: var(--navy);
|
||||
--color-navy-foreground: var(--navy-foreground);
|
||||
|
||||
/* Glass tokens */
|
||||
--color-glass-bg: var(--glass-bg);
|
||||
--color-glass-border: var(--glass-border);
|
||||
|
||||
@ -139,4 +139,8 @@ export const queryKeys = {
|
||||
currency: {
|
||||
default: () => ["currency", "default"] as const,
|
||||
},
|
||||
address: {
|
||||
zipLookup: (zipCode: string) => ["address", "zip-lookup", zipCode] as const,
|
||||
status: () => ["address", "status"] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
updateCustomerProfileRequestSchema,
|
||||
type UpdateCustomerProfileRequest,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import { bilingualAddressSchema, type BilingualAddress } from "@customer-portal/domain/address";
|
||||
|
||||
export const accountService = {
|
||||
async getProfile() {
|
||||
@ -41,4 +42,20 @@ export const accountService = {
|
||||
const data = getDataOrThrow<Address>(response, "Failed to update address");
|
||||
return addressSchema.parse(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update address with bilingual data (Japanese + English)
|
||||
* Dual-write: English to WHMCS, Japanese to Salesforce
|
||||
*
|
||||
* @param address - Bilingual address data from JapanAddressForm
|
||||
* @returns Updated address (from WHMCS)
|
||||
*/
|
||||
async updateBilingualAddress(address: BilingualAddress) {
|
||||
const sanitized = bilingualAddressSchema.parse(address);
|
||||
const response = await apiClient.PATCH<Address>("/api/me/address/bilingual", {
|
||||
body: sanitized,
|
||||
});
|
||||
const data = getDataOrThrow<Address>(response, "Failed to update address");
|
||||
return addressSchema.parse(data);
|
||||
},
|
||||
};
|
||||
|
||||
@ -21,7 +21,7 @@ import { formatJapanesePostalCode } from "@/shared/constants";
|
||||
|
||||
import { MultiStepForm } from "./MultiStepForm";
|
||||
import { AccountStep } from "./steps/AccountStep";
|
||||
import { AddressStep } from "./steps/AddressStep";
|
||||
import { AddressStepJapan } from "@/features/address";
|
||||
import { PasswordStep } from "./steps/PasswordStep";
|
||||
|
||||
/**
|
||||
@ -208,24 +208,21 @@ export function SignupForm({
|
||||
isSubmitting,
|
||||
} = form;
|
||||
|
||||
const normalizeAutofillValue = useCallback(
|
||||
(field: string, value: string) => {
|
||||
switch (field) {
|
||||
case "phoneCountryCode": {
|
||||
let normalized = value.replace(/[^\d+]/g, "");
|
||||
if (!normalized.startsWith("+")) normalized = "+" + normalized.replace(/\+/g, "");
|
||||
return normalized.slice(0, 5);
|
||||
}
|
||||
case "phone":
|
||||
return value.replace(/\D/g, "");
|
||||
case "address.postcode":
|
||||
return formatJapanesePostalCode(value);
|
||||
default:
|
||||
return value;
|
||||
const normalizeAutofillValue = useCallback((field: string, value: string) => {
|
||||
switch (field) {
|
||||
case "phoneCountryCode": {
|
||||
let normalized = value.replace(/[^\d+]/g, "");
|
||||
if (!normalized.startsWith("+")) normalized = "+" + normalized.replace(/\+/g, "");
|
||||
return normalized.slice(0, 5);
|
||||
}
|
||||
},
|
||||
[formatJapanesePostalCode]
|
||||
);
|
||||
case "phone":
|
||||
return value.replace(/\D/g, "");
|
||||
case "address.postcode":
|
||||
return formatJapanesePostalCode(value);
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const syncStepValues = useCallback(
|
||||
(shouldFlush = true) => {
|
||||
@ -368,7 +365,7 @@ export function SignupForm({
|
||||
|
||||
const stepContent = [
|
||||
<AccountStep key="account" form={formProps} />,
|
||||
<AddressStep key="address" form={formProps} />,
|
||||
<AddressStepJapan key="address" form={formProps} />,
|
||||
<PasswordStep key="security" form={formProps} />,
|
||||
];
|
||||
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/shared/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface CtaButtonProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: "primary" | "secondary" | "cta";
|
||||
size?: "default" | "lg";
|
||||
}
|
||||
|
||||
/**
|
||||
* Professional CTA button with clear hierarchy
|
||||
* - primary: Brand blue for standard actions
|
||||
* - cta: Warm amber for high-conversion actions
|
||||
* - secondary: Outline style for secondary actions
|
||||
*/
|
||||
export function CtaButton({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
variant = "primary",
|
||||
size = "default",
|
||||
}: CtaButtonProps) {
|
||||
const sizeClasses = {
|
||||
default: "px-6 py-3 text-base",
|
||||
lg: "px-8 py-4 text-lg",
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
primary: cn(
|
||||
"bg-primary text-primary-foreground",
|
||||
"hover:bg-primary-hover",
|
||||
"shadow-sm hover:shadow-md"
|
||||
),
|
||||
cta: cn("bg-cta text-cta-foreground", "hover:bg-cta-hover", "shadow-sm hover:shadow-md"),
|
||||
secondary: cn(
|
||||
"bg-transparent text-foreground",
|
||||
"border border-border",
|
||||
"hover:bg-muted hover:border-muted-foreground/20"
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2",
|
||||
"rounded-lg font-semibold",
|
||||
"transition-all duration-[var(--cp-duration-normal)]",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { ArrowRight, type LucideIcon } from "lucide-react";
|
||||
|
||||
interface FeaturedServiceCardProps {
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
highlights: string[];
|
||||
startingPrice?: string;
|
||||
priceNote?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured service card - prominent display for primary services
|
||||
* Used for Internet service on landing page
|
||||
*/
|
||||
export function FeaturedServiceCard({
|
||||
href,
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
highlights,
|
||||
startingPrice,
|
||||
priceNote,
|
||||
className,
|
||||
}: FeaturedServiceCardProps) {
|
||||
return (
|
||||
<Link href={href} className={cn("group block", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-2xl",
|
||||
"bg-gradient-to-br from-navy to-primary",
|
||||
"p-8 sm:p-10",
|
||||
"transition-all duration-[var(--cp-duration-normal)]",
|
||||
"hover:shadow-xl hover:shadow-primary/20",
|
||||
"hover:-translate-y-1"
|
||||
)}
|
||||
>
|
||||
{/* Background pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 70% 30%, rgba(255,255,255,0.3) 0%, transparent 50%),
|
||||
radial-gradient(circle at 30% 70%, rgba(255,255,255,0.2) 0%, transparent 40%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-white/15">
|
||||
<Icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-white/80 uppercase tracking-wider">
|
||||
Featured Service
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-3xl sm:text-4xl font-bold text-white font-display">{title}</h3>
|
||||
|
||||
<p className="text-lg text-white/85 leading-relaxed max-w-md">{description}</p>
|
||||
|
||||
{/* Highlights */}
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{highlights.map((highlight, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center rounded-full bg-white/15 px-3 py-1 text-sm font-medium text-white"
|
||||
>
|
||||
{highlight}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing & CTA */}
|
||||
<div className="flex flex-col items-start lg:items-end gap-4">
|
||||
{startingPrice && (
|
||||
<div className="text-white">
|
||||
<span className="text-sm text-white/70">Starting from</span>
|
||||
<div className="text-4xl sm:text-5xl font-bold">{startingPrice}</div>
|
||||
{priceNote && <span className="text-sm text-white/70">{priceNote}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2",
|
||||
"rounded-lg bg-white px-6 py-3",
|
||||
"text-primary font-semibold",
|
||||
"transition-all duration-[var(--cp-duration-normal)]",
|
||||
"group-hover:bg-white/95 group-hover:shadow-lg"
|
||||
)}
|
||||
>
|
||||
View Plans
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
126
apps/portal/src/features/landing-page/components/ServiceCard.tsx
Normal file
126
apps/portal/src/features/landing-page/components/ServiceCard.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { ArrowRight, type LucideIcon } from "lucide-react";
|
||||
|
||||
interface ServiceCardProps {
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
highlight?: string;
|
||||
featured?: boolean;
|
||||
minimal?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service card with multiple variants:
|
||||
* - Default: Standard card with description
|
||||
* - Featured: Premium styling with gradient accents
|
||||
* - Minimal: Compact icon + title only
|
||||
*/
|
||||
export function ServiceCard({
|
||||
href,
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
highlight,
|
||||
featured = false,
|
||||
minimal = false,
|
||||
className,
|
||||
}: ServiceCardProps) {
|
||||
// Minimal variant - compact icon + title card
|
||||
if (minimal) {
|
||||
return (
|
||||
<Link href={href} className={cn("group block", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center text-center",
|
||||
"rounded-xl border border-border bg-card",
|
||||
"p-6",
|
||||
"transition-all duration-[var(--cp-duration-normal)]",
|
||||
"hover:border-primary/30 hover:shadow-md",
|
||||
"hover:-translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 mb-3 transition-all group-hover:bg-primary/15">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground font-display">{title}</h3>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard / Featured variant
|
||||
return (
|
||||
<Link href={href} className={cn("group block h-full", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex flex-col",
|
||||
"rounded-xl border bg-card",
|
||||
"p-6",
|
||||
"transition-all duration-[var(--cp-duration-normal)]",
|
||||
featured
|
||||
? [
|
||||
"border-primary/20",
|
||||
"shadow-md shadow-primary/5",
|
||||
"hover:border-primary/40 hover:shadow-xl hover:shadow-primary/10",
|
||||
"hover:-translate-y-1",
|
||||
]
|
||||
: [
|
||||
"border-border",
|
||||
"hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5",
|
||||
"hover:-translate-y-0.5",
|
||||
]
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 items-center justify-center rounded-xl mb-4",
|
||||
"transition-all group-hover:scale-105",
|
||||
featured
|
||||
? "bg-primary shadow-md shadow-primary/20 group-hover:shadow-lg group-hover:shadow-primary/30"
|
||||
: "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-6 w-6", featured ? "text-primary-foreground" : "text-primary")} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2 font-display">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed flex-grow">{description}</p>
|
||||
)}
|
||||
|
||||
{/* Highlight badge */}
|
||||
{highlight && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex self-start mt-3 rounded-full px-3 py-1 text-xs font-semibold",
|
||||
featured
|
||||
? "bg-success text-success-foreground shadow-sm"
|
||||
: "bg-success/10 text-success"
|
||||
)}
|
||||
>
|
||||
{highlight}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Link indicator */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 mt-4 pt-4 border-t",
|
||||
"text-sm font-medium text-primary",
|
||||
"transition-colors group-hover:text-primary-hover",
|
||||
featured ? "border-primary/10" : "border-border/50"
|
||||
)}
|
||||
>
|
||||
Learn more
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import { cn } from "@/shared/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface TrustBadgeProps {
|
||||
icon?: LucideIcon;
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust badge for hero sections - establishes credibility
|
||||
*/
|
||||
export function TrustBadge({ icon: Icon, text, className }: TrustBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full",
|
||||
"bg-primary/8 border border-primary/15 px-4 py-2",
|
||||
"text-sm font-medium text-primary",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
import { cn } from "@/shared/utils";
|
||||
import { Users, Calendar, Shield } from "lucide-react";
|
||||
|
||||
interface TrustIndicatorsProps {
|
||||
className?: string;
|
||||
variant?: "horizontal" | "compact";
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
icon: Calendar,
|
||||
value: "20+",
|
||||
label: "Years in Japan",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
value: "10,000+",
|
||||
label: "Customers Served",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
value: "NTT",
|
||||
label: "Authorized Partner",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Trust indicators showing company credibility metrics
|
||||
*/
|
||||
export function TrustIndicators({ className, variant = "horizontal" }: TrustIndicatorsProps) {
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-x-6 gap-y-2", className)}>
|
||||
{stats.map((stat, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<stat.icon className="h-4 w-4 text-primary/70" />
|
||||
<span className="font-semibold text-foreground">{stat.value}</span>
|
||||
<span>{stat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row items-start sm:items-center gap-6 sm:gap-8",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{stats.map((stat, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/8">
|
||||
<stat.icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-foreground">{stat.value}</div>
|
||||
<div className="text-sm text-muted-foreground">{stat.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,13 @@
|
||||
// Core components
|
||||
export { CtaButton } from "./CtaButton";
|
||||
export { TrustBadge } from "./TrustBadge";
|
||||
export { TrustIndicators } from "./TrustIndicators";
|
||||
|
||||
// Service display
|
||||
export { FeaturedServiceCard } from "./FeaturedServiceCard";
|
||||
export { ServiceCard } from "./ServiceCard";
|
||||
|
||||
// Legacy (kept for compatibility, can be removed later)
|
||||
export { GlowButton } from "./GlowButton";
|
||||
export { ValuePropCard } from "./ValuePropCard";
|
||||
export { BentoServiceCard } from "./BentoServiceCard";
|
||||
|
||||
@ -2,269 +2,268 @@ import {
|
||||
ArrowRight,
|
||||
Wifi,
|
||||
Smartphone,
|
||||
Lock,
|
||||
BadgeCheck,
|
||||
Globe,
|
||||
Wrench,
|
||||
ShieldCheck,
|
||||
Building2,
|
||||
Wrench,
|
||||
Tv,
|
||||
CheckCircle,
|
||||
Headphones,
|
||||
Home,
|
||||
Sparkles,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
GlowButton,
|
||||
ValuePropCard,
|
||||
BentoServiceCard,
|
||||
FloatingGlassCard,
|
||||
AnimatedBackground,
|
||||
} from "../components";
|
||||
import Link from "next/link";
|
||||
import { CtaButton, ServiceCard, TrustIndicators } from "../components";
|
||||
|
||||
/**
|
||||
* PublicLandingView - Modern SaaS Premium Landing Page
|
||||
* PublicLandingView - Premium Landing Page
|
||||
*
|
||||
* Purpose: Hook visitors, build trust, guide to shop
|
||||
* Features:
|
||||
* - Asymmetric hero with floating glass cards
|
||||
* - Glass morphism value proposition cards
|
||||
* - Bento grid services section
|
||||
* - Glowing CTA with depth layers
|
||||
* Design Direction: "Clean, Modern & Trustworthy"
|
||||
* - Centered hero with gradient accents
|
||||
* - Trust-forward design for expat audience
|
||||
* - Clear value propositions
|
||||
* - Balanced service showcase
|
||||
*/
|
||||
export function PublicLandingView() {
|
||||
const concepts = [
|
||||
{
|
||||
icon: CheckCircle,
|
||||
title: "One Stop Solution",
|
||||
description: "All you need is just to contact us and we will take care of everything.",
|
||||
},
|
||||
{
|
||||
icon: Headphones,
|
||||
title: "English Support",
|
||||
description: "We always assist you in English. No language barrier to worry about.",
|
||||
},
|
||||
{
|
||||
icon: Home,
|
||||
title: "Onsite Support",
|
||||
description: "Our tech staff can visit your residence for setup and troubleshooting.",
|
||||
},
|
||||
];
|
||||
|
||||
const services = [
|
||||
{
|
||||
href: "/services/internet",
|
||||
icon: Wifi,
|
||||
title: "Internet",
|
||||
description: "Fiber optic connections up to 10Gbps with full English support.",
|
||||
highlight: "From ¥4,950/mo",
|
||||
},
|
||||
{
|
||||
href: "/services/sim",
|
||||
icon: Smartphone,
|
||||
title: "SIM & eSIM",
|
||||
description: "Flexible mobile plans with data-only and voice options.",
|
||||
highlight: "From ¥990/mo",
|
||||
},
|
||||
{
|
||||
href: "/services/vpn",
|
||||
icon: ShieldCheck,
|
||||
title: "VPN Services",
|
||||
description: "Secure access to streaming content from your home country.",
|
||||
highlight: "Netflix US & more",
|
||||
},
|
||||
{
|
||||
href: "/services/business",
|
||||
icon: Building2,
|
||||
title: "Business Solutions",
|
||||
description: "Enterprise connectivity and IT infrastructure for companies.",
|
||||
},
|
||||
{
|
||||
href: "/services/onsite",
|
||||
icon: Wrench,
|
||||
title: "Onsite Support",
|
||||
description: "Professional technicians visit your location for setup.",
|
||||
},
|
||||
{
|
||||
href: "/services/tv",
|
||||
icon: Tv,
|
||||
title: "TV Services",
|
||||
description: "International TV packages with channels worldwide.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-24 pb-12">
|
||||
{/* ===== HERO SECTION ===== */}
|
||||
<section className="relative min-h-[70vh] flex items-center pt-8 sm:pt-0">
|
||||
<AnimatedBackground />
|
||||
<div className="space-y-24 sm:space-y-32 pb-20">
|
||||
{/* ===== HERO SECTION - CENTERED ===== */}
|
||||
<section className="relative pt-12 sm:pt-20 pb-8 overflow-hidden">
|
||||
{/* Decorative gradient blurs */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[400px] bg-gradient-to-b from-primary/8 to-transparent rounded-full blur-3xl -z-10" />
|
||||
<div className="absolute top-40 right-0 w-72 h-72 bg-primary/5 rounded-full blur-3xl -z-10" />
|
||||
<div className="absolute top-60 left-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl -z-10" />
|
||||
|
||||
<div className="w-full grid lg:grid-cols-2 gap-12 lg:gap-8 items-center">
|
||||
{/* Left: Content */}
|
||||
<div className="space-y-8">
|
||||
{/* Badge */}
|
||||
<div
|
||||
className="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-primary/5 border border-primary/10 px-4 py-1.5 text-sm text-primary font-medium">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
||||
</span>
|
||||
Reliable Connectivity in Japan
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<div
|
||||
className="space-y-4 animate-in fade-in slide-in-from-bottom-6 duration-700"
|
||||
style={{ animationDelay: "100ms" }}
|
||||
>
|
||||
<h1 className="text-display-lg lg:text-display-xl text-foreground">
|
||||
A One Stop Solution
|
||||
<span className="block cp-gradient-text pb-2 mt-2">for Your IT Needs</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
className="text-lg sm:text-xl text-muted-foreground max-w-xl leading-relaxed animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
Serving Japan's international community with reliable, English-supported
|
||||
internet, mobile, and VPN solutions.
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div
|
||||
className="flex flex-col sm:flex-row items-start sm:items-center gap-4 pt-2 animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
<GlowButton href="/services">
|
||||
Browse Services
|
||||
<ArrowRight className="h-5 w-5 ml-1" />
|
||||
</GlowButton>
|
||||
<GlowButton href="/contact" variant="secondary">
|
||||
Contact Us
|
||||
</GlowButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Floating Glass Cards (hidden on mobile) */}
|
||||
<div className="hidden lg:block relative h-[400px]">
|
||||
<FloatingGlassCard
|
||||
icon={Wifi}
|
||||
title="High-Speed Internet"
|
||||
subtitle="Fiber & WiFi solutions"
|
||||
accentColor="blue"
|
||||
className="absolute top-0 right-0 cp-float animate-in fade-in slide-in-from-right-12 duration-700"
|
||||
style={{ animationDelay: "500ms" }}
|
||||
/>
|
||||
<FloatingGlassCard
|
||||
icon={Smartphone}
|
||||
title="Mobile SIM"
|
||||
subtitle="Voice & data plans"
|
||||
accentColor="green"
|
||||
className="absolute top-1/3 right-1/4 cp-float-delayed animate-in fade-in slide-in-from-right-12 duration-700"
|
||||
style={{ animationDelay: "650ms" }}
|
||||
/>
|
||||
<FloatingGlassCard
|
||||
icon={Lock}
|
||||
title="VPN Security"
|
||||
subtitle="Privacy protection"
|
||||
accentColor="purple"
|
||||
className="absolute bottom-0 right-1/6 cp-float-slow animate-in fade-in slide-in-from-right-12 duration-700"
|
||||
style={{ animationDelay: "800ms" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== VALUE PROPS SECTION ===== */}
|
||||
<section className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-3">
|
||||
Our Concept
|
||||
</p>
|
||||
<h2 className="text-display-md text-foreground">Why customers choose us</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 cp-stagger-children">
|
||||
<ValuePropCard
|
||||
icon={BadgeCheck}
|
||||
title="One Stop Solution"
|
||||
description="All you need is just to contact us and we will take care of everything."
|
||||
/>
|
||||
<ValuePropCard
|
||||
icon={Globe}
|
||||
title="English Support"
|
||||
description="We always assist you in English. No language barrier to worry about."
|
||||
/>
|
||||
<ValuePropCard
|
||||
icon={Wrench}
|
||||
title="Onsite Support"
|
||||
description="Our tech staff can visit your residence for setup and troubleshooting."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== SERVICES BENTO GRID ===== */}
|
||||
<section className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-3">
|
||||
Our Services
|
||||
</p>
|
||||
<h2 className="text-display-md text-foreground">What we offer</h2>
|
||||
</div>
|
||||
|
||||
{/* Bento Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-6 gap-4 auto-rows-[minmax(180px,auto)] cp-stagger-children">
|
||||
{/* Internet - Featured (spans 3 cols, 2 rows) */}
|
||||
<BentoServiceCard
|
||||
href="/services/internet"
|
||||
icon={Wifi}
|
||||
title="Internet"
|
||||
description="High-speed fiber and WiFi solutions for homes and businesses across Japan."
|
||||
accentColor="blue"
|
||||
size="large"
|
||||
className="md:col-span-3 md:row-span-2"
|
||||
/>
|
||||
|
||||
{/* SIM - Medium (spans 2 cols) */}
|
||||
<BentoServiceCard
|
||||
href="/services/sim"
|
||||
icon={Smartphone}
|
||||
title="SIM & eSIM"
|
||||
description="Flexible voice and data plans with no contracts required."
|
||||
accentColor="green"
|
||||
size="medium"
|
||||
className="md:col-span-2"
|
||||
/>
|
||||
|
||||
{/* VPN - Small (spans 1 col) */}
|
||||
<BentoServiceCard
|
||||
href="/services/vpn"
|
||||
icon={Lock}
|
||||
title="VPN"
|
||||
accentColor="purple"
|
||||
size="small"
|
||||
className="md:col-span-1"
|
||||
/>
|
||||
|
||||
{/* Business - Small (spans 1 col) */}
|
||||
<BentoServiceCard
|
||||
href="/services/business"
|
||||
icon={Building2}
|
||||
title="Business"
|
||||
accentColor="amber"
|
||||
size="small"
|
||||
className="md:col-span-1"
|
||||
/>
|
||||
|
||||
{/* Onsite - Small (spans 1 col) */}
|
||||
<BentoServiceCard
|
||||
href="/services/onsite"
|
||||
icon={Wrench}
|
||||
title="Onsite"
|
||||
accentColor="rose"
|
||||
size="small"
|
||||
className="md:col-span-1"
|
||||
/>
|
||||
|
||||
{/* TV - Medium (spans 4 cols) */}
|
||||
<BentoServiceCard
|
||||
href="/services/tv"
|
||||
icon={Tv}
|
||||
title="TV Services"
|
||||
description="International TV packages and streaming solutions for your home entertainment."
|
||||
accentColor="cyan"
|
||||
size="medium"
|
||||
className="md:col-span-4"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== CTA SECTION ===== */}
|
||||
<section className="relative py-20 -mx-[var(--cp-page-padding)] px-[var(--cp-page-padding)]">
|
||||
{/* Background layers */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-primary/10 to-accent-gradient/5" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_oklch(0.55_0.18_260_/_0.15),_transparent_70%)]" />
|
||||
|
||||
{/* Decorative floating rings */}
|
||||
<div
|
||||
className="absolute top-10 left-10 w-32 h-32 rounded-full border border-primary/10 cp-float hidden sm:block"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-10 right-10 w-24 h-24 rounded-full border border-accent-gradient/10 cp-float-delayed hidden sm:block"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 right-1/4 w-16 h-16 rounded-full bg-primary/5 blur-xl hidden md:block"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative max-w-2xl mx-auto text-center space-y-8">
|
||||
<h2
|
||||
className="text-display-md text-foreground animate-in fade-in slide-in-from-bottom-6 duration-600"
|
||||
<div className="relative max-w-3xl mx-auto text-center">
|
||||
{/* Badge */}
|
||||
<div
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20 text-primary text-sm font-medium mb-8 animate-in fade-in slide-in-from-bottom-2 duration-500"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
>
|
||||
Ready to get connected?
|
||||
</h2>
|
||||
<p
|
||||
className="text-lg text-muted-foreground max-w-lg mx-auto leading-relaxed animate-in fade-in slide-in-from-bottom-6 duration-600"
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Trusted by 10,000+ customers in Japan
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h1
|
||||
className="text-display-lg sm:text-display-xl lg:text-[3.75rem] font-display font-bold text-foreground leading-[1.08] tracking-tight animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "100ms" }}
|
||||
>
|
||||
Contact us anytime — our bilingual team is here to help you find the right solution.
|
||||
</p>
|
||||
<div
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4 animate-in fade-in slide-in-from-bottom-6 duration-600"
|
||||
Your One Stop Solution
|
||||
<br />
|
||||
<span className="bg-gradient-to-r from-primary to-accent-gradient bg-clip-text text-transparent">
|
||||
for IT in Japan
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
className="mt-6 text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
<GlowButton href="/contact">
|
||||
Contact Us
|
||||
<ArrowRight className="h-5 w-5 ml-1" />
|
||||
</GlowButton>
|
||||
<GlowButton href="/services" variant="secondary">
|
||||
Serving Japan's international community with reliable, English-supported internet,
|
||||
mobile, and VPN solutions — for over 20 years.
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4 mt-10 animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
<CtaButton href="#services" variant="primary" size="lg">
|
||||
Browse Services
|
||||
</GlowButton>
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</CtaButton>
|
||||
<CtaButton href="/contact" variant="secondary" size="lg">
|
||||
Contact Us
|
||||
</CtaButton>
|
||||
</div>
|
||||
|
||||
{/* Trust indicators */}
|
||||
<div
|
||||
className="mt-14 pt-10 border-t border-border/40 animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
<TrustIndicators variant="horizontal" className="justify-center" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== WHY CHOOSE US ===== */}
|
||||
<section>
|
||||
<div className="text-center max-w-2xl mx-auto mb-14">
|
||||
<p className="text-sm font-medium text-primary uppercase tracking-wider mb-3">
|
||||
Why Choose Us
|
||||
</p>
|
||||
<h2 className="text-display-sm sm:text-display-md font-display font-bold text-foreground">
|
||||
Built for the international community
|
||||
</h2>
|
||||
<p className="mt-4 text-muted-foreground text-lg">
|
||||
We understand the unique challenges of living in Japan as an expat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||
{concepts.map((concept, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="group relative p-6 sm:p-8 rounded-2xl bg-card border border-border hover:border-primary/20 hover:shadow-lg transition-all duration-300 animate-in fade-in slide-in-from-bottom-4"
|
||||
style={{ animationDelay: `${idx * 100}ms` }}
|
||||
>
|
||||
{/* Gradient hover effect */}
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-primary/15 to-primary/5 mb-5 group-hover:scale-105 transition-transform">
|
||||
<concept.icon className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-3 font-display">
|
||||
{concept.title}
|
||||
</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">{concept.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== OUR SERVICES ===== */}
|
||||
<section id="services">
|
||||
<div className="text-center max-w-2xl mx-auto mb-14">
|
||||
<p className="text-sm font-medium text-primary uppercase tracking-wider mb-3">
|
||||
Our Services
|
||||
</p>
|
||||
<h2 className="text-display-sm sm:text-display-md font-display font-bold text-foreground">
|
||||
Everything you need to stay connected
|
||||
</h2>
|
||||
<p className="mt-4 text-muted-foreground text-lg">
|
||||
From high-speed internet to mobile plans, we've got you covered.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Services Grid - 3x2 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 lg:gap-6">
|
||||
{services.map((service, idx) => (
|
||||
<ServiceCard
|
||||
key={idx}
|
||||
href={service.href}
|
||||
icon={service.icon}
|
||||
title={service.title}
|
||||
description={service.description}
|
||||
highlight={service.highlight}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-12">
|
||||
<Link
|
||||
href="/services"
|
||||
className="inline-flex items-center gap-2 text-primary font-medium hover:underline underline-offset-4 transition-colors hover:text-primary-hover"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
Explore all services
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== FINAL CTA ===== */}
|
||||
<section className="relative overflow-hidden rounded-3xl">
|
||||
{/* Gradient background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-navy via-primary to-accent-gradient" />
|
||||
|
||||
{/* Pattern overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 20% 80%, rgba(255,255,255,0.4) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(255,255,255,0.3) 0%, transparent 40%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative px-8 py-16 sm:px-12 sm:py-20 text-center">
|
||||
<h2 className="text-display-sm sm:text-display-md font-display font-bold text-white mb-4">
|
||||
Ready to get connected?
|
||||
</h2>
|
||||
<p className="text-lg text-white/85 mb-10 max-w-xl mx-auto leading-relaxed">
|
||||
Contact us anytime — our bilingual team is here to help you find the right solution for
|
||||
your needs.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-white px-8 py-4 text-lg font-semibold text-primary hover:bg-white/95 hover:shadow-lg transition-all"
|
||||
>
|
||||
Contact Us
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/services"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-white/15 border border-white/30 px-8 py-4 text-lg font-semibold text-white hover:bg-white/25 transition-all"
|
||||
>
|
||||
Browse Services
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export interface HowItWorksStep {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface HowItWorksProps {
|
||||
title?: string;
|
||||
eyebrow?: string;
|
||||
steps: HowItWorksStep[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* HowItWorks - Visual step-by-step process component
|
||||
*
|
||||
* Displays a numbered process with icons and descriptions.
|
||||
* Horizontal layout on desktop, vertical stack on mobile.
|
||||
*/
|
||||
export function HowItWorks({
|
||||
title = "How It Works",
|
||||
eyebrow = "Simple Process",
|
||||
steps,
|
||||
className,
|
||||
}: HowItWorksProps) {
|
||||
return (
|
||||
<section className={cn("py-8", className)}>
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-8">
|
||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h2 className="text-display-sm font-display text-foreground">{title}</h2>
|
||||
</div>
|
||||
|
||||
{/* Steps Container */}
|
||||
<div className="relative">
|
||||
{/* Connection line - visible on md+ */}
|
||||
<div
|
||||
className="hidden md:block absolute top-8 left-[10%] right-[10%] h-0.5 bg-gradient-to-r from-transparent via-border to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Steps Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="relative flex flex-col items-center text-center group">
|
||||
{/* Step Number Badge */}
|
||||
<div className="relative mb-4">
|
||||
{/* Icon Container */}
|
||||
<div className="w-16 h-16 rounded-2xl bg-card border border-border shadow-sm flex items-center justify-center text-primary group-hover:border-primary/40 group-hover:shadow-md transition-all duration-[var(--cp-duration-normal)]">
|
||||
{step.icon}
|
||||
</div>
|
||||
{/* Number Badge */}
|
||||
<div className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold flex items-center justify-center shadow-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="text-base font-semibold text-foreground mb-1.5">{step.title}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-[200px]">
|
||||
{step.description}
|
||||
</p>
|
||||
|
||||
{/* Vertical connector for mobile - between steps */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="md:hidden w-0.5 h-6 bg-border mt-4" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default HowItWorks;
|
||||
102
apps/portal/src/features/services/components/base/ServiceCTA.tsx
Normal file
102
apps/portal/src/features/services/components/base/ServiceCTA.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode, MouseEvent } from "react";
|
||||
import { ArrowRight, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export interface ServiceCTAProps {
|
||||
eyebrow?: string;
|
||||
eyebrowIcon?: ReactNode;
|
||||
headline: string;
|
||||
description: string;
|
||||
primaryAction: {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
};
|
||||
secondaryAction?: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceCTA - Reusable call-to-action section
|
||||
*
|
||||
* Gradient background CTA with decorative elements.
|
||||
* Used at the bottom of service pages to drive conversion.
|
||||
*/
|
||||
export function ServiceCTA({
|
||||
eyebrow = "Get started in minutes",
|
||||
eyebrowIcon = <Sparkles className="h-3.5 w-3.5" />,
|
||||
headline,
|
||||
description,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
className,
|
||||
}: ServiceCTAProps) {
|
||||
return (
|
||||
<section className={cn("relative text-center py-12 rounded-2xl overflow-hidden", className)}>
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 via-primary/5 to-purple-500/10" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_oklch(0.55_0.18_260_/_0.1),_transparent_70%)]" />
|
||||
|
||||
{/* Decorative rings */}
|
||||
<div
|
||||
className="absolute top-4 left-8 w-20 h-20 rounded-full border border-primary/10 cp-float hidden sm:block"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-4 right-8 w-16 h-16 rounded-full border border-blue-500/10 cp-float-delayed hidden sm:block"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative">
|
||||
{/* Eyebrow */}
|
||||
<div className="inline-flex items-center gap-1.5 text-xs font-medium text-primary mb-4 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20">
|
||||
{eyebrowIcon}
|
||||
{eyebrow}
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h2 className="text-display-sm font-display text-foreground mb-3">{headline}</h2>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-base text-muted-foreground mb-6 max-w-md mx-auto">{description}</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
{primaryAction.onClick ? (
|
||||
<Button
|
||||
onClick={primaryAction.onClick}
|
||||
size="lg"
|
||||
rightIcon={<ArrowRight className="h-4 w-4" />}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
as="a"
|
||||
href={primaryAction.href ?? "#"}
|
||||
size="lg"
|
||||
rightIcon={<ArrowRight className="h-4 w-4" />}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{secondaryAction && (
|
||||
<Button as="a" href={secondaryAction.href} variant="outline" size="lg">
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceCTA;
|
||||
106
apps/portal/src/features/services/components/base/ServiceFAQ.tsx
Normal file
106
apps/portal/src/features/services/components/base/ServiceFAQ.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export interface FAQItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface ServiceFAQProps {
|
||||
title?: string;
|
||||
eyebrow?: string;
|
||||
items: FAQItem[];
|
||||
className?: string;
|
||||
defaultOpenIndex?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual FAQ item with expand/collapse
|
||||
*/
|
||||
function FAQItemComponent({
|
||||
question,
|
||||
answer,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
question: string;
|
||||
answer: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full py-4 flex items-start justify-between gap-3 text-left hover:text-primary transition-colors"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">{question}</span>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-300",
|
||||
isOpen ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<div className="pb-4 pr-8">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{answer}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceFAQ - Reusable FAQ accordion section
|
||||
*
|
||||
* Displays frequently asked questions with collapsible answers.
|
||||
* Used at the bottom of service pages to address common concerns.
|
||||
*/
|
||||
export function ServiceFAQ({
|
||||
title = "Frequently Asked Questions",
|
||||
eyebrow = "Common Questions",
|
||||
items,
|
||||
className,
|
||||
defaultOpenIndex = null,
|
||||
}: ServiceFAQProps) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(defaultOpenIndex);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className={cn("py-8", className)}>
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h2 className="text-display-sm font-display text-foreground">{title}</h2>
|
||||
</div>
|
||||
|
||||
{/* FAQ Container */}
|
||||
<div className="bg-card border border-border rounded-2xl px-5 shadow-sm">
|
||||
{items.map((item, index) => (
|
||||
<FAQItemComponent
|
||||
key={index}
|
||||
question={item.question}
|
||||
answer={item.answer}
|
||||
isOpen={openIndex === index}
|
||||
onToggle={() => setOpenIndex(openIndex === index ? null : index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceFAQ;
|
||||
@ -1,4 +1,5 @@
|
||||
import { CheckCircle } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
export interface HighlightFeature {
|
||||
icon: React.ReactNode;
|
||||
@ -10,17 +11,39 @@ export interface HighlightFeature {
|
||||
interface ServiceHighlightsProps {
|
||||
features: HighlightFeature[];
|
||||
className?: string;
|
||||
/** Layout variant */
|
||||
variant?: "grid" | "compact";
|
||||
}
|
||||
|
||||
function HighlightItem({ icon, title, description, highlight }: HighlightFeature) {
|
||||
return (
|
||||
<div className="flex flex-col h-full p-5 rounded-2xl bg-secondary/10 hover:bg-secondary/20 transition-colors border border-transparent">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full p-5 rounded-2xl",
|
||||
"cp-glass-card",
|
||||
"transition-all duration-[var(--cp-duration-normal)]",
|
||||
"hover:-translate-y-0.5 hover:shadow-lg"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-background text-primary shadow-sm flex-shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-11 w-11 items-center justify-center rounded-xl flex-shrink-0",
|
||||
"bg-primary/10 text-primary",
|
||||
"transition-transform duration-[var(--cp-duration-normal)]",
|
||||
"group-hover:scale-105"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
{highlight && (
|
||||
<span className="inline-flex items-center gap-1.5 py-1 px-2.5 rounded-full bg-background text-[10px] font-bold text-success leading-tight shadow-sm">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 py-1 px-2.5 rounded-full",
|
||||
"bg-success/10 text-success",
|
||||
"text-[10px] font-bold leading-tight"
|
||||
)}
|
||||
>
|
||||
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="break-words">{highlight}</span>
|
||||
</span>
|
||||
@ -33,15 +56,67 @@ function HighlightItem({ icon, title, description, highlight }: HighlightFeature
|
||||
);
|
||||
}
|
||||
|
||||
function CompactHighlightItem({ icon, title, description, highlight }: HighlightFeature) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-4 p-4 rounded-xl",
|
||||
"bg-card/50 border border-border/50",
|
||||
"transition-all duration-[var(--cp-duration-normal)]",
|
||||
"hover:bg-card hover:border-border"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg flex-shrink-0",
|
||||
"bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-foreground text-sm">{title}</h3>
|
||||
{highlight && (
|
||||
<span className="text-[10px] font-medium text-success bg-success/10 px-2 py-0.5 rounded-full">
|
||||
{highlight}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceHighlights
|
||||
*
|
||||
* A clean, grid-based layout for displaying service features/highlights.
|
||||
* Replaces the old boxed "Why Choose Us" sections.
|
||||
* Supports two variants: 'grid' for larger cards and 'compact' for inline style.
|
||||
*/
|
||||
export function ServiceHighlights({ features, className = "" }: ServiceHighlightsProps) {
|
||||
export function ServiceHighlights({
|
||||
features,
|
||||
className = "",
|
||||
variant = "grid",
|
||||
}: ServiceHighlightsProps) {
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<div className={cn("grid grid-cols-1 md:grid-cols-2 gap-3", className)}>
|
||||
{features.map((feature, index) => (
|
||||
<CompactHighlightItem key={index} {...feature} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 ${className}`}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 cp-stagger-children",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{features.map((feature, index) => (
|
||||
<HighlightItem key={index} {...feature} />
|
||||
))}
|
||||
|
||||
@ -12,6 +12,10 @@ interface ServicesHeroProps {
|
||||
eyebrow?: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
/** Use display font for title */
|
||||
displayFont?: boolean;
|
||||
/** Show animated entrance */
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
const alignmentMap: Record<Alignment, string> = {
|
||||
@ -26,20 +30,59 @@ export function ServicesHero({
|
||||
eyebrow,
|
||||
children,
|
||||
className,
|
||||
displayFont = true,
|
||||
animated = true,
|
||||
}: ServicesHeroProps) {
|
||||
const animationClasses = animated ? "animate-in fade-in slide-in-from-bottom-4 duration-500" : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 mb-8",
|
||||
"flex flex-col gap-3 mb-10",
|
||||
alignmentMap[align],
|
||||
className,
|
||||
align === "center" ? "mx-auto max-w-2xl" : ""
|
||||
)}
|
||||
>
|
||||
{eyebrow ? <div className="text-xs font-medium text-primary">{eyebrow}</div> : null}
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-foreground leading-tight">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
|
||||
{children ? <div className="mt-1 w-full">{children}</div> : null}
|
||||
{eyebrow ? (
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-semibold text-primary uppercase tracking-wider",
|
||||
animationClasses
|
||||
)}
|
||||
style={animated ? { animationDelay: "0ms" } : undefined}
|
||||
>
|
||||
{eyebrow}
|
||||
</div>
|
||||
) : null}
|
||||
<h1
|
||||
className={cn(
|
||||
"text-display-md md:text-display-lg text-foreground leading-tight",
|
||||
displayFont && "font-display",
|
||||
animationClasses
|
||||
)}
|
||||
style={animated ? { animationDelay: "50ms" } : undefined}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p
|
||||
className={cn(
|
||||
"text-base md:text-lg text-muted-foreground leading-relaxed max-w-xl",
|
||||
align === "center" && "mx-auto",
|
||||
animationClasses
|
||||
)}
|
||||
style={animated ? { animationDelay: "100ms" } : undefined}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
{children ? (
|
||||
<div
|
||||
className={cn("mt-2 w-full", animationClasses)}
|
||||
style={animated ? { animationDelay: "150ms" } : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,3 +9,11 @@ export { ServicesBackLink } from "./ServicesBackLink";
|
||||
export { OrderSummary } from "./OrderSummary";
|
||||
export { PricingDisplay } from "./PricingDisplay";
|
||||
export type { PricingDisplayProps } from "./PricingDisplay";
|
||||
export { HowItWorks } from "./HowItWorks";
|
||||
export type { HowItWorksStep, HowItWorksProps } from "./HowItWorks";
|
||||
export { ServiceCTA } from "./ServiceCTA";
|
||||
export type { ServiceCTAProps } from "./ServiceCTA";
|
||||
export { ServiceFAQ } from "./ServiceFAQ";
|
||||
export type { FAQItem, ServiceFAQProps } from "./ServiceFAQ";
|
||||
export { ServiceHighlights } from "./ServiceHighlights";
|
||||
export type { HighlightFeature } from "./ServiceHighlights";
|
||||
|
||||
@ -1,132 +1,146 @@
|
||||
import Link from "next/link";
|
||||
import { Building2, Wrench, Tv, ArrowRight, Wifi, Smartphone, Lock } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
interface ServicesGridProps {
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
interface ServiceCardProps {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
price?: string;
|
||||
highlight?: string;
|
||||
featured?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ServiceCard({
|
||||
href,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
price,
|
||||
highlight,
|
||||
featured = false,
|
||||
className,
|
||||
}: ServiceCardProps) {
|
||||
return (
|
||||
<Link href={href} className={cn("group block h-full", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex flex-col rounded-xl border p-6",
|
||||
"transition-all duration-[var(--cp-duration-normal)]",
|
||||
"hover:-translate-y-0.5 hover:shadow-lg",
|
||||
featured
|
||||
? "bg-gradient-to-br from-primary/5 to-card border-primary/20 hover:border-primary/40"
|
||||
: "bg-card border-border hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-11 w-11 items-center justify-center rounded-lg mb-4 text-primary",
|
||||
featured ? "bg-primary/15" : "bg-primary/8"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2 font-display">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4 flex-grow leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Price or Highlight */}
|
||||
<div className="flex items-center justify-between mt-auto pt-4 border-t border-border/50">
|
||||
<div>
|
||||
{price && (
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
From <span className="text-primary">{price}</span>
|
||||
</span>
|
||||
)}
|
||||
{highlight && (
|
||||
<span className="inline-flex rounded-full bg-success/10 px-2.5 py-0.5 text-xs font-medium text-success">
|
||||
{highlight}
|
||||
</span>
|
||||
)}
|
||||
{!price && !highlight && (
|
||||
<span className="text-sm text-muted-foreground">Learn more</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-sm font-medium text-primary",
|
||||
"transition-transform group-hover:translate-x-0.5"
|
||||
)}
|
||||
>
|
||||
View Plans
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServicesGrid({ basePath = "/services" }: ServicesGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-20">
|
||||
{/* Internet */}
|
||||
<Link href={`${basePath}/internet`} className="group block h-full">
|
||||
<div className="h-full bg-card rounded-2xl border border-border/60 p-8 shadow-sm transition-all duration-300 hover:shadow-md hover:border-blue-500/50 flex flex-col">
|
||||
<div className="h-14 w-14 rounded-2xl bg-blue-500/10 flex items-center justify-center text-blue-600 mb-6 group-hover:scale-110 transition-transform">
|
||||
<Wifi className="h-7 w-7" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-3 group-hover:text-blue-600 transition-colors">
|
||||
Internet
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 flex-grow">
|
||||
NTT fiber with speeds up to 10Gbps and professional installation support. Fast and
|
||||
reliable connectivity.
|
||||
</p>
|
||||
<div className="flex items-center text-blue-600 font-medium mt-auto">
|
||||
View Plans{" "}
|
||||
<ArrowRight className="h-4 w-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Internet - Featured */}
|
||||
<ServiceCard
|
||||
href={`${basePath}/internet`}
|
||||
icon={<Wifi className="h-5 w-5" />}
|
||||
title="Internet"
|
||||
description="NTT fiber with speeds up to 10Gbps. Professional installation and full support included."
|
||||
price="¥4,000/mo"
|
||||
featured
|
||||
/>
|
||||
|
||||
{/* SIM & eSIM */}
|
||||
<Link href={`${basePath}/sim`} className="group block h-full">
|
||||
<div className="h-full bg-card rounded-2xl border border-border/60 p-8 shadow-sm transition-all duration-300 hover:shadow-md hover:border-green-500/50 flex flex-col">
|
||||
<div className="h-14 w-14 rounded-2xl bg-green-500/10 flex items-center justify-center text-green-600 mb-6 group-hover:scale-110 transition-transform">
|
||||
<Smartphone className="h-7 w-7" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-3 group-hover:text-green-600 transition-colors">
|
||||
SIM & eSIM
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 flex-grow">
|
||||
Data, voice & SMS on NTT Docomo's nationwide network. Available as physical SIM or
|
||||
instant eSIM.
|
||||
</p>
|
||||
<div className="flex items-center text-green-600 font-medium mt-auto">
|
||||
View Plans{" "}
|
||||
<ArrowRight className="h-4 w-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{/* SIM */}
|
||||
<ServiceCard
|
||||
href={`${basePath}/sim`}
|
||||
icon={<Smartphone className="h-5 w-5" />}
|
||||
title="SIM & eSIM"
|
||||
description="Data, voice & SMS on NTT Docomo's network. Physical SIM or instant eSIM."
|
||||
highlight="No contract"
|
||||
/>
|
||||
|
||||
{/* VPN */}
|
||||
<Link href={`${basePath}/vpn`} className="group block h-full">
|
||||
<div className="h-full bg-card rounded-2xl border border-border/60 p-8 shadow-sm transition-all duration-300 hover:shadow-md hover:border-purple-500/50 flex flex-col">
|
||||
<div className="h-14 w-14 rounded-2xl bg-purple-500/10 flex items-center justify-center text-purple-600 mb-6 group-hover:scale-110 transition-transform">
|
||||
<Lock className="h-7 w-7" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-3 group-hover:text-purple-600 transition-colors">
|
||||
VPN
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 flex-grow">
|
||||
Access US/UK content with a pre-configured router. Easy plug & play setup for seamless
|
||||
streaming.
|
||||
</p>
|
||||
<div className="flex items-center text-purple-600 font-medium mt-auto">
|
||||
View Plans{" "}
|
||||
<ArrowRight className="h-4 w-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<ServiceCard
|
||||
href={`${basePath}/vpn`}
|
||||
icon={<Lock className="h-5 w-5" />}
|
||||
title="VPN"
|
||||
description="Access US/UK content with pre-configured routers. Simple setup."
|
||||
price="¥2,500/mo"
|
||||
/>
|
||||
|
||||
{/* Business Solutions */}
|
||||
<Link href="/services/business" className="group block h-full">
|
||||
<div className="h-full bg-card rounded-2xl border border-border/60 p-8 shadow-sm transition-all duration-300 hover:shadow-md hover:border-primary/50 flex flex-col">
|
||||
<div className="h-14 w-14 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform">
|
||||
<Building2 className="h-7 w-7" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-3 group-hover:text-primary transition-colors">
|
||||
Business Solutions
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 flex-grow">
|
||||
Dedicated Internet Access (DIA), Office LAN setup, Data Center services, and
|
||||
onsite/remote tech support.
|
||||
</p>
|
||||
<div className="flex items-center text-primary font-medium mt-auto">
|
||||
Learn more{" "}
|
||||
<ArrowRight className="h-4 w-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{/* Business */}
|
||||
<ServiceCard
|
||||
href="/services/business"
|
||||
icon={<Building2 className="h-5 w-5" />}
|
||||
title="Business"
|
||||
description="DIA, Office LAN, and enterprise connectivity solutions."
|
||||
/>
|
||||
|
||||
{/* Onsite Support */}
|
||||
<Link href="/services/onsite" className="group block h-full">
|
||||
<div className="h-full bg-card rounded-2xl border border-border/60 p-8 shadow-sm transition-all duration-300 hover:shadow-md hover:border-primary/50 flex flex-col">
|
||||
<div className="h-14 w-14 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform">
|
||||
<Wrench className="h-7 w-7" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-3 group-hover:text-primary transition-colors">
|
||||
Onsite Support
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 flex-grow">
|
||||
Professional technical support at your residence or office. Network setup, device
|
||||
configuration, and troubleshooting.
|
||||
</p>
|
||||
<div className="flex items-center text-primary font-medium mt-auto">
|
||||
Learn more{" "}
|
||||
<ArrowRight className="h-4 w-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{/* Onsite */}
|
||||
<ServiceCard
|
||||
href="/services/onsite"
|
||||
icon={<Wrench className="h-5 w-5" />}
|
||||
title="Onsite"
|
||||
description="Setup and troubleshooting at your location by certified technicians."
|
||||
/>
|
||||
|
||||
{/* TV Services */}
|
||||
<Link href="/services/tv" className="group block h-full">
|
||||
<div className="h-full bg-card rounded-2xl border border-border/60 p-8 shadow-sm transition-all duration-300 hover:shadow-md hover:border-primary/50 flex flex-col">
|
||||
<div className="h-14 w-14 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform">
|
||||
<Tv className="h-7 w-7" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-3 group-hover:text-primary transition-colors">
|
||||
TV Services
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 flex-grow">
|
||||
Satellite, Cable, and Optical Fiber TV services. We arrange subscriptions for major
|
||||
Japanese TV providers.
|
||||
</p>
|
||||
<div className="flex items-center text-primary font-medium mt-auto">
|
||||
Learn more{" "}
|
||||
<ArrowRight className="h-4 w-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{/* TV */}
|
||||
<ServiceCard
|
||||
href="/services/tv"
|
||||
icon={<Tv className="h-5 w-5" />}
|
||||
title="TV Services"
|
||||
description="Satellite, cable, and optical fiber TV with international packages."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Home, Building2, Zap, Info, X } from "lucide-react";
|
||||
import { ChevronDown, Home, Building2, Info, X, Check, Star } from "lucide-react";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { CardBadge } from "@/features/services/components/base/CardBadge";
|
||||
import { cn } from "@/shared/utils";
|
||||
@ -34,16 +34,22 @@ interface PublicOfferingCardProps {
|
||||
|
||||
const tierStyles = {
|
||||
Silver: {
|
||||
card: "border-muted-foreground/20 bg-card",
|
||||
card: "border-border bg-card",
|
||||
accent: "text-muted-foreground",
|
||||
badge: "bg-muted text-muted-foreground",
|
||||
iconBg: "bg-muted",
|
||||
},
|
||||
Gold: {
|
||||
card: "border-warning/30 bg-warning-soft/20",
|
||||
card: "border-warning/40 bg-gradient-to-br from-warning/5 to-card ring-1 ring-warning/20",
|
||||
accent: "text-warning",
|
||||
badge: "bg-warning/10 text-warning",
|
||||
iconBg: "bg-warning/10",
|
||||
},
|
||||
Platinum: {
|
||||
card: "border-primary/30 bg-info-soft/20",
|
||||
card: "border-primary/40 bg-gradient-to-br from-primary/5 to-card ring-1 ring-primary/20",
|
||||
accent: "text-primary",
|
||||
badge: "bg-primary/10 text-primary",
|
||||
iconBg: "bg-primary/10",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -52,10 +58,12 @@ const tierStyles = {
|
||||
*/
|
||||
function ConnectionTypeInfo({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className="bg-info-soft/50 border border-info/20 rounded-lg p-4 mb-4">
|
||||
<div className="bg-info/5 border border-info/20 rounded-xl p-4 mb-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-5 w-5 text-info flex-shrink-0" />
|
||||
<div className="h-8 w-8 rounded-lg bg-info/10 flex items-center justify-center">
|
||||
<Info className="h-4 w-4 text-info" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-sm text-foreground">
|
||||
Why does speed vary by building?
|
||||
</h4>
|
||||
@ -63,12 +71,12 @@ function ConnectionTypeInfo({ onClose }: { onClose: () => void }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 text-xs text-muted-foreground">
|
||||
<div className="space-y-3 text-xs text-muted-foreground pl-10">
|
||||
<p>
|
||||
Apartment buildings in Japan have different fiber infrastructure installed by NTT. Your
|
||||
available speed depends on what your building supports:
|
||||
@ -94,14 +102,73 @@ function ConnectionTypeInfo({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-foreground font-medium pt-1">
|
||||
Good news: All types have the same monthly price (¥4,800~). We'll check what's
|
||||
available at your address.
|
||||
Good news: All types have the same monthly price. We'll check what's available
|
||||
at your address.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier card component
|
||||
*/
|
||||
function TierCard({ tier }: { tier: TierInfo }) {
|
||||
const styles = tierStyles[tier.tier];
|
||||
const isGold = tier.tier === "Gold";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border p-4 transition-all duration-200 flex flex-col relative",
|
||||
styles.card,
|
||||
"hover:-translate-y-0.5 hover:shadow-md"
|
||||
)}
|
||||
>
|
||||
{/* Recommended badge for Gold */}
|
||||
{isGold && (
|
||||
<div className="absolute -top-2.5 left-4">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold bg-warning text-warning-foreground px-2 py-0.5 rounded-full shadow-sm">
|
||||
<Star className="h-3 w-3" fill="currentColor" />
|
||||
Popular
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className={cn("font-bold text-sm", styles.accent)}>{tier.tier}</span>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-baseline gap-0.5 flex-wrap">
|
||||
<span className="text-2xl font-bold text-foreground">
|
||||
¥{tier.monthlyPrice.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">/mo</span>
|
||||
</div>
|
||||
{tier.pricingNote && (
|
||||
<span className="text-[10px] text-warning font-medium">{tier.pricingNote}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">{tier.description}</p>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="space-y-2 flex-grow">
|
||||
{tier.features.slice(0, 3).map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-2 text-xs">
|
||||
<Check className="h-3.5 w-3.5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground leading-relaxed">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public-facing offering card that shows pricing inline
|
||||
* No modals - all information is visible or expandable within the card
|
||||
@ -129,39 +196,45 @@ export function PublicOfferingCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border bg-card shadow-[var(--cp-shadow-1)] overflow-hidden transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5",
|
||||
isExpanded ? "shadow-[var(--cp-shadow-2)] ring-1 ring-primary/20" : "",
|
||||
isPremium ? "border-primary/30" : "border-border"
|
||||
"rounded-2xl border overflow-hidden",
|
||||
"transition-all duration-[var(--cp-duration-normal)]",
|
||||
"hover:shadow-lg",
|
||||
isExpanded ? "shadow-md ring-1 ring-primary/10" : "shadow-sm",
|
||||
isPremium
|
||||
? "border-primary/30 bg-gradient-to-r from-primary/5 to-card"
|
||||
: "border-border bg-card"
|
||||
)}
|
||||
>
|
||||
{/* Header - Always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full p-4 flex items-start justify-between gap-3 text-left hover:bg-muted/20 transition-colors"
|
||||
className="w-full p-5 flex items-start justify-between gap-4 text-left hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg border flex-shrink-0",
|
||||
"flex h-12 w-12 items-center justify-center rounded-xl border flex-shrink-0",
|
||||
"transition-transform duration-[var(--cp-duration-normal)]",
|
||||
isExpanded && "scale-105",
|
||||
iconType === "home"
|
||||
? "bg-info-soft/50 text-info border-info/20"
|
||||
: "bg-success-soft/50 text-success border-success/20"
|
||||
? "bg-blue-500/10 text-blue-500 border-blue-500/20"
|
||||
: "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-bold text-foreground">{title}</h3>
|
||||
<h3 className="text-lg font-bold text-foreground">{title}</h3>
|
||||
<CardBadge text={speedBadge} variant={isPremium ? "new" : "default"} size="sm" />
|
||||
{isPremium && <span className="text-xs text-muted-foreground">(select areas)</span>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
<div className="flex items-baseline gap-1 pt-0.5">
|
||||
<div className="flex items-baseline gap-1.5 pt-1">
|
||||
<span className="text-xs text-muted-foreground">From</span>
|
||||
<span className="text-lg font-bold text-foreground">
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
¥{startingPrice.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">/mo</span>
|
||||
@ -169,29 +242,36 @@ export function PublicOfferingCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 mt-1">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mt-1">
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
{isExpanded ? "Hide" : "View tiers"}
|
||||
{isExpanded ? "Hide tiers" : "View tiers"}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center",
|
||||
"bg-muted/50 text-muted-foreground",
|
||||
"transition-all duration-[var(--cp-duration-normal)]",
|
||||
isExpanded && "bg-primary/10 text-primary rotate-180"
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded content - Tier pricing shown inline */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border px-4 py-4 bg-muted/10">
|
||||
<div className="border-t border-border px-5 py-5 bg-muted/20">
|
||||
{/* Connection type info button (for Apartment) */}
|
||||
{showConnectionInfo && !showInfo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInfo(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-info hover:text-info/80 transition-colors mb-3"
|
||||
className="flex items-center gap-2 text-xs text-info hover:text-info/80 transition-colors mb-4 group"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<div className="h-6 w-6 rounded-md bg-info/10 flex items-center justify-center group-hover:bg-info/20 transition-colors">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span>Why does speed vary by building?</span>
|
||||
</button>
|
||||
)}
|
||||
@ -202,65 +282,26 @@ export function PublicOfferingCard({
|
||||
)}
|
||||
|
||||
{/* Tier cards - 3 columns on desktop */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-5">
|
||||
{tiers.map(tier => (
|
||||
<div
|
||||
key={tier.tier}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 transition-all duration-200 flex flex-col",
|
||||
tierStyles[tier.tier].card
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={cn("font-semibold text-sm", tierStyles[tier.tier].accent)}>
|
||||
{tier.tier}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Price - Always visible */}
|
||||
<div className="mb-2">
|
||||
<div className="flex items-baseline gap-0.5 flex-wrap">
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
¥{tier.monthlyPrice.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">/mo</span>
|
||||
{tier.pricingNote && (
|
||||
<span className="text-[10px] text-warning ml-1">{tier.pricingNote}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-muted-foreground mb-2">{tier.description}</p>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="space-y-1 flex-grow">
|
||||
{tier.features.slice(0, 3).map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-1.5 text-xs">
|
||||
<Zap className="h-3 w-3 text-success flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground leading-relaxed">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<TierCard key={tier.tier} tier={tier} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer with setup fee and CTA */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 pt-3 border-t border-border/50">
|
||||
<p className="text-xs text-muted-foreground flex-1">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4 pt-4 border-t border-border/50">
|
||||
<p className="text-sm text-muted-foreground flex-1">
|
||||
<span className="font-semibold text-foreground">
|
||||
+ ¥{setupFee.toLocaleString()} one-time setup
|
||||
</span>{" "}
|
||||
(or 12/24-month installment)
|
||||
</p>
|
||||
{onCtaClick ? (
|
||||
<Button as="button" onClick={onCtaClick} size="sm" className="whitespace-nowrap">
|
||||
<Button onClick={onCtaClick} className="whitespace-nowrap">
|
||||
{customCtaLabel ?? "Check availability"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button as="a" href={ctaPath} size="sm" className="whitespace-nowrap">
|
||||
<Button as="a" href={ctaPath} className="whitespace-nowrap">
|
||||
{customCtaLabel ?? "Check availability"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -1,23 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type ElementType, type ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Smartphone,
|
||||
Check,
|
||||
Phone,
|
||||
Globe,
|
||||
ArrowLeft,
|
||||
Signal,
|
||||
Sparkles,
|
||||
CreditCard,
|
||||
ChevronDown,
|
||||
Info,
|
||||
CircleDollarSign,
|
||||
TriangleAlert,
|
||||
Calendar,
|
||||
ArrowRightLeft,
|
||||
ArrowRight,
|
||||
Users,
|
||||
FileCheck,
|
||||
Send,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
@ -30,6 +28,9 @@ import {
|
||||
ServiceHighlights,
|
||||
type HighlightFeature,
|
||||
} from "@/features/services/components/base/ServiceHighlights";
|
||||
import { HowItWorks, type HowItWorksStep } from "@/features/services/components/base/HowItWorks";
|
||||
import { ServiceCTA } from "@/features/services/components/base/ServiceCTA";
|
||||
import { ServiceFAQ, type FAQItem } from "@/features/services/components/base/ServiceFAQ";
|
||||
|
||||
export type SimPlansTab = "data-voice" | "data-only" | "voice-only";
|
||||
|
||||
@ -39,42 +40,63 @@ interface PlansByType {
|
||||
VoiceOnly: SimCatalogProduct[];
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
icon: Icon,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ElementType;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
// SIM FAQ items - consolidated from the collapsible sections
|
||||
const simFaqItems: FAQItem[] = [
|
||||
{
|
||||
question: "What are the calling and SMS rates?",
|
||||
answer:
|
||||
"Domestic voice calls are ¥10/30 seconds, SMS is ¥3/message. Incoming calls and SMS are free. You can add unlimited domestic calling for ¥3,000/month at checkout. International rates vary by country (¥31-148/30 sec).",
|
||||
},
|
||||
{
|
||||
question: "What fees should I expect?",
|
||||
answer:
|
||||
"Activation fee is ¥1,500 (one-time). SIM replacement or eSIM re-download is also ¥1,500 if needed. Family discount gives ¥300/month off per additional Voice SIM on your account. All prices exclude 10% consumption tax.",
|
||||
},
|
||||
{
|
||||
question: "What is the contract period?",
|
||||
answer:
|
||||
"Minimum contract is 3 full billing months. First month (sign-up to end of month) is free and doesn't count. You can request cancellation after the 3rd month. Monthly fee is incurred in full for the cancellation month.",
|
||||
},
|
||||
{
|
||||
question: "What do I need to get started?",
|
||||
answer:
|
||||
"You'll need: (1) Valid ID with name, DOB, address and photo for verification, (2) A compatible unlocked device, (3) Credit card for payment. SIM is activated as 4G by default; 5G can be requested via your account portal.",
|
||||
},
|
||||
{
|
||||
question: "Can I keep my current phone number?",
|
||||
answer:
|
||||
"Yes! We support number portability (MNP). You can transfer your existing Japanese phone number when signing up. The process is handled during checkout and typically takes 1-2 business days.",
|
||||
},
|
||||
{
|
||||
question: "Can I use my SIM abroad?",
|
||||
answer:
|
||||
"International data roaming is not available. Voice/SMS roaming can be enabled upon request with a ¥50,000/month limit. We recommend using local SIMs or eSIMs for international travel.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-xl overflow-hidden bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="w-5 h-5 text-primary" />
|
||||
<span className="font-medium text-foreground">{title}</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 text-muted-foreground transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ${isOpen ? "max-h-[2000px]" : "max-h-0"}`}
|
||||
>
|
||||
<div className="p-4 pt-0 border-t border-border">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// How It Works steps for SIM - no eligibility check needed
|
||||
const simSteps: HowItWorksStep[] = [
|
||||
{
|
||||
icon: <Signal className="h-6 w-6" />,
|
||||
title: "Choose Plan",
|
||||
description: "Pick your data and voice options",
|
||||
},
|
||||
{
|
||||
icon: <FileCheck className="h-6 w-6" />,
|
||||
title: "Sign Up & Verify",
|
||||
description: "Create account and upload ID",
|
||||
},
|
||||
{
|
||||
icon: <Send className="h-6 w-6" />,
|
||||
title: "Order & Receive",
|
||||
description: "eSIM instant or SIM shipped",
|
||||
},
|
||||
{
|
||||
icon: <CheckCircle className="h-6 w-6" />,
|
||||
title: "Get Connected",
|
||||
description: "Activate and start using",
|
||||
},
|
||||
];
|
||||
|
||||
function SimPlanCardCompact({
|
||||
plan,
|
||||
@ -209,8 +231,8 @@ export function SimPlansContent({
|
||||
<Skeleton className="h-10 w-80 mx-auto mb-4" />
|
||||
<Skeleton className="h-6 w-96 max-w-full mx-auto" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="bg-card rounded-2xl border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-6 mt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -277,18 +299,30 @@ export function SimPlansContent({
|
||||
const { regularPlans, familyPlans } = getCurrentPlans();
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 pb-16 pt-8">
|
||||
<div className="space-y-8 pb-16">
|
||||
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
|
||||
|
||||
<div className="text-center pt-8 pb-8">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20 mb-6">
|
||||
<Sparkles className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-medium text-primary">Powered by NTT DOCOMO</span>
|
||||
{/* Hero Section */}
|
||||
<div className="text-center py-6">
|
||||
<div
|
||||
className="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-green-500/10 border border-green-500/20 px-4 py-1.5 text-sm text-green-600 dark:text-green-400 font-medium mb-4">
|
||||
<Signal className="h-4 w-4" />
|
||||
NTT Docomo Network
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-foreground mb-4">
|
||||
<h1
|
||||
className="text-display-md md:text-display-lg font-display text-foreground tracking-tight animate-in fade-in slide-in-from-bottom-6 duration-700"
|
||||
style={{ animationDelay: "100ms" }}
|
||||
>
|
||||
Choose Your SIM Plan
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
<p
|
||||
className="text-lg text-muted-foreground mt-3 max-w-2xl mx-auto leading-relaxed animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
Get connected with Japan's best network coverage. Choose eSIM for quick digital
|
||||
delivery or physical SIM shipped to your door.
|
||||
</p>
|
||||
@ -305,66 +339,85 @@ export function SimPlansContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ServiceHighlights features={simFeatures} className="mb-12" />
|
||||
{/* Service Highlights */}
|
||||
<section
|
||||
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
<ServiceHighlights features={simFeatures} />
|
||||
</section>
|
||||
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="inline-flex rounded-xl bg-muted p-1 border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange("data-voice")}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === "data-voice"
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Data + Voice</span>
|
||||
<span className="sm:hidden">All-in</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{plansByType.DataSmsVoice.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange("data-only")}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === "data-only"
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Data Only</span>
|
||||
<span className="sm:hidden">Data</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{plansByType.DataOnly.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange("voice-only")}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === "voice-only"
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Voice Only</span>
|
||||
<span className="sm:hidden">Voice</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{plansByType.VoiceOnly.length}
|
||||
</span>
|
||||
</button>
|
||||
{/* Plans Section Header */}
|
||||
<section
|
||||
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
||||
Select Plan Type
|
||||
</p>
|
||||
<h2 className="text-display-sm font-display text-foreground">Available Plans</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="inline-flex rounded-2xl bg-muted/50 p-1.5 border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange("data-voice")}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-all duration-[var(--cp-duration-normal)] ${
|
||||
activeTab === "data-voice"
|
||||
? "bg-card text-foreground shadow-md"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Data + Voice</span>
|
||||
<span className="sm:hidden">All-in</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
{plansByType.DataSmsVoice.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange("data-only")}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-all duration-[var(--cp-duration-normal)] ${
|
||||
activeTab === "data-only"
|
||||
? "bg-card text-foreground shadow-md"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Data Only</span>
|
||||
<span className="sm:hidden">Data</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
{plansByType.DataOnly.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange("voice-only")}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-all duration-[var(--cp-duration-normal)] ${
|
||||
activeTab === "voice-only"
|
||||
? "bg-card text-foreground shadow-md"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Voice Only</span>
|
||||
<span className="sm:hidden">Voice</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
{plansByType.VoiceOnly.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="plans" className="min-h-[300px]">
|
||||
{regularPlans.length > 0 || familyPlans.length > 0 ? (
|
||||
<div className="space-y-8 animate-in fade-in duration-300">
|
||||
{regularPlans.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{regularPlans.map(plan => (
|
||||
<SimPlanCardCompact key={plan.id} plan={plan} onSelect={onSelectPlan} />
|
||||
))}
|
||||
@ -377,7 +430,7 @@ export function SimPlansContent({
|
||||
<Users className="h-5 w-5 text-success" />
|
||||
<h3 className="text-lg font-semibold text-foreground">Family Discount Plans</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{familyPlans.map(plan => (
|
||||
<SimPlanCardCompact
|
||||
key={plan.id}
|
||||
@ -397,217 +450,33 @@ export function SimPlansContent({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<CollapsibleSection title="Calling & SMS Rates" icon={Phone}>
|
||||
<div className="space-y-6 pt-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-5 h-3 rounded-sm bg-[#BC002D] relative overflow-hidden flex items-center justify-center">
|
||||
<span className="w-2 h-2 rounded-full bg-white" />
|
||||
</span>
|
||||
Domestic (Japan)
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
||||
<div className="text-sm text-muted-foreground mb-1">Voice Calls</div>
|
||||
<div className="text-xl font-bold text-foreground">
|
||||
¥10<span className="text-sm font-normal text-muted-foreground">/30 sec</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
||||
<div className="text-sm text-muted-foreground mb-1">SMS</div>
|
||||
<div className="text-xl font-bold text-foreground">
|
||||
¥3<span className="text-sm font-normal text-muted-foreground">/message</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Incoming calls and SMS are free. Pay-per-use charges billed 5-6 weeks after usage.
|
||||
</p>
|
||||
</div>
|
||||
{/* How It Works */}
|
||||
<HowItWorks steps={simSteps} eyebrow="Getting Started" title="How It Works" />
|
||||
|
||||
<div className="p-4 bg-success/5 border border-success/20 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Phone className="w-5 h-5 text-success mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground">Unlimited Domestic Calling</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add unlimited domestic calls for{" "}
|
||||
<span className="font-semibold text-success">¥3,000/month</span> (available at
|
||||
checkout)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* CTA Section */}
|
||||
<ServiceCTA
|
||||
eyebrow="Get started in minutes"
|
||||
headline="Ready to get connected?"
|
||||
description="Choose your plan and get connected to Japan's best network"
|
||||
primaryAction={{
|
||||
label: "View Plans",
|
||||
href: "#plans",
|
||||
onClick: e => {
|
||||
e.preventDefault();
|
||||
document.getElementById("plans")?.scrollIntoView({ behavior: "smooth" });
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>
|
||||
International calling rates vary by country (¥31-148/30 sec). See{" "}
|
||||
<a
|
||||
href="https://www.docomo.ne.jp/service/world/worldcall/call/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
NTT Docomo's website
|
||||
</a>{" "}
|
||||
for full details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
{/* FAQ Section */}
|
||||
<ServiceFAQ
|
||||
items={simFaqItems}
|
||||
eyebrow="Common Questions"
|
||||
title="Frequently Asked Questions"
|
||||
/>
|
||||
|
||||
<CollapsibleSection title="Fees & Discounts" icon={CircleDollarSign}>
|
||||
<div className="space-y-6 pt-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-3">One-time Fees</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-muted-foreground">Activation Fee</span>
|
||||
<span className="font-medium text-foreground">¥1,500</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-muted-foreground">SIM Replacement (lost/damaged)</span>
|
||||
<span className="font-medium text-foreground">¥1,500</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-muted-foreground">eSIM Re-download</span>
|
||||
<span className="font-medium text-foreground">¥1,500</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-success/5 border border-success/20 rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Family Discount</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-semibold text-success">¥300/month off</span> per additional
|
||||
Voice SIM on your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">All prices exclude 10% consumption tax.</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Important Information & Terms" icon={Info}>
|
||||
<div className="space-y-6 pt-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground mb-3 flex items-center gap-2">
|
||||
<TriangleAlert className="w-4 h-4 text-warning" />
|
||||
Important Notices
|
||||
</h4>
|
||||
<ul className="space-y-2 text-muted-foreground">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground">•</span>
|
||||
<span>
|
||||
ID verification with official documents (name, date of birth, address, photo) is
|
||||
required during checkout.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground">•</span>
|
||||
<span>
|
||||
A compatible unlocked device is required. Check compatibility on our website.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground">•</span>
|
||||
<span>
|
||||
Service may not be available in areas with weak signal. See{" "}
|
||||
<a
|
||||
href="https://www.nttdocomo.co.jp/English/support/area/index.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
NTT Docomo coverage map
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground">•</span>
|
||||
<span>
|
||||
SIM is activated as 4G by default. 5G can be requested via your account portal.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground">•</span>
|
||||
<span>
|
||||
International data roaming is not available. Voice/SMS roaming can be enabled
|
||||
upon request (¥50,000/month limit).
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground mb-3">Contract Terms</h4>
|
||||
<ul className="space-y-2 text-muted-foreground">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground">•</span>
|
||||
<span>
|
||||
<strong className="text-foreground">Minimum contract:</strong> 3 full billing
|
||||
months. First month (sign-up to end of month) is free and doesn't count.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground">•</span>
|
||||
<span>
|
||||
<strong className="text-foreground">Billing cycle:</strong> 1st to end of month.
|
||||
Regular billing starts the 1st of the following month after sign-up.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground">•</span>
|
||||
<span>
|
||||
<strong className="text-foreground">Cancellation:</strong> Can be requested
|
||||
after 3rd month via cancellation form. Monthly fee is incurred in full for
|
||||
cancellation month.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground">•</span>
|
||||
<span>
|
||||
<strong className="text-foreground">SIM return:</strong> SIM card must be
|
||||
returned after service termination.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground mb-3">Additional Options</h4>
|
||||
<ul className="space-y-2 text-muted-foreground">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground">•</span>
|
||||
<span>Call waiting and voice mail available as separate paid options.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground">•</span>
|
||||
<span>Data plan changes are free and take effect next billing month.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground">•</span>
|
||||
<span>
|
||||
Voice plan changes require new SIM issuance and standard policies apply.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Payment is by credit card only. Data service is not suitable for activities
|
||||
requiring continuous large data transfers. See full Terms of Service for complete
|
||||
details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-sm text-muted-foreground">
|
||||
{/* Footer Note */}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
All prices exclude 10% consumption tax.{" "}
|
||||
<a href="#" className="text-primary hover:underline">
|
||||
|
||||
@ -2,30 +2,149 @@
|
||||
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { ArrowRight, ShieldCheck } from "lucide-react";
|
||||
import { ArrowRight, Check, ShieldCheck } from "lucide-react";
|
||||
import type { VpnCatalogProduct } from "@customer-portal/domain/services";
|
||||
import { CardPricing } from "@/features/services/components/base/CardPricing";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface VpnPlanCardProps {
|
||||
plan: VpnCatalogProduct;
|
||||
}
|
||||
|
||||
// Region-specific data
|
||||
const regionData: Record<
|
||||
string,
|
||||
{
|
||||
flag: string;
|
||||
flagAlt: string;
|
||||
location: string;
|
||||
features: string[];
|
||||
accent: string;
|
||||
}
|
||||
> = {
|
||||
"San Francisco": {
|
||||
flag: "🇺🇸",
|
||||
flagAlt: "US Flag",
|
||||
location: "United States",
|
||||
features: [
|
||||
"Access US streaming content",
|
||||
"Optimized for Netflix, Hulu, HBO",
|
||||
"West Coast US server",
|
||||
"Low latency for streaming",
|
||||
],
|
||||
accent: "blue",
|
||||
},
|
||||
London: {
|
||||
flag: "🇬🇧",
|
||||
flagAlt: "UK Flag",
|
||||
location: "United Kingdom",
|
||||
features: [
|
||||
"Access UK streaming content",
|
||||
"Optimized for BBC iPlayer, ITV",
|
||||
"London-based server",
|
||||
"European content access",
|
||||
],
|
||||
accent: "red",
|
||||
},
|
||||
};
|
||||
|
||||
// Fallback for unknown regions
|
||||
const defaultRegionData = {
|
||||
flag: "🌐",
|
||||
flagAlt: "Globe",
|
||||
location: "International",
|
||||
features: [
|
||||
"Secure VPN connection",
|
||||
"Pre-configured router",
|
||||
"Easy plug & play setup",
|
||||
"English support included",
|
||||
],
|
||||
accent: "primary",
|
||||
};
|
||||
|
||||
function getRegionData(planName: string) {
|
||||
// Try to match known regions
|
||||
for (const [region, data] of Object.entries(regionData)) {
|
||||
if (planName.toLowerCase().includes(region.toLowerCase())) {
|
||||
return { region, ...data };
|
||||
}
|
||||
}
|
||||
return { region: planName, ...defaultRegionData };
|
||||
}
|
||||
|
||||
export function VpnPlanCard({ plan }: VpnPlanCardProps) {
|
||||
const region = getRegionData(plan.name);
|
||||
const isUS = region.accent === "blue";
|
||||
const isUK = region.accent === "red";
|
||||
|
||||
return (
|
||||
<AnimatedCard className="p-6 border border-primary/20 hover:border-primary/40 transition-all duration-300 hover:shadow-lg flex flex-col h-full">
|
||||
{/* Header with icon and name */}
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
<AnimatedCard
|
||||
className={cn(
|
||||
"p-6 transition-all duration-300 hover:shadow-lg flex flex-col h-full",
|
||||
"border hover:-translate-y-0.5",
|
||||
isUS && "border-blue-500/30 hover:border-blue-500/50",
|
||||
isUK && "border-red-500/30 hover:border-red-500/50",
|
||||
!isUS && !isUK && "border-primary/20 hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
{/* Header with flag and region */}
|
||||
<div className="flex items-start gap-4 mb-5">
|
||||
{/* Flag/Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-14 h-14 rounded-xl flex items-center justify-center text-2xl",
|
||||
isUS && "bg-blue-500/10",
|
||||
isUK && "bg-red-500/10",
|
||||
!isUS && !isUK && "bg-primary/10"
|
||||
)}
|
||||
role="img"
|
||||
aria-label={region.flagAlt}
|
||||
>
|
||||
{region.flag}
|
||||
</div>
|
||||
|
||||
{/* Title and location */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-foreground">{plan.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{region.location}</p>
|
||||
</div>
|
||||
|
||||
{/* Shield icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"p-2 rounded-lg",
|
||||
isUS && "bg-blue-500/10 text-blue-600",
|
||||
isUK && "bg-red-500/10 text-red-600",
|
||||
!isUS && !isUK && "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-5">
|
||||
<CardPricing monthlyPrice={plan.monthlyPrice} size="lg" alignment="left" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Router rental included</p>
|
||||
</div>
|
||||
|
||||
{/* Features list */}
|
||||
<div className="flex-1 mb-5">
|
||||
<ul className="space-y-2">
|
||||
{region.features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-2 text-sm">
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 mt-0.5 flex-shrink-0",
|
||||
isUS && "text-blue-500",
|
||||
isUK && "text-red-500",
|
||||
!isUS && !isUK && "text-primary"
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
@ -36,7 +155,7 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) {
|
||||
className="w-full"
|
||||
rightIcon={<ArrowRight className="w-4 h-4" />}
|
||||
>
|
||||
Continue to Checkout
|
||||
Select {region.region}
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Wifi,
|
||||
Zap,
|
||||
Languages,
|
||||
FileText,
|
||||
Wrench,
|
||||
Globe,
|
||||
MapPin,
|
||||
Settings,
|
||||
Calendar,
|
||||
Router,
|
||||
} from "lucide-react";
|
||||
import { usePublicInternetCatalog } from "@/features/services/hooks";
|
||||
import type {
|
||||
@ -22,13 +22,15 @@ import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { PublicOfferingCard } from "@/features/services/components/internet/PublicOfferingCard";
|
||||
import type { TierInfo } from "@/features/services/components/internet/PublicOfferingCard";
|
||||
import {
|
||||
ServiceHighlights,
|
||||
HighlightFeature,
|
||||
} from "@/features/services/components/base/ServiceHighlights";
|
||||
import { HowItWorks, type HowItWorksStep } from "@/features/services/components/base/HowItWorks";
|
||||
import { ServiceCTA } from "@/features/services/components/base/ServiceCTA";
|
||||
import { ServiceFAQ, type FAQItem } from "@/features/services/components/base/ServiceFAQ";
|
||||
|
||||
// Types
|
||||
interface GroupedOffering {
|
||||
@ -45,7 +47,7 @@ interface GroupedOffering {
|
||||
}
|
||||
|
||||
// FAQ data
|
||||
const faqItems = [
|
||||
const faqItems: FAQItem[] = [
|
||||
{
|
||||
question: "How can I check if 10Gbps service is available at my address?",
|
||||
answer:
|
||||
@ -78,42 +80,29 @@ const faqItems = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* FAQ Item component with expand/collapse
|
||||
*/
|
||||
function FAQItem({
|
||||
question,
|
||||
answer,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
question: string;
|
||||
answer: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full py-4 flex items-start justify-between gap-3 text-left hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">{question}</span>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="pb-4 pr-8">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{answer}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// How It Works steps - reflects actual flow
|
||||
const internetSteps: HowItWorksStep[] = [
|
||||
{
|
||||
icon: <MapPin className="h-6 w-6" />,
|
||||
title: "Sign Up",
|
||||
description: "Create account with your address",
|
||||
},
|
||||
{
|
||||
icon: <Settings className="h-6 w-6" />,
|
||||
title: "We Check NTT",
|
||||
description: "We verify availability (1-2 days)",
|
||||
},
|
||||
{
|
||||
icon: <Calendar className="h-6 w-6" />,
|
||||
title: "Choose & Order",
|
||||
description: "Pick your plan and complete checkout",
|
||||
},
|
||||
{
|
||||
icon: <Router className="h-6 w-6" />,
|
||||
title: "Get Connected",
|
||||
description: "NTT installation at your home",
|
||||
},
|
||||
];
|
||||
|
||||
export interface PublicInternetPlansContentProps {
|
||||
onCtaClick?: (e: React.MouseEvent) => void;
|
||||
@ -139,7 +128,6 @@ export function PublicInternetPlansContent({
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const defaultCtaPath = `${servicesBasePath}/internet/configure`;
|
||||
const ctaPath = propCtaPath ?? defaultCtaPath;
|
||||
const [openFaqIndex, setOpenFaqIndex] = useState<number | null>(null);
|
||||
|
||||
const internetFeatures: HighlightFeature[] = [
|
||||
{
|
||||
@ -356,31 +344,63 @@ export function PublicInternetPlansContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{/* Back link */}
|
||||
<ServicesBackLink href={servicesBasePath} />
|
||||
|
||||
{/* Hero - Clean and impactful */}
|
||||
<div className="text-center py-4">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-foreground tracking-tight">
|
||||
<div className="text-center py-6">
|
||||
<div
|
||||
className="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-blue-500/10 border border-blue-500/20 px-4 py-1.5 text-sm text-blue-600 dark:text-blue-400 font-medium mb-4">
|
||||
<Wifi className="h-4 w-4" />
|
||||
NTT Optical Fiber
|
||||
</span>
|
||||
</div>
|
||||
<h1
|
||||
className="text-display-md md:text-display-lg font-display text-foreground tracking-tight animate-in fade-in slide-in-from-bottom-6 duration-700"
|
||||
style={{ animationDelay: "100ms" }}
|
||||
>
|
||||
{heroTitle}
|
||||
</h1>
|
||||
<p className="text-base text-muted-foreground mt-2 max-w-lg mx-auto">{heroDescription}</p>
|
||||
<p
|
||||
className="text-lg text-muted-foreground mt-3 max-w-xl mx-auto leading-relaxed animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
{heroDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Service Highlights */}
|
||||
<ServiceHighlights features={internetFeatures} />
|
||||
<section
|
||||
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
<ServiceHighlights features={internetFeatures} />
|
||||
</section>
|
||||
|
||||
{/* Connection types section */}
|
||||
<section
|
||||
className="space-y-4 animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
||||
Choose Your Connection
|
||||
</p>
|
||||
<h2 className="text-display-sm font-display text-foreground">Available Plans</h2>
|
||||
</div>
|
||||
|
||||
{/* Connection types - no extra header text */}
|
||||
<section className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<Skeleton key={i} className="h-28 w-full rounded-xl" />
|
||||
<Skeleton key={i} className="h-32 w-full rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4 cp-stagger-children">
|
||||
{groupedOfferings.map((offering, index) => (
|
||||
<PublicOfferingCard
|
||||
key={offering.offeringType}
|
||||
@ -404,42 +424,23 @@ export function PublicInternetPlansContent({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Final CTA - Polished */}
|
||||
<section className="text-center py-8 mt-4 rounded-2xl bg-gradient-to-br from-primary/5 via-transparent to-info/5 border border-border/50">
|
||||
<div className="inline-flex items-center gap-1.5 text-xs font-medium text-primary mb-3 px-3 py-1 rounded-full bg-primary/10">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Get started in minutes
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-foreground mb-2">Ready to get connected?</h2>
|
||||
<p className="text-sm text-muted-foreground mb-5 max-w-sm mx-auto">
|
||||
Enter your address to see what's available at your location
|
||||
</p>
|
||||
{onCtaClick ? (
|
||||
<Button onClick={onCtaClick} size="lg" rightIcon={<ArrowRight className="h-4 w-4" />}>
|
||||
{ctaLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<Button as="a" href={ctaPath} size="lg" rightIcon={<ArrowRight className="h-4 w-4" />}>
|
||||
{ctaLabel}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
{/* How It Works */}
|
||||
<HowItWorks steps={internetSteps} eyebrow="Getting Started" title="How It Works" />
|
||||
|
||||
{/* CTA Section */}
|
||||
<ServiceCTA
|
||||
eyebrow="Get started in minutes"
|
||||
headline="Ready to get connected?"
|
||||
description="Enter your address to see what's available at your location"
|
||||
primaryAction={{
|
||||
label: ctaLabel,
|
||||
href: ctaPath,
|
||||
onClick: onCtaClick,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section className="py-6">
|
||||
<h2 className="text-lg font-bold text-foreground mb-4">Frequently Asked Questions</h2>
|
||||
<div className="bg-card border border-border rounded-xl px-4">
|
||||
{faqItems.map((item, index) => (
|
||||
<FAQItem
|
||||
key={index}
|
||||
question={item.question}
|
||||
answer={item.answer}
|
||||
isOpen={openFaqIndex === index}
|
||||
onToggle={() => setOpenFaqIndex(openFaqIndex === index ? null : index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<ServiceFAQ items={faqItems} eyebrow="Common Questions" title="Frequently Asked Questions" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,26 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheck, Zap } from "lucide-react";
|
||||
import {
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Wifi,
|
||||
Router,
|
||||
Globe,
|
||||
Headphones,
|
||||
Package,
|
||||
CreditCard,
|
||||
Play,
|
||||
MonitorPlay,
|
||||
} from "lucide-react";
|
||||
import { usePublicVpnCatalog } from "@/features/services/hooks";
|
||||
import { LoadingCard } from "@/components/atoms";
|
||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { VpnPlanCard } from "@/features/services/components/vpn/VpnPlanCard";
|
||||
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
||||
import { ServicesHero } from "@/features/services/components/base/ServicesHero";
|
||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||
import {
|
||||
ServiceHighlights,
|
||||
type HighlightFeature,
|
||||
} from "@/features/services/components/base/ServiceHighlights";
|
||||
import { HowItWorks, type HowItWorksStep } from "@/features/services/components/base/HowItWorks";
|
||||
import { ServiceCTA } from "@/features/services/components/base/ServiceCTA";
|
||||
import { ServiceFAQ, type FAQItem } from "@/features/services/components/base/ServiceFAQ";
|
||||
|
||||
// VPN-specific features for ServiceHighlights
|
||||
const vpnFeatures: HighlightFeature[] = [
|
||||
{
|
||||
icon: <Router className="h-6 w-6" />,
|
||||
title: "Pre-configured Router",
|
||||
description: "Ready to use out of the box — just plug in and connect",
|
||||
highlight: "Plug & play",
|
||||
},
|
||||
{
|
||||
icon: <Globe className="h-6 w-6" />,
|
||||
title: "US & UK Servers",
|
||||
description: "Access content from San Francisco or London regions",
|
||||
highlight: "2 locations",
|
||||
},
|
||||
{
|
||||
icon: <MonitorPlay className="h-6 w-6" />,
|
||||
title: "Streaming Ready",
|
||||
description: "Works with Apple TV, Roku, Amazon Fire, and more",
|
||||
highlight: "All devices",
|
||||
},
|
||||
{
|
||||
icon: <Wifi className="h-6 w-6" />,
|
||||
title: "Separate Network",
|
||||
description: "VPN runs on dedicated WiFi, keep regular internet normal",
|
||||
highlight: "No interference",
|
||||
},
|
||||
{
|
||||
icon: <Package className="h-6 w-6" />,
|
||||
title: "Router Rental Included",
|
||||
description: "No equipment purchase — router rental is part of the plan",
|
||||
highlight: "No hidden costs",
|
||||
},
|
||||
{
|
||||
icon: <Headphones className="h-6 w-6" />,
|
||||
title: "English Support",
|
||||
description: "Full English assistance for setup and troubleshooting",
|
||||
highlight: "Dedicated help",
|
||||
},
|
||||
];
|
||||
|
||||
// Steps for HowItWorks
|
||||
const vpnSteps: HowItWorksStep[] = [
|
||||
{
|
||||
icon: <CreditCard className="h-6 w-6" />,
|
||||
title: "Sign Up",
|
||||
description: "Create your account to get started",
|
||||
},
|
||||
{
|
||||
icon: <Globe className="h-6 w-6" />,
|
||||
title: "Choose Region",
|
||||
description: "Select US (San Francisco) or UK (London)",
|
||||
},
|
||||
{
|
||||
icon: <Package className="h-6 w-6" />,
|
||||
title: "Place Order",
|
||||
description: "Complete checkout and receive router",
|
||||
},
|
||||
{
|
||||
icon: <Play className="h-6 w-6" />,
|
||||
title: "Connect & Stream",
|
||||
description: "Plug in, connect devices, enjoy",
|
||||
},
|
||||
];
|
||||
|
||||
// FAQ items for VPN
|
||||
const vpnFaqItems: FAQItem[] = [
|
||||
{
|
||||
question: "Which streaming services can I access?",
|
||||
answer:
|
||||
"Our VPN establishes a network connection that virtually locates you in the designated server location (US or UK). This can help access region-specific content on services like Netflix, Hulu, BBC iPlayer, and others. However, not all services can be unblocked, and we cannot guarantee access to any specific streaming platform.",
|
||||
},
|
||||
{
|
||||
question: "How fast is the VPN connection?",
|
||||
answer:
|
||||
"The VPN connection speed depends on your existing internet connection. For HD streaming, we recommend at least 10Mbps download speed. The VPN router is optimized for streaming and should provide smooth playback for most content.",
|
||||
},
|
||||
{
|
||||
question: "Can I use multiple devices at once?",
|
||||
answer:
|
||||
"Yes! Any device connected to the VPN router's WiFi network will be routed through the VPN. This includes smart TVs, streaming boxes, gaming consoles, and more. Your regular internet devices can stay on your normal WiFi.",
|
||||
},
|
||||
{
|
||||
question: "What happens if I need help with setup?",
|
||||
answer:
|
||||
"We provide full English support for setup and troubleshooting. The router comes pre-configured, so most users just need to plug it in. If you encounter any issues, our support team can assist via email or phone.",
|
||||
},
|
||||
{
|
||||
question: "Is there a contract or commitment period?",
|
||||
answer:
|
||||
"The VPN service is a monthly rental with no long-term contract required. You can cancel at any time. The one-time activation fee covers initial setup and router preparation.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Public VPN Plans View
|
||||
*
|
||||
* Displays VPN plans for unauthenticated users.
|
||||
* Displays VPN plans for unauthenticated users with full marketing content.
|
||||
*/
|
||||
export function PublicVpnPlansView() {
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const { data, error } = usePublicVpnCatalog();
|
||||
const vpnPlans = data?.plans || [];
|
||||
const activationFees = data?.activationFees || [];
|
||||
// Simple loading check: show skeleton until we have data or an error
|
||||
const isLoading = !data && !error;
|
||||
|
||||
if (isLoading || error) {
|
||||
@ -45,34 +154,77 @@ export function PublicVpnPlansView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 pb-16">
|
||||
<div className="max-w-6xl mx-auto px-4 pb-16 space-y-8">
|
||||
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
|
||||
|
||||
<ServicesHero
|
||||
title="VPN Router Service"
|
||||
description="Secure VPN connections to San Francisco or London using a pre-configured router."
|
||||
>
|
||||
{/* Hero Section */}
|
||||
<div className="text-center py-6">
|
||||
<div
|
||||
className="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-purple-500/10 border border-purple-500/20 px-4 py-1.5 text-sm text-purple-600 dark:text-purple-400 font-medium mb-4">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
VPN Router Service
|
||||
</span>
|
||||
</div>
|
||||
<h1
|
||||
className="text-display-md md:text-display-lg font-display text-foreground tracking-tight animate-in fade-in slide-in-from-bottom-6 duration-700"
|
||||
style={{ animationDelay: "100ms" }}
|
||||
>
|
||||
Stream Content from Abroad
|
||||
</h1>
|
||||
<p
|
||||
className="text-lg text-muted-foreground mt-3 max-w-xl mx-auto leading-relaxed animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
>
|
||||
Access US and UK streaming services using a pre-configured VPN router. No technical setup
|
||||
required.
|
||||
</p>
|
||||
|
||||
{/* Order info banner */}
|
||||
<div className="bg-success-soft border border-success/25 rounded-xl px-4 py-3 max-w-xl mt-4">
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<Zap className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">Order today</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— create account, add payment, and your router ships upon confirmation.
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
className="inline-flex mt-6 animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
<div className="bg-success-soft border border-success/25 rounded-xl px-4 py-3">
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<Zap className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">Order today</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— create account, add payment, and your router ships upon confirmation.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ServicesHero>
|
||||
</div>
|
||||
|
||||
{/* Service Highlights */}
|
||||
<section
|
||||
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
<ServiceHighlights features={vpnFeatures} />
|
||||
</section>
|
||||
|
||||
{/* Plans Section */}
|
||||
{vpnPlans.length > 0 ? (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-bold text-foreground mb-2 text-center">Choose Your Region</h2>
|
||||
<p className="text-sm text-muted-foreground text-center mb-6">
|
||||
Select one region per router rental
|
||||
</p>
|
||||
<section
|
||||
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "500ms" }}
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
||||
Choose Your Region
|
||||
</p>
|
||||
<h2 className="text-display-sm font-display text-foreground">Available Plans</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Select one region per router rental
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
||||
{vpnPlans.map(plan => (
|
||||
@ -85,7 +237,7 @@ export function PublicVpnPlansView() {
|
||||
A one-time activation fee of ¥3,000 applies per router rental. Tax (10%) not included.
|
||||
</AlertBanner>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<ShieldCheck className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
@ -102,25 +254,33 @@ export function PublicVpnPlansView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-card rounded-xl border border-border p-8 mb-8">
|
||||
<h2 className="text-xl font-bold text-foreground mb-6">How It Works</h2>
|
||||
<div className="space-y-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
SonixNet VPN is the easiest way to access video streaming services from overseas on your
|
||||
network media players such as an Apple TV, Roku, or Amazon Fire.
|
||||
</p>
|
||||
<p>
|
||||
A configured Wi-Fi router is provided for rental (no purchase required, no hidden fees).
|
||||
All you need to do is plug the VPN router into your existing internet connection.
|
||||
</p>
|
||||
<p>
|
||||
Connect your network media players to the VPN Wi-Fi network to access content from the
|
||||
selected region. For regular internet usage, use your normal home Wi-Fi.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* How It Works */}
|
||||
<HowItWorks steps={vpnSteps} eyebrow="Simple Setup" title="How It Works" />
|
||||
|
||||
<AlertBanner variant="warning" title="Important Disclaimer" className="mb-8">
|
||||
{/* CTA Section */}
|
||||
<ServiceCTA
|
||||
eyebrow="Get started today"
|
||||
headline="Ready to unlock your content?"
|
||||
description="Choose your region and get your pre-configured router shipped to you"
|
||||
primaryAction={{
|
||||
label: "View Plans",
|
||||
href: "#",
|
||||
onClick: e => {
|
||||
e.preventDefault();
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<ServiceFAQ
|
||||
items={vpnFaqItems}
|
||||
eyebrow="Common Questions"
|
||||
title="Frequently Asked Questions"
|
||||
/>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<AlertBanner variant="warning" title="Important Disclaimer">
|
||||
<p className="text-sm">
|
||||
Content subscriptions are NOT included in the VPN package. Our VPN service establishes a
|
||||
network connection that virtually locates you in the designated server location. Not all
|
||||
@ -128,6 +288,11 @@ export function PublicVpnPlansView() {
|
||||
service quality.
|
||||
</p>
|
||||
</AlertBanner>
|
||||
|
||||
{/* Footer Note */}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<p>All prices exclude 10% consumption tax.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -207,12 +207,12 @@ Historical documents kept for reference:
|
||||
|
||||
## 🏗️ Technology Stack
|
||||
|
||||
**Frontend**: Next.js 15, React 19, Tailwind CSS 4, shadcn/ui, TanStack Query, Zustand
|
||||
**Frontend**: Next.js 16, React 19, Tailwind CSS 4, shadcn/ui, TanStack Query, Zustand
|
||||
|
||||
**Backend**: NestJS 11, Prisma 6, PostgreSQL 17, Redis 7, Pino
|
||||
|
||||
**Integrations**: Salesforce (jsforce + Pub/Sub API), WHMCS, Freebit
|
||||
**Integrations**: Salesforce (jsforce + Pub/Sub API), WHMCS, Freebit, Japan Post
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: December 2025
|
||||
**Last Updated**: January 2026
|
||||
|
||||
@ -110,6 +110,14 @@
|
||||
"./notifications": {
|
||||
"import": "./dist/notifications/index.js",
|
||||
"types": "./dist/notifications/index.d.ts"
|
||||
},
|
||||
"./address": {
|
||||
"import": "./dist/address/index.js",
|
||||
"types": "./dist/address/index.d.ts"
|
||||
},
|
||||
"./address/providers": {
|
||||
"import": "./dist/address/providers/index.js",
|
||||
"types": "./dist/address/providers/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"address/**/*",
|
||||
"auth/**/*",
|
||||
"billing/**/*",
|
||||
"services/**/*",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user