diff --git a/CLAUDE.md b/CLAUDE.md index 276d0dd2..5c833cfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,196 +1,212 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Instructions for Claude Code working in this repository. -## Project Overview +--- -Customer portal with BFF (Backend for Frontend) architecture. Users can self-register, manage subscriptions, view/pay invoices, and manage support cases. +## Agent Behavior -**Systems of Record:** +**Do NOT** run long-running processes without explicit permission: -- **WHMCS**: Billing, subscriptions, invoices, authoritative address storage -- **Salesforce**: CRM (Accounts, Contacts, Cases), order address snapshots -- **Portal**: Next.js UI + NestJS BFF +- `pnpm dev`, `pnpm dev:start`, `npm start`, `npm run dev` +- Any command that starts servers, watchers, or blocking processes -## Development Commands +**Always ask first** before: + +- Starting development servers +- Running Docker containers +- Executing build watchers +- Any process that won't terminate on its own + +**Before coding**: Read relevant docs. Never guess endpoint behavior or payload shapes. + +--- + +## Quick Reference ```bash -# Start development environment (PostgreSQL + Redis via Docker) -pnpm dev:start - -# Start both frontend and backend with hot reload -pnpm dev - -# Build domain package (required before running apps if domain changed) +# Build domain (required after domain changes) pnpm domain:build -# Type checking +# Type check & lint pnpm type-check - -# Linting pnpm lint -pnpm lint:fix -# Database commands -pnpm db:migrate # Run migrations -pnpm db:studio # Open Prisma Studio GUI -pnpm db:generate # Generate Prisma client +# Database +pnpm db:migrate +pnpm db:generate -# Run tests -pnpm test # All packages -pnpm --filter @customer-portal/bff test # BFF only - -# Stop services -pnpm dev:stop +# Tests +pnpm test +pnpm --filter @customer-portal/bff test ``` -**Access points:** +**Ports**: Frontend :3000 | Backend :4000/api | Prisma Studio :5555 -- Frontend: http://localhost:3000 -- Backend API: http://localhost:4000/api -- Prisma Studio: http://localhost:5555 +--- ## Architecture -### Monorepo Structure +### Monorepo ``` apps/ -├── portal/ # Next.js 15 frontend (React 19, Tailwind, shadcn/ui) -└── bff/ # NestJS 11 backend (Prisma, BullMQ, Zod validation) +├── portal/ # Next.js 15 (React 19, Tailwind, shadcn/ui) +└── bff/ # NestJS 11 (Prisma, BullMQ, Zod) packages/ -└── domain/ # Unified domain layer (contracts, schemas, provider mappers) +└── domain/ # Shared contracts, schemas, provider mappers ``` -### Three-Layer Boundary (Non-Negotiable) +### Three-Layer Boundary -| Layer | Location | Purpose | -| ------ | ------------------ | ------------------------------------------------------------------------------ | -| Domain | `packages/domain/` | Shared contracts, Zod validation, provider mappers. Framework-agnostic. | -| BFF | `apps/bff/` | HTTP boundary, orchestration, external integrations (Salesforce/WHMCS/Freebit) | -| Portal | `apps/portal/` | UI layer. Pages are thin wrappers over feature modules. | +| Layer | Location | Purpose | +| ------ | ------------------ | -------------------------------------------------- | +| Domain | `packages/domain/` | Contracts, Zod schemas, provider mappers | +| BFF | `apps/bff/` | HTTP boundary, orchestration, integrations | +| Portal | `apps/portal/` | UI layer, thin route wrappers over feature modules | -### Domain Package Structure +### Systems of Record -Each domain module follows this pattern: +- **WHMCS**: Billing, subscriptions, invoices, authoritative addresses +- **Salesforce**: CRM (Accounts, Contacts, Cases), order snapshots +- **Portal**: UI + BFF orchestration + +--- + +## Import Rules (ESLint Enforced) + +```typescript +// Allowed (Portal + BFF) +import type { Invoice } from "@customer-portal/domain/billing"; +import { invoiceSchema } from "@customer-portal/domain/billing"; +import { Formatting } from "@customer-portal/domain/toolkit"; + +// Allowed (BFF only) +import { Whmcs } from "@customer-portal/domain/billing/providers"; + +// Forbidden everywhere +import { Billing } from "@customer-portal/domain"; // root import +import { Invoice } from "@customer-portal/domain/billing/contract"; // deep import + +// Forbidden in Portal +import { Whmcs } from "@customer-portal/domain/billing/providers"; // provider adapters +``` + +--- + +## Domain Package + +Each module follows: ``` packages/domain// ├── 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// +├── 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//schema.ts` -- Derive types from schemas: `export type X = z.infer` +- Schemas in domain: `packages/domain//schema.ts` +- Derive types: `export type X = z.infer` - 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/` | diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index 18bda10c..527181e7 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -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, diff --git a/apps/bff/src/integrations/integrations.module.ts b/apps/bff/src/integrations/integrations.module.ts index 65334405..e4bd7d9e 100644 --- a/apps/bff/src/integrations/integrations.module.ts +++ b/apps/bff/src/integrations/integrations.module.ts @@ -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 {} diff --git a/apps/bff/src/integrations/salesforce/salesforce.service.ts b/apps/bff/src/integrations/salesforce/salesforce.service.ts index 8a865f19..c0718678 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.service.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.service.ts @@ -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 { + await this.accountService.updateContactAddress(accountId, address); + } + // === ORDER METHODS (For Order Provisioning) === async updateOrder(orderData: Partial & { Id: string }): Promise { diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts index ba2cbc75..5675b6c7 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts @@ -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 { + 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 = { + 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 & { 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; +} diff --git a/apps/bff/src/modules/services/application/internet-eligibility.service.ts b/apps/bff/src/modules/services/application/internet-eligibility.service.ts index 3d47c81a..28f5a896 100644 --- a/apps/bff/src/modules/services/application/internet-eligibility.service.ts +++ b/apps/bff/src/modules/services/application/internet-eligibility.service.ts @@ -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, diff --git a/apps/bff/src/modules/users/application/users.facade.ts b/apps/bff/src/modules/users/application/users.facade.ts index b76b75d8..5b2bf7c6 100644 --- a/apps/bff/src/modules/users/application/users.facade.ts +++ b/apps/bff/src/modules/users/application/users.facade.ts @@ -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
{ + return this.profileService.updateBilingualAddress(userId, address); + } + async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise { return this.profileService.updateProfile(userId, update); } diff --git a/apps/bff/src/modules/users/infra/user-profile.service.ts b/apps/bff/src/modules/users/infra/user-profile.service.ts index 5069ed06..b811010b 100644 --- a/apps/bff/src/modules/users/infra/user-profile.service.ts +++ b/apps/bff/src/modules/users/infra/user-profile.service.ts @@ -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
{ + 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 { const validId = parseUuidOrThrow(userId, "Invalid user ID format"); const parsed = updateCustomerProfileRequestSchema.parse(update); diff --git a/apps/bff/src/modules/users/users.controller.ts b/apps/bff/src/modules/users/users.controller.ts index cec11e9c..bb426102 100644 --- a/apps/bff/src/modules/users/users.controller.ts +++ b/apps/bff/src/modules/users/users.controller.ts @@ -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
{ + 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 diff --git a/apps/portal/src/app/(public)/(site)/services/page.tsx b/apps/portal/src/app/(public)/(site)/services/page.tsx index f3d8cb70..3c4d2bde 100644 --- a/apps/portal/src/app/(public)/(site)/services/page.tsx +++ b/apps/portal/src/app/(public)/(site)/services/page.tsx @@ -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 ( -
- {/* Header */} -
-

+ +
+ {badge && ( + + {badge} + + )} + +
+
+ {icon} +
+
+

{title}

+ {price && ( + + From {price} + + )} +
+
+ +

{description}

+ +
+ Learn more + +
+
+ + ); +} + +export default function ServicesPage() { + return ( +
+ {/* Hero */} +
+ + + Full English Support + + +

Our Services

-

- From high-speed internet to onsite support, we provide comprehensive solutions for your - home and business. -

-
- +

+ Connectivity and support solutions for Japan's international community. +

+ + + {/* Value Props - Compact */} +
+
+ + One provider, all services +
+
+ + English support +
+
+ + No hidden fees +
+
+ + {/* All Services - Clean Grid */} +
+ } + title="Internet" + description="NTT Optical Fiber for homes and apartments. Speeds up to 10Gbps with professional installation." + price="¥3,200/mo" + accentColor="blue" + /> + + } + 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" + /> + + } + title="VPN Router" + description="Access US & UK streaming content with a pre-configured router. Simple plug-and-play." + price="¥2,500/mo" + accentColor="purple" + /> + + } + title="Business" + description="Enterprise solutions for offices and commercial spaces. Dedicated support and SLAs." + accentColor="orange" + /> + + } + title="Onsite Support" + description="Professional technicians visit your location for setup, troubleshooting, and maintenance." + accentColor="cyan" + /> + + } + title="TV" + description="Streaming TV packages with international channels. Watch content from home countries." + accentColor="pink" + /> +
+ + {/* CTA */} +
+

Need help choosing?

+

+ Our bilingual team can help you find the right solution. +

+ +
+ + Contact Us + + + + + 0120-660-470 (Toll Free) + +
+

); } diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css index 90a14dc2..8f1e158d 100644 --- a/apps/portal/src/app/globals.css +++ b/apps/portal/src/app/globals.css @@ -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); diff --git a/apps/portal/src/core/api/index.ts b/apps/portal/src/core/api/index.ts index eacbad34..3920dbdf 100644 --- a/apps/portal/src/core/api/index.ts +++ b/apps/portal/src/core/api/index.ts @@ -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; diff --git a/apps/portal/src/features/account/api/account.api.ts b/apps/portal/src/features/account/api/account.api.ts index b12f5c4e..1ee316c1 100644 --- a/apps/portal/src/features/account/api/account.api.ts +++ b/apps/portal/src/features/account/api/account.api.ts @@ -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
(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
("/api/me/address/bilingual", { + body: sanitized, + }); + const data = getDataOrThrow
(response, "Failed to update address"); + return addressSchema.parse(data); + }, }; diff --git a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx index d8104db7..80ea6c78 100644 --- a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx @@ -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 = [ , - , + , , ]; diff --git a/apps/portal/src/features/landing-page/components/CtaButton.tsx b/apps/portal/src/features/landing-page/components/CtaButton.tsx new file mode 100644 index 00000000..ab9d72f8 --- /dev/null +++ b/apps/portal/src/features/landing-page/components/CtaButton.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/portal/src/features/landing-page/components/FeaturedServiceCard.tsx b/apps/portal/src/features/landing-page/components/FeaturedServiceCard.tsx new file mode 100644 index 00000000..6ad3e58e --- /dev/null +++ b/apps/portal/src/features/landing-page/components/FeaturedServiceCard.tsx @@ -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 ( + +
+ {/* Background pattern */} +
+ +
+ {/* Content */} +
+
+
+ +
+ + Featured Service + +
+ +

{title}

+ +

{description}

+ + {/* Highlights */} +
+ {highlights.map((highlight, idx) => ( + + {highlight} + + ))} +
+
+ + {/* Pricing & CTA */} +
+ {startingPrice && ( +
+ Starting from +
{startingPrice}
+ {priceNote && {priceNote}} +
+ )} + +
+ View Plans + +
+
+
+
+ + ); +} diff --git a/apps/portal/src/features/landing-page/components/ServiceCard.tsx b/apps/portal/src/features/landing-page/components/ServiceCard.tsx new file mode 100644 index 00000000..bd2faef0 --- /dev/null +++ b/apps/portal/src/features/landing-page/components/ServiceCard.tsx @@ -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 ( + +
+
+ +
+

{title}

+
+ + ); + } + + // Standard / Featured variant + return ( + +
+ {/* Icon */} +
+ +
+ + {/* Content */} +

