Enhance Dashboard and Notification Features
- Introduced MeStatus module to aggregate customer status, integrating dashboard summary, payment methods, internet eligibility, and residence card verification. - Updated dashboard hooks to utilize MeStatus for improved data fetching and error handling. - Enhanced notification handling across various modules, including cancellation notifications for internet and SIM services, ensuring timely user alerts. - Refactored related schemas and services to support new dashboard tasks and notification types, improving overall user engagement and experience.
This commit is contained in:
parent
a61c2dd68b
commit
a6bc9666e1
@ -28,6 +28,7 @@ import { SalesforceEventsModule } from "@bff/integrations/salesforce/events/even
|
||||
// Feature Modules
|
||||
import { AuthModule } from "@bff/modules/auth/auth.module.js";
|
||||
import { UsersModule } from "@bff/modules/users/users.module.js";
|
||||
import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
|
||||
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
|
||||
@ -81,6 +82,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
|
||||
// === FEATURE MODULES ===
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
MeStatusModule,
|
||||
MappingsModule,
|
||||
CatalogModule,
|
||||
OrdersModule,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Routes } from "@nestjs/core";
|
||||
import { AuthModule } from "@bff/modules/auth/auth.module.js";
|
||||
import { UsersModule } from "@bff/modules/users/users.module.js";
|
||||
import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
|
||||
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
|
||||
@ -19,6 +20,7 @@ export const apiRoutes: Routes = [
|
||||
children: [
|
||||
{ path: "", module: AuthModule },
|
||||
{ path: "", module: UsersModule },
|
||||
{ path: "", module: MeStatusModule },
|
||||
{ path: "", module: MappingsModule },
|
||||
{ path: "", module: CatalogModule },
|
||||
{ path: "", module: OrdersModule },
|
||||
|
||||
@ -7,6 +7,7 @@ import { SalesforceAccountService } from "./services/salesforce-account.service.
|
||||
import { SalesforceOrderService } from "./services/salesforce-order.service.js";
|
||||
import { SalesforceCaseService } from "./services/salesforce-case.service.js";
|
||||
import { SalesforceOpportunityService } from "./services/salesforce-opportunity.service.js";
|
||||
import { OpportunityResolutionService } from "./services/opportunity-resolution.service.js";
|
||||
import { OrderFieldConfigModule } from "@bff/modules/orders/config/order-field-config.module.js";
|
||||
import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard.js";
|
||||
import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard.js";
|
||||
@ -19,6 +20,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
|
||||
SalesforceOrderService,
|
||||
SalesforceCaseService,
|
||||
SalesforceOpportunityService,
|
||||
OpportunityResolutionService,
|
||||
SalesforceService,
|
||||
SalesforceReadThrottleGuard,
|
||||
SalesforceWriteThrottleGuard,
|
||||
@ -31,6 +33,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
|
||||
SalesforceOrderService,
|
||||
SalesforceCaseService,
|
||||
SalesforceOpportunityService,
|
||||
OpportunityResolutionService,
|
||||
SalesforceReadThrottleGuard,
|
||||
SalesforceWriteThrottleGuard,
|
||||
],
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { SalesforceOpportunityService } from "./salesforce-opportunity.service.js";
|
||||
import { assertSalesforceId } from "../utils/soql.util.js";
|
||||
import type { OrderTypeValue } from "@customer-portal/domain/orders";
|
||||
import {
|
||||
APPLICATION_STAGE,
|
||||
OPPORTUNITY_PRODUCT_TYPE,
|
||||
OPPORTUNITY_SOURCE,
|
||||
OPPORTUNITY_STAGE,
|
||||
OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY,
|
||||
OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT,
|
||||
type OpportunityProductTypeValue,
|
||||
} from "@customer-portal/domain/opportunity";
|
||||
|
||||
/**
|
||||
* Opportunity Resolution Service
|
||||
*
|
||||
* Centralizes the "find or create" rules for Opportunities so eligibility, checkout,
|
||||
* and other flows cannot drift over time.
|
||||
*
|
||||
* Key principle:
|
||||
* - Eligibility can only match the initial Introduction opportunity.
|
||||
* - Order placement can match Introduction/Ready. It must never match Active.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OpportunityResolutionService {
|
||||
constructor(
|
||||
private readonly opportunities: SalesforceOpportunityService,
|
||||
private readonly lockService: DistributedLockService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve (find or create) an Internet Opportunity for eligibility request.
|
||||
*
|
||||
* NOTE: The eligibility flow itself should ensure idempotency for Case creation.
|
||||
* This method only resolves the Opportunity link.
|
||||
*/
|
||||
async findOrCreateForInternetEligibility(accountId: string): Promise<{
|
||||
opportunityId: string;
|
||||
wasCreated: boolean;
|
||||
}> {
|
||||
const safeAccountId = assertSalesforceId(accountId, "accountId");
|
||||
|
||||
const existing = await this.opportunities.findOpenOpportunityForAccount(
|
||||
safeAccountId,
|
||||
OPPORTUNITY_PRODUCT_TYPE.INTERNET,
|
||||
{ stages: OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY }
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
return { opportunityId: existing, wasCreated: false };
|
||||
}
|
||||
|
||||
const created = await this.opportunities.createOpportunity({
|
||||
accountId: safeAccountId,
|
||||
productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET,
|
||||
stage: OPPORTUNITY_STAGE.INTRODUCTION,
|
||||
source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY,
|
||||
applicationStage: APPLICATION_STAGE.INTRO_1,
|
||||
});
|
||||
|
||||
return { opportunityId: created, wasCreated: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve (find or create) an Opportunity for order placement.
|
||||
*
|
||||
* - If an OpportunityId is already provided, use it as-is.
|
||||
* - Otherwise, match only Introduction/Ready to avoid corrupting lifecycle tracking.
|
||||
* - If none found, create a new Opportunity in Post Processing stage.
|
||||
*/
|
||||
async resolveForOrderPlacement(params: {
|
||||
accountId: string | null;
|
||||
orderType: OrderTypeValue;
|
||||
existingOpportunityId?: string;
|
||||
}): Promise<string | null> {
|
||||
if (!params.accountId) return null;
|
||||
|
||||
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
|
||||
|
||||
if (params.existingOpportunityId) {
|
||||
return assertSalesforceId(params.existingOpportunityId, "existingOpportunityId");
|
||||
}
|
||||
|
||||
const productType = this.mapOrderTypeToProductType(params.orderType);
|
||||
const lockKey = `opportunity:order:${safeAccountId}:${productType}`;
|
||||
|
||||
return this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
const existing = await this.opportunities.findOpenOpportunityForAccount(
|
||||
safeAccountId,
|
||||
productType,
|
||||
{ stages: OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT }
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = await this.opportunities.createOpportunity({
|
||||
accountId: safeAccountId,
|
||||
productType,
|
||||
stage: OPPORTUNITY_STAGE.POST_PROCESSING,
|
||||
source: OPPORTUNITY_SOURCE.ORDER_PLACEMENT,
|
||||
applicationStage: APPLICATION_STAGE.INTRO_1,
|
||||
});
|
||||
|
||||
this.logger.log("Created new Opportunity for order placement", {
|
||||
accountIdTail: safeAccountId.slice(-4),
|
||||
opportunityIdTail: created.slice(-4),
|
||||
productType,
|
||||
orderType: params.orderType,
|
||||
});
|
||||
|
||||
return created;
|
||||
},
|
||||
{ ttlMs: 10_000 }
|
||||
);
|
||||
}
|
||||
|
||||
private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue {
|
||||
switch (orderType) {
|
||||
case "Internet":
|
||||
return OPPORTUNITY_PRODUCT_TYPE.INTERNET;
|
||||
case "SIM":
|
||||
return OPPORTUNITY_PRODUCT_TYPE.SIM;
|
||||
case "VPN":
|
||||
return OPPORTUNITY_PRODUCT_TYPE.VPN;
|
||||
default:
|
||||
return OPPORTUNITY_PRODUCT_TYPE.SIM;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,11 +3,9 @@ import { ZodValidationPipe } from "nestjs-zod";
|
||||
import { z } from "zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
|
||||
import {
|
||||
InternetCatalogService,
|
||||
type InternetEligibilityDto,
|
||||
} from "./services/internet-catalog.service.js";
|
||||
import { InternetCatalogService } from "./services/internet-catalog.service.js";
|
||||
import { addressSchema } from "@customer-portal/domain/customer";
|
||||
import type { InternetEligibilityDetails } from "@customer-portal/domain/catalog";
|
||||
|
||||
const eligibilityRequestSchema = z.object({
|
||||
notes: z.string().trim().max(2000).optional(),
|
||||
@ -33,7 +31,7 @@ export class InternetEligibilityController {
|
||||
|
||||
@Get("eligibility")
|
||||
@RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap)
|
||||
async getEligibility(@Req() req: RequestWithUser): Promise<InternetEligibilityDto> {
|
||||
async getEligibility(@Req() req: RequestWithUser): Promise<InternetEligibilityDetails> {
|
||||
return this.internetCatalog.getEligibilityDetailsForUser(req.user.id);
|
||||
}
|
||||
|
||||
|
||||
@ -7,16 +7,19 @@ import type {
|
||||
InternetPlanCatalogItem,
|
||||
InternetInstallationCatalogItem,
|
||||
InternetAddonCatalogItem,
|
||||
InternetEligibilityDetails,
|
||||
InternetEligibilityStatus,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import {
|
||||
Providers as CatalogProviders,
|
||||
enrichInternetPlanMetadata,
|
||||
inferAddonTypeFromSku,
|
||||
inferInstallationTermFromSku,
|
||||
internetEligibilityDetailsSchema,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
|
||||
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { Logger } from "nestjs-pino";
|
||||
@ -29,20 +32,8 @@ import {
|
||||
OPPORTUNITY_STAGE,
|
||||
OPPORTUNITY_SOURCE,
|
||||
OPPORTUNITY_PRODUCT_TYPE,
|
||||
OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY,
|
||||
} from "@customer-portal/domain/opportunity";
|
||||
|
||||
export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible";
|
||||
|
||||
export interface InternetEligibilityDto {
|
||||
status: InternetEligibilityStatusDto;
|
||||
eligibility: string | null;
|
||||
requestId: string | null;
|
||||
requestedAt: string | null;
|
||||
checkedAt: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InternetCatalogService extends BaseCatalogService {
|
||||
constructor(
|
||||
@ -52,7 +43,7 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
private mappingsService: MappingsService,
|
||||
private catalogCache: CatalogCacheService,
|
||||
private lockService: DistributedLockService,
|
||||
private opportunityService: SalesforceOpportunityService,
|
||||
private opportunityResolution: OpportunityResolutionService,
|
||||
private caseService: SalesforceCaseService
|
||||
) {
|
||||
super(sf, config, logger);
|
||||
@ -192,7 +183,7 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
// Get customer's eligibility from Salesforce
|
||||
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
|
||||
const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId);
|
||||
const details = await this.catalogCache.getCachedEligibility<InternetEligibilityDto>(
|
||||
const details = await this.catalogCache.getCachedEligibility<InternetEligibilityDetails>(
|
||||
eligibilityKey,
|
||||
async () => this.queryEligibilityDetails(sfAccountId)
|
||||
);
|
||||
@ -239,22 +230,22 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
return details.eligibility;
|
||||
}
|
||||
|
||||
async getEligibilityDetailsForUser(userId: string): Promise<InternetEligibilityDto> {
|
||||
async getEligibilityDetailsForUser(userId: string): Promise<InternetEligibilityDetails> {
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
if (!mapping?.sfAccountId) {
|
||||
return {
|
||||
return internetEligibilityDetailsSchema.parse({
|
||||
status: "not_requested",
|
||||
eligibility: null,
|
||||
requestId: null,
|
||||
requestedAt: null,
|
||||
checkedAt: null,
|
||||
notes: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
|
||||
const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId);
|
||||
return this.catalogCache.getCachedEligibility<InternetEligibilityDto>(
|
||||
return this.catalogCache.getCachedEligibility<InternetEligibilityDetails>(
|
||||
eligibilityKey,
|
||||
async () => this.queryEligibilityDetails(sfAccountId)
|
||||
);
|
||||
@ -298,29 +289,8 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
}
|
||||
|
||||
// 1) Find or create Opportunity for Internet eligibility
|
||||
// Only match Introduction stage. "Ready"/"Post Processing"/"Active" indicate the journey progressed.
|
||||
let opportunityId = await this.opportunityService.findOpenOpportunityForAccount(
|
||||
sfAccountId,
|
||||
OPPORTUNITY_PRODUCT_TYPE.INTERNET,
|
||||
{ stages: OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY }
|
||||
);
|
||||
|
||||
let opportunityCreated = false;
|
||||
if (!opportunityId) {
|
||||
// Create Opportunity - Salesforce workflow auto-generates the name
|
||||
opportunityId = await this.opportunityService.createOpportunity({
|
||||
accountId: sfAccountId,
|
||||
productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET,
|
||||
stage: OPPORTUNITY_STAGE.INTRODUCTION,
|
||||
source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY,
|
||||
});
|
||||
opportunityCreated = true;
|
||||
|
||||
this.logger.log("Created Opportunity for eligibility request", {
|
||||
opportunityIdTail: opportunityId.slice(-4),
|
||||
sfAccountIdTail: sfAccountId.slice(-4),
|
||||
});
|
||||
}
|
||||
const { opportunityId, wasCreated: opportunityCreated } =
|
||||
await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId);
|
||||
|
||||
// 2) Build case description
|
||||
const subject = "Internet availability check request (Portal)";
|
||||
@ -380,7 +350,7 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
return plan.internetOfferingType === eligibility;
|
||||
}
|
||||
|
||||
private async queryEligibilityDetails(sfAccountId: string): Promise<InternetEligibilityDto> {
|
||||
private async queryEligibilityDetails(sfAccountId: string): Promise<InternetEligibilityDetails> {
|
||||
const eligibilityField = assertSoqlFieldName(
|
||||
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_FIELD") ?? "Internet_Eligibility__c",
|
||||
"ACCOUNT_INTERNET_ELIGIBILITY_FIELD"
|
||||
@ -423,14 +393,14 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
})) as SalesforceResponse<Record<string, unknown>>;
|
||||
const record = (res.records?.[0] as Record<string, unknown> | undefined) ?? undefined;
|
||||
if (!record) {
|
||||
return {
|
||||
return internetEligibilityDetailsSchema.parse({
|
||||
status: "not_requested",
|
||||
eligibility: null,
|
||||
requestId: null,
|
||||
requestedAt: null,
|
||||
checkedAt: null,
|
||||
notes: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const eligibilityRaw = record[eligibilityField];
|
||||
@ -442,7 +412,7 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
const statusRaw = record[statusField];
|
||||
const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : "";
|
||||
|
||||
const status: InternetEligibilityStatusDto =
|
||||
const status: InternetEligibilityStatus =
|
||||
normalizedStatus === "pending" || normalizedStatus === "checking"
|
||||
? "pending"
|
||||
: normalizedStatus === "eligible"
|
||||
@ -475,7 +445,14 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
: null;
|
||||
const notes = typeof notesRaw === "string" && notesRaw.trim() ? notesRaw.trim() : null;
|
||||
|
||||
return { status, eligibility, requestId, requestedAt, checkedAt, notes };
|
||||
return internetEligibilityDetailsSchema.parse({
|
||||
status,
|
||||
eligibility,
|
||||
requestId,
|
||||
requestedAt,
|
||||
checkedAt,
|
||||
notes,
|
||||
});
|
||||
}
|
||||
|
||||
// Note: createEligibilityCaseOrTask was removed - now using this.caseService.createEligibilityCase()
|
||||
|
||||
16
apps/bff/src/modules/me-status/me-status.controller.ts
Normal file
16
apps/bff/src/modules/me-status/me-status.controller.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Controller, Get, Req, UseGuards } from "@nestjs/common";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||
import { MeStatusService } from "./me-status.service.js";
|
||||
import type { MeStatus } from "@customer-portal/domain/dashboard";
|
||||
|
||||
@Controller("me")
|
||||
export class MeStatusController {
|
||||
constructor(private readonly meStatus: MeStatusService) {}
|
||||
|
||||
@UseGuards(SalesforceReadThrottleGuard)
|
||||
@Get("status")
|
||||
async getStatus(@Req() req: RequestWithUser): Promise<MeStatus> {
|
||||
return this.meStatus.getStatusForUser(req.user.id);
|
||||
}
|
||||
}
|
||||
25
apps/bff/src/modules/me-status/me-status.module.ts
Normal file
25
apps/bff/src/modules/me-status/me-status.module.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MeStatusController } from "./me-status.controller.js";
|
||||
import { MeStatusService } from "./me-status.service.js";
|
||||
import { UsersModule } from "@bff/modules/users/users.module.js";
|
||||
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
|
||||
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
|
||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
OrdersModule,
|
||||
CatalogModule,
|
||||
VerificationModule,
|
||||
WhmcsModule,
|
||||
MappingsModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [MeStatusController],
|
||||
providers: [MeStatusService],
|
||||
})
|
||||
export class MeStatusModule {}
|
||||
264
apps/bff/src/modules/me-status/me-status.service.ts
Normal file
264
apps/bff/src/modules/me-status/me-status.service.ts
Normal file
@ -0,0 +1,264 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||
import { OrderOrchestrator } from "@bff/modules/orders/services/order-orchestrator.service.js";
|
||||
import { InternetCatalogService } from "@bff/modules/catalog/services/internet-catalog.service.js";
|
||||
import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
|
||||
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
||||
import {
|
||||
meStatusSchema,
|
||||
type DashboardSummary,
|
||||
type DashboardTask,
|
||||
type MeStatus,
|
||||
type PaymentMethodsStatus,
|
||||
} from "@customer-portal/domain/dashboard";
|
||||
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
||||
import type { InternetEligibilityDetails } from "@customer-portal/domain/catalog";
|
||||
import type { ResidenceCardVerification } from "@customer-portal/domain/customer";
|
||||
import type { OrderSummary } from "@customer-portal/domain/orders";
|
||||
|
||||
@Injectable()
|
||||
export class MeStatusService {
|
||||
constructor(
|
||||
private readonly users: UsersFacade,
|
||||
private readonly orders: OrderOrchestrator,
|
||||
private readonly internetCatalog: InternetCatalogService,
|
||||
private readonly residenceCards: ResidenceCardService,
|
||||
private readonly mappings: MappingsService,
|
||||
private readonly whmcsPayments: WhmcsPaymentService,
|
||||
private readonly notifications: NotificationService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async getStatusForUser(userId: string): Promise<MeStatus> {
|
||||
const [summary, internetEligibility, residenceCardVerification, orders] = await Promise.all([
|
||||
this.users.getUserSummary(userId),
|
||||
this.internetCatalog.getEligibilityDetailsForUser(userId),
|
||||
this.residenceCards.getStatusForUser(userId),
|
||||
this.safeGetOrders(userId),
|
||||
]);
|
||||
|
||||
const paymentMethods = await this.safeGetPaymentMethodsStatus(userId);
|
||||
|
||||
const tasks = this.computeTasks({
|
||||
summary,
|
||||
paymentMethods,
|
||||
internetEligibility,
|
||||
residenceCardVerification,
|
||||
orders,
|
||||
});
|
||||
|
||||
await this.maybeCreateInvoiceDueNotification(userId, summary);
|
||||
|
||||
return meStatusSchema.parse({
|
||||
summary,
|
||||
paymentMethods,
|
||||
internetEligibility,
|
||||
residenceCardVerification,
|
||||
tasks,
|
||||
});
|
||||
}
|
||||
|
||||
private async safeGetOrders(userId: string): Promise<OrderSummary[] | null> {
|
||||
try {
|
||||
const result = await this.orders.getOrdersForUser(userId);
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ userId, err: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to load orders for status payload"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async safeGetPaymentMethodsStatus(userId: string): Promise<PaymentMethodsStatus> {
|
||||
try {
|
||||
const mapping = await this.mappings.findByUserId(userId);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
return { totalCount: null };
|
||||
}
|
||||
|
||||
const list = await this.whmcsPayments.getPaymentMethods(mapping.whmcsClientId, userId);
|
||||
return { totalCount: typeof list?.totalCount === "number" ? list.totalCount : 0 };
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ userId, err: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to load payment methods for status payload"
|
||||
);
|
||||
return { totalCount: null };
|
||||
}
|
||||
}
|
||||
|
||||
private computeTasks(params: {
|
||||
summary: DashboardSummary;
|
||||
paymentMethods: PaymentMethodsStatus;
|
||||
internetEligibility: InternetEligibilityDetails;
|
||||
residenceCardVerification: ResidenceCardVerification;
|
||||
orders: OrderSummary[] | null;
|
||||
}): DashboardTask[] {
|
||||
const tasks: DashboardTask[] = [];
|
||||
|
||||
const { summary, paymentMethods, internetEligibility, residenceCardVerification, orders } =
|
||||
params;
|
||||
|
||||
// Priority 1: next unpaid invoice
|
||||
if (summary.nextInvoice) {
|
||||
const dueDate = new Date(summary.nextInvoice.dueDate);
|
||||
const isValid = !Number.isNaN(dueDate.getTime());
|
||||
const isOverdue = isValid ? dueDate.getTime() < Date.now() : false;
|
||||
|
||||
const formattedAmount = new Intl.NumberFormat("ja-JP", {
|
||||
style: "currency",
|
||||
currency: summary.nextInvoice.currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(summary.nextInvoice.amount);
|
||||
|
||||
const dueText = isValid
|
||||
? dueDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||
: "soon";
|
||||
|
||||
tasks.push({
|
||||
id: `invoice-${summary.nextInvoice.id}`,
|
||||
priority: 1,
|
||||
type: "invoice",
|
||||
title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice",
|
||||
description: `Invoice #${summary.nextInvoice.id} · ${formattedAmount} · Due ${dueText}`,
|
||||
actionLabel: "Pay now",
|
||||
detailHref: `/account/billing/invoices/${summary.nextInvoice.id}`,
|
||||
requiresSsoAction: true,
|
||||
tone: "critical",
|
||||
metadata: {
|
||||
invoiceId: summary.nextInvoice.id,
|
||||
amount: summary.nextInvoice.amount,
|
||||
currency: summary.nextInvoice.currency,
|
||||
...(isValid ? { dueDate: dueDate.toISOString() } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 2: no payment method (only when we could verify)
|
||||
if (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",
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 3: pending orders
|
||||
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",
|
||||
metadata: { orderId: order.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Internet eligibility review (only when explicitly pending)
|
||||
if (internetEligibility.status === "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",
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 4: ID verification rejected
|
||||
if (residenceCardVerification.status === "rejected") {
|
||||
tasks.push({
|
||||
id: "id-verification-rejected",
|
||||
priority: 4,
|
||||
type: "id_verification",
|
||||
title: "ID verification requires attention",
|
||||
description: "We couldn’t verify your ID. Please review the feedback and resubmit.",
|
||||
actionLabel: "Resubmit",
|
||||
detailHref: "/account/settings/verification",
|
||||
tone: "warning",
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 4: onboarding (only when 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",
|
||||
});
|
||||
}
|
||||
|
||||
return tasks.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
private async maybeCreateInvoiceDueNotification(
|
||||
userId: string,
|
||||
summary: DashboardSummary
|
||||
): Promise<void> {
|
||||
const invoice = summary.nextInvoice;
|
||||
if (!invoice) return;
|
||||
|
||||
try {
|
||||
const dueDate = new Date(invoice.dueDate);
|
||||
if (Number.isNaN(dueDate.getTime())) return;
|
||||
|
||||
const daysUntilDue = (dueDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
|
||||
// Notify when due within a week (or overdue).
|
||||
if (daysUntilDue > 7) return;
|
||||
|
||||
await this.notifications.createNotification({
|
||||
userId,
|
||||
type: NOTIFICATION_TYPE.INVOICE_DUE,
|
||||
source: NOTIFICATION_SOURCE.SYSTEM,
|
||||
sourceId: `invoice:${invoice.id}`,
|
||||
actionUrl: `/account/billing/invoices/${invoice.id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ userId, err: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to create invoice due notification"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,16 @@ import {
|
||||
// Notification expiry in days
|
||||
const NOTIFICATION_EXPIRY_DAYS = 30;
|
||||
|
||||
// Dedupe window (in hours) per notification type.
|
||||
// Defaults to 1 hour when not specified.
|
||||
const NOTIFICATION_DEDUPE_WINDOW_HOURS: Partial<Record<NotificationTypeValue, number>> = {
|
||||
// These are often evaluated opportunistically (e.g., on dashboard load),
|
||||
// so keep the dedupe window larger to avoid spam.
|
||||
INVOICE_DUE: 24,
|
||||
PAYMENT_METHOD_EXPIRING: 24,
|
||||
SYSTEM_ANNOUNCEMENT: 24,
|
||||
};
|
||||
|
||||
export interface CreateNotificationParams {
|
||||
userId: string;
|
||||
type: NotificationTypeValue;
|
||||
@ -54,17 +64,17 @@ export class NotificationService {
|
||||
expiresAt.setDate(expiresAt.getDate() + NOTIFICATION_EXPIRY_DAYS);
|
||||
|
||||
try {
|
||||
// Check for duplicate notification (same type + sourceId within last hour)
|
||||
// Check for duplicate notification (same type + sourceId within a short window)
|
||||
if (params.sourceId) {
|
||||
const oneHourAgo = new Date();
|
||||
oneHourAgo.setHours(oneHourAgo.getHours() - 1);
|
||||
const dedupeHours = NOTIFICATION_DEDUPE_WINDOW_HOURS[params.type] ?? 1;
|
||||
const since = new Date(Date.now() - dedupeHours * 60 * 60 * 1000);
|
||||
|
||||
const existingNotification = await this.prisma.notification.findFirst({
|
||||
where: {
|
||||
userId: params.userId,
|
||||
type: params.type,
|
||||
sourceId: params.sourceId,
|
||||
createdAt: { gte: oneHourAgo },
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import { DatabaseModule } from "@bff/core/database/database.module.js";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
|
||||
import { CacheModule } from "@bff/infra/cache/cache.module.js";
|
||||
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
|
||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||
|
||||
// Clean modular order services
|
||||
import { OrderValidator } from "./services/order-validator.service.js";
|
||||
@ -41,6 +42,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
|
||||
CatalogModule,
|
||||
CacheModule,
|
||||
VerificationModule,
|
||||
NotificationsModule,
|
||||
OrderFieldConfigModule,
|
||||
],
|
||||
controllers: [OrdersController, CheckoutController],
|
||||
|
||||
@ -12,12 +12,16 @@ import { DistributedTransactionService } from "@bff/core/database/services/distr
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { OrderEventsService } from "./order-events.service.js";
|
||||
import { OrdersCacheService } from "./orders-cache.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
||||
import {
|
||||
type OrderDetails,
|
||||
type OrderFulfillmentValidationResult,
|
||||
Providers as OrderProviders,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
|
||||
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
||||
import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
|
||||
import {
|
||||
OrderValidationException,
|
||||
FulfillmentException,
|
||||
@ -61,7 +65,9 @@ export class OrderFulfillmentOrchestrator {
|
||||
private readonly simFulfillmentService: SimFulfillmentService,
|
||||
private readonly distributedTransactionService: DistributedTransactionService,
|
||||
private readonly orderEvents: OrderEventsService,
|
||||
private readonly ordersCache: OrdersCacheService
|
||||
private readonly ordersCache: OrdersCacheService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly notifications: NotificationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -174,6 +180,12 @@ export class OrderFulfillmentOrchestrator {
|
||||
source: "fulfillment",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await this.safeNotifyOrder({
|
||||
type: NOTIFICATION_TYPE.ORDER_APPROVED,
|
||||
sfOrderId,
|
||||
accountId: context.validation?.sfOrder?.AccountId,
|
||||
actionUrl: `/account/orders/${sfOrderId}`,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
rollback: async () => {
|
||||
@ -343,6 +355,12 @@ export class OrderFulfillmentOrchestrator {
|
||||
whmcsServiceIds: whmcsCreateResult?.serviceIds,
|
||||
},
|
||||
});
|
||||
await this.safeNotifyOrder({
|
||||
type: NOTIFICATION_TYPE.ORDER_ACTIVATED,
|
||||
sfOrderId,
|
||||
accountId: context.validation?.sfOrder?.AccountId,
|
||||
actionUrl: "/account/services",
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
rollback: async () => {
|
||||
@ -442,6 +460,12 @@ export class OrderFulfillmentOrchestrator {
|
||||
} catch (error) {
|
||||
await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId);
|
||||
await this.handleFulfillmentError(context, error as Error);
|
||||
await this.safeNotifyOrder({
|
||||
type: NOTIFICATION_TYPE.ORDER_FAILED,
|
||||
sfOrderId,
|
||||
accountId: context.validation?.sfOrder?.AccountId,
|
||||
actionUrl: `/account/orders/${sfOrderId}`,
|
||||
});
|
||||
this.orderEvents.publish(sfOrderId, {
|
||||
orderId: sfOrderId,
|
||||
status: "Pending Review",
|
||||
@ -501,6 +525,38 @@ export class OrderFulfillmentOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
private async safeNotifyOrder(params: {
|
||||
type: (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE];
|
||||
sfOrderId: string;
|
||||
accountId?: unknown;
|
||||
actionUrl: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const sfAccountId = salesforceAccountIdSchema.safeParse(params.accountId);
|
||||
if (!sfAccountId.success) return;
|
||||
|
||||
const mapping = await this.mappingsService.findBySfAccountId(sfAccountId.data);
|
||||
if (!mapping?.userId) return;
|
||||
|
||||
await this.notifications.createNotification({
|
||||
userId: mapping.userId,
|
||||
type: params.type,
|
||||
source: NOTIFICATION_SOURCE.SYSTEM,
|
||||
sourceId: params.sfOrderId,
|
||||
actionUrl: params.actionUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
sfOrderId: params.sfOrderId,
|
||||
type: params.type,
|
||||
err: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to create in-app order notification"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fulfillment errors and update Salesforce
|
||||
*/
|
||||
|
||||
@ -2,7 +2,7 @@ import { Injectable, Inject, NotFoundException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js";
|
||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
|
||||
import { OrderValidator } from "./order-validator.service.js";
|
||||
import { OrderBuilder } from "./order-builder.service.js";
|
||||
import { OrderItemBuilder } from "./order-item-builder.service.js";
|
||||
@ -10,13 +10,7 @@ import type { OrderItemCompositePayload } from "./order-item-builder.service.js"
|
||||
import { OrdersCacheService } from "./orders-cache.service.js";
|
||||
import type { OrderDetails, OrderSummary, OrderTypeValue } from "@customer-portal/domain/orders";
|
||||
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
|
||||
import {
|
||||
OPPORTUNITY_STAGE,
|
||||
OPPORTUNITY_SOURCE,
|
||||
OPPORTUNITY_PRODUCT_TYPE,
|
||||
type OpportunityProductTypeValue,
|
||||
OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT,
|
||||
} from "@customer-portal/domain/opportunity";
|
||||
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
|
||||
|
||||
type OrderDetailsResponse = OrderDetails;
|
||||
type OrderSummaryResponse = OrderSummary;
|
||||
@ -31,7 +25,7 @@ export class OrderOrchestrator {
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly salesforceOrderService: SalesforceOrderService,
|
||||
private readonly opportunityService: SalesforceOpportunityService,
|
||||
private readonly lockService: DistributedLockService,
|
||||
private readonly opportunityResolution: OpportunityResolutionService,
|
||||
private readonly orderValidator: OrderValidator,
|
||||
private readonly orderBuilder: OrderBuilder,
|
||||
private readonly orderItemBuilder: OrderItemBuilder,
|
||||
@ -148,87 +142,28 @@ export class OrderOrchestrator {
|
||||
sfAccountId: string | null,
|
||||
existingOpportunityId?: string
|
||||
): Promise<string | null> {
|
||||
// If account ID is missing, can't create Opportunity
|
||||
if (!sfAccountId) {
|
||||
this.logger.warn("Cannot resolve Opportunity: no Salesforce Account ID");
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeAccountId = assertSalesforceId(sfAccountId, "sfAccountId");
|
||||
const productType = this.mapOrderTypeToProductType(orderType);
|
||||
|
||||
// If order already has Opportunity ID, use it
|
||||
if (existingOpportunityId) {
|
||||
this.logger.debug("Using existing Opportunity from order", {
|
||||
opportunityId: existingOpportunityId,
|
||||
});
|
||||
return existingOpportunityId;
|
||||
}
|
||||
|
||||
try {
|
||||
const lockKey = `opportunity:order:${safeAccountId}:${productType}`;
|
||||
|
||||
return await this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
// Try to find existing matchable Opportunity (Introduction or Ready stage)
|
||||
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
|
||||
safeAccountId,
|
||||
productType,
|
||||
{ stages: OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT }
|
||||
);
|
||||
|
||||
if (existingOppId) {
|
||||
this.logger.log("Found existing Opportunity for order", {
|
||||
opportunityIdTail: existingOppId.slice(-4),
|
||||
productType,
|
||||
});
|
||||
return existingOppId;
|
||||
}
|
||||
|
||||
// Create new Opportunity - Salesforce workflow auto-generates the name
|
||||
const newOppId = await this.opportunityService.createOpportunity({
|
||||
accountId: safeAccountId,
|
||||
productType,
|
||||
stage: OPPORTUNITY_STAGE.POST_PROCESSING,
|
||||
source: OPPORTUNITY_SOURCE.ORDER_PLACEMENT,
|
||||
});
|
||||
|
||||
this.logger.log("Created new Opportunity for order", {
|
||||
opportunityIdTail: newOppId.slice(-4),
|
||||
productType,
|
||||
});
|
||||
|
||||
return newOppId;
|
||||
},
|
||||
{ ttlMs: 10_000 }
|
||||
);
|
||||
} catch {
|
||||
this.logger.warn("Failed to resolve Opportunity for order", {
|
||||
const resolved = await this.opportunityResolution.resolveForOrderPlacement({
|
||||
accountId: sfAccountId,
|
||||
orderType,
|
||||
accountIdTail: safeAccountId.slice(-4),
|
||||
existingOpportunityId,
|
||||
});
|
||||
if (resolved) {
|
||||
this.logger.debug("Resolved Opportunity for order", {
|
||||
opportunityIdTail: resolved.slice(-4),
|
||||
orderType,
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
} catch {
|
||||
const accountIdTail =
|
||||
typeof sfAccountId === "string" && sfAccountId.length >= 4 ? sfAccountId.slice(-4) : "none";
|
||||
this.logger.warn("Failed to resolve Opportunity for order", { orderType, accountIdTail });
|
||||
// Don't fail the order if Opportunity resolution fails
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map order type to Opportunity product type
|
||||
*/
|
||||
private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue {
|
||||
switch (orderType) {
|
||||
case "Internet":
|
||||
return OPPORTUNITY_PRODUCT_TYPE.INTERNET;
|
||||
case "SIM":
|
||||
return OPPORTUNITY_PRODUCT_TYPE.SIM;
|
||||
case "VPN":
|
||||
return OPPORTUNITY_PRODUCT_TYPE.VPN;
|
||||
default:
|
||||
return OPPORTUNITY_PRODUCT_TYPE.SIM; // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order by ID with order items
|
||||
*/
|
||||
|
||||
@ -4,9 +4,10 @@ import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
|
||||
import { EmailModule } from "@bff/infra/email/email.module.js";
|
||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [WhmcsModule, MappingsModule, SalesforceModule, EmailModule],
|
||||
imports: [WhmcsModule, MappingsModule, SalesforceModule, EmailModule, NotificationsModule],
|
||||
providers: [InternetCancellationService],
|
||||
exports: [InternetCancellationService],
|
||||
})
|
||||
|
||||
@ -18,6 +18,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
||||
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
||||
import type {
|
||||
InternetCancellationPreview,
|
||||
InternetCancellationMonth,
|
||||
@ -28,6 +29,7 @@ import {
|
||||
CANCELLATION_NOTICE,
|
||||
LINE_RETURN_STATUS,
|
||||
} from "@customer-portal/domain/opportunity";
|
||||
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
||||
|
||||
@Injectable()
|
||||
export class InternetCancellationService {
|
||||
@ -37,6 +39,7 @@ export class InternetCancellationService {
|
||||
private readonly caseService: SalesforceCaseService,
|
||||
private readonly opportunityService: SalesforceOpportunityService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly notifications: NotificationService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ -232,6 +235,23 @@ export class InternetCancellationService {
|
||||
opportunityId,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.notifications.createNotification({
|
||||
userId,
|
||||
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
||||
source: NOTIFICATION_SOURCE.SYSTEM,
|
||||
sourceId: caseId,
|
||||
actionUrl: `/account/services/${subscriptionId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to create cancellation notification", {
|
||||
userId,
|
||||
subscriptionId,
|
||||
caseId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// Update Opportunity if found
|
||||
if (opportunityId) {
|
||||
try {
|
||||
|
||||
@ -9,6 +9,8 @@ import type { SimCancelRequest, SimCancelFullRequest } from "@customer-portal/do
|
||||
import { SimScheduleService } from "./sim-schedule.service.js";
|
||||
import { SimActionRunnerService } from "./sim-action-runner.service.js";
|
||||
import { SimApiNotificationService } from "./sim-api-notification.service.js";
|
||||
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
||||
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
||||
|
||||
export interface CancellationMonth {
|
||||
value: string; // YYYY-MM format
|
||||
@ -38,6 +40,7 @@ export class SimCancellationService {
|
||||
private readonly simSchedule: SimScheduleService,
|
||||
private readonly simActionRunner: SimActionRunnerService,
|
||||
private readonly apiNotification: SimApiNotificationService,
|
||||
private readonly notifications: NotificationService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
@ -254,6 +257,24 @@ export class SimCancellationService {
|
||||
runDate,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.notifications.createNotification({
|
||||
userId,
|
||||
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
||||
source: NOTIFICATION_SOURCE.SYSTEM,
|
||||
sourceId: `sim:${subscriptionId}:${runDate}`,
|
||||
actionUrl: `/account/services/${subscriptionId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to create SIM cancellation notification", {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
runDate,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// Send admin notification email
|
||||
const adminEmailBody = this.apiNotification.buildCancellationAdminEmail({
|
||||
customerName,
|
||||
|
||||
@ -28,6 +28,7 @@ import { SimManagementProcessor } from "./queue/sim-management.processor.js";
|
||||
import { SimVoiceOptionsService } from "./services/sim-voice-options.service.js";
|
||||
import { SimCallHistoryService } from "./services/sim-call-history.service.js";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
|
||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -38,6 +39,7 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
|
||||
EmailModule,
|
||||
CatalogModule,
|
||||
SftpModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
providers: [
|
||||
// Core services that the SIM services depend on
|
||||
|
||||
@ -11,10 +11,8 @@ import {
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
|
||||
import {
|
||||
ResidenceCardService,
|
||||
type ResidenceCardVerificationDto,
|
||||
} from "./residence-card.service.js";
|
||||
import { ResidenceCardService } from "./residence-card.service.js";
|
||||
import type { ResidenceCardVerification } from "@customer-portal/domain/customer";
|
||||
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB
|
||||
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "application/pdf"]);
|
||||
@ -33,7 +31,7 @@ export class ResidenceCardController {
|
||||
|
||||
@Get()
|
||||
@RateLimit({ limit: 60, ttl: 60 })
|
||||
async getStatus(@Req() req: RequestWithUser): Promise<ResidenceCardVerificationDto> {
|
||||
async getStatus(@Req() req: RequestWithUser): Promise<ResidenceCardVerification> {
|
||||
return this.residenceCards.getStatusForUser(req.user.id);
|
||||
}
|
||||
|
||||
@ -57,7 +55,7 @@ export class ResidenceCardController {
|
||||
async submit(
|
||||
@Req() req: RequestWithUser,
|
||||
@UploadedFile() file?: UploadedResidenceCard
|
||||
): Promise<ResidenceCardVerificationDto> {
|
||||
): Promise<ResidenceCardVerification> {
|
||||
if (!file) {
|
||||
throw new BadRequestException("Missing file upload.");
|
||||
}
|
||||
|
||||
@ -8,21 +8,14 @@ import {
|
||||
assertSoqlFieldName,
|
||||
} from "@bff/integrations/salesforce/utils/soql.util.js";
|
||||
import type { SalesforceResponse } from "@customer-portal/domain/common";
|
||||
import {
|
||||
residenceCardVerificationSchema,
|
||||
type ResidenceCardVerification,
|
||||
type ResidenceCardVerificationStatus,
|
||||
} from "@customer-portal/domain/customer";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { basename, extname } from "node:path";
|
||||
|
||||
type ResidenceCardStatusDto = "not_submitted" | "pending" | "verified" | "rejected";
|
||||
|
||||
export interface ResidenceCardVerificationDto {
|
||||
status: ResidenceCardStatusDto;
|
||||
filename: string | null;
|
||||
mimeType: string | null;
|
||||
sizeBytes: number | null;
|
||||
submittedAt: string | null;
|
||||
reviewedAt: string | null;
|
||||
reviewerNotes: string | null;
|
||||
}
|
||||
|
||||
function mapFileTypeToMime(fileType?: string | null): string | null {
|
||||
const normalized = String(fileType || "")
|
||||
.trim()
|
||||
@ -42,13 +35,13 @@ export class ResidenceCardService {
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async getStatusForUser(userId: string): Promise<ResidenceCardVerificationDto> {
|
||||
async getStatusForUser(userId: string): Promise<ResidenceCardVerification> {
|
||||
const mapping = await this.mappings.findByUserId(userId);
|
||||
const sfAccountId = mapping?.sfAccountId
|
||||
? assertSalesforceId(mapping.sfAccountId, "sfAccountId")
|
||||
: null;
|
||||
if (!sfAccountId) {
|
||||
return {
|
||||
return residenceCardVerificationSchema.parse({
|
||||
status: "not_submitted",
|
||||
filename: null,
|
||||
mimeType: null,
|
||||
@ -56,7 +49,7 @@ export class ResidenceCardService {
|
||||
submittedAt: null,
|
||||
reviewedAt: null,
|
||||
reviewerNotes: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const fields = this.getAccountFieldNames();
|
||||
@ -75,7 +68,7 @@ export class ResidenceCardService {
|
||||
const statusRaw = account ? account[fields.status] : undefined;
|
||||
const statusText = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : "";
|
||||
|
||||
const status: ResidenceCardStatusDto =
|
||||
const status: ResidenceCardVerificationStatus =
|
||||
statusText === "verified"
|
||||
? "verified"
|
||||
: statusText === "rejected"
|
||||
@ -114,7 +107,7 @@ export class ResidenceCardService {
|
||||
const fileMeta =
|
||||
status === "not_submitted" ? null : await this.getLatestAccountFileMetadata(sfAccountId);
|
||||
|
||||
return {
|
||||
return residenceCardVerificationSchema.parse({
|
||||
status,
|
||||
filename: fileMeta?.filename ?? null,
|
||||
mimeType: fileMeta?.mimeType ?? null,
|
||||
@ -122,7 +115,7 @@ export class ResidenceCardService {
|
||||
submittedAt: submittedAt ?? fileMeta?.submittedAt ?? null,
|
||||
reviewedAt,
|
||||
reviewerNotes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async submitForUser(params: {
|
||||
@ -131,7 +124,7 @@ export class ResidenceCardService {
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
content: Uint8Array<ArrayBuffer>;
|
||||
}): Promise<ResidenceCardVerificationDto> {
|
||||
}): Promise<ResidenceCardVerification> {
|
||||
const mapping = await this.mappings.findByUserId(params.userId);
|
||||
if (!mapping?.sfAccountId) {
|
||||
throw new Error("No Salesforce mapping found for current user");
|
||||
|
||||
@ -567,7 +567,12 @@ export default function ProfileContainer() {
|
||||
{verificationQuery.data?.reviewerNotes && (
|
||||
<p>{verificationQuery.data.reviewerNotes}</p>
|
||||
)}
|
||||
<p>Please upload a new, clear photo of your residence card.</p>
|
||||
<p>Please upload a new, clear photo or scan of your residence card.</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
<li>Make sure all text is readable and the full card is visible.</li>
|
||||
<li>Avoid glare/reflections and blurry photos.</li>
|
||||
<li>Maximum file size: 5MB.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AlertBanner>
|
||||
) : (
|
||||
@ -577,6 +582,37 @@ export default function ProfileContainer() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(verificationQuery.data?.filename ||
|
||||
verificationQuery.data?.submittedAt ||
|
||||
verificationQuery.data?.reviewedAt) && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Latest submission
|
||||
</div>
|
||||
{verificationQuery.data?.filename && (
|
||||
<div className="mt-1 text-sm font-medium text-foreground">
|
||||
{verificationQuery.data.filename}
|
||||
</div>
|
||||
)}
|
||||
{verificationQuery.data?.submittedAt && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Submitted on{" "}
|
||||
{new Date(verificationQuery.data.submittedAt).toLocaleDateString(undefined, {
|
||||
dateStyle: "medium",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{verificationQuery.data?.reviewedAt && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Reviewed on{" "}
|
||||
{new Date(verificationQuery.data.reviewedAt).toLocaleDateString(undefined, {
|
||||
dateStyle: "medium",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUploadVerification && (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
@ -644,7 +680,7 @@ export default function ProfileContainer() {
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -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<InternetCatalogCollection> {
|
||||
const response = await apiClient.GET<InternetCatalogCollection>("/api/catalog/internet/plans");
|
||||
@ -91,7 +82,8 @@ export const catalogService = {
|
||||
const response = await apiClient.GET<InternetEligibilityDetails>(
|
||||
"/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?: {
|
||||
|
||||
@ -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
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mt-2 max-w-3xl leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Service-specific ordering info */}
|
||||
<div className="bg-muted/40 border border-border rounded-2xl p-6 mb-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[
|
||||
{
|
||||
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 => (
|
||||
<div key={step.title} className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<CheckCircleIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">{step.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{step.description}</div>
|
||||
<h2 className="text-sm font-semibold text-foreground mb-4">What to expect when ordering</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/10 text-blue-500 border border-blue-500/20 flex-shrink-0">
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">Internet</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Requires address verification (1-2 business days). We'll email you when plans
|
||||
are ready.
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-green-500/10 text-green-500 border border-green-500/20 flex-shrink-0">
|
||||
<BoltIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">SIM & eSIM</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Order immediately after signup. Physical SIM ships next business day.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-500/10 text-purple-500 border border-purple-500/20 flex-shrink-0">
|
||||
<BoltIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">VPN</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Order immediately after signup. Router shipped upon order confirmation.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -117,20 +129,19 @@ export function PublicCatalogHomeView() {
|
||||
Why choose our services?
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl leading-relaxed">
|
||||
High-quality connectivity solutions with personalized recommendations and seamless
|
||||
ordering.
|
||||
Reliable connectivity with transparent pricing and dedicated support.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FeatureCard
|
||||
icon={<WifiIcon className="h-8 w-8 text-primary" />}
|
||||
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"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<GlobeAltIcon className="h-8 w-8 text-primary" />}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to plans" />
|
||||
<div className="mt-8 space-y-6">
|
||||
<Skeleton className="h-10 w-96 mx-auto" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to plans" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-6 mb-8 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 border border-primary/20">
|
||||
<WifiIcon className="h-7 w-7 text-primary" />
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-500/20 to-blue-500/5 border border-blue-500/20 shadow-lg shadow-blue-500/10">
|
||||
<WifiIcon className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">Check Internet Availability</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create an account to verify service availability at your address.
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-3">
|
||||
Request Internet Service
|
||||
</h1>
|
||||
<p className="text-muted-foreground max-w-lg mx-auto">
|
||||
Create an account to request an availability check for your address.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Process Steps - Compact */}
|
||||
<div className="mb-8 grid grid-cols-3 gap-3 text-center">
|
||||
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<CheckCircleIcon className="h-5 w-5 text-success" />
|
||||
<span className="text-xs font-medium text-foreground">Create account</span>
|
||||
{/* Plan Summary Card - only if plan is selected */}
|
||||
{plan && (
|
||||
<div className="mb-8 bg-card border border-border rounded-2xl p-6 shadow-[var(--cp-shadow-1)]">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Selected Plan
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-500/10 border border-blue-500/20">
|
||||
<WifiIcon className="h-6 w-6 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{plan.name}</h3>
|
||||
{plan.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{plan.description}</p>
|
||||
)}
|
||||
{(plan.catalogMetadata?.tierDescription ||
|
||||
plan.internetPlanTier ||
|
||||
plan.internetOfferingType) && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{(plan.catalogMetadata?.tierDescription || plan.internetPlanTier) && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-info/10 text-info border border-info/20">
|
||||
{plan.catalogMetadata?.tierDescription || plan.internetPlanTier}
|
||||
</span>
|
||||
)}
|
||||
{plan.internetOfferingType && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-muted text-muted-foreground">
|
||||
{plan.internetOfferingType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<CardPricing monthlyPrice={plan.monthlyPrice} size="md" alignment="right" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<ClockIcon className="h-5 w-5 text-info" />
|
||||
<span className="text-xs font-medium text-foreground">We verify availability</span>
|
||||
)}
|
||||
|
||||
{/* What happens after signup - honest timeline */}
|
||||
<div className="mb-8 bg-card border border-border rounded-2xl p-6 shadow-[var(--cp-shadow-1)]">
|
||||
<h2 className="text-base font-semibold text-foreground mb-4">What happens next</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary text-sm font-bold flex-shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Create your account</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sign up with your service address to start the process.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm font-bold flex-shrink-0">
|
||||
2
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-foreground">We verify availability</p>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 border border-amber-500/20">
|
||||
<ClockIcon className="h-3 w-3" />
|
||||
1-2 business days
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Our team checks service availability with NTT for your specific address.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm font-bold flex-shrink-0">
|
||||
3
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
You receive email notification
|
||||
</p>
|
||||
<EnvelopeIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We'll email you when your personalized plans are ready to view.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm font-bold flex-shrink-0">
|
||||
4
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Complete your order</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose your plan options, add payment, and schedule installation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<BellIcon className="h-5 w-5 text-primary" />
|
||||
<span className="text-xs font-medium text-foreground">Get notified</span>
|
||||
</div>
|
||||
|
||||
{/* Important note */}
|
||||
<div className="mb-8 bg-info-soft border border-info/25 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckIcon className="h-5 w-5 text-info mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Your account is ready immediately</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
While we verify your address, you can explore your account, add payment methods, and
|
||||
browse our other services like SIM and VPN.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Section */}
|
||||
<InlineAuthSection
|
||||
title="Create your account"
|
||||
description="We'll verify internet availability at your address and notify you when ready."
|
||||
description="Enter your details including service address to get started."
|
||||
redirectTo={redirectTo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -106,25 +106,33 @@ export function PublicInternetPlansView() {
|
||||
<CatalogBackLink href={shopBasePath} label="Back to Services" />
|
||||
|
||||
<CatalogHero
|
||||
title="Choose Your Internet Type"
|
||||
description="Compare apartment vs home options and pick the speed that fits your address."
|
||||
title="Internet Service Plans"
|
||||
description="High-speed fiber internet for homes and apartments."
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground text-center max-w-xl">
|
||||
Compare starting prices for each internet type. Create an account to check availability
|
||||
for your residence and unlock personalized plan options.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{offeringTypes.map(type => (
|
||||
<div
|
||||
key={type}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full border ${getEligibilityColor(type)} shadow-[var(--cp-shadow-1)]`}
|
||||
>
|
||||
{getEligibilityIcon(type)}
|
||||
<span className="text-sm font-semibold">{type}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* Availability notice */}
|
||||
<div className="bg-info-soft border border-info/25 rounded-xl px-4 py-3 max-w-xl">
|
||||
<p className="text-sm text-foreground text-center">
|
||||
<span className="font-medium">Availability check required:</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
After signup, we verify your address with NTT (1-2 business days). You'll
|
||||
receive an email when your personalized plans are ready.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{offeringTypes.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{offeringTypes.map(type => (
|
||||
<div
|
||||
key={type}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full border ${getEligibilityColor(type)} shadow-[var(--cp-shadow-1)]`}
|
||||
>
|
||||
{getEligibilityIcon(type)}
|
||||
<span className="text-sm font-semibold">{type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CatalogHero>
|
||||
|
||||
|
||||
@ -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() {
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM plans" />
|
||||
<div className="mt-8 space-y-6">
|
||||
<Skeleton className="h-10 w-96" />
|
||||
<Skeleton className="h-10 w-96 mx-auto" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
@ -51,109 +50,121 @@ export function PublicSimConfigureView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM plans" />
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM plans" />
|
||||
|
||||
<CatalogHero
|
||||
title="Get started with your SIM plan"
|
||||
description="Create an account to complete your order, add a payment method, and complete identity verification."
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="mt-6 mb-8 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-green-500/20 to-green-500/5 border border-green-500/20 shadow-lg shadow-green-500/10">
|
||||
<DevicePhoneMobileIcon className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-3">Order Your SIM</h1>
|
||||
<p className="text-muted-foreground max-w-lg mx-auto">
|
||||
Create an account to complete your order. Physical SIMs ship next business day.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-8">
|
||||
{/* Plan Summary Card */}
|
||||
<div className="bg-card border border-border rounded-2xl p-6 md:p-8 shadow-[var(--cp-shadow-1)]">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 border border-primary/20">
|
||||
<DevicePhoneMobileIcon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">{plan.name}</h3>
|
||||
{/* Plan Summary Card */}
|
||||
<div className="mb-8 bg-card border border-border rounded-2xl p-6 shadow-[var(--cp-shadow-1)]">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Selected Plan
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-green-500/10 border border-green-500/20">
|
||||
<DevicePhoneMobileIcon className="h-6 w-6 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{plan.name}</h3>
|
||||
{plan.description && (
|
||||
<p className="text-sm text-muted-foreground mb-4">{plan.description}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{plan.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{plan.simPlanType && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20">
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-500/10 text-green-600 border border-green-500/20">
|
||||
{plan.simPlanType}
|
||||
</span>
|
||||
)}
|
||||
{plan.simDataSize && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-info/10 text-info border border-info/20">
|
||||
{plan.simDataSize}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<CardPricing
|
||||
monthlyPrice={plan.monthlyPrice}
|
||||
oneTimePrice={plan.oneTimePrice}
|
||||
size="lg"
|
||||
alignment="left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<CardPricing
|
||||
monthlyPrice={plan.monthlyPrice}
|
||||
oneTimePrice={plan.oneTimePrice}
|
||||
size="md"
|
||||
alignment="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan Details */}
|
||||
{(plan.simDataSize || plan.description) && (
|
||||
<div className="border-t border-border pt-6 mt-6">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">
|
||||
Plan Details:
|
||||
</h4>
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{plan.simDataSize && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Data:{" "}
|
||||
<span className="text-foreground font-medium">{plan.simDataSize}</span>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{plan.simPlanType && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Type:{" "}
|
||||
<span className="text-foreground font-medium">{plan.simPlanType}</span>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{plan.simHasFamilyDiscount && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-foreground font-medium">
|
||||
Family Discount Available
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{plan.billingCycle && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Billing:{" "}
|
||||
<span className="text-foreground font-medium">{plan.billingCycle}</span>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InlineAuthSection
|
||||
title="Ready to order?"
|
||||
description="Create an account to complete your SIM order. You'll need to add a payment method and complete identity verification."
|
||||
redirectTo={redirectTarget}
|
||||
highlights={[
|
||||
{ title: "Secure Payment", description: "Add payment method safely" },
|
||||
{ title: "Identity Verification", description: "Complete verification process" },
|
||||
{ title: "Order Management", description: "Track your order status" },
|
||||
]}
|
||||
/>
|
||||
{/* Plan Details */}
|
||||
{(plan.simDataSize || plan.simHasFamilyDiscount || plan.billingCycle) && (
|
||||
<div className="border-t border-border pt-4 mt-4">
|
||||
<ul className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{plan.simDataSize && (
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-foreground font-medium">{plan.simDataSize}</span> data
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{plan.simPlanType && (
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{plan.simPlanType}</span>
|
||||
</li>
|
||||
)}
|
||||
{plan.simHasFamilyDiscount && (
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">Family discount</span>
|
||||
</li>
|
||||
)}
|
||||
{plan.billingCycle && (
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{plan.billingCycle} billing</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick order info */}
|
||||
<div className="mb-8 bg-success-soft border border-success/25 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<BoltIcon className="h-5 w-5 text-success mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Order today, get started fast</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
After signup, add a payment method and configure your SIM options. Choose eSIM for
|
||||
instant activation or physical SIM (ships next business day).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Auth Section */}
|
||||
<InlineAuthSection
|
||||
title="Create your account to order"
|
||||
description="Quick signup to configure your SIM and complete checkout."
|
||||
redirectTo={redirectTarget}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
<CatalogBackLink href={shopBasePath} label="Back to Services" />
|
||||
|
||||
<CatalogHero
|
||||
title="Choose Your SIM Plan"
|
||||
description="Browse plan options now. Create an account to order, manage billing, and complete verification."
|
||||
/>
|
||||
title="SIM & eSIM Plans"
|
||||
description="Data, voice, and SMS plans with 5G network coverage."
|
||||
>
|
||||
{/* Order info banner */}
|
||||
<div className="bg-success-soft border border-success/25 rounded-xl px-4 py-3 max-w-xl mt-4">
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<BoltIcon className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">Order today</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— eSIM activates instantly, physical SIM ships next business day.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CatalogHero>
|
||||
|
||||
<div className="mb-8 flex justify-center">
|
||||
<div className="border-b border-border">
|
||||
|
||||
@ -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() {
|
||||
<CatalogBackLink href={shopBasePath} label="Back to Services" />
|
||||
|
||||
<CatalogHero
|
||||
title="SonixNet VPN Router Service"
|
||||
description="Fast and secure VPN connections to San Francisco or London using a pre-configured router."
|
||||
/>
|
||||
title="VPN Router Service"
|
||||
description="Secure VPN connections to San Francisco or London using a pre-configured router."
|
||||
>
|
||||
{/* Order info banner */}
|
||||
<div className="bg-success-soft border border-success/25 rounded-xl px-4 py-3 max-w-xl mt-4">
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<BoltIcon className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">Order today</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— create account, add payment, and your router ships upon confirmation.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CatalogHero>
|
||||
|
||||
{vpnPlans.length > 0 ? (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2 text-center">Available Plans</h2>
|
||||
<p className="text-muted-foreground text-center mb-6">(One region per router)</p>
|
||||
<h2 className="text-xl font-bold text-foreground mb-2 text-center">Choose Your Region</h2>
|
||||
<p className="text-sm text-muted-foreground text-center mb-6">
|
||||
Select one region per router rental
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
||||
{vpnPlans.map(plan => (
|
||||
@ -64,8 +80,7 @@ export function PublicVpnPlansView() {
|
||||
|
||||
{activationFees.length > 0 && (
|
||||
<AlertBanner variant="info" className="mt-6 max-w-4xl mx-auto" title="Activation Fee">
|
||||
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.
|
||||
</AlertBanner>
|
||||
)}
|
||||
</div>
|
||||
@ -86,34 +101,30 @@ export function PublicVpnPlansView() {
|
||||
)}
|
||||
|
||||
<div className="bg-card rounded-xl border border-border p-8 mb-8">
|
||||
<h2 className="text-2xl font-bold text-foreground mb-6">How It Works</h2>
|
||||
<div className="space-y-4 text-muted-foreground">
|
||||
<h2 className="text-xl font-bold text-foreground mb-6">How It Works</h2>
|
||||
<div className="space-y-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
Then you can connect your network media players to the VPN Wi-Fi network, to connect to
|
||||
the VPN server.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertBanner variant="warning" title="Important Disclaimer" className="mb-8">
|
||||
*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.
|
||||
<p className="text-sm">
|
||||
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.
|
||||
</p>
|
||||
</AlertBanner>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./useDashboardSummary";
|
||||
export * from "./useDashboardTasks";
|
||||
export * from "./useMeStatus";
|
||||
|
||||
@ -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<string, unknown>
|
||||
) {
|
||||
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<DashboardSummary, DashboardError>({
|
||||
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<DashboardSummary>("/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<string, unknown> }
|
||||
);
|
||||
}
|
||||
},
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<React.SVGProps<SVGSVGElement>>;
|
||||
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<DashboardTaskType, DashboardTask["icon"]> = {
|
||||
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<DashboardTask[]>(() => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
24
apps/portal/src/features/dashboard/hooks/useMeStatus.ts
Normal file
24
apps/portal/src/features/dashboard/hooks/useMeStatus.ts
Normal file
@ -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<MeStatus, Error>({
|
||||
queryKey: queryKeys.me.status(),
|
||||
queryFn: () => getMeStatus(),
|
||||
enabled: isAuthenticated,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
}
|
||||
|
||||
export type UseMeStatusResult = ReturnType<typeof useMeStatus>;
|
||||
@ -0,0 +1,14 @@
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { meStatusSchema, type MeStatus } from "@customer-portal/domain/dashboard";
|
||||
|
||||
export async function getMeStatus(): Promise<MeStatus> {
|
||||
const response = await apiClient.GET<MeStatus>("/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;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<ResidenceCardVerification> {
|
||||
const response = await apiClient.GET<ResidenceCardVerification>(
|
||||
"/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<ResidenceCardVerification> {
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@ -132,9 +132,39 @@ export function ResidenceCardVerificationSettingsView() {
|
||||
<p>{residenceCardQuery.data.reviewerNotes}</p>
|
||||
)}
|
||||
<p>Upload a clear photo or scan of your residence card (JPG, PNG, or PDF).</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
<li>Make sure all text is readable and the full card is visible.</li>
|
||||
<li>Avoid glare/reflections and blurry photos.</li>
|
||||
<li>Maximum file size: 5MB.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AlertBanner>
|
||||
|
||||
{(residenceCardQuery.data?.filename ||
|
||||
residenceCardQuery.data?.submittedAt ||
|
||||
residenceCardQuery.data?.reviewedAt) && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Latest submission
|
||||
</div>
|
||||
{residenceCardQuery.data?.filename && (
|
||||
<div className="mt-1 text-sm font-medium text-foreground">
|
||||
{residenceCardQuery.data.filename}
|
||||
</div>
|
||||
)}
|
||||
{formatDateTime(residenceCardQuery.data?.submittedAt) && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
|
||||
</div>
|
||||
)}
|
||||
{formatDateTime(residenceCardQuery.data?.reviewedAt) && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Reviewed: {formatDateTime(residenceCardQuery.data?.reviewedAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUpload && (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
@ -211,7 +241,8 @@ export function ResidenceCardVerificationSettingsView() {
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -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<string, unknown>) => ["billing", "invoices", params] as const,
|
||||
invoice: (id: string) => ["billing", "invoice", id] as const,
|
||||
|
||||
@ -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**
|
||||
|
||||
@ -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
|
||||
|
||||
61
docs/how-it-works/dashboard-and-notifications.md
Normal file
61
docs/how-it-works/dashboard-and-notifications.md
Normal file
@ -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.
|
||||
@ -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 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<typeof internetPlanCatalogItemSche
|
||||
export type InternetInstallationCatalogItem = z.infer<typeof internetInstallationCatalogItemSchema>;
|
||||
export type InternetAddonCatalogItem = z.infer<typeof internetAddonCatalogItemSchema>;
|
||||
export type InternetCatalogCollection = z.infer<typeof internetCatalogCollectionSchema>;
|
||||
export type InternetEligibilityStatus = z.infer<typeof internetEligibilityStatusSchema>;
|
||||
export type InternetEligibilityDetails = z.infer<typeof internetEligibilityDetailsSchema>;
|
||||
|
||||
// SIM products
|
||||
export type SimCatalogProduct = z.infer<typeof simCatalogProductSchema>;
|
||||
@ -170,4 +204,3 @@ export type CatalogProduct =
|
||||
| SimActivationFeeCatalogItem
|
||||
| VpnCatalogProduct
|
||||
| CatalogProductBase;
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<typeof addressSchema>;
|
||||
export type AddressFormData = z.infer<typeof addressFormSchema>;
|
||||
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
|
||||
export type ProfileDisplayData = z.infer<typeof profileDisplayDataSchema>;
|
||||
export type ResidenceCardVerificationStatus = z.infer<typeof residenceCardVerificationStatusSchema>;
|
||||
export type ResidenceCardVerification = z.infer<typeof residenceCardVerificationSchema>;
|
||||
|
||||
// Convenience aliases
|
||||
export type UserProfile = User; // Alias for user profile
|
||||
|
||||
@ -9,6 +9,11 @@ import type {
|
||||
activityFilterSchema,
|
||||
activityFilterConfigSchema,
|
||||
dashboardSummaryResponseSchema,
|
||||
dashboardTaskTypeSchema,
|
||||
dashboardTaskToneSchema,
|
||||
dashboardTaskSchema,
|
||||
paymentMethodsStatusSchema,
|
||||
meStatusSchema,
|
||||
} from "./schema.js";
|
||||
|
||||
export type ActivityType = z.infer<typeof activityTypeSchema>;
|
||||
@ -20,3 +25,8 @@ export type DashboardError = z.infer<typeof dashboardErrorSchema>;
|
||||
export type ActivityFilter = z.infer<typeof activityFilterSchema>;
|
||||
export type ActivityFilterConfig = z.infer<typeof activityFilterConfigSchema>;
|
||||
export type DashboardSummaryResponse = z.infer<typeof dashboardSummaryResponseSchema>;
|
||||
export type DashboardTaskType = z.infer<typeof dashboardTaskTypeSchema>;
|
||||
export type DashboardTaskTone = z.infer<typeof dashboardTaskToneSchema>;
|
||||
export type DashboardTask = z.infer<typeof dashboardTaskSchema>;
|
||||
export type PaymentMethodsStatus = z.infer<typeof paymentMethodsStatusSchema>;
|
||||
export type MeStatus = z.infer<typeof meStatusSchema>;
|
||||
|
||||
@ -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<typeof invoiceActivityMetadataSchema>;
|
||||
export type ServiceActivityMetadata = z.infer<typeof serviceActivityMetadataSchema>;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
||||
title: "Good news! Internet service is available",
|
||||
message:
|
||||
"We've confirmed internet service is available at your address. You can now select a plan and complete your order.",
|
||||
actionUrl: "/shop/internet",
|
||||
actionUrl: "/account/shop/internet",
|
||||
actionLabel: "View Plans",
|
||||
priority: "high",
|
||||
},
|
||||
@ -65,7 +65,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
||||
title: "Internet service not available",
|
||||
message:
|
||||
"Unfortunately, internet service is not currently available at your address. We'll notify you if this changes.",
|
||||
actionUrl: "/support",
|
||||
actionUrl: "/account/support",
|
||||
actionLabel: "Contact Support",
|
||||
priority: "high",
|
||||
},
|
||||
@ -73,7 +73,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
||||
type: NOTIFICATION_TYPE.VERIFICATION_VERIFIED,
|
||||
title: "ID verification complete",
|
||||
message: "Your identity has been verified. You can now complete your order.",
|
||||
actionUrl: "/checkout",
|
||||
actionUrl: "/account/order",
|
||||
actionLabel: "Continue Checkout",
|
||||
priority: "high",
|
||||
},
|
||||
@ -81,7 +81,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
||||
type: NOTIFICATION_TYPE.VERIFICATION_REJECTED,
|
||||
title: "ID verification requires attention",
|
||||
message: "We couldn't verify your ID. Please review the feedback and resubmit.",
|
||||
actionUrl: "/account/verification",
|
||||
actionUrl: "/account/settings/verification",
|
||||
actionLabel: "Resubmit",
|
||||
priority: "high",
|
||||
},
|
||||
@ -89,7 +89,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
||||
type: NOTIFICATION_TYPE.ORDER_APPROVED,
|
||||
title: "Order approved",
|
||||
message: "Your order has been approved and is being processed.",
|
||||
actionUrl: "/orders",
|
||||
actionUrl: "/account/orders",
|
||||
actionLabel: "View Order",
|
||||
priority: "medium",
|
||||
},
|
||||
@ -97,7 +97,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
||||
type: NOTIFICATION_TYPE.ORDER_ACTIVATED,
|
||||
title: "Service activated",
|
||||
message: "Your service is now active and ready to use.",
|
||||
actionUrl: "/subscriptions",
|
||||
actionUrl: "/account/services",
|
||||
actionLabel: "View Service",
|
||||
priority: "high",
|
||||
},
|
||||
@ -105,7 +105,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
||||
type: NOTIFICATION_TYPE.ORDER_FAILED,
|
||||
title: "Order requires attention",
|
||||
message: "There was an issue processing your order. Please contact support.",
|
||||
actionUrl: "/support",
|
||||
actionUrl: "/account/support",
|
||||
actionLabel: "Contact Support",
|
||||
priority: "high",
|
||||
},
|
||||
@ -113,7 +113,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
||||
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
||||
title: "Cancellation scheduled",
|
||||
message: "Your cancellation request has been received and scheduled.",
|
||||
actionUrl: "/subscriptions",
|
||||
actionUrl: "/account/services",
|
||||
actionLabel: "View Details",
|
||||
priority: "medium",
|
||||
},
|
||||
@ -121,7 +121,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
||||
type: NOTIFICATION_TYPE.CANCELLATION_COMPLETE,
|
||||
title: "Service cancelled",
|
||||
message: "Your service has been successfully cancelled.",
|
||||
actionUrl: "/subscriptions",
|
||||
actionUrl: "/account/services",
|
||||
actionLabel: "View Details",
|
||||
priority: "medium",
|
||||
},
|
||||
@ -130,7 +130,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
||||
title: "Payment method expiring soon",
|
||||
message:
|
||||
"Your payment method is expiring soon. Please update it to avoid service interruption.",
|
||||
actionUrl: "/billing/payment-methods",
|
||||
actionUrl: "/account/billing/payments",
|
||||
actionLabel: "Update Payment",
|
||||
priority: "high",
|
||||
},
|
||||
@ -138,7 +138,7 @@ export const NOTIFICATION_TEMPLATES: Record<NotificationTypeValue, NotificationT
|
||||
type: NOTIFICATION_TYPE.INVOICE_DUE,
|
||||
title: "Invoice due",
|
||||
message: "You have an invoice due. Please make a payment to keep your service active.",
|
||||
actionUrl: "/billing/invoices",
|
||||
actionUrl: "/account/billing/invoices",
|
||||
actionLabel: "Pay Now",
|
||||
priority: "high",
|
||||
},
|
||||
|
||||
27
scripts/domain/check-dist.sh
Normal file
27
scripts/domain/check-dist.sh
Normal file
@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Ensures packages/domain/dist stays in sync with source.
|
||||
# Intended for CI or local verification before pushing.
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "[domain] Building @customer-portal/domain…"
|
||||
pnpm --filter @customer-portal/domain build >/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."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user