i18n: translate CampaignDetailPage

Extract user-facing strings on the campaign detail page into the
campaignsDetail.* namespace. Covers the hero (back/edit/delete chips,
author attribution, deadline pill, comment action label), the
engagement counter row above the comments (repost/quote/like counts
with pluralized labels and a bold count wrapper), the comments +
donations section header and empty state, the delete-confirm
AlertDialog, the donate sidebar (raised/of-goal labels, donation
count, recent-donations list, share button, ended state), the story
component, and pin/unpin and deletion toasts.

Chevron + arrow icons flip with rtl:rotate-180. Uses i18next plural
suffixes for the four count-driven labels (reposts/quotes/likes,
comments, donations, days-left).

This completes Priority 1 of the i18n rollout (Pledges +
Communities/Groups + Campaigns verticals — list pages, create forms,
detail pages).

Native-speaker review still pending for: km, sn, ps, fa.
This commit is contained in:
mkfain
2026-05-23 17:14:52 -05:00
parent c111ebc93e
commit 86a084f30d
9 changed files with 466 additions and 67 deletions
+49
View File
@@ -354,6 +354,55 @@
"errorWalletRequiredFallback": "نقطة محفظة مطلوبة.",
"errorPublishedInvalid": "فشل التحقق من الحدث المنشور. الرجاء التحديث والمحاولة مرة أخرى."
},
"campaignsDetail": {
"seoTitle": "{{title}} | حملات {{appName}}",
"seoDescriptionFallback": "ادعم {{title}} على {{appName}}.",
"deadlineEndedOn": "انتهى في {{date}}",
"deadlineEndsToday": "ينتهي اليوم",
"deadlineDaysLeft_one": "بقي {{count}} يوم",
"deadlineDaysLeft_other": "بقي {{count}} يومًا",
"deadlineEndsOn": "ينتهي في {{date}}",
"back": "رجوع",
"edit": "تعديل",
"delete": "حذف",
"deleting": "جارٍ الحذف…",
"byAuthor": "بواسطة <0>{{name}}</0>",
"commentLabel": "تعليق",
"linkCopied": "تم نسخ الرابط إلى الحافظة",
"pinnedToast": "تم التثبيت على الحملة",
"unpinnedToast": "تم إلغاء التثبيت",
"pinFailed": "تعذّر تحديث المثبتات",
"deletedToast": "تم حذف الحملة",
"deletedToastDesc": "تم نشر طلب حذف. ستزيل المرحّلات المتعاونة الحملة من الخلاصات.",
"deleteErrorTitle": "تعذّر حذف الحملة",
"campaigner": "صاحب الحملة",
"repost_one": "<0>{{count}}</0> إعادة نشر",
"repost_other": "<0>{{count}}</0> إعادات نشر",
"quote_one": "<0>{{count}}</0> اقتباس",
"quote_other": "<0>{{count}}</0> اقتباسات",
"like_one": "<0>{{count}}</0> إعجاب",
"like_other": "<0>{{count}}</0> إعجابات",
"commentsAndDonations": "التعليقات والتبرعات",
"commentCount_one": "{{count}} تعليق",
"commentCount_other": "{{count}} تعليقات",
"noCommentsTitle": "لا توجد تعليقات بعد",
"noCommentsHint": "كن أول من يترك رسالة دعم.",
"deleteDialogTitle": "حذف هذه الحملة؟",
"deleteDialogBody": "هذا ينشر طلب حذف NIP-09. ستزيل المرحّلات المتعاونة الحملة من الخلاصات والروابط المباشرة. تبقى إيصالات التبرعات السابقة على السلسلة بغض النظر. لا يمكن التراجع عن هذا الإجراء — لمواصلة قبول التبرعات، عدّل الحملة بدلًا من ذلك.",
"storyHeading": "القصة",
"storyEmpty": "لم يكتب المنظم قصة هذه الحملة بعد.",
"campaignEnded": "انتهت الحملة",
"donate": "تبرّع",
"share": "مشاركة",
"target": "الهدف: {{amount}}",
"raised": "مجموع",
"ofGoal": "من هدف {{amount}}",
"donationCount_one": "{{count}} تبرع",
"donationCount_other": "{{count}} تبرعات",
"recentDonations": "التبرعات الأخيرة",
"seeAllDonations_one": "عرض {{count}} تبرع",
"seeAllDonations_other": "عرض جميع {{count}} تبرع"
},
"campaigns": {
"home": {
"seoTitle": "حملات تمويل",
+49
View File
@@ -354,6 +354,55 @@
"errorWalletRequiredFallback": "Wallet endpoint is required.",
"errorPublishedInvalid": "Published event failed validation. Please refresh and try again."
},
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} Fundraisers",
"seoDescriptionFallback": "Support {{title}} on {{appName}}.",
"deadlineEndedOn": "Ended {{date}}",
"deadlineEndsToday": "Ends today",
"deadlineDaysLeft_one": "{{count}} day left",
"deadlineDaysLeft_other": "{{count}} days left",
"deadlineEndsOn": "Ends {{date}}",
"back": "Back",
"edit": "Edit",
"delete": "Delete",
"deleting": "Deleting…",
"byAuthor": "by <0>{{name}}</0>",
"commentLabel": "Comment",
"linkCopied": "Link copied to clipboard",
"pinnedToast": "Pinned to campaign",
"unpinnedToast": "Unpinned from campaign",
"pinFailed": "Failed to update campaign pins",
"deletedToast": "Campaign deleted",
"deletedToastDesc": "A deletion request was published. Well-behaved relays will drop the campaign from feeds.",
"deleteErrorTitle": "Could not delete campaign",
"campaigner": "Campaigner",
"repost_one": "<0>{{count}}</0> Repost",
"repost_other": "<0>{{count}}</0> Reposts",
"quote_one": "<0>{{count}}</0> Quote",
"quote_other": "<0>{{count}}</0> Quotes",
"like_one": "<0>{{count}}</0> Like",
"like_other": "<0>{{count}}</0> Likes",
"commentsAndDonations": "Comments & donations",
"commentCount_one": "{{count}} comment",
"commentCount_other": "{{count}} comments",
"noCommentsTitle": "No comments yet",
"noCommentsHint": "Be the first to leave a message of support.",
"deleteDialogTitle": "Delete this campaign?",
"deleteDialogBody": "This publishes a NIP-09 deletion request. Well-behaved relays will drop the campaign from feeds and direct links. Past donation receipts stay on-chain regardless. This action cannot be undone — to keep accepting donations, edit the campaign instead.",
"storyHeading": "The story",
"storyEmpty": "The organizer hasn't written a story for this campaign yet.",
"campaignEnded": "Campaign ended",
"donate": "Donate",
"share": "Share",
"target": "Target: {{amount}}",
"raised": "raised",
"ofGoal": "of {{amount}} goal",
"donationCount_one": "{{count}} donation",
"donationCount_other": "{{count}} donations",
"recentDonations": "Recent donations",
"seeAllDonations_one": "See all {{count}} donation",
"seeAllDonations_other": "See all {{count}} donations"
},
"campaigns": {
"home": {
"seoTitle": "Fundraisers",
+49
View File
@@ -354,6 +354,55 @@
"errorWalletRequiredFallback": "Se requiere un punto de cartera.",
"errorPublishedInvalid": "El evento publicado falló la validación. Recarga e inténtalo de nuevo."
},
"campaignsDetail": {
"seoTitle": "{{title}} | Recaudaciones de {{appName}}",
"seoDescriptionFallback": "Apoya {{title}} en {{appName}}.",
"deadlineEndedOn": "Finalizó el {{date}}",
"deadlineEndsToday": "Finaliza hoy",
"deadlineDaysLeft_one": "Queda {{count}} día",
"deadlineDaysLeft_other": "Quedan {{count}} días",
"deadlineEndsOn": "Finaliza el {{date}}",
"back": "Atrás",
"edit": "Editar",
"delete": "Eliminar",
"deleting": "Eliminando…",
"byAuthor": "por <0>{{name}}</0>",
"commentLabel": "Comentar",
"linkCopied": "Enlace copiado al portapapeles",
"pinnedToast": "Fijado a la campaña",
"unpinnedToast": "Quitado de la campaña",
"pinFailed": "No se pudieron actualizar los fijados",
"deletedToast": "Campaña eliminada",
"deletedToastDesc": "Se publicó una solicitud de eliminación. Los relés que se comportan bien quitarán la campaña de los feeds.",
"deleteErrorTitle": "No se pudo eliminar la campaña",
"campaigner": "Organizador",
"repost_one": "<0>{{count}}</0> Reposteo",
"repost_other": "<0>{{count}}</0> Reposteos",
"quote_one": "<0>{{count}}</0> Cita",
"quote_other": "<0>{{count}}</0> Citas",
"like_one": "<0>{{count}}</0> Me gusta",
"like_other": "<0>{{count}}</0> Me gusta",
"commentsAndDonations": "Comentarios y donaciones",
"commentCount_one": "{{count}} comentario",
"commentCount_other": "{{count}} comentarios",
"noCommentsTitle": "Aún no hay comentarios",
"noCommentsHint": "Sé la primera persona en dejar un mensaje de apoyo.",
"deleteDialogTitle": "¿Eliminar esta campaña?",
"deleteDialogBody": "Esto publica una solicitud de eliminación NIP-09. Los relés que se comportan bien quitarán la campaña de los feeds y los enlaces directos. Los recibos de donaciones pasadas quedan en cadena de todos modos. Esta acción no se puede deshacer — para seguir aceptando donaciones, edita la campaña en su lugar.",
"storyHeading": "La historia",
"storyEmpty": "El organizador todavía no ha escrito la historia para esta campaña.",
"campaignEnded": "Campaña finalizada",
"donate": "Donar",
"share": "Compartir",
"target": "Meta: {{amount}}",
"raised": "recaudado",
"ofGoal": "de la meta de {{amount}}",
"donationCount_one": "{{count}} donación",
"donationCount_other": "{{count}} donaciones",
"recentDonations": "Donaciones recientes",
"seeAllDonations_one": "Ver la {{count}} donación",
"seeAllDonations_other": "Ver las {{count}} donaciones"
},
"campaigns": {
"home": {
"seoTitle": "Recaudaciones",
+49
View File
@@ -354,6 +354,55 @@
"errorWalletRequiredFallback": "نقطهٔ کیف پول الزامی است.",
"errorPublishedInvalid": "رویداد منتشرشده اعتبارسنجی نشد. لطفاً صفحه را به‌روز کن و دوباره تلاش کن."
},
"campaignsDetail": {
"seoTitle": "{{title}} | کمپین‌های {{appName}}",
"seoDescriptionFallback": "از {{title}} در {{appName}} حمایت کن.",
"deadlineEndedOn": "در {{date}} پایان یافت",
"deadlineEndsToday": "امروز پایان می‌یابد",
"deadlineDaysLeft_one": "{{count}} روز باقی",
"deadlineDaysLeft_other": "{{count}} روز باقی",
"deadlineEndsOn": "در {{date}} پایان می‌یابد",
"back": "بازگشت",
"edit": "ویرایش",
"delete": "حذف",
"deleting": "در حال حذف…",
"byAuthor": "توسط <0>{{name}}</0>",
"commentLabel": "نظر",
"linkCopied": "پیوند در کلیپ‌بورد کپی شد",
"pinnedToast": "به کمپین سنجاق شد",
"unpinnedToast": "از کمپین جدا شد",
"pinFailed": "به‌روزرسانی سنجاق‌ها ناموفق بود",
"deletedToast": "کمپین حذف شد",
"deletedToastDesc": "درخواست حذف منتشر شد. رله‌های همکار کمپین را از فیدها حذف خواهند کرد.",
"deleteErrorTitle": "حذف کمپین ممکن نشد",
"campaigner": "میزبان کمپین",
"repost_one": "<0>{{count}}</0> بازنشر",
"repost_other": "<0>{{count}}</0> بازنشر",
"quote_one": "<0>{{count}}</0> نقل‌قول",
"quote_other": "<0>{{count}}</0> نقل‌قول",
"like_one": "<0>{{count}}</0> پسند",
"like_other": "<0>{{count}}</0> پسند",
"commentsAndDonations": "نظرها و کمک‌ها",
"commentCount_one": "{{count}} نظر",
"commentCount_other": "{{count}} نظر",
"noCommentsTitle": "هنوز نظری نیست",
"noCommentsHint": "اولین کسی باش که پیام حمایت می‌گذارد.",
"deleteDialogTitle": "این کمپین حذف شود؟",
"deleteDialogBody": "این یک درخواست حذف NIP-09 منتشر می‌کند. رله‌های همکار کمپین را از فیدها و پیوندهای مستقیم حذف خواهند کرد. رسیدهای کمک‌های گذشته در زنجیره باقی می‌مانند. این کار قابل بازگشت نیست — برای ادامهٔ دریافت کمک، به‌جای حذف کمپین را ویرایش کن.",
"storyHeading": "داستان",
"storyEmpty": "سازمان‌دهنده هنوز داستانی برای این کمپین ننوشته است.",
"campaignEnded": "کمپین پایان یافت",
"donate": "کمک کنید",
"share": "هم‌رسانی",
"target": "هدف: {{amount}}",
"raised": "جمع‌آوری‌شده",
"ofGoal": "از هدف {{amount}}",
"donationCount_one": "{{count}} کمک",
"donationCount_other": "{{count}} کمک",
"recentDonations": "کمک‌های اخیر",
"seeAllDonations_one": "نمایش همهٔ {{count}} کمک",
"seeAllDonations_other": "نمایش همهٔ {{count}} کمک"
},
"campaigns": {
"home": {
"seoTitle": "کمپین‌های جذب کمک مالی",
+49
View File
@@ -354,6 +354,55 @@
"errorWalletRequiredFallback": "ត្រូវការចំណុចកាបូប។",
"errorPublishedInvalid": "ព្រឹត្តិការណ៍ដែលបានផ្សព្វផ្សាយបរាជ័យក្នុងការវាយតម្លៃ។ សូមផ្ទុកឡើងវិញ ហើយព្យាយាមម្តងទៀត។"
},
"campaignsDetail": {
"seoTitle": "{{title}} | យុទ្ធនាការរបស់ {{appName}}",
"seoDescriptionFallback": "គាំទ្រ {{title}} នៅលើ {{appName}}។",
"deadlineEndedOn": "បានបញ្ចប់នៅ {{date}}",
"deadlineEndsToday": "បញ្ចប់នៅថ្ងៃនេះ",
"deadlineDaysLeft_one": "នៅសល់ {{count}} ថ្ងៃ",
"deadlineDaysLeft_other": "នៅសល់ {{count}} ថ្ងៃ",
"deadlineEndsOn": "បញ្ចប់នៅ {{date}}",
"back": "ត្រឡប់",
"edit": "កែសម្រួល",
"delete": "លុប",
"deleting": "កំពុងលុប…",
"byAuthor": "ដោយ <0>{{name}}</0>",
"commentLabel": "មតិយោបល់",
"linkCopied": "បានចម្លងតំណទៅក្តារតម្បៀតខ្ទាស់",
"pinnedToast": "បានបោះខ្ទាស់ទៅយុទ្ធនាការ",
"unpinnedToast": "បានដកខ្ទាស់ចេញពីយុទ្ធនាការ",
"pinFailed": "មិនអាចធ្វើបច្ចុប្បន្នភាពខ្ទាស់យុទ្ធនាការ",
"deletedToast": "បានលុបយុទ្ធនាការ",
"deletedToastDesc": "សំណើលុបត្រូវបានផ្សព្វផ្សាយ។ Relay ដែលអនុលោមនឹងលុបយុទ្ធនាការចេញពី feed។",
"deleteErrorTitle": "មិនអាចលុបយុទ្ធនាការ",
"campaigner": "អ្នករៀបចំ",
"repost_one": "<0>{{count}}</0> Repost",
"repost_other": "<0>{{count}}</0> Reposts",
"quote_one": "<0>{{count}}</0> ការដកស្រង់",
"quote_other": "<0>{{count}}</0> ការដកស្រង់",
"like_one": "<0>{{count}}</0> ចូលចិត្ត",
"like_other": "<0>{{count}}</0> ចូលចិត្ត",
"commentsAndDonations": "មតិយោបល់ និងការបរិច្ចាគ",
"commentCount_one": "{{count}} មតិយោបល់",
"commentCount_other": "{{count}} មតិយោបល់",
"noCommentsTitle": "មិនទាន់មានមតិយោបល់",
"noCommentsHint": "ក្លាយជាមនុស្សដំបូងផ្ញើសារគាំទ្រ។",
"deleteDialogTitle": "លុបយុទ្ធនាការនេះមែនទេ?",
"deleteDialogBody": "នេះផ្សព្វផ្សាយសំណើលុប NIP-09។ Relay ដែលអនុលោមនឹងលុបយុទ្ធនាការចេញពី feed និងតំណផ្ទាល់។ បង្កាន់ដៃនៃការបរិច្ចាគកន្លងមកនៅតែស្ថិតលើខ្សែសង្វាក់។ សកម្មភាពនេះមិនអាចត្រឡប់វិញបានទេ — ដើម្បីបន្តទទួលការបរិច្ចាគ កែសម្រួលយុទ្ធនាការជំនួស។",
"storyHeading": "រឿង",
"storyEmpty": "អ្នករៀបចំមិនទាន់សរសេររឿងសម្រាប់យុទ្ធនាការនេះទេ។",
"campaignEnded": "យុទ្ធនាការបានបញ្ចប់",
"donate": "បរិច្ចាគ",
"share": "ចែករំលែក",
"target": "គោលដៅ៖ {{amount}}",
"raised": "បានរៃអង្គាស",
"ofGoal": "នៃគោលដៅ {{amount}}",
"donationCount_one": "{{count}} ការបរិច្ចាគ",
"donationCount_other": "{{count}} ការបរិច្ចាគ",
"recentDonations": "ការបរិច្ចាគថ្មីៗ",
"seeAllDonations_one": "មើលការបរិច្ចាគ {{count}} ទាំងអស់",
"seeAllDonations_other": "មើលការបរិច្ចាគ {{count}} ទាំងអស់"
},
"campaigns": {
"home": {
"seoTitle": "យុទ្ធនាការប្រមូលមូលនិធិ",
+49
View File
@@ -354,6 +354,55 @@
"errorWalletRequiredFallback": "د پاکټ نقطه اړینه ده.",
"errorPublishedInvalid": "خپره شوې پیښه اعتبار نه ولاره. مهرباني وکړئ پاڼه تازه کړئ او بیا هڅه وکړئ."
},
"campaignsDetail": {
"seoTitle": "{{title}} | د {{appName}} کمپاینونه",
"seoDescriptionFallback": "په {{appName}} کې د {{title}} ملاتړ وکړئ.",
"deadlineEndedOn": "په {{date}} پای ته ورسیده",
"deadlineEndsToday": "نن پای ته رسي",
"deadlineDaysLeft_one": "{{count}} ورځ پاتې",
"deadlineDaysLeft_other": "{{count}} ورځې پاتې",
"deadlineEndsOn": "په {{date}} پای ته رسي",
"back": "شاته",
"edit": "سمول",
"delete": "ړنګول",
"deleting": "ړنګېږي…",
"byAuthor": "د <0>{{name}}</0> له لوري",
"commentLabel": "تبصره",
"linkCopied": "لینک کلپ‌بورډ ته کاپي شو",
"pinnedToast": "کمپاین ته ونښلول شو",
"unpinnedToast": "له کمپاین جلا شو",
"pinFailed": "د نښلونو تازه کول پاتې راغلل",
"deletedToast": "کمپاین ړنګ شو",
"deletedToastDesc": "د ړنګولو غوښتنه خپره شوه. ښه چلند کوونکي ریلې به کمپاین له فیدونو څخه لرې کړي.",
"deleteErrorTitle": "کمپاین نه ړنګېدلای شي",
"campaigner": "کمپاینوال",
"repost_one": "<0>{{count}}</0> بیا-خپرول",
"repost_other": "<0>{{count}}</0> بیا-خپرونې",
"quote_one": "<0>{{count}}</0> نقل",
"quote_other": "<0>{{count}}</0> نقلونه",
"like_one": "<0>{{count}}</0> خوښ",
"like_other": "<0>{{count}}</0> خوښونه",
"commentsAndDonations": "تبصرې او مرستې",
"commentCount_one": "{{count}} تبصره",
"commentCount_other": "{{count}} تبصرې",
"noCommentsTitle": "تر اوسه تبصرې نشته",
"noCommentsHint": "د ملاتړ د پیغام لومړنی شه.",
"deleteDialogTitle": "دا کمپاین ړنګ کړئ؟",
"deleteDialogBody": "دا د NIP-09 د ړنګولو غوښتنه خپروي. ښه چلند کوونکي ریلې به کمپاین له فیدونو او مستقیمو لینکونو څخه لرې کړي. د تېرو مرستو رسیدونه پر چین پاتې کیږي. دا کار بیرته نه راګرځول کیږي — د مرستو د منلو لپاره، کمپاین سم کړئ.",
"storyHeading": "کیسه",
"storyEmpty": "تنظیم کوونکی تر اوسه د دې کمپاین لپاره کیسه نه ده لیکلې.",
"campaignEnded": "کمپاین پای ته ورسید",
"donate": "مرسته",
"share": "شریکول",
"target": "هدف: {{amount}}",
"raised": "راټول‌شوي",
"ofGoal": "د {{amount}} هدف څخه",
"donationCount_one": "{{count}} مرسته",
"donationCount_other": "{{count}} مرستې",
"recentDonations": "وروستۍ مرستې",
"seeAllDonations_one": "ټولې {{count}} مرستې وګورئ",
"seeAllDonations_other": "ټولې {{count}} مرستې وګورئ"
},
"campaigns": {
"home": {
"seoTitle": "د مرستو راټولولو کمپاینونه",
+49
View File
@@ -354,6 +354,55 @@
"errorWalletRequiredFallback": "Chinangwa chechikwama chinodikanwa.",
"errorPublishedInvalid": "Chiitiko chakaburitswa chatadza chenjedzo. Tapota refresh moedza zvakare."
},
"campaignsDetail": {
"seoTitle": "{{title}} | Macampaign e{{appName}}",
"seoDescriptionFallback": "Tsigira {{title}} pa{{appName}}.",
"deadlineEndedOn": "Zvakapera pa{{date}}",
"deadlineEndsToday": "Zvinopera nhasi",
"deadlineDaysLeft_one": "{{count}} zuva rasara",
"deadlineDaysLeft_other": "Mazuva {{count}} asara",
"deadlineEndsOn": "Zvinopera pa{{date}}",
"back": "Dzokera",
"edit": "Gadzirisa",
"delete": "Bvisa",
"deleting": "Kubvisa…",
"byAuthor": "na <0>{{name}}</0>",
"commentLabel": "Pindura",
"linkCopied": "Rink yakopirwa kuclipboard",
"pinnedToast": "Yakanamatidzwa kucampaign",
"unpinnedToast": "Yabviswa pacampaign",
"pinFailed": "Yatadza kuchinja zvakanamatidzwa",
"deletedToast": "Campaign yabviswa",
"deletedToastDesc": "Chikumbiro chekubvisa chakaburitswa. Marelay anozvibata zvakanaka achabvisa campaign mufeeds.",
"deleteErrorTitle": "Hatina kukwanisa kubvisa campaign",
"campaigner": "Murongi",
"repost_one": "<0>{{count}}</0> Repost",
"repost_other": "<0>{{count}}</0> maRepost",
"quote_one": "<0>{{count}}</0> Quote",
"quote_other": "<0>{{count}}</0> maQuote",
"like_one": "<0>{{count}}</0> Like",
"like_other": "<0>{{count}}</0> maLike",
"commentsAndDonations": "Mhinduro nezvipo",
"commentCount_one": "{{count}} mhinduro",
"commentCount_other": "{{count}} mhinduro",
"noCommentsTitle": "Hapana mhinduro parizvino",
"noCommentsHint": "Iva wekutanga kusiya shoko rerutsigiro.",
"deleteDialogTitle": "Bvisa campaign iyi?",
"deleteDialogBody": "Izvi zvinoburitsa chikumbiro chekubvisa cheNIP-09. Marelay anozvibata zvakanaka achabvisa campaign mufeeds nezvirink zvakananga. Rezvi dzezvipo dzapfuura dzinoramba dzakachengetwa pachain. Izvi hazvigoni kudzosererwa — kuti urambe uchigamuchira zvipo, gadzirisa campaign panzvimbo yokuti uibvise.",
"storyHeading": "Nyaya",
"storyEmpty": "Murongi haasati anyora nyaya yecampaign iyi.",
"campaignEnded": "Campaign yapera",
"donate": "Ipa",
"share": "Govera",
"target": "Chinangwa: {{amount}}",
"raised": "yakaunganidzwa",
"ofGoal": "ye{{amount}} chinangwa",
"donationCount_one": "{{count}} chipo",
"donationCount_other": "{{count}} zvipo",
"recentDonations": "Zvipo zvichangoitika",
"seeAllDonations_one": "Ona zvipo zvese {{count}}",
"seeAllDonations_other": "Ona zvipo zvese {{count}}"
},
"campaigns": {
"home": {
"seoTitle": "Mishandirapamwe yekuunganidza mari",
+49
View File
@@ -354,6 +354,55 @@
"errorWalletRequiredFallback": "需要钱包端点。",
"errorPublishedInvalid": "已发布的事件未通过验证。请刷新并重试。"
},
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} 募款",
"seoDescriptionFallback": "在 {{appName}} 上支持 {{title}}。",
"deadlineEndedOn": "{{date}} 已结束",
"deadlineEndsToday": "今天截止",
"deadlineDaysLeft_one": "还剩 {{count}} 天",
"deadlineDaysLeft_other": "还剩 {{count}} 天",
"deadlineEndsOn": "{{date}} 截止",
"back": "返回",
"edit": "编辑",
"delete": "删除",
"deleting": "删除中……",
"byAuthor": "由 <0>{{name}}</0>",
"commentLabel": "评论",
"linkCopied": "已复制链接到剪贴板",
"pinnedToast": "已置顶到活动",
"unpinnedToast": "已取消置顶",
"pinFailed": "更新置顶失败",
"deletedToast": "活动已删除",
"deletedToastDesc": "删除请求已发布。行为良好的中继会将活动从信息流中移除。",
"deleteErrorTitle": "无法删除活动",
"campaigner": "发起人",
"repost_one": "<0>{{count}}</0> 转发",
"repost_other": "<0>{{count}}</0> 转发",
"quote_one": "<0>{{count}}</0> 引用",
"quote_other": "<0>{{count}}</0> 引用",
"like_one": "<0>{{count}}</0> 点赞",
"like_other": "<0>{{count}}</0> 点赞",
"commentsAndDonations": "评论与捐赠",
"commentCount_one": "{{count}} 条评论",
"commentCount_other": "{{count}} 条评论",
"noCommentsTitle": "暂无评论",
"noCommentsHint": "成为第一个留下支持留言的人。",
"deleteDialogTitle": "删除此活动?",
"deleteDialogBody": "这将发布一个 NIP-09 删除请求。行为良好的中继会将活动从信息流和直接链接中移除。过往的捐赠收据无论如何都会保留在链上。此操作无法撤销 — 若要继续接受捐赠,请改为编辑活动。",
"storyHeading": "故事",
"storyEmpty": "组织者尚未为此活动撰写故事。",
"campaignEnded": "活动已结束",
"donate": "捐赠",
"share": "分享",
"target": "目标:{{amount}}",
"raised": "已募",
"ofGoal": "目标 {{amount}}",
"donationCount_one": "{{count}} 笔捐赠",
"donationCount_other": "{{count}} 笔捐赠",
"recentDonations": "最近捐赠",
"seeAllDonations_one": "查看全部 {{count}} 笔捐赠",
"seeAllDonations_other": "查看全部 {{count}} 笔捐赠"
},
"campaigns": {
"home": {
"seoTitle": "众筹活动",
+74 -67
View File
@@ -2,6 +2,8 @@ import { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation, Trans } from 'react-i18next';
import type { TFunction } from 'i18next';
import type { NostrEvent } from '@nostrify/nostrify';
import {
CalendarClock,
@@ -41,6 +43,7 @@ import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { Progress } from '@/components/ui/progress';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { useAuthor } from '@/hooks/useAuthor';
import { useAppContext } from '@/hooks/useAppContext';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaign } from '@/hooks/useCampaign';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
@@ -80,16 +83,16 @@ function formatSatsFull(sats: number, btcPrice: number | undefined): string {
return `${sats.toLocaleString()} sats`;
}
function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } {
function formatDeadline(unixSeconds: number, t: TFunction): { label: string; isPast: boolean } {
const now = Math.floor(Date.now() / 1000);
const diff = unixSeconds - now;
if (diff <= 0) {
return { label: `Ended ${new Date(unixSeconds * 1000).toLocaleDateString()}`, isPast: true };
return { label: t('campaignsDetail.deadlineEndedOn', { date: new Date(unixSeconds * 1000).toLocaleDateString() }), isPast: true };
}
const days = Math.ceil(diff / 86_400);
if (days <= 1) return { label: 'Ends today', isPast: false };
if (days < 60) return { label: `${days} days left`, isPast: false };
return { label: `Ends ${new Date(unixSeconds * 1000).toLocaleDateString()}`, isPast: false };
if (days <= 1) return { label: t('campaignsDetail.deadlineEndsToday'), isPast: false };
if (days < 60) return { label: t('campaignsDetail.deadlineDaysLeft', { count: days }), isPast: false };
return { label: t('campaignsDetail.deadlineEndsOn', { date: new Date(unixSeconds * 1000).toLocaleDateString() }), isPast: false };
}
function collectReplyEvents(nodes: ReplyNode[], out = new Map<string, NostrEvent>()): Map<string, NostrEvent> {
@@ -119,6 +122,8 @@ export function CampaignDetailPage({ pubkey, identifier, relays }: CampaignDetai
}
function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
const { t } = useTranslation();
const { config } = useAppContext();
const { user } = useCurrentUser();
const { data: btcPrice } = useBtcPrice();
const author = useAuthor(campaign.pubkey);
@@ -241,7 +246,7 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
creatorMetadata?.display_name || creatorMetadata?.name || genUserName(campaign.pubkey);
const creatorProfileUrl = useProfileUrl(campaign.pubkey, creatorMetadata);
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
const deadline = campaign.deadline ? formatDeadline(campaign.deadline, t) : null;
const countryLabel = getCampaignCountryLabel(campaign);
const raisedSats = stats?.totalSats ?? 0;
@@ -256,8 +261,8 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
);
useSeoMeta({
title: `${campaign.title} | Agora Fundraisers`,
description: campaign.summary || `Support ${campaign.title} on Agora.`,
title: t('campaignsDetail.seoTitle', { title: campaign.title, appName: config.appName }),
description: campaign.summary || t('campaignsDetail.seoDescriptionFallback', { title: campaign.title, appName: config.appName }),
ogImage: cover,
});
@@ -269,7 +274,7 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
await nav.share({ title: campaign.title, text: campaign.summary, url });
} else if (nav?.clipboard) {
await nav.clipboard.writeText(url);
toast({ title: 'Link copied to clipboard' });
toast({ title: t('campaignsDetail.linkCopied') });
}
} catch {
// User likely cancelled the share sheet; nothing to do.
@@ -287,9 +292,8 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
{
onSuccess: () => {
toast({
title: 'Campaign deleted',
description:
'A deletion request was published. Well-behaved relays will drop the campaign from feeds.',
title: t('campaignsDetail.deletedToast'),
description: t('campaignsDetail.deletedToastDesc'),
});
setDeleteConfirmOpen(false);
void queryClient.invalidateQueries({ queryKey: ['campaign', campaign.pubkey, campaign.identifier] });
@@ -312,7 +316,7 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
onError: (error: unknown) => {
const msg = error instanceof Error ? error.message : String(error);
toast({
title: 'Could not delete campaign',
title: t('campaignsDetail.deleteErrorTitle'),
description: msg,
variant: 'destructive',
});
@@ -416,10 +420,12 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
onClick={() => openInteractions('reposts')}
className="hover:underline transition-colors"
>
<span className="font-bold text-foreground">
{formatNumber(engagementStats.reposts)}
</span>{' '}
Repost{engagementStats.reposts !== 1 ? 's' : ''}
<Trans
i18nKey="campaignsDetail.repost"
count={engagementStats.reposts}
values={{ count: formatNumber(engagementStats.reposts) }}
components={{ 0: <span className="font-bold text-foreground" /> }}
/>
</button>
) : null}
{engagementStats?.quotes ? (
@@ -427,10 +433,12 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
onClick={() => openInteractions('quotes')}
className="hover:underline transition-colors"
>
<span className="font-bold text-foreground">
{formatNumber(engagementStats.quotes)}
</span>{' '}
Quote{engagementStats.quotes !== 1 ? 's' : ''}
<Trans
i18nKey="campaignsDetail.quote"
count={engagementStats.quotes}
values={{ count: formatNumber(engagementStats.quotes) }}
components={{ 0: <span className="font-bold text-foreground" /> }}
/>
</button>
) : null}
{engagementStats?.reactions ? (
@@ -438,10 +446,12 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
onClick={() => openInteractions('reactions')}
className="hover:underline transition-colors"
>
<span className="font-bold text-foreground">
{formatNumber(engagementStats.reactions)}
</span>{' '}
Like{engagementStats.reactions !== 1 ? 's' : ''}
<Trans
i18nKey="campaignsDetail.like"
count={engagementStats.reactions}
values={{ count: formatNumber(engagementStats.reactions) }}
components={{ 0: <span className="font-bold text-foreground" /> }}
/>
</button>
) : null}
</div>
@@ -450,12 +460,11 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
<div className="mt-6">
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">
Comments &amp; donations
{t('campaignsDetail.commentsAndDonations')}
</h2>
{engagementStats?.replies ? (
<span className="text-sm text-muted-foreground tabular-nums">
{formatNumber(engagementStats.replies)}{' '}
{engagementStats.replies === 1 ? 'comment' : 'comments'}
{t('campaignsDetail.commentCount', { count: engagementStats.replies })}
</span>
) : null}
</div>
@@ -492,10 +501,10 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
>
<p className="text-base font-medium text-foreground">
No comments yet
{t('campaignsDetail.noCommentsTitle')}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Be the first to leave a message of support.
{t('campaignsDetail.noCommentsHint')}
</p>
</button>
)}
@@ -539,17 +548,13 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this campaign?</AlertDialogTitle>
<AlertDialogTitle>{t('campaignsDetail.deleteDialogTitle')}</AlertDialogTitle>
<AlertDialogDescription>
This publishes a NIP-09 deletion request. Well-behaved relays
will drop the campaign from feeds and direct links. Past
donation receipts stay on-chain regardless. This action cannot
be undone to keep accepting donations, edit the campaign
instead.
{t('campaignsDetail.deleteDialogBody')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={deleteMutation.isPending}>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
@@ -558,7 +563,7 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isPending ? 'Deleting' : 'Delete'}
{deleteMutation.isPending ? t('campaignsDetail.deleting') : t('campaignsDetail.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -570,10 +575,10 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
const wasPinned = isPinned(event.id);
togglePin.mutate(event.id, {
onSuccess: () => {
toast({ title: wasPinned ? 'Unpinned from campaign' : 'Pinned to campaign' });
toast({ title: wasPinned ? t('campaignsDetail.unpinnedToast') : t('campaignsDetail.pinnedToast') });
},
onError: () => {
toast({ title: 'Failed to update campaign pins', variant: 'destructive' });
toast({ title: t('campaignsDetail.pinFailed'), variant: 'destructive' });
},
});
}
@@ -592,6 +597,7 @@ function CampaignPinHeader({
pinPending: boolean;
onTogglePin: () => void;
}) {
const { t } = useTranslation();
return (
<PinnedCommentHeader
isPinned={isPinned}
@@ -602,7 +608,7 @@ function CampaignPinHeader({
{isCampaignAuthor && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary">
<ShieldCheck className="size-3" />
Campaigner
{t('campaignsDetail.campaigner')}
</span>
)}
</PinnedCommentHeader>
@@ -650,6 +656,7 @@ function CampaignHero({
onReply,
onMore,
}: CampaignHeroProps) {
const { t } = useTranslation();
const initials = creatorName.slice(0, 2).toUpperCase();
return (
@@ -696,10 +703,10 @@ function CampaignHero({
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 h-10 pl-2 pr-3.5 rounded-full bg-black/30 text-white backdrop-blur-md hover:bg-black/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 motion-safe:transition-colors"
aria-label="Go back"
aria-label={t('common.goBack')}
>
<ChevronLeft className="size-5" />
<span className="text-sm font-medium hidden sm:inline">Back</span>
<ChevronLeft className="size-5 rtl:rotate-180" />
<span className="text-sm font-medium hidden sm:inline">{t('campaignsDetail.back')}</span>
</button>
{isCreator && (
@@ -711,7 +718,7 @@ function CampaignHero({
>
<Link to={`/campaigns/new?edit=${encodeURIComponent(naddr)}`}>
<Pencil className="size-4 sm:mr-2" />
<span className="hidden sm:inline">Edit</span>
<span className="hidden sm:inline">{t('campaignsDetail.edit')}</span>
</Link>
</Button>
<Button
@@ -722,7 +729,7 @@ function CampaignHero({
className="h-10 rounded-full bg-black/30 text-white backdrop-blur-md shadow-none hover:bg-destructive/70 focus-visible:ring-white/80"
>
<Trash2 className="size-4 sm:mr-2" />
<span className="hidden sm:inline">Delete</span>
<span className="hidden sm:inline">{t('campaignsDetail.delete')}</span>
</Button>
</div>
)}
@@ -759,10 +766,11 @@ function CampaignHero({
</AvatarFallback>
</Avatar>
<span className="[text-shadow:0_1px_3px_rgba(0,0,0,0.7)]">
by{' '}
<span className="font-semibold underline-offset-4 group-hover:underline">
{creatorName}
</span>
<Trans
i18nKey="campaignsDetail.byAuthor"
values={{ name: creatorName }}
components={{ 0: <span className="font-semibold underline-offset-4 group-hover:underline" /> }}
/>
</span>
</Link>
@@ -791,7 +799,7 @@ function CampaignHero({
<div className="mt-4 pt-3 border-t border-white/15 [&_button]:!text-white/90 [&_button:hover]:!text-white [&_button:hover]:!bg-white/15 [&_button]:transition-colors [text-shadow:none]">
<PostActionBar
event={campaign.event}
replyLabel="Comment"
replyLabel={t('campaignsDetail.commentLabel')}
hideZap
showShareInSidebar
onReply={onReply}
@@ -815,13 +823,14 @@ function CampaignStory({
storyEvent: NostrEvent;
hasContent: boolean;
}) {
const { t } = useTranslation();
return (
<DetailStory
event={storyEvent}
hasContent={hasContent}
heading="The story"
heading={t('campaignsDetail.storyHeading')}
headingId="campaign-story-heading"
emptyText="The organizer hasn't written a story for this campaign yet."
emptyText={t('campaignsDetail.storyEmpty')}
/>
);
}
@@ -853,8 +862,9 @@ function DonateColumn({
onShare,
onSeeAll,
}: DonateColumnProps) {
const { t } = useTranslation();
const ended = !!deadline?.isPast;
const endedLabel = ended ? 'Campaign ended' : null;
const endedLabel = ended ? t('campaignsDetail.campaignEnded') : null;
const isSilentPayment = !campaign.wallets.onchain;
return (
@@ -871,7 +881,7 @@ function DonateColumn({
{isSilentPayment ? (
campaign.goalUsd && campaign.goalUsd > 0 ? (
<div className="text-xs text-muted-foreground">
Target: {formatUsdGoal(campaign.goalUsd)}
{t('campaignsDetail.target', { amount: formatUsdGoal(campaign.goalUsd) })}
</div>
) : null
) : statsLoading ? (
@@ -882,24 +892,22 @@ function DonateColumn({
<div className="text-2xl font-bold tracking-tight">
{formatSatsFull(raisedSats, btcPrice)}
<span className="ml-1.5 text-sm font-normal text-muted-foreground">
raised
{t('campaignsDetail.raised')}
</span>
</div>
{campaign.goalUsd ? (
<div className="text-xs text-muted-foreground">
of {formatUsdGoal(campaign.goalUsd)} goal
{t('campaignsDetail.ofGoal', { amount: formatUsdGoal(campaign.goalUsd) })}
{donations.length > 0 && (
<>
{' · '}
{formatNumber(donations.length)}{' '}
{donations.length === 1 ? 'donation' : 'donations'}
{t('campaignsDetail.donationCount', { count: donations.length })}
</>
)}
</div>
) : donations.length > 0 ? (
<div className="text-xs text-muted-foreground">
{formatNumber(donations.length)}{' '}
{donations.length === 1 ? 'donation' : 'donations'}
{t('campaignsDetail.donationCount', { count: donations.length })}
</div>
) : null}
</div>
@@ -920,11 +928,11 @@ function DonateColumn({
<div className="space-y-2">
<Button size="lg" className="w-full" disabled>
<HandHeart className="size-5 mr-2" />
{endedLabel ?? 'Donate'}
{endedLabel ?? t('campaignsDetail.donate')}
</Button>
<Button variant="outline" size="lg" className="w-full" onClick={onShare}>
<Share2 className="size-4 mr-2" />
Share
{t('campaignsDetail.share')}
</Button>
</div>
) : (
@@ -935,7 +943,7 @@ function DonateColumn({
<CampaignWalletDonatePanel wallets={campaign.wallets} />
<Button variant="outline" size="lg" className="w-full" onClick={onShare}>
<Share2 className="size-4 mr-2" />
Share
{t('campaignsDetail.share')}
</Button>
</div>
)}
@@ -945,7 +953,7 @@ function DonateColumn({
{!isSilentPayment && donations.length > 0 && (
<div className="space-y-2 border-t border-border/60 pt-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Recent donations
{t('campaignsDetail.recentDonations')}
</div>
<DonorPreviewList donations={donations} btcPrice={btcPrice} />
<button
@@ -953,8 +961,7 @@ function DonateColumn({
onClick={onSeeAll}
className="w-full text-sm font-medium text-primary hover:underline motion-safe:transition-colors text-center pt-1"
>
See all {donations.length}{' '}
{donations.length === 1 ? 'donation' : 'donations'}
{t('campaignsDetail.seeAllDonations', { count: donations.length })}
</button>
</div>
)}