{title}

+ {description && ( +

{description}

+ )} + + {/* Highlight badge */} + {highlight && ( + + {highlight} + + )} + + {/* Link indicator */} +
+ Learn more + +
+
+ + ); +} diff --git a/apps/portal/src/features/landing-page/components/TrustBadge.tsx b/apps/portal/src/features/landing-page/components/TrustBadge.tsx new file mode 100644 index 00000000..13343caf --- /dev/null +++ b/apps/portal/src/features/landing-page/components/TrustBadge.tsx @@ -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 ( + + {Icon && } + {text} + + ); +} diff --git a/apps/portal/src/features/landing-page/components/TrustIndicators.tsx b/apps/portal/src/features/landing-page/components/TrustIndicators.tsx new file mode 100644 index 00000000..05195068 --- /dev/null +++ b/apps/portal/src/features/landing-page/components/TrustIndicators.tsx @@ -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 ( +
+ {stats.map((stat, idx) => ( +
+ + {stat.value} + {stat.label} +
+ ))} +
+ ); + } + + return ( +
+ {stats.map((stat, idx) => ( +
+
+ +
+
+
{stat.value}
+
{stat.label}
+
+
+ ))} +
+ ); +} diff --git a/apps/portal/src/features/landing-page/components/index.ts b/apps/portal/src/features/landing-page/components/index.ts index 1a1f0171..59719d83 100644 --- a/apps/portal/src/features/landing-page/components/index.ts +++ b/apps/portal/src/features/landing-page/components/index.ts @@ -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"; diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index c0b1174e..3d362b37 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -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 ( -
- {/* ===== HERO SECTION ===== */} -
- +
+ {/* ===== HERO SECTION - CENTERED ===== */} +
+ {/* Decorative gradient blurs */} +
+
+
-
- {/* Left: Content */} -
- {/* Badge */} -
- - - - - - Reliable Connectivity in Japan - -
- - {/* Headline */} -
-

