.
+ */
+export function Accordion({ title, children, defaultOpen = false, className }: AccordionProps) {
+ const [open, setOpen] = useState(defaultOpen);
+ const id = useId();
+ const buttonId = `${id}-button`;
+ const panelId = `${id}-panel`;
+
+ return (
+
+
+
+
+ {/* Always rendered (hidden when closed) so aria-controls never points at
+ a missing element (same reasoning as NavBar's mobile menu, #692). */}
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/BuyPolicyModal.tsx b/src/components/BuyPolicyModal.tsx
index c1b456f..b6fcf34 100644
--- a/src/components/BuyPolicyModal.tsx
+++ b/src/components/BuyPolicyModal.tsx
@@ -3,7 +3,8 @@
import { useState, useEffect, useMemo } from 'react';
import type { Product } from '@/types';
import { useWallet } from '@/hooks/useWallet';
-import { displayToStroops, stroopsToDisplay, estimatePremium, basisPointsToPercent } from '@/lib/format';
+import { displayToStroops, stroopsToDisplay, estimatePremium, basisPointsToPercent, toDateInputValue, dateInputToEpoch } from '@/lib/format';
+import { amountInputGuards, isPlainDecimal } from '@/lib/numberInput';
import { toUserMessage } from '@/lib/errors';
import { invokeBuyPolicy } from '@/lib/contract';
import { buyPolicy as recordPolicyPurchase } from '@/lib/api';
@@ -66,7 +67,9 @@ export function BuyPolicyModal({ product, onClose }: Props) {
// Flight builder state
const [flightNumber, setFlightNumber] = useState('');
- const [flightDate, setFlightDate] = useState(new Date().toISOString().split('T')[0]);
+ // Default to the viewer's local "today", not the UTC day from toISOString(),
+ // which is still yesterday for users ahead of UTC (#778).
+ const [flightDate, setFlightDate] = useState(() => toDateInputValue());
const minDisplay = stroopsToDisplay(product.coverageMin, 2);
const maxDisplay = stroopsToDisplay(product.coverageMax, 2);
@@ -95,7 +98,7 @@ export function BuyPolicyModal({ product, onClose }: Props) {
// #475) rather than relying on parseFloat/parseInt's lenient parsing:
// parseFloat("5e8") happily returns 500000000, which could pass a naive
// range check with a value the user never intended to type.
- if (!/^\d+(\.\d+)?$/.test(coverage.trim())) {
+ if (!isPlainDecimal(coverage)) {
setCoverageError('Coverage must be a plain positive number (no scientific notation)');
isValid = false;
} else {
@@ -153,6 +156,9 @@ export function BuyPolicyModal({ product, onClose }: Props) {
if (!flightDate) {
setFlightDateError('Flight date is required');
isValid = false;
+ } else if (dateInputToEpoch(flightDate) === null) {
+ setFlightDateError('Enter a valid date');
+ isValid = false;
} else {
setFlightDateError('');
}
@@ -237,6 +243,7 @@ export function BuyPolicyModal({ product, onClose }: Props) {
max={maxDisplay}
value={coverage}
onChange={(e) => { setCoverage(e.target.value); setCoverageError(''); }}
+ {...amountInputGuards}
maxLength={MAX_INPUT_LENGTHS.amount}
placeholder={`${minDisplay} – ${maxDisplay}`}
className={`${INPUT_CLASS} ${coverageError ? 'border-red-500' : ''}`}
@@ -251,6 +258,7 @@ export function BuyPolicyModal({ product, onClose }: Props) {
type="number"
value={duration}
onChange={(e) => { setDuration(e.target.value); setDurationError(''); }}
+ {...amountInputGuards}
min={1}
max={product.maxDuration}
maxLength={MAX_INPUT_LENGTHS.amount}
diff --git a/src/components/DepositModal.tsx b/src/components/DepositModal.tsx
index f685922..53e396a 100644
--- a/src/components/DepositModal.tsx
+++ b/src/components/DepositModal.tsx
@@ -7,6 +7,7 @@ import { fetchPoolShares } from '@/lib/api';
import { buildDepositTx, submitSignedTransaction, withStateRestore } from '@/lib/contract';
import { signTransaction } from '@/lib/stellar';
import { displayToStroops, formatUSDC, stroopsToDisplay } from '@/lib/format';
+import { amountInputGuards, isPlainDecimal } from '@/lib/numberInput';
import { ContractError, toUserMessage } from '@/lib/errors';
import { CATEGORY_LABELS, MIN_DEPOSIT_STROOPS } from '@/lib/constants';
import { Modal } from './Modal';
@@ -83,9 +84,13 @@ export function DepositModal({ pool, onClose }: Props) {
}, [loadShares]);
const amountNum = parseFloat(amount) || 0;
+ // Same plain-decimal rule as WithdrawModal/BuyPolicyModal (#782): "1e10"
+ // parses as a huge positive number but displayToStroops rejects it, which
+ // used to surface as a misleading "Minimum deposit" error.
+ const invalidFormat = amount.trim() !== '' && !isPlainDecimal(amount);
let depositStroops = 0n;
try {
- depositStroops = amount ? displayToStroops(amount) : 0n;
+ depositStroops = amount && !invalidFormat ? displayToStroops(amount) : 0n;
} catch {
// Negative or otherwise invalid input — keep depositStroops at 0n so
// the render doesn't throw. handleDeposit surfaces the user-facing
@@ -101,6 +106,7 @@ export function DepositModal({ pool, onClose }: Props) {
if (!address) return;
if (!sharesAvailable) { setError('Pool data is unavailable — retry loading before depositing.'); return; }
if (paused) { setError('This pool is currently paused. Deposits are not accepted.'); return; }
+ if (invalidFormat) { setError('Enter a plain positive number (no scientific notation).'); return; }
if (amountNum <= 0) { setError('Enter a valid USDC amount.'); return; }
if (depositStroops < MIN_DEPOSIT_STROOPS) {
setError(`Minimum deposit is ${stroopsToDisplay(MIN_DEPOSIT_STROOPS, 2)} USDC`);
@@ -159,6 +165,7 @@ export function DepositModal({ pool, onClose }: Props) {
type="number"
value={amount}
onChange={(e) => { setAmount(e.target.value); setError(''); }}
+ {...amountInputGuards}
placeholder="0.00"
min={0}
step="0.01"
diff --git a/src/components/PolicyStatusTimeline.tsx b/src/components/PolicyStatusTimeline.tsx
index 6b7c4be..6eb0466 100644
--- a/src/components/PolicyStatusTimeline.tsx
+++ b/src/components/PolicyStatusTimeline.tsx
@@ -116,7 +116,7 @@ export function PolicyStatusTimeline({
onClick={() => setCollapsed((c) => !c)}
aria-expanded={!isCollapsed}
aria-controls={listId}
- className="mb-4 text-xs font-medium text-teal-400 transition-colors hover:text-teal-300"
+ className="mb-4 rounded text-xs font-medium text-teal-400 transition-colors hover:text-teal-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-400"
>
{isCollapsed ? `Show all ${events.length} steps` : 'Show current step only'}
diff --git a/src/components/WithdrawModal.tsx b/src/components/WithdrawModal.tsx
index 4439d7d..930e442 100644
--- a/src/components/WithdrawModal.tsx
+++ b/src/components/WithdrawModal.tsx
@@ -7,6 +7,7 @@ import { fetchPoolShares, fetchPoolUserShares } from '@/lib/api';
import { buildWithdrawTx, submitSignedTransaction, withStateRestore } from '@/lib/contract';
import { signTransaction } from '@/lib/stellar';
import { displayToStroops, stroopsToDisplay } from '@/lib/format';
+import { amountInputGuards, isPlainDecimal } from '@/lib/numberInput';
import { ContractError, toUserMessage } from '@/lib/errors';
import { CATEGORY_LABELS, MIN_WITHDRAW_STROOPS } from '@/lib/constants';
import { Modal } from './Modal';
@@ -80,7 +81,7 @@ export function WithdrawModal({ pool, onClose }: Props) {
// silently below and treated as "0 shares", which just disables the submit
// button with no explanation of why (#520).
const trimmedAmount = amount.trim();
- const invalidFormat = trimmedAmount !== '' && !/^\d+(\.\d+)?$/.test(trimmedAmount);
+ const invalidFormat = trimmedAmount !== '' && !isPlainDecimal(trimmedAmount);
let withdrawShares = 0n;
try {
withdrawShares = amount && !invalidFormat ? displayToStroops(amount) : 0n;
@@ -140,6 +141,7 @@ export function WithdrawModal({ pool, onClose }: Props) {
type="number"
value={amount}
onChange={(e) => { setAmount(e.target.value); setError(''); }}
+ {...amountInputGuards}
placeholder="0.00"
min={0}
step="0.01"
diff --git a/src/lib/claimsExport.ts b/src/lib/claimsExport.ts
index 01f5ebc..48eaad8 100644
--- a/src/lib/claimsExport.ts
+++ b/src/lib/claimsExport.ts
@@ -1,5 +1,5 @@
import type { Claim } from '@/types';
-import { formatUSDC, formatDateTime } from '@/lib/format';
+import { formatUSDC, formatDateTime, toDateInputValue } from '@/lib/format';
const CSV_HEADERS = [
'Claim ID',
@@ -70,11 +70,11 @@ function download(content: string, filename: string, mimeType: string) {
}
export function downloadClaimsCSV(claims: Claim[]) {
- const timestamp = new Date().toISOString().slice(0, 10);
+ const timestamp = toDateInputValue();
download(claimsToCSV(claims), `parashield-claims-${timestamp}.csv`, 'text/csv');
}
export function downloadClaimsJSON(claims: Claim[]) {
- const timestamp = new Date().toISOString().slice(0, 10);
+ const timestamp = toDateInputValue();
download(claimsToJSON(claims), `parashield-claims-${timestamp}.json`, 'application/json');
}
diff --git a/src/lib/format.ts b/src/lib/format.ts
index aad04f4..8b8ae30 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -89,6 +89,46 @@ export function formatUtcDateTime(epochSeconds: number | null | undefined): stri
return new Date(epochSeconds * 1000).toUTCString();
}
+/*
+ * Timezone handling (#778)
+ *
+ * - Instants (policy start/end, oracle readings, claim timestamps) are epoch
+ * seconds, i.e. UTC. Store and compare them as-is; render them with
+ * `formatDate` / `formatDateTime`, which use the viewer's local timezone.
+ * - Calendar dates from `
` are "YYYY-MM-DD" strings with no
+ * timezone. Never derive one with `toISOString()` (that is the *UTC* day, so
+ * for anyone ahead of UTC it is yesterday until mid-morning) and never parse
+ * one with `new Date(value)` and read it back with local getters. Use
+ * `toDateInputValue` to get the viewer's local day, and `dateInputToEpoch`
+ * to normalise a picked date to UTC midnight for storage/comparison.
+ */
+
+/** The calendar day of `date` in the viewer's local timezone, as "YYYY-MM-DD". */
+export function toDateInputValue(date: Date = new Date()): string {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, '0');
+ const d = String(date.getDate()).padStart(2, '0');
+ return `${y}-${m}-${d}`;
+}
+
+/**
+ * Normalises a "YYYY-MM-DD" date-input value to epoch seconds at UTC
+ * midnight, so the same picked day stores identically in every timezone.
+ * Returns null for anything that isn't a real calendar date.
+ */
+export function dateInputToEpoch(value: string): number | null {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
+ if (!match) return null;
+ const [, y, m, d] = match.map(Number);
+ const ms = Date.UTC(y, m - 1, d);
+ const check = new Date(ms);
+ // Reject rollovers like 2026-02-31 → 2026-03-03.
+ if (check.getUTCFullYear() !== y || check.getUTCMonth() !== m - 1 || check.getUTCDate() !== d) {
+ return null;
+ }
+ return ms / 1000;
+}
+
export function basisPointsToPercent(bps: number, decimals = 2): string {
return `${(bps / 100).toFixed(decimals)}%`;
}
diff --git a/src/lib/numberInput.ts b/src/lib/numberInput.ts
new file mode 100644
index 0000000..d1d5145
--- /dev/null
+++ b/src/lib/numberInput.ts
@@ -0,0 +1,35 @@
+import type { ClipboardEvent, KeyboardEvent } from 'react';
+
+/**
+ * Plain non-negative decimal: digits with an optional fractional part. No
+ * sign, no exponent — the backend and `displayToStroops` only accept this
+ * shape, so "1e10" must never reach them (#473, #782).
+ */
+export const PLAIN_DECIMAL_RE = /^\d+(\.\d+)?$/;
+
+export function isPlainDecimal(value: string): boolean {
+ return PLAIN_DECIMAL_RE.test(value.trim());
+}
+
+// `type="number"` lets the browser accept these as part of a valid number
+// (exponent and sign), none of which make sense for an amount.
+const BLOCKED_KEYS = new Set(['e', 'E', '+', '-']);
+
+function blockNonDecimalKeys(e: KeyboardEvent
) {
+ if (BLOCKED_KEYS.has(e.key)) e.preventDefault();
+}
+
+function blockNonDecimalPaste(e: ClipboardEvent) {
+ const text = e.clipboardData.getData('text');
+ if (text && !isPlainDecimal(text)) e.preventDefault();
+}
+
+/**
+ * Spread onto amount/currency ``s to stop scientific
+ * notation and signs being typed or pasted. This only improves typing; always
+ * re-check with `isPlainDecimal` on submit as well.
+ */
+export const amountInputGuards = {
+ onKeyDown: blockNonDecimalKeys,
+ onPaste: blockNonDecimalPaste,
+} as const;