feat(auth): update migration and password set flows with improved redirect handling

feat(billing): enhance invoice list with filtering and summary stats

feat(components): add ClearFiltersButton, FilterDropdown, and DetailStatsGrid components for better UI consistency

fix(get-started): refactor button links to use new Button component for consistency

style(services): update ServicesOverviewContent to conditionally render hero section

refactor(orders): simplify OrderDetail and OrdersList views with new stat grid and filtering components

chore: add useInvoicesFilter hook for managing invoice filtering logic
This commit is contained in:
barsa 2026-01-20 11:26:40 +09:00
parent 8d9c954230
commit 5c6bd00346
22 changed files with 554 additions and 277 deletions

View File

@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { MigrateAccountView } from "@/features/auth/views/MigrateAccountView";
export default function MigrateAccountPage() {
redirect("/auth/get-started");
return <MigrateAccountView />;
}

View File

@ -0,0 +1,45 @@
"use client";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { cn } from "@/shared/utils";
export interface ClearFiltersButtonProps {
/** Callback when button is clicked */
onClick: () => void;
/** Whether the button should be visible (typically when filters are active) */
show?: boolean;
/** Optional label text (default: "Clear") */
label?: string;
/** Optional additional class names */
className?: string;
}
/**
* ClearFiltersButton - Reusable clear filters button with consistent styling.
*
* Used across list pages (Orders, Support, Invoices) to reset filters.
* Only renders when `show` is true (defaults to true).
*/
export function ClearFiltersButton({
onClick,
show = true,
label = "Clear",
className,
}: ClearFiltersButtonProps) {
if (!show) return null;
return (
<button
onClick={onClick}
className={cn(
"flex items-center gap-1 px-3 py-2 text-sm",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"rounded-lg transition-colors",
className
)}
>
<XMarkIcon className="h-4 w-4" />
<span className="hidden sm:inline">{label}</span>
</button>
);
}

View File

@ -0,0 +1 @@
export { ClearFiltersButton, type ClearFiltersButtonProps } from "./ClearFiltersButton";

View File

