SPB Git

Repository Public

common.tsx 6.8 KB 94 lines tsx

1// Pieces shared by the share-link and deposit-portal screens of the signed-in owner.2import QRCode from 'qrcode';3import { type ReactNode, useMemo, useState } from 'react';4import { Glyph } from '../glyphs.tsx';5import { useT } from '../i18n.ts';6import { Button, CopyField, cx, Field } from '../ui.tsx';78export const inputCls = 'h-10 w-full rounded-xl border border-line-strong bg-surface px-3 text-base sm:text-sm text-ink placeholder:text-ink-3 focus:border-accent focus:outline-none focus:ring-4 focus:ring-accent/15 disabled:opacity-60';910/** QR code drawn as inline SVG (no data: URL, nothing fetched). Always dark on white so that cameras read it in dark mode too. */11export function QrCode({ text, size = 176, label }: { text: string; size?: number; label: string }) {12  const { n, d } = useMemo(() => {13    const m = QRCode.create(text, { errorCorrectionLevel: 'M' }).modules;14    let path = '';15    for (let y = 0; y < m.size; y++) for (let x = 0; x < m.size; x++) if (m.data[y * m.size + x]) path += `M${x} ${y}h1v1h-1z`;16    return { n: m.size, d: path };17  }, [text]);18  return <svg role="img" aria-label={label} width={size} height={size} viewBox={`-2 -2 ${n + 4} ${n + 4}`} shapeRendering="crispEdges" className="rounded-xl" data-testid="qr"><rect x={-2} y={-2} width={n + 4} height={n + 4} fill="#ffffff" /><path d={d} fill="#000000" /></svg>;19}2021/** A link that the server returns once: copy it, hand it to the system share sheet, or scan it. */22export function LinkOnce({ url, title, qr }: { url: string; title: string; qr?: boolean }) {23  const t = useT();24  const canShare = typeof navigator.share === 'function';25  return (26    <div className="space-y-3" data-testid="link-once">27      <CopyField value={url} label={t('sharing.link.label')} />28      <p className="flex items-start gap-2 rounded-xl bg-warn-soft px-3 py-2.5 text-sm text-warn"><Glyph name="warning" size={18} className="mt-px shrink-0" /><span>{t('sharing.link.once')}</span></p>29      {qr ? <div className="flex flex-col items-center gap-2 rounded-2xl border border-line py-4"><QrCode text={url} label={t('sharing.link.qr')} /><p className="text-xs text-ink-3">{t('sharing.link.qrHint')}</p></div> : null}30      {canShare ? <Button icon="share" className="w-full" onClick={() => void navigator.share({ title, url }).catch(() => {})}>{t('sharing.link.native')}</Button> : null}31    </div>32  );33}3435/** "iPhone · Safari" from a User-Agent header. */36export function uaSummary(ua: string, unknown: string): string {37  if (!ua) return unknown;38  const device = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Macintosh|Mac OS X/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows' : /Linux|X11/.test(ua) ? 'Linux' : '';39  const browser = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\/|CriOS\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : /curl|wget|python|node|bot/i.test(ua) ? ua.split(/[ /]/)[0]! : '';40  return [device, browser].filter(Boolean).join(' · ') || ua.slice(0, 40);41}4243/** <input type="datetime-local"> value ↔ ISO instant, in the browser's time zone. */44export const toLocalInput = (iso: string | null | undefined): string => {45  if (!iso) return '';46  const d = new Date(iso), p = (n: number) => String(n).padStart(2, '0');47  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;48};49export const fromLocalInput = (v: string): string | null => { if (!v) return null; const d = new Date(v); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };5051export type ExpiryChoice = 'never' | '1' | '7' | '30' | 'date';52/** Expiry as quick choices or a precise date. `resolve` turns the pair into the ISO instant sent to the API. */53export const resolveExpiry = (choice: ExpiryChoice, date: string): string | null =>54  choice === 'never' ? null : choice === 'date' ? fromLocalInput(date) : new Date(Date.now() + Number(choice) * 86_400_000).toISOString();55export function ExpiryField({ choice, date, onChange, error }: { choice: ExpiryChoice; date: string; onChange: (c: ExpiryChoice, d: string) => void; error?: string | null }) {56  const t = useT();57  const opts: ExpiryChoice[] = ['never', '1', '7', '30', 'date'];58  return (59    <Field label={t('sharing.expiry.label')} error={error}>{(id, desc) => (60      <div className="space-y-2">61        <div role="radiogroup" aria-labelledby={undefined} aria-label={t('sharing.expiry.label')} className="flex flex-wrap gap-1.5">62          {opts.map((o) => (63            <button key={o} type="button" role="radio" aria-checked={choice === o} onClick={() => onChange(o, o === 'date' && !date ? toLocalInput(new Date(Date.now() + 7 * 86_400_000).toISOString()) : date)}64              className={cx('h-9 rounded-full border px-3 text-sm transition', choice === o ? 'border-accent bg-accent-soft font-medium text-accent' : 'border-line-strong text-ink-2 hover:bg-hover')}>{t(`sharing.expiry.${o}`)}</button>65          ))}66        </div>67        {choice === 'date' ? <input id={id} aria-describedby={desc} type="datetime-local" value={date} onChange={(e) => onChange('date', e.target.value)} className={inputCls} /> : <input id={id} type="hidden" />}68      </div>69    )}</Field>70  );71}7273/** Titled block of a long form. */74export function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) {75  return (76    <fieldset className="space-y-4 border-t border-line pt-5 first:border-t-0 first:pt-0">77      <legend className="float-left mb-1 w-full"><span className="block text-sm font-semibold text-ink">{title}</span>{hint ? <span className="mt-0.5 block text-xs text-ink-3">{hint}</span> : null}</legend>78      <div className="clear-both space-y-4">{children}</div>79    </fieldset>80  );81}82/** Number input bound to a string state (empty allowed = "no limit"). */83export function NumberField({ label, hint, value, onChange, min = 1, max, step, placeholder, error, suffix }: { label: string; hint?: string; value: string; onChange: (v: string) => void; min?: number; max?: number; step?: number | 'any'; placeholder?: string; error?: string | null; suffix?: string }) {84  return (85    <Field label={label} hint={hint} error={error}>{(id, d) => (86      <div className="relative">87        <input id={id} aria-describedby={d} aria-invalid={!!error || undefined} type="number" inputMode={step === 'any' ? 'decimal' : 'numeric'} min={min} max={max} step={step} placeholder={placeholder} value={value} onChange={(e) => onChange(e.target.value)} className={cx(inputCls, suffix && 'pr-12')} />88        {suffix ? <span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-ink-3">{suffix}</span> : null}89      </div>90    )}</Field>91  );92}93export function useFlag(initial = false): [boolean, () => void, () => void] { const [v, set] = useState(initial); return [v, () => set(true), () => set(false)]; }94
Files main
Menu

Theme