feat: Add SummaryStats component for displaying statistics in cards and inline formats

- Introduced SummaryStats component to standardize the display of statistics across the application.
- Updated SubscriptionsList and SupportCasesView to utilize the new SummaryStats component for better UI consistency.
- Refactored existing statistics display code into the new component, improving maintainability.

feat: Implement OrderDetailSkeleton and OrderProgressTimeline components

- Created OrderDetailSkeleton for loading states in order details view.
- Developed OrderProgressTimeline to visually represent the order status progression.
- Added skeleton loading states for both components to enhance user experience during data fetching.

feat: Enhance orders filtering with useOrdersFilter hook

- Implemented useOrdersFilter hook to manage order filtering logic, including search and status filters.
- Improved filtering capabilities for orders based on various criteria, enhancing user interaction.

feat: Add VpnPlansContent component for VPN service plans display

- Developed VpnPlansContent component to showcase available VPN plans and features.
- Integrated loading and error handling states for better user feedback.
- Included FAQ and How It Works sections to provide users with essential information about the VPN service.

chore: Update index files for new components

- Added exports for new components in their respective index files for easier imports.
This commit is contained in:
barsa 2026-01-19 17:08:28 +09:00
parent 2a40d84691
commit dd8259e06f
16 changed files with 1233 additions and 479 deletions

View File

@ -0,0 +1,116 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/utils/cn";
type StatTone = "neutral" | "primary" | "info" | "success" | "warning" | "muted";
interface StatItem {
icon: ReactNode;
label: string;
value: string | number;
tone?: StatTone;
/** Whether to conditionally show this stat (defaults to true) */
show?: boolean;
}
interface SummaryStatsProps {
items: StatItem[];
variant?: "cards" | "inline";
className?: string;
}
const toneStyles: Record<StatTone, { iconBg: string; iconText: string; valueText: string }> = {
neutral: {
iconBg: "bg-muted/50",
iconText: "text-muted-foreground",
valueText: "text-foreground",
},
primary: {
iconBg: "bg-primary/10",
iconText: "text-primary",
valueText: "text-primary",
},
info: {
iconBg: "bg-info/10",
iconText: "text-info",
valueText: "text-info",
},
success: {
iconBg: "bg-success/10",
iconText: "text-success",
valueText: "text-success",
},
warning: {
iconBg: "bg-warning/10",
iconText: "text-warning",
valueText: "text-warning",
},
muted: {
iconBg: "bg-muted",
iconText: "text-muted-foreground",
valueText: "text-foreground",
},
};
function StatCardItem({ item }: { item: StatItem }) {
const tone = item.tone ?? "neutral";
const styles = toneStyles[tone];
return (
<div className="bg-card rounded-xl border border-border p-5 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-4">
<div
className={cn(
"h-10 w-10 rounded-lg flex items-center justify-center flex-shrink-0",
styles.iconBg
)}
>
<span className={cn("h-5 w-5", styles.iconText)}>{item.icon}</span>
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-muted-foreground">{item.label}</p>
<p className={cn("text-2xl font-bold", styles.valueText)}>{item.value}</p>
</div>
</div>
</div>
);
}
function StatInlineItem({ item }: { item: StatItem }) {
const tone = item.tone ?? "neutral";
const styles = toneStyles[tone];
return (
<div className="flex items-center gap-2">
<span className={cn("h-4 w-4", styles.iconText)}>{item.icon}</span>
<span className="text-muted-foreground">{item.label}</span>
<span className={cn("font-semibold", styles.valueText)}>{item.value}</span>
</div>
);
}
export function SummaryStats({ items, variant = "inline", className }: SummaryStatsProps) {
// Filter out items where show is explicitly false
const visibleItems = items.filter(item => item.show !== false);
if (visibleItems.length === 0) return null;
if (variant === "cards") {
return (
<div className={cn("grid grid-cols-1 md:grid-cols-3 gap-4", className)}>
{visibleItems.map((item, index) => (
<StatCardItem key={index} item={item} />
))}
</div>
);
}
return (
<div className={cn("flex flex-wrap items-center gap-6 px-1 text-sm", className)}>
{visibleItems.map((item, index) => (
<StatInlineItem key={index} item={item} />
))}
</div>
);
}
export type { SummaryStatsProps, StatItem, StatTone };

View File

@ -0,0 +1 @@
export { SummaryStats, type SummaryStatsProps, type StatItem, type StatTone } from "./SummaryStats";

View File

@ -22,6 +22,7 @@ export * from "./ProgressSteps/ProgressSteps";
export * from "./SubCard/SubCard";
export * from "./AnimatedCard/AnimatedCard";
export * from "./ServiceCard/ServiceCard";
export * from "./SummaryStats";
// Loading skeleton molecules
export * from "./LoadingSkeletons";

View File

