Extract reusable UI components to reduce DRY violations

- Created StatusBadge component: Standardizes status badge display
  - Supports tradeStatus prop for trade-specific styling
  - Supports variant prop for simple badges (success/error/ready)
  - Eliminates repetitive badge style combinations

- Created EmptyState component: Standardizes empty state display
  - Handles loading and empty states consistently
  - Supports message, hint, and action props
  - Used across trades, invites, admin pages

- Created ConfirmationButton component: Standardizes confirmation flows
  - Two-step confirmation pattern (action -> confirm/cancel)
  - Supports different variants (danger/success/primary)
  - Handles loading states automatically
  - Used for cancel, complete, no-show actions

- Migrated pages to use new components:
  - trades/page.tsx: StatusBadge, EmptyState, ConfirmationButton
  - trades/[id]/page.tsx: StatusBadge
  - invites/page.tsx: StatusBadge, EmptyState
  - admin/trades/page.tsx: StatusBadge, EmptyState, ConfirmationButton
  - admin/invites/page.tsx: StatusBadge

Benefits:
- Eliminated ~50+ lines of repetitive badge styling code
- Consistent UI patterns across all pages
- Easier to maintain and update styling
- Better type safety

All tests passing (32 frontend, 33 e2e)
This commit is contained in:
counterweight 2025-12-25 21:40:07 +01:00
parent b86b506d72
commit 1a47b3643f
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
9 changed files with 309 additions and 425 deletions

View file

