diff --git a/src/app/globals.css b/src/app/globals.css index 2473d38..f243feb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -12,6 +12,11 @@ */ @custom-variant dark (&:where(.dark, .dark *)); +/* Inter is loaded via next/font in app/layout.tsx (#780), which sets --font-inter. */ +@theme { + --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; +} + .dark { color-scheme: dark; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9265028..9f2c4c9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; import './globals.css'; import { NavBar } from '@/components/NavBar'; import { ToastContainer } from '@/components/Toast'; @@ -15,6 +16,16 @@ import { LogoWordmark } from '@/components/Logo'; validateConfig(); +// Self-hosted at build time by next/font (#780): no render-blocking request to +// a font CDN, the woff2 is preloaded, and `swap` paints immediately with a +// metric-adjusted fallback so the late swap doesn't shift layout. +const inter = Inter({ + subsets: ['latin'], + display: 'swap', + preload: true, + variable: '--font-inter', +}); + export const metadata: Metadata = { metadataBase: new URL('https://parashield.app'), title: 'Parashield — Parametric Insurance on Stellar', @@ -64,7 +75,7 @@ const FooterFallback = ( export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + Skip to main content diff --git a/src/components/Accordion.tsx b/src/components/Accordion.tsx new file mode 100644 index 0000000..5d1c374 --- /dev/null +++ b/src/components/Accordion.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useId, useState, type ReactNode } from 'react'; +import { ChevronDown } from 'lucide-react'; + +interface AccordionProps { + title: ReactNode; + children: ReactNode; + defaultOpen?: boolean; + className?: string; +} + +/** + * Expandable section (#781). The toggle is a real + + {/* Always rendered (hidden when closed) so aria-controls never points at + a missing element (same reasoning as NavBar's mobile menu, #692). */} + + + ); +} 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;