diff --git a/src/components/dashboard/AggregatedBanner.tsx b/src/components/dashboard/AggregatedBanner.tsx index 3957f9c8..2740e3a7 100644 --- a/src/components/dashboard/AggregatedBanner.tsx +++ b/src/components/dashboard/AggregatedBanner.tsx @@ -1,29 +1,43 @@ -import { LiInfoCircle, LiQuestionCircle } from 'solar-icon-react/li'; +import { BdInfoCircle } from 'solar-icon-react/bd'; +import { LiQuestionCircle } from 'solar-icon-react/li'; import { Switch } from '@/components/ui/switch'; /** * The blue notice shown at the top of every page while aggregated mode is on. It * explains the mode and carries an Exit toggle that mirrors the top-bar toggle, so a * miner can leave aggregated mode from wherever the banner is visible. + * + * The two viewports are drawn as separate variants: one row on desktop, and a stack on + * mobile whose body copy is shorter and a size smaller, with the exit control dropping + * below the text and indented to line up with it. The control itself is rendered once + * and moved by layout, so there is never a second switch carrying the same label. */ export function AggregatedBanner({ onExit }: { onExit: () => void }) { return ( -
+
- -
-

Viewing Aggregated Dashboard

-

+ {/* The icon sits 2px low so it lines up with the title's cap height. */} + +

+

Viewing Aggregated Dashboard

+

+ You're viewing combined data across all subaccounts. +

+

You're viewing combined workers, earnings, and mining performance across all subaccounts.

-
- + + {/* Mobile indents this by the icon column (20px icon + 4px gap) so it aligns + under the copy; desktop drops the indent and sits at the far right. */} +
+ Exit Aggregated Mode - +
setDrawerOpen(false)} aria-hidden /> @@ -82,7 +82,7 @@ function DashboardShellInner({ children }: { children: ReactNode }) { drawerOpen ? 'translate-x-0' : '-translate-x-full', )} > - setDrawerOpen(false)} /> + setDrawerOpen(false)} />
diff --git a/src/components/dashboard/Sidebar.tsx b/src/components/dashboard/Sidebar.tsx index cce78eea..61102250 100644 --- a/src/components/dashboard/Sidebar.tsx +++ b/src/components/dashboard/Sidebar.tsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'; import { useAuth } from '@/auth'; import { DmndLogo } from '@/components/auth/Logo'; import { Switch } from '@/components/ui/switch'; +import { TooltipPill } from '@/components/ui/tooltip-pill'; import { useAggregatedModeContext } from '@/hooks/AggregatedModeProvider'; import { useHasSubaccounts } from '@/hooks/useSubaccounts'; import { useAccountScope } from '@/hooks/useAccountScope'; @@ -25,20 +26,31 @@ function NavRow({ // The active nav icon is the filled (bold-duotone) glyph; custom icons // without a duotone variant fall back to their single form. const Icon = active && item.iconActive ? item.iconActive : item.icon; + const row = ( + + + {!collapsed && {item.label}} + + ); return ( - - - {!collapsed && {item.label}} - + {/* Collapsed hides the label, so the design reveals it in the tooltip pill + beside the rail rather than the browser's own title bubble. */} + {collapsed ? ( + + {row} + + ) : ( + row + )} ); } @@ -51,10 +63,13 @@ function NavRow({ */ export function Sidebar({ collapsed = false, + drawer = false, onToggleCollapse, onNavigate, }: { collapsed?: boolean; + /** The mobile drawer is wider than the docked rail and pads tighter. */ + drawer?: boolean; onToggleCollapse?: () => void; onNavigate?: () => void; }) { @@ -72,8 +87,8 @@ export function Sidebar({ return (
diff --git a/src/components/dashboard/TopBar.tsx b/src/components/dashboard/TopBar.tsx index fdc6a6ec..67a390aa 100644 --- a/src/components/dashboard/TopBar.tsx +++ b/src/components/dashboard/TopBar.tsx @@ -89,28 +89,40 @@ export function TopBar({ onMenuClick }: { onMenuClick: () => void }) { {menuOpen && ( <>
setMenuOpen(false)} aria-hidden /> -
+
{session?.email && ( -

{session.email}

+ <> +
+ + {accountInitials(session.email)} + + + {session.email.split('@')[0]} + {session.email} + +
+
+ )} setMenuOpen(false)} - className="flex items-center gap-2.5 rounded-md px-3 py-2 text-sm text-foreground transition-colors hover:bg-muted" + className="flex items-center gap-1 rounded-lg px-2 py-1 text-sm leading-5 text-body-alt transition-colors hover:bg-muted" > - - Settings + + Settings +
diff --git a/src/components/generated-btc/GeneratedBtcFilter.tsx b/src/components/generated-btc/GeneratedBtcFilter.tsx index bfd72c6a..2f0c0579 100644 --- a/src/components/generated-btc/GeneratedBtcFilter.tsx +++ b/src/components/generated-btc/GeneratedBtcFilter.tsx @@ -146,12 +146,12 @@ export function GeneratedBtcFilter({ ref={ref} role="dialog" aria-label="Filter generated BTC" - className="absolute right-0 top-full z-20 mt-2 w-[360px] max-w-[calc(100vw-2rem)] rounded-3xl border border-border bg-popover px-4 pb-5 pt-4 shadow-xl sm:px-6 sm:pb-6" + className="absolute right-0 top-full z-20 mt-4 w-[574px] max-w-[calc(100vw-2rem)] rounded-3xl border-[0.5px] border-border bg-card px-8 pb-8 pt-4 shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]" >
-

Filter generated BTC

-

+

Filter generated BTC

+

{hasAccounts ? 'Find generated BTC by date or subaccount.' : 'Find generated BTC by date.'}

@@ -163,7 +163,7 @@ export function GeneratedBtcFilter({ setShowCalendar(false); onReset(); }} - className="rounded-full border border-border px-4 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted" + className="inline-flex h-9 items-center rounded-[32px] border-[0.5px] border-black/20 bg-btn-secondary px-5 text-sm leading-5 text-foreground transition-opacity hover:opacity-80" > Reset @@ -173,7 +173,7 @@ export function GeneratedBtcFilter({ onApply(draft); onClose(); }} - className="rounded-full bg-[hsl(var(--btn))] px-4 py-1.5 text-xs font-medium text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90" + className="inline-flex h-9 items-center rounded-[32px] border border-black/20 bg-[hsl(var(--btn))] px-5 text-sm leading-5 text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90" > Apply filter(s) @@ -181,7 +181,7 @@ export function GeneratedBtcFilter({
{hasAccounts ? ( -
+
{CATEGORIES.map(({ key, label, Icon }) => ( ))}
-
+
{category === 'date' && diff --git a/src/components/generated-btc/GeneratedBtcStatCards.tsx b/src/components/generated-btc/GeneratedBtcStatCards.tsx index 9b2621cf..95d6ba56 100644 --- a/src/components/generated-btc/GeneratedBtcStatCards.tsx +++ b/src/components/generated-btc/GeneratedBtcStatCards.tsx @@ -1,13 +1,21 @@ import type { ReactNode } from 'react'; -import { formatHashrate } from '@/lib/utils'; +import { Reading } from '@/components/ui/Reading'; +import { formatAxisValue, pickHashrateScale } from '@/lib/chartAxis'; import { formatBtc } from '@/lib/generatedBtcTable'; +/** + * A stat card. Same shell and type ramp as the home, workers and subaccounts cards so + * the pages cannot drift apart, except that this page sets its unit one step larger + * (18/28) than they do, which is how the frame draws it. + */ function Card({ title, sub, children }: { title: string; sub: string; children: ReactNode }) { return ( -
- {title} - {children} -

{sub}

+
+ {title} +
+ {children} +

{sub}

+
); } @@ -22,21 +30,21 @@ export function GeneratedBtcStatCards({ averageHashrate: number; activeWorkers: number; }) { + // One unit for the hashrate figure, matching how every other hashrate reading is set. + const scale = pickHashrateScale([averageHashrate]); + return ( -
+
-

- {formatBtc(generated)} - BTC -

+
-

{formatHashrate(averageHashrate)}

+
-

{activeWorkers}

+
); diff --git a/src/components/generated-btc/GeneratedBtcTable.tsx b/src/components/generated-btc/GeneratedBtcTable.tsx index e24b28b6..61302bd1 100644 --- a/src/components/generated-btc/GeneratedBtcTable.tsx +++ b/src/components/generated-btc/GeneratedBtcTable.tsx @@ -1,7 +1,9 @@ -import { LiInfoCircle } from 'solar-icon-react/li'; +import type { ReactNode } from 'react'; import type { GeneratedBtcEntry } from '@/api/types'; +import { CellCheckbox } from '@/components/ui/CellCheckbox'; +import { InfoHint } from '@/components/ui/InfoHint'; import { formatHashrate } from '@/lib/utils'; -import { formatGeneratedDate, formatBtc } from '@/lib/generatedBtcTable'; +import { formatGeneratedDate, formatBtc, generatedBtcRowId } from '@/lib/generatedBtcTable'; /** The empty message shown in the table body when the date filter excludes every row. */ export interface GeneratedBtcEmpty { @@ -11,10 +13,6 @@ export interface GeneratedBtcEmpty { onClear: () => void; } -function InfoHint({ label }: { label: string }) { - return ; -} - /** * A day's generated amount. A known amount carries the BTC unit; an amount the API * did not report reads as a bare "--", since "-- BTC" would imply a measured zero. @@ -50,30 +48,27 @@ function EmptyRow({ empty }: { empty: GeneratedBtcEmpty }) { * line, Generated BTC below. Mode and Estimated payout are drawn on this frame too but * stay omitted here, same as the desktop table (both unbacked by the API). */ +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+

{label}

+

{children}

+
+ ); +} + function GeneratedBtcCard({ entry, showAccount }: { entry: GeneratedBtcEntry; showAccount: boolean }) { return ( -
-
-
-

Date

-

{formatGeneratedDate(entry.entry_day)}

-
- {showAccount && ( -
-

Account

-

{entry.account ?? '--'}

-
- )} -
-

Avg. hashrate

-

{formatHashrate(entry.hashrate)}

-
+
+
+ {formatGeneratedDate(entry.entry_day)} + {showAccount && {entry.account ?? '--'}} + {formatHashrate(entry.hashrate)}
-
-

Generated BTC

-

- -

+
+ + +
); @@ -88,45 +83,83 @@ export function GeneratedBtcTable({ entries, empty, showAccount = false, + selected, + allSelected = false, + someSelected = false, + onToggleAll, + onToggleOne, }: { entries: GeneratedBtcEntry[]; empty?: GeneratedBtcEmpty; /** Aggregated mode adds the owning account, since rows then span accounts. */ showAccount?: boolean; + /** + * Row selection, which scopes the CSV export. Omitted by the read-only watcher view, + * which has no export and so must not show a control that does nothing. + */ + selected?: Set; + allSelected?: boolean; + someSelected?: boolean; + onToggleAll?: () => void; + onToggleOne?: (id: string) => void; }) { + const selectable = onToggleAll !== undefined && onToggleOne !== undefined; return ( <>
- - - {showAccount && } - + {selectable && ( + + )} + + {showAccount && } + - {entries.length === 0 && empty && ( - )} {entries.map((e) => ( - - - {showAccount && } - - + {selectable && ( + + )} + + {showAccount && } + + ))} @@ -136,7 +169,7 @@ export function GeneratedBtcTable({
{entries.length === 0 && empty && } {entries.map((e) => ( - + ))}
diff --git a/src/components/generated-btc/GeneratedBtcToolbar.tsx b/src/components/generated-btc/GeneratedBtcToolbar.tsx index 7e34302f..b4a7766b 100644 --- a/src/components/generated-btc/GeneratedBtcToolbar.tsx +++ b/src/components/generated-btc/GeneratedBtcToolbar.tsx @@ -4,9 +4,13 @@ import { cn } from '@/lib/utils'; import { GeneratedBtcFilter, isGbtcDraftActive, type GbtcFilterDraft } from './GeneratedBtcFilter'; /** - * Table header bar: the title, a search box, and the Filter popover trigger. Aggregated - * mode (accounts non-empty) titles the section "Generated BTC" and adds the search box; - * single-account mode keeps the merged "Rewards" title with no search, unchanged. + * Table header bar: the title, a search box, and the Filter popover trigger. Mobile + * collapses the search and the filter into two icon pills, as the frame draws them. + * + * The search is offered only when there is an account dimension to search (aggregated + * mode). A generated-BTC row is a per-day, per-account total with no worker field on + * any endpoint, so the drawn "by worker name" search cannot be backed; offering it in + * single-account mode would be a control that can never match anything. */ export function GeneratedBtcToolbar({ filter, @@ -25,60 +29,101 @@ export function GeneratedBtcToolbar({ accounts?: string[]; }) { const [open, setOpen] = useState(false); + // Mobile shows the search field only once its pill is tapped; the frame draws the + // collapsed pill but no expanded state, so it opens below the title row. + const [searchOpen, setSearchOpen] = useState(false); const active = isGbtcDraftActive(filter); const aggregated = accounts.length > 0; + // Rendered once and placed by layout: two copies would mount two inputs carrying the + // same accessible name, one of them invisible. + const searchField = ( +
+ + + onQuery?.(e.target.value)} + // The design's copy names the worker, but a generated-BTC entry has no worker + // field on any endpoint; only the account half is searchable. + placeholder="Search by subaccount" + aria-label="Search generated BTC by subaccount" + className="h-10 w-full rounded-xl bg-muted py-2 pl-[54px] pr-4 text-sm leading-5 text-foreground placeholder:text-placeholder focus:outline-none focus:ring-1 focus:ring-ring sm:w-[252px]" + /> +
+ ); + + const filterPopover = open && ( + setOpen(false)} + /> + ); + return ( -
-

{aggregated ? 'Generated BTC' : 'Rewards'}

+
+
+

Generated BTC

-
- {aggregated && ( -
- - onQuery?.(e.target.value)} - // The design's copy names both "worker" and "subaccount", but a generated-BTC - // entry is a per-day, per-account total with no worker field, so only the - // account half of this placeholder is actually searchable. - placeholder="Search by worker or subaccount" - aria-label="Search generated BTC by subaccount" - className="w-full rounded-2xl border border-border bg-muted py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-placeholder focus:outline-none focus:ring-1 focus:ring-ring sm:w-64" - /> -
- )} -
+ {/* Mobile collapses the search and the filter into two 32px icon pills. */} +
+ {aggregated && ( + + )} - {open && ( - setOpen(false)} - /> - )}
+ + {aggregated && searchOpen &&
{searchField}
} + + {/* One popover for both triggers, anchored to this toolbar. */} + {filterPopover} + +
+ {aggregated && searchField} + +
); } diff --git a/src/components/home/CardEmptyState.tsx b/src/components/home/CardEmptyState.tsx deleted file mode 100644 index f391898b..00000000 --- a/src/components/home/CardEmptyState.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { ReactNode } from 'react'; - -/** The centered icon + title + subtitle empty state used inside the home cards. */ -export function CardEmptyState({ icon, title, subtitle }: { icon: ReactNode; title: string; subtitle: string }) { - return ( -
-
{icon}
-

{title}

-

{subtitle}

-
- ); -} diff --git a/src/components/home/CombinedHashrateCard.tsx b/src/components/home/CombinedHashrateCard.tsx index bbcede1c..31824120 100644 --- a/src/components/home/CombinedHashrateCard.tsx +++ b/src/components/home/CombinedHashrateCard.tsx @@ -27,7 +27,7 @@ export function CombinedHashrateCard({ slices, total }: { slices: DonutSlice[]; const chartData = hasHashrate ? slices : slices.map((s) => ({ ...s, hashrate: 1 })); return ( -
+
@@ -48,15 +48,15 @@ export function CombinedHashrateCard({ slices, total }: { slices: DonutSlice[];
- {totalParts.amount} - {totalParts.unit} + {totalParts.amount} + {totalParts.unit}
-

Combined Hashrate

+

Combined Hashrate

- {s.name} + {s.name} - {parts.amount} - {parts.unit} + {parts.amount} + {parts.unit}
diff --git a/src/components/home/ConnectWorkersCard.tsx b/src/components/home/ConnectWorkersCard.tsx index b838a605..3657f2af 100644 --- a/src/components/home/ConnectWorkersCard.tsx +++ b/src/components/home/ConnectWorkersCard.tsx @@ -12,22 +12,25 @@ export function ConnectWorkersCard() { const { data: account, isLoading } = useAccountProfile(); return ( -
-
-

Connect workers

+
+
+
+

Connect workers

{SETUP_TUTORIAL_URL ? ( - Setup tutorial + Setup guide ) : null} +
+
-
+
diff --git a/src/components/home/CredentialRow.tsx b/src/components/home/CredentialRow.tsx index f7707afb..4db3e2be 100644 --- a/src/components/home/CredentialRow.tsx +++ b/src/components/home/CredentialRow.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { LiCopy, LiEye, LiEyeClosed, LiCheckCircle, LiQuestionCircle } from 'solar-icon-react/li'; +import { cn } from '@/lib/utils'; function truncateMiddle(value: string): string { if (value.length <= 16) return value; @@ -11,6 +12,10 @@ function truncateMiddle(value: string): string { * eye toggle for secret values (the PPLNS / FPPS passwords). Secrets show a * truncated preview until revealed; copy always copies the full value. */ +/** The design's icon affordance: a 32px secondary-filled circle holding a 16px glyph. */ +const ICON_BUTTON = + 'flex h-8 w-8 shrink-0 items-center justify-center rounded-[32px] bg-btn-secondary text-placeholder transition-colors hover:text-foreground'; + export function CredentialRow({ label, value, @@ -38,23 +43,23 @@ export function CredentialRow({ const shown = secret && !revealed ? truncateMiddle(value) : value; return ( -
- +
+ {label} - {hint && } + {hint && } -
+
{loading ? ( ) : ( - {shown} + {shown} )} {secret && !loading && ( @@ -65,7 +70,7 @@ export function CredentialRow({ onClick={copy} aria-label={`Copy ${label}`} disabled={loading} - className="shrink-0 text-placeholder transition-colors hover:text-foreground disabled:opacity-50" + className={cn(ICON_BUTTON, 'disabled:opacity-50')} > {copied ? : } diff --git a/src/components/home/CustomizeDashboardPanel.tsx b/src/components/home/CustomizeDashboardPanel.tsx index b44144fa..1da964c7 100644 --- a/src/components/home/CustomizeDashboardPanel.tsx +++ b/src/components/home/CustomizeDashboardPanel.tsx @@ -1,8 +1,8 @@ import { Fragment, useState } from 'react'; -import { LiAltArrowDown, LiRestart } from 'solar-icon-react/li'; +import { LiAltArrowDown, LiCloseCircle, LiInfoCircle, LiRestart } from 'solar-icon-react/li'; import { Check } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { widgetsForPanel, type DashboardLayout, type WidgetId } from '@/lib/dashboardLayout'; +import { isLockedWidget, widgetsForPanel, type DashboardLayout, type WidgetId } from '@/lib/dashboardLayout'; /** * The Customize dashboard panel shown in customization mode. Each widget row has a @@ -22,6 +22,9 @@ export function CustomizeDashboardPanel({ }) { const rows = widgetsForPanel(layout); const [collapsed, setCollapsed] = useState(false); + // Explains the refusal when a miner clicks the one widget that cannot be hidden, + // rather than the click doing nothing with no feedback. + const [lockedNotice, setLockedNotice] = useState(null); return (
@@ -55,11 +58,16 @@ export function CustomizeDashboardPanel({ type="button" role="checkbox" aria-checked={w.visible} - aria-label={`${w.visible ? 'Hide' : 'Show'} ${w.label}`} - onClick={() => onToggle(w.id)} + aria-label={ + isLockedWidget(w.id) ? `${w.label} can't be hidden` : `${w.visible ? 'Hide' : 'Show'} ${w.label}` + } + onClick={() => + isLockedWidget(w.id) ? setLockedNotice(`${w.label} can't be hidden`) : onToggle(w.id) + } className={cn( 'flex h-6 w-6 shrink-0 items-center justify-center rounded-md border transition-colors', w.visible ? 'border-[hsl(var(--btn))] bg-[hsl(var(--btn))]' : 'border-placeholder', + isLockedWidget(w.id) && 'opacity-40', )} > {w.visible && } @@ -74,6 +82,29 @@ export function CustomizeDashboardPanel({ )} + {lockedNotice && ( +
+ +
+

{lockedNotice}

+

+ You can readjust the widget placement but it can't be hidden. +

+
+ +
+ )} + @@ -83,7 +96,7 @@ export function GettingStartedCard() { type="button" onClick={() => setCollapsed((c) => !c)} aria-label={collapsed ? 'Expand' : 'Collapse'} - className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-placeholder transition-colors hover:bg-muted hover:text-foreground" + className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[32px] bg-btn-secondary p-2 text-body-alt transition-colors hover:text-foreground" > {collapsed ? : } @@ -91,28 +104,28 @@ export function GettingStartedCard() {
{!collapsed && ( -
+
    {items.map((item, i) => { const isLast = i === items.length - 1; const row = ( - - {/* Stepper rail: a 24px circle over a dashed vertical connector that - runs below every item (the last one leads into the footer rule). */} - + + {/* Stepper rail: a 24px marker over a 40px dashed connector. The last + step has no connector, so the rail stops at its marker. */} + {item.done ? ( ) : ( - + )} - + {!isLast && } - + {item.label} @@ -131,13 +144,19 @@ export function GettingStartedCard() { })}
-
- + {/* The rule runs the full card width rather than stopping at the padding. */} +
+ +
+ {completed}/{items.length} Complete
{items.map((_, i) => ( - + ))}
diff --git a/src/components/home/LiveHashrateCard.tsx b/src/components/home/LiveHashrateCard.tsx index 9b41d565..ffce5717 100644 --- a/src/components/home/LiveHashrateCard.tsx +++ b/src/components/home/LiveHashrateCard.tsx @@ -1,7 +1,15 @@ +import type { ReactNode } from 'react'; import { useAccountHashrate } from '@/hooks/useAccountData'; -import { formatHashrate } from '@/lib/utils'; +import { formatAxisValue, pickHashrateScale } from '@/lib/chartAxis'; +import { InfoHint } from '@/components/ui/InfoHint'; +import { Reading } from '@/components/ui/Reading'; import { MiningIcon } from '@/components/dashboard/icons/MiningIcon'; -import { CardEmptyState } from './CardEmptyState'; + +const PPLNS_COLOR = '#2b7fff'; +const FPPS_COLOR = '#e67c2a'; + +const PPLNS_HINT = + 'Payouts are based on your contribution to recently submitted shares. Earnings can vary, but may be higher over time.'; /** "Last updated" from the snapshot's observed_at, rounded to whole minutes. */ function lastUpdatedLabel(observedAt: string | undefined, now: number): string | null { @@ -13,43 +21,83 @@ function lastUpdatedLabel(observedAt: string | undefined, now: number): string | return `Last updated ${mins} minute${mins === 1 ? '' : 's'} ago`; } -/** The account's live total hashrate, with a PPLNS / FPPS breakdown when mining. */ +/** A coloured swatch, scheme name and its hint, sitting above the figure it labels. */ +function SchemeLabel({ color, name, children }: { color: string; name: string; children?: ReactNode }) { + return ( +
+ + {name} + {children} +
+ ); +} + +/** + * The account's live hashrate. A miner on a single payout scheme sees that scheme + * labelled above one figure; once both schemes report, the headline becomes the total + * and a split row breaks it down, which is how the design draws each case. + */ export function LiveHashrateCard() { const { data, isLoading } = useAccountHashrate(); const total = data?.total_hashrate ?? 0; + const pplns = data?.pplns_hashrate ?? 0; + const fpps = data?.fpps_hashrate ?? 0; const lastUpdated = lastUpdatedLabel(data?.observed_at, Date.now()); + const split = pplns > 0 && fpps > 0; return ( -
-
- - +
+
+ + Live hashrate - {total > 0 && lastUpdated && {lastUpdated}} + {total > 0 && lastUpdated && {lastUpdated}}
+ {isLoading ? ( -
+
) : total > 0 ? ( -
-

{formatHashrate(total)}

-
- - PPLNS - {formatHashrate(data?.pplns_hashrate ?? 0)} - - - FPPS - {formatHashrate(data?.fpps_hashrate ?? 0)} - -
+
+ {/* One scheme reporting reads as that scheme's figure; both read as a total. */} + {!split && ( + 0 ? PPLNS_COLOR : FPPS_COLOR} name={pplns > 0 ? 'PPLNS' : 'FPPS'}> + + + )} + + {split && ( + <> +
+
+
+ + + + +
+
+
+ + +
+
+ + )}
) : ( - } - title="No mining activity yet" - subtitle="Your live hashrate will appear here. Connect a worker to start submitting shares and track performance." - /> + <> +
+
+ +

No mining activity yet

+

+ Your live hashrate will appear here. Connect a worker to +
+ start submitting shares and track performance. +

+
+ )}
); diff --git a/src/components/home/MiningPerformanceChart.tsx b/src/components/home/MiningPerformanceChart.tsx index 136fe1ce..7e19bf1f 100644 --- a/src/components/home/MiningPerformanceChart.tsx +++ b/src/components/home/MiningPerformanceChart.tsx @@ -1,31 +1,52 @@ import { useState } from 'react'; import { LiGraphUp } from 'solar-icon-react/li'; -import { Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { useAccountHashrateHistory, useAggregatedHashrateHistory } from '@/hooks/useAccountData'; import { useAggregatedModeContext } from '@/hooks/AggregatedModeProvider'; -import { cn, formatHashrate } from '@/lib/utils'; +import { cn } from '@/lib/utils'; import type { HashratePoint, HashrateRange } from '@/api/types'; -import { Calendar } from '@/components/payouts/Calendar'; +import { CalendarSheet } from '@/components/payouts/CalendarSheet'; import type { DateRange } from '@/lib/payoutsTable'; -import { CardEmptyState } from './CardEmptyState'; +import { formatAxisValue, pickHashrateScale, tooltipTimestamp, xAxisTickLabel, yAxisTicks } from '@/lib/chartAxis'; const RANGES: HashrateRange[] = ['1H', '6H', '24H', '7D']; -// A custom span (multiple days) reads as dates; the short presets read as time. -function formatAxisTime(value: string, isCustom: boolean): string { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return isCustom - ? date.toLocaleDateString([], { month: 'short', day: 'numeric' }) - : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); -} - -const seriesLabel = (name: string | number): string => (name === 'pplns_hashrate' ? 'PPLNS' : 'FPPS'); - // Series colours from the design: PPLNS blue, FPPS orange (the same on both themes). const PPLNS_COLOR = '#2b7fff'; const FPPS_COLOR = '#e67c2a'; +// The selected range chip carries a two-layer lift; the unselected ones are flat. +const ACTIVE_CHIP_SHADOW = 'shadow-[0_20px_30px_-5px_rgba(0,0,0,0.05),0_8px_20px_-6px_rgba(0,0,0,0.05)]'; + +// The x axis is labelled at five fixed positions rather than at every sample. +const X_TICK_COUNT = 5; + +interface Series { + key: 'pplns_hashrate' | 'fpps_hashrate'; + label: string; + color: string; +} + +const ALL_SERIES: Series[] = [ + { key: 'pplns_hashrate', label: 'PPLNS', color: PPLNS_COLOR }, + { key: 'fpps_hashrate', label: 'FPPS', color: FPPS_COLOR }, +]; + +/** Evenly spaced timestamps across the window, so the axis never crowds. */ +function pickXTicks(points: HashratePoint[]): string[] { + if (points.length <= X_TICK_COUNT) return points.map((p) => p.observed_at); + const last = points.length - 1; + return Array.from({ length: X_TICK_COUNT }, (_, i) => points[Math.round((i * last) / (X_TICK_COUNT - 1))].observed_at); +} + +function latest(points: HashratePoint[], key: Series['key']): number | null { + for (let i = points.length - 1; i >= 0; i -= 1) { + const value = points[i][key]; + if (typeof value === 'number') return value; + } + return null; +} + /** Historical PPLNS + FPPS hashrate, with a 1H/6H/24H/7D/Custom range toggle. */ export function MiningPerformanceChart() { const [range, setRange] = useState('24H'); @@ -51,105 +72,211 @@ export function MiningPerformanceChart() { setRange(r); }; + // One unit for the whole axis: the design prints it once above the plot and leaves the + // ticks as bare numbers, so each tick cannot pick its own unit. + const scale = pickHashrateScale(points.flatMap((p) => [p.pplns_hashrate, p.fpps_hashrate])); + const peak = points.reduce( + (max, p) => Math.max(max, p.pplns_hashrate ?? 0, p.fpps_hashrate ?? 0), + 0, + ); + const { domainMax, ticks: yTicks } = yAxisTicks(peak); + const xTicks = pickXTicks(points); + // "Now" is only truthful on a preset window, which always runs up to the present. + const endsNow = !isCustom; + // Only chart a scheme the account actually earned on, so a PPLNS-only miner gets one + // line and one legend entry instead of a flat zero series pinned to the axis. + const series = ALL_SERIES.filter((s) => points.some((p) => (p[s.key] ?? 0) > 0)); + + // Two different empty cases. No samples at all is the first-run state and drops the + // range control, matching the design. Samples that are all zero means the account has + // history but nothing landed in THIS window, so the control has to stay reachable or + // the miner cannot get back to a range that does have data. + const hasSamples = points.length > 0; + const hasPlot = hasSamples && peak > 0; + return ( -
-
-

Mining Performance

-
- {RANGES.map((r) => ( - - ))} - +
+
+
+

+ Mining Performance +

+ {hasSamples && ( +
+ {RANGES.map((r) => { + const active = !isCustom && range === r; + return ( + + ); + })} + +
+ )}
+ {/* The empty state rules off the title instead of showing controls with nothing to act on. */} + {!hasSamples &&
}
{pickerOpen && ( -
- setPickerOpen(false)} - onDone={(r) => { - setCustom(r); - setPickerOpen(false); - }} - /> -
+ setPickerOpen(false)} + onDone={(r) => { + setCustom(r); + setPickerOpen(false); + }} + /> )} {isLoading ? ( -
- ) : points.length === 0 ? ( - } - title="No performance data yet" - subtitle="Historical hashrate trends will appear here once your workers begin mining." - /> - ) : ( -
- - - - - - - - - - - - - - formatAxisTime(value, isCustom)} - minTickGap={48} - axisLine={false} - tickLine={false} - tick={{ fill: 'hsl(var(--body-alt))', fontSize: 11 }} - dy={8} - interval="preserveStartEnd" - /> - formatHashrate(value)} - axisLine={false} - tickLine={false} - tick={{ fill: 'hsl(var(--body-alt))', fontSize: 11 }} - width={76} - /> - formatAxisTime(String(value), isCustom)} - formatter={(value, name) => [formatHashrate(Number(value)), seriesLabel(name)]} - /> - seriesLabel(value)} iconType="plainline" wrapperStyle={{ fontSize: 12 }} /> - - - - +
+ ) : !hasPlot ? ( +
+ +

No performance data yet

+

+ Historical hashrate trends will appear here once your workers begin mining. +

+ ) : ( + <> +
+ {series.map((s) => { + const value = latest(points, s.key); + return ( +
+
+ + {s.label} +
+ + {value === null ? '--' : `${formatAxisValue(value, scale.divisor)} ${scale.unit}`} + +
+ ); + })} +
+ +
+ {scale.unit} +
+ + + + {ALL_SERIES.map((s) => ( + + + + + ))} + + {/* syncWithTicks keeps a line per labelled tick, so no unlabelled zero line. */} + + + xAxisTickLabel(value, index, xTicks.length - 1, { isCustom, endsNow }) + } + axisLine={{ stroke: 'hsl(var(--border))', strokeWidth: 1 }} + tickLine={false} + tick={{ fill: 'hsl(var(--body-alt))', fontSize: 12 }} + tickMargin={8} + /> + formatAxisValue(value, scale.divisor)} + axisLine={false} + tickLine={false} + tick={{ fill: 'hsl(var(--body-alt))', fontSize: 12 }} + /* A 30px tick column, then a 16px gutter before the plot. */ + width={46} + tickMargin={16} + /> + { + if (!active || !payload?.length) return null; + return ( +
+

+ {tooltipTimestamp(String(label), Date.now())} +

+ {payload.map((entry) => { + const meta = ALL_SERIES.find((s) => s.key === entry.dataKey); + if (!meta) return null; + return ( +
+
+ + {meta.label} +
+

+ + {formatAxisValue(Number(entry.value), scale.divisor)} + {' '} + {scale.unit} +

+
+ ); + })} +
+ ); + }} + /> + {series.map((s) => ( + + ))} +
+
+
+
+ )}
); diff --git a/src/components/home/ProductTour.tsx b/src/components/home/ProductTour.tsx index 092e517f..f765fa93 100644 --- a/src/components/home/ProductTour.tsx +++ b/src/components/home/ProductTour.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation } from 'wouter'; -import { Check } from 'lucide-react'; +import { BdCheckCircle } from 'solar-icon-react/bd'; import * as Popover from '@radix-ui/react-popover'; import { cn, overlayContainer } from '@/lib/utils'; import welcomeIllustration from '@/assets/tour-welcome.png'; @@ -110,36 +110,38 @@ export function ProductTour({ onClose }: { onClose: () => void }) { if (phase === 'welcome') { return (
-
+
-

Welcome to DMND

-

- Your dashboard is ready. Learn where to monitor hashrate, track workers, view earnings, and customize your - workspace. -

-
+
+

Welcome to DMND

+

+ Your dashboard is ready. Learn where to monitor hashrate, track workers, view earnings, and customize your + workspace. +

+
+
@@ -152,28 +154,28 @@ export function ProductTour({ onClose }: { onClose: () => void }) { if (phase === 'done') { return (
-
+
- - - - + + -

You're ready to go.

-

- Connect a worker to begin tracking mining activity. -

+
+

+ You're ready to go. +

+

Connect a worker to begin tracking mining activity.

+
@@ -225,7 +227,10 @@ function CoachMark({ // When neither side fits, drop the card below the target instead; Radix still flips it // above if the target sits near the bottom. const CARD_SIDE_SPACE = 460; - const CARD_STACK_SPACE = 210; // card height plus the side offset + // The rendered card measures 380px tall (every step carries the 180px preview), + // plus the 12px side offset. Under-stating this is what let the card anchor below + // a target with too little room, pushing its buttons off-screen. + const CARD_STACK_SPACE = 392; const wantsHorizontal = step.side === 'left' || step.side === 'right'; const noSideRoom = wantsHorizontal && @@ -246,49 +251,55 @@ function CoachMark({ // The card's contents, shared by the anchored and the floating placement. const cardBody = ( <> -

{step.title}

-

{step.body}

-
-
-
- {Array.from({ length: total }, (_, i) => ( - - ))} -
- - Step {index + 1} of {total} - + + {/* Text and footer sit in their own 24px-inset block below the preview area. */} +
+
+

{step.title}

+

{step.body}

-
- - +
+
+
+ {Array.from({ length: total }, (_, i) => ( + + ))} +
+ + Step {index + 1} of {total} + +
+
+ + +
); const cardClass = - 'flex w-[431px] max-w-[calc(100vw-2rem)] flex-col rounded-2xl border border-border bg-popover p-6 shadow-2xl'; + 'flex w-[447px] max-w-[calc(100vw-2rem)] flex-col gap-3 bg-muted px-2 pb-6 pt-2 shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]'; return (
-
+
{rect && (
@@ -301,7 +312,11 @@ function CoachMark({ floatAtBottom ? 'bottom-4' : 'top-4', )} > -
+
{cardBody}
@@ -336,10 +351,10 @@ function CoachMark({ hideWhenDetached={false} onOpenAutoFocus={(e) => e.preventDefault()} aria-label={step.title} - // No internal scrolling: the card is short, and it falls back to a centred - // overlay when no side has room, so a scrollbar inside the card would only - // ever look like a glitch. Width is capped to the viewport for narrow screens. - className={cn('z-50', cardClass)} + // Width is capped to the viewport for narrow screens; height is capped so a + // window too short for the whole card scrolls it internally rather than + // hiding its footer buttons, which would strand the user mid-tour. + className={cn('z-50 max-h-[calc(100vh-2rem)] overflow-y-auto', cardClass)} > {cardBody} @@ -396,6 +411,43 @@ function unionRect(els: Element[]): Box | null { * highlight a group of cards (e.g. the two worker stat cards) by tagging them all * with the same value; the ring wraps their union. Tracked on resize and scroll. */ +/** + * The preview panel each coach mark shows above its copy: a scaled-down, inert copy of + * the widget being described. It clones the live element rather than shipping a static + * asset so the preview always matches what the miner is actually looking at. The clone + * is inert (aria-hidden, pointer-events disabled) so it never becomes a second, stale + * set of controls. + */ +function StepPreview({ target }: { target: string }) { + const host = useRef(null); + + useEffect(() => { + const box = host.current; + const source = document.querySelector(`[data-tour="${target}"]`); + if (!box || !(source instanceof HTMLElement)) return; + const clone = source.cloneNode(true) as HTMLElement; + clone.removeAttribute('data-tour'); + clone.setAttribute('aria-hidden', 'true'); + clone.style.width = `${source.offsetWidth}px`; + clone.style.pointerEvents = 'none'; + // Fit the widget's width into the panel, matching the design's scaled thumbnails. + const scale = Math.min(1, (PREVIEW_WIDTH - PREVIEW_INSET * 2) / Math.max(source.offsetWidth, 1)); + clone.style.transform = `scale(${scale})`; + clone.style.transformOrigin = 'top left'; + box.replaceChildren(clone); + return () => box.replaceChildren(); + }, [target]); + + return ( +
+
+
+ ); +} + +const PREVIEW_WIDTH = 431; +const PREVIEW_INSET = 16; + function useTargetRect(target: string) { const selector = useMemo(() => `[data-tour="${target}"]`, [target]); const [rect, setRect] = useState(null); diff --git a/src/components/home/WorkerStatCards.tsx b/src/components/home/WorkerStatCards.tsx index 45577a96..ea607bc7 100644 --- a/src/components/home/WorkerStatCards.tsx +++ b/src/components/home/WorkerStatCards.tsx @@ -1,32 +1,56 @@ import type { ReactNode } from 'react'; import { InfoHint } from '@/components/ui/InfoHint'; +import { cn } from '@/lib/utils'; +import { Reading } from '@/components/ui/Reading'; +import { WorkerBars } from '@/components/ui/WorkerBars'; import { useAccountAllWorkers, useTodayEarnings } from '@/hooks/useAccountData'; import { deriveWorkerStats } from '@/lib/workerStats'; import { classifyWorker } from '@/lib/workersTable'; import { BTC_DISPLAY_DP } from '@/lib/utils'; import type { AggregatedStats } from '@/lib/aggregatedStats'; +/** + * A stat card. The reading is one big numeral in the heading face with the unit + * trailing it at body size, which is why value and unit are separate props rather + * than one formatted string. `emphasis` colours the numeral for a rated figure, and + * `captionTone` follows the design's split between an empty hint and a live caption. + */ function StatCard({ title, value, + unit, + unitSize = 'lg', + emphasis, caption, + captionTone = 'muted', + meter, hint, tour, }: { title: string; - value: ReactNode; + value: string | number; + unit?: string; + unitSize?: 'base' | 'lg'; + emphasis?: boolean; caption: string; + captionTone?: 'muted' | 'strong'; + meter?: ReactNode; hint?: string; tour?: string; }) { return ( -
-
- {title} +
+
+ {title} {hint && }
-

{value}

-

{caption}

+ +
+ {meter} +

+ {caption} +

+
); } @@ -67,15 +91,19 @@ export function WorkerStatCards({ aggregated }: { aggregated?: AggregatedStats } const todayEarnings = aggregated ? aggregated.todayEarnings : earnings; const hasWorkers = stats.totalCount > 0; const hasMined = stats.rejectionRate !== null; - const rejection = stats.rejectionRate === null ? '--' : `${(stats.rejectionRate * 100).toFixed(2)}%`; - const earningsLabel = todayEarnings === undefined ? '--' : formatBtc(todayEarnings); + const rejection = stats.rejectionRate === null ? '--' : (stats.rejectionRate * 100).toFixed(2); + const earningsValue = todayEarnings === undefined ? '--' : formatBtc(todayEarnings); + const earningsKnown = todayEarnings !== undefined; return (
: undefined} // Once workers exist, show the live split; before that, the empty hint. caption={ hasWorkers ? `${stats.activeCount} active • ${stats.offlineCount} offline` : 'Connected workers will appear here.' @@ -86,6 +114,7 @@ export function WorkerStatCards({ aggregated }: { aggregated?: AggregatedStats } tour="stats-workers" title="Offline workers" value={stats.offlineCount} + captionTone={hasWorkers ? 'strong' : 'muted'} caption={ !hasWorkers ? "You don't have any offline workers." @@ -100,13 +129,18 @@ export function WorkerStatCards({ aggregated }: { aggregated?: AggregatedStats } tour="stats-earnings" title="Rejection rate" value={rejection} + unit={hasMined ? '%' : undefined} + emphasis={hasMined} + captionTone={hasMined ? 'strong' : 'muted'} caption={hasMined ? 'Across PPLNS and FPPS shares.' : 'Rejected share rate will appear after mining starts.'} hint="The percentage of shares that were rejected and did not count toward Payouts." /> 0 ? 'strong' : 'muted'} // Aggregated mode sums each subaccount's today_generated_btc (accrued, not yet // paid out), a different figure than single mode's on-chain-paid total, so the // caption can't claim "paid out" for both. diff --git a/src/components/payouts/Calendar.tsx b/src/components/payouts/Calendar.tsx index 7a7936ed..16b1f7b1 100644 --- a/src/components/payouts/Calendar.tsx +++ b/src/components/payouts/Calendar.tsx @@ -1,20 +1,25 @@ import { useState } from 'react'; import { LiAltArrowLeft, LiAltArrowRight } from 'solar-icon-react/li'; import { cn } from '@/lib/utils'; -import { monthInfo, clampRange, fullDayRange, type DateRange } from '@/lib/payoutsTable'; +import { + monthInfo, + clampRange, + fullDayRange, + formatCalendarDate, + isRangeEndpoint, + type DateRange, +} from '@/lib/payoutsTable'; const WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + +/** The two date readouts share one 40px, radius-16 field at the design's 12/16 type. */ +const dateFieldClass = 'flex h-10 flex-1 items-center rounded-[16px] bg-muted px-4 py-2 text-xs leading-4 text-foreground'; const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; /** A picked calendar day as its UTC-midnight unix seconds. */ function dayKey(year: number, month0: number, day: number): number { return Math.floor(Date.UTC(year, month0, day) / 1000); } -function fmt(sec: number): string { - const d = new Date(sec * 1000); - return `${String(d.getUTCDate()).padStart(2, '0')}/${String(d.getUTCMonth() + 1).padStart(2, '0')}/${d.getUTCFullYear()}`; -} - /** * A month calendar for picking a start/end date range. The first click sets the * start; the second sets the end (order is normalized). "Done" is enabled once a @@ -57,6 +62,7 @@ export function Calendar({ onCancel, onDone }: { onCancel: () => void; onDone: ( // Live range (normalized) for highlighting and the input displays. const range = startKey !== null && endKey !== null ? clampRange(startKey, endKey) : null; const inRange = (key: number) => (range ? key >= range.startSec && key <= range.endSec : key === startKey); + const isEndpoint = (key: number) => isRangeEndpoint(key, startKey, endKey); const cells: (number | null)[] = [ ...Array(firstWeekdayMon).fill(null), @@ -64,31 +70,40 @@ export function Calendar({ onCancel, onDone }: { onCancel: () => void; onDone: ( ]; return ( -
-
- - + {MONTHS[month0]} {year} -
-
-
- {startKey !== null ? fmt(range ? range.startSec : startKey) : 'Enter start date'} + {/* The two date readouts: placeholder grey until a date is picked, then the + picked date in the body colour, as the filled frame draws them. */} +
+
+ {startKey !== null ? formatCalendarDate(range ? range.startSec : startKey) : 'Enter start date'}
-
- {range ? fmt(range.endSec) : 'Enter end date'} +
+ {range ? formatCalendarDate(range.endSec) : 'Enter end date'}
-
+
{WEEKDAYS.map((w) => ( - + {w} ))} @@ -101,10 +116,12 @@ export function Calendar({ onCancel, onDone }: { onCancel: () => void; onDone: ( type="button" onClick={() => clickDay(day)} className={cn( - 'flex h-8 items-center justify-center rounded-full text-sm transition-colors', - inRange(dayKey(year, month0, day)) - ? 'bg-[hsl(var(--btn))] text-[hsl(var(--btn-foreground))]' - : 'text-foreground hover:bg-muted', + 'flex aspect-square items-center justify-center rounded-full text-sm leading-5 transition-colors', + isEndpoint(dayKey(year, month0, day)) + ? 'bg-[#262626] text-on-solid' + : inRange(dayKey(year, month0, day)) + ? 'bg-muted text-foreground' + : 'text-foreground hover:bg-muted', )} > {day} @@ -113,11 +130,12 @@ export function Calendar({ onCancel, onDone }: { onCancel: () => void; onDone: ( )}
-
+
+
@@ -125,7 +143,7 @@ export function Calendar({ onCancel, onDone }: { onCancel: () => void; onDone: ( type="button" disabled={startKey === null} onClick={() => onDone(fullDayRange(startKey!, endKey ?? startKey!))} - className="rounded-full bg-[hsl(var(--btn))] px-5 py-2 text-sm font-medium text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90 disabled:opacity-40" + className="flex-1 rounded-[32px] border border-black/20 bg-[hsl(var(--btn))] px-6 py-2.5 text-base leading-6 text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90 disabled:opacity-40" > Done diff --git a/src/components/payouts/CalendarSheet.tsx b/src/components/payouts/CalendarSheet.tsx new file mode 100644 index 00000000..e6fe8a56 --- /dev/null +++ b/src/components/payouts/CalendarSheet.tsx @@ -0,0 +1,41 @@ +import { createPortal } from 'react-dom'; +import { cn, overlayContainer } from '@/lib/utils'; +import { Calendar } from './Calendar'; +import type { DateRange } from '@/lib/payoutsTable'; + +/** + * Places the date picker the two ways the design draws it: a popover anchored to its + * trigger on desktop, and a modal bottom sheet over a dimmed, blurred page on mobile. + * + * The mobile sheet is portalled out of the trigger's card because a card with its own + * stacking context or overflow would clip a full-bleed sheet; the desktop popover stays + * inline so it can be positioned against its anchor. + */ +export function CalendarSheet({ + anchorClassName, + onCancel, + onDone, +}: { + /** Desktop-only positioning, relative to the trigger's positioned ancestor. */ + anchorClassName: string; + onCancel: () => void; + onDone: (range: DateRange) => void; +}) { + const sheet = ( +
+
+
+ +
+
+ ); + + return ( + <> + {createPortal(sheet, overlayContainer())} + + + ); +} diff --git a/src/components/payouts/PayoutsEmptyState.tsx b/src/components/payouts/PayoutsEmptyState.tsx index 39916362..bda7befd 100644 --- a/src/components/payouts/PayoutsEmptyState.tsx +++ b/src/components/payouts/PayoutsEmptyState.tsx @@ -1,14 +1,21 @@ -import { LiWallet } from 'solar-icon-react/li'; +import { BdWalletMoney } from 'solar-icon-react/bd'; -/** Shown when the account has no payouts. */ +/** + * Shown when the account has no payouts. + * + * Like the subaccounts empty state, the card carries a top border only and no radius; + * that is how the frame draws it. No call-to-action is drawn here, so none is added. + */ export function PayoutsEmptyState() { return ( -
- -

No payouts yet

-

- Your payouts will appear here once mining rewards have been credited and sent to your payout address. -

+
+ +
+

No payouts yet

+

+ Your payouts will appear here once mining rewards have been credited and sent to your payout address. +

+
); } diff --git a/src/components/payouts/PayoutsExportModal.tsx b/src/components/payouts/PayoutsExportModal.tsx index 9c55ddc4..c49f5724 100644 --- a/src/components/payouts/PayoutsExportModal.tsx +++ b/src/components/payouts/PayoutsExportModal.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { cn } from '@/lib/utils'; import { exportPresetRange, type PayoutDatePreset, type DateRange } from '@/lib/payoutsTable'; -import { Calendar } from './Calendar'; +import { CalendarSheet } from './CalendarSheet'; type ExportChoice = PayoutDatePreset | 'custom'; @@ -12,6 +12,7 @@ const PRESETS: { value: ExportChoice; label: string }[] = [ { value: 'custom', label: 'Custom' }, ]; +/** A preset row: a 16px radio and its label, which darkens once chosen. */ function Radio({ label, checked, onClick }: { label: string; checked: boolean; onClick: () => void }) { return ( ); } @@ -34,6 +40,10 @@ function Radio({ label, checked, onClick }: { label: string; checked: boolean; o * export. Anchored under the Export CSV button; closes on outside click or Escape. * Reports the chosen range so the page builds the CSV. `title` names the data being * exported, so the workers page reuses this with its own heading. + * + * Choosing Custom opens the calendar as its own surface rather than growing this + * panel: a floating popover on desktop and a bottom sheet on mobile, which is how the + * frames draw it (the panel keeps its 328px height in every state). */ export function PayoutsExportModal({ title = 'Export payouts data', @@ -51,6 +61,9 @@ export function PayoutsExportModal({ useEffect(() => { const onDown = (e: MouseEvent) => { + // The calendar renders outside this panel (portalled on mobile, floated on + // desktop), so a click inside it must not read as "outside" and close us. + if (showCalendar) return; if (ref.current && !ref.current.contains(e.target as Node)) onCancel(); }; const onKey = (e: KeyboardEvent) => { @@ -62,7 +75,7 @@ export function PayoutsExportModal({ document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; - }, [onCancel]); + }, [onCancel, showCalendar]); const select = (value: ExportChoice) => { setChoice(value); @@ -79,53 +92,65 @@ export function PayoutsExportModal({ }; return ( -
-

{title}

-

Choose a date range for the report.

+ <> +
+
+
+

{title}

+

Choose a date range for the report.

+
+
+
+ +
+ {PRESETS.map((p) => ( + select(p.value)} /> + ))} +
-
- {PRESETS.map((p) => ( - select(p.value)} /> - ))} +
+
+
+ + +
+
{showCalendar && ( -
- { - setShowCalendar(false); - setChoice(null); - }} - onDone={(range) => { - setCustomRange(range); - setShowCalendar(false); - }} - /> -
+ { + setShowCalendar(false); + setChoice(null); + }} + onDone={(range) => { + setCustomRange(range); + setShowCalendar(false); + }} + /> )} - -
- - -
-
+ ); } diff --git a/src/components/payouts/PayoutsFilter.tsx b/src/components/payouts/PayoutsFilter.tsx index 2d6699f2..ead3cf53 100644 --- a/src/components/payouts/PayoutsFilter.tsx +++ b/src/components/payouts/PayoutsFilter.tsx @@ -11,7 +11,8 @@ type Category = 'date' | 'mode' | 'amount' | 'account'; /** The Filter popover's draft selection (UI state; the page maps the date preset to a cutoff). */ export interface PayoutFilterDraft { datePreset: PayoutDatePreset | null; - mode: PayoutMode | null; + // Modes are checkboxes in the design (both drawn checked); empty keeps every mode. + modes: PayoutMode[]; amountSort: AmountSort | null; // Account names to keep, used only in aggregated mode; empty means every account. accounts: string[]; @@ -19,13 +20,13 @@ export interface PayoutFilterDraft { export const EMPTY_PAYOUT_FILTER_DRAFT: PayoutFilterDraft = { datePreset: null, - mode: null, + modes: [], amountSort: null, accounts: [], }; export function isPayoutDraftActive(d: PayoutFilterDraft): boolean { - return d.datePreset !== null || d.mode !== null || d.amountSort !== null || d.accounts.length > 0; + return d.datePreset !== null || d.modes.length > 0 || d.amountSort !== null || d.accounts.length > 0; } const DATE_OPTIONS: { value: PayoutDatePreset; label: string }[] = [ @@ -37,6 +38,7 @@ const MODE_OPTIONS: { value: PayoutMode; label: string }[] = [ { value: 'pplns', label: 'PPLNS' }, { value: 'fpps', label: 'FPPS' }, ]; +const ALL_MODES: PayoutMode[] = ['pplns', 'fpps']; const AMOUNT_OPTIONS: { value: AmountSort; label: string }[] = [ { value: 'highest', label: 'Highest first' }, { value: 'lowest', label: 'Lowest first' }, @@ -94,7 +96,7 @@ function Option({ label, checked, onClick }: { label: string; checked: boolean; ); } -const Divider = () =>
; +const Divider = () =>
; /** * The payouts Filter popover with Date, Mode, and Amount categories. Draft-then-Apply: @@ -142,30 +144,42 @@ export function PayoutsFilter({ const toggleAccount = (name: string) => setDraft((d) => ({ ...d, accounts: toggleAllCheckedSelection(d.accounts, name, accounts) })); + // Mode uses the same all-checked-by-default convention as the account facet, so an + // untouched popover filters nothing and unchecking one mode narrows to the other. + const toggleMode = (value: PayoutMode) => + setDraft((d) => ({ ...d, modes: toggleAllCheckedSelection(d.modes, value, ALL_MODES) })); + return (
-
-
-

Filter payouts

-

- {accounts.length > 0 - ? 'Find payouts by date, mode, subaccounts or amount.' - : 'Find payouts by date, mode, or amount.'} -

-
-
+
+

Filter payouts

+

+ {accounts.length > 0 + ? 'Find payouts by date, mode, subaccounts or amount.' + : 'Find payouts by date, mode, or amount.'} +

+
+ + {/* Mobile puts these in their own row at the foot of the panel, under a rule; + sm+ returns them to the header row. One render, moved by grid placement. */} +
@@ -175,14 +189,13 @@ export function PayoutsFilter({ onApply(draft); onClose(); }} - className="rounded-full bg-[hsl(var(--btn))] px-4 py-1.5 text-xs font-medium text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90" + className="inline-flex h-9 flex-1 items-center justify-center rounded-[32px] border border-black/20 bg-[hsl(var(--btn))] px-5 text-sm leading-5 text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90 sm:flex-none" > - Apply filter(s) - -
+ Apply filter(s) +
-
+
{CATEGORIES.filter((c) => c.key !== 'account' || accounts.length > 0).map(({ key, label, Icon }) => (
DateAccount - Average hashrate - +
+ + DateAccount + + Average hashrate + + - Generated BTC - + + + Generated BTC + +
+
{formatGeneratedDate(e.entry_day)}{e.account ?? '--'}{formatHashrate(e.hashrate)} - +
+ onToggleOne(generatedBtcRowId(e))} + label={`Select ${formatGeneratedDate(e.entry_day)}`} + /> + {formatGeneratedDate(e.entry_day)}{e.account ?? '--'}{formatHashrate(e.hashrate)} +
- - - {showAccount && } - - - - - + + + + {showAccount && } + + + + + {payouts.length === 0 && empty && ( - )} {payouts.map((p) => ( - - - {showAccount && } - + + + {/* Plain text in the body colour, as drawn -- the design gives the account + no badge or chip, and no different weight from the other values. */} + {showAccount && } + - - - - + + {emptyRow && ( + + + + )} + {subaccounts.map((s) => ( + + + + + + + + + + + ))} + +
DateAccountTransaction IDAmountModePayout addressAction
+ + DateAccountTransaction IDAmountModePayout addressAction
+
{formatPayoutDate(p.date)}{p.account ?? '--'} +
+ onToggleOne(payoutRowId(p))} + label={`Select payout ${truncateMiddle(p.txid, 6, 4)}`} + /> + {formatPayoutDate(p.date)}{p.account ?? '--'} - {formatBtcFromSats(p.amountSats)} BTC + + {formatBtcFromSats(p.amountSats)}{' '} + BTC + {truncateMiddle(p.toAddress, 4, 4)} + {truncateMiddle(p.toAddress, 4, 4)} Open explorer @@ -197,7 +227,7 @@ export function PayoutsTable({
{payouts.length === 0 && empty && } {payouts.map((p) => ( - + ))}
diff --git a/src/components/settings/AboutTab.tsx b/src/components/settings/AboutTab.tsx index 5e487f6e..58c5c1ec 100644 --- a/src/components/settings/AboutTab.tsx +++ b/src/components/settings/AboutTab.tsx @@ -18,21 +18,21 @@ const LINKS: { label: string; href: string }[] = [ /** The About tab: the dashboard version and a set of external document/social links. */ export function AboutTab() { return ( -
+
-

DMND Dashboard

-

{DASHBOARD_VERSION}

+

DMND Dashboard

+

{DASHBOARD_VERSION}

-
+
-

Links

+

Links

Access our important documents and social addresses

-
+
    {LINKS.map((link) => link.href ? ( @@ -41,20 +41,20 @@ export function AboutTab() { href={link.href} target={link.href.startsWith('mailto:') ? undefined : '_blank'} rel="noopener noreferrer" - className="inline-flex items-center gap-1 text-sm text-foreground underline-offset-4 hover:underline" + className="inline-flex items-center gap-1 border-b-[0.5px] border-foreground pb-0.5 text-sm leading-5 text-foreground transition-opacity hover:opacity-80" > {link.label} - + ) : (
  • {link.label} - +
  • ), diff --git a/src/components/settings/AccountTab.tsx b/src/components/settings/AccountTab.tsx index 4f32e44f..fcae736a 100644 --- a/src/components/settings/AccountTab.tsx +++ b/src/components/settings/AccountTab.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { LiCopy, LiCheckCircle, LiClockCircle } from 'solar-icon-react/li'; +import { LiCopy, LiCheckCircle } from 'solar-icon-react/li'; +import { BdClockCircle } from 'solar-icon-react/bd'; import { useAccountProfile, userBitcoinAddresses } from '@/hooks/useAccountData'; import { useAccountScope } from '@/hooks/useAccountScope'; import { truncateMiddle } from '@/lib/payoutsTable'; @@ -21,9 +22,11 @@ const PROFILE_PLACEHOLDER = { /** A labelled read-only field styled like the other settings inputs. */ function ReadonlyField({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) { return ( -
    - {label} -
    {value}
    +
    + {label} +
    + {value} +
    {children}
    ); @@ -65,20 +68,20 @@ export function AccountTab() { const addresses = profile ? [...userBitcoinAddresses(profile)] : []; return ( -
    +
    -

    Profile

    +

    Profile

    Manage your personal information and company details

    -
    -
    +
    +
    - - + + KYB verification is in review @@ -87,10 +90,10 @@ export function AccountTab() {
    -

    Bitcoin address

    +

    Bitcoin address

    This is the address you receive your mining payouts.

    -
    +
    {isLoading ? (
    @@ -112,8 +115,8 @@ export function AccountTab() {
    Bitcoin address
    -
    - +
    + {truncateMiddle(addresses[0], 10, 8)} @@ -122,7 +125,7 @@ export function AccountTab() { type="button" onClick={() => setChanging(true)} disabled={!canEditBitcoinAddress} - className="shrink-0 rounded-full border border-border px-5 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40" + className="inline-flex h-10 shrink-0 items-center rounded-[32px] border-[0.5px] border-black/20 bg-btn-secondary px-5 text-sm leading-5 text-foreground transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-40" > Change diff --git a/src/components/settings/ChangeBitcoinAddressModal.tsx b/src/components/settings/ChangeBitcoinAddressModal.tsx index 392cb3d4..ec971559 100644 --- a/src/components/settings/ChangeBitcoinAddressModal.tsx +++ b/src/components/settings/ChangeBitcoinAddressModal.tsx @@ -62,13 +62,13 @@ export function ChangeBitcoinAddressModal({ onClose, onSaved }: { onClose: () => return (
    -
    +
    -
    +

    Change bitcoin address

    Enter a new bitcoin address to receive your payouts.

    @@ -123,7 +123,7 @@ export function ChangeBitcoinAddressModal({ onClose, onSaved }: { onClose: () => type="button" disabled={!addressValid} onClick={() => setStep('code')} - className="rounded-full bg-[hsl(var(--btn))] px-5 py-2.5 text-sm font-medium text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90 disabled:opacity-40" + className="inline-flex h-11 w-full items-center justify-center rounded-[32px] border border-black/20 bg-[hsl(var(--btn))] px-6 text-base leading-6 text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90 disabled:opacity-40" > Continue diff --git a/src/components/settings/Enable2faModal.tsx b/src/components/settings/Enable2faModal.tsx index 812f0b65..4f5085f5 100644 --- a/src/components/settings/Enable2faModal.tsx +++ b/src/components/settings/Enable2faModal.tsx @@ -68,13 +68,13 @@ export function Enable2faModal({ onClose, onEnabled }: { onClose: () => void; on return (
    -
    +
    -
    +

    Enable two-factor authentication

    @@ -139,7 +139,7 @@ export function Enable2faModal({ onClose, onEnabled }: { onClose: () => void; on type="button" disabled={code.length !== 6 || submitting} onClick={() => void submit()} - className="rounded-full bg-[hsl(var(--btn))] px-5 py-2.5 text-sm font-medium text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90 disabled:opacity-40" + className="inline-flex h-11 w-full items-center justify-center rounded-[32px] border border-black/20 bg-[hsl(var(--btn))] px-6 text-base leading-6 text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90 disabled:opacity-40" > {submitting ? 'Enabling...' : 'Enable 2FA'} diff --git a/src/components/settings/Manage2faModal.tsx b/src/components/settings/Manage2faModal.tsx index b7482b91..dcc4e4ee 100644 --- a/src/components/settings/Manage2faModal.tsx +++ b/src/components/settings/Manage2faModal.tsx @@ -68,13 +68,13 @@ export function Manage2faModal({ onClose, onChanged }: { onClose: () => void; on return (

    -
    +
    -
    +

    Manage 2FA

    @@ -105,7 +105,7 @@ export function Manage2faModal({ onClose, onChanged }: { onClose: () => void; on type="button" disabled={phase === 'loading'} onClick={() => void startReset()} - className="shrink-0 rounded-full border border-border px-5 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-40" + className="inline-flex h-9 shrink-0 items-center rounded-[32px] border-[0.5px] border-black/20 bg-btn-secondary px-5 text-sm leading-5 text-foreground transition-opacity hover:opacity-80 disabled:opacity-40" > {phase === 'loading' ? 'Loading...' : 'Reset 2FA'} @@ -113,7 +113,7 @@ export function Manage2faModal({ onClose, onChanged }: { onClose: () => void; on @@ -167,7 +167,7 @@ export function Manage2faModal({ onClose, onChanged }: { onClose: () => void; on type="button" disabled={code.length !== 6 || submitting} onClick={() => void submit()} - className="rounded-full bg-[hsl(var(--btn))] px-5 py-2.5 text-sm font-medium text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90 disabled:opacity-40" + className="inline-flex h-11 w-full items-center justify-center rounded-[32px] border border-black/20 bg-[hsl(var(--btn))] px-6 text-base leading-6 text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90 disabled:opacity-40" > {submitting ? 'Updating...' : 'Reset 2FA'} diff --git a/src/components/settings/PreferencesTab.tsx b/src/components/settings/PreferencesTab.tsx index 617f9298..784f238d 100644 --- a/src/components/settings/PreferencesTab.tsx +++ b/src/components/settings/PreferencesTab.tsx @@ -35,7 +35,7 @@ function ThemeCard({ type="button" aria-pressed={selected} onClick={onSelect} - className="flex flex-col items-center gap-2" + className="flex w-[72px] flex-col items-center gap-2" >

    -

    Theme

    +

    Theme

    Choose the theme of your dashboard

    -
    -
    +
    +
    {THEMES.map((t) => (
    -

    Localization

    +

    Localization

    {/* States the language rather than offering a choice: the account language is set server-side and there is no endpoint to change it. */}

    The language used across your dashboard

    -
    +
    Display language
    - - + + English
    diff --git a/src/components/settings/SecurityTab.tsx b/src/components/settings/SecurityTab.tsx index 964b057c..ce92f780 100644 --- a/src/components/settings/SecurityTab.tsx +++ b/src/components/settings/SecurityTab.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { LiLockPassword, LiShieldCheck, LiShieldKeyhole } from 'solar-icon-react/li'; +import { LiShieldKeyhole } from 'solar-icon-react/li'; +import { BdLockKeyholeMinimalistic, BdCheckCircle } from 'solar-icon-react/bd'; import { useAuth } from '@/auth'; import { useAccountProfile } from '@/hooks/useAccountData'; import { useAccountScope } from '@/hooks/useAccountScope'; @@ -11,10 +12,10 @@ function SectionHeading({ title, subtitle }: { title: string; subtitle: string } return (
    -

    {title}

    +

    {title}

    {subtitle}

    -
    +
    ); } @@ -58,8 +59,8 @@ export function SecurityTab() {
    - - + + ........ {/* Present per the design but disabled: the recovery flow must not be used @@ -69,7 +70,7 @@ export function SecurityTab() { type="button" disabled title="Password reset is coming soon" - className="shrink-0 rounded-full border border-border px-5 py-2.5 text-sm font-medium text-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-40" + className="inline-flex h-9 shrink-0 items-center rounded-[32px] border-[0.5px] border-black/20 bg-btn-secondary px-5 text-sm leading-5 text-foreground transition-opacity disabled:cursor-not-allowed disabled:opacity-40" > Change password @@ -85,22 +86,22 @@ export function SecurityTab() {
    ) : twoFaEnabled ? (
    - - - 2FA is enabled + + + 2FA is enabled
    ) : (
    - + 2FA is not enabled diff --git a/src/components/subaccounts/CreateSubaccountModal.tsx b/src/components/subaccounts/CreateSubaccountModal.tsx index b2e852c7..9c52781e 100644 --- a/src/components/subaccounts/CreateSubaccountModal.tsx +++ b/src/components/subaccounts/CreateSubaccountModal.tsx @@ -1,6 +1,10 @@ -import { useEffect, useState } from 'react'; -import { LiCloseCircle, LiCheckCircle, LiClipboard } from 'solar-icon-react/li'; -import { isValidBitcoinAddress } from '@/lib/utils'; +import { useEffect, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { X } from 'lucide-react'; +import { LiClipboardText } from 'solar-icon-react/li'; +import { BoShieldWarning } from 'solar-icon-react/bo'; +import { BdCheckCircle } from 'solar-icon-react/bd'; +import { cn, isValidBitcoinAddress, overlayContainer } from '@/lib/utils'; import { useCreateSubaccount } from '@/hooks/useSubaccounts'; /** Accept either a mainnet or a testnet payout address; the server is the final authority. */ @@ -9,12 +13,91 @@ function looksLikeBtc(addr: string): boolean { return isValidBitcoinAddress(a, 'mainnet') || isValidBitcoinAddress(a, 'testnet4'); } +/** The 40px field used by both inputs: no visible border, muted fill, 16px radius. */ +function Field({ + id, + label, + required, + value, + onChange, + placeholder, + action, +}: { + id: string; + label: string; + required?: boolean; + value: string; + onChange: (v: string) => void; + placeholder: string; + action?: ReactNode; +}) { + return ( +
    + + onChange(e.target.value)} + placeholder={placeholder} + className="h-10 w-full rounded-[16px] bg-muted px-4 py-2 text-sm leading-5 text-foreground placeholder:text-placeholder focus:outline-none focus:ring-1 focus:ring-ring" + /> + {action &&
    {action}
    } +
    + ); +} + +/** The primary action pill: full width, 44 tall, 32 radius. */ +function PrimaryButton({ + children, + disabled, + onClick, + type = 'button', +}: { + children: ReactNode; + disabled?: boolean; + onClick?: () => void; + type?: 'button' | 'submit'; +}) { + return ( + + ); +} + /** - * Right-side drawer to create a subaccount: name + payout address -> create -> success. - * The create endpoint takes only {sub_account, bitcoin_address} (no 2FA token in the - * body), so the form has no 2FA step. + * Create a subaccount: name + optional payout address, then a confirmation step. + * + * Both steps are the same drawer the worker details panel uses (a right-side panel on + * desktop, a bottom sheet on mobile). The create endpoint takes only + * {sub_account, bitcoin_address} and, unlike the standalone address endpoint, needs no + * 2FA token, so the form has no verification step. + * + * The payout address is optional here because it can be set afterwards against the + * subaccount; the format is still validated whenever one is typed, since a wrong + * address sends mining income somewhere unrecoverable. */ -export function CreateSubaccountModal({ onClose }: { onClose: () => void }) { +export function CreateSubaccountModal({ + onClose, + onOpenCreated, +}: { + onClose: () => void; + /** Switches the dashboard to the subaccount just created, by name. */ + onOpenCreated?: (name: string) => void; +}) { const [name, setName] = useState(''); const [address, setAddress] = useState(''); const [addrError, setAddrError] = useState(null); @@ -28,7 +111,7 @@ export function CreateSubaccountModal({ onClose }: { onClose: () => void }) { return () => window.removeEventListener('keydown', onKey); }, [onClose]); - const canSubmit = name.trim().length > 0 && address.trim().length > 0 && !create.isPending; + const canSubmit = name.trim().length > 0 && !create.isPending; const paste = async () => { try { @@ -44,119 +127,166 @@ export function CreateSubaccountModal({ onClose }: { onClose: () => void }) { }; const submit = () => { - if (!looksLikeBtc(address)) { + const addr = address.trim(); + if (addr && !looksLikeBtc(addr)) { setAddrError('Enter a valid Bitcoin address.'); return; } setAddrError(null); - create.mutate({ name: name.trim(), bitcoinAddress: address.trim() }); + create.mutate({ name: name.trim(), bitcoinAddress: addr }); }; - const errorMessage = - create.isError ? ((create.error as Error)?.message ?? 'Could not create the subaccount. Please try again.') : null; + const errorMessage = create.isError + ? ((create.error as Error)?.message ?? 'Could not create the subaccount. Please try again.') + : null; - return ( -
    -
    + const closeButton = ( + + ); + + return createPortal( +
    +
    -
    -
    -

    Create subaccount

    -

    - Create a separate mining account to organize workers, earnings, and payouts independently. -

    -
    - -
    - {create.isSuccess ? ( -
    - -

    Subaccount created

    -

    {name.trim()} is ready.

    - +
    +
    {closeButton}
    + +
    + + + +
    +

    + Subaccount created successfully +

    +

    + You can now connect workers and manage mining activity separately. +

    +
    +
    + +
    +
    +
    + +
    + onOpenCreated?.(name.trim())} disabled={!onOpenCreated}> + Open subaccount + +
    +
    +
    ) : ( -
    { - e.preventDefault(); - if (canSubmit) submit(); - }} - > -
    - - +
    +
    +
    +

    + Create subaccount +

    +

    + Create a separate mining account to organize workers, earnings, and payouts independently. +

    +
    + {closeButton} +
    +
    +
    + + { + e.preventDefault(); + if (canSubmit) submit(); + }} + > + setName(e.target.value)} + onChange={setName} placeholder="e.g. Warehouse 01" - className="mt-1.5 w-full rounded-lg border border-border bg-muted px-3 py-2 text-sm text-foreground placeholder:text-placeholder focus:outline-none focus:ring-1 focus:ring-ring" /> -
    -
    - - { - setAddress(e.target.value); + onChange={(v) => { + setAddress(v); setAddrError(null); }} placeholder="Enter your bitcoin address" - className="mt-1.5 w-full rounded-lg border border-border bg-muted px-3 py-2 text-sm text-foreground placeholder:text-placeholder focus:outline-none focus:ring-1 focus:ring-ring" + action={ + + } /> -
    - {addrError ? {addrError} : } - -
    -
    - {errorMessage &&

    {errorMessage}

    } + {addrError &&

    {addrError}

    } -
    + {address.trim().length > 0 && ( +
    + + + +
    +

    Double check address

    +

    + Bitcoin payments sent to the wrong address cannot be recovered by anyone, including DMND Pool. +

    +
    +
    + )} - - + {errorMessage &&

    {errorMessage}

    } + +
    +
    + + {create.isPending ? 'Creating...' : 'Continue'} + +
    + + )}
    -
    +
    , + overlayContainer(), ); } diff --git a/src/components/subaccounts/SubaccountsEmptyState.tsx b/src/components/subaccounts/SubaccountsEmptyState.tsx index 750b67f1..15474e7f 100644 --- a/src/components/subaccounts/SubaccountsEmptyState.tsx +++ b/src/components/subaccounts/SubaccountsEmptyState.tsx @@ -1,26 +1,32 @@ -import { LiLayersMinimalistic, LiAddCircle } from 'solar-icon-react/li'; +import { Plus } from 'lucide-react'; +import { BdLayersMinimalistic } from 'solar-icon-react/bd'; -/** New-user state: no subaccounts yet, with the primary Create call-to-action. */ +/** + * New-user state: no subaccounts yet, with the primary Create call-to-action. + * + * The card carries a top border only and no radius, unlike every other content card on + * the page; that is how the frame draws it. + */ export function SubaccountsEmptyState({ onCreate, canCreate }: { onCreate: () => void; canCreate: boolean }) { return ( -
    -
    - -

    No subaccounts yet

    -

    +

    + +
    +

    No subaccounts yet

    +

    Subaccounts help you separate workers, earnings, and payouts across different mining operations. Create a subaccount if you manage multiple farms, locations, or clients.

    - {canCreate && ( - - )}
    + {canCreate && ( + + )}
    ); } diff --git a/src/components/subaccounts/SubaccountsFilter.tsx b/src/components/subaccounts/SubaccountsFilter.tsx index 4d75bc48..78dbb60a 100644 --- a/src/components/subaccounts/SubaccountsFilter.tsx +++ b/src/components/subaccounts/SubaccountsFilter.tsx @@ -61,7 +61,7 @@ function Option({ label, checked, onClick }: { label: string; checked: boolean; ); } -const Divider = () =>
    ; +const Divider = () =>
    ; /** * The subaccounts Filter popover. A draft of the selection lives here; "Apply @@ -108,14 +108,14 @@ export function SubaccountsFilter({ ref={ref} role="dialog" aria-label="Filter subaccounts" - className="absolute right-0 top-full z-20 mt-2 w-[574px] max-w-[calc(100vw-2rem)] rounded-3xl border border-border bg-popover px-4 pb-5 pt-4 shadow-xl sm:px-8 sm:pb-8" + className="absolute right-0 top-full z-20 mt-2 w-[574px] max-w-[calc(100vw-2rem)] rounded-3xl border-[0.5px] border-border bg-card px-4 pb-5 pt-4 shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)] sm:px-8 sm:pb-8" > {/* Header: title + Reset/Apply, then a full-width divider. */}
    -

    Filter subaccounts

    -

    Find by status or performance.

    +

    Filter subaccounts

    +

    Find by status or performance.

    @@ -134,13 +134,13 @@ export function SubaccountsFilter({ onApply(draft); onClose(); }} - className="rounded-full bg-[hsl(var(--btn))] px-5 py-2 text-xs font-medium text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90" + className="inline-flex h-9 items-center rounded-[32px] border border-black/20 bg-[hsl(var(--btn))] px-5 text-sm leading-5 text-[hsl(var(--btn-foreground))] transition-opacity hover:opacity-90" > Apply filter(s)
    -
    +
    {/* Body: rail | divider | option column(s). Tighter gaps on mobile so the @@ -154,9 +154,8 @@ export function SubaccountsFilter({ onClick={() => setCategory(key)} className={cn( 'flex items-center gap-1 text-left text-sm transition-colors', - category === key - ? 'font-medium text-foreground underline underline-offset-4' - : 'text-body-alt hover:text-foreground', + 'text-foreground', + category === key ? 'underline underline-offset-4' : 'hover:opacity-70', )} > diff --git a/src/components/subaccounts/SubaccountsStatCards.tsx b/src/components/subaccounts/SubaccountsStatCards.tsx index 78f17a69..249c6c50 100644 --- a/src/components/subaccounts/SubaccountsStatCards.tsx +++ b/src/components/subaccounts/SubaccountsStatCards.tsx @@ -1,45 +1,62 @@ import type { ReactNode } from 'react'; -import { LiInfoCircle } from 'solar-icon-react/li'; -import { formatHashrate } from '@/lib/utils'; +import { InfoHint } from '@/components/ui/InfoHint'; +import { Reading } from '@/components/ui/Reading'; +import { formatAxisValue, pickHashrateScale } from '@/lib/chartAxis'; import { formatBtc, type SubaccountsPageStats } from '@/lib/subaccountsTable'; -function Card({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) { +/** + * A stat card. Same shell and type ramp as the home and workers cards, so the three + * pages cannot drift apart: the reading is a numeral in the heading face with its unit + * trailing at body size, and the caption sits at body size below it. + */ +function Card({ + title, + hint, + children, + caption, +}: { + title: string; + hint?: string; + children: ReactNode; + caption: string; +}) { return ( -
    -
    - {title} - {hint && } +
    +
    + {title} + {hint && }
    {children} +

    {caption}

    ); } /** Total subaccounts / Active workers / Combined hashrate / Today's total earnings. */ export function SubaccountsStatCards({ stats }: { stats: SubaccountsPageStats }) { + // One unit for the combined figure, matching how every other hashrate reading is set. + const scale = pickHashrateScale([stats.combinedHashrate]); + return (
    - -

    {stats.total}

    -

    Mining operations

    + + - -

    {stats.activeWorkers}

    -

    Across all subaccounts

    + + - -

    {formatHashrate(stats.combinedHashrate)}

    -

    Across all subaccounts

    + + - -

    - {formatBtc(stats.todayEarnings)} - BTC -

    -

    Generated across all subaccounts

    + +
    ); diff --git a/src/components/subaccounts/SubaccountsTable.tsx b/src/components/subaccounts/SubaccountsTable.tsx index ce579721..b530b0a4 100644 --- a/src/components/subaccounts/SubaccountsTable.tsx +++ b/src/components/subaccounts/SubaccountsTable.tsx @@ -1,4 +1,5 @@ import { formatHashrate } from '@/lib/utils'; +import { CellCheckbox } from '@/components/ui/CellCheckbox'; import { formatBtc, type EnrichedSubaccount } from '@/lib/subaccountsTable'; /** The empty message shown in the table body when a search or filter excludes every row. */ @@ -9,66 +10,171 @@ export interface SubaccountsEmpty { onClear: () => void; } +/** A rejection rate as a percentage, or -- when the account has submitted no shares. */ +function rejectionText(rejection: number | null): string { + return rejection === null ? '--' : `${(rejection * 100).toFixed(1)}%`; +} + +/** + * A BTC figure: the amount at body size with the unit trailing one step smaller and in + * the muted tone, as the design sets every BTC cell. A null total reads as unknown + * rather than 0, which on money data would wrongly say "earned nothing". + */ +function BtcCell({ value }: { value: number | null }) { + if (value === null) return --; + return ( + <> + {formatBtc(value)}{' '} + BTC + + ); +} + +/** + * One subaccount as a mobile card: Name / Active workers / Hashrate on the first line, + * Rejection rate / Today's earnings / Generated BTC on the second. The mobile frame + * draws neither the select column nor the row action, so this card carries no + * checkbox and no Open link; a phone can still open a subaccount from the account + * switcher in the top bar. + */ +function SubaccountCard({ subaccount }: { subaccount: EnrichedSubaccount }) { + const cells: [string, React.ReactNode][] = [ + ['Name', {subaccount.name}], + ['Active workers', {subaccount.active}], + ['Hashrate', {formatHashrate(subaccount.hashrate)}], + ['Rejection rate', {rejectionText(subaccount.rejection)}], + ["Today's earnings", ], + ['Generated BTC', ], + ]; + return ( +
    + {[cells.slice(0, 3), cells.slice(3)].map((row, i) => ( +
    + {row.map(([label, value]) => ( +
    +

    {label}

    +

    {value}

    +
    + ))} +
    + ))} +
    + ); +} + /** * The subaccounts table (one page of enriched rows). Columns are not sortable by * header click; sorting is done through the Filter popover's "Sort by" section, to - * match the design. + * match the design. Below sm the rows become cards (see SubaccountCard). */ export function SubaccountsTable({ subaccounts, empty, + selected, + allSelected, + someSelected, + onToggleAll, + onToggleOne, + onOpen, + opening, }: { subaccounts: EnrichedSubaccount[]; empty?: SubaccountsEmpty; + selected: Set; + allSelected: boolean; + someSelected: boolean; + onToggleAll: () => void; + onToggleOne: (id: string) => void; + /** Switches the dashboard to that subaccount; undefined when it cannot be opened. */ + onOpen?: (subaccount: EnrichedSubaccount) => void; + opening: boolean; }) { + const emptyRow = subaccounts.length === 0 && empty && ( +
    +

    {empty.title}

    +

    {empty.hint}

    + +
    + ); + return ( -
    - - - - - - - - - - - - - {subaccounts.length === 0 && empty && ( - - - - )} - {subaccounts.map((s) => ( - - - - - - - + <> +
    +
    NameActive workersOffline workersHashrateRejection rateToday's earnings
    -

    {empty.title}

    -

    {empty.hint}

    - -
    {s.name}{s.active} - {s.offline} - {s.offline24h > 0 && ({s.offline24h} >24h)} - {formatHashrate(s.hashrate)} - {s.rejection === null ? '--' : `${(s.rejection * 100).toFixed(1)}%`} - - {formatBtc(s.todayEarnings)} BTC -
    + + + + + + + + + + - ))} - -
    + + NameActive workersHashrateRejection rateGenerated BTCToday’s earningsAction
    -
    + +
{emptyRow}
+ onToggleOne(s.id)} + label={`Select ${s.name}`} + /> + {s.name}{s.active}{formatHashrate(s.hashrate)}{rejectionText(s.rejection)} + + + + + {onOpen && ( + + )} +
+
+ +
+ {emptyRow} + {subaccounts.map((s) => ( + + ))} +
+ ); } diff --git a/src/components/subaccounts/SubaccountsToolbar.tsx b/src/components/subaccounts/SubaccountsToolbar.tsx index 83a2fa55..d9496d79 100644 --- a/src/components/subaccounts/SubaccountsToolbar.tsx +++ b/src/components/subaccounts/SubaccountsToolbar.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { LiMagnifer, LiFilter } from 'solar-icon-react/li'; +import { LiMagnifer, LiSort } from 'solar-icon-react/li'; import { cn } from '@/lib/utils'; import { isSubaccountFilterActive, type SubaccountFilter } from '@/lib/subaccountsTable'; import { SubaccountsFilter } from './SubaccountsFilter'; @@ -19,24 +19,80 @@ export function SubaccountsToolbar({ onResetFilter: () => void; }) { const [open, setOpen] = useState(false); + // Mobile collapses the search field into an icon; the field itself opens below the + // row, since the frame draws the collapsed button but no expanded state. + const [searchOpen, setSearchOpen] = useState(false); const active = isSubaccountFilterActive(filter); + const searchField = ( +
+ + + onQuery(e.target.value)} + placeholder="Search subaccount" + aria-label="Search subaccounts" + className="h-10 w-full rounded-xl bg-muted py-2 pl-[54px] pr-4 text-sm leading-5 text-foreground placeholder:text-placeholder focus:outline-none focus:ring-1 focus:ring-ring sm:w-[252px]" + /> +
+ ); + + const filterPopover = open && ( + setOpen(false)} + /> + ); + return ( -
-

Subaccounts

+
+
+

Subaccounts

-
-
- - onQuery(e.target.value)} - placeholder="Search subaccount" + {/* Mobile: the search field and the filter collapse to two 32px icon pills. */} +
+ +
+ +
+
+ + {searchOpen &&
{searchField}
} + + {/* One popover for both triggers: rendering it inside each branch would mount two + dialogs at once, one of them display:none but still listening for Escape. */} + {filterPopover} + +
+ {searchField}
- {open && ( - setOpen(false)} - /> - )}
diff --git a/src/components/ui/CellCheckbox.tsx b/src/components/ui/CellCheckbox.tsx new file mode 100644 index 00000000..1e8e6fc8 --- /dev/null +++ b/src/components/ui/CellCheckbox.tsx @@ -0,0 +1,40 @@ +import { Check, Minus } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** + * A square check control for table rows (row select + header select-all), shared by + * the workers and subaccounts tables so the two leading columns cannot drift apart. + * Mirrors the customize panel's checkbox. + */ +export function CellCheckbox({ + checked, + indeterminate = false, + onChange, + label, +}: { + checked: boolean; + indeterminate?: boolean; + onChange: () => void; + label: string; +}) { + const active = checked || indeterminate; + return ( + + ); +} diff --git a/src/components/ui/InfoHint.tsx b/src/components/ui/InfoHint.tsx index 5acb0d04..93639355 100644 --- a/src/components/ui/InfoHint.tsx +++ b/src/components/ui/InfoHint.tsx @@ -1,39 +1,24 @@ -import * as Tooltip from '@radix-ui/react-tooltip'; import { LiQuestionCircle } from 'solar-icon-react/li'; -import { overlayContainer } from '@/lib/utils'; +import { TooltipPill } from './tooltip-pill'; /** * The small info icon next to a stat label that reveals an explanatory tooltip on - * hover or keyboard focus, styled as the design's dark pill. Uses Radix Tooltip (not + * hover or keyboard focus, styled as the design's tooltip pill. The pill inverts with + * the theme via the tooltip tokens rather than a fixed dark hex, so it stays legible in + * both modes. Uses Radix Tooltip (not * Popover) so it opens and closes with the pointer instead of latching open, and * portals into the themed shell so it keeps the design tokens. */ export function InfoHint({ text }: { text: string }) { return ( - - - - - - - - {text} - - - - - + + + ); } diff --git a/src/components/ui/Reading.tsx b/src/components/ui/Reading.tsx new file mode 100644 index 00000000..0aa206b5 --- /dev/null +++ b/src/components/ui/Reading.tsx @@ -0,0 +1,56 @@ +import { cn } from '@/lib/utils'; + +/** + * A numeric readout: a numeral in the heading face with its unit trailing at body size. + * + * The two are separated by an oversized space run rather than a plain space, because + * the numeral carries negative letter-spacing and a normal space would be pulled into + * the digits. Keeping the tracking on the numeral span (never the paragraph) is what + * stops the unit from colliding with the number. + */ +export function Reading({ + value, + unit, + size = 'md', + unitSize, + tone = 'default', + className, +}: { + value: string | number; + unit?: string; + /** lg = the card headline, md = a stat card, sm = one side of a split row. */ + size?: 'sm' | 'md' | 'lg'; + /** The design sets some units a step smaller than the card's default. */ + unitSize?: 'base' | 'lg'; + tone?: 'default' | 'success' | 'muted'; + className?: string; +}) { + const numeral = { + lg: 'text-4xl font-semibold leading-[48px] tracking-[-2.8px]', + md: 'text-2xl font-bold leading-9 tracking-[-1px]', + sm: 'text-2xl font-medium leading-9 tracking-[-1px]', + }[size]; + const unitClass = unitSize + ? { base: 'text-base font-normal leading-6', lg: 'text-lg font-normal leading-7' }[unitSize] + : { lg: 'text-xl font-normal leading-7', md: 'text-lg font-normal leading-7', sm: 'text-sm font-light leading-5' }[size]; + const toneClass = { + default: 'text-foreground', + success: 'text-success-text', + muted: 'text-body-alt', + }[tone]; + + return ( +

+ {value} + {unit && ( + <> + {/* The design's separator is a 56px space, which also sets the row height. */} + + {' '} + + {unit} + + )} +

+ ); +} diff --git a/src/components/ui/WorkerBars.tsx b/src/components/ui/WorkerBars.tsx new file mode 100644 index 00000000..e0b6e9ff --- /dev/null +++ b/src/components/ui/WorkerBars.tsx @@ -0,0 +1,24 @@ +import { cn } from '@/lib/utils'; +import { workerBarFill, WORKER_BAR_COUNT } from '@/lib/workerBars'; + +/** + * The online-worker meter: a fixed strip of bars whose filled count is the share of + * workers currently online, so a large fleet still reads at a glance. + * + * Shared by the home and workers pages. The fill rule lives in `workerBars` because it + * carries the two clamps that matter on a status widget: one live rig never reads as an + * empty strip, and one dead rig never reads as a full one. + */ +export function WorkerBars({ active, total, className }: { active: number; total: number; className?: string }) { + const filled = workerBarFill(active, total); + return ( + + {Array.from({ length: WORKER_BAR_COUNT }, (_, i) => ( + + ))} + + ); +} diff --git a/src/components/ui/input-otp.tsx b/src/components/ui/input-otp.tsx index b18a1543..aca5a5c8 100644 --- a/src/components/ui/input-otp.tsx +++ b/src/components/ui/input-otp.tsx @@ -26,9 +26,9 @@ export function OtpField({ value, onChange, onComplete, disabled, ariaLabel, err aria-label={ariaLabel ?? 'Verification code'} containerClassName="w-full" render={({ slots }) => ( -
+
{slots.map((slot, i) => ( - + ))}
)} @@ -36,11 +36,28 @@ export function OtpField({ value, onChange, onComplete, disabled, ariaLabel, err ); } -function OtpSlot({ isActive, char, hasFakeCaret, error }: SlotProps & { error?: boolean }) { +/** + * The six boxes read as one joined pill: only the outer corners of the run are + * rounded, the inner ones stay nearly square. + */ +const SLOT_RADIUS = { + start: 'rounded-l-[16px] rounded-r-[2px]', + middle: 'rounded-[2px]', + end: 'rounded-r-[16px] rounded-l-[2px]', +} as const; + +function OtpSlot({ + isActive, + char, + hasFakeCaret, + error, + position = 'middle', +}: SlotProps & { error?: boolean; position?: keyof typeof SLOT_RADIUS }) { return (
= { + default: "h-5 w-9 rounded-full border-2 border-transparent shadow-sm", + lg: "h-[18px] w-10 rounded-[16px] p-px", +} + +const THUMB: Record = { + default: "h-4 w-4 rounded-full shadow-lg data-[state=checked]:translate-x-4", + lg: "h-4 w-6 rounded-[8px] data-[state=checked]:translate-x-3.5 data-[state=checked]:shadow-[-8px_8px_15px_rgba(0,0,0,0.2)]", +} + const Switch = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { size?: SwitchSize } +>(({ className, size = "default", ...props }, ref) => ( diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 4ba0027f..579a6a96 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -1,11 +1,6 @@ import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'; -import { - LiCheckCircle, - LiCloseCircle, - LiCloseSquare, - LiInfoCircle, - LiShieldWarning, -} from 'solar-icon-react/li'; +import { X } from 'lucide-react'; +import { BdCheckCircle, BdCloseCircle, BdInfoCircle, BdShieldWarning } from 'solar-icon-react/bd'; import { cn } from '@/lib/utils'; export type ToastType = 'success' | 'error' | 'warning' | 'info'; @@ -26,7 +21,8 @@ interface ToastContextValue { const ToastContext = createContext(null); -const ICON = { success: LiCheckCircle, error: LiCloseCircle, warning: LiShieldWarning, info: LiInfoCircle }; +// The design draws every Info Prompt glyph in the Bold style, not the outline one. +const ICON = { success: BdCheckCircle, error: BdCloseCircle, warning: BdShieldWarning, info: BdInfoCircle }; // Info Prompt: solid tinted pill, radius 16. success/error/warning use // the light status tints with dark body text; the neutral (info) state is a dark @@ -37,12 +33,26 @@ const TINT: Record = { warning: 'bg-toast-warning text-foreground', info: 'bg-toast-neutral text-[#D4D4D4]', }; +// Only the dark neutral pill is drawn with elevation; the tinted ones carry none. +const SHADOW: Record = { + success: '', + error: '', + warning: '', + info: 'shadow-[0_20px_30px_-5px_rgba(0,0,0,0.05),0_8px_20px_-6px_rgba(0,0,0,0.05)]', +}; +// The dark pill sets its title one step lighter than the body; the tinted ones use one colour. +const TITLE_TONE: Record = { + success: '', + error: '', + warning: '', + info: 'text-[#E5E5E5]', +}; // Icon colours per state (constant across themes). const ICON_COLOR: Record = { - success: 'text-[#16A34A]', + success: 'text-[#22C55E]', error: 'text-[#EF4444]', warning: 'text-[#EAB308]', - info: 'text-[#3B82F6]', + info: 'text-[#D4D4D4]', }; const DURATION_MS = 5000; @@ -77,22 +87,26 @@ export function ToastProvider({ children }: { children: ReactNode }) { key={t.id} role="status" className={cn( - 'pointer-events-auto flex items-center gap-2.5 rounded-[16px] px-4 py-3 text-sm shadow-lg', + 'pointer-events-auto flex w-[448px] max-w-full items-start justify-between gap-1 rounded-[16px] p-3', TINT[t.type], + SHADOW[t.type], )} > - -
- {t.message} - {t.description && {t.description}} -
+ + {/* The icon sits 2px low so its optical centre lines up with the title. */} + + + {t.message} + {t.description && {t.description}} + +
); diff --git a/src/components/ui/tooltip-pill.tsx b/src/components/ui/tooltip-pill.tsx new file mode 100644 index 00000000..095e0b3f --- /dev/null +++ b/src/components/ui/tooltip-pill.tsx @@ -0,0 +1,50 @@ +import * as Tooltip from '@radix-ui/react-tooltip'; +import type { ReactNode } from 'react'; +import { cn, overlayContainer } from '@/lib/utils'; + +/** + * The design's tooltip pill, shared by every surface that reveals one on hover. + * + * The design ships it as one component with slots: the supporting-text slot renders + * light on the muted foreground, the head-text slot renders semibold on the brighter + * one. `emphasis` picks between those two rather than each caller restyling the pill. + * + * The pill inverts with the theme via the tooltip tokens rather than a fixed dark hex, + * so it stays legible in both modes. Radix Tooltip (not Popover) so it opens and closes + * with the pointer instead of latching open, and it portals into the themed shell so it + * keeps the design tokens. + */ +export function TooltipPill({ + label, + children, + side = 'top', + emphasis = false, +}: { + label: string; + children: ReactNode; + side?: 'top' | 'right' | 'bottom' | 'left'; + emphasis?: boolean; +}) { + return ( + + + {children} + + + {label} + + + + + + ); +} diff --git a/src/components/watcher-links/AggregateWatcherLinksPanel.tsx b/src/components/watcher-links/AggregateWatcherLinksPanel.tsx index ac1e3415..8c3dced3 100644 --- a/src/components/watcher-links/AggregateWatcherLinksPanel.tsx +++ b/src/components/watcher-links/AggregateWatcherLinksPanel.tsx @@ -93,17 +93,17 @@ export function AggregateWatcherLinksPanel({ return (
-
+
{createdUrl ? ( ) : ( <> -
+

Aggregate watcher links

@@ -120,7 +120,7 @@ export function AggregateWatcherLinksPanel({

-
+