import React, { useState, useEffect } from "react";
import { CheckIcon, HomeIcon, PlusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { AddressAutocomplete } from "./form-primitives";
import { Button } from "./button";
import { Input } from "./input";
import { Slider } from "./slider";
import { Spinner } from "./spinner";
import { ToggleGroup, ToggleGroupItem } from "./toggle-group";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "./input-otp";
import { FormField } from "./signup-form-primitives";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const fmt = (v: number) =>
new Intl.NumberFormat("en-AU", {
style: "currency",
currency: "AUD",
maximumFractionDigits: 0,
}).format(v);
const GOAL_MIN = 50_000;
const GOAL_MAX = 4_000_000;
const GOAL_STEP = 10_000;
// ---------------------------------------------------------------------------
// Shared OTP verification — used by both the phone and email verify steps
// ---------------------------------------------------------------------------
const OTP_LENGTH = 6;
const RESEND_COUNTDOWN = 60;
/**
* 6-digit OTP entry state plus the resend countdown. Auto-submits via
* `onVerify` once all digits are entered; `restartTimer` restarts the
* countdown when the user requests a new code.
*/
function useOtpTimer(countdown: number, onVerify?: (code: string) => void) {
const [otp, setOtp] = useState("");
const [timer, setTimer] = useState(countdown);
useEffect(() => {
if (timer <= 0) return;
const id = setInterval(() => setTimer((t) => t - 1), 1000);
return () => clearInterval(id);
}, [timer]);
const handleChange = (value: string) => {
setOtp(value);
if (value.length === OTP_LENGTH) onVerify?.(value);
};
const restartTimer = () => setTimer(RESEND_COUNTDOWN);
return { otp, handleChange, timer, restartTimer };
}
/** The 6-slot OTP input with its inline error message. */
function OtpCodeFields({
otp,
onChange,
disabled,
error,
className,
}: {
otp: string;
onChange: (value: string) => void;
disabled?: boolean;
error?: string;
className?: string;
}) {
const invalid = !!error || undefined;
return (
{[0, 1, 2].map((i) => (
))}
{[3, 4, 5].map((i) => (
))}
{error && (
{error}
)}
);
}
/** "Verifying…" spinner while submitting, otherwise the resend prompt. */
function VerifyStatus({
isLoading,
timer,
prompt,
onResend,
}: {
isLoading?: boolean;
timer: number;
prompt: string;
onResend: () => void;
}) {
if (isLoading) {
return (
Verifying…
);
}
return (
{prompt}{" "}
{timer > 0 ? (
Resend it in {timer}s
) : (
Resend it
)}
);
}
// ---------------------------------------------------------------------------
// Step 2 — Phone Verification
// ---------------------------------------------------------------------------
export interface PhoneVerifyStepProps {
phone?: string;
countdown?: number;
error?: string;
isLoading?: boolean;
onVerify?: (code: string) => void;
onResend?: () => void;
}
export function PhoneVerifyStep({
phone = "+61 412 345 789",
countdown = 45,
error = "",
isLoading = false,
onVerify,
onResend,
}: PhoneVerifyStepProps) {
const { otp, handleChange, timer, restartTimer } = useOtpTimer(
countdown,
onVerify,
);
const lastFour = phone.slice(-4);
return (
We sent you a text.
Number we have ends in •••• {lastFour}.
{
restartTimer();
onResend?.();
}}
/>
);
}
// ---------------------------------------------------------------------------
// Step 2 (email variant) — Email Verification
// ---------------------------------------------------------------------------
/** Masks the local part of an email, e.g. "jamie@example.com" → "j***e@example.com". */
function maskEmail(email: string) {
const [local, domain] = email.split("@");
if (!domain || local.length <= 2) return email;
return `${local[0]}***${local[local.length - 1]}@${domain}`;
}
export interface EmailVerifyStepProps {
email?: string;
countdown?: number;
error?: string;
isLoading?: boolean;
onVerify?: (code: string) => void;
onResend?: () => void;
}
export function EmailVerifyStep({
email = "jamie@example.com",
countdown = 45,
error = "",
isLoading = false,
onVerify,
onResend,
}: EmailVerifyStepProps) {
const { otp, handleChange, timer, restartTimer } = useOtpTimer(
countdown,
onVerify,
);
return (
We sent you an email.
Check {maskEmail(email)} for your verification code.
{
restartTimer();
onResend?.();
}}
/>
);
}
// ---------------------------------------------------------------------------
// Step 3 — Request Broker Access
// ---------------------------------------------------------------------------
export interface BrokerFormValues {
firstName: string;
lastName: string;
email: string;
phone: string;
}
export interface BrokerRequestStepProps {
onValuesChange?: (values: BrokerFormValues) => void;
}
export function BrokerRequestStep({ onValuesChange }: BrokerRequestStepProps) {
const [values, setValues] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
});
const update = (field: keyof BrokerFormValues, value: string) => {
setValues((prev) => {
const next = { ...prev, [field]: value };
onValuesChange?.(next);
return next;
});
};
return (
You have access for the first 3 months covered by us. After this date
you will need to be connected with a mortgage broker or financial
planner to maintain access.
);
}
// ---------------------------------------------------------------------------
// Step 4 — Connect Bank
// ---------------------------------------------------------------------------
const BANKS = ["CBA", "ANZ", "NAB", "WBC", "MAC", "BOQ"];
export interface ConnectBankStepProps {
onConnect?: () => void;
}
export function ConnectBankStep({ onConnect }: ConnectBankStepProps) {
return (
{BANKS.map((abbr) => (
{abbr}
))}
);
}
// ---------------------------------------------------------------------------
// Step 5 — Retrieve Bank Data (loading state)
// ---------------------------------------------------------------------------
export function RetrieveBankDataStep() {
return (
Retrieving your bank data…
This may take a few seconds. Please don't close this window.
);
}
// ---------------------------------------------------------------------------
// Step 6 — Connect Property
// ---------------------------------------------------------------------------
interface PropertyItem {
id: string;
address: string;
suburb: string;
state: string;
}
export interface ConnectPropertyStepProps {
properties?: PropertyItem[];
onAddProperty?: (address: string) => void;
onCanProceedChange?: (can: boolean) => void;
onGoalChange?: (value: number) => void;
}
export function ConnectPropertyStep({
properties = [],
onAddProperty,
onCanProceedChange,
onGoalChange,
}: ConnectPropertyStepProps) {
const [hasProperty, setHasProperty] = useState(null);
const [addressValue, setAddressValue] = useState("");
const [goalValue, setGoalValue] = useState(2_000_000);
const canProceed =
hasProperty === false || (hasProperty === true && properties.length > 0);
useEffect(() => {
onCanProceedChange?.(canProceed);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canProceed]);
const handleGoalChange = (v: number) => {
setGoalValue(v);
onGoalChange?.(v);
};
return (
{
const val = vals[0];
setHasProperty(val === "yes" ? true : val === "no" ? false : null);
}}
className="w-full"
>
Yes, I have a property
No, I don't
{hasProperty === true && (
<>
{
onAddProperty?.(opt.label);
setAddressValue("");
}}
/>
{properties.length > 0 && (
{properties.map((p) => (
{p.address}
{p.suburb}, {p.state}
))}
)}
>
)}
{hasProperty === false && (
What's your property budget?
Set your estimated buying goal to help us tailor your experience.
{fmt(goalValue)}
{fmt(GOAL_MIN)}
{fmt(GOAL_MAX)}
)}
);
}
// ---------------------------------------------------------------------------
// BuyingGoalStep — standalone step (used in template stories)
// ---------------------------------------------------------------------------
export interface BuyingGoalStepProps {
initialValue?: number;
isLoading?: boolean;
onConfirm?: (value: number) => void;
}
export function BuyingGoalStep({
initialValue = 2_000_000,
isLoading = false,
onConfirm,
}: BuyingGoalStepProps) {
const [value, setValue] = useState(initialValue);
return (
{fmt(value)}
onConfirm?.(value)}
disabled={isLoading}
>
{isLoading ? (
<>
Adding…
>
) : (
"Confirm"
)}
setValue(v)}
disabled={isLoading}
aria-label="Buying goal"
/>
{fmt(GOAL_MIN)}
{fmt(GOAL_MAX)}
);
}
// ---------------------------------------------------------------------------
// Step 7 — Success
// ---------------------------------------------------------------------------
export interface FrontendSuccessStepProps {
firstName?: string;
onGoToDashboard?: () => void;
}
export function FrontendSuccessStep({
firstName = "Alex",
onGoToDashboard,
}: FrontendSuccessStepProps) {
return (
You're all set, {firstName}!
Your account is ready. Head to your dashboard to explore your
financial overview and next steps.
Go to Dashboard
);
}