diff --git a/apps/desktop/src/renderer/components/translation/TranslationPanel.tsx b/apps/desktop/src/renderer/components/translation/TranslationPanel.tsx
index 6a8d567b..ababc393 100644
--- a/apps/desktop/src/renderer/components/translation/TranslationPanel.tsx
+++ b/apps/desktop/src/renderer/components/translation/TranslationPanel.tsx
@@ -9,9 +9,9 @@ const TEXT: Record = {
inUseBy: 'in use by {count}',
on: 'On',
off: 'Off',
- paidGuest: 'Live translation is a paid feature. Ask the host to get a translation pass.',
+ paidGuest: 'Live translation is a Pro feature. Ask the host to upgrade to Pro.',
paidHost:
- 'Live translation is a paid feature. Buy a pass below so everyone in your meetings can hear and read each other in their own language.',
+ 'Live translation is included with Pro. Upgrade so everyone in your meetings can hear and read each other in their own language.',
hearEveryoneIn: 'Hear everyone in',
nobodyElse: 'Nobody else is speaking in this meeting yet.',
speaks: '{name} speaks',
@@ -27,6 +27,7 @@ const TEXT: Record = {
credit: 'Credit {amount} until {date}.',
passDay: 'Day pass',
passMonth: 'Month pass',
+ passPro: 'Upgrade to Pro',
checkoutUnavailable: 'Checkout is unavailable.',
privacy:
'Speech you choose to translate is sent to ElevenLabs (recognition and voices) and Anthropic (translation). Nothing is recorded or stored.',
@@ -286,8 +287,13 @@ export function TranslationPanel({
}}
className="bg-blue-600 hover:bg-blue-500 rounded px-2 py-1 text-white disabled:opacity-50"
>
- {pass.id === 'translation_month' ? t('passMonth') : t('passDay')} ·{' '}
- {money(pass.priceUsd * 1_000_000)}
+ {pass.id === 'pro'
+ ? t('passPro')
+ : pass.id === 'translation_month'
+ ? t('passMonth')
+ : t('passDay')}{' '}
+ · {money(pass.priceUsd * 1_000_000)}
+ {pass.id === 'pro' ? '/mo' : ''}
))}
diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json
index a06fcd66..aa73c579 100644
--- a/apps/web/messages/ar.json
+++ b/apps/web/messages/ar.json
@@ -1367,7 +1367,8 @@
"hd": "مشاركة الشاشة بدقة عالية (1080p)",
"liveDirectory": "غرفة عامة في دليل /live",
"callAnalysis": "تحليل المكالمات بالذكاء الاصطناعي",
- "support": "دعم ذو أولوية"
+ "support": "دعم ذو أولوية",
+ "translation": "ترجمة فورية للاجتماعات"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "مشاركة الشاشة بدقة 4K",
"callAnalysis": "تحليل المكالمات بالذكاء الاصطناعي",
"admin": "أدوات تحكم للمشرفين",
- "support": "دعم ذو أولوية"
+ "support": "دعم ذو أولوية",
+ "translation": "ترجمة فورية للاجتماعات"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "ابدأ مجانًا بجلسات P2P، أو قم بالترقية إلى Pro مقابل {price} شهريًا فقط. لا رسوم لكل مقعد أبدًا.",
"download": "نزّله مجانًا",
"signup": "إنشاء حساب"
- }
+ },
+ "payMethods": "ادفع بعملات USDC وUSDT وBTC وETH وSOL وغيرها، أو عبر PayPal. كل دفعة تغطي 31 يومًا ولا يتجدد شيء تلقائيًا."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "يستخدمها {count}",
"on": "تشغيل",
"off": "إيقاف",
- "paidGuest": "الترجمة الفورية ميزة مدفوعة. اطلب من المضيف شراء تصريح ترجمة.",
- "paidHost": "الترجمة الفورية ميزة مدفوعة. اشترِ تصريحًا أدناه ليتمكن الجميع في اجتماعاتك من سماع بعضهم وقراءة كلامهم بلغتهم.",
+ "paidGuest": "الترجمة الفورية ميزة في Pro. اطلب من المضيف الترقية إلى Pro.",
+ "paidHost": "الترجمة الفورية مضمّنة في Pro. قم بالترقية ليسمع الجميع في اجتماعاتك ويقرؤوا بعضهم بلغتهم.",
"hearEveryoneIn": "استمع إلى الجميع بـ",
"nobodyElse": "لا يتحدث أحد آخر في هذا الاجتماع بعد.",
"speaks": "يتحدث {name} بـ",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "ترقية",
"starting": "جارٍ بدء الدفع لخطة {plan}…",
- "backToPricing": "العودة إلى الأسعار"
+ "backToPricing": "العودة إلى الأسعار",
+ "chooseTitle": "كيف تريد الدفع مقابل {plan}؟",
+ "chooseNote": "{price} يغطي {days} يومًا. لا يتجدد شيء تلقائيًا، وسنراسلك قبل انتهائه.",
+ "paid": "شكرًا! تُفعَّل خطة {plan} فور تأكيد الدفع، عادةً خلال دقائق.",
+ "toDashboard": "اذهب إلى لوحة التحكم"
}
}
}
diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json
index 2110873d..4b4fad32 100644
--- a/apps/web/messages/de.json
+++ b/apps/web/messages/de.json
@@ -1367,7 +1367,8 @@
"hd": "HD-Bildschirmfreigabe (1080p)",
"liveDirectory": "Öffentlicher Raum im /live-Verzeichnis",
"callAnalysis": "KI-Anrufanalyse",
- "support": "Priorisierter Support"
+ "support": "Priorisierter Support",
+ "translation": "Live-Übersetzung in Meetings"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "4K-Bildschirmfreigabe",
"callAnalysis": "KI-Anrufanalyse",
"admin": "Admin-Steuerung",
- "support": "Priorisierter Support"
+ "support": "Priorisierter Support",
+ "translation": "Live-Übersetzung in Meetings"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "Starten Sie kostenlos mit P2P-Sitzungen oder upgraden Sie für nur {price}/Monat auf Pro. Niemals Gebühren pro Platz.",
"download": "Kostenlos herunterladen",
"signup": "Konto erstellen"
- }
+ },
+ "payMethods": "Bezahle mit USDC, USDT, BTC, ETH, SOL und anderen Coins oder mit PayPal. Jede Zahlung deckt 31 Tage ab und verlängert sich nicht automatisch."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "von {count} genutzt",
"on": "An",
"off": "Aus",
- "paidGuest": "Die Live-Übersetzung ist eine kostenpflichtige Funktion. Bitte den Gastgeber, einen Übersetzungspass zu kaufen.",
- "paidHost": "Die Live-Übersetzung ist eine kostenpflichtige Funktion. Kaufe unten einen Pass, damit sich alle in deinen Meetings in ihrer eigenen Sprache hören und lesen können.",
+ "paidGuest": "Live-Übersetzung ist eine Pro-Funktion. Bitte den Host, auf Pro upzugraden.",
+ "paidHost": "Live-Übersetzung ist in Pro enthalten. Upgrade, damit sich alle in deinen Meetings in ihrer eigenen Sprache hören und lesen können.",
"hearEveryoneIn": "Alle hören auf",
"nobodyElse": "In diesem Meeting spricht noch niemand sonst.",
"speaks": "{name} spricht",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Upgrade",
"starting": "Dein {plan}-Checkout wird gestartet…",
- "backToPricing": "Zurück zu den Preisen"
+ "backToPricing": "Zurück zu den Preisen",
+ "chooseTitle": "Wie möchtest du {plan} bezahlen?",
+ "chooseNote": "{price} deckt {days} Tage ab. Nichts verlängert sich automatisch, und wir schreiben dir vor dem Ablauf eine E-Mail.",
+ "paid": "Danke! Dein {plan}-Plan wird aktiv, sobald die Zahlung bestätigt ist, meist innerhalb weniger Minuten.",
+ "toDashboard": "Zum Dashboard"
}
}
}
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 82965630..65df0809 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -1367,7 +1367,8 @@
"hd": "HD screen sharing (1080p)",
"liveDirectory": "Public room in the /live directory",
"callAnalysis": "AI call analysis",
- "support": "Priority support"
+ "support": "Priority support",
+ "translation": "Live meeting translation"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "4K screen sharing",
"callAnalysis": "AI call analysis",
"admin": "Admin controls",
- "support": "Priority support"
+ "support": "Priority support",
+ "translation": "Live meeting translation"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "Start free with P2P sessions, or upgrade to Pro for just {price}/month. No per-seat fees ever.",
"download": "Download Free",
"signup": "Create Account"
- }
+ },
+ "payMethods": "Pay with USDC, USDT, BTC, ETH, SOL and other coins, or PayPal. Each payment covers 31 days and nothing renews on its own."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "in use by {count}",
"on": "On",
"off": "Off",
- "paidGuest": "Live translation is a paid feature. Ask the host to get a translation pass.",
- "paidHost": "Live translation is a paid feature. Buy a pass below so everyone in your meetings can hear and read each other in their own language.",
+ "paidGuest": "Live translation is a Pro feature. Ask the host to upgrade to Pro.",
+ "paidHost": "Live translation is included with Pro. Upgrade so everyone in your meetings can hear and read each other in their own language.",
"hearEveryoneIn": "Hear everyone in",
"nobodyElse": "Nobody else is speaking in this meeting yet.",
"speaks": "{name} speaks",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Upgrade",
"starting": "Starting your {plan} checkout…",
- "backToPricing": "Back to pricing"
+ "backToPricing": "Back to pricing",
+ "chooseTitle": "How would you like to pay for {plan}?",
+ "chooseNote": "{price} covers {days} days. Nothing renews on its own, and we email you before it ends.",
+ "paid": "Thanks! Your {plan} plan turns on as soon as the payment confirms, usually within a few minutes.",
+ "toDashboard": "Go to your dashboard"
}
}
}
diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json
index cbd1a51b..95201aa2 100644
--- a/apps/web/messages/es.json
+++ b/apps/web/messages/es.json
@@ -1367,7 +1367,8 @@
"hd": "Pantalla compartida en HD (1080p)",
"liveDirectory": "Sala pública en el directorio /live",
"callAnalysis": "Análisis de llamadas con IA",
- "support": "Soporte prioritario"
+ "support": "Soporte prioritario",
+ "translation": "Traducción en vivo de reuniones"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "Pantalla compartida en 4K",
"callAnalysis": "Análisis de llamadas con IA",
"admin": "Controles de administración",
- "support": "Soporte prioritario"
+ "support": "Soporte prioritario",
+ "translation": "Traducción en vivo de reuniones"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "Empieza gratis con sesiones P2P o pasa a Pro por solo {price}/mes. Nunca cobramos por puesto.",
"download": "Descargar gratis",
"signup": "Crear cuenta"
- }
+ },
+ "payMethods": "Paga con USDC, USDT, BTC, ETH, SOL y otras monedas, o con PayPal. Cada pago cubre 31 días y nada se renueva solo."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "en uso por {count}",
"on": "Activada",
"off": "Desactivada",
- "paidGuest": "La traducción en directo es una función de pago. Pide al anfitrión que consiga un pase de traducción.",
- "paidHost": "La traducción en directo es una función de pago. Compra un pase abajo para que todos en tus reuniones puedan escucharse y leerse en su propio idioma.",
+ "paidGuest": "La traducción en vivo es una función de Pro. Pide al anfitrión que se pase a Pro.",
+ "paidHost": "La traducción en vivo está incluida en Pro. Mejora tu plan para que todos en tus reuniones se escuchen y se lean en su propio idioma.",
"hearEveryoneIn": "Escuchar a todos en",
"nobodyElse": "Nadie más está hablando en esta reunión todavía.",
"speaks": "{name} habla",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Mejorar plan",
"starting": "Iniciando el pago de {plan}…",
- "backToPricing": "Volver a precios"
+ "backToPricing": "Volver a precios",
+ "chooseTitle": "¿Cómo quieres pagar {plan}?",
+ "chooseNote": "{price} cubre {days} días. Nada se renueva solo y te avisamos por correo antes de que termine.",
+ "paid": "¡Gracias! Tu plan {plan} se activa en cuanto se confirme el pago, normalmente en unos minutos.",
+ "toDashboard": "Ir a tu panel"
}
}
}
diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json
index 59853d58..6d1d8bb4 100644
--- a/apps/web/messages/fr.json
+++ b/apps/web/messages/fr.json
@@ -1367,7 +1367,8 @@
"hd": "Partage d’écran HD (1080p)",
"liveDirectory": "Salle publique dans l’annuaire /live",
"callAnalysis": "Analyse d’appels par IA",
- "support": "Support prioritaire"
+ "support": "Support prioritaire",
+ "translation": "Traduction des réunions en direct"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "Partage d’écran en 4K",
"callAnalysis": "Analyse d’appels par IA",
"admin": "Contrôles d’administration",
- "support": "Support prioritaire"
+ "support": "Support prioritaire",
+ "translation": "Traduction des réunions en direct"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "Commencez gratuitement avec des sessions P2P, ou passez à Pro pour seulement {price}/mois. Jamais de frais par utilisateur.",
"download": "Télécharger gratuitement",
"signup": "Créer un compte"
- }
+ },
+ "payMethods": "Payez en USDC, USDT, BTC, ETH, SOL et d'autres cryptos, ou avec PayPal. Chaque paiement couvre 31 jours et rien ne se renouvelle automatiquement."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "utilisée par {count}",
"on": "Activée",
"off": "Désactivée",
- "paidGuest": "La traduction en direct est une fonctionnalité payante. Demandez à l'hôte d'acheter un pass de traduction.",
- "paidHost": "La traduction en direct est une fonctionnalité payante. Achetez un pass ci-dessous pour que chacun dans vos réunions puisse entendre et lire les autres dans sa propre langue.",
+ "paidGuest": "La traduction en direct est une fonctionnalité Pro. Demandez à l'hôte de passer à Pro.",
+ "paidHost": "La traduction en direct est incluse avec Pro. Passez à Pro pour que chacun dans vos réunions entende et lise les autres dans sa propre langue.",
"hearEveryoneIn": "Entendre tout le monde en",
"nobodyElse": "Personne d'autre ne parle encore dans cette réunion.",
"speaks": "{name} parle",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Mettre à niveau",
"starting": "Lancement du paiement {plan}…",
- "backToPricing": "Retour aux tarifs"
+ "backToPricing": "Retour aux tarifs",
+ "chooseTitle": "Comment voulez-vous payer {plan} ?",
+ "chooseNote": "{price} couvre {days} jours. Rien ne se renouvelle automatiquement, et nous vous écrivons avant la fin.",
+ "paid": "Merci ! Votre forfait {plan} s'active dès que le paiement est confirmé, en général en quelques minutes.",
+ "toDashboard": "Aller au tableau de bord"
}
}
}
diff --git a/apps/web/messages/hi.json b/apps/web/messages/hi.json
index dfd8d2f6..86494c3d 100644
--- a/apps/web/messages/hi.json
+++ b/apps/web/messages/hi.json
@@ -1367,7 +1367,8 @@
"hd": "HD स्क्रीन शेयरिंग (1080p)",
"liveDirectory": "/live डायरेक्टरी में सार्वजनिक रूम",
"callAnalysis": "AI कॉल विश्लेषण",
- "support": "प्राथमिकता सपोर्ट"
+ "support": "प्राथमिकता सपोर्ट",
+ "translation": "मीटिंग का लाइव अनुवाद"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "4K स्क्रीन शेयरिंग",
"callAnalysis": "AI कॉल विश्लेषण",
"admin": "एडमिन कंट्रोल",
- "support": "प्राथमिकता सपोर्ट"
+ "support": "प्राथमिकता सपोर्ट",
+ "translation": "मीटिंग का लाइव अनुवाद"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "P2P सेशन के साथ मुफ़्त शुरू करें, या सिर्फ़ {price}/महीना में Pro पर अपग्रेड करें। प्रति-सीट शुल्क कभी नहीं।",
"download": "मुफ़्त डाउनलोड करें",
"signup": "अकाउंट बनाएँ"
- }
+ },
+ "payMethods": "USDC, USDT, BTC, ETH, SOL और अन्य कॉइन से या PayPal से भुगतान करें। हर भुगतान 31 दिन का है और कुछ भी अपने आप रिन्यू नहीं होता।"
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "{count} उपयोग कर रहे हैं",
"on": "चालू",
"off": "बंद",
- "paidGuest": "लाइव अनुवाद एक सशुल्क सुविधा है। होस्ट से अनुवाद पास लेने को कहें।",
- "paidHost": "लाइव अनुवाद एक सशुल्क सुविधा है। नीचे से पास खरीदें ताकि आपकी मीटिंग में सभी एक-दूसरे को अपनी भाषा में सुन और पढ़ सकें।",
+ "paidGuest": "लाइव अनुवाद Pro सुविधा है। होस्ट से Pro में अपग्रेड करने को कहें।",
+ "paidHost": "लाइव अनुवाद Pro में शामिल है। अपग्रेड करें ताकि आपकी मीटिंग में हर कोई एक-दूसरे को अपनी भाषा में सुन और पढ़ सके।",
"hearEveryoneIn": "सभी को इस भाषा में सुनें",
"nobodyElse": "इस मीटिंग में अभी कोई और नहीं बोल रहा है।",
"speaks": "{name} की भाषा",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "अपग्रेड",
"starting": "आपका {plan} चेकआउट शुरू हो रहा है…",
- "backToPricing": "कीमतों पर वापस जाएँ"
+ "backToPricing": "कीमतों पर वापस जाएँ",
+ "chooseTitle": "आप {plan} के लिए कैसे भुगतान करना चाहेंगे?",
+ "chooseNote": "{price} में {days} दिन मिलते हैं। कुछ भी अपने आप रिन्यू नहीं होता, और खत्म होने से पहले हम आपको ईमेल करेंगे।",
+ "paid": "धन्यवाद! भुगतान की पुष्टि होते ही आपका {plan} प्लान चालू हो जाएगा, आमतौर पर कुछ मिनटों में।",
+ "toDashboard": "अपने डैशबोर्ड पर जाएं"
}
}
}
diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json
index a4485e9b..b98aebf4 100644
--- a/apps/web/messages/id.json
+++ b/apps/web/messages/id.json
@@ -1367,7 +1367,8 @@
"hd": "Berbagi layar HD (1080p)",
"liveDirectory": "Ruang publik di direktori /live",
"callAnalysis": "Analisis panggilan AI",
- "support": "Dukungan prioritas"
+ "support": "Dukungan prioritas",
+ "translation": "Terjemahan rapat langsung"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "Berbagi layar 4K",
"callAnalysis": "Analisis panggilan AI",
"admin": "Kontrol admin",
- "support": "Dukungan prioritas"
+ "support": "Dukungan prioritas",
+ "translation": "Terjemahan rapat langsung"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "Mulai gratis dengan sesi P2P, atau tingkatkan ke Pro hanya {price}/bulan. Tidak pernah ada biaya per pengguna.",
"download": "Unduh Gratis",
"signup": "Buat Akun"
- }
+ },
+ "payMethods": "Bayar dengan USDC, USDT, BTC, ETH, SOL dan koin lain, atau PayPal. Setiap pembayaran berlaku 31 hari dan tidak ada yang diperpanjang otomatis."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "dipakai {count} orang",
"on": "Aktif",
"off": "Nonaktif",
- "paidGuest": "Terjemahan langsung adalah fitur berbayar. Minta host membeli pass terjemahan.",
- "paidHost": "Terjemahan langsung adalah fitur berbayar. Beli pass di bawah agar semua orang di rapat Anda bisa saling mendengar dan membaca dalam bahasa mereka sendiri.",
+ "paidGuest": "Terjemahan langsung adalah fitur Pro. Minta host untuk upgrade ke Pro.",
+ "paidHost": "Terjemahan langsung termasuk dalam Pro. Upgrade agar semua orang di rapat Anda bisa saling mendengar dan membaca dalam bahasa masing-masing.",
"hearEveryoneIn": "Dengarkan semua orang dalam",
"nobodyElse": "Belum ada orang lain yang berbicara di rapat ini.",
"speaks": "{name} berbicara dalam",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Upgrade",
"starting": "Memulai pembayaran {plan} Anda…",
- "backToPricing": "Kembali ke harga"
+ "backToPricing": "Kembali ke harga",
+ "chooseTitle": "Bagaimana Anda ingin membayar {plan}?",
+ "chooseNote": "{price} berlaku {days} hari. Tidak ada yang diperpanjang otomatis, dan kami mengirim email sebelum berakhir.",
+ "paid": "Terima kasih! Paket {plan} Anda aktif begitu pembayaran terkonfirmasi, biasanya dalam beberapa menit.",
+ "toDashboard": "Ke dasbor Anda"
}
}
}
diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json
index 3ac7c961..f7685a96 100644
--- a/apps/web/messages/it.json
+++ b/apps/web/messages/it.json
@@ -1367,7 +1367,8 @@
"hd": "Condivisione dello schermo in HD (1080p)",
"liveDirectory": "Stanza pubblica nella directory /live",
"callAnalysis": "Analisi delle chiamate con IA",
- "support": "Supporto prioritario"
+ "support": "Supporto prioritario",
+ "translation": "Traduzione dal vivo delle riunioni"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "Condivisione dello schermo in 4K",
"callAnalysis": "Analisi delle chiamate con IA",
"admin": "Controlli di amministrazione",
- "support": "Supporto prioritario"
+ "support": "Supporto prioritario",
+ "translation": "Traduzione dal vivo delle riunioni"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "Inizia gratis con le sessioni P2P o passa a Pro per soli {price}/mese. Mai costi per utente.",
"download": "Scarica gratis",
"signup": "Crea account"
- }
+ },
+ "payMethods": "Paga con USDC, USDT, BTC, ETH, SOL e altre criptovalute, oppure con PayPal. Ogni pagamento copre 31 giorni e non si rinnova da solo."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "in uso da {count}",
"on": "Attiva",
"off": "Disattiva",
- "paidGuest": "La traduzione dal vivo è una funzione a pagamento. Chiedi all'host di acquistare un pass di traduzione.",
- "paidHost": "La traduzione dal vivo è una funzione a pagamento. Acquista un pass qui sotto perché tutti nelle tue riunioni possano ascoltarsi e leggersi nella propria lingua.",
+ "paidGuest": "La traduzione dal vivo è una funzione Pro. Chiedi all'host di passare a Pro.",
+ "paidHost": "La traduzione dal vivo è inclusa in Pro. Passa a Pro così tutti nelle tue riunioni si sentono e si leggono nella propria lingua.",
"hearEveryoneIn": "Ascolta tutti in",
"nobodyElse": "Nessun altro sta ancora parlando in questa riunione.",
"speaks": "{name} parla",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Passa a un piano superiore",
"starting": "Avvio del pagamento {plan}…",
- "backToPricing": "Torna ai prezzi"
+ "backToPricing": "Torna ai prezzi",
+ "chooseTitle": "Come vuoi pagare {plan}?",
+ "chooseNote": "{price} copre {days} giorni. Non si rinnova nulla da solo e ti scriviamo prima della scadenza.",
+ "paid": "Grazie! Il tuo piano {plan} si attiva appena il pagamento è confermato, di solito in pochi minuti.",
+ "toDashboard": "Vai alla dashboard"
}
}
}
diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json
index 56f614b2..732e2307 100644
--- a/apps/web/messages/ja.json
+++ b/apps/web/messages/ja.json
@@ -1367,7 +1367,8 @@
"hd": "HD 画面共有(1080p)",
"liveDirectory": "/live ディレクトリに公開ルームを掲載",
"callAnalysis": "AI 通話分析",
- "support": "優先サポート"
+ "support": "優先サポート",
+ "translation": "会議のライブ翻訳"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "4K 画面共有",
"callAnalysis": "AI 通話分析",
"admin": "管理者機能",
- "support": "優先サポート"
+ "support": "優先サポート",
+ "translation": "会議のライブ翻訳"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "P2P セッションで無料で始めるか、月額わずか {price} で Pro にアップグレード。シート単位の料金は一切ありません。",
"download": "無料でダウンロード",
"signup": "アカウントを作成"
- }
+ },
+ "payMethods": "USDC、USDT、BTC、ETH、SOL などのコイン、または PayPal で支払えます。1回の支払いで31日間使え、自動更新はありません。"
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "{count} 人が使用中",
"on": "オン",
"off": "オフ",
- "paidGuest": "ライブ翻訳は有料機能です。ホストに翻訳パスの購入を依頼してください。",
- "paidHost": "ライブ翻訳は有料機能です。下からパスを購入すると、会議の全員がそれぞれの言語で互いの声を聞き、読めるようになります。",
+ "paidGuest": "ライブ翻訳は Pro の機能です。ホストに Pro へのアップグレードを依頼してください。",
+ "paidHost": "ライブ翻訳は Pro に含まれます。アップグレードすると、会議の全員がお互いを自分の言語で聞いて読めます。",
"hearEveryoneIn": "全員を次の言語で聞く",
"nobodyElse": "この会議ではまだ他に誰も話していません。",
"speaks": "{name} の話す言語",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "アップグレード",
"starting": "{plan}の決済を開始しています…",
- "backToPricing": "料金ページに戻る"
+ "backToPricing": "料金ページに戻る",
+ "chooseTitle": "{plan} の支払い方法を選んでください",
+ "chooseNote": "{price} で {days} 日間使えます。自動更新はなく、期限前にメールでお知らせします。",
+ "paid": "ありがとうございます。支払いが確認されると {plan} プランが有効になります。通常は数分です。",
+ "toDashboard": "ダッシュボードへ"
}
}
}
diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json
index 9c3d7149..c00e446a 100644
--- a/apps/web/messages/ko.json
+++ b/apps/web/messages/ko.json
@@ -1367,7 +1367,8 @@
"hd": "HD 화면 공유(1080p)",
"liveDirectory": "/live 디렉터리에 공개 방 등록",
"callAnalysis": "AI 통화 분석",
- "support": "우선 지원"
+ "support": "우선 지원",
+ "translation": "회의 실시간 번역"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "4K 화면 공유",
"callAnalysis": "AI 통화 분석",
"admin": "관리자 제어",
- "support": "우선 지원"
+ "support": "우선 지원",
+ "translation": "회의 실시간 번역"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "P2P 세션으로 무료로 시작하거나, 월 {price}에 Pro로 업그레이드하세요. 사용자당 요금은 절대 없어요.",
"download": "무료 다운로드",
"signup": "계정 만들기"
- }
+ },
+ "payMethods": "USDC, USDT, BTC, ETH, SOL 등 코인이나 PayPal로 결제하세요. 한 번 결제하면 31일간 사용하며 자동 갱신되지 않습니다."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "{count}명이 사용 중",
"on": "켜짐",
"off": "꺼짐",
- "paidGuest": "실시간 번역은 유료 기능입니다. 호스트에게 번역 패스 구매를 요청하세요.",
- "paidHost": "실시간 번역은 유료 기능입니다. 아래에서 패스를 구매하면 회의 참가자 모두가 각자의 언어로 서로의 말을 듣고 읽을 수 있습니다.",
+ "paidGuest": "실시간 번역은 Pro 기능입니다. 호스트에게 Pro로 업그레이드해 달라고 요청하세요.",
+ "paidHost": "실시간 번역은 Pro에 포함되어 있습니다. 업그레이드하면 회의 참가자 모두가 서로를 자기 언어로 듣고 읽을 수 있습니다.",
"hearEveryoneIn": "모두를 다음 언어로 듣기",
"nobodyElse": "아직 이 회의에서 말하는 다른 사람이 없습니다.",
"speaks": "{name}의 언어",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "업그레이드",
"starting": "{plan} 결제를 시작하는 중…",
- "backToPricing": "요금제로 돌아가기"
+ "backToPricing": "요금제로 돌아가기",
+ "chooseTitle": "{plan} 결제 방법을 선택하세요",
+ "chooseNote": "{price}로 {days}일간 사용합니다. 자동 갱신은 없으며 만료 전에 이메일로 알려 드립니다.",
+ "paid": "감사합니다! 결제가 확인되면 {plan} 플랜이 바로 켜집니다. 보통 몇 분이면 됩니다.",
+ "toDashboard": "대시보드로 이동"
}
}
}
diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json
index 81a35d2a..7227ee8d 100644
--- a/apps/web/messages/nl.json
+++ b/apps/web/messages/nl.json
@@ -1367,7 +1367,8 @@
"hd": "Schermdelen in HD (1080p)",
"liveDirectory": "Openbare ruimte in de /live-directory",
"callAnalysis": "AI-gespreksanalyse",
- "support": "Ondersteuning met voorrang"
+ "support": "Ondersteuning met voorrang",
+ "translation": "Live vertaling van meetings"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "Schermdelen in 4K",
"callAnalysis": "AI-gespreksanalyse",
"admin": "Beheerdersinstellingen",
- "support": "Ondersteuning met voorrang"
+ "support": "Ondersteuning met voorrang",
+ "translation": "Live vertaling van meetings"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "Begin gratis met P2P-sessies of upgrade naar Pro voor maar {price}/maand. Nooit kosten per gebruiker.",
"download": "Gratis downloaden",
"signup": "Account aanmaken"
- }
+ },
+ "payMethods": "Betaal met USDC, USDT, BTC, ETH, SOL en andere munten, of met PayPal. Elke betaling dekt 31 dagen en niets verlengt automatisch."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "in gebruik door {count}",
"on": "Aan",
"off": "Uit",
- "paidGuest": "Live vertaling is een betaalde functie. Vraag de host om een vertaalpas.",
- "paidHost": "Live vertaling is een betaalde functie. Koop hieronder een pas zodat iedereen in je meetings elkaar in de eigen taal kan horen en lezen.",
+ "paidGuest": "Live vertaling is een Pro-functie. Vraag de host om naar Pro te upgraden.",
+ "paidHost": "Live vertaling zit bij Pro. Upgrade zodat iedereen in je meetings elkaar in de eigen taal hoort en leest.",
"hearEveryoneIn": "Iedereen horen in",
"nobodyElse": "Er spreekt nog niemand anders in deze meeting.",
"speaks": "{name} spreekt",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Upgraden",
"starting": "Je {plan}-betaling wordt gestart…",
- "backToPricing": "Terug naar prijzen"
+ "backToPricing": "Terug naar prijzen",
+ "chooseTitle": "Hoe wil je {plan} betalen?",
+ "chooseNote": "{price} dekt {days} dagen. Niets verlengt automatisch, en we mailen je voordat het afloopt.",
+ "paid": "Bedankt! Je {plan}-abonnement wordt actief zodra de betaling bevestigd is, meestal binnen een paar minuten.",
+ "toDashboard": "Naar je dashboard"
}
}
}
diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json
index 044bd8c2..51b41593 100644
--- a/apps/web/messages/pl.json
+++ b/apps/web/messages/pl.json
@@ -1367,7 +1367,8 @@
"hd": "Udostępnianie ekranu w HD (1080p)",
"liveDirectory": "Publiczny pokój w katalogu /live",
"callAnalysis": "Analiza rozmów AI",
- "support": "Priorytetowe wsparcie"
+ "support": "Priorytetowe wsparcie",
+ "translation": "Tłumaczenie spotkań na żywo"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "Udostępnianie ekranu w 4K",
"callAnalysis": "Analiza rozmów AI",
"admin": "Narzędzia administratora",
- "support": "Priorytetowe wsparcie"
+ "support": "Priorytetowe wsparcie",
+ "translation": "Tłumaczenie spotkań na żywo"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "Zacznij za darmo z sesjami P2P lub przejdź na Pro za jedyne {price}/mies. Nigdy żadnych opłat za stanowisko.",
"download": "Pobierz za darmo",
"signup": "Załóż konto"
- }
+ },
+ "payMethods": "Zapłać w USDC, USDT, BTC, ETH, SOL i innych monetach albo przez PayPal. Każda płatność obejmuje 31 dni i nic nie odnawia się samo."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "używane przez {count}",
"on": "Wł.",
"off": "Wył.",
- "paidGuest": "Tłumaczenie na żywo to płatna funkcja. Poproś gospodarza o karnet tłumaczenia.",
- "paidHost": "Tłumaczenie na żywo to płatna funkcja. Kup karnet poniżej, aby wszyscy na Twoich spotkaniach mogli słyszeć i czytać się nawzajem we własnym języku.",
+ "paidGuest": "Tłumaczenie na żywo to funkcja Pro. Poproś gospodarza o przejście na Pro.",
+ "paidHost": "Tłumaczenie na żywo jest w planie Pro. Przejdź na Pro, aby wszyscy na Twoich spotkaniach słyszeli i czytali się nawzajem we własnym języku.",
"hearEveryoneIn": "Słuchaj wszystkich po",
"nobodyElse": "Nikt inny jeszcze nie mówi na tym spotkaniu.",
"speaks": "{name} mówi po",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Ulepsz plan",
"starting": "Uruchamianie płatności za {plan}…",
- "backToPricing": "Wróć do cennika"
+ "backToPricing": "Wróć do cennika",
+ "chooseTitle": "Jak chcesz zapłacić za {plan}?",
+ "chooseNote": "{price} obejmuje {days} dni. Nic nie odnawia się samo, a przed końcem wyślemy Ci e-mail.",
+ "paid": "Dziękujemy! Plan {plan} włączy się, gdy płatność zostanie potwierdzona, zwykle w ciągu kilku minut.",
+ "toDashboard": "Przejdź do panelu"
}
}
}
diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json
index 9edeaf81..5ec98d83 100644
--- a/apps/web/messages/pt.json
+++ b/apps/web/messages/pt.json
@@ -1367,7 +1367,8 @@
"hd": "Compartilhamento de tela em HD (1080p)",
"liveDirectory": "Sala pública no diretório /live",
"callAnalysis": "Análise de chamadas com IA",
- "support": "Suporte prioritário"
+ "support": "Suporte prioritário",
+ "translation": "Tradução de reuniões ao vivo"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "Compartilhamento de tela em 4K",
"callAnalysis": "Análise de chamadas com IA",
"admin": "Controles de administrador",
- "support": "Suporte prioritário"
+ "support": "Suporte prioritário",
+ "translation": "Tradução de reuniões ao vivo"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "Comece grátis com sessões P2P ou assine o Pro por apenas {price}/mês. Nunca cobramos por usuário.",
"download": "Baixar grátis",
"signup": "Criar conta"
- }
+ },
+ "payMethods": "Pague com USDC, USDT, BTC, ETH, SOL e outras moedas, ou com PayPal. Cada pagamento cobre 31 dias e nada é renovado automaticamente."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "em uso por {count}",
"on": "Ligada",
"off": "Desligada",
- "paidGuest": "A tradução ao vivo é um recurso pago. Peça ao anfitrião um passe de tradução.",
- "paidHost": "A tradução ao vivo é um recurso pago. Compre um passe abaixo para que todos nas suas reuniões possam ouvir e ler uns aos outros no próprio idioma.",
+ "paidGuest": "A tradução ao vivo é um recurso do Pro. Peça ao anfitrião para fazer upgrade para o Pro.",
+ "paidHost": "A tradução ao vivo está incluída no Pro. Faça upgrade para que todos nas suas reuniões se ouçam e se leiam no próprio idioma.",
"hearEveryoneIn": "Ouvir todos em",
"nobodyElse": "Ninguém mais está falando nesta reunião ainda.",
"speaks": "{name} fala",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Fazer upgrade",
"starting": "Iniciando o pagamento do {plan}…",
- "backToPricing": "Voltar aos preços"
+ "backToPricing": "Voltar aos preços",
+ "chooseTitle": "Como você quer pagar o {plan}?",
+ "chooseNote": "{price} cobre {days} dias. Nada é renovado automaticamente, e enviamos um e-mail antes de acabar.",
+ "paid": "Obrigado! Seu plano {plan} é ativado assim que o pagamento for confirmado, normalmente em poucos minutos.",
+ "toDashboard": "Ir para o painel"
}
}
}
diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json
index 8cc1977d..f04e0d74 100644
--- a/apps/web/messages/ru.json
+++ b/apps/web/messages/ru.json
@@ -1367,7 +1367,8 @@
"hd": "Демонстрация экрана в HD (1080p)",
"liveDirectory": "Публичная комната в каталоге /live",
"callAnalysis": "ИИ-анализ звонков",
- "support": "Приоритетная поддержка"
+ "support": "Приоритетная поддержка",
+ "translation": "Живой перевод встреч"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "Демонстрация экрана в 4K",
"callAnalysis": "ИИ-анализ звонков",
"admin": "Инструменты администратора",
- "support": "Приоритетная поддержка"
+ "support": "Приоритетная поддержка",
+ "translation": "Живой перевод встреч"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "Начните бесплатно с P2P-сессий или перейдите на Pro всего за {price}/мес. Никакой оплаты за места.",
"download": "Скачать бесплатно",
"signup": "Создать аккаунт"
- }
+ },
+ "payMethods": "Оплачивайте в USDC, USDT, BTC, ETH, SOL и других монетах или через PayPal. Каждый платёж покрывает 31 день, ничего не продлевается само."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "используют: {count}",
"on": "Вкл.",
"off": "Выкл.",
- "paidGuest": "Синхронный перевод — платная функция. Попросите организатора купить пропуск на перевод.",
- "paidHost": "Синхронный перевод — платная функция. Купите пропуск ниже, чтобы все участники ваших встреч могли слышать и читать друг друга на своём языке.",
+ "paidGuest": "Живой перевод доступен в Pro. Попросите организатора перейти на Pro.",
+ "paidHost": "Живой перевод входит в Pro. Перейдите на Pro, чтобы все на ваших встречах слышали и читали друг друга на своём языке.",
"hearEveryoneIn": "Слушать всех на",
"nobodyElse": "В этой встрече пока больше никто не говорит.",
"speaks": "{name} говорит на",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Повысить тариф",
"starting": "Запуск оплаты тарифа {plan}…",
- "backToPricing": "Назад к ценам"
+ "backToPricing": "Назад к ценам",
+ "chooseTitle": "Как вы хотите оплатить {plan}?",
+ "chooseNote": "{price} покрывает {days} дн. Ничего не продлевается само, и мы напишем вам до окончания.",
+ "paid": "Спасибо! План {plan} включится, как только платёж подтвердится, обычно за несколько минут.",
+ "toDashboard": "Перейти в панель"
}
}
}
diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json
index 7e18ad59..bcf35abe 100644
--- a/apps/web/messages/sv.json
+++ b/apps/web/messages/sv.json
@@ -1367,7 +1367,8 @@
"hd": "Skärmdelning i HD (1080p)",
"liveDirectory": "Offentligt rum i /live-katalogen",
"callAnalysis": "AI-samtalsanalys",
- "support": "Prioriterad support"
+ "support": "Prioriterad support",
+ "translation": "Liveöversättning av möten"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "Skärmdelning i 4K",
"callAnalysis": "AI-samtalsanalys",
"admin": "Administratörskontroller",
- "support": "Prioriterad support"
+ "support": "Prioriterad support",
+ "translation": "Liveöversättning av möten"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "Börja gratis med P2P-sessioner, eller uppgradera till Pro för bara {price}/månad. Aldrig några avgifter per plats.",
"download": "Ladda ner gratis",
"signup": "Skapa konto"
- }
+ },
+ "payMethods": "Betala med USDC, USDT, BTC, ETH, SOL och andra mynt, eller med PayPal. Varje betalning täcker 31 dagar och inget förnyas automatiskt."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "används av {count}",
"on": "På",
"off": "Av",
- "paidGuest": "Livetolkning är en betalfunktion. Be värden att köpa ett tolkpass.",
- "paidHost": "Livetolkning är en betalfunktion. Köp ett pass nedan så att alla i dina möten kan höra och läsa varandra på sitt eget språk.",
+ "paidGuest": "Liveöversättning är en Pro-funktion. Be värden att uppgradera till Pro.",
+ "paidHost": "Liveöversättning ingår i Pro. Uppgradera så att alla i dina möten kan höra och läsa varandra på sitt eget språk.",
"hearEveryoneIn": "Hör alla på",
"nobodyElse": "Ingen annan pratar i mötet ännu.",
"speaks": "{name} talar",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Uppgradera",
"starting": "Startar betalningen för {plan}…",
- "backToPricing": "Tillbaka till priser"
+ "backToPricing": "Tillbaka till priser",
+ "chooseTitle": "Hur vill du betala för {plan}?",
+ "chooseNote": "{price} täcker {days} dagar. Inget förnyas automatiskt, och vi mejlar dig innan det tar slut.",
+ "paid": "Tack! Din {plan}-plan aktiveras så snart betalningen bekräftas, oftast inom några minuter.",
+ "toDashboard": "Till din instrumentpanel"
}
}
}
diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json
index a37161ba..e5c39ef8 100644
--- a/apps/web/messages/tr.json
+++ b/apps/web/messages/tr.json
@@ -1367,7 +1367,8 @@
"hd": "HD ekran paylaşımı (1080p)",
"liveDirectory": "/live dizininde herkese açık oda",
"callAnalysis": "Yapay zekâ görüşme analizi",
- "support": "Öncelikli destek"
+ "support": "Öncelikli destek",
+ "translation": "Canlı toplantı çevirisi"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "4K ekran paylaşımı",
"callAnalysis": "Yapay zekâ görüşme analizi",
"admin": "Yönetici kontrolleri",
- "support": "Öncelikli destek"
+ "support": "Öncelikli destek",
+ "translation": "Canlı toplantı çevirisi"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "P2P oturumlarla ücretsiz başlayın veya yalnızca {price}/ay karşılığında Pro’ya yükseltin. Kişi başı ücret asla yok.",
"download": "Ücretsiz İndir",
"signup": "Hesap Oluştur"
- }
+ },
+ "payMethods": "USDC, USDT, BTC, ETH, SOL ve diğer coinlerle ya da PayPal ile öde. Her ödeme 31 günü kapsar ve hiçbir şey kendiliğinden yenilenmez."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "{count} kişi kullanıyor",
"on": "Açık",
"off": "Kapalı",
- "paidGuest": "Canlı çeviri ücretli bir özelliktir. Toplantı sahibinden bir çeviri paketi almasını isteyin.",
- "paidHost": "Canlı çeviri ücretli bir özelliktir. Toplantılarınızdaki herkes birbirini kendi dilinde duyup okuyabilsin diye aşağıdan bir paket satın alın.",
+ "paidGuest": "Canlı çeviri bir Pro özelliğidir. Toplantı sahibinden Pro'ya geçmesini iste.",
+ "paidHost": "Canlı çeviri Pro'ya dahildir. Toplantılarındaki herkes birbirini kendi dilinde duyup okuyabilsin diye Pro'ya geç.",
"hearEveryoneIn": "Herkesi şu dilde dinle",
"nobodyElse": "Bu toplantıda henüz başka konuşan yok.",
"speaks": "{name} şu dili konuşuyor",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Yükselt",
"starting": "{plan} ödemeniz başlatılıyor…",
- "backToPricing": "Fiyatlara dön"
+ "backToPricing": "Fiyatlara dön",
+ "chooseTitle": "{plan} için nasıl ödemek istersin?",
+ "chooseNote": "{price}, {days} günü kapsar. Hiçbir şey kendiliğinden yenilenmez ve bitmeden önce sana e-posta göndeririz.",
+ "paid": "Teşekkürler! {plan} planın ödeme onaylanır onaylanmaz, genellikle birkaç dakika içinde açılır.",
+ "toDashboard": "Panele git"
}
}
}
diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json
index ad55b416..364ae011 100644
--- a/apps/web/messages/uk.json
+++ b/apps/web/messages/uk.json
@@ -1367,7 +1367,8 @@
"hd": "Демонстрація екрана в HD (1080p)",
"liveDirectory": "Публічна кімната в каталозі /live",
"callAnalysis": "ШІ-аналіз дзвінків",
- "support": "Пріоритетна підтримка"
+ "support": "Пріоритетна підтримка",
+ "translation": "Живий переклад зустрічей"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "Демонстрація екрана в 4K",
"callAnalysis": "ШІ-аналіз дзвінків",
"admin": "Інструменти адміністратора",
- "support": "Пріоритетна підтримка"
+ "support": "Пріоритетна підтримка",
+ "translation": "Живий переклад зустрічей"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "Почніть безкоштовно з P2P-сесій або перейдіть на Pro всього за {price}/міс. Жодної оплати за місце.",
"download": "Завантажити безкоштовно",
"signup": "Створити обліковий запис"
- }
+ },
+ "payMethods": "Оплачуйте в USDC, USDT, BTC, ETH, SOL та інших монетах або через PayPal. Кожен платіж покриває 31 день, нічого не продовжується само."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "використовують: {count}",
"on": "Увімк.",
"off": "Вимк.",
- "paidGuest": "Синхронний переклад — платна функція. Попросіть організатора придбати перепустку на переклад.",
- "paidHost": "Синхронний переклад — платна функція. Придбайте перепустку нижче, щоб усі учасники ваших зустрічей могли чути й читати одне одного своєю мовою.",
+ "paidGuest": "Живий переклад доступний у Pro. Попросіть організатора перейти на Pro.",
+ "paidHost": "Живий переклад входить у Pro. Перейдіть на Pro, щоб усі на ваших зустрічах чули й читали одне одного своєю мовою.",
"hearEveryoneIn": "Слухати всіх мовою",
"nobodyElse": "У цій зустрічі поки ніхто інший не говорить.",
"speaks": "{name} говорить мовою",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Підвищити тариф",
"starting": "Запуск оплати тарифу {plan}…",
- "backToPricing": "Назад до цін"
+ "backToPricing": "Назад до цін",
+ "chooseTitle": "Як ви хочете оплатити {plan}?",
+ "chooseNote": "{price} покриває {days} дн. Нічого не продовжується само, і ми напишемо вам до завершення.",
+ "paid": "Дякуємо! План {plan} увімкнеться, щойно платіж підтвердиться, зазвичай за кілька хвилин.",
+ "toDashboard": "Перейти до панелі"
}
}
}
diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json
index fa38e2e9..aa5ca498 100644
--- a/apps/web/messages/vi.json
+++ b/apps/web/messages/vi.json
@@ -1367,7 +1367,8 @@
"hd": "Chia sẻ màn hình HD (1080p)",
"liveDirectory": "Phòng công khai trong danh bạ /live",
"callAnalysis": "Phân tích cuộc gọi bằng AI",
- "support": "Hỗ trợ ưu tiên"
+ "support": "Hỗ trợ ưu tiên",
+ "translation": "Dịch cuộc họp trực tiếp"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "Chia sẻ màn hình 4K",
"callAnalysis": "Phân tích cuộc gọi bằng AI",
"admin": "Công cụ quản trị",
- "support": "Hỗ trợ ưu tiên"
+ "support": "Hỗ trợ ưu tiên",
+ "translation": "Dịch cuộc họp trực tiếp"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "Bắt đầu miễn phí với phiên P2P, hoặc nâng cấp lên Pro chỉ với {price}/tháng. Không bao giờ tính phí theo người dùng.",
"download": "Tải xuống miễn phí",
"signup": "Tạo tài khoản"
- }
+ },
+ "payMethods": "Thanh toán bằng USDC, USDT, BTC, ETH, SOL và các coin khác, hoặc PayPal. Mỗi lần thanh toán dùng được 31 ngày và không tự gia hạn."
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "{count} người đang dùng",
"on": "Bật",
"off": "Tắt",
- "paidGuest": "Dịch trực tiếp là tính năng trả phí. Hãy nhờ người chủ trì mua gói dịch.",
- "paidHost": "Dịch trực tiếp là tính năng trả phí. Hãy mua gói bên dưới để mọi người trong cuộc họp của bạn có thể nghe và đọc lời nhau bằng ngôn ngữ của mình.",
+ "paidGuest": "Dịch trực tiếp là tính năng Pro. Hãy nhờ chủ phòng nâng cấp lên Pro.",
+ "paidHost": "Dịch trực tiếp có trong gói Pro. Nâng cấp để mọi người trong cuộc họp nghe và đọc nhau bằng ngôn ngữ của mình.",
"hearEveryoneIn": "Nghe mọi người bằng",
"nobodyElse": "Chưa có ai khác nói trong cuộc họp này.",
"speaks": "{name} nói tiếng",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "Nâng cấp",
"starting": "Đang bắt đầu thanh toán gói {plan}…",
- "backToPricing": "Quay lại bảng giá"
+ "backToPricing": "Quay lại bảng giá",
+ "chooseTitle": "Bạn muốn thanh toán {plan} bằng cách nào?",
+ "chooseNote": "{price} dùng được {days} ngày. Không có gì tự gia hạn, và chúng tôi sẽ email cho bạn trước khi hết hạn.",
+ "paid": "Cảm ơn bạn! Gói {plan} sẽ bật ngay khi thanh toán được xác nhận, thường trong vài phút.",
+ "toDashboard": "Đến bảng điều khiển"
}
}
}
diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json
index d942facb..dd987284 100644
--- a/apps/web/messages/zh.json
+++ b/apps/web/messages/zh.json
@@ -1367,7 +1367,8 @@
"hd": "高清屏幕共享(1080p)",
"liveDirectory": "在 /live 目录中展示公开房间",
"callAnalysis": "AI 通话分析",
- "support": "优先支持"
+ "support": "优先支持",
+ "translation": "会议实时翻译"
}
},
"team": {
@@ -1381,7 +1382,8 @@
"uhd": "4K 屏幕共享",
"callAnalysis": "AI 通话分析",
"admin": "管理员控制",
- "support": "优先支持"
+ "support": "优先支持",
+ "translation": "会议实时翻译"
}
}
},
@@ -1481,7 +1483,8 @@
"subtitle": "从免费的 P2P 会话开始,或以每月仅 {price} 升级到 Pro。永远不按席位收费。",
"download": "免费下载",
"signup": "创建账户"
- }
+ },
+ "payMethods": "可用 USDC、USDT、BTC、ETH、SOL 等币种或 PayPal 支付。每次付款可用 31 天,不会自动续费。"
},
"download": {
"meta": {
@@ -2209,8 +2212,8 @@
"inUseBy": "{count} 人正在使用",
"on": "开",
"off": "关",
- "paidGuest": "实时翻译是付费功能。请让主持人购买翻译通行证。",
- "paidHost": "实时翻译是付费功能。在下方购买通行证后,会议中的每个人都能用自己的语言听到和读到彼此的发言。",
+ "paidGuest": "实时翻译是 Pro 功能。请让主持人升级到 Pro。",
+ "paidHost": "实时翻译包含在 Pro 中。升级后,会议中的每个人都能用自己的语言听到和读到彼此。",
"hearEveryoneIn": "用以下语言收听所有人",
"nobodyElse": "这个会议里还没有其他人发言。",
"speaks": "{name} 说的语言",
@@ -2252,7 +2255,11 @@
"page": {
"metaTitle": "升级",
"starting": "正在开始 {plan} 结账…",
- "backToPricing": "返回价格页"
+ "backToPricing": "返回价格页",
+ "chooseTitle": "你想用什么方式支付 {plan}?",
+ "chooseNote": "{price} 可用 {days} 天。不会自动续费,到期前我们会发邮件提醒你。",
+ "paid": "谢谢!付款确认后你的 {plan} 套餐会立即开通,通常只需几分钟。",
+ "toDashboard": "前往控制台"
}
}
}
diff --git a/apps/web/src/app/api/billing/checkout/route.ts b/apps/web/src/app/api/billing/checkout/route.ts
index d29f6042..d749e4d8 100644
--- a/apps/web/src/app/api/billing/checkout/route.ts
+++ b/apps/web/src/app/api/billing/checkout/route.ts
@@ -1,97 +1,74 @@
import { z } from 'zod';
-import {
- createCoinpayPayment,
- coinpayHostedPayUrl,
- SUPPORTED_CURRENCIES,
- type CoinpayCurrency,
-} from '@/lib/coinpay-client';
+import { CoinPayError, METHOD_LABELS, normalizeMethod } from '@/lib/coinpay-client';
+import { CheckoutError, checkoutMethods, startPlanCheckout } from '@/lib/billing/plan-checkout';
import { getPlanDef } from '@/lib/plans';
-/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */
import { createClient, getAuthenticatedUser } from '@/lib/supabase/server';
-import { serviceClient } from '@/lib/supabase/service';
import { successResponse, errorResponse, handleApiError } from '@/lib/api';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
-const SUPPORTED_KEYS = Object.keys(SUPPORTED_CURRENCIES) as [CoinpayCurrency, ...CoinpayCurrency[]];
-
const Body = z.object({
plan: z.enum(['plus', 'pro', 'team']),
- // Default to 'card', which routes through CoinPay's card+crypto flow.
- currency: z.enum(SUPPORTED_KEYS).default('card'),
+ /** A coin (USDC_POL, BTC, ...) or PAYPAL. Omitted: CoinPay's hosted checkout, if offered. */
+ method: z.string().optional(),
});
-// Start a CoinPay invoice to buy/renew the multistream plugin. The CoinPay
-// webhook grants the plan on payment.confirmed/forwarded (idempotent on
-// payment id), so we only record a pending row here and hand back the
-// checkout URL for the client to redirect to.
+// GET /api/billing/checkout: the payment methods the picker offers.
+export async function GET() {
+ const methods = await checkoutMethods();
+ return successResponse({
+ methods: methods.map((id) => ({ id, label: METHOD_LABELS[id] })),
+ });
+}
+
+// POST /api/billing/checkout: start a CoinPay checkout for one term of a plan.
+// The CoinPay webhook grants the plan on a settled payment (idempotent on
+// payment id). Answers either a checkout_url to redirect to, or
+// choose_method + methods when the buyer must pick a coin or PayPal first.
export async function POST(request: Request) {
try {
const body = Body.parse(await request.json());
-
const plan = getPlanDef(body.plan);
if (!plan) return errorResponse('Unknown plan', 400);
- const supabase = await createClient();
- const { user, error: authError } = await getAuthenticatedUser(supabase);
- if (authError || !user) {
- return errorResponse('Sign in to upgrade', 401);
+ let method = null;
+ if (body.method) {
+ method = normalizeMethod(body.method);
+ if (!method) return errorResponse('Unsupported payment method', 400);
}
- const cp = await createCoinpayPayment({
- amount_usd: plan.priceUsd,
- currency: body.currency,
- description: plan.label,
- metadata: {
- // Read back by the CoinPay webhook to grant the plan to this user.
- type: 'plan',
- user_id: user.id,
- plan: plan.id,
- },
- });
-
- const payment = cp.payment ?? {};
- const paymentId = cp.payment_id ?? payment.id;
- // Card/Stripe payments return a checkout URL; crypto payments don't, so we
- // fall back to CoinPay's hosted pay page for the created payment id.
- const checkoutUrl =
- payment.stripe_checkout_url ??
- cp.checkout_url ??
- payment.checkout_url ??
- (paymentId ? coinpayHostedPayUrl(paymentId) : null);
- const expiresAt = cp.expires_at ?? payment.expires_at ?? null;
-
- if (!paymentId) {
- return errorResponse('CoinPay did not return a payment id', 502);
- }
+ const supabase = await createClient();
+ const { user, error: authError } = await getAuthenticatedUser(supabase);
+ if (authError || !user) return errorResponse('Sign in to upgrade', 401);
- // Record the pending payment so the webhook can resolve user + plan even
- // if CoinPay drops the metadata on the return trip. Service role bypasses
- // RLS (clients only ever read their own rows).
- const admin = serviceClient() as any;
- const { error: insertError } = await admin.from('plan_payments').insert({
- coinpay_payment_id: paymentId,
- user_id: user.id,
- plan: plan.id,
- amount_usd: plan.priceUsd,
- currency: body.currency,
- status: 'pending',
- metadata: { checkout_url: checkoutUrl, expires_at: expiresAt },
+ const result = await startPlanCheckout({
+ plan,
+ userId: user.id,
+ email: user.email ?? null,
+ method,
});
- if (insertError) {
- console.error('[billing/checkout] insert failed:', insertError);
- return errorResponse('Failed to record payment', 500);
+ if (result.kind === 'choose') {
+ return successResponse({
+ choose_method: true,
+ plan: plan.id,
+ amount_usd: plan.priceUsd,
+ methods: result.methods.map((id) => ({ id, label: METHOD_LABELS[id] })),
+ });
}
-
return successResponse({
- payment_id: paymentId,
- checkout_url: checkoutUrl,
+ payment_id: result.paymentId,
+ checkout_url: result.checkoutUrl,
+ rail: result.rail,
plan: plan.id,
amount_usd: plan.priceUsd,
- expires_at: expiresAt,
});
} catch (error) {
+ if (error instanceof CheckoutError) return errorResponse(error.message, error.status);
+ if (error instanceof CoinPayError) {
+ console.error('[billing/checkout] CoinPay refused:', error.status, error.message);
+ return errorResponse(`CoinPay: ${error.message}`, 502);
+ }
return handleApiError(error);
}
}
diff --git a/apps/web/src/app/api/billing/translation-checkout/route.ts b/apps/web/src/app/api/billing/translation-checkout/route.ts
index e9ea9bfc..eb54d0fe 100644
--- a/apps/web/src/app/api/billing/translation-checkout/route.ts
+++ b/apps/web/src/app/api/billing/translation-checkout/route.ts
@@ -1,74 +1,24 @@
-import { z } from 'zod';
-import {
- createCoinpayPayment,
- coinpayHostedPayUrl,
- SUPPORTED_CURRENCIES,
- type CoinpayCurrency,
-} from '@/lib/coinpay-client';
-import { translationPass } from '@/lib/translation/meter';
-/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */
+import { PLANS, upgradePath } from '@/lib/plans';
import { createClient, getAuthenticatedUser } from '@/lib/supabase/server';
-import { serviceClient } from '@/lib/supabase/service';
import { successResponse, errorResponse, handleApiError } from '@/lib/api';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
-const SUPPORTED_KEYS = Object.keys(SUPPORTED_CURRENCIES) as [CoinpayCurrency, ...CoinpayCurrency[]];
-const Body = z.object({
- pass: z.enum(['translation_day', 'translation_month']),
- currency: z.enum(SUPPORTED_KEYS).default('card'),
-});
-
-// POST /api/billing/translation-checkout — buy prepaid meeting-translation
-// credit (Day $1 / Month $5). The CoinPay webhook credits it once, atomically
-// (translation_credit_payment), on confirmed/forwarded.
-export async function POST(request: Request) {
+// POST /api/billing/translation-checkout: live meeting translation is part of
+// Pro (and Team), so "buy" from the translation panel sends the host to the Pro
+// checkout. The old Day/Month pass ids are accepted and answered the same way,
+// so a desktop app from before this change still lands somewhere useful.
+export async function POST() {
try {
- const body = Body.parse(await request.json());
- const pass = translationPass(body.pass);
- if (!pass) return errorResponse('Unknown pass', 400);
-
const supabase = await createClient();
const { user, error: authError } = await getAuthenticatedUser(supabase);
- if (authError || !user) return errorResponse('Sign in to buy a translation pass', 401);
-
- const cp = await createCoinpayPayment({
- amount_usd: pass.priceUsd,
- currency: body.currency,
- description: `PairUX meeting translation: ${pass.name}`,
- metadata: { type: 'translation', user_id: user.id, plan: pass.id },
- });
- const payment = cp.payment ?? {};
- const paymentId = cp.payment_id ?? payment.id;
- const checkoutUrl =
- payment.stripe_checkout_url ??
- cp.checkout_url ??
- payment.checkout_url ??
- (paymentId ? coinpayHostedPayUrl(paymentId) : null);
- const expiresAt = cp.expires_at ?? payment.expires_at ?? null;
- if (!paymentId) return errorResponse('CoinPay did not return a payment id', 502);
-
- const admin = serviceClient() as any;
- const { error: insertError } = await admin.from('plan_payments').insert({
- coinpay_payment_id: paymentId,
- user_id: user.id,
- plan: pass.id,
- amount_usd: pass.priceUsd,
- currency: body.currency,
- status: 'pending',
- metadata: { checkout_url: checkoutUrl, expires_at: expiresAt },
- });
- if (insertError) {
- console.error('[billing/translation-checkout] insert failed:', insertError);
- return errorResponse('Failed to record payment', 500);
- }
+ if (authError || !user) return errorResponse('Sign in to upgrade to Pro', 401);
+ const base = (process.env.NEXT_PUBLIC_APP_URL ?? 'https://pairux.com').replace(/\/+$/, '');
return successResponse({
- payment_id: paymentId,
- checkout_url: checkoutUrl,
- pass: pass.id,
- amount_usd: pass.priceUsd,
- expires_at: expiresAt,
+ checkout_url: `${base}${upgradePath('pro')}`,
+ plan: 'pro',
+ amount_usd: PLANS.pro.priceUsd,
});
} catch (error) {
return handleApiError(error);
diff --git a/apps/web/src/app/api/chat/translate/route.ts b/apps/web/src/app/api/chat/translate/route.ts
index 04455f58..a2519657 100644
--- a/apps/web/src/app/api/chat/translate/route.ts
+++ b/apps/web/src/app/api/chat/translate/route.ts
@@ -31,7 +31,7 @@ export async function POST(request: Request) {
return errorResponse('Too many translation requests. Try again shortly.', 429);
const runtime = translationRuntime();
if (!runtime) return errorResponse('Translation is not configured on this server.', 503);
- // Paid feature: chat translation rides on the host's translation pass.
+ // Pro feature: chat translation rides on the host's plan (Pro or Team).
if (!(await runtime.meter.isAdmin(caller.userId)))
await runtime.meter.require(caller.hostUserId, body.sessionId);
const { texts } = await runtime.translator.translate(
diff --git a/apps/web/src/app/api/reminders/run/route.test.ts b/apps/web/src/app/api/reminders/run/route.test.ts
index 859a2b10..122e8b64 100644
--- a/apps/web/src/app/api/reminders/run/route.test.ts
+++ b/apps/web/src/app/api/reminders/run/route.test.ts
@@ -7,6 +7,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
*/
const runMeetingReminders = vi.fn();
+vi.mock('@/lib/plan-renewal', () => ({
+ runPlanRenewalReminders: () =>
+ Promise.resolve({ renewalEmails: 0, renewalSkipped: 0, renewalErrors: [] }),
+}));
vi.mock('@/lib/meeting-reminders-runner', () => ({
runMeetingReminders: (...args: unknown[]) => runMeetingReminders(...args),
}));
diff --git a/apps/web/src/app/api/reminders/run/route.ts b/apps/web/src/app/api/reminders/run/route.ts
index 6b1dea14..3ef51844 100644
--- a/apps/web/src/app/api/reminders/run/route.ts
+++ b/apps/web/src/app/api/reminders/run/route.ts
@@ -2,6 +2,7 @@ import { timingSafeEqual } from 'node:crypto';
import { NextResponse } from 'next/server';
import { runMeetingReminders } from '@/lib/meeting-reminders-runner';
+import { runPlanRenewalReminders } from '@/lib/plan-renewal';
/**
* The tick that sends meeting reminders.
@@ -54,11 +55,18 @@ export async function POST(request: Request): Promise {
try {
const summary = await runMeetingReminders();
+ // Paid-plan renewal reminders ride the same tick; a failure there must not
+ // hide the meeting summary.
+ const renewals = await runPlanRenewalReminders().catch((err: unknown) => ({
+ renewalEmails: 0,
+ renewalSkipped: 0,
+ renewalErrors: [err instanceof Error ? err.message : String(err)],
+ }));
// Errors inside the run are reported rather than thrown: one meeting whose
// host has been deleted should not make the whole tick look like a failure,
// and pg_cron's log is the only place anybody will see this.
- return NextResponse.json({ ok: true, ...summary });
+ return NextResponse.json({ ok: true, ...summary, ...renewals });
} catch (err) {
console.error('[reminders] run failed:', err);
return NextResponse.json(
diff --git a/apps/web/src/app/api/sessions/[sessionId]/translation/route.ts b/apps/web/src/app/api/sessions/[sessionId]/translation/route.ts
index 2a7962f0..f7a7f3db 100644
--- a/apps/web/src/app/api/sessions/[sessionId]/translation/route.ts
+++ b/apps/web/src/app/api/sessions/[sessionId]/translation/route.ts
@@ -1,6 +1,6 @@
import { authorizeMeetingCaller, MeetingAccessError } from '@/lib/translation/access';
import { TRANSLATION_LANGUAGES } from '@/lib/translation/languages';
-import { TRANSLATION_PASSES } from '@/lib/translation/meter';
+import { TRANSLATION_OFFER } from '@/lib/translation/meter';
import { translationRuntime } from '@/lib/translation/server';
import { errorResponse, handleApiError, successResponse } from '@/lib/api';
@@ -26,13 +26,13 @@ export async function GET(request: Request, { params }: RouteParams) {
isHost: caller.isHost,
// Clients tap and upload remote tracks only while someone is listening.
listening: runtime?.rooms.get(sessionId)?.size ?? 0,
- // Paid feature: everyone in the meeting can see whether the host's pass covers it.
+ // Pro feature: everyone in the meeting can see whether the host's plan covers it.
funded: runtime
? (await runtime.meter.funded(caller.hostUserId, sessionId)) ||
(await runtime.meter.isAdmin(caller.userId))
: false,
access,
- passes: caller.isHost ? TRANSLATION_PASSES : [],
+ passes: caller.isHost ? TRANSLATION_OFFER : [],
});
} catch (error) {
if (error instanceof MeetingAccessError) return errorResponse(error.message, error.status);
diff --git a/apps/web/src/app/api/webhooks/coinpay/route.test.ts b/apps/web/src/app/api/webhooks/coinpay/route.test.ts
new file mode 100644
index 00000000..7e57402c
--- /dev/null
+++ b/apps/web/src/app/api/webhooks/coinpay/route.test.ts
@@ -0,0 +1,126 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@profullstack/stack/coinpay', () => ({
+ verifyCoinPayWebhook: ({ signature }: { signature: string | null }) => signature === 'good',
+}));
+
+interface Row {
+ coinpay_payment_id: string;
+ user_id: string;
+ plan: string;
+ credited_at: string | null;
+ amount_usd?: number;
+}
+let rows: Row[] = [];
+const rpc = vi.fn();
+const inserts: unknown[] = [];
+
+vi.mock('@/lib/supabase/service', () => ({
+ serviceClient: () => ({
+ from: () => ({
+ select: () => ({
+ in: (_col: string, ids: string[]) => ({
+ limit: async () => ({
+ data: rows.filter((r) => ids.includes(r.coinpay_payment_id)),
+ error: null,
+ }),
+ }),
+ }),
+ update: () => ({ eq: async () => ({ error: null }) }),
+ insert: async (row: Row) => {
+ inserts.push(row);
+ rows.push({ ...row, credited_at: null });
+ return { error: null };
+ },
+ }),
+ rpc: async (name: string, args: unknown) => {
+ rpc(name, args);
+ return { data: null, error: null };
+ },
+ }),
+}));
+
+process.env.COINPAY_WEBHOOK_SECRET = 'secret';
+const { POST } = await import('./route');
+
+function hook(body: unknown, signature = 'good') {
+ return POST(
+ new Request('https://pairux.com/api/webhooks/coinpay', {
+ method: 'POST',
+ headers: { 'x-coinpay-signature': signature },
+ body: JSON.stringify(body),
+ }) as any
+ );
+}
+
+beforeEach(() => {
+ rows = [];
+ inserts.length = 0;
+ rpc.mockReset();
+});
+
+describe('POST /api/webhooks/coinpay', () => {
+ it('refuses a bad signature', async () => {
+ expect((await hook({}, 'bad')).status).toBe(401);
+ });
+
+ it('grants one term through the atomic RPC on a confirmed payment', async () => {
+ rows = [{ coinpay_payment_id: 'pay_1', user_id: 'u1', plan: 'pro', credited_at: null }];
+ const res = await hook({
+ type: 'payment.confirmed',
+ data: { payment_id: 'pay_1', status: 'confirmed' },
+ });
+ expect(res.status).toBe(200);
+ expect(rpc).toHaveBeenCalledWith('plan_credit_payment', {
+ p_payment_id: 'pay_1',
+ p_days: 31,
+ p_status: 'confirmed',
+ });
+ });
+
+ it('finds a hosted session row by checkout_session_id', async () => {
+ rows = [{ coinpay_payment_id: 'cs_1', user_id: 'u1', plan: 'plus', credited_at: null }];
+ await hook({
+ type: 'payment.confirmed',
+ data: { payment_id: 'pay_9', checkout_session_id: 'cs_1', status: 'confirmed' },
+ });
+ expect(rpc).toHaveBeenCalledWith(
+ 'plan_credit_payment',
+ expect.objectContaining({ p_payment_id: 'cs_1' })
+ );
+ });
+
+ it('does not grant on an unsettled status', async () => {
+ rows = [{ coinpay_payment_id: 'pay_1', user_id: 'u1', plan: 'pro', credited_at: null }];
+ await hook({ type: 'payment.detected', data: { payment_id: 'pay_1', status: 'detected' } });
+ await hook({ type: 'payment.expired', data: { payment_id: 'pay_1', status: 'expired' } });
+ expect(rpc).not.toHaveBeenCalled();
+ });
+
+ it('rebuilds a missing row from pairux metadata, and ignores other properties', async () => {
+ await hook({
+ type: 'payment.confirmed',
+ data: {
+ payment_id: 'pp_1',
+ status: 'confirmed',
+ metadata: { property: 'smshub.dev', account_id: 'u2', plan: 'pro' },
+ },
+ });
+ expect(inserts).toHaveLength(0);
+ await hook({
+ type: 'payment.confirmed',
+ data: {
+ payment_id: 'pp_1',
+ status: 'confirmed',
+ amount_usd: 12,
+ metadata: { property: 'pairux.com', account_id: 'u2', plan: 'pro' },
+ },
+ });
+ expect(inserts).toHaveLength(1);
+ expect(rpc).toHaveBeenCalledWith(
+ 'plan_credit_payment',
+ expect.objectContaining({ p_payment_id: 'pp_1' })
+ );
+ });
+});
diff --git a/apps/web/src/app/api/webhooks/coinpay/route.ts b/apps/web/src/app/api/webhooks/coinpay/route.ts
index 4967bbf3..1a63f8fb 100644
--- a/apps/web/src/app/api/webhooks/coinpay/route.ts
+++ b/apps/web/src/app/api/webhooks/coinpay/route.ts
@@ -1,30 +1,21 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */
import { NextResponse, type NextRequest } from 'next/server';
import { verifyCoinPayWebhook } from '@profullstack/stack/coinpay';
-import { TERM_DAYS } from '@/lib/plans';
+import { asText, PAYMENT_PROPERTY, readCoinpayWebhook } from '@/lib/coinpay-client';
+import { getPlanDef, TERM_DAYS } from '@/lib/plans';
import { translationPass } from '@/lib/translation/meter';
import { serviceClient } from '@/lib/supabase/service';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
-interface NormalizedWebhook {
- eventType: string;
- paymentId: string | null;
- txHash: string | null;
-}
-
-// CoinPay sends either a nested ({ type, data: {...} }) or flat shape, so we
-// read fields defensively from the parsed JSON.
-function normalize(payload: any): NormalizedWebhook {
- const data = payload?.data && typeof payload.data === 'object' ? payload.data : payload;
- return {
- eventType: (payload?.type ?? payload?.event ?? '') as string,
- paymentId: (data?.payment_id ?? null) as string | null,
- txHash: (data?.tx_hash ?? data?.merchant_tx_hash ?? null) as string | null,
- };
-}
+const STATUSES = new Set(['confirmed', 'forwarded', 'expired', 'failed']);
+/**
+ * CoinPay settles plan payments here: crypto (payment id), PayPal (CoinPay's
+ * PayPal transaction id, sent as payment_id) and hosted checkout sessions
+ * (payment_id plus checkout_session_id; our row is keyed by the session id).
+ */
export async function POST(request: NextRequest) {
const secret = process.env.COINPAY_WEBHOOK_SECRET;
if (!secret) {
@@ -46,79 +37,96 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
}
- const { eventType, paymentId, txHash } = normalize(payload);
- if (!paymentId) {
+ const event = readCoinpayWebhook(payload);
+ if (!event.paymentId && !event.sessionId) {
return NextResponse.json({ error: 'Missing payment_id' }, { status: 400 });
}
-
- let nextStatus: string | null = null;
- switch (eventType) {
- case 'payment.confirmed':
- case 'payment.completed':
- nextStatus = 'confirmed';
- break;
- case 'payment.forwarded':
- nextStatus = 'forwarded';
- break;
- case 'payment.expired':
- nextStatus = 'expired';
- break;
- case 'payment.failed':
- nextStatus = 'failed';
- break;
- default:
- return NextResponse.json({ received: true, ignored: eventType });
+ if (!STATUSES.has(event.status)) {
+ return NextResponse.json({ received: true, ignored: event.eventType || event.status });
}
const supabase = serviceClient() as any;
const now = new Date().toISOString();
+ const ids = [event.paymentId, event.sessionId].filter((id): id is string => Boolean(id));
- // Keep the payment row's status in sync.
- await supabase
+ // Find our row by payment id, else by the session id it was opened under.
+ const { data: rows, error: lookupError } = await supabase
.from('plan_payments')
- .update({ status: nextStatus, tx_hash: txHash, updated_at: now })
- .eq('coinpay_payment_id', paymentId);
-
- // Grant the plan only on terminal success — confirmed (crypto) or forwarded
- // (merchant payout). Both can fire for one payment, so we gate on
- // credited_at: grant_plan extends the period exactly once.
- if (nextStatus === 'confirmed' || nextStatus === 'forwarded') {
- const { data: payment, error: lookupError } = await supabase
- .from('plan_payments')
- .select('user_id, plan, credited_at')
- .eq('coinpay_payment_id', paymentId)
- .maybeSingle();
+ .select('coinpay_payment_id, user_id, plan, credited_at, amount_usd')
+ .in('coinpay_payment_id', ids)
+ .limit(1);
+ if (lookupError) {
+ console.error('[coinpay webhook] payment lookup failed:', lookupError);
+ return NextResponse.json({ error: 'Lookup failed' }, { status: 500 });
+ }
+ let payment = rows?.[0] ?? null;
- if (lookupError) {
- console.error('[coinpay webhook] payment lookup failed:', lookupError);
- } else if (payment && String(payment.plan).startsWith('translation_')) {
- // Prepaid translation credit: marked credited and added in one statement,
- // so the confirmed + forwarded pair can never credit twice.
- const pass = translationPass(String(payment.plan));
- const { error: creditError } = await supabase.rpc('translation_credit_payment', {
- p_payment_id: paymentId,
- p_days: pass?.days ?? 1,
- p_status: nextStatus,
+ // No row (the insert after create failed, or CoinPay opened the payment some
+ // other way): rebuild it from our own metadata, but only for a settled pairux
+ // plan payment that names an account and a plan we sell.
+ if (!payment && event.settled) {
+ const meta = event.metadata;
+ const plan = getPlanDef(asText(meta.plan));
+ const userId = asText(meta.account_id) || asText(meta.user_id);
+ if (plan && userId && (asText(meta.property) || PAYMENT_PROPERTY) === PAYMENT_PROPERTY) {
+ const key = event.paymentId ?? event.sessionId ?? '';
+ const { error: insertError } = await supabase.from('plan_payments').insert({
+ coinpay_payment_id: key,
+ user_id: userId,
+ plan: plan.id,
+ amount_usd: event.amountUsd ?? plan.priceUsd,
+ currency: 'WEBHOOK',
+ status: 'pending',
+ metadata: { rebuilt_from_webhook: true, ...meta },
});
- if (creditError) {
- console.error('[coinpay webhook] translation_credit_payment failed:', creditError);
- return NextResponse.json({ error: 'Credit failed' }, { status: 500 });
- }
- } else if (payment && !payment.credited_at) {
- const { error: grantError } = await supabase.rpc('grant_plan', {
- p_user_id: payment.user_id,
- p_plan: payment.plan,
- p_days: TERM_DAYS,
- });
- if (grantError) {
- console.error('[coinpay webhook] grant_plan failed:', grantError);
- } else {
- await supabase
- .from('plan_payments')
- .update({ status: nextStatus, credited_at: now, updated_at: now })
- .eq('coinpay_payment_id', paymentId);
+ if (insertError && insertError.code !== '23505') {
+ console.error('[coinpay webhook] rebuild insert failed:', insertError);
+ return NextResponse.json({ error: 'Record failed' }, { status: 500 });
}
+ payment = { coinpay_payment_id: key, user_id: userId, plan: plan.id, credited_at: null };
+ }
+ }
+
+ if (!payment) {
+ return NextResponse.json({ received: true, ignored: 'unknown payment' });
+ }
+ const rowKey = String(payment.coinpay_payment_id);
+
+ // Keep the row's status in sync; never move a credited row off its success.
+ if (!payment.credited_at) {
+ await supabase
+ .from('plan_payments')
+ .update({ status: event.status, tx_hash: event.txHash, updated_at: now })
+ .eq('coinpay_payment_id', rowKey);
+ }
+
+ if (!event.settled) return NextResponse.json({ received: true });
+
+ if (String(payment.plan).startsWith('translation_')) {
+ // Legacy prepaid translation credit: marked credited and added in one statement.
+ const pass = translationPass(String(payment.plan));
+ const { error: creditError } = await supabase.rpc('translation_credit_payment', {
+ p_payment_id: rowKey,
+ p_days: pass?.days ?? 1,
+ p_status: event.status,
+ });
+ if (creditError) {
+ console.error('[coinpay webhook] translation_credit_payment failed:', creditError);
+ return NextResponse.json({ error: 'Credit failed' }, { status: 500 });
}
+ return NextResponse.json({ received: true });
+ }
+
+ // Grant one term exactly once, even when confirmed and forwarded race.
+ const { error: grantError } = await supabase.rpc('plan_credit_payment', {
+ p_payment_id: rowKey,
+ p_days: TERM_DAYS,
+ p_status: event.status,
+ });
+ if (grantError) {
+ console.error('[coinpay webhook] plan_credit_payment failed:', grantError);
+ // 500 so CoinPay retries rather than the buyer paying for nothing.
+ return NextResponse.json({ error: 'Grant failed' }, { status: 500 });
}
return NextResponse.json({ received: true });
diff --git a/apps/web/src/app/pricing/UpgradeButton.tsx b/apps/web/src/app/pricing/UpgradeButton.tsx
index 1f665bd2..48b84f90 100644
--- a/apps/web/src/app/pricing/UpgradeButton.tsx
+++ b/apps/web/src/app/pricing/UpgradeButton.tsx
@@ -1,7 +1,5 @@
-'use client';
-
-import { useTranslations } from 'next-intl';
-import { useCheckout } from '@/components/billing/useCheckout';
+import Link from 'next/link';
+import { upgradePath } from '@/lib/plans';
interface UpgradeButtonProps {
plan: 'plus' | 'pro' | 'team';
@@ -10,25 +8,14 @@ interface UpgradeButtonProps {
}
/**
- * Starts a CoinPay checkout for the plan and redirects to the hosted payment
- * page. Unauthenticated users are sent to login first. The CoinPay webhook
- * grants the plan once payment confirms.
+ * Sends the buyer to /upgrade/, which starts the CoinPay checkout (every
+ * coin plus PayPal) and sends signed-out buyers through login first. The
+ * CoinPay webhook grants the plan once payment confirms.
*/
export function UpgradeButton({ plan, label, className }: UpgradeButtonProps) {
- const t = useTranslations('pricing.upgrade');
- const { start, loading, error } = useCheckout();
-
return (
- <>
-
- {error &&