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" }) @ApiOperation({ summary: "Get Internet plans filtered by customer eligibility" })
async getInternetPlans( async getInternetPlans(
@Request() req: { user: { id: string } } @Request() req: { user: { id: string } }
): Promise<InternetPlanCatalogItem[]> { ): Promise<{
plans: InternetPlanCatalogItem[];
installations: InternetInstallationCatalogItem[];
addons: InternetAddonCatalogItem[];
}> {
const userId = req.user?.id; const userId = req.user?.id;
if (!userId) { if (!userId) {
// Fallback to all plans if no user context // Fallback to all catalog data if no user context
return this.internetCatalog.getPlans(); 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") @Get("internet/addons")

View File

@ -5,21 +5,21 @@ import { cn } from "@/lib/utils";
import { Spinner } from "./Spinner"; import { Spinner } from "./Spinner";
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
default: "bg-blue-600 text-white hover:bg-blue-700", 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", 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", 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", secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 shadow-sm hover:shadow-md",
ghost: "hover:bg-gray-100", ghost: "hover:bg-gray-100 hover:shadow-sm",
link: "underline-offset-4 hover:underline text-blue-600", link: "underline-offset-4 hover:underline text-blue-600",
}, },
size: { size: {
default: "h-10 py-2 px-4", default: "h-11 py-2.5 px-4",
sm: "h-9 px-3 rounded-md", sm: "h-9 px-3 text-xs",
lg: "h-11 px-8 rounded-md", lg: "h-12 px-6 text-base",
}, },
}, },
defaultVariants: { defaultVariants: {
@ -77,8 +77,8 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
> >
<span className="inline-flex items-center justify-center gap-2"> <span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon} {loading ? <Spinner size="sm" /> : leftIcon}
<span>{loading ? (loadingText ?? children) : children}</span> <span className="flex-1">{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null} {!loading && rightIcon ? <span className="transition-transform duration-200 group-hover:translate-x-0.5">{rightIcon}</span> : null}
</span> </span>
</a> </a>
); );
@ -102,8 +102,8 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
> >
<span className="inline-flex items-center justify-center gap-2"> <span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon} {loading ? <Spinner size="sm" /> : leftIcon}
<span>{loading ? (loadingText ?? children) : children}</span> <span className="flex-1">{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null} {!loading && rightIcon ? <span className="transition-transform duration-200 group-hover:translate-x-0.5">{rightIcon}</span> : null}
</span> </span>
</button> </button>
); );

View File

