From e938671eefbf1f2ea5881a8b0cf2ad36a5fd611f Mon Sep 17 00:00:00 2001 From: adenueltech Date: Sat, 17 Oct 2026 20:18:48 +0100 Subject: [PATCH] feat: complete step-by-step invoice creation wizard (#891) - Move wizard step definitions, help text and validation into src/lib/invoiceWizard.ts so the rules are shared and unit tested - Fix equal-split mode always failing recipient validation - Validate the deadline on the step it lives on (1-365 days) - Use real StrKey checks for token and recipient addresses, reject duplicates - Re-validate every step on submit so ?step=4 deep links cannot skip checks - Enter on an earlier step advances the wizard instead of submitting - Save the full wizard state (step, equal split, tags, co-signers, installments) in drafts and restore all of it - Only autosave once the user has interacted; show "Draft saved" time - Add a progress bar, per-step descriptions, help text for each field and Edit shortcuts on the review step - Remove duplicated DeadlineSuggester on step 1 - FormField: point aria-describedby at the error message when shown Closes #891 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/__tests__/invoiceWizardSteps.test.ts | 143 ++++++++ src/app/invoice/new/page.tsx | 402 +++++++++++++++-------- src/components/ui/FormField.tsx | 2 +- src/lib/invoiceWizard.ts | 192 +++++++++++ src/lib/offlineDraftDB.ts | 13 + 5 files changed, 609 insertions(+), 143 deletions(-) create mode 100644 src/__tests__/invoiceWizardSteps.test.ts create mode 100644 src/lib/invoiceWizard.ts diff --git a/src/__tests__/invoiceWizardSteps.test.ts b/src/__tests__/invoiceWizardSteps.test.ts new file mode 100644 index 00000000..25de3309 --- /dev/null +++ b/src/__tests__/invoiceWizardSteps.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest"; +import { StrKey } from "@stellar/stellar-sdk"; +import { + WIZARD_STEPS, + REVIEW_STEP, + FIELD_HELP, + validateWizardStep, + firstInvalidStep, + type WizardState, +} from "@/lib/invoiceWizard"; + +const TOKEN = StrKey.encodeContract(Buffer.alloc(32, 1)); +const ALICE = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 2)); +const BOB = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 3)); + +function validState(overrides: Partial = {}): WizardState { + return { + token: TOKEN, + deadlineDays: 7, + isClone: false, + cloneDeadlineError: null, + recurring: false, + intervalDays: 7, + recipients: [ + { address: ALICE, amount: "10" }, + { address: BOB, amount: "5.5" }, + ], + equalSplit: false, + totalAmount: "", + splitError: null, + cosigners: [], + cosignerThreshold: 1, + ...overrides, + }; +} + +describe("invoice wizard (#891)", () => { + it("defines four steps ending in review, each with a description", () => { + expect(WIZARD_STEPS.map((s) => s.label)).toEqual([ + "Basic Info", + "Recipients", + "Options", + "Review & Submit", + ]); + expect(REVIEW_STEP).toBe(3); + WIZARD_STEPS.forEach((s) => expect(s.description.length).toBeGreaterThan(0)); + }); + + it("has help text for every field", () => { + Object.values(FIELD_HELP).forEach((text) => expect(text.trim().length).toBeGreaterThan(10)); + }); + + it("accepts a fully valid state", () => { + expect(firstInvalidStep(validState())).toBe(-1); + }); + + describe("step 1: basic info", () => { + it("requires a token", () => { + const r = validateWizardStep(0, validState({ token: "" })); + expect(r.valid).toBe(false); + expect(r.fieldErrors.token).toMatch(/required/); + }); + + it("rejects a token that only looks like a contract address", () => { + const r = validateWizardStep(0, validState({ token: "Cnotarealcontract" })); + expect(r.valid).toBe(false); + expect(r.fieldErrors.token).toBeDefined(); + }); + + it.each([0, -3, 366, 2.5, NaN])("rejects a deadline of %s days", (days) => { + const r = validateWizardStep(0, validState({ deadlineDays: days })); + expect(r.fieldErrors.deadlineDays).toBeDefined(); + }); + + it.each([1, 30, 365])("accepts a deadline of %s days", (days) => { + expect(validateWizardStep(0, validState({ deadlineDays: days })).valid).toBe(true); + }); + + it("uses the clone deadline instead of days when cloning", () => { + const state = validState({ isClone: true, deadlineDays: 0, cloneDeadlineError: "Deadline must be in the future" }); + const r = validateWizardStep(0, state); + expect(r.fieldErrors.deadlineDays).toBeUndefined(); + expect(r.fieldErrors.cloneDeadline).toBe("Deadline must be in the future"); + }); + }); + + describe("step 2: recipients", () => { + it("requires at least one recipient", () => { + const r = validateWizardStep(1, validState({ recipients: [{ address: "", amount: "" }] })); + expect(r.message).toBe("At least one recipient is required"); + }); + + it("rejects an invalid Stellar address and names the row", () => { + const r = validateWizardStep(1, validState({ + recipients: [{ address: ALICE, amount: "1" }, { address: "G".padEnd(56, "A"), amount: "1" }], + })); + expect(r.fieldErrors.recipients).toMatch(/Recipient 2/); + }); + + it("rejects the same wallet twice", () => { + const r = validateWizardStep(1, validState({ + recipients: [{ address: ALICE, amount: "1" }, { address: ALICE, amount: "2" }], + })); + expect(r.fieldErrors.recipients).toMatch(/more than once/); + }); + + it.each(["", "0", "-1", "abc"])("rejects an amount of %j", (amount) => { + const r = validateWizardStep(1, validState({ recipients: [{ address: ALICE, amount }] })); + expect(r.fieldErrors.recipients).toMatch(/greater than zero/); + }); + + it("in equal-split mode checks the total, not per-row amounts", () => { + const rows = [{ address: ALICE, amount: "" }, { address: BOB, amount: "" }]; + expect(validateWizardStep(1, validState({ equalSplit: true, totalAmount: "100", recipients: rows })).valid).toBe(true); + + const r = validateWizardStep(1, validState({ equalSplit: true, totalAmount: "", recipients: rows })); + expect(r.fieldErrors.totalAmount).toBeDefined(); + }); + + it("surfaces a split calculator error", () => { + const r = validateWizardStep(1, validState({ splitError: "Shares must add up to 100%" })); + expect(r.valid).toBe(false); + expect(r.message).toBe("Shares must add up to 100%"); + }); + }); + + describe("step 3: options", () => { + it("is valid with no co-signers", () => { + expect(validateWizardStep(2, validState()).valid).toBe(true); + }); + + it("rejects a threshold above the number of co-signers", () => { + const r = validateWizardStep(2, validState({ cosigners: [ALICE], cosignerThreshold: 2 })); + expect(r.fieldErrors.cosigners).toBeDefined(); + }); + }); + + it("firstInvalidStep points at the earliest failing step", () => { + expect(firstInvalidStep(validState({ token: "", recipients: [] }))).toBe(0); + expect(firstInvalidStep(validState({ recipients: [] }))).toBe(1); + expect(firstInvalidStep(validState({ cosigners: [ALICE], cosignerThreshold: 0 }))).toBe(2); + }); +}); diff --git a/src/app/invoice/new/page.tsx b/src/app/invoice/new/page.tsx index 064de160..5960f6c3 100644 --- a/src/app/invoice/new/page.tsx +++ b/src/app/invoice/new/page.tsx @@ -28,6 +28,7 @@ import { useOfflineDraftAutosave } from "@/hooks/useOfflineDraftAutosave"; import { getOrCreateLocalUserId, listDraftsForUser, + type DraftFormData, type StoredDraft, } from "@/lib/offlineDraftDB"; import SplitCalculator from "@/components/SplitCalculator"; @@ -42,6 +43,14 @@ import InstallmentPlanBuilder, { type InstallmentMilestone as PlanMilestone } fr import CoSignerSection from "@/components/invoice/CoSignerSection"; import AmountDenominationInput from "@/components/AmountDenominationInput"; import { useXlmUsdcRate } from "@/hooks/useXlmUsdcRate"; +import { + WIZARD_STEPS, + REVIEW_STEP, + FIELD_HELP, + validateWizardStep, + firstInvalidStep, + type WizardState, +} from "@/lib/invoiceWizard"; import { useInvoiceCollaboration } from "@/hooks/useInvoiceCollaboration"; import CursorOverlay from "@/components/CursorOverlay"; @@ -84,7 +93,7 @@ function useToasts() { return { toasts, addToast }; } -const STEPS = ["Basic Info", "Recipients", "Options", "Review & Submit"]; +const STEPS = WIZARD_STEPS.map((s) => s.label); /** Clamp an arbitrary `?step=` value onto a real step index. */ function parseStep(raw: string | null): number { @@ -222,38 +231,60 @@ function NewInvoiceForm() { } }, [recipients, token, deadlineDays, tags, hasUserInteracted]); - const draftSnapshot = { + const [equalSplit, setEqualSplit] = useState(false); + const [totalAmount, setTotalAmount] = useState(""); + + const draftSnapshot: DraftFormData = { recipients, token, deadlineDays, recurring, intervalDays, + step, + equalSplit, + totalAmount, + tags, splitMeta, installments, + cosigners, + cosignerThreshold, }; - const { isOffline: draftOffline, discardDraft } = useOfflineDraftAutosave( + // Only autosave once the user has actually typed something, so an + // untouched form never shows up as a "recovered draft" on the next visit. + const { isOffline: draftOffline, lastSavedAt, discardDraft } = useOfflineDraftAutosave( draftUserId ?? "", - formMode === "create" && !cloneSourceId ? draftId ?? "" : "", + formMode === "create" && !cloneSourceId && hasUserInteracted ? draftId ?? "" : "", draftSnapshot ); const handleRestoreDraft = () => { if (!recoveredDraft) return; - setRecipients(recoveredDraft.data.recipients); - setToken(recoveredDraft.data.token); - setDeadlineDays(recoveredDraft.data.deadlineDays); - setRecurring(recoveredDraft.data.recurring); - setIntervalDays(recoveredDraft.data.intervalDays); - if ((recoveredDraft.data as any).splitMeta) { - setSplitMeta((recoveredDraft.data as any).splitMeta); - } + const data = recoveredDraft.data; + setRecipients(data.recipients); + setToken(data.token); + setDeadlineDays(data.deadlineDays); + setRecurring(data.recurring); + setIntervalDays(data.intervalDays); + if (data.splitMeta) setSplitMeta(data.splitMeta); + if (data.equalSplit !== undefined) setEqualSplit(data.equalSplit); + if (data.totalAmount !== undefined) setTotalAmount(data.totalAmount); + if (data.tags) setTags(data.tags); + if (data.installments) setInstallments(data.installments); + if (data.cosigners) setCosigners(data.cosigners); + if (data.cosignerThreshold !== undefined) setCosignerThreshold(data.cosignerThreshold); if (draftUserId) { import("@/lib/offlineDraftDB").then(({ deleteDraft }) => deleteDraft(draftUserId, recoveredDraft.draftId) ); } setRecoveredDraft(null); + setHasUserInteracted(true); + // Resume where the user left off; the review step is excluded so a + // restored draft is always re-checked before it can be submitted. + if (data.step !== undefined && data.step > 0 && data.step < REVIEW_STEP) { + goToStep(data.step); + } addToast("Draft restored", "success"); }; @@ -305,35 +336,33 @@ function NewInvoiceForm() { setTouchedFields((prev) => new Set([...prev, fieldName])); }; - const validateField = (fieldName: string, value: unknown): string | null => { - if (fieldName === 'token') { - const tokenValue = value as string; - if (!tokenValue || !tokenValue.startsWith('C')) { - return 'Valid token contract address (starting with C) is required'; - } - } else if (fieldName === 'deadlineDays') { - const days = value as number; - if (days < 1 || days > 365) { - return 'Deadline must be between 1 and 365 days'; - } - } else if (fieldName === 'cloneDeadline') { - const deadlineError = validateDeadline(value as string); - return deadlineError; + /** + * Validate one field against the wizard rules, using `patch` for the value + * that just changed (state updates are not visible until the next render). + */ + const validateField = (fieldName: string, patch: Partial): string | null => { + const state = { ...wizardState, ...patch }; + if (patch.cloneDeadlineError === undefined && fieldName === "cloneDeadline") { + state.cloneDeadlineError = validateDeadline(cloneDeadlineIso); + } + for (let s = 0; s < REVIEW_STEP; s++) { + const error = validateWizardStep(s, state).fieldErrors[fieldName]; + if (error) return error; } return null; }; - const handleFieldBlur = (fieldName: string, value: unknown) => { + const handleFieldBlur = (fieldName: string, patch: Partial = {}) => { markFieldTouched(fieldName); setHasUserInteracted(true); - const error = validateField(fieldName, value); + const error = validateField(fieldName, patch); setFieldErrors((prev) => ({ ...prev, [fieldName]: error })); }; - const handleFieldChange = (fieldName: string, value: unknown) => { + const handleFieldChange = (fieldName: string, patch: Partial) => { setHasUserInteracted(true); if (touchedFields.has(fieldName)) { - const error = validateField(fieldName, value); + const error = validateField(fieldName, patch); setFieldErrors((prev) => ({ ...prev, [fieldName]: error })); } }; @@ -413,8 +442,6 @@ function NewInvoiceForm() { const [publicKey, setPublicKey] = useState(null); const [error, setError] = useState(null); const [txModal, setTxModal] = useState<{ txHash: string; invoiceId: string } | null>(null); - const [equalSplit, setEqualSplit] = useState(false); - const [totalAmount, setTotalAmount] = useState(""); const [loading, setLoading] = useState(false); const [autofilled, setAutofilled] = useState(false); const [stepErrors, setStepErrors] = useState>({}); @@ -534,62 +561,6 @@ function NewInvoiceForm() { const tokenChanged = !!originalToken && token !== originalToken; - const validateStep = (s: number): boolean => { - switch (s) { - case 0: { - if (!token || !token.startsWith("C")) { - setStepErrors((prev) => ({ ...prev, [s]: "Valid token contract address is required" })); - return false; - } - if (cloneSourceId && cloneDeadlineIso) { - const err = validateDeadline(cloneDeadlineIso); - if (err) { - setDeadlineError(err); - setStepErrors((prev) => ({ ...prev, [s]: err })); - return false; - } - } - setStepErrors((prev) => ({ ...prev, [s]: null })); - return true; - } - case 1: { - if (recipients.length === 0 || !recipients[0].address) { - setStepErrors((prev) => ({ ...prev, [s]: "At least one recipient is required" })); - return false; - } - const validRecipients = recipients.every( - (r) => r.address.startsWith("G") && r.address.length >= 50 && parseFloat(r.amount || "0") > 0 - ); - if (!validRecipients) { - setStepErrors((prev) => ({ ...prev, [s]: "Each recipient needs a valid G... address and positive amount" })); - return false; - } - if (splitMeta && splitMeta.recipients.length > 0) { - const splitResult = calculateSplit(splitMeta.totalAmount, splitMeta.recipients, splitMeta.assetCode); - if (!splitResult.validation.isValid) { - setStepErrors((prev) => ({ - ...prev, - [s]: splitResult.validation.errorMessage ?? "Split calculator has invalid configuration", - })); - return false; - } - } - setStepErrors((prev) => ({ ...prev, [s]: null })); - return true; - } - case 2: { - if (deadlineDays && Number(deadlineDays) <= 0) { - setStepErrors((prev) => ({ ...prev, [s]: "Deadline must be a positive number of days" })); - return false; - } - setStepErrors((prev) => ({ ...prev, [s]: null })); - return true; - } - default: - return true; - } - }; - /** * Reason the split is not submittable, or null when it is. Drives both the * disabled state and the tooltip on the create button so the user can see @@ -605,6 +576,37 @@ function NewInvoiceForm() { return validation.isValid ? null : validation.errorMessage ?? "Split configuration is invalid"; }, [splitMeta]); + const wizardState: WizardState = { + token, + deadlineDays, + isClone: !!cloneSourceId, + cloneDeadlineError: cloneSourceId ? validateDeadline(cloneDeadlineIso) : null, + recurring, + intervalDays, + recipients, + equalSplit, + totalAmount, + splitError: splitBlockReason, + cosigners, + cosignerThreshold, + }; + + /** + * Validate step `s`, surfacing the summary above the step and the + * per-field messages inline. Returns whether the step is valid. + */ + const validateStep = (s: number): boolean => { + const { valid, message, fieldErrors: errors } = validateWizardStep(s, wizardState); + setStepErrors((prev) => ({ ...prev, [s]: message })); + const fields = Object.keys(errors); + if (fields.length > 0) { + setTouchedFields((prev) => new Set([...prev, ...fields])); + setFieldErrors((prev) => ({ ...prev, ...errors })); + } + if (s === 0 && errors.cloneDeadline) setDeadlineError(errors.cloneDeadline); + return valid; + }; + const handleNext = () => { if (validateStep(step)) { goToStep(Math.min(step + 1, STEPS.length - 1)); @@ -652,7 +654,20 @@ function NewInvoiceForm() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!validateStep(step)) return; + // Pressing Enter inside a field on an earlier step should advance the + // wizard, never create the invoice. + if (step !== REVIEW_STEP) { + handleNext(); + return; + } + // Re-check every step: a deep link to ?step=4 or a restored draft must + // not be able to skip validation on earlier steps. + const invalid = firstInvalidStep(wizardState); + if (invalid !== -1) { + validateStep(invalid); + goToStep(invalid); + return; + } setError(null); if (cloneSourceId && cloneDeadlineIso) { @@ -760,13 +775,61 @@ function NewInvoiceForm() { goToStep(targetStep); }; + const progressPercent = Math.round(((step + 1) / STEPS.length) * 100); + const renderStepIndicator = () => ( - +
+ +
+
+
+
+ + Step {step + 1} of {STEPS.length} + + {lastSavedAt && !cloneSourceId && ( + + Draft saved {new Date(lastSavedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + + )} +
+
+ ); + + /** Small "Edit" link on the review step that jumps back to step `s`. */ + const renderEditButton = (s: number) => ( + + ); + + /** Heading plus one-line explanation of what the current step is for. */ + const renderStepHeader = (s: number) => ( +
+

{WIZARD_STEPS[s].label}

+

{WIZARD_STEPS[s].description}

+
); const renderBasicInfo = () => (
-

Basic Info

+ {renderStepHeader(0)} {!cloneSourceId && ( setToken(e.target.value)} + onChange={(e) => { + setToken(e.target.value); + handleFieldChange('token', { token: e.target.value }); + }} onFocus={() => setFocusedField("token-address")} onBlur={() => { + handleFieldBlur('token'); if (focusedField === "token-address") emitFieldBlur(); }} required @@ -809,6 +877,7 @@ function NewInvoiceForm() { id="clone-deadline" label={t("invoiceNew.deadline")} error={touchedFields.has('cloneDeadline') ? fieldErrors['cloneDeadline'] : null} + helperText={FIELD_HELP.cloneDeadline} required > { setCloneDeadlineIso(e.target.value); - handleFieldChange('cloneDeadline', e.target.value); + handleFieldChange('cloneDeadline', { cloneDeadlineError: validateDeadline(e.target.value) }); }} - onBlur={(e) => handleFieldBlur('cloneDeadline', e.target.value)} + onBlur={(e) => handleFieldBlur('cloneDeadline', { cloneDeadlineError: validateDeadline(e.target.value) })} className={`w-full min-h-11 bg-gray-800 border rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${ touchedFields.has('cloneDeadline') && fieldErrors['cloneDeadline'] ? 'border-red-500' @@ -831,6 +900,7 @@ function NewInvoiceForm() { id="deadline-days" label={t("invoiceNew.deadline")} error={touchedFields.has('deadlineDays') ? fieldErrors['deadlineDays'] : null} + helperText={FIELD_HELP.deadlineDays} required > setDeadlineDays(Number(e.target.value))} + onChange={(e) => { + setDeadlineDays(Number(e.target.value)); + handleFieldChange('deadlineDays', { deadlineDays: Number(e.target.value) }); + }} onFocus={() => setFocusedField("deadline-days")} onBlur={() => { + handleFieldBlur('deadlineDays'); if (focusedField === "deadline-days") emitFieldBlur(); }} required - className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500" + className={`w-full min-h-11 bg-gray-800 border rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${ + touchedFields.has('deadlineDays') && fieldErrors['deadlineDays'] + ? 'border-red-500' + : 'border-gray-700' + }`} /> - sum + parseFloat(r.amount || "0"), 0) - .toString() - } - recipientCount={recipients.filter((r) => r.address).length} - onUseSuggestion={(days: number) => setDeadlineDays(days)} - /> )} @@ -876,15 +943,21 @@ function NewInvoiceForm() { )} {!cloneSourceId && ( -
- +
+
+ +

+ {FIELD_HELP.recurring} +

+
)}
@@ -920,20 +997,26 @@ function NewInvoiceForm() { const renderRecipients = () => (
-

Recipients

+ {renderStepHeader(1)} {!cloneSourceId && ( <>
- +
+ +

+ {FIELD_HELP.equalSplit} +

+