- A One Stop Solution - for Your IT Needs -

-
- - {/* Subtitle */} -

- Serving Japan's international community with reliable, English-supported - internet, mobile, and VPN solutions. -

- - {/* CTAs */} -
- - Browse Services - - - - Contact Us - -
-
- - {/* Right: Floating Glass Cards (hidden on mobile) */} -
- - - -
-
-
- - {/* ===== VALUE PROPS SECTION ===== */} -
-
-

- Our Concept -

-

Why customers choose us

-
- -
- - - -
-
- - {/* ===== SERVICES BENTO GRID ===== */} -
-
-

- Our Services -

-

What we offer

-
- - {/* Bento Grid */} -
- {/* Internet - Featured (spans 3 cols, 2 rows) */} - - - {/* SIM - Medium (spans 2 cols) */} - - - {/* VPN - Small (spans 1 col) */} - - - {/* Business - Small (spans 1 col) */} - - - {/* Onsite - Small (spans 1 col) */} - - - {/* TV - Medium (spans 4 cols) */} - -
-
- - {/* ===== CTA SECTION ===== */} -
- {/* Background layers */} -
-
- - {/* Decorative floating rings */} -
+ + {/* ===== WHY CHOOSE US ===== */} +
+
+

+ Why Choose Us +

+

+ Built for the international community +

