From dd8259e06ff5878ec3fe45a96d2ebd4e920e55d8 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 19 Jan 2026 17:08:28 +0900 Subject: [PATCH] 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. --- .../molecules/SummaryStats/SummaryStats.tsx | 116 +++++++ .../molecules/SummaryStats/index.ts | 1 + apps/portal/src/components/molecules/index.ts | 1 + .../features/orders/components/OrderCard.tsx | 32 +- .../orders/components/OrderDetailSkeleton.tsx | 113 +++++++ .../components/OrderProgressTimeline.tsx | 166 ++++++++++ .../portal/src/features/orders/hooks/index.ts | 7 + .../features/orders/hooks/useOrdersFilter.ts | 149 +++++++++ .../src/features/orders/views/OrderDetail.tsx | 103 ++++++- .../src/features/orders/views/OrdersList.tsx | 162 +++++++++- .../components/vpn/VpnPlansContent.tsx | 290 ++++++++++++++++++ .../features/services/components/vpn/index.ts | 2 + .../services/views/PublicVpnPlans.tsx | 233 +------------- .../src/features/services/views/VpnPlans.tsx | 140 +-------- .../subscriptions/views/SubscriptionsList.tsx | 64 ++-- .../support/views/SupportCasesView.tsx | 133 ++++---- 16 files changed, 1233 insertions(+), 479 deletions(-) create mode 100644 apps/portal/src/components/molecules/SummaryStats/SummaryStats.tsx create mode 100644 apps/portal/src/components/molecules/SummaryStats/index.ts create mode 100644 apps/portal/src/features/orders/components/OrderDetailSkeleton.tsx create mode 100644 apps/portal/src/features/orders/components/OrderProgressTimeline.tsx create mode 100644 apps/portal/src/features/orders/hooks/useOrdersFilter.ts create mode 100644 apps/portal/src/features/services/components/vpn/VpnPlansContent.tsx create mode 100644 apps/portal/src/features/services/components/vpn/index.ts diff --git a/apps/portal/src/components/molecules/SummaryStats/SummaryStats.tsx b/apps/portal/src/components/molecules/SummaryStats/SummaryStats.tsx new file mode 100644 index 00000000..7d7ef341 --- /dev/null +++ b/apps/portal/src/components/molecules/SummaryStats/SummaryStats.tsx @@ -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 = { + 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 ( +
+
+
+ {item.icon} +
+
+

{item.label}

+

{item.value}

+
+
+
+ ); +} + +function StatInlineItem({ item }: { item: StatItem }) { + const tone = item.tone ?? "neutral"; + const styles = toneStyles[tone]; + + return ( +
+ {item.icon} + {item.label} + {item.value} +
+ ); +} + +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 ( +
+ {visibleItems.map((item, index) => ( + + ))} +
+ ); + } + + return ( +
+ {visibleItems.map((item, index) => ( + + ))} +
+ ); +} + +export type { SummaryStatsProps, StatItem, StatTone }; diff --git a/apps/portal/src/components/molecules/SummaryStats/index.ts b/apps/portal/src/components/molecules/SummaryStats/index.ts new file mode 100644 index 00000000..414c4662 --- /dev/null +++ b/apps/portal/src/components/molecules/SummaryStats/index.ts @@ -0,0 +1 @@ +export { SummaryStats, type SummaryStatsProps, type StatItem, type StatTone } from "./SummaryStats"; diff --git a/apps/portal/src/components/molecules/index.ts b/apps/portal/src/components/molecules/index.ts index 1f64544e..aaca352a 100644 --- a/apps/portal/src/components/molecules/index.ts +++ b/apps/portal/src/components/molecules/index.ts @@ -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"; diff --git a/apps/portal/src/features/orders/components/OrderCard.tsx b/apps/portal/src/features/orders/components/OrderCard.tsx index 88b03ec4..034262cd 100644 --- a/apps/portal/src/features/orders/components/OrderCard.tsx +++ b/apps/portal/src/features/orders/components/OrderCard.tsx @@ -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 (
{footer && ( -
{footer}
+
+ {footer} +
)}
); diff --git a/apps/portal/src/features/orders/components/OrderDetailSkeleton.tsx b/apps/portal/src/features/orders/components/OrderDetailSkeleton.tsx new file mode 100644 index 00000000..3acd7d43 --- /dev/null +++ b/apps/portal/src/features/orders/components/OrderDetailSkeleton.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { cn } from "@/shared/utils"; + +interface OrderDetailSkeletonProps { + className?: string; +} + +export function OrderDetailSkeleton({ className }: OrderDetailSkeletonProps) { + return ( +
+ {/* Stats Cards Grid Skeleton */} +
+ {[1, 2, 3, 4].map(index => ( +
+
+
+
+
+
+ ))} +
+ + {/* Progress Timeline Skeleton */} +
+
+
+ {[1, 2, 3, 4].map((_, index) => ( +
+
+
+ {index < 3 && ( +
+ )} +
+ ))} +
+
+
+ + {/* Main Content Card Skeleton */} +
+ {/* Header Section */} +
+
+ {/* Left: Title & Date */} +
+
+
+
+
+
+
+
+
+
+ + {/* Right: Pricing */} +
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Content Section */} +
+
+ {/* Order Details Label */} +
+ + {/* Order Items */} +
+ {[1, 2, 3].map(index => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ + {/* Info boxes */} +
+
+
+
+
+
+
+
+ ); +} + +export default OrderDetailSkeleton; diff --git a/apps/portal/src/features/orders/components/OrderProgressTimeline.tsx b/apps/portal/src/features/orders/components/OrderProgressTimeline.tsx new file mode 100644 index 00000000..36d22a19 --- /dev/null +++ b/apps/portal/src/features/orders/components/OrderProgressTimeline.tsx @@ -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 = { + 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 ( +
+
+ {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 ( +
+ {/* Step indicator */} +
+ {isCompleted && ( +
+ +
+ )} + {isCurrent && ( +
+
+
+ )} + {isUpcoming && ( +
+
+
+ )} +
+ + {/* Step label */} + + {step.label} + + + {/* Connecting line to next step */} + {!isLast && ( +
currentStepIndex && + "border-t-2 border-dashed border-muted-foreground/30" + )} + style={{ left: "calc(50% + 16px)", width: "calc(100% - 32px)" }} + /> + )} +
+ ); + })} +
+
+ ); +} + +/** + * Skeleton for the progress timeline + */ +export function OrderProgressTimelineSkeleton() { + return ( +
+
+ {[1, 2, 3, 4].map((_, index) => ( +
+
+
+ {index < 3 && ( +
+ )} +
+ ))} +
+
+ ); +} + +export default OrderProgressTimeline; diff --git a/apps/portal/src/features/orders/hooks/index.ts b/apps/portal/src/features/orders/hooks/index.ts index 35a3388e..eec7cc0f 100644 --- a/apps/portal/src/features/orders/hooks/index.ts +++ b/apps/portal/src/features/orders/hooks/index.ts @@ -1,2 +1,9 @@ export { useOrdersList } from "./useOrdersList"; export { useOrderUpdates } from "./useOrderUpdates"; +export { useOrdersFilter } from "./useOrdersFilter"; +export type { + OrderStatusFilter, + OrderTypeFilter, + OrdersSummaryStats, + UseOrdersFilterResult, +} from "./useOrdersFilter"; diff --git a/apps/portal/src/features/orders/hooks/useOrdersFilter.ts b/apps/portal/src/features/orders/hooks/useOrdersFilter.ts new file mode 100644 index 00000000..ba6111e2 --- /dev/null +++ b/apps/portal/src/features/orders/hooks/useOrdersFilter.ts @@ -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; +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("all"); + const [typeFilter, setTypeFilter] = useState("all"); + + const hasActiveFilters = + searchTerm.trim() !== "" || statusFilter !== "all" || typeFilter !== "all"; + + const clearFilters = useCallback(() => { + setSearchTerm(""); + setStatusFilter("all"); + setTypeFilter("all"); + }, []); + + const stats = useMemo(() => { + 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(() => { + 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, + }; +} diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx index 83b2c826..c906df75 100644 --- a/apps/portal/src/features/orders/views/OrderDetail.tsx +++ b/apps/portal/src/features/orders/views/OrderDetail.tsx @@ -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 ( +
+
+
+ {icon} +
+
+

{label}

+
{value}
+
+
+
+ ); +} + export function OrderDetailContainer() { const params = useParams<{ id: string }>(); const searchParams = useSearchParams(); const [data, setData] = useState(null); const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); const isNewOrder = searchParams.get("status") === "success"; const activeControllerRef = useRef(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() {
)} - {data ? ( - <> + {isLoading || !data ? ( + + ) : ( +
+ {/* Stats Cards Section */} +
+ } + label="Order Status" + value={ + statusDescriptor ? ( + + ) : ( + "—" + ) + } + /> + } + label="Monthly" + value={totals.monthlyTotal > 0 ? Formatting.formatCurrency(totals.monthlyTotal) : "—"} + /> + } + label="One-Time" + value={totals.oneTimeTotal > 0 ? Formatting.formatCurrency(totals.oneTimeTotal) : "—"} + /> + } + label="Order Date" + value={placedDateShort} + /> +
+ + {/* Progress Timeline Section */} +
+

Order Progress

+ {statusDescriptor && ( + + )} +
+ + {/* Main Content Card */}
{/* Header Section */}
@@ -289,12 +377,7 @@ export function OrderDetailContainer() { {serviceIcon}
-
-

{serviceLabel}

- {statusDescriptor && ( - - )} -
+

{serviceLabel}

{placedDate &&

{placedDate}

}
@@ -453,10 +536,6 @@ export function OrderDetailContainer() {
- - ) : ( -
- Loading order details…
)} diff --git a/apps/portal/src/features/orders/views/OrdersList.tsx b/apps/portal/src/features/orders/views/OrdersList.tsx index 043d7a7a..d3995e07 100644 --- a/apps/portal/src/features/orders/views/OrdersList.tsx +++ b/apps/portal/src/features/orders/views/OrdersList.tsx @@ -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( + () => [ + { + icon: , + label: "Total", + value: stats.total, + tone: "muted", + }, + { + icon: , + label: "Pending", + value: stats.pending, + tone: "info", + }, + { + icon: , + label: "Active", + value: stats.active, + tone: "success", + }, + { + icon: , + label: "Processing", + value: stats.processing, + tone: "warning", + show: stats.processing > 0, + }, + ], + [stats] + ); + return ( } @@ -104,16 +177,81 @@ export function OrdersListContainer() { ); } + return ( -
- {orders.map(order => ( - router.push(`/account/orders/${order.id}`)} - /> - ))} -
+ <> + {/* Summary Stats */} + + + {/* Search & Filters */} + setStatusFilter(value as OrderStatusFilter)} + filterOptions={STATUS_FILTER_OPTIONS} + filterLabel="Filter by status" + > + {/* Type filter as additional child */} +
+ +
+ +
+
+ + {/* Clear filters button */} + {hasActiveFilters && ( + + )} +
+ + {/* Showing X of Y count */} + {hasActiveFilters && ( +

+ Showing {filteredOrders.length} of {orders.length} orders +

+ )} + + {/* Orders List */} + {filteredOrders.length > 0 ? ( +
+ {filteredOrders.map(order => ( + router.push(`/account/orders/${order.id}`)} + /> + ))} +
+ ) : ( + + + + )} + ); })()}
diff --git a/apps/portal/src/features/services/components/vpn/VpnPlansContent.tsx b/apps/portal/src/features/services/components/vpn/VpnPlansContent.tsx new file mode 100644 index 00000000..df0fbe89 --- /dev/null +++ b/apps/portal/src/features/services/components/vpn/VpnPlansContent.tsx @@ -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: , + title: "Sign Up", + description: "Create your account to get started", + }, + { + icon: , + title: "Choose Region", + description: "Select US (San Francisco) or UK (London)", + }, + { + icon: , + title: "Place Order", + description: "Complete checkout and receive router", + }, + { + icon: , + title: "Connect & Stream", + description: "Plug in, connect devices, enjoy", + }, +]; + +// FAQ items for VPN +const vpnFaqItems: FAQItem[] = [ + { + question: "Which streaming services can I access?", + answer: + "Our VPN establishes a network connection that virtually locates you in the designated server location (US or UK). This can help access region-specific content on services like Netflix, Hulu, BBC iPlayer, and others. However, not all services can be unblocked, and we cannot guarantee access to any specific streaming platform.", + }, + { + question: "How fast is the VPN connection?", + answer: + "The VPN connection speed depends on your existing internet connection. For HD streaming, we recommend at least 10Mbps download speed. The VPN router is optimized for streaming and should provide smooth playback for most content.", + }, + { + question: "Can I use multiple devices at once?", + answer: + "Yes! Any device connected to the VPN router's WiFi network will be routed through the VPN. This includes smart TVs, streaming boxes, gaming consoles, and more. Your regular internet devices can stay on your normal WiFi.", + }, + { + question: "What happens if I need help with setup?", + answer: + "We provide full English support for setup and troubleshooting. The router comes pre-configured, so most users just need to plug it in. If you encounter any issues, our support team can assist via email or phone.", + }, + { + question: "Is there a contract or commitment period?", + answer: + "The VPN service is a monthly rental with no long-term contract required. You can cancel at any time. The one-time activation fee covers initial setup and router preparation.", + }, +]; + +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 ( +
+ +
+ + +
+
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+ +
+ + +
+
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, j) => ( + + ))} +
+ +
+ ))} +
+
+ ); + } + + if (error) { + const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred"; + return ( +
+ +
+
Failed to load VPN plans
+
{errorMessage}
+ +
+
+ ); + } + + return ( +
+ + + {/* Hero Section */} +
+
+ + + VPN Router Service + +
+

+ Stream Content from Abroad +

+

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

+ + {/* Order info banner - public variant only */} + {variant === "public" && ( +
+
+
+ +

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

+
+
+
+ )} +
+ + {/* Service Highlights */} +
+ +
+ + {/* Plans Section */} + {plans.length > 0 ? ( +
+
+

+ Choose Your Region +

+

Available Plans

+

+ Select one region per router rental +

+
+ +
+ {plans.map(plan => ( + + ))} +
+ + {activationFees.length > 0 && ( + + A one-time activation fee of ¥3,000 applies per router rental. Tax (10%) not included. + + )} +
+ ) : ( +
+ +

No VPN Plans Available

+

+ We couldn't find any VPN plans available at this time. +

+ +
+ )} + + {/* How It Works */} + + + {/* CTA Section - public variant only */} + {variant === "public" && ( + { + e.preventDefault(); + document.getElementById("plans")?.scrollIntoView({ behavior: "smooth" }); + }, + }} + /> + )} + + {/* FAQ Section */} + + + {/* Disclaimer */} + +

