@neko-os/rc-subscription 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +142 -0
  2. package/dist/config.js +5 -0
  3. package/dist/containers/SubscriptionHandler.js +75 -0
  4. package/dist/containers/SubscriptionRequired.js +11 -0
  5. package/dist/containers/SubscriptionRequiredCTA.js +37 -0
  6. package/dist/containers/paywall/Paywall.js +130 -0
  7. package/dist/containers/paywall/PaywallFeatures.js +42 -0
  8. package/dist/containers/paywall/PaywallFooter.js +16 -0
  9. package/dist/containers/paywall/PaywallHero.js +33 -0
  10. package/dist/containers/paywall/PaywallPlanCard.js +71 -0
  11. package/dist/containers/paywall/PaywallReturnIcon.js +8 -0
  12. package/dist/containers/paywall/_request/usePaywallActions.js +39 -0
  13. package/dist/index.js +6 -0
  14. package/dist/locales/cs.js +30 -0
  15. package/dist/locales/da.js +30 -0
  16. package/dist/locales/de.js +30 -0
  17. package/dist/locales/el.js +30 -0
  18. package/dist/locales/en.js +30 -0
  19. package/dist/locales/es.js +30 -0
  20. package/dist/locales/fi.js +30 -0
  21. package/dist/locales/fr.js +30 -0
  22. package/dist/locales/hi.js +30 -0
  23. package/dist/locales/hu.js +30 -0
  24. package/dist/locales/id.js +30 -0
  25. package/dist/locales/index.js +63 -0
  26. package/dist/locales/it.js +30 -0
  27. package/dist/locales/ja.js +30 -0
  28. package/dist/locales/ko.js +30 -0
  29. package/dist/locales/nl.js +30 -0
  30. package/dist/locales/no.js +30 -0
  31. package/dist/locales/pl.js +30 -0
  32. package/dist/locales/pt.js +30 -0
  33. package/dist/locales/ro.js +30 -0
  34. package/dist/locales/ru.js +30 -0
  35. package/dist/locales/sv.js +30 -0
  36. package/dist/locales/th.js +30 -0
  37. package/dist/locales/tr.js +30 -0
  38. package/dist/locales/uk.js +30 -0
  39. package/dist/locales/vi.js +30 -0
  40. package/dist/locales/zh.js +30 -0
  41. package/dist/views/active/ActiveSubscriptionView.js +51 -0
  42. package/package.json +53 -0
  43. package/src/config.js +5 -0
  44. package/src/containers/SubscriptionHandler.js +75 -0
  45. package/src/containers/SubscriptionRequired.js +11 -0
  46. package/src/containers/SubscriptionRequiredCTA.js +37 -0
  47. package/src/containers/paywall/Paywall.js +130 -0
  48. package/src/containers/paywall/PaywallFeatures.js +42 -0
  49. package/src/containers/paywall/PaywallFooter.js +16 -0
  50. package/src/containers/paywall/PaywallHero.js +33 -0
  51. package/src/containers/paywall/PaywallPlanCard.js +71 -0
  52. package/src/containers/paywall/PaywallReturnIcon.js +8 -0
  53. package/src/containers/paywall/_request/usePaywallActions.js +39 -0
  54. package/src/index.js +6 -0
  55. package/src/locales/cs.js +30 -0
  56. package/src/locales/da.js +30 -0
  57. package/src/locales/de.js +30 -0
  58. package/src/locales/el.js +30 -0
  59. package/src/locales/en.js +30 -0
  60. package/src/locales/es.js +30 -0
  61. package/src/locales/fi.js +30 -0
  62. package/src/locales/fr.js +30 -0
  63. package/src/locales/hi.js +30 -0
  64. package/src/locales/hu.js +30 -0
  65. package/src/locales/id.js +30 -0
  66. package/src/locales/index.js +63 -0
  67. package/src/locales/it.js +30 -0
  68. package/src/locales/ja.js +30 -0
  69. package/src/locales/ko.js +30 -0
  70. package/src/locales/nl.js +30 -0
  71. package/src/locales/no.js +30 -0
  72. package/src/locales/pl.js +30 -0
  73. package/src/locales/pt.js +30 -0
  74. package/src/locales/ro.js +30 -0
  75. package/src/locales/ru.js +30 -0
  76. package/src/locales/sv.js +30 -0
  77. package/src/locales/th.js +30 -0
  78. package/src/locales/tr.js +30 -0
  79. package/src/locales/uk.js +30 -0
  80. package/src/locales/vi.js +30 -0
  81. package/src/locales/zh.js +30 -0
  82. package/src/views/active/ActiveSubscriptionView.js +51 -0
