arbret/frontend/app/exchange/components/ExchangeDetailsStep.tsx

403 lines
11 KiB
TypeScript
Raw Normal View History

"use client";
import { ChangeEvent, CSSProperties } from "react";
import { SatsDisplay } from "../../components/SatsDisplay";
import { formatEur } from "../../utils/exchange";
import { buttonStyles } from "../../styles/shared";
import constants from "../../../../shared/constants.json";
import { useTranslation } from "../../hooks/useTranslation";
const { lightningMaxEur: LIGHTNING_MAX_EUR } = constants.exchange;
type Direction = "buy" | "sell";
type BitcoinTransferMethod = "onchain" | "lightning";
interface ExchangeDetailsStepProps {
direction: Direction;
onDirectionChange: (direction: Direction) => void;
bitcoinTransferMethod: BitcoinTransferMethod;
onBitcoinTransferMethodChange: (method: BitcoinTransferMethod) => void;
eurAmount: number;
onEurAmountChange: (amount: number) => void;
satsAmount: number;
eurMin: number;
eurMax: number;
eurIncrement: number;
isPriceStale: boolean;
hasPrice: boolean;
onContinue: () => void;
}
const styles: Record<string, CSSProperties> = {
tradeCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "1.5rem",
marginBottom: "2rem",
},
directionRow: {
display: "flex",
gap: "0.5rem",
marginBottom: "1.5rem",
},
directionBtn: {
flex: 1,
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
fontWeight: 600,
padding: "0.875rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "rgba(255, 255, 255, 0.6)",
cursor: "pointer",
transition: "all 0.2s",
},
directionBtnBuyActive: {
background: "rgba(74, 222, 128, 0.15)",
border: "1px solid #4ade80",
color: "#4ade80",
},
directionBtnSellActive: {
background: "rgba(248, 113, 113, 0.15)",
border: "1px solid #f87171",
color: "#f87171",
},
paymentMethodSection: {
marginBottom: "1.5rem",
},
paymentMethodLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.7)",
fontSize: "0.9rem",
marginBottom: "0.75rem",
},
required: {
color: "#f87171",
},
paymentMethodRow: {
display: "flex",
gap: "0.5rem",
},
paymentMethodBtn: {
flex: 1,
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.95rem",
fontWeight: 600,
padding: "0.875rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "rgba(255, 255, 255, 0.6)",
cursor: "pointer",
transition: "all 0.2s",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
},
paymentMethodBtnActive: {
background: "rgba(167, 139, 250, 0.15)",
border: "1px solid #a78bfa",
color: "#a78bfa",
},
paymentMethodBtnDisabled: {
opacity: 0.4,
cursor: "not-allowed",
},
paymentMethodIcon: {
fontSize: "1.2rem",
},
thresholdMessage: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "rgba(251, 146, 60, 0.9)",
marginTop: "0.5rem",
padding: "0.5rem",
background: "rgba(251, 146, 60, 0.1)",
borderRadius: "6px",
border: "1px solid rgba(251, 146, 60, 0.2)",
},
amountSection: {
marginBottom: "1.5rem",
},
amountHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.75rem",
},
amountLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.7)",
fontSize: "0.9rem",
},
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",
fontSize: "1.25rem",
fontWeight: 600,
color: "#fff",
background: "transparent",
border: "none",
outline: "none",
width: "80px",
textAlign: "right" as const,
},
slider: {
width: "100%",
height: "8px",
appearance: "none" as const,
background: "rgba(255, 255, 255, 0.1)",
borderRadius: "4px",
outline: "none",
cursor: "pointer",
},
amountRange: {
display: "flex",
justifyContent: "space-between",
marginTop: "0.5rem",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
},
tradeSummary: {
background: "rgba(255, 255, 255, 0.02)",
borderRadius: "8px",
padding: "1rem",
textAlign: "center" as const,
marginBottom: "1.5rem",
},
summaryText: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.8)",
fontSize: "0.95rem",
margin: 0,
},
satsValue: {
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",
},
};
/**
* Step 1 of the exchange wizard: Exchange Details
* Allows user to select direction (buy/sell), payment method, and amount.
*/
export function ExchangeDetailsStep({
direction,
onDirectionChange,
bitcoinTransferMethod,
onBitcoinTransferMethodChange,
eurAmount,
onEurAmountChange,
satsAmount,
eurMin,
eurMax,
eurIncrement,
isPriceStale,
hasPrice,
onContinue,
}: ExchangeDetailsStepProps) {
const t = useTranslation("exchange");
const isLightningDisabled = eurAmount > LIGHTNING_MAX_EUR * 100;
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;
onEurAmountChange(clamped);
};
const handleAmountInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value.replace(/[^0-9]/g, "");
if (inputValue === "") {
onEurAmountChange(eurMin * 100);
return;
}
const eurValue = parseInt(inputValue, 10);
handleAmountChange(eurValue * 100);
};
return (
<div style={styles.tradeCard}>
{/* Direction Selector */}
<div style={styles.directionRow}>
<button
onClick={() => onDirectionChange("buy")}
style={{
...styles.directionBtn,
...(direction === "buy" ? styles.directionBtnBuyActive : {}),
}}
>
{t("direction.buyShort")}
</button>
<button
onClick={() => onDirectionChange("sell")}
style={{
...styles.directionBtn,
...(direction === "sell" ? styles.directionBtnSellActive : {}),
}}
>
{t("direction.sellShort")}
</button>
</div>
{/* Payment Method Selector */}
<div style={styles.paymentMethodSection}>
<div style={styles.paymentMethodLabel}>
{t("detailsStep.paymentMethod")}{" "}
<span style={styles.required}>{t("detailsStep.required")}</span>
</div>
<div style={styles.paymentMethodRow}>
<button
onClick={() => onBitcoinTransferMethodChange("onchain")}
style={{
...styles.paymentMethodBtn,
...(bitcoinTransferMethod === "onchain" ? styles.paymentMethodBtnActive : {}),
}}
>
<span style={styles.paymentMethodIcon}>🔗</span>
<span>{t("transferMethod.onchain")}</span>
</button>
<button
onClick={() => onBitcoinTransferMethodChange("lightning")}
disabled={isLightningDisabled}
style={{
...styles.paymentMethodBtn,
...(bitcoinTransferMethod === "lightning" ? styles.paymentMethodBtnActive : {}),
...(isLightningDisabled ? styles.paymentMethodBtnDisabled : {}),
}}
>
<span style={styles.paymentMethodIcon}></span>
<span>{t("transferMethod.lightning")}</span>
</button>
</div>
{isLightningDisabled && (
<div style={styles.thresholdMessage}>
{t("detailsStep.lightningThreshold", { max: LIGHTNING_MAX_EUR })}
</div>
)}
</div>
{/* Amount Section */}
<div style={styles.amountSection}>
<div style={styles.amountHeader}>
<span style={styles.amountLabel}>{t("detailsStep.amount")}</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) => onEurAmountChange(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 */}
2025-12-26 23:27:33 +01:00
<div style={styles.tradeSummary} data-testid="trade-summary">
{direction === "buy" ? (
<p style={styles.summaryText}>
2025-12-26 23:27:33 +01:00
{t("detailsStep.summaryBuy", {
eur: formatEur(eurAmount),
sats: "",
})
.replace("{eur}", formatEur(eurAmount))
.split("{sats}")
.map((part, i) => (
<span key={i}>
{part}
{i === 0 && (
<strong style={styles.satsValue}>
<SatsDisplay sats={satsAmount} />
</strong>
)}
</span>
))}
</p>
) : (
<p style={styles.summaryText}>
2025-12-26 23:27:33 +01:00
{t("detailsStep.summarySell", {
sats: "",
eur: formatEur(eurAmount),
})
.split("{sats}")
.map((part, i) => (
<span key={i}>
{i === 0 && (
<strong style={styles.satsValue}>
<SatsDisplay sats={satsAmount} />
</strong>
)}
{part.replace("{eur}", formatEur(eurAmount))}
</span>
))}
</p>
)}
</div>
{/* Continue Button */}
<button
onClick={onContinue}
disabled={isPriceStale || !hasPrice}
style={{
...styles.continueButton,
...(isPriceStale || !hasPrice ? buttonStyles.buttonDisabled : {}),
}}
>
{t("detailsStep.continueToBooking")}
</button>
</div>
);
}