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:
parent
2a40d84691
commit
dd8259e06f
@ -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 };
|
||||
@ -0,0 +1 @@
|
||||
export { SummaryStats, type SummaryStatsProps, type StatItem, type StatTone } from "./SummaryStats";
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -1,2 +1,9 @@
|
||||
export { useOrdersList } from "./useOrdersList";
|
||||
export { useOrderUpdates } from "./useOrderUpdates";
|
||||
export { useOrdersFilter } from "./useOrdersFilter";
|
||||
export type {
|
||||
OrderStatusFilter,
|
||||
OrderTypeFilter,
|
||||
OrdersSummaryStats,
|
||||
UseOrdersFilterResult,
|
||||
} from "./useOrdersFilter";
|
||||
|
||||
149
apps/portal/src/features/orders/hooks/useOrdersFilter.ts
Normal file
149
apps/portal/src/features/orders/hooks/useOrdersFilter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'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;
|
||||
@ -0,0 +1,2 @@
|
||||
export { VpnPlanCard, type VpnPlanCardProps } from "./VpnPlanCard";
|
||||
export { VpnPlansContent } from "./VpnPlansContent";
|
||||
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user