+

+ We understand the unique challenges of living in Japan as an expat. +

+
+ +
+ {concepts.map((concept, idx) => ( +
+ {/* Gradient hover effect */} +
+ +
+
+ +
+

+ {concept.title} +

+

{concept.description}

+
+
+ ))} +
+
+ + {/* ===== OUR SERVICES ===== */} +
+
+

+ Our Services +

+

+ Everything you need to stay connected +

+

+ From high-speed internet to mobile plans, we've got you covered. +

+
+ + {/* Services Grid - 3x2 */} +
+ {services.map((service, idx) => ( + + ))} +
+ +
+ + + Explore all services + + +
+
+ + {/* ===== FINAL CTA ===== */} +
+ {/* Gradient background */} +
+ + {/* Pattern overlay */} +
+ +
+

+ Ready to get connected? +

+

+ Contact us anytime — our bilingual team is here to help you find the right solution for + your needs. +

+ +
+ + Contact Us + + + + Browse Services +
diff --git a/apps/portal/src/features/services/components/base/HowItWorks.tsx b/apps/portal/src/features/services/components/base/HowItWorks.tsx new file mode 100644 index 00000000..42dfaa80 --- /dev/null +++ b/apps/portal/src/features/services/components/base/HowItWorks.tsx @@ -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 Header */} +
+

+ {eyebrow} +

+

{title}

+
+ + {/* Steps Container */} +
+ {/* Connection line - visible on md+ */} +
+ ); +} + +export default HowItWorks; diff --git a/apps/portal/src/features/services/components/base/ServiceCTA.tsx b/apps/portal/src/features/services/components/base/ServiceCTA.tsx new file mode 100644 index 00000000..d6c28d32 --- /dev/null +++ b/apps/portal/src/features/services/components/base/ServiceCTA.tsx @@ -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 = , + headline, + description, + primaryAction, + secondaryAction, + className, +}: ServiceCTAProps) { + return ( +
+ {/* Background */} +
+
+ + {/* Decorative rings */} +
+ ); +} + +export default ServiceCTA; diff --git a/apps/portal/src/features/services/components/base/ServiceFAQ.tsx b/apps/portal/src/features/services/components/base/ServiceFAQ.tsx new file mode 100644 index 00000000..badae1cb --- /dev/null +++ b/apps/portal/src/features/services/components/base/ServiceFAQ.tsx @@ -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 ( +
+ +
+
+

{answer}

+
+
+
+ ); +} + +/** + * 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(defaultOpenIndex); + + if (items.length === 0) return null; + + return ( +
+ {/* Section Header */} +
+

+ {eyebrow} +

+

{title}

