- New component renders button + consent modal + spinner + attribution - Best-effort warning shown once per session for stock categories - Hidden if not premium or category kind != 'priced' - Consent persisted per-profile in user_preferences.price_fetching_consent - Manual unit_price input remains active in all paths - 17 vitest tests (no RTL/jsdom — logged MEDIUM in decisions-log.md) - Wired into SnapshotLineRow/SnapshotEditor/SnapshotEditPage - asset_type hardcoded to 'stock' pending category schema extension (MEDIUM) Closes #158
287 lines
9.4 KiB
TypeScript
287 lines
9.4 KiB
TypeScript
// 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<boolean> {
|
|
try {
|
|
const raw = await getPreference(CONSENT_KEY);
|
|
return !!raw;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Persist consent (consented_at + version shape). */
|
|
async function writeConsent(): Promise<void> {
|
|
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<PriceError | null>(null);
|
|
const [attribution, setAttribution] = useState<string | null>(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 (
|
|
<div className="flex flex-col gap-1">
|
|
{/* Stock best-effort warning — shown once per session, dismissable */}
|
|
{assetType === "stock" && showBestEffortWarning && (
|
|
<div className="flex items-start gap-1 text-[10px] text-[var(--muted-foreground)] bg-[var(--muted)]/60 rounded px-2 py-1">
|
|
<span className="flex-1">{t("balance.priceFetching.bestEffortNotice")}</span>
|
|
<button
|
|
type="button"
|
|
aria-label={t("common.close")}
|
|
onClick={dismissBestEffortWarning}
|
|
className="shrink-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* Fetch button */}
|
|
<button
|
|
type="button"
|
|
onClick={handleClick}
|
|
disabled={isFetching}
|
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-[var(--border)] text-xs font-medium text-[var(--foreground)] bg-[var(--card)] hover:bg-[var(--muted)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
aria-label={t("balance.priceFetching.button")}
|
|
>
|
|
{isFetching ? (
|
|
<Loader2 size={12} className="animate-spin" />
|
|
) : null}
|
|
{t("balance.priceFetching.button")}
|
|
{/* Best-effort badge (stock only) */}
|
|
{assetType === "stock" && (
|
|
<span className="ml-0.5 text-[9px] uppercase tracking-wide px-1 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
|
best-effort
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Attribution line — shown after a successful fetch */}
|
|
{attribution && !isFetching && (
|
|
<span className="text-[10px] text-[var(--muted-foreground)]">
|
|
{attribution}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Inline error message */}
|
|
{errorMessage && !isFetching && (
|
|
<p
|
|
role="alert"
|
|
className="text-xs text-[var(--negative)] mt-0.5"
|
|
data-testid="price-fetch-error"
|
|
>
|
|
{errorMessage}
|
|
</p>
|
|
)}
|
|
|
|
{/* Consent modal — rendered inline, portaled via fixed positioning */}
|
|
{showConsentModal && (
|
|
<ConsentModal
|
|
onAccept={handleConsentAccept}
|
|
onDecline={handleConsentDecline}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConsentModal — minimal overlay, no external modal lib required
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ConsentModal({
|
|
onAccept,
|
|
onDecline,
|
|
}: {
|
|
onAccept: () => void;
|
|
onDecline: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="price-consent-title"
|
|
>
|
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-md w-full p-6">
|
|
<h2
|
|
id="price-consent-title"
|
|
className="text-base font-semibold mb-2"
|
|
>
|
|
{t("balance.priceFetching.consent.title")}
|
|
</h2>
|
|
<p className="text-sm text-[var(--muted-foreground)] mb-5">
|
|
{t("balance.priceFetching.consent.body")}
|
|
</p>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onDecline}
|
|
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
|
|
>
|
|
{t("balance.priceFetching.consent.decline")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onAccept}
|
|
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
|
>
|
|
{t("balance.priceFetching.consent.accept")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|