@ -14,11 +14,15 @@ import { cn } from "@/shared/utils";
export type OrderSummaryLike = OrderSummary & { itemSummary?: string };
export type OrderCardVariant = "standalone" | "list";
export interface OrderCardProps {
order: OrderSummaryLike;
onClick?: () => void;
footer?: ReactNode;
className?: string;
/** Visual variant: "standalone" (default) has border/shadow, "list" works inside divide-y container */
variant?: OrderCardVariant;
}
const STATUS_PILL_VARIANT = {
@ -35,7 +39,13 @@ const SERVICE_ICON_STYLES = {
default: "bg-muted text-muted-foreground border border-border",
} as const;
export function OrderCard({ order, onClick, footer, className }: OrderCardProps) {
export function OrderCard({
order,
onClick,
footer,
className,
variant = "standalone",
}: OrderCardProps) {
const statusDescriptor = deriveOrderStatusDescriptor({
status: order.status,
activationStatus: order.activationStatus ?? "",
@ -77,13 +87,22 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
}
};
const isListVariant = variant === "list";
return (
<article
key={String(order.id)}
className={cn(
"group overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--cp-shadow-1)] transition-all duration-200 focus-visible:outline-none",
"group overflow-hidden bg-card transition-all duration-200 focus-visible:outline-none",
// Standalone variant: full card styling with border, shadow, rounded corners
!isListVariant && "rounded-2xl border border-border shadow-[var(--cp-shadow-1)]",
// List variant: no border/shadow/rounded (used inside divide-y container)
isListVariant && "rounded-none border-0 shadow-none",
// Interactive styles (hover, focus) for both variants
isInteractive &&
!isListVariant &&
"cursor-pointer hover:border-primary/30 hover:shadow-lg hover:-translate-y-0.5 focus-within:border-primary/40 focus-within:ring-2 focus-within:ring-primary/10",
isInteractive && isListVariant && "cursor-pointer hover:bg-muted focus-within:bg-muted",
className
)}
onClick={onClick}
@ -158,7 +177,14 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
</div>
</div>
{footer && (
<div className="border-t border-border bg-muted/30 px-5 sm:px-6 py-3">{footer}</div>
<div
className={cn(
"border-t border-border bg-muted/30 px-5 sm:px-6 py-3",
isListVariant && "border-t-0 bg-transparent"
)}
>
{footer}
</div>
)}
</article>
);

View File

@ -0,0 +1,113 @@
"use client";
import { cn } from "@/shared/utils";
interface OrderDetailSkeletonProps {
className?: string;
}
export function OrderDetailSkeleton({ className }: OrderDetailSkeletonProps) {
return (
<div className={cn("space-y-6", className)}>
{/* Stats Cards Grid Skeleton */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map(index => (
<div
key={index}
className="bg-card rounded-xl border border-border p-4 shadow-[var(--cp-shadow-1)]"
>
<div className="animate-pulse space-y-2">
<div className="h-3 w-16 rounded bg-muted" />
<div className="h-6 w-24 rounded bg-muted" />
</div>
</div>
))}
</div>
{/* Progress Timeline Skeleton */}
<div className="bg-card rounded-xl border border-border p-5 shadow-[var(--cp-shadow-1)]">
<div className="animate-pulse">
<div className="flex items-start justify-between">
{[1, 2, 3, 4].map((_, index) => (
<div key={index} className="flex flex-col items-center flex-1 relative">
<div className="h-8 w-8 rounded-full bg-muted" />
<div className="mt-2 h-3 w-16 rounded bg-muted" />
{index < 3 && (
<div
className="absolute top-4 h-0.5 bg-muted -translate-y-1/2"
style={{ left: "calc(50% + 16px)", width: "calc(100% - 32px)" }}
/>
)}
</div>
))}
</div>
</div>
</div>
{/* Main Content Card Skeleton */}
<div className="rounded-3xl border border-border bg-card shadow-[var(--cp-shadow-1)]">
{/* Header Section */}
<div className="border-b border-border px-6 py-6 sm:px-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between animate-pulse">
{/* Left: Title & Date */}
<div className="flex items-center gap-3">
<div className="h-11 w-11 rounded-xl bg-muted" />
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="h-7 w-40 rounded bg-muted" />
<div className="h-5 w-20 rounded-full bg-muted" />
</div>
<div className="h-4 w-32 rounded bg-muted" />
</div>
</div>
{/* Right: Pricing */}
<div className="flex items-start gap-6">
<div className="text-right space-y-1">
<div className="h-3 w-16 rounded bg-muted" />
<div className="h-8 w-24 rounded bg-muted" />
</div>
<div className="text-right space-y-1">
<div className="h-3 w-16 rounded bg-muted" />
<div className="h-8 w-20 rounded bg-muted" />
</div>
</div>
</div>
</div>
{/* Content Section */}
<div className="px-6 py-6 sm:px-8">
<div className="space-y-6 animate-pulse">
{/* Order Details Label */}
<div className="h-3 w-24 rounded bg-muted" />
{/* Order Items */}
<div className="rounded-xl border border-border overflow-hidden divide-y divide-border">
{[1, 2, 3].map(index => (
<div key={index} className="flex items-center gap-3 px-4 py-4">
<div className="h-8 w-8 rounded-lg bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-5 w-48 rounded bg-muted" />
<div className="h-3 w-16 rounded bg-muted" />
</div>
<div className="text-right space-y-1">
<div className="h-5 w-20 rounded bg-muted" />
<div className="h-3 w-12 rounded bg-muted" />
</div>
</div>
))}
</div>
{/* Info boxes */}
<div className="space-y-4">
<div className="h-16 w-full rounded-lg bg-muted/50" />
<div className="h-20 w-full rounded-lg bg-muted/50" />
</div>
</div>
</div>
</div>
</div>
);
}
export default OrderDetailSkeleton;

View File

@ -0,0 +1,166 @@
"use client";
import { CheckIcon } from "@heroicons/react/24/solid";
import { cn } from "@/shared/utils";
import type { OrderServiceCategory, OrderStatusState } from "@customer-portal/domain/orders";
interface TimelineStep {
id: string;
label: string;
}
interface OrderProgressTimelineProps {
serviceCategory: OrderServiceCategory;
currentState: OrderStatusState;
className?: string;
}
/**
* Timeline steps by service category
*/
const TIMELINE_STEPS: Record<OrderServiceCategory, TimelineStep[]> = {
internet: [
{ id: "submitted", label: "Submitted" },
{ id: "review", label: "Under Review" },
{ id: "scheduled", label: "Scheduled" },
{ id: "active", label: "Active" },
],
sim: [
{ id: "submitted", label: "Submitted" },
{ id: "processing", label: "Processing" },
{ id: "activating", label: "Activating" },
{ id: "active", label: "Active" },
],
vpn: [
{ id: "submitted", label: "Submitted" },
{ id: "processing", label: "Processing" },
{ id: "active", label: "Active" },
],
default: [
{ id: "submitted", label: "Submitted" },
{ id: "processing", label: "Processing" },
{ id: "active", label: "Active" },
],
};
/**
* Map OrderStatusState to timeline step index
*/
function getStepIndex(state: OrderStatusState, serviceCategory: OrderServiceCategory): number {
const steps = TIMELINE_STEPS[serviceCategory];
switch (state) {
case "active":
return steps.length - 1; // Always the last step
case "review":
return serviceCategory === "internet" ? 1 : 1; // "Under Review" for internet
case "scheduled":
return serviceCategory === "internet" ? 2 : 1;
case "activating":
return serviceCategory === "sim" ? 2 : 1;
case "processing":
default:
return 1; // Processing is the second step
}
}
export function OrderProgressTimeline({
serviceCategory,
currentState,
className,
}: OrderProgressTimelineProps) {
const steps = TIMELINE_STEPS[serviceCategory];
const currentStepIndex = getStepIndex(currentState, serviceCategory);
const isComplete = currentState === "active";
return (
<div className={cn("w-full", className)}>
<div className="flex items-start justify-between">
{steps.map((step, index) => {
const isCompleted = isComplete ? true : index < currentStepIndex;
const isCurrent = !isComplete && index === currentStepIndex;
const isUpcoming = !isComplete && index > currentStepIndex;
const isLast = index === steps.length - 1;
return (
<div
key={step.id}
className={cn("flex flex-col items-center flex-1", !isLast && "relative")}
>
{/* Step indicator */}
<div className="relative flex items-center justify-center">
{isCompleted && (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-success text-success-foreground shadow-sm">
<CheckIcon className="h-4 w-4" />
</div>
)}
{isCurrent && (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm animate-pulse">
<div className="h-2.5 w-2.5 rounded-full bg-primary-foreground" />
</div>
)}
{isUpcoming && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-dashed border-muted-foreground/40 bg-card">
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
</div>
)}
</div>
{/* Step label */}
<span
className={cn(
"mt-2 text-xs font-medium text-center",
isCompleted && "text-success",
isCurrent && "text-primary",
isUpcoming && "text-muted-foreground"
)}
>
{step.label}
</span>
{/* Connecting line to next step */}
{!isLast && (
<div
className={cn(
"absolute top-4 left-1/2 w-full h-0.5 -translate-y-1/2",
index < currentStepIndex && "bg-success",
index === currentStepIndex && "bg-gradient-to-r from-primary to-muted",
index > currentStepIndex &&
"border-t-2 border-dashed border-muted-foreground/30"
)}
style={{ left: "calc(50% + 16px)", width: "calc(100% - 32px)" }}
/>
)}
</div>
);
})}
</div>
</div>
);
}
/**
* Skeleton for the progress timeline
*/
export function OrderProgressTimelineSkeleton() {
return (
<div className="w-full">
<div className="flex items-start justify-between">
{[1, 2, 3, 4].map((_, index) => (
<div key={index} className="flex flex-col items-center flex-1 relative">
<div className="h-8 w-8 rounded-full bg-muted animate-pulse" />
<div className="mt-2 h-3 w-16 rounded bg-muted animate-pulse" />
{index < 3 && (
<div
className="absolute top-4 h-0.5 bg-muted animate-pulse -translate-y-1/2"
style={{ left: "calc(50% + 16px)", width: "calc(100% - 32px)" }}
/>
)}
</div>
))}
</div>
</div>
);
}
export default OrderProgressTimeline;

View File

@ -1,2 +1,9 @@
export { useOrdersList } from "./useOrdersList";
export { useOrderUpdates } from "./useOrderUpdates";
export { useOrdersFilter } from "./useOrdersFilter";
export type {
OrderStatusFilter,
OrderTypeFilter,
OrdersSummaryStats,
UseOrdersFilterResult,
} from "./useOrdersFilter";

View File

@ -0,0 +1,149 @@
"use client";
import { useMemo, useState, useCallback } from "react";
import type { OrderSummary } from "@customer-portal/domain/orders";
import { getOrderServiceCategory } from "@customer-portal/domain/orders";
export type OrderStatusFilter = "all" | "pending" | "active" | "processing" | "cancelled";
export type OrderStatusCategory = Exclude<OrderStatusFilter, "all">;
export type OrderTypeFilter = "all" | "internet" | "sim" | "vpn";
export interface OrdersSummaryStats {
total: number;
pending: number;
active: number;
processing: number;
cancelled: number;
}
export interface UseOrdersFilterOptions {
orders: OrderSummary[] | undefined;
}
export interface UseOrdersFilterResult {
// Filter state
searchTerm: string;
setSearchTerm: (value: string) => void;
statusFilter: OrderStatusFilter;
setStatusFilter: (value: OrderStatusFilter) => void;
typeFilter: OrderTypeFilter;
setTypeFilter: (value: OrderTypeFilter) => void;
// Computed values
filteredOrders: OrderSummary[];
stats: OrdersSummaryStats;
hasActiveFilters: boolean;
// Actions
clearFilters: () => void;
}
/**
* Determines the status category for filtering purposes
*/
function getStatusCategory(
status: string | undefined,
activationStatus: string | undefined
): OrderStatusCategory {
// Active: Activated orders
if (activationStatus === "Activated") {
return "active";
}
// Pending: Draft or Pending Review
if (status === "Draft" || status === "Pending Review" || status === "Pending") {
return "pending";
}
// Cancelled
if (status === "Cancelled" || status === "Failed") {
return "cancelled";
}
// Processing: Everything else in progress
if (
activationStatus === "Scheduled" ||
activationStatus === "Activating" ||
status === "Activated"
) {
return "processing";
}
return "processing";
}
export function useOrdersFilter({ orders }: UseOrdersFilterOptions): UseOrdersFilterResult {
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<OrderStatusFilter>("all");
const [typeFilter, setTypeFilter] = useState<OrderTypeFilter>("all");
const hasActiveFilters =
searchTerm.trim() !== "" || statusFilter !== "all" || typeFilter !== "all";
const clearFilters = useCallback(() => {
setSearchTerm("");
setStatusFilter("all");
setTypeFilter("all");
}, []);
const stats = useMemo<OrdersSummaryStats>(() => {
if (!orders || orders.length === 0) {
return { total: 0, pending: 0, active: 0, processing: 0, cancelled: 0 };
}
return orders.reduce(
(acc, order) => {
const category = getStatusCategory(order.status, order.activationStatus);
acc.total++;
acc[category]++;
return acc;
},
{ total: 0, pending: 0, active: 0, processing: 0, cancelled: 0 }
);
}, [orders]);
const filteredOrders = useMemo<OrderSummary[]>(() => {
if (!orders) return [];
return orders.filter(order => {
// Search filter - match order number
if (searchTerm.trim()) {
const orderNumber = order.orderNumber || String(order.id).slice(-8);
if (!orderNumber.toLowerCase().includes(searchTerm.toLowerCase())) {
return false;
}
}
// Status filter
if (statusFilter !== "all") {
const orderStatusCategory = getStatusCategory(order.status, order.activationStatus);
if (orderStatusCategory !== statusFilter) {
return false;
}
}
// Type filter
if (typeFilter !== "all") {
const serviceCategory = getOrderServiceCategory(order.orderType);
if (serviceCategory !== typeFilter) {
return false;
}
}
return true;
});
}, [orders, searchTerm, statusFilter, typeFilter]);
return {
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
typeFilter,
setTypeFilter,
filteredOrders,
stats,
hasActiveFilters,
clearFilters,
};
}

View File

@ -13,6 +13,9 @@ import {
ClockIcon,
Squares2X2Icon,
ExclamationTriangleIcon,
CalendarDaysIcon,
CurrencyYenIcon,
DocumentTextIcon,
} from "@heroicons/react/24/outline";
import { StatusPill } from "@/components/atoms/status-pill";
import { ordersService } from "@/features/orders/api/orders.api";
@ -29,6 +32,8 @@ import {
type OrderDisplayItemCharge,
} from "@/features/orders/utils/order-display";
import { OrderServiceIcon } from "@/features/orders/components/OrderServiceIcon";
import { OrderProgressTimeline } from "@/features/orders/components/OrderProgressTimeline";
import { OrderDetailSkeleton } from "@/features/orders/components/OrderDetailSkeleton";
import type { OrderDetails, OrderUpdateEventPayload } from "@customer-portal/domain/orders";
import { Formatting } from "@customer-portal/domain/toolkit";
import { cn, formatIsoDate } from "@/shared/utils";
@ -121,11 +126,40 @@ const describeCharge = (charge: OrderDisplayItemCharge): string => {
return charge.label.toLowerCase();
};
interface StatCardProps {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
className?: string;
}
function StatCard({ icon, label, value, className }: StatCardProps) {
return (
<div
className={cn(
"bg-card rounded-xl border border-border p-4 shadow-[var(--cp-shadow-1)]",
className
)}
>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted/50 text-muted-foreground flex-shrink-0">
{icon}
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<div className="font-semibold text-foreground">{value}</div>
</div>
</div>
</div>
);
}
export function OrderDetailContainer() {
const params = useParams<{ id: string }>();
const searchParams = useSearchParams();
const [data, setData] = useState<OrderDetails | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const isNewOrder = searchParams.get("status") === "success";
const activeControllerRef = useRef<AbortController | null>(null);
const isMountedRef = useRef(true);
@ -167,6 +201,12 @@ export function OrderDetailContainer() {
return formatted === "Invalid date" ? null : formatted;
}, [data?.createdDate]);
const placedDateShort = useMemo(() => {
if (!data?.createdDate) return "—";
const formatted = formatIsoDate(data.createdDate, { dateStyle: "medium" });
return formatted === "Invalid date" ? "—" : formatted;
}, [data?.createdDate]);
const serviceLabel = useMemo(() => {
switch (serviceCategory) {
case "internet":
@ -197,6 +237,7 @@ export function OrderDetailContainer() {
activeControllerRef.current = controller;
try {
setIsLoading(true);
const order = await ordersService.getOrderById(params.id, { signal: controller.signal });
if (!isMountedRef.current || controller.signal.aborted) {
return;
@ -212,6 +253,9 @@ export function OrderDetailContainer() {
if (activeControllerRef.current === controller) {
activeControllerRef.current = null;
}
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, [params.id]);
@ -276,8 +320,52 @@ export function OrderDetailContainer() {
</div>
)}
{data ? (
<>
{isLoading || !data ? (
<OrderDetailSkeleton />
) : (
<div className="space-y-6">
{/* Stats Cards Section */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
icon={<DocumentTextIcon className="h-5 w-5" />}
label="Order Status"
value={
statusDescriptor ? (
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
) : (
"—"
)
}
/>
<StatCard
icon={<CurrencyYenIcon className="h-5 w-5" />}
label="Monthly"
value={totals.monthlyTotal > 0 ? Formatting.formatCurrency(totals.monthlyTotal) : "—"}
/>
<StatCard
icon={<CurrencyYenIcon className="h-5 w-5" />}
label="One-Time"
value={totals.oneTimeTotal > 0 ? Formatting.formatCurrency(totals.oneTimeTotal) : "—"}
/>
<StatCard
icon={<CalendarDaysIcon className="h-5 w-5" />}
label="Order Date"
value={placedDateShort}
/>
</div>
{/* Progress Timeline Section */}
<div className="bg-card rounded-xl border border-border p-5 shadow-[var(--cp-shadow-1)]">
<h3 className="text-sm font-medium text-muted-foreground mb-4">Order Progress</h3>
{statusDescriptor && (
<OrderProgressTimeline
serviceCategory={serviceCategory}
currentState={statusDescriptor.state}
/>
)}
</div>
{/* Main Content Card */}
<div className="rounded-3xl border border-border bg-card text-card-foreground shadow-[var(--cp-shadow-1)]">
{/* Header Section */}
<div className="border-b border-border bg-card px-6 py-6 sm:px-8">
@ -289,12 +377,7 @@ export function OrderDetailContainer() {
{serviceIcon}
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold text-foreground">{serviceLabel}</h2>
{statusDescriptor && (
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
)}
</div>
<h2 className="text-2xl font-bold text-foreground">{serviceLabel}</h2>
{placedDate && <p className="text-sm text-muted-foreground">{placedDate}</p>}
</div>
</div>
@ -453,10 +536,6 @@ export function OrderDetailContainer() {
</div>
</div>
</div>
</>
) : (
<div className="rounded-2xl border border-dashed border-border bg-muted/40 p-6 text-sm text-muted-foreground">
Loading order details
</div>
)}
</PageLayout>

View File

@ -2,14 +2,28 @@
import { Suspense, useMemo } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import {
ClipboardDocumentListIcon,
ClockIcon,
CheckCircleIcon,
XMarkIcon,
FunnelIcon,
ArrowPathIcon,
} from "@heroicons/react/24/outline";
import { PageLayout } from "@/components/templates/PageLayout";
import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline";
import { AnimatedCard } from "@/components/molecules";
import { AnimatedCard, SummaryStats } from "@/components/molecules";
import type { StatItem } from "@/components/molecules/SummaryStats";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { OrderCard } from "@/features/orders/components/OrderCard";
import { OrderCardSkeleton } from "@/features/orders/components/OrderCardSkeleton";
import { EmptyState } from "@/components/atoms/empty-state";
import { EmptyState, SearchEmptyState } from "@/components/atoms/empty-state";
import { useOrdersList } from "@/features/orders/hooks/useOrdersList";
import {
useOrdersFilter,
type OrderStatusFilter,
type OrderTypeFilter,
} from "@/features/orders/hooks/useOrdersFilter";
import { isApiError } from "@/core/api";
import { Button } from "@/components/atoms/button";
@ -24,10 +38,38 @@ function OrdersSuccessBanner() {
);
}
const STATUS_FILTER_OPTIONS = [
{ value: "all", label: "All Statuses" },
{ value: "pending", label: "Pending" },
{ value: "active", label: "Active" },
{ value: "processing", label: "Processing" },
{ value: "cancelled", label: "Cancelled" },
];
const TYPE_FILTER_OPTIONS = [
{ value: "all", label: "All Types" },
{ value: "internet", label: "Internet" },
{ value: "sim", label: "SIM" },
{ value: "vpn", label: "VPN" },
];
export function OrdersListContainer() {
const router = useRouter();
const { data: orders, isLoading, isError, error, refetch, isFetching } = useOrdersList();
const {
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
typeFilter,
setTypeFilter,
filteredOrders,
stats,
hasActiveFilters,
clearFilters,
} = useOrdersFilter({ orders });
const { errorMessage, showRetry } = useMemo(() => {
if (!isError) {
return { errorMessage: null, showRetry: false };
@ -48,6 +90,37 @@ export function OrdersListContainer() {
return { errorMessage: "We couldn't load your orders right now.", showRetry: true };
}, [error, isError]);
const summaryStatsItems = useMemo<StatItem[]>(
() => [
{
icon: <ClipboardDocumentListIcon className="h-4 w-4" />,
label: "Total",
value: stats.total,
tone: "muted",
},
{
icon: <ClockIcon className="h-4 w-4" />,
label: "Pending",
value: stats.pending,
tone: "info",
},
{
icon: <CheckCircleIcon className="h-4 w-4" />,
label: "Active",
value: stats.active,
tone: "success",
},
{
icon: <ArrowPathIcon className="h-4 w-4" />,
label: "Processing",
value: stats.processing,
tone: "warning",
show: stats.processing > 0,
},
],
[stats]
);
return (
<PageLayout
icon={<ClipboardDocumentListIcon />}
@ -104,16 +177,81 @@ export function OrdersListContainer() {
</AnimatedCard>
);
}
return (
<div className="space-y-4">
{orders.map(order => (
<OrderCard
key={String(order.id)}
order={order}
onClick={() => router.push(`/account/orders/${order.id}`)}
/>
))}
</div>
<>
{/* Summary Stats */}
<SummaryStats variant="inline" items={summaryStatsItems} />
{/* Search & Filters */}
<SearchFilterBar
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Search by order number..."
filterValue={statusFilter}
onFilterChange={value => setStatusFilter(value as OrderStatusFilter)}
filterOptions={STATUS_FILTER_OPTIONS}
filterLabel="Filter by status"
>
{/* Type filter as additional child */}
<div className="relative">
<select
value={typeFilter}
onChange={event => setTypeFilter(event.target.value as OrderTypeFilter)}
className="block w-36 pl-3 pr-8 py-2.5 text-sm border border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary rounded-lg appearance-none bg-card text-foreground shadow-sm cursor-pointer transition-colors"
aria-label="Filter by type"
>
{TYPE_FILTER_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none">
<FunnelIcon className="h-4 w-4 text-muted-foreground" />
</div>
</div>
{/* Clear filters button */}
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
>
<XMarkIcon className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span>
</button>
)}
</SearchFilterBar>
{/* Showing X of Y count */}
{hasActiveFilters && (
<p className="text-sm text-muted-foreground">
Showing {filteredOrders.length} of {orders.length} orders
</p>
)}
{/* Orders List */}
{filteredOrders.length > 0 ? (
<div className="border border-border rounded-xl bg-card divide-y divide-border overflow-hidden shadow-[var(--cp-shadow-1)]">
{filteredOrders.map(order => (
<OrderCard
key={String(order.id)}
order={order}
variant="list"
onClick={() => router.push(`/account/orders/${order.id}`)}
/>
))}
</div>
) : (
<AnimatedCard className="p-8" variant="static">
<SearchEmptyState
searchTerm={searchTerm || "filters"}
onClearSearch={clearFilters}
/>
</AnimatedCard>
)}
</>
);
})()}
</PageLayout>

View File

@ -0,0 +1,290 @@
"use client";
import { ShieldCheck, Zap, CreditCard, Play, Globe, Package, ArrowLeft } from "lucide-react";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import type { VpnCatalogProduct } from "@customer-portal/domain/services";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
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";
import { VpnPlanCard } from "./VpnPlanCard";
import { VPN_FEATURES } from "@/features/services/utils";
// 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.",
},
];
interface VpnPlansContentProps {
variant: "public" | "account";
plans: VpnCatalogProduct[];
activationFees: VpnCatalogProduct[];
isLoading: boolean;
error: unknown;
}
export function VpnPlansContent({
variant,
plans,
activationFees,
isLoading,
error,
}: VpnPlansContentProps) {
const servicesBasePath = useServicesBasePath();
// Convert VPN_FEATURES to the HighlightFeature type expected by ServiceHighlights
const vpnFeatures: HighlightFeature[] = VPN_FEATURES;
if (isLoading) {
return (
<div className="max-w-6xl mx-auto px-4 pb-16 pt-8">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<div className="text-center mb-12 pt-8">
<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 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="bg-card rounded-2xl border border-border p-6">
<div className="flex items-start gap-4 mb-5">
<Skeleton className="h-14 w-14 rounded-xl" />
<div className="flex-1">
<Skeleton className="h-6 w-32 mb-2" />
<Skeleton className="h-4 w-24" />
</div>
</div>
<div className="mb-5 space-y-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-4 w-28" />
</div>
<div className="space-y-2 mb-6">
{Array.from({ length: 4 }).map((_, j) => (
<Skeleton key={j} className="h-4 w-full" />
))}
</div>
<Skeleton className="h-10 w-full rounded-md" />
</div>
))}
</div>
</div>
);
}
if (error) {
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred";
return (
<div className="max-w-6xl mx-auto px-4 pb-16 pt-8">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-8 text-center mt-8">
<div className="text-destructive font-medium text-lg mb-2">Failed to load VPN plans</div>
<div className="text-destructive/80 text-sm mb-6">{errorMessage}</div>
<Button as="a" href={servicesBasePath} leftIcon={<ArrowLeft className="w-4 h-4" />}>
Back to Services
</Button>
</div>
</div>
);
}
return (
<div className="space-y-8 pb-16">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
{/* 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 - public variant only */}
{variant === "public" && (
<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>
)}
</div>
{/* Service Highlights */}
<section>
<ServiceHighlights features={vpnFeatures} />
</section>
{/* Plans Section */}
{plans.length > 0 ? (
<section
id="plans"
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">
{plans.map(plan => (
<VpnPlanCard key={plan.id} plan={plan} />
))}
</div>
{activationFees.length > 0 && (
<AlertBanner variant="info" className="mt-6 max-w-4xl mx-auto" title="Activation Fee">
A one-time activation fee of ¥3,000 applies per router rental. Tax (10%) not included.
</AlertBanner>
)}
</section>
) : (
<div className="text-center py-12">
<ShieldCheck className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">No VPN Plans Available</h3>
<p className="text-muted-foreground mb-6">
We couldn&apos;t find any VPN plans available at this time.
</p>
<ServicesBackLink
href={servicesBasePath}
label="Back to Services"
align="center"
className="mt-4 mb-0"
/>
</div>
)}
{/* How It Works */}
<HowItWorks steps={vpnSteps} eyebrow="Simple Setup" title="How It Works" />
{/* CTA Section - public variant only */}
{variant === "public" && (
<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: "#plans",
onClick: e => {
e.preventDefault();
document.getElementById("plans")?.scrollIntoView({ 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
services can be unblocked. We do not guarantee access to any specific website or streaming
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>
);
}
export default VpnPlansContent;

View File

@ -0,0 +1,2 @@
export { VpnPlanCard, type VpnPlanCardProps } from "./VpnPlanCard";
export { VpnPlansContent } from "./VpnPlansContent";

View File

@ -1,242 +1,29 @@
"use client";
import { ShieldCheck, Zap, CreditCard, Play, Globe, Package } from "lucide-react";
import { usePublicVpnCatalog } from "@/features/services/hooks";
import { VPN_FEATURES } from "@/features/services/utils";
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 { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { ServiceHighlights } 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";
// 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.",
},
];
import { VpnPlansContent } from "@/features/services/components/vpn/VpnPlansContent";
/**
* Public VPN Plans View
*
* Displays VPN plans for unauthenticated users with full marketing content.
* Thin wrapper that provides data to VpnPlansContent with variant="public".
* Uses public catalog hook for unauthenticated users.
*/
export function PublicVpnPlansView() {
const servicesBasePath = useServicesBasePath();
const { data, error } = usePublicVpnCatalog();
const vpnPlans = data?.plans || [];
const activationFees = data?.activationFees || [];
const isLoading = !data && !error;
if (isLoading || error) {
return (
<div className="max-w-6xl mx-auto px-4">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<AsyncBlock
isLoading={isLoading}
error={error}
loadingText="Loading VPN plans..."
variant="page"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{Array.from({ length: 4 }).map((_, index) => (
<LoadingCard key={index} className="h-64" />
))}
</div>
</AsyncBlock>
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-4 pb-16 space-y-8">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
{/* 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="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>
</div>
{/* Service Highlights - uses cp-stagger-children internally */}
<section>
<ServiceHighlights features={VPN_FEATURES} />
</section>
{/* Plans Section */}
{vpnPlans.length > 0 ? (
<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 => (
<VpnPlanCard key={plan.id} plan={plan} />
))}
</div>
{activationFees.length > 0 && (
<AlertBanner variant="info" className="mt-6 max-w-4xl mx-auto" title="Activation Fee">
A one-time activation fee of ¥3,000 applies per router rental. Tax (10%) not included.
</AlertBanner>
)}
</section>
) : (
<div className="text-center py-12">
<ShieldCheck className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">No VPN Plans Available</h3>
<p className="text-muted-foreground mb-6">
We couldn&apos;t find any VPN plans available at this time.
</p>
<ServicesBackLink
href={servicesBasePath}
label="Back to Services"
align="center"
className="mt-4 mb-0"
/>
</div>
)}
{/* How It Works */}
<HowItWorks steps={vpnSteps} eyebrow="Simple Setup" title="How It Works" />
{/* 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" });
},
}}
<div className="max-w-6xl mx-auto px-4">
<VpnPlansContent
variant="public"
plans={vpnPlans}
activationFees={activationFees}
isLoading={isLoading}
error={error}
/>
{/* 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
services can be unblocked. We do not guarantee access to any specific website or streaming
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

@ -1,139 +1,29 @@
"use client";
import { PageLayout } from "@/components/templates/PageLayout";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import { useAccountVpnCatalog } 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 { VpnPlansContent } from "@/features/services/components/vpn/VpnPlansContent";
/**
* Account VPN Plans View
*
* Thin wrapper that provides data to VpnPlansContent with variant="account".
* Uses authenticated catalog hook for account-specific pricing.
*/
export function VpnPlansView() {
const servicesBasePath = useServicesBasePath();
const { data, error } = useAccountVpnCatalog();
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) {
return (
<div className="min-h-screen bg-slate-50">
<PageLayout
title="VPN Plans"
description="Loading plans..."
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="max-w-6xl mx-auto px-4">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<AsyncBlock
isLoading={isLoading}
error={error}
loadingText="Loading VPN plans..."
variant="page"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{Array.from({ length: 4 }).map((_, index) => (
<LoadingCard key={index} className="h-64" />
))}
</div>
</AsyncBlock>
</div>
</PageLayout>
</div>
);
}
return (
<div className="min-h-screen bg-slate-50">
<PageLayout
title="VPN Router Rental"
description="Secure VPN router rental"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="max-w-6xl mx-auto px-4 pb-16">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<ServicesHero
title="SonixNet VPN Router Service"
description="Fast and secure VPN connections to San Francisco or London using a pre-configured router."
/>
{vpnPlans.length > 0 ? (
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2 text-center">Available Plans</h2>
<p className="text-gray-600 text-center mb-6">(One region per router)</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{vpnPlans.map(plan => (
<VpnPlanCard key={plan.id} plan={plan} />
))}
</div>
{activationFees.length > 0 && (
<AlertBanner
variant="info"
className="mt-6 max-w-4xl mx-auto"
title="Activation Fee"
>
A one-time activation fee of 3000 JPY is incurred separately for each rental unit.
Tax (10%) not included.
</AlertBanner>
)}
</div>
) : (
<div className="text-center py-12">
<ShieldCheckIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No VPN Plans Available</h3>
<p className="text-gray-600 mb-6">
We couldn&apos;t find any VPN plans available at this time.
</p>
<ServicesBackLink
href={servicesBasePath}
label="Back to Services"
align="center"
className="mt-4 mb-0"
/>
</div>
)}
<div className="bg-white rounded-xl border border-gray-200 p-8 mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6">How It Works</h2>
<div className="space-y-4 text-gray-700">
<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 will need to do is to plug the VPN router into your existing internet
connection.
</p>
<p>
Then you can connect your network media players to the VPN Wi-Fi network, to connect
to the VPN server.
</p>
<p>
For daily Internet usage that does not require a VPN, we recommend connecting to
your regular home Wi-Fi.
</p>
</div>
</div>
<AlertBanner variant="warning" title="Important Disclaimer" className="mb-8">
*1: Content subscriptions are NOT included in the SonixNet VPN package. Our VPN service
will establish a network connection that virtually locates you in the designated server
location, then you will sign up for the streaming services of your choice. Not all
services/websites can be unblocked. Assist Solutions does not guarantee or bear any
responsibility over the unblocking of any websites or the quality of the
streaming/browsing.
</AlertBanner>
</div>
</PageLayout>
<div className="max-w-6xl mx-auto px-4">
<VpnPlansContent
variant="account"
plans={vpnPlans}
activationFees={activationFees}
isLoading={isLoading}
error={error}
/>
</div>
);
}

View File

@ -2,7 +2,8 @@
import { useState, useMemo } from "react";
import { Button } from "@/components/atoms/button";
import { ErrorBoundary } from "@/components/molecules";
import { ErrorBoundary, SummaryStats } from "@/components/molecules";
import type { StatItem } from "@/components/molecules/SummaryStats";
import { PageLayout } from "@/components/templates/PageLayout";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
import {
@ -93,41 +94,32 @@ export function SubscriptionsListContainer() {
>
<ErrorBoundary>
{stats && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-card rounded-xl border border-border p-5 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-lg bg-success/10 flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-success" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Active</p>
<p className="text-2xl font-bold text-foreground">{stats.active}</p>
</div>
</div>
</div>
<div className="bg-card rounded-xl border border-border p-5 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Completed</p>
<p className="text-2xl font-bold text-foreground">{stats.completed}</p>
</div>
</div>
</div>
<div className="bg-card rounded-xl border border-border p-5 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center">
<XCircle className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Cancelled</p>
<p className="text-2xl font-bold text-foreground">{stats.cancelled}</p>
</div>
</div>
</div>
</div>
<SummaryStats
variant="cards"
className="mb-6"
items={
[
{
icon: <CheckCircle className="h-5 w-5" />,
label: "Active",
value: stats.active,
tone: "success",
},
{
icon: <CheckCircle className="h-5 w-5" />,
label: "Completed",
value: stats.completed,
tone: "primary",
},
{
icon: <XCircle className="h-5 w-5" />,
label: "Cancelled",
value: stats.cancelled,
tone: "muted",
},
] satisfies StatItem[]
}
/>
)}
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">

View File

@ -4,17 +4,19 @@ import { useDeferredValue, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
ChatBubbleLeftRightIcon,
MagnifyingGlassIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
ClockIcon,
ChevronRightIcon,
TicketIcon,
XMarkIcon,
FunnelIcon,
} from "@heroicons/react/24/outline";
import type { StatItem } from "@/components/molecules/SummaryStats";
import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
import { PageLayout } from "@/components/templates/PageLayout";
import { AnimatedCard } from "@/components/molecules";
import { AnimatedCard, SummaryStats } from "@/components/molecules";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
import { Button } from "@/components/atoms";
import { EmptyState, SearchEmptyState } from "@/components/atoms/empty-state";
import { useSupportCases } from "@/features/support/hooks/useSupportCases";
@ -100,66 +102,57 @@ export function SupportCasesView() {
</Button>
}
>
{/* Summary Strip */}
<div className="flex flex-wrap items-center gap-6 px-1 text-sm">
<div className="flex items-center gap-2">
<ChatBubbleLeftRightIcon className="h-4 w-4 text-muted-foreground/70" />
<span className="text-muted-foreground">Total</span>
<span className="font-semibold text-foreground">{summary.total}</span>
</div>
<div className="flex items-center gap-2">
<ClockIcon className="h-4 w-4 text-primary" />
<span className="text-muted-foreground">Open</span>
<span className="font-semibold text-primary">{summary.open}</span>
</div>
{summary.highPriority > 0 && (
<div className="flex items-center gap-2">
<ExclamationTriangleIcon className="h-4 w-4 text-warning" />
<span className="text-muted-foreground">High Priority</span>
<span className="font-semibold text-warning">{summary.highPriority}</span>
</div>
)}
<div className="flex items-center gap-2">
<CheckCircleIcon className="h-4 w-4 text-success" />
<span className="text-muted-foreground">Resolved</span>
<span className="font-semibold text-success">{summary.resolved}</span>
</div>
</div>
{/* Summary Stats */}
<SummaryStats
variant="inline"
items={
[
{
icon: <ChatBubbleLeftRightIcon className="h-4 w-4" />,
label: "Total",
value: summary.total,
tone: "muted",
},
{
icon: <ClockIcon className="h-4 w-4" />,
label: "Open",
value: summary.open,
tone: "primary",
},
{
icon: <ExclamationTriangleIcon className="h-4 w-4" />,
label: "High Priority",
value: summary.highPriority,
tone: "warning",
show: summary.highPriority > 0,
},
{
icon: <CheckCircleIcon className="h-4 w-4" />,
label: "Resolved",
value: summary.resolved,
tone: "success",
},
] satisfies StatItem[]
}
/>
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row gap-3">
{/* Search */}
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-4 w-4 text-muted-foreground" />
</div>
<input
type="text"
placeholder="Search by case number or subject..."
value={searchTerm}
onChange={event => setSearchTerm(event.target.value)}
className="block w-full pl-9 pr-3 py-2 border border-border rounded-lg text-sm bg-card shadow-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary transition-colors"
/>
</div>
{/* Filters */}
<div className="flex gap-2">
<select
value={statusFilter}
onChange={event => setStatusFilter(event.target.value)}
className="appearance-none pl-3 pr-8 py-2 border border-border rounded-lg text-sm bg-card shadow-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary transition-colors cursor-pointer"
>
{statusFilterOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<SearchFilterBar
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Search by case number or subject..."
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterOptions={statusFilterOptions}
filterLabel="Filter by status"
>
{/* Priority filter as additional child */}
<div className="relative">
<select
value={priorityFilter}
onChange={event => setPriorityFilter(event.target.value)}
className="appearance-none pl-3 pr-8 py-2 border border-border rounded-lg text-sm bg-card shadow-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary transition-colors cursor-pointer"
className="block w-40 pl-3 pr-8 py-2.5 text-sm border border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary rounded-lg appearance-none bg-card text-foreground shadow-sm cursor-pointer transition-colors"
aria-label="Filter by priority"
>
{priorityFilterOptions.map(option => (
<option key={option.value} value={option.value}>
@ -167,18 +160,22 @@ export function SupportCasesView() {
</option>
))}
</select>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
>
<XMarkIcon className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span>
</button>
)}
<div className="absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none">
<FunnelIcon className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</div>
{/* Clear filters button */}
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
>
<XMarkIcon className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span>
</button>
)}
</SearchFilterBar>
{/* Cases List */}
{cases.length > 0 ? (