@ -0,0 +1,58 @@
"use client";
import { cn } from "@/shared/utils";
export interface StatGridItem {
/** Optional icon to display */
icon?: React.ReactNode;
/** Label for the stat */
label: string;
/** Value to display (can be string, number, or React node like StatusPill) */
value: React.ReactNode;
}
export interface DetailStatsGridProps {
/** Array of stat items to display */
items: StatGridItem[];
/** Number of columns (default: 4 on md screens) */
columns?: 2 | 3 | 4;
/** Optional additional class names */
className?: string;
}
const COLUMN_CLASSES: Record<2 | 3 | 4, string> = {
2: "grid-cols-2",
3: "grid-cols-2 md:grid-cols-3",
4: "grid-cols-2 md:grid-cols-4",
};
/**
* DetailStatsGrid - Reusable stats grid for detail pages.
*
* Used across detail pages (Orders, Subscriptions) to display key stats
* in a consistent grid layout with individual card styling.
*/
export function DetailStatsGrid({ items, columns = 4, className }: DetailStatsGridProps) {
return (
<div className={cn("grid gap-4", COLUMN_CLASSES[columns], className)}>
{items.map((item, index) => (
<div
key={index}
className="bg-card rounded-xl border border-border p-4 shadow-[var(--cp-shadow-1)]"
>
<div className="flex items-center gap-3">
{item.icon && (
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted/50 text-muted-foreground flex-shrink-0">
{item.icon}
</div>
)}
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">{item.label}</p>
<div className="font-semibold text-foreground">{item.value}</div>
</div>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1 @@
export { DetailStatsGrid, type DetailStatsGridProps, type StatGridItem } from "./DetailStatsGrid";

View File

@ -0,0 +1,64 @@
"use client";
import { FunnelIcon } from "@heroicons/react/24/outline";
import { cn } from "@/shared/utils";
export interface FilterOption {
value: string;
label: string;
}
export interface FilterDropdownProps {
/** Current selected value */
value: string;
/** Callback when value changes */
onChange: (value: string) => void;
/** Array of filter options */
options: FilterOption[];
/** Accessible label for the dropdown */
label: string;
/** Optional width class (default: "w-40") */
width?: string;
/** Optional additional class names */
className?: string;
}
/**
* FilterDropdown - Reusable filter dropdown component with consistent styling.
*
* Used across list pages (Orders, Support, Invoices) for filtering by status, type, priority, etc.
*/
export function FilterDropdown({
value,
onChange,
options,
label,
width = "w-40",
className,
}: FilterDropdownProps) {
return (
<div className={cn("relative", className)}>
<select
value={value}
onChange={event => onChange(event.target.value)}
className={cn(
"block 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",
width
)}
aria-label={label}
>
{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>
);
}

View File

@ -0,0 +1 @@
export { FilterDropdown, type FilterDropdownProps, type FilterOption } from "./FilterDropdown";

View File

@ -23,6 +23,9 @@ export * from "./SubCard/SubCard";
export * from "./AnimatedCard/AnimatedCard";
export * from "./ServiceCard/ServiceCard";
export * from "./SummaryStats";
export * from "./FilterDropdown";
export * from "./ClearFiltersButton";
export * from "./DetailStatsGrid";
// Loading skeleton molecules
export * from "./LoadingSkeletons";

View File

@ -5,13 +5,17 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { AuthLayout } from "../components";
import { LinkWhmcsForm } from "@/features/auth/components";
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth";
export function MigrateAccountView() {
const router = useRouter();
const searchParams = useSearchParams();
const initialEmail = searchParams.get("email") ?? undefined;
const redirectTo = getSafeRedirect(searchParams.get("redirect"), "/account");
return (
<AuthLayout
@ -40,11 +44,16 @@ export function MigrateAccountView() {
Use your previous Assist Solutions portal email and password.
</p>
<LinkWhmcsForm
initialEmail={initialEmail}
onTransferred={result => {
if (result.needsPasswordSet) {
router.push(`/auth/set-password?email=${encodeURIComponent(result.user.email)}`);
const params = new URLSearchParams({
email: result.user.email,
redirect: redirectTo,
});
router.push(`/auth/set-password?${params.toString()}`);
} else {
router.push("/account");
router.push(redirectTo);
}
}}
/>

View File

@ -5,13 +5,14 @@ import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { AuthLayout } from "../components";
import { SetPasswordForm } from "@/features/auth/components";
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
import { LoadingOverlay } from "@/components/atoms";
function SetPasswordContent() {
const router = useRouter();
const searchParams = useSearchParams();
const email = searchParams.get("email") ?? "";
const redirect = searchParams.get("redirect");
const redirectTo = getSafeRedirect(searchParams.get("redirect"), "/account");
useEffect(() => {
if (!email) {
@ -20,11 +21,7 @@ function SetPasswordContent() {
}, [email, router]);
const handlePasswordSetSuccess = () => {
if (redirect) {
router.push(redirect);
return;
}
router.push("/account");
router.push(redirectTo);
};
if (!email) {

View File

@ -1,10 +1,18 @@
"use client";
import React, { useMemo, useState } from "react";
import { MagnifyingGlassIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
import {
DocumentTextIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
ClockIcon,
} from "@heroicons/react/24/outline";
import { Spinner } from "@/components/atoms";
import { SummaryStats, ClearFiltersButton } from "@/components/molecules";
import type { StatItem } from "@/components/molecules/SummaryStats";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
import { useInvoices } from "@/features/billing/hooks/useBilling";
import { useSubscriptionInvoices } from "@/features/subscriptions/hooks/useSubscriptions";
@ -19,7 +27,13 @@ interface InvoicesListProps {
className?: string;
}
const INVOICE_STATUS_OPTIONS = Object.values(INVOICE_STATUS) as InvoiceStatus[];
const INVOICE_STATUS_OPTIONS = [
{ value: "all", label: "All Statuses" },
...Object.values(INVOICE_STATUS).map(status => ({
value: status,
label: status,
})),
];
export function InvoicesList({
subscriptionId,
@ -61,6 +75,7 @@ export function InvoicesList({
const invoices = useMemo(() => rawInvoices ?? [], [rawInvoices]);
const pagination = data?.pagination;
// Client-side search filtering on loaded invoices
const filtered = useMemo(() => {
if (!searchTerm) return invoices;
const term = searchTerm.toLowerCase();
@ -72,17 +87,60 @@ export function InvoicesList({
});
}, [invoices, searchTerm]);
const statusFilterOptions = useMemo(
// Compute stats from loaded invoices
const stats = useMemo(() => {
const result = { total: 0, paid: 0, unpaid: 0, overdue: 0 };
if (!invoices || invoices.length === 0) return result;
for (const invoice of invoices) {
result.total++;
if (invoice.status === "Paid") result.paid++;
else if (invoice.status === "Unpaid") result.unpaid++;
else if (invoice.status === "Overdue") result.overdue++;
}
return result;
}, [invoices]);
const summaryStatsItems = useMemo<StatItem[]>(
() => [
{ value: "all" as const, label: "All Status" },
...INVOICE_STATUS_OPTIONS.map(status => ({
value: status,
label: status,
})),
{
icon: <DocumentTextIcon className="h-4 w-4" />,
label: "Total",
value: pagination?.totalItems ?? stats.total,
tone: "muted",
},
{
icon: <CheckCircleIcon className="h-4 w-4" />,
label: "Paid",
value: stats.paid,
tone: "success",
},
{
icon: <ClockIcon className="h-4 w-4" />,
label: "Unpaid",
value: stats.unpaid,
tone: "warning",
show: stats.unpaid > 0,
},
{
icon: <ExclamationTriangleIcon className="h-4 w-4" />,
label: "Overdue",
value: stats.overdue,
tone: "warning",
show: stats.overdue > 0,
},
],
[]
[pagination?.totalItems, stats]
);
const hasActiveFilters = searchTerm.trim() !== "" || statusFilter !== "all";
const clearFilters = () => {
setSearchTerm("");
setStatusFilter("all");
setCurrentPage(1);
};
// Loading state - show centered spinner
if (isPending) {
return (
@ -106,86 +164,55 @@ export function InvoicesList({
}
return (
<div
className={cn(
"bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden",
className
<div className={cn("space-y-4", className)}>
{/* Summary Stats (inline variant) */}
{showFilters && invoices.length > 0 && (
<SummaryStats variant="inline" items={summaryStatsItems} />
)}
>
{/* Search/Filter Header */}
{/* Search & Filters */}
{showFilters && (
<div className="px-6 py-4 border-b border-border">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
{/* Search Input */}
<div className="relative flex-1 max-w-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-muted-foreground" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2.5 text-sm border border-border rounded-lg bg-card shadow-sm placeholder-muted-foreground text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary transition-colors"
placeholder="Search invoices..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<SearchFilterBar
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Search by invoice number..."
{...(!isSubscriptionMode && {
filterValue: statusFilter,
onFilterChange: (value: string) => {
setStatusFilter(value as InvoiceStatus | "all");
setCurrentPage(1);
},
filterOptions: INVOICE_STATUS_OPTIONS,
filterLabel: "Filter by status",
})}
>
{/* Clear filters button */}
<ClearFiltersButton onClick={clearFilters} show={hasActiveFilters} />
</SearchFilterBar>
)}
{/* Controls */}
<div className="flex items-center gap-3">
{/* Total Count */}
{pagination?.totalItems && (
<span className="text-sm text-muted-foreground">
{pagination.totalItems} invoice{pagination.totalItems === 1 ? "" : "s"}
</span>
)}
{/* Invoice Table Card */}
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
{/* Invoice Table */}
<InvoiceTable
invoices={filtered}
loading={isLoading}
compact={compact}
className="border-0 rounded-none shadow-none"
/>
{/* Filter Dropdown */}
{!isSubscriptionMode && (
<div className="relative">
<select
value={statusFilter}
onChange={e => {
const nextValue = e.target.value as InvoiceStatus | "all";
setStatusFilter(nextValue);
setCurrentPage(1);
}}
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 shadow-sm text-foreground cursor-pointer transition-colors"
>
{statusFilterOptions.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">
<ChevronDownIcon className="h-4 w-4 text-muted-foreground" />
</div>
</div>
)}
</div>
{/* Pagination */}
{pagination && filtered.length > 0 && (
<div className="border-t border-border px-6 py-4">
<PaginationBar
currentPage={currentPage}
pageSize={pageSize}
totalItems={pagination?.totalItems || 0}
onPageChange={setCurrentPage}
/>
</div>
</div>
)}
{/* Invoice Table */}
<InvoiceTable
invoices={filtered}
loading={isLoading}
compact={compact}
className="border-0 rounded-none shadow-none"
/>
{/* Pagination */}
{pagination && filtered.length > 0 && (
<div className="border-t border-border px-6 py-4">
<PaginationBar
currentPage={currentPage}
pageSize={pageSize}
totalItems={pagination?.totalItems || 0}
onPageChange={setCurrentPage}
/>
</div>
)}
)}
</div>
</div>
);
}

View File

@ -1,2 +1,3 @@
export * from "./useBilling";
export * from "./usePaymentRefresh";
export * from "./useInvoicesFilter";

View File

@ -0,0 +1,102 @@
"use client";
import { useMemo, useState, useCallback } from "react";
import type { Invoice, InvoiceStatus } from "@customer-portal/domain/billing";
export type InvoiceStatusFilter = InvoiceStatus | "all";
export interface InvoicesSummaryStats {
total: number;
paid: number;
unpaid: number;
overdue: number;
}
export interface UseInvoicesFilterOptions {
invoices: Invoice[] | undefined;
}
export interface UseInvoicesFilterResult {
// Filter state
searchTerm: string;
setSearchTerm: (value: string) => void;
statusFilter: InvoiceStatusFilter;
setStatusFilter: (value: InvoiceStatusFilter) => void;
// Computed values
filteredInvoices: Invoice[];
stats: InvoicesSummaryStats;
hasActiveFilters: boolean;
// Actions
clearFilters: () => void;
}
/**
* useInvoicesFilter - Hook for filtering and computing stats on invoices.
*
* Provides search, status filtering, summary statistics, and clear functionality
* for invoice list pages.
*/
export function useInvoicesFilter({ invoices }: UseInvoicesFilterOptions): UseInvoicesFilterResult {
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<InvoiceStatusFilter>("all");
const hasActiveFilters = searchTerm.trim() !== "" || statusFilter !== "all";
const clearFilters = useCallback(() => {
setSearchTerm("");
setStatusFilter("all");
}, []);
const stats = useMemo<InvoicesSummaryStats>(() => {
if (!invoices || invoices.length === 0) {
return { total: 0, paid: 0, unpaid: 0, overdue: 0 };
}
return invoices.reduce(
(acc, invoice) => {
acc.total++;
if (invoice.status === "Paid") {
acc.paid++;
} else if (invoice.status === "Unpaid") {
acc.unpaid++;
} else if (invoice.status === "Overdue") {
acc.overdue++;
}
return acc;
},
{ total: 0, paid: 0, unpaid: 0, overdue: 0 }
);
}, [invoices]);
const filteredInvoices = useMemo<Invoice[]>(() => {
if (!invoices) return [];
return invoices.filter(invoice => {
// Search filter - match invoice number or description
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase();
const matchesNumber = invoice.number.toLowerCase().includes(term);
const matchesDescription = invoice.description?.toLowerCase().includes(term);
if (!matchesNumber && !matchesDescription) {
return false;
}
}
// Status filter
return statusFilter === "all" || invoice.status === statusFilter;
});
}, [invoices, searchTerm, statusFilter]);
return {
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
filteredInvoices,
stats,
hasActiveFilters,
clearFilters,
};
}

View File

@ -55,7 +55,7 @@ export function InvoiceDetailContainer() {
>
<div className="space-y-6">
<LoadingCard />
<div className="bg-card text-card-foreground rounded-2xl border border-border p-6 space-y-4 shadow-[var(--cp-shadow-1)]">
<div className="bg-card text-card-foreground rounded-xl border border-border p-6 space-y-4 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-40" />
@ -113,7 +113,7 @@ export function InvoiceDetailContainer() {
]}
>
<div>
<div className="bg-card text-card-foreground rounded-2xl shadow-[var(--cp-shadow-1)] border border-border overflow-hidden">
<div className="bg-card text-card-foreground rounded-xl shadow-[var(--cp-shadow-1)] border border-border overflow-hidden">
<InvoiceSummaryBar
invoice={invoice}
loadingDownload={loadingDownload}

View File

@ -10,7 +10,6 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/atoms";
import {
@ -80,12 +79,14 @@ export function AccountStatusStep() {
</p>
</div>
<Link href={loginUrl}>
<Button className="w-full h-11">
Go to Login
<ArrowRightIcon className="h-4 w-4 ml-2" />
</Button>
</Link>
<Button
as="a"
href={loginUrl}
className="w-full h-11"
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
>
Go to Login
</Button>
</div>
);
}
@ -148,12 +149,14 @@ export function AccountStatusStep() {
</p>
</div>
<Link href={migrateUrl}>
<Button className="w-full h-11">
Link My Account
<ArrowRightIcon className="h-4 w-4 ml-2" />
</Button>
</Link>
<Button
as="a"
href={migrateUrl}
className="w-full h-11"
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
>
Link My Account
</Button>
</div>
);
}
@ -223,9 +226,12 @@ export function AccountStatusStep() {
</p>
)}
<Button onClick={() => goToStep("complete-account")} className="w-full h-11">
<Button
onClick={() => goToStep("complete-account")}
className="w-full h-11"
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
>
Continue
<ArrowRightIcon className="h-4 w-4 ml-2" />
</Button>
</div>
);
@ -247,9 +253,12 @@ export function AccountStatusStep() {
</p>
</div>
<Button onClick={() => goToStep("complete-account")} className="w-full h-11">
<Button
onClick={() => goToStep("complete-account")}
className="w-full h-11"
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
>
Continue
<ArrowRightIcon className="h-4 w-4 ml-2" />
</Button>
</div>
);

View File

@ -4,7 +4,6 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/atoms";
import { CheckCircleIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import { useGetStartedStore } from "../../../stores/get-started.store";
@ -34,19 +33,19 @@ export function SuccessStep() {
</div>
<div className="space-y-3">
<Link href={effectiveRedirectTo}>
<Button className="w-full h-11">
{isDefaultRedirect ? "Go to Dashboard" : "Continue"}
<ArrowRightIcon className="h-4 w-4 ml-2" />
</Button>
</Link>
<Button
as="a"
href={effectiveRedirectTo}
className="w-full h-11"
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
>
{isDefaultRedirect ? "Go to Dashboard" : "Continue"}
</Button>
{isDefaultRedirect && (
<Link href="/services/internet">
<Button variant="outline" className="w-full h-11">
Check Internet Availability
</Button>
</Link>
<Button as="a" href="/services/internet" variant="outline" className="w-full h-11">
Check Internet Availability
</Button>
)}
</div>
</div>

View File

@ -18,6 +18,7 @@ import {
DocumentTextIcon,
} from "@heroicons/react/24/outline";
import { StatusPill } from "@/components/atoms/status-pill";
import { DetailStatsGrid, type StatGridItem } from "@/components/molecules";
import { ordersService } from "@/features/orders/api/orders.api";
import { useOrderUpdates } from "@/features/orders/hooks/useOrderUpdates";
import {
@ -126,34 +127,6 @@ 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();
@ -325,34 +298,38 @@ export function OrderDetailContainer() {
) : (
<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>
<DetailStatsGrid
items={
[
{
icon: <DocumentTextIcon className="h-5 w-5" />,
label: "Order Status",
value: statusDescriptor ? (
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
) : (
"—"
),
},
{
icon: <CurrencyYenIcon className="h-5 w-5" />,
label: "Monthly",
value:
totals.monthlyTotal > 0 ? Formatting.formatCurrency(totals.monthlyTotal) : "—",
},
{
icon: <CurrencyYenIcon className="h-5 w-5" />,
label: "One-Time",
value:
totals.oneTimeTotal > 0 ? Formatting.formatCurrency(totals.oneTimeTotal) : "—",
},
{
icon: <CalendarDaysIcon className="h-5 w-5" />,
label: "Order Date",
value: placedDateShort,
},
] satisfies StatGridItem[]
}
/>
{/* Progress Timeline Section */}
<div className="bg-card rounded-xl border border-border p-5 shadow-[var(--cp-shadow-1)]">
@ -366,7 +343,7 @@ export function OrderDetailContainer() {
</div>
{/* Main Content Card */}
<div className="rounded-3xl border border-border bg-card text-card-foreground shadow-[var(--cp-shadow-1)]">
<div className="rounded-xl 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">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">

View File

@ -6,12 +6,15 @@ import {
ClipboardDocumentListIcon,
ClockIcon,
CheckCircleIcon,
XMarkIcon,
FunnelIcon,
ArrowPathIcon,
} from "@heroicons/react/24/outline";
import { PageLayout } from "@/components/templates/PageLayout";
import { AnimatedCard, SummaryStats } from "@/components/molecules";
import {
AnimatedCard,
SummaryStats,
FilterDropdown,
ClearFiltersButton,
} from "@/components/molecules";
import type { StatItem } from "@/components/molecules/SummaryStats";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
@ -194,34 +197,16 @@ export function OrdersListContainer() {
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>
<FilterDropdown
value={typeFilter}
onChange={value => setTypeFilter(value as OrderTypeFilter)}
options={TYPE_FILTER_OPTIONS}
label="Filter by type"
width="w-36"
/>
{/* 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>
)}
<ClearFiltersButton onClick={clearFilters} show={hasActiveFilters} />
</SearchFilterBar>
{/* Showing X of Y count */}

View File

@ -18,6 +18,8 @@ import { ServicesHero } from "@/features/services/components/base/ServicesHero";
interface ServicesOverviewContentProps {
/** Base path for service links ("/services" or "/account/services") */
basePath: "/services" | "/account/services";
/** Whether to show the hero section (default: true) */
showHero?: boolean;
/** Whether to show the CTA section (default: true) */
showCta?: boolean;
}
@ -30,41 +32,46 @@ interface ServicesOverviewContentProps {
*/
export function ServicesOverviewContent({
basePath,
showHero = true,
showCta = true,
}: ServicesOverviewContentProps) {
return (
<div className="space-y-12 pb-16">
{/* Hero */}
<ServicesHero
title="Our Services"
description="Connectivity and support solutions for Japan's international community."
eyebrow={
<span className="inline-flex items-center gap-2 rounded-full bg-primary/8 border border-primary/15 px-4 py-2 text-sm text-primary font-medium normal-case tracking-normal">
<CheckCircle2 className="h-4 w-4" />
Full English Support
</span>
}
animated
/>
{showHero && (
<>
<ServicesHero
title="Our Services"
description="Connectivity and support solutions for Japan's international community."
eyebrow={
<span className="inline-flex items-center gap-2 rounded-full bg-primary/8 border border-primary/15 px-4 py-2 text-sm text-primary font-medium normal-case tracking-normal">
<CheckCircle2 className="h-4 w-4" />
Full English Support
</span>
}
animated
/>
{/* Value Props - Compact */}
<section
className="flex flex-wrap justify-center gap-6 text-sm animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }}
>
<div className="flex items-center gap-2 text-muted-foreground">
<Globe className="h-4 w-4 text-primary" />
<span>One provider, all services</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Headphones className="h-4 w-4 text-success" />
<span>English support</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-info" />
<span>No hidden fees</span>
</div>
</section>
{/* Value Props - Compact */}
<section
className="flex flex-wrap justify-center gap-6 text-sm animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }}
>
<div className="flex items-center gap-2 text-muted-foreground">
<Globe className="h-4 w-4 text-primary" />
<span>One provider, all services</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Headphones className="h-4 w-4 text-success" />
<span>English support</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-info" />
<span>No hidden fees</span>
</div>
</section>
</>
)}
{/* All Services - Clean Grid with staggered animations */}
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 cp-stagger-children">

View File

@ -1,3 +1,5 @@
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { PageLayout } from "@/components/templates/PageLayout";
import { ServicesOverviewContent } from "@/features/services/components/common/ServicesOverviewContent";
/**
@ -5,13 +7,16 @@ import { ServicesOverviewContent } from "@/features/services/components/common/S
*
* Shows available services for the logged-in user with the same rich
* design as the public page, linking to account-specific service pages.
* Uses PageLayout for consistent account page header and navigation.
*/
export function AccountServicesOverview() {
return (
<div className="py-8">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-4 sm:px-6 md:px-8">
<ServicesOverviewContent basePath="/account/services" />
</div>
</div>
<PageLayout
icon={<Squares2X2Icon />}
title="Services"
description="Browse and order connectivity services"
>
<ServicesOverviewContent basePath="/account/services" showHero={false} />
</PageLayout>
);
}

View File

@ -117,7 +117,7 @@ export function SubscriptionDetailContainer() {
{subscription ? (
<div className="space-y-6">
{/* Subscription Stats */}
<div className="bg-card border border-border rounded-2xl shadow-sm overflow-hidden">
<div className="bg-card border border-border rounded-xl shadow-[var(--cp-shadow-1)] overflow-hidden">
<div className="px-6 py-5 grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">

View File

@ -9,13 +9,16 @@ import {
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, SummaryStats } from "@/components/molecules";
import {
AnimatedCard,
SummaryStats,
FilterDropdown,
ClearFiltersButton,
} from "@/components/molecules";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
import { Button } from "@/components/atoms";
import { EmptyState, SearchEmptyState } from "@/components/atoms/empty-state";
@ -60,7 +63,8 @@ export function SupportCasesView() {
// Show loading until we have data or an error
const showLoading = !data && !error;
const hasActiveFilters = statusFilter !== "all" || priorityFilter !== "all" || searchTerm.trim();
const hasActiveFilters =
statusFilter !== "all" || priorityFilter !== "all" || searchTerm.trim() !== "";
const statusFilterOptions = useMemo(
() => [
@ -147,34 +151,16 @@ export function SupportCasesView() {
filterLabel="Filter by status"
>
{/* Priority filter as additional child */}
<div className="relative">
<select
value={priorityFilter}
onChange={event => setPriorityFilter(event.target.value)}
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}>
{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>
<FilterDropdown
value={priorityFilter}
onChange={setPriorityFilter}
options={priorityFilterOptions}
label="Filter by priority"
width="w-40"
/>
{/* 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>
)}
<ClearFiltersButton onClick={clearFilters} show={hasActiveFilters} />
</SearchFilterBar>
{/* Cases List */}