+ Content subscriptions are NOT included in the VPN package. Our VPN service establishes a + network connection that virtually locates you in the designated server location. Not all + services can be unblocked. We do not guarantee access to any specific website or streaming + service quality. +

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

All prices exclude 10% consumption tax.

+
+
+ ); +} + +export default VpnPlansContent; diff --git a/apps/portal/src/features/services/components/vpn/index.ts b/apps/portal/src/features/services/components/vpn/index.ts new file mode 100644 index 00000000..255b0dc9 --- /dev/null +++ b/apps/portal/src/features/services/components/vpn/index.ts @@ -0,0 +1,2 @@ +export { VpnPlanCard, type VpnPlanCardProps } from "./VpnPlanCard"; +export { VpnPlansContent } from "./VpnPlansContent"; diff --git a/apps/portal/src/features/services/views/PublicVpnPlans.tsx b/apps/portal/src/features/services/views/PublicVpnPlans.tsx index aa27b038..d4ea686c 100644 --- a/apps/portal/src/features/services/views/PublicVpnPlans.tsx +++ b/apps/portal/src/features/services/views/PublicVpnPlans.tsx @@ -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: , - title: "Sign Up", - description: "Create your account to get started", - }, - { - icon: , - title: "Choose Region", - description: "Select US (San Francisco) or UK (London)", - }, - { - icon: , - title: "Place Order", - description: "Complete checkout and receive router", - }, - { - icon: , - title: "Connect & Stream", - description: "Plug in, connect devices, enjoy", - }, -]; - -// FAQ items for VPN -const vpnFaqItems: FAQItem[] = [ - { - question: "Which streaming services can I access?", - answer: - "Our VPN establishes a network connection that virtually locates you in the designated server location (US or UK). This can help access region-specific content on services like Netflix, Hulu, BBC iPlayer, and others. However, not all services can be unblocked, and we cannot guarantee access to any specific streaming platform.", - }, - { - question: "How fast is the VPN connection?", - answer: - "The VPN connection speed depends on your existing internet connection. For HD streaming, we recommend at least 10Mbps download speed. The VPN router is optimized for streaming and should provide smooth playback for most content.", - }, - { - question: "Can I use multiple devices at once?", - answer: - "Yes! Any device connected to the VPN router's WiFi network will be routed through the VPN. This includes smart TVs, streaming boxes, gaming consoles, and more. Your regular internet devices can stay on your normal WiFi.", - }, - { - question: "What happens if I need help with setup?", - answer: - "We provide full English support for setup and troubleshooting. The router comes pre-configured, so most users just need to plug it in. If you encounter any issues, our support team can assist via email or phone.", - }, - { - question: "Is there a contract or commitment period?", - answer: - "The VPN service is a monthly rental with no long-term contract required. You can cancel at any time. The one-time activation fee covers initial setup and router preparation.", - }, -]; +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 ( -
- - - -
- {Array.from({ length: 4 }).map((_, index) => ( - - ))} -
-
-
- ); - } - return ( -
- - - {/* Hero Section */} -
-
- - - VPN Router Service - -
-

- Stream Content from Abroad -

-

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

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

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

-
-
-
-
- - {/* Service Highlights - uses cp-stagger-children internally */} -
- -
- - {/* Plans Section */} - {vpnPlans.length > 0 ? ( -
-
-

- Choose Your Region -

-

Available Plans

-

- Select one region per router rental -

-
- -
- {vpnPlans.map(plan => ( - - ))} -
- - {activationFees.length > 0 && ( - - A one-time activation fee of ¥3,000 applies per router rental. Tax (10%) not included. - - )} -
- ) : ( -
- -

No VPN Plans Available

-

- We couldn't find any VPN plans available at this time. -

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

- Content subscriptions are NOT included in the VPN package. Our VPN service establishes a - network connection that virtually locates you in the designated server location. Not all - services can be unblocked. We do not guarantee access to any specific website or streaming - service quality. -

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

All prices exclude 10% consumption tax.

-
); } diff --git a/apps/portal/src/features/services/views/VpnPlans.tsx b/apps/portal/src/features/services/views/VpnPlans.tsx index 3d32ceb6..496bb3ad 100644 --- a/apps/portal/src/features/services/views/VpnPlans.tsx +++ b/apps/portal/src/features/services/views/VpnPlans.tsx @@ -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 ( -
- } - > -
- - - -
- {Array.from({ length: 4 }).map((_, index) => ( - - ))} -
-
-
-
-
- ); - } - return ( -
- } - > -
- - - - - {vpnPlans.length > 0 ? ( -
-

Available Plans

-

(One region per router)

- -
- {vpnPlans.map(plan => ( - - ))} -
- - {activationFees.length > 0 && ( - - A one-time activation fee of 3000 JPY is incurred separately for each rental unit. - Tax (10%) not included. - - )} -
- ) : ( -
- -

No VPN Plans Available

-

- We couldn't find any VPN plans available at this time. -

- -
- )} - -
-

