diff --git a/apps/bff/src/modules/catalog/catalog.controller.ts b/apps/bff/src/modules/catalog/catalog.controller.ts index e687ef96..a5bdb2fc 100644 --- a/apps/bff/src/modules/catalog/catalog.controller.ts +++ b/apps/bff/src/modules/catalog/catalog.controller.ts @@ -26,13 +26,25 @@ export class CatalogController { @ApiOperation({ summary: "Get Internet plans filtered by customer eligibility" }) async getInternetPlans( @Request() req: { user: { id: string } } - ): Promise { + ): 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") diff --git a/apps/portal/src/components/atoms/button.tsx b/apps/portal/src/components/atoms/button.tsx index c6474aa2..ea883182 100644 --- a/apps/portal/src/components/atoms/button.tsx +++ b/apps/portal/src/components/atoms/button.tsx @@ -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((p > {loading ? : leftIcon} - {loading ? (loadingText ?? children) : children} - {!loading && rightIcon ? {rightIcon} : null} + {loading ? (loadingText ?? children) : children} + {!loading && rightIcon ? {rightIcon} : null} ); @@ -102,8 +102,8 @@ const Button = forwardRef((p > {loading ? : leftIcon} - {loading ? (loadingText ?? children) : children} - {!loading && rightIcon ? {rightIcon} : null} + {loading ? (loadingText ?? children) : children} + {!loading && rightIcon ? {rightIcon} : null} ); diff --git a/apps/portal/src/features/catalog/components/base/AddonGroup.tsx b/apps/portal/src/features/catalog/components/base/AddonGroup.tsx index 29b2e9fd..420847bf 100644 --- a/apps/portal/src/features/catalog/components/base/AddonGroup.tsx +++ b/apps/portal/src/features/catalog/components/base/AddonGroup.tsx @@ -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(); - + const processed = new Set(); + + // 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({ diff --git a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx index 1abf7e1b..85c88eb4 100644 --- a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx @@ -348,12 +348,12 @@ export function EnhancedOrderSummary({ variant="outline" className="flex-1 group" disabled={disabled || loading} + leftIcon={} onClick={() => { if (disabled || loading) return; router.push(backUrl); }} > - {backLabel} ) : onBack ? ( @@ -362,44 +362,22 @@ export function EnhancedOrderSummary({ variant="outline" className="flex-1 group" disabled={disabled || loading} + leftIcon={} > - {backLabel} ) : null} {onContinue && ( - )} diff --git a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx index c86a901e..731b8796 100644 --- a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx @@ -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 ? ( - + ) : null} {onContinue ? ( - + ) : null} ) : onContinue ? ( - + ) : null} )} diff --git a/apps/portal/src/features/catalog/components/base/ProductCard.tsx b/apps/portal/src/features/catalog/components/base/ProductCard.tsx index f931224c..d32d7559 100644 --- a/apps/portal/src/features/catalog/components/base/ProductCard.tsx +++ b/apps/portal/src/features/catalog/components/base/ProductCard.tsx @@ -154,18 +154,22 @@ export function ProductCard({ ) : onClick ? ( - ) : null} diff --git a/apps/portal/src/features/catalog/components/common/ServiceHeroCard.tsx b/apps/portal/src/features/catalog/components/common/ServiceHeroCard.tsx index 0574e4f3..3939d46e 100644 --- a/apps/portal/src/features/catalog/components/common/ServiceHeroCard.tsx +++ b/apps/portal/src/features/catalog/components/common/ServiceHeroCard.tsx @@ -78,9 +78,9 @@ export function ServiceHeroCard({ href={href} className="w-full font-semibold rounded-2xl relative z-10 group" size="lg" + rightIcon={} > - Explore Plans - + Explore Plans diff --git a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx index b3534cd8..b4351be2 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx @@ -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 (
@@ -129,15 +129,13 @@ export function InternetPlanCard({
diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx b/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx index bdc6533a..5cbf75fb 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx @@ -54,7 +54,7 @@ export function ReviewOrderStep({ />
-
+
-

Order Summary

+
+ {/* Receipt Header */} +
+

Order Summary

+

Review your configuration

+
{/* Plan Details */} -
- - - - - {selectedAddons.map(addon => ( - - ))} +
+
+
+

{plan.name}

+

Internet Service

+ {mode &&

Access Mode: {mode}

} +
+
+

+ ¥{getMonthlyPrice(plan).toLocaleString()} +

+

per month

+
+
+ {/* Installation */} + {getMonthlyPrice(selectedInstallation) > 0 || getOneTimePrice(selectedInstallation) > 0 ? ( +
+

Installation

+
+ {selectedInstallation.name} + + {getMonthlyPrice(selectedInstallation) > 0 && ( + <> + ¥{getMonthlyPrice(selectedInstallation).toLocaleString()} + /mo + + )} + {getOneTimePrice(selectedInstallation) > 0 && ( + <> + ¥{getOneTimePrice(selectedInstallation).toLocaleString()} + /once + + )} + +
+
+ ) : null} + + {/* Add-ons */} + {selectedAddons.length > 0 && ( +
+

Add-ons

+
+ {selectedAddons.map(addon => ( +
+ {addon.name} + + {getMonthlyPrice(addon) > 0 && ( + <> + ¥{getMonthlyPrice(addon).toLocaleString()} + /mo + + )} + {getOneTimePrice(addon) > 0 && ( + <> + ¥{getOneTimePrice(addon).toLocaleString()} + /once + + )} + +
+ ))} +
+
+ )} + {/* Totals */} -
-
- Monthly Total: - ¥{monthlyTotal.toLocaleString()} -
-
- One-time Total: - ¥{oneTimeTotal.toLocaleString()} -
-
- Total First Month: - ¥{(monthlyTotal + oneTimeTotal).toLocaleString()} +
+
+
+ Monthly Total + ¥{monthlyTotal.toLocaleString()} +
+ {oneTimeTotal > 0 && ( +
+ One-time Total + + ¥{oneTimeTotal.toLocaleString()} + +
+ )}
- - ); -} -function OrderItem({ - title, - subtitle, - monthlyPrice, - oneTimePrice, -}: { - title: string; - subtitle?: string; - monthlyPrice: number; - oneTimePrice: number; -}) { - return ( -
-
-

{title}

- {subtitle &&

{subtitle}

} -
-
- {monthlyPrice > 0 && ( -
¥{monthlyPrice.toLocaleString()}/mo
- )} - {oneTimePrice > 0 && ( -
¥{oneTimePrice.toLocaleString()} setup
- )} + {/* Receipt Footer */} +
+

High-speed internet service

); } + diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts index 0bf0c470..666fa3ba 100644 --- a/apps/portal/src/features/catalog/services/catalog.service.ts +++ b/apps/portal/src/features/catalog/services/catalog.service.ts @@ -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("/api/catalog/internet/plans"); - return getDataOrDefault(response, defaultInternetCatalog); + return getDataOrThrow(response, "Failed to load internet catalog"); }, async getInternetInstallations(): Promise { diff --git a/apps/portal/src/features/catalog/views/CatalogHome.tsx b/apps/portal/src/features/catalog/views/CatalogHome.tsx index ffeffdce..2f3b176b 100644 --- a/apps/portal/src/features/catalog/views/CatalogHome.tsx +++ b/apps/portal/src/features/catalog/views/CatalogHome.tsx @@ -15,8 +15,9 @@ import { FeatureCard } from "@/features/catalog/components/common/FeatureCard"; export function CatalogHomeView() { return ( - } title="" description=""> -
+
+ } title="" description=""> +
@@ -96,8 +97,9 @@ export function CatalogHomeView() { />
-
-
+
+ +
); } diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx index 21b7588b..018c0685 100644 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx @@ -106,35 +106,56 @@ export function InternetPlansContainer() {
- ); - } +
+ ); +} return ( - } - > -
-
- -
+
+ } + > +
+ {/* Enhanced Back Button */} +
+ +
-
-

