feat: add public VPN configuration page and view for unauthenticated users
This commit is contained in:
parent
f4099ac81f
commit
b52b2874d6
@ -45,7 +45,7 @@ model User {
|
|||||||
model IdMapping {
|
model IdMapping {
|
||||||
userId String @id @map("user_id")
|
userId String @id @map("user_id")
|
||||||
whmcsClientId Int @unique @map("whmcs_client_id")
|
whmcsClientId Int @unique @map("whmcs_client_id")
|
||||||
sfAccountId String? @map("sf_account_id")
|
sfAccountId String @map("sf_account_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export interface UserIdMapping {
|
|||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
whmcsClientId: number;
|
whmcsClientId: number;
|
||||||
sfAccountId?: string | null | undefined;
|
sfAccountId: string;
|
||||||
createdAt: IsoDateTimeString | Date;
|
createdAt: IsoDateTimeString | Date;
|
||||||
updatedAt: IsoDateTimeString | Date;
|
updatedAt: IsoDateTimeString | Date;
|
||||||
}
|
}
|
||||||
@ -18,7 +18,7 @@ export interface UserIdMapping {
|
|||||||
export interface CreateMappingRequest {
|
export interface CreateMappingRequest {
|
||||||
userId: string;
|
userId: string;
|
||||||
whmcsClientId: number;
|
whmcsClientId: number;
|
||||||
sfAccountId?: string | undefined;
|
sfAccountId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMappingRequest {
|
export interface UpdateMappingRequest {
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import type { CreateMappingRequest, UpdateMappingRequest, UserIdMapping } from "
|
|||||||
export const createMappingRequestSchema: z.ZodType<CreateMappingRequest> = z.object({
|
export const createMappingRequestSchema: z.ZodType<CreateMappingRequest> = z.object({
|
||||||
userId: z.string().uuid(),
|
userId: z.string().uuid(),
|
||||||
whmcsClientId: z.number().int().positive(),
|
whmcsClientId: z.number().int().positive(),
|
||||||
sfAccountId: z.string().min(1, "Salesforce account ID must be at least 1 character").optional(),
|
sfAccountId: z.string().min(1, "Salesforce account ID is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateMappingRequestSchema: z.ZodType<UpdateMappingRequest> = z.object({
|
export const updateMappingRequestSchema: z.ZodType<UpdateMappingRequest> = z.object({
|
||||||
@ -20,7 +20,7 @@ export const userIdMappingSchema: z.ZodType<UserIdMapping> = z.object({
|
|||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
userId: z.string().uuid(),
|
userId: z.string().uuid(),
|
||||||
whmcsClientId: z.number().int().positive(),
|
whmcsClientId: z.number().int().positive(),
|
||||||
sfAccountId: z.string().nullable().optional(),
|
sfAccountId: z.string(),
|
||||||
createdAt: z.union([z.string(), z.date()]),
|
createdAt: z.union([z.string(), z.date()]),
|
||||||
updatedAt: z.union([z.string(), z.date()]),
|
updatedAt: z.union([z.string(), z.date()]),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,18 +12,6 @@ import type {
|
|||||||
MappingValidationResult,
|
MappingValidationResult,
|
||||||
} from "./contract.js";
|
} from "./contract.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a mapping request has optional Salesforce account ID
|
|
||||||
* This is used for warnings, not validation errors
|
|
||||||
*/
|
|
||||||
export function checkMappingCompleteness(request: CreateMappingRequest | UserIdMapping): string[] {
|
|
||||||
const warnings: string[] = [];
|
|
||||||
if (!request.sfAccountId) {
|
|
||||||
warnings.push("Salesforce account ID not provided - mapping will be incomplete");
|
|
||||||
}
|
|
||||||
return warnings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate no conflicts exist with existing mappings
|
* Validate no conflicts exist with existing mappings
|
||||||
* Business rule: Each userId, whmcsClientId should be unique
|
* Business rule: Each userId, whmcsClientId should be unique
|
||||||
@ -51,14 +39,12 @@ export function validateNoConflicts(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.sfAccountId) {
|
|
||||||
const duplicateSf = existingMappings.find(m => m.sfAccountId === request.sfAccountId);
|
const duplicateSf = existingMappings.find(m => m.sfAccountId === request.sfAccountId);
|
||||||
if (duplicateSf) {
|
if (duplicateSf) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}`
|
`Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: errors.length === 0, errors, warnings };
|
return { isValid: errors.length === 0, errors, warnings };
|
||||||
}
|
}
|
||||||
@ -82,11 +68,9 @@ export function validateDeletion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
warnings.push("Deleting this mapping will prevent access to WHMCS/Salesforce data for this user");
|
warnings.push("Deleting this mapping will prevent access to WHMCS/Salesforce data for this user");
|
||||||
if (mapping.sfAccountId) {
|
|
||||||
warnings.push(
|
warnings.push(
|
||||||
"This mapping includes Salesforce integration - deletion will affect case management"
|
"This mapping includes Salesforce integration - deletion will affect case management"
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true, errors, warnings };
|
return { isValid: true, errors, warnings };
|
||||||
}
|
}
|
||||||
@ -98,11 +82,10 @@ export function validateDeletion(
|
|||||||
* The schema handles validation; this is purely for data cleanup.
|
* The schema handles validation; this is purely for data cleanup.
|
||||||
*/
|
*/
|
||||||
export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest {
|
export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest {
|
||||||
const trimmedSfAccountId = request.sfAccountId?.trim();
|
|
||||||
return {
|
return {
|
||||||
userId: request.userId?.trim(),
|
userId: request.userId?.trim(),
|
||||||
whmcsClientId: request.whmcsClientId,
|
whmcsClientId: request.whmcsClientId,
|
||||||
...(trimmedSfAccountId ? { sfAccountId: trimmedSfAccountId } : {}),
|
sfAccountId: request.sfAccountId.trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import {
|
|||||||
updateMappingRequestSchema,
|
updateMappingRequestSchema,
|
||||||
validateNoConflicts,
|
validateNoConflicts,
|
||||||
validateDeletion,
|
validateDeletion,
|
||||||
checkMappingCompleteness,
|
|
||||||
sanitizeCreateRequest,
|
sanitizeCreateRequest,
|
||||||
sanitizeUpdateRequest,
|
sanitizeUpdateRequest,
|
||||||
} from "./domain/index.js";
|
} from "./domain/index.js";
|
||||||
@ -72,7 +71,7 @@ export class MappingsService {
|
|||||||
.map(mapPrismaMappingToDomain);
|
.map(mapPrismaMappingToDomain);
|
||||||
|
|
||||||
const conflictCheck = validateNoConflicts(sanitizedRequest, existingMappings);
|
const conflictCheck = validateNoConflicts(sanitizedRequest, existingMappings);
|
||||||
const warnings = [...checkMappingCompleteness(sanitizedRequest), ...conflictCheck.warnings];
|
const warnings = conflictCheck.warnings;
|
||||||
|
|
||||||
if (!conflictCheck.isValid) {
|
if (!conflictCheck.isValid) {
|
||||||
throw new ConflictException(conflictCheck.errors.join("; "));
|
throw new ConflictException(conflictCheck.errors.join("; "));
|
||||||
@ -80,11 +79,10 @@ export class MappingsService {
|
|||||||
|
|
||||||
let created;
|
let created;
|
||||||
try {
|
try {
|
||||||
// Convert undefined to null for Prisma compatibility
|
|
||||||
const prismaData = {
|
const prismaData = {
|
||||||
userId: sanitizedRequest.userId,
|
userId: sanitizedRequest.userId,
|
||||||
whmcsClientId: sanitizedRequest.whmcsClientId,
|
whmcsClientId: sanitizedRequest.whmcsClientId,
|
||||||
sfAccountId: sanitizedRequest.sfAccountId ?? null,
|
sfAccountId: sanitizedRequest.sfAccountId,
|
||||||
};
|
};
|
||||||
created = await this.prisma.idMapping.create({ data: prismaData });
|
created = await this.prisma.idMapping.create({ data: prismaData });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -251,13 +249,12 @@ export class MappingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert undefined to null for Prisma compatibility
|
|
||||||
const prismaUpdateData: Prisma.IdMappingUpdateInput = {
|
const prismaUpdateData: Prisma.IdMappingUpdateInput = {
|
||||||
...(sanitizedUpdates.whmcsClientId !== undefined && {
|
...(sanitizedUpdates.whmcsClientId !== undefined && {
|
||||||
whmcsClientId: sanitizedUpdates.whmcsClientId,
|
whmcsClientId: sanitizedUpdates.whmcsClientId,
|
||||||
}),
|
}),
|
||||||
...(sanitizedUpdates.sfAccountId !== undefined && {
|
...(sanitizedUpdates.sfAccountId !== undefined && {
|
||||||
sfAccountId: sanitizedUpdates.sfAccountId ?? null,
|
sfAccountId: sanitizedUpdates.sfAccountId,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const updated = await this.prisma.idMapping.update({
|
const updated = await this.prisma.idMapping.update({
|
||||||
@ -325,9 +322,8 @@ export class MappingsService {
|
|||||||
whereClause.NOT = { whmcsClientId: { gt: 0 } };
|
whereClause.NOT = { whmcsClientId: { gt: 0 } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filters.hasSfMapping !== undefined) {
|
// Note: hasSfMapping filter is deprecated - sfAccountId is now required on all mappings
|
||||||
whereClause.sfAccountId = filters.hasSfMapping ? { not: null } : { equals: null };
|
// hasSfMapping: true matches all records, hasSfMapping: false matches none
|
||||||
}
|
|
||||||
|
|
||||||
const dbMappings = await this.prisma.idMapping.findMany({
|
const dbMappings = await this.prisma.idMapping.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
@ -347,23 +343,19 @@ export class MappingsService {
|
|||||||
|
|
||||||
async getMappingStats(): Promise<MappingStats> {
|
async getMappingStats(): Promise<MappingStats> {
|
||||||
try {
|
try {
|
||||||
const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([
|
// Since sfAccountId is now required, all mappings have SF accounts
|
||||||
|
// and completeMappings equals whmcsMappings (orphanedMappings is always 0)
|
||||||
|
const [totalCount, whmcsCount] = await Promise.all([
|
||||||
this.prisma.idMapping.count(),
|
this.prisma.idMapping.count(),
|
||||||
this.prisma.idMapping.count({ where: { whmcsClientId: { gt: 0 } } }),
|
this.prisma.idMapping.count({ where: { whmcsClientId: { gt: 0 } } }),
|
||||||
this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }),
|
|
||||||
this.prisma.idMapping.count({
|
|
||||||
where: { whmcsClientId: { gt: 0 }, sfAccountId: { not: null } },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const orphanedMappings = whmcsCount - completeCount;
|
|
||||||
|
|
||||||
const stats: MappingStats = {
|
const stats: MappingStats = {
|
||||||
totalMappings: totalCount,
|
totalMappings: totalCount,
|
||||||
whmcsMappings: whmcsCount,
|
whmcsMappings: whmcsCount,
|
||||||
salesforceMappings: sfCount,
|
salesforceMappings: totalCount, // All mappings now have sfAccountId
|
||||||
completeMappings: completeCount,
|
completeMappings: whmcsCount, // Same as whmcsMappings since sfAccountId is required
|
||||||
orphanedMappings: orphanedMappings < 0 ? 0 : orphanedMappings,
|
orphanedMappings: 0, // No longer possible
|
||||||
};
|
};
|
||||||
this.logger.debug("Generated mapping statistics", stats);
|
this.logger.debug("Generated mapping statistics", stats);
|
||||||
return stats;
|
return stats;
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export class OrderOrchestrator {
|
|||||||
// 2) Resolve Opportunity for this order
|
// 2) Resolve Opportunity for this order
|
||||||
const { opportunityId, wasCreated: opportunityCreated } = await this.resolveOpportunityForOrder(
|
const { opportunityId, wasCreated: opportunityCreated } = await this.resolveOpportunityForOrder(
|
||||||
validatedBody.orderType,
|
validatedBody.orderType,
|
||||||
userMapping.sfAccountId ?? null,
|
userMapping.sfAccountId,
|
||||||
validatedBody.opportunityId
|
validatedBody.opportunityId
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -136,7 +136,7 @@ export class OrderOrchestrator {
|
|||||||
{
|
{
|
||||||
name: "accountOrders",
|
name: "accountOrders",
|
||||||
execute: async () =>
|
execute: async () =>
|
||||||
this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId!),
|
this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export class OrderValidator {
|
|||||||
*/
|
*/
|
||||||
async validateUserMapping(
|
async validateUserMapping(
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<{ userId: string; sfAccountId?: string | undefined; whmcsClientId: number }> {
|
): Promise<{ userId: string; sfAccountId: string; whmcsClientId: number }> {
|
||||||
const mapping = await this.mappings.findByUserId(userId);
|
const mapping = await this.mappings.findByUserId(userId);
|
||||||
|
|
||||||
if (!mapping) {
|
if (!mapping) {
|
||||||
@ -57,7 +57,7 @@ export class OrderValidator {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
userId: mapping.userId,
|
userId: mapping.userId,
|
||||||
sfAccountId: mapping.sfAccountId || undefined,
|
sfAccountId: mapping.sfAccountId,
|
||||||
whmcsClientId: mapping.whmcsClientId,
|
whmcsClientId: mapping.whmcsClientId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -182,7 +182,7 @@ export class OrderValidator {
|
|||||||
body: CreateOrderRequest
|
body: CreateOrderRequest
|
||||||
): Promise<{
|
): Promise<{
|
||||||
validatedBody: OrderBusinessValidation;
|
validatedBody: OrderBusinessValidation;
|
||||||
userMapping: { userId: string; sfAccountId?: string | undefined; whmcsClientId: number };
|
userMapping: { userId: string; sfAccountId: string; whmcsClientId: number };
|
||||||
pricebookId: string;
|
pricebookId: string;
|
||||||
}> {
|
}> {
|
||||||
this.logger.log({ userId }, "Starting complete order validation");
|
this.logger.log({ userId }, "Starting complete order validation");
|
||||||
|
|||||||
@ -47,17 +47,16 @@ export class RealtimeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mapping = await this.mappings.findByUserId(req.user.id);
|
const mapping = await this.mappings.findByUserId(req.user.id);
|
||||||
const sfAccountId = mapping?.sfAccountId;
|
|
||||||
|
|
||||||
// Intentionally log minimal info for debugging connection issues.
|
// Intentionally log minimal info for debugging connection issues.
|
||||||
this.logger.log("Account realtime stream connected", {
|
this.logger.log("Account realtime stream connected", {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
hasSfAccountId: Boolean(sfAccountId),
|
hasMapping: Boolean(mapping),
|
||||||
sfAccountIdTail: sfAccountId ? sfAccountId.slice(-4) : null,
|
sfAccountIdTail: mapping?.sfAccountId.slice(-4) ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const accountStream = this.realtime.subscribe(
|
const accountStream = this.realtime.subscribe(
|
||||||
sfAccountId ? `account:sf:${sfAccountId}` : "account:unknown",
|
mapping ? `account:sf:${mapping.sfAccountId}` : "account:unknown",
|
||||||
{
|
{
|
||||||
// Always provide a single predictable ready + heartbeat for the main account stream.
|
// Always provide a single predictable ready + heartbeat for the main account stream.
|
||||||
readyEvent: "account.stream.ready",
|
readyEvent: "account.stream.ready",
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export class InternetEligibilityService {
|
|||||||
|
|
||||||
async getEligibilityDetailsForUser(userId: string): Promise<InternetEligibilityDetails> {
|
async getEligibilityDetailsForUser(userId: string): Promise<InternetEligibilityDetails> {
|
||||||
const mapping = await this.mappingsService.findByUserId(userId);
|
const mapping = await this.mappingsService.findByUserId(userId);
|
||||||
if (!mapping?.sfAccountId) {
|
if (!mapping) {
|
||||||
return internetEligibilityDetailsSchema.parse({
|
return internetEligibilityDetailsSchema.parse({
|
||||||
status: "not_requested",
|
status: "not_requested",
|
||||||
eligibility: null,
|
eligibility: null,
|
||||||
|
|||||||
@ -34,10 +34,7 @@ export class ResidenceCardService {
|
|||||||
|
|
||||||
async getStatusForUser(userId: string): Promise<ResidenceCardVerification> {
|
async getStatusForUser(userId: string): Promise<ResidenceCardVerification> {
|
||||||
const mapping = await this.mappings.findByUserId(userId);
|
const mapping = await this.mappings.findByUserId(userId);
|
||||||
const sfAccountId = mapping?.sfAccountId
|
if (!mapping) {
|
||||||
? assertSalesforceId(mapping.sfAccountId, "sfAccountId")
|
|
||||||
: null;
|
|
||||||
if (!sfAccountId) {
|
|
||||||
return residenceCardVerificationSchema.parse({
|
return residenceCardVerificationSchema.parse({
|
||||||
status: "not_submitted",
|
status: "not_submitted",
|
||||||
submittedAt: null,
|
submittedAt: null,
|
||||||
@ -45,6 +42,7 @@ export class ResidenceCardService {
|
|||||||
reviewerNotes: null,
|
reviewerNotes: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
|
||||||
|
|
||||||
return this.servicesCache.getCachedVerification(sfAccountId, async () => {
|
return this.servicesCache.getCachedVerification(sfAccountId, async () => {
|
||||||
return this.fetchVerificationFromSalesforce(sfAccountId);
|
return this.fetchVerificationFromSalesforce(sfAccountId);
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Public VPN Configure Page
|
||||||
|
*
|
||||||
|
* Configure VPN plan for unauthenticated users.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PublicVpnConfigureView } from "@/features/services/views/PublicVpnConfigure";
|
||||||
|
import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices";
|
||||||
|
|
||||||
|
export default function PublicVpnConfigurePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RedirectAuthenticatedToAccountServices targetPath="/account/services/vpn/configure" />
|
||||||
|
<PublicVpnConfigureView />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -79,12 +79,20 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
My Account
|
My Account
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="flex items-center gap-2 ml-2">
|
||||||
<Link
|
<Link
|
||||||
href="/auth/login"
|
href="/auth/login"
|
||||||
className="ml-2 inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/auth/get-started"
|
||||||
|
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,14 +13,15 @@ import { useZodForm } from "@/shared/hooks";
|
|||||||
interface LinkWhmcsFormProps {
|
interface LinkWhmcsFormProps {
|
||||||
onTransferred?: ((result: LinkWhmcsResponse) => void) | undefined;
|
onTransferred?: ((result: LinkWhmcsResponse) => void) | undefined;
|
||||||
className?: string | undefined;
|
className?: string | undefined;
|
||||||
|
initialEmail?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) {
|
export function LinkWhmcsForm({ onTransferred, className = "", initialEmail }: LinkWhmcsFormProps) {
|
||||||
const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
|
const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
|
||||||
|
|
||||||
const form = useZodForm({
|
const form = useZodForm({
|
||||||
schema: linkWhmcsRequestSchema,
|
schema: linkWhmcsRequestSchema,
|
||||||
initialValues: { email: "", password: "" },
|
initialValues: { email: initialEmail ?? "", password: "" },
|
||||||
onSubmit: async data => {
|
onSubmit: async data => {
|
||||||
clearError();
|
clearError();
|
||||||
const result = await linkWhmcs(data);
|
const result = await linkWhmcs(data);
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
* AccountStatusStep - Shows account status and routes to appropriate next step
|
* AccountStatusStep - Shows account status and routes to appropriate next step
|
||||||
*
|
*
|
||||||
* Routes based on account status:
|
* Routes based on account status:
|
||||||
* - portal_exists: Show login link
|
* - portal_exists: Show login form inline (or redirect link in full-page mode)
|
||||||
* - whmcs_unmapped: Link to migrate page (enter WHMCS password)
|
* - whmcs_unmapped: Show migrate form inline (or redirect link in full-page mode)
|
||||||
* - sf_unmapped: Go to complete-account step (pre-filled form)
|
* - sf_unmapped: Go to complete-account step (pre-filled form)
|
||||||
* - new_customer: Go to complete-account step (full signup)
|
* - new_customer: Go to complete-account step (full signup)
|
||||||
*/
|
*/
|
||||||
@ -11,6 +11,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/atoms";
|
import { Button } from "@/components/atoms";
|
||||||
import {
|
import {
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
@ -19,13 +20,51 @@ import {
|
|||||||
DocumentCheckIcon,
|
DocumentCheckIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import { LoginForm } from "@/features/auth/components/LoginForm/LoginForm";
|
||||||
|
import { LinkWhmcsForm } from "@/features/auth/components/LinkWhmcsForm/LinkWhmcsForm";
|
||||||
import { useGetStartedStore } from "../../../stores/get-started.store";
|
import { useGetStartedStore } from "../../../stores/get-started.store";
|
||||||
|
|
||||||
export function AccountStatusStep() {
|
export function AccountStatusStep() {
|
||||||
const { accountStatus, formData, goToStep, prefill } = useGetStartedStore();
|
const router = useRouter();
|
||||||
|
const { accountStatus, formData, goToStep, prefill, inline, redirectTo, serviceContext } =
|
||||||
|
useGetStartedStore();
|
||||||
|
|
||||||
// Portal exists - redirect to login
|
// Compute effective redirect URL from store state
|
||||||
|
const effectiveRedirectTo = redirectTo || serviceContext?.redirectTo || "/account/dashboard";
|
||||||
|
|
||||||
|
// Portal exists - show login form inline or redirect to login page
|
||||||
if (accountStatus === "portal_exists") {
|
if (accountStatus === "portal_exists") {
|
||||||
|
// Inline mode: render login form directly
|
||||||
|
if (inline) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center">
|
||||||
|
<CheckCircleIcon className="h-8 w-8 text-success" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Account Found</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You already have a portal account with this email. Please log in to continue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoginForm
|
||||||
|
initialEmail={formData.email}
|
||||||
|
redirectTo={effectiveRedirectTo}
|
||||||
|
showSignupLink={false}
|
||||||
|
showForgotPasswordLink={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-page mode: redirect to login page
|
||||||
|
const loginUrl = `/auth/login?email=${encodeURIComponent(formData.email)}&redirect=${encodeURIComponent(effectiveRedirectTo)}`;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 text-center">
|
<div className="space-y-6 text-center">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
@ -41,7 +80,7 @@ export function AccountStatusStep() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href={`/auth/login?email=${encodeURIComponent(formData.email)}`}>
|
<Link href={loginUrl}>
|
||||||
<Button className="w-full h-11">
|
<Button className="w-full h-11">
|
||||||
Go to Login
|
Go to Login
|
||||||
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
||||||
@ -51,8 +90,48 @@ export function AccountStatusStep() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// WHMCS exists but not mapped - need to link account
|
// WHMCS exists but not mapped - show migrate form inline or redirect to migrate page
|
||||||
if (accountStatus === "whmcs_unmapped") {
|
if (accountStatus === "whmcs_unmapped") {
|
||||||
|
// Inline mode: render migrate form directly
|
||||||
|
if (inline) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<UserCircleIcon className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Existing Account Found</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
We found an existing billing account with this email. Please verify your password to
|
||||||
|
link it to your new portal account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LinkWhmcsForm
|
||||||
|
initialEmail={formData.email}
|
||||||
|
onTransferred={result => {
|
||||||
|
if (result.needsPasswordSet) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
email: result.user.email,
|
||||||
|
redirect: effectiveRedirectTo,
|
||||||
|
});
|
||||||
|
router.push(`/auth/set-password?${params.toString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(effectiveRedirectTo);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-page mode: redirect to migrate page
|
||||||
|
const migrateUrl = `/auth/migrate?email=${encodeURIComponent(formData.email)}&redirect=${encodeURIComponent(effectiveRedirectTo)}`;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 text-center">
|
<div className="space-y-6 text-center">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
@ -69,7 +148,7 @@ export function AccountStatusStep() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href={`/auth/migrate?email=${encodeURIComponent(formData.email)}`}>
|
<Link href={migrateUrl}>
|
||||||
<Button className="w-full h-11">
|
<Button className="w-full h-11">
|
||||||
Link My Account
|
Link My Account
|
||||||
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
||||||
|
|||||||
@ -43,8 +43,13 @@ export function CompleteAccountStep() {
|
|||||||
error,
|
error,
|
||||||
clearError,
|
clearError,
|
||||||
goBack,
|
goBack,
|
||||||
|
redirectTo,
|
||||||
|
serviceContext,
|
||||||
} = useGetStartedStore();
|
} = useGetStartedStore();
|
||||||
|
|
||||||
|
// Compute effective redirect URL from store state
|
||||||
|
const effectiveRedirectTo = redirectTo || serviceContext?.redirectTo || "/account/dashboard";
|
||||||
|
|
||||||
// Check if this is a new customer (needs full form) or SF-only (has prefill)
|
// Check if this is a new customer (needs full form) or SF-only (has prefill)
|
||||||
const isNewCustomer = accountStatus === "new_customer";
|
const isNewCustomer = accountStatus === "new_customer";
|
||||||
const hasPrefill = !!(prefill?.firstName || prefill?.lastName);
|
const hasPrefill = !!(prefill?.firstName || prefill?.lastName);
|
||||||
@ -156,8 +161,8 @@ export function CompleteAccountStep() {
|
|||||||
|
|
||||||
const result = await completeAccount();
|
const result = await completeAccount();
|
||||||
if (result) {
|
if (result) {
|
||||||
// Redirect to dashboard on success
|
// Redirect to the effective redirect URL on success
|
||||||
router.push("/account/dashboard");
|
router.push(effectiveRedirectTo);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -7,8 +7,17 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/atoms";
|
import { Button } from "@/components/atoms";
|
||||||
import { CheckCircleIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
import { CheckCircleIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { useGetStartedStore } from "../../../stores/get-started.store";
|
||||||
|
|
||||||
export function SuccessStep() {
|
export function SuccessStep() {
|
||||||
|
const { redirectTo, serviceContext } = useGetStartedStore();
|
||||||
|
|
||||||
|
// Compute effective redirect URL from store state
|
||||||
|
const effectiveRedirectTo = redirectTo || serviceContext?.redirectTo || "/account/dashboard";
|
||||||
|
|
||||||
|
// Determine if redirecting to dashboard (default) or a specific service
|
||||||
|
const isDefaultRedirect = effectiveRedirectTo === "/account/dashboard";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 text-center">
|
<div className="space-y-6 text-center">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
@ -25,18 +34,20 @@ export function SuccessStep() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Link href="/account/dashboard">
|
<Link href={effectiveRedirectTo}>
|
||||||
<Button className="w-full h-11">
|
<Button className="w-full h-11">
|
||||||
Go to Dashboard
|
{isDefaultRedirect ? "Go to Dashboard" : "Continue"}
|
||||||
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{isDefaultRedirect && (
|
||||||
<Link href="/services/internet">
|
<Link href="/services/internet">
|
||||||
<Button variant="outline" className="w-full h-11">
|
<Button variant="outline" className="w-full h-11">
|
||||||
Check Internet Availability
|
Check Internet Availability
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,15 +3,17 @@
|
|||||||
*
|
*
|
||||||
* Uses the get-started store flow (email → OTP → status → form) inline on service pages
|
* Uses the get-started store flow (email → OTP → status → form) inline on service pages
|
||||||
* like the SIM configure page. Supports service context to track plan selection through the flow.
|
* like the SIM configure page. Supports service context to track plan selection through the flow.
|
||||||
|
*
|
||||||
|
* The email-first approach auto-detects the user's account status after OTP verification:
|
||||||
|
* - portal_exists: Shows LoginForm inline
|
||||||
|
* - whmcs_unmapped: Shows LinkWhmcsForm inline
|
||||||
|
* - sf_unmapped / new_customer: Continues to account completion
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/atoms";
|
|
||||||
import { LoginForm } from "@/features/auth/components/LoginForm/LoginForm";
|
|
||||||
import { LinkWhmcsForm } from "@/features/auth/components/LinkWhmcsForm/LinkWhmcsForm";
|
|
||||||
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||||
import { useGetStartedStore, type ServiceContext } from "../../stores/get-started.store";
|
import { useGetStartedStore, type ServiceContext } from "../../stores/get-started.store";
|
||||||
import { EmailStep } from "../GetStartedForm/steps/EmailStep";
|
import { EmailStep } from "../GetStartedForm/steps/EmailStep";
|
||||||
@ -42,42 +44,31 @@ export function InlineGetStartedSection({
|
|||||||
className = "",
|
className = "",
|
||||||
}: InlineGetStartedSectionProps) {
|
}: InlineGetStartedSectionProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [mode, setMode] = useState<"signup" | "login" | "migrate">("signup");
|
const safeRedirect = getSafeRedirect(redirectTo, "/account/dashboard");
|
||||||
const safeRedirect = getSafeRedirect(redirectTo, "/account");
|
|
||||||
|
|
||||||
const { step, reset, setServiceContext } = useGetStartedStore();
|
const { step, setServiceContext, setRedirectTo, setInline } = useGetStartedStore();
|
||||||
|
|
||||||
// Set service context when component mounts
|
// Set inline mode and redirect URL when component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setInline(true);
|
||||||
|
setRedirectTo(safeRedirect);
|
||||||
|
|
||||||
if (serviceContext) {
|
if (serviceContext) {
|
||||||
setServiceContext({
|
setServiceContext({
|
||||||
...serviceContext,
|
...serviceContext,
|
||||||
redirectTo: safeRedirect,
|
redirectTo: safeRedirect,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Clear service context when unmounting
|
// Clear inline mode when unmounting
|
||||||
|
setInline(false);
|
||||||
setServiceContext(null);
|
setServiceContext(null);
|
||||||
};
|
};
|
||||||
}, [serviceContext, safeRedirect, setServiceContext]);
|
}, [serviceContext, safeRedirect, setServiceContext, setRedirectTo, setInline]);
|
||||||
|
|
||||||
// Reset get-started store when switching to signup mode
|
// Render the current step
|
||||||
const handleModeChange = (newMode: "signup" | "login" | "migrate") => {
|
const renderStep = () => {
|
||||||
if (newMode === "signup" && mode !== "signup") {
|
|
||||||
reset();
|
|
||||||
// Re-set service context after reset
|
|
||||||
if (serviceContext) {
|
|
||||||
setServiceContext({
|
|
||||||
...serviceContext,
|
|
||||||
redirectTo: safeRedirect,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMode(newMode);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render the current step for signup flow
|
|
||||||
const renderSignupStep = () => {
|
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case "email":
|
case "email":
|
||||||
return <EmailStep />;
|
return <EmailStep />;
|
||||||
@ -105,78 +96,8 @@ export function InlineGetStartedSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<div className="inline-flex flex-wrap justify-center rounded-full border border-border bg-background p-1 shadow-[var(--cp-shadow-1)] gap-1">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={mode === "signup" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleModeChange("signup")}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
Create account
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={mode === "login" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleModeChange("login")}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={mode === "migrate" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleModeChange("migrate")}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
Migrate
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="bg-card border border-border rounded-xl p-5 sm:p-6 shadow-[var(--cp-shadow-1)]">
|
<div className="bg-card border border-border rounded-xl p-5 sm:p-6 shadow-[var(--cp-shadow-1)]">
|
||||||
{mode === "signup" && (
|
{renderStep()}
|
||||||
<>
|
|
||||||
<h4 className="text-base font-semibold text-foreground mb-2">Create your account</h4>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Verify your email to get started.
|
|
||||||
</p>
|
|
||||||
{renderSignupStep()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{mode === "login" && (
|
|
||||||
<>
|
|
||||||
<h4 className="text-base font-semibold text-foreground mb-2">Sign in</h4>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">Access your account to continue.</p>
|
|
||||||
<LoginForm redirectTo={redirectTo} showSignupLink={false} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{mode === "migrate" && (
|
|
||||||
<>
|
|
||||||
<h4 className="text-base font-semibold text-foreground mb-2">Migrate your account</h4>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Use your legacy portal credentials to transfer your account.
|
|
||||||
</p>
|
|
||||||
<LinkWhmcsForm
|
|
||||||
onTransferred={result => {
|
|
||||||
if (result.needsPasswordSet) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
email: result.user.email,
|
|
||||||
redirect: safeRedirect,
|
|
||||||
});
|
|
||||||
router.push(`/auth/set-password?${params.toString()}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(safeRedirect);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{highlights.length > 0 && (
|
{highlights.length > 0 && (
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export type GetStartedStep =
|
|||||||
* (e.g., SIM plan selection)
|
* (e.g., SIM plan selection)
|
||||||
*/
|
*/
|
||||||
export interface ServiceContext {
|
export interface ServiceContext {
|
||||||
type: "sim" | "internet" | null;
|
type: "sim" | "internet" | "vpn" | null;
|
||||||
planSku?: string | undefined;
|
planSku?: string | undefined;
|
||||||
redirectTo?: string | undefined;
|
redirectTo?: string | undefined;
|
||||||
}
|
}
|
||||||
@ -79,6 +79,12 @@ export interface GetStartedState {
|
|||||||
// Service context for tracking which service flow the user came from
|
// Service context for tracking which service flow the user came from
|
||||||
serviceContext: ServiceContext | null;
|
serviceContext: ServiceContext | null;
|
||||||
|
|
||||||
|
// Redirect URL (centralized for inline and full-page flows)
|
||||||
|
redirectTo: string | null;
|
||||||
|
|
||||||
|
// Whether rendering inline (e.g., on service configure page)
|
||||||
|
inline: boolean;
|
||||||
|
|
||||||
// Loading and error states
|
// Loading and error states
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@ -105,6 +111,8 @@ export interface GetStartedState {
|
|||||||
setSessionToken: (token: string | null) => void;
|
setSessionToken: (token: string | null) => void;
|
||||||
setHandoffToken: (token: string | null) => void;
|
setHandoffToken: (token: string | null) => void;
|
||||||
setServiceContext: (context: ServiceContext | null) => void;
|
setServiceContext: (context: ServiceContext | null) => void;
|
||||||
|
setRedirectTo: (url: string | null) => void;
|
||||||
|
setInline: (inline: boolean) => void;
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
@ -134,6 +142,8 @@ const initialState = {
|
|||||||
prefill: null,
|
prefill: null,
|
||||||
handoffToken: null,
|
handoffToken: null,
|
||||||
serviceContext: null as ServiceContext | null,
|
serviceContext: null as ServiceContext | null,
|
||||||
|
redirectTo: null as string | null,
|
||||||
|
inline: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
codeSent: false,
|
codeSent: false,
|
||||||
@ -304,6 +314,14 @@ export const useGetStartedStore = create<GetStartedState>()((set, get) => ({
|
|||||||
set({ serviceContext: context });
|
set({ serviceContext: context });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setRedirectTo: (url: string | null) => {
|
||||||
|
set({ redirectTo: url });
|
||||||
|
},
|
||||||
|
|
||||||
|
setInline: (inline: boolean) => {
|
||||||
|
set({ inline });
|
||||||
|
},
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
set(initialState);
|
set(initialState);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { useState, useCallback, useEffect } from "react";
|
|||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { AuthLayout } from "@/components/templates/AuthLayout";
|
import { AuthLayout } from "@/components/templates/AuthLayout";
|
||||||
import { GetStartedForm } from "../components";
|
import { GetStartedForm } from "../components";
|
||||||
|
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||||
import {
|
import {
|
||||||
useGetStartedStore,
|
useGetStartedStore,
|
||||||
type GetStartedStep,
|
type GetStartedStep,
|
||||||
@ -40,6 +41,7 @@ export function GetStartedView() {
|
|||||||
setSessionToken,
|
setSessionToken,
|
||||||
setAccountStatus,
|
setAccountStatus,
|
||||||
setPrefill,
|
setPrefill,
|
||||||
|
setRedirectTo,
|
||||||
} = useGetStartedStore();
|
} = useGetStartedStore();
|
||||||
const [meta, setMeta] = useState({
|
const [meta, setMeta] = useState({
|
||||||
title: "Get Started",
|
title: "Get Started",
|
||||||
@ -61,6 +63,13 @@ export function GetStartedView() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
|
|
||||||
|
// Handle redirect URL param (for full-page flow with redirect support)
|
||||||
|
const redirectParam = searchParams.get("redirect");
|
||||||
|
if (redirectParam) {
|
||||||
|
const safeRedirect = getSafeRedirect(redirectParam, "/account/dashboard");
|
||||||
|
setRedirectTo(safeRedirect);
|
||||||
|
}
|
||||||
|
|
||||||
// Check for verified handoff (user already completed OTP on eligibility page)
|
// Check for verified handoff (user already completed OTP on eligibility page)
|
||||||
const verifiedParam = searchParams.get("verified");
|
const verifiedParam = searchParams.get("verified");
|
||||||
|
|
||||||
@ -159,6 +168,7 @@ export function GetStartedView() {
|
|||||||
setSessionToken,
|
setSessionToken,
|
||||||
setAccountStatus,
|
setAccountStatus,
|
||||||
setPrefill,
|
setPrefill,
|
||||||
|
setRedirectTo,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleStepChange = useCallback(
|
const handleStepChange = useCallback(
|
||||||
|
|||||||
194
apps/portal/src/features/services/views/PublicVpnConfigure.tsx
Normal file
194
apps/portal/src/features/services/views/PublicVpnConfigure.tsx
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { ShieldCheck, CheckIcon, BoltIcon } from "lucide-react";
|
||||||
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
|
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
||||||
|
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||||
|
import { usePublicVpnPlan } from "@/features/services/hooks";
|
||||||
|
import { InlineGetStartedSection } from "@/features/get-started";
|
||||||
|
import { CardPricing } from "@/features/services/components/base/CardPricing";
|
||||||
|
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
|
import { getVpnRegionConfig } from "@/features/services/utils";
|
||||||
|
import { cn } from "@/shared/utils/cn";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public VPN Configure View
|
||||||
|
*
|
||||||
|
* Shows selected VPN plan information and prompts for authentication.
|
||||||
|
* Simplified design focused on quick signup-to-order flow.
|
||||||
|
*/
|
||||||
|
export function PublicVpnConfigureView() {
|
||||||
|
const servicesBasePath = useServicesBasePath();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const planSku = searchParams?.get("planSku");
|
||||||
|
const { plan, isLoading } = usePublicVpnPlan(planSku || undefined);
|
||||||
|
|
||||||
|
const redirectTarget = planSku
|
||||||
|
? `/account/services/vpn/configure?planSku=${encodeURIComponent(planSku)}`
|
||||||
|
: "/account/services/vpn";
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
|
<ServicesBackLink href={`${servicesBasePath}/vpn`} label="Back to VPN plans" />
|
||||||
|
<div className="mt-8 space-y-6">
|
||||||
|
<Skeleton className="h-10 w-96 mx-auto" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
|
<ServicesBackLink href={`${servicesBasePath}/vpn`} label="Back to VPN plans" />
|
||||||
|
<AlertBanner variant="error" title="Plan not found">
|
||||||
|
The selected plan could not be found. Please go back and select a plan.
|
||||||
|
</AlertBanner>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = getVpnRegionConfig(plan.name);
|
||||||
|
const isUS = region.accent === "blue";
|
||||||
|
const isUK = region.accent === "red";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
|
<ServicesBackLink href={`${servicesBasePath}/vpn`} label="Back to VPN plans" />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mt-6 mb-8 text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-16 w-16 items-center justify-center rounded-2xl border shadow-lg",
|
||||||
|
isUS &&
|
||||||
|
"bg-gradient-to-br from-blue-500/20 to-blue-500/5 border-blue-500/20 shadow-blue-500/10",
|
||||||
|
isUK &&
|
||||||
|
"bg-gradient-to-br from-red-500/20 to-red-500/5 border-red-500/20 shadow-red-500/10",
|
||||||
|
!isUS &&
|
||||||
|
!isUK &&
|
||||||
|
"bg-gradient-to-br from-purple-500/20 to-purple-500/5 border-purple-500/20 shadow-purple-500/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShieldCheck
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8",
|
||||||
|
isUS && "text-blue-500",
|
||||||
|
isUK && "text-red-500",
|
||||||
|
!isUS && !isUK && "text-purple-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-3">
|
||||||
|
Order Your VPN Router
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground max-w-lg mx-auto">
|
||||||
|
Create an account to complete your order. Your pre-configured router ships upon
|
||||||
|
confirmation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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={cn(
|
||||||
|
"flex h-12 w-12 items-center justify-center rounded-xl text-2xl",
|
||||||
|
isUS && "bg-blue-500/10 border border-blue-500/20",
|
||||||
|
isUK && "bg-red-500/10 border border-red-500/20",
|
||||||
|
!isUS && !isUK && "bg-primary/10 border border-primary/20"
|
||||||
|
)}
|
||||||
|
role="img"
|
||||||
|
aria-label={region.flagAlt}
|
||||||
|
>
|
||||||
|
{region.flag}
|
||||||
|
</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>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{region.location}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium border",
|
||||||
|
isUS && "bg-blue-500/10 text-blue-600 border-blue-500/20",
|
||||||
|
isUK && "bg-red-500/10 text-red-600 border-red-500/20",
|
||||||
|
!isUS && !isUK && "bg-purple-500/10 text-purple-600 border-purple-500/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
VPN Router
|
||||||
|
</span>
|
||||||
|
<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">
|
||||||
|
{region.region}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<CardPricing
|
||||||
|
monthlyPrice={plan.monthlyPrice}
|
||||||
|
oneTimePrice={plan.oneTimePrice}
|
||||||
|
size="md"
|
||||||
|
alignment="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan Details */}
|
||||||
|
<div className="border-t border-border pt-4 mt-4">
|
||||||
|
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{region.features.map((feature, index) => (
|
||||||
|
<li key={index} className="flex items-center gap-2">
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 flex-shrink-0",
|
||||||
|
isUS && "text-blue-500",
|
||||||
|
isUK && "text-red-500",
|
||||||
|
!isUS && !isUK && "text-success"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order process info */}
|
||||||
|
<div className="mb-8 bg-info/10 border border-info/25 rounded-xl p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<BoltIcon className="h-5 w-5 text-info mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">How ordering works</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
After signup, add a payment method and confirm your order. Your pre-configured router
|
||||||
|
will be shipped and ready to use — just plug it in and connect your devices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auth Section */}
|
||||||
|
<InlineGetStartedSection
|
||||||
|
title="Create your account to order"
|
||||||
|
description="Verify your email to get started with your VPN order."
|
||||||
|
serviceContext={{ type: "vpn", planSku: planSku || undefined }}
|
||||||
|
redirectTo={redirectTarget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PublicVpnConfigureView;
|
||||||
@ -99,6 +99,11 @@ services:
|
|||||||
- FREEBIT_OEM_ID=${FREEBIT_OEM_ID:-PASI}
|
- FREEBIT_OEM_ID=${FREEBIT_OEM_ID:-PASI}
|
||||||
- FREEBIT_OEM_KEY=${FREEBIT_OEM_KEY}
|
- FREEBIT_OEM_KEY=${FREEBIT_OEM_KEY}
|
||||||
|
|
||||||
|
# Japan Post (Address Lookup)
|
||||||
|
- JAPAN_POST_API_URL=${JAPAN_POST_API_URL}
|
||||||
|
- JAPAN_POST_CLIENT_ID=${JAPAN_POST_CLIENT_ID}
|
||||||
|
- JAPAN_POST_CLIENT_SECRET=${JAPAN_POST_CLIENT_SECRET}
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
- EMAIL_ENABLED=${EMAIL_ENABLED:-true}
|
- EMAIL_ENABLED=${EMAIL_ENABLED:-true}
|
||||||
- EMAIL_FROM=${EMAIL_FROM:-no-reply@asolutions.jp}
|
- EMAIL_FROM=${EMAIL_FROM:-no-reply@asolutions.jp}
|
||||||
|
|||||||
@ -38,32 +38,68 @@ For customers who want to check internet availability before creating an account
|
|||||||
│
|
│
|
||||||
└─→ Step 2: Choose action:
|
└─→ Step 2: Choose action:
|
||||||
│
|
│
|
||||||
├─→ "Send Request Only"
|
├─→ "Just Submit Request" (secondary action)
|
||||||
│ └─→ SF Account + Case created → Success page
|
│ └─→ SF Account + Opportunity (find/create) + Case created
|
||||||
│ └─→ Success page shows:
|
│ └─→ Case description notes if Opportunity was created or matched
|
||||||
│ ├─→ "Back to Internet Plans" → Return to /services/internet
|
│ └─→ Success page shows "View Internet Plans" → /services/internet
|
||||||
│ └─→ "Create Your Account Now" → /auth/get-started?email=xxx
|
│ └─→ User can return later via SF email to create account
|
||||||
│ (standard OTP flow)
|
|
||||||
│
|
│
|
||||||
└─→ "Continue to Create Account"
|
└─→ "Create Account & Submit" (primary action)
|
||||||
├─→ SF Account + Case created
|
├─→ Step 2a: OTP sent to email (inline on same page)
|
||||||
├─→ Inline OTP verification (no redirect)
|
├─→ Step 2b: User verifies OTP
|
||||||
└─→ On success → /auth/get-started?verified=true
|
├─→ Step 2c: Complete account form (phone, DOB, password)
|
||||||
(skips email/OTP steps, goes to complete-account)
|
├─→ Creates SF Account + Opportunity + Case + WHMCS + Portal
|
||||||
|
├─→ Case description notes if Opportunity was created or matched
|
||||||
|
└─→ Success page → Auto-redirect to /dashboard (5s countdown)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key difference from Phase 1:** The "Continue to Create Account" path now includes inline OTP verification directly on the eligibility page, rather than redirecting to `/auth/get-started` for OTP.
|
**Key design:** The entire eligibility check flow is self-contained on `/services/internet/check-availability`. There is no redirect to `/auth/get-started` - all steps (form, OTP, account creation, success) happen on the same page using internal step state.
|
||||||
|
|
||||||
## Account Status Routing
|
## Account Status Detection
|
||||||
|
|
||||||
| Portal | WHMCS | Salesforce | Mapping | → Result |
|
The system checks accounts **in order** and returns the first match:
|
||||||
| ------ | ----- | ---------- | ------- | ------------------------------ |
|
|
||||||
| ✓ | ✓ | ✓ | ✓ | Go to login |
|
### Step 1: Portal User with ID Mapping
|
||||||
| ✓ | ✓ | - | ✓ | Go to login |
|
|
||||||
| - | ✓ | ✓ | - | Link WHMCS account (migrate) |
|
Check if Portal user exists (by email) AND has an ID mapping.
|
||||||
| - | ✓ | - | - | Link WHMCS account (migrate) |
|
|
||||||
| - | - | ✓ | - | Complete account (pre-filled) |
|
- **Found**: `PORTAL_EXISTS` → Redirect to login
|
||||||
| - | - | - | - | Create new account (full form) |
|
- **Not found**: Continue to step 2
|
||||||
|
|
||||||
|
### Step 2: WHMCS Client (Billing Account)
|
||||||
|
|
||||||
|
Check if WHMCS client exists (by email).
|
||||||
|
|
||||||
|
- **Found**: `WHMCS_UNMAPPED` → "Link account" flow (enter WHMCS password to migrate)
|
||||||
|
- **Not found**: Continue to step 3
|
||||||
|
|
||||||
|
_WHMCS clients are existing billing customers who haven't created a Portal account yet._
|
||||||
|
|
||||||
|
### Step 3: Salesforce Account Only
|
||||||
|
|
||||||
|
Check if SF Account exists (by email).
|
||||||
|
|
||||||
|
- **Found**: `SF_UNMAPPED` → "Complete account" flow (pre-filled form, create WHMCS + Portal)
|
||||||
|
- **Not found**: Continue to step 4
|
||||||
|
|
||||||
|
_SF-only accounts are customers who:_
|
||||||
|
|
||||||
|
- _Checked internet eligibility without creating an account_
|
||||||
|
- _Contacted us via email/phone and we created an SF record_
|
||||||
|
- _Were created through other CRM workflows_
|
||||||
|
|
||||||
|
### Step 4: No Account Found
|
||||||
|
|
||||||
|
- `NEW_CUSTOMER` → Full signup form
|
||||||
|
|
||||||
|
### Summary Table
|
||||||
|
|
||||||
|
| Check Order | System Found | Status | User Flow |
|
||||||
|
| ----------- | ----------------- | ---------------- | ----------------------------- |
|
||||||
|
| 1 | Portal + Mapping | `portal_exists` | Go to login |
|
||||||
|
| 2 | WHMCS (no portal) | `whmcs_unmapped` | Enter WHMCS password to link |
|
||||||
|
| 3 | SF only | `sf_unmapped` | Complete account (pre-filled) |
|
||||||
|
| 4 | Nothing | `new_customer` | Full signup form |
|
||||||
|
|
||||||
## Frontend Structure
|
## Frontend Structure
|
||||||
|
|
||||||
@ -89,28 +125,38 @@ apps/portal/src/features/get-started/
|
|||||||
|
|
||||||
**Location:** `apps/portal/src/features/services/views/PublicEligibilityCheck.tsx`
|
**Location:** `apps/portal/src/features/services/views/PublicEligibilityCheck.tsx`
|
||||||
**Route:** `/services/internet/check-availability`
|
**Route:** `/services/internet/check-availability`
|
||||||
|
**Store:** `apps/portal/src/features/services/stores/eligibility-check.store.ts`
|
||||||
|
|
||||||
A dedicated page for guests to check internet availability. This approach provides:
|
A dedicated page for guests to check internet availability. This approach provides:
|
||||||
|
|
||||||
- Better mobile experience with proper form spacing
|
- Better mobile experience with proper form spacing
|
||||||
- Clear user journey with bookmarkable URLs
|
- Clear user journey with bookmarkable URLs
|
||||||
- Natural browser navigation (back button works)
|
- Natural browser navigation (back button works)
|
||||||
- Focused multi-step experience
|
- Self-contained multi-step experience (no redirects to other pages)
|
||||||
|
|
||||||
**Flow:**
|
**Steps:** `form` → `otp` → `complete-account` → `success`
|
||||||
|
|
||||||
|
**Path 1: "Just Submit Request"** (guest, no account):
|
||||||
|
|
||||||
1. Collects name, email, and address (with Japan ZIP code lookup)
|
1. Collects name, email, and address (with Japan ZIP code lookup)
|
||||||
2. Verifies email with 6-digit OTP
|
2. Creates SF Account + Opportunity (find/create) + Eligibility Case
|
||||||
3. Creates SF Account + Eligibility Case immediately on verification
|
3. Shows success with "View Internet Plans" button
|
||||||
4. Shows success with options: "Create Account Now" or "View Internet Plans"
|
|
||||||
|
**Path 2: "Create Account & Submit"** (full account creation):
|
||||||
|
|
||||||
|
1. Collects name, email, and address (with Japan ZIP code lookup)
|
||||||
|
2. Sends OTP, user verifies on same page
|
||||||
|
3. Collects account details (phone, DOB, password)
|
||||||
|
4. Creates SF Account + Opportunity + Case + WHMCS client + Portal user
|
||||||
|
5. Shows success with auto-redirect to dashboard (5s countdown)
|
||||||
|
|
||||||
## Backend Endpoints
|
## Backend Endpoints
|
||||||
|
|
||||||
| Endpoint | Rate Limit | Purpose |
|
| Endpoint | Rate Limit | Purpose |
|
||||||
| ------------------------------------------------ | ---------- | --------------------------------------------- |
|
| ------------------------------------------------ | ---------- | ------------------------------------------------------------------- |
|
||||||
| `POST /auth/get-started/send-code` | 5/5min | Send OTP to email |
|
| `POST /auth/get-started/send-code` | 5/5min | Send OTP to email |
|
||||||
| `POST /auth/get-started/verify-code` | 10/5min | Verify OTP, return account status |
|
| `POST /auth/get-started/verify-code` | 10/5min | Verify OTP, return account status |
|
||||||
| `POST /auth/get-started/guest-eligibility` | 3/15min | Guest eligibility (no OTP, creates SF + Case) |
|
| `POST /auth/get-started/guest-eligibility` | 3/15min | Guest eligibility (no OTP, creates SF Account + Opportunity + Case) |
|
||||||
| `POST /auth/get-started/complete-account` | 5/15min | Complete SF-only account |
|
| `POST /auth/get-started/complete-account` | 5/15min | Complete SF-only account |
|
||||||
| `POST /auth/get-started/signup-with-eligibility` | 5/15min | Full signup with eligibility (OTP verified) |
|
| `POST /auth/get-started/signup-with-eligibility` | 5/15min | Full signup with eligibility (OTP verified) |
|
||||||
|
|
||||||
@ -134,42 +180,34 @@ Key schemas:
|
|||||||
- **Max Attempts**: 3 per code
|
- **Max Attempts**: 3 per code
|
||||||
- **Rate Limits**: 5 codes per 5 minutes
|
- **Rate Limits**: 5 codes per 5 minutes
|
||||||
|
|
||||||
## Handoff from Eligibility Check
|
## Eligibility Check Flow Details
|
||||||
|
|
||||||
### Flow A: "Continue to Create Account" (Inline OTP)
|
### Path 1: "Just Submit Request" (Guest Flow)
|
||||||
|
|
||||||
When a user clicks "Continue to Create Account":
|
When a user clicks "Just Submit Request":
|
||||||
|
|
||||||
1. Eligibility form is submitted (creates SF Account + Case)
|
1. Calls `guestEligibilityCheck` API with `continueToAccount: false`
|
||||||
2. OTP is sent and verified **inline on the same page**
|
2. Backend creates SF Account + Opportunity (find/create) + Eligibility Case
|
||||||
3. On successful verification:
|
3. Frontend navigates to success step with `hasAccount: false`
|
||||||
- Session data stored in sessionStorage with timestamp:
|
4. Success page shows only "View Internet Plans" button
|
||||||
- `get-started-session-token`
|
5. User can return later via SF email to create an account at `/auth/get-started`
|
||||||
- `get-started-account-status`
|
|
||||||
- `get-started-prefill` (JSON with name, address from SF)
|
|
||||||
- `get-started-email`
|
|
||||||
- `get-started-timestamp` (for staleness validation)
|
|
||||||
- Redirect to: `/auth/get-started?verified=true`
|
|
||||||
4. GetStartedView detects `?verified=true` param and:
|
|
||||||
- Reads session data from sessionStorage (validates timestamp < 5 min)
|
|
||||||
- Clears sessionStorage immediately after reading
|
|
||||||
- Sets session token, account status, and prefill data in Zustand store
|
|
||||||
- Skips directly to `complete-account` step (no email/OTP required)
|
|
||||||
- User only needs to add phone, DOB, and password
|
|
||||||
|
|
||||||
### Flow B: "Send Request Only" → Return Later
|
### Path 2: "Create Account & Submit" (Full Account Creation)
|
||||||
|
|
||||||
When a user clicks "Send Request Only":
|
When a user clicks "Create Account & Submit":
|
||||||
|
|
||||||
1. Eligibility form is submitted (creates SF Account + Case)
|
1. **OTP Step**: Calls `sendVerificationCode` API → navigates to OTP step (same page)
|
||||||
2. Success page is shown with two options:
|
2. **Verify OTP**: User enters code, calls `verifyCode` API → receives session token
|
||||||
- **"Back to Internet Plans"** → Returns to `/services/internet`
|
3. **Complete Account**: Navigates to complete-account step (same page)
|
||||||
- **"Create Your Account Now"** → Redirects to `/auth/get-started?email=xxx&handoff=true`
|
4. **Submit**: Calls `signupWithEligibility` API which creates:
|
||||||
3. If user returns later via success page CTA or SF email:
|
- SF Account (find or create)
|
||||||
- Standard flow: Email (pre-filled) → OTP → Account Status → Complete
|
- Opportunity (find or create)
|
||||||
- Backend detects `sf_unmapped` status and returns prefill data
|
- Eligibility Case
|
||||||
|
- WHMCS client
|
||||||
|
- Portal user
|
||||||
|
5. **Success**: Shows success with "Go to Dashboard" button + auto-redirect (5s)
|
||||||
|
|
||||||
### Salesforce Email Link Format
|
### Guest Return Flow via SF Email
|
||||||
|
|
||||||
SF can send "finish your account" emails with this link format:
|
SF can send "finish your account" emails with this link format:
|
||||||
|
|
||||||
@ -177,32 +215,35 @@ SF can send "finish your account" emails with this link format:
|
|||||||
https://portal.example.com/auth/get-started?email={Account.PersonEmail}
|
https://portal.example.com/auth/get-started?email={Account.PersonEmail}
|
||||||
```
|
```
|
||||||
|
|
||||||
- No handoff token needed (SF Account persists)
|
- User goes to `/auth/get-started` (not the eligibility check page)
|
||||||
- User verifies via standard OTP flow on get-started page
|
- Standard flow: Email (pre-filled) → OTP → Account Status → Complete
|
||||||
- Backend detects `sf_unmapped` status and pre-fills form data
|
- Backend detects `sf_unmapped` status and returns prefill data from existing SF Account
|
||||||
|
|
||||||
## Testing Checklist
|
## Testing Checklist
|
||||||
|
|
||||||
### Manual Testing
|
### Manual Testing - Get Started Page (`/auth/get-started`)
|
||||||
|
|
||||||
1. **New customer flow**: Enter new email → Verify OTP → Full signup form
|
1. **New customer flow**: Enter new email → Verify OTP → Full signup form
|
||||||
2. **SF-only flow**: Enter email with SF account → Verify → Pre-filled form (name, address pre-filled, add phone, DOB, password)
|
2. **SF-only flow**: Enter email with SF account → Verify → Pre-filled form (name, address pre-filled, add phone, DOB, password)
|
||||||
3. **WHMCS migration**: Enter email with WHMCS → Verify → Enter WHMCS password
|
3. **WHMCS migration**: Enter email with WHMCS → Verify → Enter WHMCS password
|
||||||
4. **Eligibility check - Send Request Only**:
|
4. **Return flow**: Customer with existing SF account returns, enters same email → Auto-links to SF account
|
||||||
- Click "Check Availability" → Fill form → Click "Send Request Only"
|
|
||||||
- Verify success page shows "Back to Plans" and "Create Account" buttons
|
### Manual Testing - Eligibility Check Page (`/services/internet/check-availability`)
|
||||||
- Click "Create Account" → Verify redirect to `/auth/get-started?email=xxx`
|
|
||||||
- Complete standard OTP flow → Verify sf_unmapped prefill works
|
5. **Eligibility check - Just Submit Request**:
|
||||||
5. **Eligibility check - Continue to Create Account**:
|
- Click "Check Availability" → Fill form → Click "Just Submit Request"
|
||||||
- Click "Check Availability" → Fill form → Click "Continue to Create Account"
|
- Verify success page shows only "View Internet Plans" button
|
||||||
- Verify inline OTP step appears (no redirect)
|
- Verify SF Account + Opportunity + Case are created
|
||||||
- Complete OTP → Verify redirect to `/auth/get-started?verified=true`
|
6. **Eligibility check - Create Account & Submit**:
|
||||||
- Verify CompleteAccountStep shows directly (skips email/OTP steps)
|
- Click "Check Availability" → Fill form → Click "Create Account & Submit"
|
||||||
- Verify form is pre-filled with name and address
|
- Verify OTP step appears (same page, no redirect)
|
||||||
6. **Return flow**: Customer returns, enters same email → Auto-links to SF account
|
- Complete OTP → Verify complete-account step appears (same page)
|
||||||
|
- Fill account details → Submit
|
||||||
|
- Verify success page with auto-redirect countdown to dashboard
|
||||||
|
- Verify SF Account + Opportunity + Case + WHMCS + Portal user created
|
||||||
7. **Mobile experience**: Test eligibility check page on mobile viewport
|
7. **Mobile experience**: Test eligibility check page on mobile viewport
|
||||||
8. **Browser back button**: After OTP success, press back → Verify graceful handling
|
8. **Browser back button**: After OTP success, press back → Verify graceful handling
|
||||||
9. **Session timeout**: Wait 5+ minutes after OTP → Verify stale data is rejected
|
9. **Existing account handling**: During OTP verification, if `portal_exists` or `whmcs_unmapped` status returned, verify appropriate error message
|
||||||
|
|
||||||
### Security Testing
|
### Security Testing
|
||||||
|
|
||||||
|
|||||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@ -167,9 +167,6 @@ importers:
|
|||||||
ssh2-sftp-client:
|
ssh2-sftp-client:
|
||||||
specifier: ^12.0.1
|
specifier: ^12.0.1
|
||||||
version: 12.0.1
|
version: 12.0.1
|
||||||
swagger-ui-express:
|
|
||||||
specifier: ^5.0.1
|
|
||||||
version: 5.0.1(express@5.1.0)
|
|
||||||
zod:
|
zod:
|
||||||
specifier: "catalog:"
|
specifier: "catalog:"
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
@ -7830,27 +7827,12 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=10" }
|
engines: { node: ">=10" }
|
||||||
|
|
||||||
swagger-ui-dist@5.21.0:
|
|
||||||
resolution:
|
|
||||||
{
|
|
||||||
integrity: sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==,
|
|
||||||
}
|
|
||||||
|
|
||||||
swagger-ui-dist@5.30.2:
|
swagger-ui-dist@5.30.2:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
integrity: sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==,
|
integrity: sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==,
|
||||||
}
|
}
|
||||||
|
|
||||||
swagger-ui-express@5.0.1:
|
|
||||||
resolution:
|
|
||||||
{
|
|
||||||
integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==,
|
|
||||||
}
|
|
||||||
engines: { node: ">= v0.10.32" }
|
|
||||||
peerDependencies:
|
|
||||||
express: ">=4.0.0 || >=5.0.0-beta"
|
|
||||||
|
|
||||||
symbol-observable@4.0.0:
|
symbol-observable@4.0.0:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -13079,19 +13061,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
|
|
||||||
swagger-ui-dist@5.21.0:
|
|
||||||
dependencies:
|
|
||||||
"@scarf/scarf": 1.4.0
|
|
||||||
|
|
||||||
swagger-ui-dist@5.30.2:
|
swagger-ui-dist@5.30.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
"@scarf/scarf": 1.4.0
|
"@scarf/scarf": 1.4.0
|
||||||
|
|
||||||
swagger-ui-express@5.0.1(express@5.1.0):
|
|
||||||
dependencies:
|
|
||||||
express: 5.1.0
|
|
||||||
swagger-ui-dist: 5.21.0
|
|
||||||
|
|
||||||
symbol-observable@4.0.0: {}
|
symbol-observable@4.0.0: {}
|
||||||
|
|
||||||
tailwind-merge@3.4.0: {}
|
tailwind-merge@3.4.0: {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user