@orangecheck/design 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/format.js CHANGED
@@ -1,5 +1,41 @@
1
1
  'use strict';
2
2
 
3
+ var react = require('react');
4
+
5
+ // src/format/use-spot-price.ts
6
+ function useSpotPrice(opts = {}) {
7
+ const { endpoint = "/api/price/btc-usd", intervalMs = 6e4 } = opts;
8
+ const [state, setState] = react.useState({
9
+ btcUsd: REFERENCE_BTC_USD,
10
+ fetchedAt: null
11
+ });
12
+ react.useEffect(() => {
13
+ let cancelled = false;
14
+ async function pull() {
15
+ try {
16
+ const res = await fetch(endpoint);
17
+ if (!res.ok) return;
18
+ const json = await res.json();
19
+ if (cancelled) return;
20
+ if (typeof json.btc_usd === "number" && json.btc_usd > 0) {
21
+ setState({
22
+ btcUsd: Math.round(json.btc_usd),
23
+ fetchedAt: json.fetched_at ?? null
24
+ });
25
+ }
26
+ } catch {
27
+ }
28
+ }
29
+ void pull();
30
+ const t = setInterval(pull, intervalMs);
31
+ return () => {
32
+ cancelled = true;
33
+ clearInterval(t);
34
+ };
35
+ }, [endpoint, intervalMs]);
36
+ return state;
37
+ }
38
+
3
39
  // src/format/index.ts
4
40
  var REFERENCE_BTC_USD = 95e3;
