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:
parent
8d9c954230
commit
5c6bd00346
@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { ClearFiltersButton, type ClearFiltersButtonProps } from "./ClearFiltersButton";
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { DetailStatsGrid, type DetailStatsGridProps, type StatGridItem } from "./DetailStatsGrid";
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { FilterDropdown, type FilterDropdownProps, type FilterOption } from "./FilterDropdown";
|
||||||
@ -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";
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,67 +164,35 @@ 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"
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filter Dropdown */}
|
|
||||||
{!isSubscriptionMode && (
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={e => {
|
|
||||||
const nextValue = e.target.value as InvoiceStatus | "all";
|
|
||||||
setStatusFilter(nextValue);
|
|
||||||
setCurrentPage(1);
|
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"
|
filterOptions: INVOICE_STATUS_OPTIONS,
|
||||||
|
filterLabel: "Filter by status",
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{statusFilterOptions.map(option => (
|
{/* Clear filters button */}
|
||||||
<option key={option.value} value={option.value}>
|
<ClearFiltersButton onClick={clearFilters} show={hasActiveFilters} />
|
||||||
{option.label}
|
</SearchFilterBar>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Invoice Table Card */}
|
||||||
|
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
|
||||||
{/* Invoice Table */}
|
{/* Invoice Table */}
|
||||||
<InvoiceTable
|
<InvoiceTable
|
||||||
invoices={filtered}
|
invoices={filtered}
|
||||||
@ -187,6 +213,7 @@ export function InvoicesList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export * from "./useBilling";
|
export * from "./useBilling";
|
||||||
export * from "./usePaymentRefresh";
|
export * from "./usePaymentRefresh";
|
||||||
|
export * from "./useInvoicesFilter";
|
||||||
|
|||||||
102
apps/portal/src/features/billing/hooks/useInvoicesFilter.ts
Normal file
102
apps/portal/src/features/billing/hooks/useInvoicesFilter.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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"
|
||||||
|
href={loginUrl}
|
||||||
|
className="w-full h-11"
|
||||||
|
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
|
||||||
|
>
|
||||||
Go to Login
|
Go to Login
|
||||||
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
|
||||||
</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"
|
||||||
|
href={migrateUrl}
|
||||||
|
className="w-full h-11"
|
||||||
|
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
|
||||||
|
>
|
||||||
Link My Account
|
Link My Account
|
||||||
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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"
|
||||||
|
href={effectiveRedirectTo}
|
||||||
|
className="w-full h-11"
|
||||||
|
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
|
||||||
|
>
|
||||||
{isDefaultRedirect ? "Go to Dashboard" : "Continue"}
|
{isDefaultRedirect ? "Go to Dashboard" : "Continue"}
|
||||||
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
|
||||||
|
|
||||||
{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>
|
||||||
|
|||||||
@ -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",
|
||||||
|
value: statusDescriptor ? (
|
||||||
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
|
<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[]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
|
||||||
icon={<CurrencyYenIcon className="h-5 w-5" />}
|
|
||||||
label="Monthly"
|
|
||||||
value={totals.monthlyTotal > 0 ? Formatting.formatCurrency(totals.monthlyTotal) : "—"}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<CurrencyYenIcon className="h-5 w-5" />}
|
|
||||||
label="One-Time"
|
|
||||||
value={totals.oneTimeTotal > 0 ? Formatting.formatCurrency(totals.oneTimeTotal) : "—"}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={<CalendarDaysIcon className="h-5 w-5" />}
|
|
||||||
label="Order Date"
|
|
||||||
value={placedDateShort}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Timeline Section */}
|
{/* 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">
|
||||||
|
|||||||
@ -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={event => setTypeFilter(event.target.value as OrderTypeFilter)}
|
onChange={value => setTypeFilter(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"
|
options={TYPE_FILTER_OPTIONS}
|
||||||
aria-label="Filter by type"
|
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 */}
|
||||||
|
|||||||
@ -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,11 +32,14 @@ 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 */}
|
||||||
|
{showHero && (
|
||||||
|
<>
|
||||||
<ServicesHero
|
<ServicesHero
|
||||||
title="Our Services"
|
title="Our Services"
|
||||||
description="Connectivity and support solutions for Japan's international community."
|
description="Connectivity and support solutions for Japan's international community."
|
||||||
@ -65,6 +70,8 @@ export function ServicesOverviewContent({
|
|||||||
<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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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={event => setPriorityFilter(event.target.value)}
|
onChange={setPriorityFilter}
|
||||||
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"
|
options={priorityFilterOptions}
|
||||||
aria-label="Filter by priority"
|
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 */}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user