Choose Your Internet Plan

+ {/* Enhanced Header */} +
+ {/* Background decoration */} +
+
+
+
+ +

+ Choose Your Internet Plan +

+

+ High-speed fiber internet with reliable connectivity for your home or business +

{eligibility && ( -
+
{getEligibilityIcon(eligibility)} - Available for: {eligibility} + Available for: {eligibility}
-

+

Plans shown are tailored to your house type and local infrastructure

@@ -197,8 +218,11 @@ export function InternetPlansContainer() {

We couldn't find any internet plans available for your location at this time.

-
diff --git a/apps/portal/src/features/catalog/views/SimPlans.tsx b/apps/portal/src/features/catalog/views/SimPlans.tsx index 2d53f3ac..a5f2846a 100644 --- a/apps/portal/src/features/catalog/views/SimPlans.tsx +++ b/apps/portal/src/features/catalog/views/SimPlans.tsx @@ -109,8 +109,12 @@ export function SimPlansContainer() {
Failed to load SIM plans
{errorMessage}
-
@@ -130,22 +134,39 @@ export function SimPlansContainer() { ); return ( - } - > +
+ } + >
-
-
-
-

Choose Your SIM Plan

-

+ {/* Enhanced Header */} +

+ {/* Background decoration */} +
+
+
+
+ +

+ Choose Your SIM Plan +

+

Wide range of data options and voice plans with both physical SIM and eSIM options.

@@ -371,7 +392,8 @@ export function SimPlansContainer() {
-
+ +
); } diff --git a/apps/portal/src/features/catalog/views/VpnPlans.tsx b/apps/portal/src/features/catalog/views/VpnPlans.tsx index 0ca2fbcb..be100c6f 100644 --- a/apps/portal/src/features/catalog/views/VpnPlans.tsx +++ b/apps/portal/src/features/catalog/views/VpnPlans.tsx @@ -16,44 +16,80 @@ export function VpnPlansView() { if (isLoading || error) { return ( - } - > - + } > - <> - - +
+ {/* Enhanced Back Button */} +
+ +
+ + +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+
+
+
); } return ( - } - > +
+ } + >
-
-
-
-

