705 lines
29 KiB
TypeScript
Raw Normal View History

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import {
MapPinIcon,
PencilIcon,
CheckIcon,
XMarkIcon,
UserIcon,
ShieldCheckIcon,
} from "@heroicons/react/24/outline";
import { useAuthStore } from "@/features/auth/stores/auth.store";
import { accountService } from "@/features/account/api/account.api";
import { useProfileEdit } from "@/features/account/hooks/useProfileEdit";
import { AddressForm } from "@/features/services/components/base/AddressForm";
import { Button } from "@/components/atoms/button";
import { StatusPill } from "@/components/atoms/status-pill";
import { useAddressEdit } from "@/features/account/hooks/useAddressEdit";
import {
useResidenceCardVerification,
useSubmitResidenceCard,
} from "@/features/verification/hooks/useResidenceCardVerification";
import { PageLayout } from "@/components/templates";
import { formatIsoDate } from "@/shared/utils";
export default function ProfileContainer() {
const { user } = useAuthStore();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingProfile, setEditingProfile] = useState(false);
const [editingAddress, setEditingAddress] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const profile = useProfileEdit({
email: user?.email || "",
phonenumber: user?.phonenumber || "",
});
const address = useAddressEdit({
address1: "",
address2: "",
city: "",
state: "",
postcode: "",
country: "",
countryCode: "",
phoneNumber: "",
phoneCountryCode: "",
});
// ID Verification status from Salesforce
const verificationQuery = useResidenceCardVerification();
const submitResidenceCard = useSubmitResidenceCard();
const verificationStatus = verificationQuery.data?.status;
const [verificationFile, setVerificationFile] = useState<File | null>(null);
const verificationFileInputRef = useRef<HTMLInputElement | null>(null);
const canUploadVerification = verificationStatus !== "verified";
// Helper to render verification status pill
const renderVerificationStatusPill = () => {
if (verificationQuery.isLoading) {
return <Skeleton className="h-6 w-20" />;
}
switch (verificationStatus) {
case "verified":
return <StatusPill label="Verified" variant="success" />;
case "pending":
return <StatusPill label="Under Review" variant="info" />;
case "rejected":
return <StatusPill label="Action Needed" variant="warning" />;
default:
return <StatusPill label="Required for SIM" variant="warning" />;
}
};
// Helper to render verification content based on status
const renderVerificationContent = () => {
if (verificationQuery.isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-32" />
</div>
);
}
if (verificationStatus === "verified") {
return (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Your identity has been verified. No further action is needed.
</p>
{verificationQuery.data?.reviewedAt && (
<p className="text-xs text-muted-foreground">
Verified on{" "}
{formatIsoDate(verificationQuery.data.reviewedAt, { dateStyle: "medium" })}
</p>
)}
</div>
);
}
if (verificationStatus === "pending") {
return (
<div className="space-y-4">
<AlertBanner variant="info" title="Under review" size="sm" elevated>
Your residence card has been submitted. We&apos;ll verify it before activating SIM
service.
</AlertBanner>
{verificationQuery.data?.submittedAt && (
<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">
Submission status
</div>
<div className="mt-1 text-xs text-muted-foreground">
Submitted on{" "}
{formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })}
</div>
</div>
)}
</div>
);
}
// Default: rejected or not submitted
return null;
};
// Extract stable setValue functions to avoid infinite re-render loop.
// The hook objects (address, profile) are recreated every render, but
// the setValue callbacks inside them are stable (memoized with useCallback).
const setAddressValue = address.setValue;
const setProfileValue = profile.setValue;
const loadProfile = useCallback(() => {
void (async () => {
try {
setError(null);
setLoading(true);
const [addr, prof] = await Promise.all([
accountService.getAddress().catch(() => null),
accountService.getProfile().catch(() => null),
]);
if (addr) {
setAddressValue("address1", addr.address1 ?? "");
setAddressValue("address2", addr.address2 ?? "");
setAddressValue("city", addr.city ?? "");
setAddressValue("state", addr.state ?? "");
setAddressValue("postcode", addr.postcode ?? "");
setAddressValue("country", addr.country ?? "");
setAddressValue("countryCode", addr.countryCode ?? "");
setAddressValue("phoneNumber", addr.phoneNumber ?? "");
setAddressValue("phoneCountryCode", addr.phoneCountryCode ?? "");
}
if (prof) {
setProfileValue("email", prof.email || "");
setProfileValue("phonenumber", prof.phonenumber || "");
// Spread all profile fields into the user state, including sfNumber, dateOfBirth, gender
useAuthStore.setState(state => ({
...state,
user: state.user
? {
...state.user,
...prof,
}
: prof,
}));
}
} catch (e) {
// Keep message customer-safe (no internal details)
setError(e instanceof Error ? e.message : "Failed to load profile data");
} finally {
setLoading(false);
}
})();
}, [setAddressValue, setProfileValue]);
useEffect(() => {
loadProfile();
}, [loadProfile, reloadKey]);
if (loading) {
return (
<PageLayout
icon={<UserIcon />}
title="Profile"
description="Manage your account information"
loading
>
<div className="space-y-8">
<div className="bg-card border border-border rounded-xl shadow-[var(--cp-shadow-1)]">
<div className="px-6 py-5 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="h-6 w-6 bg-muted rounded" />
<div className="h-6 w-40 bg-muted rounded" />
</div>
<div className="h-8 w-20 bg-muted rounded" />
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
<div className="sm:col-span-2">
<Skeleton className="h-4 w-28 mb-3" />
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-5 w-24" />
</div>
<Skeleton className="h-3 w-64 mt-2" />
</div>
</div>
</div>
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border mt-6">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-28" />
</div>
</div>
</div>
<div className="bg-card border border-border rounded-xl shadow-[var(--cp-shadow-1)]">
<div className="px-6 py-5 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="h-6 w-6 bg-muted rounded" />
<div className="h-6 w-48 bg-muted rounded" />
</div>
<div className="h-8 w-20 bg-muted rounded" />
</div>
</div>
<div className="p-6">
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
<div className="space-y-2">
<Skeleton className="h-4 w-60" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-52" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<div className="flex items-center justify-end space-x-3 pt-6">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-28" />
</div>
</div>
</div>
</div>
</PageLayout>
);
}
return (
<PageLayout
icon={<UserIcon />}
title="Profile"
description="Manage your account information"
error={error}
onRetry={() => setReloadKey(k => k + 1)}
>
{error && (
<AlertBanner variant="error" title="Unable to load profile" className="mb-6" elevated>
{error}
</AlertBanner>
)}
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
<div className="px-6 py-5 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<UserIcon className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">Personal Information</h2>
</div>
{!editingProfile && (
<Button
variant="outline"
size="sm"
onClick={() => setEditingProfile(true)}
leftIcon={<PencilIcon className="h-4 w-4" />}
>
Edit
</Button>
)}
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">
First Name
</label>
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
<p className="text-base text-foreground font-medium">
{user?.firstname || (
<span className="text-muted-foreground italic">Not provided</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-2">
Name cannot be changed from the portal.
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">
Last Name
</label>
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
<p className="text-base text-foreground font-medium">
{user?.lastname || (
<span className="text-muted-foreground italic">Not provided</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-2">
Name cannot be changed from the portal.
</p>
</div>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-muted-foreground mb-2">
Email Address
</label>
{editingProfile ? (
<input
type="email"
value={profile.values.email}
onChange={e => profile.setValue("email", e.target.value)}
className="block w-full px-4 py-2.5 border border-input rounded-lg bg-background text-foreground shadow-[var(--cp-shadow-1)] focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
/>
) : (
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
<div className="flex items-center justify-between">
<p className="text-base text-foreground font-medium">{user?.email}</p>
</div>
<p className="text-xs text-muted-foreground mt-2">
Email can be updated from the portal.
</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">
Customer Number
</label>
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
<p className="text-base text-foreground font-medium">
{user?.sfNumber || (
<span className="text-muted-foreground italic">Not available</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-2">Customer number is read-only.</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">
Date of Birth
</label>
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
<p className="text-base text-foreground font-medium">
{user?.dateOfBirth || (
<span className="text-muted-foreground italic">Not provided</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-2">
Date of birth is stored in billing profile.
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">
Phone Number
</label>
{editingProfile ? (
<input
type="tel"
value={profile.values.phonenumber}
onChange={e => profile.setValue("phonenumber", e.target.value)}
placeholder="+81 XX-XXXX-XXXX"
className="block w-full px-4 py-2.5 border border-input rounded-lg bg-background text-foreground shadow-[var(--cp-shadow-1)] focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
/>
) : (
<p className="text-base text-foreground py-2">
{user?.phonenumber || (
<span className="text-muted-foreground italic">Not provided</span>
)}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">Gender</label>
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
<p className="text-base text-foreground font-medium">
{user?.gender || (
<span className="text-muted-foreground italic">Not provided</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-2">
Gender is stored in billing profile.
</p>
</div>
</div>
</div>
{editingProfile && (
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border mt-6">
<Button
variant="outline"
size="sm"
onClick={() => setEditingProfile(false)}
disabled={profile.isSubmitting}
leftIcon={<XMarkIcon className="h-4 w-4" />}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
void profile
.handleSubmit()
.then(() => {
setEditingProfile(false);
})
.catch(() => {
// Error is handled by useZodForm
});
}}
isLoading={profile.isSubmitting}
leftIcon={profile.isSubmitting ? undefined : <CheckIcon className="h-4 w-4" />}
>
{profile.isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
)}
</div>
</div>
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
<div className="px-6 py-5 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<MapPinIcon className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">Address Information</h2>
</div>
{!editingAddress && (
<Button
variant="outline"
size="sm"
onClick={() => setEditingAddress(true)}
leftIcon={<PencilIcon className="h-4 w-4" />}
>
Edit
</Button>
)}
</div>
</div>
<div className="p-6">
{editingAddress ? (
<div className="space-y-6">
<AddressForm
initialAddress={{
address1: address.values.address1,
address2: address.values.address2,
city: address.values.city,
state: address.values.state,
postcode: address.values.postcode,
country: address.values.country,
countryCode: address.values.countryCode,
phoneNumber: address.values.phoneNumber,
phoneCountryCode: address.values.phoneCountryCode,
}}
onChange={a => {
address.setValue("address1", a.address1 ?? "");
address.setValue("address2", a.address2 ?? "");
address.setValue("city", a.city ?? "");
address.setValue("state", a.state ?? "");
address.setValue("postcode", a.postcode ?? "");
address.setValue("country", a.country ?? "");
address.setValue("countryCode", a.countryCode ?? "");
address.setValue("phoneNumber", a.phoneNumber ?? "");
address.setValue("phoneCountryCode", a.phoneCountryCode ?? "");
}}
title="Mailing Address"
/>
<div className="flex items-center justify-end space-x-3 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditingAddress(false)}
disabled={address.isSubmitting}
leftIcon={<XMarkIcon className="h-4 w-4" />}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
void address
.handleSubmit()
.then(() => {
setEditingAddress(false);
})
.catch(() => {
// Error is handled by useZodForm
});
}}
isLoading={address.isSubmitting}
leftIcon={address.isSubmitting ? undefined : <CheckIcon className="h-4 w-4" />}
>
{address.isSubmitting ? "Saving..." : "Save Address"}
</Button>
</div>
{address.submitError && (
<AlertBanner variant="error" title="Address Error">
{address.submitError}
</AlertBanner>
)}
</div>
) : (
<div>
{address.values.address1 || address.values.city ? (
<div className="bg-card rounded-lg p-5 border border-border shadow-sm">
<div className="text-foreground space-y-1.5">
{(address.values.address2 || address.values.address1) && (
<p className="font-medium text-base">
{address.values.address2 || address.values.address1}
</p>
)}
{address.values.address2 && address.values.address1 && (
<p className="text-muted-foreground">{address.values.address1}</p>
)}
<p className="text-muted-foreground">
{[address.values.city, address.values.state, address.values.postcode]
.filter(Boolean)
.join(", ")}
</p>
<p className="text-muted-foreground">{address.values.country}</p>
</div>
</div>
) : (
<div className="text-center py-12">
<MapPinIcon className="h-12 w-12 text-muted-foreground/60 mx-auto mb-4" />
<p className="text-muted-foreground mb-4">No address on file</p>
<Button
onClick={() => setEditingAddress(true)}
leftIcon={<PencilIcon className="h-4 w-4" />}
>
Add Address
</Button>
</div>
)}
</div>
)}
</div>
</div>
{/* ID Verification Card - Integrated Upload */}
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
<div className="px-6 py-5 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<ShieldCheckIcon className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">Identity Verification</h2>
</div>
{renderVerificationStatusPill()}
</div>
</div>
<div className="p-6">
{renderVerificationContent()}
{/* Upload section for rejected or not submitted status */}
{!verificationQuery.isLoading &&
verificationStatus !== "verified" &&
verificationStatus !== "pending" && (
<div className="space-y-4">
{verificationStatus === "rejected" ? (
<AlertBanner variant="warning" title="Verification rejected" size="sm" elevated>
<div className="space-y-2">
{verificationQuery.data?.reviewerNotes && (
<p>{verificationQuery.data.reviewerNotes}</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>
) : (
<p className="text-sm text-muted-foreground">
Upload your residence card to activate SIM services. This is required for SIM
orders.
</p>
)}
{(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?.submittedAt && (
<div className="mt-1 text-xs text-muted-foreground">
Submitted on{" "}
{formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })}
</div>
)}
{verificationQuery.data?.reviewedAt && (
<div className="mt-1 text-xs text-muted-foreground">
Reviewed on{" "}
{formatIsoDate(verificationQuery.data.reviewedAt, { dateStyle: "medium" })}
</div>
)}
</div>
)}
{canUploadVerification && (
<div className="space-y-3">
<input
ref={verificationFileInputRef}
type="file"
accept="image/*,application/pdf"
onChange={e => setVerificationFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80"
/>
{verificationFile && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground">
Selected file
</div>
<div className="text-sm font-medium text-foreground truncate">
{verificationFile.name}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setVerificationFile(null);
if (verificationFileInputRef.current) {
verificationFileInputRef.current.value = "";
}
}}
>
Change
</Button>
</div>
)}
<div className="flex items-center justify-end">
<Button
type="button"
disabled={!verificationFile || submitResidenceCard.isPending}
isLoading={submitResidenceCard.isPending}
loadingText="Uploading…"
onClick={() => {
if (!verificationFile) return;
submitResidenceCard.mutate(verificationFile, {
onSuccess: () => {
setVerificationFile(null);
if (verificationFileInputRef.current) {
verificationFileInputRef.current.value = "";
}
},
});
}}
>
Submit Document
</Button>
</div>
{submitResidenceCard.isError && (
<p className="text-sm text-destructive">
{submitResidenceCard.error instanceof Error
? submitResidenceCard.error.message
: "Failed to submit residence card."}
</p>
)}
<p className="text-xs text-muted-foreground">
Accepted formats: JPG, PNG, or PDF (max 5MB). Make sure all text is readable.
</p>
</div>
)}
</div>
)}
</div>
</div>
</PageLayout>
);
}