small details

This commit is contained in:
counterweight 2025-12-26 23:27:33 +01:00
parent 61ae2807de
commit 86c92a7c65
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
11 changed files with 328 additions and 184 deletions

View file

@ -224,168 +224,212 @@ export default function AdminPricingPage() {
isLoading={isLoading} isLoading={isLoading}
isAuthorized={isAuthorized} isAuthorized={isAuthorized}
error={displayError} error={displayError}
contentStyle={{
flex: 1,
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
padding: "2rem",
overflowY: "auto",
}}
> >
<div style={cardStyles.card}> <div style={{ ...cardStyles.card, width: "100%", maxWidth: "1400px" }}>
<h1 style={cardStyles.title}>{t("pricing.title")}</h1> <h1 style={cardStyles.cardTitle}>{t("pricing.title")}</h1>
<p style={cardStyles.subtitle}>{t("pricing.subtitle")}</p> <p style={cardStyles.cardSubtitle}>{t("pricing.subtitle")}</p>
{success && <div style={bannerStyles.success}>{t("pricing.success")}</div>} {success && <div style={bannerStyles.success}>{t("pricing.success")}</div>}
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
handleSave(); setIsConfirming(true);
}} }}
> >
{/* Premium Settings - Full Width */}
<div style={formStyles.section}> <div style={formStyles.section}>
<h2 style={formStyles.sectionTitle}>{t("pricing.premiumSettings")}</h2> <h2 style={formStyles.sectionTitle}>{t("pricing.premiumSettings")}</h2>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, minmax(200px, 1fr))",
gap: "1.5rem",
alignItems: "start",
}}
>
<div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.premiumBuy")} (%)</label>
<input
type="number"
value={formData.premium_buy}
onChange={(e) => handleFieldChange("premium_buy", e.target.value)}
min={-100}
max={100}
style={{
...formStyles.input,
width: "100%",
maxWidth: "180px",
...(errors.premium_buy ? formStyles.inputError : {}),
}}
/>
{errors.premium_buy && <div style={formStyles.error}>{errors.premium_buy}</div>}
</div>
<div style={formStyles.field}> <div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.premiumBuy")} (%)</label> <label style={formStyles.label}>{t("pricing.premiumSell")} (%)</label>
<input <input
type="number" type="number"
value={formData.premium_buy} value={formData.premium_sell}
onChange={(e) => handleFieldChange("premium_buy", e.target.value)} onChange={(e) => handleFieldChange("premium_sell", e.target.value)}
min={-100} min={-100}
max={100} max={100}
style={{ style={{
...formStyles.input, ...formStyles.input,
...(errors.premium_buy ? formStyles.inputError : {}), width: "100%",
}} maxWidth: "180px",
/> ...(errors.premium_sell ? formStyles.inputError : {}),
{errors.premium_buy && <div style={formStyles.error}>{errors.premium_buy}</div>} }}
</div> />
{errors.premium_sell && <div style={formStyles.error}>{errors.premium_sell}</div>}
</div>
<div style={formStyles.field}> <div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.premiumSell")} (%)</label> <label style={formStyles.label}>{t("pricing.smallTradeThreshold")} (EUR)</label>
<input <input
type="number" type="number"
value={formData.premium_sell} value={formData.small_trade_threshold_eur / 100}
onChange={(e) => handleFieldChange("premium_sell", e.target.value)} onChange={(e) =>
min={-100} handleFieldChange(
max={100} "small_trade_threshold_eur",
style={{ (parseFloat(e.target.value) * 100).toString()
...formStyles.input, )
...(errors.premium_sell ? formStyles.inputError : {}), }
}} min={1}
/> style={{
{errors.premium_sell && <div style={formStyles.error}>{errors.premium_sell}</div>} ...formStyles.input,
</div> width: "100%",
maxWidth: "180px",
...(errors.small_trade_threshold_eur ? formStyles.inputError : {}),
}}
/>
{errors.small_trade_threshold_eur && (
<div style={formStyles.error}>{errors.small_trade_threshold_eur}</div>
)}
</div>
<div style={formStyles.field}> <div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.smallTradeThreshold")} (EUR)</label> <label style={formStyles.label}>{t("pricing.smallTradeExtraPremium")} (%)</label>
<input <input
type="number" type="number"
value={formData.small_trade_threshold_eur / 100} value={formData.small_trade_extra_premium}
onChange={(e) => onChange={(e) => handleFieldChange("small_trade_extra_premium", e.target.value)}
handleFieldChange( min={-100}
"small_trade_threshold_eur", max={100}
(parseFloat(e.target.value) * 100).toString() style={{
) ...formStyles.input,
} width: "100%",
min={1} maxWidth: "180px",
style={{ ...(errors.small_trade_extra_premium ? formStyles.inputError : {}),
...formStyles.input, }}
...(errors.small_trade_threshold_eur ? formStyles.inputError : {}), />
}} {errors.small_trade_extra_premium && (
/> <div style={formStyles.error}>{errors.small_trade_extra_premium}</div>
{errors.small_trade_threshold_eur && ( )}
<div style={formStyles.error}>{errors.small_trade_threshold_eur}</div> </div>
)}
</div>
<div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.smallTradeExtraPremium")} (%)</label>
<input
type="number"
value={formData.small_trade_extra_premium}
onChange={(e) => handleFieldChange("small_trade_extra_premium", e.target.value)}
min={-100}
max={100}
style={{
...formStyles.input,
...(errors.small_trade_extra_premium ? formStyles.inputError : {}),
}}
/>
{errors.small_trade_extra_premium && (
<div style={formStyles.error}>{errors.small_trade_extra_premium}</div>
)}
</div> </div>
</div> </div>
<div style={formStyles.section}> {/* Trade Limits - Side by Side */}
<h2 style={formStyles.sectionTitle}>{t("pricing.tradeLimitsBuy")}</h2> <div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(300px, 1fr))",
gap: "2rem",
marginBottom: "2rem",
alignItems: "start",
}}
>
<div style={{ ...formStyles.section, marginBottom: 0 }}>
<h2 style={formStyles.sectionTitle}>{t("pricing.tradeLimitsBuy")}</h2>
<div style={formStyles.field}> <div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.minAmount")} (EUR)</label> <label style={formStyles.label}>{t("pricing.minAmount")} (EUR)</label>
<input <input
type="number" type="number"
value={formData.eur_min_buy / 100} value={formData.eur_min_buy / 100}
onChange={(e) => onChange={(e) =>
handleFieldChange("eur_min_buy", (parseFloat(e.target.value) * 100).toString()) handleFieldChange("eur_min_buy", (parseFloat(e.target.value) * 100).toString())
} }
min={1} min={1}
style={{ style={{
...formStyles.input, ...formStyles.input,
...(errors.eur_min_buy ? formStyles.inputError : {}), width: "100%",
}} maxWidth: "180px",
/> ...(errors.eur_min_buy ? formStyles.inputError : {}),
{errors.eur_min_buy && <div style={formStyles.error}>{errors.eur_min_buy}</div>} }}
/>
{errors.eur_min_buy && <div style={formStyles.error}>{errors.eur_min_buy}</div>}
</div>
<div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.maxAmount")} (EUR)</label>
<input
type="number"
value={formData.eur_max_buy / 100}
onChange={(e) =>
handleFieldChange("eur_max_buy", (parseFloat(e.target.value) * 100).toString())
}
min={1}
style={{
...formStyles.input,
width: "100%",
maxWidth: "180px",
...(errors.eur_max_buy ? formStyles.inputError : {}),
}}
/>
{errors.eur_max_buy && <div style={formStyles.error}>{errors.eur_max_buy}</div>}
</div>
</div> </div>
<div style={formStyles.field}> <div style={{ ...formStyles.section, marginBottom: 0 }}>
<label style={formStyles.label}>{t("pricing.maxAmount")} (EUR)</label> <h2 style={formStyles.sectionTitle}>{t("pricing.tradeLimitsSell")}</h2>
<input
type="number"
value={formData.eur_max_buy / 100}
onChange={(e) =>
handleFieldChange("eur_max_buy", (parseFloat(e.target.value) * 100).toString())
}
min={1}
style={{
...formStyles.input,
...(errors.eur_max_buy ? formStyles.inputError : {}),
}}
/>
{errors.eur_max_buy && <div style={formStyles.error}>{errors.eur_max_buy}</div>}
</div>
</div>
<div style={formStyles.section}> <div style={formStyles.field}>
<h2 style={formStyles.sectionTitle}>{t("pricing.tradeLimitsSell")}</h2> <label style={formStyles.label}>{t("pricing.minAmount")} (EUR)</label>
<input
type="number"
value={formData.eur_min_sell / 100}
onChange={(e) =>
handleFieldChange("eur_min_sell", (parseFloat(e.target.value) * 100).toString())
}
min={1}
style={{
...formStyles.input,
width: "100%",
maxWidth: "180px",
...(errors.eur_min_sell ? formStyles.inputError : {}),
}}
/>
{errors.eur_min_sell && <div style={formStyles.error}>{errors.eur_min_sell}</div>}
</div>
<div style={formStyles.field}> <div style={formStyles.field}>
<label style={formStyles.label}>{t("pricing.minAmount")} (EUR)</label> <label style={formStyles.label}>{t("pricing.maxAmount")} (EUR)</label>
<input <input
type="number" type="number"
value={formData.eur_min_sell / 100} value={formData.eur_max_sell / 100}
onChange={(e) => onChange={(e) =>
handleFieldChange("eur_min_sell", (parseFloat(e.target.value) * 100).toString()) handleFieldChange("eur_max_sell", (parseFloat(e.target.value) * 100).toString())
} }
min={1} min={1}
style={{ style={{
...formStyles.input, ...formStyles.input,
...(errors.eur_min_sell ? formStyles.inputError : {}), width: "100%",
}} maxWidth: "180px",
/> ...(errors.eur_max_sell ? formStyles.inputError : {}),
{errors.eur_min_sell && <div style={formStyles.error}>{errors.eur_min_sell}</div>} }}
</div> />
{errors.eur_max_sell && <div style={formStyles.error}>{errors.eur_max_sell}</div>}
<div style={formStyles.field}> </div>
<label style={formStyles.label}>{t("pricing.maxAmount")} (EUR)</label>
<input
type="number"
value={formData.eur_max_sell / 100}
onChange={(e) =>
handleFieldChange("eur_max_sell", (parseFloat(e.target.value) * 100).toString())
}
min={1}
style={{
...formStyles.input,
...(errors.eur_max_sell ? formStyles.inputError : {}),
}}
/>
{errors.eur_max_sell && <div style={formStyles.error}>{errors.eur_max_sell}</div>}
</div> </div>
</div> </div>