How It Works

-
-

- SonixNet VPN is the easiest way to access video streaming services from overseas on - your network media players such as an Apple TV, Roku, or Amazon Fire. -

-

- A configured Wi-Fi router is provided for rental (no purchase required, no hidden - fees). All you will need to do is to plug the VPN router into your existing internet - connection. -

-

- Then you can connect your network media players to the VPN Wi-Fi network, to connect - to the VPN server. -

-

- For daily Internet usage that does not require a VPN, we recommend connecting to - your regular home Wi-Fi. -

-
-
- - - *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. - -
-
+
+
); } diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx index 9f8a98dd..181a04ca 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx @@ -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() { > {stats && ( -
-
-
-
- -
-
-

Active

-

{stats.active}

-
-
-
-
-
-
- -
-
-

Completed

-

{stats.completed}

-
-
-
-
-
-
- -
-
-

Cancelled

-

{stats.cancelled}

-
-
-
-
+ , + label: "Active", + value: stats.active, + tone: "success", + }, + { + icon: , + label: "Completed", + value: stats.completed, + tone: "primary", + }, + { + icon: , + label: "Cancelled", + value: stats.cancelled, + tone: "muted", + }, + ] satisfies StatItem[] + } + /> )}
diff --git a/apps/portal/src/features/support/views/SupportCasesView.tsx b/apps/portal/src/features/support/views/SupportCasesView.tsx index 2af32703..771d1601 100644 --- a/apps/portal/src/features/support/views/SupportCasesView.tsx +++ b/apps/portal/src/features/support/views/SupportCasesView.tsx @@ -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() { } > - {/* Summary Strip */} -
-
- - Total - {summary.total} -
-
- - Open - {summary.open} -
- {summary.highPriority > 0 && ( -
- - High Priority - {summary.highPriority} -
- )} -
- - Resolved - {summary.resolved} -
-
+ {/* Summary Stats */} + , + label: "Total", + value: summary.total, + tone: "muted", + }, + { + icon: , + label: "Open", + value: summary.open, + tone: "primary", + }, + { + icon: , + label: "High Priority", + value: summary.highPriority, + tone: "warning", + show: summary.highPriority > 0, + }, + { + icon: , + label: "Resolved", + value: summary.resolved, + tone: "success", + }, + ] satisfies StatItem[] + } + /> {/* Search & Filters */} -
- {/* Search */} -
-
- -
- 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" - /> -
- - {/* Filters */} -
- - + + {/* Priority filter as additional child */} +
- - {hasActiveFilters && ( - - )} +
+ +
-
+ + {/* Clear filters button */} + {hasActiveFilters && ( + + )} + {/* Cases List */} {cases.length > 0 ? (