diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 000000000..79d0951a0 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,317 @@ +# Internationalization (i18n) and RTL + +InvoFi ships in 12 languages, three of them right-to-left. This document covers +how the system is wired, how to add a language, how to translate one, and the +rules that keep the right-to-left layouts correct. + +Implemented for [issue #227](https://github.com/Stellar-VaultLink/invofi/issues/227). + +--- + +## Supported locales + +| Tag | Language | Direction | Catalogue coverage | +| --- | --- | --- | --- | +| `en` | English | LTR | source of truth — 253 keys | +| `ar` | العربية (Arabic) | **RTL** | complete (253 / 253) | +| `he` | עברית (Hebrew) | **RTL** | application (186 / 253) | +| `fa` | فارسی (Persian) | **RTL** | application (186 / 253) | +| `de` | Deutsch | LTR | application (186 / 253) | +| `es` | Español | LTR | application (186 / 253) | +| `fr` | Français | LTR | application (186 / 253) | +| `ja` | 日本語 | LTR | application (186 / 253) | +| `ko` | 한국어 | LTR | application (186 / 253) | +| `pt` | Português | LTR | application (186 / 253) | +| `tr` | Türkçe | LTR | application (186 / 253) | +| `zh` | 中文 | LTR | application (186 / 253) | + +"Application" means every screen behind the front door — navigation, dashboard, +marketplace, portfolio, settings, statuses and error pages. The remaining 67 +keys are the `Landing` namespace: long-form marketing and FAQ prose on the +public home page. Those fall back to English, which renders correctly rather +than showing a missing-key placeholder (see [Fallback](#fallback)). Arabic is +translated end to end and is the reference pair used to verify the RTL layout +with no English left in it. + +Filling in a `Landing` namespace is a good first translation contribution — see +[Contributing a translation](#contributing-a-translation). + +--- + +## Architecture + +### No locale in the URL + +next-intl supports two modes: locale-prefixed routes (`/ar/invoices/…`) and a +locale held outside the URL. **InvoFi uses the second.** The reasoning: + +- Every application route is behind an auth or wallet gate, so per-locale URLs + buy nothing in search indexing. The only public SEO surface is `/`. +- The Supabase session middleware, `sitemap.ts`, `robots.ts` and every existing + deep link are keyed on unprefixed paths. A `[locale]` segment would fork all + of them, and every link shared before the change would need a redirect. +- Language is a **reader preference**, like the dark-mode toggle — not part of a + document's identity. + +The locale therefore lives in a cookie, `INVOFI_LOCALE`, readable by the Edge +middleware and by every server component. + +### Request flow + +``` +Request + │ + ├─ middleware.ts ────────► No INVOFI_LOCALE cookie? + │ negotiate from Accept-Language, set the cookie + │ (an existing cookie is never overwritten) + │ + ├─ i18n/request.ts ─────► getUserLocale() → cookie, else re-negotiate + │ loadMessages(locale) → .json over en.json + │ + └─ app/layout.tsx ──────► + +``` + +Because the cookie is written in middleware, the **first** HTML response already +carries the right `lang` and `dir`. An Arabic reader never sees a flash of +left-to-right English. + +### Files + +| File | Responsibility | +| --- | --- | +| `src/i18n/config.ts` | The locale registry: tags, RTL set, display names, `Accept-Language` negotiation. Dependency-free so the Edge middleware can import it. | +| `src/i18n/locale.ts` | Server actions that read and write the locale cookie. | +| `src/i18n/messages.ts` | Loads `messages/.json` and deep-merges it over English. | +| `src/i18n/request.ts` | next-intl request config. | +| `src/middleware.ts` | `Accept-Language` negotiation on first visit. | +| `src/app/layout.tsx` | Stamps `lang`/`dir`, mounts the provider. | +| `src/components/settings/LanguageSwitcher.tsx` | The picker in Settings. | +| `src/lib/intl.ts` | Locale-aware number, currency, date and relative-time formatting. | +| `src/hooks/useFormat.ts` | Binds `src/lib/intl.ts` to the reader's active locale. | +| `messages/*.json` | The catalogues. | + +### Precedence + +1. An explicit choice in **Settings → Language** (the cookie). +2. The browser's `Accept-Language` header, q-values honoured, regional tags + folded to their base language (`pt-BR` → `pt`, `zh-Hans-CN` → `zh`). +3. English. + +Once a reader has chosen a language, their browser header must never override +it again — the middleware only writes the cookie when none is set. + +### Fallback + +`loadMessages` deep-merges a translation over English, so a partial catalogue +renders translated where it can and English where it cannot. This is what makes +it safe to accept a translation PR that covers 40 keys out of 253: nothing +breaks, and the untranslated remainder is still readable. A locale listed in +`config.ts` with no file at all still renders — in English — rather than +throwing on every request. + +--- + +## Formatting + +Never build a user-visible number, currency amount or date with string +concatenation. Use `useFormat()`: + +```tsx +const format = useFormat(); + +format.currency(offer.amount, offer.currency); // 10.000 XLM (de) · 10,000 XLM (en) +format.percent(offer.interest_rate); // 5.00% (en) · %5,00 (tr) +format.date(invoice.due_date); // May 18, 2033 (en) · 2033/05/18 (ja) +format.relativeDays(3); // in 3 days (en) · بعد ٣ أيام (ar) +``` + +The differences these cover are not cosmetic: + +- **Grouping and decimals.** `1,234.50` in English is `1.234,50` in German. A + hardcoded `en-US` format does not read as ugly in Germany — it reads as a + different number. +- **Currency placement.** `$10,000` in English, `10 000 $` in French. In RTL + locales the symbol moves to the other side of the number. Only + `Intl.NumberFormat` with `style: 'currency'` gets this right. +- **Date field order.** `Aug 24, 2026` · `24 août 2026` · `2026年8月24日`. +- **Plurals.** English has two forms; Arabic has six (`zero`, `one`, `two`, + `few`, `many`, `other`); Japanese, Korean and Chinese have one. `{n} days` is + wrong in most of the languages we ship. + +`XLM` has no ISO 4217 code, so it is formatted as a locale-grouped decimal with +the ticker appended after a non-breaking space. `USDC` maps to `USD` and goes +through the currency formatter. + +### Plurals in the catalogue + +Put the plural *inside* the message, never in the component: + +```jsonc +// messages/en.json +"accruing": "Accruing across {count, plural, =0 {no active positions} one {# active position} other {# active positions}}" +``` + +```jsonc +// messages/ar.json — Arabic supplies all six of its forms +"accruing": "يتراكم لحظيًا عبر {count, plural, zero {لا مراكز نشطة} one {مركز نشط واحد} two {مركزين نشطين} few {# مراكز نشطة} many {# مركزًا نشطًا} other {# مركز نشط}}" +``` + +```jsonc +// messages/ja.json — Japanese has a single form +"accruing": "{count, plural, =0 {進行中のポジションはありません} other {# 件の進行中ポジション}}でリアルタイムに積み上がっています" +``` + +A component that writes `{count} position{count !== 1 ? 's' : ''}` cannot be +translated correctly into any of these, which is why the catalogue owns the +wording. + +--- + +## RTL + +`dir="rtl"` on `` flips text direction. It does **not** flip a layout that +positions things with physical left/right. Two rules keep RTL correct. + +### 1. Use CSS logical properties + +| Never | Always | +| --- | --- | +| `ml-2` / `mr-2` | `ms-2` / `me-2` | +| `pl-4` / `pr-4` | `ps-4` / `pe-4` | +| `left-3` / `right-3` | `start-3` / `end-3` | +| `border-l` / `border-r` | `border-s` / `border-e` | +| `rounded-l-md` / `rounded-r-md` | `rounded-s-md` / `rounded-e-md` | +| `text-left` / `text-right` | `text-start` / `text-end` | +| `space-x-4` | `gap-4` | + +`space-x-*` deserves its own note: it applies a physical `margin-left` to +sibling elements and does **not** mirror under `dir="rtl"`. Use `gap` on the +flex container instead. + +The one legitimate use of physical values is symmetric centring +(`left-1/2` paired with `-translate-x-1/2`), which is direction-independent — +converting only half of that pair breaks it. + +### 2. Mirror directional glyphs and motion + +Arrows and chevrons mean *forward* and *back*, not *right* and *left*: + +```tsx + +``` + +Anything that slides in from an edge needs the same treatment — the mobile +drawer is anchored with `end-0`, so it must slide out towards the physical edge +it sits on: + +```tsx +drawerOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full" +``` + +### 3. Pin identifiers to LTR + +Stellar addresses, contract IDs and URLs are base32/ASCII identifiers. Inside an +RTL paragraph the browser will reorder them. Pin them: + +```tsx +{contractId} +``` + +### Checking your work + +Set the language to العربية in Settings, then: + +- Does the navigation start on the right? +- Do icons sit on the correct side of their labels? +- Do chevrons and arrows point the other way? +- Are contract IDs and addresses still readable left to right? +- Does the mobile drawer slide in from the right edge? + +--- + +## Adding a language + +Three steps, no other code changes: + +1. Add the tag to `locales` in `src/i18n/config.ts`, in alphabetical order + after `en`. +2. Add its `native` and `english` display names to `localeNames`. +3. If it is right-to-left, add the tag to `rtlLocales`. +4. Create `messages/.json` — see below. + +`src/i18n/config.test.ts` fails if a locale is missing display names, and +`src/i18n/messages.test.ts` fails if it has no catalogue file. + +--- + +## Contributing a translation + +You do not need to run the app to contribute a translation. You need one file. + +1. **Pick a locale.** Either a language in the table above showing partial + coverage, or a new one (add it to `config.ts` as above). + +2. **Copy the shape from `messages/en.json`.** Keys are nested by namespace + (`Navbar`, `Dashboard`, `Portfolio`, …). Keep the key names *exactly* as + they are in English — the key is the address, only the value is translated. + +3. **Translate values only. Keep every `{placeholder}`.** + + ```jsonc + // en + "description": "Invoice {id} will be marked as Cancelled. This cannot be undone." + // de — {id} survives, word order changes freely + "description": "Rechnung {id} wird als storniert markiert. Das lässt sich nicht rückgängig machen." + ``` + + A message with `…` wraps a link. Keep the tag around whatever + text should be clickable in your language. + +4. **Use your language's real plural forms** in `{count, plural, …}` blocks. + The [CLDR plural rules](https://cldr.unicode.org/index/cldr-spec/plural-rules) + list which categories your language needs. Do not copy English's + `one`/`other` if your language has more or fewer. + +5. **Leave out what you cannot translate.** A partial file is fine and merges + over English. Do not ship a machine-translated placeholder you would not put + your name to. + +6. **Do not translate:** contract IDs, Stellar addresses, `XLM`/`USDC` tickers, + `RPC`/`Horizon`, CSV column headers (they are machine-readable), or the + product name *InvoFi*. + +7. **Verify before opening the PR:** + + ```bash + cd invofi/apps/frontend + npm test -- src/i18n/messages.test.ts + ``` + + This checks that your file introduces no unknown keys, keeps every + placeholder its English source uses, and parses as valid ICU for your + locale's plural rules. + +8. **See it in the app** (optional): + + ```bash + npm run dev + ``` + + Then Settings → Language → your language. + +Open the PR with only `messages/.json` changed (plus `config.ts` if the +language is new). Translation PRs are reviewed for correctness of meaning, not +for style. + +--- + +## Tests + +| Suite | What it covers | +| --- | --- | +| `src/i18n/config.test.ts` | Locale registry, RTL direction resolution, `Accept-Language` negotiation (q-values, regional fallback, `q=0`, unsupported languages). | +| `src/i18n/messages.test.ts` | Every locale has a file; no unknown keys; placeholders preserved; ICU structurally valid; English fallback for untranslated keys; Arabic is complete. | +| `src/i18n/icu.test.ts` | The catalogue reader itself: argument extraction that is not fooled by plural branch text, and rejection of unbalanced braces, bodiless branches, and plurals with no `other` catch-all. | +| `src/lib/intl.test.ts` | Locale-correct grouping, currency placement, date field order, plural-aware relative time, i128 stroop precision. | +| `e2e/i18n.spec.ts` | `Accept-Language` → RTL page; unsupported language → English; regional tag → base language; switching language in Settings; an explicit choice beating the browser header; locale-formatted dates on a real page. | diff --git a/invofi/apps/frontend/e2e/i18n.spec.ts b/invofi/apps/frontend/e2e/i18n.spec.ts new file mode 100644 index 000000000..e75845acd --- /dev/null +++ b/invofi/apps/frontend/e2e/i18n.spec.ts @@ -0,0 +1,133 @@ +import { test, expect, type Page } from '@playwright/test'; +import { authenticate, SMOKE_INVOICE } from './fixtures'; + +/** + * Internationalization and RTL (Issue #227). + * + * These drive the real path: the middleware negotiates a locale from the + * browser's `Accept-Language`, the root layout stamps `lang`/`dir` on ``, + * the catalogue supplies the copy, and the Settings switcher persists a + * different choice through a server action. + */ + +const LOCALE_COOKIE = 'INVOFI_LOCALE'; + +async function localeCookie(page: Page): Promise { + const cookies = await page.context().cookies(); + return cookies.find(c => c.name === LOCALE_COOKIE)?.value; +} + +test.describe('browser language detection', () => { + test('negotiates Arabic from Accept-Language and renders the page RTL', async ({ browser }) => { + const context = await browser.newContext({ locale: 'ar-EG' }); + const page = await context.newPage(); + await authenticate(page); + + await page.goto('/'); + + // The middleware persists the negotiated locale, so the very first HTML + // response is already Arabic — no left-to-right flash. + await expect(page.locator('html')).toHaveAttribute('lang', 'ar'); + await expect(page.locator('html')).toHaveAttribute('dir', 'rtl'); + expect(await localeCookie(page)).toBe('ar'); + + // The layout mirrors: computed text direction really is RTL, not just the + // attribute. + const direction = await page.evaluate(() => getComputedStyle(document.body).direction); + expect(direction).toBe('rtl'); + + await context.close(); + }); + + test('falls back from an unsupported language to English, left-to-right', async ({ browser }) => { + const context = await browser.newContext({ locale: 'is-IS' }); + const page = await context.newPage(); + await authenticate(page); + + await page.goto('/'); + + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); + await expect(page.locator('html')).toHaveAttribute('dir', 'ltr'); + + await context.close(); + }); + + test('maps a regional tag to its base language', async ({ browser }) => { + const context = await browser.newContext({ locale: 'pt-BR' }); + const page = await context.newPage(); + await authenticate(page); + + await page.goto('/'); + + await expect(page.locator('html')).toHaveAttribute('lang', 'pt'); + await expect(page.locator('html')).toHaveAttribute('dir', 'ltr'); + + await context.close(); + }); +}); + +test.describe('language switcher', () => { + test('changing the language in Settings re-renders the app in it', async ({ page }) => { + await authenticate(page); + await page.goto('/settings'); + + // English first — the page is in the default locale. + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); + await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible(); + + const switcher = page.getByTestId('language-switcher'); + await expect(switcher).toBeVisible(); + + // Pick Arabic. The server action writes the cookie, and router.refresh() + // re-renders the tree — including . + await switcher.selectOption('ar'); + + await expect(page.locator('html')).toHaveAttribute('dir', 'rtl', { timeout: 45_000 }); + await expect(page.locator('html')).toHaveAttribute('lang', 'ar'); + expect(await localeCookie(page)).toBe('ar'); + + // Copy is actually translated, not just re-laid out. + await expect(page.getByRole('heading', { name: 'الإعدادات' })).toBeVisible(); + + // The choice survives a full reload — it is a cookie, not component state. + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('lang', 'ar'); + await expect(page.locator('html')).toHaveAttribute('dir', 'rtl'); + }); + + test('an explicit choice wins over the browser language', async ({ browser }) => { + // Browser says Japanese… + const context = await browser.newContext({ locale: 'ja-JP' }); + const page = await context.newPage(); + await authenticate(page); + await page.goto('/settings'); + await expect(page.locator('html')).toHaveAttribute('lang', 'ja'); + + // …the reader picks German, and the header must not override it again. + // Selected by test id, not by label: the label itself is translated. + await page.getByTestId('language-switcher').selectOption('de'); + await expect(page.locator('html')).toHaveAttribute('lang', 'de', { timeout: 45_000 }); + + await page.goto('/settings'); + await expect(page.locator('html')).toHaveAttribute('lang', 'de'); + + await context.close(); + }); +}); + +test.describe('locale-aware formatting', () => { + test('renders amounts and dates in the active locale', async ({ browser }) => { + const context = await browser.newContext({ locale: 'de-DE' }); + const page = await context.newPage(); + await authenticate(page, { invoice: SMOKE_INVOICE }); + + await page.goto(`/invoices/${SMOKE_INVOICE.id}`); + + await expect(page.locator('html')).toHaveAttribute('lang', 'de'); + // 2 XLM on a German locale: the decimal separator and the date field order + // both differ from en-US. Assert the date, which is unambiguous. + await expect(page.getByText(/18\.\s*Mai\s*2033|18\.05\.2033/)).toBeVisible({ timeout: 20_000 }); + + await context.close(); + }); +}); diff --git a/invofi/apps/frontend/messages/ar.json b/invofi/apps/frontend/messages/ar.json new file mode 100644 index 000000000..eb6f0613d --- /dev/null +++ b/invofi/apps/frontend/messages/ar.json @@ -0,0 +1,431 @@ +{ + "Navbar": { + "dashboard": "لوحة التحكم", + "marketplace": "السوق", + "portfolio": "المحفظة", + "approvals": "الموافقات", + "wrongNetwork": "شبكة غير صحيحة", + "toggleTheme": "تبديل المظهر", + "testnet": "شبكة الاختبار", + "viewContracts": "عرض عناوين العقود الذكية", + "settings": "الإعدادات", + "signOut": "تسجيل الخروج" + }, + "Footer": { + "tagline": "تمويل الفواتير اللامركزي على ستيلر سوروبان", + "stats": "الإحصائيات", + "github": "غيت هاب", + "docs": "التوثيق", + "issues": "المشكلات", + "contractOnStellar": "العقد على ستيلر {network}:", + "viewOnStellarExpert": "عرض عقد InvoFi على Stellar Expert", + "openSource": "مفتوح المصدر" + }, + "DashboardLayout": { + "title": "لوحة التحكم", + "description": "أدر فواتيرك المسجّلة، وتابع عروض التمويل، وراقب حالة السداد." + }, + "Settings": { + "title": "الإعدادات", + "description": "أدر تفضيلات حسابك", + "profile": { + "label": "الملف الشخصي", + "hint": "عدّل اسم العرض واطّلع على تفاصيل الحساب" + }, + "language": { + "title": "اللغة", + "label": "لغة العرض", + "hint": "تغيّر لغة الواجهة، وتنسيق الأرقام والتواريخ، واتجاه النص." + }, + "network": { + "title": "الشبكة والعقود", + "label": "شبكة ستيلر", + "connected": "متصل" + }, + "account": { + "title": "الحساب", + "signOut": "تسجيل الخروج", + "signingOut": "جارٍ تسجيل الخروج…", + "signedOut": "تم تسجيل الخروج بنجاح" + }, + "contracts": { + "title": "العقود", + "rpcUrl": "عنوان RPC", + "horizonUrl": "عنوان Horizon", + "registry": "السجل", + "financing": "التمويل", + "repayment": "السداد", + "notConfigured": "غير مُهيّأ", + "copy": "نسخ", + "copied": "تم النسخ", + "explorer": "المستكشف", + "copyAria": "نسخ معرّف عقد {label}", + "explorerAria": "فتح عقد {label} في Stellar Expert", + "copyFailed": "فشل النسخ", + "copyFailedHint": "تعذّر الوصول إلى الحافظة." + } + }, + "Errors": { + "forbidden": { + "title": "الوصول ممنوع", + "description": "ليس لديك إذن للوصول إلى هذا المورد.", + "backHome": "العودة إلى الرئيسية" + }, + "notFound": { + "title": "الصفحة غير موجودة", + "description": "الصفحة التي تبحث عنها غير موجودة أو تم نقلها.", + "backHome": "العودة إلى الرئيسية" + }, + "unexpected": { + "title": "حدث خطأ ما", + "description": "وقع خطأ غير متوقع. يُرجى المحاولة مرة أخرى.", + "retry": "أعد المحاولة" + } + }, + "Status": { + "Pending": "قيد الانتظار", + "Financed": "مموَّلة", + "Repaid": "مسدَّدة", + "Overdue": "متأخرة", + "Cancelled": "ملغاة", + "Accepted": "مقبول", + "Rejected": "مرفوض", + "Defaulted": "متعثّر" + }, + "Common": { + "confirm": "تأكيد", + "cancel": "إلغاء", + "close": "إغلاق", + "save": "حفظ", + "saving": "جارٍ الحفظ…", + "loading": "جارٍ التحميل…", + "retry": "أعد المحاولة", + "back": "رجوع", + "next": "التالي", + "submit": "إرسال", + "submitting": "جارٍ الإرسال…", + "copy": "نسخ", + "copied": "تم النسخ", + "export": "تصدير", + "search": "بحث", + "filter": "تصفية", + "all": "الكل", + "none": "لا شيء", + "optional": "اختياري", + "hold": { + "start": "استمر بالضغط للتأكيد", + "almost": "استمر بالضغط قليلاً للتأكيد", + "cancelled": "تم إلغاء التأكيد" + } + }, + "Dashboard": { + "titleBusiness": "لوحة الفواتير", + "titleLender": "محفظة المموِّل", + "welcomeBack": "مرحبًا بعودتك", + "newInvoice": "فاتورة جديدة", + "yourInvoices": "فواتيرك", + "yourInvestments": "استثماراتك", + "browseMarketplace": "تصفّح السوق", + "exportCsv": "تصدير CSV", + "role": { + "business": "منشأة", + "lender": "مموِّل", + "admin": "مسؤول" + }, + "wallet": { + "title": "محفظة ستيلر", + "connected": "المحفظة متصلة. يمكنك توقيع المعاملات.", + "disconnected": "اربط محفظة ستيلر للتفاعل مع العقود." + }, + "stats": { + "totalInvoices": "إجمالي الفواتير", + "pending": "قيد الانتظار", + "financed": "مموَّلة", + "repaid": "مسدَّدة", + "activeInvestments": "الاستثمارات النشطة", + "pendingOffers": "العروض المعلّقة", + "totalYield": "إجمالي العائد" + }, + "view": { + "grid": "عرض شبكي", + "table": "عرض جدولي" + }, + "empty": { + "invoices": "لا توجد فواتير بعد.", + "createFirst": "أنشئ فاتورتك الأولى", + "investments": "لا توجد استثمارات نشطة بعد." + }, + "cancel": { + "action": "إلغاء الفاتورة", + "title": "إلغاء الفاتورة؟", + "description": "سيتم وضع علامة «ملغاة» على الفاتورة {id}. لا يمكن التراجع عن هذا.", + "confirm": "نعم، ألغِ" + } + }, + "Marketplace": { + "title": "سوق الفواتير", + "description": "تصفّح الفواتير المتاحة للتمويل وقدّم عروضًا لتحقيق عائد.", + "searchPlaceholder": "ابحث بمعرّف الفاتورة أو اسم المدين أو المُصدِر…", + "view": { + "suggested": "مقترَح لي", + "browseAll": "تصفّح الكل" + }, + "filters": { + "allStatuses": "كل الحالات", + "allCurrencies": "كل العملات" + }, + "sort": { + "label": "ترتيب الفواتير", + "newest": "الأحدث أولاً", + "oldest": "الأقدم أولاً", + "amount_desc": "المبلغ: من الأعلى إلى الأدنى", + "amount_asc": "المبلغ: من الأدنى إلى الأعلى", + "due_soonest": "تاريخ الاستحقاق: الأقرب" + }, + "empty": { + "title": "لا توجد فواتير تطابق عوامل التصفية", + "hint": "جرّب تعديل البحث أو عوامل التصفية." + }, + "clearSearch": "مسح البحث" + }, + "Portfolio": { + "title": "محفظتك", + "description": "تابع عروض التمويل والعوائد — تصل التحديثات مباشرةً", + "refresh": "تحديث", + "exportCsv": "تصدير CSV", + "stats": { + "active": "الاستثمارات النشطة", + "pending": "العروض المعلّقة", + "completed": "مكتملة", + "value": "قيمة المحفظة (دولار)" + }, + "yield": { + "estimated": "العائد المقدَّر حتى الآن: {amount}", + "accruing": "يتراكم لحظيًا عبر {count, plural, zero {لا مراكز نشطة} one {مركز نشط واحد} two {مركزين نشطين} few {# مراكز نشطة} many {# مركزًا نشطًا} other {# مركز نشط}}", + "realized": "العائد المحقَّق: {amount}", + "acrossRepaid": "عبر {count, plural, zero {لا عروض مسدَّدة} one {عرض مسدَّد واحد} two {عرضين مسدَّدين} few {# عروض مسدَّدة} many {# عرضًا مسدَّدًا} other {# عرض مسدَّد}}" + }, + "empty": { + "title": "لا توجد عروض تمويل بعد.", + "browse": "تصفّح السوق" + }, + "position": { + "days": "{count, plural, zero {بدون أيام} one {يوم واحد} two {يومان} few {# أيام} many {# يومًا} other {# يوم}}", + "funded": "مُوِّلت في {date}", + "apy": "العائد السنوي", + "earnedToDate": "المكتسب حتى الآن", + "repayment": "السداد", + "percentRepaid": "تم سداد {percent}", + "progressLabel": "تم سداد {percent} من إجمالي المستحق", + "repaidRemaining": "{repaid} مسدَّد · {remaining} متبقٍّ", + "updated": "حُدِّث {when}" + }, + "transfer": { + "title": "تحويل المركز", + "description": "ترمز رموز المركز إلى حقك في الفواتير المموَّلة (رمز واحد = وحدة أساسية واحدة من أصل المبلغ). أرسلها إلى محفظة ستيلر أخرى لتحويل المركز.", + "secondaryBoard": "تبحث عن مشترٍ؟ أدرج المركز في السوق الثانوي — تتم التسوية هنا، عبر هذا التحويل.", + "prefilled": "تمت تعبئة المبلغ من إدراجك ({amount} رمزًا). أدخل عنوان المشتري لإتمام التسوية.", + "refreshBalance": "تحديث الرصيد", + "connectWallet": "اربط محفظة لعرض المراكز وتحويلها.", + "notConfigured": "رموز المراكز غير مُهيّأة في هذا النشر بعد.", + "needsTrustline": "رموز المراكز أصول على ستيلر — أضف خط ثقة POS مرة واحدة لاستلامها وتحويلها.", + "addTrustline": "أضف خط ثقة POS", + "adding": "جارٍ الإضافة…", + "trustlineAdded": "تمت إضافة خط الثقة", + "trustlineAddedHint": "يمكن لمحفظتك الآن الاحتفاظ برموز مراكز POS.", + "trustlineFailed": "فشل خط الثقة", + "trustlineFailedHint": "تعذّر إعداد خط الثقة", + "recipientLabel": "عنوان المستلم", + "amountLabel": "المبلغ", + "available": "(المتاح: {balance})", + "transfer": "تحويل", + "transferring": "جارٍ التحويل…", + "invalidAddress": "عنوان غير صالح", + "invalidAddressHint": "أدخل عنوان ستيلر صالحًا (يبدأ بحرف G).", + "invalidAmount": "مبلغ غير صالح", + "invalidAmountHint": "أدخل مبلغًا بما لا يزيد عن {decimals} منزلة عشرية.", + "insufficient": "رصيد غير كافٍ", + "insufficientHint": "لا تملك رموز مراكز كافية لهذا التحويل.", + "recipientTrustline": "المستلم بحاجة إلى خط ثقة", + "recipientTrustlineHint": "محفظة المستلم لا تملك خط ثقة POS بعد. اطلب منه إضافته (من أي محفظة أو من هذا التطبيق) قبل التحويل.", + "transferred": "تم تحويل المركز", + "transferredHint": "تم إرسال {amount} من رموز المراكز إلى {recipient}.", + "transferFailed": "فشل التحويل", + "transferFailedHint": "فشلت المعاملة" + } + }, + "Landing": { + "hero": { + "liveOnTestnet": "يعمل الآن على شبكة اختبار ستيلر", + "titlePart1": "تمويل الفواتير،", + "titlePart2": "على السلسلة.", + "description": "تحوّل المنشآت فواتيرها غير المسدَّدة إلى أصول على سوروبان وتحصل على سيولة فورية. ويحقّق المستثمرون عائدًا بتمويل مستحقات واقعية — دون وسيط موثوق.", + "getStarted": "ابدأ الآن", + "browseMarketplace": "تصفّح السوق" + }, + "stats": { + "totalInvoices": "إجمالي الفواتير المموَّلة", + "totalVolume": "إجمالي الحجم", + "activeLenders": "المموِّلون النشطون", + "avgInterestRate": "متوسط سعر الفائدة" + }, + "howItWorks": { + "title": "كيف يعمل InvoFi", + "subtitle": "ثلاث خطوات من فاتورة غير مسدَّدة إلى محفظة مموَّلة — كلها على السلسلة، وكلها دون وسيط موثوق.", + "step1Title": "سجّل فاتورتك", + "step1Desc": "اربط محفظة ستيلر، واملأ تفاصيل الفاتورة — المبلغ والعملة وتاريخ الاستحقاق — وسكّها كأصل على سلسلة سوروبان في ثوانٍ.", + "step2Title": "يتنافس المموِّلون", + "step2Desc": "تظهر فاتورتك في السوق المباشر. يقدّم مستثمرون من أنحاء العالم عروضًا متنافسة بأسعارهم ومددهم. وأنت تختار الأفضل.", + "step3Title": "استلم التمويل وسدّد", + "step3Desc": "اقبل أفضل عرض — ينقل العقد الذكي أصل المبلغ إلى محفظتك فورًا. سدّد كاملًا أو جزئيًا وفق وتيرتك.", + "stepLabel": "الخطوة {step}" + }, + "features": { + "title": "مصمَّم للطرفين", + "forBusinesses": "للمنشآت", + "forBusinessesDesc": "حوّل الفواتير غير المسدَّدة إلى رأس مال عامل فوري — دون التنازل عن حصص أو الانتظار 90 يومًا.", + "businessPoint1": "سيولة فورية — تصل الأموال إلى محفظتك خلال دقائق من قبول العرض", + "businessPoint2": "لا حاجة إلى حساب بنكي أو تصنيف ائتماني أو ضمانات", + "businessPoint3": "دعم السداد الجزئي — سدّد وفق جدولك", + "businessPoint4": "المنافسة العالمية بين المموِّلين تخفض سعر الفائدة عليك", + "businessPoint5": "شفافية كاملة — كل عرض وكل سداد مسجَّل على السلسلة", + "registerInvoiceBtn": "سجّل فاتورة", + "forLenders": "للمموِّلين", + "forLendersDesc": "احقّق عائدًا يمكن التنبؤ به من تمويل تجاري واقعي. شروطك، وسعرك، وحدود المخاطر التي تقبلها.", + "lenderPoint1": "عائد يمكن التنبؤ به من تمويل تجاري واقعي — لا مضاربة", + "lenderPoint2": "تصفّح الفواتير وصفّها حسب المبلغ والعملة وتاريخ الاستحقاق", + "lenderPoint3": "العقد الذكي يفرض كل الشروط — بلا مخاطر وسيط", + "lenderPoint4": "تُتتبَّع السدادات الجزئية على السلسلة — تعرف انكشافك دائمًا", + "lenderPoint5": "تعرض لوحة المحفظة المراكز النشطة والعوائد والسجل" + }, + "assets": { + "title": "الأصول المدعومة", + "subtitle": "يمكن تقويم الفواتير وعروض التمويل بأي أصل ستيلر مدعوم.", + "xlmDesc": "الأصل الأصلي لشبكة ستيلر. بلا مخاطر حفظ، ورسوم دون السنت الواحد، ونهائية خلال 5 ثوانٍ.", + "usdcDesc": "العملة المستقرة من Circle المغطاة بالكامل على ستيلر عبر SEP-41. تُلغي مخاطر الصرف للفواتير المقوَّمة بالدولار." + }, + "stellar": { + "poweredBy": "مدعوم من ستيلر", + "title": "لماذا نبني على ستيلر", + "subtitle": "صُمّمت ستيلر خصيصًا للتطبيقات المالية. ويضيف سوروبان عقودًا ذكية حتمية، ورخيصة التشغيل، ومصمَّمة لنقل أموال حقيقية.", + "finalityTitle": "نهائية خلال 5 ثوانٍ", + "finalityDesc": "تؤكّد ستيلر المعاملات في ثوانٍ — تصبح الأموال نهائية فعلًا، لا «قيد المعالجة».", + "sorobanTitle": "عقود سوروبان الذكية", + "sorobanDesc": "مصمَّمة للتمويل اللامركزي. حتمية وقابلة للتدقيق وكفؤة — بلا تكاليف غاز جامحة.", + "globalTitle": "عالمي وبلا أذونات", + "globalDesc": "يمكن لأي شخص لديه محفظة ستيلر المشاركة. بلا قيود جغرافية وبلا حرّاس بوابة." + }, + "faq": { + "title": "الأسئلة الشائعة", + "q1": "هل أحتاج إلى حساب بنكي؟", + "a1": "لا. كل ما تحتاجه محفظة ستيلر — يدعم InvoFi كلًا من Freighter وLOBSTR. لا يلزم تحقّق من الهوية ولا حساب بنكي ولا فحص ائتماني لاستخدام InvoFi.", + "q2": "ما المحافظ المدعومة؟", + "a2": "يدعم InvoFi محفظة Freighter (إضافة متصفح — freighter.app) وLOBSTR (تطبيق جوال وإضافة متصفح — lobstr.co). عند النقر على «اربط المحفظة» يمكنك اختيار أيّهما. وقد تُضاف محافظ أخرى مع نمو منظومة ستيلر.", + "q3": "كيف يستردّ المموِّل أمواله؟", + "a3": "تسدّد المنشأة عبر العقد الذكي، الذي ينقل الأموال مباشرةً إلى محفظة المموِّل. والسداد الجزئي مدعوم — يتتبّع العرض قيمة amount_repaid على السلسلة حتى تسديد الرصيد كاملًا (الأصل + العائد).", + "q4": "ماذا يحدث إذا لم تسدّد المنشأة إطلاقًا؟", + "a4": "بعد مهلة سماح مدتها 7 أيام من تاريخ الاستحقاق، يمكن للمموِّل استدعاء reclaim_invoice، فيُوسَم العرض «متعثّرًا» على السلسلة. لا يحتفظ InvoFi بضمانات — وسِجل التعثّر على السلسلة إشارة دائمة وشفافة للشبكة.", + "q5": "ما العملات المدعومة؟", + "a5": "حاليًا XLM (لومنز ستيلر) وUSDC (العملة المستقرة من Circle على ستيلر). وستُضاف رموز SEP-41 أخرى مع نمو البروتوكول.", + "q6": "هل خضع العقد للتدقيق؟", + "a6": "العقد مفتوح المصدر ويعمل على شبكة اختبار ستيلر. وثمة تدقيق رسمي من طرف ثالث على خارطة الطريق قبل الإطلاق على الشبكة الرئيسية. يمكنك قراءة الشيفرة الكاملة على GitHub.", + "q7": "كيف أبدأ كمموِّل؟", + "a7": "أنشئ حسابًا، واختر دور المموِّل، واربط محفظة ستيلر (Freighter أو LOBSTR)، ثم انتقل إلى السوق. تصفّح الفواتير المفتوحة وقدّم عرض تمويل بالسعر والمدة اللذين تريدهما." + }, + "cta": { + "title": "جاهز للبدء؟", + "subtitle": "اربط محفظة ستيلر أو أنشئ حسابًا — بلا بنك، وبلا وسيط، وبلا انتظار.", + "imBusinessBtn": "أنا منشأة", + "imLenderBtn": "أنا مموِّل" + } + }, + "Invoice": { + "title": "الفاتورة", + "backToDashboard": "العودة إلى لوحة التحكم", + "print": "طباعة / تصدير PDF", + "notFound": "الفاتورة غير موجودة", + "fields": { + "amount": "المبلغ", + "currency": "العملة", + "dueDate": "تاريخ الاستحقاق", + "originator": "المُصدِر" + }, + "counterparty": { + "lender": "المموِّل", + "business": "المنشأة" + }, + "cancel": { + "action": "إلغاء", + "title": "إلغاء هذه الفاتورة؟", + "description": "ستُلغى الفاتورة على السلسلة ولن تتمكن من استقبال عروض تمويل بعد ذلك. لا يمكن التراجع عن هذا.", + "confirm": "إلغاء الفاتورة", + "done": "تم إلغاء الفاتورة", + "doneHint": "الفاتورة ملغاة الآن على السلسلة.", + "failed": "تعذّر إلغاء الفاتورة", + "undo": "تراجع", + "undoAlt": "التراجع عن الإلغاء", + "restored": "تمت استعادة الفاتورة", + "restoredHint": "أُنشئت فاتورة جديدة بالشروط نفسها.", + "restoreFailed": "تعذّرت استعادة الفاتورة" + } + }, + "Offers": { + "title": "عروض التمويل ({count})", + "empty": "لا توجد عروض بعد.", + "makeOffer": "قدّم عرضًا", + "markOverdue": "وسم كمتأخرة", + "exportHint": "تصدير العروض بصيغة CSV", + "exportEmpty": "لا توجد عروض للتصدير", + "accept": "قبول", + "reject": "رفض", + "repay": "سداد", + "reclaim": "استرداد", + "repayAmount": "مبلغ السداد", + "days": "{count, plural, zero {بدون أيام} one {يوم واحد} two {يومان} few {# أيام} many {# يومًا} other {# يوم}}", + "repaid": "{amount} مسدَّد", + "remaining": "{amount} متبقٍّ", + "remainingBalance": "الرصيد المتبقي: {remaining} (إجمالي المستحق {total} ناقص {repaid})", + "form": { + "title": "عرض تمويل جديد", + "amount": "المبلغ", + "currency": "العملة", + "interest": "الفائدة (نقاط أساس)", + "interestHint": "500 = {example}", + "duration": "المدة (أيام)", + "submit": "إرسال العرض" + }, + "confirm": { + "rejectTitle": "رفض هذا العرض؟", + "rejectDescription": "سيُبلَّغ المموِّل برفض عرضه. لا يمكن التراجع عن هذا.", + "reclaimTitle": "استرداد هذا العرض؟", + "reclaimDescription": "يوسم هذا العرض «متعثّرًا» على السلسلة. وقد دُفع أصل المبلغ للمنشأة عند القبول — هذا لا يعيد الأموال، ولا يمكن التراجع عنه." + }, + "toast": { + "submitted": "تم إرسال العرض!", + "submittedHint": "سيتم إبلاغ مُصدِر الفاتورة.", + "submitFailed": "تعذّر إرسال العرض", + "accepted": "تم قبول العرض!", + "acceptedHint": "الفاتورة الآن موسومة كمموَّلة.", + "acceptFailed": "تعذّر قبول العرض", + "rejected": "تم رفض العرض.", + "rejectFailed": "تعذّر رفض العرض", + "invalidAmount": "أدخل مبلغًا صالحًا", + "amountTooSmall": "يجب أن يكون المبلغ أكبر من صفر", + "repaidFull": "تم سداد الفاتورة بالكامل", + "repaidFullHint": "تم تحويل الأصل والعائد إلى المموِّل. الفاتورة الآن مسدَّدة.", + "repaidPartial": "تم إرسال السداد", + "repaidPartialHint": "سُجِّل سداد جزئي على السلسلة. واصل السداد حتى يُسوَّى الرصيد.", + "repayFailed": "تعذّر السداد", + "markedOverdue": "وُسمت الفاتورة كمتأخرة.", + "overdueFailed": "تعذّر وسمها كمتأخرة", + "reclaimed": "وُسم العرض كمتعثّر.", + "reclaimedHint": "هذا سجل على السلسلة — تابع التحصيل خارجها.", + "reclaimFailed": "تعذّر الاسترداد", + "undo": "تراجع", + "undoRejectAlt": "التراجع عن الرفض", + "rejectUndone": "تم التراجع عن الرفض", + "rejectUndoneHint": "العرض الآن قيد الانتظار مجددًا.", + "undoRejectFailed": "تعذّر التراجع عن الرفض" + } + } +} diff --git a/invofi/apps/frontend/messages/de.json b/invofi/apps/frontend/messages/de.json new file mode 100644 index 000000000..97eb9f20c --- /dev/null +++ b/invofi/apps/frontend/messages/de.json @@ -0,0 +1,346 @@ +{ + "Navbar": { + "dashboard": "Übersicht", + "marketplace": "Marktplatz", + "portfolio": "Portfolio", + "approvals": "Freigaben", + "wrongNetwork": "falsches Netzwerk", + "toggleTheme": "Design wechseln", + "testnet": "Testnet", + "viewContracts": "Adressen der Smart Contracts anzeigen", + "settings": "Einstellungen", + "signOut": "Abmelden" + }, + "Footer": { + "tagline": "Dezentrale Rechnungsfinanzierung auf Stellar Soroban", + "stats": "Statistiken", + "github": "GitHub", + "docs": "Dokumentation", + "issues": "Issues", + "contractOnStellar": "Contract auf Stellar {network}:", + "viewOnStellarExpert": "InvoFi-Contract in Stellar Expert ansehen", + "openSource": "Open Source" + }, + "DashboardLayout": { + "title": "Übersicht", + "description": "Verwalte deine erfassten Rechnungen, verfolge Finanzierungsangebote und behalte den Rückzahlungsstatus im Blick." + }, + "Settings": { + "title": "Einstellungen", + "description": "Verwalte deine Kontoeinstellungen", + "profile": { + "label": "Profil", + "hint": "Anzeigenamen bearbeiten und Kontodaten ansehen" + }, + "language": { + "title": "Sprache", + "label": "Anzeigesprache", + "hint": "Ändert Oberflächensprache, Zahlen- und Datumsformat sowie die Textrichtung." + }, + "network": { + "title": "Netzwerk & Contracts", + "label": "Stellar-Netzwerk", + "connected": "Verbunden" + }, + "account": { + "title": "Konto", + "signOut": "Abmelden", + "signingOut": "Wird abgemeldet…", + "signedOut": "Erfolgreich abgemeldet" + }, + "contracts": { + "title": "Contracts", + "rpcUrl": "RPC-URL", + "horizonUrl": "Horizon-URL", + "registry": "Registry", + "financing": "Finanzierung", + "repayment": "Rückzahlung", + "notConfigured": "nicht konfiguriert", + "copy": "Kopieren", + "copied": "Kopiert", + "explorer": "Explorer", + "copyAria": "Contract-ID {label} kopieren", + "explorerAria": "Contract {label} in Stellar Expert öffnen", + "copyFailed": "Kopieren fehlgeschlagen", + "copyFailedHint": "Zugriff auf die Zwischenablage nicht möglich." + } + }, + "Errors": { + "forbidden": { + "title": "Zugriff verweigert", + "description": "Du hast keine Berechtigung für diese Ressource.", + "backHome": "Zurück zur Startseite" + }, + "notFound": { + "title": "Seite nicht gefunden", + "description": "Die gesuchte Seite existiert nicht oder wurde verschoben.", + "backHome": "Zurück zur Startseite" + }, + "unexpected": { + "title": "Etwas ist schiefgelaufen", + "description": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.", + "retry": "Erneut versuchen" + } + }, + "Status": { + "Pending": "Ausstehend", + "Financed": "Finanziert", + "Repaid": "Zurückgezahlt", + "Overdue": "Überfällig", + "Cancelled": "Storniert", + "Accepted": "Angenommen", + "Rejected": "Abgelehnt", + "Defaulted": "Ausgefallen" + }, + "Common": { + "confirm": "Bestätigen", + "cancel": "Abbrechen", + "close": "Schließen", + "save": "Speichern", + "saving": "Wird gespeichert…", + "loading": "Wird geladen…", + "retry": "Erneut versuchen", + "back": "Zurück", + "next": "Weiter", + "submit": "Absenden", + "submitting": "Wird gesendet…", + "copy": "Kopieren", + "copied": "Kopiert", + "export": "Exportieren", + "search": "Suchen", + "filter": "Filtern", + "all": "Alle", + "none": "Keine", + "optional": "Optional", + "hold": { + "start": "Zum Bestätigen gedrückt halten", + "almost": "Noch etwas länger halten zum Bestätigen", + "cancelled": "Bestätigung abgebrochen" + } + }, + "Dashboard": { + "titleBusiness": "Rechnungsübersicht", + "titleLender": "Portfolio des Kapitalgebers", + "welcomeBack": "Willkommen zurück", + "newInvoice": "Neue Rechnung", + "yourInvoices": "Deine Rechnungen", + "yourInvestments": "Deine Investitionen", + "browseMarketplace": "Marktplatz durchsuchen", + "exportCsv": "CSV exportieren", + "role": { + "business": "Unternehmen", + "lender": "Kapitalgeber", + "admin": "Administrator" + }, + "wallet": { + "title": "Stellar-Wallet", + "connected": "Wallet verbunden. Du kannst Transaktionen signieren.", + "disconnected": "Verbinde deine Stellar-Wallet, um mit den Contracts zu interagieren." + }, + "stats": { + "totalInvoices": "Rechnungen gesamt", + "pending": "Ausstehend", + "financed": "Finanziert", + "repaid": "Zurückgezahlt", + "activeInvestments": "Aktive Investitionen", + "pendingOffers": "Offene Angebote", + "totalYield": "Gesamtrendite" + }, + "view": { + "grid": "Kachelansicht", + "table": "Tabellenansicht" + }, + "empty": { + "invoices": "Noch keine Rechnungen.", + "createFirst": "Erstelle deine erste Rechnung", + "investments": "Noch keine aktiven Investitionen." + }, + "cancel": { + "action": "Rechnung stornieren", + "title": "Rechnung stornieren?", + "description": "Rechnung {id} wird als storniert markiert. Das lässt sich nicht rückgängig machen.", + "confirm": "Ja, stornieren" + } + }, + "Marketplace": { + "title": "Rechnungsmarktplatz", + "description": "Durchsuche finanzierbare Rechnungen und gib Angebote ab, um Rendite zu erzielen.", + "searchPlaceholder": "Nach Rechnungs-ID, Schuldnername oder Aussteller suchen…", + "view": { + "suggested": "Für mich vorgeschlagen", + "browseAll": "Alle durchsuchen" + }, + "filters": { + "allStatuses": "Alle Status", + "allCurrencies": "Alle Währungen" + }, + "sort": { + "label": "Rechnungen sortieren", + "newest": "Neueste zuerst", + "oldest": "Älteste zuerst", + "amount_desc": "Betrag: absteigend", + "amount_asc": "Betrag: aufsteigend", + "due_soonest": "Fälligkeit: am frühesten" + }, + "empty": { + "title": "Keine Rechnung passt zu deinen Filtern", + "hint": "Passe die Suche oder die Filter an." + }, + "clearSearch": "Suche zurücksetzen" + }, + "Portfolio": { + "title": "Dein Portfolio", + "description": "Verfolge deine Finanzierungsangebote und Erträge — Aktualisierungen laufen live ein", + "refresh": "Aktualisieren", + "exportCsv": "CSV exportieren", + "stats": { + "active": "Aktive Investitionen", + "pending": "Offene Angebote", + "completed": "Abgeschlossen", + "value": "Portfoliowert (USD)" + }, + "yield": { + "estimated": "Geschätzter Ertrag bis heute: {amount}", + "accruing": "Wächst in Echtzeit über {count, plural, =0 {keine aktive Position} one {# aktive Position} other {# aktive Positionen}}", + "realized": "Realisierter Ertrag: {amount}", + "acrossRepaid": "Über {count, plural, one {# zurückgezahltes Angebot} other {# zurückgezahlte Angebote}}" + }, + "empty": { + "title": "Noch keine Finanzierungsangebote.", + "browse": "Marktplatz durchsuchen" + }, + "position": { + "days": "{count, plural, one {# Tag} other {# Tage}}", + "funded": "Finanziert am {date}", + "apy": "Jahresrendite", + "earnedToDate": "Bisher verdient", + "repayment": "Rückzahlung", + "percentRepaid": "{percent} zurückgezahlt", + "progressLabel": "{percent} der Gesamtschuld zurückgezahlt", + "repaidRemaining": "{repaid} zurückgezahlt · {remaining} offen", + "updated": "aktualisiert {when}" + }, + "transfer": { + "title": "Position übertragen", + "description": "Positions-Token verbriefen deinen Anspruch auf finanzierte Rechnungen (1 Token = 1 Basiseinheit des Kapitals). Sende sie an eine andere Stellar-Wallet, um die Position zu übertragen.", + "secondaryBoard": "Auf Käufersuche? Stelle die Position auf das Sekundärboard — die Abwicklung läuft weiterhin hier, über diese Übertragung.", + "prefilled": "Betrag aus deinem Inserat übernommen ({amount} Token). Gib die Adresse des Käufers ein, um abzuwickeln.", + "refreshBalance": "Guthaben aktualisieren", + "connectWallet": "Verbinde eine Wallet, um Positionen zu sehen und zu übertragen.", + "notConfigured": "Positions-Token sind in diesem Deployment noch nicht konfiguriert.", + "needsTrustline": "Positions-Token sind Stellar-Assets — füge einmalig eine POS-Trustline hinzu, um sie zu empfangen und zu übertragen.", + "addTrustline": "POS-Trustline hinzufügen", + "adding": "Wird hinzugefügt…", + "trustlineAdded": "Trustline hinzugefügt", + "trustlineAddedHint": "Deine Wallet kann jetzt POS-Positions-Token halten.", + "trustlineFailed": "Trustline fehlgeschlagen", + "trustlineFailedHint": "Einrichtung der Trustline fehlgeschlagen", + "recipientLabel": "Empfängeradresse", + "amountLabel": "Betrag", + "available": "(verfügbar: {balance})", + "transfer": "Übertragen", + "transferring": "Wird übertragen…", + "invalidAddress": "Ungültige Adresse", + "invalidAddressHint": "Gib eine gültige Stellar-Adresse ein (G…).", + "invalidAmount": "Ungültiger Betrag", + "invalidAmountHint": "Gib einen Betrag mit höchstens {decimals} Nachkommastellen ein.", + "insufficient": "Guthaben reicht nicht", + "insufficientHint": "Du hältst nicht genug Positions-Token für diese Übertragung.", + "recipientTrustline": "Empfänger braucht eine Trustline", + "recipientTrustlineHint": "Die Wallet des Empfängers hat noch keine POS-Trustline. Bitte ihn, vor der Übertragung eine hinzuzufügen (in einer beliebigen Wallet oder in dieser App).", + "transferred": "Position übertragen", + "transferredHint": "{amount} Positions-Token an {recipient} gesendet.", + "transferFailed": "Übertragung fehlgeschlagen", + "transferFailedHint": "Transaktion fehlgeschlagen" + } + }, + "Invoice": { + "title": "Rechnung", + "backToDashboard": "Zurück zur Übersicht", + "print": "Drucken / als PDF exportieren", + "notFound": "Rechnung nicht gefunden", + "fields": { + "amount": "Betrag", + "currency": "Währung", + "dueDate": "Fälligkeit", + "originator": "Aussteller" + }, + "counterparty": { + "lender": "Kapitalgeber", + "business": "Unternehmen" + }, + "cancel": { + "action": "Stornieren", + "title": "Diese Rechnung stornieren?", + "description": "Die Rechnung wird on-chain storniert und kann keine Finanzierungsangebote mehr erhalten. Das lässt sich nicht rückgängig machen.", + "confirm": "Rechnung stornieren", + "done": "Rechnung storniert", + "doneHint": "Die Rechnung ist jetzt on-chain storniert.", + "failed": "Stornierung der Rechnung fehlgeschlagen", + "undo": "Rückgängig", + "undoAlt": "Stornierung rückgängig machen", + "restored": "Rechnung wiederhergestellt", + "restoredHint": "Eine neue Rechnung mit denselben Konditionen wurde angelegt.", + "restoreFailed": "Wiederherstellung der Rechnung fehlgeschlagen" + } + }, + "Offers": { + "title": "Finanzierungsangebote ({count})", + "empty": "Noch keine Angebote.", + "makeOffer": "Angebot abgeben", + "markOverdue": "Als überfällig markieren", + "exportHint": "Angebote als CSV exportieren", + "exportEmpty": "Keine Angebote zum Exportieren", + "accept": "Annehmen", + "reject": "Ablehnen", + "repay": "Zurückzahlen", + "reclaim": "Einfordern", + "repayAmount": "Rückzahlungsbetrag", + "days": "{count, plural, one {# Tag} other {# Tage}}", + "repaid": "{amount} zurückgezahlt", + "remaining": "{amount} offen", + "remainingBalance": "Offener Betrag: {remaining} (Gesamtschuld {total} abzüglich {repaid})", + "form": { + "title": "Neues Finanzierungsangebot", + "amount": "Betrag", + "currency": "Währung", + "interest": "Zins (Basispunkte)", + "interestHint": "500 = {example}", + "duration": "Laufzeit (Tage)", + "submit": "Angebot absenden" + }, + "confirm": { + "rejectTitle": "Dieses Angebot ablehnen?", + "rejectDescription": "Der Kapitalgeber wird über die Ablehnung informiert. Das lässt sich nicht rückgängig machen.", + "reclaimTitle": "Dieses Angebot einfordern?", + "reclaimDescription": "Damit wird das Angebot on-chain als ausgefallen markiert. Das Kapital wurde bei der Annahme bereits an das Unternehmen ausgezahlt — das gibt keine Mittel zurück und lässt sich nicht rückgängig machen." + }, + "toast": { + "submitted": "Angebot abgegeben!", + "submittedHint": "Der Rechnungsaussteller wird benachrichtigt.", + "submitFailed": "Angebot konnte nicht abgegeben werden", + "accepted": "Angebot angenommen!", + "acceptedHint": "Die Rechnung ist jetzt als finanziert markiert.", + "acceptFailed": "Angebot konnte nicht angenommen werden", + "rejected": "Angebot abgelehnt.", + "rejectFailed": "Angebot konnte nicht abgelehnt werden", + "invalidAmount": "Gib einen gültigen Betrag ein", + "amountTooSmall": "Der Betrag muss größer als null sein", + "repaidFull": "Rechnung vollständig zurückgezahlt", + "repaidFullHint": "Kapital + Ertrag an den Kapitalgeber überwiesen. Die Rechnung ist zurückgezahlt.", + "repaidPartial": "Rückzahlung gesendet", + "repaidPartialHint": "Teilrückzahlung on-chain erfasst. Zahle weiter, bis der Saldo ausgeglichen ist.", + "repayFailed": "Rückzahlung fehlgeschlagen", + "markedOverdue": "Rechnung als überfällig markiert.", + "overdueFailed": "Markierung als überfällig fehlgeschlagen", + "reclaimed": "Angebot als ausgefallen markiert.", + "reclaimedHint": "Das ist ein On-Chain-Eintrag — die Beitreibung läuft off-chain.", + "reclaimFailed": "Einfordern fehlgeschlagen", + "undo": "Rückgängig", + "undoRejectAlt": "Ablehnung rückgängig machen", + "rejectUndone": "Ablehnung rückgängig gemacht", + "rejectUndoneHint": "Das Angebot ist wieder ausstehend.", + "undoRejectFailed": "Ablehnung konnte nicht rückgängig gemacht werden" + } + } +} diff --git a/invofi/apps/frontend/messages/en.json b/invofi/apps/frontend/messages/en.json index d5e7095d7..caa094909 100644 --- a/invofi/apps/frontend/messages/en.json +++ b/invofi/apps/frontend/messages/en.json @@ -109,5 +109,323 @@ "imBusinessBtn": "I'm a Business", "imLenderBtn": "I'm a Lender" } + }, + "Settings": { + "title": "Settings", + "description": "Manage your account preferences", + "profile": { + "label": "Profile", + "hint": "Edit your display name and view account details" + }, + "language": { + "title": "Language", + "label": "Display language", + "hint": "Changes the interface language, number and date formats, and text direction." + }, + "network": { + "title": "Network & Contracts", + "label": "Stellar Network", + "connected": "Connected" + }, + "account": { + "title": "Account", + "signOut": "Sign out", + "signingOut": "Signing out…", + "signedOut": "Signed out successfully" + }, + "contracts": { + "title": "Contracts", + "rpcUrl": "RPC URL", + "horizonUrl": "Horizon URL", + "registry": "Registry", + "financing": "Financing", + "repayment": "Repayment", + "notConfigured": "not configured", + "copy": "Copy", + "copied": "Copied", + "explorer": "Explorer", + "copyAria": "Copy {label} contract ID", + "explorerAria": "Open {label} contract on Stellar Expert", + "copyFailed": "Copy failed", + "copyFailedHint": "Could not access the clipboard." + } + }, + "Errors": { + "forbidden": { + "title": "Access forbidden", + "description": "You don’t have permission to access this resource.", + "backHome": "Back to home" + }, + "notFound": { + "title": "Page not found", + "description": "The page you’re looking for doesn’t exist or has been moved.", + "backHome": "Back to home" + }, + "unexpected": { + "title": "Something went wrong", + "description": "An unexpected error occurred. Please try again.", + "retry": "Try again" + } + }, + "Status": { + "Pending": "Pending", + "Financed": "Financed", + "Repaid": "Repaid", + "Overdue": "Overdue", + "Cancelled": "Cancelled", + "Accepted": "Accepted", + "Rejected": "Rejected", + "Defaulted": "Defaulted" + }, + "Common": { + "confirm": "Confirm", + "cancel": "Cancel", + "close": "Close", + "save": "Save", + "saving": "Saving…", + "loading": "Loading…", + "retry": "Retry", + "back": "Back", + "next": "Next", + "submit": "Submit", + "submitting": "Submitting…", + "copy": "Copy", + "copied": "Copied", + "export": "Export", + "search": "Search", + "filter": "Filter", + "all": "All", + "none": "None", + "optional": "Optional", + "hold": { + "start": "Hold to confirm", + "almost": "Hold a little longer to confirm", + "cancelled": "Confirmation cancelled" + } + }, + "Dashboard": { + "titleBusiness": "Invoice Dashboard", + "titleLender": "Lender Portfolio", + "welcomeBack": "Welcome back", + "newInvoice": "New Invoice", + "yourInvoices": "Your Invoices", + "yourInvestments": "Your Investments", + "browseMarketplace": "Browse Marketplace", + "exportCsv": "Export CSV", + "role": { + "business": "Business", + "lender": "Lender", + "admin": "Admin" + }, + "wallet": { + "title": "Stellar Wallet", + "connected": "Wallet connected. You can sign transactions.", + "disconnected": "Connect your Stellar wallet to interact with contracts." + }, + "stats": { + "totalInvoices": "Total Invoices", + "pending": "Pending", + "financed": "Financed", + "repaid": "Repaid", + "activeInvestments": "Active Investments", + "pendingOffers": "Pending Offers", + "totalYield": "Total Yield" + }, + "view": { + "grid": "Grid view", + "table": "Table view" + }, + "empty": { + "invoices": "No invoices yet.", + "createFirst": "Create your first invoice", + "investments": "No active investments yet." + }, + "cancel": { + "action": "Cancel invoice", + "title": "Cancel invoice?", + "description": "Invoice {id} will be marked as Cancelled. This cannot be undone.", + "confirm": "Yes, cancel" + } + }, + "Marketplace": { + "title": "Invoice Marketplace", + "description": "Browse invoices available for financing and submit offers to earn yield.", + "searchPlaceholder": "Search by invoice ID, debtor name, or originator…", + "view": { + "suggested": "Suggested for me", + "browseAll": "Browse all" + }, + "filters": { + "allStatuses": "All statuses", + "allCurrencies": "All currencies" + }, + "sort": { + "label": "Sort invoices", + "newest": "Newest first", + "oldest": "Oldest first", + "amount_desc": "Amount: high to low", + "amount_asc": "Amount: low to high", + "due_soonest": "Due date: soonest" + }, + "empty": { + "title": "No invoices match your filters", + "hint": "Try adjusting the search or filters." + }, + "clearSearch": "Clear search" + }, + "Portfolio": { + "title": "Your Portfolio", + "description": "Track your financing offers and returns — updates stream in live", + "refresh": "Refresh", + "exportCsv": "Export CSV", + "stats": { + "active": "Active Investments", + "pending": "Pending Offers", + "completed": "Completed", + "value": "Portfolio Value (USD)" + }, + "yield": { + "estimated": "Est. yield earned to date: {amount}", + "accruing": "Accruing in real time across {count, plural, =0 {no active positions} one {# active position} other {# active positions}}", + "realized": "Realized yield: {amount}", + "acrossRepaid": "Across {count, plural, one {# repaid offer} other {# repaid offers}}" + }, + "empty": { + "title": "No financing offers yet.", + "browse": "Browse the marketplace" + }, + "position": { + "days": "{count, plural, one {# day} other {# days}}", + "funded": "Funded {date}", + "apy": "APY", + "earnedToDate": "Earned to date", + "repayment": "Repayment", + "percentRepaid": "{percent} repaid", + "progressLabel": "{percent} of total due repaid", + "repaidRemaining": "{repaid} repaid · {remaining} remaining", + "updated": "updated {when}" + }, + "transfer": { + "title": "Transfer Position", + "description": "Position tokens represent your claim on financed invoices (1 token = 1 base unit of principal). Send them to another Stellar wallet to transfer the position.", + "secondaryBoard": "Looking for a buyer? List the position on the secondary board — settlement still happens here, with this transfer.", + "prefilled": "Amount prefilled from your listing ({amount} tokens). Enter the buyer’s address to settle.", + "refreshBalance": "Refresh balance", + "connectWallet": "Connect a wallet to view and transfer positions.", + "notConfigured": "Position tokens are not configured on this deployment yet.", + "needsTrustline": "Position tokens are Stellar assets — add a POS trustline once to receive and transfer them.", + "addTrustline": "Add POS trustline", + "adding": "Adding…", + "trustlineAdded": "Trustline added", + "trustlineAddedHint": "Your wallet can now hold POS position tokens.", + "trustlineFailed": "Trustline failed", + "trustlineFailedHint": "Trustline setup failed", + "recipientLabel": "Recipient address", + "amountLabel": "Amount", + "available": "(available: {balance})", + "transfer": "Transfer", + "transferring": "Transferring…", + "invalidAddress": "Invalid address", + "invalidAddressHint": "Enter a valid Stellar address (G…).", + "invalidAmount": "Invalid amount", + "invalidAmountHint": "Enter an amount with at most {decimals} decimal places.", + "insufficient": "Insufficient balance", + "insufficientHint": "You do not hold enough position tokens for this transfer.", + "recipientTrustline": "Recipient needs a trustline", + "recipientTrustlineHint": "The recipient wallet has no POS trustline yet. Ask them to add one (any wallet or this app) before transferring.", + "transferred": "Position transferred", + "transferredHint": "Sent {amount} position tokens to {recipient}.", + "transferFailed": "Transfer failed", + "transferFailedHint": "Transaction failed" + } + }, + "Invoice": { + "title": "Invoice", + "backToDashboard": "Back to dashboard", + "print": "Print / Export PDF", + "notFound": "Invoice not found", + "fields": { + "amount": "Amount", + "currency": "Currency", + "dueDate": "Due Date", + "originator": "Originator" + }, + "counterparty": { + "lender": "Lender", + "business": "Business" + }, + "cancel": { + "action": "Cancel", + "title": "Cancel this invoice?", + "description": "The invoice will be cancelled on-chain and can no longer receive financing offers. This cannot be undone.", + "confirm": "Cancel Invoice", + "done": "Invoice cancelled", + "doneHint": "The invoice is now cancelled on-chain.", + "failed": "Failed to cancel invoice", + "undo": "Undo", + "undoAlt": "Undo cancel", + "restored": "Invoice restored", + "restoredHint": "A new invoice with the same terms has been created.", + "restoreFailed": "Failed to restore invoice" + } + }, + "Offers": { + "title": "Financing Offers ({count})", + "empty": "No offers yet.", + "makeOffer": "Make Offer", + "markOverdue": "Mark Overdue", + "exportHint": "Export offers as CSV", + "exportEmpty": "No offers to export", + "accept": "Accept", + "reject": "Reject", + "repay": "Repay", + "reclaim": "Reclaim", + "repayAmount": "Repayment amount", + "days": "{count, plural, one {# day} other {# days}}", + "repaid": "{amount} repaid", + "remaining": "{amount} remaining", + "remainingBalance": "Remaining balance: {remaining} (total due {total} minus {repaid})", + "form": { + "title": "New Financing Offer", + "amount": "Amount", + "currency": "Currency", + "interest": "Interest (basis pts)", + "interestHint": "500 = {example}", + "duration": "Duration (days)", + "submit": "Submit Offer" + }, + "confirm": { + "rejectTitle": "Reject this offer?", + "rejectDescription": "The lender will be notified their offer was rejected. This cannot be undone.", + "reclaimTitle": "Reclaim this offer?", + "reclaimDescription": "This marks the offer Defaulted on-chain. Principal was already paid to the business at acceptance — this does not return funds, and cannot be undone." + }, + "toast": { + "submitted": "Offer submitted!", + "submittedHint": "The invoice originator will be notified.", + "submitFailed": "Failed to submit offer", + "accepted": "Offer accepted!", + "acceptedHint": "Invoice is now marked as Financed.", + "acceptFailed": "Failed to accept offer", + "rejected": "Offer rejected.", + "rejectFailed": "Failed to reject offer", + "invalidAmount": "Enter a valid amount", + "amountTooSmall": "Amount must be greater than zero", + "repaidFull": "Invoice fully repaid", + "repaidFullHint": "Principal + yield transferred to the lender. The invoice is now Repaid.", + "repaidPartial": "Repayment sent", + "repaidPartialHint": "Partial repayment recorded on-chain. Continue repaying until the balance clears.", + "repayFailed": "Failed to repay", + "markedOverdue": "Invoice marked overdue.", + "overdueFailed": "Failed to mark overdue", + "reclaimed": "Offer marked defaulted.", + "reclaimedHint": "This is an on-chain record — pursue recovery off-chain.", + "reclaimFailed": "Failed to reclaim", + "undo": "Undo", + "undoRejectAlt": "Undo reject", + "rejectUndone": "Rejection undone", + "rejectUndoneHint": "Offer is now Pending again.", + "undoRejectFailed": "Failed to undo reject" + } } } diff --git a/invofi/apps/frontend/messages/es.json b/invofi/apps/frontend/messages/es.json new file mode 100644 index 000000000..2095defbd --- /dev/null +++ b/invofi/apps/frontend/messages/es.json @@ -0,0 +1,346 @@ +{ + "Navbar": { + "dashboard": "Panel", + "marketplace": "Mercado", + "portfolio": "Cartera", + "approvals": "Aprobaciones", + "wrongNetwork": "red incorrecta", + "toggleTheme": "Cambiar tema", + "testnet": "Testnet", + "viewContracts": "Ver las direcciones de los contratos inteligentes", + "settings": "Ajustes", + "signOut": "Cerrar sesión" + }, + "Footer": { + "tagline": "Financiación descentralizada de facturas en Stellar Soroban", + "stats": "Estadísticas", + "github": "GitHub", + "docs": "Documentación", + "issues": "Incidencias", + "contractOnStellar": "Contrato en Stellar {network}:", + "viewOnStellarExpert": "Ver el contrato de InvoFi en Stellar Expert", + "openSource": "Código abierto" + }, + "DashboardLayout": { + "title": "Panel", + "description": "Gestiona tus facturas registradas, sigue las ofertas de financiación y controla el estado de los reembolsos." + }, + "Settings": { + "title": "Ajustes", + "description": "Gestiona las preferencias de tu cuenta", + "profile": { + "label": "Perfil", + "hint": "Edita tu nombre visible y consulta los datos de la cuenta" + }, + "language": { + "title": "Idioma", + "label": "Idioma de la interfaz", + "hint": "Cambia el idioma de la interfaz, el formato de números y fechas y la dirección del texto." + }, + "network": { + "title": "Red y contratos", + "label": "Red de Stellar", + "connected": "Conectado" + }, + "account": { + "title": "Cuenta", + "signOut": "Cerrar sesión", + "signingOut": "Cerrando sesión…", + "signedOut": "Sesión cerrada correctamente" + }, + "contracts": { + "title": "Contratos", + "rpcUrl": "URL de RPC", + "horizonUrl": "URL de Horizon", + "registry": "Registro", + "financing": "Financiación", + "repayment": "Reembolso", + "notConfigured": "sin configurar", + "copy": "Copiar", + "copied": "Copiado", + "explorer": "Explorador", + "copyAria": "Copiar el ID del contrato {label}", + "explorerAria": "Abrir el contrato {label} en Stellar Expert", + "copyFailed": "No se pudo copiar", + "copyFailedHint": "No se pudo acceder al portapapeles." + } + }, + "Errors": { + "forbidden": { + "title": "Acceso denegado", + "description": "No tienes permiso para acceder a este recurso.", + "backHome": "Volver al inicio" + }, + "notFound": { + "title": "Página no encontrada", + "description": "La página que buscas no existe o se ha movido.", + "backHome": "Volver al inicio" + }, + "unexpected": { + "title": "Algo ha salido mal", + "description": "Se ha producido un error inesperado. Inténtalo de nuevo.", + "retry": "Reintentar" + } + }, + "Status": { + "Pending": "Pendiente", + "Financed": "Financiada", + "Repaid": "Reembolsada", + "Overdue": "Vencida", + "Cancelled": "Cancelada", + "Accepted": "Aceptada", + "Rejected": "Rechazada", + "Defaulted": "En impago" + }, + "Common": { + "confirm": "Confirmar", + "cancel": "Cancelar", + "close": "Cerrar", + "save": "Guardar", + "saving": "Guardando…", + "loading": "Cargando…", + "retry": "Reintentar", + "back": "Atrás", + "next": "Siguiente", + "submit": "Enviar", + "submitting": "Enviando…", + "copy": "Copiar", + "copied": "Copiado", + "export": "Exportar", + "search": "Buscar", + "filter": "Filtrar", + "all": "Todo", + "none": "Ninguno", + "optional": "Opcional", + "hold": { + "start": "Mantén pulsado para confirmar", + "almost": "Mantén un poco más para confirmar", + "cancelled": "Confirmación cancelada" + } + }, + "Dashboard": { + "titleBusiness": "Panel de facturas", + "titleLender": "Cartera del prestamista", + "welcomeBack": "Bienvenido de nuevo", + "newInvoice": "Nueva factura", + "yourInvoices": "Tus facturas", + "yourInvestments": "Tus inversiones", + "browseMarketplace": "Explorar el mercado", + "exportCsv": "Exportar CSV", + "role": { + "business": "Empresa", + "lender": "Prestamista", + "admin": "Administrador" + }, + "wallet": { + "title": "Cartera de Stellar", + "connected": "Cartera conectada. Puedes firmar transacciones.", + "disconnected": "Conecta tu cartera de Stellar para interactuar con los contratos." + }, + "stats": { + "totalInvoices": "Facturas totales", + "pending": "Pendientes", + "financed": "Financiadas", + "repaid": "Reembolsadas", + "activeInvestments": "Inversiones activas", + "pendingOffers": "Ofertas pendientes", + "totalYield": "Rendimiento total" + }, + "view": { + "grid": "Vista de cuadrícula", + "table": "Vista de tabla" + }, + "empty": { + "invoices": "Todavía no hay facturas.", + "createFirst": "Crea tu primera factura", + "investments": "Todavía no hay inversiones activas." + }, + "cancel": { + "action": "Cancelar factura", + "title": "¿Cancelar la factura?", + "description": "La factura {id} se marcará como cancelada. Esta acción no se puede deshacer.", + "confirm": "Sí, cancelar" + } + }, + "Marketplace": { + "title": "Mercado de facturas", + "description": "Explora las facturas disponibles para financiación y envía ofertas para obtener rendimiento.", + "searchPlaceholder": "Buscar por ID de factura, nombre del deudor o emisor…", + "view": { + "suggested": "Sugeridas para mí", + "browseAll": "Ver todas" + }, + "filters": { + "allStatuses": "Todos los estados", + "allCurrencies": "Todas las monedas" + }, + "sort": { + "label": "Ordenar facturas", + "newest": "Más recientes primero", + "oldest": "Más antiguas primero", + "amount_desc": "Importe: de mayor a menor", + "amount_asc": "Importe: de menor a mayor", + "due_soonest": "Vencimiento: más próximo" + }, + "empty": { + "title": "Ninguna factura coincide con tus filtros", + "hint": "Prueba a ajustar la búsqueda o los filtros." + }, + "clearSearch": "Borrar la búsqueda" + }, + "Portfolio": { + "title": "Tu cartera", + "description": "Sigue tus ofertas de financiación y tus rendimientos: las actualizaciones llegan en directo", + "refresh": "Actualizar", + "exportCsv": "Exportar CSV", + "stats": { + "active": "Inversiones activas", + "pending": "Ofertas pendientes", + "completed": "Completadas", + "value": "Valor de la cartera (USD)" + }, + "yield": { + "estimated": "Rendimiento estimado hasta la fecha: {amount}", + "accruing": "Acumulándose en tiempo real en {count, plural, =0 {ninguna posición activa} one {# posición activa} other {# posiciones activas}}", + "realized": "Rendimiento realizado: {amount}", + "acrossRepaid": "En {count, plural, one {# oferta reembolsada} other {# ofertas reembolsadas}}" + }, + "empty": { + "title": "Todavía no hay ofertas de financiación.", + "browse": "Explorar el mercado" + }, + "position": { + "days": "{count, plural, one {# día} other {# días}}", + "funded": "Financiada el {date}", + "apy": "TAE", + "earnedToDate": "Ganado hasta la fecha", + "repayment": "Reembolso", + "percentRepaid": "{percent} reembolsado", + "progressLabel": "{percent} del total adeudado reembolsado", + "repaidRemaining": "{repaid} reembolsado · {remaining} restante", + "updated": "actualizado {when}" + }, + "transfer": { + "title": "Transferir posición", + "description": "Los tokens de posición representan tu derecho sobre facturas financiadas (1 token = 1 unidad base del principal). Envíalos a otra cartera de Stellar para transferir la posición.", + "secondaryBoard": "¿Buscas comprador? Publica la posición en el tablón secundario: la liquidación sigue haciéndose aquí, con esta transferencia.", + "prefilled": "Importe rellenado a partir de tu publicación ({amount} tokens). Introduce la dirección del comprador para liquidar.", + "refreshBalance": "Actualizar saldo", + "connectWallet": "Conecta una cartera para ver y transferir posiciones.", + "notConfigured": "Los tokens de posición aún no están configurados en este despliegue.", + "needsTrustline": "Los tokens de posición son activos de Stellar: añade una línea de confianza POS una vez para recibirlos y transferirlos.", + "addTrustline": "Añadir línea de confianza POS", + "adding": "Añadiendo…", + "trustlineAdded": "Línea de confianza añadida", + "trustlineAddedHint": "Tu cartera ya puede mantener tokens de posición POS.", + "trustlineFailed": "Error en la línea de confianza", + "trustlineFailedHint": "No se pudo configurar la línea de confianza", + "recipientLabel": "Dirección del destinatario", + "amountLabel": "Importe", + "available": "(disponible: {balance})", + "transfer": "Transferir", + "transferring": "Transfiriendo…", + "invalidAddress": "Dirección no válida", + "invalidAddressHint": "Introduce una dirección de Stellar válida (G…).", + "invalidAmount": "Importe no válido", + "invalidAmountHint": "Introduce un importe con {decimals} decimales como máximo.", + "insufficient": "Saldo insuficiente", + "insufficientHint": "No tienes suficientes tokens de posición para esta transferencia.", + "recipientTrustline": "El destinatario necesita una línea de confianza", + "recipientTrustlineHint": "La cartera del destinatario aún no tiene línea de confianza POS. Pídele que añada una (en cualquier cartera o en esta aplicación) antes de transferir.", + "transferred": "Posición transferida", + "transferredHint": "Se han enviado {amount} tokens de posición a {recipient}.", + "transferFailed": "Error en la transferencia", + "transferFailedHint": "La transacción ha fallado" + } + }, + "Invoice": { + "title": "Factura", + "backToDashboard": "Volver al panel", + "print": "Imprimir / Exportar PDF", + "notFound": "Factura no encontrada", + "fields": { + "amount": "Importe", + "currency": "Moneda", + "dueDate": "Vencimiento", + "originator": "Emisor" + }, + "counterparty": { + "lender": "Prestamista", + "business": "Empresa" + }, + "cancel": { + "action": "Cancelar", + "title": "¿Cancelar esta factura?", + "description": "La factura se cancelará on-chain y ya no podrá recibir ofertas de financiación. Esta acción no se puede deshacer.", + "confirm": "Cancelar factura", + "done": "Factura cancelada", + "doneHint": "La factura ya está cancelada on-chain.", + "failed": "No se pudo cancelar la factura", + "undo": "Deshacer", + "undoAlt": "Deshacer la cancelación", + "restored": "Factura restaurada", + "restoredHint": "Se ha creado una factura nueva con las mismas condiciones.", + "restoreFailed": "No se pudo restaurar la factura" + } + }, + "Offers": { + "title": "Ofertas de financiación ({count})", + "empty": "Todavía no hay ofertas.", + "makeOffer": "Hacer una oferta", + "markOverdue": "Marcar como vencida", + "exportHint": "Exportar ofertas en CSV", + "exportEmpty": "No hay ofertas que exportar", + "accept": "Aceptar", + "reject": "Rechazar", + "repay": "Reembolsar", + "reclaim": "Reclamar", + "repayAmount": "Importe del reembolso", + "days": "{count, plural, one {# día} other {# días}}", + "repaid": "{amount} reembolsado", + "remaining": "{amount} restante", + "remainingBalance": "Saldo pendiente: {remaining} (total adeudado {total} menos {repaid})", + "form": { + "title": "Nueva oferta de financiación", + "amount": "Importe", + "currency": "Moneda", + "interest": "Interés (puntos básicos)", + "interestHint": "500 = {example}", + "duration": "Duración (días)", + "submit": "Enviar oferta" + }, + "confirm": { + "rejectTitle": "¿Rechazar esta oferta?", + "rejectDescription": "Se notificará al prestamista que su oferta fue rechazada. Esta acción no se puede deshacer.", + "reclaimTitle": "¿Reclamar esta oferta?", + "reclaimDescription": "Esto marca la oferta como en impago on-chain. El principal ya se pagó a la empresa al aceptarla: esto no devuelve fondos y no se puede deshacer." + }, + "toast": { + "submitted": "¡Oferta enviada!", + "submittedHint": "Se notificará al emisor de la factura.", + "submitFailed": "No se pudo enviar la oferta", + "accepted": "¡Oferta aceptada!", + "acceptedHint": "La factura ya está marcada como financiada.", + "acceptFailed": "No se pudo aceptar la oferta", + "rejected": "Oferta rechazada.", + "rejectFailed": "No se pudo rechazar la oferta", + "invalidAmount": "Introduce un importe válido", + "amountTooSmall": "El importe debe ser mayor que cero", + "repaidFull": "Factura reembolsada por completo", + "repaidFullHint": "Principal + rendimiento transferidos al prestamista. La factura ya está reembolsada.", + "repaidPartial": "Reembolso enviado", + "repaidPartialHint": "Reembolso parcial registrado on-chain. Sigue reembolsando hasta saldar el importe.", + "repayFailed": "No se pudo reembolsar", + "markedOverdue": "Factura marcada como vencida.", + "overdueFailed": "No se pudo marcar como vencida", + "reclaimed": "Oferta marcada en impago.", + "reclaimedHint": "Es un registro on-chain: la recuperación se gestiona fuera de la cadena.", + "reclaimFailed": "No se pudo reclamar", + "undo": "Deshacer", + "undoRejectAlt": "Deshacer el rechazo", + "rejectUndone": "Rechazo deshecho", + "rejectUndoneHint": "La oferta vuelve a estar pendiente.", + "undoRejectFailed": "No se pudo deshacer el rechazo" + } + } +} diff --git a/invofi/apps/frontend/messages/fa.json b/invofi/apps/frontend/messages/fa.json new file mode 100644 index 000000000..2524b5d1a --- /dev/null +++ b/invofi/apps/frontend/messages/fa.json @@ -0,0 +1,346 @@ +{ + "Navbar": { + "dashboard": "داشبورد", + "marketplace": "بازار", + "portfolio": "سبد", + "approvals": "تأییدها", + "wrongNetwork": "شبکه نادرست", + "toggleTheme": "تغییر پوسته", + "testnet": "شبکه آزمایشی", + "viewContracts": "نمایش نشانی قراردادهای هوشمند", + "settings": "تنظیمات", + "signOut": "خروج" + }, + "Footer": { + "tagline": "تأمین مالی غیرمتمرکز فاکتور روی استلار سوروبان", + "stats": "آمار", + "github": "گیت‌هاب", + "docs": "مستندات", + "issues": "مسائل", + "contractOnStellar": "قرارداد روی استلار {network}:", + "viewOnStellarExpert": "نمایش قرارداد InvoFi در Stellar Expert", + "openSource": "متن‌باز" + }, + "DashboardLayout": { + "title": "داشبورد", + "description": "فاکتورهای ثبت‌شده را مدیریت کنید، پیشنهادهای تأمین مالی را دنبال کنید و وضعیت بازپرداخت را پایش کنید." + }, + "Settings": { + "title": "تنظیمات", + "description": "ترجیحات حساب خود را مدیریت کنید", + "profile": { + "label": "نمایه", + "hint": "نام نمایشی را ویرایش کنید و جزئیات حساب را ببینید" + }, + "language": { + "title": "زبان", + "label": "زبان نمایش", + "hint": "زبان رابط، قالب اعداد و تاریخ، و جهت متن را تغییر می‌دهد." + }, + "network": { + "title": "شبکه و قراردادها", + "label": "شبکه استلار", + "connected": "متصل" + }, + "account": { + "title": "حساب", + "signOut": "خروج", + "signingOut": "در حال خروج…", + "signedOut": "با موفقیت خارج شدید" + }, + "contracts": { + "title": "قراردادها", + "rpcUrl": "نشانی RPC", + "horizonUrl": "نشانی Horizon", + "registry": "ثبت", + "financing": "تأمین مالی", + "repayment": "بازپرداخت", + "notConfigured": "پیکربندی‌نشده", + "copy": "رونوشت", + "copied": "رونوشت شد", + "explorer": "کاوشگر", + "copyAria": "رونوشت شناسهٔ قرارداد {label}", + "explorerAria": "گشودن قرارداد {label} در Stellar Expert", + "copyFailed": "رونوشت ناموفق", + "copyFailedHint": "دسترسی به حافظهٔ موقت ممکن نشد." + } + }, + "Errors": { + "forbidden": { + "title": "دسترسی ممنوع", + "description": "اجازهٔ دسترسی به این منبع را ندارید.", + "backHome": "بازگشت به خانه" + }, + "notFound": { + "title": "صفحه یافت نشد", + "description": "صفحه‌ای که می‌خواهید وجود ندارد یا جابه‌جا شده است.", + "backHome": "بازگشت به خانه" + }, + "unexpected": { + "title": "خطایی رخ داد", + "description": "خطای پیش‌بینی‌نشده‌ای رخ داد. دوباره تلاش کنید.", + "retry": "تلاش دوباره" + } + }, + "Status": { + "Pending": "در انتظار", + "Financed": "تأمین‌شده", + "Repaid": "بازپرداخت‌شده", + "Overdue": "سررسیدگذشته", + "Cancelled": "لغوشده", + "Accepted": "پذیرفته‌شده", + "Rejected": "ردشده", + "Defaulted": "نکول‌شده" + }, + "Common": { + "confirm": "تأیید", + "cancel": "لغو", + "close": "بستن", + "save": "ذخیره", + "saving": "در حال ذخیره…", + "loading": "در حال بارگذاری…", + "retry": "تلاش دوباره", + "back": "بازگشت", + "next": "بعدی", + "submit": "ارسال", + "submitting": "در حال ارسال…", + "copy": "رونوشت", + "copied": "رونوشت شد", + "export": "برون‌بری", + "search": "جست‌وجو", + "filter": "پالایه", + "all": "همه", + "none": "هیچ‌کدام", + "optional": "اختیاری", + "hold": { + "start": "برای تأیید نگه دارید", + "almost": "کمی بیشتر نگه دارید تا تأیید شود", + "cancelled": "تأیید لغو شد" + } + }, + "Dashboard": { + "titleBusiness": "داشبورد فاکتورها", + "titleLender": "سبد تأمین‌کننده", + "welcomeBack": "خوش آمدید", + "newInvoice": "فاکتور تازه", + "yourInvoices": "فاکتورهای شما", + "yourInvestments": "سرمایه‌گذاری‌های شما", + "browseMarketplace": "مرور بازار", + "exportCsv": "برون‌بری CSV", + "role": { + "business": "کسب‌وکار", + "lender": "تأمین‌کننده", + "admin": "مدیر" + }, + "wallet": { + "title": "کیف‌پول استلار", + "connected": "کیف‌پول متصل است. می‌توانید تراکنش‌ها را امضا کنید.", + "disconnected": "برای کار با قراردادها کیف‌پول استلار خود را متصل کنید." + }, + "stats": { + "totalInvoices": "کل فاکتورها", + "pending": "در انتظار", + "financed": "تأمین‌شده", + "repaid": "بازپرداخت‌شده", + "activeInvestments": "سرمایه‌گذاری‌های فعال", + "pendingOffers": "پیشنهادهای در انتظار", + "totalYield": "کل بازده" + }, + "view": { + "grid": "نمای شبکه‌ای", + "table": "نمای جدولی" + }, + "empty": { + "invoices": "هنوز فاکتوری نیست.", + "createFirst": "نخستین فاکتور خود را بسازید", + "investments": "هنوز سرمایه‌گذاری فعالی نیست." + }, + "cancel": { + "action": "لغو فاکتور", + "title": "فاکتور لغو شود؟", + "description": "فاکتور {id} «لغوشده» علامت می‌خورد. این کار بازگشت‌پذیر نیست.", + "confirm": "بله، لغو کن" + } + }, + "Marketplace": { + "title": "بازار فاکتور", + "description": "فاکتورهای آمادهٔ تأمین مالی را مرور کنید و برای کسب بازده پیشنهاد بدهید.", + "searchPlaceholder": "جست‌وجو بر پایهٔ شناسهٔ فاکتور، نام بدهکار یا صادرکننده…", + "view": { + "suggested": "پیشنهاد برای من", + "browseAll": "مرور همه" + }, + "filters": { + "allStatuses": "همهٔ وضعیت‌ها", + "allCurrencies": "همهٔ ارزها" + }, + "sort": { + "label": "مرتب‌سازی فاکتورها", + "newest": "تازه‌ترین", + "oldest": "قدیمی‌ترین", + "amount_desc": "مبلغ: از زیاد به کم", + "amount_asc": "مبلغ: از کم به زیاد", + "due_soonest": "سررسید: نزدیک‌ترین" + }, + "empty": { + "title": "فاکتوری با این پالایه‌ها یافت نشد", + "hint": "جست‌وجو یا پالایه‌ها را تغییر دهید." + }, + "clearSearch": "پاک کردن جست‌وجو" + }, + "Portfolio": { + "title": "سبد شما", + "description": "پیشنهادهای تأمین مالی و بازده را دنبال کنید — به‌روزرسانی‌ها زنده می‌رسند", + "refresh": "به‌روزرسانی", + "exportCsv": "برون‌بری CSV", + "stats": { + "active": "سرمایه‌گذاری‌های فعال", + "pending": "پیشنهادهای در انتظار", + "completed": "تکمیل‌شده", + "value": "ارزش سبد (دلار)" + }, + "yield": { + "estimated": "بازده برآوردی تا امروز: {amount}", + "accruing": "به‌صورت زنده روی {count, plural, one {# موقعیت فعال} other {# موقعیت فعال}} انباشته می‌شود", + "realized": "بازده محقق‌شده: {amount}", + "acrossRepaid": "روی {count, plural, one {# پیشنهاد بازپرداخت‌شده} other {# پیشنهاد بازپرداخت‌شده}}" + }, + "empty": { + "title": "هنوز پیشنهاد تأمین مالی نیست.", + "browse": "مرور بازار" + }, + "position": { + "days": "{count, plural, one {# روز} other {# روز}}", + "funded": "تأمین‌شده در {date}", + "apy": "بازده سالانه", + "earnedToDate": "کسب‌شده تا امروز", + "repayment": "بازپرداخت", + "percentRepaid": "{percent} بازپرداخت شد", + "progressLabel": "{percent} از کل بدهی بازپرداخت شد", + "repaidRemaining": "{repaid} بازپرداخت‌شده · {remaining} مانده", + "updated": "به‌روزرسانی {when}" + }, + "transfer": { + "title": "انتقال موقعیت", + "description": "توکن‌های موقعیت نشان‌دهندهٔ حق شما بر فاکتورهای تأمین‌شده‌اند (هر توکن = یک واحد پایه از اصل مبلغ). برای انتقال موقعیت آن‌ها را به کیف‌پول استلار دیگری بفرستید.", + "secondaryBoard": "به دنبال خریدارید؟ موقعیت را در تابلوی ثانویه فهرست کنید — تسویه همچنان همین‌جا و با همین انتقال انجام می‌شود.", + "prefilled": "مبلغ از فهرست شما پر شد ({amount} توکن). برای تسویه نشانی خریدار را وارد کنید.", + "refreshBalance": "به‌روزرسانی موجودی", + "connectWallet": "برای دیدن و انتقال موقعیت‌ها کیف‌پول متصل کنید.", + "notConfigured": "توکن‌های موقعیت هنوز در این استقرار پیکربندی نشده‌اند.", + "needsTrustline": "توکن‌های موقعیت دارایی استلار هستند — یک‌بار خط اعتماد POS بیفزایید تا بتوانید آن‌ها را دریافت و منتقل کنید.", + "addTrustline": "افزودن خط اعتماد POS", + "adding": "در حال افزودن…", + "trustlineAdded": "خط اعتماد افزوده شد", + "trustlineAddedHint": "کیف‌پول شما اکنون می‌تواند توکن موقعیت POS نگه دارد.", + "trustlineFailed": "خط اعتماد ناموفق", + "trustlineFailedHint": "برپایی خط اعتماد ناموفق بود", + "recipientLabel": "نشانی گیرنده", + "amountLabel": "مبلغ", + "available": "(موجود: {balance})", + "transfer": "انتقال", + "transferring": "در حال انتقال…", + "invalidAddress": "نشانی نامعتبر", + "invalidAddressHint": "یک نشانی معتبر استلار وارد کنید (G…).", + "invalidAmount": "مبلغ نامعتبر", + "invalidAmountHint": "مبلغی با حداکثر {decimals} رقم اعشار وارد کنید.", + "insufficient": "موجودی ناکافی", + "insufficientHint": "توکن موقعیت کافی برای این انتقال ندارید.", + "recipientTrustline": "گیرنده به خط اعتماد نیاز دارد", + "recipientTrustlineHint": "کیف‌پول گیرنده هنوز خط اعتماد POS ندارد. پیش از انتقال از او بخواهید یکی بیفزاید (در هر کیف‌پولی یا در همین برنامه).", + "transferred": "موقعیت منتقل شد", + "transferredHint": "{amount} توکن موقعیت به {recipient} فرستاده شد.", + "transferFailed": "انتقال ناموفق", + "transferFailedHint": "تراکنش ناموفق بود" + } + }, + "Invoice": { + "title": "فاکتور", + "backToDashboard": "بازگشت به داشبورد", + "print": "چاپ / برون‌بری PDF", + "notFound": "فاکتور یافت نشد", + "fields": { + "amount": "مبلغ", + "currency": "ارز", + "dueDate": "سررسید", + "originator": "صادرکننده" + }, + "counterparty": { + "lender": "تأمین‌کننده", + "business": "کسب‌وکار" + }, + "cancel": { + "action": "لغو", + "title": "این فاکتور لغو شود؟", + "description": "فاکتور روی زنجیره لغو می‌شود و دیگر پیشنهاد تأمین مالی نمی‌پذیرد. این کار بازگشت‌پذیر نیست.", + "confirm": "لغو فاکتور", + "done": "فاکتور لغو شد", + "doneHint": "فاکتور اکنون روی زنجیره لغو شده است.", + "failed": "لغو فاکتور ناموفق بود", + "undo": "واگرد", + "undoAlt": "واگرد لغو", + "restored": "فاکتور بازیابی شد", + "restoredHint": "فاکتور تازه‌ای با همان شرایط ساخته شد.", + "restoreFailed": "بازیابی فاکتور ناموفق بود" + } + }, + "Offers": { + "title": "پیشنهادهای تأمین مالی ({count})", + "empty": "هنوز پیشنهادی نیست.", + "makeOffer": "ارائهٔ پیشنهاد", + "markOverdue": "علامت‌گذاری سررسیدگذشته", + "exportHint": "برون‌بری پیشنهادها به CSV", + "exportEmpty": "پیشنهادی برای برون‌بری نیست", + "accept": "پذیرش", + "reject": "رد", + "repay": "بازپرداخت", + "reclaim": "بازپس‌گیری", + "repayAmount": "مبلغ بازپرداخت", + "days": "{count, plural, one {# روز} other {# روز}}", + "repaid": "{amount} بازپرداخت‌شده", + "remaining": "{amount} مانده", + "remainingBalance": "مانده: {remaining} (کل بدهی {total} منهای {repaid})", + "form": { + "title": "پیشنهاد تأمین مالی تازه", + "amount": "مبلغ", + "currency": "ارز", + "interest": "بهره (واحد پایه)", + "interestHint": "500 = {example}", + "duration": "مدت (روز)", + "submit": "ارسال پیشنهاد" + }, + "confirm": { + "rejectTitle": "این پیشنهاد رد شود؟", + "rejectDescription": "به تأمین‌کننده اطلاع داده می‌شود که پیشنهادش رد شد. این کار بازگشت‌پذیر نیست.", + "reclaimTitle": "این پیشنهاد بازپس گرفته شود؟", + "reclaimDescription": "این کار پیشنهاد را روی زنجیره «نکول‌شده» علامت می‌زند. اصل مبلغ هنگام پذیرش به کسب‌وکار پرداخت شده بود — این کار وجهی بازنمی‌گرداند و بازگشت‌پذیر نیست." + }, + "toast": { + "submitted": "پیشنهاد ارسال شد!", + "submittedHint": "به صادرکنندهٔ فاکتور اطلاع داده می‌شود.", + "submitFailed": "ارسال پیشنهاد ناموفق بود", + "accepted": "پیشنهاد پذیرفته شد!", + "acceptedHint": "فاکتور اکنون «تأمین‌شده» علامت خورده است.", + "acceptFailed": "پذیرش پیشنهاد ناموفق بود", + "rejected": "پیشنهاد رد شد.", + "rejectFailed": "رد پیشنهاد ناموفق بود", + "invalidAmount": "مبلغ معتبری وارد کنید", + "amountTooSmall": "مبلغ باید بزرگ‌تر از صفر باشد", + "repaidFull": "فاکتور به‌طور کامل بازپرداخت شد", + "repaidFullHint": "اصل و سود به تأمین‌کننده منتقل شد. فاکتور اکنون بازپرداخت‌شده است.", + "repaidPartial": "بازپرداخت ارسال شد", + "repaidPartialHint": "بازپرداخت جزئی روی زنجیره ثبت شد. تا تسویهٔ مانده ادامه دهید.", + "repayFailed": "بازپرداخت ناموفق بود", + "markedOverdue": "فاکتور سررسیدگذشته علامت خورد.", + "overdueFailed": "علامت‌گذاری ناموفق بود", + "reclaimed": "پیشنهاد نکول‌شده علامت خورد.", + "reclaimedHint": "این یک سابقه روی زنجیره است — پیگیری وصول را بیرون از زنجیره ادامه دهید.", + "reclaimFailed": "بازپس‌گیری ناموفق بود", + "undo": "واگرد", + "undoRejectAlt": "واگرد رد", + "rejectUndone": "رد واگرد شد", + "rejectUndoneHint": "پیشنهاد دوباره در انتظار است.", + "undoRejectFailed": "واگرد رد ناموفق بود" + } + } +} diff --git a/invofi/apps/frontend/messages/fr.json b/invofi/apps/frontend/messages/fr.json new file mode 100644 index 000000000..352c783e8 --- /dev/null +++ b/invofi/apps/frontend/messages/fr.json @@ -0,0 +1,346 @@ +{ + "Navbar": { + "dashboard": "Tableau de bord", + "marketplace": "Place de marché", + "portfolio": "Portefeuille", + "approvals": "Approbations", + "wrongNetwork": "réseau incorrect", + "toggleTheme": "Changer de thème", + "testnet": "Testnet", + "viewContracts": "Voir les adresses des contrats intelligents", + "settings": "Paramètres", + "signOut": "Se déconnecter" + }, + "Footer": { + "tagline": "Financement décentralisé de factures sur Stellar Soroban", + "stats": "Statistiques", + "github": "GitHub", + "docs": "Documentation", + "issues": "Tickets", + "contractOnStellar": "Contrat sur Stellar {network} :", + "viewOnStellarExpert": "Voir le contrat InvoFi sur Stellar Expert", + "openSource": "Open source" + }, + "DashboardLayout": { + "title": "Tableau de bord", + "description": "Gérez vos factures enregistrées, suivez les offres de financement et surveillez l’état des remboursements." + }, + "Settings": { + "title": "Paramètres", + "description": "Gérez les préférences de votre compte", + "profile": { + "label": "Profil", + "hint": "Modifiez votre nom affiché et consultez les détails du compte" + }, + "language": { + "title": "Langue", + "label": "Langue d’affichage", + "hint": "Modifie la langue de l’interface, le format des nombres et des dates, ainsi que le sens du texte." + }, + "network": { + "title": "Réseau et contrats", + "label": "Réseau Stellar", + "connected": "Connecté" + }, + "account": { + "title": "Compte", + "signOut": "Se déconnecter", + "signingOut": "Déconnexion…", + "signedOut": "Déconnexion réussie" + }, + "contracts": { + "title": "Contrats", + "rpcUrl": "URL RPC", + "horizonUrl": "URL Horizon", + "registry": "Registre", + "financing": "Financement", + "repayment": "Remboursement", + "notConfigured": "non configuré", + "copy": "Copier", + "copied": "Copié", + "explorer": "Explorateur", + "copyAria": "Copier l’identifiant du contrat {label}", + "explorerAria": "Ouvrir le contrat {label} dans Stellar Expert", + "copyFailed": "Échec de la copie", + "copyFailedHint": "Impossible d’accéder au presse-papiers." + } + }, + "Errors": { + "forbidden": { + "title": "Accès interdit", + "description": "Vous n’avez pas l’autorisation d’accéder à cette ressource.", + "backHome": "Retour à l’accueil" + }, + "notFound": { + "title": "Page introuvable", + "description": "La page que vous cherchez n’existe pas ou a été déplacée.", + "backHome": "Retour à l’accueil" + }, + "unexpected": { + "title": "Une erreur est survenue", + "description": "Une erreur inattendue s’est produite. Veuillez réessayer.", + "retry": "Réessayer" + } + }, + "Status": { + "Pending": "En attente", + "Financed": "Financée", + "Repaid": "Remboursée", + "Overdue": "En retard", + "Cancelled": "Annulée", + "Accepted": "Acceptée", + "Rejected": "Refusée", + "Defaulted": "En défaut" + }, + "Common": { + "confirm": "Confirmer", + "cancel": "Annuler", + "close": "Fermer", + "save": "Enregistrer", + "saving": "Enregistrement…", + "loading": "Chargement…", + "retry": "Réessayer", + "back": "Retour", + "next": "Suivant", + "submit": "Envoyer", + "submitting": "Envoi…", + "copy": "Copier", + "copied": "Copié", + "export": "Exporter", + "search": "Rechercher", + "filter": "Filtrer", + "all": "Tout", + "none": "Aucun", + "optional": "Facultatif", + "hold": { + "start": "Maintenez pour confirmer", + "almost": "Maintenez encore un instant pour confirmer", + "cancelled": "Confirmation annulée" + } + }, + "Dashboard": { + "titleBusiness": "Tableau de bord des factures", + "titleLender": "Portefeuille du prêteur", + "welcomeBack": "Bon retour", + "newInvoice": "Nouvelle facture", + "yourInvoices": "Vos factures", + "yourInvestments": "Vos investissements", + "browseMarketplace": "Parcourir la place de marché", + "exportCsv": "Exporter en CSV", + "role": { + "business": "Entreprise", + "lender": "Prêteur", + "admin": "Administrateur" + }, + "wallet": { + "title": "Portefeuille Stellar", + "connected": "Portefeuille connecté. Vous pouvez signer des transactions.", + "disconnected": "Connectez votre portefeuille Stellar pour interagir avec les contrats." + }, + "stats": { + "totalInvoices": "Total des factures", + "pending": "En attente", + "financed": "Financées", + "repaid": "Remboursées", + "activeInvestments": "Investissements actifs", + "pendingOffers": "Offres en attente", + "totalYield": "Rendement total" + }, + "view": { + "grid": "Vue en grille", + "table": "Vue en tableau" + }, + "empty": { + "invoices": "Aucune facture pour l’instant.", + "createFirst": "Créez votre première facture", + "investments": "Aucun investissement actif pour l’instant." + }, + "cancel": { + "action": "Annuler la facture", + "title": "Annuler la facture ?", + "description": "La facture {id} sera marquée comme annulée. Cette action est irréversible.", + "confirm": "Oui, annuler" + } + }, + "Marketplace": { + "title": "Place de marché des factures", + "description": "Parcourez les factures disponibles au financement et soumettez des offres pour générer du rendement.", + "searchPlaceholder": "Rechercher par identifiant de facture, nom du débiteur ou émetteur…", + "view": { + "suggested": "Suggérées pour moi", + "browseAll": "Tout parcourir" + }, + "filters": { + "allStatuses": "Tous les statuts", + "allCurrencies": "Toutes les devises" + }, + "sort": { + "label": "Trier les factures", + "newest": "Les plus récentes d’abord", + "oldest": "Les plus anciennes d’abord", + "amount_desc": "Montant : décroissant", + "amount_asc": "Montant : croissant", + "due_soonest": "Échéance : la plus proche" + }, + "empty": { + "title": "Aucune facture ne correspond à vos filtres", + "hint": "Essayez d’ajuster la recherche ou les filtres." + }, + "clearSearch": "Effacer la recherche" + }, + "Portfolio": { + "title": "Votre portefeuille", + "description": "Suivez vos offres de financement et vos rendements — les mises à jour arrivent en direct", + "refresh": "Actualiser", + "exportCsv": "Exporter en CSV", + "stats": { + "active": "Investissements actifs", + "pending": "Offres en attente", + "completed": "Terminés", + "value": "Valeur du portefeuille (USD)" + }, + "yield": { + "estimated": "Rendement estimé à ce jour : {amount}", + "accruing": "S’accumule en temps réel sur {count, plural, =0 {aucune position active} one {# position active} other {# positions actives}}", + "realized": "Rendement réalisé : {amount}", + "acrossRepaid": "Sur {count, plural, one {# offre remboursée} other {# offres remboursées}}" + }, + "empty": { + "title": "Aucune offre de financement pour l’instant.", + "browse": "Parcourir la place de marché" + }, + "position": { + "days": "{count, plural, one {# jour} other {# jours}}", + "funded": "Financée le {date}", + "apy": "TAEG", + "earnedToDate": "Gagné à ce jour", + "repayment": "Remboursement", + "percentRepaid": "{percent} remboursé", + "progressLabel": "{percent} du total dû remboursé", + "repaidRemaining": "{repaid} remboursé · {remaining} restant", + "updated": "mis à jour {when}" + }, + "transfer": { + "title": "Transférer la position", + "description": "Les jetons de position représentent votre créance sur des factures financées (1 jeton = 1 unité de base du principal). Envoyez-les à un autre portefeuille Stellar pour transférer la position.", + "secondaryBoard": "Vous cherchez un acheteur ? Publiez la position sur le tableau secondaire — le règlement a toujours lieu ici, via ce transfert.", + "prefilled": "Montant prérempli depuis votre annonce ({amount} jetons). Saisissez l’adresse de l’acheteur pour régler.", + "refreshBalance": "Actualiser le solde", + "connectWallet": "Connectez un portefeuille pour consulter et transférer des positions.", + "notConfigured": "Les jetons de position ne sont pas encore configurés sur ce déploiement.", + "needsTrustline": "Les jetons de position sont des actifs Stellar — ajoutez une ligne de confiance POS une fois pour les recevoir et les transférer.", + "addTrustline": "Ajouter une ligne de confiance POS", + "adding": "Ajout…", + "trustlineAdded": "Ligne de confiance ajoutée", + "trustlineAddedHint": "Votre portefeuille peut désormais détenir des jetons de position POS.", + "trustlineFailed": "Échec de la ligne de confiance", + "trustlineFailedHint": "La configuration de la ligne de confiance a échoué", + "recipientLabel": "Adresse du destinataire", + "amountLabel": "Montant", + "available": "(disponible : {balance})", + "transfer": "Transférer", + "transferring": "Transfert…", + "invalidAddress": "Adresse invalide", + "invalidAddressHint": "Saisissez une adresse Stellar valide (G…).", + "invalidAmount": "Montant invalide", + "invalidAmountHint": "Saisissez un montant comportant au plus {decimals} décimales.", + "insufficient": "Solde insuffisant", + "insufficientHint": "Vous ne détenez pas assez de jetons de position pour ce transfert.", + "recipientTrustline": "Le destinataire a besoin d’une ligne de confiance", + "recipientTrustlineHint": "Le portefeuille du destinataire n’a pas encore de ligne de confiance POS. Demandez-lui d’en ajouter une (dans n’importe quel portefeuille ou dans cette application) avant le transfert.", + "transferred": "Position transférée", + "transferredHint": "{amount} jetons de position envoyés à {recipient}.", + "transferFailed": "Échec du transfert", + "transferFailedHint": "La transaction a échoué" + } + }, + "Invoice": { + "title": "Facture", + "backToDashboard": "Retour au tableau de bord", + "print": "Imprimer / Exporter en PDF", + "notFound": "Facture introuvable", + "fields": { + "amount": "Montant", + "currency": "Devise", + "dueDate": "Échéance", + "originator": "Émetteur" + }, + "counterparty": { + "lender": "Prêteur", + "business": "Entreprise" + }, + "cancel": { + "action": "Annuler", + "title": "Annuler cette facture ?", + "description": "La facture sera annulée on-chain et ne pourra plus recevoir d’offres de financement. Cette action est irréversible.", + "confirm": "Annuler la facture", + "done": "Facture annulée", + "doneHint": "La facture est désormais annulée on-chain.", + "failed": "Échec de l’annulation de la facture", + "undo": "Annuler", + "undoAlt": "Annuler l’annulation", + "restored": "Facture restaurée", + "restoredHint": "Une nouvelle facture aux mêmes conditions a été créée.", + "restoreFailed": "Échec de la restauration de la facture" + } + }, + "Offers": { + "title": "Offres de financement ({count})", + "empty": "Aucune offre pour l’instant.", + "makeOffer": "Faire une offre", + "markOverdue": "Marquer en retard", + "exportHint": "Exporter les offres en CSV", + "exportEmpty": "Aucune offre à exporter", + "accept": "Accepter", + "reject": "Refuser", + "repay": "Rembourser", + "reclaim": "Récupérer", + "repayAmount": "Montant du remboursement", + "days": "{count, plural, one {# jour} other {# jours}}", + "repaid": "{amount} remboursé", + "remaining": "{amount} restant", + "remainingBalance": "Solde restant : {remaining} (total dû {total} moins {repaid})", + "form": { + "title": "Nouvelle offre de financement", + "amount": "Montant", + "currency": "Devise", + "interest": "Intérêt (points de base)", + "interestHint": "500 = {example}", + "duration": "Durée (jours)", + "submit": "Envoyer l’offre" + }, + "confirm": { + "rejectTitle": "Refuser cette offre ?", + "rejectDescription": "Le prêteur sera informé du refus de son offre. Cette action est irréversible.", + "reclaimTitle": "Récupérer cette offre ?", + "reclaimDescription": "Cela marque l’offre en défaut on-chain. Le principal a déjà été versé à l’entreprise à l’acceptation — cela ne restitue pas les fonds et est irréversible." + }, + "toast": { + "submitted": "Offre envoyée !", + "submittedHint": "L’émetteur de la facture en sera informé.", + "submitFailed": "Échec de l’envoi de l’offre", + "accepted": "Offre acceptée !", + "acceptedHint": "La facture est désormais marquée comme financée.", + "acceptFailed": "Échec de l’acceptation de l’offre", + "rejected": "Offre refusée.", + "rejectFailed": "Échec du refus de l’offre", + "invalidAmount": "Saisissez un montant valide", + "amountTooSmall": "Le montant doit être supérieur à zéro", + "repaidFull": "Facture intégralement remboursée", + "repaidFullHint": "Principal + rendement transférés au prêteur. La facture est remboursée.", + "repaidPartial": "Remboursement envoyé", + "repaidPartialHint": "Remboursement partiel enregistré on-chain. Continuez jusqu’à apurement du solde.", + "repayFailed": "Échec du remboursement", + "markedOverdue": "Facture marquée en retard.", + "overdueFailed": "Échec du marquage en retard", + "reclaimed": "Offre marquée en défaut.", + "reclaimedHint": "C’est un enregistrement on-chain — le recouvrement se poursuit hors chaîne.", + "reclaimFailed": "Échec de la récupération", + "undo": "Annuler", + "undoRejectAlt": "Annuler le refus", + "rejectUndone": "Refus annulé", + "rejectUndoneHint": "L’offre est de nouveau en attente.", + "undoRejectFailed": "Échec de l’annulation du refus" + } + } +} diff --git a/invofi/apps/frontend/messages/he.json b/invofi/apps/frontend/messages/he.json new file mode 100644 index 000000000..414f95774 --- /dev/null +++ b/invofi/apps/frontend/messages/he.json @@ -0,0 +1,346 @@ +{ + "Navbar": { + "dashboard": "לוח בקרה", + "marketplace": "שוק", + "portfolio": "תיק", + "approvals": "אישורים", + "wrongNetwork": "רשת שגויה", + "toggleTheme": "החלפת ערכת נושא", + "testnet": "רשת בדיקות", + "viewContracts": "הצגת כתובות החוזים החכמים", + "settings": "הגדרות", + "signOut": "התנתקות" + }, + "Footer": { + "tagline": "מימון חשבוניות מבוזר על Stellar Soroban", + "stats": "סטטיסטיקה", + "github": "GitHub", + "docs": "תיעוד", + "issues": "תקלות", + "contractOnStellar": "חוזה ב-Stellar {network}:", + "viewOnStellarExpert": "הצגת חוזה InvoFi ב-Stellar Expert", + "openSource": "קוד פתוח" + }, + "DashboardLayout": { + "title": "לוח בקרה", + "description": "ניהול החשבוניות הרשומות, מעקב אחר הצעות מימון ובקרה על מצב ההחזר." + }, + "Settings": { + "title": "הגדרות", + "description": "ניהול העדפות החשבון", + "profile": { + "label": "פרופיל", + "hint": "עריכת שם התצוגה וצפייה בפרטי החשבון" + }, + "language": { + "title": "שפה", + "label": "שפת תצוגה", + "hint": "משנה את שפת הממשק, את תבניות המספרים והתאריכים ואת כיוון הטקסט." + }, + "network": { + "title": "רשת וחוזים", + "label": "רשת Stellar", + "connected": "מחובר" + }, + "account": { + "title": "חשבון", + "signOut": "התנתקות", + "signingOut": "מתנתק…", + "signedOut": "ההתנתקות הושלמה" + }, + "contracts": { + "title": "חוזים", + "rpcUrl": "כתובת RPC", + "horizonUrl": "כתובת Horizon", + "registry": "רישום", + "financing": "מימון", + "repayment": "החזר", + "notConfigured": "לא מוגדר", + "copy": "העתקה", + "copied": "הועתק", + "explorer": "סייר", + "copyAria": "העתקת מזהה החוזה {label}", + "explorerAria": "פתיחת החוזה {label} ב-Stellar Expert", + "copyFailed": "ההעתקה נכשלה", + "copyFailedHint": "לא ניתן לגשת ללוח." + } + }, + "Errors": { + "forbidden": { + "title": "הגישה נדחתה", + "description": "אין לך הרשאה לגשת למשאב הזה.", + "backHome": "חזרה לדף הבית" + }, + "notFound": { + "title": "הדף לא נמצא", + "description": "הדף שחיפשת אינו קיים או שהועבר.", + "backHome": "חזרה לדף הבית" + }, + "unexpected": { + "title": "משהו השתבש", + "description": "אירעה שגיאה בלתי צפויה. נסה שוב.", + "retry": "נסה שוב" + } + }, + "Status": { + "Pending": "ממתין", + "Financed": "ממומן", + "Repaid": "נפרע", + "Overdue": "באיחור", + "Cancelled": "בוטל", + "Accepted": "התקבל", + "Rejected": "נדחה", + "Defaulted": "בכשל פירעון" + }, + "Common": { + "confirm": "אישור", + "cancel": "ביטול", + "close": "סגירה", + "save": "שמירה", + "saving": "שומר…", + "loading": "טוען…", + "retry": "נסה שוב", + "back": "חזרה", + "next": "הבא", + "submit": "שליחה", + "submitting": "שולח…", + "copy": "העתקה", + "copied": "הועתק", + "export": "ייצוא", + "search": "חיפוש", + "filter": "סינון", + "all": "הכול", + "none": "ללא", + "optional": "רשות", + "hold": { + "start": "לחץ והחזק לאישור", + "almost": "החזק עוד רגע לאישור", + "cancelled": "האישור בוטל" + } + }, + "Dashboard": { + "titleBusiness": "לוח החשבוניות", + "titleLender": "תיק המממן", + "welcomeBack": "ברוך שובך", + "newInvoice": "חשבונית חדשה", + "yourInvoices": "החשבוניות שלך", + "yourInvestments": "ההשקעות שלך", + "browseMarketplace": "עיון בשוק", + "exportCsv": "ייצוא CSV", + "role": { + "business": "עסק", + "lender": "מממן", + "admin": "מנהל" + }, + "wallet": { + "title": "ארנק Stellar", + "connected": "הארנק מחובר. אפשר לחתום על עסקאות.", + "disconnected": "חבר ארנק Stellar כדי לפעול מול החוזים." + }, + "stats": { + "totalInvoices": "סך החשבוניות", + "pending": "ממתינות", + "financed": "ממומנות", + "repaid": "נפרעו", + "activeInvestments": "השקעות פעילות", + "pendingOffers": "הצעות ממתינות", + "totalYield": "סך התשואה" + }, + "view": { + "grid": "תצוגת רשת", + "table": "תצוגת טבלה" + }, + "empty": { + "invoices": "אין עדיין חשבוניות.", + "createFirst": "צור את החשבונית הראשונה", + "investments": "אין עדיין השקעות פעילות." + }, + "cancel": { + "action": "ביטול חשבונית", + "title": "לבטל את החשבונית?", + "description": "החשבונית {id} תסומן כמבוטלת. לא ניתן לבטל פעולה זו.", + "confirm": "כן, בטל" + } + }, + "Marketplace": { + "title": "שוק החשבוניות", + "description": "עיין בחשבוניות הזמינות למימון והגש הצעות כדי להרוויח תשואה.", + "searchPlaceholder": "חיפוש לפי מזהה חשבונית, שם החייב או היוצר…", + "view": { + "suggested": "מומלץ עבורי", + "browseAll": "עיון בהכול" + }, + "filters": { + "allStatuses": "כל הסטטוסים", + "allCurrencies": "כל המטבעות" + }, + "sort": { + "label": "מיון חשבוניות", + "newest": "החדשות ביותר", + "oldest": "הישנות ביותר", + "amount_desc": "סכום: מהגבוה לנמוך", + "amount_asc": "סכום: מהנמוך לגבוה", + "due_soonest": "מועד פירעון: הקרוב ביותר" + }, + "empty": { + "title": "אין חשבוניות התואמות את הסינון", + "hint": "נסה לשנות את החיפוש או את הסינון." + }, + "clearSearch": "ניקוי החיפוש" + }, + "Portfolio": { + "title": "התיק שלך", + "description": "מעקב אחר הצעות מימון ותשואות — העדכונים זורמים בזמן אמת", + "refresh": "רענון", + "exportCsv": "ייצוא CSV", + "stats": { + "active": "השקעות פעילות", + "pending": "הצעות ממתינות", + "completed": "הושלמו", + "value": "שווי התיק (דולר)" + }, + "yield": { + "estimated": "תשואה משוערת עד כה: {amount}", + "accruing": "נצברת בזמן אמת על פני {count, plural, one {פוזיציה פעילה אחת} two {שתי פוזיציות פעילות} other {# פוזיציות פעילות}}", + "realized": "תשואה שמומשה: {amount}", + "acrossRepaid": "על פני {count, plural, one {הצעה אחת שנפרעה} two {שתי הצעות שנפרעו} other {# הצעות שנפרעו}}" + }, + "empty": { + "title": "אין עדיין הצעות מימון.", + "browse": "עיון בשוק" + }, + "position": { + "days": "{count, plural, one {יום אחד} two {יומיים} other {# ימים}}", + "funded": "מומן ב-{date}", + "apy": "תשואה שנתית", + "earnedToDate": "נצבר עד כה", + "repayment": "החזר", + "percentRepaid": "{percent} נפרעו", + "progressLabel": "{percent} מסך החוב נפרעו", + "repaidRemaining": "{repaid} נפרעו · {remaining} נותרו", + "updated": "עודכן {when}" + }, + "transfer": { + "title": "העברת פוזיציה", + "description": "אסימוני פוזיציה מייצגים את זכותך בחשבוניות ממומנות (אסימון אחד = יחידת בסיס אחת של הקרן). שלח אותם לארנק Stellar אחר כדי להעביר את הפוזיציה.", + "secondaryBoard": "מחפש קונה? פרסם את הפוזיציה בלוח המשני — הסליקה עדיין מתבצעת כאן, בהעברה הזו.", + "prefilled": "הסכום מולא מתוך הפרסום שלך ({amount} אסימונים). הזן את כתובת הקונה כדי לסלוק.", + "refreshBalance": "רענון יתרה", + "connectWallet": "חבר ארנק כדי לצפות בפוזיציות ולהעביר אותן.", + "notConfigured": "אסימוני פוזיציה אינם מוגדרים בפריסה הזו עדיין.", + "needsTrustline": "אסימוני פוזיציה הם נכסי Stellar — הוסף קו אמון POS פעם אחת כדי לקבל ולהעביר אותם.", + "addTrustline": "הוספת קו אמון POS", + "adding": "מוסיף…", + "trustlineAdded": "קו האמון נוסף", + "trustlineAddedHint": "הארנק שלך יכול כעת להחזיק אסימוני פוזיציה POS.", + "trustlineFailed": "קו האמון נכשל", + "trustlineFailedHint": "הגדרת קו האמון נכשלה", + "recipientLabel": "כתובת הנמען", + "amountLabel": "סכום", + "available": "(זמין: {balance})", + "transfer": "העברה", + "transferring": "מעביר…", + "invalidAddress": "כתובת לא חוקית", + "invalidAddressHint": "הזן כתובת Stellar חוקית (G…).", + "invalidAmount": "סכום לא חוקי", + "invalidAmountHint": "הזן סכום עם {decimals} ספרות עשרוניות לכל היותר.", + "insufficient": "יתרה לא מספיקה", + "insufficientHint": "אין ברשותך מספיק אסימוני פוזיציה להעברה הזו.", + "recipientTrustline": "הנמען זקוק לקו אמון", + "recipientTrustlineHint": "לארנק הנמען אין עדיין קו אמון POS. בקש ממנו להוסיף אחד (בכל ארנק או באפליקציה הזו) לפני ההעברה.", + "transferred": "הפוזיציה הועברה", + "transferredHint": "נשלחו {amount} אסימוני פוזיציה אל {recipient}.", + "transferFailed": "ההעברה נכשלה", + "transferFailedHint": "העסקה נכשלה" + } + }, + "Invoice": { + "title": "חשבונית", + "backToDashboard": "חזרה ללוח הבקרה", + "print": "הדפסה / ייצוא PDF", + "notFound": "החשבונית לא נמצאה", + "fields": { + "amount": "סכום", + "currency": "מטבע", + "dueDate": "מועד פירעון", + "originator": "יוצר" + }, + "counterparty": { + "lender": "מממן", + "business": "עסק" + }, + "cancel": { + "action": "ביטול", + "title": "לבטל את החשבונית?", + "description": "החשבונית תבוטל על השרשרת ולא תוכל לקבל עוד הצעות מימון. לא ניתן לבטל פעולה זו.", + "confirm": "ביטול החשבונית", + "done": "החשבונית בוטלה", + "doneHint": "החשבונית מבוטלת כעת על השרשרת.", + "failed": "ביטול החשבונית נכשל", + "undo": "ביטול פעולה", + "undoAlt": "ביטול הביטול", + "restored": "החשבונית שוחזרה", + "restoredHint": "נוצרה חשבונית חדשה באותם תנאים.", + "restoreFailed": "שחזור החשבונית נכשל" + } + }, + "Offers": { + "title": "הצעות מימון ({count})", + "empty": "אין עדיין הצעות.", + "makeOffer": "הגשת הצעה", + "markOverdue": "סימון כבאיחור", + "exportHint": "ייצוא הצעות כ-CSV", + "exportEmpty": "אין הצעות לייצוא", + "accept": "קבלה", + "reject": "דחייה", + "repay": "פירעון", + "reclaim": "מימוש", + "repayAmount": "סכום פירעון", + "days": "{count, plural, one {יום אחד} two {יומיים} other {# ימים}}", + "repaid": "{amount} נפרעו", + "remaining": "{amount} נותרו", + "remainingBalance": "יתרה: {remaining} (סך חוב {total} פחות {repaid})", + "form": { + "title": "הצעת מימון חדשה", + "amount": "סכום", + "currency": "מטבע", + "interest": "ריבית (נקודות בסיס)", + "interestHint": "500 = {example}", + "duration": "משך (ימים)", + "submit": "שליחת הצעה" + }, + "confirm": { + "rejectTitle": "לדחות את ההצעה?", + "rejectDescription": "המממן יקבל הודעה שההצעה נדחתה. לא ניתן לבטל פעולה זו.", + "reclaimTitle": "לממש את ההצעה?", + "reclaimDescription": "פעולה זו מסמנת את ההצעה כבכשל פירעון על השרשרת. הקרן כבר שולמה לעסק בעת הקבלה — הפעולה אינה מחזירה כספים ואינה ניתנת לביטול." + }, + "toast": { + "submitted": "ההצעה נשלחה!", + "submittedHint": "יוצר החשבונית יקבל הודעה.", + "submitFailed": "שליחת ההצעה נכשלה", + "accepted": "ההצעה התקבלה!", + "acceptedHint": "החשבונית מסומנת כעת כממומנת.", + "acceptFailed": "קבלת ההצעה נכשלה", + "rejected": "ההצעה נדחתה.", + "rejectFailed": "דחיית ההצעה נכשלה", + "invalidAmount": "הזן סכום חוקי", + "amountTooSmall": "הסכום חייב להיות גדול מאפס", + "repaidFull": "החשבונית נפרעה במלואה", + "repaidFullHint": "הקרן והתשואה הועברו למממן. החשבונית נפרעה.", + "repaidPartial": "הפירעון נשלח", + "repaidPartialHint": "פירעון חלקי נרשם על השרשרת. המשך לפרוע עד לסגירת היתרה.", + "repayFailed": "הפירעון נכשל", + "markedOverdue": "החשבונית סומנה כבאיחור.", + "overdueFailed": "הסימון כבאיחור נכשל", + "reclaimed": "ההצעה סומנה בכשל פירעון.", + "reclaimedHint": "זהו רישום על השרשרת — המשך בגבייה מחוץ לשרשרת.", + "reclaimFailed": "המימוש נכשל", + "undo": "ביטול פעולה", + "undoRejectAlt": "ביטול הדחייה", + "rejectUndone": "הדחייה בוטלה", + "rejectUndoneHint": "ההצעה ממתינה שוב.", + "undoRejectFailed": "ביטול הדחייה נכשל" + } + } +} diff --git a/invofi/apps/frontend/messages/ja.json b/invofi/apps/frontend/messages/ja.json new file mode 100644 index 000000000..5036082a2 --- /dev/null +++ b/invofi/apps/frontend/messages/ja.json @@ -0,0 +1,346 @@ +{ + "Navbar": { + "dashboard": "ダッシュボード", + "marketplace": "マーケット", + "portfolio": "ポートフォリオ", + "approvals": "承認", + "wrongNetwork": "ネットワークが違います", + "toggleTheme": "テーマを切り替え", + "testnet": "テストネット", + "viewContracts": "スマートコントラクトのアドレスを表示", + "settings": "設定", + "signOut": "ログアウト" + }, + "Footer": { + "tagline": "Stellar Soroban 上の分散型請求書ファイナンス", + "stats": "統計", + "github": "GitHub", + "docs": "ドキュメント", + "issues": "課題", + "contractOnStellar": "Stellar {network} のコントラクト:", + "viewOnStellarExpert": "InvoFi のコントラクトを Stellar Expert で表示", + "openSource": "オープンソース" + }, + "DashboardLayout": { + "title": "ダッシュボード", + "description": "登録済みの請求書を管理し、ファイナンス条件を追跡し、返済状況を確認します。" + }, + "Settings": { + "title": "設定", + "description": "アカウントの設定を管理します", + "profile": { + "label": "プロフィール", + "hint": "表示名を編集し、アカウント情報を確認します" + }, + "language": { + "title": "言語", + "label": "表示言語", + "hint": "画面の言語、数値と日付の書式、文字の方向を変更します。" + }, + "network": { + "title": "ネットワークとコントラクト", + "label": "Stellar ネットワーク", + "connected": "接続済み" + }, + "account": { + "title": "アカウント", + "signOut": "ログアウト", + "signingOut": "ログアウトしています…", + "signedOut": "ログアウトしました" + }, + "contracts": { + "title": "コントラクト", + "rpcUrl": "RPC の URL", + "horizonUrl": "Horizon の URL", + "registry": "レジストリ", + "financing": "ファイナンス", + "repayment": "返済", + "notConfigured": "未設定", + "copy": "コピー", + "copied": "コピーしました", + "explorer": "エクスプローラー", + "copyAria": "{label} のコントラクト ID をコピー", + "explorerAria": "{label} のコントラクトを Stellar Expert で開く", + "copyFailed": "コピーできませんでした", + "copyFailedHint": "クリップボードにアクセスできませんでした。" + } + }, + "Errors": { + "forbidden": { + "title": "アクセスが拒否されました", + "description": "このリソースにアクセスする権限がありません。", + "backHome": "ホームに戻る" + }, + "notFound": { + "title": "ページが見つかりません", + "description": "お探しのページは存在しないか、移動されました。", + "backHome": "ホームに戻る" + }, + "unexpected": { + "title": "問題が発生しました", + "description": "予期しないエラーが発生しました。もう一度お試しください。", + "retry": "再試行" + } + }, + "Status": { + "Pending": "保留中", + "Financed": "ファイナンス済み", + "Repaid": "返済済み", + "Overdue": "期限超過", + "Cancelled": "キャンセル済み", + "Accepted": "承諾済み", + "Rejected": "却下", + "Defaulted": "デフォルト" + }, + "Common": { + "confirm": "確認", + "cancel": "キャンセル", + "close": "閉じる", + "save": "保存", + "saving": "保存しています…", + "loading": "読み込んでいます…", + "retry": "再試行", + "back": "戻る", + "next": "次へ", + "submit": "送信", + "submitting": "送信しています…", + "copy": "コピー", + "copied": "コピーしました", + "export": "エクスポート", + "search": "検索", + "filter": "絞り込み", + "all": "すべて", + "none": "なし", + "optional": "任意", + "hold": { + "start": "長押しで確定", + "almost": "もう少し長押しすると確定します", + "cancelled": "確定を取り消しました" + } + }, + "Dashboard": { + "titleBusiness": "請求書ダッシュボード", + "titleLender": "レンダーのポートフォリオ", + "welcomeBack": "おかえりなさい", + "newInvoice": "新しい請求書", + "yourInvoices": "あなたの請求書", + "yourInvestments": "あなたの投資", + "browseMarketplace": "マーケットを見る", + "exportCsv": "CSV をエクスポート", + "role": { + "business": "事業者", + "lender": "レンダー", + "admin": "管理者" + }, + "wallet": { + "title": "Stellar ウォレット", + "connected": "ウォレットに接続しました。取引に署名できます。", + "disconnected": "コントラクトを利用するには Stellar ウォレットを接続してください。" + }, + "stats": { + "totalInvoices": "請求書の合計", + "pending": "保留中", + "financed": "ファイナンス済み", + "repaid": "返済済み", + "activeInvestments": "進行中の投資", + "pendingOffers": "保留中のオファー", + "totalYield": "合計利回り" + }, + "view": { + "grid": "グリッド表示", + "table": "テーブル表示" + }, + "empty": { + "invoices": "請求書はまだありません。", + "createFirst": "最初の請求書を作成する", + "investments": "進行中の投資はまだありません。" + }, + "cancel": { + "action": "請求書をキャンセル", + "title": "請求書をキャンセルしますか?", + "description": "請求書 {id} はキャンセル済みになります。この操作は取り消せません。", + "confirm": "はい、キャンセルします" + } + }, + "Marketplace": { + "title": "請求書マーケット", + "description": "ファイナンス可能な請求書を見て、利回りを得るためにオファーを出します。", + "searchPlaceholder": "請求書 ID、債務者名、発行者で検索…", + "view": { + "suggested": "あなたへのおすすめ", + "browseAll": "すべて見る" + }, + "filters": { + "allStatuses": "すべてのステータス", + "allCurrencies": "すべての通貨" + }, + "sort": { + "label": "請求書を並べ替え", + "newest": "新しい順", + "oldest": "古い順", + "amount_desc": "金額: 高い順", + "amount_asc": "金額: 低い順", + "due_soonest": "支払期日: 近い順" + }, + "empty": { + "title": "条件に一致する請求書はありません", + "hint": "検索条件や絞り込みを変えてみてください。" + }, + "clearSearch": "検索をクリア" + }, + "Portfolio": { + "title": "あなたのポートフォリオ", + "description": "ファイナンスのオファーとリターンを追跡します。更新はリアルタイムで届きます", + "refresh": "更新", + "exportCsv": "CSV をエクスポート", + "stats": { + "active": "進行中の投資", + "pending": "保留中のオファー", + "completed": "完了", + "value": "ポートフォリオ評価額 (USD)" + }, + "yield": { + "estimated": "現時点の推定利回り: {amount}", + "accruing": "{count, plural, =0 {進行中のポジションはありません} other {# 件の進行中ポジション}}でリアルタイムに積み上がっています", + "realized": "実現利回り: {amount}", + "acrossRepaid": "{count, plural, other {# 件の返済済みオファー}}にわたって" + }, + "empty": { + "title": "ファイナンスのオファーはまだありません。", + "browse": "マーケットを見る" + }, + "position": { + "days": "{count, plural, other {# 日}}", + "funded": "{date} にファイナンス", + "apy": "年利", + "earnedToDate": "現時点の獲得額", + "repayment": "返済", + "percentRepaid": "{percent} 返済済み", + "progressLabel": "支払総額の {percent} を返済済み", + "repaidRemaining": "{repaid} 返済済み・残り {remaining}", + "updated": "更新 {when}" + }, + "transfer": { + "title": "ポジションを譲渡", + "description": "ポジショントークンはファイナンス済み請求書に対するあなたの請求権を表します (1 トークン = 元本の 1 基本単位)。別の Stellar ウォレットに送るとポジションを譲渡できます。", + "secondaryBoard": "買い手をお探しですか?セカンダリーボードにポジションを掲載——決済は引き続きここで、この送付によって行われます。", + "prefilled": "金額は掲載内容から自動入力されました ({amount} トークン)。決済するには買い手のアドレスを入力してください。", + "refreshBalance": "残高を更新", + "connectWallet": "ポジションを表示・譲渡するにはウォレットを接続してください。", + "notConfigured": "このデプロイではポジショントークンがまだ設定されていません。", + "needsTrustline": "ポジショントークンは Stellar のアセットです。受け取りと譲渡には POS のトラストラインを一度追加してください。", + "addTrustline": "POS のトラストラインを追加", + "adding": "追加しています…", + "trustlineAdded": "トラストラインを追加しました", + "trustlineAddedHint": "ウォレットで POS ポジショントークンを保有できるようになりました。", + "trustlineFailed": "トラストラインに失敗しました", + "trustlineFailedHint": "トラストラインの設定に失敗しました", + "recipientLabel": "送付先アドレス", + "amountLabel": "金額", + "available": "(利用可能: {balance})", + "transfer": "譲渡", + "transferring": "譲渡しています…", + "invalidAddress": "アドレスが正しくありません", + "invalidAddressHint": "有効な Stellar アドレス (G…) を入力してください。", + "invalidAmount": "金額が正しくありません", + "invalidAmountHint": "小数点以下 {decimals} 桁までの金額を入力してください。", + "insufficient": "残高が不足しています", + "insufficientHint": "この譲渡に必要なポジショントークンが足りません。", + "recipientTrustline": "送付先にトラストラインが必要です", + "recipientTrustlineHint": "送付先ウォレットにはまだ POS のトラストラインがありません。譲渡の前に追加してもらってください (どのウォレットでも、またはこのアプリでも可能です)。", + "transferred": "ポジションを譲渡しました", + "transferredHint": "{recipient} に {amount} のポジショントークンを送りました。", + "transferFailed": "譲渡に失敗しました", + "transferFailedHint": "取引に失敗しました" + } + }, + "Invoice": { + "title": "請求書", + "backToDashboard": "ダッシュボードに戻る", + "print": "印刷 / PDF 出力", + "notFound": "請求書が見つかりません", + "fields": { + "amount": "金額", + "currency": "通貨", + "dueDate": "支払期日", + "originator": "発行者" + }, + "counterparty": { + "lender": "レンダー", + "business": "事業者" + }, + "cancel": { + "action": "キャンセル", + "title": "この請求書をキャンセルしますか?", + "description": "請求書はオンチェーンでキャンセルされ、以後ファイナンスのオファーを受け取れなくなります。この操作は取り消せません。", + "confirm": "請求書をキャンセル", + "done": "請求書をキャンセルしました", + "doneHint": "請求書はオンチェーンでキャンセルされました。", + "failed": "請求書のキャンセルに失敗しました", + "undo": "元に戻す", + "undoAlt": "キャンセルを元に戻す", + "restored": "請求書を復元しました", + "restoredHint": "同じ条件で新しい請求書を作成しました。", + "restoreFailed": "請求書の復元に失敗しました" + } + }, + "Offers": { + "title": "ファイナンスのオファー ({count})", + "empty": "オファーはまだありません。", + "makeOffer": "オファーを出す", + "markOverdue": "期限超過にする", + "exportHint": "オファーを CSV でエクスポート", + "exportEmpty": "エクスポートするオファーがありません", + "accept": "承諾", + "reject": "却下", + "repay": "返済", + "reclaim": "回収", + "repayAmount": "返済額", + "days": "{count, plural, other {# 日}}", + "repaid": "{amount} 返済済み", + "remaining": "残り {amount}", + "remainingBalance": "残高: {remaining} (支払総額 {total} から {repaid} を差し引き)", + "form": { + "title": "新しいファイナンスのオファー", + "amount": "金額", + "currency": "通貨", + "interest": "金利 (ベーシスポイント)", + "interestHint": "500 = {example}", + "duration": "期間 (日)", + "submit": "オファーを送信" + }, + "confirm": { + "rejectTitle": "このオファーを却下しますか?", + "rejectDescription": "オファーが却下されたことがレンダーに通知されます。この操作は取り消せません。", + "reclaimTitle": "このオファーを回収しますか?", + "reclaimDescription": "オファーをオンチェーンでデフォルトとして記録します。元本は承諾時にすでに事業者へ支払われています——資金は戻らず、取り消しもできません。" + }, + "toast": { + "submitted": "オファーを送信しました。", + "submittedHint": "請求書の発行者に通知されます。", + "submitFailed": "オファーの送信に失敗しました", + "accepted": "オファーを承諾しました。", + "acceptedHint": "請求書はファイナンス済みになりました。", + "acceptFailed": "オファーの承諾に失敗しました", + "rejected": "オファーを却下しました。", + "rejectFailed": "オファーの却下に失敗しました", + "invalidAmount": "有効な金額を入力してください", + "amountTooSmall": "金額は 0 より大きい必要があります", + "repaidFull": "請求書を全額返済しました", + "repaidFullHint": "元本と利回りをレンダーへ送金しました。請求書は返済済みです。", + "repaidPartial": "返済を送信しました", + "repaidPartialHint": "一部返済をオンチェーンに記録しました。残高が解消するまで返済を続けてください。", + "repayFailed": "返済に失敗しました", + "markedOverdue": "請求書を期限超過にしました。", + "overdueFailed": "期限超過にできませんでした", + "reclaimed": "オファーをデフォルトとして記録しました。", + "reclaimedHint": "これはオンチェーンの記録です——回収はオフチェーンで進めてください。", + "reclaimFailed": "回収に失敗しました", + "undo": "元に戻す", + "undoRejectAlt": "却下を元に戻す", + "rejectUndone": "却下を元に戻しました", + "rejectUndoneHint": "オファーは再び保留中です。", + "undoRejectFailed": "却下を元に戻せませんでした" + } + } +} diff --git a/invofi/apps/frontend/messages/ko.json b/invofi/apps/frontend/messages/ko.json new file mode 100644 index 000000000..2614b7ba0 --- /dev/null +++ b/invofi/apps/frontend/messages/ko.json @@ -0,0 +1,346 @@ +{ + "Navbar": { + "dashboard": "대시보드", + "marketplace": "마켓", + "portfolio": "포트폴리오", + "approvals": "승인", + "wrongNetwork": "잘못된 네트워크", + "toggleTheme": "테마 전환", + "testnet": "테스트넷", + "viewContracts": "스마트 컨트랙트 주소 보기", + "settings": "설정", + "signOut": "로그아웃" + }, + "Footer": { + "tagline": "Stellar Soroban 기반 탈중앙 인보이스 금융", + "stats": "통계", + "github": "GitHub", + "docs": "문서", + "issues": "이슈", + "contractOnStellar": "Stellar {network}의 컨트랙트:", + "viewOnStellarExpert": "Stellar Expert에서 InvoFi 컨트랙트 보기", + "openSource": "오픈 소스" + }, + "DashboardLayout": { + "title": "대시보드", + "description": "등록한 인보이스를 관리하고, 금융 제안을 추적하며, 상환 상태를 확인하세요." + }, + "Settings": { + "title": "설정", + "description": "계정 환경설정을 관리합니다", + "profile": { + "label": "프로필", + "hint": "표시 이름을 수정하고 계정 정보를 확인합니다" + }, + "language": { + "title": "언어", + "label": "표시 언어", + "hint": "인터페이스 언어, 숫자와 날짜 형식, 글자 방향을 바꿉니다." + }, + "network": { + "title": "네트워크 및 컨트랙트", + "label": "Stellar 네트워크", + "connected": "연결됨" + }, + "account": { + "title": "계정", + "signOut": "로그아웃", + "signingOut": "로그아웃 중…", + "signedOut": "로그아웃되었습니다" + }, + "contracts": { + "title": "컨트랙트", + "rpcUrl": "RPC 주소", + "horizonUrl": "Horizon 주소", + "registry": "레지스트리", + "financing": "금융", + "repayment": "상환", + "notConfigured": "설정되지 않음", + "copy": "복사", + "copied": "복사됨", + "explorer": "익스플로러", + "copyAria": "{label} 컨트랙트 ID 복사", + "explorerAria": "Stellar Expert에서 {label} 컨트랙트 열기", + "copyFailed": "복사 실패", + "copyFailedHint": "클립보드에 접근할 수 없습니다." + } + }, + "Errors": { + "forbidden": { + "title": "접근이 거부되었습니다", + "description": "이 리소스에 접근할 권한이 없습니다.", + "backHome": "홈으로 돌아가기" + }, + "notFound": { + "title": "페이지를 찾을 수 없습니다", + "description": "찾으시는 페이지가 없거나 이동되었습니다.", + "backHome": "홈으로 돌아가기" + }, + "unexpected": { + "title": "문제가 발생했습니다", + "description": "예기치 못한 오류가 발생했습니다. 다시 시도해 주세요.", + "retry": "다시 시도" + } + }, + "Status": { + "Pending": "대기 중", + "Financed": "금융 완료", + "Repaid": "상환 완료", + "Overdue": "연체", + "Cancelled": "취소됨", + "Accepted": "수락됨", + "Rejected": "거절됨", + "Defaulted": "부도" + }, + "Common": { + "confirm": "확인", + "cancel": "취소", + "close": "닫기", + "save": "저장", + "saving": "저장 중…", + "loading": "불러오는 중…", + "retry": "다시 시도", + "back": "뒤로", + "next": "다음", + "submit": "제출", + "submitting": "제출 중…", + "copy": "복사", + "copied": "복사됨", + "export": "내보내기", + "search": "검색", + "filter": "필터", + "all": "전체", + "none": "없음", + "optional": "선택 사항", + "hold": { + "start": "길게 눌러 확인", + "almost": "조금만 더 누르면 확인됩니다", + "cancelled": "확인이 취소되었습니다" + } + }, + "Dashboard": { + "titleBusiness": "인보이스 대시보드", + "titleLender": "대출자 포트폴리오", + "welcomeBack": "다시 오신 것을 환영합니다", + "newInvoice": "새 인보이스", + "yourInvoices": "내 인보이스", + "yourInvestments": "내 투자", + "browseMarketplace": "마켓 둘러보기", + "exportCsv": "CSV 내보내기", + "role": { + "business": "기업", + "lender": "대출자", + "admin": "관리자" + }, + "wallet": { + "title": "Stellar 지갑", + "connected": "지갑이 연결되었습니다. 트랜잭션에 서명할 수 있습니다.", + "disconnected": "컨트랙트를 사용하려면 Stellar 지갑을 연결하세요." + }, + "stats": { + "totalInvoices": "전체 인보이스", + "pending": "대기 중", + "financed": "금융 완료", + "repaid": "상환 완료", + "activeInvestments": "진행 중 투자", + "pendingOffers": "대기 중 제안", + "totalYield": "총 수익" + }, + "view": { + "grid": "그리드 보기", + "table": "테이블 보기" + }, + "empty": { + "invoices": "아직 인보이스가 없습니다.", + "createFirst": "첫 인보이스 만들기", + "investments": "아직 진행 중인 투자가 없습니다." + }, + "cancel": { + "action": "인보이스 취소", + "title": "인보이스를 취소할까요?", + "description": "인보이스 {id}가 취소됨으로 표시됩니다. 이 작업은 되돌릴 수 없습니다.", + "confirm": "예, 취소합니다" + } + }, + "Marketplace": { + "title": "인보이스 마켓", + "description": "금융이 가능한 인보이스를 살펴보고 제안을 넣어 수익을 얻으세요.", + "searchPlaceholder": "인보이스 ID, 채무자 이름 또는 발행자로 검색…", + "view": { + "suggested": "추천 항목", + "browseAll": "전체 보기" + }, + "filters": { + "allStatuses": "모든 상태", + "allCurrencies": "모든 통화" + }, + "sort": { + "label": "인보이스 정렬", + "newest": "최신순", + "oldest": "오래된순", + "amount_desc": "금액: 높은순", + "amount_asc": "금액: 낮은순", + "due_soonest": "만기일: 가까운순" + }, + "empty": { + "title": "필터와 일치하는 인보이스가 없습니다", + "hint": "검색어나 필터를 조정해 보세요." + }, + "clearSearch": "검색 지우기" + }, + "Portfolio": { + "title": "내 포트폴리오", + "description": "금융 제안과 수익을 추적하세요 — 업데이트가 실시간으로 도착합니다", + "refresh": "새로고침", + "exportCsv": "CSV 내보내기", + "stats": { + "active": "진행 중 투자", + "pending": "대기 중 제안", + "completed": "완료", + "value": "포트폴리오 가치 (USD)" + }, + "yield": { + "estimated": "현재까지 예상 수익: {amount}", + "accruing": "{count, plural, =0 {진행 중인 포지션 없음} other {# 개의 진행 중 포지션}}에서 실시간으로 누적 중", + "realized": "실현 수익: {amount}", + "acrossRepaid": "{count, plural, other {# 건의 상환 완료 제안}} 기준" + }, + "empty": { + "title": "아직 금융 제안이 없습니다.", + "browse": "마켓 둘러보기" + }, + "position": { + "days": "{count, plural, other {# 일}}", + "funded": "{date}에 금융", + "apy": "연 수익률", + "earnedToDate": "현재까지 수익", + "repayment": "상환", + "percentRepaid": "{percent} 상환", + "progressLabel": "총 상환액의 {percent} 상환", + "repaidRemaining": "{repaid} 상환 · {remaining} 남음", + "updated": "{when} 업데이트" + }, + "transfer": { + "title": "포지션 이전", + "description": "포지션 토큰은 금융된 인보이스에 대한 권리를 나타냅니다 (토큰 1개 = 원금 1 기본 단위). 다른 Stellar 지갑으로 보내 포지션을 이전하세요.", + "secondaryBoard": "매수자를 찾고 계신가요? 2차 게시판에 포지션을 등록하세요 — 정산은 여전히 여기에서, 이 이전으로 이뤄집니다.", + "prefilled": "등록 내용에서 금액이 채워졌습니다 ({amount} 토큰). 정산하려면 매수자 주소를 입력하세요.", + "refreshBalance": "잔액 새로고침", + "connectWallet": "포지션을 확인하고 이전하려면 지갑을 연결하세요.", + "notConfigured": "이 배포에는 아직 포지션 토큰이 설정되지 않았습니다.", + "needsTrustline": "포지션 토큰은 Stellar 자산입니다 — 수신과 이전을 위해 POS 신뢰선을 한 번 추가하세요.", + "addTrustline": "POS 신뢰선 추가", + "adding": "추가 중…", + "trustlineAdded": "신뢰선이 추가되었습니다", + "trustlineAddedHint": "이제 지갑에서 POS 포지션 토큰을 보유할 수 있습니다.", + "trustlineFailed": "신뢰선 실패", + "trustlineFailedHint": "신뢰선 설정에 실패했습니다", + "recipientLabel": "받는 주소", + "amountLabel": "금액", + "available": "(사용 가능: {balance})", + "transfer": "이전", + "transferring": "이전 중…", + "invalidAddress": "잘못된 주소", + "invalidAddressHint": "올바른 Stellar 주소(G…)를 입력하세요.", + "invalidAmount": "잘못된 금액", + "invalidAmountHint": "소수점 이하 최대 {decimals}자리까지 입력하세요.", + "insufficient": "잔액 부족", + "insufficientHint": "이 이전에 필요한 포지션 토큰이 부족합니다.", + "recipientTrustline": "받는 쪽에 신뢰선이 필요합니다", + "recipientTrustlineHint": "받는 지갑에 아직 POS 신뢰선이 없습니다. 이전하기 전에 추가해 달라고 요청하세요 (어떤 지갑에서도, 또는 이 앱에서도 가능합니다).", + "transferred": "포지션이 이전되었습니다", + "transferredHint": "{recipient}에게 포지션 토큰 {amount}개를 보냈습니다.", + "transferFailed": "이전 실패", + "transferFailedHint": "트랜잭션이 실패했습니다" + } + }, + "Invoice": { + "title": "인보이스", + "backToDashboard": "대시보드로 돌아가기", + "print": "인쇄 / PDF 내보내기", + "notFound": "인보이스를 찾을 수 없습니다", + "fields": { + "amount": "금액", + "currency": "통화", + "dueDate": "만기일", + "originator": "발행자" + }, + "counterparty": { + "lender": "대출자", + "business": "기업" + }, + "cancel": { + "action": "취소", + "title": "이 인보이스를 취소할까요?", + "description": "인보이스가 온체인에서 취소되며 이후 금융 제안을 받을 수 없습니다. 이 작업은 되돌릴 수 없습니다.", + "confirm": "인보이스 취소", + "done": "인보이스가 취소되었습니다", + "doneHint": "인보이스가 이제 온체인에서 취소되었습니다.", + "failed": "인보이스 취소에 실패했습니다", + "undo": "실행 취소", + "undoAlt": "취소 되돌리기", + "restored": "인보이스가 복원되었습니다", + "restoredHint": "동일한 조건의 새 인보이스가 생성되었습니다.", + "restoreFailed": "인보이스 복원에 실패했습니다" + } + }, + "Offers": { + "title": "금융 제안 ({count})", + "empty": "아직 제안이 없습니다.", + "makeOffer": "제안하기", + "markOverdue": "연체로 표시", + "exportHint": "제안을 CSV로 내보내기", + "exportEmpty": "내보낼 제안이 없습니다", + "accept": "수락", + "reject": "거절", + "repay": "상환", + "reclaim": "회수", + "repayAmount": "상환 금액", + "days": "{count, plural, other {# 일}}", + "repaid": "{amount} 상환", + "remaining": "{amount} 남음", + "remainingBalance": "남은 잔액: {remaining} (총 상환액 {total}에서 {repaid} 차감)", + "form": { + "title": "새 금융 제안", + "amount": "금액", + "currency": "통화", + "interest": "이자 (베이시스 포인트)", + "interestHint": "500 = {example}", + "duration": "기간 (일)", + "submit": "제안 보내기" + }, + "confirm": { + "rejectTitle": "이 제안을 거절할까요?", + "rejectDescription": "대출자에게 제안이 거절되었음이 통보됩니다. 이 작업은 되돌릴 수 없습니다.", + "reclaimTitle": "이 제안을 회수할까요?", + "reclaimDescription": "이 작업은 제안을 온체인에서 부도로 표시합니다. 원금은 수락 시 이미 기업에 지급되었습니다 — 자금이 반환되지 않으며 되돌릴 수 없습니다." + }, + "toast": { + "submitted": "제안을 보냈습니다!", + "submittedHint": "인보이스 발행자에게 알림이 갑니다.", + "submitFailed": "제안 전송에 실패했습니다", + "accepted": "제안을 수락했습니다!", + "acceptedHint": "인보이스가 이제 금융 완료로 표시됩니다.", + "acceptFailed": "제안 수락에 실패했습니다", + "rejected": "제안을 거절했습니다.", + "rejectFailed": "제안 거절에 실패했습니다", + "invalidAmount": "유효한 금액을 입력하세요", + "amountTooSmall": "금액은 0보다 커야 합니다", + "repaidFull": "인보이스를 전액 상환했습니다", + "repaidFullHint": "원금과 수익이 대출자에게 이체되었습니다. 인보이스가 상환 완료되었습니다.", + "repaidPartial": "상환을 보냈습니다", + "repaidPartialHint": "부분 상환이 온체인에 기록되었습니다. 잔액이 정리될 때까지 계속 상환하세요.", + "repayFailed": "상환에 실패했습니다", + "markedOverdue": "인보이스를 연체로 표시했습니다.", + "overdueFailed": "연체 표시에 실패했습니다", + "reclaimed": "제안을 부도로 표시했습니다.", + "reclaimedHint": "온체인 기록입니다 — 회수는 오프체인에서 진행하세요.", + "reclaimFailed": "회수에 실패했습니다", + "undo": "실행 취소", + "undoRejectAlt": "거절 되돌리기", + "rejectUndone": "거절을 되돌렸습니다", + "rejectUndoneHint": "제안이 다시 대기 중입니다.", + "undoRejectFailed": "거절을 되돌리지 못했습니다" + } + } +} diff --git a/invofi/apps/frontend/messages/pt.json b/invofi/apps/frontend/messages/pt.json new file mode 100644 index 000000000..c04edd4ca --- /dev/null +++ b/invofi/apps/frontend/messages/pt.json @@ -0,0 +1,346 @@ +{ + "Navbar": { + "dashboard": "Painel", + "marketplace": "Mercado", + "portfolio": "Carteira", + "approvals": "Aprovações", + "wrongNetwork": "rede incorreta", + "toggleTheme": "Alternar tema", + "testnet": "Testnet", + "viewContracts": "Ver os endereços dos contratos inteligentes", + "settings": "Configurações", + "signOut": "Sair" + }, + "Footer": { + "tagline": "Financiamento descentralizado de faturas na Stellar Soroban", + "stats": "Estatísticas", + "github": "GitHub", + "docs": "Documentação", + "issues": "Problemas", + "contractOnStellar": "Contrato na Stellar {network}:", + "viewOnStellarExpert": "Ver o contrato do InvoFi no Stellar Expert", + "openSource": "Código aberto" + }, + "DashboardLayout": { + "title": "Painel", + "description": "Gerencie suas faturas registradas, acompanhe as ofertas de financiamento e monitore o status do pagamento." + }, + "Settings": { + "title": "Configurações", + "description": "Gerencie as preferências da sua conta", + "profile": { + "label": "Perfil", + "hint": "Edite seu nome de exibição e veja os dados da conta" + }, + "language": { + "title": "Idioma", + "label": "Idioma da interface", + "hint": "Altera o idioma da interface, o formato de números e datas e a direção do texto." + }, + "network": { + "title": "Rede e contratos", + "label": "Rede Stellar", + "connected": "Conectado" + }, + "account": { + "title": "Conta", + "signOut": "Sair", + "signingOut": "Saindo…", + "signedOut": "Sessão encerrada com sucesso" + }, + "contracts": { + "title": "Contratos", + "rpcUrl": "URL do RPC", + "horizonUrl": "URL do Horizon", + "registry": "Registro", + "financing": "Financiamento", + "repayment": "Pagamento", + "notConfigured": "não configurado", + "copy": "Copiar", + "copied": "Copiado", + "explorer": "Explorador", + "copyAria": "Copiar o ID do contrato {label}", + "explorerAria": "Abrir o contrato {label} no Stellar Expert", + "copyFailed": "Falha ao copiar", + "copyFailedHint": "Não foi possível acessar a área de transferência." + } + }, + "Errors": { + "forbidden": { + "title": "Acesso negado", + "description": "Você não tem permissão para acessar este recurso.", + "backHome": "Voltar ao início" + }, + "notFound": { + "title": "Página não encontrada", + "description": "A página que você procura não existe ou foi movida.", + "backHome": "Voltar ao início" + }, + "unexpected": { + "title": "Algo deu errado", + "description": "Ocorreu um erro inesperado. Tente novamente.", + "retry": "Tentar de novo" + } + }, + "Status": { + "Pending": "Pendente", + "Financed": "Financiada", + "Repaid": "Paga", + "Overdue": "Vencida", + "Cancelled": "Cancelada", + "Accepted": "Aceita", + "Rejected": "Recusada", + "Defaulted": "Inadimplente" + }, + "Common": { + "confirm": "Confirmar", + "cancel": "Cancelar", + "close": "Fechar", + "save": "Salvar", + "saving": "Salvando…", + "loading": "Carregando…", + "retry": "Tentar de novo", + "back": "Voltar", + "next": "Avançar", + "submit": "Enviar", + "submitting": "Enviando…", + "copy": "Copiar", + "copied": "Copiado", + "export": "Exportar", + "search": "Buscar", + "filter": "Filtrar", + "all": "Tudo", + "none": "Nenhum", + "optional": "Opcional", + "hold": { + "start": "Mantenha pressionado para confirmar", + "almost": "Segure mais um pouco para confirmar", + "cancelled": "Confirmação cancelada" + } + }, + "Dashboard": { + "titleBusiness": "Painel de faturas", + "titleLender": "Carteira do financiador", + "welcomeBack": "Bem-vindo de volta", + "newInvoice": "Nova fatura", + "yourInvoices": "Suas faturas", + "yourInvestments": "Seus investimentos", + "browseMarketplace": "Explorar o mercado", + "exportCsv": "Exportar CSV", + "role": { + "business": "Empresa", + "lender": "Financiador", + "admin": "Administrador" + }, + "wallet": { + "title": "Carteira Stellar", + "connected": "Carteira conectada. Você pode assinar transações.", + "disconnected": "Conecte sua carteira Stellar para interagir com os contratos." + }, + "stats": { + "totalInvoices": "Total de faturas", + "pending": "Pendentes", + "financed": "Financiadas", + "repaid": "Pagas", + "activeInvestments": "Investimentos ativos", + "pendingOffers": "Ofertas pendentes", + "totalYield": "Rendimento total" + }, + "view": { + "grid": "Visualização em grade", + "table": "Visualização em tabela" + }, + "empty": { + "invoices": "Ainda não há faturas.", + "createFirst": "Crie sua primeira fatura", + "investments": "Ainda não há investimentos ativos." + }, + "cancel": { + "action": "Cancelar fatura", + "title": "Cancelar a fatura?", + "description": "A fatura {id} será marcada como cancelada. Esta ação não pode ser desfeita.", + "confirm": "Sim, cancelar" + } + }, + "Marketplace": { + "title": "Mercado de faturas", + "description": "Explore as faturas disponíveis para financiamento e envie ofertas para obter rendimento.", + "searchPlaceholder": "Buscar por ID da fatura, nome do devedor ou emissor…", + "view": { + "suggested": "Sugeridas para mim", + "browseAll": "Ver todas" + }, + "filters": { + "allStatuses": "Todos os status", + "allCurrencies": "Todas as moedas" + }, + "sort": { + "label": "Ordenar faturas", + "newest": "Mais recentes primeiro", + "oldest": "Mais antigas primeiro", + "amount_desc": "Valor: do maior para o menor", + "amount_asc": "Valor: do menor para o maior", + "due_soonest": "Vencimento: mais próximo" + }, + "empty": { + "title": "Nenhuma fatura corresponde aos seus filtros", + "hint": "Tente ajustar a busca ou os filtros." + }, + "clearSearch": "Limpar a busca" + }, + "Portfolio": { + "title": "Sua carteira", + "description": "Acompanhe suas ofertas de financiamento e seus retornos — as atualizações chegam ao vivo", + "refresh": "Atualizar", + "exportCsv": "Exportar CSV", + "stats": { + "active": "Investimentos ativos", + "pending": "Ofertas pendentes", + "completed": "Concluídos", + "value": "Valor da carteira (USD)" + }, + "yield": { + "estimated": "Rendimento estimado até agora: {amount}", + "accruing": "Acumulando em tempo real em {count, plural, =0 {nenhuma posição ativa} one {# posição ativa} other {# posições ativas}}", + "realized": "Rendimento realizado: {amount}", + "acrossRepaid": "Em {count, plural, one {# oferta paga} other {# ofertas pagas}}" + }, + "empty": { + "title": "Ainda não há ofertas de financiamento.", + "browse": "Explorar o mercado" + }, + "position": { + "days": "{count, plural, one {# dia} other {# dias}}", + "funded": "Financiada em {date}", + "apy": "Rendimento anual", + "earnedToDate": "Ganho até agora", + "repayment": "Pagamento", + "percentRepaid": "{percent} pago", + "progressLabel": "{percent} do total devido pago", + "repaidRemaining": "{repaid} pago · {remaining} restante", + "updated": "atualizado {when}" + }, + "transfer": { + "title": "Transferir posição", + "description": "Os tokens de posição representam seu direito sobre faturas financiadas (1 token = 1 unidade base do principal). Envie-os a outra carteira Stellar para transferir a posição.", + "secondaryBoard": "Procurando um comprador? Anuncie a posição no quadro secundário — a liquidação continua acontecendo aqui, com esta transferência.", + "prefilled": "Valor preenchido a partir do seu anúncio ({amount} tokens). Informe o endereço do comprador para liquidar.", + "refreshBalance": "Atualizar saldo", + "connectWallet": "Conecte uma carteira para ver e transferir posições.", + "notConfigured": "Os tokens de posição ainda não estão configurados nesta implantação.", + "needsTrustline": "Tokens de posição são ativos Stellar — adicione uma linha de confiança POS uma vez para recebê-los e transferi-los.", + "addTrustline": "Adicionar linha de confiança POS", + "adding": "Adicionando…", + "trustlineAdded": "Linha de confiança adicionada", + "trustlineAddedHint": "Sua carteira já pode manter tokens de posição POS.", + "trustlineFailed": "Falha na linha de confiança", + "trustlineFailedHint": "Não foi possível configurar a linha de confiança", + "recipientLabel": "Endereço do destinatário", + "amountLabel": "Valor", + "available": "(disponível: {balance})", + "transfer": "Transferir", + "transferring": "Transferindo…", + "invalidAddress": "Endereço inválido", + "invalidAddressHint": "Informe um endereço Stellar válido (G…).", + "invalidAmount": "Valor inválido", + "invalidAmountHint": "Informe um valor com no máximo {decimals} casas decimais.", + "insufficient": "Saldo insuficiente", + "insufficientHint": "Você não tem tokens de posição suficientes para esta transferência.", + "recipientTrustline": "O destinatário precisa de uma linha de confiança", + "recipientTrustlineHint": "A carteira do destinatário ainda não tem linha de confiança POS. Peça que ele adicione uma (em qualquer carteira ou neste aplicativo) antes de transferir.", + "transferred": "Posição transferida", + "transferredHint": "Foram enviados {amount} tokens de posição para {recipient}.", + "transferFailed": "Falha na transferência", + "transferFailedHint": "A transação falhou" + } + }, + "Invoice": { + "title": "Fatura", + "backToDashboard": "Voltar ao painel", + "print": "Imprimir / Exportar PDF", + "notFound": "Fatura não encontrada", + "fields": { + "amount": "Valor", + "currency": "Moeda", + "dueDate": "Vencimento", + "originator": "Emissor" + }, + "counterparty": { + "lender": "Financiador", + "business": "Empresa" + }, + "cancel": { + "action": "Cancelar", + "title": "Cancelar esta fatura?", + "description": "A fatura será cancelada on-chain e não poderá mais receber ofertas de financiamento. Esta ação não pode ser desfeita.", + "confirm": "Cancelar fatura", + "done": "Fatura cancelada", + "doneHint": "A fatura já está cancelada on-chain.", + "failed": "Não foi possível cancelar a fatura", + "undo": "Desfazer", + "undoAlt": "Desfazer o cancelamento", + "restored": "Fatura restaurada", + "restoredHint": "Foi criada uma nova fatura com as mesmas condições.", + "restoreFailed": "Falha ao restaurar a fatura" + } + }, + "Offers": { + "title": "Ofertas de financiamento ({count})", + "empty": "Ainda não há ofertas.", + "makeOffer": "Fazer oferta", + "markOverdue": "Marcar como vencida", + "exportHint": "Exportar ofertas em CSV", + "exportEmpty": "Não há ofertas para exportar", + "accept": "Aceitar", + "reject": "Recusar", + "repay": "Pagar", + "reclaim": "Reivindicar", + "repayAmount": "Valor do pagamento", + "days": "{count, plural, one {# dia} other {# dias}}", + "repaid": "{amount} pago", + "remaining": "{amount} restante", + "remainingBalance": "Saldo restante: {remaining} (total devido {total} menos {repaid})", + "form": { + "title": "Nova oferta de financiamento", + "amount": "Valor", + "currency": "Moeda", + "interest": "Juros (pontos-base)", + "interestHint": "500 = {example}", + "duration": "Duração (dias)", + "submit": "Enviar oferta" + }, + "confirm": { + "rejectTitle": "Recusar esta oferta?", + "rejectDescription": "O financiador será notificado de que a oferta foi recusada. Esta ação não pode ser desfeita.", + "reclaimTitle": "Reivindicar esta oferta?", + "reclaimDescription": "Isto marca a oferta como inadimplente on-chain. O principal já foi pago à empresa na aceitação — isto não devolve fundos e não pode ser desfeito." + }, + "toast": { + "submitted": "Oferta enviada!", + "submittedHint": "O emissor da fatura será notificado.", + "submitFailed": "Falha ao enviar a oferta", + "accepted": "Oferta aceita!", + "acceptedHint": "A fatura agora está marcada como financiada.", + "acceptFailed": "Falha ao aceitar a oferta", + "rejected": "Oferta recusada.", + "rejectFailed": "Falha ao recusar a oferta", + "invalidAmount": "Informe um valor válido", + "amountTooSmall": "O valor deve ser maior que zero", + "repaidFull": "Fatura totalmente paga", + "repaidFullHint": "Principal + rendimento transferidos ao financiador. A fatura está paga.", + "repaidPartial": "Pagamento enviado", + "repaidPartialHint": "Pagamento parcial registrado on-chain. Continue pagando até quitar o saldo.", + "repayFailed": "Falha ao pagar", + "markedOverdue": "Fatura marcada como vencida.", + "overdueFailed": "Falha ao marcar como vencida", + "reclaimed": "Oferta marcada como inadimplente.", + "reclaimedHint": "É um registro on-chain — a recuperação é feita fora da cadeia.", + "reclaimFailed": "Falha ao reivindicar", + "undo": "Desfazer", + "undoRejectAlt": "Desfazer a recusa", + "rejectUndone": "Recusa desfeita", + "rejectUndoneHint": "A oferta está pendente novamente.", + "undoRejectFailed": "Falha ao desfazer a recusa" + } + } +} diff --git a/invofi/apps/frontend/messages/tr.json b/invofi/apps/frontend/messages/tr.json new file mode 100644 index 000000000..251075502 --- /dev/null +++ b/invofi/apps/frontend/messages/tr.json @@ -0,0 +1,346 @@ +{ + "Navbar": { + "dashboard": "Panel", + "marketplace": "Pazar", + "portfolio": "Portföy", + "approvals": "Onaylar", + "wrongNetwork": "yanlış ağ", + "toggleTheme": "Temayı değiştir", + "testnet": "Test ağı", + "viewContracts": "Akıllı sözleşme adreslerini gör", + "settings": "Ayarlar", + "signOut": "Çıkış yap" + }, + "Footer": { + "tagline": "Stellar Soroban üzerinde merkeziyetsiz fatura finansmanı", + "stats": "İstatistikler", + "github": "GitHub", + "docs": "Belgeler", + "issues": "Sorunlar", + "contractOnStellar": "Stellar {network} üzerindeki sözleşme:", + "viewOnStellarExpert": "InvoFi sözleşmesini Stellar Expert’te gör", + "openSource": "Açık kaynak" + }, + "DashboardLayout": { + "title": "Panel", + "description": "Kayıtlı faturalarını yönet, finansman tekliflerini izle ve geri ödeme durumunu takip et." + }, + "Settings": { + "title": "Ayarlar", + "description": "Hesap tercihlerini yönet", + "profile": { + "label": "Profil", + "hint": "Görünen adını düzenle ve hesap ayrıntılarını gör" + }, + "language": { + "title": "Dil", + "label": "Görüntüleme dili", + "hint": "Arayüz dilini, sayı ve tarih biçimlerini ve metin yönünü değiştirir." + }, + "network": { + "title": "Ağ ve sözleşmeler", + "label": "Stellar ağı", + "connected": "Bağlı" + }, + "account": { + "title": "Hesap", + "signOut": "Çıkış yap", + "signingOut": "Çıkış yapılıyor…", + "signedOut": "Başarıyla çıkış yapıldı" + }, + "contracts": { + "title": "Sözleşmeler", + "rpcUrl": "RPC adresi", + "horizonUrl": "Horizon adresi", + "registry": "Kayıt", + "financing": "Finansman", + "repayment": "Geri ödeme", + "notConfigured": "yapılandırılmadı", + "copy": "Kopyala", + "copied": "Kopyalandı", + "explorer": "Gezgin", + "copyAria": "{label} sözleşme kimliğini kopyala", + "explorerAria": "{label} sözleşmesini Stellar Expert’te aç", + "copyFailed": "Kopyalanamadı", + "copyFailedHint": "Panoya erişilemedi." + } + }, + "Errors": { + "forbidden": { + "title": "Erişim reddedildi", + "description": "Bu kaynağa erişim izniniz yok.", + "backHome": "Ana sayfaya dön" + }, + "notFound": { + "title": "Sayfa bulunamadı", + "description": "Aradığınız sayfa yok ya da taşınmış.", + "backHome": "Ana sayfaya dön" + }, + "unexpected": { + "title": "Bir şeyler ters gitti", + "description": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", + "retry": "Tekrar dene" + } + }, + "Status": { + "Pending": "Beklemede", + "Financed": "Finanse edildi", + "Repaid": "Geri ödendi", + "Overdue": "Gecikmiş", + "Cancelled": "İptal edildi", + "Accepted": "Kabul edildi", + "Rejected": "Reddedildi", + "Defaulted": "Temerrüde düştü" + }, + "Common": { + "confirm": "Onayla", + "cancel": "Vazgeç", + "close": "Kapat", + "save": "Kaydet", + "saving": "Kaydediliyor…", + "loading": "Yükleniyor…", + "retry": "Tekrar dene", + "back": "Geri", + "next": "İleri", + "submit": "Gönder", + "submitting": "Gönderiliyor…", + "copy": "Kopyala", + "copied": "Kopyalandı", + "export": "Dışa aktar", + "search": "Ara", + "filter": "Filtrele", + "all": "Tümü", + "none": "Hiçbiri", + "optional": "İsteğe bağlı", + "hold": { + "start": "Onaylamak için basılı tutun", + "almost": "Onaylamak için biraz daha tutun", + "cancelled": "Onay iptal edildi" + } + }, + "Dashboard": { + "titleBusiness": "Fatura paneli", + "titleLender": "Finansör portföyü", + "welcomeBack": "Tekrar hoş geldiniz", + "newInvoice": "Yeni fatura", + "yourInvoices": "Faturalarınız", + "yourInvestments": "Yatırımlarınız", + "browseMarketplace": "Pazarı incele", + "exportCsv": "CSV dışa aktar", + "role": { + "business": "İşletme", + "lender": "Finansör", + "admin": "Yönetici" + }, + "wallet": { + "title": "Stellar cüzdanı", + "connected": "Cüzdan bağlı. İşlemleri imzalayabilirsiniz.", + "disconnected": "Sözleşmelerle etkileşim için Stellar cüzdanınızı bağlayın." + }, + "stats": { + "totalInvoices": "Toplam fatura", + "pending": "Beklemede", + "financed": "Finanse edildi", + "repaid": "Geri ödendi", + "activeInvestments": "Aktif yatırımlar", + "pendingOffers": "Bekleyen teklifler", + "totalYield": "Toplam getiri" + }, + "view": { + "grid": "Izgara görünümü", + "table": "Tablo görünümü" + }, + "empty": { + "invoices": "Henüz fatura yok.", + "createFirst": "İlk faturanızı oluşturun", + "investments": "Henüz aktif yatırım yok." + }, + "cancel": { + "action": "Faturayı iptal et", + "title": "Fatura iptal edilsin mi?", + "description": "{id} numaralı fatura iptal edildi olarak işaretlenecek. Bu işlem geri alınamaz.", + "confirm": "Evet, iptal et" + } + }, + "Marketplace": { + "title": "Fatura pazarı", + "description": "Finansmana açık faturaları inceleyin ve getiri için teklif verin.", + "searchPlaceholder": "Fatura kimliği, borçlu adı veya düzenleyene göre ara…", + "view": { + "suggested": "Bana önerilenler", + "browseAll": "Tümünü gör" + }, + "filters": { + "allStatuses": "Tüm durumlar", + "allCurrencies": "Tüm para birimleri" + }, + "sort": { + "label": "Faturaları sırala", + "newest": "Önce en yeni", + "oldest": "Önce en eski", + "amount_desc": "Tutar: yüksekten düşüğe", + "amount_asc": "Tutar: düşükten yükseğe", + "due_soonest": "Vade: en yakın" + }, + "empty": { + "title": "Filtrelerinizle eşleşen fatura yok", + "hint": "Aramayı ya da filtreleri değiştirmeyi deneyin." + }, + "clearSearch": "Aramayı temizle" + }, + "Portfolio": { + "title": "Portföyünüz", + "description": "Finansman tekliflerinizi ve getirilerinizi izleyin — güncellemeler canlı akar", + "refresh": "Yenile", + "exportCsv": "CSV dışa aktar", + "stats": { + "active": "Aktif yatırımlar", + "pending": "Bekleyen teklifler", + "completed": "Tamamlanan", + "value": "Portföy değeri (USD)" + }, + "yield": { + "estimated": "Bugüne kadarki tahmini getiri: {amount}", + "accruing": "{count, plural, =0 {aktif pozisyon yok} other {# aktif pozisyon}} üzerinde gerçek zamanlı birikiyor", + "realized": "Gerçekleşen getiri: {amount}", + "acrossRepaid": "{count, plural, other {# geri ödenmiş teklif}} üzerinde" + }, + "empty": { + "title": "Henüz finansman teklifi yok.", + "browse": "Pazarı incele" + }, + "position": { + "days": "{count, plural, other {# gün}}", + "funded": "{date} tarihinde finanse edildi", + "apy": "Yıllık getiri", + "earnedToDate": "Bugüne kadar kazanılan", + "repayment": "Geri ödeme", + "percentRepaid": "{percent} geri ödendi", + "progressLabel": "Toplam borcun {percent} kadarı geri ödendi", + "repaidRemaining": "{repaid} ödendi · {remaining} kaldı", + "updated": "güncellendi {when}" + }, + "transfer": { + "title": "Pozisyonu aktar", + "description": "Pozisyon jetonları finanse edilmiş faturalar üzerindeki hakkınızı temsil eder (1 jeton = anaparanın 1 temel birimi). Pozisyonu aktarmak için başka bir Stellar cüzdanına gönderin.", + "secondaryBoard": "Alıcı mı arıyorsunuz? Pozisyonu ikincil panoda listeleyin — mutabakat yine burada, bu aktarımla yapılır.", + "prefilled": "Tutar ilanınızdan dolduruldu ({amount} jeton). Mutabakat için alıcının adresini girin.", + "refreshBalance": "Bakiyeyi yenile", + "connectWallet": "Pozisyonları görmek ve aktarmak için bir cüzdan bağlayın.", + "notConfigured": "Pozisyon jetonları bu dağıtımda henüz yapılandırılmadı.", + "needsTrustline": "Pozisyon jetonları birer Stellar varlığıdır — bunları alıp aktarabilmek için bir kez POS güven hattı ekleyin.", + "addTrustline": "POS güven hattı ekle", + "adding": "Ekleniyor…", + "trustlineAdded": "Güven hattı eklendi", + "trustlineAddedHint": "Cüzdanınız artık POS pozisyon jetonu tutabilir.", + "trustlineFailed": "Güven hattı başarısız", + "trustlineFailedHint": "Güven hattı kurulamadı", + "recipientLabel": "Alıcı adresi", + "amountLabel": "Tutar", + "available": "(kullanılabilir: {balance})", + "transfer": "Aktar", + "transferring": "Aktarılıyor…", + "invalidAddress": "Geçersiz adres", + "invalidAddressHint": "Geçerli bir Stellar adresi girin (G…).", + "invalidAmount": "Geçersiz tutar", + "invalidAmountHint": "En fazla {decimals} ondalık basamaklı bir tutar girin.", + "insufficient": "Yetersiz bakiye", + "insufficientHint": "Bu aktarım için yeterli pozisyon jetonunuz yok.", + "recipientTrustline": "Alıcının güven hattına ihtiyacı var", + "recipientTrustlineHint": "Alıcının cüzdanında henüz POS güven hattı yok. Aktarmadan önce bir tane eklemesini isteyin (herhangi bir cüzdanda ya da bu uygulamada).", + "transferred": "Pozisyon aktarıldı", + "transferredHint": "{recipient} adresine {amount} pozisyon jetonu gönderildi.", + "transferFailed": "Aktarım başarısız", + "transferFailedHint": "İşlem başarısız oldu" + } + }, + "Invoice": { + "title": "Fatura", + "backToDashboard": "Panele dön", + "print": "Yazdır / PDF olarak dışa aktar", + "notFound": "Fatura bulunamadı", + "fields": { + "amount": "Tutar", + "currency": "Para birimi", + "dueDate": "Vade", + "originator": "Düzenleyen" + }, + "counterparty": { + "lender": "Finansör", + "business": "İşletme" + }, + "cancel": { + "action": "İptal", + "title": "Bu fatura iptal edilsin mi?", + "description": "Fatura zincir üzerinde iptal edilecek ve artık finansman teklifi alamayacak. Bu işlem geri alınamaz.", + "confirm": "Faturayı iptal et", + "done": "Fatura iptal edildi", + "doneHint": "Fatura artık zincir üzerinde iptal edilmiş durumda.", + "failed": "Fatura iptal edilemedi", + "undo": "Geri al", + "undoAlt": "İptali geri al", + "restored": "Fatura geri yüklendi", + "restoredHint": "Aynı koşullarla yeni bir fatura oluşturuldu.", + "restoreFailed": "Fatura geri yüklenemedi" + } + }, + "Offers": { + "title": "Finansman teklifleri ({count})", + "empty": "Henüz teklif yok.", + "makeOffer": "Teklif ver", + "markOverdue": "Gecikmiş olarak işaretle", + "exportHint": "Teklifleri CSV olarak dışa aktar", + "exportEmpty": "Dışa aktarılacak teklif yok", + "accept": "Kabul et", + "reject": "Reddet", + "repay": "Geri öde", + "reclaim": "Geri al", + "repayAmount": "Geri ödeme tutarı", + "days": "{count, plural, other {# gün}}", + "repaid": "{amount} ödendi", + "remaining": "{amount} kaldı", + "remainingBalance": "Kalan bakiye: {remaining} (toplam borç {total} eksi {repaid})", + "form": { + "title": "Yeni finansman teklifi", + "amount": "Tutar", + "currency": "Para birimi", + "interest": "Faiz (baz puan)", + "interestHint": "500 = {example}", + "duration": "Süre (gün)", + "submit": "Teklifi gönder" + }, + "confirm": { + "rejectTitle": "Bu teklif reddedilsin mi?", + "rejectDescription": "Finansöre teklifinin reddedildiği bildirilecek. Bu işlem geri alınamaz.", + "reclaimTitle": "Bu teklif geri alınsın mı?", + "reclaimDescription": "Bu işlem teklifi zincir üzerinde temerrüde düşmüş olarak işaretler. Anapara kabul sırasında işletmeye ödenmişti — bu işlem parayı geri getirmez ve geri alınamaz." + }, + "toast": { + "submitted": "Teklif gönderildi!", + "submittedHint": "Faturayı düzenleyene bildirilecek.", + "submitFailed": "Teklif gönderilemedi", + "accepted": "Teklif kabul edildi!", + "acceptedHint": "Fatura artık finanse edildi olarak işaretli.", + "acceptFailed": "Teklif kabul edilemedi", + "rejected": "Teklif reddedildi.", + "rejectFailed": "Teklif reddedilemedi", + "invalidAmount": "Geçerli bir tutar girin", + "amountTooSmall": "Tutar sıfırdan büyük olmalı", + "repaidFull": "Fatura tamamen geri ödendi", + "repaidFullHint": "Anapara ve getiri finansöre aktarıldı. Fatura geri ödendi.", + "repaidPartial": "Geri ödeme gönderildi", + "repaidPartialHint": "Kısmi geri ödeme zincire yazıldı. Bakiye kapanana dek ödemeye devam edin.", + "repayFailed": "Geri ödeme başarısız", + "markedOverdue": "Fatura gecikmiş olarak işaretlendi.", + "overdueFailed": "Gecikmiş olarak işaretlenemedi", + "reclaimed": "Teklif temerrüt olarak işaretlendi.", + "reclaimedHint": "Bu bir zincir kaydıdır — tahsilatı zincir dışında sürdürün.", + "reclaimFailed": "Geri alma başarısız", + "undo": "Geri al", + "undoRejectAlt": "Reddi geri al", + "rejectUndone": "Ret geri alındı", + "rejectUndoneHint": "Teklif yeniden beklemede.", + "undoRejectFailed": "Ret geri alınamadı" + } + } +} diff --git a/invofi/apps/frontend/messages/zh.json b/invofi/apps/frontend/messages/zh.json new file mode 100644 index 000000000..986937827 --- /dev/null +++ b/invofi/apps/frontend/messages/zh.json @@ -0,0 +1,346 @@ +{ + "Navbar": { + "dashboard": "仪表板", + "marketplace": "市场", + "portfolio": "投资组合", + "approvals": "审批", + "wrongNetwork": "网络不正确", + "toggleTheme": "切换主题", + "testnet": "测试网", + "viewContracts": "查看智能合约地址", + "settings": "设置", + "signOut": "退出登录" + }, + "Footer": { + "tagline": "基于 Stellar Soroban 的去中心化发票融资", + "stats": "统计", + "github": "GitHub", + "docs": "文档", + "issues": "问题", + "contractOnStellar": "Stellar {network} 上的合约:", + "viewOnStellarExpert": "在 Stellar Expert 上查看 InvoFi 合约", + "openSource": "开源" + }, + "DashboardLayout": { + "title": "仪表板", + "description": "管理已登记的发票、跟踪融资报价并监控还款状态。" + }, + "Settings": { + "title": "设置", + "description": "管理你的账户偏好", + "profile": { + "label": "个人资料", + "hint": "编辑显示名称并查看账户详情" + }, + "language": { + "title": "语言", + "label": "显示语言", + "hint": "更改界面语言、数字与日期格式以及文字方向。" + }, + "network": { + "title": "网络与合约", + "label": "Stellar 网络", + "connected": "已连接" + }, + "account": { + "title": "账户", + "signOut": "退出登录", + "signingOut": "正在退出…", + "signedOut": "已成功退出登录" + }, + "contracts": { + "title": "合约", + "rpcUrl": "RPC 地址", + "horizonUrl": "Horizon 地址", + "registry": "登记", + "financing": "融资", + "repayment": "还款", + "notConfigured": "未配置", + "copy": "复制", + "copied": "已复制", + "explorer": "浏览器", + "copyAria": "复制 {label} 合约 ID", + "explorerAria": "在 Stellar Expert 中打开 {label} 合约", + "copyFailed": "复制失败", + "copyFailedHint": "无法访问剪贴板。" + } + }, + "Errors": { + "forbidden": { + "title": "访问被拒绝", + "description": "你没有访问该资源的权限。", + "backHome": "返回首页" + }, + "notFound": { + "title": "页面未找到", + "description": "你要找的页面不存在或已被移动。", + "backHome": "返回首页" + }, + "unexpected": { + "title": "出错了", + "description": "发生了意外错误。请重试。", + "retry": "重试" + } + }, + "Status": { + "Pending": "待处理", + "Financed": "已融资", + "Repaid": "已还款", + "Overdue": "已逾期", + "Cancelled": "已取消", + "Accepted": "已接受", + "Rejected": "已拒绝", + "Defaulted": "已违约" + }, + "Common": { + "confirm": "确认", + "cancel": "取消", + "close": "关闭", + "save": "保存", + "saving": "保存中…", + "loading": "加载中…", + "retry": "重试", + "back": "返回", + "next": "下一步", + "submit": "提交", + "submitting": "提交中…", + "copy": "复制", + "copied": "已复制", + "export": "导出", + "search": "搜索", + "filter": "筛选", + "all": "全部", + "none": "无", + "optional": "可选", + "hold": { + "start": "按住以确认", + "almost": "再按住片刻即可确认", + "cancelled": "确认已取消" + } + }, + "Dashboard": { + "titleBusiness": "发票仪表板", + "titleLender": "出资方组合", + "welcomeBack": "欢迎回来", + "newInvoice": "新建发票", + "yourInvoices": "你的发票", + "yourInvestments": "你的投资", + "browseMarketplace": "浏览市场", + "exportCsv": "导出 CSV", + "role": { + "business": "企业", + "lender": "出资方", + "admin": "管理员" + }, + "wallet": { + "title": "Stellar 钱包", + "connected": "钱包已连接,可以签署交易。", + "disconnected": "连接你的 Stellar 钱包以与合约交互。" + }, + "stats": { + "totalInvoices": "发票总数", + "pending": "待处理", + "financed": "已融资", + "repaid": "已还款", + "activeInvestments": "活跃投资", + "pendingOffers": "待处理报价", + "totalYield": "总收益" + }, + "view": { + "grid": "网格视图", + "table": "表格视图" + }, + "empty": { + "invoices": "还没有发票。", + "createFirst": "创建你的第一张发票", + "investments": "还没有活跃投资。" + }, + "cancel": { + "action": "取消发票", + "title": "要取消发票吗?", + "description": "发票 {id} 将被标记为已取消。此操作无法撤销。", + "confirm": "是,取消" + } + }, + "Marketplace": { + "title": "发票市场", + "description": "浏览可供融资的发票并提交报价以赚取收益。", + "searchPlaceholder": "按发票 ID、债务人名称或开票方搜索…", + "view": { + "suggested": "为我推荐", + "browseAll": "浏览全部" + }, + "filters": { + "allStatuses": "全部状态", + "allCurrencies": "全部币种" + }, + "sort": { + "label": "发票排序", + "newest": "最新优先", + "oldest": "最早优先", + "amount_desc": "金额:从高到低", + "amount_asc": "金额:从低到高", + "due_soonest": "到期日:最近优先" + }, + "empty": { + "title": "没有符合筛选条件的发票", + "hint": "试着调整搜索或筛选条件。" + }, + "clearSearch": "清除搜索" + }, + "Portfolio": { + "title": "你的投资组合", + "description": "跟踪你的融资报价与回报——更新实时推送", + "refresh": "刷新", + "exportCsv": "导出 CSV", + "stats": { + "active": "活跃投资", + "pending": "待处理报价", + "completed": "已完成", + "value": "组合价值(美元)" + }, + "yield": { + "estimated": "迄今预估收益:{amount}", + "accruing": "正在 {count, plural, =0 {没有活跃头寸} other {# 个活跃头寸}} 上实时累积", + "realized": "已实现收益:{amount}", + "acrossRepaid": "涵盖 {count, plural, other {# 笔已还款报价}}" + }, + "empty": { + "title": "还没有融资报价。", + "browse": "浏览市场" + }, + "position": { + "days": "{count, plural, other {# 天}}", + "funded": "于 {date} 融资", + "apy": "年化收益率", + "earnedToDate": "迄今收益", + "repayment": "还款", + "percentRepaid": "已还 {percent}", + "progressLabel": "应还总额已还 {percent}", + "repaidRemaining": "已还 {repaid} · 剩余 {remaining}", + "updated": "更新于 {when}" + }, + "transfer": { + "title": "转让头寸", + "description": "头寸代币代表你对已融资发票的权利(1 个代币 = 1 个本金基本单位)。将其发送到另一个 Stellar 钱包即可转让头寸。", + "secondaryBoard": "在找买家吗?把头寸挂到二级板块——结算仍在这里通过本次转账完成。", + "prefilled": "金额已根据你的挂单预填({amount} 个代币)。输入买家地址以完成结算。", + "refreshBalance": "刷新余额", + "connectWallet": "连接钱包以查看并转让头寸。", + "notConfigured": "本次部署尚未配置头寸代币。", + "needsTrustline": "头寸代币是 Stellar 资产——添加一次 POS 信任线即可接收和转让。", + "addTrustline": "添加 POS 信任线", + "adding": "添加中…", + "trustlineAdded": "信任线已添加", + "trustlineAddedHint": "你的钱包现在可以持有 POS 头寸代币。", + "trustlineFailed": "信任线失败", + "trustlineFailedHint": "信任线设置失败", + "recipientLabel": "收款地址", + "amountLabel": "金额", + "available": "(可用:{balance})", + "transfer": "转让", + "transferring": "转让中…", + "invalidAddress": "地址无效", + "invalidAddressHint": "请输入有效的 Stellar 地址(G…)。", + "invalidAmount": "金额无效", + "invalidAmountHint": "请输入最多 {decimals} 位小数的金额。", + "insufficient": "余额不足", + "insufficientHint": "你持有的头寸代币不足以完成本次转让。", + "recipientTrustline": "收款方需要信任线", + "recipientTrustlineHint": "收款钱包还没有 POS 信任线。转让前请对方添加一条(在任意钱包或本应用中)。", + "transferred": "头寸已转让", + "transferredHint": "已向 {recipient} 发送 {amount} 个头寸代币。", + "transferFailed": "转让失败", + "transferFailedHint": "交易失败" + } + }, + "Invoice": { + "title": "发票", + "backToDashboard": "返回仪表板", + "print": "打印 / 导出 PDF", + "notFound": "未找到发票", + "fields": { + "amount": "金额", + "currency": "币种", + "dueDate": "到期日", + "originator": "开票方" + }, + "counterparty": { + "lender": "出资方", + "business": "企业" + }, + "cancel": { + "action": "取消", + "title": "要取消这张发票吗?", + "description": "该发票将在链上取消,之后无法再收到融资报价。此操作无法撤销。", + "confirm": "取消发票", + "done": "发票已取消", + "doneHint": "该发票现已在链上取消。", + "failed": "取消发票失败", + "undo": "撤销", + "undoAlt": "撤销取消", + "restored": "发票已恢复", + "restoredHint": "已按相同条款创建一张新发票。", + "restoreFailed": "恢复发票失败" + } + }, + "Offers": { + "title": "融资报价({count})", + "empty": "还没有报价。", + "makeOffer": "提交报价", + "markOverdue": "标记为逾期", + "exportHint": "将报价导出为 CSV", + "exportEmpty": "没有可导出的报价", + "accept": "接受", + "reject": "拒绝", + "repay": "还款", + "reclaim": "追索", + "repayAmount": "还款金额", + "days": "{count, plural, other {# 天}}", + "repaid": "已还 {amount}", + "remaining": "剩余 {amount}", + "remainingBalance": "剩余余额:{remaining}(应还总额 {total} 减去 {repaid})", + "form": { + "title": "新的融资报价", + "amount": "金额", + "currency": "币种", + "interest": "利率(基点)", + "interestHint": "500 = {example}", + "duration": "期限(天)", + "submit": "提交报价" + }, + "confirm": { + "rejectTitle": "要拒绝这份报价吗?", + "rejectDescription": "出资方将收到报价被拒绝的通知。此操作无法撤销。", + "reclaimTitle": "要追索这份报价吗?", + "reclaimDescription": "此操作会在链上把该报价标记为违约。本金在接受时已支付给企业——这不会退还资金,且无法撤销。" + }, + "toast": { + "submitted": "报价已提交!", + "submittedHint": "将通知发票的开票方。", + "submitFailed": "提交报价失败", + "accepted": "报价已接受!", + "acceptedHint": "发票现已标记为已融资。", + "acceptFailed": "接受报价失败", + "rejected": "报价已拒绝。", + "rejectFailed": "拒绝报价失败", + "invalidAmount": "请输入有效金额", + "amountTooSmall": "金额必须大于零", + "repaidFull": "发票已全额还款", + "repaidFullHint": "本金与收益已转给出资方。发票现已还款。", + "repaidPartial": "还款已发送", + "repaidPartialHint": "部分还款已记录到链上。请继续还款直至结清余额。", + "repayFailed": "还款失败", + "markedOverdue": "发票已标记为逾期。", + "overdueFailed": "标记逾期失败", + "reclaimed": "报价已标记为违约。", + "reclaimedHint": "这是一条链上记录——请在链下继续追偿。", + "reclaimFailed": "追索失败", + "undo": "撤销", + "undoRejectAlt": "撤销拒绝", + "rejectUndone": "已撤销拒绝", + "rejectUndoneHint": "该报价重新变为待处理。", + "undoRejectFailed": "撤销拒绝失败" + } + } +} diff --git a/invofi/apps/frontend/package-lock.json b/invofi/apps/frontend/package-lock.json index b651648a4..c0da259fb 100644 --- a/invofi/apps/frontend/package-lock.json +++ b/invofi/apps/frontend/package-lock.json @@ -46,6 +46,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.6", "@types/node": "^22.5.4", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", @@ -7313,6 +7314,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@trezor/analytics": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@trezor/analytics/-/analytics-1.5.0.tgz", diff --git a/invofi/apps/frontend/package.json b/invofi/apps/frontend/package.json index 6ee9e4aad..421309b03 100644 --- a/invofi/apps/frontend/package.json +++ b/invofi/apps/frontend/package.json @@ -52,6 +52,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.6", "@types/node": "^22.5.4", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", diff --git a/invofi/apps/frontend/security-headers.mjs b/invofi/apps/frontend/security-headers.mjs index f2fa18265..21d86d9d6 100644 --- a/invofi/apps/frontend/security-headers.mjs +++ b/invofi/apps/frontend/security-headers.mjs @@ -80,10 +80,26 @@ export function buildConnectSrc() { return [...origins].filter(Boolean).join(' '); } -export function buildContentSecurityPolicy() { +/** + * `next dev` compiles modules with an eval-based devtool, so the dev server's + * own bootstrap is blocked outright by a policy without 'unsafe-eval' — the + * app renders a bare shell and every client component dies, which also makes + * the Playwright suite unrunnable. Production builds contain no eval, so the + * allowance is scoped to development only and the shipped policy is unchanged. + * + * @param {{ allowEval?: boolean }} [options] + */ +export function buildContentSecurityPolicy({ + allowEval = process.env.NODE_ENV === 'development', +} = {}) { + const scriptSrc = [ + "script-src 'self' 'unsafe-inline'", + WALLET_EXTENSION_SOURCES.join(' '), + ...(allowEval ? ["'unsafe-eval'"] : []), + ].join(' '); return [ "default-src 'self'", - `script-src 'self' 'unsafe-inline' ${WALLET_EXTENSION_SOURCES.join(' ')}`, + scriptSrc, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", "font-src 'self' data:", @@ -97,17 +113,18 @@ export function buildContentSecurityPolicy() { } /** - * @param {{ includeHsts?: boolean }} [options] + * @param {{ includeHsts?: boolean, allowEval?: boolean }} [options] * @returns {{ key: string, value: string }[]} */ export function buildSecurityHeaders({ includeHsts = process.env.NODE_ENV === 'production', + allowEval = process.env.NODE_ENV === 'development', } = {}) { const headers = [ { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'X-Frame-Options', value: 'DENY' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, - { key: 'Content-Security-Policy', value: buildContentSecurityPolicy() }, + { key: 'Content-Security-Policy', value: buildContentSecurityPolicy({ allowEval }) }, ]; if (includeHsts) { headers.push({ diff --git a/invofi/apps/frontend/src/app/403/page.tsx b/invofi/apps/frontend/src/app/403/page.tsx index 6ba4e483a..4b1ed421a 100644 --- a/invofi/apps/frontend/src/app/403/page.tsx +++ b/invofi/apps/frontend/src/app/403/page.tsx @@ -1,16 +1,17 @@ import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/button'; export default function Forbidden() { + const t = useTranslations('Errors.forbidden'); + return (

