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:
parent
7dd13292a0
commit
246553c402
22 changed files with 559 additions and 115 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
frontend/locales/ca/invites.json
Normal file
17
frontend/locales/ca/invites.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
frontend/locales/ca/profile.json
Normal file
33
frontend/locales/ca/profile.json
Normal 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..."
|
||||
}
|
||||
}
|
||||
16
frontend/locales/ca/trades.json
Normal file
16
frontend/locales/ca/trades.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
frontend/locales/en/invites.json
Normal file
17
frontend/locales/en/invites.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
frontend/locales/en/profile.json
Normal file
33
frontend/locales/en/profile.json
Normal 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..."
|
||||
}
|
||||
}
|
||||
16
frontend/locales/en/trades.json
Normal file
16
frontend/locales/en/trades.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
frontend/locales/es/invites.json
Normal file
17
frontend/locales/es/invites.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
frontend/locales/es/profile.json
Normal file
33
frontend/locales/es/profile.json
Normal 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..."
|
||||
}
|
||||
}
|
||||
16
frontend/locales/es/trades.json
Normal file
16
frontend/locales/es/trades.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue