feat: expand cloud storage with one-time payment (#2325)
This commit is contained in:
@@ -639,5 +639,17 @@
|
||||
"TTS Text": "نص تحويل النص إلى كلام",
|
||||
"The book file is corrupted": "ملف الكتاب تالف",
|
||||
"The book file is empty": "ملف الكتاب فارغ",
|
||||
"Failed to open the book file": "فشل في فتح ملف الكتاب"
|
||||
"Failed to open the book file": "فشل في فتح ملف الكتاب",
|
||||
"On-Demand Purchase": "الشراء عند الطلب",
|
||||
"Full Customization": "تخصيص كامل",
|
||||
"Lifetime Plan": "خطة مدى الحياة",
|
||||
"One-Time Payment": "دفعة لمرة واحدة",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "قم بإجراء دفعة واحدة للاستمتاع بالوصول مدى الحياة إلى ميزات معينة على جميع الأجهزة. اشترِ ميزات أو خدمات معينة فقط عند الحاجة إليها.",
|
||||
"Expand Cloud Sync Storage": "توسيع مساحة تخزين السحابة",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "قم بتوسيع مساحة التخزين السحابية الخاصة بك إلى الأبد من خلال عملية شراء لمرة واحدة. كل عملية شراء إضافية تضيف المزيد من المساحة.",
|
||||
"Unlock All Customization Options": "فتح جميع خيارات التخصيص",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "فتح سمات إضافية، خطوط، خيارات تخطيط، وخدمات قراءة بصوت عالٍ، ومترجمين، وخدمات تخزين سحابية.",
|
||||
"Purchase Successful!": "تمت عملية الشراء بنجاح!",
|
||||
"lifetime": "مدى الحياة",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "شكرًا لشرائك! تم معالجة الدفع بنجاح."
|
||||
}
|
||||
|
||||
@@ -623,5 +623,17 @@
|
||||
"TTS Text": "TTS টেক্সট",
|
||||
"The book file is corrupted": "বইয়ের ফাইলটি ক্ষতিগ্রস্ত।",
|
||||
"The book file is empty": "বইয়ের ফাইলটি খালি।",
|
||||
"Failed to open the book file": "বইয়ের ফাইল খুলতে ব্যর্থ।"
|
||||
"Failed to open the book file": "বইয়ের ফাইল খুলতে ব্যর্থ।",
|
||||
"On-Demand Purchase": "অনুরোধে ক্রয়",
|
||||
"Full Customization": "সম্পূর্ণ কাস্টমাইজেশন",
|
||||
"Lifetime Plan": "জীবনকাল পরিকল্পনা",
|
||||
"One-Time Payment": "এককালীন পেমেন্ট",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "নির্দিষ্ট বৈশিষ্ট্যগুলিতে সমস্ত ডিভাইসে জীবনকাল অ্যাক্সেস উপভোগ করতে এককালীন পেমেন্ট করুন। যখন আপনার প্রয়োজন তখনই নির্দিষ্ট বৈশিষ্ট্য বা পরিষেবাগুলি কিনুন।",
|
||||
"Expand Cloud Sync Storage": "ক্লাউড সিঙ্ক স্টোরেজ বাড়ান",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "এককালীন ক্রয়ের মাধ্যমে আপনার ক্লাউড স্টোরেজ চিরকাল বাড়ান। প্রতিটি অতিরিক্ত ক্রয় আরও স্থান যোগ করে।",
|
||||
"Unlock All Customization Options": "সমস্ত কাস্টমাইজেশন বিকল্প আনলক করুন",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "অতিরিক্ত থিম, ফন্ট, লেআউট বিকল্প এবং উচ্চস্বরে পড়া, অনুবাদক, ক্লাউড স্টোরেজ পরিষেবাগুলি আনলক করুন।",
|
||||
"Purchase Successful!": "ক্রয় সফল!",
|
||||
"lifetime": "জীবনকাল",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "আপনার ক্রয়ের জন্য ধন্যবাদ! আপনার পেমেন্ট সফলভাবে প্রক্রিয়া করা হয়েছে।"
|
||||
}
|
||||
|
||||
@@ -619,5 +619,17 @@
|
||||
"TTS Text": "TTS ཡིག",
|
||||
"The book file is corrupted": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
|
||||
"The book file is empty": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
|
||||
"Failed to open the book file": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།"
|
||||
"Failed to open the book file": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
|
||||
"On-Demand Purchase": "অনুরোধে ক্রয়",
|
||||
"Full Customization": "সম্পূর্ণ কাস্টমাইজেশন",
|
||||
"Lifetime Plan": "জীবনকাল পরিকল্পনা",
|
||||
"One-Time Payment": "এককালীন পেমেন্ট",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "নির্দিষ্ট বৈশিষ্ট্যগুলিতে সমস্ত ডিভাইসে জীবনকাল অ্যাক্সেস উপভোগ করতে এককালীন পেমেন্ট করুন। যখন আপনার প্রয়োজন তখনই নির্দিষ্ট বৈশিষ্ট্য বা পরিষেবাগুলি কিনুন।",
|
||||
"Expand Cloud Sync Storage": "ক্লাউড সিঙ্ক স্টোরেজ বাড়ান",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "এককালীন ক্রয়ের মাধ্যমে আপনার ক্লাউড স্টোরেজ চিরকাল বাড়ান। প্রতিটি অতিরিক্ত ক্রয় আরও স্থান যোগ করে।",
|
||||
"Unlock All Customization Options": "সমস্ত কাস্টমাইজেশন বিকল্প আনলক করুন",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "অতিরিক্ত থিম, ফন্ট, লেআউট বিকল্প এবং উচ্চস্বরে পড়া, অনুবাদক, ক্লাউড স্টোরেজ পরিষেবাগুলি আনলক করুন।",
|
||||
"Purchase Successful!": "ক্রয় সফল!",
|
||||
"lifetime": "জীবনকাল",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "আপনার ক্রয়ের জন্য ধন্যবাদ! আপনার পেমেন্ট সফলভাবে প্রক্রিয়াকৃত হয়েছে।"
|
||||
}
|
||||
|
||||
@@ -623,5 +623,17 @@
|
||||
"TTS Text": "TTS-Text",
|
||||
"The book file is corrupted": "Die Buchdatei ist beschädigt",
|
||||
"The book file is empty": "Die Buchdatei ist leer",
|
||||
"Failed to open the book file": "Fehler beim Öffnen der Buchdatei"
|
||||
"Failed to open the book file": "Fehler beim Öffnen der Buchdatei",
|
||||
"On-Demand Purchase": "Kauf auf Anfrage",
|
||||
"Full Customization": "Vollständige Anpassung",
|
||||
"Lifetime Plan": "Lebenslange Planung",
|
||||
"One-Time Payment": "Einmalige Zahlung",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Zahlen Sie einmalig, um lebenslangen Zugriff auf bestimmte Funktionen auf allen Geräten zu genießen. Kaufen Sie bestimmte Funktionen oder Dienstleistungen nur, wenn Sie sie benötigen.",
|
||||
"Expand Cloud Sync Storage": "Cloud-Synchronisierungs-Speicher erweitern",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Erweitern Sie Ihren Cloud-Speicher für immer mit einem einmaligen Kauf. Jeder zusätzliche Kauf fügt mehr Speicherplatz hinzu.",
|
||||
"Unlock All Customization Options": "Alle Anpassungsoptionen freischalten",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Zusätzliche Themen, Schriftarten, Layoutoptionen und Vorlesefunktionen, Übersetzer, Cloud-Speicherdienste freischalten.",
|
||||
"Purchase Successful!": "Kauf erfolgreich!",
|
||||
"lifetime": "lebenslang",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Danke für Ihren Kauf! Ihre Zahlung wurde erfolgreich verarbeitet."
|
||||
}
|
||||
|
||||
@@ -623,5 +623,17 @@
|
||||
"TTS Text": "Κείμενο TTS",
|
||||
"The book file is corrupted": "Το αρχείο του βιβλίου είναι κατεστραμμένο",
|
||||
"The book file is empty": "Το αρχείο του βιβλίου είναι κενό",
|
||||
"Failed to open the book file": "Αποτυχία ανοίγματος του αρχείου βιβλίου"
|
||||
"Failed to open the book file": "Αποτυχία ανοίγματος του αρχείου βιβλίου",
|
||||
"On-Demand Purchase": "Αγορά κατόπιν αιτήματος",
|
||||
"Full Customization": "Πλήρης προσαρμογή",
|
||||
"Lifetime Plan": "Σχέδιο ζωής",
|
||||
"One-Time Payment": "Εφάπαξ πληρωμή",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Κάντε μια εφάπαξ πληρωμή για να απολαύσετε διαρκή πρόσβαση σε συγκεκριμένες δυνατότητες σε όλες τις συσκευές. Αγοράστε συγκεκριμένες δυνατότητες ή υπηρεσίες μόνο όταν τις χρειάζεστε.",
|
||||
"Expand Cloud Sync Storage": "Επέκταση αποθήκευσης συγχρονισμού Cloud",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Επεκτείνετε την αποθήκευση cloud σας για πάντα με μια εφάπαξ αγορά. Κάθε επιπλέον αγορά προσθέτει περισσότερο χώρο.",
|
||||
"Unlock All Customization Options": "Ξεκλειδώστε όλες τις επιλογές προσαρμογής",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Ξεκλειδώστε επιπλέον θέματα, γραμματοσειρές, επιλογές διάταξης και δυνατότητες ανάγνωσης, μεταφραστές, υπηρεσίες αποθήκευσης cloud.",
|
||||
"Purchase Successful!": "Η αγορά ήταν επιτυχής!",
|
||||
"lifetime": "διαρκής",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Σας ευχαριστούμε για την αγορά σας! Η πληρωμή σας έχει επεξεργαστεί με επιτυχία."
|
||||
}
|
||||
|
||||
@@ -627,5 +627,17 @@
|
||||
"TTS Text": "TTS Texto",
|
||||
"The book file is corrupted": "El archivo del libro está dañado",
|
||||
"The book file is empty": "El archivo del libro está vacío",
|
||||
"Failed to open the book file": "Error al abrir el archivo del libro"
|
||||
"Failed to open the book file": "Error al abrir el archivo del libro",
|
||||
"On-Demand Purchase": "Compra bajo demanda",
|
||||
"Full Customization": "Personalización Completa",
|
||||
"Lifetime Plan": "Plan de por vida",
|
||||
"One-Time Payment": "Pago Único",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Realiza un solo pago para disfrutar de acceso de por vida a funciones específicas en todos los dispositivos. Compra funciones o servicios específicos solo cuando los necesites.",
|
||||
"Expand Cloud Sync Storage": "Expandir Almacenamiento de Sincronización en la Nube",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Expande tu almacenamiento en la nube para siempre con una compra única. Cada compra adicional agrega más espacio.",
|
||||
"Unlock All Customization Options": "Desbloquear Todas las Opciones de Personalización",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Desbloquear temas adicionales, fuentes, opciones de diseño y funciones de lectura en voz alta, traductores, servicios de almacenamiento en la nube.",
|
||||
"Purchase Successful!": "¡Compra Exitosa!",
|
||||
"lifetime": "de por vida",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "¡Gracias por tu compra! Tu pago se ha procesado correctamente."
|
||||
}
|
||||
|
||||
@@ -627,5 +627,17 @@
|
||||
"TTS Text": "TTS Texte",
|
||||
"The book file is corrupted": "Le fichier du livre est corrompu",
|
||||
"The book file is empty": "Le fichier du livre est vide",
|
||||
"Failed to open the book file": "Échec de l'ouverture du fichier du livre"
|
||||
"Failed to open the book file": "Échec de l'ouverture du fichier du livre",
|
||||
"On-Demand Purchase": "Achat à la Demande",
|
||||
"Full Customization": "Personnalisation Complète",
|
||||
"Lifetime Plan": "Plan de Vie",
|
||||
"One-Time Payment": "Paiement Unique",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Effectuez un paiement unique pour profiter d'un accès à vie à des fonctionnalités spécifiques sur tous les appareils. Achetez des fonctionnalités ou des services spécifiques uniquement lorsque vous en avez besoin.",
|
||||
"Expand Cloud Sync Storage": "Étendre le Stockage de Synchronisation Cloud",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Étendez votre stockage cloud pour toujours avec un achat unique. Chaque achat supplémentaire ajoute plus d'espace.",
|
||||
"Unlock All Customization Options": "Débloquer Toutes les Options de Personnalisation",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Débloquer des thèmes supplémentaires, des polices, des options de mise en page et des fonctions de lecture à haute voix, des traducteurs, des services de stockage cloud.",
|
||||
"Purchase Successful!": "Achat Réussi!",
|
||||
"lifetime": "à vie",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Merci pour votre achat ! Votre paiement a été traité avec succès."
|
||||
}
|
||||
|
||||
@@ -623,5 +623,17 @@
|
||||
"TTS Text": "TTS टेक्स्ट",
|
||||
"The book file is corrupted": "पुस्तक फ़ाइल भ्रष्ट है।",
|
||||
"The book file is empty": "पुस्तक फ़ाइल खाली है।",
|
||||
"Failed to open the book file": "पुस्तक फ़ाइल खोलने में विफल।"
|
||||
"Failed to open the book file": "पुस्तक फ़ाइल खोलने में विफल।",
|
||||
"On-Demand Purchase": "आवश्यकता के अनुसार खरीदारी",
|
||||
"Full Customization": "पूर्ण अनुकूलन",
|
||||
"Lifetime Plan": "जीवनकाल योजना",
|
||||
"One-Time Payment": "एक बार का भुगतान",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "सभी उपकरणों पर विशिष्ट सुविधाओं तक जीवनकाल पहुंच का आनंद लेने के लिए एक बार का भुगतान करें। जब आपको उनकी आवश्यकता हो, तो केवल विशिष्ट सुविधाओं या सेवाओं को खरीदें।",
|
||||
"Expand Cloud Sync Storage": "क्लाउड सिंक स्टोरेज का विस्तार करें",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "एक बार के खरीदारी के साथ अपने क्लाउड स्टोरेज का विस्तार करें। प्रत्येक अतिरिक्त खरीदारी अधिक स्थान जोड़ती है।",
|
||||
"Unlock All Customization Options": "सभी अनुकूलन विकल्प अनलॉक करें",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "अतिरिक्त थीम, फ़ॉन्ट, लेआउट विकल्प और पढ़ने के लिए, अनुवादक, क्लाउड स्टोरेज सेवाएँ अनलॉक करें।",
|
||||
"Purchase Successful!": "खरीदारी सफल!",
|
||||
"lifetime": "जीवनकाल",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "आपकी खरीदारी के लिए धन्यवाद! आपका भुगतान सफलतापूर्वक संसाधित हो गया है।"
|
||||
}
|
||||
|
||||
@@ -619,5 +619,17 @@
|
||||
"TTS Text": "Teks TTS",
|
||||
"The book file is corrupted": "File buku rusak",
|
||||
"The book file is empty": "File buku kosong",
|
||||
"Failed to open the book file": "Gagal membuka file buku"
|
||||
"Failed to open the book file": "Gagal membuka file buku",
|
||||
"On-Demand Purchase": "Pembelian Sesuai Permintaan",
|
||||
"Full Customization": "Kustomisasi Penuh",
|
||||
"Lifetime Plan": "Rencana Seumur Hidup",
|
||||
"One-Time Payment": "Pembayaran Sekali",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Lakukan pembayaran sekali untuk menikmati akses seumur hidup ke fitur tertentu di semua perangkat. Beli fitur atau layanan tertentu hanya saat Anda membutuhkannya.",
|
||||
"Expand Cloud Sync Storage": "Perluas Penyimpanan Sinkronisasi Cloud",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Perluas penyimpanan cloud Anda selamanya dengan pembelian sekali. Setiap pembelian tambahan menambah lebih banyak ruang.",
|
||||
"Unlock All Customization Options": "Buka Semua Opsi Kustomisasi",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Buka tema tambahan, font, opsi tata letak, dan baca nyaring, penerjemah, layanan penyimpanan cloud.",
|
||||
"Purchase Successful!": "Pembelian Berhasil!",
|
||||
"lifetime": "seumur hidup",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Terima kasih atas pembelian Anda! Pembayaran Anda telah berhasil diproses."
|
||||
}
|
||||
|
||||
@@ -627,5 +627,17 @@
|
||||
"TTS Text": "Testo TTS",
|
||||
"The book file is corrupted": "Il file del libro è danneggiato",
|
||||
"The book file is empty": "Il file del libro è vuoto",
|
||||
"Failed to open the book file": "Impossibile aprire il file del libro"
|
||||
"Failed to open the book file": "Impossibile aprire il file del libro",
|
||||
"On-Demand Purchase": "Acquisto su Richiesta",
|
||||
"Full Customization": "Personalizzazione Completa",
|
||||
"Lifetime Plan": "Piano a Vita",
|
||||
"One-Time Payment": "Pagamento Una Tantum",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Effettua un pagamento una tantum per godere dell'accesso a vita a funzionalità specifiche su tutti i dispositivi. Acquista funzionalità o servizi specifici solo quando ne hai bisogno.",
|
||||
"Expand Cloud Sync Storage": "Espandi Spazio di Archiviazione Cloud",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Espandi il tuo spazio di archiviazione cloud per sempre con un acquisto una tantum. Ogni acquisto aggiuntivo aumenta lo spazio.",
|
||||
"Unlock All Customization Options": "Sblocca Tutte le Opzioni di Personalizzazione",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Sblocca temi aggiuntivi, font, opzioni di layout e lettura ad alta voce, traduttori, servizi di archiviazione cloud.",
|
||||
"Purchase Successful!": "Acquisto Riuscito!",
|
||||
"lifetime": "a vita",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Grazie per il tuo acquisto! Il pagamento è stato elaborato con successo."
|
||||
}
|
||||
|
||||
@@ -619,5 +619,17 @@
|
||||
"TTS Text": "TTSテキスト",
|
||||
"The book file is corrupted": "本のファイルが破損しています",
|
||||
"The book file is empty": "本のファイルが空です",
|
||||
"Failed to open the book file": "本のファイルを開くことができませんでした"
|
||||
"Failed to open the book file": "本のファイルを開くことができませんでした",
|
||||
"On-Demand Purchase": "オンデマンド購入",
|
||||
"Full Customization": "フルカスタマイズ",
|
||||
"Lifetime Plan": "ライフタイムプラン",
|
||||
"One-Time Payment": "ワンタイムペイメント",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "すべてのデバイスで特定の機能にライフタイムアクセスを楽しむために、1回の支払いを行います。必要なときにのみ特定の機能やサービスを購入します。",
|
||||
"Expand Cloud Sync Storage": "クラウド同期ストレージを拡張",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "1回の購入でクラウドストレージを永遠に拡張します。追加の購入ごとに、さらに多くのスペースが追加されます。",
|
||||
"Unlock All Customization Options": "すべてのカスタマイズオプションを解除",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "追加のテーマ、フォント、レイアウトオプション、読み上げ、翻訳者、クラウドストレージサービスを解除します。",
|
||||
"Purchase Successful!": "購入成功!",
|
||||
"lifetime": "ライフタイム",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "ご購入ありがとうございます!お支払いが正常に処理されました。"
|
||||
}
|
||||
|
||||
@@ -619,5 +619,17 @@
|
||||
"TTS Text": "TTS 텍스트",
|
||||
"The book file is corrupted": "책 파일이 손상되었습니다",
|
||||
"The book file is empty": "책 파일이 비어 있습니다",
|
||||
"Failed to open the book file": "책 파일을 열지 못했습니다"
|
||||
"Failed to open the book file": "책 파일을 열지 못했습니다",
|
||||
"On-Demand Purchase": "온디맨드 구매",
|
||||
"Full Customization": "풀 커스터마이징",
|
||||
"Lifetime Plan": "평생 플랜",
|
||||
"One-Time Payment": "일회성 결제",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "모든 장치에서 특정 기능에 대한 평생 액세스를 즐기기 위해 단일 결제를 수행합니다. 필요할 때만 특정 기능이나 서비스를 구매합니다.",
|
||||
"Expand Cloud Sync Storage": "클라우드 동기화 저장소 확장",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "1회 구매로 클라우드 저장소를 영구적으로 확장합니다. 추가 구매 시 더 많은 공간이 추가됩니다.",
|
||||
"Unlock All Customization Options": "모든 커스터마이징 옵션 잠금 해제",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "추가 테마, 글꼴, 레이아웃 옵션 및 읽기, 번역기, 클라우드 스토리지 서비스를 잠금 해제합니다.",
|
||||
"Purchase Successful!": "구매 성공!",
|
||||
"lifetime": "평생",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "구매해주셔서 감사합니다! 결제가 성공적으로 처리되었습니다."
|
||||
}
|
||||
|
||||
@@ -623,5 +623,17 @@
|
||||
"TTS Text": "TTS Tekst",
|
||||
"The book file is corrupted": "Het boekbestand is beschadigd",
|
||||
"The book file is empty": "Het boekbestand is leeg",
|
||||
"Failed to open the book file": "Het openen van het boekbestand is mislukt"
|
||||
"Failed to open the book file": "Het openen van het boekbestand is mislukt",
|
||||
"On-Demand Purchase": "On-Demand Aankoop",
|
||||
"Full Customization": "Volledige Aanpassing",
|
||||
"Lifetime Plan": "Levenslange Abonnement",
|
||||
"One-Time Payment": "Eenmalige Betaling",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Doe een eenmalige betaling om levenslange toegang te krijgen tot specifieke functies op alle apparaten. Koop specifieke functies of diensten alleen wanneer je ze nodig hebt.",
|
||||
"Expand Cloud Sync Storage": "Cloud Sync Opslag Uitbreiden",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Breid je cloudopslag voor altijd uit met een eenmalige aankoop. Elke extra aankoop voegt meer ruimte toe.",
|
||||
"Unlock All Customization Options": "Ontgrendel Alle Aanpassingsopties",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Ontgrendel extra thema's, lettertypen, lay-outopties en voorlezen, vertalers, cloudopslagdiensten.",
|
||||
"Purchase Successful!": "Aankoop Succesvol!",
|
||||
"lifetime": "levenslang",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Bedankt voor je aankoop! Je betaling is succesvol verwerkt."
|
||||
}
|
||||
|
||||
@@ -631,5 +631,17 @@
|
||||
"TTS Text": "Tekst TTS",
|
||||
"The book file is corrupted": "Plik książki jest uszkodzony",
|
||||
"The book file is empty": "Plik książki jest pusty",
|
||||
"Failed to open the book file": "Nie udało się otworzyć pliku książki"
|
||||
"Failed to open the book file": "Nie udało się otworzyć pliku książki",
|
||||
"On-Demand Purchase": "Zakup na żądanie",
|
||||
"Full Customization": "Pełna Personalizacja",
|
||||
"Lifetime Plan": "Plan na Całe Życie",
|
||||
"One-Time Payment": "Jednorazowa Płatność",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Dokonaj jednorazowej płatności, aby cieszyć się dożywotnim dostępem do określonych funkcji na wszystkich urządzeniach. Kupuj konkretne funkcje lub usługi tylko wtedy, gdy ich potrzebujesz.",
|
||||
"Expand Cloud Sync Storage": "Rozszerz Przechowywanie w Chmurze",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Rozszerz swoją przestrzeń w chmurze na zawsze dzięki jednorazowemu zakupowi. Każdy dodatkowy zakup dodaje więcej miejsca.",
|
||||
"Unlock All Customization Options": "Odblokuj Wszystkie Opcje Personalizacji",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Odblokuj dodatkowe motywy, czcionki, opcje układu oraz funkcje czytania na głos, tłumacze, usługi przechowywania w chmurze.",
|
||||
"Purchase Successful!": "Zakup Udany!",
|
||||
"lifetime": "dożywotnio",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Dziękujemy za zakup! Twoja płatność została pomyślnie przetworzona."
|
||||
}
|
||||
|
||||
@@ -627,5 +627,17 @@
|
||||
"TTS Text": "TTS Texto",
|
||||
"The book file is corrupted": "O arquivo do livro está corrompido",
|
||||
"The book file is empty": "O arquivo do livro está vazio",
|
||||
"Failed to open the book file": "Falha ao abrir o arquivo do livro"
|
||||
"Failed to open the book file": "Falha ao abrir o arquivo do livro",
|
||||
"On-Demand Purchase": "Compra sob demanda",
|
||||
"Full Customization": "Personalização Completa",
|
||||
"Lifetime Plan": "Plano Vitalício",
|
||||
"One-Time Payment": "Pagamento Único",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Faça um único pagamento para desfrutar de acesso vitalício a recursos específicos em todos os dispositivos. Compre recursos ou serviços específicos apenas quando precisar deles.",
|
||||
"Expand Cloud Sync Storage": "Expandir Armazenamento de Sincronização em Nuvem",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Expanda seu armazenamento em nuvem para sempre com uma compra única. Cada compra adicional adiciona mais espaço.",
|
||||
"Unlock All Customization Options": "Desbloquear Todas as Opções de Personalização",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Desbloquear temas adicionais, fontes, opções de layout e leitura em voz alta, tradutores, serviços de armazenamento em nuvem.",
|
||||
"Purchase Successful!": "Compra Bem-Sucedida!",
|
||||
"lifetime": "vitalício",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Obrigado pela sua compra! Seu pagamento foi processado com sucesso."
|
||||
}
|
||||
|
||||
@@ -631,5 +631,17 @@
|
||||
"TTS Text": "TTS Текст",
|
||||
"The book file is corrupted": "Файл книги поврежден",
|
||||
"The book file is empty": "Файл книги пуст",
|
||||
"Failed to open the book file": "Не удалось открыть файл книги"
|
||||
"Failed to open the book file": "Не удалось открыть файл книги",
|
||||
"On-Demand Purchase": "Покупка по запросу",
|
||||
"Full Customization": "Полная настройка",
|
||||
"Lifetime Plan": "Пожизненный план",
|
||||
"One-Time Payment": "Единовременный платеж",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Сделайте единовременный платеж, чтобы получить пожизненный доступ к определенным функциям на всех устройствах. Покупайте конкретные функции или услуги только тогда, когда они вам нужны.",
|
||||
"Expand Cloud Sync Storage": "Расширить облачное хранилище",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Расширьте свое облачное хранилище навсегда с помощью единовременной покупки. Каждая дополнительная покупка добавляет больше места.",
|
||||
"Unlock All Customization Options": "Разблокировать Все Опции Настройки",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Разблокировать дополнительные темы, шрифты, параметры макета и чтения вслух, переводчики, услуги облачного хранилища.",
|
||||
"Purchase Successful!": "Покупка Успешна!",
|
||||
"lifetime": "пожизненный",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Спасибо за вашу покупку! Ваш платеж был успешно обработан."
|
||||
}
|
||||
|
||||
@@ -623,5 +623,17 @@
|
||||
"TTS Text": "TTS පෙළ",
|
||||
"The book file is corrupted": "පොතේ ගොනුව අසමත් වී ඇත",
|
||||
"The book file is empty": "පොතේ ගොනුව හිස් වේ",
|
||||
"Failed to open the book file": "පොතේ ගොනුව විවෘත කිරීමට අසමත් විය"
|
||||
"Failed to open the book file": "පොතේ ගොනුව විවෘත කිරීමට අසමත් විය",
|
||||
"On-Demand Purchase": "අවශ්යතාවය මත මිලදී ගැනීම",
|
||||
"Full Customization": "සම්පූර්ණ අභිරුචි",
|
||||
"Lifetime Plan": "පොදුකාලීන සැලැස්ම",
|
||||
"One-Time Payment": "එක්වර ගෙවීම",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "සියලු උපකරණ සඳහා විශේෂිත විශේෂාංග සඳහා පොදුකාලීන ප්රවේශය ලබා ගැනීමට එක්වර ගෙවීමක් කරන්න. ඔබට අවශ්ය වන විශේෂිත විශේෂාංග හෝ සේවා මිලදී ගන්න.",
|
||||
"Expand Cloud Sync Storage": "කලාප සමාන්ය ගබඩා විශාල කරන්න",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "එක්වර මිලදී ගැනීමක් මඟින් ඔබගේ කලාප ගබඩාව සදාකාලිකව විශාල කරන්න. එක් එක් අමතර මිලදී ගැනීමක් තවත් ස්ථානයක් එක් කරයි.",
|
||||
"Unlock All Customization Options": "සියලු අභිරුචි විකල්ප විවෘත කරන්න",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "අමතර තේමාවන්, ෆොන්ට්, ආකෘති විකල්ප සහ පවසා කියවීම, පරිවර්තක, කලාප ගබඩා සේවා විවෘත කරන්න.",
|
||||
"Purchase Successful!": "මිලදී ගැනීම සාර්ථකයි!",
|
||||
"lifetime": "පොදුකාලීන",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "ඔබේ මිලදී ගැනීමට ස්තූතියි! ඔබේ ගෙවීම සාර්ථකව සැකසිණි."
|
||||
}
|
||||
|
||||
@@ -623,5 +623,17 @@
|
||||
"TTS Text": "TTS-text",
|
||||
"The book file is corrupted": "Bokfilen är skadad",
|
||||
"The book file is empty": "Bokfilen är tom",
|
||||
"Failed to open the book file": "Kunde inte öppna bokfilen"
|
||||
"Failed to open the book file": "Kunde inte öppna bokfilen",
|
||||
"On-Demand Purchase": "Köp på begäran",
|
||||
"Full Customization": "Full anpassning",
|
||||
"Lifetime Plan": "Livstidsplan",
|
||||
"One-Time Payment": "Engångsbetalning",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Gör en engångsbetalning för att få livstidsåtkomst till specifika funktioner på alla enheter. Köp specifika funktioner eller tjänster endast när du behöver dem.",
|
||||
"Expand Cloud Sync Storage": "Utöka molnsynkroniseringslagring",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Utöka din molnlagring för alltid med ett engångsköp. Varje ytterligare köp lägger till mer utrymme.",
|
||||
"Unlock All Customization Options": "Lås upp alla anpassningsalternativ",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Lås upp ytterligare teman, typsnitt, layoutalternativ och uppläsning, översättare, molnlagringstjänster.",
|
||||
"Purchase Successful!": "Köp lyckades!",
|
||||
"lifetime": "livstid",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Tack för ditt köp! Din betalning har behandlats."
|
||||
}
|
||||
|
||||
@@ -623,5 +623,17 @@
|
||||
"TTS Text": "TTS உரை",
|
||||
"The book file is corrupted": "பொதுவாக, புத்தகக் கோப்பு சேதமடைந்துள்ளது",
|
||||
"The book file is empty": "புத்தகக் கோப்பு காலியாக உள்ளது",
|
||||
"Failed to open the book file": "புத்தகக் கோப்பைத் திறக்க முடியவில்லை"
|
||||
"Failed to open the book file": "புத்தகக் கோப்பைத் திறக்க முடியவில்லை",
|
||||
"On-Demand Purchase": "தேவைப்படும் போது வாங்குதல்",
|
||||
"Full Customization": "முழு தனிப்பயனாக்கம்",
|
||||
"Lifetime Plan": "ஆயுள்திருத்தம்",
|
||||
"One-Time Payment": "ஒரே முறை கட்டணம்",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "எல்லா சாதனங்களிலும் குறிப்பிட்ட அம்சங்களுக்கு ஆயுள்திருத்தம் பெற ஒரே முறை கட்டணம் செலுத்தவும். நீங்கள் தேவைப்படும் போது மட்டுமே குறிப்பிட்ட அம்சங்கள் அல்லது சேவைகளை வாங்கவும்.",
|
||||
"Expand Cloud Sync Storage": "மோலச் சின்க் சேமிப்பிடத்தை விரிவாக்கவும்",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "ஒரே முறை வாங்குதலுடன் உங்கள் மேக சேமிப்பிடத்தை எப்போதும் விரிவாக்கவும். ஒவ்வொரு கூடுதல் வாங்குதலும் மேலும் இடத்தைச் சேர்க்கிறது.",
|
||||
"Unlock All Customization Options": "அனைத்து தனிப்பயனாக்க விருப்பங்களைத் திறக்கவும்",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "கூடுதல் தீமைகள், எழுத்துருக்கள், வடிவமைப்பு விருப்பங்கள் மற்றும் வாசிக்க, மொழிபெயர்க்க, மேக சேமிப்பு சேவைகளைத் திறக்கவும்.",
|
||||
"Purchase Successful!": "கொள்முதல் வெற்றிகரமாக முடிந்தது!",
|
||||
"lifetime": "ஆயுள்திருத்தம்",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "உங்கள் வாங்குதலுக்கு நன்றி! உங்கள் கட்டணம் வெற்றிகரமாக செயல்படுத்தப்பட்டது."
|
||||
}
|
||||
|
||||
@@ -619,5 +619,17 @@
|
||||
"TTS Text": "ข้อความ TTS",
|
||||
"The book file is corrupted": "ไฟล์หนังสือเสียหาย",
|
||||
"The book file is empty": "ไฟล์หนังสือว่างเปล่า",
|
||||
"Failed to open the book file": "ไม่สามารถเปิดไฟล์หนังสือได้"
|
||||
"Failed to open the book file": "ไม่สามารถเปิดไฟล์หนังสือได้",
|
||||
"On-Demand Purchase": "การซื้อเมื่อจำเป็น",
|
||||
"Full Customization": "การปรับแต่งเต็มรูปแบบ",
|
||||
"Lifetime Plan": "แผนการใช้งานตลอดชีพ",
|
||||
"One-Time Payment": "การชำระเงินครั้งเดียว",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "ทำการชำระเงินครั้งเดียวเพื่อเพลิดเพลินกับการเข้าถึงฟีเจอร์เฉพาะตลอดชีพบนอุปกรณ์ทั้งหมด ซื้อฟีเจอร์หรือบริการเฉพาะเมื่อคุณต้องการเท่านั้น",
|
||||
"Expand Cloud Sync Storage": "ขยายพื้นที่เก็บข้อมูลซิงค์คลาวด์",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "ขยายพื้นที่เก็บข้อมูลซิงค์คลาวด์ของคุณตลอดไปด้วยการซื้อครั้งเดียว การซื้อเพิ่มเติมแต่ละครั้งจะเพิ่มพื้นที่มากขึ้น",
|
||||
"Unlock All Customization Options": "ปลดล็อกตัวเลือกการปรับแต่งทั้งหมด",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "ปลดล็อกธีมเพิ่มเติม ฟอนต์ ตัวเลือกการจัดรูปแบบ และฟีเจอร์การอ่านออกเสียง ตัวแปล และบริการพื้นที่เก็บข้อมูลคลาวด์",
|
||||
"Purchase Successful!": "การซื้อสำเร็จ!",
|
||||
"lifetime": "ตลอดชีพ",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "ขอบคุณสำหรับการซื้อ! การชำระเงินของคุณได้รับการประมวลผลเรียบร้อยแล้ว"
|
||||
}
|
||||
|
||||
@@ -623,5 +623,17 @@
|
||||
"TTS Text": "TTS Metni",
|
||||
"The book file is corrupted": "Kitap dosyası bozulmuş",
|
||||
"The book file is empty": "Kitap dosyası boş",
|
||||
"Failed to open the book file": "Kitap dosyası açılamadı"
|
||||
"Failed to open the book file": "Kitap dosyası açılamadı",
|
||||
"On-Demand Purchase": "İhtiyaç Duyulduğunda Satın Alma",
|
||||
"Full Customization": "Tam Özelleştirme",
|
||||
"Lifetime Plan": "Ömür Boyu Plan",
|
||||
"One-Time Payment": "Tek Seferlik Ödeme",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Tüm cihazlarda belirli özelliklere ömür boyu erişim sağlamak için tek seferlik bir ödeme yapın. Belirli özellikleri veya hizmetleri yalnızca ihtiyaç duyduğunuzda satın alın.",
|
||||
"Expand Cloud Sync Storage": "Bulut Senkronizasyonu Depolama Alanını Genişlet",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Tek seferlik bir satın alma ile bulut depolama alanınızı sonsuza kadar genişletin. Her ek satın alma daha fazla alan ekler.",
|
||||
"Unlock All Customization Options": "Tüm Özelleştirme Seçeneklerini Aç",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Ek tema, yazı tipi, düzen seçeneklerini ve sesli okuma, çevirmenler, bulut depolama hizmetlerini açın.",
|
||||
"Purchase Successful!": "Satın Alma Başarılı!",
|
||||
"lifetime": "ömür boyu",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Satın alma işleminiz için teşekkürler! Ödemeniz başarıyla işlendi."
|
||||
}
|
||||
|
||||
@@ -631,5 +631,17 @@
|
||||
"TTS Text": "Текст TTS",
|
||||
"The book file is corrupted": "Файл книги пошкоджено",
|
||||
"The book file is empty": "Файл книги порожній",
|
||||
"Failed to open the book file": "Не вдалося відкрити файл книги"
|
||||
"Failed to open the book file": "Не вдалося відкрити файл книги",
|
||||
"On-Demand Purchase": "Покупка за запитом",
|
||||
"Full Customization": "Повна налаштування",
|
||||
"Lifetime Plan": "План на все життя",
|
||||
"One-Time Payment": "Одноразовий платіж",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Зробіть одноразовий платіж, щоб отримати довічний доступ до певних функцій на всіх пристроях. Купуйте конкретні функції або послуги лише тоді, коли вони вам потрібні.",
|
||||
"Expand Cloud Sync Storage": "Розширити сховище синхронізації в хмарі",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Розширте своє хмарне сховище назавжди за допомогою одноразової покупки. Кожна додаткова покупка додає більше місця.",
|
||||
"Unlock All Customization Options": "Відкрити всі параметри налаштування",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Відкрийте додаткові теми, шрифти, параметри макета та функції читання вголос, перекладачі, послуги хмарного зберігання.",
|
||||
"Purchase Successful!": "Покупка успішна!",
|
||||
"lifetime": "довічний",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Дякуємо за вашу покупку! Ваш платіж успішно оброблено."
|
||||
}
|
||||
|
||||
@@ -619,5 +619,17 @@
|
||||
"TTS Text": "Văn bản TTS",
|
||||
"The book file is corrupted": "Tệp sách bị hỏng",
|
||||
"The book file is empty": "Tệp sách trống",
|
||||
"Failed to open the book file": "Không thể mở tệp sách"
|
||||
"Failed to open the book file": "Không thể mở tệp sách",
|
||||
"On-Demand Purchase": "Mua theo yêu cầu",
|
||||
"Full Customization": "Tùy chỉnh đầy đủ",
|
||||
"Lifetime Plan": "Kế hoạch trọn đời",
|
||||
"One-Time Payment": "Thanh toán một lần",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Thực hiện một khoản thanh toán duy nhất để tận hưởng quyền truy cập trọn đời vào các tính năng cụ thể trên tất cả các thiết bị. Mua các tính năng hoặc dịch vụ cụ thể chỉ khi bạn cần chúng.",
|
||||
"Expand Cloud Sync Storage": "Mở rộng dung lượng lưu trữ đồng bộ hóa đám mây",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Mở rộng dung lượng lưu trữ đám mây của bạn mãi mãi với một lần mua. Mỗi lần mua thêm sẽ thêm không gian.",
|
||||
"Unlock All Customization Options": "Mở khóa tất cả tùy chọn tùy chỉnh",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Mở khóa các chủ đề, phông chữ, tùy chọn bố cục và đọc to, dịch giả, dịch vụ lưu trữ đám mây bổ sung.",
|
||||
"Purchase Successful!": "Mua hàng thành công!",
|
||||
"lifetime": "trọn đời",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Cảm ơn bạn đã mua hàng! Thanh toán của bạn đã được xử lý thành công."
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
"Show Book Details": "显示书籍详情",
|
||||
"Sidebar": "侧边栏",
|
||||
"Sign In": "登录",
|
||||
"Sign Out": "登出",
|
||||
"Sign Out": "登出账户",
|
||||
"Sky": "天青",
|
||||
"Slow": "慢",
|
||||
"Solarized": "日晖",
|
||||
@@ -393,7 +393,7 @@
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "已达每日翻译配额。升级套餐以继续使用 AI 翻译。",
|
||||
"Includes All Plus Plan Benefits": "包含所有 Plus 套餐功能",
|
||||
"Early Feature Access": "抢先体验新功能",
|
||||
"Be the first to explore new features, updates, and innovations before anyone else.": "抢先体验新功能、更新和创新。",
|
||||
"Be the first to explore new features, updates, and innovations before anyone else.": "抢先体验最新功能、更新与创新成果。",
|
||||
"Advanced AI Tools": "高级 AI 工具",
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "利用强大的 AI 工具实现智能阅读、翻译和内容发现。",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "使用最精准的翻译引擎每天可翻译10万字。",
|
||||
@@ -619,5 +619,17 @@
|
||||
"TTS Text": "TTS 文本",
|
||||
"The book file is corrupted": "书籍文件已损坏",
|
||||
"The book file is empty": "书籍文件为空",
|
||||
"Failed to open the book file": "无法打开书籍文件"
|
||||
"Failed to open the book file": "无法打开书籍文件",
|
||||
"On-Demand Purchase": "按需购买",
|
||||
"Full Customization": "完全自定义",
|
||||
"Lifetime Plan": "终身计划",
|
||||
"One-Time Payment": "一次性付款",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "进行一次性付款以享受在所有设备上对特定功能的终身访问。仅在需要时购买特定功能或服务。",
|
||||
"Expand Cloud Sync Storage": "扩展云同步存储",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "通过一次性购买,永久扩展您的云存储。每次额外购买都会增加更多空间。",
|
||||
"Unlock All Customization Options": "解锁所有自定义选项",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "解锁其他主题、字体、布局选项以及朗读、翻译、云存储服务。",
|
||||
"Purchase Successful!": "购买成功!",
|
||||
"lifetime": "终身",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "感谢您的购买!您的付款已成功处理。"
|
||||
}
|
||||
|
||||
@@ -393,7 +393,7 @@
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "已達每日翻譯配額。升級方案以繼續使用 AI 翻譯。",
|
||||
"Includes All Plus Plan Benefits": "包含所有 Plus 方案優惠",
|
||||
"Early Feature Access": "搶先體驗新功能",
|
||||
"Be the first to explore new features, updates, and innovations before anyone else.": "搶先體驗新功能、更新和創新。",
|
||||
"Be the first to explore new features, updates, and innovations before anyone else.": "搶先體驗最新功能、更新與創新成果。",
|
||||
"Advanced AI Tools": "進階 AI 工具",
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "運用強大的 AI 工具實現智慧閱讀、翻譯和內容探索。",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "使用最精準的翻譯引擎每天翻譯多達10萬字元。",
|
||||
@@ -619,5 +619,17 @@
|
||||
"TTS Text": "TTS 文字",
|
||||
"The book file is corrupted": "書籍文件已損壞",
|
||||
"The book file is empty": "書籍文件為空",
|
||||
"Failed to open the book file": "無法打開書籍文件"
|
||||
"Failed to open the book file": "無法打開書籍文件",
|
||||
"On-Demand Purchase": "按需購買",
|
||||
"Full Customization": "完全自定義",
|
||||
"Lifetime Plan": "終身計劃",
|
||||
"One-Time Payment": "一次性付款",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "進行一次性付款以享受在所有設備上對特定功能的終身訪問。僅在需要時購買特定功能或服務。",
|
||||
"Expand Cloud Sync Storage": "擴展雲同步存儲",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "通過一次性購買,永久擴展您的雲存儲。每次額外購買都會增加更多空間。",
|
||||
"Unlock All Customization Options": "解鎖所有自定義選項",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "解鎖其他主題、字體、佈局選項以及朗讀、翻譯、雲存儲服務。",
|
||||
"Purchase Successful!": "購買成功!",
|
||||
"lifetime": "終身",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "感謝您的購買!您的付款已成功處理。"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { AppleIAPVerifier, createAppleIAPVerifier } from '@/libs/iap/apple/verifier';
|
||||
import { AppleIAPVerifier, createAppleIAPVerifier } from '@/libs/payment/apple/verifier';
|
||||
|
||||
const SKIP_IAP_API_TESTS = !process.env['ENABLE_IAP_API_TESTS'];
|
||||
const REAL_TEST_DATA = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAppleIAPVerifier } from '@/libs/iap/apple/verifier';
|
||||
import { getAppleIAPVerifier } from '@/libs/payment/apple/verifier';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
import { IAPError } from '@/types/error';
|
||||
@@ -115,7 +115,7 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
}
|
||||
const { transactionId, originalTransactionId } = validatedInput!;
|
||||
const { originalTransactionId } = validatedInput!;
|
||||
|
||||
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
|
||||
if (!user || !token) {
|
||||
@@ -138,36 +138,6 @@ export async function POST(request: Request) {
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (
|
||||
existingSubscription &&
|
||||
existingSubscription.transactionId === transactionId &&
|
||||
existingSubscription.status === 'active'
|
||||
) {
|
||||
console.log('Transaction already verified and active');
|
||||
|
||||
const purchase = {
|
||||
status: existingSubscription.status,
|
||||
customerEmail: user.email,
|
||||
subscriptionId:
|
||||
existingSubscription.web_order_line_item_id ||
|
||||
existingSubscription.original_transaction_id,
|
||||
planName: getProductName(existingSubscription.product_id),
|
||||
productId: existingSubscription.product_id,
|
||||
platform: existingSubscription.platform,
|
||||
transactionId: existingSubscription.transaction_id,
|
||||
originalTransactionId: existingSubscription.original_transaction_id,
|
||||
purchaseDate: existingSubscription.purchase_date,
|
||||
expiresDate: existingSubscription.expires_date,
|
||||
quantity: existingSubscription.quantity,
|
||||
environment: existingSubscription.environment,
|
||||
bundleId: existingSubscription.bundle_id,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
purchase,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultIAPVerifier = getAppleIAPVerifier();
|
||||
const verificationResult = await defaultIAPVerifier.verifyTransaction(originalTransactionId);
|
||||
@@ -197,6 +167,7 @@ export async function POST(request: Request) {
|
||||
customerEmail: user.email!,
|
||||
subscriptionId: transaction.webOrderLineItemId || transaction.originalTransactionId,
|
||||
planName: getProductName(transaction.productId),
|
||||
planType: verificationResult.planType,
|
||||
productId: transaction.productId,
|
||||
platform: 'ios',
|
||||
transactionId: transaction.transactionId,
|
||||
|
||||
@@ -4,10 +4,11 @@ import {
|
||||
getGoogleIAPVerifier,
|
||||
ProductPurchase,
|
||||
SubscriptionPurchase,
|
||||
} from '@/libs/iap/google/verifier';
|
||||
} from '@/libs/payment/google/verifier';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
import { IAPError } from '@/types/error';
|
||||
import { PlanType } from '@/types/quota';
|
||||
|
||||
const iapVerificationSchema = z.object({
|
||||
packageName: z.string().min(1, 'Package name is required'),
|
||||
@@ -41,6 +42,7 @@ interface Purchase {
|
||||
customerEmail: string;
|
||||
subscriptionId: string;
|
||||
planName: string;
|
||||
planType: PlanType;
|
||||
productId: string;
|
||||
platform: string;
|
||||
purchaseToken: string;
|
||||
@@ -137,7 +139,7 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
}
|
||||
const { purchaseToken, productId, packageName, orderId } = validatedInput!;
|
||||
const { purchaseToken, productId, packageName } = validatedInput!;
|
||||
|
||||
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
|
||||
if (!user || !token) {
|
||||
@@ -148,11 +150,11 @@ export async function POST(request: Request) {
|
||||
const supabase = createSupabaseAdminClient();
|
||||
|
||||
// Check if this purchase already exists
|
||||
if (orderId) {
|
||||
if (purchaseToken) {
|
||||
const { data: existingSubscription } = await supabase
|
||||
.from('google_iap_subscriptions')
|
||||
.select('*')
|
||||
.eq('order_id', orderId)
|
||||
.eq('purchase_token', purchaseToken)
|
||||
.single();
|
||||
|
||||
console.log('Existing subscription:', existingSubscription);
|
||||
@@ -164,35 +166,6 @@ export async function POST(request: Request) {
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
existingSubscription &&
|
||||
existingSubscription.purchase_token === purchaseToken &&
|
||||
existingSubscription.status === 'active'
|
||||
) {
|
||||
console.log('Transaction already verified and active');
|
||||
|
||||
const purchase = {
|
||||
status: existingSubscription.status,
|
||||
customerEmail: user.email,
|
||||
subscriptionId: existingSubscription.order_id,
|
||||
planName: getProductName(existingSubscription.product_id),
|
||||
productId: existingSubscription.product_id,
|
||||
platform: existingSubscription.platform,
|
||||
purchaseToken: existingSubscription.purchase_token,
|
||||
orderId: existingSubscription.order_id,
|
||||
purchaseDate: existingSubscription.purchase_date,
|
||||
expiresDate: existingSubscription.expires_date,
|
||||
quantity: existingSubscription.quantity,
|
||||
environment: existingSubscription.environment,
|
||||
packageName: existingSubscription.package_name,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
purchase,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const googleIAPVerifier = getGoogleIAPVerifier();
|
||||
@@ -236,6 +209,7 @@ export async function POST(request: Request) {
|
||||
customerEmail: user.email!,
|
||||
subscriptionId: subData.orderId || purchaseToken,
|
||||
planName: getProductName(productId),
|
||||
planType: 'subscription',
|
||||
productId: productId,
|
||||
platform: 'android',
|
||||
purchaseToken: purchaseToken,
|
||||
@@ -265,6 +239,7 @@ export async function POST(request: Request) {
|
||||
customerEmail: user.email!,
|
||||
subscriptionId: prodData.orderId || purchaseToken,
|
||||
planName: getProductName(productId),
|
||||
planType: 'purchase',
|
||||
productId: productId,
|
||||
platform: 'android',
|
||||
purchaseToken: purchaseToken,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import Stripe from 'stripe';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getStripe } from '@/libs/stripe/server';
|
||||
import { createOrUpdateSubscription } from '@/utils/stripe';
|
||||
import {
|
||||
getStripe,
|
||||
createOrUpdatePayment,
|
||||
createOrUpdateSubscription,
|
||||
} from '@/libs/payment/stripe/server';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -16,10 +19,11 @@ export async function POST(request: Request) {
|
||||
const stripe = getStripe();
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
|
||||
if (session.payment_status === 'paid') {
|
||||
const customerId = session.customer as string;
|
||||
const subscriptionId = session.subscription as string;
|
||||
await createOrUpdateSubscription(user.id, customerId, subscriptionId);
|
||||
const customerId = session.customer as string;
|
||||
if (session.payment_status === 'paid' && session.subscription) {
|
||||
await createOrUpdateSubscription(user.id, customerId, session.subscription as string);
|
||||
} else if (session.payment_status === 'paid' && session.payment_intent) {
|
||||
await createOrUpdatePayment(user.id, customerId, sessionId);
|
||||
}
|
||||
|
||||
return NextResponse.json({ session });
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStripe } from '@/libs/stripe/server';
|
||||
import { getStripe } from '@/libs/payment/stripe/server';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { priceId, embedded = true, metadata = {} } = await request.json();
|
||||
const {
|
||||
priceId,
|
||||
planType = 'subscription',
|
||||
embedded = true,
|
||||
metadata = {},
|
||||
} = await request.json();
|
||||
|
||||
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
|
||||
if (!user || !token) {
|
||||
@@ -43,13 +48,12 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const stripe = getStripe();
|
||||
const successUrl = `${request.headers.get('origin')}/user/subscription/success?session_id={CHECKOUT_SESSION_ID}`;
|
||||
const successUrl = `${request.headers.get('origin')}/user/subscription/success?payment=stripe&session_id={CHECKOUT_SESSION_ID}`;
|
||||
const returnUrl = `${request.headers.get('origin')}/user`;
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
ui_mode: embedded ? 'embedded' : 'hosted',
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
mode: planType === 'subscription' ? 'subscription' : 'payment',
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Stripe from 'stripe';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getStripe } from '@/libs/stripe/server';
|
||||
import { UserPlan } from '@/types/user';
|
||||
import { getStripe } from '@/libs/payment/stripe/server';
|
||||
import { StripeProductMetadata } from '@/types/payment';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -9,7 +9,6 @@ export async function GET() {
|
||||
const prices = await stripe.prices.list({
|
||||
expand: ['data.product'],
|
||||
active: true,
|
||||
type: 'recurring',
|
||||
});
|
||||
|
||||
const plans = prices.data
|
||||
@@ -19,16 +18,18 @@ export async function GET() {
|
||||
})
|
||||
.map((price) => {
|
||||
const product = price.product as Stripe.Product & {
|
||||
metadata: { plan: UserPlan };
|
||||
metadata: StripeProductMetadata;
|
||||
};
|
||||
return {
|
||||
plan: product.metadata.plan,
|
||||
price_id: price.id,
|
||||
productId: price.id,
|
||||
price: price.unit_amount,
|
||||
currency: price.currency,
|
||||
interval: price.recurring?.interval,
|
||||
product: price.product,
|
||||
productName: product.name,
|
||||
metadata: product.metadata,
|
||||
price_id: price.id, // deprecated
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStripe } from '@/libs/stripe/server';
|
||||
import { getStripe } from '@/libs/payment/stripe/server';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import Stripe from 'stripe';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStripe } from '@/libs/stripe/server';
|
||||
import { createOrUpdateSubscription } from '@/utils/stripe';
|
||||
import {
|
||||
getStripe,
|
||||
createOrUpdateSubscription,
|
||||
createOrUpdatePayment,
|
||||
} from '@/libs/payment/stripe/server';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -72,17 +75,9 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
async function handleSuccessfulPayment(session: Stripe.Checkout.Session, userId: string) {
|
||||
const supabase = createSupabaseAdminClient();
|
||||
await supabase.from('payments').insert({
|
||||
user_id: userId,
|
||||
stripe_checkout_id: session.id,
|
||||
amount: session.amount_total,
|
||||
currency: session.currency,
|
||||
status: 'completed',
|
||||
payment_intent: session.payment_intent,
|
||||
payment_method: session.payment_method_types?.[0] || 'unknown',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
const customerId = session.customer as string;
|
||||
|
||||
await createOrUpdatePayment(userId, customerId, session.id);
|
||||
}
|
||||
|
||||
async function handleSuccessfulSubscription(session: Stripe.Checkout.Session, userId: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { UserPlan } from '@/types/user';
|
||||
import { UserPlan } from '@/types/quota';
|
||||
|
||||
interface DeleteConfirmationModalProps {
|
||||
show: boolean;
|
||||
@@ -84,7 +84,7 @@ const AccountActions: React.FC<AccountActionsProps> = ({
|
||||
setShowConfirmDelete(false);
|
||||
}}
|
||||
/>
|
||||
<div className='flex flex-col gap-4 md:flex-row'>
|
||||
<div className='flex flex-col gap-4 md:flex-row md:justify-center'>
|
||||
{appService?.hasIAP ? (
|
||||
<button
|
||||
onClick={onRestorePurchase}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback } from 'react';
|
||||
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
||||
import { getStripe } from '@/libs/stripe/client';
|
||||
import { getStripe } from '@/libs/payment/stripe/client';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
interface CheckoutProps {
|
||||
|
||||
@@ -33,7 +33,7 @@ const PlanActionButton: React.FC<PlanActionButtonProps> = ({
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSubscribe(plan.price_id)}
|
||||
onClick={() => onSubscribe(plan.productId)}
|
||||
className='w-full rounded-lg bg-blue-600 px-6 py-3 font-semibold text-white transition-colors hover:bg-blue-700'
|
||||
>
|
||||
{_('Upgrade to {{plan}}', { plan: _(plan.name) })}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { IoCheckmark } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import { PlanDetails } from '../utils/plan';
|
||||
import { PlanType } from '@/types/quota';
|
||||
import PlanActionButton from './PlanActionButton';
|
||||
import PurchaseCallToActions from './PurchaseCallToActions';
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: PlanDetails;
|
||||
@@ -11,7 +13,7 @@ interface PlanCardProps {
|
||||
upgradable?: boolean;
|
||||
index: number;
|
||||
currentPlanIndex: number;
|
||||
onSubscribe: (priceId?: string) => void;
|
||||
onSubscribe: (priceId?: string, planType?: PlanType) => void;
|
||||
onSelectPlan: (index: number) => void;
|
||||
}
|
||||
|
||||
@@ -34,7 +36,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={plan.plan}
|
||||
className='w-full flex-shrink-0 p-6 sm:min-w-96 sm:max-w-96'
|
||||
className='w-full flex-shrink-0 px-4 py-6 sm:min-w-96 sm:max-w-96'
|
||||
style={{ scrollSnapAlign: 'start' }}
|
||||
>
|
||||
<div
|
||||
@@ -43,8 +45,14 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
<div className='mb-6 text-center'>
|
||||
<h4 className='mb-2 text-2xl font-bold'>{_(plan.name)}</h4>
|
||||
<div className='text-3xl font-bold'>
|
||||
{formattedPrice}
|
||||
<span className='text-lg font-normal'>/{_(plan.interval)}</span>
|
||||
{plan.plan !== 'purchase' ? (
|
||||
<>
|
||||
{formattedPrice}
|
||||
<span className='text-lg font-normal'>/{_(plan.interval)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className='text-lg font-normal'>{_('On-Demand Purchase')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,30 +72,38 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role='none'
|
||||
className='mb-6 rounded-lg bg-white/50 p-4'
|
||||
onClick={() => onSelectPlan(index)}
|
||||
>
|
||||
<h5 className='mb-3 font-semibold'>{_('Plan Limits')}</h5>
|
||||
<div className='space-y-2'>
|
||||
{Object.entries(plan.limits).map(([key, value]) => (
|
||||
<div key={key} className='flex justify-between text-sm'>
|
||||
<span>{_(key)}:</span>
|
||||
<span className='font-medium'>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
{plan.limits && Object.keys(plan.limits).length > 0 && (
|
||||
<div
|
||||
role='none'
|
||||
className='mb-6 rounded-lg bg-white/50 p-4'
|
||||
onClick={() => onSelectPlan(index)}
|
||||
>
|
||||
<h5 className='mb-3 font-semibold'>{_('Plan Limits')}</h5>
|
||||
<div className='space-y-2'>
|
||||
{Object.entries(plan.limits).map(([key, value]) => (
|
||||
<div key={key} className='flex justify-between text-sm'>
|
||||
<span>{_(key)}:</span>
|
||||
<span className='font-medium'>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlanActionButton
|
||||
plan={plan}
|
||||
comingSoon={comingSoon}
|
||||
upgradable={upgradable}
|
||||
isUserPlan={isUserPlan}
|
||||
onSubscribe={onSubscribe}
|
||||
onSelectPlan={onSelectPlan}
|
||||
/>
|
||||
{plan.plan === 'purchase' && (
|
||||
<PurchaseCallToActions plan={plan} onSubscribe={onSubscribe} />
|
||||
)}
|
||||
|
||||
{plan.plan !== 'purchase' && (
|
||||
<PlanActionButton
|
||||
plan={plan}
|
||||
comingSoon={comingSoon}
|
||||
upgradable={upgradable}
|
||||
isUserPlan={isUserPlan}
|
||||
onSubscribe={onSubscribe}
|
||||
onSelectPlan={onSelectPlan}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,18 +17,19 @@ const PlanNavigation: React.FC<PlanNavigationProps> = ({
|
||||
return (
|
||||
<div className='bg-base-200/50 border-base-200 border-b px-6 py-4'>
|
||||
<div className='flex items-center justify-center'>
|
||||
<div className='bg-base-300 flex items-center space-x-1 rounded-lg p-1'>
|
||||
<div
|
||||
className='bg-base-300 flex gap-2 overflow-x-auto rounded-lg p-1'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{allPlans.map((plan, index) => (
|
||||
<button
|
||||
key={plan.name}
|
||||
key={`plan-${plan.plan}-${index}`}
|
||||
onClick={() => onSelectPlan(index)}
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||
currentPlanIndex === index
|
||||
? `${plan.color} shadow-sm`
|
||||
: 'text-base-content hover:bg-base-200'
|
||||
className={`rounded-md px-2 py-2 text-sm font-medium shadow-sm transition-colors ${
|
||||
currentPlanIndex === index ? `${plan.color}` : 'text-base-content hover:bg-base-200'
|
||||
}`}
|
||||
>
|
||||
{_(plan.name)}
|
||||
<span className='whitespace-nowrap'>{_(plan.name)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { PlanType, UserPlan } from '@/types/quota';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { getPlanDetails } from '../utils/plan';
|
||||
import { UserPlan } from '@/types/user';
|
||||
import { AvailablePlan } from '../page';
|
||||
import PlanNavigation from './PlanNavigation';
|
||||
import PlanCard from './PlanCard';
|
||||
@@ -11,7 +10,7 @@ import PlanIndicators from './PlanIndicators';
|
||||
interface PlansComparisonProps {
|
||||
availablePlans: AvailablePlan[];
|
||||
userPlan: UserPlan;
|
||||
onSubscribe: (priceId?: string) => void;
|
||||
onSubscribe: (priceId?: string, planType?: PlanType) => void;
|
||||
}
|
||||
|
||||
const PlansComparison: React.FC<PlansComparisonProps> = ({
|
||||
@@ -19,12 +18,11 @@ const PlansComparison: React.FC<PlansComparisonProps> = ({
|
||||
userPlan,
|
||||
onSubscribe,
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const [currentPlanIndex, setCurrentPlanIndex] = useState(0);
|
||||
const [userPlanIndex, setUserPlanIndex] = useState(0);
|
||||
const plansScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const userPlans: UserPlan[] = ['free', 'plus', 'pro'];
|
||||
const userPlans: UserPlan[] = ['free', 'plus', 'pro', 'purchase'];
|
||||
|
||||
useEffect(() => {
|
||||
if (userPlan) {
|
||||
@@ -108,9 +106,12 @@ const PlansComparison: React.FC<PlansComparisonProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (plansScrollRef.current) {
|
||||
const planWidth = plansScrollRef.current.scrollWidth / allPlans.length;
|
||||
const scrollPosition = currentPlanIndex * planWidth;
|
||||
plansScrollRef.current.scrollTo({
|
||||
const container = plansScrollRef.current;
|
||||
const planWidth = (container.scrollWidth - 208 * 0) / allPlans.length;
|
||||
const cardCenter = 208 * 0 + currentPlanIndex * planWidth + planWidth / 2;
|
||||
const scrollPosition = cardCenter - container.clientWidth / 2;
|
||||
|
||||
container.scrollTo({
|
||||
left: scrollPosition,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
@@ -132,17 +133,16 @@ const PlansComparison: React.FC<PlansComparisonProps> = ({
|
||||
|
||||
<div
|
||||
ref={plansScrollRef}
|
||||
className='scrollbar-hide flex items-start overflow-x-auto scroll-smooth'
|
||||
className='plans-container scrollbar-hide flex items-start overflow-x-auto scroll-smooth sm:px-52'
|
||||
onTouchStart={handleTouchStart}
|
||||
style={{
|
||||
scrollSnapType: appService?.isAndroidApp ? 'none' : 'x mandatory',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
}}
|
||||
>
|
||||
{allPlans.map((plan, index) => (
|
||||
<PlanCard
|
||||
key={plan.plan}
|
||||
key={`plan-${plan.plan}-${index}`}
|
||||
plan={plan}
|
||||
comingSoon={false}
|
||||
isUserPlan={plan.plan === userPlan}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { PlanType } from '@/types/quota';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import { PlanDetails } from '../utils/plan';
|
||||
|
||||
interface PurchaseCallToActionsProps {
|
||||
plan: PlanDetails;
|
||||
onSubscribe: (priceId?: string, planType?: PlanType) => void;
|
||||
}
|
||||
|
||||
const PurchaseCallToActions: React.FC<PurchaseCallToActionsProps> = ({ plan, onSubscribe }) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
if (!plan.products || plan.products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageProducts = plan.products.filter((product) => product.feature === 'storage');
|
||||
const customizationProducts = plan.products.filter(
|
||||
(product) => product.feature === 'customization',
|
||||
);
|
||||
|
||||
const formatProductPrice = (price: number, currency: string) => {
|
||||
return new Intl.NumberFormat(getLocale(), {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(price / 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{storageProducts.length > 0 && (
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{storageProducts.map((product) => {
|
||||
const productPrice = formatProductPrice(product.price, product.currency);
|
||||
return (
|
||||
<button
|
||||
key={product.id}
|
||||
onClick={() => onSubscribe(product.id, 'purchase')}
|
||||
className='flex w-full flex-col items-center justify-center rounded-lg bg-green-200 p-2 transition-colors hover:bg-green-300'
|
||||
>
|
||||
<span className='text-base font-semibold text-green-800'>{_(product.name)}</span>
|
||||
<span className='text-sm font-bold text-green-600'>{productPrice}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customizationProducts.length > 0 ? (
|
||||
<div className='grid grid-cols-1 gap-2'>
|
||||
{customizationProducts.map((product) => {
|
||||
const productPrice = formatProductPrice(product.price, product.currency);
|
||||
return (
|
||||
<button
|
||||
key={product.id}
|
||||
onClick={() => onSubscribe(product.id, 'purchase')}
|
||||
className='flex w-full flex-col items-center justify-center rounded-lg bg-green-200 p-2 transition-colors hover:bg-green-300'
|
||||
>
|
||||
<span className='text-base font-semibold text-green-700'>{_(product.name)}</span>
|
||||
<span className='text-sm font-bold text-green-600'>{productPrice}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-1 gap-2'>
|
||||
<button className='flex min-h-[3.5rem] w-full flex-col items-center justify-center rounded-lg bg-green-200 p-2'>
|
||||
<span className='text-base font-semibold text-green-700'>
|
||||
{_('Full Customization')} ({_('Coming Soon')})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseCallToActions;
|
||||
@@ -1,5 +1,5 @@
|
||||
import Quota from '@/components/Quota';
|
||||
import { QuotaType } from '@/types/user';
|
||||
import { QuotaType } from '@/types/quota';
|
||||
|
||||
interface UsageStatsProps {
|
||||
quotas: QuotaType[];
|
||||
|
||||
@@ -12,16 +12,17 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { useQuotaStats } from '@/hooks/useQuotaStats';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { UserPlan } from '@/types/user';
|
||||
import { PlanType, UserPlan } from '@/types/quota';
|
||||
import { navigateToLibrary, navigateToResetPassword } from '@/utils/nav';
|
||||
import { deleteUser } from '@/libs/user';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getStripe } from '@/libs/stripe/client';
|
||||
import { getStripe } from '@/libs/payment/stripe/client';
|
||||
import { getAPIBaseUrl, isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { getAccessToken } from '@/utils/access';
|
||||
import { IAPService, IAPProduct, IAPPurchase } from '@/utils/iap';
|
||||
import { getPlanDetails } from './utils/plan';
|
||||
import { StripeProductMetadata } from '@/types/payment';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import LegalLinks from '@/components/LegalLinks';
|
||||
import Spinner from '@/components/Spinner';
|
||||
@@ -37,13 +38,16 @@ const WEB_STRIPE_CHECKOUT_URL = `${getAPIBaseUrl()}/stripe/checkout`;
|
||||
const WEB_STRIPE_PORTAL_URL = `${getAPIBaseUrl()}/stripe/portal`;
|
||||
const SUBSCRIPTION_SUCCESS_PATH = '/user/subscription/success';
|
||||
|
||||
export type PlanInterval = 'month' | 'year' | 'lifetime';
|
||||
|
||||
export type AvailablePlan = {
|
||||
plan: UserPlan;
|
||||
price_id: string;
|
||||
productId: string;
|
||||
price: number; // in cents
|
||||
currency: string;
|
||||
interval: string;
|
||||
interval: PlanInterval;
|
||||
productName: string;
|
||||
metadata?: StripeProductMetadata;
|
||||
product?: Stripe.Product;
|
||||
};
|
||||
|
||||
@@ -110,7 +114,7 @@ const ProfilePage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStripeSubscribe = async (priceId?: string) => {
|
||||
const handleStripeSubscribe = async (productId?: string, planType: PlanType = 'subscription') => {
|
||||
const token = await getAccessToken();
|
||||
const stripe = await getStripe();
|
||||
if (!stripe) {
|
||||
@@ -125,7 +129,7 @@ const ProfilePage = () => {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ priceId, embedded: isEmbeddedCheckout }),
|
||||
body: JSON.stringify({ priceId: productId, planType, embedded: isEmbeddedCheckout }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (!response.ok) {
|
||||
@@ -141,7 +145,7 @@ const ProfilePage = () => {
|
||||
}
|
||||
const { sessionId, clientSecret, url } = await response.json();
|
||||
|
||||
const selectedPlan = availablePlans.find((plan) => plan.price_id === priceId)!;
|
||||
const selectedPlan = availablePlans.find((plan) => plan.productId === productId)!;
|
||||
const planName = selectedPlan.product?.name || selectedPlan.productName;
|
||||
if (isEmbeddedCheckout && sessionId && clientSecret) {
|
||||
setShowEmbeddedCheckout(true);
|
||||
@@ -291,11 +295,17 @@ const ProfilePage = () => {
|
||||
? 'plus'
|
||||
: product.id.includes('pro')
|
||||
? 'pro'
|
||||
: 'free',
|
||||
price_id: product.id,
|
||||
: product.id.includes('purchase')
|
||||
? 'purchase'
|
||||
: 'free',
|
||||
productId: product.id,
|
||||
price: product.priceAmountMicros / 10000,
|
||||
currency: product.priceCurrencyCode || 'USD',
|
||||
interval: 'month',
|
||||
interval: product.id.includes('monthly')
|
||||
? 'month'
|
||||
: product.id.includes('yearly')
|
||||
? 'year'
|
||||
: 'lifetime',
|
||||
productName: product.title,
|
||||
}));
|
||||
setAvailablePlans(availablePlans);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
import Stripe from 'stripe';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getAPIBaseUrl, getNodeAPIBaseUrl } from '@/services/environment';
|
||||
import { getAccessToken } from '@/utils/access';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { PlanType } from '@/types/quota';
|
||||
import Spinner from '@/components/Spinner';
|
||||
|
||||
const STRIPE_CHECK_URL = `${getAPIBaseUrl()}/stripe/check`;
|
||||
@@ -16,6 +18,7 @@ interface SessionStatus {
|
||||
customerEmail: string;
|
||||
subscriptionId?: string;
|
||||
planName?: string;
|
||||
planType: PlanType;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
}
|
||||
@@ -24,6 +27,7 @@ const SuccessPageWithSearchParams = () => {
|
||||
const _ = useTranslation();
|
||||
const [sessionStatus, setSessionStatus] = useState<SessionStatus>({
|
||||
status: 'loading',
|
||||
planType: 'subscription',
|
||||
customerEmail: '',
|
||||
});
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
@@ -62,7 +66,7 @@ const SuccessPageWithSearchParams = () => {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const { session, error } = await response.json();
|
||||
const { session: stripeCheckoutSession, error } = await response.json();
|
||||
|
||||
if (error) {
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
@@ -70,15 +74,15 @@ const SuccessPageWithSearchParams = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = stripeCheckoutSession as Stripe.Checkout.Session;
|
||||
setSessionStatus({
|
||||
status:
|
||||
session.payment_status === 'paid' ? 'complete' : session.payment_status || 'processing',
|
||||
status: session.payment_status === 'paid' ? 'complete' : 'failed',
|
||||
customerEmail: session.customer_email || session.customer_details?.email || '',
|
||||
subscriptionId: session.subscription,
|
||||
planName:
|
||||
session.display_items?.[0]?.custom?.name || session.line_items?.data?.[0]?.description,
|
||||
amount: session.amount_total,
|
||||
currency: session.currency,
|
||||
subscriptionId: (session.subscription || session.payment_intent || '') as string,
|
||||
planName: session.line_items?.data?.[0]?.description || '',
|
||||
planType: session.mode === 'payment' ? 'purchase' : 'subscription',
|
||||
amount: session.amount_total || undefined,
|
||||
currency: session.currency || undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -129,6 +133,7 @@ const SuccessPageWithSearchParams = () => {
|
||||
customerEmail: purchase.customerEmail || '',
|
||||
subscriptionId: purchase.subscriptionId,
|
||||
planName: purchase.planName,
|
||||
planType: purchase.planType,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -185,6 +190,7 @@ const SuccessPageWithSearchParams = () => {
|
||||
customerEmail: purchase.customerEmail || '',
|
||||
subscriptionId: purchase.subscriptionId || purchase.orderId,
|
||||
planName: purchase.planName,
|
||||
planType: purchase.planType,
|
||||
amount: purchase.priceAmountMicros
|
||||
? Number(purchase.priceAmountMicros) / 1000000
|
||||
: undefined,
|
||||
@@ -365,12 +371,17 @@ const SuccessPageWithSearchParams = () => {
|
||||
|
||||
{/* Success Message */}
|
||||
<h1 className='mb-4 text-3xl font-bold text-gray-800'>
|
||||
🎉 {_('Subscription Successful!')}
|
||||
🎉{' '}
|
||||
{sessionStatus.planType === 'purchase'
|
||||
? _('Purchase Successful!')
|
||||
: _('Subscription Successful!')}
|
||||
</h1>
|
||||
|
||||
<div className='mb-6 rounded-lg bg-white p-6 shadow-md'>
|
||||
<p className='mb-4 text-lg text-gray-700'>
|
||||
{_('Thank you for your subscription! Your payment has been processed successfully.')}
|
||||
{sessionStatus.planType === 'purchase'
|
||||
? _('Thank you for your purchase! Your payment has been processed successfully.')
|
||||
: _('Thank you for your subscription! Your payment has been processed successfully.')}
|
||||
</p>
|
||||
|
||||
{/* Subscription Details */}
|
||||
|
||||
@@ -1,44 +1,112 @@
|
||||
import { UserPlan } from '@/types/user';
|
||||
import { PlanType, QuotaFeature, UserPlan } from '@/types/quota';
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
import { AvailablePlan } from '../page';
|
||||
import { AvailablePlan, PlanInterval } from '../page';
|
||||
|
||||
type FeatureType = {
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type ProductInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
feature: QuotaFeature;
|
||||
price: number; // in cents
|
||||
currency: string;
|
||||
};
|
||||
|
||||
export type PlanDetails = {
|
||||
name: string;
|
||||
plan: UserPlan;
|
||||
type: PlanType;
|
||||
color: string;
|
||||
hintColor: string;
|
||||
price: number;
|
||||
price: number; // in cents
|
||||
currency: string;
|
||||
price_id?: string;
|
||||
productId?: string;
|
||||
interval: string;
|
||||
features: FeatureType[];
|
||||
limits: Record<string, string | number>;
|
||||
limits?: Record<string, string | number>;
|
||||
products?: ProductInfo[];
|
||||
};
|
||||
|
||||
const getProductFeature = (productId: string): QuotaFeature | undefined => {
|
||||
const features: QuotaFeature[] = ['storage', 'translation', 'tokens', 'customization'];
|
||||
const lowerId = productId.toLowerCase();
|
||||
for (const feature of features) {
|
||||
if (lowerId.includes(feature)) {
|
||||
return feature;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getPlanDetails = (
|
||||
userPlan: UserPlan,
|
||||
planCode: UserPlan,
|
||||
availablePlans: AvailablePlan[],
|
||||
interval: 'month' | 'year' = 'month',
|
||||
interval: PlanInterval = 'month',
|
||||
): PlanDetails => {
|
||||
const availablePlan = availablePlans.find(
|
||||
(plan) => plan.plan === userPlan && (!plan.interval || plan.interval === interval),
|
||||
(plan) => plan.plan === planCode && (!plan.interval || plan.interval === interval),
|
||||
);
|
||||
const currency = availablePlans.length > 0 ? availablePlans[0]!.currency : 'USD';
|
||||
switch (userPlan) {
|
||||
switch (planCode) {
|
||||
case 'purchase':
|
||||
const purchasableProducts: ProductInfo[] = availablePlans
|
||||
.filter((plan) => plan.plan === planCode)
|
||||
.sort((a, b) => a.price - b.price)
|
||||
.map((plan) => {
|
||||
return {
|
||||
id: plan.productId,
|
||||
name: plan.productName,
|
||||
feature: plan.metadata?.feature || getProductFeature(plan.productId) || 'generic',
|
||||
price: plan.price,
|
||||
currency: plan.currency,
|
||||
} as ProductInfo;
|
||||
});
|
||||
return {
|
||||
name: _('Lifetime Plan'),
|
||||
plan: planCode,
|
||||
type: 'purchase',
|
||||
color: 'bg-green-100 text-green-800',
|
||||
hintColor: 'text-green-800/75',
|
||||
price: availablePlan?.price || 1999,
|
||||
currency,
|
||||
productId: availablePlan?.productId,
|
||||
interval: _('lifetime'),
|
||||
features: [
|
||||
{
|
||||
label: _('One-Time Payment'),
|
||||
description: _(
|
||||
'Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.',
|
||||
),
|
||||
},
|
||||
{
|
||||
label: _('Expand Cloud Sync Storage'),
|
||||
description: _(
|
||||
'Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.',
|
||||
),
|
||||
},
|
||||
{
|
||||
label: _('Unlock All Customization Options'),
|
||||
description: _(
|
||||
'Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.',
|
||||
),
|
||||
},
|
||||
],
|
||||
products: purchasableProducts,
|
||||
};
|
||||
case 'free':
|
||||
return {
|
||||
name: _('Free Plan'),
|
||||
plan: userPlan,
|
||||
plan: planCode,
|
||||
type: 'subscription',
|
||||
color: 'bg-gray-200 text-gray-800',
|
||||
hintColor: 'text-gray-800/75',
|
||||
price: 0,
|
||||
currency,
|
||||
price_id: availablePlan?.price_id,
|
||||
productId: availablePlan?.productId,
|
||||
interval: interval === 'month' ? _('month') : _('year'),
|
||||
features: [
|
||||
{
|
||||
@@ -80,12 +148,13 @@ export const getPlanDetails = (
|
||||
case 'plus':
|
||||
return {
|
||||
name: _('Plus Plan'),
|
||||
plan: userPlan,
|
||||
plan: planCode,
|
||||
type: 'subscription',
|
||||
color: 'bg-blue-200 text-blue-800',
|
||||
hintColor: 'text-blue-800/75',
|
||||
price: availablePlan?.price || 499,
|
||||
currency,
|
||||
price_id: availablePlan?.price_id,
|
||||
productId: availablePlan?.productId,
|
||||
interval: interval === 'month' ? _('month') : _('year'),
|
||||
features: [
|
||||
{
|
||||
@@ -130,12 +199,13 @@ export const getPlanDetails = (
|
||||
case 'pro':
|
||||
return {
|
||||
name: _('Pro Plan'),
|
||||
plan: userPlan,
|
||||
plan: planCode,
|
||||
type: 'subscription',
|
||||
color: 'bg-purple-200 text-purple-800',
|
||||
hintColor: 'text-purple-800/75',
|
||||
price: availablePlan?.price || 999,
|
||||
currency,
|
||||
price_id: availablePlan?.price_id,
|
||||
productId: availablePlan?.productId,
|
||||
interval: interval === 'month' ? _('month') : _('year'),
|
||||
features: [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { QuotaType, UserPlan } from '@/types/user';
|
||||
import { QuotaType, UserPlan } from '@/types/quota';
|
||||
import { getStoragePlanData, getTranslationPlanData, getUserPlan } from '@/utils/access';
|
||||
import { useTranslation } from './useTranslation';
|
||||
|
||||
|
||||
+10
@@ -1,8 +1,10 @@
|
||||
import { IAPError } from '@/types/error';
|
||||
import { PlanType } from '@/types/quota';
|
||||
import {
|
||||
AppStoreServerAPI,
|
||||
Environment,
|
||||
JWSTransactionDecodedPayload,
|
||||
TransactionType,
|
||||
decodeTransaction,
|
||||
} from 'app-store-server-api';
|
||||
|
||||
@@ -18,6 +20,7 @@ export interface VerificationResult {
|
||||
success: boolean;
|
||||
verified?: boolean;
|
||||
status?: string;
|
||||
planType?: PlanType;
|
||||
transaction?: JWSTransactionDecodedPayload;
|
||||
environment?: string;
|
||||
bundleId?: string;
|
||||
@@ -84,10 +87,17 @@ export class AppleIAPVerifier {
|
||||
// Status 1 = Active subscription
|
||||
const isActive = status === 1 && (!expiresDate || expiresDate > now);
|
||||
|
||||
const planType: PlanType =
|
||||
decodedTransaction.type === TransactionType.NonConsumable ||
|
||||
decodedTransaction.type === TransactionType.Consumable
|
||||
? 'purchase'
|
||||
: 'subscription';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
verified: true,
|
||||
status: isActive ? 'active' : 'expired',
|
||||
planType: planType,
|
||||
transaction: decodedTransaction,
|
||||
environment: this.environment,
|
||||
bundleId: this.bundleId,
|
||||
@@ -0,0 +1,41 @@
|
||||
import { COMPLETED_PAYMENT_STATUSES } from '@/types/payment';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
|
||||
export const updateUserStorage = async (userId: string) => {
|
||||
const supabase = createSupabaseAdminClient();
|
||||
|
||||
try {
|
||||
const { data: payments, error: paymentsError } = await supabase
|
||||
.from('payments')
|
||||
.select('storage_gb')
|
||||
.eq('user_id', userId)
|
||||
.in('status', COMPLETED_PAYMENT_STATUSES);
|
||||
|
||||
if (paymentsError) {
|
||||
throw paymentsError;
|
||||
}
|
||||
|
||||
const totalStorageGB =
|
||||
payments?.reduce((sum, payment) => {
|
||||
return sum + (payment.storage_gb || 0);
|
||||
}, 0) || 0;
|
||||
|
||||
console.log(`User ${userId} total storage: ${totalStorageGB} GB`);
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('plans')
|
||||
.update({
|
||||
storage_purchased_bytes: totalStorageGB * 1024 * 1024 * 1024,
|
||||
})
|
||||
.eq('id', userId);
|
||||
|
||||
if (updateError) {
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
return totalStorageGB;
|
||||
} catch (error) {
|
||||
console.error('Error updating user storage:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import Stripe from 'stripe';
|
||||
import { UserPlan } from '@/types/quota';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
import { PaymentStatus, StripePaymentData, StripeProductMetadata } from '@/types/payment';
|
||||
import { updateUserStorage } from '../storage';
|
||||
|
||||
let stripe: Stripe | null;
|
||||
|
||||
export const getStripe = () => {
|
||||
if (!stripe) {
|
||||
const stripeSecretKey =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? process.env['STRIPE_SECRET_KEY']
|
||||
: process.env['STRIPE_SECRET_KEY_DEV'];
|
||||
stripe = new Stripe(stripeSecretKey!, {
|
||||
httpClient: Stripe.createFetchHttpClient(),
|
||||
});
|
||||
}
|
||||
return stripe;
|
||||
};
|
||||
|
||||
export const createOrUpdateSubscription = async (
|
||||
userId: string,
|
||||
customerId: string,
|
||||
subscriptionId: string,
|
||||
) => {
|
||||
const stripe = getStripe();
|
||||
const supabase = createSupabaseAdminClient();
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
|
||||
expand: ['items.data.price.product'],
|
||||
});
|
||||
const subscriptionItem = subscription.items.data[0]!;
|
||||
const priceId = subscriptionItem.price.id;
|
||||
const product = subscriptionItem.price.product as Stripe.Product & {
|
||||
metadata: StripeProductMetadata;
|
||||
};
|
||||
const plan = product.metadata?.plan || 'free';
|
||||
|
||||
try {
|
||||
const { data: existingSubscription } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('stripe_subscription_id', subscriptionId)
|
||||
.single();
|
||||
|
||||
const period_start = new Date(subscriptionItem.current_period_start * 1000).toISOString();
|
||||
const period_end = new Date(subscriptionItem.current_period_end * 1000).toISOString();
|
||||
if (existingSubscription) {
|
||||
await supabase
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
status: subscription.status,
|
||||
current_period_start: period_start,
|
||||
current_period_end: period_end,
|
||||
})
|
||||
.eq('id', existingSubscription.id);
|
||||
} else {
|
||||
await supabase.from('subscriptions').insert({
|
||||
user_id: userId,
|
||||
stripe_customer_id: customerId,
|
||||
stripe_subscription_id: subscriptionId,
|
||||
stripe_price_id: priceId,
|
||||
status: subscription.status,
|
||||
current_period_start: period_start,
|
||||
current_period_end: period_end,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking existing subscription:', error);
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('plans')
|
||||
.update({
|
||||
plan: ['active', 'trialing'].includes(subscription.status) ? plan : 'free',
|
||||
status: subscription.status,
|
||||
})
|
||||
.eq('id', userId);
|
||||
};
|
||||
|
||||
export const COMPLETED_PAYMENT_STATUSES: PaymentStatus[] = ['completed', 'succeeded'];
|
||||
|
||||
export const createOrUpdatePayment = async (
|
||||
userId: string,
|
||||
customerId: string,
|
||||
checkoutSessionId: string,
|
||||
) => {
|
||||
const stripe = getStripe();
|
||||
const supabase = createSupabaseAdminClient();
|
||||
|
||||
const session = await stripe.checkout.sessions.retrieve(checkoutSessionId, {
|
||||
expand: ['line_items.data.price.product', 'payment_intent'],
|
||||
});
|
||||
|
||||
if (!session.payment_intent) {
|
||||
throw new Error('No payment intent in checkout session');
|
||||
}
|
||||
|
||||
const paymentIntent = session.payment_intent as Stripe.PaymentIntent;
|
||||
const lineItem = session.line_items?.data[0];
|
||||
const product = lineItem?.price?.product as Stripe.Product & {
|
||||
metadata: { plan: UserPlan; storageGB: string };
|
||||
};
|
||||
const productMetadata = product?.metadata;
|
||||
|
||||
try {
|
||||
const paymentData: Partial<StripePaymentData> = {
|
||||
user_id: userId,
|
||||
provider: 'stripe',
|
||||
stripe_customer_id: customerId,
|
||||
stripe_checkout_id: checkoutSessionId,
|
||||
stripe_payment_intent_id: paymentIntent.id,
|
||||
amount: paymentIntent.amount,
|
||||
currency: paymentIntent.currency,
|
||||
status: paymentIntent.status as PaymentStatus,
|
||||
payment_method: paymentIntent.payment_method as string | null,
|
||||
product_id: product?.id,
|
||||
storage_gb: productMetadata?.storageGB ? parseInt(productMetadata.storageGB) : 0,
|
||||
metadata: product?.metadata,
|
||||
};
|
||||
|
||||
const { error } = await supabase.from('payments').upsert(paymentData, {
|
||||
onConflict: 'stripe_payment_intent_id',
|
||||
ignoreDuplicates: false,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
await updateUserStorage(userId);
|
||||
} catch (error) {
|
||||
console.error('Error creating or updating payment:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
let stripe: Stripe | null;
|
||||
|
||||
export const getStripe = () => {
|
||||
if (!stripe) {
|
||||
const stripeSecretKey =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? process.env['STRIPE_SECRET_KEY']
|
||||
: process.env['STRIPE_SECRET_KEY_DEV'];
|
||||
stripe = new Stripe(stripeSecretKey!, {
|
||||
httpClient: Stripe.createFetchHttpClient(),
|
||||
});
|
||||
}
|
||||
return stripe;
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ViewSettings,
|
||||
} from '@/types/book';
|
||||
import { KOSyncSettings, ReadSettings, SystemSettings } from '@/types/settings';
|
||||
import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/user';
|
||||
import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/quota';
|
||||
import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config';
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
|
||||
@@ -615,12 +615,14 @@ export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
|
||||
free: 500 * 1024 * 1024,
|
||||
plus: 5 * 1024 * 1024 * 1024,
|
||||
pro: 20 * 1024 * 1024 * 1024,
|
||||
purchase: 0,
|
||||
};
|
||||
|
||||
export const DEFAULT_DAILY_TRANSLATION_QUOTA: UserDailyTranslationQuota = {
|
||||
free: 10 * 1024,
|
||||
plus: 100 * 1024,
|
||||
pro: 500 * 1024,
|
||||
purchase: 0,
|
||||
};
|
||||
|
||||
export const DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getAPIBaseUrl } from '@/services/environment';
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
import { ErrorCodes, TranslationProvider } from '../types';
|
||||
import { UserPlan } from '@/types/user';
|
||||
import { UserPlan } from '@/types/quota';
|
||||
import { getUserPlan } from '@/utils/access';
|
||||
import { normalizeToShortLang } from '@/utils/lang';
|
||||
import { DEFAULT_DAILY_TRANSLATION_QUOTA } from '@/services/constants';
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { QuotaFeature, UserPlan } from './quota';
|
||||
|
||||
export type PaymentProvider = 'stripe' | 'apple' | 'google';
|
||||
|
||||
export type PaymentStatus =
|
||||
| 'pending'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'refunded'
|
||||
| 'canceled';
|
||||
|
||||
export const COMPLETED_PAYMENT_STATUSES: PaymentStatus[] = ['completed', 'succeeded'];
|
||||
|
||||
export interface StripeProductMetadata {
|
||||
plan: UserPlan;
|
||||
feature?: QuotaFeature;
|
||||
storageGB?: string;
|
||||
translationChars?: string;
|
||||
tokensM?: string;
|
||||
customization?: string;
|
||||
}
|
||||
|
||||
export interface BasePaymentData {
|
||||
user_id: string;
|
||||
provider: PaymentProvider;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: PaymentStatus;
|
||||
payment_method?: string | null;
|
||||
product_id?: string;
|
||||
storage_gb?: number;
|
||||
metadata?: Record<string, number | string | boolean>;
|
||||
}
|
||||
|
||||
export interface StripePaymentData extends BasePaymentData {
|
||||
provider: 'stripe';
|
||||
stripe_customer_id: string;
|
||||
stripe_checkout_id: string;
|
||||
stripe_payment_intent_id: string;
|
||||
}
|
||||
|
||||
export interface ApplePaymentData extends BasePaymentData {
|
||||
provider: 'apple';
|
||||
apple_transaction_id: string;
|
||||
apple_original_transaction_id: string;
|
||||
}
|
||||
|
||||
export interface GooglePaymentData extends BasePaymentData {
|
||||
provider: 'google';
|
||||
google_order_id: string;
|
||||
google_purchase_token: string;
|
||||
}
|
||||
|
||||
export type PaymentData = StripePaymentData | ApplePaymentData | GooglePaymentData;
|
||||
@@ -2,12 +2,14 @@ export interface UserQuota {
|
||||
free: number;
|
||||
plus: number;
|
||||
pro: number;
|
||||
purchase: number;
|
||||
}
|
||||
|
||||
export type UserPlan = keyof UserQuota;
|
||||
export type UserStorageQuota = UserQuota;
|
||||
export type UserDailyTranslationQuota = UserQuota;
|
||||
|
||||
export type PlanType = 'subscription' | 'purchase';
|
||||
export type QuotaType = {
|
||||
name: string;
|
||||
tooltip: string;
|
||||
@@ -15,3 +17,5 @@ export type QuotaType = {
|
||||
total: number;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
export type QuotaFeature = 'storage' | 'translation' | 'tokens' | 'customization' | 'generic';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { UserPlan } from '@/types/user';
|
||||
import { UserPlan } from '@/types/quota';
|
||||
import { DEFAULT_DAILY_TRANSLATION_QUOTA, DEFAULT_STORAGE_QUOTA } from '@/services/constants';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { getDailyUsage } from '@/services/translators/utils';
|
||||
@@ -8,6 +8,7 @@ import { getDailyUsage } from '@/services/translators/utils';
|
||||
interface Token {
|
||||
plan: UserPlan;
|
||||
storage_usage_bytes: number;
|
||||
storage_purchased_bytes: number;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
@@ -22,8 +23,10 @@ export const getStoragePlanData = (token: string) => {
|
||||
const data = jwtDecode<Token>(token) || {};
|
||||
const plan = data['plan'] || 'free';
|
||||
const usage = data['storage_usage_bytes'] || 0;
|
||||
const purchasedQuota = data['storage_purchased_bytes'] || 0;
|
||||
const fixedQuota = parseInt(process.env['NEXT_PUBLIC_STORAGE_FIXED_QUOTA'] || '0');
|
||||
const quota = fixedQuota || DEFAULT_STORAGE_QUOTA[plan] || DEFAULT_STORAGE_QUOTA['free'];
|
||||
const planQuota = fixedQuota || DEFAULT_STORAGE_QUOTA[plan] || DEFAULT_STORAGE_QUOTA['free'];
|
||||
const quota = planQuota + purchasedQuota;
|
||||
|
||||
return {
|
||||
plan,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import Stripe from 'stripe';
|
||||
import { getStripe } from '@/libs/stripe/server';
|
||||
import { createSupabaseAdminClient } from './supabase';
|
||||
import { UserPlan } from '@/types/user';
|
||||
|
||||
export const createOrUpdateSubscription = async (
|
||||
userId: string,
|
||||
customerId: string,
|
||||
subscriptionId: string,
|
||||
) => {
|
||||
const stripe = getStripe();
|
||||
const supabase = createSupabaseAdminClient();
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
|
||||
expand: ['items.data.price.product'],
|
||||
});
|
||||
const subscriptionItem = subscription.items.data[0]!;
|
||||
const priceId = subscriptionItem.price.id;
|
||||
const product = subscriptionItem.price.product as Stripe.Product & {
|
||||
metadata: { plan: UserPlan };
|
||||
};
|
||||
const plan = product.metadata['plan'] || 'free';
|
||||
|
||||
try {
|
||||
const { data: existingSubscription } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('stripe_subscription_id', subscriptionId)
|
||||
.single();
|
||||
|
||||
const period_start = new Date(subscriptionItem.current_period_start * 1000).toISOString();
|
||||
const period_end = new Date(subscriptionItem.current_period_end * 1000).toISOString();
|
||||
if (existingSubscription) {
|
||||
await supabase
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
status: subscription.status,
|
||||
current_period_start: period_start,
|
||||
current_period_end: period_end,
|
||||
})
|
||||
.eq('id', existingSubscription.id);
|
||||
} else {
|
||||
await supabase.from('subscriptions').insert({
|
||||
user_id: userId,
|
||||
stripe_customer_id: customerId,
|
||||
stripe_subscription_id: subscriptionId,
|
||||
stripe_price_id: priceId,
|
||||
status: subscription.status,
|
||||
current_period_start: period_start,
|
||||
current_period_end: period_end,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking existing subscription:', error);
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('plans')
|
||||
.update({
|
||||
plan: ['active', 'trialing'].includes(subscription.status) ? plan : 'free',
|
||||
status: subscription.status,
|
||||
})
|
||||
.eq('id', userId);
|
||||
};
|
||||
Reference in New Issue
Block a user