barsa 0f8435e6bd Update Documentation and Refactor Service Structure
- Revised README and documentation links to reflect updated paths and improve clarity on service offerings.
- Refactored service components to enhance organization and maintainability, including updates to the Internet and SIM offerings.
- Improved user navigation and experience in service-related views by streamlining component structures and enhancing data handling.
- Updated internal documentation to align with recent changes in service architecture and eligibility processes.
2025-12-25 15:48:57 +09:00

754 lines
26 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Server, CheckCircle, Clock, TriangleAlert, MapPin } from "lucide-react";
import { useAccountInternetCatalog } from "@/features/services/hooks";
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
import type {
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
} from "@customer-portal/domain/services";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { InternetImportantNotes } from "@/features/services/components/internet/InternetImportantNotes";
import {
InternetOfferingCard,
type TierInfo,
} from "@/features/services/components/internet/InternetOfferingCard";
import { PublicInternetPlansContent } from "@/features/services/views/PublicInternetPlans";
import { PlanComparisonGuide } from "@/features/services/components/internet/PlanComparisonGuide";
import {
useInternetEligibility,
useRequestInternetEligibilityCheck,
} from "@/features/services/hooks";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { cn } from "@/lib/utils";
type AutoRequestStatus = "idle" | "submitting" | "submitted" | "failed" | "missing_address";
// Offering configuration for display
interface OfferingConfig {
offeringType: string;
title: string;
speedBadge: string;
description: string;
iconType: "home" | "apartment";
isPremium: boolean;
displayOrder: number;
isAlternative?: boolean;
alternativeNote?: string;
}
const OFFERING_CONFIGS: Record<string, Omit<OfferingConfig, "offeringType">> = {
"Home 10G": {
title: "Home 10Gbps",
speedBadge: "10 Gbps",
description: "Ultra-fast fiber with the highest speeds available in Japan.",
iconType: "home",
isPremium: true,
displayOrder: 1,
},
"Home 1G": {
title: "Home 1Gbps",
speedBadge: "1 Gbps",
description: "High-speed fiber. The most popular choice for home internet.",
iconType: "home",
isPremium: false,
displayOrder: 2,
},
"Apartment 1G": {
title: "Apartment 1Gbps",
speedBadge: "1 Gbps",
description: "High-speed fiber-to-the-unit for mansions and apartment buildings.",
iconType: "apartment",
isPremium: false,
displayOrder: 1,
},
"Apartment 100M": {
title: "Apartment 100Mbps",
speedBadge: "100 Mbps",
description: "Standard speed via VDSL or LAN for apartment buildings.",
iconType: "apartment",
isPremium: false,
displayOrder: 2,
},
};
function getTierInfo(plans: InternetPlanCatalogItem[], offeringType: string): TierInfo[] {
const filtered = plans.filter(p => p.internetOfferingType === offeringType);
const tierOrder: ("Silver" | "Gold" | "Platinum")[] = ["Silver", "Gold", "Platinum"];
const tierDescriptions: Record<
string,
{ description: string; features: string[]; pricingNote?: string }
> = {
Silver: {
description: "Essential setup—bring your own router",
features: ["NTT modem + ISP connection", "IPoE or PPPoE protocols", "Self-configuration"],
},
Gold: {
description: "All-inclusive with router rental",
features: [
"Everything in Silver",
"WiFi router included",
"Auto-configured",
"Range extender option",
],
},
Platinum: {
description: "Tailored setup for larger homes",
features: [
"Netgear INSIGHT mesh routers",
"Cloud-managed WiFi",
"Remote support",
"Custom setup",
],
pricingNote: "+ equipment fees",
},
};
const result: TierInfo[] = [];
for (const tier of tierOrder) {
const plan = filtered.find(p => p.internetPlanTier?.toLowerCase() === tier.toLowerCase());
if (!plan) continue;
const config = tierDescriptions[tier];
result.push({
tier,
planSku: plan.sku,
monthlyPrice: plan.monthlyPrice ?? 0,
description: config.description,
features: config.features,
recommended: tier === "Gold",
pricingNote: config.pricingNote,
});
}
return result;
}
function getSetupFee(installations: InternetInstallationCatalogItem[]): number {
const basic = installations.find(i => i.sku?.toLowerCase().includes("basic"));
return basic?.oneTimePrice ?? 22800;
}
function getAvailableOfferings(
eligibility: string | null,
plans: InternetPlanCatalogItem[]
): OfferingConfig[] {
if (!eligibility) return [];
const results: OfferingConfig[] = [];
const eligibilityLower = eligibility.toLowerCase();
if (eligibilityLower.includes("home 10g")) {
const config10g = OFFERING_CONFIGS["Home 10G"];
const config1g = OFFERING_CONFIGS["Home 1G"];
if (config10g && plans.some(p => p.internetOfferingType === "Home 10G")) {
results.push({ offeringType: "Home 10G", ...config10g });
}
if (config1g && plans.some(p => p.internetOfferingType === "Home 1G")) {
results.push({
offeringType: "Home 1G",
...config1g,
isAlternative: true,
alternativeNote: "Lower monthly cost option",
});
}
} else if (eligibilityLower.includes("home 1g")) {
const config = OFFERING_CONFIGS["Home 1G"];
if (config && plans.some(p => p.internetOfferingType === "Home 1G")) {
results.push({ offeringType: "Home 1G", ...config });
}
} else if (eligibilityLower.includes("apartment 1g")) {
const config = OFFERING_CONFIGS["Apartment 1G"];
if (config && plans.some(p => p.internetOfferingType === "Apartment 1G")) {
results.push({ offeringType: "Apartment 1G", ...config });
}
} else if (eligibilityLower.includes("apartment 100m")) {
const config = OFFERING_CONFIGS["Apartment 100M"];
if (config && plans.some(p => p.internetOfferingType === "Apartment 100M")) {
results.push({ offeringType: "Apartment 100M", ...config });
}
}
return results.sort((a, b) => a.displayOrder - b.displayOrder);
}
function formatEligibilityDisplay(eligibility: string): {
residenceType: "home" | "apartment";
speed: string;
label: string;
description: string;
} {
const lower = eligibility.toLowerCase();
if (lower.includes("home 10g")) {
return {
residenceType: "home",
speed: "10 Gbps",
label: "Standalone House (10Gbps available)",
description:
"Your address supports our fastest 10Gbps service. You can also choose 1Gbps for lower monthly cost.",
};
}
if (lower.includes("home 1g")) {
return {
residenceType: "home",
speed: "1 Gbps",
label: "Standalone House (1Gbps)",
description: "Your address supports high-speed 1Gbps fiber connection.",
};
}
if (lower.includes("apartment 1g")) {
return {
residenceType: "apartment",
speed: "1 Gbps",
label: "Apartment/Mansion (1Gbps FTTH)",
description: "Your building has fiber-to-the-unit infrastructure supporting 1Gbps speeds.",
};
}
if (lower.includes("apartment 100m")) {
return {
residenceType: "apartment",
speed: "100 Mbps",
label: "Apartment/Mansion (100Mbps)",
description: "Your building uses VDSL or LAN infrastructure with up to 100Mbps speeds.",
};
}
return {
residenceType: "home",
speed: eligibility,
label: eligibility,
description: "Service is available at your address.",
};
}
// Status badge component
function EligibilityStatusBadge({
status,
speed,
}: {
status: "eligible" | "pending" | "not_requested" | "ineligible";
speed?: string;
}) {
const configs = {
eligible: {
icon: CheckCircle,
bg: "bg-success-soft",
border: "border-success/30",
text: "text-success",
label: "Service Available",
},
pending: {
icon: Clock,
bg: "bg-info-soft",
border: "border-info/30",
text: "text-info",
label: "Review in Progress",
},
not_requested: {
icon: MapPin,
bg: "bg-muted",
border: "border-border",
text: "text-muted-foreground",
label: "Verification Required",
},
ineligible: {
icon: TriangleAlert,
bg: "bg-warning/10",
border: "border-warning/30",
text: "text-warning",
label: "Not Available",
},
};
const config = configs[status];
const Icon = config.icon;
return (
<div
className={cn(
"inline-flex items-center gap-2 px-4 py-2 rounded-full border",
config.bg,
config.border
)}
>
<Icon className={cn("h-4 w-4", config.text)} />
<span className={cn("font-semibold text-sm", config.text)}>{config.label}</span>
{status === "eligible" && speed && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-sm text-foreground font-medium">Up to {speed}</span>
</>
)}
</div>
);
}
export function InternetPlansContainer() {
const router = useRouter();
const servicesBasePath = useServicesBasePath();
const searchParams = useSearchParams();
const { user } = useAuthSession();
const { data, isLoading, error } = useAccountInternetCatalog();
const eligibilityQuery = useInternetEligibility();
const eligibilityLoading = eligibilityQuery.isLoading;
const refetchEligibility = eligibilityQuery.refetch;
const eligibilityRequest = useRequestInternetEligibilityCheck();
const submitEligibilityRequest = eligibilityRequest.mutateAsync;
const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]);
const installations: InternetInstallationCatalogItem[] = useMemo(
() => data?.installations ?? [],
[data?.installations]
);
const { data: activeSubs } = useActiveSubscriptions();
const hasActiveInternet = useMemo(
() =>
Array.isArray(activeSubs)
? activeSubs.some(
s =>
String(s.productName || "")
.toLowerCase()
.includes("sonixnet via ntt optical fiber") &&
String(s.status || "").toLowerCase() === "active"
)
: false,
[activeSubs]
);
const eligibilityValue = eligibilityQuery.data?.eligibility;
const eligibilityStatus = eligibilityQuery.data?.status;
const requestedAt = eligibilityQuery.data?.requestedAt;
const rejectionNotes = eligibilityQuery.data?.notes;
const isEligible =
eligibilityStatus === "eligible" &&
typeof eligibilityValue === "string" &&
eligibilityValue.trim().length > 0;
const isPending = eligibilityStatus === "pending";
const isNotRequested = eligibilityStatus === "not_requested";
const isIneligible = eligibilityStatus === "ineligible";
const hasServiceAddress = Boolean(
user?.address?.address1 &&
user?.address?.city &&
user?.address?.postcode &&
(user?.address?.country || user?.address?.countryCode)
);
const autoEligibilityRequest = searchParams?.get("autoEligibilityRequest") === "1";
const autoPlanSku = searchParams?.get("planSku");
const [autoRequestStatus, setAutoRequestStatus] = useState<AutoRequestStatus>("idle");
const [autoRequestId, setAutoRequestId] = useState<string | null>(null);
const addressLabel = useMemo(() => {
const a = user?.address;
if (!a) return "";
return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode]
.filter(Boolean)
.map(part => String(part).trim())
.filter(part => part.length > 0)
.join(", ");
}, [user?.address]);
const eligibility = useMemo(() => {
if (!isEligible) return null;
return eligibilityValue?.trim() ?? null;
}, [eligibilityValue, isEligible]);
const setupFee = useMemo(() => getSetupFee(installations), [installations]);
const availableOfferings = useMemo(() => {
if (!eligibility) return [];
return getAvailableOfferings(eligibility, plans);
}, [eligibility, plans]);
const eligibilityDisplay = useMemo(() => {
if (!eligibility) return null;
return formatEligibilityDisplay(eligibility);
}, [eligibility]);
const offeringCards = useMemo(() => {
return availableOfferings
.map(config => {
const tiers = getTierInfo(plans, config.offeringType);
const startingPrice = tiers.length > 0 ? Math.min(...tiers.map(t => t.monthlyPrice)) : 0;
return {
...config,
tiers,
startingPrice,
setupFee,
ctaPath: `${servicesBasePath}/internet/configure`,
};
})
.filter(card => card.tiers.length > 0);
}, [availableOfferings, plans, setupFee, servicesBasePath]);
// Logic to handle check availability click
const handleCheckAvailability = async (e?: React.MouseEvent) => {
if (e) e.preventDefault();
if (!hasServiceAddress) {
// Should redirect to address page if not handled by parent UI
router.push("/account/settings");
return;
}
// Trigger eligibility check
const confirmed =
typeof window === "undefined" ||
window.confirm(`Request availability check for:\n\n${addressLabel}`);
if (!confirmed) return;
setAutoRequestId(null);
setAutoRequestStatus("submitting");
try {
const result = await submitEligibilityRequest({ address: user?.address ?? undefined });
setAutoRequestId(result.requestId ?? null);
setAutoRequestStatus("submitted");
await refetchEligibility();
const query = result.requestId ? `?requestId=${encodeURIComponent(result.requestId)}` : "";
router.push(`${servicesBasePath}/internet/request-submitted${query}`);
} catch {
setAutoRequestStatus("failed");
}
};
// Auto eligibility request effect
useEffect(() => {
if (!autoEligibilityRequest) return;
if (autoRequestStatus !== "idle") return;
if (eligibilityLoading) return;
if (!isNotRequested) {
router.replace(`${servicesBasePath}/internet`);
return;
}
if (!hasServiceAddress) {
setAutoRequestStatus("missing_address");
router.replace(`${servicesBasePath}/internet`);
return;
}
const submit = async () => {
setAutoRequestStatus("submitting");
try {
const notes = autoPlanSku
? `Requested after signup. Selected plan SKU: ${autoPlanSku}`
: "Requested after signup.";
const result = await submitEligibilityRequest({
address: user?.address ?? undefined,
notes,
});
setAutoRequestId(result.requestId ?? null);
setAutoRequestStatus("submitted");
await refetchEligibility();
const query = result.requestId ? `?requestId=${encodeURIComponent(result.requestId)}` : "";
router.replace(`${servicesBasePath}/internet/request-submitted${query}`);
return;
} catch {
setAutoRequestStatus("failed");
}
router.replace(`${servicesBasePath}/internet`);
};
void submit();
}, [
autoEligibilityRequest,
autoPlanSku,
autoRequestStatus,
eligibilityLoading,
refetchEligibility,
submitEligibilityRequest,
hasServiceAddress,
isNotRequested,
servicesBasePath,
user?.address,
router,
]);
// Loading state
if (isLoading || error) {
return (
<div className="max-w-4xl mx-auto px-4 pt-8">
<AsyncBlock isLoading={false} error={error}>
<div className="max-w-4xl mx-auto px-4">
<ServicesBackLink href={servicesBasePath} label="Back to Services" className="mb-4" />
<div className="text-center mb-12">
<Skeleton className="h-10 w-96 mx-auto mb-4" />
<Skeleton className="h-4 w-[32rem] max-w-full mx-auto" />
</div>
<div className="space-y-4">
{[1, 2].map(i => (
<div
key={i}
className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]"
>
<div className="flex items-start gap-4">
<Skeleton className="h-12 w-12 rounded-xl" />
<div className="flex-1 space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
<Skeleton className="h-6 w-32" />
</div>
</div>
</div>
))}
</div>
</div>
</AsyncBlock>
</div>
);
}
// Determine current status for the badge
const currentStatus = isEligible
? "eligible"
: isPending
? "pending"
: isIneligible
? "ineligible"
: "not_requested";
// Case 1: Unverified / Not Requested - Show Public Content exactly
if (isNotRequested && autoRequestStatus !== "submitting" && autoRequestStatus !== "submitted") {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20 pt-8">
{/* Already has internet warning */}
{hasActiveInternet && (
<AlertBanner variant="warning" title="Active subscription found" className="mb-6">
You already have an internet subscription. For additional residences, please{" "}
<a href="/account/support/new" className="underline font-medium">
contact support
</a>
.
</AlertBanner>
)}
{/* Auto-request status alerts - only show for errors/success */}
{autoRequestStatus === "failed" && (
<AlertBanner variant="warning" title="Request failed" className="mb-6">
Please try again below or contact support.
</AlertBanner>
)}
{autoRequestStatus === "missing_address" && (
<AlertBanner variant="warning" title="Address required" className="mb-6">
<div className="flex items-center justify-between gap-4">
<span>Add your service address to request availability verification.</span>
<Button as="a" href="/account/settings" size="sm">
Add address
</Button>
</div>
</AlertBanner>
)}
<PublicInternetPlansContent
onCtaClick={handleCheckAvailability}
ctaLabel={hasServiceAddress ? "Check Availability" : "Add Service Address"}
/>
</div>
);
}
// Case 2: Standard Portal View (Pending, Eligible, Ineligible, Loading)
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20 pt-8">
<ServicesBackLink href={servicesBasePath} label="Back to Services" className="mb-6" />
{/* Hero section - compact (for portal view) */}
<div className="text-center mb-8">
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-2">
Your Internet Options
</h1>
<p className="text-muted-foreground mb-4">
Plans tailored to your residence and available infrastructure
</p>
{/* Status badge */}
{!eligibilityLoading && autoRequestStatus !== "submitting" && (
<EligibilityStatusBadge status={currentStatus} speed={eligibilityDisplay?.speed} />
)}
{/* Loading states */}
{(eligibilityLoading || autoRequestStatus === "submitting") && (
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-border bg-muted">
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span className="font-medium text-sm text-foreground">
{autoRequestStatus === "submitting" ? "Submitting request..." : "Checking status..."}
</span>
</div>
)}
</div>
{/* Already has internet warning */}
{hasActiveInternet && (
<AlertBanner variant="warning" title="Active subscription found" className="mb-6">
You already have an internet subscription. For additional residences, please{" "}
<a href="/account/support/new" className="underline font-medium">
contact support
</a>
.
</AlertBanner>
)}
{/* Auto-request status alerts - only show for errors/success */}
{autoRequestStatus === "submitted" && (
<AlertBanner variant="success" title="Request submitted" className="mb-6">
We'll verify your address and notify you when complete.
{autoRequestId && (
<span className="text-xs text-muted-foreground ml-2">ID: {autoRequestId}</span>
)}
</AlertBanner>
)}
{autoRequestStatus === "failed" && (
<AlertBanner variant="warning" title="Request failed" className="mb-6">
Please try again below or contact support.
</AlertBanner>
)}
{autoRequestStatus === "missing_address" && (
<AlertBanner variant="warning" title="Address required" className="mb-6">
<div className="flex items-center justify-between gap-4">
<span>Add your service address to request availability verification.</span>
<Button as="a" href="/account/settings" size="sm">
Add address
</Button>
</div>
</AlertBanner>
)}
{/* ELIGIBLE STATE - Clean & Personalized */}
{isEligible && eligibilityDisplay && offeringCards.length > 0 && (
<>
{/* Plan comparison guide */}
<div className="mb-6">
<PlanComparisonGuide />
</div>
{/* Speed options header (only if multiple) */}
{offeringCards.length > 1 && (
<div className="mb-4">
<h2 className="text-lg font-semibold text-foreground">Choose your speed</h2>
<p className="text-sm text-muted-foreground">
Your address supports multiple options
</p>
</div>
)}
{/* Offering cards */}
<div className="space-y-4 mb-6">
{offeringCards.map(card => (
<div key={card.offeringType}>
{card.isAlternative && (
<div className="flex items-center gap-2 mb-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs font-medium text-muted-foreground">
Alternative option
</span>
<div className="h-px flex-1 bg-border" />
</div>
)}
<InternetOfferingCard
offeringType={card.offeringType}
title={card.title}
speedBadge={card.speedBadge}
description={card.alternativeNote ?? card.description}
iconType={card.iconType}
startingPrice={card.startingPrice}
setupFee={card.setupFee}
tiers={card.tiers}
ctaPath={card.ctaPath}
isPremium={card.isPremium}
defaultExpanded={false}
disabled={hasActiveInternet}
disabledReason={
hasActiveInternet ? "Contact support for additional lines" : undefined
}
/>
</div>
))}
</div>
{/* Important notes - collapsed by default */}
<InternetImportantNotes />
<ServicesBackLink
href={servicesBasePath}
label="Back to Services"
align="center"
className="mt-10"
/>
</>
)}
{/* PENDING STATE - Clean Status View */}
{isPending && (
<>
<div className="bg-info-soft/30 border border-info/20 rounded-xl p-8 mb-6 text-center max-w-2xl mx-auto">
<Clock className="h-16 w-16 text-info mx-auto mb-6" />
<h2 className="text-2xl font-semibold text-foreground mb-3">
Verification in Progress
</h2>
<p className="text-base text-muted-foreground mb-4 leading-relaxed">
We're currently verifying NTT service availability at your registered address.
<br />
This manual check ensures we offer you the correct fiber connection type.
</p>
<div className="inline-flex flex-col items-center p-4 bg-background rounded-lg border border-border">
<span className="text-sm font-medium text-foreground mb-1">Estimated time</span>
<span className="text-sm text-muted-foreground">1-2 business days</span>
</div>
{requestedAt && (
<p className="text-xs text-muted-foreground mt-6">
Request submitted: {new Date(requestedAt).toLocaleDateString()}
</p>
)}
</div>
<div className="text-center">
<Button as="a" href={servicesBasePath} variant="outline">
Back to Services
</Button>
</div>
</>
)}
{/* INELIGIBLE STATE */}
{isIneligible && (
<div className="bg-warning/5 border border-warning/20 rounded-xl p-6 text-center">
<TriangleAlert className="h-12 w-12 text-warning mx-auto mb-4" />
<h2 className="text-lg font-semibold text-foreground mb-2">Service not available</h2>
<p className="text-sm text-muted-foreground mb-4 max-w-md mx-auto">
{rejectionNotes ||
"Our review determined that NTT fiber service isn't available at your address."}
</p>
<Button as="a" href="/account/support/new" variant="outline">
Contact support
</Button>
</div>
)}
{/* No plans available */}
{plans.length === 0 && !isLoading && (
<div className="text-center py-16">
<div className="bg-card rounded-2xl shadow-[var(--cp-shadow-1)] border border-border p-12 max-w-md mx-auto">
<Server className="h-16 w-16 text-muted-foreground mx-auto mb-6" />
<h3 className="text-xl font-semibold text-foreground mb-2">No Plans Available</h3>
<p className="text-muted-foreground mb-8">
We couldn&apos;t find any internet plans at this time.
</p>
<ServicesBackLink href={servicesBasePath} label="Back to Services" align="center" />
</div>
</div>
)}
</div>
);
}
export default InternetPlansContainer;