Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { getChatInfrastructure } from "./lib/chat-infrastructure.js";
import { juryArbitrationRoutes } from "./routes/jury-arbitration.js";
import { webhookRoutes } from "./routes/webhooks.js";
import { WebhookDeliveryStore } from "./lib/webhookDeliveryStore.js";
import { userPreferencesRoutes } from "./routes/user-preferences.js";

const MAX_PAYMENTS_CACHE = 10000;
const usedPayments = new Map<string, number>();
Expand Down Expand Up @@ -472,3 +473,6 @@ app.register(webhookRoutes, {
// (#404) Decentralized Jury Dispute Arbitration: commit-reveal voting,
// VRF juror selection, and automated escrow resolution with stake slashing.
app.register(juryArbitrationRoutes, { prefix: "/api/v1" });
// (#435) Multi-Lingual i18n & RTL support: persists per-provider locale and
// display currency preferences to provider_profiles.
app.register(userPreferencesRoutes, { prefix: "/api/v1" });
11 changes: 11 additions & 0 deletions apps/api/src/db/migrations/033_add_user_locale_preferences.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Migration: 033_add_user_locale_preferences.sql
-- Adds per-user locale and currency preferences to provider_profiles.
-- Both columns default to the current global defaults so existing rows
-- are unaffected and no back-fill is required.

ALTER TABLE provider_profiles
ADD COLUMN IF NOT EXISTS preferred_locale VARCHAR(10) NOT NULL DEFAULT 'en',
ADD COLUMN IF NOT EXISTS preferred_currency VARCHAR(5) NOT NULL DEFAULT 'USD';

COMMENT ON COLUMN provider_profiles.preferred_locale IS 'BCP-47 language tag chosen by the provider (e.g. en, es, fr, ar, pt).';
COMMENT ON COLUMN provider_profiles.preferred_currency IS 'ISO 4217 currency code used to format cash amounts (e.g. USD, EUR, ARS, BRL).';
88 changes: 88 additions & 0 deletions apps/api/src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
{
"errors": {
"invalidRequest": "طلب غير صالح",
"notFound": "الطلب غير موجود",
"internalError": "خطأ داخلي في الخادم",
"rateLimited": "تم تجاوز حد المعدل. لقد أرسلت طلبات كثيرة جداً في {{after}}. يرجى الانتظار قبل إعادة المحاولة.",
"tooManyRequests": "طلبات كثيرة جداً",
"paymentRequired": "الدفع مطلوب",
"paymentAlreadyUsed": "الدفع مستخدم بالفعل",
"paymentNotSuccessful": "معاملة الدفع غير ناجحة",
"invalidPaymentMemo": "مذكرة الدفع غير صالحة",
"invalidPaymentTransaction": "معاملة الدفع غير صالحة",
"noValidPayment": "المعاملة لا تحتوي على دفع صالح",
"onlyParticipants": "فقط المشاركون يمكنهم الطعن في صفقة",
"notParticipant": "لست مشاركاً في هذه الصفقة",
"tradeNotFound": "الصفقة غير موجودة",
"chatOnlyWhileLocked": "الدردشة متاحة فقط أثناء قفل الصفقة",
"tradeNoLongerActive": "الصفقة لم تعد نشطة",
"invalidJSON": "JSON غير صالح",
"invalidCoordinates": "إحداثيات رقمية أو نصف قطر غير صالح",
"precisionRange": "يجب أن تكون الدقة عدداً صحيحاً بين 4 و 8",
"nameLatLngRequired": "الاسم والعرض (رقم) والطول (رقم) مطلوبة",
"registrationLimit": "تم تجاوز حد التسجيل لهذه الشبكة أو الجهاز",
"contactInfoRequired": "معلومات الاتصال مطلوبة عند تحديد نوع الإشعار",
"invalidEmail": "تنسيق عنوان البريد الإلكتروني غير صالح",
"invalidPhone": "تنسيق رقم الهاتف غير صالح",
"modeMustBe": "يجب أن يكون الوضع 'custodial' أو 'non_custodial'",
"signedXdrRequired": "XDR موقع مطلوب",
"wrongStatus": "الطلب بالفعل في حالة {{status}}",
"wrongStatusExpected": "الطلب في حالة {{status}}، المتوقع {{expected}}",
"escrowLockFailed": "فشل قفل الضمان",
"escrowReleaseFailed": "فشل الإفراج عن الضمان",
"escrowRefundFailed": "فشل استرداد الضمان",
"escrowDisputeFailed": "فشل الطعن في الضمان",
"transactionBuildFailed": "فشل بناء المعاملة",
"transactionSubmitFailed": "فشل إرسال المعاملة",
"releaseSubmissionFailed": "فشل إرسال الإفراج",
"refundSubmissionFailed": "فشل إرسال الاسترداد",
"secretOrSignedXdrRequired": "يجب توفير 'secret' أو 'signed_xdr'",
"locationRevealedOnlyAfterMatch": "يتم الكشف عن الموقع فقط بعد تأكيد التطابق (الضمان مقفل)",
"noProviderForTrade": "لا يوجد مزود مسجل لهذه الصفقة"
},
"instructions": {
"showQR": "أرِ هذا الرمز للمزود النقدي لاستلام أموالك.",
"signAndSubmit": "وقّع المعاملة بمحفظتك وأرسلها إلى النقطة النهائية المقدمة."
},
"notifications": {
"claimUpdate": "تحديث مطالبة Velo: مطالبتك {{id}} بمبلغ {{amount}} XLM/USDC قد تم {{status}}.",
"emailSubject": "تحديث مطالبة Velo: {{status}}"
},
"privacy": {
"note": "يتم تعميم المواقع إلى خلية geohash؛ لا تُكشف الإحداثيات الدقيقة إلا للتطابق المؤكد."
},
"discovery": {
"noProvidersNearby": "Velo غير متاح في هذه المنطقة بعد لأنه لا يوجد مزودو نقدية معتمدون قريبون. تحقق مرة أخرى لاحقاً."
},
"zk": {
"credentialWallet": "محفظة بيانات اعتماد ZK",
"yourCredentials": "بيانات اعتمادك",
"loadingCommitments": "جارٍ تحميل الالتزامات...",
"noCredentialsYet": "لا توجد بيانات اعتماد بعد. أصدر التزاماً للبدء.",
"commitment": "الالتزام:",
"expires": "تنتهي:",
"generateRangeProof": "إنشاء دليل النطاق",
"verifiedAttestations": "التصديقات المُتحقق منها",
"loadingAttestations": "جارٍ تحميل التصديقات...",
"noAttestationsYet": "لا توجد تصديقات مُتحقق منها بعد. أنشئ دليلاً وتحقق منه.",
"verified": "مُتحقق",
"issuer": "المُصدر:",
"secretValue": "القيمة السرية:",
"enterSecretValue": "أدخل قيمتك السرية",
"rangeMin": "الحد الأدنى للنطاق:",
"rangeMax": "الحد الأقصى للنطاق:",
"generationTime": "وقت الإنشاء:",
"ms": "ms",
"generationExceeded": "تجاوز إنشاء الدليل 1.5 ثانية",
"generating": "جارٍ الإنشاء...",
"generateAndVerifyProof": "إنشاء الدليل والتحقق منه",
"attestationQRCode": "رمز QR للتصديق",
"scanToVerify": "امسح للتحقق من بيانات الاعتماد",
"generatingQRCode": "جارٍ إنشاء رمز QR...",
"attestationExpired": "انتهت صلاحية هذا التصديق",
"copied": "تم النسخ!",
"copyAttestation": "نسخ التصديق",
"download": "تنزيل",
"id": "المعرف:"
}
}
88 changes: 88 additions & 0 deletions apps/api/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
{
"errors": {
"invalidRequest": "Requête invalide",
"notFound": "Requête introuvable",
"internalError": "Erreur interne du serveur",
"rateLimited": "Limite de débit dépassée. Vous avez envoyé trop de requêtes en {{after}}. Veuillez patienter avant de réessayer.",
"tooManyRequests": "Trop de requêtes",
"paymentRequired": "Paiement requis",
"paymentAlreadyUsed": "Paiement déjà utilisé",
"paymentNotSuccessful": "Transaction de paiement non réussie",
"invalidPaymentMemo": "Mémo de paiement invalide",
"invalidPaymentTransaction": "Transaction de paiement invalide",
"noValidPayment": "La transaction ne contient pas de paiement valide",
"onlyParticipants": "Seuls les participants peuvent contester un échange",
"notParticipant": "Vous n'êtes pas participant de cet échange",
"tradeNotFound": "Échange introuvable",
"chatOnlyWhileLocked": "Le chat n'est disponible que lorsque l'échange est verrouillé",
"tradeNoLongerActive": "L'échange n'est plus actif",
"invalidJSON": "JSON invalide",
"invalidCoordinates": "Coordonnées numériques ou rayon non valides",
"precisionRange": "La précision doit être un entier entre 4 et 8",
"nameLatLngRequired": "Le nom, lat (nombre) et lng (nombre) sont requis",
"registrationLimit": "Limite d'inscription dépassée pour ce réseau ou cet appareil",
"contactInfoRequired": "Les informations de contact sont requises lorsque le type de notification est spécifié",
"invalidEmail": "Format d'adresse e-mail invalide",
"invalidPhone": "Format de numéro de téléphone invalide",
"modeMustBe": "Le mode doit être 'custodial' ou 'non_custodial'",
"signedXdrRequired": "XDR signé requis",
"wrongStatus": "La requête est déjà {{status}}",
"wrongStatusExpected": "La requête est au statut {{status}}, attendu {{expected}}",
"escrowLockFailed": "Échec du verrouillage de l'entiercement",
"escrowReleaseFailed": "Échec de la libération de l'entiercement",
"escrowRefundFailed": "Échec du remboursement de l'entiercement",
"escrowDisputeFailed": "Échec du litige de l'entiercement",
"transactionBuildFailed": "Échec de la construction de la transaction",
"transactionSubmitFailed": "Échec de la soumission de la transaction",
"releaseSubmissionFailed": "Échec de la soumission de la libération",
"refundSubmissionFailed": "Échec de la soumission du remboursement",
"secretOrSignedXdrRequired": "'secret' ou 'signed_xdr' est requis",
"locationRevealedOnlyAfterMatch": "La localisation n'est révélée qu'après confirmation d'une correspondance (entiercement verrouillé)",
"noProviderForTrade": "Aucun prestataire enregistré pour cet échange"
},
"instructions": {
"showQR": "Montrez ce QR au prestataire de liquidités pour recevoir votre argent.",
"signAndSubmit": "Signez la transaction avec votre portefeuille et soumettez-la à l'endpoint fourni."
},
"notifications": {
"claimUpdate": "Mise à jour de réclamation Velo : Votre réclamation {{id}} pour {{amount}} XLM/USDC a été {{status}}.",
"emailSubject": "Mise à jour de réclamation Velo : {{status}}"
},
"privacy": {
"note": "Les localisations sont généralisées à une cellule geohash ; les coordonnées exactes ne sont révélées qu'à une correspondance confirmée."
},
"discovery": {
"noProvidersNearby": "Velo n'est pas encore disponible dans cette zone car il n'y a pas de prestataires de liquidités approuvés à proximité. Revenez plus tard."
},
"zk": {
"credentialWallet": "Portefeuille de Justificatifs ZK",
"yourCredentials": "Vos Justificatifs",
"loadingCommitments": "Chargement des engagements...",
"noCredentialsYet": "Pas encore de justificatifs. Émettez un engagement pour commencer.",
"commitment": "Engagement :",
"expires": "Expire :",
"generateRangeProof": "Générer une Preuve de Plage",
"verifiedAttestations": "Attestations Vérifiées",
"loadingAttestations": "Chargement des attestations...",
"noAttestationsYet": "Pas encore d'attestations vérifiées. Générez et vérifiez une preuve.",
"verified": "Vérifié",
"issuer": "Émetteur :",
"secretValue": "Valeur Secrète :",
"enterSecretValue": "Entrez votre valeur secrète",
"rangeMin": "Min. de Plage :",
"rangeMax": "Max. de Plage :",
"generationTime": "Temps de génération :",
"ms": "ms",
"generationExceeded": "La génération de preuve a dépassé 1,5 seconde",
"generating": "Génération...",
"generateAndVerifyProof": "Générer et Vérifier la Preuve",
"attestationQRCode": "QR Code d'Attestation",
"scanToVerify": "Scannez pour vérifier le justificatif",
"generatingQRCode": "Génération du QR code...",
"attestationExpired": "Cette attestation a expiré",
"copied": "Copié !",
"copyAttestation": "Copier l'Attestation",
"download": "Télécharger",
"id": "ID :"
}
}
88 changes: 88 additions & 0 deletions apps/api/src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
{
"errors": {
"invalidRequest": "Requisição inválida",
"notFound": "Requisição não encontrada",
"internalError": "Erro interno do servidor",
"rateLimited": "Limite de taxa excedido. Você enviou muitas requisições em {{after}}. Por favor, aguarde antes de tentar novamente.",
"tooManyRequests": "Muitas requisições",
"paymentRequired": "Pagamento necessário",
"paymentAlreadyUsed": "Pagamento já utilizado",
"paymentNotSuccessful": "Transação de pagamento não bem-sucedida",
"invalidPaymentMemo": "Memo de pagamento inválido",
"invalidPaymentTransaction": "Transação de pagamento inválida",
"noValidPayment": "A transação não contém um pagamento válido",
"onlyParticipants": "Somente participantes podem contestar uma negociação",
"notParticipant": "Você não é participante desta negociação",
"tradeNotFound": "Negociação não encontrada",
"chatOnlyWhileLocked": "O chat está disponível somente enquanto a negociação estiver bloqueada",
"tradeNoLongerActive": "A negociação não está mais ativa",
"invalidJSON": "JSON inválido",
"invalidCoordinates": "Coordenadas numéricas ou raio inválidos",
"precisionRange": "A precisão deve ser um inteiro entre 4 e 8",
"nameLatLngRequired": "Nome, lat (número) e lng (número) são obrigatórios",
"registrationLimit": "Limite de registro excedido para esta rede ou dispositivo",
"contactInfoRequired": "Informações de contato são necessárias quando o tipo de notificação é especificado",
"invalidEmail": "Formato de endereço de e-mail inválido",
"invalidPhone": "Formato de número de telefone inválido",
"modeMustBe": "O modo deve ser 'custodial' ou 'non_custodial'",
"signedXdrRequired": "XDR assinado é obrigatório",
"wrongStatus": "A requisição já está {{status}}",
"wrongStatusExpected": "A requisição está no status {{status}}, esperado {{expected}}",
"escrowLockFailed": "Falha ao bloquear o escrow",
"escrowReleaseFailed": "Falha ao liberar o escrow",
"escrowRefundFailed": "Falha ao reembolsar o escrow",
"escrowDisputeFailed": "Falha ao contestar o escrow",
"transactionBuildFailed": "Falha ao construir a transação",
"transactionSubmitFailed": "Falha ao enviar a transação",
"releaseSubmissionFailed": "Falha ao enviar a liberação",
"refundSubmissionFailed": "Falha ao enviar o reembolso",
"secretOrSignedXdrRequired": "'secret' ou 'signed_xdr' é obrigatório",
"locationRevealedOnlyAfterMatch": "A localização é revelada somente após a confirmação de uma correspondência (escrow bloqueado)",
"noProviderForTrade": "Nenhum provedor registrado para esta negociação"
},
"instructions": {
"showQR": "Mostre este QR ao provedor de dinheiro para receber seu dinheiro.",
"signAndSubmit": "Assine a transação com sua carteira e envie para o endpoint fornecido."
},
"notifications": {
"claimUpdate": "Atualização de solicitação Velo: Sua solicitação {{id}} de {{amount}} XLM/USDC foi {{status}}.",
"emailSubject": "Atualização de solicitação Velo: {{status}}"
},
"privacy": {
"note": "As localizações são generalizadas para uma célula geohash; as coordenadas exatas são reveladas somente a uma correspondência confirmada."
},
"discovery": {
"noProvidersNearby": "O Velo ainda não está disponível nesta área porque não há provedores de dinheiro aprovados por perto. Verifique novamente mais tarde."
},
"zk": {
"credentialWallet": "Carteira de Credenciais ZK",
"yourCredentials": "Suas Credenciais",
"loadingCommitments": "Carregando compromissos...",
"noCredentialsYet": "Sem credenciais ainda. Emita um compromisso para começar.",
"commitment": "Compromisso:",
"expires": "Expira:",
"generateRangeProof": "Gerar Prova de Intervalo",
"verifiedAttestations": "Atestações Verificadas",
"loadingAttestations": "Carregando atestações...",
"noAttestationsYet": "Sem atestações verificadas ainda. Gere e verifique uma prova.",
"verified": "Verificado",
"issuer": "Emissor:",
"secretValue": "Valor Secreto:",
"enterSecretValue": "Insira seu valor secreto",
"rangeMin": "Mín. do Intervalo:",
"rangeMax": "Máx. do Intervalo:",
"generationTime": "Tempo de geração:",
"ms": "ms",
"generationExceeded": "A geração da prova excedeu 1,5 segundos",
"generating": "Gerando...",
"generateAndVerifyProof": "Gerar e Verificar Prova",
"attestationQRCode": "QR Code de Atestação",
"scanToVerify": "Escaneie para verificar a credencial",
"generatingQRCode": "Gerando QR code...",
"attestationExpired": "Esta atestação expirou",
"copied": "Copiado!",
"copyAttestation": "Copiar Atestação",
"download": "Baixar",
"id": "ID:"
}
}
16 changes: 12 additions & 4 deletions apps/api/src/lib/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import en from "../i18n/locales/en.json" with { type: "json" };
import es from "../i18n/locales/es.json" with { type: "json" };
import fr from "../i18n/locales/fr.json" with { type: "json" };
import ar from "../i18n/locales/ar.json" with { type: "json" };
import pt from "../i18n/locales/pt.json" with { type: "json" };

export type Locale = "en" | "es";
export type Locale = "en" | "es" | "fr" | "ar" | "pt";

const translations: Record<Locale, typeof en> = { en, es };
const translations: Record<Locale, typeof en> = { en, es, fr, ar, pt };

/**
* Resolve the best locale from an Accept-Language header.
Expand All @@ -23,6 +26,9 @@ export function resolveLocale(acceptLanguage: string | undefined): Locale {

for (const { tag } of locales) {
if (tag === "es") return "es";
if (tag === "fr") return "fr";
if (tag === "ar") return "ar";
if (tag === "pt") return "pt";
if (tag === "en") return "en";
}
return "en";
Expand All @@ -31,7 +37,9 @@ export function resolveLocale(acceptLanguage: string | undefined): Locale {
/**
* Get a localized string with optional interpolation.
* Usage: t("en", "errors.notFound") => "Request not found"
* Usage: t("es", "notifications.claimUpdate", { id: "abc", amount: "10.00", status: "released" })
* Usage: t("ar", "notifications.claimUpdate", { id: "abc", amount: "10.00", status: "released" })
*
* Falls back to English when a key is missing in the requested locale.
*/
export function t(
locale: Locale,
Expand All @@ -47,7 +55,7 @@ export function t(
}

if (typeof value !== "string") {
// Try fallback to English
// Fallback to English when the key is missing in the requested locale.
let fallback: any = translations.en;
for (const k of keys) {
if (fallback === undefined || fallback === null) break;
Expand Down
Loading
Loading