View file

@ -345,36 +345,43 @@ export function ExchangeDetailsStep({
</div> </div>
{/* Trade Summary */} {/* Trade Summary */}
<div style={styles.tradeSummary}> <div style={styles.tradeSummary} data-testid="trade-summary">
{direction === "buy" ? ( {direction === "buy" ? (
<p style={styles.summaryText}> <p style={styles.summaryText}>
{t("detailsStep.summaryBuy", { sats: "", eur: "" }).split("{sats}")[0].trim()}{" "} {t("detailsStep.summaryBuy", {
<strong style={styles.satsValue}> eur: formatEur(eurAmount),
<SatsDisplay sats={satsAmount} /> sats: "",
</strong> })
{", "} .replace("{eur}", formatEur(eurAmount))
{t("detailsStep.summaryBuy", { sats: "", eur: "" }) .split("{sats}")
.split("{sats}")[1] .map((part, i) => (
?.split("{eur}")[0] <span key={i}>
?.trim()}{" "} {part}
<strong>{formatEur(eurAmount)}</strong> {i === 0 && (
<strong style={styles.satsValue}>
<SatsDisplay sats={satsAmount} />
</strong>
)}
</span>
))}
</p> </p>
) : ( ) : (
<p style={styles.summaryText}> <p style={styles.summaryText}>
{t("detailsStep.summarySell", { sats: "", eur: "" }) {t("detailsStep.summarySell", {
.split("{sats}")[0] sats: "",
?.split("{eur}")[0] eur: formatEur(eurAmount),
?.trim()}{" "} })
<strong>{formatEur(eurAmount)}</strong> .split("{sats}")
{", "} .map((part, i) => (
{t("detailsStep.summarySell", { sats: "", eur: "" }) <span key={i}>
.split("{sats}")[0] {i === 0 && (
?.split("{eur}")[1] <strong style={styles.satsValue}>
?.trim()}{" "} <SatsDisplay sats={satsAmount} />
<strong style={styles.satsValue}> </strong>
<SatsDisplay sats={satsAmount} /> )}
</strong> {part.replace("{eur}", formatEur(eurAmount))}
{t("detailsStep.summarySell", { sats: "", eur: "" }).split("{sats}")[1]?.trim()} </span>
))}
</p> </p>
)} )}
</div> </div>

View file

@ -439,6 +439,30 @@ export const formStyles: StyleRecord = {
fontSize: "0.75rem", fontSize: "0.75rem",
color: "#fca5a5", color: "#fca5a5",
}, },
error: {
fontFamily: tokens.fontSans,
fontSize: "0.75rem",
color: "#fca5a5",
marginTop: "0.25rem",
},
section: {
marginBottom: "2rem",
},
sectionTitle: {
fontFamily: tokens.fontSans,
fontSize: "1rem",
fontWeight: 600,
color: tokens.textSecondary,
marginBottom: "1.25rem",
marginTop: 0,
},
actions: {
display: "flex",
justifyContent: "flex-end",
marginTop: "2rem",
paddingTop: "1.5rem",
borderTop: `1px solid ${tokens.surfaceBorder}`,
},
charCount: { charCount: {
fontFamily: tokens.fontSans, fontFamily: tokens.fontSans,
fontSize: "0.75rem", fontSize: "0.75rem",
@ -511,6 +535,26 @@ export const buttonStyles: StyleRecord = {
cursor: "not-allowed", cursor: "not-allowed",
boxShadow: "none", boxShadow: "none",
}, },
/** Primary button alias (for backwards compatibility) */
primary: {
fontFamily: tokens.fontSans,
padding: "1rem",
fontSize: "1rem",
fontWeight: 600,
background: tokens.primaryGradient,
color: tokens.white,
border: "none",
borderRadius: tokens.radiusLg,
cursor: "pointer",
transition: "transform 0.2s, box-shadow 0.2s",
boxShadow: tokens.buttonShadow,
},
/** Disabled button alias (for backwards compatibility) */
disabled: {
opacity: 0.5,
cursor: "not-allowed",
boxShadow: "none",
},
}; };
// ============================================================================= // =============================================================================
@ -575,6 +619,17 @@ export const bannerStyles: StyleRecord = {
color: "#4ade80", color: "#4ade80",
marginBottom: "1rem", marginBottom: "1rem",
}, },
/** Success banner alias (for backwards compatibility) */
success: {
fontFamily: tokens.fontSans,
fontSize: "0.875rem",
padding: "1rem",
background: "rgba(34, 197, 94, 0.15)",
border: `1px solid ${tokens.successBorder}`,
borderRadius: tokens.radiusMd,
color: "#4ade80",
marginBottom: "1rem",
},
}; };
// ============================================================================= // =============================================================================