- SonixNet VPN Rental Router Service + {/* Enhanced Header */} +
+ {/* Background decoration */} +
+
+
+
+ +

+ SonixNet VPN Router Service

-

- Fast and secure VPN connection to San Francisco or London for accessing geo-restricted - content. +

+ Fast and secure VPN connection to San Francisco or London for accessing geo-restricted content.

@@ -82,8 +118,11 @@ export function VpnPlansView() {

We couldn't find any VPN plans available at this time.

-

@@ -121,7 +160,8 @@ export function VpnPlansView() { streaming/browsing.
-
+ +
); } diff --git a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts index 24f491b4..d592210e 100644 --- a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts +++ b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts @@ -61,7 +61,7 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) { "/api/subscriptions", status ? { params: { query: { status } } } : undefined ); - return toSubscriptionList(getNullableData(response)); + return toSubscriptionList(getDataOrThrow(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("/api/subscriptions/active"); - return getDataOrDefault(response, []); + return getDataOrThrow(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("/api/subscriptions/stats"); - return getDataOrDefault(response, emptyStats); + return getDataOrThrow(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("/api/subscriptions/{id}", { params: { path: { id: subscriptionId } }, }); - return getDataOrThrow(response, "Subscription not found"); + return getDataOrThrow(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(response, { - ...emptyInvoiceList, - pagination: { - ...emptyInvoiceList.pagination, - page, - }, - }); + return getDataOrThrow(response, "Failed to load subscription invoices"); }, staleTime: 60 * 1000, gcTime: 5 * 60 * 1000,