Refactor CatalogController to return comprehensive internet plan data including installations and addons. Update Button component styles for improved visual feedback and consistency across the application. Enhance AddonGroup logic for better handling of bundled addons. Revamp OrderSummary and related components for a more structured display of order details. Improve error handling in subscription hooks for better reliability in data fetching.

This commit is contained in:
barsa 2025-09-29 11:00:56 +09:00
parent 50d8fdfdd1
commit a102f362e2
15 changed files with 385 additions and 283 deletions

View File

@ -26,13 +26,25 @@ export class CatalogController {
@ApiOperation({ summary: "Get Internet plans filtered by customer eligibility" })
async getInternetPlans(
@Request() req: { user: { id: string } }
): Promise<InternetPlanCatalogItem[]> {
): Promise<{
plans: InternetPlanCatalogItem[];
installations: InternetInstallationCatalogItem[];
addons: InternetAddonCatalogItem[];
}> {
const userId = req.user?.id;
if (!userId) {
// Fallback to all plans if no user context
return this.internetCatalog.getPlans();
// Fallback to all catalog data if no user context
return this.internetCatalog.getCatalogData();
}
return this.internetCatalog.getPlansForUser(userId);
// Get user-specific plans but all installations and addons
const [plans, installations, addons] = await Promise.all([
this.internetCatalog.getPlansForUser(userId),
this.internetCatalog.getInstallations(),
this.internetCatalog.getAddons(),
]);
return { plans, installations, addons };
}
@Get("internet/addons")

View File

@ -5,21 +5,21 @@ import { cn } from "@/lib/utils";
import { Spinner } from "./Spinner";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
"inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background active:scale-[0.98]",
{
variants: {
variant: {
default: "bg-blue-600 text-white hover:bg-blue-700",
destructive: "bg-red-600 text-white hover:bg-red-700",
outline: "border border-gray-300 bg-white hover:bg-gray-50",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
ghost: "hover:bg-gray-100",
default: "bg-blue-600 text-white hover:bg-blue-700 shadow-sm hover:shadow-md",
destructive: "bg-red-600 text-white hover:bg-red-700 shadow-sm hover:shadow-md",
outline: "border border-gray-300 bg-white hover:bg-gray-50 hover:border-gray-400 shadow-sm hover:shadow-md",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 shadow-sm hover:shadow-md",
ghost: "hover:bg-gray-100 hover:shadow-sm",
link: "underline-offset-4 hover:underline text-blue-600",
},
size: {
default: "h-10 py-2 px-4",
sm: "h-9 px-3 rounded-md",
lg: "h-11 px-8 rounded-md",
default: "h-11 py-2.5 px-4",
sm: "h-9 px-3 text-xs",
lg: "h-12 px-6 text-base",
},
},
defaultVariants: {
@ -77,8 +77,8 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
>
<span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon}
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
<span className="flex-1">{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? <span className="transition-transform duration-200 group-hover:translate-x-0.5">{rightIcon}</span> : null}
</span>
</a>
);
@ -102,8 +102,8 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
>
<span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon}
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
<span className="flex-1">{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? <span className="transition-transform duration-200 group-hover:translate-x-0.5">{rightIcon}</span> : null}
</span>
</button>
);

View File

