Skip to content

Commit 69fd5a9

Browse files
Email validation, rate-limit feedback, rounding handler, and rich text editor (#540)
* feat: recipient email validation with MX checking (#498) * feat: invoice rate-limit feedback UI with countdown timer (#499) * feat: split amount rounding discrepancy handler and warning (#500) * feat: rich text notes editor for invoice description (#501) --------- Co-authored-by: Emmanuel Chukwunyere <emmanuelanalaba@gmail.com>
1 parent c953187 commit 69fd5a9

12 files changed

Lines changed: 679 additions & 62 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { resolveRounding } from '@/hooks/useSplitCalculator';
2+
3+
describe('resolveRounding', () => {
4+
const STROOP_SCALE = 1e7;
5+
6+
it('should resolve rounding for 3-recipient split', () => {
7+
const percentages = [33.33, 33.33, 33.34];
8+
const totalAmount = 100;
9+
10+
const result = resolveRounding(percentages, totalAmount);
11+
12+
expect(result.amounts).toHaveLength(3);
13+
const sumStroops = result.amounts.reduce(
14+
(s, a) => s + Math.round(a * STROOP_SCALE),
15+
0
16+
);
17+
const totalStroops = Math.round(totalAmount * STROOP_SCALE);
18+
expect(sumStroops).toBe(totalStroops);
19+
});
20+
21+
it('should resolve rounding for 7-recipient split', () => {
22+
const percentages = [14.28, 14.28, 14.28, 14.28, 14.28, 14.29, 14.31];
23+
const totalAmount = 1000;
24+
25+
const result = resolveRounding(percentages, totalAmount);
26+
27+
expect(result.amounts).toHaveLength(7);
28+
const sumStroops = result.amounts.reduce(
29+
(s, a) => s + Math.round(a * STROOP_SCALE),
30+
0
31+
);
32+
const totalStroops = Math.round(totalAmount * STROOP_SCALE);
33+
expect(sumStroops).toBe(totalStroops);
34+
});
35+
36+
it('should resolve rounding for 11-recipient split', () => {
37+
const percentages = Array(11)
38+
.fill(0)
39+
.map((_, i) => (i === 10 ? 9.1 : 9.09));
40+
const totalAmount = 5000;
41+
42+
const result = resolveRounding(percentages, totalAmount);
43+
44+
expect(result.amounts).toHaveLength(11);
45+
const sumStroops = result.amounts.reduce(
46+
(s, a) => s + Math.round(a * STROOP_SCALE),
47+
0
48+
);
49+
const totalStroops = Math.round(totalAmount * STROOP_SCALE);
50+
expect(sumStroops).toBe(totalStroops);
51+
});
52+
53+
it('should assign adjustment to first recipient', () => {
54+
const percentages = [50, 50];
55+
const totalAmount = 100;
56+
57+
const result = resolveRounding(percentages, totalAmount);
58+
59+
expect(result.recipientIndex).toBe(0);
60+
});
61+
62+
it('should return zero adjustment when no rounding needed', () => {
63+
const percentages = [25, 25, 25, 25];
64+
const totalAmount = 100;
65+
66+
const result = resolveRounding(percentages, totalAmount);
67+
68+
const sumStroops = result.amounts.reduce(
69+
(s, a) => s + Math.round(a * STROOP_SCALE),
70+
0
71+
);
72+
const totalStroops = Math.round(totalAmount * STROOP_SCALE);
73+
expect(sumStroops).toBe(totalStroops);
74+
});
75+
76+
it('should handle fractional stroop amounts', () => {
77+
const percentages = [33.333, 33.333, 33.334];
78+
const totalAmount = 123.456789;
79+
80+
const result = resolveRounding(percentages, totalAmount);
81+
82+
const sumStroops = result.amounts.reduce(
83+
(s, a) => s + Math.round(a * STROOP_SCALE),
84+
0
85+
);
86+
const totalStroops = Math.round(totalAmount * STROOP_SCALE);
87+
expect(sumStroops).toBe(totalStroops);
88+
});
89+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { NextResponse } from "next/server";
2+
import { promises as dns } from "dns";
3+
4+
export async function POST(request: Request) {
5+
try {
6+
const { email } = await request.json();
7+
8+
if (!email || typeof email !== "string") {
9+
return NextResponse.json(
10+
{ error: "Email is required" },
11+
{ status: 400 }
12+
);
13+
}
14+
15+
const parts = email.split("@");
16+
if (parts.length !== 2) {
17+
return NextResponse.json(
18+
{ valid: false, hasMX: false },
19+
{ status: 200 }
20+
);
21+
}
22+
23+
const domain = parts[1];
24+
25+
try {
26+
const mxRecords = await dns.resolveMx(domain);
27+
const hasMX = Array.isArray(mxRecords) && mxRecords.length > 0;
28+
return NextResponse.json({ valid: true, hasMX }, { status: 200 });
29+
} catch {
30+
return NextResponse.json({ valid: true, hasMX: false }, { status: 200 });
31+
}
32+
} catch (error) {
33+
const message = error instanceof Error ? error.message : String(error);
34+
return NextResponse.json(
35+
{ error: `Validation failed: ${message}` },
36+
{ status: 500 }
37+
);
38+
}
39+
}

src/components/EmailField.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"use client";
2+
3+
import { useEmailValidation } from "@/hooks/useEmailValidation";
4+
5+
interface EmailFieldProps {
6+
email: string;
7+
onEmailChange: (email: string) => void;
8+
onBlur?: () => void;
9+
placeholder?: string;
10+
}
11+
12+
export default function EmailField({
13+
email,
14+
onEmailChange,
15+
onBlur,
16+
placeholder = "recipient@example.com",
17+
}: EmailFieldProps) {
18+
const { isValidFormat, isCheckingMX, mxValid } = useEmailValidation(email);
19+
20+
return (
21+
<div className="flex flex-col gap-1">
22+
<div className="relative flex items-center gap-2">
23+
<input
24+
type="email"
25+
value={email}
26+
onChange={(e) => onEmailChange(e.target.value)}
27+
onBlur={onBlur}
28+
placeholder={placeholder}
29+
className={`flex-1 bg-gray-800 border rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 ${
30+
!email
31+
? "border-gray-700 focus:ring-indigo-500"
32+
: !isValidFormat
33+
? "border-red-500 focus:ring-red-500"
34+
: mxValid === false
35+
? "border-yellow-500 focus:ring-yellow-500"
36+
: "border-green-500 focus:ring-green-500"
37+
}`}
38+
/>
39+
{email && isValidFormat && (
40+
<>
41+
{isCheckingMX && (
42+
<span className="text-xs text-gray-400">Checking...</span>
43+
)}
44+
{!isCheckingMX && mxValid === true && (
45+
<span className="text-green-500 text-sm"></span>
46+
)}
47+
{!isCheckingMX && mxValid === false && (
48+
<span className="text-yellow-500 text-sm"></span>
49+
)}
50+
</>
51+
)}
52+
</div>
53+
54+
{email && !isValidFormat && (
55+
<p className="text-xs text-red-400">Invalid email format</p>
56+
)}
57+
{email && isValidFormat && mxValid === false && !isCheckingMX && (
58+
<p className="text-xs text-yellow-400">Domain has no MX records (delivery may fail)</p>
59+
)}
60+
</div>
61+
);
62+
}

src/components/RecipientForm.tsx

Lines changed: 78 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import { searchAddressHistory, searchAmountHistory } from "@/lib/invoiceHistory"
88
import { searchRecipients, touchRecipient, type RecipientEntry } from "@/lib/recipients";
99
import { truncateAddress } from "@stellar-split/sdk";
1010
import CsvRecipientImport from "@/components/CsvRecipientImport";
11+
import { useEmailValidation } from "@/hooks/useEmailValidation";
12+
import EmailField from "@/components/EmailField";
1113

1214
export interface RecipientRow {
1315
address: string;
1416
amount: string;
1517
label?: string;
18+
email?: string;
1619
}
1720

1821
interface Props {
@@ -67,9 +70,9 @@ export default function RecipientForm({
6770
}).filter((r) => r.address);
6871
};
6972

70-
const updateRow = (index: number, address: string, label?: string) => {
73+
const updateRow = (index: number, address: string, label?: string, email?: string) => {
7174
const next = recipients.map((r, i) =>
72-
i === index ? { ...r, address, label: label !== undefined ? label : r.label } : r
75+
i === index ? { ...r, address, label: label !== undefined ? label : r.label, email: email !== undefined ? email : r.email } : r
7376
);
7477
onChange(next);
7578
};
@@ -168,71 +171,84 @@ export default function RecipientForm({
168171
return (
169172
<div className="flex flex-col gap-3">
170173
{recipients.map((row, i) => (
171-
<div key={i} className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-start min-w-0">
172-
<Avatar
173-
address={row.address}
174-
email={emailByAddress[row.address]}
175-
size={32}
176-
className="mt-1.5 hidden sm:inline-flex"
177-
/>
178-
179-
<div className="relative flex-1 min-w-0 w-full">
180-
<AddressBookPicker
181-
value={row.address}
182-
label={row.label}
183-
onChange={(address, label) => updateRow(i, address, label)}
184-
placeholder="G... or name*domain.com address"
185-
ariaLabel={`Recipient ${i + 1} address`}
174+
<div key={i} className="flex flex-col gap-2">
175+
<div className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-start min-w-0">
176+
<Avatar
177+
address={row.address}
178+
email={emailByAddress[row.address]}
179+
size={32}
180+
className="mt-1.5 hidden sm:inline-flex"
186181
/>
187-
</div>
188182

189-
<div className="relative w-full sm:w-28">
190-
<input
191-
type="number"
192-
placeholder="USDC"
193-
step="0.0000001"
194-
min="0.0000001"
195-
value={equalSplit ? (amountOverride ?? "") : row.amount}
196-
onChange={
197-
equalSplit ? undefined : (e) => handleAmountChange(i, e.target.value)
198-
}
199-
onFocus={() => !equalSplit && handleAmountFocus(i)}
200-
readOnly={equalSplit}
201-
required
202-
aria-label={`Recipient ${i + 1} amount`}
203-
className={`w-full bg-gray-800 border rounded-lg px-3 py-2 min-h-11 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${
204-
equalSplit
205-
? "border-gray-600 text-gray-400 cursor-not-allowed"
206-
: "border-gray-700"
207-
}`}
208-
/>
209-
{activeField === "amount" && activeIndex === i && amountSuggestions.length > 0 && !equalSplit && (
210-
<ul className="absolute z-10 right-0 w-full bg-gray-800 border border-gray-700 rounded-lg mt-1 max-h-40 overflow-y-auto shadow-lg">
211-
{amountSuggestions.map((amount) => (
212-
<li key={amount}>
213-
<button
214-
type="button"
215-
onMouseDown={() => selectAmountSuggestion(i, amount)}
216-
className="w-full min-h-11 text-left px-3 py-2 text-sm hover:bg-gray-700 font-mono text-gray-200"
217-
>
218-
{amount} USDC
219-
</button>
220-
</li>
221-
))}
222-
</ul>
183+
<div className="relative flex-1 min-w-0 w-full">
184+
<AddressBookPicker
185+
value={row.address}
186+
label={row.label}
187+
onChange={(address, label) => updateRow(i, address, label, row.email)}
188+
placeholder="G... or name*domain.com address"
189+
ariaLabel={`Recipient ${i + 1} address`}
190+
/>
191+
</div>
192+
193+
<div className="relative w-full sm:w-28">
194+
<input
195+
type="number"
196+
placeholder="USDC"
197+
step="0.0000001"
198+
min="0.0000001"
199+
value={equalSplit ? (amountOverride ?? "") : row.amount}
200+
onChange={
201+
equalSplit ? undefined : (e) => handleAmountChange(i, e.target.value)
202+
}
203+
onFocus={() => !equalSplit && handleAmountFocus(i)}
204+
readOnly={equalSplit}
205+
required
206+
aria-label={`Recipient ${i + 1} amount`}
207+
className={`w-full bg-gray-800 border rounded-lg px-3 py-2 min-h-11 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${
208+
equalSplit
209+
? "border-gray-600 text-gray-400 cursor-not-allowed"
210+
: "border-gray-700"
211+
}`}
212+
/>
213+
{activeField === "amount" && activeIndex === i && amountSuggestions.length > 0 && !equalSplit && (
214+
<ul className="absolute z-10 right-0 w-full bg-gray-800 border border-gray-700 rounded-lg mt-1 max-h-40 overflow-y-auto shadow-lg">
215+
{amountSuggestions.map((amount) => (
216+
<li key={amount}>
217+
<button
218+
type="button"
219+
onMouseDown={() => selectAmountSuggestion(i, amount)}
220+
className="w-full min-h-11 text-left px-3 py-2 text-sm hover:bg-gray-700 font-mono text-gray-200"
221+
>
222+
{amount} USDC
223+
</button>
224+
</li>
225+
))}
226+
</ul>
227+
)}
228+
</div>
229+
230+
{recipients.length > 1 && (
231+
<button
232+
type="button"
233+
onClick={() => removeRow(i)}
234+
aria-label={`Remove recipient ${i + 1}`}
235+
className="min-h-11 px-3 py-2 rounded-lg bg-gray-700 hover:bg-red-700 text-sm transition-colors sm:self-start focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
236+
>
237+
238+
</button>
223239
)}
224240
</div>
225241

226-
{recipients.length > 1 && (
227-
<button
228-
type="button"
229-
onClick={() => removeRow(i)}
230-
aria-label={`Remove recipient ${i + 1}`}
231-
className="min-h-11 px-3 py-2 rounded-lg bg-gray-700 hover:bg-red-700 text-sm transition-colors sm:self-start focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
232-
>
233-
234-
</button>
235-
)}
242+
<div className="flex items-center gap-2 min-w-0 w-full">
243+
<div className="hidden sm:block w-8" />
244+
<div className="flex-1">
245+
<EmailField
246+
email={row.email || ""}
247+
onEmailChange={(email) => updateRow(i, row.address, row.label, email)}
248+
onBlur={() => {}}
249+
/>
250+
</div>
251+
</div>
236252
</div>
237253
))}
238254

0 commit comments

Comments
 (0)