- Accepted formats: JPG, PNG, or PDF. Make sure all text is readable.
+ Accepted formats: JPG, PNG, or PDF (max 5MB). Make sure all text is readable.
)}
diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts
index 1c662894..58ed4caf 100644
--- a/apps/portal/src/features/catalog/services/catalog.service.ts
+++ b/apps/portal/src/features/catalog/services/catalog.service.ts
@@ -4,12 +4,14 @@ import {
EMPTY_VPN_CATALOG,
internetInstallationCatalogItemSchema,
internetAddonCatalogItemSchema,
+ internetEligibilityDetailsSchema,
simActivationFeeCatalogItemSchema,
simCatalogProductSchema,
vpnCatalogProductSchema,
type InternetCatalogCollection,
type InternetAddonCatalogItem,
type InternetInstallationCatalogItem,
+ type InternetEligibilityDetails,
type SimActivationFeeCatalogItem,
type SimCatalogCollection,
type SimCatalogProduct,
@@ -18,17 +20,6 @@ import {
} from "@customer-portal/domain/catalog";
import type { Address } from "@customer-portal/domain/customer";
-export type InternetEligibilityStatus = "not_requested" | "pending" | "eligible" | "ineligible";
-
-export interface InternetEligibilityDetails {
- status: InternetEligibilityStatus;
- eligibility: string | null;
- requestId: string | null;
- requestedAt: string | null;
- checkedAt: string | null;
- notes: string | null;
-}
-
export const catalogService = {
async getInternetCatalog(): Promise {
const response = await apiClient.GET("/api/catalog/internet/plans");
@@ -91,7 +82,8 @@ export const catalogService = {
const response = await apiClient.GET(
"/api/catalog/internet/eligibility"
);
- return getDataOrThrow(response, "Failed to load internet eligibility");
+ const data = getDataOrThrow(response, "Failed to load internet eligibility");
+ return internetEligibilityDetailsSchema.parse(data);
},
async requestInternetEligibilityCheck(body?: {
diff --git a/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx b/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx
index 7e0d2244..9b3a751a 100644
--- a/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx
+++ b/apps/portal/src/features/catalog/views/PublicCatalogHome.tsx
@@ -8,7 +8,8 @@ import {
ShieldCheckIcon,
WifiIcon,
GlobeAltIcon,
- CheckCircleIcon,
+ ClockIcon,
+ BoltIcon,
} from "@heroicons/react/24/outline";
import { ServiceHeroCard } from "@/features/catalog/components/common/ServiceHeroCard";
import { FeatureCard } from "@/features/catalog/components/common/FeatureCard";
@@ -34,38 +35,49 @@ export function PublicCatalogHomeView() {
Choose your connectivity solution
- Discover high-speed internet, mobile data/voice options, and secure VPN services. Browse
- our catalog and see starting prices. Create an account to unlock personalized plans and
- check internet availability for your address.
+ Explore our internet, mobile, and VPN services. Browse plans and pricing, then create an
+ account when you're ready to order.
+ {/* Service-specific ordering info */}
-
- {[
- {
- title: "Pick a service",
- description: "Internet, SIM, or VPN based on your needs.",
- },
- {
- title: "Create an account",
- description: "Confirm your address and unlock eligibility checks.",
- },
- {
- title: "Configure and order",
- description: "Choose your plan and complete checkout.",
- },
- ].map(step => (
-
-
-
-
-
-
{step.title}
-
{step.description}
+
What to expect when ordering
+
+
+
+
+
+
+
Internet
+
+ Requires address verification (1-2 business days). We'll email you when plans
+ are ready.
- ))}
+
+
+
+
+
+
+
SIM & eSIM
+
+ Order immediately after signup. Physical SIM ships next business day.
+
+
+
+
+
+
+
+
+
VPN
+
+ Order immediately after signup. Router shipped upon order confirmation.
+
- High-quality connectivity solutions with personalized recommendations and seamless
- ordering.
+ Reliable connectivity with transparent pricing and dedicated support.
}
- title="Personalized Plans"
- description="Sign up to see eligibility-based internet offerings and plan options"
+ title="Quality Networks"
+ description="NTT fiber for internet, 5G coverage for mobile, secure VPN infrastructure"
/>
}
- title="Account-First Ordering"
- description="Create an account to verify eligibility and complete your order"
+ title="Simple Management"
+ description="Manage all your services, billing, and support from one account portal"
/>
diff --git a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx
index e6559941..e7c33198 100644
--- a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx
+++ b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx
@@ -1,63 +1,186 @@
"use client";
import { useSearchParams } from "next/navigation";
-import { WifiIcon, CheckCircleIcon, ClockIcon, BellIcon } from "@heroicons/react/24/outline";
+import { WifiIcon, CheckIcon, ClockIcon, EnvelopeIcon } from "@heroicons/react/24/outline";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
+import { useInternetPlan } from "@/features/catalog/hooks";
+import { CardPricing } from "@/features/catalog/components/base/CardPricing";
+import { Skeleton } from "@/components/atoms/loading-skeleton";
/**
* Public Internet Configure View
*
- * Generic signup flow for internet availability check.
- * Focuses on account creation, not plan details.
+ * Signup flow for internet ordering with honest expectations about
+ * the verification timeline (1-2 business days, not instant).
*/
export function PublicInternetConfigureView() {
const shopBasePath = useShopBasePath();
const searchParams = useSearchParams();
const planSku = searchParams?.get("planSku");
+ const { plan, isLoading } = useInternetPlan(planSku || undefined);
const redirectTo = planSku
? `/account/shop/internet?autoEligibilityRequest=1&planSku=${encodeURIComponent(planSku)}`
: "/account/shop/internet?autoEligibilityRequest=1";
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
return (
-
+
{/* Header */}
-
-
+
+
-
Check Internet Availability
-
- Create an account to verify service availability at your address.
+
+ Request Internet Service
+
+
+ Create an account to request an availability check for your address.
- {/* Process Steps - Compact */}
-
-
-
- Create account
+ {/* Plan Summary Card - only if plan is selected */}
+ {plan && (
+
-
- We verify availability
+ )}
+
+ {/* What happens after signup - honest timeline */}
+
+
What happens next
+
+
+
+ 1
+
+
+
Create your account
+
+ Sign up with your service address to start the process.
+
+
+
+
+
+ 2
+
+
+
+
We verify availability
+
+
+ 1-2 business days
+
+
+
+ Our team checks service availability with NTT for your specific address.
+
+
+
+
+
+ 3
+
+
+
+
+ You receive email notification
+
+
+
+
+ We'll email you when your personalized plans are ready to view.
+
+
+
+
+
+ 4
+
+
+
Complete your order
+
+ Choose your plan options, add payment, and schedule installation.
+
+
+
-
-
- Get notified
+
+
+ {/* Important note */}
+
+
+
+
+
Your account is ready immediately
+
+ While we verify your address, you can explore your account, add payment methods, and
+ browse our other services like SIM and VPN.
+
+
{/* Auth Section */}
diff --git a/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx
index 933a6d04..a32438ab 100644
--- a/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx
+++ b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx
@@ -106,25 +106,33 @@ export function PublicInternetPlansView() {
-
-
- Compare starting prices for each internet type. Create an account to check availability
- for your residence and unlock personalized plan options.
-
-
- {offeringTypes.map(type => (
-
- {getEligibilityIcon(type)}
- {type}
-
- ))}
+
+ {/* Availability notice */}
+
+
+ Availability check required:{" "}
+
+ After signup, we verify your address with NTT (1-2 business days). You'll
+ receive an email when your personalized plans are ready.
+
+
+ {offeringTypes.length > 0 && (
+
+ {offeringTypes.map(type => (
+
+ {getEligibilityIcon(type)}
+ {type}
+
+ ))}
+
+ )}
diff --git a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx
index 0b2b9c7b..ecb64781 100644
--- a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx
+++ b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx
@@ -1,10 +1,9 @@
"use client";
import { useSearchParams } from "next/navigation";
-import { DevicePhoneMobileIcon, CheckIcon } from "@heroicons/react/24/outline";
+import { DevicePhoneMobileIcon, CheckIcon, BoltIcon } from "@heroicons/react/24/outline";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
-import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import { useSimPlan } from "@/features/catalog/hooks";
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
@@ -14,8 +13,8 @@ import { Skeleton } from "@/components/atoms/loading-skeleton";
/**
* Public SIM Configure View
*
- * Shows selected plan information and prompts for authentication via modal.
- * Much better UX than redirecting to a full signup page.
+ * Shows selected plan information and prompts for authentication.
+ * Simplified design focused on quick signup-to-order flow.
*/
export function PublicSimConfigureView() {
const shopBasePath = useShopBasePath();
@@ -32,7 +31,7 @@ export function PublicSimConfigureView() {
+ After signup, add a payment method and configure your SIM options. Choose eSIM for
+ instant activation or physical SIM (ships next business day).
+
+
- >
+
+ {/* Auth Section */}
+
+
);
}
diff --git a/apps/portal/src/features/catalog/views/PublicSimPlans.tsx b/apps/portal/src/features/catalog/views/PublicSimPlans.tsx
index af374111..c8203629 100644
--- a/apps/portal/src/features/catalog/views/PublicSimPlans.tsx
+++ b/apps/portal/src/features/catalog/views/PublicSimPlans.tsx
@@ -7,6 +7,7 @@ import {
PhoneIcon,
GlobeAltIcon,
ArrowLeftIcon,
+ BoltIcon,
} from "@heroicons/react/24/outline";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { Button } from "@/components/atoms/button";
@@ -104,9 +105,23 @@ export function PublicSimPlansView() {
+ title="SIM & eSIM Plans"
+ description="Data, voice, and SMS plans with 5G network coverage."
+ >
+ {/* Order info banner */}
+
+
+
+
+ Order today
+
+ {" "}
+ — eSIM activates instantly, physical SIM ships next business day.
+
+
+
+
+
diff --git a/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx b/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx
index f0b33004..67962259 100644
--- a/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx
+++ b/apps/portal/src/features/catalog/views/PublicVpnPlans.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ShieldCheckIcon } from "@heroicons/react/24/outline";
+import { ShieldCheckIcon, BoltIcon } from "@heroicons/react/24/outline";
import { useVpnCatalog } from "@/features/catalog/hooks";
import { LoadingCard } from "@/components/atoms";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
@@ -47,14 +47,30 @@ export function PublicVpnPlansView() {
+ title="VPN Router Service"
+ description="Secure VPN connections to San Francisco or London using a pre-configured router."
+ >
+ {/* Order info banner */}
+
+
+
+
+ Order today
+
+ {" "}
+ — create account, add payment, and your router ships upon confirmation.
+
+
+
+
+
{vpnPlans.length > 0 ? (
-
Available Plans
-
(One region per router)
+
Choose Your Region
+
+ Select one region per router rental
+
{vpnPlans.map(plan => (
@@ -64,8 +80,7 @@ export function PublicVpnPlansView() {
{activationFees.length > 0 && (
- A one-time activation fee of 3000 JPY is incurred separately for each rental unit. Tax
- (10%) not included.
+ A one-time activation fee of ¥3,000 applies per router rental. Tax (10%) not included.
)}
@@ -86,34 +101,30 @@ export function PublicVpnPlansView() {
)}
-
How It Works
-
+
How It Works
+
SonixNet VPN is the easiest way to access video streaming services from overseas on your
network media players such as an Apple TV, Roku, or Amazon Fire.
A configured Wi-Fi router is provided for rental (no purchase required, no hidden fees).
- All you will need to do is to plug the VPN router into your existing internet
- connection.
+ All you need to do is plug the VPN router into your existing internet connection.
- Then you can connect your network media players to the VPN Wi-Fi network, to connect to
- the VPN server.
-
-
- For daily Internet usage that does not require a VPN, we recommend connecting to your
- regular home Wi-Fi.
+ Connect your network media players to the VPN Wi-Fi network to access content from the
+ selected region. For regular internet usage, use your normal home Wi-Fi.
- *1: Content subscriptions are NOT included in the SonixNet VPN package. Our VPN service will
- establish a network connection that virtually locates you in the designated server location,
- then you will sign up for the streaming services of your choice. Not all services/websites
- can be unblocked. Assist Solutions does not guarantee or bear any responsibility over the
- unblocking of any websites or the quality of the streaming/browsing.
+
+ Content subscriptions are NOT included in the VPN package. Our VPN service establishes a
+ network connection that virtually locates you in the designated server location. Not all
+ services can be unblocked. We do not guarantee access to any specific website or streaming
+ service quality.
+
);
diff --git a/apps/portal/src/features/dashboard/hooks/index.ts b/apps/portal/src/features/dashboard/hooks/index.ts
index 1c598b0c..5dceb40a 100644
--- a/apps/portal/src/features/dashboard/hooks/index.ts
+++ b/apps/portal/src/features/dashboard/hooks/index.ts
@@ -1,2 +1,3 @@
export * from "./useDashboardSummary";
export * from "./useDashboardTasks";
+export * from "./useMeStatus";
diff --git a/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts b/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts
index 98f4d778..05aa3b65 100644
--- a/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts
+++ b/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts
@@ -3,80 +3,20 @@
* Provides dashboard data with proper error handling, caching, and loading states
*/
-import { useQuery } from "@tanstack/react-query";
-import { useAuthSession } from "@/features/auth/services/auth.store";
-import { apiClient, queryKeys } from "@/lib/api";
-import {
- dashboardSummarySchema,
- type DashboardSummary,
- type DashboardError,
-} from "@customer-portal/domain/dashboard";
-
-class DashboardDataError extends Error {
- constructor(
- public code: DashboardError["code"],
- message: string,
- public details?: Record
- ) {
- super(message);
- this.name = "DashboardDataError";
- }
-}
+import type { DashboardSummary } from "@customer-portal/domain/dashboard";
+import { useMeStatus } from "./useMeStatus";
/**
* Hook for fetching dashboard summary data
*/
export function useDashboardSummary() {
- const { isAuthenticated } = useAuthSession();
+ const status = useMeStatus();
- return useQuery({
- queryKey: queryKeys.dashboard.summary(),
- queryFn: async () => {
- if (!isAuthenticated) {
- throw new DashboardDataError(
- "AUTHENTICATION_REQUIRED",
- "Authentication required to fetch dashboard data"
- );
- }
-
- try {
- const response = await apiClient.GET("/api/me/summary");
- if (!response.data) {
- throw new DashboardDataError("FETCH_ERROR", "Dashboard summary response was empty");
- }
- const parsed = dashboardSummarySchema.safeParse(response.data);
- if (!parsed.success) {
- throw new DashboardDataError(
- "FETCH_ERROR",
- "Dashboard summary response failed validation",
- { issues: parsed.error.issues }
- );
- }
- return parsed.data;
- } catch (error) {
- // Transform API errors to DashboardError format
- if (error instanceof Error) {
- throw new DashboardDataError("FETCH_ERROR", error.message, {
- originalError: error,
- });
- }
-
- throw new DashboardDataError(
- "UNKNOWN_ERROR",
- "An unexpected error occurred while fetching dashboard data",
- { originalError: error as Record }
- );
- }
- },
- enabled: isAuthenticated,
- retry: (failureCount, error) => {
- // Don't retry authentication errors
- if (error?.code === "AUTHENTICATION_REQUIRED") {
- return false;
- }
- // Retry up to 3 times for other errors
- return failureCount < 3;
- },
- retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
- });
+ return {
+ data: (status.data?.summary ?? undefined) as DashboardSummary | undefined,
+ isLoading: status.isLoading,
+ isError: status.isError,
+ error: status.error,
+ refetch: status.refetch,
+ };
}
diff --git a/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts
index e079a781..e94cacfd 100644
--- a/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts
+++ b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts
@@ -1,190 +1,38 @@
"use client";
import { useMemo } from "react";
-import { formatDistanceToNow, format } from "date-fns";
import {
ExclamationCircleIcon,
CreditCardIcon,
ClockIcon,
SparklesIcon,
+ IdentificationIcon,
} from "@heroicons/react/24/outline";
-import type { DashboardSummary } from "@customer-portal/domain/dashboard";
-import type { PaymentMethodList } from "@customer-portal/domain/payments";
-import type { OrderSummary } from "@customer-portal/domain/orders";
import type { TaskTone } from "../components/TaskCard";
-import { useDashboardSummary } from "./useDashboardSummary";
-import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
-import { useOrdersList } from "@/features/orders/hooks/useOrdersList";
-import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
-import { useInternetEligibility } from "@/features/catalog/hooks";
-import { useAuthSession } from "@/features/auth/services/auth.store";
+import type {
+ DashboardTask as DomainDashboardTask,
+ DashboardTaskType,
+} from "@customer-portal/domain/dashboard";
+import { useMeStatus } from "./useMeStatus";
/**
* Task type for dashboard actions
*/
-export type DashboardTaskType =
- | "invoice"
- | "payment_method"
- | "order"
- | "internet_eligibility"
- | "onboarding";
+export type { DashboardTaskType };
-/**
- * Dashboard task structure
- */
-export interface DashboardTask {
- id: string;
- priority: 1 | 2 | 3 | 4;
- type: DashboardTaskType;
- title: string;
- description: string;
- /** Label for the action button */
- actionLabel: string;
- /** Link for card click (navigates to detail page) */
- detailHref?: string;
- /** Whether the action opens an external SSO link */
- requiresSsoAction?: boolean;
+export interface DashboardTask extends DomainDashboardTask {
tone: TaskTone;
icon: React.ComponentType>;
- metadata?: {
- invoiceId?: number;
- orderId?: string;
- amount?: number;
- currency?: string;
- };
}
-interface ComputeTasksParams {
- summary: DashboardSummary | undefined;
- paymentMethods: PaymentMethodList | undefined;
- orders: OrderSummary[] | undefined;
- internetEligibilityStatus: "not_requested" | "pending" | "eligible" | "ineligible" | undefined;
- formatCurrency: (amount: number, options?: { currency?: string }) => string;
-}
-
-/**
- * Compute dashboard tasks based on user's account state
- */
-function computeTasks({
- summary,
- paymentMethods,
- orders,
- internetEligibilityStatus,
- formatCurrency,
-}: ComputeTasksParams): DashboardTask[] {
- const tasks: DashboardTask[] = [];
-
- if (!summary) return tasks;
-
- // Priority 1: Unpaid invoices
- if (summary.nextInvoice) {
- const dueDate = new Date(summary.nextInvoice.dueDate);
- const isOverdue = dueDate < new Date();
- const dueText = isOverdue
- ? `Overdue since ${format(dueDate, "MMM d")}`
- : `Due ${formatDistanceToNow(dueDate, { addSuffix: true })}`;
-
- tasks.push({
- id: `invoice-${summary.nextInvoice.id}`,
- priority: 1,
- type: "invoice",
- title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice",
- description: `Invoice #${summary.nextInvoice.id} · ${formatCurrency(summary.nextInvoice.amount, { currency: summary.nextInvoice.currency })} · ${dueText}`,
- actionLabel: "Pay now",
- detailHref: `/account/billing/invoices/${summary.nextInvoice.id}`,
- requiresSsoAction: true,
- tone: "critical",
- icon: ExclamationCircleIcon,
- metadata: {
- invoiceId: summary.nextInvoice.id,
- amount: summary.nextInvoice.amount,
- currency: summary.nextInvoice.currency,
- },
- });
- }
-
- // Priority 2: No payment method
- if (paymentMethods && paymentMethods.totalCount === 0) {
- tasks.push({
- id: "add-payment-method",
- priority: 2,
- type: "payment_method",
- title: "Add a payment method",
- description: "Required to place orders and process invoices",
- actionLabel: "Add method",
- detailHref: "/account/billing/payments",
- requiresSsoAction: true,
- tone: "warning",
- icon: CreditCardIcon,
- });
- }
-
- // Priority 3: Pending orders (Draft, Pending, or Activated but not yet complete)
- if (orders && orders.length > 0) {
- const pendingOrders = orders.filter(
- o =>
- o.status === "Draft" ||
- o.status === "Pending" ||
- (o.status === "Activated" && o.activationStatus !== "Completed")
- );
-
- if (pendingOrders.length > 0) {
- const order = pendingOrders[0];
- const statusText =
- order.status === "Pending"
- ? "awaiting review"
- : order.status === "Draft"
- ? "in draft"
- : "being activated";
-
- tasks.push({
- id: `order-${order.id}`,
- priority: 3,
- type: "order",
- title: "Order in progress",
- description: `${order.orderType || "Your"} order is ${statusText}`,
- actionLabel: "View details",
- detailHref: `/account/orders/${order.id}`,
- tone: "info",
- icon: ClockIcon,
- metadata: { orderId: order.id },
- });
- }
- }
-
- // Priority 4: Internet eligibility review (only when explicitly pending)
- if (internetEligibilityStatus === "pending") {
- tasks.push({
- id: "internet-eligibility-review",
- priority: 4,
- type: "internet_eligibility",
- title: "Internet availability review",
- description:
- "We’re verifying if our service is available at your residence. We’ll notify you when review is complete.",
- actionLabel: "View status",
- detailHref: "/account/shop/internet",
- tone: "info",
- icon: ClockIcon,
- });
- }
-
- // Priority 4: No subscriptions (onboarding) - only show if no other tasks
- if (summary.stats.activeSubscriptions === 0 && tasks.length === 0) {
- tasks.push({
- id: "start-subscription",
- priority: 4,
- type: "onboarding",
- title: "Start your first service",
- description: "Browse our catalog and subscribe to internet, SIM, or VPN",
- actionLabel: "Browse services",
- detailHref: "/shop",
- tone: "neutral",
- icon: SparklesIcon,
- });
- }
-
- return tasks.sort((a, b) => a.priority - b.priority);
-}
+const TASK_ICONS: Record = {
+ invoice: ExclamationCircleIcon,
+ payment_method: CreditCardIcon,
+ order: ClockIcon,
+ internet_eligibility: ClockIcon,
+ id_verification: IdentificationIcon,
+ onboarding: SparklesIcon,
+};
export interface UseDashboardTasksResult {
tasks: DashboardTask[];
@@ -194,47 +42,25 @@ export interface UseDashboardTasksResult {
}
/**
- * Hook to compute and return prioritized dashboard tasks
+ * Hook to return prioritized dashboard tasks computed by the BFF.
*/
export function useDashboardTasks(): UseDashboardTasksResult {
- const { formatCurrency } = useFormatCurrency();
- const { isAuthenticated } = useAuthSession();
+ const { data, isLoading, error } = useMeStatus();
- const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary();
-
- const {
- data: paymentMethods,
- isLoading: paymentMethodsLoading,
- error: paymentMethodsError,
- } = usePaymentMethods();
-
- const { data: orders, isLoading: ordersLoading, error: ordersError } = useOrdersList();
-
- const {
- data: eligibility,
- isLoading: eligibilityLoading,
- error: eligibilityError,
- } = useInternetEligibility({ enabled: isAuthenticated });
-
- const isLoading = summaryLoading || paymentMethodsLoading || ordersLoading;
- const hasError = Boolean(summaryError || paymentMethodsError || ordersError || eligibilityError);
-
- const tasks = useMemo(
- () =>
- computeTasks({
- summary,
- paymentMethods,
- orders,
- internetEligibilityStatus: eligibility?.status,
- formatCurrency,
- }),
- [summary, paymentMethods, orders, eligibility?.status, formatCurrency]
- );
+ const tasks = useMemo(() => {
+ const raw = data?.tasks ?? [];
+ return raw.map(task => ({
+ ...task,
+ // Default to neutral when undefined (shouldn't happen due to domain validation)
+ tone: (task.tone ?? "neutral") as TaskTone,
+ icon: TASK_ICONS[task.type] ?? SparklesIcon,
+ }));
+ }, [data?.tasks]);
return {
tasks,
- isLoading: isLoading || eligibilityLoading,
- hasError,
+ isLoading,
+ hasError: Boolean(error),
taskCount: tasks.length,
};
}
diff --git a/apps/portal/src/features/dashboard/hooks/useMeStatus.ts b/apps/portal/src/features/dashboard/hooks/useMeStatus.ts
new file mode 100644
index 00000000..10828ff8
--- /dev/null
+++ b/apps/portal/src/features/dashboard/hooks/useMeStatus.ts
@@ -0,0 +1,24 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { useAuthSession } from "@/features/auth/services/auth.store";
+import { queryKeys } from "@/lib/api";
+import { getMeStatus } from "../services/meStatus.service";
+import type { MeStatus } from "@customer-portal/domain/dashboard";
+
+/**
+ * Fetches aggregated customer status used by the dashboard (tasks, summary, gating signals).
+ */
+export function useMeStatus() {
+ const { isAuthenticated } = useAuthSession();
+
+ return useQuery({
+ queryKey: queryKeys.me.status(),
+ queryFn: () => getMeStatus(),
+ enabled: isAuthenticated,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ });
+}
+
+export type UseMeStatusResult = ReturnType;
diff --git a/apps/portal/src/features/dashboard/services/meStatus.service.ts b/apps/portal/src/features/dashboard/services/meStatus.service.ts
new file mode 100644
index 00000000..410d6c45
--- /dev/null
+++ b/apps/portal/src/features/dashboard/services/meStatus.service.ts
@@ -0,0 +1,14 @@
+import { apiClient } from "@/lib/api";
+import { meStatusSchema, type MeStatus } from "@customer-portal/domain/dashboard";
+
+export async function getMeStatus(): Promise {
+ const response = await apiClient.GET("/api/me/status");
+ if (!response.data) {
+ throw new Error("Status response was empty");
+ }
+ const parsed = meStatusSchema.safeParse(response.data);
+ if (!parsed.success) {
+ throw new Error("Status response failed validation");
+ }
+ return parsed.data;
+}
diff --git a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts
index d27c499d..355d6be5 100644
--- a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts
+++ b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts
@@ -11,8 +11,8 @@ import {
ACTIVITY_FILTERS,
filterActivities,
isActivityClickable,
- generateDashboardTasks,
- type DashboardTask,
+ generateQuickActions,
+ type QuickActionTask,
type DashboardTaskSummary,
} from "@customer-portal/domain/dashboard";
import { formatCurrency as formatCurrencyUtil } from "@customer-portal/domain/toolkit";
@@ -22,8 +22,8 @@ export {
ACTIVITY_FILTERS,
filterActivities,
isActivityClickable,
- generateDashboardTasks,
- type DashboardTask,
+ generateQuickActions,
+ type QuickActionTask,
type DashboardTaskSummary,
};
diff --git a/apps/portal/src/features/realtime/components/AccountEventsListener.tsx b/apps/portal/src/features/realtime/components/AccountEventsListener.tsx
index 570f2ea6..b563c789 100644
--- a/apps/portal/src/features/realtime/components/AccountEventsListener.tsx
+++ b/apps/portal/src/features/realtime/components/AccountEventsListener.tsx
@@ -59,6 +59,7 @@ export function AccountEventsListener() {
void queryClient.invalidateQueries({ queryKey: queryKeys.orders.list() });
// Dashboard summary often depends on orders/subscriptions; cheap to keep in sync.
void queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.summary() });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.me.status() });
return;
}
} catch (error) {
diff --git a/apps/portal/src/features/verification/services/verification.service.ts b/apps/portal/src/features/verification/services/verification.service.ts
index 9f4ca292..d897f4e2 100644
--- a/apps/portal/src/features/verification/services/verification.service.ts
+++ b/apps/portal/src/features/verification/services/verification.service.ts
@@ -1,25 +1,18 @@
"use client";
import { apiClient, getDataOrThrow } from "@/lib/api";
-
-export type ResidenceCardVerificationStatus = "not_submitted" | "pending" | "verified" | "rejected";
-
-export interface ResidenceCardVerification {
- status: ResidenceCardVerificationStatus;
- filename: string | null;
- mimeType: string | null;
- sizeBytes: number | null;
- submittedAt: string | null;
- reviewedAt: string | null;
- reviewerNotes: string | null;
-}
+import {
+ residenceCardVerificationSchema,
+ type ResidenceCardVerification,
+} from "@customer-portal/domain/customer";
export const verificationService = {
async getResidenceCardVerification(): Promise {
const response = await apiClient.GET(
"/api/verification/residence-card"
);
- return getDataOrThrow(response, "Failed to load residence card verification status");
+ const data = getDataOrThrow(response, "Failed to load residence card verification status");
+ return residenceCardVerificationSchema.parse(data);
},
async submitResidenceCard(file: File): Promise {
@@ -32,6 +25,7 @@ export const verificationService = {
body: form,
}
);
- return getDataOrThrow(response, "Failed to submit residence card");
+ const data = getDataOrThrow(response, "Failed to submit residence card");
+ return residenceCardVerificationSchema.parse(data);
},
};
diff --git a/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx b/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx
index f60c8e2a..70c3fc64 100644
--- a/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx
+++ b/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx
@@ -132,9 +132,39 @@ export function ResidenceCardVerificationSettingsView() {
{residenceCardQuery.data.reviewerNotes}
)}
Upload a clear photo or scan of your residence card (JPG, PNG, or PDF).
+
+
Make sure all text is readable and the full card is visible.
- Accepted formats: JPG, PNG, or PDF. Make sure all text is readable.
+ Accepted formats: JPG, PNG, or PDF (max 5MB). Tip: higher resolution photos make
+ review faster.
)}
diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts
index c64ff6b2..60c92ef3 100644
--- a/apps/portal/src/lib/api/index.ts
+++ b/apps/portal/src/lib/api/index.ts
@@ -122,6 +122,9 @@ export const queryKeys = {
me: () => ["auth", "me"] as const,
session: () => ["auth", "session"] as const,
},
+ me: {
+ status: () => ["me", "status"] as const,
+ },
billing: {
invoices: (params?: Record) => ["billing", "invoices", params] as const,
invoice: (id: string) => ["billing", "invoice", id] as const,
diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md
index 33979c37..918b383a 100644
--- a/docs/architecture/system-overview.md
+++ b/docs/architecture/system-overview.md
@@ -67,6 +67,7 @@ src/
modules/ # Feature-aligned modules
auth/ # Authentication and authorization
users/ # User management
+ me-status/ # Aggregated customer status (dashboard + gating signals)
id-mappings/ # Portal-WHMCS-Salesforce ID mappings
catalog/ # Product catalog
orders/ # Order creation and fulfillment
@@ -208,6 +209,13 @@ Centralized logging is implemented in the BFF using `nestjs-pino`:
- ESLint and Prettier for consistent formatting
- Pre-commit hooks for quality gates
+### **Domain Build Hygiene**
+
+The domain package (`packages/domain`) is consumed via committed `dist/` outputs.
+
+- **Build**: `pnpm domain:build`
+- **Verify dist drift** (CI-friendly): `pnpm domain:check-dist`
+
## 📈 **Performance & Scalability**
### **Caching Strategy**
diff --git a/docs/how-it-works/README.md b/docs/how-it-works/README.md
index 1282450d..ba616492 100644
--- a/docs/how-it-works/README.md
+++ b/docs/how-it-works/README.md
@@ -17,6 +17,7 @@ Start with `system-overview.md`, then jump into the feature you care about.
| [Billing & Payments](./billing-and-payments.md) | Invoices, payment methods, billing links |
| [Subscriptions](./subscriptions.md) | How active services are read and refreshed |
| [Support Cases](./support-cases.md) | Case creation/reading in Salesforce |
+| [Dashboard & Notifications](./dashboard-and-notifications.md) | Dashboard status model + in-app notification triggers |
| [UI Design System](./ui-design-system.md) | UI tokens, page shells, component patterns |
## Related Documentation
diff --git a/docs/how-it-works/dashboard-and-notifications.md b/docs/how-it-works/dashboard-and-notifications.md
new file mode 100644
index 00000000..ebbd904b
--- /dev/null
+++ b/docs/how-it-works/dashboard-and-notifications.md
@@ -0,0 +1,61 @@
+# Dashboard & Notifications
+
+This guide explains how the **customer dashboard** stays consistent and how **in-app notifications** are generated.
+
+## Dashboard “single read model” (`/api/me/status`)
+
+To keep business logic out of the frontend, the Portal uses a single BFF endpoint:
+
+- **Endpoint**: `GET /api/me/status`
+- **Purpose**: Return a consistent snapshot of the customer’s current state (summary + tasks + gating signals).
+- **Domain type**: `@customer-portal/domain/dashboard` → `meStatusSchema`
+
+The response includes:
+
+- **`summary`**: Same shape as `GET /api/me/summary` (stats, next invoice, activity).
+- **`internetEligibility`**: Internet eligibility status/details for the logged-in customer.
+- **`residenceCardVerification`**: Residence card verification status/details.
+- **`paymentMethods.totalCount`**: Count of stored payment methods (or `null` if unavailable).
+- **`tasks[]`**: A prioritized list of dashboard tasks (invoice due, add payment method, order in progress, eligibility pending, IDV rejected, onboarding).
+
+Portal UI maps task `type` → icon locally; everything else (priority, copy, links) is computed server-side.
+
+## In-app notifications
+
+In-app notifications are stored in Postgres and fetched via the Notifications API. Notifications use domain templates in:
+
+- `packages/domain/notifications/schema.ts`
+
+### Where notifications are created
+
+- **Eligibility / Verification**:
+ - Triggered from Salesforce events (Account fields change).
+ - Created by the Salesforce events handlers.
+
+- **Orders**:
+ - **Approved / Activated / Failed** notifications are created during the fulfillment workflow:
+ - `apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts`
+ - The notification `sourceId` uses the Salesforce Order Id to prevent duplicates during retries.
+
+- **Cancellations**:
+ - A “Cancellation scheduled” notification is created when the cancellation request is submitted:
+ - Internet: `apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts`
+ - SIM: `apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts`
+
+- **Invoice due**:
+ - Created opportunistically when the dashboard status is requested (`GET /api/me/status`) if an invoice is due within 7 days (or overdue).
+
+### Dedupe behavior
+
+Notifications dedupe is enforced in:
+
+- `apps/bff/src/modules/notifications/notifications.service.ts`
+
+Rules:
+
+- For most types: dedupe is **type + sourceId within 1 hour**.
+- For “reminder-style” types (invoice due, payment method expiring, system announcement): dedupe is **type + sourceId within 24 hours**.
+
+### Action URLs
+
+Notification templates use **authenticated Portal routes** (e.g. `/account/orders`, `/account/services`, `/account/billing/*`) so clicks always land in the correct shell.
diff --git a/docs/integrations/salesforce/opportunity-lifecycle.md b/docs/integrations/salesforce/opportunity-lifecycle.md
index 57615b11..61df6a01 100644
--- a/docs/integrations/salesforce/opportunity-lifecycle.md
+++ b/docs/integrations/salesforce/opportunity-lifecycle.md
@@ -260,28 +260,20 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
│ └─ If "Verified" → Skip verification, proceed to checkout │
│ │
│ 2. CUSTOMER UPLOADS ID DOCUMENTS │
-│ └─ Portal: POST /api/verification/submit │
-│ └─ eKYC service processes documents │
+│ └─ Portal: POST /api/verification/residence-card │
+│ └─ BFF uploads file to Salesforce Files (ContentVersion) │
│ │
│ 3. UPDATE ACCOUNT │
-│ └─ Id_Verification_Status__c = "Pending" │
+│ └─ Id_Verification_Status__c = "Submitted" (portal maps to pending) │
│ └─ Id_Verification_Submitted_Date_Time__c = now() │
│ │
-│ 4. IF eKYC AUTO-APPROVED │
-│ └─ Id_Verification_Status__c = "Verified" │
-│ └─ Id_Verification_Verified_Date_Time__c = now() │
-│ └─ Customer can proceed to order immediately │
+│ 4. CS REVIEWS IN SALESFORCE │
+│ └─ Id_Verification_Status__c = "Verified" or "Rejected" │
+│ └─ Id_Verification_Verified_Date_Time__c = now() (on verify) │
+│ └─ Id_Verification_Rejection_Message__c = reason (on reject) │
│ │
-│ 5. IF MANUAL REVIEW NEEDED │
-│ └─ Create Case for CS review │
-│ └─ Case.Type = "ID Verification" │
-│ └─ Case.OpportunityId = linked Opportunity (if exists) │
-│ └─ CS reviews and updates Account │
-│ │
-│ 6. IF REJECTED │
-│ └─ Id_Verification_Status__c = "Rejected" │
-│ └─ Id_Verification_Rejection_Message__c = reason │
-│ └─ Customer must resubmit │
+│ 5. CUSTOMER RESUBMITS IF NEEDED │
+│ └─ Portal shows feedback + upload UI │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
diff --git a/package.json b/package.json
index 9f175338..1422bf79 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,8 @@
"dev": "./scripts/dev/manage.sh apps",
"dev:all": "pnpm --filter @customer-portal/domain build && pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run dev",
"dev:apps": "pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run dev",
+ "domain:build": "pnpm --filter @customer-portal/domain build",
+ "domain:check-dist": "bash ./scripts/domain/check-dist.sh",
"build": "pnpm --filter @customer-portal/domain build && pnpm --recursive --filter=!@customer-portal/domain run build",
"start": "pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run start",
"test": "pnpm --recursive run test",
diff --git a/packages/domain/catalog/contract.ts b/packages/domain/catalog/contract.ts
index 0c6e1d2a..2fca6f4f 100644
--- a/packages/domain/catalog/contract.ts
+++ b/packages/domain/catalog/contract.ts
@@ -1,6 +1,6 @@
/**
* Catalog Domain - Contract
- *
+ *
* Constants and types for the catalog domain.
* Most types are derived from schemas (see schema.ts).
*/
@@ -43,7 +43,7 @@ export interface SalesforceProductFieldMap {
export interface PricingTier {
name: string;
price: number;
- billingCycle: 'Monthly' | 'Onetime' | 'Annual';
+ billingCycle: "Monthly" | "Onetime" | "Annual";
description?: string;
features?: string[];
isRecommended?: boolean;
@@ -84,6 +84,8 @@ export type {
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
+ InternetEligibilityStatus,
+ InternetEligibilityDetails,
// SIM products
SimCatalogProduct,
SimActivationFeeCatalogItem,
@@ -91,4 +93,4 @@ export type {
VpnCatalogProduct,
// Union type
CatalogProduct,
-} from './schema.js';
+} from "./schema.js";
diff --git a/packages/domain/catalog/index.ts b/packages/domain/catalog/index.ts
index 1bfe33d9..43c8b261 100644
--- a/packages/domain/catalog/index.ts
+++ b/packages/domain/catalog/index.ts
@@ -1,13 +1,13 @@
/**
* Catalog Domain
- *
+ *
* Exports all catalog-related contracts, schemas, and provider mappers.
- *
+ *
* Types are derived from Zod schemas (Schema-First Approach)
*/
// Provider-specific types
-export {
+export {
type SalesforceProductFieldMap,
type PricingTier,
type CatalogFilter,
@@ -27,6 +27,8 @@ export type {
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
+ InternetEligibilityStatus,
+ InternetEligibilityDetails,
// SIM products
SimCatalogProduct,
SimActivationFeeCatalogItem,
@@ -34,7 +36,7 @@ export type {
VpnCatalogProduct,
// Union type
CatalogProduct,
-} from './schema.js';
+} from "./schema.js";
// Provider adapters
export * as Providers from "./providers/index.js";
diff --git a/packages/domain/catalog/schema.ts b/packages/domain/catalog/schema.ts
index 10277959..13750c50 100644
--- a/packages/domain/catalog/schema.ts
+++ b/packages/domain/catalog/schema.ts
@@ -1,6 +1,6 @@
/**
* Catalog Domain - Schemas
- *
+ *
* Zod schemas for runtime validation of catalog product data.
*/
@@ -53,17 +53,21 @@ export const internetPlanTemplateSchema = z.object({
});
export const internetPlanCatalogItemSchema = internetCatalogProductSchema.extend({
- catalogMetadata: z.object({
- tierDescription: z.string().optional(),
- features: z.array(z.string()).optional(),
- isRecommended: z.boolean().optional(),
- }).optional(),
+ catalogMetadata: z
+ .object({
+ tierDescription: z.string().optional(),
+ features: z.array(z.string()).optional(),
+ isRecommended: z.boolean().optional(),
+ })
+ .optional(),
});
export const internetInstallationCatalogItemSchema = internetCatalogProductSchema.extend({
- catalogMetadata: z.object({
- installationTerm: z.enum(["One-time", "12-Month", "24-Month"]),
- }).optional(),
+ catalogMetadata: z
+ .object({
+ installationTerm: z.enum(["One-time", "12-Month", "24-Month"]),
+ })
+ .optional(),
});
export const internetAddonCatalogItemSchema = internetCatalogProductSchema.extend({
@@ -79,6 +83,34 @@ export const internetCatalogCollectionSchema = z.object({
export const internetCatalogResponseSchema = internetCatalogCollectionSchema;
+// ============================================================================
+// Internet Eligibility Schemas
+// ============================================================================
+
+/**
+ * Portal-facing internet eligibility status.
+ *
+ * NOTE: This is intentionally a small, stable enum used across BFF + Portal.
+ * The raw Salesforce field value is returned separately as `eligibility`.
+ */
+export const internetEligibilityStatusSchema = z.enum([
+ "not_requested",
+ "pending",
+ "eligible",
+ "ineligible",
+]);
+
+export const internetEligibilityDetailsSchema = z.object({
+ status: internetEligibilityStatusSchema,
+ /** Raw Salesforce value from Account.Internet_Eligibility__c (if present) */
+ eligibility: z.string().nullable(),
+ /** Salesforce Case Id (eligibility request) */
+ requestId: z.string().nullable(),
+ requestedAt: z.string().datetime().nullable(),
+ checkedAt: z.string().datetime().nullable(),
+ notes: z.string().nullable(),
+});
+
// ============================================================================
// SIM Product Schemas
// ============================================================================
@@ -151,6 +183,8 @@ export type InternetPlanCatalogItem = z.infer;
export type InternetAddonCatalogItem = z.infer;
export type InternetCatalogCollection = z.infer;
+export type InternetEligibilityStatus = z.infer;
+export type InternetEligibilityDetails = z.infer;
// SIM products
export type SimCatalogProduct = z.infer;
@@ -170,4 +204,3 @@ export type CatalogProduct =
| SimActivationFeeCatalogItem
| VpnCatalogProduct
| CatalogProductBase;
-
diff --git a/packages/domain/customer/contract.ts b/packages/domain/customer/contract.ts
index b85b4826..a3503601 100644
--- a/packages/domain/customer/contract.ts
+++ b/packages/domain/customer/contract.ts
@@ -1,9 +1,9 @@
/**
* Customer Domain - Contract
- *
+ *
* Constants and provider-specific types.
* Main domain types exported from schema.ts
- *
+ *
* Pattern matches billing and subscriptions domains.
*/
@@ -52,4 +52,6 @@ export type {
UserRole,
Address,
AddressFormData,
-} from './schema.js';
+ ResidenceCardVerificationStatus,
+ ResidenceCardVerification,
+} from "./schema.js";
diff --git a/packages/domain/customer/index.ts b/packages/domain/customer/index.ts
index 3fabd9aa..20bcd236 100644
--- a/packages/domain/customer/index.ts
+++ b/packages/domain/customer/index.ts
@@ -1,13 +1,13 @@
/**
* Customer Domain
- *
+ *
* Main exports:
* - User: API response type
* - UserAuth: Portal DB auth state
* - Address: Address structure (follows billing/subscriptions pattern)
- *
+ *
* Pattern matches billing and subscriptions domains.
- *
+ *
* Types are derived from Zod schemas (Schema-First Approach)
*/
@@ -22,16 +22,18 @@ export { USER_ROLE, type UserRoleValue } from "./contract.js";
// ============================================================================
export type {
- User, // API response type (normalized camelCase)
- UserAuth, // Portal DB auth state
- UserRole, // "USER" | "ADMIN"
- Address, // Address structure (not "CustomerAddress")
+ User, // API response type (normalized camelCase)
+ UserAuth, // Portal DB auth state
+ UserRole, // "USER" | "ADMIN"
+ Address, // Address structure (not "CustomerAddress")
AddressFormData, // Address form validation
ProfileEditFormData, // Profile edit form data
- ProfileDisplayData, // Profile display data (alias)
- UserProfile, // Alias for User
+ ProfileDisplayData, // Profile display data (alias)
+ ResidenceCardVerificationStatus,
+ ResidenceCardVerification,
+ UserProfile, // Alias for User
AuthenticatedUser, // Alias for authenticated user
- WhmcsClient, // Provider-normalized WHMCS client shape
+ WhmcsClient, // Provider-normalized WHMCS client shape
} from "./schema.js";
// ============================================================================
@@ -45,9 +47,11 @@ export {
addressFormSchema,
profileEditFormSchema,
profileDisplayDataSchema,
-
+ residenceCardVerificationStatusSchema,
+ residenceCardVerificationSchema,
+
// Helper functions
- combineToUser, // Domain helper: UserAuth + WhmcsClient → User
+ combineToUser, // Domain helper: UserAuth + WhmcsClient → User
addressFormToRequest,
profileFormToRequest,
} from "./schema.js";
@@ -58,7 +62,7 @@ export {
/**
* Providers namespace contains provider-specific implementations
- *
+ *
* Access as:
* - Providers.Whmcs.Client (full WHMCS type)
* - Providers.Whmcs.transformWhmcsClientResponse()
@@ -94,7 +98,4 @@ export type {
* Salesforce integration types
* Provider-specific, not validated at runtime
*/
-export type {
- SalesforceAccountFieldMap,
- SalesforceAccountRecord,
-} from "./contract.js";
+export type { SalesforceAccountFieldMap, SalesforceAccountRecord } from "./contract.js";
diff --git a/packages/domain/customer/schema.ts b/packages/domain/customer/schema.ts
index 78541241..3eb4ed47 100644
--- a/packages/domain/customer/schema.ts
+++ b/packages/domain/customer/schema.ts
@@ -390,6 +390,32 @@ export function combineToUser(userAuth: UserAuth, whmcsClient: WhmcsClient): Use
});
}
+// ============================================================================
+// Verification (Customer-facing)
+// ============================================================================
+
+/**
+ * Residence card verification status shown in the portal.
+ *
+ * Stored in Salesforce on the Account record.
+ */
+export const residenceCardVerificationStatusSchema = z.enum([
+ "not_submitted",
+ "pending",
+ "verified",
+ "rejected",
+]);
+
+export const residenceCardVerificationSchema = z.object({
+ status: residenceCardVerificationStatusSchema,
+ filename: z.string().nullable(),
+ mimeType: z.string().nullable(),
+ sizeBytes: z.number().int().nonnegative().nullable(),
+ submittedAt: z.string().datetime().nullable(),
+ reviewedAt: z.string().datetime().nullable(),
+ reviewerNotes: z.string().nullable(),
+});
+
// ============================================================================
// Exported Types (Public API)
// ============================================================================
@@ -401,6 +427,8 @@ export type Address = z.infer;
export type AddressFormData = z.infer;
export type ProfileEditFormData = z.infer;
export type ProfileDisplayData = z.infer;
+export type ResidenceCardVerificationStatus = z.infer;
+export type ResidenceCardVerification = z.infer;
// Convenience aliases
export type UserProfile = User; // Alias for user profile
diff --git a/packages/domain/dashboard/contract.ts b/packages/domain/dashboard/contract.ts
index efd1deab..94e3432b 100644
--- a/packages/domain/dashboard/contract.ts
+++ b/packages/domain/dashboard/contract.ts
@@ -9,6 +9,11 @@ import type {
activityFilterSchema,
activityFilterConfigSchema,
dashboardSummaryResponseSchema,
+ dashboardTaskTypeSchema,
+ dashboardTaskToneSchema,
+ dashboardTaskSchema,
+ paymentMethodsStatusSchema,
+ meStatusSchema,
} from "./schema.js";
export type ActivityType = z.infer;
@@ -20,3 +25,8 @@ export type DashboardError = z.infer;
export type ActivityFilter = z.infer;
export type ActivityFilterConfig = z.infer;
export type DashboardSummaryResponse = z.infer;
+export type DashboardTaskType = z.infer;
+export type DashboardTaskTone = z.infer;
+export type DashboardTask = z.infer;
+export type PaymentMethodsStatus = z.infer;
+export type MeStatus = z.infer;
diff --git a/packages/domain/dashboard/schema.ts b/packages/domain/dashboard/schema.ts
index 790993d5..e7fb3f2f 100644
--- a/packages/domain/dashboard/schema.ts
+++ b/packages/domain/dashboard/schema.ts
@@ -1,5 +1,7 @@
import { z } from "zod";
import { invoiceSchema } from "../billing/schema.js";
+import { internetEligibilityDetailsSchema } from "../catalog/schema.js";
+import { residenceCardVerificationSchema } from "../customer/schema.js";
export const activityTypeSchema = z.enum([
"invoice_created",
@@ -81,5 +83,57 @@ export const dashboardSummaryResponseSchema = dashboardSummarySchema.extend({
invoices: z.array(invoiceSchema).optional(),
});
+// ============================================================================
+// Dashboard Tasks (Customer-facing)
+// ============================================================================
+
+export const dashboardTaskTypeSchema = z.enum([
+ "invoice",
+ "payment_method",
+ "order",
+ "internet_eligibility",
+ "id_verification",
+ "onboarding",
+]);
+
+export const dashboardTaskToneSchema = z.enum(["critical", "warning", "info", "neutral"]);
+
+export const dashboardTaskSchema = z.object({
+ id: z.string(),
+ priority: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]),
+ type: dashboardTaskTypeSchema,
+ title: z.string(),
+ description: z.string(),
+ actionLabel: z.string(),
+ detailHref: z.string().optional(),
+ requiresSsoAction: z.boolean().optional(),
+ tone: dashboardTaskToneSchema,
+ metadata: z
+ .object({
+ invoiceId: z.number().int().positive().optional(),
+ orderId: z.string().optional(),
+ amount: z.number().optional(),
+ currency: z.string().optional(),
+ dueDate: z.string().datetime().optional(),
+ })
+ .optional(),
+});
+
+export const paymentMethodsStatusSchema = z.object({
+ /** null indicates the value could not be loaded (avoid incorrect UI gating). */
+ totalCount: z.number().int().nonnegative().nullable(),
+});
+
+/**
+ * Aggregated customer status payload intended to power dashboard + gating UX.
+ */
+export const meStatusSchema = z.object({
+ summary: dashboardSummarySchema,
+ paymentMethods: paymentMethodsStatusSchema,
+ internetEligibility: internetEligibilityDetailsSchema,
+ residenceCardVerification: residenceCardVerificationSchema,
+ tasks: z.array(dashboardTaskSchema),
+});
+
export type InvoiceActivityMetadata = z.infer;
export type ServiceActivityMetadata = z.infer;
diff --git a/packages/domain/dashboard/utils.ts b/packages/domain/dashboard/utils.ts
index 93c2c925..8cd4976f 100644
--- a/packages/domain/dashboard/utils.ts
+++ b/packages/domain/dashboard/utils.ts
@@ -57,9 +57,9 @@ export function isActivityClickable(activity: Activity): boolean {
}
/**
- * Dashboard task definition
+ * Quick action task definition for dashboard
*/
-export interface DashboardTask {
+export interface QuickActionTask {
label: string;
href: string;
}
@@ -73,10 +73,10 @@ export interface DashboardTaskSummary {
}
/**
- * Generate dashboard task suggestions based on summary data
+ * Generate dashboard quick action suggestions based on summary data
*/
-export function generateDashboardTasks(summary: DashboardTaskSummary): DashboardTask[] {
- const tasks: DashboardTask[] = [];
+export function generateQuickActions(summary: DashboardTaskSummary): QuickActionTask[] {
+ const tasks: QuickActionTask[] = [];
if (summary.nextInvoice) {
tasks.push({
@@ -101,4 +101,3 @@ export function generateDashboardTasks(summary: DashboardTaskSummary): Dashboard
return tasks;
}
-
diff --git a/packages/domain/notifications/schema.ts b/packages/domain/notifications/schema.ts
index 0c15f5e6..ab2575cf 100644
--- a/packages/domain/notifications/schema.ts
+++ b/packages/domain/notifications/schema.ts
@@ -56,7 +56,7 @@ export const NOTIFICATION_TEMPLATES: Record/dev/null
+
+if command -v git >/dev/null 2>&1; then
+ if git diff --quiet -- packages/domain/dist; then
+ echo "[domain] OK: packages/domain/dist is up to date."
+ exit 0
+ fi
+
+ echo "[domain] ERROR: packages/domain/dist is out of sync with source."
+ echo "[domain] Run: pnpm --filter @customer-portal/domain build"
+ echo "[domain] Then commit the updated dist outputs."
+ git --no-pager diff -- packages/domain/dist | head -200
+ exit 1
+fi
+
+echo "[domain] WARNING: git not found; cannot verify dist drift. Build completed."
+