umpordez 1.4.0 → 1.5.1
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.
- package/.claude/settings.local.json +10 -1
- package/create.mjs +121 -2
- package/package.json +1 -1
- package/template/AGENTS.md +1 -1
- package/template/CLAUDE.md +1 -1
- package/template/README.md +1 -1
- package/template/doc/index.html +5 -2
- package/template/gitignore +2 -0
- package/template/install.sh +4 -0
- package/template/mobile/{{PROJECT_SLUG}}/CLAUDE.md +7 -4
- package/template/mobile/{{PROJECT_SLUG}}/app.json +2 -4
- package/template/mobile/{{PROJECT_SLUG}}/eslint.config.js +12 -1
- package/template/mobile/{{PROJECT_SLUG}}/native/README.md +3 -3
- package/template/mobile/{{PROJECT_SLUG}}/package.json +1 -3
- package/template/mobile/{{PROJECT_SLUG}}/src/components/AnimatedSplash.tsx +5 -1
- package/template/mobile/{{PROJECT_SLUG}}/src/components/PremiumGate.tsx +4 -1
- package/template/mobile/{{PROJECT_SLUG}}/src/config.ts +10 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/content/signing.ts +2 -1
- package/template/mobile/{{PROJECT_SLUG}}/src/services/iap/index.ts +17 -22
- package/template/scripts/clean-appledouble.sh +42 -0
- package/template/scripts/prebuild.sh +8 -1
- package/template/scripts/run-ios.sh +5 -0
- package/template-variables.mjs +56 -0
|
@@ -54,7 +54,16 @@
|
|
|
54
54
|
"Bash(tar -xzf expo-57.0.4.tgz package/bundledNativeModules.json package/package.json)",
|
|
55
55
|
"Bash(npx expo-doctor *)",
|
|
56
56
|
"Bash(pkill -f \"expo start --port 8090\")",
|
|
57
|
-
"Bash(git *)"
|
|
57
|
+
"Bash(git *)",
|
|
58
|
+
"Bash(npm init *)",
|
|
59
|
+
"Bash(awk 'NR>=383 && NR<=386 {print NR\": \"substr\\($0,1,120\\)}' '/Users/ligeiro/dev/umpordez-cli/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/AuthLanding.tsx')",
|
|
60
|
+
"Bash(awk 'NR>=394 && NR<=410 {print NR\": \"substr\\($0,1,100\\)}' '/Users/ligeiro/dev/umpordez-cli/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/AuthLanding.tsx')",
|
|
61
|
+
"Bash(diskutil info *)",
|
|
62
|
+
"Bash(mount)",
|
|
63
|
+
"Bash(bash -n template/scripts/clean-appledouble.sh)",
|
|
64
|
+
"Bash(bash -n template/scripts/prebuild.sh)",
|
|
65
|
+
"Bash(bash -n template/scripts/run-ios.sh)",
|
|
66
|
+
"Bash(bash -n template/install.sh)"
|
|
58
67
|
]
|
|
59
68
|
}
|
|
60
69
|
}
|
package/create.mjs
CHANGED
|
@@ -146,6 +146,7 @@ async function promptComponents() {
|
|
|
146
146
|
// profile.
|
|
147
147
|
let authApple = true;
|
|
148
148
|
let authGoogle = true;
|
|
149
|
+
let iap = true;
|
|
149
150
|
if (mobile) {
|
|
150
151
|
({ authApple } = await inquirer.prompt([{
|
|
151
152
|
type: 'confirm',
|
|
@@ -161,9 +162,19 @@ async function promptComponents() {
|
|
|
161
162
|
+ 'OAuth flow)',
|
|
162
163
|
default: true,
|
|
163
164
|
}]));
|
|
165
|
+
// Disabling IAP strips the expo-iap dependency, its config
|
|
166
|
+
// plugin and the Android BILLING permission — PremiumGate
|
|
167
|
+
// then renders everything unlocked (a fully free app).
|
|
168
|
+
({ iap } = await inquirer.prompt([{
|
|
169
|
+
type: 'confirm',
|
|
170
|
+
name: 'iap',
|
|
171
|
+
message: 'In-app purchases? (premium subscriptions via '
|
|
172
|
+
+ 'expo-iap + server receipt validation)',
|
|
173
|
+
default: true,
|
|
174
|
+
}]));
|
|
164
175
|
}
|
|
165
176
|
|
|
166
|
-
return { components, appLanding, authApple, authGoogle };
|
|
177
|
+
return { components, appLanding, authApple, authGoogle, iap };
|
|
167
178
|
}
|
|
168
179
|
|
|
169
180
|
async function promptProjectDetails() {
|
|
@@ -172,7 +183,7 @@ async function promptProjectDetails() {
|
|
|
172
183
|
console.log('');
|
|
173
184
|
|
|
174
185
|
const {
|
|
175
|
-
components, appLanding, authApple, authGoogle
|
|
186
|
+
components, appLanding, authApple, authGoogle, iap
|
|
176
187
|
} = await promptComponents();
|
|
177
188
|
const preset = presetNameForComponents(components);
|
|
178
189
|
|
|
@@ -355,6 +366,7 @@ async function promptProjectDetails() {
|
|
|
355
366
|
appLanding,
|
|
356
367
|
authApple,
|
|
357
368
|
authGoogle,
|
|
369
|
+
iap,
|
|
358
370
|
name: name.trim(),
|
|
359
371
|
// Defaults for the parts the user didn't see — they're still
|
|
360
372
|
// referenced by replacements (some unconditionally), so we fill
|
|
@@ -382,6 +394,104 @@ function validatePort(v) {
|
|
|
382
394
|
: 'Must be a valid port number';
|
|
383
395
|
}
|
|
384
396
|
|
|
397
|
+
// Filesystems that can't store extended attributes or symlinks. On
|
|
398
|
+
// macOS these make the OS scatter AppleDouble `._*` sidecar files next
|
|
399
|
+
// to everything npm installs — CocoaPods then fails parsing a
|
|
400
|
+
// `._Foo.podspec` during `expo prebuild`, and node_modules symlinks
|
|
401
|
+
// break. Generating an Expo project on one of these is a trap, so we
|
|
402
|
+
// warn before doing it.
|
|
403
|
+
const NON_NATIVE_FS = new Set([
|
|
404
|
+
'exfat', 'msdos', 'fat32', 'vfat',
|
|
405
|
+
'ntfs', 'ntfs-3g', 'fuseblk',
|
|
406
|
+
'smbfs', 'cifs', 'nfs', 'afpfs', 'webdav',
|
|
407
|
+
]);
|
|
408
|
+
|
|
409
|
+
// Resolve the filesystem type backing `targetPath`. The output dir
|
|
410
|
+
// usually doesn't exist yet, so we walk up to the nearest existing
|
|
411
|
+
// ancestor, then match it against the longest mount point in `mount`.
|
|
412
|
+
// Returns { mountPoint, fsType } or null (non-darwin, or undetectable).
|
|
413
|
+
function volumeInfo(targetPath) {
|
|
414
|
+
if (process.platform !== 'darwin') {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let dir = path.resolve(targetPath);
|
|
419
|
+
while (dir !== path.dirname(dir) && !fs.existsSync(dir)) {
|
|
420
|
+
dir = path.dirname(dir);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let mounts;
|
|
424
|
+
try {
|
|
425
|
+
mounts = execSync('mount', { encoding: 'utf-8' });
|
|
426
|
+
} catch {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Lines look like: `/dev/disk10s1 on /Volumes/big (exfat, local, …)`
|
|
431
|
+
let best = null;
|
|
432
|
+
for (const line of mounts.split('\n')) {
|
|
433
|
+
const m = line.match(/ on (.+?) \(([^,)]+)/);
|
|
434
|
+
if (!m) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const mountPoint = m[1];
|
|
438
|
+
const fsType = m[2].trim().toLowerCase();
|
|
439
|
+
const prefix = mountPoint.endsWith('/') ? mountPoint : mountPoint + '/';
|
|
440
|
+
if (dir === mountPoint || dir.startsWith(prefix)) {
|
|
441
|
+
if (!best || mountPoint.length > best.mountPoint.length) {
|
|
442
|
+
best = { mountPoint, fsType };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return best;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Warn (and confirm) before generating a mobile project onto a
|
|
450
|
+
// filesystem macOS can't build native code on. Returns true to
|
|
451
|
+
// proceed, false to abort. Only meaningful when a mobile app is part
|
|
452
|
+
// of the project — the acute failure is CocoaPods + `expo prebuild`.
|
|
453
|
+
async function confirmFilesystem(outputDir, components) {
|
|
454
|
+
if (!components.mobile) {
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
const vol = volumeInfo(outputDir);
|
|
458
|
+
if (!vol || !NON_NATIVE_FS.has(vol.fsType)) {
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
console.log('');
|
|
463
|
+
console.log(red.bold(
|
|
464
|
+
` ⚠ ${vol.mountPoint} is a ${vol.fsType} volume.`
|
|
465
|
+
));
|
|
466
|
+
console.log(dim(
|
|
467
|
+
' macOS can\'t store extended attributes or symlinks there,'
|
|
468
|
+
));
|
|
469
|
+
console.log(dim(
|
|
470
|
+
' so it scatters AppleDouble `._*` files next to everything'
|
|
471
|
+
));
|
|
472
|
+
console.log(dim(
|
|
473
|
+
' npm installs. `expo prebuild` then fails when CocoaPods'
|
|
474
|
+
));
|
|
475
|
+
console.log(dim(
|
|
476
|
+
' tries to parse a `._Foo.podspec`, and node_modules'
|
|
477
|
+
));
|
|
478
|
+
console.log(dim(' symlinks break.'));
|
|
479
|
+
console.log('');
|
|
480
|
+
console.log(dim(
|
|
481
|
+
' Recommended: generate on your internal drive (e.g. ~/dev),'
|
|
482
|
+
));
|
|
483
|
+
console.log(dim(' an APFS / Mac OS Extended volume.'));
|
|
484
|
+
console.log('');
|
|
485
|
+
|
|
486
|
+
const { proceed } = await inquirer.prompt([{
|
|
487
|
+
type: 'confirm',
|
|
488
|
+
name: 'proceed',
|
|
489
|
+
message: `Generate on the ${vol.fsType} volume anyway?`,
|
|
490
|
+
default: false,
|
|
491
|
+
}]);
|
|
492
|
+
return proceed;
|
|
493
|
+
}
|
|
494
|
+
|
|
385
495
|
function componentsLabel(c) {
|
|
386
496
|
const parts = [];
|
|
387
497
|
if (c.server) parts.push('server API');
|
|
@@ -432,6 +542,9 @@ function showSummary(details) {
|
|
|
432
542
|
if (details.authApple) providers.push('Apple');
|
|
433
543
|
if (details.authGoogle) providers.push('Google');
|
|
434
544
|
console.log(` ${chalk.bold('Sign-in:')} ${providers.join(' | ')}`);
|
|
545
|
+
console.log(` ${chalk.bold('IAP:')} ${details.iap
|
|
546
|
+
? 'premium subscriptions (expo-iap)'
|
|
547
|
+
: 'none (all features unlocked)'}`);
|
|
435
548
|
}
|
|
436
549
|
console.log('');
|
|
437
550
|
console.log(dim(' Generates:'));
|
|
@@ -589,6 +702,11 @@ export async function createProject() {
|
|
|
589
702
|
|
|
590
703
|
const outputDir = path.resolve(details.outputDir);
|
|
591
704
|
|
|
705
|
+
if (!await confirmFilesystem(outputDir, details.components)) {
|
|
706
|
+
console.log(chalk.yellow('Aborted. No files were created.'));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
592
710
|
if (fs.existsSync(outputDir)) {
|
|
593
711
|
const { overwrite } = await inquirer.prompt([{
|
|
594
712
|
type: 'confirm',
|
|
@@ -695,6 +813,7 @@ export async function createProject() {
|
|
|
695
813
|
if (details.components.mobile) {
|
|
696
814
|
scripts.push(
|
|
697
815
|
'scripts/bump-version.sh',
|
|
816
|
+
'scripts/clean-appledouble.sh',
|
|
698
817
|
'scripts/prebuild.sh',
|
|
699
818
|
'scripts/run-ios.sh',
|
|
700
819
|
'scripts/release-android.sh',
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -312,7 +312,7 @@ if (!ok) return;
|
|
|
312
312
|
- **Services own data access** — pages never call `getDb()` directly
|
|
313
313
|
- **User-scoped writes check `demandSigned()`** — reads always work, writes no-op when gate closed
|
|
314
314
|
- Every string through `t()` — no bare text in JSX
|
|
315
|
-
-
|
|
315
|
+
- Native-module crash in Expo Go (e.g. `expo-iap`) — lazy-load with `try/require`
|
|
316
316
|
- Never `expo prebuild --clean` (wipes native code; use `scripts/prebuild.sh`)
|
|
317
317
|
|
|
318
318
|
### i18n Coverage
|
package/template/CLAUDE.md
CHANGED
|
@@ -97,7 +97,7 @@ HTTP request builds a fresh `Context` with every model instantiated
|
|
|
97
97
|
| 1 | auth flows, theme, navigation, splash, toasts, typed fetch |
|
|
98
98
|
| 2 | offline SQLite (USER vs SHARED tables), cloud backup/restore |
|
|
99
99
|
| 3 | server-driven content sync + HMAC-signed presigned asset URLs |
|
|
100
|
-
| 4 | IAP via `
|
|
100
|
+
| 4 | IAP via `expo-iap` (optional at scaffold), premium gate, foreground re-check |
|
|
101
101
|
| 5 | custom-native local notifications with cold-start tap dispatch |
|
|
102
102
|
| 6 | Apple Health + Android Health Connect unified surface |
|
|
103
103
|
| 7 | native cookbook (Hello iOS/Android + Live Activity scaffold) |
|
package/template/README.md
CHANGED
|
@@ -112,7 +112,7 @@ HTTP request builds a fresh `Context` with every model instantiated
|
|
|
112
112
|
| 1 | auth flows, theme, navigation, splash, toasts, typed fetch |
|
|
113
113
|
| 2 | offline SQLite (USER vs SHARED tables), cloud backup/restore via S3 |
|
|
114
114
|
| 3 | server-driven content sync + HMAC-signed presigned asset URLs |
|
|
115
|
-
| 4 | IAP (`
|
|
115
|
+
| 4 | IAP (`expo-iap`, optional at scaffold), receipt validation, premium gate, foreground re-check |
|
|
116
116
|
| 5 | custom-native local notifications with cold-start tap dispatch |
|
|
117
117
|
| 6 | Apple Health + Android Health Connect unified surface |
|
|
118
118
|
| 7 | native modules cookbook (Hello iOS/Android + Live Activity scaffold + gotchas) |
|
package/template/doc/index.html
CHANGED
|
@@ -514,8 +514,11 @@ POST /api/logout</code></pre>
|
|
|
514
514
|
<section id="iap">
|
|
515
515
|
<h2>IAP & premium <span class="badge">Phase 4</span></h2>
|
|
516
516
|
<p>
|
|
517
|
-
<code>
|
|
518
|
-
|
|
517
|
+
<code>expo-iap</code> lazy-loaded with try/require so
|
|
518
|
+
Expo Go gets graceful no-ops. Optional at scaffold
|
|
519
|
+
time — when disabled, the dependency ships
|
|
520
|
+
stripped and PremiumGate renders everything
|
|
521
|
+
unlocked.
|
|
519
522
|
</p>
|
|
520
523
|
<pre><code>const { activatePremium } = useAuth();
|
|
521
524
|
const purchase = await purchaseSubscription(PRODUCT_IDS.annual);
|
package/template/gitignore
CHANGED
package/template/install.sh
CHANGED
|
@@ -55,6 +55,10 @@ if [ -d "mobile/{{PROJECT_SLUG}}" ]; then
|
|
|
55
55
|
|
|
56
56
|
if [[ "$(uname)" == "Darwin" ]]; then
|
|
57
57
|
if [ -d "mobile/{{PROJECT_SLUG}}/ios" ]; then
|
|
58
|
+
# Strip AppleDouble `._*` files (exFAT/NTFS/network
|
|
59
|
+
# volumes) so pod install doesn't choke on a
|
|
60
|
+
# `._Foo.podspec`. No-op on APFS.
|
|
61
|
+
./scripts/clean-appledouble.sh "mobile/{{PROJECT_SLUG}}"
|
|
58
62
|
echo "Installing iOS pods..."
|
|
59
63
|
(cd "mobile/{{PROJECT_SLUG}}/ios" && pod install)
|
|
60
64
|
fi
|
|
@@ -25,7 +25,7 @@ src/
|
|
|
25
25
|
│ ├── db/ SQLite (USER_TABLES vs SHARED_TABLES)
|
|
26
26
|
│ ├── backup/ Two-phase S3 backup (premium)
|
|
27
27
|
│ ├── content/ Versioned catalog + HMAC-signed URLs
|
|
28
|
-
│ ├── iap/
|
|
28
|
+
│ ├── iap/ expo-iap wrapped lazily (optional)
|
|
29
29
|
│ └── notifications/ Custom-native, cold-start tap dispatch
|
|
30
30
|
└── components/
|
|
31
31
|
├── AnimatedSplash.tsx
|
|
@@ -86,9 +86,12 @@ hasCompletedOnboarding
|
|
|
86
86
|
- **Every user-facing string goes through `t()`.** Even pt-BR
|
|
87
87
|
default strings. No bare strings in JSX.
|
|
88
88
|
|
|
89
|
-
- **
|
|
90
|
-
|
|
91
|
-
`try/require`. See `services/iap/index.ts`.
|
|
89
|
+
- **Native modules + Expo Go = crash on import.** Anything whose
|
|
90
|
+
native side isn't bundled in Expo Go (e.g. `expo-iap`) must be
|
|
91
|
+
lazy-loaded with `try/require`. See `services/iap/index.ts`.
|
|
92
|
+
IAP itself is optional at scaffold time: `config.iap === false`
|
|
93
|
+
means the expo-iap dependency was never installed and PremiumGate
|
|
94
|
+
renders everything unlocked.
|
|
92
95
|
|
|
93
96
|
- **`expo prebuild --clean` will silently delete native code.**
|
|
94
97
|
Use `./scripts/prebuild.sh` (refuses --clean). To genuinely
|
|
@@ -22,8 +22,7 @@
|
|
|
22
22
|
"backgroundColor": "{{PRIMARY_COLOR}}"
|
|
23
23
|
},
|
|
24
24
|
"permissions": [
|
|
25
|
-
"android.permission.INTERNET"
|
|
26
|
-
"com.android.vending.BILLING"
|
|
25
|
+
"android.permission.INTERNET"{{IAP_BILLING_PERMISSION_JSON}}
|
|
27
26
|
]
|
|
28
27
|
},
|
|
29
28
|
"web": {
|
|
@@ -51,8 +50,7 @@
|
|
|
51
50
|
}
|
|
52
51
|
],
|
|
53
52
|
"expo-web-browser",{{APPLE_AUTH_PLUGIN_JSON}}
|
|
54
|
-
"expo-sqlite"
|
|
55
|
-
"react-native-iap"
|
|
53
|
+
"expo-sqlite"{{IAP_PLUGIN_JSON}}
|
|
56
54
|
],
|
|
57
55
|
"extra": {
|
|
58
56
|
"eas": {
|
|
@@ -11,7 +11,18 @@ export default [
|
|
|
11
11
|
rules: {
|
|
12
12
|
"@stylistic/indent": ["error", 4],
|
|
13
13
|
"@stylistic/semi": ["error", "always"],
|
|
14
|
-
|
|
14
|
+
// ignorePattern: SVG path data (brand icons) can't be
|
|
15
|
+
// wrapped without corrupting the artwork.
|
|
16
|
+
"@stylistic/max-len": ["error", {
|
|
17
|
+
"code": 80,
|
|
18
|
+
"ignorePattern": "\\sd=\""
|
|
19
|
+
}],
|
|
20
|
+
// Underscore prefix = intentionally unused (e.g. a screen
|
|
21
|
+
// that takes navigation props but doesn't read them yet).
|
|
22
|
+
"@typescript-eslint/no-unused-vars": ["error", {
|
|
23
|
+
"argsIgnorePattern": "^_",
|
|
24
|
+
"varsIgnorePattern": "^_"
|
|
25
|
+
}],
|
|
15
26
|
"curly": ["error", "all"]
|
|
16
27
|
}
|
|
17
28
|
}
|
|
@@ -89,9 +89,9 @@ these are auto-installed** — copy out what you need:
|
|
|
89
89
|
`scripts/prebuild.sh` refuses `--clean`. To genuinely reset, move
|
|
90
90
|
customizations into config plugins under `./plugins/` first.
|
|
91
91
|
|
|
92
|
-
- **
|
|
93
|
-
(e.g.
|
|
94
|
-
`try/require` so Expo Go doesn't blow up. See
|
|
92
|
+
- **Native modules + Expo Go = crash on import.** Anything whose
|
|
93
|
+
native side isn't in Expo Go (e.g. expo-iap) needs to be
|
|
94
|
+
lazy-loaded with `try/require` so Expo Go doesn't blow up. See
|
|
95
95
|
`src/services/iap/index.ts` for the pattern.
|
|
96
96
|
|
|
97
97
|
- **iOS HealthKit needs an entitlement.** It's wired in `app.json`
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"expo-auth-session": "~57.0.2",
|
|
29
29
|
"expo-build-properties": "~57.0.3",
|
|
30
30
|
"expo-constants": "~57.0.3",
|
|
31
|
-
"expo-file-system": "~57.0.0",
|
|
31
|
+
"expo-file-system": "~57.0.0",{{IAP_DEP_JSON}}
|
|
32
32
|
"expo-linking": "~57.0.2",
|
|
33
33
|
"expo-localization": "~57.0.0",
|
|
34
34
|
"expo-splash-screen": "~57.0.2",
|
|
@@ -42,8 +42,6 @@
|
|
|
42
42
|
"react-native": "0.86.0",
|
|
43
43
|
"react-i18next": "^15.1.1",
|
|
44
44
|
"react-native-gesture-handler": "~2.32.0",
|
|
45
|
-
"react-native-iap": "^15.3.6",
|
|
46
|
-
"react-native-nitro-modules": "^0.35.4",
|
|
47
45
|
"react-native-reanimated": "4.5.0",
|
|
48
46
|
"react-native-safe-area-context": "~5.7.0",
|
|
49
47
|
"react-native-screens": "4.25.2",
|
|
@@ -25,6 +25,10 @@ const { width, height } = Dimensions.get('window');
|
|
|
25
25
|
const BG = DARK.bg;
|
|
26
26
|
const GLOW_COLOR = DARK.primary;
|
|
27
27
|
|
|
28
|
+
// Static asset — require() is the RN idiom for bundled images.
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
30
|
+
const SPLASH_ICON = require('../../assets/splash-icon.png');
|
|
31
|
+
|
|
28
32
|
// Glow + ring sit at screen-centre because our generated splash-icon
|
|
29
33
|
// is a trimmed glyph composited onto a transparent square canvas (see
|
|
30
34
|
// icons.mjs renderFromScratch — splash mode). With resizeMode:contain
|
|
@@ -177,7 +181,7 @@ export default function AnimatedSplash({ onDone }: Props) {
|
|
|
177
181
|
/>
|
|
178
182
|
|
|
179
183
|
<Animated.Image
|
|
180
|
-
source={
|
|
184
|
+
source={SPLASH_ICON}
|
|
181
185
|
style={[
|
|
182
186
|
s.splash,
|
|
183
187
|
{ opacity: logoOp, transform: [{ scale: logoSc }] }
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from 'react-native';
|
|
8
8
|
import { Lock } from 'lucide-react-native';
|
|
9
9
|
import { useTranslation } from 'react-i18next';
|
|
10
|
+
import config from '../config';
|
|
10
11
|
import { useAuth } from '../contexts/AuthContext';
|
|
11
12
|
import { useTheme } from '../contexts/ThemeContext';
|
|
12
13
|
|
|
@@ -39,7 +40,9 @@ export default function PremiumGate({
|
|
|
39
40
|
const finalDescription =
|
|
40
41
|
description ?? t('premiumGate.defaultDescription');
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
// Projects scaffolded without in-app purchases have nothing to
|
|
44
|
+
// sell — every feature renders unlocked.
|
|
45
|
+
if (!config.iap || user?.is_premium) {
|
|
43
46
|
return <>{children}</>;
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -56,6 +56,15 @@ export const SPLASH_ANIMATED = {{SPLASH_ANIMATED}};
|
|
|
56
56
|
const GOOGLE_AUTH_ENABLED = {{AUTH_GOOGLE_ENABLED}};
|
|
57
57
|
const APPLE_AUTH_ENABLED = {{AUTH_APPLE_ENABLED}};
|
|
58
58
|
|
|
59
|
+
// In-app purchases, chosen at scaffold time via `umpordez create`.
|
|
60
|
+
// When disabled the expo-iap dependency, config plugin and Android
|
|
61
|
+
// BILLING permission were stripped from the scaffold — PremiumGate
|
|
62
|
+
// renders its children unlocked and the iap service no-ops. To turn
|
|
63
|
+
// IAP on later: `npx expo install expo-iap`, add "expo-iap" to
|
|
64
|
+
// app.json plugins, re-add com.android.vending.BILLING, restore the
|
|
65
|
+
// try/require in src/services/iap/index.ts and flip this to true.
|
|
66
|
+
const IAP_ENABLED = {{IAP_ENABLED}};
|
|
67
|
+
|
|
59
68
|
if (__DEV__) {
|
|
60
69
|
console.log(
|
|
61
70
|
`[config] API: ${baseApiUrl} | prod=${isProdBuild} `
|
|
@@ -73,5 +82,6 @@ export default {
|
|
|
73
82
|
googleIosClientId: GOOGLE_IOS_CLIENT_ID,
|
|
74
83
|
googleAuth: GOOGLE_AUTH_ENABLED,
|
|
75
84
|
appleAuth: APPLE_AUTH_ENABLED,
|
|
85
|
+
iap: IAP_ENABLED,
|
|
76
86
|
scheme: '{{MOBILE_SCHEME}}'
|
|
77
87
|
};
|
|
@@ -19,7 +19,8 @@ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
|
|
|
19
19
|
|
|
20
20
|
// CHANGE THIS to a long random string and keep it in sync with
|
|
21
21
|
// server/.env CONTENT_SECRET. Same value at both ends.
|
|
22
|
-
export const CONTENT_SECRET =
|
|
22
|
+
export const CONTENT_SECRET =
|
|
23
|
+
'{{CONTENT_SECRET}}';
|
|
23
24
|
|
|
24
25
|
interface SignedHeaders {
|
|
25
26
|
'X-Content-Timestamp': string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* In-App Purchase service — wraps `
|
|
2
|
+
* In-App Purchase service — wraps `expo-iap` (OpenIAP) with:
|
|
3
3
|
*
|
|
4
|
-
* - Lazy-load via try/require so Expo Go (no
|
|
4
|
+
* - Lazy-load via try/require so Expo Go (no native module) gets
|
|
5
5
|
* graceful no-ops instead of crashing on import.
|
|
6
6
|
* - One source of truth for product IDs (PRODUCT_IDS below — change
|
|
7
7
|
* them to match what you set up in App Store Connect / Play Console).
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { Platform } from 'react-native';
|
|
16
|
+
import config from '../../config';
|
|
16
17
|
import { fetchApi } from '../fetch-api';
|
|
17
18
|
import type { ApiUser } from '../api';
|
|
18
19
|
|
|
@@ -35,22 +36,13 @@ interface IAPNative {
|
|
|
35
36
|
isConsumable: boolean;
|
|
36
37
|
}) => Promise<unknown>;
|
|
37
38
|
getAvailablePurchases: () => Promise<NativePurchase[]>;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}) => Promise<unknown>;
|
|
39
|
+
deepLinkToSubscriptions?: (args?: {
|
|
40
|
+
packageNameAndroid?: string;
|
|
41
|
+
skuAndroid?: string;
|
|
42
|
+
} | null) => Promise<unknown>;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
48
|
-
_iap = require('react-native-iap') as IAPNative;
|
|
49
|
-
} catch {
|
|
50
|
-
console.info(
|
|
51
|
-
'[iap] react-native-iap not available (Expo Go) — IAP disabled'
|
|
52
|
-
);
|
|
53
|
-
}
|
|
45
|
+
{{IAP_REQUIRE_BLOCK}}
|
|
54
46
|
|
|
55
47
|
const isAvailable = !!_iap;
|
|
56
48
|
|
|
@@ -60,8 +52,8 @@ const isAvailable = !!_iap;
|
|
|
60
52
|
// product IDs) and Google Play Console (Monetize → Subscriptions). The
|
|
61
53
|
// strings can be anything but must match across all three places.
|
|
62
54
|
export const PRODUCT_IDS = {
|
|
63
|
-
monthly: '{{
|
|
64
|
-
annual: '{{
|
|
55
|
+
monthly: '{{PROJECT_SLUG_UNDERSCORE}}_premium_monthly',
|
|
56
|
+
annual: '{{PROJECT_SLUG_UNDERSCORE}}_premium_annual'
|
|
65
57
|
} as const;
|
|
66
58
|
|
|
67
59
|
const PRODUCT_ID_LIST = Object.values(PRODUCT_IDS);
|
|
@@ -314,7 +306,7 @@ export async function openSubscriptionManagement(): Promise<boolean> {
|
|
|
314
306
|
}
|
|
315
307
|
try {
|
|
316
308
|
if (Platform.OS === 'ios') {
|
|
317
|
-
await _iap!.
|
|
309
|
+
await _iap!.deepLinkToSubscriptions?.();
|
|
318
310
|
return true;
|
|
319
311
|
}
|
|
320
312
|
await _iap!.deepLinkToSubscriptions?.({
|
|
@@ -350,9 +342,9 @@ export async function validateWithServer(
|
|
|
350
342
|
): Promise<ValidateResponse> {
|
|
351
343
|
let body: Record<string, unknown>;
|
|
352
344
|
if (Platform.OS === 'ios') {
|
|
353
|
-
// StoreKit 2 /
|
|
354
|
-
//
|
|
355
|
-
//
|
|
345
|
+
// StoreKit 2 / expo-iap no longer exposes a receipt blob —
|
|
346
|
+
// server validates via transactionId using the App Store
|
|
347
|
+
// Server API.
|
|
356
348
|
const originalTransactionId =
|
|
357
349
|
purchase.originalTransactionIdentifierIOS
|
|
358
350
|
?? purchase.transactionId;
|
|
@@ -397,6 +389,9 @@ export async function fetchPremiumStatus(token: string): Promise<{
|
|
|
397
389
|
ok: boolean;
|
|
398
390
|
is_premium: boolean;
|
|
399
391
|
}> {
|
|
392
|
+
if (!config.iap) {
|
|
393
|
+
return { ok: false, is_premium: false };
|
|
394
|
+
}
|
|
400
395
|
try {
|
|
401
396
|
return await fetchApi('/api/iap/status', { token });
|
|
402
397
|
} catch (err) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# clean-appledouble — remove macOS AppleDouble `._*` sidecar files.
|
|
4
|
+
#
|
|
5
|
+
# macOS drops a `._<name>` metadata file next to every file that carries
|
|
6
|
+
# extended attributes when it lives on a filesystem that can't store
|
|
7
|
+
# xattrs natively (exFAT, MS-DOS/FAT, NTFS, SMB/NFS network mounts).
|
|
8
|
+
# CocoaPods then scans for `*.podspec`, finds `._Foo.podspec`, tries to
|
|
9
|
+
# parse the binary metadata as Ruby, and `pod install` — and therefore
|
|
10
|
+
# `expo prebuild` / `expo run:ios` — dies with:
|
|
11
|
+
#
|
|
12
|
+
# Invalid `Podfile` file: Invalid podspec file at path
|
|
13
|
+
# .../node_modules/.../ios/._Foo.podspec
|
|
14
|
+
#
|
|
15
|
+
# On APFS / Mac OS Extended there are none, so this is a fast no-op. Run
|
|
16
|
+
# it before any pod install / prebuild / native build.
|
|
17
|
+
#
|
|
18
|
+
# The real fix is to keep the project on an APFS / Mac OS Extended disk
|
|
19
|
+
# (e.g. ~/dev) — exFAT/NTFS regenerate these files on every npm install.
|
|
20
|
+
#
|
|
21
|
+
# Usage: ./scripts/clean-appledouble.sh [dir] (default: repo root)
|
|
22
|
+
|
|
23
|
+
set -euo pipefail
|
|
24
|
+
|
|
25
|
+
# AppleDouble files are a macOS-only artifact.
|
|
26
|
+
[[ "$(uname)" == "Darwin" ]] || exit 0
|
|
27
|
+
|
|
28
|
+
TARGET="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
|
|
29
|
+
[[ -d "$TARGET" ]] || exit 0
|
|
30
|
+
|
|
31
|
+
n=$(
|
|
32
|
+
find "$TARGET" -name '._*' -type f -print -delete 2>/dev/null \
|
|
33
|
+
| wc -l | tr -d ' '
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if [[ "${n:-0}" -gt 0 ]]; then
|
|
37
|
+
echo "==> Removed $n AppleDouble ._* file(s) under $TARGET"
|
|
38
|
+
echo " This disk can't store macOS extended attributes (likely"
|
|
39
|
+
echo " exFAT/NTFS/network), so they'll return on the next npm"
|
|
40
|
+
echo " install. For clean native builds, move the project to an"
|
|
41
|
+
echo " APFS / Mac OS Extended disk (e.g. ~/dev)."
|
|
42
|
+
fi
|
|
@@ -24,5 +24,12 @@ for arg in "$@"; do
|
|
|
24
24
|
esac
|
|
25
25
|
done
|
|
26
26
|
|
|
27
|
-
cd "$(dirname "$0")
|
|
27
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
28
|
+
cd "$SCRIPT_DIR/../mobile/{{PROJECT_SLUG}}"
|
|
29
|
+
|
|
30
|
+
# Strip macOS AppleDouble `._*` files before prebuild — CocoaPods
|
|
31
|
+
# chokes on `._Foo.podspec` when the project lives on exFAT/NTFS/network
|
|
32
|
+
# volumes. No-op on APFS. See scripts/clean-appledouble.sh.
|
|
33
|
+
"$SCRIPT_DIR/clean-appledouble.sh" "$PWD"
|
|
34
|
+
|
|
28
35
|
exec npx expo prebuild "$@"
|
|
@@ -53,6 +53,11 @@ done
|
|
|
53
53
|
|
|
54
54
|
cd "$APP_DIR"
|
|
55
55
|
|
|
56
|
+
# Strip macOS AppleDouble `._*` files so the pod install triggered by
|
|
57
|
+
# `expo run:ios` doesn't choke on `._Foo.podspec` (exFAT/NTFS/network
|
|
58
|
+
# volumes). No-op on APFS. See scripts/clean-appledouble.sh.
|
|
59
|
+
"$SCRIPT_DIR/clean-appledouble.sh" "$APP_DIR"
|
|
60
|
+
|
|
56
61
|
if [[ $CLEAN -eq 1 ]]; then
|
|
57
62
|
echo "==> Cleaning iOS build artifacts..."
|
|
58
63
|
rm -rf ios/build
|
package/template-variables.mjs
CHANGED
|
@@ -52,6 +52,29 @@
|
|
|
52
52
|
// when off (the plugin adds the Sign in
|
|
53
53
|
// with Apple entitlement unconditionally,
|
|
54
54
|
// so it must not ship when disabled).
|
|
55
|
+
// {{PROJECT_SLUG_UNDERSCORE}} — Slug with dashes → underscores. Used
|
|
56
|
+
// for IAP product IDs (Apple rejects
|
|
57
|
+
// dashes in product identifiers).
|
|
58
|
+
// {{IAP_ENABLED}} — "true"/"false" — in-app purchases picked
|
|
59
|
+
// at scaffold time. Consumed by mobile
|
|
60
|
+
// src/config.ts; gates PremiumGate and the
|
|
61
|
+
// iap service.
|
|
62
|
+
// {{IAP_DEP_JSON}} — package.json fragment: the `"expo-iap"`
|
|
63
|
+
// dependency line when IAP is on, empty
|
|
64
|
+
// when off (expo-iap autolinks its native
|
|
65
|
+
// billing code whenever installed, so the
|
|
66
|
+
// dep itself must not ship when disabled).
|
|
67
|
+
// {{IAP_PLUGIN_JSON}} — app.json fragment: the `"expo-iap"`
|
|
68
|
+
// plugins entry when IAP is on, empty
|
|
69
|
+
// when off.
|
|
70
|
+
// {{IAP_BILLING_PERMISSION_JSON}} — app.json fragment: the Android
|
|
71
|
+
// `com.android.vending.BILLING` permission
|
|
72
|
+
// when IAP is on, empty when off.
|
|
73
|
+
// {{IAP_REQUIRE_BLOCK}} — services/iap/index.ts fragment: the lazy
|
|
74
|
+
// try/require of expo-iap when IAP is on;
|
|
75
|
+
// a stub comment when off (Metro resolves
|
|
76
|
+
// require() statically, so the literal
|
|
77
|
+
// must not appear when the dep is absent).
|
|
55
78
|
|
|
56
79
|
// ─── Site landing variants ────────────────────────────────────────────────────
|
|
57
80
|
// The public site ships two landing flavors. The SaaS landing (CTAs →
|
|
@@ -355,6 +378,7 @@ export function buildReplacements(details) {
|
|
|
355
378
|
'{{PROJECT_SLUG_UPPER}}': details.slug
|
|
356
379
|
.toUpperCase()
|
|
357
380
|
.replace(/-/g, '_'),
|
|
381
|
+
'{{PROJECT_SLUG_UNDERSCORE}}': details.slug.replace(/-/g, '_'),
|
|
358
382
|
'{{PROJECT_DESCRIPTION}}': details.description,
|
|
359
383
|
'{{DOMAIN}}': details.domain,
|
|
360
384
|
'{{DB_NAME}}': details.dbName,
|
|
@@ -402,5 +426,37 @@ export function buildReplacements(details) {
|
|
|
402
426
|
'{{APPLE_AUTH_PLUGIN_JSON}}': details.authApple === false
|
|
403
427
|
? ''
|
|
404
428
|
: '\n "expo-apple-authentication",',
|
|
429
|
+
// In-app purchases chosen at scaffold time. Defaults to
|
|
430
|
+
// enabled so legacy manifests (which never recorded a
|
|
431
|
+
// choice) keep their current behavior on update. When
|
|
432
|
+
// disabled, the expo-iap dependency / config plugin /
|
|
433
|
+
// BILLING permission / require() all disappear — the iap
|
|
434
|
+
// service and PremiumGate degrade to graceful no-ops.
|
|
435
|
+
'{{IAP_ENABLED}}': details.iap === false ? 'false' : 'true',
|
|
436
|
+
'{{IAP_DEP_JSON}}': details.iap === false
|
|
437
|
+
? ''
|
|
438
|
+
: '\n "expo-iap": "^4.4.0",',
|
|
439
|
+
'{{IAP_PLUGIN_JSON}}': details.iap === false
|
|
440
|
+
? ''
|
|
441
|
+
: ',\n "expo-iap"',
|
|
442
|
+
'{{IAP_BILLING_PERMISSION_JSON}}': details.iap === false
|
|
443
|
+
? ''
|
|
444
|
+
: ',\n "com.android.vending.BILLING"',
|
|
445
|
+
'{{IAP_REQUIRE_BLOCK}}': details.iap === false
|
|
446
|
+
? '// IAP disabled at scaffold time (`umpordez create`\n'
|
|
447
|
+
+ '// without in-app purchases) — expo-iap is not\n'
|
|
448
|
+
+ '// installed and every function below no-ops.\n'
|
|
449
|
+
+ 'const _iap: IAPNative | null = null;'
|
|
450
|
+
: 'let _iap: IAPNative | null = null;\n'
|
|
451
|
+
+ 'try {\n'
|
|
452
|
+
+ ' // eslint-disable-next-line '
|
|
453
|
+
+ '@typescript-eslint/no-require-imports\n'
|
|
454
|
+
+ ' _iap = require(\'expo-iap\') as IAPNative;\n'
|
|
455
|
+
+ '} catch {\n'
|
|
456
|
+
+ ' console.info(\n'
|
|
457
|
+
+ ' \'[iap] expo-iap not available (Expo Go)'
|
|
458
|
+
+ ' — IAP disabled\'\n'
|
|
459
|
+
+ ' );\n'
|
|
460
|
+
+ '}',
|
|
405
461
|
};
|
|
406
462
|
}
|