@@ -0,0 +1,30 @@
1
+ export var subscriptionRU = {
2
+ settings: {
3
+ linkLabel: 'Подписка'
4
+ },
5
+ paywall: {
6
+ title: 'Стать Premium',
7
+ subtitle: 'Разблокируйте все функции',
8
+ cta: 'Продолжить',
9
+ ctaTrial: 'Начать бесплатный пробный период',
10
+ restore: 'Восстановить покупки',
11
+ monthly: 'Ежемесячно',
12
+ yearly: 'Ежегодно',
13
+ lifetime: 'Навсегда',
14
+ perMonth: '{{price}}/мес.',
15
+ save: 'Скидка {{percent}}%',
16
+ freeTrial: '{{count}} дней бесплатно',
17
+ free: 'Бесплатно',
18
+ pro: 'Pro'
19
+ },
20
+ cta: {
21
+ unlock: 'Разблокировать полный доступ'
22
+ },
23
+ active: {
24
+ title: 'Подписка',
25
+ plan: 'План',
26
+ expires: 'Истекает',
27
+ lifetime: 'Пожизненный доступ',
28
+ manage: 'Управление подпиской'
29
+ }
30
+ };
@@ -0,0 +1,30 @@
1
+ export var subscriptionSV = {
2
+ settings: {
3
+ linkLabel: 'Prenumeration'
4
+ },
5
+ paywall: {
6
+ title: 'Bli Premium',
7
+ subtitle: 'Lås upp alla funktioner',
8
+ cta: 'Fortsätt',
9
+ ctaTrial: 'Starta gratis provperiod',
10
+ restore: 'Återställ köp',
11
+ monthly: 'Månadsvis',
12
+ yearly: 'Årsvis',
13
+ lifetime: 'Livstid',
14
+ perMonth: '{{price}}/mån',
15
+ save: 'Spara {{percent}}%',
16
+ freeTrial: '{{count}} dagar gratis',
17
+ free: 'Gratis',
18
+ pro: 'Pro'
19
+ },
20
+ cta: {
21
+ unlock: 'Lås upp full åtkomst'
22
+ },
23
+ active: {
24
+ title: 'Prenumeration',
25
+ plan: 'Plan',
26
+ expires: 'Löper ut',
27
+ lifetime: 'Livstidsåtkomst',
28
+ manage: 'Hantera prenumeration'
29
+ }
30
+ };
@@ -0,0 +1,30 @@
1
+ export var subscriptionTH = {
2
+ settings: {
3
+ linkLabel: 'การสมัครสมาชิก'
4
+ },
5
+ paywall: {
6
+ title: 'อัปเกรดเป็น Premium',
7
+ subtitle: 'ปลดล็อกฟีเจอร์ทั้งหมด',
8
+ cta: 'ดำเนินการต่อ',
9
+ ctaTrial: 'เริ่มทดลองใช้ฟรี',
10
+ restore: 'กู้คืนการซื้อ',
11
+ monthly: 'รายเดือน',
12
+ yearly: 'รายปี',
13
+ lifetime: 'ตลอดชีพ',
14
+ perMonth: '{{price}}/เดือน',
15
+ save: 'ประหยัด {{percent}}%',
16
+ freeTrial: 'ฟรี {{count}} วัน',
17
+ free: 'ฟรี',
18
+ pro: 'Pro'
19
+ },
20
+ cta: {
21
+ unlock: 'ปลดล็อกการเข้าถึงเต็มรูปแบบ'
22
+ },
23
+ active: {
24
+ title: 'การสมัครสมาชิก',
25
+ plan: 'แผน',
26
+ expires: 'หมดอายุ',
27
+ lifetime: 'เข้าถึงตลอดชีพ',
28
+ manage: 'จัดการการสมัครสมาชิก'
29
+ }
30
+ };
@@ -0,0 +1,30 @@
1
+ export var subscriptionTR = {
2
+ settings: {
3
+ linkLabel: 'Abonelik'
4
+ },
5
+ paywall: {
6
+ title: 'Premium\'a Geç',
7
+ subtitle: 'Tüm özelliklerin kilidini aç',
8
+ cta: 'Devam et',
9
+ ctaTrial: 'Ücretsiz denemeyi başlat',
10
+ restore: 'Satın alımları geri yükle',
11
+ monthly: 'Aylık',
12
+ yearly: 'Yıllık',
13
+ lifetime: 'Ömür boyu',
14
+ perMonth: '{{price}}/ay',
15
+ save: '%{{percent}} tasarruf',
16
+ freeTrial: '{{count}} gün ücretsiz',
17
+ free: 'Ücretsiz',
18
+ pro: 'Pro'
19
+ },
20
+ cta: {
21
+ unlock: 'Tam erişimin kilidini aç'
22
+ },
23
+ active: {
24
+ title: 'Abonelik',
25
+ plan: 'Plan',
26
+ expires: 'Sona erme',
27
+ lifetime: 'Ömür boyu erişim',
28
+ manage: 'Aboneliği yönet'
29
+ }
30
+ };
@@ -0,0 +1,30 @@
1
+ export var subscriptionUK = {
2
+ settings: {
3
+ linkLabel: 'Підписка'
4
+ },
5
+ paywall: {
6
+ title: 'Стати Premium',
7
+ subtitle: 'Розблокуйте всі функції',
8
+ cta: 'Продовжити',
9
+ ctaTrial: 'Почати безкоштовний пробний період',
10
+ restore: 'Відновити покупки',
11
+ monthly: 'Щомісячно',
12
+ yearly: 'Щорічно',
13
+ lifetime: 'Назавжди',
14
+ perMonth: '{{price}}/міс.',
15
+ save: 'Знижка {{percent}}%',
16
+ freeTrial: '{{count}} днів безкоштовно',
17
+ free: 'Безкоштовно',
18
+ pro: 'Pro'
19
+ },
20
+ cta: {
21
+ unlock: 'Розблокувати повний доступ'
22
+ },
23
+ active: {
24
+ title: 'Підписка',
25
+ plan: 'План',
26
+ expires: 'Закінчується',
27
+ lifetime: 'Довічний доступ',
28
+ manage: 'Керувати підпискою'
29
+ }
30
+ };
@@ -0,0 +1,30 @@
1
+ export var subscriptionVI = {
2
+ settings: {
3
+ linkLabel: 'Gói đăng ký'
4
+ },
5
+ paywall: {
6
+ title: 'Nâng cấp Premium',
7
+ subtitle: 'Mở khóa tất cả tính năng',
8
+ cta: 'Tiếp tục',
9
+ ctaTrial: 'Bắt đầu dùng thử miễn phí',
10
+ restore: 'Khôi phục giao dịch',
11
+ monthly: 'Hàng tháng',
12
+ yearly: 'Hàng năm',
13
+ lifetime: 'Trọn đời',
14
+ perMonth: '{{price}}/tháng',
15
+ save: 'Tiết kiệm {{percent}}%',
16
+ freeTrial: 'Miễn phí {{count}} ngày',
17
+ free: 'Miễn phí',
18
+ pro: 'Pro'
19
+ },
20
+ cta: {
21
+ unlock: 'Mở khóa toàn bộ quyền truy cập'
22
+ },
23
+ active: {
24
+ title: 'Gói đăng ký',
25
+ plan: 'Gói',
26
+ expires: 'Hết hạn',
27
+ lifetime: 'Truy cập trọn đời',
28
+ manage: 'Quản lý gói đăng ký'
29
+ }
30
+ };
@@ -0,0 +1,30 @@
1
+ export var subscriptionZH = {
2
+ settings: {
3
+ linkLabel: '订阅'
4
+ },
5
+ paywall: {
6
+ title: '升级高级版',
7
+ subtitle: '解锁所有功能',
8
+ cta: '继续',
9
+ ctaTrial: '开始免费试用',
10
+ restore: '恢复购买',
11
+ monthly: '月付',
12
+ yearly: '年付',
13
+ lifetime: '终身',
14
+ perMonth: '{{price}}/月',
15
+ save: '节省 {{percent}}%',
16
+ freeTrial: '免费 {{count}} 天',
17
+ free: '免费',
18
+ pro: 'Pro'
19
+ },
20
+ cta: {
21
+ unlock: '解锁完整访问权限'
22
+ },
23
+ active: {
24
+ title: '订阅',
25
+ plan: '方案',
26
+ expires: '到期时间',
27
+ lifetime: '终身访问',
28
+ manage: '管理订阅'
29
+ }
30
+ };
@@ -0,0 +1,51 @@
1
+ var _jsxFileName = "/Users/christianstorch/Apps/nekoapps/libs/neko-rc-subscription/src/views/active/ActiveSubscriptionView.js";function asyncGeneratorStep(n, t, e, r, o, a, c) {try {var i = n[a](c),u = i.value;} catch (n) {return void e(n);}i.done ? t(u) : Promise.resolve(u).then(r, o);}function _asyncToGenerator(n) {return function () {var t = this,e = arguments;return new Promise(function (r, o) {var a = n.apply(t, e);function _next(n) {asyncGeneratorStep(a, r, o, _next, _throw, "next", n);}function _throw(n) {asyncGeneratorStep(a, r, o, _next, _throw, "throw", n);}_next(void 0);});};}import { Button, Section, SectionItem, TopBar, View, useTranslation } from '@neko-os/ui';
2
+ import { Linking } from "react-native-web";
3
+ import { useNavigation } from '@react-navigation/native';
4
+ import dayjs from 'dayjs';
5
+
6
+ import { RC_ENTITLEMENT_ID } from "../../config";
7
+ import SubscriptionRequired from "../../containers/SubscriptionRequired";
8
+ import { useSubscription } from "../../containers/SubscriptionHandler";import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
+
10
+ function ActiveSubscriptionContent() {var _customerInfo$entitle, _customerInfo$entitle2;
11
+ var _useTranslation = useTranslation('subscription'),t = _useTranslation.t;
12
+ var _useSubscription = useSubscription(),customerInfo = _useSubscription.customerInfo;
13
+
14
+ var entitlement = customerInfo == null ? void 0 : (_customerInfo$entitle = customerInfo.entitlements) == null ? void 0 : (_customerInfo$entitle2 = _customerInfo$entitle.active) == null ? void 0 : _customerInfo$entitle2[RC_ENTITLEMENT_ID];function
15
+
16
+ handleManage() {return _handleManage.apply(this, arguments);}function _handleManage() {_handleManage = _asyncToGenerator(function* () {
17
+ if (customerInfo != null && customerInfo.managementURL) yield Linking.openURL(customerInfo.managementURL);
18
+ });return _handleManage.apply(this, arguments);}
19
+
20
+ return (
21
+ _jsxs(View, { flex: true, padding: "md", gap: "md", children: [
22
+ _jsxs(Section, { children: [
23
+ _jsx(SectionItem, { label: t('active.plan'), value: (entitlement == null ? void 0 : entitlement.identifier) || '-' }),
24
+ entitlement != null && entitlement.expirationDate ?
25
+ _jsx(SectionItem, { label: t('active.expires'), value: dayjs(entitlement.expirationDate).format('LL') }) :
26
+
27
+ _jsx(SectionItem, { label: t('active.expires'), value: t('active.lifetime') })] }
28
+
29
+ ),
30
+ (customerInfo == null ? void 0 : customerInfo.managementURL) && _jsx(Button, { label: t('active.manage'), onPress: handleManage })] }
31
+ ));
32
+
33
+ }
34
+
35
+ export default function ActiveSubscriptionView() {
36
+ var _useTranslation2 = useTranslation('subscription'),t = _useTranslation2.t;
37
+ var _useNavigation = useNavigation(),goBack = _useNavigation.goBack;
38
+
39
+ return (
40
+ _jsx(View, { flex: true, bg: "mainBG", children:
41
+ _jsxs(SubscriptionRequired, { showReturn: true, children: [
42
+ _jsx(TopBar, {
43
+ title: t('active.title'),
44
+ left: _jsx(Button, { icon: "arrow-left-s-line", ratio: 1, mainBG: true, onPress: goBack }) }
45
+ ),
46
+
47
+ _jsx(ActiveSubscriptionContent, {})] }
48
+ ) }
49
+ ));
50
+
51
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@neko-os/rc-subscription",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "author": "Christian Storch <ccstorch@gmail.com>",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "react-native": "src/index.js",
9
+ "files": [
10
+ "dist",
11
+ "src"
12
+ ],
13
+ "scripts": {
14
+ "build": "rm -rf dist && babel src --out-dir dist --extensions \".js,.jsx\" --copy-files",
15
+ "watch": "babel src --out-dir dist --extensions \".js,.jsx\" --copy-files --watch",
16
+ "dev": "yarn build && yarn watch",
17
+ "prepublishOnly": "yarn build",
18
+ "publish": "npm publish --access public"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "peerDependencies": {
24
+ "@neko-os/ui": "*",
25
+ "react": "*",
26
+ "react-native": "*",
27
+ "react-native-purchases": "*",
28
+ "react-native-reanimated": "*",
29
+ "react-native-safe-area-context": "*",
30
+ "@react-navigation/native": "*",
31
+ "dayjs": "*"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "react-native-reanimated": {
35
+ "optional": true
36
+ },
37
+ "react-native-safe-area-context": {
38
+ "optional": true
39
+ },
40
+ "@react-navigation/native": {
41
+ "optional": true
42
+ },
43
+ "dayjs": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@babel/cli": "^7",
49
+ "@babel/core": "^7",
50
+ "babel-plugin-module-resolver": "^5.0.2",
51
+ "metro-react-native-babel-preset": "^0.77.0"
52
+ }
53
+ }
package/src/config.js ADDED
@@ -0,0 +1,5 @@
1
+ import { Platform } from 'react-native'
2
+
3
+ export const RC_API_KEY =
4
+ Platform.OS === 'ios' ? process.env.EXPO_PUBLIC_RC_API_KEY_IOS : process.env.EXPO_PUBLIC_RC_API_KEY_ANDROID
5
+ export const RC_ENTITLEMENT_ID = process.env.EXPO_PUBLIC_RC_ENTITLEMENT_ID || 'premium'
@@ -0,0 +1,75 @@
1
+ import React, { createContext, useContext, useEffect, useState } from 'react'
2
+ import Purchases from 'react-native-purchases'
3
+
4
+ import { RC_API_KEY, RC_ENTITLEMENT_ID } from '../config'
5
+
6
+ const SubscriptionContext = createContext({
7
+ isSubscribed: false,
8
+ isLoading: true,
9
+ customerInfo: null,
10
+ offerings: null,
11
+ paywallConfig: null,
12
+ refresh: async () => {},
13
+ })
14
+
15
+ export function useSubscription() {
16
+ return useContext(SubscriptionContext)
17
+ }
18
+
19
+ export function useIsSubscribed() {
20
+ return useContext(SubscriptionContext).isSubscribed
21
+ }
22
+
23
+ export default function SubscriptionHandler({ children, paywallConfig }) {
24
+ const [isSubscribed, setIsSubscribed] = useState(false)
25
+ const [isLoading, setIsLoading] = useState(true)
26
+ const [customerInfo, setCustomerInfo] = useState(null)
27
+ const [offerings, setOfferings] = useState(null)
28
+
29
+ async function refresh() {
30
+ try {
31
+ const info = await Purchases.getCustomerInfo()
32
+ setCustomerInfo(info)
33
+ setIsSubscribed(!!info?.entitlements?.active?.[RC_ENTITLEMENT_ID])
34
+ } catch (_) {}
35
+ }
36
+
37
+ useEffect(() => {
38
+ if (!RC_API_KEY) {
39
+ setIsLoading(false)
40
+ return
41
+ }
42
+
43
+ function handleCustomerInfo(info) {
44
+ setCustomerInfo(info)
45
+ setIsSubscribed(!!info?.entitlements?.active?.[RC_ENTITLEMENT_ID])
46
+ }
47
+
48
+ Purchases.configure({ apiKey: RC_API_KEY })
49
+
50
+ ;(async () => {
51
+ try {
52
+ const [info, offeringsResult] = await Promise.all([
53
+ Purchases.getCustomerInfo(),
54
+ Purchases.getOfferings(),
55
+ ])
56
+ handleCustomerInfo(info)
57
+ setOfferings(offeringsResult)
58
+ } catch (_) {
59
+ } finally {
60
+ setIsLoading(false)
61
+ }
62
+ })()
63
+
64
+ Purchases.addCustomerInfoUpdateListener(handleCustomerInfo)
65
+ return () => Purchases.removeCustomerInfoUpdateListener(handleCustomerInfo)
66
+ }, [])
67
+
68
+ return (
69
+ <SubscriptionContext.Provider
70
+ value={{ isSubscribed, isLoading, customerInfo, offerings, paywallConfig, refresh }}
71
+ >
72
+ {children}
73
+ </SubscriptionContext.Provider>
74
+ )
75
+ }
@@ -0,0 +1,11 @@
1
+ import { Loading } from '@neko-os/ui'
2
+
3
+ import Paywall from './paywall/Paywall'
4
+ import { useSubscription } from './SubscriptionHandler'
5
+
6
+ export default function SubscriptionRequired({ children, disabled = false, modal, showReturn, footerPaddingB }) {
7
+ const { isSubscribed, isLoading } = useSubscription()
8
+ if (disabled || isSubscribed) return children
9
+ if (isLoading) return <Loading />
10
+ return <Paywall modal={modal} showReturn={showReturn} footerPaddingB={footerPaddingB} />
11
+ }
@@ -0,0 +1,37 @@
1
+ import { BlurView, Button, View, useTranslation } from '@neko-os/ui'
2
+ import { useNavigation } from '@react-navigation/native'
3
+
4
+ import { useIsSubscribed } from './SubscriptionHandler'
5
+
6
+ export default function SubscriptionRequiredCTA({
7
+ children,
8
+ disabled = false,
9
+ hideButton = false,
10
+ size,
11
+ buttonProps,
12
+ ...props
13
+ }) {
14
+ const { t } = useTranslation('subscription')
15
+ const { navigate } = useNavigation()
16
+ const isSubscribed = useIsSubscribed()
17
+
18
+ if (disabled || isSubscribed) return children
19
+
20
+ return (
21
+ <View relative hiddenOverflow {...props}>
22
+ {children}
23
+ <BlurView absoluteFill center zIndex={10} intensity={18} onPress={() => navigate('subscription/active')}>
24
+ {!hideButton && (
25
+ <Button
26
+ label={t('cta.unlock')}
27
+ icon="trophy-fill"
28
+ yellow
29
+ size={size || 'xs'}
30
+ {...buttonProps}
31
+ onPress={() => navigate('subscription/active')}
32
+ />
33
+ )}
34
+ </BlurView>
35
+ </View>
36
+ )
37
+ }
@@ -0,0 +1,130 @@
1
+ import {
2
+ AnimatedTopBar,
3
+ BlurView,
4
+ ReanimatedScrollHandler,
5
+ ScrollView,
6
+ View,
7
+ useReanimatedScroll,
8
+ useTranslation,
9
+ } from '@neko-os/ui'
10
+ import { Platform } from 'react-native'
11
+ import { useState } from 'react'
12
+ import Animated from 'react-native-reanimated'
13
+
14
+ import { useSubscription } from '../SubscriptionHandler'
15
+ import PaywallFeatures from './PaywallFeatures'
16
+ import PaywallFooter from './PaywallFooter'
17
+ import PaywallHero from './PaywallHero'
18
+ import PaywallPlanCard from './PaywallPlanCard'
19
+ import PaywallReturnIcon from './PaywallReturnIcon'
20
+ import usePaywallActions from './_request/usePaywallActions'
21
+
22
+ const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView)
23
+
24
+ function getAvailablePackages(offerings) {
25
+ if (!offerings?.current) return []
26
+ const { annual, monthly, lifetime } = offerings.current
27
+ return [annual, monthly, lifetime].filter(Boolean)
28
+ }
29
+
30
+ function getTrialDays(pkg) {
31
+ const intro = pkg?.product?.introPrice
32
+ if (!intro || intro.price > 0) return null
33
+ const units = intro.periodNumberOfUnits || 0
34
+ const unit = intro.periodUnit?.toUpperCase()
35
+ if (unit === 'DAY') return units
36
+ if (unit === 'WEEK') return units * 7
37
+ if (unit === 'MONTH') return units * 30
38
+ return units
39
+ }
40
+
41
+ function PaywallScrollContent({
42
+ image,
43
+ title,
44
+ subtitle,
45
+ features,
46
+ packages,
47
+ selectedPkg,
48
+ onSelect,
49
+ monthlyPrice,
50
+ showReturnIcon,
51
+ modal,
52
+ }) {
53
+ const { scrollHandler } = useReanimatedScroll()
54
+
55
+ return (
56
+ <AnimatedScrollView onScroll={scrollHandler}>
57
+ <PaywallHero image={image} title={title} subtitle={subtitle} showReturnIcon={showReturnIcon} modal={modal} />
58
+ <PaywallFeatures features={features} />
59
+
60
+ <View gap="xs" paddingH="md" paddingT="sm">
61
+ {packages.map((pkg) => (
62
+ <PaywallPlanCard
63
+ key={pkg.identifier}
64
+ pkg={pkg}
65
+ selected={selectedPkg?.identifier === pkg.identifier}
66
+ onSelect={onSelect}
67
+ monthlyPrice={monthlyPrice}
68
+ />
69
+ ))}
70
+ </View>
71
+ <View height={200} />
72
+ </AnimatedScrollView>
73
+ )
74
+ }
75
+
76
+ export default function Paywall({ modal, showReturn, footerPaddingB }) {
77
+ const { t } = useTranslation('subscription')
78
+ const { offerings, paywallConfig } = useSubscription()
79
+ const { handlePurchase, handleRestore, loading } = usePaywallActions()
80
+
81
+ const packages = getAvailablePackages(offerings)
82
+ const [selectedPkg, setSelectedPkg] = useState(() => packages[0] || null)
83
+
84
+ const monthlyPrice = packages.find((p) => p.packageType === 'MONTHLY')?.product?.price
85
+ const trialDays = getTrialDays(selectedPkg)
86
+
87
+ const title = paywallConfig?.title || t('paywall.title')
88
+ const subtitle = paywallConfig?.subtitle || t('paywall.subtitle')
89
+ const image = paywallConfig?.image
90
+ const features = paywallConfig?.features
91
+ const showReturnIcon = modal || showReturn
92
+
93
+ return (
94
+ <View flex bg="mainBG">
95
+ <ReanimatedScrollHandler>
96
+ <AnimatedTopBar
97
+ WrapperView={BlurView}
98
+ title={title}
99
+ subtitle={subtitle}
100
+ slide
101
+ shadow
102
+ showAfter={230}
103
+ useSafeArea={!(modal && Platform.OS === 'ios')}
104
+ left={showReturnIcon && <PaywallReturnIcon modal={modal} />}
105
+ />
106
+
107
+ <PaywallScrollContent
108
+ image={image}
109
+ title={title}
110
+ subtitle={subtitle}
111
+ features={features}
112
+ packages={packages}
113
+ selectedPkg={selectedPkg}
114
+ onSelect={setSelectedPkg}
115
+ monthlyPrice={monthlyPrice}
116
+ showReturnIcon={showReturnIcon}
117
+ modal={modal}
118
+ />
119
+ </ReanimatedScrollHandler>
120
+
121
+ <PaywallFooter
122
+ onPurchase={() => handlePurchase(selectedPkg)}
123
+ onRestore={handleRestore}
124
+ loading={loading}
125
+ trialDays={trialDays}
126
+ footerPaddingB={footerPaddingB}
127
+ />
128
+ </View>
129
+ )
130
+ }
@@ -0,0 +1,42 @@
1
+ import { Icon, Text, View, useTranslation } from '@neko-os/ui'
2
+
3
+ export default function PaywallFeatures({ features }) {
4
+ const { t } = useTranslation('subscription')
5
+
6
+ if (!features?.length) return null
7
+
8
+ return (
9
+ <View paddingH="xl" paddingB="md">
10
+ <View row paddingB="xs">
11
+ <View flex />
12
+ <Text xs text3 center width={40}>
13
+ {t('paywall.free')}
14
+ </Text>
15
+ <Text xs strong center width={40} color="primary">
16
+ {t('paywall.pro')}
17
+ </Text>
18
+ </View>
19
+
20
+ {features.map((feature, i) => (
21
+ <View key={i} row centerV paddingV="sm">
22
+ <View flex row gap="sm" centerV>
23
+ <Icon name={feature.icon || 'check-line'} size="sm" />
24
+ <Text sm flex>
25
+ {t(feature.label)}
26
+ </Text>
27
+ </View>
28
+ <View width={40} center>
29
+ {feature.free ? (
30
+ <Icon name="checkbox-circle-fill" primary size="sm" />
31
+ ) : (
32
+ <Icon name="close-circle-fill" color="text4_op40" size="sm" />
33
+ )}
34
+ </View>
35
+ <View width={40} center>
36
+ <Icon name="checkbox-circle-fill" primary size="sm" />
37
+ </View>
38
+ </View>
39
+ ))}
40
+ </View>
41
+ )
42
+ }
@@ -0,0 +1,16 @@
1
+ import { Button, Link, View, useTranslation } from '@neko-os/ui'
2
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
3
+
4
+ export default function PaywallFooter({ onPurchase, onRestore, loading, trialDays, footerPaddingB }) {
5
+ const { t } = useTranslation('subscription')
6
+ const { bottom } = useSafeAreaInsets()
7
+
8
+ const ctaLabel = trialDays ? t('paywall.ctaTrial') : t('paywall.cta')
9
+
10
+ return (
11
+ <View gap="sm" padding="md" paddingB={footerPaddingB || Math.max(bottom / 2, 16)} borderT shadow={!footerPaddingB}>
12
+ <Button label={ctaLabel} onPress={onPurchase} disabled={loading} loading={loading} />
13
+ <Link label={t('paywall.restore')} onPress={onRestore} disabled={loading} xs text3 center underline />
14
+ </View>
15
+ )
16
+ }
@@ -0,0 +1,33 @@
1
+ import { Image, ParallaxHeader, SafeAreaView, Text, View } from '@neko-os/ui'
2
+
3
+ import PaywallReturnIcon from './PaywallReturnIcon'
4
+
5
+ export default function PaywallHero({ image, title, subtitle, showReturnIcon, modal }) {
6
+ const IMAGE_HEIGHT = modal ? 180 : 280
7
+ return (
8
+ <>
9
+ {image && (
10
+ <ParallaxHeader height={IMAGE_HEIGHT} disableResistence>
11
+ <Image source={image} height={IMAGE_HEIGHT} fullW br={0} />
12
+ </ParallaxHeader>
13
+ )}
14
+
15
+ {showReturnIcon && (
16
+ <SafeAreaView absolute top="sm" left="md" zIndex={100}>
17
+ <PaywallReturnIcon modal={modal} />
18
+ </SafeAreaView>
19
+ )}
20
+
21
+ <View brT="xxxl" bg="mainBG" marginT={-25} paddingH="md" gap="xs" paddingV="xl">
22
+ <Text h2 strong center>
23
+ {title}
24
+ </Text>
25
+ {subtitle && (
26
+ <Text sm text3 center>
27
+ {subtitle}
28
+ </Text>
29
+ )}
30
+ </View>
31
+ </>
32
+ )
33
+ }