Phase 6: Translate User Pages - exchange, trades, invites, profile

- Expand exchange.json with all exchange page strings (page, steps, detailsStep, bookingStep, confirmationStep, priceDisplay)
- Create trades.json translation files for es, en, ca
- Create invites.json translation files for es, en, ca
- Create profile.json translation files for es, en, ca
- Translate exchange page and all components (ExchangeDetailsStep, BookingStep, ConfirmationStep, StepIndicator, PriceDisplay)
- Translate trades page (titles, sections, buttons, status labels)
- Translate invites page (titles, sections, status badges, copy button)
- Translate profile page (form labels, hints, placeholders, messages)
- Update IntlProvider to load all new namespaces
- All frontend tests passing
This commit is contained in:
counterweight 2025-12-25 22:19:13 +01:00
parent 7dd13292a0
commit 246553c402
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
22 changed files with 559 additions and 115 deletions

View file

@ -17,11 +17,44 @@ import caExchange from "../../locales/ca/exchange.json";
import esAuth from "../../locales/es/auth.json";
import enAuth from "../../locales/en/auth.json";
import caAuth from "../../locales/ca/auth.json";
import esTrades from "../../locales/es/trades.json";
import enTrades from "../../locales/en/trades.json";
import caTrades from "../../locales/ca/trades.json";
import esInvites from "../../locales/es/invites.json";
import enInvites from "../../locales/en/invites.json";
import caInvites from "../../locales/ca/invites.json";
import esProfile from "../../locales/es/profile.json";
import enProfile from "../../locales/en/profile.json";
import caProfile from "../../locales/ca/profile.json";
const messages = {
es: { common: esCommon, navigation: esNavigation, exchange: esExchange, auth: esAuth },
en: { common: enCommon, navigation: enNavigation, exchange: enExchange, auth: enAuth },
ca: { common: caCommon, navigation: caNavigation, exchange: caExchange, auth: caAuth },
es: {
common: esCommon,
navigation: esNavigation,
exchange: esExchange,
auth: esAuth,
trades: esTrades,
invites: esInvites,
profile: esProfile,
},
en: {
common: enCommon,
navigation: enNavigation,
exchange: enExchange,
auth: enAuth,
trades: enTrades,
invites: enInvites,
profile: enProfile,
},
ca: {
common: caCommon,
navigation: caNavigation,
exchange: caExchange,
auth: caAuth,
trades: caTrades,
invites: caInvites,
profile: caProfile,
},
};
interface IntlProviderProps {

View file

@ -6,6 +6,7 @@ import { components } from "../../generated/api";
import { formatDate, formatTime } from "../../utils/date";
import { formatEur } from "../../utils/exchange";
import { bannerStyles } from "../../styles/shared";
import { useTranslation } from "../../hooks/useTranslation";
type BookableSlot = components["schemas"]["BookableSlot"];
type ExchangeResponse = components["schemas"]["ExchangeResponse"];
@ -215,14 +216,15 @@ export function BookingStep({
onSlotSelect,
onBackToDetails,
}: BookingStepProps) {
const t = useTranslation("exchange");
return (
<>
{/* Trade Summary Card */}
<div style={styles.summaryCard}>
<div style={styles.summaryHeader}>
<span style={styles.summaryTitle}>Your Exchange</span>
<span style={styles.summaryTitle}>{t("bookingStep.yourExchange")}</span>
<button onClick={onBackToDetails} style={styles.editButton}>
Edit
{t("bookingStep.edit")}
</button>
</div>
<div style={styles.summaryDetails}>
@ -232,7 +234,7 @@ export function BookingStep({
color: direction === "buy" ? "#4ade80" : "#f87171",
}}
>
{direction === "buy" ? "Buy" : "Sell"} BTC
{direction === "buy" ? t("bookingStep.buy") : t("bookingStep.sell")} BTC
</span>
<span style={styles.summaryDivider}></span>
<span>{formatEur(eurAmount)}</span>
@ -242,15 +244,17 @@ export function BookingStep({
</span>
<span style={styles.summaryDivider}></span>
<span style={styles.summaryPaymentMethod}>
{direction === "buy" ? "Receive via " : "Send via "}
{bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"}
{direction === "buy" ? t("bookingStep.receiveVia") : t("bookingStep.sendVia")}{" "}
{bitcoinTransferMethod === "onchain"
? t("transferMethod.onchain")
: t("transferMethod.lightning")}
</span>
</div>
</div>
{/* Date Selection */}
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Select a Date</h2>
<h2 style={styles.sectionTitle}>{t("bookingStep.selectDate")}</h2>
<div style={styles.dateGrid}>
{dates.map((date) => {
const dateStr = formatDate(date);
@ -291,15 +295,13 @@ export function BookingStep({
{/* 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>{t("bookingStep.existingTradeWarning")}</div>
<div style={styles.errorLink}>
<a
href={`/trades/${existingTradeOnSelectedDate.public_id}`}
style={styles.errorLinkAnchor}
>
View your existing trade
{t("bookingStep.viewExistingTrade")}
</a>
</div>
</div>
@ -309,7 +311,7 @@ export function BookingStep({
{selectedDate && !existingTradeOnSelectedDate && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Available Slots for{" "}
{t("bookingStep.availableSlots")}{" "}
{selectedDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
@ -318,9 +320,9 @@ export function BookingStep({
</h2>
{isLoadingSlots ? (
<div style={styles.emptyState}>Loading slots...</div>
<div style={styles.emptyState}>{t("bookingStep.loadingSlots")}</div>
) : availableSlots.length === 0 ? (
<div style={styles.emptyState}>No available slots for this date</div>
<div style={styles.emptyState}>{t("bookingStep.noSlots")}</div>
) : (
<div style={styles.slotGrid}>
{availableSlots.map((slot) => {

View file

@ -6,6 +6,7 @@ import { components } from "../../generated/api";
import { formatTime } from "../../utils/date";
import { formatEur } from "../../utils/exchange";
import { buttonStyles } from "../../styles/shared";
import { useTranslation } from "../../hooks/useTranslation";
type BookableSlot = components["schemas"]["BookableSlot"];
type Direction = "buy" | "sell";
@ -154,14 +155,15 @@ export function ConfirmationStep({
onConfirm,
onBack,
}: ConfirmationStepProps) {
const t = useTranslation("exchange");
return (
<>
{/* Compressed Booking Summary */}
<div style={styles.compressedBookingCard}>
<div style={styles.compressedBookingHeader}>
<span style={styles.compressedBookingTitle}>Appointment</span>
<span style={styles.compressedBookingTitle}>{t("confirmationStep.appointment")}</span>
<button onClick={onBack} style={styles.editButton}>
Edit
{t("confirmationStep.edit")}
</button>
</div>
<div style={styles.compressedBookingDetails}>
@ -181,44 +183,48 @@ export function ConfirmationStep({
{/* Confirmation Card */}
<div style={styles.confirmCard}>
<h3 style={styles.confirmTitle}>Confirm Trade</h3>
<h3 style={styles.confirmTitle}>{t("confirmationStep.confirmTrade")}</h3>
<div style={styles.confirmDetails}>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Time:</span>
<span style={styles.confirmLabel}>{t("confirmationStep.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.confirmLabel}>{t("confirmationStep.direction")}</span>
<span
style={{
...styles.confirmValue,
color: direction === "buy" ? "#4ade80" : "#f87171",
}}
>
{direction === "buy" ? "Buy BTC" : "Sell BTC"}
{direction === "buy" ? t("direction.buyShort") : t("direction.sellShort")}
</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>EUR:</span>
<span style={styles.confirmLabel}>{t("confirmationStep.eur")}</span>
<span style={styles.confirmValue}>{formatEur(eurAmount)}</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>BTC:</span>
<span style={styles.confirmLabel}>{t("confirmationStep.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.confirmLabel}>{t("confirmationStep.rate")}</span>
<span style={styles.confirmValue}>{formatPrice(agreedPrice)}/BTC</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Payment:</span>
<span style={styles.confirmLabel}>{t("confirmationStep.payment")}</span>
<span style={styles.confirmValue}>
{direction === "buy" ? "Receive via " : "Send via "}
{bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"}
{direction === "buy"
? t("confirmationStep.receiveVia")
: t("confirmationStep.sendVia")}{" "}
{bitcoinTransferMethod === "onchain"
? t("transferMethod.onchain")
: t("transferMethod.lightning")}
</span>
</div>
</div>
@ -237,13 +243,15 @@ export function ConfirmationStep({
}}
>
{isBooking
? "Booking..."
? t("confirmationStep.booking")
: isPriceStale
? "Price Stale"
: `Confirm ${direction === "buy" ? "Buy" : "Sell"}`}
? t("confirmationStep.priceStale")
: direction === "buy"
? t("confirmationStep.confirmBuy")
: t("confirmationStep.confirmSell")}
</button>
<button onClick={onBack} disabled={isBooking} style={styles.cancelButton}>
Back
{t("confirmationStep.back")}
</button>
</div>
</div>

View file

@ -5,6 +5,7 @@ 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;
@ -225,6 +226,7 @@ export function ExchangeDetailsStep({
hasPrice,
onContinue,
}: ExchangeDetailsStepProps) {
const t = useTranslation("exchange");
const isLightningDisabled = eurAmount > LIGHTNING_MAX_EUR * 100;
const handleAmountChange = (value: number) => {
@ -263,7 +265,7 @@ export function ExchangeDetailsStep({
...(direction === "buy" ? styles.directionBtnBuyActive : {}),
}}
>
Buy BTC
{t("direction.buyShort")}
</button>
<button
onClick={() => onDirectionChange("sell")}
@ -272,14 +274,15 @@ export function ExchangeDetailsStep({
...(direction === "sell" ? styles.directionBtnSellActive : {}),
}}
>
Sell BTC
{t("direction.sellShort")}
</button>
</div>
{/* Payment Method Selector */}
<div style={styles.paymentMethodSection}>
<div style={styles.paymentMethodLabel}>
Payment Method <span style={styles.required}>*</span>
{t("detailsStep.paymentMethod")}{" "}
<span style={styles.required}>{t("detailsStep.required")}</span>
</div>
<div style={styles.paymentMethodRow}>
<button
@ -290,7 +293,7 @@ export function ExchangeDetailsStep({
}}
>
<span style={styles.paymentMethodIcon}>🔗</span>
<span>Onchain</span>
<span>{t("transferMethod.onchain")}</span>
</button>
<button
onClick={() => onBitcoinTransferMethodChange("lightning")}
@ -302,12 +305,12 @@ export function ExchangeDetailsStep({
}}
>
<span style={styles.paymentMethodIcon}></span>
<span>Lightning</span>
<span>{t("transferMethod.lightning")}</span>
</button>
</div>
{isLightningDisabled && (
<div style={styles.thresholdMessage}>
Lightning payments are only available for amounts up to {LIGHTNING_MAX_EUR}
{t("detailsStep.lightningThreshold", { max: LIGHTNING_MAX_EUR })}
</div>
)}
</div>
@ -315,7 +318,7 @@ export function ExchangeDetailsStep({
{/* Amount Section */}
<div style={styles.amountSection}>
<div style={styles.amountHeader}>
<span style={styles.amountLabel}>Amount (EUR)</span>
<span style={styles.amountLabel}>{t("detailsStep.amount")}</span>
<div style={styles.amountInputWrapper}>
<span style={styles.amountCurrency}></span>
<input
@ -345,18 +348,24 @@ export function ExchangeDetailsStep({
<div style={styles.tradeSummary}>
{direction === "buy" ? (
<p style={styles.summaryText}>
You buy{" "}
{t("detailsStep.summaryBuy").split("{sats}")[0].trim()}{" "}
<strong style={styles.satsValue}>
<SatsDisplay sats={satsAmount} />
</strong>
, you sell <strong>{formatEur(eurAmount)}</strong>
{", "}
{t("detailsStep.summaryBuy").split("{sats}")[1]?.split("{eur}")[0]?.trim()}{" "}
<strong>{formatEur(eurAmount)}</strong>
</p>
) : (
<p style={styles.summaryText}>
You buy <strong>{formatEur(eurAmount)}</strong>, you sell{" "}
{t("detailsStep.summarySell").split("{sats}")[0]?.split("{eur}")[0]?.trim()}{" "}
<strong>{formatEur(eurAmount)}</strong>
{", "}
{t("detailsStep.summarySell").split("{sats}")[0]?.split("{eur}")[1]?.trim()}{" "}
<strong style={styles.satsValue}>
<SatsDisplay sats={satsAmount} />
</strong>
{t("detailsStep.summarySell").split("{sats}")[1]?.trim()}
</p>
)}
</div>
@ -370,7 +379,7 @@ export function ExchangeDetailsStep({
...(isPriceStale || !hasPrice ? buttonStyles.buttonDisabled : {}),
}}
>
Continue to Booking
{t("detailsStep.continueToBooking")}
</button>
</div>
);

View file

@ -2,6 +2,7 @@
import { CSSProperties } from "react";
import { components } from "../../generated/api";
import { useTranslation } from "../../hooks/useTranslation";
type ExchangePriceResponse = components["schemas"]["ExchangePriceResponse"];
@ -94,6 +95,7 @@ export function PriceDisplay({
direction,
agreedPrice,
}: PriceDisplayProps) {
const t = useTranslation("exchange");
const marketPrice = priceData?.price?.market_price ?? 0;
const premiumPercent = priceData?.price?.premium_percentage ?? 5;
const isPriceStale = priceData?.price?.is_stale ?? false;
@ -101,16 +103,16 @@ export function PriceDisplay({
return (
<div style={styles.priceCard}>
{isLoading && !priceData ? (
<div style={styles.priceLoading}>Loading price...</div>
<div style={styles.priceLoading}>{t("priceDisplay.loading")}</div>
) : error && !priceData?.price ? (
<div style={styles.priceError}>{error}</div>
) : (
<>
<div style={styles.priceRow}>
<span style={styles.priceLabel}>Market:</span>
<span style={styles.priceLabel}>{t("priceDisplay.market")}</span>
<span style={styles.priceValue}>{formatPrice(marketPrice)}</span>
<span style={styles.priceDivider}></span>
<span style={styles.priceLabel}>Our price:</span>
<span style={styles.priceLabel}>{t("priceDisplay.ourPrice")}</span>
<span style={styles.priceValue}>{formatPrice(agreedPrice)}</span>
<span style={styles.premiumBadge}>
{direction === "buy" ? "+" : "-"}
@ -119,8 +121,8 @@ export function PriceDisplay({
</div>
{lastUpdate && (
<div style={styles.priceTimestamp}>
Updated {lastUpdate.toLocaleTimeString()}
{isPriceStale && <span style={styles.staleWarning}> (stale)</span>}
{t("priceDisplay.updated")} {lastUpdate.toLocaleTimeString()}
{isPriceStale && <span style={styles.staleWarning}> {t("priceDisplay.stale")}</span>}
</div>
)}
</>

View file

@ -1,6 +1,7 @@
"use client";
import { CSSProperties } from "react";
import { useTranslation } from "../../hooks/useTranslation";
type WizardStep = "details" | "booking" | "confirmation";
@ -58,6 +59,7 @@ const styles: Record<string, CSSProperties> = {
* Shows which step the user is currently on and which steps are completed.
*/
export function StepIndicator({ currentStep }: StepIndicatorProps) {
const t = useTranslation("exchange");
return (
<div style={styles.stepIndicator}>
<div
@ -67,7 +69,7 @@ export function StepIndicator({ currentStep }: StepIndicatorProps) {
}}
>
<span style={styles.stepNumber}>1</span>
<span style={styles.stepLabel}>Exchange Details</span>
<span style={styles.stepLabel}>{t("steps.details")}</span>
</div>
<div style={styles.stepDivider} />
<div
@ -81,7 +83,7 @@ export function StepIndicator({ currentStep }: StepIndicatorProps) {
}}
>
<span style={styles.stepNumber}>2</span>
<span style={styles.stepLabel}>Book Appointment</span>
<span style={styles.stepLabel}>{t("steps.booking")}</span>
</div>
<div style={styles.stepDivider} />
<div
@ -91,7 +93,7 @@ export function StepIndicator({ currentStep }: StepIndicatorProps) {
}}
>
<span style={styles.stepNumber}>3</span>
<span style={styles.stepLabel}>Confirm</span>
<span style={styles.stepLabel}>{t("steps.confirm")}</span>
</div>
</div>
);

View file

@ -19,6 +19,7 @@ import { StepIndicator } from "./components/StepIndicator";
import { ExchangeDetailsStep } from "./components/ExchangeDetailsStep";
import { BookingStep } from "./components/BookingStep";
import { ConfirmationStep } from "./components/ConfirmationStep";
import { useTranslation } from "../hooks/useTranslation";
type ExchangeResponse = components["schemas"]["ExchangeResponse"];
@ -57,6 +58,7 @@ const styles = {
export default function ExchangePage() {
const router = useRouter();
const t = useTranslation("exchange");
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.CREATE_EXCHANGE,
fallbackRedirect: "/",
@ -281,8 +283,8 @@ export default function ExchangePage() {
<main style={layoutStyles.main}>
<Header currentPage="exchange" />
<div style={styles.content}>
<h1 style={typographyStyles.pageTitle}>Exchange Bitcoin</h1>
<p style={typographyStyles.pageSubtitle}>Buy or sell Bitcoin with a 5% premium</p>
<h1 style={typographyStyles.pageTitle}>{t("page.title")}</h1>
<p style={typographyStyles.pageSubtitle}>{t("page.subtitle")}</p>
{error && (
<div style={bannerStyles.errorBanner}>
@ -290,7 +292,7 @@ export default function ExchangePage() {
{existingTradeId && (
<div style={styles.errorLink}>
<a href={`/trades/${existingTradeId}`} style={styles.errorLinkAnchor}>
View your existing trade
{t("page.viewExistingTrade")}
</a>
</div>
)}

View file

@ -11,11 +11,13 @@ import { components } from "../generated/api";
import constants from "../../../shared/constants.json";
import { Permission } from "../auth-context";
import { cardStyles, typographyStyles, buttonStyles } from "../styles/shared";
import { useTranslation } from "../hooks/useTranslation";
// Use generated type from OpenAPI schema
type Invite = components["schemas"]["UserInviteResponse"];
export default function InvitesPage() {
const t = useTranslation("invites");
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_OWN_INVITES,
fallbackRedirect: "/admin/trades",
@ -62,26 +64,21 @@ export default function InvitesPage() {
>
<div style={styles.pageCard}>
<div style={cardStyles.cardHeader}>
<h1 style={cardStyles.cardTitle}>My Invites</h1>
<p style={cardStyles.cardSubtitle}>
Share your invite codes with friends to let them join
</p>
<h1 style={cardStyles.cardTitle}>{t("page.title")}</h1>
<p style={cardStyles.cardSubtitle}>{t("page.subtitle")}</p>
</div>
{(invites?.length ?? 0) === 0 ? (
<EmptyState
message="You don't have any invites yet."
hint="Contact an admin if you need invite codes to share."
/>
<EmptyState message={t("page.noInvites")} hint={t("page.noInvitesHint")} />
) : (
<div style={styles.sections}>
{/* Ready Invites */}
{readyInvites.length > 0 && (
<div style={styles.section}>
<h2 style={typographyStyles.sectionTitle}>Available ({readyInvites.length})</h2>
<p style={typographyStyles.sectionHint}>
Share these links with people you want to invite
</p>
<h2 style={typographyStyles.sectionTitle}>
{t("page.available", { count: readyInvites.length })}
</h2>
<p style={typographyStyles.sectionHint}>{t("page.availableHint")}</p>
<div style={styles.inviteList}>
{readyInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCard}>
@ -91,7 +88,7 @@ export default function InvitesPage() {
onClick={() => copyToClipboard(invite)}
style={buttonStyles.accentButton}
>
{copiedId === invite.id ? "Copied!" : "Copy Link"}
{copiedId === invite.id ? t("page.copied") : t("page.copyLink")}
</button>
</div>
</div>
@ -103,14 +100,20 @@ export default function InvitesPage() {
{/* Spent Invites */}
{spentInvites.length > 0 && (
<div style={styles.section}>
<h2 style={typographyStyles.sectionTitle}>Used ({spentInvites.length})</h2>
<h2 style={typographyStyles.sectionTitle}>
{t("page.used", { count: spentInvites.length })}
</h2>
<div style={styles.inviteList}>
{spentInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCardSpent}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<div style={styles.inviteeMeta}>
<StatusBadge variant="success">Used</StatusBadge>
<span style={styles.inviteeEmail}>by {invite.used_by_email}</span>
<StatusBadge variant="success">{t("page.usedStatus")}</StatusBadge>
{invite.used_by_email && (
<span style={styles.inviteeEmail}>
{t("page.usedBy", { email: invite.used_by_email })}
</span>
)}
</div>
</div>
))}
@ -121,12 +124,14 @@ export default function InvitesPage() {
{/* Revoked Invites */}
{revokedInvites.length > 0 && (
<div style={styles.section}>
<h2 style={typographyStyles.sectionTitle}>Revoked ({revokedInvites.length})</h2>
<h2 style={typographyStyles.sectionTitle}>
{t("page.revoked", { count: revokedInvites.length })}
</h2>
<div style={styles.inviteList}>
{revokedInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCardRevoked}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<StatusBadge variant="error">Revoked</StatusBadge>
<StatusBadge variant="error">{t("page.revokedStatus")}</StatusBadge>
</div>
))}
</div>

View file

@ -19,6 +19,7 @@ import {
utilityStyles,
} from "../styles/shared";
import { validateProfileFields } from "../utils/validation";
import { useTranslation } from "../hooks/useTranslation";
// Use generated type from OpenAPI schema
type ProfileData = components["schemas"]["ProfileResponse"];
@ -41,6 +42,7 @@ function toFormData(data: ProfileData): FormData {
}
export default function ProfilePage() {
const t = useTranslation("profile");
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.MANAGE_OWN_PROFILE,
fallbackRedirect: "/admin/trades",
@ -88,7 +90,7 @@ export default function ProfilePage() {
setGodfatherEmail(data.godfather_email ?? null);
} catch (err) {
console.error("Profile load error:", err);
setToast({ message: "Failed to load profile", type: "error" });
setToast({ message: t("messages.loadError"), type: "error" });
} finally {
setIsLoadingProfile(false);
}
@ -141,16 +143,16 @@ export default function ProfilePage() {
const formValues = toFormData(data);
setFormData(formValues);
setOriginalData(formValues);
setToast({ message: "Profile saved successfully!", type: "success" });
setToast({ message: t("messages.saveSuccess"), type: "success" });
} catch (err) {
console.error("Profile save error:", err);
const fieldErrors = extractFieldErrors(err);
if (fieldErrors?.detail?.field_errors) {
setErrors(fieldErrors.detail.field_errors);
setToast({ message: "Please fix the errors below", type: "error" });
setToast({ message: t("messages.fixErrors"), type: "error" });
} else {
setToast({
message: extractApiErrorMessage(err, "Network error. Please try again."),
message: extractApiErrorMessage(err, t("messages.networkError")),
type: "error",
});
}
@ -181,16 +183,16 @@ export default function ProfilePage() {
<div style={layoutStyles.contentCentered}>
<div style={styles.profileCard}>
<div style={cardStyles.cardHeader}>
<h1 style={cardStyles.cardTitle}>My Profile</h1>
<p style={cardStyles.cardSubtitle}>Manage your contact information</p>
<h1 style={cardStyles.cardTitle}>{t("page.title")}</h1>
<p style={cardStyles.cardSubtitle}>{t("page.subtitle")}</p>
</div>
<form onSubmit={handleSubmit} style={formStyles.form}>
{/* Login email - read only */}
<div style={formStyles.field}>
<label style={styles.labelWithBadge}>
Login Email
<span style={utilityStyles.readOnlyBadge}>Read only</span>
{t("form.email")}
<span style={utilityStyles.readOnlyBadge}>{t("form.readOnly")}</span>
</label>
<input
type="email"
@ -198,36 +200,32 @@ export default function ProfilePage() {
style={{ ...formStyles.input, ...formStyles.inputReadOnly }}
disabled
/>
<span style={formStyles.hint}>
This is your login email and cannot be changed here.
</span>
<span style={formStyles.hint}>{t("form.emailHint")}</span>
</div>
{/* Godfather - shown if user was invited */}
{godfatherEmail && (
<div style={formStyles.field}>
<label style={styles.labelWithBadge}>
Invited By
<span style={utilityStyles.readOnlyBadge}>Read only</span>
{t("form.invitedBy")}
<span style={utilityStyles.readOnlyBadge}>{t("form.readOnly")}</span>
</label>
<div style={styles.godfatherBox}>
<span style={styles.godfatherEmail}>{godfatherEmail}</span>
</div>
<span style={formStyles.hint}>The user who invited you to join.</span>
<span style={formStyles.hint}>{t("form.invitedByHint")}</span>
</div>
)}
<div style={utilityStyles.divider} />
<p style={styles.sectionLabel}>Contact Details</p>
<p style={styles.sectionHint}>
These are for communication purposes only they won&apos;t affect your login.
</p>
<p style={styles.sectionLabel}>{t("form.contactDetails")}</p>
<p style={styles.sectionHint}>{t("form.contactDetailsHint")}</p>
{/* Contact email */}
<div style={formStyles.field}>
<label htmlFor="contact_email" style={formStyles.label}>
Contact Email
{t("form.contactEmail")}
</label>
<input
id="contact_email"
@ -238,7 +236,7 @@ export default function ProfilePage() {
...formStyles.input,
...(errors.contact_email ? formStyles.inputError : {}),
}}
placeholder="alternate@example.com"
placeholder={t("placeholders.contactEmail")}
/>
{errors.contact_email && (
<span style={formStyles.errorText}>{errors.contact_email}</span>
@ -248,7 +246,7 @@ export default function ProfilePage() {
{/* Telegram */}
<div style={formStyles.field}>
<label htmlFor="telegram" style={formStyles.label}>
Telegram
{t("form.telegram")}
</label>
<input
id="telegram"
@ -259,7 +257,7 @@ export default function ProfilePage() {
...formStyles.input,
...(errors.telegram ? formStyles.inputError : {}),
}}
placeholder="@username"
placeholder={t("placeholders.telegram")}
/>
{errors.telegram && <span style={formStyles.errorText}>{errors.telegram}</span>}
</div>
@ -267,7 +265,7 @@ export default function ProfilePage() {
{/* Signal */}
<div style={formStyles.field}>
<label htmlFor="signal" style={formStyles.label}>
Signal
{t("form.signal")}
</label>
<input
id="signal"
@ -278,7 +276,7 @@ export default function ProfilePage() {
...formStyles.input,
...(errors.signal ? formStyles.inputError : {}),
}}
placeholder="username.01"
placeholder={t("placeholders.signal")}
/>
{errors.signal && <span style={formStyles.errorText}>{errors.signal}</span>}
</div>
@ -286,7 +284,7 @@ export default function ProfilePage() {
{/* Nostr npub */}
<div style={formStyles.field}>
<label htmlFor="nostr_npub" style={formStyles.label}>
Nostr (npub)
{t("form.nostrNpub")}
</label>
<input
id="nostr_npub"
@ -297,7 +295,7 @@ export default function ProfilePage() {
...formStyles.input,
...(errors.nostr_npub ? formStyles.inputError : {}),
}}
placeholder="npub1..."
placeholder={t("placeholders.nostrNpub")}
/>
{errors.nostr_npub && <span style={formStyles.errorText}>{errors.nostr_npub}</span>}
</div>
@ -311,7 +309,7 @@ export default function ProfilePage() {
}}
disabled={!canSubmit}
>
{isSubmitting ? "Saving..." : "Save Changes"}
{isSubmitting ? t("form.saving") : t("form.saveChanges")}
</button>
</form>
</div>

View file

@ -15,9 +15,12 @@ import { useMutation } from "../hooks/useMutation";
import { formatDateTime } from "../utils/date";
import { formatEur } from "../utils/exchange";
import { typographyStyles, tradeCardStyles } from "../styles/shared";
import { useTranslation } from "../hooks/useTranslation";
export default function TradesPage() {
const router = useRouter();
const t = useTranslation("trades");
const tExchange = useTranslation("exchange");
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_OWN_EXCHANGES,
fallbackRedirect: "/",
@ -70,17 +73,17 @@ export default function TradesPage() {
error={error}
contentStyle={styles.content}
>
<h1 style={typographyStyles.pageTitle}>My Trades</h1>
<p style={typographyStyles.pageSubtitle}>View and manage your Bitcoin trades</p>
<h1 style={typographyStyles.pageTitle}>{t("page.title")}</h1>
<p style={typographyStyles.pageSubtitle}>{t("page.subtitle")}</p>
{isLoadingTrades ? (
<EmptyState message="Loading trades..." isLoading={true} />
<EmptyState message={t("page.loadingTrades")} isLoading={true} />
) : (trades?.length ?? 0) === 0 ? (
<EmptyState
message="You don't have any trades yet."
message={t("page.noTrades")}
action={
<a href="/exchange" style={styles.emptyStateLink}>
Start trading
{t("page.startTrading")}
</a>
}
/>
@ -89,7 +92,9 @@ export default function TradesPage() {
{/* Upcoming Trades */}
{upcomingTrades.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Upcoming ({upcomingTrades.length})</h2>
<h2 style={styles.sectionTitle}>
{t("page.upcoming", { count: upcomingTrades.length })}
</h2>
<div style={tradeCardStyles.tradeList}>
{upcomingTrades.map((trade) => {
const isBuy = trade.direction === "buy";
@ -110,7 +115,7 @@ export default function TradesPage() {
color: isBuy ? "#4ade80" : "#f87171",
}}
>
{isBuy ? "BUY BTC" : "SELL BTC"}
{isBuy ? tExchange("direction.buy") : tExchange("direction.sell")}
</span>
<span
style={{
@ -120,8 +125,8 @@ export default function TradesPage() {
}}
>
{isBuy
? `Receive via ${trade.bitcoin_transfer_method === "onchain" ? "Onchain" : "Lightning"}`
: `Send via ${trade.bitcoin_transfer_method === "onchain" ? "Onchain" : "Lightning"}`}
? `${tExchange("bookingStep.receiveVia")} ${trade.bitcoin_transfer_method === "onchain" ? tExchange("transferMethod.onchain") : tExchange("transferMethod.lightning")}`
: `${tExchange("bookingStep.sendVia")} ${trade.bitcoin_transfer_method === "onchain" ? tExchange("transferMethod.onchain") : tExchange("transferMethod.lightning")}`}
</span>
<span style={tradeCardStyles.amount}>
{formatEur(trade.eur_amount)}
@ -132,7 +137,7 @@ export default function TradesPage() {
</span>
</div>
<div style={tradeCardStyles.rateRow}>
<span style={tradeCardStyles.rateLabel}>Rate:</span>
<span style={tradeCardStyles.rateLabel}>{t("trade.rate")}</span>
<span style={tradeCardStyles.rateValue}>
{trade.agreed_price_eur.toLocaleString("de-DE", {
@ -153,7 +158,7 @@ export default function TradesPage() {
onConfirm={() => handleCancel(trade.public_id)}
onCancel={() => setConfirmCancelId(null)}
onActionClick={() => setConfirmCancelId(trade.public_id)}
actionLabel="Cancel"
actionLabel={t("trade.cancel")}
isLoading={cancellingId === trade.public_id}
confirmVariant="danger"
confirmButtonStyle={styles.confirmButton}
@ -166,7 +171,7 @@ export default function TradesPage() {
}}
style={styles.viewDetailsButton}
>
View Details
{t("trade.viewDetails")}
</button>
</div>
</div>
@ -181,7 +186,7 @@ export default function TradesPage() {
{pastOrFinalTrades.length > 0 && (
<div style={styles.section}>
<h2 style={typographyStyles.sectionTitleMuted}>
History ({pastOrFinalTrades.length})
{t("page.history", { count: pastOrFinalTrades.length })}
</h2>
<div style={tradeCardStyles.tradeList}>
{pastOrFinalTrades.map((trade) => {

View file

@ -8,10 +8,70 @@
},
"direction": {
"buy": "COMPRAR BTC",
"sell": "VENDRE BTC"
"sell": "VENDRE BTC",
"buyShort": "Comprar BTC",
"sellShort": "Vendre BTC"
},
"transferMethod": {
"onchain": "Onchain",
"lightning": "Lightning"
},
"page": {
"title": "Intercanviar Bitcoin",
"subtitle": "Compra o ven Bitcoin amb una prima del 5%",
"viewExistingTrade": "Veure la teva operació existent →"
},
"steps": {
"details": "Detalls de l'Intercanvi",
"booking": "Reservar Cita",
"confirm": "Confirmar"
},
"detailsStep": {
"paymentMethod": "Mètode de Pagament",
"required": "*",
"lightningThreshold": "Els pagaments Lightning només estan disponibles per importants fins a €{max}",
"amount": "Quantitat (EUR)",
"summaryBuy": "Compres {sats}, vens {eur}",
"summarySell": "Compres {eur}, vens {sats}",
"continueToBooking": "Continuar a Reserva"
},
"bookingStep": {
"yourExchange": "El Teu Intercanvi",
"edit": "Editar",
"buy": "Comprar",
"sell": "Vendre",
"receiveVia": "Rebre via",
"sendVia": "Enviar via",
"selectDate": "Seleccionar una Data",
"existingTradeWarning": "Ja tens una operació reservada en aquest dia. Només pots reservar una operació per dia.",
"viewExistingTrade": "Veure la teva operació existent →",
"availableSlots": "Espais Disponibles per a",
"loadingSlots": "Carregant espais...",
"noSlots": "No hi ha espais disponibles per a aquesta data"
},
"confirmationStep": {
"appointment": "Cita",
"edit": "Editar",
"confirmTrade": "Confirmar Operació",
"time": "Hora:",
"direction": "Direcció:",
"eur": "EUR:",
"btc": "BTC:",
"rate": "Taxa:",
"payment": "Pagament:",
"receiveVia": "Rebre via",
"sendVia": "Enviar via",
"booking": "Reservant...",
"priceStale": "Preu Desactualitzat",
"confirmBuy": "Confirmar Compra",
"confirmSell": "Confirmar Venda",
"back": "Enrere"
},
"priceDisplay": {
"loading": "Carregant preu...",
"market": "Mercat:",
"ourPrice": "El nostre preu:",
"updated": "Actualitzat",
"stale": "(desactualitzat)"
}
}

View file

@ -0,0 +1,17 @@
{
"page": {
"title": "Les Meves Invitacions",
"subtitle": "Comparteix els teus codis d'invitació amb amics perquè s'uneixin",
"noInvites": "Encara no tens invitacions.",
"noInvitesHint": "Contacta amb un administrador si necessites codis d'invitació per compartir.",
"available": "Disponibles ({count})",
"availableHint": "Comparteix aquests enllaços amb les persones que vulguis convidar",
"used": "Usades ({count})",
"revoked": "Revocades ({count})",
"copyLink": "Copiar Enllaç",
"copied": "Copiat!",
"usedBy": "per {email}",
"usedStatus": "Usada",
"revokedStatus": "Revocada"
}
}

View file

@ -0,0 +1,33 @@
{
"page": {
"title": "El Meu Perfil",
"subtitle": "Gestiona la teva informació de contacte"
},
"form": {
"email": "Correu Electrònic",
"emailHint": "Aquest és el teu correu d'inici de sessió i no es pot canviar aquí.",
"invitedBy": "Convidat Per",
"invitedByHint": "L'usuari que et va convidar a unir-te.",
"readOnly": "Només lectura",
"contactDetails": "Detalls de Contacte",
"contactDetailsHint": "Aquests són només per a fins de comunicació — no afectaran el teu inici de sessió.",
"contactEmail": "Correu de Contacte",
"telegram": "Telegram",
"signal": "Signal",
"nostrNpub": "Nostr (npub)",
"saving": "Desant...",
"saveChanges": "Desar Canvis"
},
"messages": {
"loadError": "Error en carregar el perfil",
"saveSuccess": "Perfil desat amb èxit!",
"fixErrors": "Si us plau, corregeix els errors a continuació",
"networkError": "Error de xarxa. Si us plau, intenta-ho de nou."
},
"placeholders": {
"contactEmail": "alternate@example.com",
"telegram": "@username",
"signal": "username.01",
"nostrNpub": "npub1..."
}
}

View file

@ -0,0 +1,16 @@
{
"page": {
"title": "Les Meves Operacions",
"subtitle": "Veure i gestionar les teves operacions de Bitcoin",
"loadingTrades": "Carregant operacions...",
"noTrades": "Encara no tens operacions.",
"startTrading": "Començar a operar",
"upcoming": "Properes ({count})",
"history": "Historial ({count})"
},
"trade": {
"rate": "Taxa:",
"cancel": "Cancel·lar",
"viewDetails": "Veure Detalls"
}
}

View file

@ -8,10 +8,70 @@
},
"direction": {
"buy": "BUY BTC",
"sell": "SELL BTC"
"sell": "SELL BTC",
"buyShort": "Buy BTC",
"sellShort": "Sell BTC"
},
"transferMethod": {
"onchain": "Onchain",
"lightning": "Lightning"
},
"page": {
"title": "Exchange Bitcoin",
"subtitle": "Buy or sell Bitcoin with a 5% premium",
"viewExistingTrade": "View your existing trade →"
},
"steps": {
"details": "Exchange Details",
"booking": "Book Appointment",
"confirm": "Confirm"
},
"detailsStep": {
"paymentMethod": "Payment Method",
"required": "*",
"lightningThreshold": "Lightning payments are only available for amounts up to €{max}",
"amount": "Amount (EUR)",
"summaryBuy": "You buy {sats}, you sell {eur}",
"summarySell": "You buy {eur}, you sell {sats}",
"continueToBooking": "Continue to Booking"
},
"bookingStep": {
"yourExchange": "Your Exchange",
"edit": "Edit",
"buy": "Buy",
"sell": "Sell",
"receiveVia": "Receive via",
"sendVia": "Send via",
"selectDate": "Select a Date",
"existingTradeWarning": "You already have a trade booked on this day. You can only book one trade per day.",
"viewExistingTrade": "View your existing trade →",
"availableSlots": "Available Slots for",
"loadingSlots": "Loading slots...",
"noSlots": "No available slots for this date"
},
"confirmationStep": {
"appointment": "Appointment",
"edit": "Edit",
"confirmTrade": "Confirm Trade",
"time": "Time:",
"direction": "Direction:",
"eur": "EUR:",
"btc": "BTC:",
"rate": "Rate:",
"payment": "Payment:",
"receiveVia": "Receive via",
"sendVia": "Send via",
"booking": "Booking...",
"priceStale": "Price Stale",
"confirmBuy": "Confirm Buy",
"confirmSell": "Confirm Sell",
"back": "Back"
},
"priceDisplay": {
"loading": "Loading price...",
"market": "Market:",
"ourPrice": "Our price:",
"updated": "Updated",
"stale": "(stale)"
}
}

View file

@ -0,0 +1,17 @@
{
"page": {
"title": "My Invites",
"subtitle": "Share your invite codes with friends to let them join",
"noInvites": "You don't have any invites yet.",
"noInvitesHint": "Contact an admin if you need invite codes to share.",
"available": "Available ({count})",
"availableHint": "Share these links with people you want to invite",
"used": "Used ({count})",
"revoked": "Revoked ({count})",
"copyLink": "Copy Link",
"copied": "Copied!",
"usedBy": "by {email}",
"usedStatus": "Used",
"revokedStatus": "Revoked"
}
}

View file

@ -0,0 +1,33 @@
{
"page": {
"title": "My Profile",
"subtitle": "Manage your contact information"
},
"form": {
"email": "Email",
"emailHint": "This is your login email and cannot be changed here.",
"invitedBy": "Invited By",
"invitedByHint": "The user who invited you to join.",
"readOnly": "Read only",
"contactDetails": "Contact Details",
"contactDetailsHint": "These are for communication purposes only — they won't affect your login.",
"contactEmail": "Contact Email",
"telegram": "Telegram",
"signal": "Signal",
"nostrNpub": "Nostr (npub)",
"saving": "Saving...",
"saveChanges": "Save Changes"
},
"messages": {
"loadError": "Failed to load profile",
"saveSuccess": "Profile saved successfully!",
"fixErrors": "Please fix the errors below",
"networkError": "Network error. Please try again."
},
"placeholders": {
"contactEmail": "alternate@example.com",
"telegram": "@username",
"signal": "username.01",
"nostrNpub": "npub1..."
}
}

View file

@ -0,0 +1,16 @@
{
"page": {
"title": "My Trades",
"subtitle": "View and manage your Bitcoin trades",
"loadingTrades": "Loading trades...",
"noTrades": "You don't have any trades yet.",
"startTrading": "Start trading",
"upcoming": "Upcoming ({count})",
"history": "History ({count})"
},
"trade": {
"rate": "Rate:",
"cancel": "Cancel",
"viewDetails": "View Details"
}
}

View file

@ -8,10 +8,70 @@
},
"direction": {
"buy": "COMPRAR BTC",
"sell": "VENDER BTC"
"sell": "VENDER BTC",
"buyShort": "Comprar BTC",
"sellShort": "Vender BTC"
},
"transferMethod": {
"onchain": "Onchain",
"lightning": "Lightning"
},
"page": {
"title": "Intercambiar Bitcoin",
"subtitle": "Compra o vende Bitcoin con una prima del 5%",
"viewExistingTrade": "Ver tu operación existente →"
},
"steps": {
"details": "Detalles del Intercambio",
"booking": "Reservar Cita",
"confirm": "Confirmar"
},
"detailsStep": {
"paymentMethod": "Método de Pago",
"required": "*",
"lightningThreshold": "Los pagos Lightning solo están disponibles para montos de hasta €{max}",
"amount": "Cantidad (EUR)",
"summaryBuy": "Compras {sats}, vendes {eur}",
"summarySell": "Compras {eur}, vendes {sats}",
"continueToBooking": "Continuar a Reserva"
},
"bookingStep": {
"yourExchange": "Tu Intercambio",
"edit": "Editar",
"buy": "Comprar",
"sell": "Vender",
"receiveVia": "Recibir vía",
"sendVia": "Enviar vía",
"selectDate": "Seleccionar una Fecha",
"existingTradeWarning": "Ya tienes una operación reservada en este día. Solo puedes reservar una operación por día.",
"viewExistingTrade": "Ver tu operación existente →",
"availableSlots": "Espacios Disponibles para",
"loadingSlots": "Cargando espacios...",
"noSlots": "No hay espacios disponibles para esta fecha"
},
"confirmationStep": {
"appointment": "Cita",
"edit": "Editar",
"confirmTrade": "Confirmar Operación",
"time": "Hora:",
"direction": "Dirección:",
"eur": "EUR:",
"btc": "BTC:",
"rate": "Tasa:",
"payment": "Pago:",
"receiveVia": "Recibir vía",
"sendVia": "Enviar vía",
"booking": "Reservando...",
"priceStale": "Precio Desactualizado",
"confirmBuy": "Confirmar Compra",
"confirmSell": "Confirmar Venta",
"back": "Atrás"
},
"priceDisplay": {
"loading": "Cargando precio...",
"market": "Mercado:",
"ourPrice": "Nuestro precio:",
"updated": "Actualizado",
"stale": "(desactualizado)"
}
}

View file

@ -0,0 +1,17 @@
{
"page": {
"title": "Mis Invitaciones",
"subtitle": "Comparte tus códigos de invitación con amigos para que se unan",
"noInvites": "Aún no tienes invitaciones.",
"noInvitesHint": "Contacta con un administrador si necesitas códigos de invitación para compartir.",
"available": "Disponibles ({count})",
"availableHint": "Comparte estos enlaces con las personas que quieras invitar",
"used": "Usadas ({count})",
"revoked": "Revocadas ({count})",
"copyLink": "Copiar Enlace",
"copied": "¡Copiado!",
"usedBy": "por {email}",
"usedStatus": "Usada",
"revokedStatus": "Revocada"
}
}

View file

@ -0,0 +1,33 @@
{
"page": {
"title": "Mi Perfil",
"subtitle": "Gestiona tu información de contacto"
},
"form": {
"email": "Correo Electrónico",
"emailHint": "Este es tu correo de inicio de sesión y no se puede cambiar aquí.",
"invitedBy": "Invitado Por",
"invitedByHint": "El usuario que te invitó a unirte.",
"readOnly": "Solo lectura",
"contactDetails": "Detalles de Contacto",
"contactDetailsHint": "Estos son solo para fines de comunicación — no afectarán tu inicio de sesión.",
"contactEmail": "Correo de Contacto",
"telegram": "Telegram",
"signal": "Signal",
"nostrNpub": "Nostr (npub)",
"saving": "Guardando...",
"saveChanges": "Guardar Cambios"
},
"messages": {
"loadError": "Error al cargar el perfil",
"saveSuccess": "¡Perfil guardado exitosamente!",
"fixErrors": "Por favor corrige los errores a continuación",
"networkError": "Error de red. Por favor intenta de nuevo."
},
"placeholders": {
"contactEmail": "alternate@example.com",
"telegram": "@username",
"signal": "username.01",
"nostrNpub": "npub1..."
}
}

View file

@ -0,0 +1,16 @@
{
"page": {
"title": "Mis Operaciones",
"subtitle": "Ver y gestionar tus operaciones de Bitcoin",
"loadingTrades": "Cargando operaciones...",
"noTrades": "Aún no tienes operaciones.",
"startTrading": "Empezar a operar",
"upcoming": "Próximas ({count})",
"history": "Historial ({count})"
},
"trade": {
"rate": "Tasa:",
"cancel": "Cancelar",
"viewDetails": "Ver Detalles"
}
}