@ -6,7 +6,7 @@ import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
interface AddonGroupProps { interface AddonGroupProps {
addons: Array< addons: Array<
CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean; raw?: unknown } CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean }
>; >;
selectedAddonSkus: string[]; selectedAddonSkus: string[];
onAddonToggle: (skus: string[]) => void; onAddonToggle: (skus: string[]) => void;
@ -26,68 +26,71 @@ type BundledAddonGroup = {
function buildGroupedAddons( function buildGroupedAddons(
addons: Array< addons: Array<
CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean; raw?: unknown } CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean }
> >
): BundledAddonGroup[] { ): BundledAddonGroup[] {
const groups: 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)); const sorted = [...addons].sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
sorted.forEach(addon => { for (const addon of sorted) {
if (processedSkus.has(addon.sku)) return; if (processed.has(addon.sku)) continue;
// Try to find bundle partner
if (addon.isBundledAddon && addon.bundledAddonId) { if (addon.isBundledAddon && addon.bundledAddonId) {
const partner = sorted.find( const partner = sorted.find(candidate => candidate.id === addon.bundledAddonId);
candidate =>
candidate.raw &&
typeof candidate.raw === "object" &&
"Id" in candidate.raw &&
candidate.raw.Id === addon.bundledAddonId
);
if (partner) { if (partner && !processed.has(partner.sku)) {
const monthlyAddon = addon.billingCycle === "Monthly" ? addon : partner; // Create bundle
const activationAddon = addon.billingCycle === "Onetime" ? addon : partner; const bundle = createBundle(addon, partner);
groups.push(bundle);
const name = processed.add(addon.sku);
monthlyAddon.name.replace(/\s*(Monthly|Installation|Fee)\s*/gi, "").trim() || addon.name; processed.add(partner.sku);
continue;
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;
} }
} }
groups.push({ // Create standalone item
id: addon.sku, groups.push(createStandaloneItem(addon));
name: addon.name, processed.add(addon.sku);
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);
});
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({ export function AddonGroup({

View File

@ -348,12 +348,12 @@ export function EnhancedOrderSummary({
variant="outline" variant="outline"
className="flex-1 group" className="flex-1 group"
disabled={disabled || loading} disabled={disabled || loading}
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
onClick={() => { onClick={() => {
if (disabled || loading) return; if (disabled || loading) return;
router.push(backUrl); router.push(backUrl);
}} }}
> >
<ArrowLeftIcon className="w-4 h-4 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
{backLabel} {backLabel}
</Button> </Button>
) : onBack ? ( ) : onBack ? (
@ -362,44 +362,22 @@ export function EnhancedOrderSummary({
variant="outline" variant="outline"
className="flex-1 group" className="flex-1 group"
disabled={disabled || loading} 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} {backLabel}
</Button> </Button>
) : null} ) : null}
{onContinue && ( {onContinue && (
<Button onClick={onContinue} className="flex-1 group" disabled={disabled || loading}> <Button
{loading ? ( onClick={onContinue}
<span className="flex items-center justify-center"> className="flex-1 group"
<svg disabled={disabled || loading}
className="animate-spin -ml-1 mr-3 h-4 w-4 text-white" loading={loading}
xmlns="http://www.w3.org/2000/svg" loadingText="Processing..."
fill="none" rightIcon={!loading ? <ArrowRightIcon className="w-4 h-4" /> : undefined}
viewBox="0 0 24 24" >
> {continueLabel}
<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> </Button>
)} )}
</div> </div>

View File

@ -1,6 +1,7 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import type { CatalogProductBase } from "@customer-portal/domain"; import type { CatalogProductBase } from "@customer-portal/domain";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/atoms/button";
import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing"; import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
interface OrderSummaryProps { interface OrderSummaryProps {
@ -237,41 +238,40 @@ export function OrderSummary({
{variant === "simple" ? ( {variant === "simple" ? (
<> <>
{backUrl ? ( {backUrl ? (
<button <Button
type="button" variant="outline"
className="flex-1"
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
onClick={() => { onClick={() => {
if (!disabled) router.push(backUrl); if (!disabled) router.push(backUrl);
}} }}
disabled={disabled} 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} {backLabel}
</button> </Button>
) : null} ) : null}
{onContinue ? ( {onContinue ? (
<button <Button
type="button" className="flex-1"
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
onClick={onContinue} onClick={onContinue}
disabled={disabled} 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} {continueLabel}
<ArrowRightIcon className="h-5 w-5" /> </Button>
</button>
) : null} ) : null}
</> </>
) : onContinue ? ( ) : onContinue ? (
<button <Button
type="button" size="lg"
className="w-full mt-8 group text-lg font-bold"
rightIcon={<ArrowRightIcon className="w-5 h-5" />}
onClick={onContinue} onClick={onContinue}
disabled={disabled} 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} {continueLabel}
<ArrowRightIcon className="w-6 h-6 ml-3 group-hover:translate-x-1 transition-transform" /> </Button>
</button>
) : null} ) : null}
</div> </div>
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,8 +15,9 @@ import { FeatureCard } from "@/features/catalog/components/common/FeatureCard";
export function CatalogHomeView() { export function CatalogHomeView() {
return ( return (
<PageLayout icon={<></>} title="" description=""> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
<div className="max-w-6xl mx-auto"> <PageLayout icon={<></>} title="" description="">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16"> <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"> <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" /> <Squares2X2Icon className="h-4 w-4" />
@ -96,8 +97,9 @@ export function CatalogHomeView() {
/> />
</div> </div>
</div> </div>
</div> </div>
</PageLayout> </PageLayout>
</div>
); );
} }

View File

@ -106,35 +106,56 @@ export function InternetPlansContainer() {
</div> </div>
</AsyncBlock> </AsyncBlock>
</PageLayout> </PageLayout>
); </div>
} );
}
return ( return (
<PageLayout <div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
title="Internet Plans" <PageLayout
description="High-speed internet services for your home or business" title="Internet Plans"
icon={<WifiIcon className="h-6 w-6" />} 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"> <div className="max-w-6xl mx-auto">
<Button as="a" href="/catalog" variant="outline" size="sm" className="group"> {/* Enhanced Back Button */}
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" /> <div className="mb-8">
Back to Services <Button
</Button> as="a"
</div> 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"> {/* Enhanced Header */}
<h1 className="text-4xl font-bold text-gray-900 mb-4">Choose Your Internet Plan</h1> <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 && ( {eligibility && (
<div className="mt-6"> <div className="mt-8">
<div <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)} {getEligibilityIcon(eligibility)}
<span className="font-medium">Available for: {eligibility}</span> <span className="font-semibold text-lg">Available for: {eligibility}</span>
</div> </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 Plans shown are tailored to your house type and local infrastructure
</p> </p>
</div> </div>
@ -197,8 +218,11 @@ export function InternetPlansContainer() {
<p className="text-gray-600 mb-6"> <p className="text-gray-600 mb-6">
We couldn&apos;t find any internet plans available for your location at this time. We couldn&apos;t find any internet plans available for your location at this time.
</p> </p>
<Button as="a" href="/catalog" className="flex items-center"> <Button
<ArrowLeftIcon className="w-4 h-4 mr-2" /> as="a"
href="/catalog"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back to Services Back to Services
</Button> </Button>
</div> </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="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-800 font-medium">Failed to load SIM plans</div>
<div className="text-red-600 text-sm mt-1">{errorMessage}</div> <div className="text-red-600 text-sm mt-1">{errorMessage}</div>
<Button as="a" href="/catalog" className="flex items-center mt-4"> <Button
<ArrowLeftIcon className="w-4 h-4 mr-2" /> as="a"
href="/catalog"
className="mt-4"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back to Services Back to Services
</Button> </Button>
</div> </div>
@ -130,22 +134,39 @@ export function SimPlansContainer() {
); );
return ( return (
<PageLayout <div className="min-h-screen bg-gradient-to-br from-slate-50 via-emerald-50 to-teal-50">
title="SIM Plans" <PageLayout
description="Choose your mobile plan with flexible options" title="SIM Plans"
icon={<DevicePhoneMobileIcon className="h-6 w-6" />} 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="max-w-6xl mx-auto px-4">
<div className="mb-6 flex justify-center"> {/* Enhanced Back Button */}
<Button as="a" href="/catalog" variant="outline" size="sm" className="group"> <div className="mb-8 flex justify-center">
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" /> <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 Back to Services
</Button> </Button>
</div> </div>
<div className="text-center mb-12"> {/* Enhanced Header */}
<h1 className="text-4xl font-bold text-gray-900 mb-4">Choose Your SIM Plan</h1> <div className="text-center mb-16 relative">
<p className="text-xl text-gray-600 max-w-3xl mx-auto"> {/* 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. Wide range of data options and voice plans with both physical SIM and eSIM options.
</p> </p>
</div> </div>
@ -371,7 +392,8 @@ export function SimPlansContainer() {
</div> </div>
</AlertBanner> </AlertBanner>
</div> </div>
</PageLayout> </PageLayout>
</div>
); );
} }

View File

@ -16,44 +16,80 @@ export function VpnPlansView() {
if (isLoading || error) { if (isLoading || error) {
return ( return (
<PageLayout <div className="min-h-screen bg-gradient-to-br from-slate-50 via-purple-50 to-indigo-50">
title="VPN Plans" <PageLayout
description="Loading plans..." title="VPN Plans"
icon={<ShieldCheckIcon className="h-6 w-6" />} description="Loading plans..."
> icon={<ShieldCheckIcon className="h-6 w-6" />}
<AsyncBlock
isLoading={isLoading}
error={error}
loadingText="Loading VPN plans..."
variant="page"
> >
<></> <div className="max-w-6xl mx-auto">
</AsyncBlock> {/* Enhanced Back Button */}
</PageLayout> <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 ( return (
<PageLayout <div className="min-h-screen bg-gradient-to-br from-slate-50 via-purple-50 to-indigo-50">
title="VPN Router Rental" <PageLayout
description="Secure VPN router rental" title="VPN Router Rental"
icon={<ShieldCheckIcon className="h-6 w-6" />} description="Secure VPN router rental"
> icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="mb-6"> {/* Enhanced Back Button */}
<Button as="a" href="/catalog" variant="outline" size="sm" className="group"> <div className="mb-8">
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" /> <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 Back to Services
</Button> </Button>
</div> </div>
<div className="text-center mb-12"> {/* Enhanced Header */}
<h1 className="text-4xl font-bold text-gray-900 mb-4"> <div className="text-center mb-16 relative">
SonixNet VPN Rental Router Service {/* 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> </h1>
<p className="text-xl text-gray-600 max-w-3xl mx-auto"> <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 Fast and secure VPN connection to San Francisco or London for accessing geo-restricted content.
content.
</p> </p>
</div> </div>
@ -82,8 +118,11 @@ export function VpnPlansView() {
<p className="text-gray-600 mb-6"> <p className="text-gray-600 mb-6">
We couldn&apos;t find any VPN plans available at this time. We couldn&apos;t find any VPN plans available at this time.
</p> </p>
<Button as="a" href="/catalog" className="flex items-center"> <Button
<ArrowLeftIcon className="w-4 h-4 mr-2" /> as="a"
href="/catalog"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back to Services Back to Services
</Button> </Button>
</div> </div>
@ -121,7 +160,8 @@ export function VpnPlansView() {
streaming/browsing. streaming/browsing.
</AlertBanner> </AlertBanner>
</div> </div>
</PageLayout> </PageLayout>
</div>
); );
} }

View File

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