// PriceFetchControl — fetch-price button with consent modal, spinner, // best-effort warning (stocks only), and attribution display. // // Issue #158 — wires into SnapshotLineRow for priced-kind categories. // // Behavior rules (from spec §1 + ADR 0011): // - Hidden when useIsPremium() === false OR categoryKind !== 'priced' // - First use requires explicit consent (persisted in user_preferences) // - For stock assetType: shows a "best-effort" badge + dismissable warning // (once per session, in-memory only — NOT persisted) // - Manual unit_price input stays active in all error paths (this component // is purely additive) import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Loader2, X } from "lucide-react"; import { useIsPremium } from "../../hooks/useIsPremium"; import { prices } from "../../services/balance.service"; import type { PriceError } from "../../services/balance.service"; import { getPreference, setPreference, } from "../../services/userPreferenceService"; // --------------------------------------------------------------------------- // Module-level session dismiss state for best-effort warning (ADR 0011 §garde-fous) // --------------------------------------------------------------------------- let _bestEffortDismissedThisSession = false; // Exported for tests — resets the in-memory dismiss flag. export function __resetBestEffortDismissForTests(): void { _bestEffortDismissedThisSession = false; } // Consent preference key (per-profile via per-profile SQLite DB). const CONSENT_KEY = "price_fetching_consent"; interface PriceFetchControlProps { symbol: string; date: string; // YYYY-MM-DD categoryKind: "simple" | "priced"; assetType: "stock" | "crypto"; onPriceFetched: (price: number, currency: string) => void; } /** * Check whether the user has already given consent for price fetching. * Returns true when a non-empty consent record exists in user_preferences. */ async function hasConsent(): Promise { try { const raw = await getPreference(CONSENT_KEY); return !!raw; } catch { return false; } } /** Persist consent (consented_at + version shape). */ async function writeConsent(): Promise { await setPreference( CONSENT_KEY, JSON.stringify({ consented_at: new Date().toISOString(), version: 1 }) ); } export default function PriceFetchControl({ symbol, date, categoryKind, assetType, onPriceFetched, }: PriceFetchControlProps) { const { t, i18n } = useTranslation(); const isPremium = useIsPremium(); // Local UI state const [showConsentModal, setShowConsentModal] = useState(false); const [isFetching, setIsFetching] = useState(false); const [error, setError] = useState(null); const [attribution, setAttribution] = useState(null); // Whether the best-effort warning is currently shown (stock only). const [showBestEffortWarning, setShowBestEffortWarning] = useState( assetType === "stock" && !_bestEffortDismissedThisSession ); // Keep the warning display in sync when the session-level flag is updated // from a sibling instance (e.g. multiple priced rows dismiss in sequence). useEffect(() => { if (assetType === "stock") { setShowBestEffortWarning(!_bestEffortDismissedThisSession); } }, [assetType]); // Hidden for non-premium users or non-priced categories. if (!isPremium || categoryKind !== "priced") { return null; } const dismissBestEffortWarning = () => { _bestEffortDismissedThisSession = true; setShowBestEffortWarning(false); }; /** Actually trigger the price fetch (called after consent is confirmed). */ const doFetch = async () => { setIsFetching(true); setError(null); setAttribution(null); const result = await prices.fetchPrice(symbol, date); setIsFetching(false); if (result.ok) { onPriceFetched(result.price, result.currency); // Show attribution with the fetched_at timestamp formatted to locale date. const fetchedAt = new Date(result.fetched_at); const formattedDate = fetchedAt.toLocaleDateString( i18n.language === "fr" ? "fr-CA" : "en-CA" ); setAttribution(t("balance.priceFetching.attribution", { date: formattedDate })); } else { setError(result.error); } }; /** Handle the main button click: check consent, then fetch or show modal. */ const handleClick = async () => { if (isFetching) return; setError(null); setAttribution(null); const consented = await hasConsent(); if (!consented) { setShowConsentModal(true); } else { await doFetch(); } }; /** User accepted in the consent modal. */ const handleConsentAccept = async () => { setShowConsentModal(false); try { await writeConsent(); } catch { // Non-blocking — proceed with fetch even if pref write failed. } await doFetch(); }; /** User declined in the consent modal. */ const handleConsentDecline = () => { setShowConsentModal(false); }; // Build the error i18n args. const errorMessage = error ? t(error.i18nKey, { seconds: "retry_after_s" in error ? Math.ceil(error.retry_after_s) : undefined, minutes: "retry_after_s" in error ? Math.ceil(error.retry_after_s / 60) : undefined, defaultValue: error.i18nKey, }) : null; return (
{/* Stock best-effort warning — shown once per session, dismissable */} {assetType === "stock" && showBestEffortWarning && (
{t("balance.priceFetching.bestEffortNotice")}
)}
{/* Fetch button */} {/* Attribution line — shown after a successful fetch */} {attribution && !isFetching && ( {attribution} )}
{/* Inline error message */} {errorMessage && !isFetching && (

{errorMessage}

)} {/* Consent modal — rendered inline, portaled via fixed positioning */} {showConsentModal && ( )}
); } // --------------------------------------------------------------------------- // ConsentModal — minimal overlay, no external modal lib required // --------------------------------------------------------------------------- function ConsentModal({ onAccept, onDecline, }: { onAccept: () => void; onDecline: () => void; }) { const { t } = useTranslation(); return (

{t("balance.priceFetching.consent.body")}

); }