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:
parent
50d8fdfdd1
commit
a102f362e2
@ -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")
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 &&
|
if (partner && !processed.has(partner.sku)) {
|
||||||
typeof candidate.raw === "object" &&
|
// Create bundle
|
||||||
"Id" in candidate.raw &&
|
const bundle = createBundle(addon, partner);
|
||||||
candidate.raw.Id === addon.bundledAddonId
|
groups.push(bundle);
|
||||||
);
|
processed.add(addon.sku);
|
||||||
|
processed.add(partner.sku);
|
||||||
if (partner) {
|
continue;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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[]> {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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't find any internet plans available for your location at this time.
|
We couldn'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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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't find any VPN plans available at this time.
|
We couldn'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user