create-techtiten-app 0.0.0-stage → 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.
- package/README.md +48 -2
- package/bin/index.mjs +210 -0
- package/package.json +15 -4
- package/templates/mobile/ads/src/ads/Banner.tsx +14 -0
- package/templates/mobile/ads/src/ads/Banner.web.tsx +4 -0
- package/templates/mobile/common/app/_layout.tsx +36 -0
- package/templates/mobile/common/app/index.tsx +48 -0
- package/templates/mobile/common/app/settings.tsx +22 -0
- package/templates/mobile/common/app.config.ts +36 -0
- package/templates/mobile/common/config/plugins.json +1 -0
- package/templates/mobile/common/distribution/whatsnew/whatsnew-en-US +1 -0
- package/templates/mobile/common/plugins/withSigning.js +63 -0
- package/templates/mobile/common/src/api/client.ts +16 -0
- package/templates/mobile/common/src/theme/index.tsx +17 -0
- package/templates/mobile/common/src/ui/index.tsx +37 -0
- package/templates/mobile/common/tsconfig.json +8 -0
- package/templates/mobile/noads/src/ads/Banner.tsx +4 -0
- package/templates/mobile/redux/src/state/Providers.tsx +13 -0
- package/templates/mobile/redux/src/state/index.ts +16 -0
- package/templates/mobile/redux/src/state/store.ts +33 -0
- package/templates/mobile/zustand/src/state/Providers.tsx +7 -0
- package/templates/mobile/zustand/src/state/index.ts +25 -0
- package/templates/packages/shared/package.json +7 -0
- package/templates/packages/shared/src/format.ts +18 -0
- package/templates/packages/shared/src/index.ts +5 -0
- package/templates/packages/shared/src/money.ts +13 -0
- package/templates/packages/shared/src/schemas.ts +9 -0
- package/templates/packages/theme/package.json +7 -0
- package/templates/packages/theme/src/index.ts +53 -0
- package/templates/root/AGENTS.md +16 -0
- package/templates/root/README.md +22 -0
- package/templates/root/STATUS.md +13 -0
- package/templates/root/_github/workflows/android.yml +79 -0
- package/templates/root/_github/workflows/web-check.yml +19 -0
- package/templates/root/_gitignore +33 -0
- package/templates/root/package.json +13 -0
- package/templates/web/src/app/(site)/page.tsx +18 -0
- package/templates/web/src/app/admin/layout.tsx +17 -0
- package/templates/web/src/app/admin/page.tsx +32 -0
- package/templates/web/src/app/api/cron/[job]/route.ts +17 -0
- package/templates/web/src/app/api/health/route.ts +8 -0
- package/templates/web/src/app/globals.css +23 -0
- package/templates/web/src/app/layout.tsx +23 -0
- package/templates/web/src/proxy.ts +26 -0
- package/templates/web/src/server/jobs.ts +9 -0
package/README.md
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
|
-
#
|
|
1
|
+
# create-techtiten-app
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One command for a new app, with the same structure every time:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npx create-techtiten-app my-app
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
my-app/
|
|
11
|
+
├─ apps/mobile Expo (latest SDK), Expo Router, TypeScript
|
|
12
|
+
├─ apps/web Next.js (latest): website + /admin panel + /api backend (optional)
|
|
13
|
+
├─ packages/theme colors, spacing, radius, font sizes, shared by both apps
|
|
14
|
+
├─ packages/shared functions, types, zod schemas, shared by both apps
|
|
15
|
+
├─ .github/workflows Android build → Google Play (only when mobile changes) + web check
|
|
16
|
+
└─ AGENTS.md, STATUS.md, .gitignore (secrets blocked)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## It asks
|
|
20
|
+
| Question | Options |
|
|
21
|
+
|---|---|
|
|
22
|
+
| Display name, package id | e.g. `Retro Games`, `com.techtiten.retrogames` |
|
|
23
|
+
| Web | Next.js app for website + admin (basic auth) + API (health, cron jobs) |
|
|
24
|
+
| State | Zustand + TanStack Query (default) or Redux Toolkit + TanStack Query |
|
|
25
|
+
| Ads | AdMob banner (test ads in dev) |
|
|
26
|
+
|
|
27
|
+
Skip the questions: `npx create-techtiten-app my-app --yes --web --state=zustand --ads --name="My App" --package=com.techtiten.myapp`
|
|
28
|
+
Other flags: `--no-web`, `--no-ads`, `--skip-install`, `--no-git`.
|
|
29
|
+
|
|
30
|
+
## Always the latest
|
|
31
|
+
- The base apps come from `create-expo-app@latest` and `create-next-app@latest`, so a new project starts on the newest Expo SDK and Next.js.
|
|
32
|
+
- Native packages are added with `npx expo install`, which picks the versions that match that SDK. Nothing is pinned in this package.
|
|
33
|
+
- No `android/` or `ios/` folders are stored: they are generated at build time (`expo prebuild`), so the native side always matches the SDK.
|
|
34
|
+
- The Expo template's own `app.json` values (icons, splash, defaults) are kept in `apps/mobile/config/base.json`; your settings go on top in `app.config.ts`.
|
|
35
|
+
- One React version for the whole repo (web is pinned to the version Expo installed), so hooks never break.
|
|
36
|
+
|
|
37
|
+
## What you get
|
|
38
|
+
**Mobile:** theme provider (system/light/dark, saved), UI primitives (Screen, Card, Text, Button with haptics), state demo, API client + TanStack Query hook, release signing plugin (CI secrets or `credentials/signing.properties`), optional AdMob banner.
|
|
39
|
+
|
|
40
|
+
**Web:** landing page using the same theme colors, `/admin` dashboard behind `ADMIN_USER`/`ADMIN_PASSWORD` (`src/proxy.ts`), `GET /api/health` (shared zod schema), `POST /api/cron/[job]` protected by `CRON_API_KEY` for outside schedulers (Vercel functions are short-lived).
|
|
41
|
+
|
|
42
|
+
**CI:** `android.yml` builds a signed AAB and uploads to Play (internal track by default). Secrets: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEY_ALIAS`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_PASSWORD`, `ANDROID_SERVICE_ACCOUNT_JSON`.
|
|
43
|
+
|
|
44
|
+
## Develop this package
|
|
45
|
+
```
|
|
46
|
+
npm install
|
|
47
|
+
node bin/index.mjs test-app --yes # try it
|
|
48
|
+
npm publish # first time: npm login
|
|
49
|
+
```
|
package/bin/index.mjs
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// create-techtiten-app: scaffolds an npm-workspaces monorepo
|
|
3
|
+
// apps/mobile Expo (latest SDK, Expo Router, typed) always
|
|
4
|
+
// apps/web Next.js (latest): website + /admin + /api optional
|
|
5
|
+
// packages/theme colors, spacing, radius, fonts shared
|
|
6
|
+
// packages/shared functions, types, zod schemas shared
|
|
7
|
+
// Latest versions every time: the base apps come from create-expo-app / create-next-app,
|
|
8
|
+
// and native-aware packages are added with `npx expo install` (SDK-matched). Nothing native
|
|
9
|
+
// is committed: android/ and ios/ are generated at build time (CNG).
|
|
10
|
+
import * as p from '@clack/prompts';
|
|
11
|
+
import pc from 'picocolors';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const TPL = resolve(dirname(fileURLToPath(import.meta.url)), '../templates');
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const flag = (name) => argv.includes(`--${name}`);
|
|
20
|
+
const opt = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.split('=').slice(1).join('=');
|
|
21
|
+
const positional = argv.find((a) => !a.startsWith('--'));
|
|
22
|
+
const YES = flag('yes') || flag('y');
|
|
23
|
+
|
|
24
|
+
const slugify = (s) => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
25
|
+
const titleCase = (s) => s.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
26
|
+
const cancel = (v) => { if (p.isCancel(v)) { p.cancel('Cancelled.'); process.exit(0); } return v; };
|
|
27
|
+
|
|
28
|
+
function run(cmd, args, cwd, { quiet = false, env = {} } = {}) {
|
|
29
|
+
const r = spawnSync(cmd, args, { cwd, stdio: quiet ? 'pipe' : 'inherit', shell: process.platform === 'win32', env: { ...process.env, CI: '1', EXPO_NO_TELEMETRY: '1', NEXT_TELEMETRY_DISABLED: '1', ...env } });
|
|
30
|
+
if (r.status !== 0) {
|
|
31
|
+
if (quiet) process.stderr.write(String(r.stderr ?? '') + String(r.stdout ?? ''));
|
|
32
|
+
throw new Error(`Command failed: ${cmd} ${args.join(' ')} (in ${cwd})`);
|
|
33
|
+
}
|
|
34
|
+
return String(r.stdout ?? '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Copy a template folder, replacing __TOKENS__ in text files. Files named *.tpl lose the suffix;
|
|
38
|
+
// "_gitignore" becomes ".gitignore" (npm strips real .gitignore files from published packages).
|
|
39
|
+
function copyTemplate(from, to, vars) {
|
|
40
|
+
if (!existsSync(from)) return;
|
|
41
|
+
for (const name of readdirSync(from)) {
|
|
42
|
+
const src = join(from, name);
|
|
43
|
+
let outName = name.replace(/\.tpl$/, '');
|
|
44
|
+
if (outName === '_gitignore') outName = '.gitignore';
|
|
45
|
+
if (outName === '_github') outName = '.github';
|
|
46
|
+
const dest = join(to, outName);
|
|
47
|
+
if (statSync(src).isDirectory()) { mkdirSync(dest, { recursive: true }); copyTemplate(src, dest, vars); continue; }
|
|
48
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
49
|
+
const buf = readFileSync(src);
|
|
50
|
+
const isText = !/\.(png|jpg|jpeg|gif|ttf|otf|woff2?|ico)$/i.test(name);
|
|
51
|
+
writeFileSync(dest, isText ? buf.toString('utf8').replace(/__([A-Z_]+)__/g, (m, k) => (k in vars ? vars[k] : m)) : buf);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const readJson = (f) => JSON.parse(readFileSync(f, 'utf8'));
|
|
56
|
+
const writeJson = (f, o) => writeFileSync(f, JSON.stringify(o, null, 2) + '\n');
|
|
57
|
+
|
|
58
|
+
async function main() {
|
|
59
|
+
console.log();
|
|
60
|
+
p.intro(pc.bgGreen(pc.black(' create-techtiten-app ')));
|
|
61
|
+
|
|
62
|
+
const dirName = positional ?? (YES ? 'my-app' : cancel(await p.text({ message: 'Project folder name', placeholder: 'my-app', validate: (v) => (slugify(v || '') ? undefined : 'Required') })));
|
|
63
|
+
const slug = slugify(dirName);
|
|
64
|
+
const target = resolve(process.cwd(), dirName);
|
|
65
|
+
if (existsSync(target) && readdirSync(target).length) { p.cancel(`${dirName} already exists and isn't empty.`); process.exit(1); }
|
|
66
|
+
|
|
67
|
+
const displayName = opt('name') ?? (YES ? titleCase(slug) : cancel(await p.text({ message: 'App display name', initialValue: titleCase(slug) })));
|
|
68
|
+
const packageId = opt('package') ?? (YES ? `com.techtiten.${slug.replace(/-/g, '')}` : cancel(await p.text({
|
|
69
|
+
message: 'Android package / iOS bundle id', initialValue: `com.techtiten.${slug.replace(/-/g, '')}`,
|
|
70
|
+
validate: (v) => (/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(v) ? undefined : 'Like com.techtiten.myapp (lowercase, dots)'),
|
|
71
|
+
})));
|
|
72
|
+
const web = flag('web') ? true : flag('no-web') ? false : YES ? true : cancel(await p.confirm({ message: 'Add a Next.js app (website + admin panel + API) next to the mobile app?', initialValue: true }));
|
|
73
|
+
const state = opt('state') ?? (YES ? 'zustand' : cancel(await p.select({
|
|
74
|
+
message: 'State management',
|
|
75
|
+
options: [
|
|
76
|
+
{ value: 'zustand', label: 'Zustand + TanStack Query', hint: 'recommended: tiny stores, server data cached' },
|
|
77
|
+
{ value: 'redux', label: 'Redux Toolkit + TanStack Query', hint: 'more structure, Redux DevTools' },
|
|
78
|
+
],
|
|
79
|
+
})));
|
|
80
|
+
const ads = flag('ads') ? true : flag('no-ads') ? false : YES ? false : cancel(await p.confirm({ message: 'Google AdMob ads (banner component, test ads in dev)?', initialValue: false }));
|
|
81
|
+
const install = !flag('skip-install');
|
|
82
|
+
|
|
83
|
+
const scope = `@${slug}`;
|
|
84
|
+
const vars = { APP_SLUG: slug, APP_NAME: displayName, PACKAGE_ID: packageId, SCOPE: scope, STATE: state, YEAR: String(new Date().getFullYear()), WEB_ENABLED: web ? 'true' : 'false' };
|
|
85
|
+
const s = p.spinner();
|
|
86
|
+
|
|
87
|
+
// 1. Root + shared packages
|
|
88
|
+
mkdirSync(target, { recursive: true });
|
|
89
|
+
copyTemplate(join(TPL, 'root'), target, vars);
|
|
90
|
+
copyTemplate(join(TPL, 'packages'), join(target, 'packages'), vars);
|
|
91
|
+
if (!web) rmSync(join(target, '.github/workflows/web-check.yml'), { force: true });
|
|
92
|
+
const rootPkg = readJson(join(target, 'package.json'));
|
|
93
|
+
if (!web) { delete rootPkg.scripts.web; delete rootPkg.scripts['build:web']; }
|
|
94
|
+
writeJson(join(target, 'package.json'), rootPkg);
|
|
95
|
+
|
|
96
|
+
// 2. Mobile: latest Expo blank TypeScript app, then our layer on top
|
|
97
|
+
s.start('Creating the Expo app (latest SDK)');
|
|
98
|
+
mkdirSync(join(target, 'apps'), { recursive: true });
|
|
99
|
+
run('npx', ['--yes', 'create-expo-app@latest', 'mobile', '--template', 'blank-typescript', '--no-install'], join(target, 'apps'), { quiet: true });
|
|
100
|
+
const m = join(target, 'apps/mobile');
|
|
101
|
+
// Keep the SDK template's own app.json values (icons, splash, platform defaults) as config/base.json.
|
|
102
|
+
const sdkBase = existsSync(join(m, 'app.json')) ? readJson(join(m, 'app.json')).expo ?? {} : {};
|
|
103
|
+
for (const k of ['name', 'slug', 'plugins']) delete sdkBase[k];
|
|
104
|
+
for (const f of ['App.tsx', 'index.ts', 'app.json', '.gitignore', 'LICENSE']) rmSync(join(m, f), { force: true });
|
|
105
|
+
rmSync(join(m, '.git'), { recursive: true, force: true });
|
|
106
|
+
copyTemplate(join(TPL, 'mobile/common'), m, vars);
|
|
107
|
+
writeJson(join(m, 'config/base.json'), sdkBase);
|
|
108
|
+
copyTemplate(join(TPL, `mobile/${state}`), m, vars);
|
|
109
|
+
copyTemplate(join(TPL, ads ? 'mobile/ads' : 'mobile/noads'), m, vars);
|
|
110
|
+
const mp = readJson(join(m, 'package.json'));
|
|
111
|
+
mp.name = `${scope}/mobile`;
|
|
112
|
+
mp.main = 'expo-router/entry';
|
|
113
|
+
mp.scripts = { ...mp.scripts, start: 'expo start', android: 'expo run:android', ios: 'expo run:ios', web: 'expo start --web', prebuild: 'expo prebuild --clean', typecheck: 'tsc --noEmit' };
|
|
114
|
+
mp.dependencies = { ...mp.dependencies, [`${scope}/theme`]: '*', [`${scope}/shared`]: '*' };
|
|
115
|
+
writeJson(join(m, 'package.json'), mp);
|
|
116
|
+
s.stop('Expo app created');
|
|
117
|
+
|
|
118
|
+
// 3. Web: latest Next.js (App Router, TS, Tailwind, src/), then our layer
|
|
119
|
+
if (web) {
|
|
120
|
+
s.start('Creating the Next.js app (latest)');
|
|
121
|
+
run('npx', ['--yes', 'create-next-app@latest', 'web', '--ts', '--app', '--eslint', '--tailwind', '--src-dir', '--import-alias', '@/*', '--use-npm', '--skip-install', '--disable-git', '--yes'], join(target, 'apps'), { quiet: true });
|
|
122
|
+
const w = join(target, 'apps/web');
|
|
123
|
+
rmSync(join(w, '.gitignore'), { force: true });
|
|
124
|
+
rmSync(join(w, 'README.md'), { force: true });
|
|
125
|
+
rmSync(join(w, 'src/app/page.tsx'), { force: true }); // replaced by src/app/(site)/page.tsx
|
|
126
|
+
copyTemplate(join(TPL, 'web'), w, vars);
|
|
127
|
+
// Keep Next's own generated next.config.ts (it changes between versions) and only add
|
|
128
|
+
// transpilePackages so the shared TypeScript packages compile.
|
|
129
|
+
const nc = join(w, 'next.config.ts');
|
|
130
|
+
const tp = `transpilePackages: ['${scope}/theme', '${scope}/shared'],`;
|
|
131
|
+
if (existsSync(nc) && /const nextConfig: NextConfig = \{/.test(readFileSync(nc, 'utf8'))) {
|
|
132
|
+
writeFileSync(nc, readFileSync(nc, 'utf8').replace(/const nextConfig: NextConfig = \{/, (m) => `${m}\n ${tp}`));
|
|
133
|
+
} else {
|
|
134
|
+
writeFileSync(nc, `import type { NextConfig } from 'next';\n\nconst nextConfig: NextConfig = {\n ${tp}\n};\n\nexport default nextConfig;\n`);
|
|
135
|
+
}
|
|
136
|
+
const wp = readJson(join(w, 'package.json'));
|
|
137
|
+
wp.name = `${scope}/web`;
|
|
138
|
+
wp.scripts = { ...wp.scripts, typecheck: 'next typegen && tsc --noEmit' }; // typegen writes LayoutProps/RouteContext types
|
|
139
|
+
wp.dependencies = { ...wp.dependencies, [`${scope}/theme`]: '*', [`${scope}/shared`]: '*' };
|
|
140
|
+
writeJson(join(w, 'package.json'), wp);
|
|
141
|
+
s.stop('Next.js app created');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 4. Install: one root install, then SDK-matched native packages via expo install
|
|
145
|
+
if (install) {
|
|
146
|
+
s.start('Installing (one install for all workspaces)');
|
|
147
|
+
run('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
148
|
+
s.message('Adding Expo packages matched to this SDK');
|
|
149
|
+
const expoPkgs = ['expo-router', 'expo-linking', 'expo-constants', 'expo-status-bar', 'expo-haptics', 'expo-system-ui', 'react-native-safe-area-context', 'react-native-screens', '@react-native-async-storage/async-storage', 'react-dom', 'react-native-web'];
|
|
150
|
+
if (ads) expoPkgs.push('react-native-google-mobile-ads');
|
|
151
|
+
const expoArgs = ['expo', 'install', ...expoPkgs, '--', '--no-audit', '--no-fund', '--loglevel=error'];
|
|
152
|
+
// expo install adds config plugins itself, but can't edit app.config.ts. Give it a temporary
|
|
153
|
+
// app.json, then copy the plugins it added into config/plugins.json (read by app.config.ts).
|
|
154
|
+
renameSync(join(m, 'app.config.ts'), join(m, 'app.config.ts.tmp'));
|
|
155
|
+
writeJson(join(m, 'app.json'), { expo: { name: displayName, slug, plugins: [] } });
|
|
156
|
+
try {
|
|
157
|
+
run('npx', expoArgs, m, { quiet: true });
|
|
158
|
+
} catch {
|
|
159
|
+
// Expo's version API unreachable (proxy/offline): use the versions bundled with the installed SDK.
|
|
160
|
+
s.message('Expo API unreachable, using the SDK\'s bundled version list');
|
|
161
|
+
run('npx', expoArgs, m, { quiet: true, env: { EXPO_OFFLINE: '1' } });
|
|
162
|
+
}
|
|
163
|
+
const added = (readJson(join(m, 'app.json')).expo.plugins ?? []).map((x) => (Array.isArray(x) ? x[0] : x));
|
|
164
|
+
rmSync(join(m, 'app.json'));
|
|
165
|
+
renameSync(join(m, 'app.config.ts.tmp'), join(m, 'app.config.ts'));
|
|
166
|
+
const statePkgs = state === 'redux' ? ['@reduxjs/toolkit', 'react-redux', '@tanstack/react-query'] : ['zustand', '@tanstack/react-query'];
|
|
167
|
+
run('npm', ['install', ...statePkgs, '-w', `${scope}/mobile`, '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
168
|
+
run('npm', ['install', 'zod', '-w', `${scope}/shared`, '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
169
|
+
// Now that the packages exist, turn their config plugins on.
|
|
170
|
+
writeJson(join(m, 'config/plugins.json'), [...new Set(['expo-router', ...added, ...(ads ? ['react-native-google-mobile-ads'] : []), './plugins/withSigning.js'])]);
|
|
171
|
+
if (web) {
|
|
172
|
+
// One React for the whole repo: pin web's react/react-dom to the version Expo installed,
|
|
173
|
+
// otherwise npm can install two Reacts and hooks break ("Invalid hook call").
|
|
174
|
+
const rv = readJson(join(m, 'package.json')).dependencies.react;
|
|
175
|
+
const w = join(target, 'apps/web');
|
|
176
|
+
const wp = readJson(join(w, 'package.json'));
|
|
177
|
+
wp.dependencies.react = rv;
|
|
178
|
+
wp.dependencies['react-dom'] = rv;
|
|
179
|
+
writeJson(join(w, 'package.json'), wp);
|
|
180
|
+
const rootPkg2 = readJson(join(target, 'package.json'));
|
|
181
|
+
rootPkg2.overrides = { ...(rootPkg2.overrides ?? {}), react: rv, 'react-dom': rv };
|
|
182
|
+
writeJson(join(target, 'package.json'), rootPkg2);
|
|
183
|
+
run('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
184
|
+
}
|
|
185
|
+
s.stop('Installed');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!install) writeJson(join(m, 'config/plugins.json'), ['expo-router', './plugins/withSigning.js', ...(ads ? ['react-native-google-mobile-ads'] : [])]);
|
|
189
|
+
|
|
190
|
+
// 5. Git
|
|
191
|
+
if (!flag('no-git')) {
|
|
192
|
+
try {
|
|
193
|
+
run('git', ['init', '-b', 'main'], target, { quiet: true });
|
|
194
|
+
run('git', ['add', '-A'], target, { quiet: true });
|
|
195
|
+
run('git', ['-c', 'user.name=create-techtiten-app', '-c', 'user.email=noreply@techtiten.com', 'commit', '-m', 'Initial commit from create-techtiten-app', '--no-verify'], target, { quiet: true });
|
|
196
|
+
} catch { p.log.warn('git init skipped (git missing or commit failed). Run git init yourself.'); }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rel = relative(process.cwd(), target) || '.';
|
|
200
|
+
p.note([
|
|
201
|
+
`cd ${rel}`,
|
|
202
|
+
'npm run mobile # Expo dev server (press a for Android)',
|
|
203
|
+
web ? 'npm run web # Next.js on http://localhost:3000 (/admin, /api/health)' : null,
|
|
204
|
+
'npm run typecheck # all workspaces',
|
|
205
|
+
ads ? 'Ads need a dev build (npm run android), not Expo Go.' : null,
|
|
206
|
+
].filter(Boolean).join('\n'), 'Next steps');
|
|
207
|
+
p.outro(`${pc.green('Done.')} ${displayName} is ready in ${pc.cyan(rel)}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
main().catch((e) => { p.cancel(e.message); process.exit(1); });
|
package/package.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-techtiten-app",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold a Techtiten app: Expo (Android/iOS) + optional Next.js (web, admin panel, API) with a shared theme and shared logic. Always on the latest Expo and Next.js.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "create-techtiten-app": "bin/index.mjs" },
|
|
7
|
+
"files": ["bin", "templates"],
|
|
8
|
+
"engines": { "node": ">=20.19" },
|
|
9
|
+
"keywords": ["expo", "react-native", "nextjs", "monorepo", "template", "create-app", "zustand", "redux"],
|
|
10
|
+
"author": "Arun Kumar (Techtiten)",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": { "type": "git", "url": "git+https://github.com/codewitharun/rnboilerplate.git" },
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@clack/prompts": "^1.8.1",
|
|
15
|
+
"picocolors": "^1.1.1"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { View } from 'react-native';
|
|
2
|
+
import { BannerAd, BannerAdSize, TestIds } from 'react-native-google-mobile-ads';
|
|
3
|
+
|
|
4
|
+
// Test ads in development, your real unit id in release builds (EXPO_PUBLIC_ADMOB_BANNER_ID).
|
|
5
|
+
// Ads need a dev build (npm run android), not Expo Go.
|
|
6
|
+
const UNIT_ID = __DEV__ ? TestIds.ADAPTIVE_BANNER : process.env.EXPO_PUBLIC_ADMOB_BANNER_ID ?? TestIds.ADAPTIVE_BANNER;
|
|
7
|
+
|
|
8
|
+
export function Banner() {
|
|
9
|
+
return (
|
|
10
|
+
<View style={{ alignItems: 'center' }}>
|
|
11
|
+
<BannerAd unitId={UNIT_ID} size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER} />
|
|
12
|
+
</View>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Stack } from 'expo-router';
|
|
2
|
+
import { StatusBar } from 'expo-status-bar';
|
|
3
|
+
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
4
|
+
import { Providers } from '@/state/Providers';
|
|
5
|
+
import { ThemeProvider, useTheme } from '@/theme';
|
|
6
|
+
|
|
7
|
+
function ThemedStack() {
|
|
8
|
+
const t = useTheme();
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
<StatusBar style={t.mode === 'dark' ? 'light' : 'dark'} />
|
|
12
|
+
<Stack
|
|
13
|
+
screenOptions={{
|
|
14
|
+
headerStyle: { backgroundColor: t.colors.background },
|
|
15
|
+
headerTintColor: t.colors.text,
|
|
16
|
+
headerShadowVisible: false,
|
|
17
|
+
contentStyle: { backgroundColor: t.colors.background },
|
|
18
|
+
}}>
|
|
19
|
+
<Stack.Screen name="index" options={{ title: '__APP_NAME__' }} />
|
|
20
|
+
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
|
|
21
|
+
</Stack>
|
|
22
|
+
</>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function RootLayout() {
|
|
27
|
+
return (
|
|
28
|
+
<SafeAreaProvider>
|
|
29
|
+
<Providers>
|
|
30
|
+
<ThemeProvider>
|
|
31
|
+
<ThemedStack />
|
|
32
|
+
</ThemeProvider>
|
|
33
|
+
</Providers>
|
|
34
|
+
</SafeAreaProvider>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Link } from 'expo-router';
|
|
2
|
+
import { formatCurrency, splitEvenly, fromMinor, timeAgo } from '__SCOPE__/shared';
|
|
3
|
+
import { useHealth } from '@/api/client';
|
|
4
|
+
import { Banner } from '@/ads/Banner';
|
|
5
|
+
import { useTaps } from '@/state';
|
|
6
|
+
import { Button, Card, Screen, Text } from '@/ui';
|
|
7
|
+
|
|
8
|
+
const WEB_API = __WEB_ENABLED__;
|
|
9
|
+
|
|
10
|
+
export default function Home() {
|
|
11
|
+
const { taps, addTap } = useTaps();
|
|
12
|
+
const health = useHealth(WEB_API);
|
|
13
|
+
const shares = splitEvenly(100000, 3).map((m) => formatCurrency(fromMinor(m)));
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Screen>
|
|
17
|
+
<Card>
|
|
18
|
+
<Text size="xl" weight="black">Welcome to __APP_NAME__</Text>
|
|
19
|
+
<Text muted>Edit app/index.tsx to start. Colors come from packages/theme, helpers from packages/shared.</Text>
|
|
20
|
+
</Card>
|
|
21
|
+
|
|
22
|
+
<Card>
|
|
23
|
+
<Text weight="bold">State demo (__STATE__)</Text>
|
|
24
|
+
<Text muted>Tapped {taps} times</Text>
|
|
25
|
+
<Button title="Tap" onPress={addTap} />
|
|
26
|
+
</Card>
|
|
27
|
+
|
|
28
|
+
<Card>
|
|
29
|
+
<Text weight="bold">Shared logic demo</Text>
|
|
30
|
+
<Text muted>₹1,000 split 3 ways: {shares.join(' · ')}</Text>
|
|
31
|
+
</Card>
|
|
32
|
+
|
|
33
|
+
{WEB_API ? (
|
|
34
|
+
<Card>
|
|
35
|
+
<Text weight="bold">API demo (Next.js /api/health)</Text>
|
|
36
|
+
<Text muted>
|
|
37
|
+
{health.isLoading ? 'Checking…' : health.data ? `OK · ${timeAgo(health.data.time)}` : `Not reachable (${health.error?.message ?? 'start npm run web'})`}
|
|
38
|
+
</Text>
|
|
39
|
+
</Card>
|
|
40
|
+
) : null}
|
|
41
|
+
|
|
42
|
+
<Link href="/settings" asChild>
|
|
43
|
+
<Button title="Settings" variant="ghost" />
|
|
44
|
+
</Link>
|
|
45
|
+
<Banner />
|
|
46
|
+
</Screen>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { View } from 'react-native';
|
|
2
|
+
import { useAppearance, type Appearance } from '@/state';
|
|
3
|
+
import { Button, Card, Screen, Text } from '@/ui';
|
|
4
|
+
|
|
5
|
+
const OPTIONS: Appearance[] = ['system', 'light', 'dark'];
|
|
6
|
+
|
|
7
|
+
export default function Settings() {
|
|
8
|
+
const { appearance, setAppearance } = useAppearance();
|
|
9
|
+
return (
|
|
10
|
+
<Screen>
|
|
11
|
+
<Card>
|
|
12
|
+
<Text weight="bold">Appearance</Text>
|
|
13
|
+
<Text muted>Saved on this phone.</Text>
|
|
14
|
+
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
15
|
+
{OPTIONS.map((o) => (
|
|
16
|
+
<Button key={o} title={o} variant={appearance === o ? 'primary' : 'ghost'} onPress={() => setAppearance(o)} style={{ flex: 1 }} />
|
|
17
|
+
))}
|
|
18
|
+
</View>
|
|
19
|
+
</Card>
|
|
20
|
+
</Screen>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ExpoConfig } from 'expo/config';
|
|
2
|
+
import base from './config/base.json';
|
|
3
|
+
import pluginNames from './config/plugins.json';
|
|
4
|
+
|
|
5
|
+
// config/base.json = what this Expo SDK's own template ships (icons, splash, platform defaults),
|
|
6
|
+
// saved by create-techtiten-app. Our settings go on top, so new SDK defaults are never lost.
|
|
7
|
+
// config/plugins.json = config plugins of the installed packages.
|
|
8
|
+
|
|
9
|
+
// versionCode/buildNumber come from CI (BUILD_NUMBER = GitHub run number): every Play upload is higher.
|
|
10
|
+
const buildNumber = Number(process.env.BUILD_NUMBER ?? 1);
|
|
11
|
+
|
|
12
|
+
// AdMob app ids: Google's TEST ids unless ADMOB_ANDROID_APP_ID / ADMOB_IOS_APP_ID are set (CI or .env).
|
|
13
|
+
const pluginOptions: Record<string, object> = {
|
|
14
|
+
'react-native-google-mobile-ads': {
|
|
15
|
+
androidAppId: process.env.ADMOB_ANDROID_APP_ID ?? 'ca-app-pub-3940256099942544~3347511713',
|
|
16
|
+
iosAppId: process.env.ADMOB_IOS_APP_ID ?? 'ca-app-pub-3940256099942544~1458002511',
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
const plugins: ExpoConfig['plugins'] = (pluginNames as string[]).map((name) => (pluginOptions[name] ? [name, pluginOptions[name]] : name));
|
|
20
|
+
|
|
21
|
+
const sdkDefaults = base as Partial<ExpoConfig>;
|
|
22
|
+
|
|
23
|
+
const config: ExpoConfig = {
|
|
24
|
+
...sdkDefaults,
|
|
25
|
+
name: '__APP_NAME__',
|
|
26
|
+
slug: '__APP_SLUG__',
|
|
27
|
+
scheme: '__APP_SLUG__',
|
|
28
|
+
version: '1.0.0',
|
|
29
|
+
userInterfaceStyle: 'automatic',
|
|
30
|
+
ios: { ...sdkDefaults.ios, bundleIdentifier: '__PACKAGE_ID__', buildNumber: String(buildNumber) },
|
|
31
|
+
android: { ...sdkDefaults.android, package: '__PACKAGE_ID__', versionCode: buildNumber },
|
|
32
|
+
plugins,
|
|
33
|
+
experiments: { ...sdkDefaults.experiments, typedRoutes: true },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default config;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Bug fixes and improvements.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release + debug signing for the generated android/ project (CNG).
|
|
3
|
+
* Release key, first match wins:
|
|
4
|
+
* 1. CI env: ANDROID_KEYSTORE_PATH / ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD
|
|
5
|
+
* 2. credentials/signing.properties (MYAPP_UPLOAD_* keys, git-ignored) for local release builds
|
|
6
|
+
* 3. neither: release falls back to debug signing (Play rejects it, so nothing ships by accident)
|
|
7
|
+
* Debug: credentials/debug.keystore if present (keeps the same SHA-1 for Google Sign-In), else Expo's key.
|
|
8
|
+
*/
|
|
9
|
+
const { withAppBuildGradle } = require('expo/config-plugins');
|
|
10
|
+
|
|
11
|
+
const MARK = '// @techtiten-signing';
|
|
12
|
+
const HEADER = `${MARK}
|
|
13
|
+
def ttCredDir = new File(rootDir, "../credentials")
|
|
14
|
+
def ttSigning = new Properties()
|
|
15
|
+
def ttSigningFile = new File(ttCredDir, "signing.properties")
|
|
16
|
+
if (ttSigningFile.exists()) { ttSigningFile.withInputStream { ttSigning.load(it) } }
|
|
17
|
+
def ttDebugKeystore = new File(ttCredDir, "debug.keystore")
|
|
18
|
+
def ttHasRelease = System.getenv("ANDROID_KEYSTORE_PATH") || ttSigning['MYAPP_UPLOAD_STORE_FILE']
|
|
19
|
+
|
|
20
|
+
`;
|
|
21
|
+
const RELEASE_CONFIG = `
|
|
22
|
+
release {
|
|
23
|
+
if (System.getenv("ANDROID_KEYSTORE_PATH")) {
|
|
24
|
+
storeFile file(System.getenv("ANDROID_KEYSTORE_PATH"))
|
|
25
|
+
storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
|
26
|
+
keyAlias System.getenv("ANDROID_KEY_ALIAS")
|
|
27
|
+
keyPassword System.getenv("ANDROID_KEY_PASSWORD")
|
|
28
|
+
} else if (ttSigning['MYAPP_UPLOAD_STORE_FILE']) {
|
|
29
|
+
storeFile new File(ttCredDir, ttSigning['MYAPP_UPLOAD_STORE_FILE'])
|
|
30
|
+
storePassword ttSigning['MYAPP_UPLOAD_STORE_PASSWORD']
|
|
31
|
+
keyAlias ttSigning['MYAPP_UPLOAD_KEY_ALIAS']
|
|
32
|
+
keyPassword ttSigning['MYAPP_UPLOAD_KEY_PASSWORD']
|
|
33
|
+
}
|
|
34
|
+
}`;
|
|
35
|
+
|
|
36
|
+
function patch(gradle) {
|
|
37
|
+
if (gradle.includes(MARK)) return gradle;
|
|
38
|
+
const fail = (what) => { throw new Error(`[withSigning] Could not find ${what} in android/app/build.gradle. The Expo template changed: update plugins/withSigning.js.`); };
|
|
39
|
+
if (!/\nandroid\s*\{/.test(gradle)) fail('"android {"');
|
|
40
|
+
gradle = gradle.replace(/\nandroid\s*\{/, `\n${HEADER}android {`);
|
|
41
|
+
const debugStore = /storeFile file\('debug\.keystore'\)/;
|
|
42
|
+
if (!debugStore.test(gradle)) fail("storeFile file('debug.keystore')");
|
|
43
|
+
gradle = gradle.replace(debugStore, "storeFile(ttDebugKeystore.exists() ? ttDebugKeystore : file('debug.keystore'))");
|
|
44
|
+
const sc = /signingConfigs\s*\{/;
|
|
45
|
+
if (!sc.test(gradle)) fail('"signingConfigs {"');
|
|
46
|
+
gradle = gradle.replace(sc, (m) => m + RELEASE_CONFIG);
|
|
47
|
+
const bt = gradle.indexOf('buildTypes');
|
|
48
|
+
if (bt < 0) fail('"buildTypes"');
|
|
49
|
+
const rel = gradle.indexOf('release', bt);
|
|
50
|
+
const target = 'signingConfig signingConfigs.debug';
|
|
51
|
+
const at = rel < 0 ? -1 : gradle.indexOf(target, rel);
|
|
52
|
+
if (at < 0) fail('release signingConfig');
|
|
53
|
+
return gradle.slice(0, at) + 'signingConfig ttHasRelease ? signingConfigs.release : signingConfigs.debug' + gradle.slice(at + target.length);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = function withSigning(config) {
|
|
57
|
+
return withAppBuildGradle(config, (cfg) => {
|
|
58
|
+
if (cfg.modResults.language !== 'groovy') throw new Error('[withSigning] expected a Groovy build.gradle');
|
|
59
|
+
cfg.modResults.contents = patch(cfg.modResults.contents);
|
|
60
|
+
return cfg;
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
module.exports.patch = patch;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { healthSchema } from '__SCOPE__/shared';
|
|
3
|
+
|
|
4
|
+
// Your Next.js API. Set EXPO_PUBLIC_API_URL in apps/mobile/.env (e.g. https://myapp.vercel.app).
|
|
5
|
+
// On an Android emulator, your Mac's localhost is http://10.0.2.2:3000.
|
|
6
|
+
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
|
|
7
|
+
|
|
8
|
+
export async function api<T>(path: string, parse: (data: unknown) => T, init?: RequestInit): Promise<T> {
|
|
9
|
+
const r = await fetch(`${API_URL}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...init?.headers } });
|
|
10
|
+
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
11
|
+
return parse(await r.json());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Example server query: cached, retried and refetched by TanStack Query. */
|
|
15
|
+
export const useHealth = (enabled = true) =>
|
|
16
|
+
useQuery({ queryKey: ['health'], queryFn: () => api('/api/health', (d) => healthSchema.parse(d)), enabled, retry: 1 });
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
2
|
+
import { useColorScheme } from 'react-native';
|
|
3
|
+
import { theme as makeTheme, type Theme } from '__SCOPE__/theme';
|
|
4
|
+
import { useAppearance } from '@/state';
|
|
5
|
+
|
|
6
|
+
const ThemeContext = createContext<Theme>(makeTheme('dark'));
|
|
7
|
+
|
|
8
|
+
/** Light/dark from the user's choice in Settings, or the phone's setting when it's "system". */
|
|
9
|
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
10
|
+
const system = useColorScheme();
|
|
11
|
+
const { appearance } = useAppearance();
|
|
12
|
+
const mode = appearance === 'system' ? (system === 'light' ? 'light' : 'dark') : appearance;
|
|
13
|
+
const value = useMemo(() => makeTheme(mode), [mode]);
|
|
14
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const useTheme = () => useContext(ThemeContext);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Pressable, Text as RNText, View, type PressableProps, type TextProps, type ViewProps } from 'react-native';
|
|
2
|
+
import * as Haptics from 'expo-haptics';
|
|
3
|
+
import { useTheme } from '@/theme';
|
|
4
|
+
|
|
5
|
+
export function Screen({ style, ...rest }: ViewProps) {
|
|
6
|
+
const t = useTheme();
|
|
7
|
+
return <View style={[{ flex: 1, backgroundColor: t.colors.background, padding: t.spacing.lg, gap: t.spacing.lg }, style]} {...rest} />;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Card({ style, ...rest }: ViewProps) {
|
|
11
|
+
const t = useTheme();
|
|
12
|
+
return <View style={[{ backgroundColor: t.colors.surface, borderColor: t.colors.border, borderWidth: 1, borderRadius: t.radius.lg, padding: t.spacing.lg, gap: t.spacing.sm }, style]} {...rest} />;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Text({ style, muted, size = 'md', weight = 'regular', ...rest }: TextProps & { muted?: boolean; size?: keyof ReturnType<typeof useTheme>['fontSize']; weight?: keyof ReturnType<typeof useTheme>['fontWeight'] }) {
|
|
16
|
+
const t = useTheme();
|
|
17
|
+
return <RNText style={[{ color: muted ? t.colors.textMuted : t.colors.text, fontSize: t.fontSize[size], fontWeight: t.fontWeight[weight] }, style]} {...rest} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Button({ title, variant = 'primary', onPress, style, ...rest }: PressableProps & { title: string; variant?: 'primary' | 'ghost' }) {
|
|
21
|
+
const t = useTheme();
|
|
22
|
+
const primary = variant === 'primary';
|
|
23
|
+
return (
|
|
24
|
+
<Pressable
|
|
25
|
+
accessibilityRole="button"
|
|
26
|
+
onPress={(e) => { Haptics.selectionAsync().catch(() => {}); onPress?.(e); }}
|
|
27
|
+
style={(state) => [{
|
|
28
|
+
backgroundColor: primary ? t.colors.brand : 'transparent',
|
|
29
|
+
borderColor: primary ? t.colors.brand : t.colors.border,
|
|
30
|
+
borderWidth: 1, borderRadius: t.radius.md, paddingVertical: t.spacing.md, paddingHorizontal: t.spacing.lg,
|
|
31
|
+
alignItems: 'center', opacity: state.pressed ? 0.8 : 1,
|
|
32
|
+
}, typeof style === 'function' ? style(state) : style]}
|
|
33
|
+
{...rest}>
|
|
34
|
+
<RNText style={{ color: primary ? t.colors.onBrand : t.colors.text, fontWeight: t.fontWeight.bold, fontSize: t.fontSize.md }}>{title}</RNText>
|
|
35
|
+
</Pressable>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { useState, type ReactNode } from 'react';
|
|
3
|
+
import { Provider } from 'react-redux';
|
|
4
|
+
import { store } from './store';
|
|
5
|
+
|
|
6
|
+
export function Providers({ children }: { children: ReactNode }) {
|
|
7
|
+
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } } }));
|
|
8
|
+
return (
|
|
9
|
+
<Provider store={store}>
|
|
10
|
+
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
11
|
+
</Provider>
|
|
12
|
+
);
|
|
13
|
+
}
|