From 31b41892b7ccd3ea9f51ada2c1b0403541b40849 Mon Sep 17 00:00:00 2001 From: alfred micheal Date: Tue, 28 Jul 2026 18:58:17 +0100 Subject: [PATCH 1/4] feat: recipient email validation with MX checking (#498) --- src/app/api/validate-email/route.ts | 39 +++++++ src/components/EmailField.tsx | 62 +++++++++++ src/components/RecipientForm.tsx | 140 +++++++++++++----------- src/components/invoice/RecipientRow.tsx | 60 ++++++++++ src/hooks/useEmailValidation.ts | 91 +++++++++++++++ 5 files changed, 330 insertions(+), 62 deletions(-) create mode 100644 src/app/api/validate-email/route.ts create mode 100644 src/components/EmailField.tsx create mode 100644 src/components/invoice/RecipientRow.tsx create mode 100644 src/hooks/useEmailValidation.ts 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/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/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, + }; +} From aa4c50e458a91e49b117ee783d919ab4cb40184d Mon Sep 17 00:00:00 2001 From: alfred micheal Date: Tue, 28 Jul 2026 18:58:39 +0100 Subject: [PATCH 2/4] feat: invoice rate-limit feedback UI with countdown timer (#499) --- src/components/invoice/RateLimitBanner.tsx | 29 +++++++++ src/hooks/useInvoiceForm.ts | 73 ++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/components/invoice/RateLimitBanner.tsx create mode 100644 src/hooks/useInvoiceForm.ts 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/hooks/useInvoiceForm.ts b/src/hooks/useInvoiceForm.ts new file mode 100644 index 0000000..862977b --- /dev/null +++ b/src/hooks/useInvoiceForm.ts @@ -0,0 +1,73 @@ +'use client'; + +import { useState, useCallback, useEffect } from 'react'; + +interface RateLimitState { + isRateLimited: boolean; + resetTimestamp: number | null; + retryAfterSeconds: number | null; +} + +export function useInvoiceForm() { + const [rateLimitState, setRateLimitState] = useState({ + isRateLimited: false, + resetTimestamp: null, + retryAfterSeconds: null, + }); + + const [secondsRemaining, setSecondsRemaining] = useState(0); + + useEffect(() => { + if (!rateLimitState.isRateLimited || !rateLimitState.resetTimestamp) { + return; + } + + const interval = setInterval(() => { + const now = Date.now(); + const remaining = Math.max(0, Math.ceil((rateLimitState.resetTimestamp! - now) / 1000)); + setSecondsRemaining(remaining); + + if (remaining <= 0) { + setRateLimitState({ + isRateLimited: false, + resetTimestamp: null, + retryAfterSeconds: null, + }); + clearInterval(interval); + } + }, 1000); + + return () => clearInterval(interval); + }, [rateLimitState.isRateLimited, rateLimitState.resetTimestamp]); + + const handleRateLimit = useCallback((response: Response) => { + if (response.status === 429) { + const retryAfterHeader = response.headers.get('Retry-After'); + const retryAfterSeconds = retryAfterHeader ? parseInt(retryAfterHeader, 10) : 60; + const resetTimestamp = Date.now() + retryAfterSeconds * 1000; + + setRateLimitState({ + isRateLimited: true, + resetTimestamp, + retryAfterSeconds, + }); + setSecondsRemaining(retryAfterSeconds); + } + }, []); + + const resetRateLimit = useCallback(() => { + setRateLimitState({ + isRateLimited: false, + resetTimestamp: null, + retryAfterSeconds: null, + }); + setSecondsRemaining(0); + }, []); + + return { + ...rateLimitState, + secondsRemaining, + handleRateLimit, + resetRateLimit, + }; +} From aae31913d21425dbdfd585cae69d0ff1739e4bbb Mon Sep 17 00:00:00 2001 From: alfred micheal Date: Tue, 28 Jul 2026 18:59:48 +0100 Subject: [PATCH 3/4] feat: split amount rounding discrepancy handler and warning (#500) --- src/__tests__/useSplitCalculator.test.ts | 89 +++++++++++++++++++++ src/components/invoice/SplitSummaryCard.tsx | 48 +++++++++++ src/hooks/useSplitCalculator.ts | 33 ++++++++ src/lib/stellar.ts | 7 ++ 4 files changed, 177 insertions(+) create mode 100644 src/__tests__/useSplitCalculator.test.ts create mode 100644 src/components/invoice/SplitSummaryCard.tsx 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/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/useSplitCalculator.ts b/src/hooks/useSplitCalculator.ts index f04082b..133738b 100644 --- a/src/hooks/useSplitCalculator.ts +++ b/src/hooks/useSplitCalculator.ts @@ -43,6 +43,12 @@ export interface SplitCalculatorResult { validation: SplitValidation; } +export interface RoundingResolution { + amounts: number[]; + roundingAdjustment: number; + recipientIndex: number; +} + const STROOP_SCALE = 1e7; function roundToStroops(value: number): number { @@ -158,3 +164,30 @@ export function defaultRecipientLine(address = ''): RecipientLine { fixedFeeXLM: 0, }; } + +export function resolveRounding( + percentages: number[], + totalAmount: number +): RoundingResolution { + const STROOP_SCALE_INT = 1e7; + const totalStroops = Math.round(totalAmount * STROOP_SCALE_INT); + + const amounts = percentages.map((pct) => { + const stroopAmount = Math.round((totalStroops * pct) / 100); + return stroopAmount / STROOP_SCALE_INT; + }); + + const sumStroops = amounts.reduce((s, a) => s + Math.round(a * STROOP_SCALE_INT), 0); + const discrepancy = totalStroops - sumStroops; + + if (discrepancy !== 0 && amounts.length > 0) { + const firstRecipientStroops = Math.round(amounts[0] * STROOP_SCALE_INT) + discrepancy; + amounts[0] = firstRecipientStroops / STROOP_SCALE_INT; + } + + return { + amounts, + roundingAdjustment: discrepancy, + recipientIndex: 0, + }; +} diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index 0a8b695..59c2b6a 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -302,3 +302,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; +} From b7a2ced339fb89c1f3a1430ed75e500000191299 Mon Sep 17 00:00:00 2001 From: alfred micheal Date: Tue, 28 Jul 2026 19:00:27 +0100 Subject: [PATCH 4/4] feat: rich text notes editor for invoice description (#501) --- src/components/invoice/NotesEditor.tsx | 147 +++++++++++++++++++++++++ src/lib/htmlSanitizer.ts | 23 ++++ 2 files changed, 170 insertions(+) create mode 100644 src/components/invoice/NotesEditor.tsx create mode 100644 src/lib/htmlSanitizer.ts 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/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), + }, + }; +}