fix: Remove agreed_price from price API response
The agreed_price depends on trade direction (buy/sell) and must be calculated on the frontend. Returning a buy-side-only agreed_price from the API was misleading and unused. Frontend already calculates the direction-aware price correctly.
This commit is contained in:
parent
1008eea2d9
commit
bf57fc6b77
7 changed files with 640 additions and 270 deletions
|
|
@ -26,14 +26,56 @@ function formatEur(cents: number): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Format satoshi amount with thousand separators
|
||||
* Format satoshi amount with styled components
|
||||
* Leading zeros are subtle, main digits are prominent
|
||||
*/
|
||||
function formatSats(sats: number): string {
|
||||
function SatsDisplay({ sats }: { sats: number }) {
|
||||
const btc = sats / 100_000_000;
|
||||
const btcStr = btc.toFixed(8);
|
||||
const [whole, decimal] = btcStr.split(".");
|
||||
const grouped = decimal.replace(/(.{2})(.{3})(.{3})/, "$1 $2 $3");
|
||||
return `${whole}.${grouped}`;
|
||||
|
||||
const part1 = decimal.slice(0, 2);
|
||||
const part2 = decimal.slice(2, 5);
|
||||
const part3 = decimal.slice(5, 8);
|
||||
|
||||
const fullDecimal = part1 + part2 + part3;
|
||||
let firstNonZero = fullDecimal.length;
|
||||
for (let i = 0; i < fullDecimal.length; i++) {
|
||||
if (fullDecimal[i] !== "0") {
|
||||
firstNonZero = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const subtleStyle: React.CSSProperties = {
|
||||
opacity: 0.45,
|
||||
fontWeight: 400,
|
||||
};
|
||||
|
||||
const renderPart = (part: string, startIdx: number) => {
|
||||
return part.split("").map((char, i) => {
|
||||
const globalIdx = startIdx + i;
|
||||
const isSubtle = globalIdx < firstNonZero;
|
||||
return (
|
||||
<span key={globalIdx} style={isSubtle ? subtleStyle : undefined}>
|
||||
{char}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<span style={{ fontFamily: "'DM Mono', monospace" }}>
|
||||
<span style={subtleStyle}>₿</span>
|
||||
<span style={subtleStyle}> {whole}.</span>
|
||||
{renderPart(part1, 0)}
|
||||
<span> </span>
|
||||
{renderPart(part2, 2)}
|
||||
<span> </span>
|
||||
{renderPart(part3, 5)}
|
||||
<span> sats</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -289,7 +331,9 @@ export default function AdminTradesPage() {
|
|||
</span>
|
||||
<span style={styles.amount}>{formatEur(trade.eur_amount)}</span>
|
||||
<span style={styles.arrow}>↔</span>
|
||||
<span style={styles.satsAmount}>{formatSats(trade.sats_amount)} sats</span>
|
||||
<span style={styles.satsAmount}>
|
||||
<SatsDisplay sats={trade.sats_amount} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.rateRow}>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React from "react";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Permission } from "../auth-context";
|
||||
import { api } from "../api";
|
||||
import { Header } from "../components/Header";
|
||||
|
|
@ -20,6 +21,7 @@ const MIN_ADVANCE_DAYS = 1;
|
|||
const MAX_ADVANCE_DAYS = 30;
|
||||
|
||||
type Direction = "buy" | "sell";
|
||||
type WizardStep = "details" | "booking";
|
||||
|
||||
/**
|
||||
* Format EUR amount from cents to display string
|
||||
|
|
@ -29,17 +31,62 @@ function formatEur(cents: number): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Format satoshi amount with thousand separators
|
||||
* e.g., 476190 -> "0.00 476 190 sats"
|
||||
* Format satoshi amount with styled components
|
||||
* Leading zeros are subtle, main digits are prominent
|
||||
* e.g., 1876088 -> "₿ 0.01" (subtle) + "876 088 sats" (prominent)
|
||||
*/
|
||||
function formatSats(sats: number): string {
|
||||
function SatsDisplay({ sats }: { sats: number }) {
|
||||
const btc = sats / 100_000_000;
|
||||
const btcStr = btc.toFixed(8);
|
||||
const [whole, decimal] = btcStr.split(".");
|
||||
|
||||
// Group decimal into chunks of 3 for readability
|
||||
const grouped = decimal.replace(/(.{2})(.{3})(.{3})/, "$1 $2 $3");
|
||||
return `${whole}.${grouped} sats`;
|
||||
// Group decimal into chunks: first 2, then two groups of 3
|
||||
const part1 = decimal.slice(0, 2);
|
||||
const part2 = decimal.slice(2, 5);
|
||||
const part3 = decimal.slice(5, 8);
|
||||
|
||||
// Find where meaningful digits start (first non-zero after decimal)
|
||||
const fullDecimal = part1 + part2 + part3;
|
||||
let firstNonZero = fullDecimal.length;
|
||||
for (let i = 0; i < fullDecimal.length; i++) {
|
||||
if (fullDecimal[i] !== "0") {
|
||||
firstNonZero = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the display with subtle leading zeros and prominent digits
|
||||
const subtleStyle: React.CSSProperties = {
|
||||
opacity: 0.45,
|
||||
fontWeight: 400,
|
||||
};
|
||||
|
||||
// Determine which parts are subtle vs prominent
|
||||
const renderPart = (part: string, startIdx: number) => {
|
||||
const chars = part.split("").map((char, i) => {
|
||||
const globalIdx = startIdx + i;
|
||||
const isSubtle = globalIdx < firstNonZero;
|
||||
return (
|
||||
<span key={globalIdx} style={isSubtle ? subtleStyle : undefined}>
|
||||
{char}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
return chars;
|
||||
};
|
||||
|
||||
return (
|
||||
<span style={{ fontFamily: "'DM Mono', monospace" }}>
|
||||
<span style={subtleStyle}>₿</span>
|
||||
<span style={subtleStyle}> {whole}.</span>
|
||||
{renderPart(part1, 0)}
|
||||
<span> </span>
|
||||
{renderPart(part2, 2)}
|
||||
<span> </span>
|
||||
{renderPart(part3, 5)}
|
||||
<span> sats</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,11 +97,15 @@ function formatPrice(price: number): string {
|
|||
}
|
||||
|
||||
export default function ExchangePage() {
|
||||
const router = useRouter();
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.CREATE_EXCHANGE,
|
||||
fallbackRedirect: "/",
|
||||
});
|
||||
|
||||
// Wizard state
|
||||
const [wizardStep, setWizardStep] = useState<WizardStep>("details");
|
||||
|
||||
// Price and config state
|
||||
const [priceData, setPriceData] = useState<ExchangePriceResponse | null>(null);
|
||||
const [isPriceLoading, setIsPriceLoading] = useState(true);
|
||||
|
|
@ -75,7 +126,6 @@ export default function ExchangePage() {
|
|||
|
||||
// UI state
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isBooking, setIsBooking] = useState(false);
|
||||
|
||||
// Compute dates
|
||||
|
|
@ -162,9 +212,9 @@ export default function ExchangePage() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Fetch availability for all dates on mount
|
||||
// Fetch availability for all dates when entering booking step
|
||||
useEffect(() => {
|
||||
if (!user || !isAuthorized) return;
|
||||
if (!user || !isAuthorized || wizardStep !== "booking") return;
|
||||
|
||||
const fetchAllAvailability = async () => {
|
||||
setIsLoadingAvailability(true);
|
||||
|
|
@ -188,7 +238,7 @@ export default function ExchangePage() {
|
|||
};
|
||||
|
||||
fetchAllAvailability();
|
||||
}, [user, isAuthorized, dates]);
|
||||
}, [user, isAuthorized, dates, wizardStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && user && isAuthorized) {
|
||||
|
|
@ -200,7 +250,6 @@ export default function ExchangePage() {
|
|||
const dateStr = formatDate(date);
|
||||
if (datesWithAvailability.has(dateStr)) {
|
||||
setSelectedDate(date);
|
||||
setSuccessMessage(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -209,6 +258,43 @@ export default function ExchangePage() {
|
|||
setError(null);
|
||||
};
|
||||
|
||||
const handleContinueToBooking = () => {
|
||||
setWizardStep("booking");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleBackToDetails = () => {
|
||||
setWizardStep("details");
|
||||
setSelectedDate(null);
|
||||
setSelectedSlot(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleAmountChange = (value: number) => {
|
||||
// Clamp to valid range and snap to increment
|
||||
const minCents = eurMin * 100;
|
||||
const maxCents = eurMax * 100;
|
||||
const incrementCents = eurIncrement * 100;
|
||||
|
||||
// Clamp value
|
||||
let clamped = Math.max(minCents, Math.min(maxCents, value));
|
||||
|
||||
// Snap to nearest increment
|
||||
clamped = Math.round(clamped / incrementCents) * incrementCents;
|
||||
|
||||
setEurAmount(clamped);
|
||||
};
|
||||
|
||||
const handleAmountInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value.replace(/[^0-9]/g, "");
|
||||
if (inputValue === "") {
|
||||
setEurAmount(eurMin * 100);
|
||||
return;
|
||||
}
|
||||
const eurValue = parseInt(inputValue, 10);
|
||||
handleAmountChange(eurValue * 100);
|
||||
};
|
||||
|
||||
const handleBook = async () => {
|
||||
if (!selectedSlot) return;
|
||||
|
||||
|
|
@ -216,26 +302,16 @@ export default function ExchangePage() {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const exchange = await api.post<ExchangeResponse>("/api/exchange", {
|
||||
await api.post<ExchangeResponse>("/api/exchange", {
|
||||
slot_start: selectedSlot.start_time,
|
||||
direction,
|
||||
eur_amount: eurAmount,
|
||||
});
|
||||
|
||||
const dirLabel = direction === "buy" ? "Buy" : "Sell";
|
||||
setSuccessMessage(
|
||||
`${dirLabel} trade booked for ${formatTime(exchange.slot_start)}! ` +
|
||||
`${formatEur(exchange.eur_amount)} ↔ ${formatSats(exchange.sats_amount)}`
|
||||
);
|
||||
setSelectedSlot(null);
|
||||
|
||||
// Refresh slots
|
||||
if (selectedDate) {
|
||||
await fetchSlots(selectedDate);
|
||||
}
|
||||
// Redirect to trades page after successful booking
|
||||
router.push("/trades");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to book trade");
|
||||
} finally {
|
||||
setIsBooking(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -266,7 +342,6 @@ export default function ExchangePage() {
|
|||
<h1 style={typographyStyles.pageTitle}>Exchange Bitcoin</h1>
|
||||
<p style={typographyStyles.pageSubtitle}>Buy or sell Bitcoin with a 5% premium</p>
|
||||
|
||||
{successMessage && <div style={bannerStyles.successBanner}>{successMessage}</div>}
|
||||
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
|
||||
|
||||
{/* Price Display */}
|
||||
|
|
@ -282,22 +357,8 @@ export default function ExchangePage() {
|
|||
<span style={styles.priceValue}>{formatPrice(marketPrice)}</span>
|
||||
<span style={styles.priceDivider}>•</span>
|
||||
<span style={styles.priceLabel}>Our price:</span>
|
||||
<span
|
||||
style={{
|
||||
...styles.priceValue,
|
||||
color: direction === "buy" ? "#f87171" : "#4ade80",
|
||||
}}
|
||||
>
|
||||
{formatPrice(agreedPrice)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
...styles.premiumBadge,
|
||||
background:
|
||||
direction === "buy" ? "rgba(248, 113, 113, 0.2)" : "rgba(74, 222, 128, 0.2)",
|
||||
color: direction === "buy" ? "#f87171" : "#4ade80",
|
||||
}}
|
||||
>
|
||||
<span style={styles.priceValue}>{formatPrice(agreedPrice)}</span>
|
||||
<span style={styles.premiumBadge}>
|
||||
{direction === "buy" ? "+" : "-"}
|
||||
{premiumPercent}%
|
||||
</span>
|
||||
|
|
@ -312,207 +373,288 @@ export default function ExchangePage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Trade Form */}
|
||||
<div style={styles.tradeCard}>
|
||||
{/* Direction Selector */}
|
||||
<div style={styles.directionRow}>
|
||||
<button
|
||||
onClick={() => setDirection("buy")}
|
||||
style={{
|
||||
...styles.directionBtn,
|
||||
...(direction === "buy" ? styles.directionBtnBuyActive : {}),
|
||||
}}
|
||||
>
|
||||
Buy BTC
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDirection("sell")}
|
||||
style={{
|
||||
...styles.directionBtn,
|
||||
...(direction === "sell" ? styles.directionBtnSellActive : {}),
|
||||
}}
|
||||
>
|
||||
Sell BTC
|
||||
</button>
|
||||
{/* Step Indicator */}
|
||||
<div style={styles.stepIndicator}>
|
||||
<div
|
||||
style={{
|
||||
...styles.step,
|
||||
...(wizardStep === "details" ? styles.stepActive : styles.stepCompleted),
|
||||
}}
|
||||
>
|
||||
<span style={styles.stepNumber}>1</span>
|
||||
<span style={styles.stepLabel}>Exchange Details</span>
|
||||
</div>
|
||||
|
||||
{/* Amount Slider */}
|
||||
<div style={styles.amountSection}>
|
||||
<div style={styles.amountHeader}>
|
||||
<span style={styles.amountLabel}>Amount</span>
|
||||
<span style={styles.amountValue}>{formatEur(eurAmount)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={eurMin * 100}
|
||||
max={eurMax * 100}
|
||||
step={eurIncrement * 100}
|
||||
value={eurAmount}
|
||||
onChange={(e) => setEurAmount(Number(e.target.value))}
|
||||
style={styles.slider}
|
||||
/>
|
||||
<div style={styles.amountRange}>
|
||||
<span>{formatEur(eurMin * 100)}</span>
|
||||
<span>{formatEur(eurMax * 100)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade Summary */}
|
||||
<div style={styles.tradeSummary}>
|
||||
{direction === "buy" ? (
|
||||
<p style={styles.summaryText}>
|
||||
You pay <strong>{formatEur(eurAmount)}</strong>, you receive{" "}
|
||||
<strong style={styles.satsValue}>{formatSats(satsAmount)}</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p style={styles.summaryText}>
|
||||
You send <strong style={styles.satsValue}>{formatSats(satsAmount)}</strong>, you
|
||||
receive <strong>{formatEur(eurAmount)}</strong>
|
||||
</p>
|
||||
)}
|
||||
<div style={styles.stepDivider} />
|
||||
<div
|
||||
style={{
|
||||
...styles.step,
|
||||
...(wizardStep === "booking" ? styles.stepActive : {}),
|
||||
}}
|
||||
>
|
||||
<span style={styles.stepNumber}>2</span>
|
||||
<span style={styles.stepLabel}>Book Appointment</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Selection */}
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>Select a Date</h2>
|
||||
<div style={styles.dateGrid}>
|
||||
{dates.map((date) => {
|
||||
const dateStr = formatDate(date);
|
||||
const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
|
||||
const hasAvailability = datesWithAvailability.has(dateStr);
|
||||
const isDisabled = !hasAvailability || isLoadingAvailability;
|
||||
{/* Step 1: Exchange Details */}
|
||||
{wizardStep === "details" && (
|
||||
<div style={styles.tradeCard}>
|
||||
{/* Direction Selector */}
|
||||
<div style={styles.directionRow}>
|
||||
<button
|
||||
onClick={() => setDirection("buy")}
|
||||
style={{
|
||||
...styles.directionBtn,
|
||||
...(direction === "buy" ? styles.directionBtnBuyActive : {}),
|
||||
}}
|
||||
>
|
||||
Buy BTC
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDirection("sell")}
|
||||
style={{
|
||||
...styles.directionBtn,
|
||||
...(direction === "sell" ? styles.directionBtnSellActive : {}),
|
||||
}}
|
||||
>
|
||||
Sell BTC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
onClick={() => handleDateSelect(date)}
|
||||
disabled={isDisabled}
|
||||
{/* Amount Section */}
|
||||
<div style={styles.amountSection}>
|
||||
<div style={styles.amountHeader}>
|
||||
<span style={styles.amountLabel}>Amount (EUR)</span>
|
||||
<div style={styles.amountInputWrapper}>
|
||||
<span style={styles.amountCurrency}>€</span>
|
||||
<input
|
||||
type="text"
|
||||
value={Math.round(eurAmount / 100)}
|
||||
onChange={handleAmountInputChange}
|
||||
style={styles.amountInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={eurMin * 100}
|
||||
max={eurMax * 100}
|
||||
step={eurIncrement * 100}
|
||||
value={eurAmount}
|
||||
onChange={(e) => setEurAmount(Number(e.target.value))}
|
||||
style={styles.slider}
|
||||
/>
|
||||
<div style={styles.amountRange}>
|
||||
<span>{formatEur(eurMin * 100)}</span>
|
||||
<span>{formatEur(eurMax * 100)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade Summary */}
|
||||
<div style={styles.tradeSummary}>
|
||||
{direction === "buy" ? (
|
||||
<p style={styles.summaryText}>
|
||||
You buy{" "}
|
||||
<strong style={styles.satsValue}>
|
||||
<SatsDisplay sats={satsAmount} />
|
||||
</strong>
|
||||
, you sell <strong>{formatEur(eurAmount)}</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p style={styles.summaryText}>
|
||||
You buy <strong>{formatEur(eurAmount)}</strong>, you sell{" "}
|
||||
<strong style={styles.satsValue}>
|
||||
<SatsDisplay sats={satsAmount} />
|
||||
</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Continue Button */}
|
||||
<button
|
||||
onClick={handleContinueToBooking}
|
||||
disabled={isPriceStale || !priceData?.price}
|
||||
style={{
|
||||
...styles.continueButton,
|
||||
...(isPriceStale || !priceData?.price ? buttonStyles.buttonDisabled : {}),
|
||||
}}
|
||||
>
|
||||
Continue to Booking
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Booking */}
|
||||
{wizardStep === "booking" && (
|
||||
<>
|
||||
{/* Trade Summary Card */}
|
||||
<div style={styles.summaryCard}>
|
||||
<div style={styles.summaryHeader}>
|
||||
<span style={styles.summaryTitle}>Your Exchange</span>
|
||||
<button onClick={handleBackToDetails} style={styles.editButton}>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<div style={styles.summaryDetails}>
|
||||
<span
|
||||
style={{
|
||||
...styles.dateButton,
|
||||
...(isSelected ? styles.dateButtonSelected : {}),
|
||||
...(isDisabled ? styles.dateButtonDisabled : {}),
|
||||
...styles.summaryDirection,
|
||||
color: direction === "buy" ? "#4ade80" : "#f87171",
|
||||
}}
|
||||
>
|
||||
<div style={styles.dateWeekday}>
|
||||
{date.toLocaleDateString("en-US", { weekday: "short" })}
|
||||
</div>
|
||||
<div style={styles.dateDay}>
|
||||
{date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{direction === "buy" ? "Buy" : "Sell"} BTC
|
||||
</span>
|
||||
<span style={styles.summaryDivider}>•</span>
|
||||
<span>{formatEur(eurAmount)}</span>
|
||||
<span style={styles.summaryDivider}>↔</span>
|
||||
<span style={styles.satsValue}>
|
||||
<SatsDisplay sats={satsAmount} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Slots */}
|
||||
{selectedDate && (
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>
|
||||
Available Slots for{" "}
|
||||
{selectedDate.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</h2>
|
||||
{/* Date Selection */}
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>Select a Date</h2>
|
||||
<div style={styles.dateGrid}>
|
||||
{dates.map((date) => {
|
||||
const dateStr = formatDate(date);
|
||||
const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
|
||||
const hasAvailability = datesWithAvailability.has(dateStr);
|
||||
const isDisabled = !hasAvailability || isLoadingAvailability;
|
||||
|
||||
{isLoadingSlots ? (
|
||||
<div style={styles.emptyState}>Loading slots...</div>
|
||||
) : availableSlots.length === 0 ? (
|
||||
<div style={styles.emptyState}>No available slots for this date</div>
|
||||
) : (
|
||||
<div style={styles.slotGrid}>
|
||||
{availableSlots.map((slot) => {
|
||||
const isSelected = selectedSlot?.start_time === slot.start_time;
|
||||
return (
|
||||
<button
|
||||
key={slot.start_time}
|
||||
onClick={() => handleSlotSelect(slot)}
|
||||
key={dateStr}
|
||||
onClick={() => handleDateSelect(date)}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
...styles.slotButton,
|
||||
...(isSelected ? styles.slotButtonSelected : {}),
|
||||
...styles.dateButton,
|
||||
...(isSelected ? styles.dateButtonSelected : {}),
|
||||
...(isDisabled ? styles.dateButtonDisabled : {}),
|
||||
}}
|
||||
>
|
||||
{formatTime(slot.start_time)}
|
||||
<div style={styles.dateWeekday}>
|
||||
{date.toLocaleDateString("en-US", { weekday: "short" })}
|
||||
</div>
|
||||
<div style={styles.dateDay}>
|
||||
{date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Slots */}
|
||||
{selectedDate && (
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>
|
||||
Available Slots for{" "}
|
||||
{selectedDate.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</h2>
|
||||
|
||||
{isLoadingSlots ? (
|
||||
<div style={styles.emptyState}>Loading slots...</div>
|
||||
) : availableSlots.length === 0 ? (
|
||||
<div style={styles.emptyState}>No available slots for this date</div>
|
||||
) : (
|
||||
<div style={styles.slotGrid}>
|
||||
{availableSlots.map((slot) => {
|
||||
const isSelected = selectedSlot?.start_time === slot.start_time;
|
||||
return (
|
||||
<button
|
||||
key={slot.start_time}
|
||||
onClick={() => handleSlotSelect(slot)}
|
||||
style={{
|
||||
...styles.slotButton,
|
||||
...(isSelected ? styles.slotButtonSelected : {}),
|
||||
}}
|
||||
>
|
||||
{formatTime(slot.start_time)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm Booking */}
|
||||
{selectedSlot && (
|
||||
<div style={styles.confirmCard}>
|
||||
<h3 style={styles.confirmTitle}>Confirm Trade</h3>
|
||||
<div style={styles.confirmDetails}>
|
||||
<div style={styles.confirmRow}>
|
||||
<span style={styles.confirmLabel}>Time:</span>
|
||||
<span style={styles.confirmValue}>
|
||||
{formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.confirmRow}>
|
||||
<span style={styles.confirmLabel}>Direction:</span>
|
||||
<span
|
||||
style={{
|
||||
...styles.confirmValue,
|
||||
color: direction === "buy" ? "#4ade80" : "#f87171",
|
||||
}}
|
||||
>
|
||||
{direction === "buy" ? "Buy BTC" : "Sell BTC"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.confirmRow}>
|
||||
<span style={styles.confirmLabel}>EUR:</span>
|
||||
<span style={styles.confirmValue}>{formatEur(eurAmount)}</span>
|
||||
</div>
|
||||
<div style={styles.confirmRow}>
|
||||
<span style={styles.confirmLabel}>BTC:</span>
|
||||
<span style={{ ...styles.confirmValue, ...styles.satsValue }}>
|
||||
{formatSats(satsAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.confirmRow}>
|
||||
<span style={styles.confirmLabel}>Rate:</span>
|
||||
<span style={styles.confirmValue}>{formatPrice(agreedPrice)}/BTC</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Confirm Booking */}
|
||||
{selectedSlot && (
|
||||
<div style={styles.confirmCard}>
|
||||
<h3 style={styles.confirmTitle}>Confirm Trade</h3>
|
||||
<div style={styles.confirmDetails}>
|
||||
<div style={styles.confirmRow}>
|
||||
<span style={styles.confirmLabel}>Time:</span>
|
||||
<span style={styles.confirmValue}>
|
||||
{formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.confirmRow}>
|
||||
<span style={styles.confirmLabel}>Direction:</span>
|
||||
<span
|
||||
style={{
|
||||
...styles.confirmValue,
|
||||
color: direction === "buy" ? "#4ade80" : "#f87171",
|
||||
}}
|
||||
>
|
||||
{direction === "buy" ? "Buy BTC" : "Sell BTC"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.confirmRow}>
|
||||
<span style={styles.confirmLabel}>EUR:</span>
|
||||
<span style={styles.confirmValue}>{formatEur(eurAmount)}</span>
|
||||
</div>
|
||||
<div style={styles.confirmRow}>
|
||||
<span style={styles.confirmLabel}>BTC:</span>
|
||||
<span style={{ ...styles.confirmValue, ...styles.satsValue }}>
|
||||
<SatsDisplay sats={satsAmount} />
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.confirmRow}>
|
||||
<span style={styles.confirmLabel}>Rate:</span>
|
||||
<span style={styles.confirmValue}>{formatPrice(agreedPrice)}/BTC</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.buttonRow}>
|
||||
<button
|
||||
onClick={handleBook}
|
||||
disabled={isBooking || isPriceStale}
|
||||
style={{
|
||||
...styles.bookButton,
|
||||
background:
|
||||
direction === "buy"
|
||||
? "linear-gradient(135deg, #4ade80 0%, #22c55e 100%)"
|
||||
: "linear-gradient(135deg, #f87171 0%, #ef4444 100%)",
|
||||
...(isBooking || isPriceStale ? buttonStyles.buttonDisabled : {}),
|
||||
}}
|
||||
>
|
||||
{isBooking
|
||||
? "Booking..."
|
||||
: isPriceStale
|
||||
? "Price Stale"
|
||||
: `Confirm ${direction === "buy" ? "Buy" : "Sell"}`}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelSlotSelection}
|
||||
disabled={isBooking}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.buttonRow}>
|
||||
<button
|
||||
onClick={handleBook}
|
||||
disabled={isBooking || isPriceStale}
|
||||
style={{
|
||||
...styles.bookButton,
|
||||
background:
|
||||
direction === "buy"
|
||||
? "linear-gradient(135deg, #4ade80 0%, #22c55e 100%)"
|
||||
: "linear-gradient(135deg, #f87171 0%, #ef4444 100%)",
|
||||
...(isBooking || isPriceStale ? buttonStyles.buttonDisabled : {}),
|
||||
}}
|
||||
>
|
||||
{isBooking
|
||||
? "Booking..."
|
||||
: isPriceStale
|
||||
? "Price Stale"
|
||||
: `Confirm ${direction === "buy" ? "Buy" : "Sell"}`}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelSlotSelection}
|
||||
disabled={isBooking}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
|
@ -563,6 +705,8 @@ const styles: Record<string, React.CSSProperties> = {
|
|||
padding: "0.2rem 0.5rem",
|
||||
borderRadius: "4px",
|
||||
marginLeft: "0.25rem",
|
||||
background: "rgba(255, 255, 255, 0.1)",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
},
|
||||
priceTimestamp: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
|
|
@ -584,6 +728,48 @@ const styles: Record<string, React.CSSProperties> = {
|
|||
color: "#f87171",
|
||||
textAlign: "center" as const,
|
||||
},
|
||||
stepIndicator: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "1rem",
|
||||
marginBottom: "2rem",
|
||||
},
|
||||
step: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
opacity: 0.4,
|
||||
},
|
||||
stepActive: {
|
||||
opacity: 1,
|
||||
},
|
||||
stepCompleted: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
stepNumber: {
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255, 255, 255, 0.1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
},
|
||||
stepLabel: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.875rem",
|
||||
color: "#fff",
|
||||
},
|
||||
stepDivider: {
|
||||
width: "40px",
|
||||
height: "1px",
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
},
|
||||
tradeCard: {
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
|
|
@ -633,11 +819,30 @@ const styles: Record<string, React.CSSProperties> = {
|
|||
color: "rgba(255, 255, 255, 0.7)",
|
||||
fontSize: "0.9rem",
|
||||
},
|
||||
amountValue: {
|
||||
amountInputWrapper: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
background: "rgba(255, 255, 255, 0.05)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: "8px",
|
||||
padding: "0.5rem 0.75rem",
|
||||
},
|
||||
amountCurrency: {
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
fontSize: "1rem",
|
||||
marginRight: "0.25rem",
|
||||
},
|
||||
amountInput: {
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
color: "#fff",
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
width: "80px",
|
||||
textAlign: "right" as const,
|
||||
},
|
||||
slider: {
|
||||
width: "100%",
|
||||
|
|
@ -661,6 +866,7 @@ const styles: Record<string, React.CSSProperties> = {
|
|||
borderRadius: "8px",
|
||||
padding: "1rem",
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "1.5rem",
|
||||
},
|
||||
summaryText: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
|
|
@ -672,6 +878,61 @@ const styles: Record<string, React.CSSProperties> = {
|
|||
fontFamily: "'DM Mono', monospace",
|
||||
color: "#f7931a", // Bitcoin orange
|
||||
},
|
||||
continueButton: {
|
||||
width: "100%",
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 600,
|
||||
padding: "0.875rem",
|
||||
background: "linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%)",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
},
|
||||
summaryCard: {
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: "12px",
|
||||
padding: "1rem 1.5rem",
|
||||
marginBottom: "1.5rem",
|
||||
},
|
||||
summaryHeader: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "0.5rem",
|
||||
},
|
||||
summaryTitle: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.875rem",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
editButton: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.75rem",
|
||||
color: "#a78bfa",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
},
|
||||
summaryDetails: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
flexWrap: "wrap",
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1rem",
|
||||
color: "#fff",
|
||||
},
|
||||
summaryDirection: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
summaryDivider: {
|
||||
color: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
section: {
|
||||
marginBottom: "2rem",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,14 +26,56 @@ function formatEur(cents: number): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Format satoshi amount with thousand separators
|
||||
* Format satoshi amount with styled components
|
||||
* Leading zeros are subtle, main digits are prominent
|
||||
*/
|
||||
function formatSats(sats: number): string {
|
||||
function SatsDisplay({ sats }: { sats: number }) {
|
||||
const btc = sats / 100_000_000;
|
||||
const btcStr = btc.toFixed(8);
|
||||
const [whole, decimal] = btcStr.split(".");
|
||||
const grouped = decimal.replace(/(.{2})(.{3})(.{3})/, "$1 $2 $3");
|
||||
return `${whole}.${grouped}`;
|
||||
|
||||
const part1 = decimal.slice(0, 2);
|
||||
const part2 = decimal.slice(2, 5);
|
||||
const part3 = decimal.slice(5, 8);
|
||||
|
||||
const fullDecimal = part1 + part2 + part3;
|
||||
let firstNonZero = fullDecimal.length;
|
||||
for (let i = 0; i < fullDecimal.length; i++) {
|
||||
if (fullDecimal[i] !== "0") {
|
||||
firstNonZero = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const subtleStyle: React.CSSProperties = {
|
||||
opacity: 0.45,
|
||||
fontWeight: 400,
|
||||
};
|
||||
|
||||
const renderPart = (part: string, startIdx: number) => {
|
||||
return part.split("").map((char, i) => {
|
||||
const globalIdx = startIdx + i;
|
||||
const isSubtle = globalIdx < firstNonZero;
|
||||
return (
|
||||
<span key={globalIdx} style={isSubtle ? subtleStyle : undefined}>
|
||||
{char}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<span style={{ fontFamily: "'DM Mono', monospace" }}>
|
||||
<span style={subtleStyle}>₿</span>
|
||||
<span style={subtleStyle}> {whole}.</span>
|
||||
{renderPart(part1, 0)}
|
||||
<span> </span>
|
||||
{renderPart(part2, 2)}
|
||||
<span> </span>
|
||||
{renderPart(part3, 5)}
|
||||
<span> sats</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -196,7 +238,7 @@ export default function TradesPage() {
|
|||
<span style={styles.amount}>{formatEur(trade.eur_amount)}</span>
|
||||
<span style={styles.arrow}>↔</span>
|
||||
<span style={styles.satsAmount}>
|
||||
{formatSats(trade.sats_amount)} sats
|
||||
<SatsDisplay sats={trade.sats_amount} />
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.rateRow}>
|
||||
|
|
@ -208,18 +250,6 @@ export default function TradesPage() {
|
|||
})}
|
||||
/BTC
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
...styles.premiumBadge,
|
||||
background: isBuy
|
||||
? "rgba(248, 113, 113, 0.15)"
|
||||
: "rgba(74, 222, 128, 0.15)",
|
||||
color: isBuy ? "#f87171" : "#4ade80",
|
||||
}}
|
||||
>
|
||||
{isBuy ? "+" : "-"}
|
||||
{trade.premium_percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
|
|
@ -297,7 +327,7 @@ export default function TradesPage() {
|
|||
<span style={styles.amount}>{formatEur(trade.eur_amount)}</span>
|
||||
<span style={styles.arrow}>↔</span>
|
||||
<span style={styles.satsAmount}>
|
||||
{formatSats(trade.sats_amount)} sats
|
||||
<SatsDisplay sats={trade.sats_amount} />
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
|
|
@ -421,13 +451,6 @@ const styles: Record<string, React.CSSProperties> = {
|
|||
fontSize: "0.8rem",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
},
|
||||
premiumBadge: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 600,
|
||||
padding: "0.15rem 0.4rem",
|
||||
borderRadius: "3px",
|
||||
},
|
||||
buttonGroup: {
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ export default defineConfig({
|
|||
workers: 1,
|
||||
// Ensure tests within a file run in order
|
||||
fullyParallel: false,
|
||||
// Test timeout (per test)
|
||||
timeout: 10000,
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
url: "http://localhost:3000",
|
||||
|
|
@ -13,5 +15,7 @@ export default defineConfig({
|
|||
},
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
// Action timeout (clicks, fills, etc.)
|
||||
actionTimeout: 5000,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue