lots of stuff

This commit is contained in:
counterweight 2025-12-23 17:03:51 +01:00
parent f946fbf7b8
commit 4be45f8f7c
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
9 changed files with 513 additions and 236 deletions

View file

@ -27,7 +27,7 @@ const {
type Direction = "buy" | "sell";
type BitcoinTransferMethod = "onchain" | "lightning";
type WizardStep = "details" | "booking";
type WizardStep = "details" | "booking" | "confirmation";
/**
* Format price for display
@ -66,9 +66,14 @@ export default function ExchangePage() {
const [datesWithAvailability, setDatesWithAvailability] = useState<Set<string>>(new Set());
const [isLoadingAvailability, setIsLoadingAvailability] = useState(true);
// User trades state (for same-day booking check)
const [userTrades, setUserTrades] = useState<ExchangeResponse[]>([]);
const [existingTradeOnSelectedDate, setExistingTradeOnSelectedDate] =
useState<ExchangeResponse | null>(null);
// UI state
const [error, setError] = useState<string | null>(null);
const [existingTradeId, setExistingTradeId] = useState<number | null>(null);
const [existingTradeId, setExistingTradeId] = useState<string | null>(null);
const [isBooking, setIsBooking] = useState(false);
// Compute dates
@ -167,10 +172,28 @@ export default function ExchangePage() {
}
}, []);
// Fetch availability for all dates when entering booking step
// Fetch user trades when entering booking step
useEffect(() => {
if (!user || !isAuthorized || wizardStep !== "booking") return;
const fetchUserTrades = async () => {
try {
const data = await api.get<ExchangeResponse[]>("/api/trades");
setUserTrades(data);
} catch (err) {
console.error("Failed to fetch user trades:", err);
// Don't block the UI if this fails
}
};
fetchUserTrades();
}, [user, isAuthorized, wizardStep]);
// Fetch availability for all dates when entering booking or confirmation step
useEffect(() => {
if (!user || !isAuthorized || (wizardStep !== "booking" && wizardStep !== "confirmation"))
return;
const fetchAllAvailability = async () => {
setIsLoadingAvailability(true);
const availabilitySet = new Set<string>();
@ -201,9 +224,36 @@ export default function ExchangePage() {
}
}, [selectedDate, user, isAuthorized, fetchSlots]);
// Check if a date has an existing trade (only consider booked trades, not cancelled ones)
const getExistingTradeOnDate = useCallback(
(date: Date): ExchangeResponse | null => {
const dateStr = formatDate(date);
return (
userTrades.find((trade) => {
const tradeDate = formatDate(new Date(trade.slot_start));
return tradeDate === dateStr && trade.status === "booked";
}) || null
);
},
[userTrades]
);
const handleDateSelect = (date: Date) => {
const dateStr = formatDate(date);
if (datesWithAvailability.has(dateStr)) {
if (!datesWithAvailability.has(dateStr)) {
return;
}
// Check if user already has a trade on this date
const existingTrade = getExistingTradeOnDate(date);
if (existingTrade) {
setExistingTradeOnSelectedDate(existingTrade);
setSelectedDate(null);
setSelectedSlot(null);
setAvailableSlots([]);
setError(null);
} else {
setExistingTradeOnSelectedDate(null);
setSelectedDate(date);
}
};
@ -211,6 +261,7 @@ export default function ExchangePage() {
const handleSlotSelect = (slot: BookableSlot) => {
setSelectedSlot(slot);
setError(null);
setWizardStep("confirmation");
};
const handleContinueToBooking = () => {
@ -223,6 +274,12 @@ export default function ExchangePage() {
setSelectedDate(null);
setSelectedSlot(null);
setError(null);
setExistingTradeOnSelectedDate(null);
};
const handleBackToBooking = () => {
setWizardStep("booking");
setError(null);
};
const handleAmountChange = (value: number) => {
@ -282,10 +339,10 @@ export default function ExchangePage() {
}
setError(errorMessage);
// Check if it's a "same day" error and extract trade ID
const tradeIdMatch = errorMessage.match(/Trade ID: (\d+)/);
// Check if it's a "same day" error and extract trade public_id (UUID)
const tradeIdMatch = errorMessage.match(/Trade ID: ([a-f0-9-]{36})/i);
if (tradeIdMatch) {
setExistingTradeId(parseInt(tradeIdMatch[1], 10));
setExistingTradeId(tradeIdMatch[1]);
} else {
setExistingTradeId(null);
}
@ -294,11 +351,6 @@ export default function ExchangePage() {
}
};
const cancelSlotSelection = () => {
setSelectedSlot(null);
setError(null);
};
if (isLoading) {
return (
<main style={layoutStyles.main}>
@ -377,12 +429,26 @@ export default function ExchangePage() {
<div
style={{
...styles.step,
...(wizardStep === "booking" ? styles.stepActive : {}),
...(wizardStep === "booking"
? styles.stepActive
: wizardStep === "confirmation"
? styles.stepCompleted
: {}),
}}
>
<span style={styles.stepNumber}>2</span>
<span style={styles.stepLabel}>Book Appointment</span>
</div>
<div style={styles.stepDivider} />
<div
style={{
...styles.step,
...(wizardStep === "confirmation" ? styles.stepActive : {}),
}}
>
<span style={styles.stepNumber}>3</span>
<span style={styles.stepLabel}>Confirm</span>
</div>
</div>
{/* Step 1: Exchange Details */}
@ -553,6 +619,7 @@ export default function ExchangePage() {
const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
const hasAvailability = datesWithAvailability.has(dateStr);
const isDisabled = !hasAvailability || isLoadingAvailability;
const hasExistingTrade = getExistingTradeOnDate(date) !== null;
return (
<button
@ -564,6 +631,7 @@ export default function ExchangePage() {
...styles.dateButton,
...(isSelected ? styles.dateButtonSelected : {}),
...(isDisabled ? styles.dateButtonDisabled : {}),
...(hasExistingTrade && !isDisabled ? styles.dateButtonHasTrade : {}),
}}
>
<div style={styles.dateWeekday}>
@ -575,14 +643,32 @@ export default function ExchangePage() {
day: "numeric",
})}
</div>
{hasExistingTrade && !isDisabled && <div style={styles.dateWarning}></div>}
</button>
);
})}
</div>
</div>
{/* Warning for existing trade on selected date */}
{existingTradeOnSelectedDate && (
<div style={bannerStyles.errorBanner}>
<div>
You already have a trade booked on this day. You can only book one trade per day.
</div>
<div style={styles.errorLink}>
<a
href={`/trades/${existingTradeOnSelectedDate.public_id}`}
style={styles.errorLinkAnchor}
>
View your existing trade
</a>
</div>
</div>
)}
{/* Available Slots */}
{selectedDate && (
{selectedDate && !existingTradeOnSelectedDate && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Available Slots for{" "}
@ -618,83 +704,109 @@ export default function ExchangePage() {
)}
</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 style={styles.confirmRow}>
<span style={styles.confirmLabel}>Payment:</span>
<span style={styles.confirmValue}>
{direction === "buy" ? "Receive via " : "Send via "}
{bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"}
</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>
)}
</>
)}
{/* Step 2: Booking (Compressed when step 3 is active) */}
{wizardStep === "confirmation" && (
<div style={styles.compressedBookingCard}>
<div style={styles.compressedBookingHeader}>
<span style={styles.compressedBookingTitle}>Appointment</span>
<button onClick={handleBackToBooking} style={styles.editButton}>
Edit
</button>
</div>
<div style={styles.compressedBookingDetails}>
<span>
{selectedDate?.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})}
</span>
<span style={styles.summaryDivider}></span>
<span>
{selectedSlot && formatTime(selectedSlot.start_time)} -{" "}
{selectedSlot && formatTime(selectedSlot.end_time)}
</span>
</div>
</div>
)}
{/* Step 3: Confirmation */}
{wizardStep === "confirmation" && 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 style={styles.confirmRow}>
<span style={styles.confirmLabel}>Payment:</span>
<span style={styles.confirmValue}>
{direction === "buy" ? "Receive via " : "Send via "}
{bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"}
</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={handleBackToBooking}
disabled={isBooking}
style={styles.cancelButton}
>
Back
</button>
</div>
</div>
)}
</div>
</main>
);
@ -937,6 +1049,33 @@ const styles: Record<string, CSSProperties> = {
padding: "1rem 1.5rem",
marginBottom: "1.5rem",
},
compressedBookingCard: {
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",
},
compressedBookingHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.5rem",
},
compressedBookingTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.5)",
},
compressedBookingDetails: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
flexWrap: "wrap",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
color: "#fff",
},
summaryHeader: {
display: "flex",
justifyContent: "space-between",
@ -1080,6 +1219,10 @@ const styles: Record<string, CSSProperties> = {
background: "rgba(255, 255, 255, 0.01)",
border: "1px solid rgba(255, 255, 255, 0.04)",
},
dateButtonHasTrade: {
border: "1px solid rgba(251, 146, 60, 0.5)",
background: "rgba(251, 146, 60, 0.1)",
},
dateWeekday: {
color: "#fff",
fontWeight: 500,
@ -1090,6 +1233,11 @@ const styles: Record<string, CSSProperties> = {
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.8rem",
},
dateWarning: {
fontSize: "0.7rem",
marginTop: "0.25rem",
opacity: 0.8,
},
slotGrid: {
display: "flex",
flexWrap: "wrap",