From 994dc9e27e05122fcd0a74e7fdd8c15b6448e4c4 Mon Sep 17 00:00:00 2001 From: Nonso Bethel Date: Thu, 20 Aug 2026 12:46:43 +0100 Subject: [PATCH 01/11] feat(payroll): add page scaffold with types, CSS modules, and multi-step shell Set up the /payroll route with TypeScript types for payroll state, recipient parsing utilities, CSS module matching existing design tokens, and a basic page component with step progress indicator. --- src/app/payroll/page.tsx | 51 ++++++++++ src/app/payroll/payroll.module.css | 149 +++++++++++++++++++++++++++++ src/app/payroll/types.ts | 44 +++++++++ 3 files changed, 244 insertions(+) create mode 100644 src/app/payroll/page.tsx create mode 100644 src/app/payroll/payroll.module.css create mode 100644 src/app/payroll/types.ts diff --git a/src/app/payroll/page.tsx b/src/app/payroll/page.tsx new file mode 100644 index 0000000..e8dd534 --- /dev/null +++ b/src/app/payroll/page.tsx @@ -0,0 +1,51 @@ +'use client'; +import { useState } from 'react'; +import { useWallet } from '@/context/WalletContext'; +import type { PayrollState, RecipientResult } from './types'; +import styles from './payroll.module.css'; + +const STEPS = ['Mode', 'Recipients', 'Details', 'Review', 'Results']; + +const INITIAL: PayrollState = { + mode: null, + recipients: [], + token: 'native', + ratePerDay: '', + startDate: '', + stopDate: '', +}; + +export default function PayrollPage() { + const { address } = useWallet(); + const [step, setStep] = useState(0); + const [state, setState] = useState(INITIAL); + const [results, setResults] = useState([]); + + if (!address) { + return ( +
+

Connect your wallet to use batch payroll.

+
+ ); + } + + return ( +
+

Batch Payroll

+ +
+ {STEPS.map((_, i) => ( +
+ ))} +
+

Step {step + 1} of {STEPS.length}: {STEPS[step]}

+ + {/* Steps will be rendered here */} +
+ ); +} diff --git a/src/app/payroll/payroll.module.css b/src/app/payroll/payroll.module.css new file mode 100644 index 0000000..9f256eb --- /dev/null +++ b/src/app/payroll/payroll.module.css @@ -0,0 +1,149 @@ +.page { max-width: 640px; margin: 2rem auto; padding: 0 1.5rem; } +.title { font-size: 1.75rem; font-weight: 700; margin-bottom: 1.5rem; } + +/* Progress bar */ +.progress { display: flex; gap: 0.5rem; margin-bottom: 2rem; } +.progressStep { + flex: 1; height: 4px; border-radius: 2px; + background: var(--surface-2); transition: background 0.3s; +} +.progressStepDone { background: var(--accent); } +.progressStepActive { background: var(--accent); opacity: 0.6; } + +/* Step labels */ +.stepLabel { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1rem; } + +/* Mode cards */ +.modeGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem; } +.modeCard { + background: var(--surface); border: 2px solid var(--border); + border-radius: var(--radius); padding: 1.5rem; cursor: pointer; + transition: border-color 0.2s, background 0.2s; text-align: center; +} +.modeCard:hover { border-color: var(--accent); background: var(--surface-2); } +.modeCardSelected { border-color: var(--accent); background: var(--surface-2); } +.modeCard h3 { font-size: 1rem; margin-bottom: 0.4rem; } +.modeCard p { font-size: 0.8rem; color: var(--text-muted); } + +/* Form fields */ +.form { display: flex; flex-direction: column; gap: 1.25rem; } +.field { display: flex; flex-direction: column; gap: 0.4rem; font-size: 0.9rem; } +.field span { color: var(--text-muted); font-weight: 500; } + +.input, .inputError { + background: var(--surface); border: 1px solid var(--border); + color: var(--text); padding: 0.6rem 0.85rem; border-radius: var(--radius); + font-size: 0.95rem; width: 100%; +} +.input:focus, .inputError:focus { outline: none; border-color: var(--accent); } +.inputError { border-color: var(--danger); } +.error { color: var(--danger); font-size: 0.8rem; } + +textarea.input { min-height: 100px; resize: vertical; font-family: monospace; font-size: 0.85rem; } + +.dateRow { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } + +/* Recipient list */ +.recipientList { display: flex; flex-direction: column; gap: 0.5rem; } +.recipientRow { + display: flex; gap: 0.5rem; align-items: center; + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 0.5rem 0.75rem; +} +.recipientRow .input { flex: 1; border: none; background: transparent; padding: 0.3rem 0; } +.recipientRow .input:focus { outline: none; } +.recipientAddr { font-family: monospace; font-size: 0.8rem; color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; } +.recipientAmt { width: 100px; } +.removeBtn { + background: none; border: none; color: var(--danger); + font-size: 1.1rem; padding: 0.2rem 0.4rem; cursor: pointer; +} +.addBtn { + background: var(--surface); border: 1px dashed var(--border); + color: var(--text-muted); padding: 0.6rem; border-radius: var(--radius); + cursor: pointer; font-size: 0.85rem; transition: border-color 0.2s; +} +.addBtn:hover { border-color: var(--accent); color: var(--text); } + +/* Tabs for input method */ +.tabs { display: flex; gap: 0; margin-bottom: 1rem; } +.tab { + flex: 1; padding: 0.5rem; text-align: center; font-size: 0.85rem; + background: var(--surface); border: 1px solid var(--border); + cursor: pointer; color: var(--text-muted); transition: all 0.2s; +} +.tab:first-child { border-radius: var(--radius) 0 0 var(--radius); } +.tab:last-child { border-radius: 0 var(--radius) var(--radius) 0; } +.tabActive { background: var(--accent); color: #fff; border-color: var(--accent); } + +/* File upload */ +.uploadZone { + border: 2px dashed var(--border); border-radius: var(--radius); + padding: 2rem; text-align: center; cursor: pointer; + transition: border-color 0.2s; color: var(--text-muted); +} +.uploadZone:hover { border-color: var(--accent); } +.uploadZone input { display: none; } + +/* Review table */ +.reviewTable { width: 100%; border-collapse: collapse; font-size: 0.85rem; } +.reviewTable th { + text-align: left; padding: 0.5rem; color: var(--text-muted); + border-bottom: 1px solid var(--border); font-weight: 500; +} +.reviewTable td { + padding: 0.5rem; border-bottom: 1px solid var(--border); + font-family: monospace; font-size: 0.8rem; +} +.reviewTotal { + display: flex; justify-content: space-between; align-items: center; + background: var(--surface); border-radius: var(--radius); + padding: 1rem; margin-bottom: 1.5rem; font-weight: 600; +} +.reviewTotal span:last-child { color: var(--accent); font-size: 1.1rem; } + +/* Results */ +.resultRow { + display: flex; align-items: center; gap: 0.75rem; + padding: 0.75rem; border-radius: var(--radius); + background: var(--surface); margin-bottom: 0.5rem; +} +.resultIcon { font-size: 1.2rem; } +.resultSuccess .resultIcon { color: var(--success); } +.resultFail .resultIcon { color: var(--danger); } +.resultAddr { font-family: monospace; font-size: 0.8rem; flex: 1; overflow: hidden; text-overflow: ellipsis; } +.resultError { font-size: 0.75rem; color: var(--danger); } + +/* Buttons */ +.btnRow { display: flex; gap: 0.75rem; margin-top: 1rem; } +.submit, .nextBtn { + background: var(--accent); color: #fff; border: none; + padding: 0.75rem 1.5rem; border-radius: var(--radius); + font-weight: 600; font-size: 0.95rem; cursor: pointer; + transition: background 0.2s; +} +.submit:hover:not(:disabled), .nextBtn:hover:not(:disabled) { background: var(--accent-hover); } +.submit:disabled, .nextBtn:disabled { opacity: 0.6; cursor: not-allowed; } +.backBtn { + background: var(--surface); color: var(--text); border: 1px solid var(--border); + padding: 0.75rem 1.5rem; border-radius: var(--radius); + font-weight: 600; font-size: 0.95rem; cursor: pointer; + transition: background 0.2s; +} +.backBtn:hover { background: var(--surface-2); } + +/* Gated / success */ +.gated, .success { text-align: center; margin-top: 6rem; color: var(--text-muted); } +.checkmark { display: block; font-size: 3rem; color: var(--success); margin-bottom: 1rem; } +.success h2 { color: var(--text); margin-bottom: 1rem; } +.dashLink { + background: var(--accent); color: #fff; padding: 0.6rem 1.5rem; + border-radius: var(--radius); font-weight: 600; display: inline-block; +} + +/* Responsive */ +@media (max-width: 600px) { + .modeGrid { grid-template-columns: 1fr; } + .dateRow { grid-template-columns: 1fr; } + .recipientRow { flex-wrap: wrap; } +} diff --git a/src/app/payroll/types.ts b/src/app/payroll/types.ts new file mode 100644 index 0000000..c7f3032 --- /dev/null +++ b/src/app/payroll/types.ts @@ -0,0 +1,44 @@ +export type PayrollMode = 'uniform' | 'custom'; + +export interface Recipient { + address: string; + amount: string; // per-person amount (custom mode) or empty (uniform mode) +} + +export interface PayrollState { + mode: PayrollMode | null; + recipients: Recipient[]; + token: string; + ratePerDay: string; // uniform mode: rate per day for everyone + startDate: string; + stopDate: string; +} + +export interface RecipientResult { + address: string; + success: boolean; + error?: string; + txHash?: string; +} + +export const ADDRESS_RE = /^G[A-Z2-7]{55}$/; + +export function parseCsvAddresses(text: string): string[] { + return text + .split(/[\n,]+/) + .map(line => line.trim()) + .filter(line => ADDRESS_RE.test(line)); +} + +export function parseCsvFile(content: string): Recipient[] { + const lines = content.split('\n').filter(l => l.trim()); + const recipients: Recipient[] = []; + for (const line of lines) { + const parts = line.split(',').map(p => p.trim()); + const addr = parts[0]; + if (ADDRESS_RE.test(addr)) { + recipients.push({ address: addr, amount: parts[1] ?? '' }); + } + } + return recipients; +} From 9a57062ceb59ea964d41dbce343e1c7aa3642b62 Mon Sep 17 00:00:00 2001 From: Nonso Bethel Date: Thu, 20 Aug 2026 12:47:55 +0100 Subject: [PATCH 02/11] feat(payroll): build all step components and wire multi-step flow Add ModeStep (uniform vs custom), RecipientsStep (manual/paste/upload), DetailsStep (token, rate, dates), ReviewStep (cost breakdown table), and ResultsStep (success/fail per recipient). Wire into the main page with back/next navigation, validation, and simulated submission. --- src/app/payroll/DetailsStep.tsx | 66 ++++++++++++++ src/app/payroll/ModeStep.tsx | 33 +++++++ src/app/payroll/RecipientsStep.tsx | 138 +++++++++++++++++++++++++++++ src/app/payroll/ResultsStep.tsx | 40 +++++++++ src/app/payroll/ReviewStep.tsx | 61 +++++++++++++ src/app/payroll/page.tsx | 109 ++++++++++++++++++++++- 6 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 src/app/payroll/DetailsStep.tsx create mode 100644 src/app/payroll/ModeStep.tsx create mode 100644 src/app/payroll/RecipientsStep.tsx create mode 100644 src/app/payroll/ResultsStep.tsx create mode 100644 src/app/payroll/ReviewStep.tsx diff --git a/src/app/payroll/DetailsStep.tsx b/src/app/payroll/DetailsStep.tsx new file mode 100644 index 0000000..ad876b9 --- /dev/null +++ b/src/app/payroll/DetailsStep.tsx @@ -0,0 +1,66 @@ +'use client'; +import type { PayrollMode } from './types'; +import styles from './payroll.module.css'; + +interface Props { + mode: PayrollMode; + token: string; + ratePerDay: string; + startDate: string; + stopDate: string; + errors: Partial>; + onChange: (field: string, value: string) => void; +} + +export function DetailsStep({ mode, token, ratePerDay, startDate, stopDate, errors, onChange }: Props) { + return ( +
+ + + {mode === 'uniform' && ( + + )} + +
+ + +
+
+ ); +} diff --git a/src/app/payroll/ModeStep.tsx b/src/app/payroll/ModeStep.tsx new file mode 100644 index 0000000..a89a40f --- /dev/null +++ b/src/app/payroll/ModeStep.tsx @@ -0,0 +1,33 @@ +'use client'; +import type { PayrollMode } from './types'; +import styles from './payroll.module.css'; + +interface Props { + selected: PayrollMode | null; + onSelect: (mode: PayrollMode) => void; +} + +export function ModeStep({ selected, onSelect }: Props) { + return ( +
+
+ + +
+
+ ); +} diff --git a/src/app/payroll/RecipientsStep.tsx b/src/app/payroll/RecipientsStep.tsx new file mode 100644 index 0000000..d7b3fc9 --- /dev/null +++ b/src/app/payroll/RecipientsStep.tsx @@ -0,0 +1,138 @@ +'use client'; +import { useState, useRef } from 'react'; +import type { Recipient, PayrollMode } from './types'; +import { ADDRESS_RE, parseCsvAddresses, parseCsvFile } from './types'; +import styles from './payroll.module.css'; + +type InputTab = 'manual' | 'paste' | 'upload'; + +interface Props { + mode: PayrollMode; + recipients: Recipient[]; + onChange: (recipients: Recipient[]) => void; +} + +export function RecipientsStep({ mode, recipients, onChange }: Props) { + const [tab, setTab] = useState('manual'); + const [manualAddr, setManualAddr] = useState(''); + const [pasteText, setPasteText] = useState(''); + const [pasteError, setPasteError] = useState(''); + const fileRef = useRef(null); + + function addManual() { + if (!ADDRESS_RE.test(manualAddr)) return; + if (recipients.some(r => r.address === manualAddr)) return; + onChange([...recipients, { address: manualAddr, amount: '' }]); + setManualAddr(''); + } + + function handlePaste() { + const addrs = parseCsvAddresses(pasteText); + if (addrs.length === 0) { + setPasteError('No valid Stellar addresses found'); + return; + } + setPasteError(''); + const existing = new Set(recipients.map(r => r.address)); + const newRecipients = addrs + .filter(a => !existing.has(a)) + .map(address => ({ address, amount: '' })); + onChange([...recipients, ...newRecipients]); + setPasteText(''); + } + + function handleFileUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const content = reader.result as string; + const parsed = parseCsvFile(content); + if (parsed.length === 0) return; + const existing = new Set(recipients.map(r => r.address)); + const newRecipients = parsed.filter(r => !existing.has(r.address)); + onChange([...recipients, ...newRecipients]); + }; + reader.readAsText(file); + if (fileRef.current) fileRef.current.value = ''; + } + + function removeRecipient(addr: string) { + onChange(recipients.filter(r => r.address !== addr)); + } + + function updateAmount(addr: string, amount: string) { + onChange(recipients.map(r => r.address === addr ? { ...r, amount } : r)); + } + + return ( +
+
+ + + +
+ + {tab === 'manual' && ( +
+
+ setManualAddr(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addManual(); } }} + /> + +
+
+ )} + + {tab === 'paste' && ( +
+