5
41
  function formatSats(sats) {
@@ -28,6 +64,16 @@ function usdToSats(usd, btcUsd = REFERENCE_BTC_USD) {
28
64
  function priceBoth(sats, btcUsd = REFERENCE_BTC_USD) {
29
65
  return `${formatSats(sats)} sats \xB7 ${satsToUsd(sats, btcUsd)}`;
30
66
  }
67
+ function asOf(iso) {
68
+ if (!iso) return null;
69
+ const t = new Date(iso);
70
+ if (Number.isNaN(t.getTime())) return null;
71
+ return `as of ${t.toLocaleTimeString(void 0, {
72
+ hour: "2-digit",
73
+ minute: "2-digit",
74
+ hour12: false
75
+ })}`;
76
+ }
31
77
  function relativeTime(when) {
32
78
  const t = when instanceof Date ? when.getTime() : new Date(when).getTime();
33
79
  if (Number.isNaN(t)) return "";
@@ -56,6 +102,7 @@ function explorerUrl(target, network = "mainnet") {
56
102
  }
57
103
 
58
104
  exports.REFERENCE_BTC_USD = REFERENCE_BTC_USD;
105
+ exports.asOf = asOf;
59
106
  exports.explorerUrl = explorerUrl;
60
107
  exports.formatSats = formatSats;
61
108
  exports.formatSatsCompact = formatSatsCompact;
@@ -64,5 +111,6 @@ exports.relativeTime = relativeTime;
64
111
  exports.satsToUsd = satsToUsd;
65
112
  exports.shortenAddress = shortenAddress;
66
113
  exports.usdToSats = usdToSats;
114
+ exports.useSpotPrice = useSpotPrice;
67
115
  //# sourceMappingURL=format.js.map
68
116
  //# sourceMappingURL=format.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/format/index.ts"],"names":[],"mappings":"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","file":"format.js","sourcesContent":["/**\n * @orangecheck/design/format — Bitcoin/money display + formatting utilities.\n *\n * Presentational only. Lifted from the canonical implementations across the\n * family (oc-me-web's utils, oc-vault-web's billing) and standardized so every\n * site formats sats, prices, addresses, and times identically. NOTE: crypto\n * (BIP-322 signing, bech32 validation) deliberately does NOT live here — that\n * belongs in a bitcoin/crypto library, not the design system.\n */\n\n/** Reference BTC/USD rate for sats↔USD display. Override per call when a live\n * rate is available; this is a display affordance, not a price oracle. */\nexport const REFERENCE_BTC_USD = 95_000;\n\n/** Format a satoshi integer as a thousands-separated string (\"12,408\"). */\nexport function formatSats(sats: number): string {\n return new Intl.NumberFormat('en-US').format(Math.round(sats));\n}\n\n/** Compact sats for buttons/chips: \"7k sats\", \"1.5k sats\", \"999 sats\". */\nexport function formatSatsCompact(sats: number): string {\n const n = Math.round(sats);\n if (n < 1000) return `${n} sats`;\n const k = n / 1000;\n return `${Number.isInteger(k) ? k : k.toFixed(1)}k sats`;\n}\n\n/** Convert sats to a USD currency string using the reference (or given) rate. */\nexport function satsToUsd(sats: number, btcUsd: number = REFERENCE_BTC_USD): string {\n const usd = (sats / 100_000_000) * btcUsd;\n if (usd > 0 && usd < 0.01) return '< $0.01';\n // Whole dollars once >= $100, cents below. min must never exceed max.\n const fractionDigits = usd >= 100 ? 0 : 2;\n return new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: 'USD',\n minimumFractionDigits: fractionDigits,\n maximumFractionDigits: fractionDigits,\n }).format(usd);\n}\n\n/** Convert a USD amount to sats using the reference (or given) rate. */\nexport function usdToSats(usd: number, btcUsd: number = REFERENCE_BTC_USD): number {\n return Math.round((usd / btcUsd) * 100_000_000);\n}\n\n/** Both denominations as one label: \"7,000 sats · $6.65\". */\nexport function priceBoth(sats: number, btcUsd: number = REFERENCE_BTC_USD): string {\n return `${formatSats(sats)} sats · ${satsToUsd(sats, btcUsd)}`;\n}\n\n/** Compact relative time — \"now\", \"3m ago\", \"2h ago\", \"4d ago\", or a date. */\nexport function relativeTime(when: string | number | Date): string {\n const t = when instanceof Date ? when.getTime() : new Date(when).getTime();\n if (Number.isNaN(t)) return '';\n const diff = Date.now() - t;\n const m = Math.floor(diff / 60_000);\n if (m < 1) return 'now';\n if (m < 60) return `${m}m ago`;\n const h = Math.floor(m / 60);\n if (h < 24) return `${h}h ago`;\n const d = Math.floor(h / 24);\n if (d < 30) return `${d}d ago`;\n const mo = Math.floor(d / 30);\n if (mo < 12) return `${mo}mo ago`;\n return `${Math.floor(mo / 12)}y ago`;\n}\n\n/**\n * Truncate a Bitcoin/Lightning address (or any long id) for display:\n * \"bc1qxy…wxyz\". Defaults match the family's most common rule (8 head / 4 tail).\n */\nexport function shortenAddress(value: string, head = 8, tail = 4): string {\n if (!value) return '';\n if (value.length <= head + tail + 1) return value;\n return `${value.slice(0, head)}…${value.slice(-tail)}`;\n}\n\nexport type ExplorerNetwork = 'mainnet' | 'testnet' | 'signet';\nexport type ExplorerTarget =\n | { address: string }\n | { tx: string }\n | { block: string | number };\n\n/** A mempool.space explorer URL for an address, tx, or block. */\nexport function explorerUrl(target: ExplorerTarget, network: ExplorerNetwork = 'mainnet'): string {\n const base =\n network === 'mainnet'\n ? 'https://mempool.space'\n : `https://mempool.space/${network}`;\n if ('address' in target) return `${base}/address/${target.address}`;\n if ('tx' in target) return `${base}/tx/${target.tx}`;\n return `${base}/block/${target.block}`;\n}\n"]}
1
+ {"version":3,"sources":["../src/format/use-spot-price.ts","../src/format/index.ts"],"names":["useState","useEffect"],"mappings":"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","file":"format.js","sourcesContent":["/**\n * useSpotPrice — live BTC/USD spot rate for display surfaces.\n *\n * Pulls a JSON shape `{ btc_usd: number, fetched_at?: string | null }`\n * from a consumer-hosted endpoint (default `/api/price/btc-usd`),\n * refreshing every 60 seconds on a setInterval. The initial paint\n * reads `REFERENCE_BTC_USD` so the first frame is never NaN.\n *\n * Consumer contract (read this when wiring):\n *\n * The hook makes a relative fetch to `endpoint`. Every consumer that\n * mounts this MUST host that route locally — typically a Next.js\n * pages-router API route that proxies mempool.space's spot feed and\n * caches in-process. The canonical implementation lives at\n * `oc-vault-web/src/pages/api/price/btc-usd.ts` (and the mirror in\n * `oc-me-web`). Both also host the matching server-side fetch +\n * cache in `src/lib/price/feed.ts` — that part stays per-consumer\n * because it's Node-only code that doesn't belong in a UI package.\n *\n * If you don't have the route yet, pass an explicit endpoint\n * (e.g. `useSpotPrice({ endpoint: 'https://other-site.ochk.io/api/price/btc-usd' })`)\n * pointing at a sibling site's endpoint — they're CORS-open.\n *\n * Returns the rich `{ btcUsd, fetchedAt }` shape rather than a bare\n * number so callers that want a provenance caption can render\n * `asOf(fetchedAt)` without a second hook.\n */\n\n'use client';\n\nimport { useEffect, useState } from 'react';\n\nimport { REFERENCE_BTC_USD } from './index';\n\nexport interface SpotPrice {\n /** BTC/USD rate. Falls back to `REFERENCE_BTC_USD` until the first\n * fetch lands so the initial paint never reads NaN. */\n btcUsd: number;\n /** ISO timestamp the rate was fetched upstream. `null` until the\n * first successful fetch. Pair with `asOf()` for \"as of HH:MM\". */\n fetchedAt: string | null;\n}\n\nexport interface UseSpotPriceOptions {\n /** Override the endpoint path. Defaults to `/api/price/btc-usd` —\n * the family-canonical proxy route. Useful when consuming a sibling\n * site's endpoint cross-origin, or when self-hosting the proxy at\n * a non-default path. */\n endpoint?: string;\n /** Override the refresh cadence in milliseconds. Defaults to 60_000,\n * matching the upstream mempool.space cache TTL. Lower than ~10s\n * is wasted bandwidth — the underlying feed doesn't refresh faster. */\n intervalMs?: number;\n}\n\ninterface SpotPriceResponse {\n btc_usd?: number;\n fetched_at?: string | null;\n}\n\n/**\n * Hook · returns the latest BTC/USD spot rate from `endpoint`.\n *\n * Behaviour:\n * - First mount: synchronous return of `REFERENCE_BTC_USD` (no NaN),\n * then an async fetch updates state.\n * - Every `intervalMs` thereafter, a background fetch keeps the rate\n * fresh while the tab is alive.\n * - Network blips keep the last-good value — no flash to fallback.\n * - Unmount cancels both the in-flight read (`cancelled` flag) and\n * the interval timer.\n */\nexport function useSpotPrice(opts: UseSpotPriceOptions = {}): SpotPrice {\n const { endpoint = '/api/price/btc-usd', intervalMs = 60_000 } = opts;\n const [state, setState] = useState<SpotPrice>({\n btcUsd: REFERENCE_BTC_USD,\n fetchedAt: null,\n });\n\n useEffect(() => {\n let cancelled = false;\n async function pull() {\n try {\n const res = await fetch(endpoint);\n if (!res.ok) return;\n const json = (await res.json()) as SpotPriceResponse;\n if (cancelled) return;\n if (typeof json.btc_usd === 'number' && json.btc_usd > 0) {\n setState({\n btcUsd: Math.round(json.btc_usd),\n fetchedAt: json.fetched_at ?? null,\n });\n }\n } catch {\n // Network blip · keep the last good value, don't flash a\n // fallback. The next poll will recover.\n }\n }\n void pull();\n const t = setInterval(pull, intervalMs);\n return () => {\n cancelled = true;\n clearInterval(t);\n };\n }, [endpoint, intervalMs]);\n\n return state;\n}\n","/**\n * @orangecheck/design/format — Bitcoin/money display + formatting utilities.\n *\n * Presentational only. Lifted from the canonical implementations across the\n * family (oc-me-web's utils, oc-vault-web's billing) and standardized so every\n * site formats sats, prices, addresses, and times identically. NOTE: crypto\n * (BIP-322 signing, bech32 validation) deliberately does NOT live here — that\n * belongs in a bitcoin/crypto library, not the design system.\n *\n * Includes the `useSpotPrice` React hook for live BTC/USD display. The hook\n * fetches a consumer-hosted endpoint — see `use-spot-price.ts` for the\n * one-route-per-consumer contract.\n */\n\nexport { useSpotPrice, type SpotPrice, type UseSpotPriceOptions } from './use-spot-price';\n\n/** Reference BTC/USD rate for sats↔USD display. Override per call when a live\n * rate is available; this is a display affordance, not a price oracle. */\nexport const REFERENCE_BTC_USD = 95_000;\n\n/** Format a satoshi integer as a thousands-separated string (\"12,408\"). */\nexport function formatSats(sats: number): string {\n return new Intl.NumberFormat('en-US').format(Math.round(sats));\n}\n\n/** Compact sats for buttons/chips: \"7k sats\", \"1.5k sats\", \"999 sats\". */\nexport function formatSatsCompact(sats: number): string {\n const n = Math.round(sats);\n if (n < 1000) return `${n} sats`;\n const k = n / 1000;\n return `${Number.isInteger(k) ? k : k.toFixed(1)}k sats`;\n}\n\n/** Convert sats to a USD currency string using the reference (or given) rate. */\nexport function satsToUsd(sats: number, btcUsd: number = REFERENCE_BTC_USD): string {\n const usd = (sats / 100_000_000) * btcUsd;\n if (usd > 0 && usd < 0.01) return '< $0.01';\n // Whole dollars once >= $100, cents below. min must never exceed max.\n const fractionDigits = usd >= 100 ? 0 : 2;\n return new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: 'USD',\n minimumFractionDigits: fractionDigits,\n maximumFractionDigits: fractionDigits,\n }).format(usd);\n}\n\n/** Convert a USD amount to sats using the reference (or given) rate. */\nexport function usdToSats(usd: number, btcUsd: number = REFERENCE_BTC_USD): number {\n return Math.round((usd / btcUsd) * 100_000_000);\n}\n\n/** Both denominations as one label: \"7,000 sats · $6.65\". */\nexport function priceBoth(sats: number, btcUsd: number = REFERENCE_BTC_USD): string {\n return `${formatSats(sats)} sats · ${satsToUsd(sats, btcUsd)}`;\n}\n\n/**\n * \"as of HH:MM\" provenance footnote. Returns `null` for nullish or\n * unparseable input so callers can render nothing\n * (`{x && <span>{x}</span>}`) rather than gate on a magic empty\n * string. 24-hour, locale-aware. Pairs with `useSpotPrice().fetchedAt`\n * to caption a live-rate display.\n */\nexport function asOf(iso: string | null | undefined): string | null {\n if (!iso) return null;\n const t = new Date(iso);\n if (Number.isNaN(t.getTime())) return null;\n return `as of ${t.toLocaleTimeString(undefined, {\n hour: '2-digit',\n minute: '2-digit',\n hour12: false,\n })}`;\n}\n\n/** Compact relative time — \"now\", \"3m ago\", \"2h ago\", \"4d ago\", or a date. */\nexport function relativeTime(when: string | number | Date): string {\n const t = when instanceof Date ? when.getTime() : new Date(when).getTime();\n if (Number.isNaN(t)) return '';\n const diff = Date.now() - t;\n const m = Math.floor(diff / 60_000);\n if (m < 1) return 'now';\n if (m < 60) return `${m}m ago`;\n const h = Math.floor(m / 60);\n if (h < 24) return `${h}h ago`;\n const d = Math.floor(h / 24);\n if (d < 30) return `${d}d ago`;\n const mo = Math.floor(d / 30);\n if (mo < 12) return `${mo}mo ago`;\n return `${Math.floor(mo / 12)}y ago`;\n}\n\n/**\n * Truncate a Bitcoin/Lightning address (or any long id) for display:\n * \"bc1qxy…wxyz\". Defaults match the family's most common rule (8 head / 4 tail).\n */\nexport function shortenAddress(value: string, head = 8, tail = 4): string {\n if (!value) return '';\n if (value.length <= head + tail + 1) return value;\n return `${value.slice(0, head)}…${value.slice(-tail)}`;\n}\n\nexport type ExplorerNetwork = 'mainnet' | 'testnet' | 'signet';\nexport type ExplorerTarget =\n | { address: string }\n | { tx: string }\n | { block: string | number };\n\n/** A mempool.space explorer URL for an address, tx, or block. */\nexport function explorerUrl(target: ExplorerTarget, network: ExplorerNetwork = 'mainnet'): string {\n const base =\n network === 'mainnet'\n ? 'https://mempool.space'\n : `https://mempool.space/${network}`;\n if ('address' in target) return `${base}/address/${target.address}`;\n if ('tx' in target) return `${base}/tx/${target.tx}`;\n return `${base}/block/${target.block}`;\n}\n"]}
package/dist/format.mjs CHANGED
@@ -1,3 +1,39 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ // src/format/use-spot-price.ts
4
+ function useSpotPrice(opts = {}) {
5
+ const { endpoint = "/api/price/btc-usd", intervalMs = 6e4 } = opts;
6
+ const [state, setState] = useState({
7
+ btcUsd: REFERENCE_BTC_USD,
8
+ fetchedAt: null
9
+ });
10
+ useEffect(() => {
11
+ let cancelled = false;
12
+ async function pull() {
13
+ try {
14
+ const res = await fetch(endpoint);
15
+ if (!res.ok) return;
16
+ const json = await res.json();
17
+ if (cancelled) return;
18
+ if (typeof json.btc_usd === "number" && json.btc_usd > 0) {
19
+ setState({
20
+ btcUsd: Math.round(json.btc_usd),
21
+ fetchedAt: json.fetched_at ?? null
22
+ });
23
+ }
24
+ } catch {
25
+ }
26
+ }
27
+ void pull();
28
+ const t = setInterval(pull, intervalMs);
29
+ return () => {
30
+ cancelled = true;
31
+ clearInterval(t);
32
+ };
33
+ }, [endpoint, intervalMs]);
34
+ return state;
35
+ }
36
+
1
37
  // src/format/index.ts
2
38
  var REFERENCE_BTC_USD = 95e3;
3
39
  function formatSats(sats) {
@@ -26,6 +62,16 @@ function usdToSats(usd, btcUsd = REFERENCE_BTC_USD) {
26
62
  function priceBoth(sats, btcUsd = REFERENCE_BTC_USD) {
27
63
  return `${formatSats(sats)} sats \xB7 ${satsToUsd(sats, btcUsd)}`;
28
64
  }
65
+ function asOf(iso) {
66
+ if (!iso) return null;
67
+ const t = new Date(iso);
68
+ if (Number.isNaN(t.getTime())) return null;
69
+ return `as of ${t.toLocaleTimeString(void 0, {
70
+ hour: "2-digit",
71
+ minute: "2-digit",
72
+ hour12: false
73
+ })}`;
74
+ }
29
75
  function relativeTime(when) {
30
76
  const t = when instanceof Date ? when.getTime() : new Date(when).getTime();
31
77
  if (Number.isNaN(t)) return "";
@@ -53,6 +99,6 @@ function explorerUrl(target, network = "mainnet") {
53
99
  return `${base}/block/${target.block}`;
54
100
  }
55
101
 
56
- export { REFERENCE_BTC_USD, explorerUrl, formatSats, formatSatsCompact, priceBoth, relativeTime, satsToUsd, shortenAddress, usdToSats };
102
+ export { REFERENCE_BTC_USD, asOf, explorerUrl, formatSats, formatSatsCompact, priceBoth, relativeTime, satsToUsd, shortenAddress, usdToSats, useSpotPrice };
57
103
  //# sourceMappingURL=format.mjs.map
58
104
  //# sourceMappingURL=format.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/format/index.ts"],"names":[],"mappings":"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","file":"format.mjs","sourcesContent":["/**\n * @orangecheck/design/format — Bitcoin/money display + formatting utilities.\n *\n * Presentational only. Lifted from the canonical implementations across the\n * family (oc-me-web's utils, oc-vault-web's billing) and standardized so every\n * site formats sats, prices, addresses, and times identically. NOTE: crypto\n * (BIP-322 signing, bech32 validation) deliberately does NOT live here — that\n * belongs in a bitcoin/crypto library, not the design system.\n */\n\n/** Reference BTC/USD rate for sats↔USD display. Override per call when a live\n * rate is available; this is a display affordance, not a price oracle. */\nexport const REFERENCE_BTC_USD = 95_000;\n\n/** Format a satoshi integer as a thousands-separated string (\"12,408\"). */\nexport function formatSats(sats: number): string {\n return new Intl.NumberFormat('en-US').format(Math.round(sats));\n}\n\n/** Compact sats for buttons/chips: \"7k sats\", \"1.5k sats\", \"999 sats\". */\nexport function formatSatsCompact(sats: number): string {\n const n = Math.round(sats);\n if (n < 1000) return `${n} sats`;\n const k = n / 1000;\n return `${Number.isInteger(k) ? k : k.toFixed(1)}k sats`;\n}\n\n/** Convert sats to a USD currency string using the reference (or given) rate. */\nexport function satsToUsd(sats: number, btcUsd: number = REFERENCE_BTC_USD): string {\n const usd = (sats / 100_000_000) * btcUsd;\n if (usd > 0 && usd < 0.01) return '< $0.01';\n // Whole dollars once >= $100, cents below. min must never exceed max.\n const fractionDigits = usd >= 100 ? 0 : 2;\n return new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: 'USD',\n minimumFractionDigits: fractionDigits,\n maximumFractionDigits: fractionDigits,\n }).format(usd);\n}\n\n/** Convert a USD amount to sats using the reference (or given) rate. */\nexport function usdToSats(usd: number, btcUsd: number = REFERENCE_BTC_USD): number {\n return Math.round((usd / btcUsd) * 100_000_000);\n}\n\n/** Both denominations as one label: \"7,000 sats · $6.65\". */\nexport function priceBoth(sats: number, btcUsd: number = REFERENCE_BTC_USD): string {\n return `${formatSats(sats)} sats · ${satsToUsd(sats, btcUsd)}`;\n}\n\n/** Compact relative time — \"now\", \"3m ago\", \"2h ago\", \"4d ago\", or a date. */\nexport function relativeTime(when: string | number | Date): string {\n const t = when instanceof Date ? when.getTime() : new Date(when).getTime();\n if (Number.isNaN(t)) return '';\n const diff = Date.now() - t;\n const m = Math.floor(diff / 60_000);\n if (m < 1) return 'now';\n if (m < 60) return `${m}m ago`;\n const h = Math.floor(m / 60);\n if (h < 24) return `${h}h ago`;\n const d = Math.floor(h / 24);\n if (d < 30) return `${d}d ago`;\n const mo = Math.floor(d / 30);\n if (mo < 12) return `${mo}mo ago`;\n return `${Math.floor(mo / 12)}y ago`;\n}\n\n/**\n * Truncate a Bitcoin/Lightning address (or any long id) for display:\n * \"bc1qxy…wxyz\". Defaults match the family's most common rule (8 head / 4 tail).\n */\nexport function shortenAddress(value: string, head = 8, tail = 4): string {\n if (!value) return '';\n if (value.length <= head + tail + 1) return value;\n return `${value.slice(0, head)}…${value.slice(-tail)}`;\n}\n\nexport type ExplorerNetwork = 'mainnet' | 'testnet' | 'signet';\nexport type ExplorerTarget =\n | { address: string }\n | { tx: string }\n | { block: string | number };\n\n/** A mempool.space explorer URL for an address, tx, or block. */\nexport function explorerUrl(target: ExplorerTarget, network: ExplorerNetwork = 'mainnet'): string {\n const base =\n network === 'mainnet'\n ? 'https://mempool.space'\n : `https://mempool.space/${network}`;\n if ('address' in target) return `${base}/address/${target.address}`;\n if ('tx' in target) return `${base}/tx/${target.tx}`;\n return `${base}/block/${target.block}`;\n}\n"]}
1
+ {"version":3,"sources":["../src/format/use-spot-price.ts","../src/format/index.ts"],"names":[],"mappings":";;;AAwEO,SAAS,YAAA,CAAa,IAAA,GAA4B,EAAC,EAAc;AACpE,EAAA,MAAM,EAAE,QAAA,GAAW,oBAAA,EAAsB,UAAA,GAAa,KAAO,GAAI,IAAA;AACjE,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,CAAA,GAAI,QAAA,CAAoB;AAAA,IAC1C,MAAA,EAAQ,iBAAA;AAAA,IACR,SAAA,EAAW;AAAA,GACd,CAAA;AAED,EAAA,SAAA,CAAU,MAAM;AACZ,IAAA,IAAI,SAAA,GAAY,KAAA;AAChB,IAAA,eAAe,IAAA,GAAO;AAClB,MAAA,IAAI;AACA,QAAA,MAAM,GAAA,GAAM,MAAM,KAAA,CAAM,QAAQ,CAAA;AAChC,QAAA,IAAI,CAAC,IAAI,EAAA,EAAI;AACb,QAAA,MAAM,IAAA,GAAQ,MAAM,GAAA,CAAI,IAAA,EAAK;AAC7B,QAAA,IAAI,SAAA,EAAW;AACf,QAAA,IAAI,OAAO,IAAA,CAAK,OAAA,KAAY,QAAA,IAAY,IAAA,CAAK,UAAU,CAAA,EAAG;AACtD,UAAA,QAAA,CAAS;AAAA,YACL,MAAA,EAAQ,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,OAAO,CAAA;AAAA,YAC/B,SAAA,EAAW,KAAK,UAAA,IAAc;AAAA,WACjC,CAAA;AAAA,QACL;AAAA,MACJ,CAAA,CAAA,MAAQ;AAAA,MAGR;AAAA,IACJ;AACA,IAAA,KAAK,IAAA,EAAK;AACV,IAAA,MAAM,CAAA,GAAI,WAAA,CAAY,IAAA,EAAM,UAAU,CAAA;AACtC,IAAA,OAAO,MAAM;AACT,MAAA,SAAA,GAAY,IAAA;AACZ,MAAA,aAAA,CAAc,CAAC,CAAA;AAAA,IACnB,CAAA;AAAA,EACJ,CAAA,EAAG,CAAC,QAAA,EAAU,UAAU,CAAC,CAAA;AAEzB,EAAA,OAAO,KAAA;AACX;;;ACzFO,IAAM,iBAAA,GAAoB;AAG1B,SAAS,WAAW,IAAA,EAAsB;AAC7C,EAAA,OAAO,IAAI,KAAK,YAAA,CAAa,OAAO,EAAE,MAAA,CAAO,IAAA,CAAK,KAAA,CAAM,IAAI,CAAC,CAAA;AACjE;AAGO,SAAS,kBAAkB,IAAA,EAAsB;AACpD,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,IAAI,CAAA;AACzB,EAAA,IAAI,CAAA,GAAI,GAAA,EAAM,OAAO,CAAA,EAAG,CAAC,CAAA,KAAA,CAAA;AACzB,EAAA,MAAM,IAAI,CAAA,GAAI,GAAA;AACd,EAAA,OAAO,CAAA,EAAG,OAAO,SAAA,CAAU,CAAC,IAAI,CAAA,GAAI,CAAA,CAAE,OAAA,CAAQ,CAAC,CAAC,CAAA,MAAA,CAAA;AACpD;AAGO,SAAS,SAAA,CAAU,IAAA,EAAc,MAAA,GAAiB,iBAAA,EAA2B;AAChF,EAAA,MAAM,GAAA,GAAO,OAAO,GAAA,GAAe,MAAA;AACnC,EAAA,IAAI,GAAA,GAAM,CAAA,IAAK,GAAA,GAAM,IAAA,EAAM,OAAO,SAAA;AAElC,EAAA,MAAM,cAAA,GAAiB,GAAA,IAAO,GAAA,GAAM,CAAA,GAAI,CAAA;AACxC,EAAA,OAAO,IAAI,IAAA,CAAK,YAAA,CAAa,OAAA,EAAS;AAAA,IAClC,KAAA,EAAO,UAAA;AAAA,IACP,QAAA,EAAU,KAAA;AAAA,IACV,qBAAA,EAAuB,cAAA;AAAA,IACvB,qBAAA,EAAuB;AAAA,GAC1B,CAAA,CAAE,MAAA,CAAO,GAAG,CAAA;AACjB;AAGO,SAAS,SAAA,CAAU,GAAA,EAAa,MAAA,GAAiB,iBAAA,EAA2B;AAC/E,EAAA,OAAO,IAAA,CAAK,KAAA,CAAO,GAAA,GAAM,MAAA,GAAU,GAAW,CAAA;AAClD;AAGO,SAAS,SAAA,CAAU,IAAA,EAAc,MAAA,GAAiB,iBAAA,EAA2B;AAChF,EAAA,OAAO,CAAA,EAAG,WAAW,IAAI,CAAC,cAAW,SAAA,CAAU,IAAA,EAAM,MAAM,CAAC,CAAA,CAAA;AAChE;AASO,SAAS,KAAK,GAAA,EAA+C;AAChE,EAAA,IAAI,CAAC,KAAK,OAAO,IAAA;AACjB,EAAA,MAAM,CAAA,GAAI,IAAI,IAAA,CAAK,GAAG,CAAA;AACtB,EAAA,IAAI,OAAO,KAAA,CAAM,CAAA,CAAE,OAAA,EAAS,GAAG,OAAO,IAAA;AACtC,EAAA,OAAO,CAAA,MAAA,EAAS,CAAA,CAAE,kBAAA,CAAmB,MAAA,EAAW;AAAA,IAC5C,IAAA,EAAM,SAAA;AAAA,IACN,MAAA,EAAQ,SAAA;AAAA,IACR,MAAA,EAAQ;AAAA,GACX,CAAC,CAAA,CAAA;AACN;AAGO,SAAS,aAAa,IAAA,EAAsC;AAC/D,EAAA,MAAM,CAAA,GAAI,IAAA,YAAgB,IAAA,GAAO,IAAA,CAAK,OAAA,KAAY,IAAI,IAAA,CAAK,IAAI,CAAA,CAAE,OAAA,EAAQ;AACzE,EAAA,IAAI,MAAA,CAAO,KAAA,CAAM,CAAC,CAAA,EAAG,OAAO,EAAA;AAC5B,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,GAAA,EAAI,GAAI,CAAA;AAC1B,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,IAAA,GAAO,GAAM,CAAA;AAClC,EAAA,IAAI,CAAA,GAAI,GAAG,OAAO,KAAA;AAClB,EAAA,IAAI,CAAA,GAAI,EAAA,EAAI,OAAO,CAAA,EAAG,CAAC,CAAA,KAAA,CAAA;AACvB,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,CAAA,GAAI,EAAE,CAAA;AAC3B,EAAA,IAAI,CAAA,GAAI,EAAA,EAAI,OAAO,CAAA,EAAG,CAAC,CAAA,KAAA,CAAA;AACvB,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,CAAA,GAAI,EAAE,CAAA;AAC3B,EAAA,IAAI,CAAA,GAAI,EAAA,EAAI,OAAO,CAAA,EAAG,CAAC,CAAA,KAAA,CAAA;AACvB,EAAA,MAAM,EAAA,GAAK,IAAA,CAAK,KAAA,CAAM,CAAA,GAAI,EAAE,CAAA;AAC5B,EAAA,IAAI,EAAA,GAAK,EAAA,EAAI,OAAO,CAAA,EAAG,EAAE,CAAA,MAAA,CAAA;AACzB,EAAA,OAAO,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,EAAA,GAAK,EAAE,CAAC,CAAA,KAAA,CAAA;AACjC;AAMO,SAAS,cAAA,CAAe,KAAA,EAAe,IAAA,GAAO,CAAA,EAAG,OAAO,CAAA,EAAW;AACtE,EAAA,IAAI,CAAC,OAAO,OAAO,EAAA;AACnB,EAAA,IAAI,KAAA,CAAM,MAAA,IAAU,IAAA,GAAO,IAAA,GAAO,GAAG,OAAO,KAAA;AAC5C,EAAA,OAAO,CAAA,EAAG,KAAA,CAAM,KAAA,CAAM,CAAA,EAAG,IAAI,CAAC,CAAA,MAAA,EAAI,KAAA,CAAM,KAAA,CAAM,CAAC,IAAI,CAAC,CAAA,CAAA;AACxD;AASO,SAAS,WAAA,CAAY,MAAA,EAAwB,OAAA,GAA2B,SAAA,EAAmB;AAC9F,EAAA,MAAM,IAAA,GACF,OAAA,KAAY,SAAA,GACN,uBAAA,GACA,yBAAyB,OAAO,CAAA,CAAA;AAC1C,EAAA,IAAI,aAAa,MAAA,EAAQ,OAAO,GAAG,IAAI,CAAA,SAAA,EAAY,OAAO,OAAO,CAAA,CAAA;AACjE,EAAA,IAAI,QAAQ,MAAA,EAAQ,OAAO,GAAG,IAAI,CAAA,IAAA,EAAO,OAAO,EAAE,CAAA,CAAA;AAClD,EAAA,OAAO,CAAA,EAAG,IAAI,CAAA,OAAA,EAAU,MAAA,CAAO,KAAK,CAAA,CAAA;AACxC","file":"format.mjs","sourcesContent":["/**\n * useSpotPrice — live BTC/USD spot rate for display surfaces.\n *\n * Pulls a JSON shape `{ btc_usd: number, fetched_at?: string | null }`\n * from a consumer-hosted endpoint (default `/api/price/btc-usd`),\n * refreshing every 60 seconds on a setInterval. The initial paint\n * reads `REFERENCE_BTC_USD` so the first frame is never NaN.\n *\n * Consumer contract (read this when wiring):\n *\n * The hook makes a relative fetch to `endpoint`. Every consumer that\n * mounts this MUST host that route locally — typically a Next.js\n * pages-router API route that proxies mempool.space's spot feed and\n * caches in-process. The canonical implementation lives at\n * `oc-vault-web/src/pages/api/price/btc-usd.ts` (and the mirror in\n * `oc-me-web`). Both also host the matching server-side fetch +\n * cache in `src/lib/price/feed.ts` — that part stays per-consumer\n * because it's Node-only code that doesn't belong in a UI package.\n *\n * If you don't have the route yet, pass an explicit endpoint\n * (e.g. `useSpotPrice({ endpoint: 'https://other-site.ochk.io/api/price/btc-usd' })`)\n * pointing at a sibling site's endpoint — they're CORS-open.\n *\n * Returns the rich `{ btcUsd, fetchedAt }` shape rather than a bare\n * number so callers that want a provenance caption can render\n * `asOf(fetchedAt)` without a second hook.\n */\n\n'use client';\n\nimport { useEffect, useState } from 'react';\n\nimport { REFERENCE_BTC_USD } from './index';\n\nexport interface SpotPrice {\n /** BTC/USD rate. Falls back to `REFERENCE_BTC_USD` until the first\n * fetch lands so the initial paint never reads NaN. */\n btcUsd: number;\n /** ISO timestamp the rate was fetched upstream. `null` until the\n * first successful fetch. Pair with `asOf()` for \"as of HH:MM\". */\n fetchedAt: string | null;\n}\n\nexport interface UseSpotPriceOptions {\n /** Override the endpoint path. Defaults to `/api/price/btc-usd` —\n * the family-canonical proxy route. Useful when consuming a sibling\n * site's endpoint cross-origin, or when self-hosting the proxy at\n * a non-default path. */\n endpoint?: string;\n /** Override the refresh cadence in milliseconds. Defaults to 60_000,\n * matching the upstream mempool.space cache TTL. Lower than ~10s\n * is wasted bandwidth — the underlying feed doesn't refresh faster. */\n intervalMs?: number;\n}\n\ninterface SpotPriceResponse {\n btc_usd?: number;\n fetched_at?: string | null;\n}\n\n/**\n * Hook · returns the latest BTC/USD spot rate from `endpoint`.\n *\n * Behaviour:\n * - First mount: synchronous return of `REFERENCE_BTC_USD` (no NaN),\n * then an async fetch updates state.\n * - Every `intervalMs` thereafter, a background fetch keeps the rate\n * fresh while the tab is alive.\n * - Network blips keep the last-good value — no flash to fallback.\n * - Unmount cancels both the in-flight read (`cancelled` flag) and\n * the interval timer.\n */\nexport function useSpotPrice(opts: UseSpotPriceOptions = {}): SpotPrice {\n const { endpoint = '/api/price/btc-usd', intervalMs = 60_000 } = opts;\n const [state, setState] = useState<SpotPrice>({\n btcUsd: REFERENCE_BTC_USD,\n fetchedAt: null,\n });\n\n useEffect(() => {\n let cancelled = false;\n async function pull() {\n try {\n const res = await fetch(endpoint);\n if (!res.ok) return;\n const json = (await res.json()) as SpotPriceResponse;\n if (cancelled) return;\n if (typeof json.btc_usd === 'number' && json.btc_usd > 0) {\n setState({\n btcUsd: Math.round(json.btc_usd),\n fetchedAt: json.fetched_at ?? null,\n });\n }\n } catch {\n // Network blip · keep the last good value, don't flash a\n // fallback. The next poll will recover.\n }\n }\n void pull();\n const t = setInterval(pull, intervalMs);\n return () => {\n cancelled = true;\n clearInterval(t);\n };\n }, [endpoint, intervalMs]);\n\n return state;\n}\n","/**\n * @orangecheck/design/format — Bitcoin/money display + formatting utilities.\n *\n * Presentational only. Lifted from the canonical implementations across the\n * family (oc-me-web's utils, oc-vault-web's billing) and standardized so every\n * site formats sats, prices, addresses, and times identically. NOTE: crypto\n * (BIP-322 signing, bech32 validation) deliberately does NOT live here — that\n * belongs in a bitcoin/crypto library, not the design system.\n *\n * Includes the `useSpotPrice` React hook for live BTC/USD display. The hook\n * fetches a consumer-hosted endpoint — see `use-spot-price.ts` for the\n * one-route-per-consumer contract.\n */\n\nexport { useSpotPrice, type SpotPrice, type UseSpotPriceOptions } from './use-spot-price';\n\n/** Reference BTC/USD rate for sats↔USD display. Override per call when a live\n * rate is available; this is a display affordance, not a price oracle. */\nexport const REFERENCE_BTC_USD = 95_000;\n\n/** Format a satoshi integer as a thousands-separated string (\"12,408\"). */\nexport function formatSats(sats: number): string {\n return new Intl.NumberFormat('en-US').format(Math.round(sats));\n}\n\n/** Compact sats for buttons/chips: \"7k sats\", \"1.5k sats\", \"999 sats\". */\nexport function formatSatsCompact(sats: number): string {\n const n = Math.round(sats);\n if (n < 1000) return `${n} sats`;\n const k = n / 1000;\n return `${Number.isInteger(k) ? k : k.toFixed(1)}k sats`;\n}\n\n/** Convert sats to a USD currency string using the reference (or given) rate. */\nexport function satsToUsd(sats: number, btcUsd: number = REFERENCE_BTC_USD): string {\n const usd = (sats / 100_000_000) * btcUsd;\n if (usd > 0 && usd < 0.01) return '< $0.01';\n // Whole dollars once >= $100, cents below. min must never exceed max.\n const fractionDigits = usd >= 100 ? 0 : 2;\n return new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: 'USD',\n minimumFractionDigits: fractionDigits,\n maximumFractionDigits: fractionDigits,\n }).format(usd);\n}\n\n/** Convert a USD amount to sats using the reference (or given) rate. */\nexport function usdToSats(usd: number, btcUsd: number = REFERENCE_BTC_USD): number {\n return Math.round((usd / btcUsd) * 100_000_000);\n}\n\n/** Both denominations as one label: \"7,000 sats · $6.65\". */\nexport function priceBoth(sats: number, btcUsd: number = REFERENCE_BTC_USD): string {\n return `${formatSats(sats)} sats · ${satsToUsd(sats, btcUsd)}`;\n}\n\n/**\n * \"as of HH:MM\" provenance footnote. Returns `null` for nullish or\n * unparseable input so callers can render nothing\n * (`{x && <span>{x}</span>}`) rather than gate on a magic empty\n * string. 24-hour, locale-aware. Pairs with `useSpotPrice().fetchedAt`\n * to caption a live-rate display.\n */\nexport function asOf(iso: string | null | undefined): string | null {\n if (!iso) return null;\n const t = new Date(iso);\n if (Number.isNaN(t.getTime())) return null;\n return `as of ${t.toLocaleTimeString(undefined, {\n hour: '2-digit',\n minute: '2-digit',\n hour12: false,\n })}`;\n}\n\n/** Compact relative time — \"now\", \"3m ago\", \"2h ago\", \"4d ago\", or a date. */\nexport function relativeTime(when: string | number | Date): string {\n const t = when instanceof Date ? when.getTime() : new Date(when).getTime();\n if (Number.isNaN(t)) return '';\n const diff = Date.now() - t;\n const m = Math.floor(diff / 60_000);\n if (m < 1) return 'now';\n if (m < 60) return `${m}m ago`;\n const h = Math.floor(m / 60);\n if (h < 24) return `${h}h ago`;\n const d = Math.floor(h / 24);\n if (d < 30) return `${d}d ago`;\n const mo = Math.floor(d / 30);\n if (mo < 12) return `${mo}mo ago`;\n return `${Math.floor(mo / 12)}y ago`;\n}\n\n/**\n * Truncate a Bitcoin/Lightning address (or any long id) for display:\n * \"bc1qxy…wxyz\". Defaults match the family's most common rule (8 head / 4 tail).\n */\nexport function shortenAddress(value: string, head = 8, tail = 4): string {\n if (!value) return '';\n if (value.length <= head + tail + 1) return value;\n return `${value.slice(0, head)}…${value.slice(-tail)}`;\n}\n\nexport type ExplorerNetwork = 'mainnet' | 'testnet' | 'signet';\nexport type ExplorerTarget =\n | { address: string }\n | { tx: string }\n | { block: string | number };\n\n/** A mempool.space explorer URL for an address, tx, or block. */\nexport function explorerUrl(target: ExplorerTarget, network: ExplorerNetwork = 'mainnet'): string {\n const base =\n network === 'mainnet'\n ? 'https://mempool.space'\n : `https://mempool.space/${network}`;\n if ('address' in target) return `${base}/address/${target.address}`;\n if ('tx' in target) return `${base}/tx/${target.tx}`;\n return `${base}/block/${target.block}`;\n}\n"]}
package/dist/index.d.mts CHANGED
@@ -2,7 +2,7 @@ export { DEFAULT_OC_THEME, OC_DEFAULT_ACCENT, OC_SKIN_COOKIE, OC_THEMES, OcAppea
2
2
  export { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Alert, AlertDescription, AlertTitle, AlertWithAction, AlertWithActionProps, AlertWithCountdown, AlertWithCountdownProps, Badge, Bar, BarProps, BitcoinAddress, BitcoinAddressProps, Button, Card, CardProps, Checkbox, ConfirmHost, ConfirmOptions, CopyButton, CopyButtonProps, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, ErrorBoundary, Input, Label, Modal, ModalProps, Pagination, PaginationProps, Popover, PopoverAnchor, PopoverContent, PopoverTrigger, PromptHost, PromptOptions, QrCode, QrCodeProps, RadioGroup, RadioGroupItem, SatsAmount, SatsAmountProps, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, Skeleton, SkeletonProps, Sparkline, SparklineProps, StatBlock, StatBlockProps, StatusPill, StatusPillProps, StatusPillSpec, StatusPillVariant, StatusTone, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Textarea, ThemeToggle, ThemeToggleLink, Toaster, Working, WorkingPanel, WorkingProps, badgeVariants, buttonVariants, confirm, makeStatusPill, prompt } from './primitives.mjs';
3
3
  export { Callout, CalloutProps, CalloutTone, DataRow, DataRowProps, DefinitionItem, DefinitionList, DefinitionListProps, EmptyState, EmptyStateCta, EmptyStateProps, HelpHint, HelpHintProps, SectionHeader, SectionHeaderProps, StatCard, StatCardProps, StatGrid, StatGridProps, StatItem, StatTile } from './composites.mjs';
4
4
  export { AppShell, AppShellProps, EcosystemSlug, EcosystemSwitcher, EcosystemSwitcherProps, FAMILY_PROPERTIES, FamilyCategory, FamilyProperty, LayoutSubHeader, LayoutSubHeaderProps, LayoutSubHeaderTag, OcAccountMenu, OcAccountMenuBuildInfo, OcAccountMenuItem, OcAccountMenuProps, OcAccountMenuSession, OcAccountMenuView, OcAccountMenuViewProps, OcDashboardHub, OcDashboardHubProps, OcDashboardShell, OcDashboardShellProps, OcDashboardTool, OcLogoDropdown, OcLogoDropdownProps, OcPrimaryNav, OcPrimaryNavLink, OcPrimaryNavProps, SITE_STATE_LABEL, SiteState, findFamilyProperty } from './chrome.mjs';
5
- export { ExplorerNetwork, ExplorerTarget, REFERENCE_BTC_USD, explorerUrl, formatSats, formatSatsCompact, priceBoth, relativeTime, satsToUsd, shortenAddress, usdToSats } from './format.mjs';
5
+ export { ExplorerNetwork, ExplorerTarget, REFERENCE_BTC_USD, SpotPrice, UseSpotPriceOptions, asOf, explorerUrl, formatSats, formatSatsCompact, priceBoth, relativeTime, satsToUsd, shortenAddress, usdToSats, useSpotPrice } from './format.mjs';
6
6
  import 'clsx';
7
7
  import 'react/jsx-runtime';
8
8
  import 'react';
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { DEFAULT_OC_THEME, OC_DEFAULT_ACCENT, OC_SKIN_COOKIE, OC_THEMES, OcAppea
2
2
  export { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Alert, AlertDescription, AlertTitle, AlertWithAction, AlertWithActionProps, AlertWithCountdown, AlertWithCountdownProps, Badge, Bar, BarProps, BitcoinAddress, BitcoinAddressProps, Button, Card, CardProps, Checkbox, ConfirmHost, ConfirmOptions, CopyButton, CopyButtonProps, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, ErrorBoundary, Input, Label, Modal, ModalProps, Pagination, PaginationProps, Popover, PopoverAnchor, PopoverContent, PopoverTrigger, PromptHost, PromptOptions, QrCode, QrCodeProps, RadioGroup, RadioGroupItem, SatsAmount, SatsAmountProps, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, Skeleton, SkeletonProps, Sparkline, SparklineProps, StatBlock, StatBlockProps, StatusPill, StatusPillProps, StatusPillSpec, StatusPillVariant, StatusTone, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Textarea, ThemeToggle, ThemeToggleLink, Toaster, Working, WorkingPanel, WorkingProps, badgeVariants, buttonVariants, confirm, makeStatusPill, prompt } from './primitives.js';
3
3
  export { Callout, CalloutProps, CalloutTone, DataRow, DataRowProps, DefinitionItem, DefinitionList, DefinitionListProps, EmptyState, EmptyStateCta, EmptyStateProps, HelpHint, HelpHintProps, SectionHeader, SectionHeaderProps, StatCard, StatCardProps, StatGrid, StatGridProps, StatItem, StatTile } from './composites.js';
4
4
  export { AppShell, AppShellProps, EcosystemSlug, EcosystemSwitcher, EcosystemSwitcherProps, FAMILY_PROPERTIES, FamilyCategory, FamilyProperty, LayoutSubHeader, LayoutSubHeaderProps, LayoutSubHeaderTag, OcAccountMenu, OcAccountMenuBuildInfo, OcAccountMenuItem, OcAccountMenuProps, OcAccountMenuSession, OcAccountMenuView, OcAccountMenuViewProps, OcDashboardHub, OcDashboardHubProps, OcDashboardShell, OcDashboardShellProps, OcDashboardTool, OcLogoDropdown, OcLogoDropdownProps, OcPrimaryNav, OcPrimaryNavLink, OcPrimaryNavProps, SITE_STATE_LABEL, SiteState, findFamilyProperty } from './chrome.js';
5
- export { ExplorerNetwork, ExplorerTarget, REFERENCE_BTC_USD, explorerUrl, formatSats, formatSatsCompact, priceBoth, relativeTime, satsToUsd, shortenAddress, usdToSats } from './format.js';
5
+ export { ExplorerNetwork, ExplorerTarget, REFERENCE_BTC_USD, SpotPrice, UseSpotPriceOptions, asOf, explorerUrl, formatSats, formatSatsCompact, priceBoth, relativeTime, satsToUsd, shortenAddress, usdToSats, useSpotPrice } from './format.js';
6
6
  import 'clsx';
7
7
  import 'react/jsx-runtime';
8
8
  import 'react';
package/dist/index.js CHANGED
@@ -1939,6 +1939,38 @@ function TabsContent({ className, ...props }) {
1939
1939
  }
1940
1940
  );
1941
1941
  }
1942
+ function useSpotPrice(opts = {}) {
1943
+ const { endpoint = "/api/price/btc-usd", intervalMs = 6e4 } = opts;
1944
+ const [state, setState] = React2.useState({
1945
+ btcUsd: REFERENCE_BTC_USD,
1946
+ fetchedAt: null
1947
+ });
1948
+ React2.useEffect(() => {
1949
+ let cancelled = false;
1950
+ async function pull() {
1951
+ try {
1952
+ const res = await fetch(endpoint);
1953
+ if (!res.ok) return;
1954
+ const json = await res.json();
1955
+ if (cancelled) return;
1956
+ if (typeof json.btc_usd === "number" && json.btc_usd > 0) {
1957
+ setState({
1958
+ btcUsd: Math.round(json.btc_usd),
1959
+ fetchedAt: json.fetched_at ?? null
1960
+ });
1961
+ }
1962
+ } catch {
1963
+ }
1964
+ }
1965
+ void pull();
1966
+ const t = setInterval(pull, intervalMs);
1967
+ return () => {
1968
+ cancelled = true;
1969
+ clearInterval(t);
1970
+ };
1971
+ }, [endpoint, intervalMs]);
1972
+ return state;
1973
+ }
1942
1974
 
1943
1975
  // src/format/index.ts
1944
1976
  var REFERENCE_BTC_USD = 95e3;
@@ -1968,6 +2000,16 @@ function usdToSats(usd, btcUsd = REFERENCE_BTC_USD) {
1968
2000
  function priceBoth(sats, btcUsd = REFERENCE_BTC_USD) {
1969
2001
  return `${formatSats(sats)} sats \xB7 ${satsToUsd(sats, btcUsd)}`;
1970
2002
  }
2003
+ function asOf(iso) {
2004
+ if (!iso) return null;
2005
+ const t = new Date(iso);
2006
+ if (Number.isNaN(t.getTime())) return null;
2007
+ return `as of ${t.toLocaleTimeString(void 0, {
2008
+ hour: "2-digit",
2009
+ minute: "2-digit",
2010
+ hour12: false
2011
+ })}`;
2012
+ }
1971
2013
  function relativeTime(when) {
1972
2014
  const t = when instanceof Date ? when.getTime() : new Date(when).getTime();
1973
2015
  if (Number.isNaN(t)) return "";
@@ -2109,28 +2151,46 @@ function QrCode({ value, size = 200, label, className }) {
2109
2151
  );
2110
2152
  }
2111
2153
  function HelpHint({
2112
- title,
2113
2154
  children,
2114
- side = "bottom",
2115
- label = "more information",
2155
+ title,
2156
+ side = "top",
2157
+ size = "sm",
2158
+ tone = "default",
2159
+ label = "what is this?",
2116
2160
  className
2117
2161
  }) {
2118
- return /* @__PURE__ */ jsxRuntime.jsxs(Popover, { children: [
2162
+ const iconSize = size === "xs" ? 12 : 14;
2163
+ return /* @__PURE__ */ jsxRuntime.jsxs(PopoverPrimitive__namespace.Root, { children: [
2119
2164
  /* @__PURE__ */ jsxRuntime.jsx(
2120
- PopoverTrigger,
2165
+ PopoverPrimitive__namespace.Trigger,
2121
2166
  {
2122
2167
  "aria-label": label,
2168
+ onClick: (e) => e.stopPropagation(),
2123
2169
  className: cn(
2124
- "text-muted-foreground/60 hover:text-foreground inline-flex size-3.5 items-center justify-center align-middle transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
2170
+ "focus-visible:ring-primary/60 inline-flex shrink-0 items-center justify-center rounded-full transition-colors outline-none focus-visible:ring-2",
2171
+ tone === "subtle" ? "text-muted-foreground/60 hover:text-foreground/80" : "text-primary/70 hover:text-primary",
2125
2172
  className
2126
2173
  ),
2127
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.HelpCircle, { className: "size-3.5", "aria-hidden": true })
2174
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.HelpCircle, { size: iconSize, "aria-hidden": true })
2128
2175
  }
2129
2176
  ),
2130
- /* @__PURE__ */ jsxRuntime.jsxs(PopoverContent, { side, className: "w-72 space-y-1.5", children: [
2131
- title && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "label-mono text-foreground text-[10px]", children: title }),
2132
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "text-muted-foreground text-xs leading-relaxed", children })
2133
- ] })
2177
+ /* @__PURE__ */ jsxRuntime.jsx(PopoverPrimitive__namespace.Portal, { children: /* @__PURE__ */ jsxRuntime.jsxs(
2178
+ PopoverPrimitive__namespace.Content,
2179
+ {
2180
+ side,
2181
+ sideOffset: 6,
2182
+ collisionPadding: 12,
2183
+ className: "border-border bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-50 max-w-xs rounded-md border p-3 text-xs leading-relaxed shadow-md outline-none",
2184
+ children: [
2185
+ title && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "label-mono text-primary mb-1.5", children: [
2186
+ "\xA7 ",
2187
+ title
2188
+ ] }),
2189
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "text-foreground/90", children }),
2190
+ /* @__PURE__ */ jsxRuntime.jsx(PopoverPrimitive__namespace.Arrow, { className: "fill-popover" })
2191
+ ]
2192
+ }
2193
+ ) })
2134
2194
  ] });
2135
2195
  }
2136
2196
  var TONE_CLASS = {
@@ -4012,6 +4072,7 @@ exports.Toaster = Toaster;
4012
4072
  exports.Working = Working;
4013
4073
  exports.WorkingPanel = WorkingPanel;
4014
4074
  exports.accentFor = accentFor;
4075
+ exports.asOf = asOf;
4015
4076
  exports.badgeVariants = badgeVariants;
4016
4077
  exports.buttonVariants = buttonVariants;
4017
4078
  exports.cn = cn;
@@ -4031,5 +4092,6 @@ exports.satsToUsd = satsToUsd;
4031
4092
  exports.shortenAddress = shortenAddress;
4032
4093
  exports.usdToSats = usdToSats;
4033
4094
  exports.useOcSkin = useOcSkin;
4095
+ exports.useSpotPrice = useSpotPrice;
4034
4096
  //# sourceMappingURL=index.js.map
4035
4097
  //# sourceMappingURL=index.js.map