@ -6,7 +6,7 @@ import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
interface AddonGroupProps {
addons: Array<
CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean; raw?: unknown }
CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean }
>;
selectedAddonSkus: string[];
onAddonToggle: (skus: string[]) => void;
@ -26,68 +26,71 @@ type BundledAddonGroup = {
function buildGroupedAddons(
addons: Array<
CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean; raw?: unknown }
CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean }
>
): BundledAddonGroup[] {
const groups: BundledAddonGroup[] = [];
const processedSkus = new Set<string>();
const processed = new Set<string>();
// Sort by display order
const sorted = [...addons].sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
sorted.forEach(addon => {
if (processedSkus.has(addon.sku)) return;
for (const addon of sorted) {
if (processed.has(addon.sku)) continue;
// Try to find bundle partner
if (addon.isBundledAddon && addon.bundledAddonId) {
const partner = sorted.find(
candidate =>
candidate.raw &&
typeof candidate.raw === "object" &&
"Id" in candidate.raw &&
candidate.raw.Id === addon.bundledAddonId
);
if (partner) {
const monthlyAddon = addon.billingCycle === "Monthly" ? addon : partner;
const activationAddon = addon.billingCycle === "Onetime" ? addon : partner;
const name =
monthlyAddon.name.replace(/\s*(Monthly|Installation|Fee)\s*/gi, "").trim() || addon.name;
groups.push({
id: `bundle-${addon.sku}-${partner.sku}`,
name,
description: `${name} bundle (installation included)`,
monthlyPrice:
monthlyAddon.billingCycle === "Monthly" ? getMonthlyPrice(monthlyAddon) : undefined,
activationPrice:
activationAddon.billingCycle === "Onetime"
? getOneTimePrice(activationAddon)
: undefined,
skus: [addon.sku, partner.sku],
isBundled: true,
displayOrder: addon.displayOrder ?? 0,
});
processedSkus.add(addon.sku);
processedSkus.add(partner.sku);
return;
const partner = sorted.find(candidate => candidate.id === addon.bundledAddonId);
if (partner && !processed.has(partner.sku)) {
// Create bundle
const bundle = createBundle(addon, partner);
groups.push(bundle);
processed.add(addon.sku);
processed.add(partner.sku);
continue;
}
}
groups.push({
id: addon.sku,
name: addon.name,
description: addon.description || "",
monthlyPrice: addon.billingCycle === "Monthly" ? getMonthlyPrice(addon) : undefined,
activationPrice: addon.billingCycle === "Onetime" ? getOneTimePrice(addon) : undefined,
skus: [addon.sku],
isBundled: false,
displayOrder: addon.displayOrder ?? 0,
});
processedSkus.add(addon.sku);
});
// Create standalone item
groups.push(createStandaloneItem(addon));
processed.add(addon.sku);
}
return groups.sort((a, b) => a.displayOrder - b.displayOrder);
return groups;
}
function createBundle(addon1: CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean }, addon2: CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean }): BundledAddonGroup {
// Determine which is monthly vs onetime
const monthlyAddon = addon1.billingCycle === "Monthly" ? addon1 : addon2;
const onetimeAddon = addon1.billingCycle === "Onetime" ? addon1 : addon2;
// Use monthly addon name as base, clean it up
const baseName = monthlyAddon.name.replace(/\s*(Monthly|Installation|Fee)\s*/gi, "").trim();
return {
id: `bundle-${addon1.sku}-${addon2.sku}`,
name: baseName,
description: `${baseName} (monthly service + installation)`,
monthlyPrice: monthlyAddon.billingCycle === "Monthly" ? getMonthlyPrice(monthlyAddon) : undefined,
activationPrice: onetimeAddon.billingCycle === "Onetime" ? getOneTimePrice(onetimeAddon) : undefined,
skus: [addon1.sku, addon2.sku],
isBundled: true,
displayOrder: Math.min(addon1.displayOrder ?? 0, addon2.displayOrder ?? 0),
};
}
function createStandaloneItem(addon: CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean }): BundledAddonGroup {
return {
id: addon.sku,
name: addon.name,
description: addon.description || "",
monthlyPrice: addon.billingCycle === "Monthly" ? getMonthlyPrice(addon) : undefined,
activationPrice: addon.billingCycle === "Onetime" ? getOneTimePrice(addon) : undefined,
skus: [addon.sku],
isBundled: false,
displayOrder: addon.displayOrder ?? 0,
};
}
export function AddonGroup({

View File

@ -348,12 +348,12 @@ export function EnhancedOrderSummary({
variant="outline"
className="flex-1 group"
disabled={disabled || loading}
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
onClick={() => {
if (disabled || loading) return;
router.push(backUrl);
}}
>
<ArrowLeftIcon className="w-4 h-4 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
{backLabel}
</Button>
) : onBack ? (
@ -362,44 +362,22 @@ export function EnhancedOrderSummary({
variant="outline"
className="flex-1 group"
disabled={disabled || loading}
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
<ArrowLeftIcon className="w-4 h-4 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
{backLabel}
</Button>
) : null}
{onContinue && (
<Button onClick={onContinue} className="flex-1 group" disabled={disabled || loading}>
{loading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Processing...
</span>
) : (
<>
{continueLabel}
<ArrowRightIcon className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform duration-300" />
</>
)}
<Button
onClick={onContinue}
className="flex-1 group"
disabled={disabled || loading}
loading={loading}
loadingText="Processing..."
rightIcon={!loading ? <ArrowRightIcon className="w-4 h-4" /> : undefined}
>
{continueLabel}
</Button>
)}
</div>

View File

@ -1,6 +1,7 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import type { CatalogProductBase } from "@customer-portal/domain";
import { useRouter } from "next/navigation";
import { Button } from "@/components/atoms/button";
import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
interface OrderSummaryProps {
@ -237,41 +238,40 @@ export function OrderSummary({
{variant === "simple" ? (
<>
{backUrl ? (
<button
type="button"
<Button
variant="outline"
className="flex-1"
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
onClick={() => {
if (!disabled) router.push(backUrl);
}}
disabled={disabled}
className="flex-1 px-6 py-3 border border-gray-300 rounded-lg text-center hover:bg-gray-50 transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowLeftIcon className="h-5 w-5" />
{backLabel}
</button>
</Button>
) : null}
{onContinue ? (
<button
type="button"
<Button
className="flex-1"
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
onClick={onContinue}
disabled={disabled}
className="flex-1 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
{continueLabel}
<ArrowRightIcon className="h-5 w-5" />
</button>
</Button>
) : null}
</>
) : onContinue ? (
<button
type="button"
<Button
size="lg"
className="w-full mt-8 group text-lg font-bold"
rightIcon={<ArrowRightIcon className="w-5 h-5" />}
onClick={onContinue}
disabled={disabled}
className="w-full mt-8 px-8 py-4 bg-blue-600 text-white font-bold rounded-2xl hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-xl hover:shadow-2xl flex items-center justify-center group text-lg"
>
{continueLabel}
<ArrowRightIcon className="w-6 h-6 ml-3 group-hover:translate-x-1 transition-transform" />
</button>
</Button>
) : null}
</div>
)}

View File

@ -154,18 +154,22 @@ export function ProductCard({
<Button
className="w-full group"
disabled={disabled}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
onClick={() => {
if (disabled) return;
router.push(href);
}}
>
<span>{actionLabel}</span>
<ArrowRightIcon className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform duration-300" />
{actionLabel}
</Button>
) : onClick ? (
<Button onClick={onClick} className="w-full group" disabled={disabled}>
<span>{actionLabel}</span>
<ArrowRightIcon className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform duration-300" />
<Button
onClick={onClick}
className="w-full group"
disabled={disabled}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
{actionLabel}
</Button>
) : null}
</div>

View File

@ -78,9 +78,9 @@ export function ServiceHeroCard({
href={href}
className="w-full font-semibold rounded-2xl relative z-10 group"
size="lg"
rightIcon={<ArrowRightIcon className="w-5 h-5" />}
>
<span>Explore Plans</span>
<ArrowRightIcon className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
Explore Plans
</Button>
</div>
</div>

View File

@ -40,16 +40,16 @@ export function InternetPlanCard({
const minInstallationPrice = installationPrices.length ? Math.min(...installationPrices) : 0;
const getBorderClass = () => {
if (isGold) return "border-2 border-yellow-400 shadow-lg hover:shadow-xl";
if (isPlatinum) return "border-2 border-indigo-400 shadow-lg hover:shadow-xl";
if (isSilver) return "border-2 border-gray-300 shadow-lg hover:shadow-xl";
return "border border-gray-200 shadow-lg hover:shadow-xl";
if (isGold) return "border-2 border-yellow-400/50 bg-gradient-to-br from-yellow-50/80 to-amber-50/80 backdrop-blur-sm shadow-xl hover:shadow-2xl ring-2 ring-yellow-200/30";
if (isPlatinum) return "border-2 border-indigo-400/50 bg-gradient-to-br from-indigo-50/80 to-purple-50/80 backdrop-blur-sm shadow-xl hover:shadow-2xl ring-2 ring-indigo-200/30";
if (isSilver) return "border-2 border-gray-300/50 bg-gradient-to-br from-gray-50/80 to-slate-50/80 backdrop-blur-sm shadow-xl hover:shadow-2xl ring-2 ring-gray-200/30";
return "border border-gray-200/50 bg-white/80 backdrop-blur-sm shadow-lg hover:shadow-xl";
};
return (
<AnimatedCard
variant="default"
className={`overflow-hidden flex flex-col h-full ${getBorderClass()}`}
variant="static"
className={`overflow-hidden flex flex-col h-full transition-all duration-500 ease-out hover:-translate-y-2 hover:scale-[1.02] ${getBorderClass()}`}
>
<div className="p-6 flex flex-col flex-grow">
<div className="flex items-center justify-between mb-4">
@ -129,15 +129,13 @@ export function InternetPlanCard({
<Button
className="w-full group"
disabled={disabled}
rightIcon={!disabled ? <ArrowRightIcon className="w-4 h-4" /> : undefined}
onClick={() => {
if (disabled) return;
router.push(`/catalog/internet/configure?plan=${plan.sku}`);
}}
>
<span>{disabled ? disabledReason || "Not available" : "Configure Plan"}</span>
{!disabled && (
<ArrowRightIcon className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform duration-300" />
)}
{disabled ? disabledReason || "Not available" : "Configure Plan"}
</Button>
</div>
</AnimatedCard>

View File

@ -54,7 +54,7 @@ export function ReviewOrderStep({
/>
</div>
<div className="max-w-lg mx-auto mb-8 bg-gradient-to-b from-white to-gray-50 shadow-xl rounded-lg border border-gray-200 p-6">
<div className="max-w-lg mx-auto mb-8">
<OrderSummary
plan={plan}
selectedInstallation={selectedInstallation}
@ -95,80 +95,105 @@ function OrderSummary({
oneTimeTotal: number;
}) {
return (
<>
<h4 className="text-lg font-semibold text-gray-900 mb-4">Order Summary</h4>
<div className="bg-gradient-to-b from-white to-gray-50 shadow-xl rounded-lg border border-gray-200 p-6">
{/* Receipt Header */}
<div className="text-center border-b-2 border-dashed border-gray-300 pb-4 mb-6">
<h3 className="text-xl font-bold text-gray-900 mb-1">Order Summary</h3>
<p className="text-sm text-gray-500">Review your configuration</p>
</div>
{/* Plan Details */}
<div className="space-y-4 mb-6">
<OrderItem
title={plan.name}
subtitle={mode ? `Configuration: ${mode}` : undefined}
monthlyPrice={getMonthlyPrice(plan)}
oneTimePrice={getOneTimePrice(plan)}
/>
<OrderItem
title={selectedInstallation.name}
subtitle="Installation Service"
monthlyPrice={getMonthlyPrice(selectedInstallation)}
oneTimePrice={getOneTimePrice(selectedInstallation)}
/>
{selectedAddons.map(addon => (
<OrderItem
key={addon.sku}
title={addon.name}
subtitle="Add-on Service"
monthlyPrice={getMonthlyPrice(addon)}
oneTimePrice={getOneTimePrice(addon)}
/>
))}
<div className="space-y-3 mb-6">
<div className="flex justify-between items-start">
<div>
<h4 className="font-semibold text-gray-900">{plan.name}</h4>
<p className="text-sm text-gray-600">Internet Service</p>
{mode && <p className="text-sm text-gray-600">Access Mode: {mode}</p>}
</div>
<div className="text-right">
<p className="font-semibold text-gray-900">
¥{getMonthlyPrice(plan).toLocaleString()}
</p>
<p className="text-xs text-gray-500">per month</p>
</div>
</div>
</div>
{/* Installation */}
{getMonthlyPrice(selectedInstallation) > 0 || getOneTimePrice(selectedInstallation) > 0 ? (
<div className="border-t border-gray-200 pt-4 mb-6">
<h4 className="font-medium text-gray-900 mb-3">Installation</h4>
<div className="flex justify-between text-sm">
<span className="text-gray-600">{selectedInstallation.name}</span>
<span className="text-gray-900">
{getMonthlyPrice(selectedInstallation) > 0 && (
<>
¥{getMonthlyPrice(selectedInstallation).toLocaleString()}
<span className="text-xs text-gray-500 ml-1">/mo</span>
</>
)}
{getOneTimePrice(selectedInstallation) > 0 && (
<>
¥{getOneTimePrice(selectedInstallation).toLocaleString()}
<span className="text-xs text-gray-500 ml-1">/once</span>
</>
)}
</span>
</div>
</div>
) : null}
{/* Add-ons */}
{selectedAddons.length > 0 && (
<div className="border-t border-gray-200 pt-4 mb-6">
<h4 className="font-medium text-gray-900 mb-3">Add-ons</h4>
<div className="space-y-2">
{selectedAddons.map(addon => (
<div key={addon.sku} className="flex justify-between text-sm">
<span className="text-gray-600">{addon.name}</span>
<span className="text-gray-900">
{getMonthlyPrice(addon) > 0 && (
<>
¥{getMonthlyPrice(addon).toLocaleString()}
<span className="text-xs text-gray-500 ml-1">/mo</span>
</>
)}
{getOneTimePrice(addon) > 0 && (
<>
¥{getOneTimePrice(addon).toLocaleString()}
<span className="text-xs text-gray-500 ml-1">/once</span>
</>
)}
</span>
</div>
))}
</div>
</div>
)}
{/* Totals */}
<div className="border-t border-gray-200 pt-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Monthly Total:</span>
<span className="font-medium">¥{monthlyTotal.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">One-time Total:</span>
<span className="font-medium">¥{oneTimeTotal.toLocaleString()}</span>
</div>
<div className="flex justify-between text-lg font-semibold pt-2 border-t border-gray-200">
<span>Total First Month:</span>
<span>¥{(monthlyTotal + oneTimeTotal).toLocaleString()}</span>
<div className="border-t-2 border-dashed border-gray-300 pt-4 bg-gray-50 -mx-6 px-6 py-4 rounded-b-lg">
<div className="space-y-2">
<div className="flex justify-between text-xl font-bold">
<span className="text-gray-900">Monthly Total</span>
<span className="text-blue-600">¥{monthlyTotal.toLocaleString()}</span>
</div>
{oneTimeTotal > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-600">One-time Total</span>
<span className="text-orange-600 font-semibold">
¥{oneTimeTotal.toLocaleString()}
</span>
</div>
)}
</div>
</div>
</>
);
}
function OrderItem({
title,
subtitle,
monthlyPrice,
oneTimePrice,
}: {
title: string;
subtitle?: string;
monthlyPrice: number;
oneTimePrice: number;
}) {
return (
<div className="flex justify-between items-start">
<div className="flex-1">
<p className="font-medium text-gray-900">{title}</p>
{subtitle && <p className="text-sm text-gray-500">{subtitle}</p>}
</div>
<div className="text-right text-sm">
{monthlyPrice > 0 && (
<div className="text-gray-900">¥{monthlyPrice.toLocaleString()}/mo</div>
)}
{oneTimePrice > 0 && (
<div className="text-gray-600">¥{oneTimePrice.toLocaleString()} setup</div>
)}
{/* Receipt Footer */}
<div className="text-center mt-6 pt-4 border-t border-gray-200">
<p className="text-xs text-gray-500">High-speed internet service</p>
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { apiClient, getDataOrDefault } from "@/lib/api";
import { apiClient, getDataOrDefault, getDataOrThrow } from "@/lib/api";
import type {
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
@ -40,7 +40,7 @@ export const catalogService = {
addons: InternetAddonCatalogItem[];
}> {
const response = await apiClient.GET<typeof defaultInternetCatalog>("/api/catalog/internet/plans");
return getDataOrDefault<typeof defaultInternetCatalog>(response, defaultInternetCatalog);
return getDataOrThrow<typeof defaultInternetCatalog>(response, "Failed to load internet catalog");
},
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {

View File

@ -15,8 +15,9 @@ import { FeatureCard } from "@/features/catalog/components/common/FeatureCard";
export function CatalogHomeView() {
return (
<PageLayout icon={<></>} title="" description="">
<div className="max-w-6xl mx-auto">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
<PageLayout icon={<></>} title="" description="">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<div className="inline-flex items-center gap-2 bg-blue-50 text-blue-700 px-4 py-2 rounded-full text-sm font-medium mb-6">
<Squares2X2Icon className="h-4 w-4" />
@ -96,8 +97,9 @@ export function CatalogHomeView() {
/>
</div>
</div>
</div>
</PageLayout>
</div>
</PageLayout>
</div>
);
}

View File

@ -106,35 +106,56 @@ export function InternetPlansContainer() {
</div>
</AsyncBlock>
</PageLayout>
);
}
</div>
);
}
return (
<PageLayout
title="Internet Plans"
description="High-speed internet services for your home or business"
icon={<WifiIcon className="h-6 w-6" />}
>
<div className="max-w-6xl mx-auto">
<div className="mb-6">
<Button as="a" href="/catalog" variant="outline" size="sm" className="group">
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
Back to Services
</Button>
</div>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
<PageLayout
title="Internet Plans"
description="High-speed internet services for your home or business"
icon={<WifiIcon className="h-6 w-6" />}
>
<div className="max-w-6xl mx-auto">
{/* Enhanced Back Button */}
<div className="mb-8">
<Button
as="a"
href="/catalog"
variant="outline"
size="sm"
className="group bg-white/80 backdrop-blur-sm border-white/50 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-0.5"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back to Services
</Button>
</div>
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">Choose Your Internet Plan</h1>
{/* Enhanced Header */}
<div className="text-center mb-16 relative">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-20 -right-20 w-40 h-40 bg-gradient-to-br from-blue-400/10 to-indigo-600/10 rounded-full blur-3xl"></div>
<div className="absolute -bottom-20 -left-20 w-40 h-40 bg-gradient-to-tr from-indigo-400/10 to-purple-600/10 rounded-full blur-3xl"></div>
</div>
<h1 className="text-5xl font-bold bg-gradient-to-r from-gray-900 via-blue-900 to-indigo-900 bg-clip-text text-transparent mb-6 relative">
Choose Your Internet Plan
</h1>
<p className="text-xl text-gray-600 mb-8 max-w-3xl mx-auto leading-relaxed">
High-speed fiber internet with reliable connectivity for your home or business
</p>
{eligibility && (
<div className="mt-6">
<div className="mt-8">
<div
className={`inline-flex items-center gap-2 px-6 py-3 rounded-2xl border ${getEligibilityColor(eligibility)}`}
className={`inline-flex items-center gap-3 px-8 py-4 rounded-2xl border backdrop-blur-sm shadow-lg hover:shadow-xl transition-all duration-300 ${getEligibilityColor(eligibility)}`}
>
{getEligibilityIcon(eligibility)}
<span className="font-medium">Available for: {eligibility}</span>
<span className="font-semibold text-lg">Available for: {eligibility}</span>
</div>
<p className="text-sm text-gray-500 mt-2 max-w-2xl mx-auto">
<p className="text-gray-600 mt-4 max-w-2xl mx-auto leading-relaxed">
Plans shown are tailored to your house type and local infrastructure
</p>
</div>
@ -197,8 +218,11 @@ export function InternetPlansContainer() {
<p className="text-gray-600 mb-6">
We couldn&apos;t find any internet plans available for your location at this time.
</p>
<Button as="a" href="/catalog" className="flex items-center">
<ArrowLeftIcon className="w-4 h-4 mr-2" />
<Button
as="a"
href="/catalog"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back to Services
</Button>
</div>

View File

@ -109,8 +109,12 @@ export function SimPlansContainer() {
<div className="rounded-lg bg-red-50 border border-red-200 p-6">
<div className="text-red-800 font-medium">Failed to load SIM plans</div>
<div className="text-red-600 text-sm mt-1">{errorMessage}</div>
<Button as="a" href="/catalog" className="flex items-center mt-4">
<ArrowLeftIcon className="w-4 h-4 mr-2" />
<Button
as="a"
href="/catalog"
className="mt-4"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back to Services
</Button>
</div>
@ -130,22 +134,39 @@ export function SimPlansContainer() {
);
return (
<PageLayout
title="SIM Plans"
description="Choose your mobile plan with flexible options"
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-emerald-50 to-teal-50">
<PageLayout
title="SIM Plans"
description="Choose your mobile plan with flexible options"
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
>
<div className="max-w-6xl mx-auto px-4">
<div className="mb-6 flex justify-center">
<Button as="a" href="/catalog" variant="outline" size="sm" className="group">
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
{/* Enhanced Back Button */}
<div className="mb-8 flex justify-center">
<Button
as="a"
href="/catalog"
variant="outline"
size="sm"
className="group bg-white/80 backdrop-blur-sm border-white/50 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-0.5"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back to Services
</Button>
</div>
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">Choose Your SIM Plan</h1>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
{/* Enhanced Header */}
<div className="text-center mb-16 relative">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-20 -right-20 w-40 h-40 bg-gradient-to-br from-emerald-400/10 to-teal-600/10 rounded-full blur-3xl"></div>
<div className="absolute -bottom-20 -left-20 w-40 h-40 bg-gradient-to-tr from-teal-400/10 to-cyan-600/10 rounded-full blur-3xl"></div>
</div>
<h1 className="text-5xl font-bold bg-gradient-to-r from-gray-900 via-emerald-900 to-teal-900 bg-clip-text text-transparent mb-6 relative">
Choose Your SIM Plan
</h1>
<p className="text-xl text-gray-600 max-w-3xl mx-auto leading-relaxed">
Wide range of data options and voice plans with both physical SIM and eSIM options.
</p>
</div>
@ -371,7 +392,8 @@ export function SimPlansContainer() {
</div>
</AlertBanner>
</div>
</PageLayout>
</PageLayout>
</div>
);
}

View File

@ -16,44 +16,80 @@ export function VpnPlansView() {
if (isLoading || error) {
return (
<PageLayout
title="VPN Plans"
description="Loading plans..."
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<AsyncBlock
isLoading={isLoading}
error={error}
loadingText="Loading VPN plans..."
variant="page"
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-purple-50 to-indigo-50">
<PageLayout
title="VPN Plans"
description="Loading plans..."
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<></>
</AsyncBlock>
</PageLayout>
<div className="max-w-6xl mx-auto">
{/* Enhanced Back Button */}
<div className="mb-8">
<Button
as="a"
href="/catalog"
variant="outline"
size="sm"
className="group bg-white/80 backdrop-blur-sm border-white/50 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-0.5"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back to Services
</Button>
</div>
<AsyncBlock
isLoading={isLoading}
error={error}
loadingText="Loading VPN plans..."
variant="page"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{Array.from({ length: 4 }).map((_, index) => (
<LoadingCard key={index} className="h-64" />
))}
</div>
</AsyncBlock>
</div>
</PageLayout>
</div>
);
}
return (
<PageLayout
title="VPN Router Rental"
description="Secure VPN router rental"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-purple-50 to-indigo-50">
<PageLayout
title="VPN Router Rental"
description="Secure VPN router rental"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="max-w-6xl mx-auto">
<div className="mb-6">
<Button as="a" href="/catalog" variant="outline" size="sm" className="group">
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
{/* Enhanced Back Button */}
<div className="mb-8">
<Button
as="a"
href="/catalog"
variant="outline"
size="sm"
className="group bg-white/80 backdrop-blur-sm border-white/50 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-0.5"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back to Services
</Button>
</div>
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
SonixNet VPN Rental Router Service
{/* Enhanced Header */}
<div className="text-center mb-16 relative">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-20 -right-20 w-40 h-40 bg-gradient-to-br from-purple-400/10 to-indigo-600/10 rounded-full blur-3xl"></div>
<div className="absolute -bottom-20 -left-20 w-40 h-40 bg-gradient-to-tr from-indigo-400/10 to-violet-600/10 rounded-full blur-3xl"></div>
</div>
<h1 className="text-5xl font-bold bg-gradient-to-r from-gray-900 via-purple-900 to-indigo-900 bg-clip-text text-transparent mb-6 relative">
SonixNet VPN Router Service
</h1>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
Fast and secure VPN connection to San Francisco or London for accessing geo-restricted
content.
<p className="text-xl text-gray-600 max-w-3xl mx-auto leading-relaxed">
Fast and secure VPN connection to San Francisco or London for accessing geo-restricted content.
</p>
</div>
@ -82,8 +118,11 @@ export function VpnPlansView() {
<p className="text-gray-600 mb-6">
We couldn&apos;t find any VPN plans available at this time.
</p>
<Button as="a" href="/catalog" className="flex items-center">
<ArrowLeftIcon className="w-4 h-4 mr-2" />
<Button
as="a"
href="/catalog"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back to Services
</Button>
</div>
@ -121,7 +160,8 @@ export function VpnPlansView() {
streaming/browsing.
</AlertBanner>
</div>
</PageLayout>
</PageLayout>
</div>
);
}

View File

@ -61,7 +61,7 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
"/api/subscriptions",
status ? { params: { query: { status } } } : undefined
);
return toSubscriptionList(getNullableData<SubscriptionList>(response));
return toSubscriptionList(getDataOrThrow<SubscriptionList>(response, "Failed to load subscriptions"));
},
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
@ -79,7 +79,7 @@ export function useActiveSubscriptions() {
queryKey: queryKeys.subscriptions.active(),
queryFn: async () => {
const response = await apiClient.GET<Subscription[]>("/api/subscriptions/active");
return getDataOrDefault<Subscription[]>(response, []);
return getDataOrThrow<Subscription[]>(response, "Failed to load active subscriptions");
},
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
@ -97,7 +97,7 @@ export function useSubscriptionStats() {
queryKey: queryKeys.subscriptions.stats(),
queryFn: async () => {
const response = await apiClient.GET<typeof emptyStats>("/api/subscriptions/stats");
return getDataOrDefault<typeof emptyStats>(response, emptyStats);
return getDataOrThrow<typeof emptyStats>(response, "Failed to load subscription statistics");
},
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
@ -117,7 +117,7 @@ export function useSubscription(subscriptionId: number) {
const response = await apiClient.GET<Subscription>("/api/subscriptions/{id}", {
params: { path: { id: subscriptionId } },
});
return getDataOrThrow<Subscription>(response, "Subscription not found");
return getDataOrThrow<Subscription>(response, "Failed to load subscription details");
},
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
@ -144,13 +144,7 @@ export function useSubscriptionInvoices(
query: { page, limit },
},
});
return getDataOrDefault<InvoiceList>(response, {
...emptyInvoiceList,
pagination: {
...emptyInvoiceList.pagination,
page,
},
});
return getDataOrThrow<InvoiceList>(response, "Failed to load subscription invoices");
},
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,