Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 176 additions & 27 deletions app/split/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"use client";

import { useMemo, useState } from "react";
import Link from "next/link";
import { Save, ShieldCheck, Wallet, Clock3, Layers3 } from "lucide-react";
import { Loader2, Save, ShieldCheck, Wallet, Clock3, Layers3 } from "lucide-react";
import SmartMoneySplitHeader from "@/components/SmartMoneySplitHeader";
import HowItWorks from "@/components/HowItWorksModal";
import AsyncOperationsPanel from "@/components/AsyncOperationsPanel";
import AsyncSubmissionStatus from "@/components/AsyncSubmissionStatus";
import { DEFAULT_SPLIT_CONFIG, type SplitConfig } from "@/lib/remittance/split";
import { validatePercentages } from "@/lib/validation/percentages";

const splitStages = [
{
Expand Down Expand Up @@ -66,7 +69,53 @@ const splitQueue = [
},
];

type AllocationKey = keyof SplitConfig;

function computeTotal(alloc: SplitConfig): number {
return alloc.spending + alloc.savings + alloc.bills + alloc.insurance;
}

export default function SplitConfiguration() {
const [allocation, setAllocation] = useState<SplitConfig>(DEFAULT_SPLIT_CONFIG);
const [pending, setPending] = useState(false);
const [submissionError, setSubmissionError] = useState<string | undefined>();
const [submissionSuccess, setSubmissionSuccess] = useState<string | undefined>();

const validationResult = useMemo(() => {
try {
validatePercentages(allocation);
return { valid: true, message: undefined };
} catch (e) {
return {
valid: false,
message: e instanceof Error ? e.message : "Allocation must sum to 100%",
};
}
}, [allocation]);

const total = computeTotal(allocation);
const isValid = validationResult.valid;

const handleChange = (key: AllocationKey, value: number) => {
setAllocation((prev) => ({ ...prev, [key]: value }));
setSubmissionError(undefined);
setSubmissionSuccess(undefined);
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!isValid || pending) return;

setPending(true);
setSubmissionError(undefined);
setSubmissionSuccess(undefined);

// No contract submission in this phase — simulate async round-trip
await new Promise((resolve) => setTimeout(resolve, 800));
setPending(false);
setSubmissionSuccess("Split configuration saved. Changes will apply to your next remittance.");
};

return (
<div className='min-h-screen bg-[#010101] safari-safe-bottom'>
<SmartMoneySplitHeader />
Expand All @@ -92,51 +141,99 @@ export default function SplitConfiguration() {
Allocation changes are saved as a USDC smart contract action. The payload is prepared in-app and the wallet signs it locally.
</p>

<form className='mt-6 space-y-5 375:space-y-6'>
<form
className='mt-6 space-y-5 375:space-y-6'
onSubmit={handleSubmit}
noValidate
aria-label='Smart money split configuration'
>
<SplitInput
label='Daily Spending'
description='For immediate family expenses'
value={50}
value={allocation.spending}
color='bg-blue-500'
onChange={(v) => handleChange("spending", v)}
/>
<SplitInput
label='Savings'
description='Allocated to savings goals'
value={30}
value={allocation.savings}
color='bg-green-500'
onChange={(v) => handleChange("savings", v)}
/>
<SplitInput
label='Bills'
description='Automated bill payments'
value={15}
value={allocation.bills}
color='bg-yellow-500'
onChange={(v) => handleChange("bills", v)}
/>
<SplitInput
label='Insurance'
description='Micro-insurance premiums'
value={5}
value={allocation.insurance}
color='bg-violet-500'
onChange={(v) => handleChange("insurance", v)}
/>

<div className='rounded-2xl border border-white/[0.08] bg-[#141414] p-4 375:p-5'>
{/* Live total with inline validation */}
<div
className={`rounded-2xl border p-4 375:p-5 transition-colors duration-200 ${
isValid
? "border-emerald-500/30 bg-emerald-500/[0.06]"
: total > 100
? "border-red-500/30 bg-red-500/[0.06]"
: "border-white/[0.08] bg-[#141414]"
}`}
aria-live='polite'
aria-atomic='true'
>
<div className='flex items-center justify-between gap-4'>
<div>
<div className='min-w-0'>
<p className='text-sm font-medium text-gray-300'>Total</p>
<p className='mt-1 text-xs 375:text-sm text-gray-500'>
Every contract build should block submission until this is
exactly 100%.
</p>
{isValid ? (
<p className='mt-1 text-xs 375:text-sm text-emerald-400'>
Ready to submit
</p>
) : (
<p
className='mt-1 text-xs 375:text-sm text-amber-400'
role='alert'
>
{validationResult.message}
</p>
)}
</div>
<span className='text-2xl 375:text-3xl font-semibold text-white'>100%</span>
<span
className={`flex-shrink-0 text-2xl 375:text-3xl font-semibold tabular-nums transition-colors duration-200 ${
isValid
? "text-emerald-400"
: total > 100
? "text-red-400"
: "text-white"
}`}
aria-label={`Total allocation: ${total} percent`}
>
{total}%
</span>
</div>
</div>

<AsyncSubmissionStatus
pending={false}
idleTitle='Contract submission placement'
idleDescription='Keep validation and build feedback inline with the form. Reserve stacked status cards for submit and confirmation so they can persist after navigation.'
pending={pending}
error={submissionError}
success={submissionSuccess}
idleTitle={isValid ? "Ready to save" : "Adjust your allocation"}
idleDescription={
isValid
? "All percentages sum to 100%. Click Save Allocation to commit this configuration."
: "Keep the percentage check inline with the sliders so errors resolve before a contract build starts."
}
pendingTitle='Building contract request'
pendingDescription='Show the user that the remittance_split payload is being prepared before the wallet step opens.'
pendingDescription='The remittance_split payload is being prepared before the wallet step opens.'
successTitle='Configuration saved'
successDescription={submissionSuccess}
errorTitle='Submission failed'
/>

<HowItWorks />
Expand All @@ -150,9 +247,20 @@ export default function SplitConfiguration() {
<button
type='submit'
className='touch-target-wide flex flex-1 items-center justify-center gap-2 rounded-2xl bg-gradient-to-b from-red-600 to-red-700 px-6 py-3.5 text-sm 375:text-base font-semibold text-white transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-2 focus-visible:ring-offset-[#101010] disabled:cursor-not-allowed disabled:opacity-60'
disabled>
<Save className='h-5 w-5' />
<span>Connect Contract First</span>
disabled={!isValid || pending}
aria-disabled={!isValid || pending}
>
{pending ? (
<>
<Loader2 className='h-5 w-5 animate-spin' aria-hidden='true' />
<span>Saving…</span>
</>
) : (
<>
<Save className='h-5 w-5' aria-hidden='true' />
<span>Save Allocation</span>
</>
)}
</button>
</div>
</form>
Expand Down Expand Up @@ -181,33 +289,74 @@ function SplitInput({
description,
value,
color,
onChange,
}: {
label: string;
description: string;
value: number;
color: string;
onChange: (value: number) => void;
}) {
const handleSlider = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(Number(e.target.value));
};

const handleNumber = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = parseInt(e.target.value, 10);
if (!Number.isFinite(raw)) return;
onChange(Math.min(100, Math.max(0, raw)));
};

const inputId = `split-${label.toLowerCase().replace(/\s+/g, "-")}`;

return (
<div className='rounded-2xl border border-white/[0.08] bg-black/20 p-4 375:p-5 transition-colors hover:bg-white/[0.02]'>
<div className='mb-3 flex items-center justify-between gap-4'>
<div>
<label className='block text-sm 375:text-base font-medium text-white'>{label}</label>
<div className='min-w-0'>
<label
htmlFor={inputId}
className='block text-sm 375:text-base font-medium text-white'
>
{label}
</label>
<p className='mt-0.5 text-xs 375:text-sm text-gray-500'>{description}</p>
</div>
<div className='text-xl 375:text-2xl font-semibold text-white'>{value}%</div>
<div className='flex flex-shrink-0 items-center gap-0.5'>
<input
type='number'
min='0'
max='100'
step='1'
value={value}
onChange={handleNumber}
className='w-14 rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-right text-xl 375:text-2xl font-semibold text-white focus:outline-none focus:ring-2 focus:ring-red-500 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
aria-label={`${label} percentage`}
/>
<span className='text-xl 375:text-2xl font-semibold text-white' aria-hidden='true'>
%
</span>
</div>
</div>
<div className='mb-4 h-2 w-full rounded-full bg-white/10'>
<div className='mb-4 h-2 w-full rounded-full bg-white/10' aria-hidden='true'>
<div
className={`${color} h-2 rounded-full shadow-[0_0_10px_rgba(0,0,0,0.5)]`}
style={{ width: `${value}%` }}></div>
className={`${color} h-2 rounded-full shadow-[0_0_10px_rgba(0,0,0,0.5)] transition-all duration-150`}
style={{ width: `${value}%` }}
/>
</div>
<input
id={inputId}
type='range'
min='0'
max='100'
step='1'
value={value}
onChange={handleSlider}
className='h-11 w-full accent-red-600 touch-target'
disabled
aria-label={`${label} slider`}
aria-valuetext={`${value} percent`}
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
);
Expand Down
Loading