+
+ + {/* FAQ Container */} +
+ {items.map((item, index) => ( + setOpenIndex(openIndex === index ? null : index)} + /> + ))} +
+
+ ); +} + +export default ServiceFAQ; diff --git a/apps/portal/src/features/services/components/base/ServiceHighlights.tsx b/apps/portal/src/features/services/components/base/ServiceHighlights.tsx index c35d1fa1..f2329387 100644 --- a/apps/portal/src/features/services/components/base/ServiceHighlights.tsx +++ b/apps/portal/src/features/services/components/base/ServiceHighlights.tsx @@ -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 ( -
+
-
+
{icon}
{highlight && ( - + {highlight} @@ -33,15 +56,67 @@ function HighlightItem({ icon, title, description, highlight }: HighlightFeature ); } +function CompactHighlightItem({ icon, title, description, highlight }: HighlightFeature) { + return ( +
+
+ {icon} +
+
+
+

{title}

+ {highlight && ( + + {highlight} + + )} +
+

{description}

+
+
+ ); +} + /** * 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 ( +
+ {features.map((feature, index) => ( + + ))} +
+ ); + } + return ( -
+
{features.map((feature, index) => ( ))} diff --git a/apps/portal/src/features/services/components/base/ServicesHero.tsx b/apps/portal/src/features/services/components/base/ServicesHero.tsx index d6c98ed9..08d8f875 100644 --- a/apps/portal/src/features/services/components/base/ServicesHero.tsx +++ b/apps/portal/src/features/services/components/base/ServicesHero.tsx @@ -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 = { @@ -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 (
- {eyebrow ?
{eyebrow}
: null} -

{title}

-

{description}

- {children ?
{children}
: null} + {eyebrow ? ( +
+ {eyebrow} +
+ ) : null} +

+ {title} +

+

+ {description} +

+ {children ? ( +
+ {children} +
+ ) : null}
); } diff --git a/apps/portal/src/features/services/components/base/index.ts b/apps/portal/src/features/services/components/base/index.ts index 41c36e86..657a30b8 100644 --- a/apps/portal/src/features/services/components/base/index.ts +++ b/apps/portal/src/features/services/components/base/index.ts @@ -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"; diff --git a/apps/portal/src/features/services/components/common/ServicesGrid.tsx b/apps/portal/src/features/services/components/common/ServicesGrid.tsx index e55965a3..9bf7d9c3 100644 --- a/apps/portal/src/features/services/components/common/ServicesGrid.tsx +++ b/apps/portal/src/features/services/components/common/ServicesGrid.tsx @@ -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 ( + +
+ {/* Icon */} +
+ {icon} +
+ + {/* Content */} +

{title}

+

+ {description} +

+ + {/* Price or Highlight */} +
+
+ {price && ( + + From {price} + + )} + {highlight && ( + + {highlight} + + )} + {!price && !highlight && ( + Learn more + )} +
+
+ View Plans + +
+
+
+ + ); +} + export function ServicesGrid({ basePath = "/services" }: ServicesGridProps) { return ( -
- {/* Internet */} - -
-
- -
-

- Internet -

-

- NTT fiber with speeds up to 10Gbps and professional installation support. Fast and - reliable connectivity. -

-
- View Plans{" "} - -
-
- +
+ {/* Internet - Featured */} + } + title="Internet" + description="NTT fiber with speeds up to 10Gbps. Professional installation and full support included." + price="¥4,000/mo" + featured + /> - {/* SIM & eSIM */} - -
-
- -
-

- SIM & eSIM -

-

- Data, voice & SMS on NTT Docomo's nationwide network. Available as physical SIM or - instant eSIM. -

-
- View Plans{" "} - -
-
- + {/* SIM */} + } + title="SIM & eSIM" + description="Data, voice & SMS on NTT Docomo's network. Physical SIM or instant eSIM." + highlight="No contract" + /> {/* VPN */} - -
-
- -
-

- VPN -

-

- Access US/UK content with a pre-configured router. Easy plug & play setup for seamless - streaming. -

-
- View Plans{" "} - -
-
- + } + title="VPN" + description="Access US/UK content with pre-configured routers. Simple setup." + price="¥2,500/mo" + /> - {/* Business Solutions */} - -
-
- -
-

- Business Solutions -

-

- Dedicated Internet Access (DIA), Office LAN setup, Data Center services, and - onsite/remote tech support. -

-
- Learn more{" "} - -
-
- + {/* Business */} + } + title="Business" + description="DIA, Office LAN, and enterprise connectivity solutions." + /> - {/* Onsite Support */} - -
-
- -
-

