Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
13 changes: 12 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -64,7 +75,7 @@ const FooterFallback = (

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<html lang="en" className={`dark ${inter.variable}`}>
<body className="bg-gray-950 text-white dark:bg-gray-950 dark:text-white bg-white text-gray-950 antialiased">
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:bg-teal-500 focus:px-4 focus:py-2 focus:text-white">Skip to main content</a>
<Analytics />
Expand Down
50 changes: 50 additions & 0 deletions src/components/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -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 <button>, so Tab focuses it
* and Enter/Space activate it natively, and it reports its state through
* aria-expanded/aria-controls. Use this instead of a clickable <div>.
*/
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 (
<div className={className}>
<h3>
<button
type="button"
id={buttonId}
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls={panelId}
className="flex w-full items-center justify-between gap-4 rounded-lg py-3 text-left text-sm font-semibold text-gray-950 dark:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-400"
>
{title}
<ChevronDown
size={16}
aria-hidden="true"
className={`shrink-0 text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
</h3>
{/* Always rendered (hidden when closed) so aria-controls never points at
a missing element (same reasoning as NavBar's mobile menu, #692). */}
<div id={panelId} role="region" aria-labelledby={buttonId} hidden={!open} className="pb-3">
{children}
</div>
</div>
);
}
14 changes: 11 additions & 3 deletions src/components/BuyPolicyModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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('');
}
Expand Down Expand Up @@ -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' : ''}`}
Expand All @@ -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}
Expand Down
9 changes: 8 additions & 1 deletion src/components/DepositModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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`);
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/components/PolicyStatusTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'}
</button>
Expand Down
4 changes: 3 additions & 1 deletion src/components/WithdrawModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions src/lib/claimsExport.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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');
}
40 changes: 40 additions & 0 deletions src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input type="date">` 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)}%`;
}
Expand Down
35 changes: 35 additions & 0 deletions src/lib/numberInput.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>) {
if (BLOCKED_KEYS.has(e.key)) e.preventDefault();
}

function blockNonDecimalPaste(e: ClipboardEvent<HTMLInputElement>) {
const text = e.clipboardData.getData('text');
if (text && !isPlainDecimal(text)) e.preventDefault();
}

/**
* Spread onto amount/currency `<input type="number">`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;