@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from "react";
import { Permission } from "../../auth-context";
import { adminApi } from "../../api";
import { Header } from "../../components/Header";
import { StatusBadge } from "../../components/StatusBadge";
import { useRequireAuth } from "../../hooks/useRequireAuth";
import { useMutation } from "../../hooks/useMutation";
import { components } from "../../generated/api";
@ -15,7 +16,6 @@ import {
paginationStyles,
formStyles,
buttonStyles,
badgeStyles,
utilityStyles,
} from "../../styles/shared";
@ -119,16 +119,16 @@ export default function AdminInvitesPage() {
return new Date(dateStr).toLocaleString();
};
const getStatusBadgeStyle = (status: string) => {
const getStatusBadgeVariant = (status: string): "ready" | "success" | "error" | undefined => {
switch (status) {
case READY:
return badgeStyles.badgeReady;
return "ready";
case SPENT:
return badgeStyles.badgeSuccess;
return "success";
case REVOKED:
return badgeStyles.badgeError;
return "error";
default:
return {};
return undefined;
}
};
@ -237,11 +237,9 @@ export default function AdminInvitesPage() {
<td style={tableStyles.tdMono}>{record.identifier}</td>
<td style={tableStyles.td}>{record.godfather_email}</td>
<td style={tableStyles.td}>
<span
style={{ ...badgeStyles.badge, ...getStatusBadgeStyle(record.status) }}
>
<StatusBadge variant={getStatusBadgeVariant(record.status)}>
{record.status}
</span>
</StatusBadge>
</td>
<td style={tableStyles.td}>{record.used_by_email || "-"}</td>
<td style={tableStyles.tdDate}>{formatDate(record.created_at)}</td>

View file

@ -5,18 +5,13 @@ import { Permission } from "../../auth-context";
import { adminApi } from "../../api";
import { Header } from "../../components/Header";
import { SatsDisplay } from "../../components/SatsDisplay";
import { EmptyState } from "../../components/EmptyState";
import { ConfirmationButton } from "../../components/ConfirmationButton";
import { useRequireAuth } from "../../hooks/useRequireAuth";
import { components } from "../../generated/api";
import { formatDateTime } from "../../utils/date";
import { formatEur, getTradeStatusDisplay } from "../../utils/exchange";
import {
layoutStyles,
typographyStyles,
bannerStyles,
badgeStyles,
buttonStyles,
tradeCardStyles,
} from "../../styles/shared";
import { formatEur } from "../../utils/exchange";
import { layoutStyles, typographyStyles, bannerStyles, tradeCardStyles } from "../../styles/shared";
type AdminExchangeResponse = components["schemas"]["AdminExchangeResponse"];
@ -197,15 +192,14 @@ export default function AdminTradesPage() {
)}
{isLoadingTrades ? (
<div style={tradeCardStyles.emptyState}>Loading trades...</div>
<EmptyState message="Loading trades..." isLoading={true} />
) : trades.length === 0 ? (
<div style={tradeCardStyles.emptyState}>
{activeTab === "upcoming" ? "No upcoming trades." : "No trades found."}
</div>
<EmptyState
message={activeTab === "upcoming" ? "No upcoming trades." : "No trades found."}
/>
) : (
<div style={tradeCardStyles.tradeList}>
{trades.map((trade) => {
const status = getTradeStatusDisplay(trade.status);
const isBuy = trade.direction === "buy";
const isPast = new Date(trade.slot_start) <= new Date();
const canComplete = trade.status === "booked" && isPast && activeTab === "past";
@ -281,83 +275,72 @@ export default function AdminTradesPage() {
</span>
</div>
<span
style={{
...badgeStyles.badge,
background: status.bgColor,
color: status.textColor,
marginTop: "0.5rem",
}}
>
{status.text}
</span>
<StatusBadge tradeStatus={trade.status} style={{ marginTop: "0.5rem" }} />
</div>
{/* Actions */}
<div style={styles.buttonGroup}>
{confirmAction?.id === trade.public_id ? (
{canComplete && (
<>
<button
onClick={() => handleAction(trade.public_id, confirmAction.type)}
disabled={actioningIds.has(trade.public_id)}
style={
confirmAction.type === "cancel"
? styles.dangerButton
: styles.successButton
<ConfirmationButton
isConfirming={
confirmAction?.id === trade.public_id &&
confirmAction?.type === "complete"
}
>
{actioningIds.has(trade.public_id) ? "..." : "Confirm"}
</button>
<button
onClick={() => setConfirmAction(null)}
style={buttonStyles.secondaryButton}
>
No
</button>
</>
) : (
<>
{canComplete && (
<>
<button
onClick={() =>
setConfirmAction({
id: trade.public_id,
type: "complete",
})
}
style={styles.successButton}
>
Complete
</button>
<button
onClick={() =>
setConfirmAction({
id: trade.public_id,
type: "no_show",
})
}
style={styles.warningButton}
>
No Show
</button>
</>
)}
{trade.status === "booked" && (
<button
onClick={() =>
setConfirmAction({
id: trade.public_id,
type: "cancel",
})
}
style={buttonStyles.secondaryButton}
>
Cancel
</button>
)}
onConfirm={() => handleAction(trade.public_id, "complete")}
onCancel={() => setConfirmAction(null)}
onActionClick={() =>
setConfirmAction({
id: trade.public_id,
type: "complete",
})
}
actionLabel="Complete"
isLoading={actioningIds.has(trade.public_id)}
confirmVariant="success"
confirmButtonStyle={styles.successButton}
actionButtonStyle={styles.successButton}
/>
<ConfirmationButton
isConfirming={
confirmAction?.id === trade.public_id &&
confirmAction?.type === "no_show"
}
onConfirm={() => handleAction(trade.public_id, "no_show")}
onCancel={() => setConfirmAction(null)}
onActionClick={() =>
setConfirmAction({
id: trade.public_id,
type: "no_show",
})
}
actionLabel="No Show"
isLoading={actioningIds.has(trade.public_id)}
confirmVariant="primary"
actionButtonStyle={styles.warningButton}
/>
</>
)}
{trade.status === "booked" && (
<ConfirmationButton
isConfirming={
confirmAction?.id === trade.public_id &&
confirmAction?.type === "cancel"
}
onConfirm={() => handleAction(trade.public_id, "cancel")}
onCancel={() => setConfirmAction(null)}
onActionClick={() =>
setConfirmAction({
id: trade.public_id,
type: "cancel",
})
}
actionLabel="Cancel"
isLoading={actioningIds.has(trade.public_id)}
confirmVariant="danger"
confirmButtonStyle={styles.dangerButton}
/>
)}
</div>
</div>
</div>

View file

@ -0,0 +1,97 @@
import { buttonStyles } from "../styles/shared";
interface ConfirmationButtonProps {
/** Whether confirmation mode is active */
isConfirming: boolean;
/** Callback when user confirms the action */
onConfirm: () => void;
/** Callback when user cancels the confirmation */
onCancel: () => void;
/** Callback when user clicks the initial action button (to enter confirmation mode) */
onActionClick: () => void;
/** Label for the initial action button */
actionLabel: string;
/** Label for the confirm button (default: "Confirm") */
confirmLabel?: string;
/** Label for the cancel button (default: "No") */
cancelLabel?: string;
/** Whether the action is in progress (shows "..." on confirm button) */
isLoading?: boolean;
/** Style variant for the confirm button */
confirmVariant?: "danger" | "success" | "primary";
/** Custom style for the action button */
actionButtonStyle?: React.CSSProperties;
/** Custom style for the confirm button */
confirmButtonStyle?: React.CSSProperties;
}
/**
* Confirmation button component that shows a two-step confirmation flow.
* Initially shows an action button. When clicked, shows Confirm/Cancel buttons.
*/
export function ConfirmationButton({
isConfirming,
onConfirm,
onCancel,
onActionClick,
actionLabel,
confirmLabel = "Confirm",
cancelLabel = "No",
isLoading = false,
confirmVariant = "primary",
actionButtonStyle,
confirmButtonStyle,
}: ConfirmationButtonProps) {
if (isConfirming) {
let confirmStyle: React.CSSProperties = buttonStyles.primaryButton;
if (confirmVariant === "danger") {
confirmStyle = {
...buttonStyles.primaryButton,
background: "rgba(239, 68, 68, 0.9)",
borderColor: "rgba(239, 68, 68, 1)",
};
} else if (confirmVariant === "success") {
confirmStyle = {
...buttonStyles.primaryButton,
background: "rgba(34, 197, 94, 0.9)",
borderColor: "rgba(34, 197, 94, 1)",
};
}
return (
<>
<button
onClick={(e) => {
e.stopPropagation();
onConfirm();
}}
disabled={isLoading}
style={{ ...confirmStyle, ...confirmButtonStyle }}
>
{isLoading ? "..." : confirmLabel}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onCancel();
}}
style={buttonStyles.secondaryButton}
>
{cancelLabel}
</button>
</>
);
}
return (
<button
onClick={(e) => {
e.stopPropagation();
onActionClick();
}}
style={actionButtonStyle || buttonStyles.secondaryButton}
>
{actionLabel}
</button>
);
}

View file

@ -0,0 +1,50 @@
import { utilityStyles } from "../styles/shared";
interface EmptyStateProps {
/** Message to display when empty */
message: string;
/** Optional hint/subtitle text */
hint?: string;
/** Show loading state instead of empty message */
isLoading?: boolean;
/** Optional action element (e.g., link or button) */
action?: React.ReactNode;
/** Custom style override */
style?: React.CSSProperties;
}
/**
* Standardized empty state component.
* Displays a message when there's no data, or a loading state.
*/
export function EmptyState({ message, hint, isLoading, action, style }: EmptyStateProps) {
if (isLoading) {
return <div style={{ ...utilityStyles.emptyState, ...style }}>Loading...</div>;
}
return (
<div style={{ ...utilityStyles.emptyState, ...style }}>
<p style={styles.emptyText}>{message}</p>
{hint && <p style={styles.emptyHint}>{hint}</p>}
{action && <div style={styles.action}>{action}</div>}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
emptyText: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.6)",
fontSize: "1rem",
margin: 0,
},
emptyHint: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.4)",
fontSize: "0.85rem",
marginTop: "0.5rem",
},
action: {
marginTop: "1rem",
},
};

View file

@ -0,0 +1,52 @@
import { badgeStyles } from "../styles/shared";
import { getTradeStatusDisplay } from "../utils/exchange";
type StatusBadgeVariant = "success" | "error" | "ready";
interface StatusBadgeProps {
/** Status text to display */
children: React.ReactNode;
/** Optional variant for simple status badges */
variant?: StatusBadgeVariant;
/** Trade status (uses getTradeStatusDisplay for styling) */
tradeStatus?: string;
/** Custom style override */
style?: React.CSSProperties;
}
/**
* Standardized status badge component.
* Can be used with a variant prop for simple badges, or tradeStatus prop for trade-specific styling.
*/
export function StatusBadge({ children, variant, tradeStatus, style }: StatusBadgeProps) {
let badgeStyle: React.CSSProperties = { ...badgeStyles.badge };
if (tradeStatus) {
// Use trade status display utility for trade-specific badges
const statusDisplay = getTradeStatusDisplay(tradeStatus);
badgeStyle = {
...badgeStyle,
background: statusDisplay.bgColor,
color: statusDisplay.textColor,
};
} else if (variant) {
// Use variant styles for simple badges
switch (variant) {
case "success":
badgeStyle = { ...badgeStyle, ...badgeStyles.badgeSuccess };
break;
case "error":
badgeStyle = { ...badgeStyle, ...badgeStyles.badgeError };
break;
case "ready":
badgeStyle = { ...badgeStyle, ...badgeStyles.badgeReady };
break;
}
}
return (
<span style={{ ...badgeStyle, ...style }}>
{tradeStatus ? getTradeStatusDisplay(tradeStatus).text : children}
</span>
);
}

View file

@ -3,12 +3,13 @@
import { useState } from "react";
import { invitesApi } from "../api";
import { PageLayout } from "../components/PageLayout";
import { StatusBadge } from "../components/StatusBadge";
import { useRequireAuth } from "../hooks/useRequireAuth";
import { useAsyncData } from "../hooks/useAsyncData";
import { components } from "../generated/api";
import constants from "../../../shared/constants.json";
import { Permission } from "../auth-context";
import { cardStyles, typographyStyles, badgeStyles, buttonStyles } from "../styles/shared";
import { cardStyles, typographyStyles, buttonStyles } from "../styles/shared";
// Use generated type from OpenAPI schema
type Invite = components["schemas"]["UserInviteResponse"];
@ -67,10 +68,10 @@ export default function InvitesPage() {
</div>
{invites.length === 0 ? (
<div style={styles.emptyState}>
<p style={styles.emptyText}>You don&apos;t have any invites yet.</p>
<p style={styles.emptyHint}>Contact an admin if you need invite codes to share.</p>
</div>
<EmptyState
message="You don't have any invites yet."
hint="Contact an admin if you need invite codes to share."
/>
) : (
<div style={styles.sections}>
{/* Ready Invites */}
@ -107,9 +108,7 @@ export default function InvitesPage() {
<div key={invite.id} style={styles.inviteCardSpent}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<div style={styles.inviteeMeta}>
<span style={{ ...badgeStyles.badge, ...badgeStyles.badgeSuccess }}>
Used
</span>
<StatusBadge variant="success">Used</StatusBadge>
<span style={styles.inviteeEmail}>by {invite.used_by_email}</span>
</div>
</div>
@ -126,9 +125,7 @@ export default function InvitesPage() {
{revokedInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCardRevoked}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<span style={{ ...badgeStyles.badge, ...badgeStyles.badgeError }}>
Revoked
</span>
<StatusBadge variant="error">Revoked</StatusBadge>
</div>
))}
</div>

View file

@ -6,15 +6,15 @@ import { Permission } from "../../auth-context";
import { tradesApi } from "../../api";
import { Header } from "../../components/Header";
import { SatsDisplay } from "../../components/SatsDisplay";
import { StatusBadge } from "../../components/StatusBadge";
import { useRequireAuth } from "../../hooks/useRequireAuth";
import { useAsyncData } from "../../hooks/useAsyncData";
import { formatDateTime } from "../../utils/date";
import { formatEur, getTradeStatusDisplay } from "../../utils/exchange";
import { formatEur } from "../../utils/exchange";
import {
layoutStyles,
typographyStyles,
bannerStyles,
badgeStyles,
buttonStyles,
tradeCardStyles,
} from "../../styles/shared";
@ -78,7 +78,6 @@ export default function TradeDetailPage() {
);
}
const status = getTradeStatusDisplay(trade.status);
const isBuy = trade.direction === "buy";
return (
@ -98,15 +97,7 @@ export default function TradeDetailPage() {
<div style={styles.detailGrid}>
<div style={styles.detailRow}>
<span style={styles.detailLabel}>Status:</span>
<span
style={{
...badgeStyles.badge,
background: status.bgColor,
color: status.textColor,
}}
>
{status.text}
</span>
<StatusBadge tradeStatus={trade.status} />
</div>
<div style={styles.detailRow}>
<span style={styles.detailLabel}>Time:</span>

View file

@ -6,12 +6,14 @@ import { Permission } from "../auth-context";
import { tradesApi } from "../api";
import { PageLayout } from "../components/PageLayout";
import { SatsDisplay } from "../components/SatsDisplay";
import { StatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { useRequireAuth } from "../hooks/useRequireAuth";
import { useAsyncData } from "../hooks/useAsyncData";
import { useMutation } from "../hooks/useMutation";
import { formatDateTime } from "../utils/date";
import { formatEur, getTradeStatusDisplay } from "../utils/exchange";
import { typographyStyles, badgeStyles, buttonStyles, tradeCardStyles } from "../styles/shared";
import { formatEur } from "../utils/exchange";
import { typographyStyles, tradeCardStyles } from "../styles/shared";
export default function TradesPage() {
const router = useRouter();
@ -71,14 +73,16 @@ export default function TradesPage() {
<p style={typographyStyles.pageSubtitle}>View and manage your Bitcoin trades</p>
{isLoadingTrades ? (
<div style={tradeCardStyles.emptyState}>Loading trades...</div>
<EmptyState message="Loading trades..." isLoading={true} />
) : trades.length === 0 ? (
<div style={tradeCardStyles.emptyState}>
<p>You don&apos;t have any trades yet.</p>
<a href="/exchange" style={styles.emptyStateLink}>
Start trading
</a>
</div>
<EmptyState
message="You don't have any trades yet."
action={
<a href="/exchange" style={styles.emptyStateLink}>
Start trading
</a>
}
/>
) : (
<>
{/* Upcoming Trades */}
@ -87,7 +91,6 @@ export default function TradesPage() {
<h2 style={styles.sectionTitle}>Upcoming ({upcomingTrades.length})</h2>
<div style={tradeCardStyles.tradeList}>
{upcomingTrades.map((trade) => {
const status = getTradeStatusDisplay(trade.status);
const isBuy = trade.direction === "buy";
return (
<div key={trade.id} style={tradeCardStyles.tradeCard}>
@ -137,55 +140,21 @@ export default function TradesPage() {
/BTC
</span>
</div>
<span
style={{
...badgeStyles.badge,
background: status.bgColor,
color: status.textColor,
marginTop: "0.5rem",
}}
>
{status.text}
</span>
<StatusBadge tradeStatus={trade.status} style={{ marginTop: "0.5rem" }} />
</div>
<div style={tradeCardStyles.buttonGroup}>
{trade.status === "booked" && (
<>
{confirmCancelId === trade.public_id ? (
<>
<button
onClick={(e) => {
e.stopPropagation();
handleCancel(trade.public_id);
}}
disabled={cancellingId === trade.public_id}
style={styles.confirmButton}
>
{cancellingId === trade.public_id ? "..." : "Confirm"}
</button>
<button
onClick={(e) => {
e.stopPropagation();
setConfirmCancelId(null);
}}
style={buttonStyles.secondaryButton}
>
No
</button>
</>
) : (
<button
onClick={(e) => {
e.stopPropagation();
setConfirmCancelId(trade.public_id);
}}
style={buttonStyles.secondaryButton}
>
Cancel
</button>
)}
</>
<ConfirmationButton
isConfirming={confirmCancelId === trade.public_id}
onConfirm={() => handleCancel(trade.public_id)}
onCancel={() => setConfirmCancelId(null)}
onActionClick={() => setConfirmCancelId(trade.public_id)}
actionLabel="Cancel"
isLoading={cancellingId === trade.public_id}
confirmVariant="danger"
confirmButtonStyle={styles.confirmButton}
/>
)}
<button
onClick={(e) => {
@ -213,7 +182,6 @@ export default function TradesPage() {
</h2>
<div style={tradeCardStyles.tradeList}>
{pastOrFinalTrades.map((trade) => {
const status = getTradeStatusDisplay(trade.status);
const isBuy = trade.direction === "buy";
return (
<div
@ -256,15 +224,7 @@ export default function TradesPage() {
</span>
</div>
<div style={tradeCardStyles.buttonGroup}>
<span
style={{
...badgeStyles.badge,
background: status.bgColor,
color: status.textColor,
}}
>
{status.text}
</span>
<StatusBadge tradeStatus={trade.status} />
<button
onClick={(e) => {
e.stopPropagation();