403

-

Access forbidden

-

- You don’t have permission to access this resource. -

+

{t('title')}

+

{t('description')}

); diff --git a/invofi/apps/frontend/src/app/auth/login/page.tsx b/invofi/apps/frontend/src/app/auth/login/page.tsx index ddde9d21b..bae524371 100644 --- a/invofi/apps/frontend/src/app/auth/login/page.tsx +++ b/invofi/apps/frontend/src/app/auth/login/page.tsx @@ -154,7 +154,7 @@ export default function LoginPage() { diff --git a/invofi/apps/frontend/src/app/auth/register/page.tsx b/invofi/apps/frontend/src/app/auth/register/page.tsx index c7ec601e3..6b1ad0687 100644 --- a/invofi/apps/frontend/src/app/auth/register/page.tsx +++ b/invofi/apps/frontend/src/app/auth/register/page.tsx @@ -130,7 +130,7 @@ function RegisterForm() { type="button" onClick={() => setRole(r.id)} className={cn( - 'p-4 rounded-xl border-2 text-left transition-all', + 'p-4 rounded-xl border-2 text-start transition-all', role === r.id ? 'border-blue-600 bg-blue-50 dark:bg-blue-950' : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-gray-300 dark:hover:border-gray-600', @@ -173,7 +173,7 @@ function RegisterForm() { diff --git a/invofi/apps/frontend/src/app/dashboard/page.tsx b/invofi/apps/frontend/src/app/dashboard/page.tsx index 2f6c58056..cb71b991f 100644 --- a/invofi/apps/frontend/src/app/dashboard/page.tsx +++ b/invofi/apps/frontend/src/app/dashboard/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import { Plus, FileText, TrendingUp, Wallet, Download, LayoutGrid, List, Loader2, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -17,12 +18,15 @@ import { getUserProfile, supabase } from '@/lib/supabase'; import { getXlmBalance } from '@/lib/horizon'; import { useWallet } from '@/components/auth/WalletProvider'; import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { useFormat } from '@/hooks/useFormat'; import { STROOPS_PER_XLM } from '@/lib/constants'; import { toCsv, downloadCsv } from '@/lib/csv'; import type { UserProfile, Invoice } from '@/types'; import { SupabaseUser } from '@/lib/types/supabase-auth'; export default function DashboardPage() { + const t = useTranslations('Dashboard'); + const format = useFormat(); const router = useRouter(); const { publicKey } = useWallet(); const [profile, setProfile] = useState(null); @@ -101,13 +105,13 @@ export default function DashboardPage() {

- {isBusiness ? 'Invoice Dashboard' : 'Lender Portfolio'} + {isBusiness ? t('titleBusiness') : t('titleLender')}

- {profile?.display_name ?? 'Welcome back'} + {profile?.display_name ?? t('welcomeBack')} {profile?.role && ( - - {profile.role} + + {t(`role.${profile.role}`)} )}

@@ -115,7 +119,7 @@ export default function DashboardPage() { {isBusiness && ( )} @@ -125,19 +129,19 @@ export default function DashboardPage() { - Stellar Wallet + {t('wallet.title')} {publicKey - ? 'Wallet connected. You can sign transactions.' - : 'Connect your Stellar wallet to interact with contracts.'} + ? t('wallet.connected') + : t('wallet.disconnected')} {xlmBalance !== null && ( - {parseFloat(xlmBalance).toFixed(2)} XLM + {format.number(parseFloat(xlmBalance), { maximumFractionDigits: 2 })} XLM )} @@ -147,17 +151,17 @@ export default function DashboardPage() {
{isBusiness ? ( <> - - i.status === 'Pending').length} /> - i.status === 'Financed').length} /> - i.status === 'Repaid').length} /> + + i.status === 'Pending').length)} /> + i.status === 'Financed').length)} /> + i.status === 'Repaid').length)} /> ) : ( <> - - - - + + + + )}
@@ -171,24 +175,24 @@ export default function DashboardPage() { {isBusiness && (
-

Your Invoices

+

{t('yourInvoices')}

{invoices.length > 0 && !loading && (
)}
@@ -210,10 +214,10 @@ export default function DashboardPage() { ) : invoices.length === 0 ? (
-

No invoices yet.

+

{t('empty.invoices')}

@@ -230,8 +234,8 @@ export default function DashboardPage() { {inv.status === 'Pending' && ( @@ -245,12 +249,12 @@ export default function DashboardPage() { {!isBusiness && (
-

Your Investments

+

{t('yourInvestments')}

-

No active investments yet.

+

{t('empty.investments')}

@@ -261,9 +265,9 @@ export default function DashboardPage() { { if (!open) setCancelTarget(null); }} - title="Cancel invoice?" - description={`Invoice ${cancelTarget?.id ?? ''} will be marked as Cancelled. This cannot be undone.`} - confirmLabel="Yes, cancel" + title={t('cancel.title')} + description={t('cancel.description', { id: cancelTarget?.id ?? '' })} + confirmLabel={t('cancel.confirm')} variant="destructive" holdToConfirm onConfirm={handleCancelInvoice} diff --git a/invofi/apps/frontend/src/app/error.tsx b/invofi/apps/frontend/src/app/error.tsx index d470cf929..feb08072a 100644 --- a/invofi/apps/frontend/src/app/error.tsx +++ b/invofi/apps/frontend/src/app/error.tsx @@ -1,9 +1,12 @@ 'use client'; import { useEffect } from 'react'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/button'; export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations('Errors.unexpected'); + useEffect(() => { console.error(error); }, [error]); @@ -11,11 +14,11 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri return (

⚠️

-

Something went wrong

-

- {error.message || 'An unexpected error occurred. Please try again.'} -

- +

{t('title')}

+ {/* `error.message` is not translatable — it comes from the SDK or the + network. The fallback below is. */} +

{error.message || t('description')}

+
); } diff --git a/invofi/apps/frontend/src/app/invoices/[id]/page.tsx b/invofi/apps/frontend/src/app/invoices/[id]/page.tsx index 4c911cd76..c23483e5a 100644 --- a/invofi/apps/frontend/src/app/invoices/[id]/page.tsx +++ b/invofi/apps/frontend/src/app/invoices/[id]/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import Link from 'next/link'; import { ArrowLeft, ExternalLink, Loader2, Printer } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -19,10 +20,14 @@ import { supabase } from '@/lib/supabase'; import { useToast } from '@/components/ui/use-toast'; import { ToastAction } from '@/components/ui/toast'; import { toErrorMessage } from '@/lib/errors'; -import { formatAmount, formatDate, formatAddress, INVOICE_STATUS_COLORS, generateInvoiceId } from '@/lib/utils'; +import { INVOICE_STATUS_COLORS, generateInvoiceId } from '@/lib/utils'; +import { useFormat } from '@/hooks/useFormat'; import type { Invoice, FinancingOffer } from '@/types'; export default function InvoiceDetailPage() { + const t = useTranslations('Invoice'); + const tStatus = useTranslations('Status'); + const format = useFormat(); const { id } = useParams<{ id: string }>(); const { publicKey } = useWallet(); const { toast } = useToast(); @@ -46,11 +51,11 @@ export default function InvoiceDetailPage() { await supabase.from('invoices').update({ status: 'Cancelled' }).eq('id', invoice.id); setInvoice(updated); toast({ - title: 'Invoice cancelled', - description: 'The invoice is now cancelled on-chain.', + title: t('cancel.done'), + description: t('cancel.doneHint'), action: ( { try { const newId = generateInvoiceId(); @@ -72,24 +77,26 @@ export default function InvoiceDetailPage() { status: 'Pending', }); setInvoice(restored); - toast({ title: 'Invoice restored', description: 'A new invoice with the same terms has been created.' }); + toast({ title: t('cancel.restored'), description: t('cancel.restoredHint') }); } catch (undoErr: unknown) { toast({ - title: 'Failed to restore invoice', - description: toErrorMessage(undoErr, 'Error'), + title: t('cancel.restoreFailed'), + description: toErrorMessage(undoErr, t('cancel.restoreFailed')), variant: 'destructive', }); } }} > - Undo + {t('cancel.undo')} ), }); } catch (err: unknown) { toast({ - title: 'Failed to cancel invoice', - description: toErrorMessage(err, 'Error'), + title: t('cancel.failed'), + // SDK/network messages are not translatable — they come from the + // chain; only the fallback is. + description: toErrorMessage(err, t('cancel.failed')), variant: 'destructive', }); } finally { @@ -107,7 +114,7 @@ export default function InvoiceDetailPage() { if (/403|unauthorized|forbidden|not authorized|access denied/i.test(errMsg)) { setIsUnauthorized(true); } else { - setError(errMsg || 'Invoice not found'); + setError(errMsg || t('notFound')); } }) .finally(() => setLoading(false)); @@ -162,7 +169,7 @@ export default function InvoiceDetailPage() { href="/dashboard" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800" > - Back to dashboard + {t('backToDashboard')} {invoice && ( @@ -173,7 +180,7 @@ export default function InvoiceDetailPage() { onClick={() => window.open(`/invoices/${id}/print`, '_blank')} > - Print / Export PDF + {t('print')} )}
@@ -196,12 +203,13 @@ export default function InvoiceDetailPage() {
-

{invoice.id}

- Invoice + {/* Invoice IDs are ASCII identifiers — pinned LTR inside an RTL layout. */} +

{invoice.id}

+ {t('title')}
- {invoice.status} + {tStatus(invoice.status)} {invoice.status === 'Pending' && publicKey === invoice.originator && ( )}
- - - + + + @@ -246,7 +258,7 @@ export default function InvoiceDetailPage() { currentAddress={publicKey} counterpartyAddress={counterpartyAddress} counterpartyLabel={ - publicKey === invoice.originator ? 'Lender' : 'Business' + publicKey === invoice.originator ? t('counterparty.lender') : t('counterparty.business') } /> )} @@ -256,9 +268,9 @@ export default function InvoiceDetailPage() { { if (!open) setConfirmCancel(false); }} - title="Cancel this invoice?" - description="The invoice will be cancelled on-chain and can no longer receive financing offers. This cannot be undone." - confirmLabel="Cancel Invoice" + title={t('cancel.title')} + description={t('cancel.description')} + confirmLabel={t('cancel.confirm')} variant="destructive" holdToConfirm onConfirm={() => { diff --git a/invofi/apps/frontend/src/app/layout.tsx b/invofi/apps/frontend/src/app/layout.tsx index 4a623e633..24510d874 100644 --- a/invofi/apps/frontend/src/app/layout.tsx +++ b/invofi/apps/frontend/src/app/layout.tsx @@ -1,5 +1,8 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; +import { NextIntlClientProvider } from 'next-intl'; +import { getLocale, getMessages } from 'next-intl/server'; +import { dirFor } from '@/i18n/config'; import './globals.css'; import { Providers } from '@/components/layout/Providers'; import { Navbar } from '@/components/layout/Navbar'; @@ -42,22 +45,25 @@ export const metadata: Metadata = { }, }; -import { NextIntlClientProvider } from 'next-intl'; -import { getMessages } from 'next-intl/server'; - export default async function RootLayout({ children, }: { children: React.ReactNode; }) { + const locale = await getLocale(); const messages = await getMessages(); return ( - + // `dir` is what actually mirrors the layout for Arabic, Hebrew and Persian. + // It only produces a correct mirror because the app's spacing, alignment + // and borders use CSS logical properties (`ms-*`/`me-*`, `ps-*`/`pe-*`, + // `start-*`/`end-*`, `text-start`) rather than physical left/right ones — + // see docs/i18n.md before adding a directional utility. + - +
{children}
diff --git a/invofi/apps/frontend/src/app/marketplace/page.tsx b/invofi/apps/frontend/src/app/marketplace/page.tsx index 36ad8642b..867971d63 100644 --- a/invofi/apps/frontend/src/app/marketplace/page.tsx +++ b/invofi/apps/frontend/src/app/marketplace/page.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'; import type { ChangeEvent } from 'react'; +import { useTranslations } from 'next-intl'; import { useDebounce } from '@/hooks/useDebounce'; import { Search, LayoutGrid, X } from 'lucide-react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -35,17 +36,17 @@ const queryClient = new QueryClient(); type Filters = { currency: Currency | 'ALL'; status: InvoiceStatus | 'ALL' }; type SortKey = 'newest' | 'oldest' | 'amount_desc' | 'amount_asc' | 'due_soonest'; -const SORT_OPTIONS: { value: SortKey; label: string }[] = [ - { value: 'newest', label: 'Newest first' }, - { value: 'oldest', label: 'Oldest first' }, - { value: 'amount_desc', label: 'Amount: high to low' }, - { value: 'amount_asc', label: 'Amount: low to high' }, - { value: 'due_soonest', label: 'Due date: soonest' }, -]; +/** Sort keys; their labels live in the `Marketplace.sort` message namespace. */ +const SORT_OPTIONS: SortKey[] = ['newest', 'oldest', 'amount_desc', 'amount_asc', 'due_soonest']; + +/** Status filter values; labels come from the shared `Status` namespace. */ +const STATUS_OPTIONS: InvoiceStatus[] = ['Pending', 'Financed', 'Overdue']; // ── Inner page (needs query context) ───────────────────────────────────────── function MarketplacePageInner() { + const t = useTranslations('Marketplace'); + const tStatus = useTranslations('Status'); const [search, setSearch] = useState(''); const handleSearchChange = useCallback((e: ChangeEvent) => { setSearch(e.target.value); @@ -142,10 +143,8 @@ function MarketplacePageInner() { {/* Page header */}
-

Invoice Marketplace

-

- Browse invoices available for financing and submit offers to earn yield. -

+

{t('title')}

+

{t('description')}

{/* Preferences button */} @@ -172,7 +171,7 @@ function MarketplacePageInner() { }`} aria-pressed={viewMode === 'suggested'} > - ✦ Suggested for me + ✦ {t('view.suggested')}
@@ -206,10 +205,11 @@ function MarketplacePageInner() { {/* Filters bar */}
- + @@ -217,8 +217,8 @@ function MarketplacePageInner() { @@ -227,19 +227,21 @@ function MarketplacePageInner() { @@ -247,10 +249,10 @@ function MarketplacePageInner() { className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground" value={sort} onChange={e => setSort(e.target.value as SortKey)} - aria-label="Sort invoices" + aria-label={t('sort.label')} > - {SORT_OPTIONS.map(opt => ( - + {SORT_OPTIONS.map(option => ( + ))}
@@ -263,8 +265,8 @@ function MarketplacePageInner() { {!allInvoicesQuery.isLoading && sortedAll.length === 0 && (
-

No invoices match your filters

-

Try adjusting the search query or filters.

+

{t('empty.title')}

+

{t('empty.hint')}

)} diff --git a/invofi/apps/frontend/src/app/marketplace/positions/page.tsx b/invofi/apps/frontend/src/app/marketplace/positions/page.tsx index fa508680b..4af2d7e01 100644 --- a/invofi/apps/frontend/src/app/marketplace/positions/page.tsx +++ b/invofi/apps/frontend/src/app/marketplace/positions/page.tsx @@ -135,7 +135,7 @@ export default function PositionListingsPage() {

Sell a position

@@ -181,11 +181,11 @@ export default function PositionListingsPage() {
- + setFilters(f => ({ ...f, search: e.target.value }))} /> diff --git a/invofi/apps/frontend/src/app/not-found.tsx b/invofi/apps/frontend/src/app/not-found.tsx index 15d87e331..0858a810d 100644 --- a/invofi/apps/frontend/src/app/not-found.tsx +++ b/invofi/apps/frontend/src/app/not-found.tsx @@ -1,16 +1,17 @@ import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/button'; export default function NotFound() { + const t = useTranslations('Errors.notFound'); + return (

404

-

Page not found

-

- The page you’re looking for doesn’t exist or has been moved. -

+

{t('title')}

+

{t('description')}

); diff --git a/invofi/apps/frontend/src/app/page.tsx b/invofi/apps/frontend/src/app/page.tsx index b54b4ce01..f15d26b1f 100644 --- a/invofi/apps/frontend/src/app/page.tsx +++ b/invofi/apps/frontend/src/app/page.tsx @@ -153,7 +153,7 @@ export default async function LandingPage() { className="bg-white text-blue-700 hover:bg-blue-50 font-semibold shadow-lg shadow-blue-900/30" > - {t('hero.getStarted')} + {t('hero.getStarted')} @@ -195,7 +195,7 @@ export default async function LandingPage() { {HOW_IT_WORKS.map((item, i) => (
{i < HOW_IT_WORKS.length - 1 && ( -
+
)}
@@ -240,7 +240,7 @@ export default async function LandingPage() {
@@ -263,7 +263,7 @@ export default async function LandingPage() {
@@ -283,7 +283,7 @@ export default async function LandingPage() { {ASSETS.map((asset) => (
(
diff --git a/invofi/apps/frontend/src/app/portfolio/page.tsx b/invofi/apps/frontend/src/app/portfolio/page.tsx index 6b6f1c026..02831136f 100644 --- a/invofi/apps/frontend/src/app/portfolio/page.tsx +++ b/invofi/apps/frontend/src/app/portfolio/page.tsx @@ -4,6 +4,7 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'rea import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { useVirtualizer } from '@tanstack/react-virtual'; +import { useLocale, useTranslations } from 'next-intl'; import { TrendingUp, Clock, CheckCircle2, AlertCircle, Download, Copy, Check, Send, RefreshCw, Tag, DollarSign } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -14,7 +15,8 @@ import { TableSkeleton } from '@/components/common/LoadingSkeleton'; import { useToast } from '@/components/ui/use-toast'; import { toErrorMessage } from '@/lib/errors'; import { addPositionTrustline, getPositionTokenId, getTokenBalance, getTokenDecimals, hasPositionTrustline, transferPositionToken } from '@/lib/contract'; -import { formatAmount, formatDate, interestRateLabel, durationLabel, OFFER_STATUS_COLORS } from '@/lib/utils'; +import { OFFER_STATUS_COLORS } from '@/lib/utils'; +import { useFormat } from '@/hooks/useFormat'; import { STROOPS_PER_XLM } from '@/lib/constants'; import { toCsv, downloadCsv } from '@/lib/csv'; import { stroopsToUsd } from '@/lib/live/prices'; @@ -54,12 +56,22 @@ function isStellarAddress(addr: string): boolean { return /^G[A-Z2-7]{55}$/.test(addr); } -/** Compact "updated Xs ago" for the per-row live timestamp. */ -function relativeUpdate(ts: number): string { - const diff = Date.now() - ts; - if (diff < 1_000) return 'just now'; - if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`; - return `${Math.floor(diff / 60_000)}m ago`; +/** + * Compact "updated 12s ago" for the per-row live timestamp, in the reader's + * language. `Intl.RelativeTimeFormat` supplies the wording and the right + * plural form — English's single rule is wrong for Arabic and for CJK. + */ +function useRelativeUpdate() { + const locale = useLocale(); + return useCallback( + (ts: number): string => { + const diffMs = Date.now() - ts; + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' }); + if (diffMs < 60_000) return rtf.format(-Math.floor(diffMs / 1000), 'second'); + return rtf.format(-Math.floor(diffMs / 60_000), 'minute'); + }, + [locale], + ); } /** @@ -72,6 +84,7 @@ function relativeUpdate(ts: number): string { * seller signs the transfer themselves. The board never mediates it. */ function TransferPositionCard() { + const t = useTranslations('Portfolio.transfer'); const { publicKey } = useWallet(); const { toast } = useToast(); const searchParams = useSearchParams(); @@ -119,11 +132,11 @@ function TransferPositionCard() { setAddingTrustline(true); try { await addPositionTrustline(publicKey); - toast({ title: 'Trustline added', description: 'Your wallet can now hold POS position tokens.' }); + toast({ title: t('trustlineAdded'), description: t('trustlineAddedHint') }); await refresh(); } catch (err) { - const msg = toErrorMessage(err, 'Trustline setup failed'); - toast({ title: 'Trustline failed', description: msg, variant: 'destructive' }); + const msg = toErrorMessage(err, t('trustlineFailedHint')); + toast({ title: t('trustlineFailed'), description: msg, variant: 'destructive' }); } finally { setAddingTrustline(false); } @@ -137,16 +150,16 @@ function TransferPositionCard() { if (!tokenId || !publicKey) return; const to = recipient.trim(); if (!isStellarAddress(to)) { - toast({ title: 'Invalid address', description: 'Enter a valid Stellar address (G…).', variant: 'destructive' }); + toast({ title: t('invalidAddress'), description: t('invalidAddressHint'), variant: 'destructive' }); return; } const units = toBaseUnits(amount, decimals); if (units === null || units <= 0n) { - toast({ title: 'Invalid amount', description: `Enter an amount with at most ${decimals} decimal places.`, variant: 'destructive' }); + toast({ title: t('invalidAmount'), description: t('invalidAmountHint', { decimals }), variant: 'destructive' }); return; } if (balance !== null && units > balance) { - toast({ title: 'Insufficient balance', description: 'You do not hold enough position tokens for this transfer.', variant: 'destructive' }); + toast({ title: t('insufficient'), description: t('insufficientHint'), variant: 'destructive' }); return; } setBusy(true); @@ -155,22 +168,24 @@ function TransferPositionCard() { // transfer can credit them. Pre-check so the failure is friendly. if (!(await hasPositionTrustline(to))) { toast({ - title: 'Recipient needs a trustline', - description: - 'The recipient wallet has no POS trustline yet. Ask them to add one (any wallet or this app) before transferring.', + title: t('recipientTrustline'), + description: t('recipientTrustlineHint'), variant: 'destructive', }); setBusy(false); return; } await transferPositionToken(tokenId, publicKey, to, units); - toast({ title: 'Position transferred', description: `Sent ${amount} position tokens to ${to.slice(0, 6)}…${to.slice(-4)}.` }); + toast({ + title: t('transferred'), + description: t('transferredHint', { amount, recipient: `${to.slice(0, 6)}…${to.slice(-4)}` }), + }); setRecipient(''); setAmount(''); await refresh(); } catch (err) { - const msg = toErrorMessage(err, 'Transaction failed'); - toast({ title: 'Transfer failed', description: msg, variant: 'destructive' }); + const msg = toErrorMessage(err, t('transferFailedHint')); + toast({ title: t('transferFailed'), description: msg, variant: 'destructive' }); } finally { setBusy(false); } @@ -185,74 +200,72 @@ function TransferPositionCard() {
-

Transfer Position

+

{t('title')}

-
-

- Position tokens represent your claim on financed invoices (1 token = 1 base unit of - principal). Send them to another Stellar wallet to transfer the position. -

+

{t('description')}

- - Looking for a buyer?{' '} - - List the position on the secondary board - {' '} - — settlement still happens here, with this transfer. + + {t.rich('secondaryBoard', { + link: chunks => ( + + {chunks} + + ), + })}

{prefilledAmount && (

- Amount prefilled from your listing ({prefilledAmount} tokens). Enter the buyer's - address to settle. + {t('prefilled', { amount: prefilledAmount })}

)} {!publicKey ? ( -

Connect a wallet to view and transfer positions.

+

{t('connectWallet')}

) : tokenId === null && !loading ? ( -

- Position tokens are not configured on this deployment yet. -

+

{t('notConfigured')}

) : hasTrustline === false ? (
-

- Position tokens are Stellar assets — add a POS trustline once to - receive and transfer them. -

+

{t('needsTrustline')}

) : (
- + setRecipient(e.target.value)} placeholder="G…" + aria-label={t('recipientLabel')} + dir="ltr" className="w-full px-3 py-2 rounded-lg border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50" />
setAmount(e.target.value)} placeholder="0.0" + aria-label={t('amountLabel')} + dir="ltr" inputMode="decimal" className="w-full px-3 py-2 rounded-lg border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50" />
)} @@ -262,6 +275,7 @@ function TransferPositionCard() { } function CopyId({ id }: { id: string }) { + const t = useTranslations('Common'); const [copied, setCopied] = useState(false); const copy = async () => { try { @@ -273,10 +287,12 @@ function CopyId({ id }: { id: string }) { return (

- {interestRateLabel(offer.interest_rate)} · {durationLabel(offer.duration)} - {offer.funded_at > 0 && ` · Funded ${formatDate(offer.funded_at)}`} + {format.percent(offer.interest_rate)} · {t('days', { count: Math.round(offer.duration / 86_400) })} + {offer.funded_at > 0 && ` · ${t('funded', { date: format.date(offer.funded_at) })}`}

-
+

- {formatAmount(offer.amount)} {offer.currency} + {format.currency(offer.amount, offer.currency)}

- ≈ ${offer.liveValueUsd.toFixed(2)} USD + ≈ {format.number(offer.liveValueUsd, { style: 'currency', currency: 'USD' })}

- {offer.status} + {tStatus(offer.status)}
@@ -332,28 +351,41 @@ function PositionCard({ offer }: { offer: LivePosition }) { <>
-

APY

-

{offer.apy.toFixed(2)}%

+

{t('apy')}

+

+ {format.number(offer.apy / 100, { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

-

Earned to date

+

{t('earnedToDate')}

- {formatAmount(offer.earnedToDate)} {offer.currency} + {format.currency(offer.earnedToDate, offer.currency)} - {' '}≈ ${stroopsToUsd(offer.earnedToDate, offer.currency).toFixed(2)} + {' '}≈ {format.number(stroopsToUsd(offer.earnedToDate, offer.currency), { style: 'currency', currency: 'USD' })}

-

Repayment

-

{pct}% repaid

+

{t('repayment')}

+

+ {t('percentRepaid', { percent: format.number(offer.repaymentProgress, { style: 'percent' }) })} +

- +

- {formatAmount(offer.amount_repaid)} {offer.currency} repaid · {formatAmount(offer.remaining)} {offer.currency} remaining ·{' '} - updated {relativeUpdate(offer.updatedAt)} + {t('repaidRemaining', { + repaid: format.currency(offer.amount_repaid, offer.currency), + remaining: format.currency(offer.remaining, offer.currency), + })}{' '} + ·{' '} + + {t('updated', { when: relativeUpdate(offer.updatedAt) })} +

@@ -364,6 +396,9 @@ function PositionCard({ offer }: { offer: LivePosition }) { } export default function PortfolioPage() { + const t = useTranslations('Portfolio'); + const format = useFormat(); + const relativeUpdate = useRelativeUpdate(); const { positions, loading, @@ -446,22 +481,26 @@ export default function PortfolioPage() {
-

Your Portfolio

+

{t('title')}

- Track your financing offers and returns — updates stream in live + {t('description')} {lastUpdatedAt - ? · updated {relativeUpdate(lastUpdatedAt)} + ? ( + + {' '}· {t('position.updated', { when: relativeUpdate(lastUpdatedAt) })} + + ) : null}

- {positions.length > 0 && ( )}
@@ -479,29 +518,31 @@ export default function PortfolioPage() { -

{active.length}

-

Active Investments

+

{format.number(active.length)}

+

{t('stats.active')}

-

{pending.length}

-

Pending Offers

+

{format.number(pending.length)}

+

{t('stats.pending')}

-

{repaid.length}

-

Completed

+

{format.number(repaid.length)}

+

{t('stats.completed')}

-

${totalValueUsd.toFixed(2)}

-

Portfolio Value (USD)

+

+ {format.number(totalValueUsd, { style: 'currency', currency: 'USD' })} +

+

{t('stats.value')}

@@ -513,16 +554,26 @@ export default function PortfolioPage() {

- Est. yield earned to date: ${totalEarnedToDateUsd.toFixed(2)} + {t('yield.estimated', { + amount: format.number(totalEarnedToDateUsd, { style: 'currency', currency: 'USD' }), + })} +

+ {/* ICU plural: `count` selects the form, so Arabic supplies + its six and Japanese its one. */} +

+ {t('yield.accruing', { count: active.length })}

-

Accruing in real time across {active.length} active position{active.length !== 1 ? 's' : ''}

{repaid.length > 0 && (

- Realized yield: ${totalEarned.toFixed(2)} USD + {t('yield.realized', { + amount: format.number(totalEarned, { style: 'currency', currency: 'USD' }), + })} +

+

+ {t('yield.acrossRepaid', { count: repaid.length })}

-

Across {repaid.length} repaid offer{repaid.length !== 1 ? 's' : ''}

)}
@@ -536,12 +587,13 @@ export default function PortfolioPage() { {!loading && positions.length === 0 && (
-

No financing offers yet.

+

{t('empty.title')}

- Browse the marketplace → + {t('empty.browse')} +
)} diff --git a/invofi/apps/frontend/src/app/settings/__tests__/page.test.tsx b/invofi/apps/frontend/src/app/settings/__tests__/page.test.tsx index 86b1722ca..16f2d326c 100644 --- a/invofi/apps/frontend/src/app/settings/__tests__/page.test.tsx +++ b/invofi/apps/frontend/src/app/settings/__tests__/page.test.tsx @@ -1,4 +1,7 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { cleanup, screen, fireEvent, waitFor } from '@testing-library/react'; +// `render` wraps in NextIntlClientProvider — the settings page reads its copy +// from the message catalogue (issue #227). +import { render } from '@/test/intl'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { ComponentType } from 'react'; @@ -30,6 +33,11 @@ async function renderSettingsPage() { // ── Tests ──────────────────────────────────────────────────────────────────── describe('SettingsPage — Network & Contracts panel (issue #163)', () => { afterEach(() => { + // `renderSettingsPage` calls `vi.resetModules()` and re-imports the page, + // so the global teardown in src/test/setup.ts can end up holding a stale + // module instance and leave the previous render mounted. Unmount here, + // where the instance is the one this file rendered with. + cleanup(); vi.restoreAllMocks(); delete process.env.NEXT_PUBLIC_RPC_URL; delete process.env.NEXT_PUBLIC_HORIZON_URL; diff --git a/invofi/apps/frontend/src/app/settings/page.tsx b/invofi/apps/frontend/src/app/settings/page.tsx index 23d18c14e..51f689f63 100644 --- a/invofi/apps/frontend/src/app/settings/page.tsx +++ b/invofi/apps/frontend/src/app/settings/page.tsx @@ -3,10 +3,12 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import { Check, ChevronRight, Copy, ExternalLink, User } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { PageHeader } from '@/components/common/PageHeader'; +import { LanguageSwitcher } from '@/components/settings/LanguageSwitcher'; import { useToast } from '@/components/ui/use-toast'; import { createClient } from '@/utils/supabase/client'; import { @@ -29,6 +31,7 @@ interface ContractRowProps { /** One contract row: ID + copy button + Stellar Expert deep link, or a "not * configured" warning when the env var is missing. */ function ContractRow({ label, contractId }: ContractRowProps) { + const t = useTranslations('Settings.contracts'); const [copied, setCopied] = useState(false); const { toast } = useToast(); @@ -38,7 +41,7 @@ function ContractRow({ label, contractId }: ContractRowProps) { setCopied(true); window.setTimeout(() => setCopied(false), 2000); } catch { - toast({ title: 'Copy failed', description: 'Could not access the clipboard.', variant: 'destructive' }); + toast({ title: t('copyFailed'), description: t('copyFailedHint'), variant: 'destructive' }); } }; @@ -46,7 +49,7 @@ function ContractRow({ label, contractId }: ContractRowProps) { return (
{label} - — not configured + — {t('notConfigured')}
); } @@ -55,7 +58,9 @@ function ContractRow({ label, contractId }: ContractRowProps) {

{label}

-

+ {/* Contract IDs are base32 identifiers — pinned LTR so they read + correctly inside an RTL layout. */} +

{contractId}

@@ -64,20 +69,20 @@ function ContractRow({ label, contractId }: ContractRowProps) { type="button" onClick={copyId} className="inline-flex items-center gap-1 text-xs font-medium text-gray-600 hover:text-gray-900 rounded-md px-2 py-1 hover:bg-gray-100 transition-colors" - aria-label={`Copy ${label} contract ID`} + aria-label={t('copyAria', { label })} > {copied ? : } - {copied ? 'Copied' : 'Copy'} + {copied ? t('copied') : t('copy')} - Explorer + {t('explorer')}
@@ -93,21 +98,23 @@ interface EndpointRowProps { /** Endpoint row: shows the value or an explicit "not configured" warning. */ function EndpointRow({ label, value }: EndpointRowProps) { + const t = useTranslations('Settings.contracts'); return (

{label}

{value ? ( -

+

{value}

) : ( -

not configured

+

{t('notConfigured')}

)}
); } export default function SettingsPage() { + const t = useTranslations('Settings'); const router = useRouter(); const { toast } = useToast(); const [loading, setLoading] = useState(false); @@ -116,13 +123,13 @@ export default function SettingsPage() { setLoading(true); const supabase = createClient(); await supabase.auth.signOut(); - toast({ title: 'Signed out successfully' }); + toast({ title: t('account.signedOut') }); router.push('/'); }; return (
- +
@@ -131,42 +138,52 @@ export default function SettingsPage() {
-

Profile

-

Edit your display name and view account details

+

{t('profile.label')}

+

{t('profile.hint')}

- + {/* Chevrons point "forward", which is leftwards in RTL. */} + - Network & Contracts + {t('language.title')} + + + + + + + + + {t('network.title')}
-

Stellar Network

+

{t('network.label')}

{STELLAR_NETWORK}

- Connected + {t('network.connected')}
- - + +
-

Contracts

+

{t('contracts.title')}

- - - + + +
@@ -174,11 +191,11 @@ export default function SettingsPage() { - Account + {t('account.title')} diff --git a/invofi/apps/frontend/src/app/transactions/page.tsx b/invofi/apps/frontend/src/app/transactions/page.tsx index 3c560103d..c4e62d7e1 100644 --- a/invofi/apps/frontend/src/app/transactions/page.tsx +++ b/invofi/apps/frontend/src/app/transactions/page.tsx @@ -114,7 +114,7 @@ export default function TransactionsPage() {
{publicKey && ( )} diff --git a/invofi/apps/frontend/src/components/auth/WalletButton.tsx b/invofi/apps/frontend/src/components/auth/WalletButton.tsx index 0b1e48d6b..a9301fcd2 100644 --- a/invofi/apps/frontend/src/components/auth/WalletButton.tsx +++ b/invofi/apps/frontend/src/components/auth/WalletButton.tsx @@ -92,14 +92,14 @@ export function WalletButton({ onConnected }: WalletButtonProps) { {xlmBalance !== null && ( - + {xlmBalance} XLM )} {holdToConfirm && !loading ? ( @@ -193,7 +197,7 @@ export function ConfirmDialog({ {isHolding ? `${Math.round(holdProgress)}%` - : confirmLabel} + : confirmText} ) : ( @@ -202,7 +206,7 @@ export function ConfirmDialog({ onClick={onConfirm} disabled={loading} > - {loading ? 'Processing...' : confirmLabel} + {loading ? t('submitting') : confirmText} )} diff --git a/invofi/apps/frontend/src/components/common/StatusBadge.tsx b/invofi/apps/frontend/src/components/common/StatusBadge.tsx index 4d4a59c9d..46c5498d3 100644 --- a/invofi/apps/frontend/src/components/common/StatusBadge.tsx +++ b/invofi/apps/frontend/src/components/common/StatusBadge.tsx @@ -1,3 +1,6 @@ +'use client'; + +import { useTranslations } from 'next-intl'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; @@ -18,12 +21,17 @@ interface StatusBadgeProps { } export function StatusBadge({ status, className }: StatusBadgeProps) { + // `status` is the contract's own identifier (`Pending`, `Financed`, …); the + // catalogue turns it into display text. An unknown status falls through to + // the raw identifier rather than rendering an empty badge. + const t = useTranslations('Status'); + return ( - {status} + {status in STATUS_STYLES ? t(status as keyof typeof STATUS_STYLES) : status} ); } diff --git a/invofi/apps/frontend/src/components/invoices/EventTimeline.tsx b/invofi/apps/frontend/src/components/invoices/EventTimeline.tsx index 15f3f8746..f416a235f 100644 --- a/invofi/apps/frontend/src/components/invoices/EventTimeline.tsx +++ b/invofi/apps/frontend/src/components/invoices/EventTimeline.tsx @@ -84,9 +84,9 @@ function EventRow({ entry }: { entry: InvoiceTimelineEntry }) { const accent = EVENT_STYLES[entry.type] ?? FALLBACK_STYLE; return ( -
  • +
  • diff --git a/invofi/apps/frontend/src/components/invoices/documents/DocumentPreviewDialog.tsx b/invofi/apps/frontend/src/components/invoices/documents/DocumentPreviewDialog.tsx index aee76a133..03a6801fc 100644 --- a/invofi/apps/frontend/src/components/invoices/documents/DocumentPreviewDialog.tsx +++ b/invofi/apps/frontend/src/components/invoices/documents/DocumentPreviewDialog.tsx @@ -30,7 +30,7 @@ export function DocumentPreviewDialog({ document, onClose }: DocumentPreviewDial { if (!open) onClose(); }}> - + {document?.mime_type === 'application/pdf' ? ( ) : ( diff --git a/invofi/apps/frontend/src/components/layout/Navbar.tsx b/invofi/apps/frontend/src/components/layout/Navbar.tsx index 54baf264d..d0dce4233 100644 --- a/invofi/apps/frontend/src/components/layout/Navbar.tsx +++ b/invofi/apps/frontend/src/components/layout/Navbar.tsx @@ -322,8 +322,8 @@ export function Navbar() {
    onChange(value)} aria-pressed={active} className={cn( - 'flex-1 rounded-lg border px-3 py-2.5 text-sm text-left transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-ring', + 'flex-1 rounded-lg border px-3 py-2.5 text-sm text-start transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-ring', active ? 'border-primary bg-primary/10 text-primary font-medium' : 'border-input bg-background text-foreground hover:border-muted-foreground/50', @@ -178,7 +178,7 @@ export function LenderPreferencesForm({ {trigger ?? ( )} @@ -238,7 +238,7 @@ export function LenderPreferencesForm({
    @@ -249,10 +249,10 @@ export function LenderPreferencesForm({ step="0.01" min="0" max="1000" - className="pr-8" + className="pe-8" {...register('minYieldPercent', { valueAsNumber: true })} /> - % + %
    @@ -262,7 +262,7 @@ export function LenderPreferencesForm({
    - + Reset to defaults diff --git a/invofi/apps/frontend/src/components/marketplace/ListPositionForm.tsx b/invofi/apps/frontend/src/components/marketplace/ListPositionForm.tsx index e954d02c9..0b8ed97f2 100644 --- a/invofi/apps/frontend/src/components/marketplace/ListPositionForm.tsx +++ b/invofi/apps/frontend/src/components/marketplace/ListPositionForm.tsx @@ -195,7 +195,7 @@ export function ListPositionForm({ sellerAddress, sellerId, onCreated }: ListPos diff --git a/invofi/apps/frontend/src/components/marketplace/MatchQualityBadge.tsx b/invofi/apps/frontend/src/components/marketplace/MatchQualityBadge.tsx index 0cf637f51..56df6f45a 100644 --- a/invofi/apps/frontend/src/components/marketplace/MatchQualityBadge.tsx +++ b/invofi/apps/frontend/src/components/marketplace/MatchQualityBadge.tsx @@ -56,7 +56,7 @@ export function MatchQualityBadge({ > {label} {score !== undefined && !compact && ( - ({score}) + ({score}) )} ); diff --git a/invofi/apps/frontend/src/components/marketplace/PositionListingCard.tsx b/invofi/apps/frontend/src/components/marketplace/PositionListingCard.tsx index 7195496fe..bd74e7cf0 100644 --- a/invofi/apps/frontend/src/components/marketplace/PositionListingCard.tsx +++ b/invofi/apps/frontend/src/components/marketplace/PositionListingCard.tsx @@ -82,7 +82,7 @@ export function PositionListingCard({ listing, isOwn, onStatusChange, busy }: Po

    {listing.note && ( -

    {listing.note}

    +

    {listing.note}

    )}
    @@ -92,7 +92,7 @@ export function PositionListingCard({ listing, isOwn, onStatusChange, busy }: Po <> diff --git a/invofi/apps/frontend/src/components/marketplace/SuggestedMatches.tsx b/invofi/apps/frontend/src/components/marketplace/SuggestedMatches.tsx index 509101c21..0654cd25f 100644 --- a/invofi/apps/frontend/src/components/marketplace/SuggestedMatches.tsx +++ b/invofi/apps/frontend/src/components/marketplace/SuggestedMatches.tsx @@ -106,7 +106,7 @@ interface ScoreBreakdownPanelProps { function ScoreBreakdownPanel({ breakdown, score }: ScoreBreakdownPanelProps) { return ( -
    +

    Score breakdown ({score}/100)

    @@ -206,7 +206,7 @@ function MatchedInvoiceCard({ result }: MatchedInvoiceCardProps) { @@ -273,7 +273,7 @@ export function SuggestedMatches({

    Suggested for you {!isLoading && matches.length > 0 && ( - + ({matches.length} match{matches.length !== 1 ? 'es' : ''}) )} diff --git a/invofi/apps/frontend/src/components/multisig/InitiateTransactionForm.tsx b/invofi/apps/frontend/src/components/multisig/InitiateTransactionForm.tsx index 748cb4248..97623ccf1 100644 --- a/invofi/apps/frontend/src/components/multisig/InitiateTransactionForm.tsx +++ b/invofi/apps/frontend/src/components/multisig/InitiateTransactionForm.tsx @@ -163,7 +163,7 @@ export function InitiateTransactionForm({ @@ -146,16 +146,16 @@ export function PendingTransactionCard({ {canExecute && ( )} {canReject && ( )} diff --git a/invofi/apps/frontend/src/components/settings/LanguageSwitcher.tsx b/invofi/apps/frontend/src/components/settings/LanguageSwitcher.tsx new file mode 100644 index 000000000..b909c35d0 --- /dev/null +++ b/invofi/apps/frontend/src/components/settings/LanguageSwitcher.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useTransition } from 'react'; +import { useRouter } from 'next/navigation'; +import { useLocale, useTranslations } from 'next-intl'; +import { Globe, Loader2 } from 'lucide-react'; +import { setUserLocale } from '@/i18n/locale'; +import { dirFor, localeNames, locales, type Locale } from '@/i18n/config'; +import { cn } from '@/lib/utils'; + +/** + * Language picker (issue #227). + * + * Writing the choice is a server action, so the cookie is set on a real + * response and the *next server render* — not just the client — uses the new + * locale. `router.refresh()` re-fetches the RSC payload so the whole tree, + * including `` and ``, updates without a full reload. + * + * A native ` onChange(event.target.value)} + className={cn( + 'h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm', + 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'disabled:cursor-not-allowed disabled:opacity-50', + )} + > + {options.map(locale => ( + + ))} + +

    +
    + ); +} diff --git a/invofi/apps/frontend/src/components/ui/alert.tsx b/invofi/apps/frontend/src/components/ui/alert.tsx index 9555155fa..ceb9744fb 100644 --- a/invofi/apps/frontend/src/components/ui/alert.tsx +++ b/invofi/apps/frontend/src/components/ui/alert.tsx @@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const alertVariants = cva( - 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + 'relative w-full rounded-lg border p-4 [&>svg~*]:ps-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:start-4 [&>svg]:top-4 [&>svg]:text-foreground', { variants: { variant: { diff --git a/invofi/apps/frontend/src/components/ui/dialog.tsx b/invofi/apps/frontend/src/components/ui/dialog.tsx index c0d34ce3b..8741edc1f 100644 --- a/invofi/apps/frontend/src/components/ui/dialog.tsx +++ b/invofi/apps/frontend/src/components/ui/dialog.tsx @@ -40,7 +40,7 @@ const DialogContent = React.forwardRef< {...props} > {children} - + Close @@ -50,12 +50,12 @@ const DialogContent = React.forwardRef< DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
    +
    ); DialogHeader.displayName = 'DialogHeader'; const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( -
    +
    ); DialogFooter.displayName = 'DialogFooter'; diff --git a/invofi/apps/frontend/src/components/ui/table.tsx b/invofi/apps/frontend/src/components/ui/table.tsx index 3c4c577ec..77ef266b5 100644 --- a/invofi/apps/frontend/src/components/ui/table.tsx +++ b/invofi/apps/frontend/src/components/ui/table.tsx @@ -33,14 +33,14 @@ TableRow.displayName = 'TableRow'; const TableHead = React.forwardRef>( ({ className, ...props }, ref) => ( - + ), ); TableHead.displayName = 'TableHead'; const TableCell = React.forwardRef>( ({ className, ...props }, ref) => ( - + ), ); TableCell.displayName = 'TableCell'; diff --git a/invofi/apps/frontend/src/components/ui/toast.tsx b/invofi/apps/frontend/src/components/ui/toast.tsx index a023bea1b..f1160c167 100644 --- a/invofi/apps/frontend/src/components/ui/toast.tsx +++ b/invofi/apps/frontend/src/components/ui/toast.tsx @@ -14,14 +14,14 @@ const ToastViewport = React.forwardRef< >(({ className, ...props }, ref) => ( )); ToastViewport.displayName = ToastPrimitives.Viewport.displayName; const toastVariants = cva( - 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', + 'group pointer-events-auto relative flex w-full items-center justify-between gap-4 overflow-hidden rounded-md border p-6 pe-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', { variants: { variant: { @@ -59,7 +59,7 @@ const ToastClose = React.forwardRef< >(({ className, ...props }, ref) => ( diff --git a/invofi/apps/frontend/src/hooks/useFormat.ts b/invofi/apps/frontend/src/hooks/useFormat.ts new file mode 100644 index 000000000..daf1edff7 --- /dev/null +++ b/invofi/apps/frontend/src/hooks/useFormat.ts @@ -0,0 +1,48 @@ +'use client'; + +import { useLocale } from 'next-intl'; +import { useMemo } from 'react'; +import { + daysUntil, + formatAddress, + formatCurrency, + formatDate, + formatDateTime, + formatNumber, + formatPercent, + formatRelativeDays, +} from '@/lib/intl'; + +/** + * Binds the reader's active locale to the formatters in `src/lib/intl.ts`. + * + * Components should call this rather than importing the pure functions + * directly, so a language change re-renders every amount and date without any + * component having to know the locale itself. + */ +export function useFormat() { + const locale = useLocale(); + + return useMemo( + () => ({ + locale, + currency: ( + stroops: bigint | number | string | null | undefined, + code: string, + options?: { maximumFractionDigits?: number }, + ) => formatCurrency(stroops, code, locale, options), + number: (value: number | bigint | string | null | undefined, options?: Intl.NumberFormatOptions) => + formatNumber(value, locale, options), + percent: (basisPoints: number | bigint | string | null | undefined) => + formatPercent(basisPoints, locale), + date: (timestamp: number | bigint | string | null | undefined, options?: Intl.DateTimeFormatOptions) => + formatDate(timestamp, locale, options), + dateTime: (timestamp: number | bigint | string | null | undefined) => + formatDateTime(timestamp, locale), + relativeDays: (days: number) => formatRelativeDays(days, locale), + daysUntil, + address: formatAddress, + }), + [locale], + ); +} diff --git a/invofi/apps/frontend/src/i18n/config.test.ts b/invofi/apps/frontend/src/i18n/config.test.ts new file mode 100644 index 000000000..2a1d42d7e --- /dev/null +++ b/invofi/apps/frontend/src/i18n/config.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { + defaultLocale, + dirFor, + isLocale, + isRtl, + localeNames, + locales, + negotiateLocale, + rtlLocales, +} from './config'; + +describe('locale registry', () => { + it('ships more than ten languages, including the three RTL scripts', () => { + expect(locales.length).toBeGreaterThanOrEqual(10); + expect(rtlLocales).toEqual(expect.arrayContaining(['ar', 'he', 'fa'])); + }); + + it('has a native and English display name for every locale', () => { + for (const locale of locales) { + expect(localeNames[locale]?.native, locale).toBeTruthy(); + expect(localeNames[locale]?.english, locale).toBeTruthy(); + } + }); + + it('resolves text direction from the script, not the tag order', () => { + expect(dirFor('ar')).toBe('rtl'); + expect(dirFor('he')).toBe('rtl'); + expect(dirFor('fa')).toBe('rtl'); + expect(dirFor('en')).toBe('ltr'); + expect(dirFor('ja')).toBe('ltr'); + // An unknown tag must not throw, and must not claim to be RTL. + expect(isRtl('klingon')).toBe(false); + expect(dirFor('klingon')).toBe('ltr'); + }); + + it('narrows unknown values', () => { + expect(isLocale('ar')).toBe(true); + expect(isLocale('ar-EG')).toBe(false); + expect(isLocale(undefined)).toBe(false); + expect(isLocale(42)).toBe(false); + }); +}); + +describe('negotiateLocale', () => { + it('falls back to English when the header is missing or unusable', () => { + expect(negotiateLocale(null)).toBe(defaultLocale); + expect(negotiateLocale('')).toBe(defaultLocale); + expect(negotiateLocale('*')).toBe(defaultLocale); + expect(negotiateLocale('kl,tlh')).toBe(defaultLocale); + }); + + it('picks an exact match', () => { + expect(negotiateLocale('ar')).toBe('ar'); + expect(negotiateLocale('ja,en;q=0.8')).toBe('ja'); + }); + + it('falls back from a regional tag to its base language', () => { + expect(negotiateLocale('pt-BR,pt;q=0.9')).toBe('pt'); + expect(negotiateLocale('zh-Hans-CN')).toBe('zh'); + expect(negotiateLocale('ar-EG,en-US;q=0.5')).toBe('ar'); + }); + + it('honours q-values rather than header order', () => { + // Chrome sends the reader's real preference via q, not position. + expect(negotiateLocale('en;q=0.2,he;q=0.9')).toBe('he'); + expect(negotiateLocale('de;q=0.1,fr;q=0.4,ko;q=0.9')).toBe('ko'); + }); + + it('ignores q=0, which explicitly rejects a language', () => { + expect(negotiateLocale('fa;q=0,en;q=0.5')).toBe('en'); + }); + + it('skips unsupported languages instead of stopping at them', () => { + expect(negotiateLocale('sv,no,ko')).toBe('ko'); + }); +}); diff --git a/invofi/apps/frontend/src/i18n/config.ts b/invofi/apps/frontend/src/i18n/config.ts new file mode 100644 index 000000000..f1516eea0 --- /dev/null +++ b/invofi/apps/frontend/src/i18n/config.ts @@ -0,0 +1,117 @@ +/** + * Locale registry for InvoFi (issue #227). + * + * One source of truth: adding a language means adding its tag here, its + * display names below, and a `messages/.json` file. Nothing else in the + * app enumerates locales. + * + * This module is imported by middleware (Edge runtime), server components and + * client components alike, so it must stay dependency-free and side-effect + * free. + */ + +export const locales = [ + 'en', + 'ar', + 'de', + 'es', + 'fa', + 'fr', + 'he', + 'ja', + 'ko', + 'pt', + 'tr', + 'zh', +] as const; + +export type Locale = (typeof locales)[number]; + +export const defaultLocale: Locale = 'en'; + +/** + * Right-to-left scripts. These drive ``, which is what actually + * mirrors the layout — see `docs/i18n.md` for why CSS logical properties are + * required for that mirroring to be correct. + */ +export const rtlLocales: readonly Locale[] = ['ar', 'fa', 'he']; + +/** Cookie holding the reader's chosen locale. Readable by the Edge middleware. */ +export const LOCALE_COOKIE = 'INVOFI_LOCALE'; + +/** One year — a language choice should outlive a session. */ +export const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +/** + * Display names. `native` is what the switcher shows (a reader looking for + * their language recognises it in their own script, not in English); + * `english` is the accessible label and the sort key. + */ +export const localeNames: Record = { + en: { native: 'English', english: 'English' }, + ar: { native: 'العربية', english: 'Arabic' }, + de: { native: 'Deutsch', english: 'German' }, + es: { native: 'Español', english: 'Spanish' }, + fa: { native: 'فارسی', english: 'Persian' }, + fr: { native: 'Français', english: 'French' }, + he: { native: 'עברית', english: 'Hebrew' }, + ja: { native: '日本語', english: 'Japanese' }, + ko: { native: '한국어', english: 'Korean' }, + pt: { native: 'Português', english: 'Portuguese' }, + tr: { native: 'Türkçe', english: 'Turkish' }, + zh: { native: '中文', english: 'Chinese' }, +}; + +export function isLocale(value: unknown): value is Locale { + return typeof value === 'string' && (locales as readonly string[]).includes(value); +} + +/** + * Accepts a bare `string` because next-intl's `useLocale()`/`getLocale()` + * are typed loosely; an unrecognised tag is treated as left-to-right. + */ +export function isRtl(locale: string): boolean { + return isLocale(locale) && rtlLocales.includes(locale); +} + +/** The value for ``. */ +export function dirFor(locale: string): 'rtl' | 'ltr' { + return isRtl(locale) ? 'rtl' : 'ltr'; +} + +/** + * Picks the best supported locale from an `Accept-Language` header. + * + * Implements the parts of RFC 9110 §12.5.4 that matter here: q-values order + * the candidates, `*` is ignored, and a regional tag falls back to its base + * language (`pt-BR` → `pt`, `zh-Hans-CN` → `zh`) so readers are not dropped to + * English just because they sent a region. Returns `defaultLocale` when the + * header is absent, malformed, or lists nothing we support. + */ +export function negotiateLocale(acceptLanguage: string | null | undefined): Locale { + if (!acceptLanguage) return defaultLocale; + + const candidates = acceptLanguage + .split(',') + .map(part => { + const [tag, ...params] = part.trim().split(';'); + const q = params + .map(p => p.trim()) + .find(p => p.startsWith('q=')) + ?.slice(2); + const quality = q === undefined ? 1 : Number.parseFloat(q); + return { tag: tag.trim().toLowerCase(), quality: Number.isFinite(quality) ? quality : 0 }; + }) + .filter(c => c.tag && c.tag !== '*' && c.quality > 0) + // Stable sort by descending quality: equal-q tags keep header order, which + // is the reader's own preference order. + .sort((a, b) => b.quality - a.quality); + + for (const { tag } of candidates) { + if (isLocale(tag)) return tag; + const base = tag.split('-')[0]; + if (isLocale(base)) return base; + } + + return defaultLocale; +} diff --git a/invofi/apps/frontend/src/i18n/icu.test.ts b/invofi/apps/frontend/src/i18n/icu.test.ts new file mode 100644 index 000000000..586f640d3 --- /dev/null +++ b/invofi/apps/frontend/src/i18n/icu.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { icuArguments, assertIcuValid, IcuSyntaxError } from './icu'; + +describe('icuArguments', () => { + it('finds simple arguments', () => { + expect([...icuArguments('Invoice {id} was cancelled.')]).toEqual(['id']); + expect([...icuArguments('{repaid} repaid · {remaining} remaining')]).toEqual([ + 'repaid', + 'remaining', + ]); + expect([...icuArguments('No placeholders here.')]).toEqual([]); + }); + + it('does not mistake plural branch text for arguments', () => { + // The braces around "no active positions" delimit literal text. A regex + // scan reports a phantom `no` here. + const message = + 'Accruing across {count, plural, =0 {no active positions} one {# active position} other {# active positions}}'; + expect([...icuArguments(message)]).toEqual(['count']); + }); + + it('does not mistake a one-word branch for an argument', () => { + // `one {day}` looks character-for-character like `{amount}` to a regex. + expect([...icuArguments('{count, plural, one {day} other {days}}')]).toEqual(['count']); + }); + + it('finds arguments nested inside plural branches', () => { + const message = '{count, plural, one {{name} has one invoice} other {{name} has # invoices}}'; + expect([...icuArguments(message)].sort()).toEqual(['count', 'name']); + }); + + it('handles typed arguments and select', () => { + expect([...icuArguments('Due {date, date, medium}')]).toEqual(['date']); + expect([...icuArguments('{role, select, lender {Lender} other {Business}}')]).toEqual(['role']); + }); + + it('respects ICU apostrophe escaping', () => { + expect([...icuArguments("Use '{'literal'}' braces with {real}")]).toEqual(['real']); + }); +}); + +describe('assertIcuValid', () => { + it('accepts well-formed messages', () => { + expect(() => assertIcuValid('{count, plural, one {# day} other {# days}}')).not.toThrow(); + expect(() => + assertIcuValid( + '{count, plural, zero {لا مراكز} one {مركز} two {مركزان} few {# مراكز} many {# مركزًا} other {# مركز}}', + ), + ).not.toThrow(); + }); + + it('rejects an unbalanced brace', () => { + expect(() => assertIcuValid('Invoice {id was cancelled')).toThrow(IcuSyntaxError); + expect(() => assertIcuValid('Invoice {id} }')).toThrow(IcuSyntaxError); + }); + + it('rejects a plural with no catch-all branch', () => { + // A translator who enumerates only `one` and `two` renders nothing for + // every other count. + expect(() => assertIcuValid('{count, plural, one {# day} two {# days}}')).toThrow( + /no 'other' branch/, + ); + }); + + it('rejects a branch with no body', () => { + expect(() => assertIcuValid('{count, plural, other}')).toThrow(IcuSyntaxError); + }); +}); diff --git a/invofi/apps/frontend/src/i18n/icu.ts b/invofi/apps/frontend/src/i18n/icu.ts new file mode 100644 index 000000000..a4d100cc0 --- /dev/null +++ b/invofi/apps/frontend/src/i18n/icu.ts @@ -0,0 +1,140 @@ +/** + * A minimal ICU MessageFormat reader (issue #227). + * + * Only two questions need answering about a catalogue entry, and both are + * asked by `src/i18n/messages.test.ts`: + * + * 1. which arguments does this message expect? + * 2. is its structure well-formed? + * + * A regex cannot answer either. In + * `{count, plural, =0 {no active positions} other {# positions}}` the inner + * braces delimit *literal text*, not arguments — a naive scan reports a + * phantom `no` placeholder, and a one-word branch like `one {day}` looks + * exactly like `{amount}`. Distinguishing them requires knowing whether a + * given `{` sits in argument position or inside a plural branch, which is + * what this walker tracks. + * + * This is deliberately not a general ICU implementation — rendering is + * next-intl's job. It is a structural reader for the catalogue tests. + */ + +const SUBMESSAGE_TYPES = new Set(['plural', 'select', 'selectordinal']); +const IDENT = /[A-Za-z0-9_]/; + +export class IcuSyntaxError extends Error {} + +interface Cursor { + text: string; + i: number; +} + +function skipSpace(c: Cursor): void { + while (c.i < c.text.length && /\s/.test(c.text[c.i])) c.i += 1; +} + +function readIdent(c: Cursor): string { + const start = c.i; + while (c.i < c.text.length && IDENT.test(c.text[c.i])) c.i += 1; + return c.text.slice(start, c.i); +} + +/** Reads a message body up to its closing `}` (or end of input at depth 0). */ +function readMessage(c: Cursor, names: Set, depth: number): void { + while (c.i < c.text.length) { + const ch = c.text[c.i]; + + if (ch === '}') { + if (depth === 0) throw new IcuSyntaxError(`unexpected '}' at ${c.i}`); + return; + } + + if (ch === "'") { + // ICU apostrophe escaping: '{' and '}' are literal, '' is an apostrophe. + c.i += 1; + if (c.text[c.i] === "'") { c.i += 1; continue; } + while (c.i < c.text.length && c.text[c.i] !== "'") c.i += 1; + c.i += 1; + continue; + } + + if (ch !== '{') { c.i += 1; continue; } + + // ── Argument ── + c.i += 1; + skipSpace(c); + const name = readIdent(c); + if (!name) throw new IcuSyntaxError(`argument with no name at ${c.i}`); + names.add(name); + skipSpace(c); + + if (c.text[c.i] === '}') { c.i += 1; continue; } + if (c.text[c.i] !== ',') throw new IcuSyntaxError(`expected ',' or '}' after {${name}`); + + c.i += 1; + skipSpace(c); + const type = readIdent(c); + skipSpace(c); + + if (!SUBMESSAGE_TYPES.has(type)) { + // number / date / time / a bare style — skip to the matching brace. + let open = 1; + while (c.i < c.text.length && open > 0) { + if (c.text[c.i] === '{') open += 1; + else if (c.text[c.i] === '}') open -= 1; + c.i += 1; + } + if (open > 0) throw new IcuSyntaxError(`unclosed {${name}`); + continue; + } + + // ── plural / select: a sequence of `key {submessage}` ── + if (c.text[c.i] !== ',') throw new IcuSyntaxError(`expected ',' after ${type}`); + c.i += 1; + + const branches: string[] = []; + for (;;) { + skipSpace(c); + if (c.text[c.i] === '}') { c.i += 1; break; } + if (c.i >= c.text.length) throw new IcuSyntaxError(`unclosed ${type} for {${name}`); + + // `offset:1` is a plural modifier, not a branch. + if (c.text.startsWith('offset:', c.i)) { + c.i += 'offset:'.length; + readIdent(c); + continue; + } + + const exact = c.text[c.i] === '='; + if (exact) c.i += 1; + const key = readIdent(c); + if (!key) throw new IcuSyntaxError(`empty ${type} branch key for {${name}`); + branches.push(exact ? `=${key}` : key); + + skipSpace(c); + if (c.text[c.i] !== '{') throw new IcuSyntaxError(`branch '${key}' has no body`); + c.i += 1; + readMessage(c, names, depth + 1); // branch bodies are messages + if (c.text[c.i] !== '}') throw new IcuSyntaxError(`unclosed branch '${key}'`); + c.i += 1; + } + + // Every plural/select needs a catch-all, or a count the translator did not + // enumerate renders nothing at all. + if (!branches.includes('other')) { + throw new IcuSyntaxError(`${type} for {${name}} has no 'other' branch`); + } + } +} + +/** Argument names a message expects, e.g. `{amount}` and `{count, plural, …}`. */ +export function icuArguments(message: string): Set { + const names = new Set(); + readMessage({ text: message, i: 0 }, names, 0); + return names; +} + +/** Throws `IcuSyntaxError` if the message is not structurally well-formed. */ +export function assertIcuValid(message: string): void { + icuArguments(message); +} diff --git a/invofi/apps/frontend/src/i18n/locale.ts b/invofi/apps/frontend/src/i18n/locale.ts new file mode 100644 index 000000000..d052b2259 --- /dev/null +++ b/invofi/apps/frontend/src/i18n/locale.ts @@ -0,0 +1,44 @@ +'use server'; + +import { cookies, headers } from 'next/headers'; +import { + LOCALE_COOKIE, + LOCALE_COOKIE_MAX_AGE, + defaultLocale, + isLocale, + negotiateLocale, + type Locale, +} from './config'; + +/** + * The reader's active locale. + * + * Precedence: an explicit choice (cookie) beats the browser's + * `Accept-Language`, which beats English. The middleware normally persists the + * negotiated value on the first request; re-negotiating here keeps the very + * first render correct even on paths the middleware skips (static assets + * aside, that is mainly the case in tests and previews). + */ +export async function getUserLocale(): Promise { + const chosen = cookies().get(LOCALE_COOKIE)?.value; + if (isLocale(chosen)) return chosen; + + try { + return negotiateLocale(headers().get('accept-language')); + } catch { + return defaultLocale; + } +} + +/** + * Persists an explicit language choice. Called from the settings switcher as a + * server action, so the next render (and every later visit) uses it. + */ +export async function setUserLocale(locale: Locale): Promise { + if (!isLocale(locale)) return; + cookies().set(LOCALE_COOKIE, locale, { + maxAge: LOCALE_COOKIE_MAX_AGE, + sameSite: 'lax', + path: '/', + }); +} diff --git a/invofi/apps/frontend/src/i18n/messages.test.ts b/invofi/apps/frontend/src/i18n/messages.test.ts new file mode 100644 index 000000000..809223cca --- /dev/null +++ b/invofi/apps/frontend/src/i18n/messages.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { assertIcuValid, icuArguments } from './icu'; +import { locales, defaultLocale, type Locale } from './config'; +import { loadMessages } from './messages'; + +/** + * Catalogue integrity (Issue #227). + * + * These are the guardrails the contribution workflow in `docs/i18n.md` leans + * on: a translator can open a PR touching only `messages/.json` and + * these tests tell them whether it is safe to merge. + */ + +const MESSAGES_DIR = path.join(process.cwd(), 'messages'); + +type Tree = { [key: string]: string | Tree }; + +function readCatalogue(locale: string): Tree { + return JSON.parse(fs.readFileSync(path.join(MESSAGES_DIR, `${locale}.json`), 'utf8')); +} + +/** Flattens to `Namespace.section.key` → message. */ +function flatten(tree: Tree, prefix = ''): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(tree)) { + const full = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'string') out[full] = value; + else Object.assign(out, flatten(value, full)); + } + return out; +} + +/** Arguments a message expects; unparseable messages are the ICU test's job. */ +function safeArguments(message: string): Set { + try { + return icuArguments(message); + } catch { + return new Set(); + } +} + +const english = flatten(readCatalogue(defaultLocale)); +const translated = locales.filter(l => l !== defaultLocale); + +describe('message catalogues', () => { + it('ships a file for every configured locale', () => { + for (const locale of locales) { + expect(fs.existsSync(path.join(MESSAGES_DIR, `${locale}.json`)), locale).toBe(true); + } + }); + + it.each(translated)('%s introduces no keys English does not have', locale => { + // An extra key is either a typo or a stale message: it can never render, + // and it hides the fact that the real key is still untranslated. + const extra = Object.keys(flatten(readCatalogue(locale))).filter(k => !(k in english)); + expect(extra).toEqual([]); + }); + + it.each(translated)('%s keeps every placeholder its English source uses', locale => { + const catalogue = flatten(readCatalogue(locale)); + const broken: string[] = []; + + for (const [key, message] of Object.entries(catalogue)) { + const expected = safeArguments(english[key] ?? ''); + const actual = safeArguments(message); + for (const name of expected) { + // A dropped `{amount}` renders a sentence with a hole in it; a renamed + // one throws at render time. + if (!actual.has(name)) broken.push(`${key}: missing {${name}}`); + } + } + + expect(broken).toEqual([]); + }); + + it.each(translated)('%s is structurally valid ICU', locale => { + const catalogue = flatten(readCatalogue(locale)); + const invalid: string[] = []; + + for (const [key, message] of Object.entries(catalogue)) { + try { + // Catches an unbalanced brace, a branch with no body, or a plural + // that enumerates some categories but has no catch-all — all of which + // render as nothing on a reader's screen. + assertIcuValid(message); + } catch (error) { + invalid.push(`${key}: ${(error as Error).message}`); + } + } + + expect(invalid).toEqual([]); + }); + + it('falls back to English for keys a translation has not covered yet', async () => { + // Turkish has no Landing copy; the merge must still yield the English one + // rather than a missing-key placeholder. + const merged = flatten((await loadMessages('tr' as Locale)) as Tree); + expect(Object.keys(merged).sort()).toEqual(Object.keys(english).sort()); + expect(merged['Landing.hero.getStarted']).toBe(english['Landing.hero.getStarted']); + expect(merged['Navbar.dashboard']).toBe('Panel'); + }); + + it('translates the whole catalogue for the RTL reference locale', () => { + // Arabic is the correct-by-construction reference pair for RTL: every key + // is translated, so the RTL layout is exercised with no English left in it. + const arabic = flatten(readCatalogue('ar')); + expect(Object.keys(arabic).sort()).toEqual(Object.keys(english).sort()); + }); +}); diff --git a/invofi/apps/frontend/src/i18n/messages.ts b/invofi/apps/frontend/src/i18n/messages.ts new file mode 100644 index 000000000..1969b0613 --- /dev/null +++ b/invofi/apps/frontend/src/i18n/messages.ts @@ -0,0 +1,39 @@ +import { defaultLocale, type Locale } from './config'; + +type MessageTree = { [key: string]: string | MessageTree }; + +/** + * Deep-merges a translation file over the English baseline. + * + * Translations arrive incrementally — a contributor may land 40 of 300 keys + * for a new language. Without a merge, next-intl reports every absent key and + * the UI shows raw key names. Merging means a partial file renders translated + * where it can and English where it cannot, which is what makes the + * contribution workflow in `docs/i18n.md` safe to open up. + */ +function mergeMessages(base: MessageTree, override: MessageTree): MessageTree { + const merged: MessageTree = { ...base }; + for (const [key, value] of Object.entries(override)) { + const existing = merged[key]; + merged[key] = + typeof value === 'object' && value !== null && typeof existing === 'object' && existing !== null + ? mergeMessages(existing, value) + : value; + } + return merged; +} + +/** Loads `messages/.json`, backfilled with English. */ +export async function loadMessages(locale: Locale): Promise { + const base = (await import('../../messages/en.json')).default as MessageTree; + if (locale === defaultLocale) return base; + + try { + const translated = (await import(`../../messages/${locale}.json`)).default as MessageTree; + return mergeMessages(base, translated); + } catch { + // A locale listed in config.ts without a messages file still renders, + // in English, rather than throwing on every request. + return base; + } +} diff --git a/invofi/apps/frontend/src/i18n/request.ts b/invofi/apps/frontend/src/i18n/request.ts index dd6ac6312..51f0ac45f 100644 --- a/invofi/apps/frontend/src/i18n/request.ts +++ b/invofi/apps/frontend/src/i18n/request.ts @@ -1,11 +1,28 @@ import { getRequestConfig } from 'next-intl/server'; +import { getUserLocale } from './locale'; +import { loadMessages } from './messages'; +/** + * next-intl request config (issue #227). + * + * The app deliberately runs *without* locale-prefixed routes: language is a + * reader preference held in a cookie, not part of the URL. See `docs/i18n.md` + * for the reasoning — in short, every app route here is wallet/auth-gated, the + * Supabase session middleware and the sitemap are keyed on unprefixed paths, + * and duplicating every route under `[locale]` would fork all existing deep + * links for no SEO gain. + */ export default getRequestConfig(async () => { - // Provide a static locale, fetch messages - const locale = 'en'; + const locale = await getUserLocale(); return { locale, - messages: (await import(`../../messages/${locale}.json`)).default + messages: await loadMessages(locale), + formats: { + dateTime: { + short: { dateStyle: 'medium' }, + long: { dateStyle: 'long', timeStyle: 'short' }, + }, + }, }; }); diff --git a/invofi/apps/frontend/src/lib/intl.test.ts b/invofi/apps/frontend/src/lib/intl.test.ts new file mode 100644 index 000000000..7134b8b60 --- /dev/null +++ b/invofi/apps/frontend/src/lib/intl.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + daysUntil, + formatAddress, + formatCurrency, + formatDate, + formatDateTime, + formatNumber, + formatPercent, + formatRelativeDays, +} from './intl'; + +/** 10,000.00 in stroops (7 dp). */ +const TEN_THOUSAND = 100_000_000_000n; + +/** The amount and its ticker are joined by U+00A0 so they never line-break. */ +const NBSP = '\u00A0'; + +describe('formatCurrency', () => { + it('groups digits the way each locale does', () => { + // German swaps the roles of "." and ",", so a hardcoded en-US format is + // not merely ugly there — it reads as a different number. + expect(formatCurrency(TEN_THOUSAND, 'XLM', 'en')).toBe(`10,000${NBSP}XLM`); + expect(formatCurrency(TEN_THOUSAND, 'XLM', 'de')).toBe(`10.000${NBSP}XLM`); + }); + + it('places the currency symbol per locale for ISO-coded assets', () => { + const en = formatCurrency(TEN_THOUSAND, 'USDC', 'en'); + const fr = formatCurrency(TEN_THOUSAND, 'USDC', 'fr'); + expect(en.startsWith('$')).toBe(true); + // French puts the symbol after the amount — the exact spacing character is + // an ICU implementation detail, so assert on placement, not on the string. + expect(fr.startsWith('$')).toBe(false); + expect(fr.trimEnd().endsWith('$')).toBe(true); + }); + + it('keeps non-ISO tickers as a suffix rather than inventing a symbol', () => { + expect(formatCurrency(TEN_THOUSAND, 'XLM', 'ja')).toContain('XLM'); + }); + + it('handles fractional stroops without floating-point drift', () => { + expect(formatCurrency(25_000_000n, 'XLM', 'en')).toBe(`2.5${NBSP}XLM`); + expect(formatCurrency(1n, 'XLM', 'en')).toBe(`0.0000001${NBSP}XLM`); + }); +}); + +describe('formatPercent', () => { + it('renders basis points in the locale’s percent convention', () => { + expect(formatPercent(500, 'en')).toBe('5.00%'); + // Turkish writes the sign first. + expect(formatPercent(500, 'tr').startsWith('%')).toBe(true); + }); +}); + +describe('formatDate', () => { + const TS = 1_787_000_000; // Unix seconds + + it('orders date fields per locale', () => { + const en = formatDate(TS, 'en'); + const ja = formatDate(TS, 'ja'); + expect(en).not.toBe(ja); + // CJK is year-first; English is month-first. No single format string can + // express both, which is the reason this goes through Intl. + expect(ja.startsWith('2026')).toBe(true); + expect(en.startsWith('2026')).toBe(false); + // The long form marks each field with its CJK unit. + expect(formatDate(TS, 'ja', { dateStyle: 'long' })).toMatch(/年/); + }); + + it('accepts seconds, milliseconds and ISO strings alike', () => { + const fromSeconds = formatDate(TS, 'en'); + const fromMillis = formatDate(TS * 1000, 'en'); + const fromIso = formatDate(new Date(TS * 1000).toISOString(), 'en'); + expect(fromMillis).toBe(fromSeconds); + expect(fromIso).toBe(fromSeconds); + }); + + it('renders a dash for missing or unparseable input', () => { + expect(formatDate(null, 'en')).toBe('-'); + expect(formatDate(undefined, 'en')).toBe('-'); + expect(formatDate('not a date', 'en')).toBe('-'); + }); + + it('includes a time component in formatDateTime', () => { + expect(formatDateTime(TS, 'en').length).toBeGreaterThan(formatDate(TS, 'en').length); + }); +}); + +describe('relative days', () => { + afterEach(() => vi.useRealTimers()); + + it('counts whole days to the due date, negative when overdue', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-24T00:00:00Z')); + expect(daysUntil(Math.floor(Date.parse('2026-08-27T00:00:00Z') / 1000))).toBe(3); + expect(daysUntil(Math.floor(Date.parse('2026-08-22T00:00:00Z') / 1000))).toBe(-2); + expect(daysUntil(null)).toBeNull(); + }); + + it('uses the language’s own plural rules', () => { + // English has two plural forms, Japanese has none, Arabic has six — which + // is exactly why this goes through Intl rather than `${n} days`. + expect(formatRelativeDays(3, 'en')).toBe('in 3 days'); + expect(formatRelativeDays(1, 'en')).toBe('tomorrow'); + expect(formatRelativeDays(-1, 'en')).toBe('yesterday'); + expect(formatRelativeDays(3, 'ja')).not.toContain('days'); + expect(formatRelativeDays(3, 'ar')).toMatch(/[؀-ۿ]/); + }); +}); + +describe('formatNumber / formatAddress', () => { + it('formats counts per locale and survives bad input', () => { + expect(formatNumber(1234, 'en')).toBe('1,234'); + expect(formatNumber(1234, 'de')).toBe('1.234'); + expect(formatNumber(undefined, 'en')).toBe('0'); + expect(formatNumber('abc', 'en')).toBe('-'); + }); + + it('elides a strkey without localising it', () => { + const address = 'GCHVSUK5XKL44CSZ3WGI2W2OZCC7SXZMM5B34TCOQ2YNEGPNP3BLOVMT'; + expect(formatAddress(address)).toBe('GCHV…OVMT'); + expect(formatAddress('short')).toBe('short'); + }); +}); diff --git a/invofi/apps/frontend/src/lib/intl.ts b/invofi/apps/frontend/src/lib/intl.ts new file mode 100644 index 000000000..d772c8009 --- /dev/null +++ b/invofi/apps/frontend/src/lib/intl.ts @@ -0,0 +1,155 @@ +/** + * Locale-aware value formatting (issue #227). + * + * Everything here goes through the platform `Intl` APIs rather than string + * concatenation, because the differences between locales are not cosmetic: + * + * - Digit grouping and the decimal separator differ (`1,234.50` vs `1.234,50` + * vs the Arabic-Indic digits some `ar` regions default to). + * - Currency *placement* is locale-dependent, and in RTL locales the symbol + * sits on the other side of the number. `Intl.NumberFormat` with + * `style: 'currency'` is the only thing that gets this right. + * - Date field order differs (`Aug 24, 2026` vs `24 août 2026` vs + * `2026年8月24日`), which no format string can cover for twelve locales. + * - Relative time ("3 days remaining") needs `Intl.RelativeTimeFormat`; + * English's single plural rule is wrong for Arabic (six forms) and for CJK + * (none). + * + * The pure functions below take an explicit locale so they are testable + * without React; components should use the `useFormat()` hook in + * `src/hooks/useFormat.ts`, which binds the reader's active locale. + */ + +import { STROOPS_PER_XLM } from './constants'; +import { toStroopsBigInt } from './utils'; + +/** + * Assets InvoFi denominates in. `XLM` has no ISO 4217 code, so it cannot go + * through `style: 'currency'`; it is formatted as a decimal with the ticker + * appended in the locale's own writing order. + */ +const ISO_CURRENCIES: Record = { USDC: 'USD' }; + +/** Stroops → a human amount, as a `number` safe for `Intl` (7 dp). */ +function stroopsToUnits(stroops: bigint | number | string | null | undefined): number { + const value = toStroopsBigInt(stroops); + const whole = value / BigInt(STROOPS_PER_XLM); + const fraction = value % BigInt(STROOPS_PER_XLM); + return Number(whole) + Number(fraction) / STROOPS_PER_XLM; +} + +/** + * Formats an on-chain amount for display, e.g. `1.234,50 $` (de) or + * `‏10,000.00 US$` (ar, with the symbol mirrored by the locale's own rules). + */ +export function formatCurrency( + stroops: bigint | number | string | null | undefined, + currency: string, + locale: string, + options: { maximumFractionDigits?: number } = {}, +): string { + const units = stroopsToUnits(stroops); + const iso = ISO_CURRENCIES[currency]; + + if (iso) { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: iso, + currencyDisplay: 'narrowSymbol', + maximumFractionDigits: options.maximumFractionDigits ?? 2, + }).format(units); + } + + // XLM and any future non-ISO asset: locale-formatted number, ticker + // appended after a non-breaking space so the amount and its ticker are + // never split across a line break. + const number = new Intl.NumberFormat(locale, { + maximumFractionDigits: options.maximumFractionDigits ?? 7, + }).format(units); + return `${number}\u00A0${currency}`; +} + +/** A plain locale-formatted number (no currency), e.g. counts and totals. */ +export function formatNumber( + value: number | bigint | string | null | undefined, + locale: string, + options: Intl.NumberFormatOptions = {}, +): string { + const numeric = typeof value === 'bigint' ? Number(value) : Number(value ?? 0); + if (!Number.isFinite(numeric)) return '-'; + return new Intl.NumberFormat(locale, options).format(numeric); +} + +/** Basis points → a locale-formatted percentage, e.g. `5.00%` / `%5,00`. */ +export function formatPercent( + basisPoints: number | bigint | string | null | undefined, + locale: string, +): string { + const numeric = Number(basisPoints ?? 0); + if (!Number.isFinite(numeric)) return '-'; + return new Intl.NumberFormat(locale, { + style: 'percent', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numeric / 10_000); +} + +/** Accepts Unix seconds, milliseconds, or an ISO string. */ +function toDate(timestamp: number | bigint | string | null | undefined): Date | null { + if (timestamp === null || timestamp === undefined || timestamp === '') return null; + const numeric = Number(timestamp); + if (Number.isFinite(numeric) && numeric !== 0) { + // Below ~1e11 the value is Unix *seconds*, above it milliseconds. + return new Date(numeric < 1e11 ? numeric * 1000 : numeric); + } + const parsed = new Date(String(timestamp)); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +/** A date in the locale's own field order, e.g. `2026年8月24日`. */ +export function formatDate( + timestamp: number | bigint | string | null | undefined, + locale: string, + options: Intl.DateTimeFormatOptions = { dateStyle: 'medium' }, +): string { + const date = toDate(timestamp); + if (!date) return '-'; + return new Intl.DateTimeFormat(locale, options).format(date); +} + +/** A date and time, for audit trails and event timelines. */ +export function formatDateTime( + timestamp: number | bigint | string | null | undefined, + locale: string, +): string { + return formatDate(timestamp, locale, { dateStyle: 'medium', timeStyle: 'short' }); +} + +/** + * Whole days between now and `timestamp`, negative when overdue. Split out so + * the plural/overdue *wording* stays in the message catalogue (where Arabic + * can supply its six plural forms) rather than being hardcoded here. + */ +export function daysUntil(timestamp: number | bigint | string | null | undefined): number | null { + const date = toDate(timestamp); + if (!date) return null; + return Math.ceil((date.getTime() - Date.now()) / 86_400_000); +} + +/** + * "in 3 days" / "3 days ago", in the reader's language and with the correct + * plural form for it. + */ +export function formatRelativeDays(days: number, locale: string): string { + return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(days, 'day'); +} + +/** + * A truncated Stellar address. Deliberately *not* localised: strkeys are + * base32 identifiers, and the ellipsis stays visually centred in RTL because + * the surrounding element carries `dir="ltr"`. + */ +export function formatAddress(address: string, chars = 4): string { + if (!address || address.length < chars * 2 + 2) return address; + return `${address.slice(0, chars)}…${address.slice(-chars)}`; +} diff --git a/invofi/apps/frontend/src/middleware.ts b/invofi/apps/frontend/src/middleware.ts index dc8ebad89..91c48411d 100644 --- a/invofi/apps/frontend/src/middleware.ts +++ b/invofi/apps/frontend/src/middleware.ts @@ -1,6 +1,12 @@ import { type NextRequest, NextResponse } from 'next/server'; import { updateSession } from '@/utils/supabase/middleware'; import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; +import { + LOCALE_COOKIE, + LOCALE_COOKIE_MAX_AGE, + isLocale, + negotiateLocale, +} from '@/i18n/config'; /** * Rate-limit config for auth and wallet-sign endpoints (roadmap v0.4). @@ -53,7 +59,31 @@ export async function middleware(request: NextRequest) { } } - return await updateSession(request); + const response = await updateSession(request); + persistNegotiatedLocale(request, response); + return response; +} + +/** + * Browser-language auto-detection (issue #227). + * + * On a reader's first request there is no locale cookie, so the best supported + * match for their `Accept-Language` header is written to one. Doing it here + * rather than in a Server Component means the very first HTML response already + * carries the right `lang`/`dir`, so an Arabic reader never sees a flash of + * left-to-right English. + * + * An existing cookie is never overwritten: once a reader has chosen a language + * in Settings, their browser's header must not silently override it. + */ +function persistNegotiatedLocale(request: NextRequest, response: NextResponse): void { + if (isLocale(request.cookies.get(LOCALE_COOKIE)?.value)) return; + + response.cookies.set(LOCALE_COOKIE, negotiateLocale(request.headers.get('accept-language')), { + maxAge: LOCALE_COOKIE_MAX_AGE, + sameSite: 'lax', + path: '/', + }); } export const config = { diff --git a/invofi/apps/frontend/src/test/intl.tsx b/invofi/apps/frontend/src/test/intl.tsx new file mode 100644 index 000000000..05e159320 --- /dev/null +++ b/invofi/apps/frontend/src/test/intl.tsx @@ -0,0 +1,25 @@ +import { render, type RenderOptions, type RenderResult } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement, ReactNode } from 'react'; +import messages from '../../messages/en.json'; +import { defaultLocale } from '@/i18n/config'; + +/** + * Renders a component inside the i18n provider (issue #227). + * + * Components that call `useTranslations()` throw without a provider, so every + * component test needs one. Using the real `messages/en.json` rather than a + * stub means these tests also fail if a key is renamed or removed from the + * catalogue without the component being updated. + */ +export function renderWithIntl(ui: ReactElement, options?: RenderOptions): RenderResult { + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + return render(ui, { wrapper: Wrapper, ...options }); +} + +export * from '@testing-library/react'; +export { renderWithIntl as render };