- Onsite Support -

-

- Professional technical support at your residence or office. Network setup, device - configuration, and troubleshooting. -

-
- Learn more{" "} - -
-
- + {/* Onsite */} + } + title="Onsite" + description="Setup and troubleshooting at your location by certified technicians." + /> - {/* TV Services */} - -
-
- -
-

- TV Services -

-

- Satellite, Cable, and Optical Fiber TV services. We arrange subscriptions for major - Japanese TV providers. -

-
- Learn more{" "} - -
-
- + {/* TV */} + } + title="TV Services" + description="Satellite, cable, and optical fiber TV with international packages." + />
); } diff --git a/apps/portal/src/features/services/components/internet/PublicOfferingCard.tsx b/apps/portal/src/features/services/components/internet/PublicOfferingCard.tsx index b7603b34..df1584b3 100644 --- a/apps/portal/src/features/services/components/internet/PublicOfferingCard.tsx +++ b/apps/portal/src/features/services/components/internet/PublicOfferingCard.tsx @@ -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 ( -
+
- +
+ +

Why does speed vary by building?

@@ -63,12 +71,12 @@ function ConnectionTypeInfo({ onClose }: { onClose: () => void }) {
-
+

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

- Good news: All types have the same monthly price (¥4,800~). We'll check what's - available at your address. + Good news: All types have the same monthly price. We'll check what's available + at your address.

); } +/** + * Tier card component + */ +function TierCard({ tier }: { tier: TierInfo }) { + const styles = tierStyles[tier.tier]; + const isGold = tier.tier === "Gold"; + + return ( +
+ {/* Recommended badge for Gold */} + {isGold && ( +
+ + + Popular + +
+ )} + + {/* Header */} +
+ {tier.tier} +
+ + {/* Price */} +
+
+ + ¥{tier.monthlyPrice.toLocaleString()} + + /mo +
+ {tier.pricingNote && ( + {tier.pricingNote} + )} +
+ + {/* Description */} +

{tier.description}

+ + {/* Features */} +
    + {tier.features.slice(0, 3).map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+
+ ); +} + /** * 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 (
{/* Header - Always visible */} {/* Expanded content - Tier pricing shown inline */} {isExpanded && ( -
+
{/* Connection type info button (for Apartment) */} {showConnectionInfo && !showInfo && ( )} @@ -202,65 +282,26 @@ export function PublicOfferingCard({ )} {/* Tier cards - 3 columns on desktop */} -
+
{tiers.map(tier => ( -
- {/* Header */} -
- - {tier.tier} - -
- - {/* Price - Always visible */} -
-
- - ¥{tier.monthlyPrice.toLocaleString()} - - /mo - {tier.pricingNote && ( - {tier.pricingNote} - )} -
-
- - {/* Description */} -

{tier.description}

- - {/* Features */} -
    - {tier.features.slice(0, 3).map((feature, index) => ( -
  • - - {feature} -
  • - ))} -
-
+ ))}
{/* Footer with setup fee and CTA */} -
-

+

+

+ ¥{setupFee.toLocaleString()} one-time setup {" "} (or 12/24-month installment)

{onCtaClick ? ( - ) : ( - )} diff --git a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx index 32340ae6..f20a9f70 100644 --- a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx +++ b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx @@ -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 ( -
- -
-
{children}
-
-
- ); -} +// How It Works steps for SIM - no eligibility check needed +const simSteps: HowItWorksStep[] = [ + { + icon: , + title: "Choose Plan", + description: "Pick your data and voice options", + }, + { + icon: , + title: "Sign Up & Verify", + description: "Create account and upload ID", + }, + { + icon: , + title: "Order & Receive", + description: "eSIM instant or SIM shipped", + }, + { + icon: , + title: "Get Connected", + description: "Activate and start using", + }, +]; function SimPlanCardCompact({ plan, @@ -209,8 +231,8 @@ export function SimPlansContent({
-
- {Array.from({ length: 4 }).map((_, i) => ( +
+ {Array.from({ length: 6 }).map((_, i) => (
@@ -277,18 +299,30 @@ export function SimPlansContent({ const { regularPlans, familyPlans } = getCurrentPlans(); return ( -
+
-
-
- - Powered by NTT DOCOMO + {/* Hero Section */} +
+
+ + + NTT Docomo Network +
-

+

Choose Your SIM Plan

-

+

Get connected with Japan's best network coverage. Choose eSIM for quick digital delivery or physical SIM shipped to your door.

@@ -305,66 +339,85 @@ export function SimPlansContent({
)} - + {/* Service Highlights */} +
+ +
-
-
- - - + {/* Plans Section Header */} +
+
+

+ Select Plan Type +

+

Available Plans

-
+ +
+
+ + + +
+
+
{regularPlans.length > 0 || familyPlans.length > 0 ? (
{regularPlans.length > 0 && ( -
+
{regularPlans.map(plan => ( ))} @@ -377,7 +430,7 @@ export function SimPlansContent({

Family Discount Plans

-
+
{familyPlans.map(plan => ( -
- -
-
-

- - - - Domestic (Japan) -

-
-
-
Voice Calls
-
- ¥10/30 sec -
-
-
-
SMS
-
- ¥3/message -
-
-
-

- Incoming calls and SMS are free. Pay-per-use charges billed 5-6 weeks after usage. -

-
+ {/* How It Works */} + -
-
- -
-

Unlimited Domestic Calling

-

- Add unlimited domestic calls for{" "} - ¥3,000/month (available at - checkout) -

-
-
-
+ {/* CTA Section */} + { + e.preventDefault(); + document.getElementById("plans")?.scrollIntoView({ behavior: "smooth" }); + }, + }} + /> -
-

- International calling rates vary by country (¥31-148/30 sec). See{" "} - - NTT Docomo's website - {" "} - for full details. -

-
-
-
+ {/* FAQ Section */} + - -
-
-

One-time Fees

-
-
- Activation Fee - ¥1,500 -
-
- SIM Replacement (lost/damaged) - ¥1,500 -
-
- eSIM Re-download - ¥1,500 -
-
-
- -
-

Family Discount

-

- ¥300/month off per additional - Voice SIM on your account -

-
- -

All prices exclude 10% consumption tax.

-
-
- - -
-
-

- - Important Notices -

-
    -
  • - - - ID verification with official documents (name, date of birth, address, photo) is - required during checkout. - -
  • -
  • - - - A compatible unlocked device is required. Check compatibility on our website. - -
  • -
  • - - - Service may not be available in areas with weak signal. See{" "} - - NTT Docomo coverage map - - . - -
  • -
  • - - - SIM is activated as 4G by default. 5G can be requested via your account portal. - -
  • -
  • - - - International data roaming is not available. Voice/SMS roaming can be enabled - upon request (¥50,000/month limit). - -
  • -
-
- -
-

Contract Terms

-
    -
  • - - - Minimum contract: 3 full billing - months. First month (sign-up to end of month) is free and doesn't count. - -
  • -
  • - - - Billing cycle: 1st to end of month. - Regular billing starts the 1st of the following month after sign-up. - -
  • -
  • - - - Cancellation: Can be requested - after 3rd month via cancellation form. Monthly fee is incurred in full for - cancellation month. - -
  • -
  • - - - SIM return: SIM card must be - returned after service termination. - -
  • -
-
- -
-

Additional Options

-
    -
  • - - Call waiting and voice mail available as separate paid options. -
  • -
  • - - Data plan changes are free and take effect next billing month. -
  • -
  • - - - Voice plan changes require new SIM issuance and standard policies apply. - -
  • -
-
- -
-

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

-
-
-
-
- -
+ {/* Footer Note */} +

All prices exclude 10% consumption tax.{" "} diff --git a/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx b/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx index 036233ea..77659a11 100644 --- a/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx +++ b/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx @@ -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 ( - - {/* Header with icon and name */} -

-
- + + {/* Header with flag and region */} +
+ {/* Flag/Icon */} +
+ {region.flag}
+ + {/* Title and location */}

{plan.name}

+

{region.location}

+
+ + {/* Shield icon */} +
+
{/* Pricing */} -
+
+

Router rental included

+
+ + {/* Features list */} +
+
    + {region.features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
{/* Action Button */} @@ -36,7 +155,7 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) { className="w-full" rightIcon={} > - Continue to Checkout + Select {region.region}
diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index 12a24205..74111fd2 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -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 ( -
- - {isOpen && ( -
-

{answer}

-
- )} -
- ); -} +// How It Works steps - reflects actual flow +const internetSteps: HowItWorksStep[] = [ + { + icon: , + title: "Sign Up", + description: "Create account with your address", + }, + { + icon: , + title: "We Check NTT", + description: "We verify availability (1-2 days)", + }, + { + icon: , + title: "Choose & Order", + description: "Pick your plan and complete checkout", + }, + { + icon: , + 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(null); const internetFeatures: HighlightFeature[] = [ { @@ -356,31 +344,63 @@ export function PublicInternetPlansContent({ } return ( -
+
{/* Back link */} {/* Hero - Clean and impactful */} -
-

+
+
+ + + NTT Optical Fiber + +
+

{heroTitle}

-

{heroDescription}

+

+ {heroDescription} +

{/* Service Highlights */} - +
+ +
+ + {/* Connection types section */} +
+
+

+ Choose Your Connection +

+

Available Plans

+
- {/* Connection types - no extra header text */} -
{isLoading ? ( -
+
{[1, 2, 3].map(i => ( - + ))}
) : ( -
+
{groupedOfferings.map((offering, index) => ( - {/* Final CTA - Polished */} -
-
- - Get started in minutes -
-

Ready to get connected?

-

- Enter your address to see what's available at your location -

- {onCtaClick ? ( - - ) : ( - - )} -
+ {/* How It Works */} + + + {/* CTA Section */} + {/* FAQ Section */} -
-

Frequently Asked Questions

-
- {faqItems.map((item, index) => ( - setOpenFaqIndex(openFaqIndex === index ? null : index)} - /> - ))} -
-
+
); } diff --git a/apps/portal/src/features/services/views/PublicVpnPlans.tsx b/apps/portal/src/features/services/views/PublicVpnPlans.tsx index b3b775a3..e5fa4b91 100644 --- a/apps/portal/src/features/services/views/PublicVpnPlans.tsx +++ b/apps/portal/src/features/services/views/PublicVpnPlans.tsx @@ -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: , + title: "Pre-configured Router", + description: "Ready to use out of the box — just plug in and connect", + highlight: "Plug & play", + }, + { + icon: , + title: "US & UK Servers", + description: "Access content from San Francisco or London regions", + highlight: "2 locations", + }, + { + icon: , + title: "Streaming Ready", + description: "Works with Apple TV, Roku, Amazon Fire, and more", + highlight: "All devices", + }, + { + icon: , + title: "Separate Network", + description: "VPN runs on dedicated WiFi, keep regular internet normal", + highlight: "No interference", + }, + { + icon: , + title: "Router Rental Included", + description: "No equipment purchase — router rental is part of the plan", + highlight: "No hidden costs", + }, + { + icon: , + title: "English Support", + description: "Full English assistance for setup and troubleshooting", + highlight: "Dedicated help", + }, +]; + +// Steps for HowItWorks +const vpnSteps: HowItWorksStep[] = [ + { + icon: , + title: "Sign Up", + description: "Create your account to get started", + }, + { + icon: , + title: "Choose Region", + description: "Select US (San Francisco) or UK (London)", + }, + { + icon: , + title: "Place Order", + description: "Complete checkout and receive router", + }, + { + icon: , + 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 ( -
+
- + {/* Hero Section */} +
+
+ + + VPN Router Service + +
+

+ Stream Content from Abroad +

+

+ Access US and UK streaming services using a pre-configured VPN router. No technical setup + required. +

+ {/* Order info banner */} -
-
- -

- Order today - - {" "} - — create account, add payment, and your router ships upon confirmation. - -

+
+
+
+ +

+ Order today + + {" "} + — create account, add payment, and your router ships upon confirmation. + +

+
- +
+ {/* Service Highlights */} +
+ +
+ + {/* Plans Section */} {vpnPlans.length > 0 ? ( -
-

Choose Your Region

-

- Select one region per router rental -

+
+
+

+ Choose Your Region +

+

Available Plans

+

+ Select one region per router rental +

+
{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. )} -
+
) : (
@@ -102,25 +254,33 @@ export function PublicVpnPlansView() {
)} -
-

How It Works

-
-

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

-

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

-

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

-
-
+ {/* How It Works */} + - + {/* CTA Section */} + { + e.preventDefault(); + window.scrollTo({ top: 0, behavior: "smooth" }); + }, + }} + /> + + {/* FAQ Section */} + + + {/* Disclaimer */} +

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.

+ + {/* Footer Note */} +
+

All prices exclude 10% consumption tax.

+
); } diff --git a/docs/README.md b/docs/README.md index b3e841f7..c8970136 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/packages/domain/package.json b/packages/domain/package.json index 041c060d..88e44815 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -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": { diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json index 7ba8c14f..d8abf246 100644 --- a/packages/domain/tsconfig.json +++ b/packages/domain/tsconfig.json @@ -15,6 +15,7 @@ } }, "include": [ + "address/**/*", "auth/**/*", "billing/**/*", "services/**/*",