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:
barsa 2026-01-13 18:19:58 +09:00
parent bde9f706ce
commit dc32e7aa07
36 changed files with 2640 additions and 1122 deletions

264
CLAUDE.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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&apos;ll check what&apos;s
available at your address.
Good news: All types have the same monthly price. We&apos;ll check what&apos;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>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@
}
},
"include": [
"address/**/*",
"auth/**/*",
"billing/**/*",
"services/**/*",