View file

@ -148,6 +148,44 @@ test.describe("Exchange Page - Regular User Access", () => {
timeout: 10000, timeout: 10000,
}); });
}); });
test("summary card displays correct text for buy and sell directions", async ({ page }) => {
await page.goto("/exchange");
await expect(page.getByRole("heading", { name: "Exchange Bitcoin" })).toBeVisible();
// Wait for price data to load
await expect(page.getByText("Market:")).toBeVisible({ timeout: 5000 });
// Test BUY direction summary
const buyButton = page.getByRole("button", { name: "Buy BTC" });
await buyButton.click();
// Wait for summary to update
await page.waitForTimeout(1000);
// Check that summary contains "You pay" and "you receive" with EUR amount and sats
const summaryElement = page.getByTestId("trade-summary");
await expect(summaryElement).toBeVisible({ timeout: 5000 });
const summaryText = await summaryElement.textContent();
expect(summaryText).toContain("You pay");
expect(summaryText).toContain("you receive");
expect(summaryText).toMatch(/€\s*\d+/); // Should contain EUR amount
expect(summaryText).toMatch(/\d+\s*sats/); // Should contain sats amount
// Test SELL direction summary
const sellButton = page.getByRole("button", { name: "Sell BTC" });
await sellButton.click();
// Wait for summary to update
await page.waitForTimeout(1000);
// Check that summary contains "You pay" and "you receive" with sats and EUR amount
const summaryTextSell = await summaryElement.textContent();
expect(summaryTextSell).toContain("You pay");
expect(summaryTextSell).toContain("you receive");
expect(summaryTextSell).toMatch(/€\s*\d+/); // Should contain EUR amount
expect(summaryTextSell).toMatch(/\d+\s*sats/); // Should contain sats amount
});
}); });
test.describe("Exchange Page - With Availability", () => { test.describe("Exchange Page - With Availability", () => {

View file

@ -114,12 +114,12 @@
"title": "Configuració de Preus", "title": "Configuració de Preus",
"subtitle": "Configura els preus de prima i els límits d'import de les operacions", "subtitle": "Configura els preus de prima i els límits d'import de les operacions",
"premiumSettings": "Configuració de Prima", "premiumSettings": "Configuració de Prima",
"premiumBuy": "Prima per COMPRAR", "premiumBuy": "Prima quan l'Usuari Compra BTC (Usuari paga EUR, rep BTC)",
"premiumSell": "Prima per VENDRE", "premiumSell": "Prima quan l'Usuari Vén BTC (Usuari paga BTC, rep EUR)",
"smallTradeThreshold": "Umbral d'Operacions Petites", "smallTradeThreshold": "Umbral d'Operacions Petites",
"smallTradeExtraPremium": "Prima Extra per Operacions Petites", "smallTradeExtraPremium": "Prima Extra per Operacions Petites",
"tradeLimitsBuy": "Límits d'Import d'Operacions (COMPRAR)", "tradeLimitsBuy": "Límits d'Import en Comprar BTC (EUR pagat per l'usuari)",
"tradeLimitsSell": "Límits d'Import d'Operacions (VENDRE)", "tradeLimitsSell": "Límits d'Import en Vendre BTC (EUR rebut per l'usuari)",
"minAmount": "Import Mínim", "minAmount": "Import Mínim",
"maxAmount": "Import Màxim", "maxAmount": "Import Màxim",
"save": "Guardar Canvis", "save": "Guardar Canvis",

View file

@ -31,8 +31,8 @@
"required": "*", "required": "*",
"lightningThreshold": "Els pagaments Lightning només estan disponibles per importants fins a €{max}", "lightningThreshold": "Els pagaments Lightning només estan disponibles per importants fins a €{max}",
"amount": "Quantitat (EUR)", "amount": "Quantitat (EUR)",
"summaryBuy": "Compres {sats}, vens {eur}", "summaryBuy": "Pagues {eur}, reps {sats}",
"summarySell": "Compres {eur}, vens {sats}", "summarySell": "Pagues {sats}, reps {eur}",
"continueToBooking": "Continuar a reserva" "continueToBooking": "Continuar a reserva"
}, },
"bookingStep": { "bookingStep": {

View file

@ -114,12 +114,12 @@
"title": "Pricing Configuration", "title": "Pricing Configuration",
"subtitle": "Configure premium pricing and trade amount limits", "subtitle": "Configure premium pricing and trade amount limits",
"premiumSettings": "Premium Settings", "premiumSettings": "Premium Settings",
"premiumBuy": "Premium for BUY", "premiumBuy": "Premium when User Buys BTC (User pays EUR, receives BTC)",
"premiumSell": "Premium for SELL", "premiumSell": "Premium when User Sells BTC (User pays BTC, receives EUR)",
"smallTradeThreshold": "Small Trade Threshold", "smallTradeThreshold": "Small Trade Threshold",
"smallTradeExtraPremium": "Extra Premium for Small Trades", "smallTradeExtraPremium": "Extra Premium for Small Trades",
"tradeLimitsBuy": "Trade Amount Limits (BUY)", "tradeLimitsBuy": "Trade Amount Limits when Buying BTC (EUR paid by user)",
"tradeLimitsSell": "Trade Amount Limits (SELL)", "tradeLimitsSell": "Trade Amount Limits when Selling BTC (EUR received by user)",
"minAmount": "Minimum Amount", "minAmount": "Minimum Amount",
"maxAmount": "Maximum Amount", "maxAmount": "Maximum Amount",
"save": "Save Changes", "save": "Save Changes",

View file

@ -31,8 +31,8 @@
"required": "*", "required": "*",
"lightningThreshold": "Lightning payments are only available for amounts up to €{max}", "lightningThreshold": "Lightning payments are only available for amounts up to €{max}",
"amount": "Amount (EUR)", "amount": "Amount (EUR)",
"summaryBuy": "You buy {sats}, you sell {eur}", "summaryBuy": "You pay {eur}, you receive {sats}",
"summarySell": "You buy {eur}, you sell {sats}", "summarySell": "You pay {sats}, you receive {eur}",
"continueToBooking": "Continue to Booking" "continueToBooking": "Continue to Booking"
}, },
"bookingStep": { "bookingStep": {

View file

@ -114,12 +114,12 @@
"title": "Configuración de Precios", "title": "Configuración de Precios",
"subtitle": "Configura los precios de prima y los límites de importe de las operaciones", "subtitle": "Configura los precios de prima y los límites de importe de las operaciones",
"premiumSettings": "Configuración de Prima", "premiumSettings": "Configuración de Prima",
"premiumBuy": "Prima para COMPRAR", "premiumBuy": "Prima cuando el Usuario Compra BTC (Usuario paga EUR, recibe BTC)",
"premiumSell": "Prima para VENDER", "premiumSell": "Prima cuando el Usuario Vende BTC (Usuario paga BTC, recibe EUR)",
"smallTradeThreshold": "Umbral de Operaciones Pequeñas", "smallTradeThreshold": "Umbral de Operaciones Pequeñas",
"smallTradeExtraPremium": "Prima Extra para Operaciones Pequeñas", "smallTradeExtraPremium": "Prima Extra para Operaciones Pequeñas",
"tradeLimitsBuy": "Límites de Importe de Operaciones (COMPRAR)", "tradeLimitsBuy": "Límites de Importe al Comprar BTC (EUR pagado por el usuario)",
"tradeLimitsSell": "Límites de Importe de Operaciones (VENDER)", "tradeLimitsSell": "Límites de Importe al Vender BTC (EUR recibido por el usuario)",
"minAmount": "Importe Mínimo", "minAmount": "Importe Mínimo",
"maxAmount": "Importe Máximo", "maxAmount": "Importe Máximo",
"save": "Guardar Cambios", "save": "Guardar Cambios",

View file

@ -31,8 +31,8 @@
"required": "*", "required": "*",
"lightningThreshold": "Los pagos Lightning solo están disponibles para montos de hasta €{max}", "lightningThreshold": "Los pagos Lightning solo están disponibles para montos de hasta €{max}",
"amount": "Cantidad (EUR)", "amount": "Cantidad (EUR)",
"summaryBuy": "Compras {sats}, vendes {eur}", "summaryBuy": "Pagas {eur}, recibes {sats}",
"summarySell": "Compras {eur}, vendes {sats}", "summarySell": "Pagas {sats}, recibes {eur}",
"continueToBooking": "Continuar a reserva" "continueToBooking": "Continuar a reserva"
}, },
"bookingStep": { "bookingStep": {

View file

@ -27,7 +27,7 @@
"slotDurationMinutes": 15, "slotDurationMinutes": 15,
"maxAdvanceDays": 30, "maxAdvanceDays": 30,
"minAdvanceDays": 1, "minAdvanceDays": 1,
"eurTradeIncrement": 20, "eurTradeIncrement": 5,
"priceRefreshSeconds": 60, "priceRefreshSeconds": 60,
"priceStalenessSeconds": 300, "priceStalenessSeconds": 300,
"lightningMaxEur": 1000 "lightningMaxEur": 1000