diff --git a/src/__tests__/useSplitCalculator.test.ts b/src/__tests__/useSplitCalculator.test.ts new file mode 100644 index 0000000..e2511d1 --- /dev/null +++ b/src/__tests__/useSplitCalculator.test.ts @@ -0,0 +1,89 @@ +import { resolveRounding } from '@/hooks/useSplitCalculator'; + +describe('resolveRounding', () => { + const STROOP_SCALE = 1e7; + + it('should resolve rounding for 3-recipient split', () => { + const percentages = [33.33, 33.33, 33.34]; + const totalAmount = 100; + + const result = resolveRounding(percentages, totalAmount); + + expect(result.amounts).toHaveLength(3); + const sumStroops = result.amounts.reduce( + (s, a) => s + Math.round(a * STROOP_SCALE), + 0 + ); + const totalStroops = Math.round(totalAmount * STROOP_SCALE); + expect(sumStroops).toBe(totalStroops); + }); + + it('should resolve rounding for 7-recipient split', () => { + const percentages = [14.28, 14.28, 14.28, 14.28, 14.28, 14.29, 14.31]; + const totalAmount = 1000; + + const result = resolveRounding(percentages, totalAmount); + + expect(result.amounts).toHaveLength(7); + const sumStroops = result.amounts.reduce( + (s, a) => s + Math.round(a * STROOP_SCALE), + 0 + ); + const totalStroops = Math.round(totalAmount * STROOP_SCALE); + expect(sumStroops).toBe(totalStroops); + }); + + it('should resolve rounding for 11-recipient split', () => { + const percentages = Array(11) + .fill(0) + .map((_, i) => (i === 10 ? 9.1 : 9.09)); + const totalAmount = 5000; + + const result = resolveRounding(percentages, totalAmount); + + expect(result.amounts).toHaveLength(11); + const sumStroops = result.amounts.reduce( + (s, a) => s + Math.round(a * STROOP_SCALE), + 0 + ); + const totalStroops = Math.round(totalAmount * STROOP_SCALE); + expect(sumStroops).toBe(totalStroops); + }); + + it('should assign adjustment to first recipient', () => { + const percentages = [50, 50]; + const totalAmount = 100; + + const result = resolveRounding(percentages, totalAmount); + + expect(result.recipientIndex).toBe(0); + }); + + it('should return zero adjustment when no rounding needed', () => { + const percentages = [25, 25, 25, 25]; + const totalAmount = 100; + + const result = resolveRounding(percentages, totalAmount); + + const sumStroops = result.amounts.reduce( + (s, a) => s + Math.round(a * STROOP_SCALE), + 0 + ); + const totalStroops = Math.round(totalAmount * STROOP_SCALE); + expect(sumStroops).toBe(totalStroops); + }); + + it('should handle fractional stroop amounts', () => { + const percentages = [33.333, 33.333, 33.334]; + const totalAmount = 123.456789; + + const result = resolveRounding(percentages, totalAmount); + + const sumStroops = result.amounts.reduce( + (s, a) => s + Math.round(a * STROOP_SCALE), + 0 + ); + const totalStroops = Math.round(totalAmount * STROOP_SCALE); + expect(sumStroops).toBe(totalStroops); + }); +}); diff --git a/src/app/api/validate-email/route.ts b/src/app/api/validate-email/route.ts new file mode 100644 index 0000000..fdfa177 --- /dev/null +++ b/src/app/api/validate-email/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { promises as dns } from "dns"; + +export async function POST(request: Request) { + try { + const { email } = await request.json(); + + if (!email || typeof email !== "string") { + return NextResponse.json( + { error: "Email is required" }, + { status: 400 } + ); + } + + const parts = email.split("@"); + if (parts.length !== 2) { + return NextResponse.json( + { valid: false, hasMX: false }, + { status: 200 } + ); + } + + const domain = parts[1]; + + try { + const mxRecords = await dns.resolveMx(domain); + const hasMX = Array.isArray(mxRecords) && mxRecords.length > 0; + return NextResponse.json({ valid: true, hasMX }, { status: 200 }); + } catch { + return NextResponse.json({ valid: true, hasMX: false }, { status: 200 }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return NextResponse.json( + { error: `Validation failed: ${message}` }, + { status: 500 } + ); + } +} diff --git a/src/components/EmailField.tsx b/src/components/EmailField.tsx new file mode 100644 index 0000000..5b3b629 --- /dev/null +++ b/src/components/EmailField.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEmailValidation } from "@/hooks/useEmailValidation"; + +interface EmailFieldProps { + email: string; + onEmailChange: (email: string) => void; + onBlur?: () => void; + placeholder?: string; +} + +export default function EmailField({ + email, + onEmailChange, + onBlur, + placeholder = "recipient@example.com", +}: EmailFieldProps) { + const { isValidFormat, isCheckingMX, mxValid } = useEmailValidation(email); + + return ( +
+
+ onEmailChange(e.target.value)} + onBlur={onBlur} + placeholder={placeholder} + className={`flex-1 bg-gray-800 border rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 ${ + !email + ? "border-gray-700 focus:ring-indigo-500" + : !isValidFormat + ? "border-red-500 focus:ring-red-500" + : mxValid === false + ? "border-yellow-500 focus:ring-yellow-500" + : "border-green-500 focus:ring-green-500" + }`} + /> + {email && isValidFormat && ( + <> + {isCheckingMX && ( + Checking... + )} + {!isCheckingMX && mxValid === true && ( + + )} + {!isCheckingMX && mxValid === false && ( + + )} + + )} +
+ + {email && !isValidFormat && ( +

Invalid email format

+ )} + {email && isValidFormat && mxValid === false && !isCheckingMX && ( +

Domain has no MX records (delivery may fail)

+ )} +
+ ); +} diff --git a/src/components/RecipientForm.tsx b/src/components/RecipientForm.tsx index 03d2432..8d4d8d1 100644 --- a/src/components/RecipientForm.tsx +++ b/src/components/RecipientForm.tsx @@ -8,11 +8,14 @@ import { searchAddressHistory, searchAmountHistory } from "@/lib/invoiceHistory" import { searchRecipients, touchRecipient, type RecipientEntry } from "@/lib/recipients"; import { truncateAddress } from "@stellar-split/sdk"; import CsvRecipientImport from "@/components/CsvRecipientImport"; +import { useEmailValidation } from "@/hooks/useEmailValidation"; +import EmailField from "@/components/EmailField"; export interface RecipientRow { address: string; amount: string; label?: string; + email?: string; } interface Props { @@ -67,9 +70,9 @@ export default function RecipientForm({ }).filter((r) => r.address); }; - const updateRow = (index: number, address: string, label?: string) => { + const updateRow = (index: number, address: string, label?: string, email?: string) => { const next = recipients.map((r, i) => - i === index ? { ...r, address, label: label !== undefined ? label : r.label } : r + i === index ? { ...r, address, label: label !== undefined ? label : r.label, email: email !== undefined ? email : r.email } : r ); onChange(next); }; @@ -168,71 +171,84 @@ export default function RecipientForm({ return (
{recipients.map((row, i) => ( -
- - -
- updateRow(i, address, label)} - placeholder="G... or name*domain.com address" - ariaLabel={`Recipient ${i + 1} address`} +
+
+ -
-
- handleAmountChange(i, e.target.value) - } - onFocus={() => !equalSplit && handleAmountFocus(i)} - readOnly={equalSplit} - required - aria-label={`Recipient ${i + 1} amount`} - 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 ${ - equalSplit - ? "border-gray-600 text-gray-400 cursor-not-allowed" - : "border-gray-700" - }`} - /> - {activeField === "amount" && activeIndex === i && amountSuggestions.length > 0 && !equalSplit && ( -
    - {amountSuggestions.map((amount) => ( -
  • - -
  • - ))} -
+
+ updateRow(i, address, label, row.email)} + placeholder="G... or name*domain.com address" + ariaLabel={`Recipient ${i + 1} address`} + /> +
+ +
+ handleAmountChange(i, e.target.value) + } + onFocus={() => !equalSplit && handleAmountFocus(i)} + readOnly={equalSplit} + required + aria-label={`Recipient ${i + 1} amount`} + 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 ${ + equalSplit + ? "border-gray-600 text-gray-400 cursor-not-allowed" + : "border-gray-700" + }`} + /> + {activeField === "amount" && activeIndex === i && amountSuggestions.length > 0 && !equalSplit && ( +
    + {amountSuggestions.map((amount) => ( +
  • + +
  • + ))} +
+ )} +
+ + {recipients.length > 1 && ( + )}
- {recipients.length > 1 && ( - - )} +
+
+
+ updateRow(i, row.address, row.label, email)} + onBlur={() => {}} + /> +
+
))} diff --git a/src/components/invoice/NotesEditor.tsx b/src/components/invoice/NotesEditor.tsx new file mode 100644 index 0000000..ff96027 --- /dev/null +++ b/src/components/invoice/NotesEditor.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useEditor, EditorContent } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Link from '@tiptap/extension-link'; +import DOMPurify from 'dompurify'; +import { useEffect, useCallback } from 'react'; + +interface NotesEditorProps { + value: string; + onChange: (html: string) => void; + placeholder?: string; + disabled?: boolean; +} + +export default function NotesEditor({ + value, + onChange, + placeholder = 'Add notes...', + disabled = false, +}: NotesEditorProps) { + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + }), + Link.configure({ openOnClick: false }), + ], + content: value || '', + onUpdate: ({ editor }) => { + const html = editor.getHTML(); + const sanitized = DOMPurify.sanitize(html, { + ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li', 'a', 'h1', 'h2', 'h3'], + ALLOWED_ATTR: ['href', 'title'], + }); + onChange(sanitized); + }, + editable: !disabled, + }); + + useEffect(() => { + if (editor && value && editor.getHTML() !== value) { + editor.commands.setContent(value); + } + }, [editor, value]); + + const toggleBold = useCallback(() => { + editor?.chain().focus().toggleBold().run(); + }, [editor]); + + const toggleItalic = useCallback(() => { + editor?.chain().focus().toggleItalic().run(); + }, [editor]); + + const toggleBulletList = useCallback(() => { + editor?.chain().focus().toggleBulletList().run(); + }, [editor]); + + const insertLink = useCallback(() => { + const url = prompt('Enter URL:'); + if (url) { + editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); + } + }, [editor]); + + if (!editor) { + return null; + } + + return ( +
+
{ + if (e.key === 'Tab') { + e.preventDefault(); + editor.chain().focus().run(); + } + }} + > + + + + + + + +
+ +
+ +
+
+ ); +} diff --git a/src/components/invoice/RateLimitBanner.tsx b/src/components/invoice/RateLimitBanner.tsx new file mode 100644 index 0000000..eebbf77 --- /dev/null +++ b/src/components/invoice/RateLimitBanner.tsx @@ -0,0 +1,29 @@ +'use client'; + +interface RateLimitBannerProps { + isVisible: boolean; + secondsRemaining: number; + retryAfterSeconds: number | null; +} + +export default function RateLimitBanner({ + isVisible, + secondsRemaining, + retryAfterSeconds, +}: RateLimitBannerProps) { + if (!isVisible) return null; + + if (retryAfterSeconds === null) { + return ( +
+

Please wait before trying again

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

You can create another invoice in {secondsRemaining} s

+
+ ); +} diff --git a/src/components/invoice/RecipientRow.tsx b/src/components/invoice/RecipientRow.tsx new file mode 100644 index 0000000..4f6524f --- /dev/null +++ b/src/components/invoice/RecipientRow.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEmailValidation } from "@/hooks/useEmailValidation"; + +interface RecipientRowProps { + email: string; + onEmailChange: (email: string) => void; + onBlur?: () => void; +} + +export default function RecipientRow({ + email, + onEmailChange, + onBlur, +}: RecipientRowProps) { + const { isValidFormat, isCheckingMX, mxValid } = useEmailValidation(email); + + return ( +
+
+ onEmailChange(e.target.value)} + onBlur={onBlur} + placeholder="recipient@example.com" + className={`flex-1 bg-gray-800 border rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 ${ + !email + ? "border-gray-700 focus:ring-indigo-500" + : !isValidFormat + ? "border-red-500 focus:ring-red-500" + : mxValid === false + ? "border-yellow-500 focus:ring-yellow-500" + : "border-green-500 focus:ring-green-500" + }`} + /> + {email && isValidFormat && ( + <> + {isCheckingMX && ( + Checking... + )} + {!isCheckingMX && mxValid === true && ( + + )} + {!isCheckingMX && mxValid === false && ( + + )} + + )} +
+ + {email && !isValidFormat && ( +

Invalid email format

+ )} + {email && isValidFormat && mxValid === false && !isCheckingMX && ( +

Domain has no MX records (delivery may fail)

+ )} +
+ ); +} diff --git a/src/components/invoice/SplitSummaryCard.tsx b/src/components/invoice/SplitSummaryCard.tsx new file mode 100644 index 0000000..2fa3124 --- /dev/null +++ b/src/components/invoice/SplitSummaryCard.tsx @@ -0,0 +1,48 @@ +'use client'; + +interface SplitSummaryCardProps { + totalAmount: number; + recipientCount: number; + roundingAdjustment?: number; + recipientIndex?: number; +} + +export default function SplitSummaryCard({ + totalAmount, + recipientCount, + roundingAdjustment = 0, + recipientIndex = 0, +}: SplitSummaryCardProps) { + const hasRoundingAdjustment = roundingAdjustment !== 0 && roundingAdjustment !== undefined; + + return ( +
+
+
+ Total amount: + {totalAmount} USDC +
+
+ Recipients: + {recipientCount} +
+ + {hasRoundingAdjustment && ( +
+
+ +
+

+ Rounding adjustment applied +

+

+ {recipientIndex === 0 ? 'First' : `Recipient ${recipientIndex + 1}`} received {Math.abs(roundingAdjustment)} additional stroops to resolve rounding discrepancy. +

+
+
+
+ )} +
+
+ ); +} diff --git a/src/hooks/useEmailValidation.ts b/src/hooks/useEmailValidation.ts new file mode 100644 index 0000000..7985f82 --- /dev/null +++ b/src/hooks/useEmailValidation.ts @@ -0,0 +1,91 @@ +'use client'; + +import { useState, useCallback, useRef, useEffect } from 'react'; + +interface ValidationResult { + valid: boolean; + hasMX: boolean; +} + +interface CacheEntry { + result: ValidationResult; + timestamp: number; +} + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const domainCache = new Map(); + +export function useEmailValidation(email: string, debounceMs = 500) { + const [isValidFormat, setIsValidFormat] = useState(false); + const [isCheckingMX, setIsCheckingMX] = useState(false); + const [mxValid, setMxValid] = useState(null); + const debounceTimerRef = useRef(null); + + const checkEmail = useCallback(async () => { + if (!email) { + setIsValidFormat(false); + setMxValid(null); + return; + } + + const formatValid = EMAIL_PATTERN.test(email); + setIsValidFormat(formatValid); + + if (!formatValid) { + setMxValid(null); + return; + } + + const domain = email.split('@')[1]; + const now = Date.now(); + const cached = domainCache.get(domain); + + if (cached && now - cached.timestamp < CACHE_TTL) { + setMxValid(cached.result.hasMX); + return; + } + + setIsCheckingMX(true); + try { + const response = await fetch('/api/validate-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + + if (!response.ok) { + setMxValid(null); + return; + } + + const result: ValidationResult = await response.json(); + domainCache.set(domain, { result, timestamp: now }); + setMxValid(result.hasMX); + } catch { + setMxValid(null); + } finally { + setIsCheckingMX(false); + } + }, [email]); + + useEffect(() => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + debounceTimerRef.current = setTimeout(checkEmail, debounceMs); + + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [email, debounceMs, checkEmail]); + + return { + isValidFormat, + isCheckingMX, + mxValid, + }; +} diff --git a/src/hooks/useSplitCalculator.ts b/src/hooks/useSplitCalculator.ts index 027d679..9c22445 100644 --- a/src/hooks/useSplitCalculator.ts +++ b/src/hooks/useSplitCalculator.ts @@ -46,6 +46,12 @@ export interface SplitCalculatorResult { recipientCount: number; } +export interface RoundingResolution { + amounts: number[]; + roundingAdjustment: number; + recipientIndex: number; +} + const STROOP_SCALE = 1e7; function roundToStroops(value: number): number { diff --git a/src/lib/htmlSanitizer.ts b/src/lib/htmlSanitizer.ts new file mode 100644 index 0000000..7a80110 --- /dev/null +++ b/src/lib/htmlSanitizer.ts @@ -0,0 +1,23 @@ +import DOMPurify from 'dompurify'; + +const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li', 'a', 'h1', 'h2', 'h3']; +const ALLOWED_ATTR = ['href', 'title']; + +export function sanitizeHtml(dirty: string): string { + if (!dirty || typeof dirty !== 'string') { + return ''; + } + + return DOMPurify.sanitize(dirty, { + ALLOWED_TAGS, + ALLOWED_ATTR, + }); +} + +export function createSafeHtmlProps(html: string) { + return { + dangerouslySetInnerHTML: { + __html: sanitizeHtml(html), + }, + }; +} diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index bb7e506..78fbfa4 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -305,3 +305,10 @@ export async function validateFederationAddress(address: string): Promise<{ return { isFunded: false, requiresMemo: false }; } } + +export function validateRecipientAmountSum(amounts: number[], totalAmount: number): boolean { + const STROOP_SCALE = 1e7; + const totalStroops = Math.round(totalAmount * STROOP_SCALE); + const sumStroops = amounts.reduce((s, a) => s + Math.round(a * STROOP_SCALE), 0); + return totalStroops === sumStroops; +}