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() { 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 "./AnimatedCard/AnimatedCard";
export * from "./ServiceCard/ServiceCard"; export * from "./ServiceCard/ServiceCard";
export * from "./SummaryStats"; export * from "./SummaryStats";
export * from "./FilterDropdown";
export * from "./ClearFiltersButton";
export * from "./DetailStatsGrid";
// Loading skeleton molecules // Loading skeleton molecules
export * from "./LoadingSkeletons"; export * from "./LoadingSkeletons";

View File

@ -5,13 +5,17 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { AuthLayout } from "../components"; import { AuthLayout } from "../components";
import { LinkWhmcsForm } from "@/features/auth/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"; import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth";
export function MigrateAccountView() { export function MigrateAccountView() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const initialEmail = searchParams.get("email") ?? undefined;
const redirectTo = getSafeRedirect(searchParams.get("redirect"), "/account");
return ( return (
<AuthLayout <AuthLayout
@ -40,11 +44,16 @@ export function MigrateAccountView() {
Use your previous Assist Solutions portal email and password. Use your previous Assist Solutions portal email and password.
</p> </p>
<LinkWhmcsForm <LinkWhmcsForm
initialEmail={initialEmail}
onTransferred={result => { onTransferred={result => {
if (result.needsPasswordSet) { 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 { } 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 { useRouter, useSearchParams } from "next/navigation";
import { AuthLayout } from "../components"; import { AuthLayout } from "../components";
import { SetPasswordForm } from "@/features/auth/components"; import { SetPasswordForm } from "@/features/auth/components";
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
import { LoadingOverlay } from "@/components/atoms"; import { LoadingOverlay } from "@/components/atoms";
function SetPasswordContent() { function SetPasswordContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const email = searchParams.get("email") ?? ""; const email = searchParams.get("email") ?? "";
const redirect = searchParams.get("redirect"); const redirectTo = getSafeRedirect(searchParams.get("redirect"), "/account");
useEffect(() => { useEffect(() => {
if (!email) { if (!email) {
@ -20,11 +21,7 @@ function SetPasswordContent() {
}, [email, router]); }, [email, router]);
const handlePasswordSetSuccess = () => { const handlePasswordSetSuccess = () => {
if (redirect) { router.push(redirectTo);
router.push(redirect);
return;
}
router.push("/account");
}; };
if (!email) { if (!email) {

View File

@ -1,10 +1,18 @@
"use client"; "use client";
import React, { useMemo, useState } from "react"; 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 { 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 { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar"; import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable"; import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
import { useInvoices } from "@/features/billing/hooks/useBilling"; import { useInvoices } from "@/features/billing/hooks/useBilling";
import { useSubscriptionInvoices } from "@/features/subscriptions/hooks/useSubscriptions"; import { useSubscriptionInvoices } from "@/features/subscriptions/hooks/useSubscriptions";
@ -19,7 +27,13 @@ interface InvoicesListProps {
className?: string; 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({ export function InvoicesList({
subscriptionId, subscriptionId,
@ -61,6 +75,7 @@ export function InvoicesList({
const invoices = useMemo(() => rawInvoices ?? [], [rawInvoices]); const invoices = useMemo(() => rawInvoices ?? [], [rawInvoices]);
const pagination = data?.pagination; const pagination = data?.pagination;
// Client-side search filtering on loaded invoices
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!searchTerm) return invoices; if (!searchTerm) return invoices;
const term = searchTerm.toLowerCase(); const term = searchTerm.toLowerCase();
@ -72,17 +87,60 @@ export function InvoicesList({
}); });
}, [invoices, searchTerm]); }, [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 => ({ icon: <DocumentTextIcon className="h-4 w-4" />,
value: status, label: "Total",
label: status, 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 // Loading state - show centered spinner
if (isPending) { if (isPending) {
return ( return (
@ -106,86 +164,55 @@ export function InvoicesList({
} }
return ( return (
<div <div className={cn("space-y-4", className)}>
className={cn( {/* Summary Stats (inline variant) */}
"bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden", {showFilters && invoices.length > 0 && (
className <SummaryStats variant="inline" items={summaryStatsItems} />
)} )}
>
{/* Search/Filter Header */} {/* Search & Filters */}
{showFilters && ( {showFilters && (
<div className="px-6 py-4 border-b border-border"> <SearchFilterBar
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> searchValue={searchTerm}
{/* Search Input */} onSearchChange={setSearchTerm}
<div className="relative flex-1 max-w-sm"> searchPlaceholder="Search by invoice number..."
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> {...(!isSubscriptionMode && {
<MagnifyingGlassIcon className="h-5 w-5 text-muted-foreground" /> filterValue: statusFilter,
</div> onFilterChange: (value: string) => {
<input setStatusFilter(value as InvoiceStatus | "all");
type="text" setCurrentPage(1);
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..." filterOptions: INVOICE_STATUS_OPTIONS,
value={searchTerm} filterLabel: "Filter by status",
onChange={e => setSearchTerm(e.target.value)} })}
/> >
</div> {/* Clear filters button */}
<ClearFiltersButton onClick={clearFilters} show={hasActiveFilters} />
</SearchFilterBar>
)}
{/* Controls */} {/* Invoice Table Card */}
<div className="flex items-center gap-3"> <div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
{/* Total Count */} {/* Invoice Table */}
{pagination?.totalItems && ( <InvoiceTable
<span className="text-sm text-muted-foreground"> invoices={filtered}
{pagination.totalItems} invoice{pagination.totalItems === 1 ? "" : "s"} loading={isLoading}
</span> compact={compact}
)} className="border-0 rounded-none shadow-none"
/>
{/* Filter Dropdown */} {/* Pagination */}
{!isSubscriptionMode && ( {pagination && filtered.length > 0 && (
<div className="relative"> <div className="border-t border-border px-6 py-4">
<select <PaginationBar
value={statusFilter} currentPage={currentPage}
onChange={e => { pageSize={pageSize}
const nextValue = e.target.value as InvoiceStatus | "all"; totalItems={pagination?.totalItems || 0}
setStatusFilter(nextValue); onPageChange={setCurrentPage}
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>
</div> </div>
</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 "./useBilling";
export * from "./usePaymentRefresh"; 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"> <div className="space-y-6">
<LoadingCard /> <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="flex items-center justify-between">
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-5 w-40" /> <Skeleton className="h-5 w-40" />
@ -113,7 +113,7 @@ export function InvoiceDetailContainer() {
]} ]}
> >
<div> <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 <InvoiceSummaryBar
invoice={invoice} invoice={invoice}
loadingDownload={loadingDownload} loadingDownload={loadingDownload}

View File

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

View File

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

View File

@ -18,6 +18,7 @@ import {
DocumentTextIcon, DocumentTextIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { StatusPill } from "@/components/atoms/status-pill"; import { StatusPill } from "@/components/atoms/status-pill";
import { DetailStatsGrid, type StatGridItem } from "@/components/molecules";
import { ordersService } from "@/features/orders/api/orders.api"; import { ordersService } from "@/features/orders/api/orders.api";
import { useOrderUpdates } from "@/features/orders/hooks/useOrderUpdates"; import { useOrderUpdates } from "@/features/orders/hooks/useOrderUpdates";
import { import {
@ -126,34 +127,6 @@ const describeCharge = (charge: OrderDisplayItemCharge): string => {
return charge.label.toLowerCase(); 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() { export function OrderDetailContainer() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -325,34 +298,38 @@ export function OrderDetailContainer() {
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{/* Stats Cards Section */} {/* Stats Cards Section */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <DetailStatsGrid
<StatCard items={
icon={<DocumentTextIcon className="h-5 w-5" />} [
label="Order Status" {
value={ icon: <DocumentTextIcon className="h-5 w-5" />,
statusDescriptor ? ( label: "Order Status",
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} /> value: statusDescriptor ? (
) : ( <StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
"—" ) : (
) "—"
} ),
/> },
<StatCard {
icon={<CurrencyYenIcon className="h-5 w-5" />} icon: <CurrencyYenIcon className="h-5 w-5" />,
label="Monthly" label: "Monthly",
value={totals.monthlyTotal > 0 ? Formatting.formatCurrency(totals.monthlyTotal) : "—"} value:
/> totals.monthlyTotal > 0 ? Formatting.formatCurrency(totals.monthlyTotal) : "—",
<StatCard },
icon={<CurrencyYenIcon className="h-5 w-5" />} {
label="One-Time" icon: <CurrencyYenIcon className="h-5 w-5" />,
value={totals.oneTimeTotal > 0 ? Formatting.formatCurrency(totals.oneTimeTotal) : "—"} label: "One-Time",
/> value:
<StatCard totals.oneTimeTotal > 0 ? Formatting.formatCurrency(totals.oneTimeTotal) : "—",
icon={<CalendarDaysIcon className="h-5 w-5" />} },
label="Order Date" {
value={placedDateShort} icon: <CalendarDaysIcon className="h-5 w-5" />,
/> label: "Order Date",
</div> value: placedDateShort,
},
] satisfies StatGridItem[]
}
/>
{/* Progress Timeline Section */} {/* Progress Timeline Section */}
<div className="bg-card rounded-xl border border-border p-5 shadow-[var(--cp-shadow-1)]"> <div className="bg-card rounded-xl border border-border p-5 shadow-[var(--cp-shadow-1)]">
@ -366,7 +343,7 @@ export function OrderDetailContainer() {
</div> </div>
{/* Main Content Card */} {/* 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 */} {/* Header Section */}
<div className="border-b border-border bg-card px-6 py-6 sm:px-8"> <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"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">

View File

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

View File

@ -18,6 +18,8 @@ import { ServicesHero } from "@/features/services/components/base/ServicesHero";
interface ServicesOverviewContentProps { interface ServicesOverviewContentProps {
/** Base path for service links ("/services" or "/account/services") */ /** Base path for service links ("/services" or "/account/services") */
basePath: "/services" | "/account/services"; basePath: "/services" | "/account/services";
/** Whether to show the hero section (default: true) */
showHero?: boolean;
/** Whether to show the CTA section (default: true) */ /** Whether to show the CTA section (default: true) */
showCta?: boolean; showCta?: boolean;
} }
@ -30,41 +32,46 @@ interface ServicesOverviewContentProps {
*/ */
export function ServicesOverviewContent({ export function ServicesOverviewContent({
basePath, basePath,
showHero = true,
showCta = true, showCta = true,
}: ServicesOverviewContentProps) { }: ServicesOverviewContentProps) {
return ( return (
<div className="space-y-12 pb-16"> <div className="space-y-12 pb-16">
{/* Hero */} {/* Hero */}
<ServicesHero {showHero && (
title="Our Services" <>
description="Connectivity and support solutions for Japan's international community." <ServicesHero
eyebrow={ title="Our Services"
<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"> description="Connectivity and support solutions for Japan's international community."
<CheckCircle2 className="h-4 w-4" /> eyebrow={
Full English Support <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">
</span> <CheckCircle2 className="h-4 w-4" />
} Full English Support
animated </span>
/> }
animated
/>
{/* Value Props - Compact */} {/* Value Props - Compact */}
<section <section
className="flex flex-wrap justify-center gap-6 text-sm animate-in fade-in slide-in-from-bottom-8 duration-700" className="flex flex-wrap justify-center gap-6 text-sm animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }} style={{ animationDelay: "300ms" }}
> >
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Globe className="h-4 w-4 text-primary" /> <Globe className="h-4 w-4 text-primary" />
<span>One provider, all services</span> <span>One provider, all services</span>
</div> </div>
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Headphones className="h-4 w-4 text-success" /> <Headphones className="h-4 w-4 text-success" />
<span>English support</span> <span>English support</span>
</div> </div>
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-info" /> <CheckCircle2 className="h-4 w-4 text-info" />
<span>No hidden fees</span> <span>No hidden fees</span>
</div> </div>
</section> </section>
</>
)}
{/* All Services - Clean Grid with staggered animations */} {/* 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"> <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"; 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 * Shows available services for the logged-in user with the same rich
* design as the public page, linking to account-specific service pages. * design as the public page, linking to account-specific service pages.
* Uses PageLayout for consistent account page header and navigation.
*/ */
export function AccountServicesOverview() { export function AccountServicesOverview() {
return ( return (
<div className="py-8"> <PageLayout
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-4 sm:px-6 md:px-8"> icon={<Squares2X2Icon />}
<ServicesOverviewContent basePath="/account/services" /> title="Services"
</div> description="Browse and order connectivity services"
</div> >
<ServicesOverviewContent basePath="/account/services" showHero={false} />
</PageLayout>
); );
} }

View File

@ -117,7 +117,7 @@ export function SubscriptionDetailContainer() {
{subscription ? ( {subscription ? (
<div className="space-y-6"> <div className="space-y-6">
{/* Subscription Stats */} {/* 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 className="px-6 py-5 grid grid-cols-2 md:grid-cols-4 gap-6">
<div> <div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"> <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">

View File

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