create-techtiten-app 0.0.0-stage → 0.2.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 (55) hide show
  1. package/README.md +88 -2
  2. package/bin/index.mjs +345 -0
  3. package/bin/presets.mjs +68 -0
  4. package/package.json +38 -4
  5. package/templates/mobile/ads/src/ads/Banner.tsx +14 -0
  6. package/templates/mobile/ads/src/ads/Banner.web.tsx +4 -0
  7. package/templates/mobile/common/app.config.ts +36 -0
  8. package/templates/mobile/common/config/plugins.json +1 -0
  9. package/templates/mobile/common/distribution/whatsnew/whatsnew-en-US +1 -0
  10. package/templates/mobile/common/plugins/withSigning.js +63 -0
  11. package/templates/mobile/common/src/AppProviders.tsx +22 -0
  12. package/templates/mobile/common/src/api/client.ts +16 -0
  13. package/templates/mobile/common/src/screens/Home.tsx +48 -0
  14. package/templates/mobile/common/src/screens/Settings.tsx +22 -0
  15. package/templates/mobile/common/src/theme/index.tsx +17 -0
  16. package/templates/mobile/common/src/ui/index.tsx +41 -0
  17. package/templates/mobile/common/tsconfig.json +8 -0
  18. package/templates/mobile/nav-stack/app/_layout.tsx +33 -0
  19. package/templates/mobile/nav-stack/app/index.tsx +1 -0
  20. package/templates/mobile/nav-stack/app/settings.tsx +1 -0
  21. package/templates/mobile/nav-tabs/app/(tabs)/_layout.tsx +25 -0
  22. package/templates/mobile/nav-tabs/app/(tabs)/index.tsx +1 -0
  23. package/templates/mobile/nav-tabs/app/(tabs)/settings.tsx +1 -0
  24. package/templates/mobile/nav-tabs/app/_layout.tsx +25 -0
  25. package/templates/mobile/noads/src/ads/Banner.tsx +4 -0
  26. package/templates/mobile/redux/src/state/Providers.tsx +13 -0
  27. package/templates/mobile/redux/src/state/index.ts +16 -0
  28. package/templates/mobile/redux/src/state/store.ts +33 -0
  29. package/templates/mobile/zustand/src/state/Providers.tsx +7 -0
  30. package/templates/mobile/zustand/src/state/index.ts +25 -0
  31. package/templates/packages/shared/package.json +7 -0
  32. package/templates/packages/shared/src/format.ts +18 -0
  33. package/templates/packages/shared/src/index.ts +5 -0
  34. package/templates/packages/shared/src/money.ts +13 -0
  35. package/templates/packages/shared/src/schemas.ts +9 -0
  36. package/templates/packages/theme/package.json +7 -0
  37. package/templates/packages/theme/src/index.ts +26 -0
  38. package/templates/root/.prettierignore +7 -0
  39. package/templates/root/.prettierrc +5 -0
  40. package/templates/root/AGENTS.md +16 -0
  41. package/templates/root/README.md +31 -0
  42. package/templates/root/STATUS.md +13 -0
  43. package/templates/root/_github/workflows/android.yml +79 -0
  44. package/templates/root/_github/workflows/web-check.yml +19 -0
  45. package/templates/root/_gitignore +33 -0
  46. package/templates/root/package.json +21 -0
  47. package/templates/web/src/app/(site)/page.tsx +18 -0
  48. package/templates/web/src/app/admin/layout.tsx +17 -0
  49. package/templates/web/src/app/admin/page.tsx +32 -0
  50. package/templates/web/src/app/api/cron/[job]/route.ts +17 -0
  51. package/templates/web/src/app/api/health/route.ts +8 -0
  52. package/templates/web/src/app/globals.css +23 -0
  53. package/templates/web/src/app/layout.tsx +24 -0
  54. package/templates/web/src/proxy.ts +26 -0
  55. package/templates/web/src/server/jobs.ts +9 -0
@@ -0,0 +1,79 @@
1
+ name: Android Build & Release
2
+
3
+ # Push to main (or run by hand) -> build a signed AAB on GitHub's runner -> upload to Google Play.
4
+ # No EAS servers: `expo prebuild` generates android/ on the runner, then plain Gradle builds it.
5
+ # Only mobile changes trigger it, so a web/API fix never starts a Play release.
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ paths:
10
+ - "apps/mobile/**"
11
+ - "packages/**"
12
+ - "package-lock.json"
13
+ - "!**/*.md"
14
+ workflow_dispatch:
15
+ inputs:
16
+ track:
17
+ description: "Play track (internal / alpha / beta / production)"
18
+ required: false
19
+ default: ""
20
+
21
+ env:
22
+ # New apps start on the internal track. Switch to production once the app is live and tested.
23
+ PLAY_TRACK: internal
24
+
25
+ jobs:
26
+ android:
27
+ name: Build & Release Android
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - uses: actions/checkout@v4
31
+ - uses: actions/setup-java@v4
32
+ with: { distribution: zulu, java-version: "17" }
33
+ - uses: gradle/actions/setup-gradle@v4
34
+ - uses: actions/setup-node@v4
35
+ with: { node-version: "22", cache: npm }
36
+
37
+ - name: Install
38
+ run: npm ci
39
+
40
+ - name: Typecheck
41
+ run: npm run typecheck
42
+
43
+ - name: Expo prebuild (generate android/)
44
+ working-directory: apps/mobile
45
+ env:
46
+ BUILD_NUMBER: ${{ github.run_number }}
47
+ EXPO_NO_TELEMETRY: "1"
48
+ run: npx expo prebuild --platform android --clean --no-install
49
+
50
+ # After prebuild: --clean wipes android/
51
+ - name: Decode keystore
52
+ working-directory: apps/mobile
53
+ env:
54
+ ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
55
+ run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/app/release.keystore
56
+
57
+ - name: Build release bundle (AAB)
58
+ working-directory: apps/mobile/android
59
+ env:
60
+ ANDROID_KEYSTORE_PATH: release.keystore # read by plugins/withSigning.js (relative to android/app)
61
+ ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
62
+ ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
63
+ ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
64
+ run: ./gradlew bundleRelease --no-daemon
65
+
66
+ - name: Upload to Google Play
67
+ uses: r0adkll/upload-google-play@v1
68
+ with:
69
+ serviceAccountJsonPlainText: ${{ secrets.ANDROID_SERVICE_ACCOUNT_JSON }}
70
+ packageName: __PACKAGE_ID__
71
+ releaseFiles: apps/mobile/android/app/build/outputs/bundle/release/app-release.aab
72
+ track: ${{ github.event.inputs.track || env.PLAY_TRACK }}
73
+ status: completed
74
+ whatsNewDirectory: apps/mobile/distribution/whatsnew
75
+
76
+ - uses: actions/upload-artifact@v4
77
+ with:
78
+ name: app-release-bundle
79
+ path: apps/mobile/android/app/build/outputs/bundle/release/app-release.aab
@@ -0,0 +1,19 @@
1
+ name: Web check
2
+
3
+ # Typecheck + build the Next.js app on every PR (Vercel deploys it on merge).
4
+ on:
5
+ pull_request:
6
+ paths: ["apps/web/**", "packages/**", "package-lock.json"]
7
+
8
+ jobs:
9
+ web:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-node@v4
14
+ with: { node-version: "22", cache: npm }
15
+ - run: npm ci
16
+ - run: npm run typecheck
17
+ - run: npm run build:web
18
+ env:
19
+ NEXT_TELEMETRY_DISABLED: "1"
@@ -0,0 +1,33 @@
1
+ # deps / builds
2
+ node_modules/
3
+ .expo/
4
+ dist/
5
+ web-build/
6
+ .next/
7
+ out/
8
+ build/
9
+ *.tsbuildinfo
10
+ next-env.d.ts
11
+
12
+ # generated native projects (CNG: made by `expo prebuild` at build time)
13
+ apps/mobile/android/
14
+ apps/mobile/ios/
15
+
16
+ # secrets: never commit these
17
+ .env
18
+ .env.*
19
+ credentials/
20
+ *.keystore
21
+ *.jks
22
+ *.p8
23
+ *.p12
24
+ *.mobileprovision
25
+ google-services.json
26
+ GoogleService-Info.plist
27
+ *service-account*.json
28
+ *-firebase-adminsdk-*.json
29
+
30
+ # misc
31
+ .DS_Store
32
+ .vercel
33
+ *.log
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "__APP_SLUG__",
3
+ "private": true,
4
+ "workspaces": [
5
+ "apps/*",
6
+ "packages/*"
7
+ ],
8
+ "scripts": {
9
+ "mobile": "npm run start -w __SCOPE__/mobile",
10
+ "android": "npm run android -w __SCOPE__/mobile",
11
+ "web": "npm run dev -w __SCOPE__/web",
12
+ "build:web": "npm run build -w __SCOPE__/web",
13
+ "typecheck": "npm run typecheck --workspaces --if-present",
14
+ "format": "prettier --write .",
15
+ "upgrade:mobile": "cd apps/mobile && npx expo install expo@latest && npx expo install --fix",
16
+ "upgrade:web": "npm install next@latest eslint-config-next@latest -w __SCOPE__/web"
17
+ },
18
+ "engines": {
19
+ "node": ">=20.19"
20
+ }
21
+ }
@@ -0,0 +1,18 @@
1
+ import Link from 'next/link';
2
+
3
+ // The public website / web version of the app. Shares colors with the phone app (packages/theme).
4
+ export default function Home() {
5
+ return (
6
+ <main className="mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center gap-8 px-6 py-16">
7
+ <div className="flex flex-col gap-4">
8
+ <span className="w-fit rounded-full bg-surface-alt px-3 py-1 text-sm text-text-muted">by Techtiten</span>
9
+ <h1 className="text-5xl font-black tracking-tight">__APP_NAME__</h1>
10
+ <p className="text-lg text-text-muted">This is the website. Edit <code>src/app/(site)/page.tsx</code>. The admin panel is at <code>/admin</code> and the API at <code>/api</code>.</p>
11
+ </div>
12
+ <div className="flex flex-wrap gap-3">
13
+ <Link href="/admin" className="rounded-xl bg-brand px-5 py-3 font-bold text-on-brand">Open admin</Link>
14
+ <a href="/api/health" className="rounded-xl border border-border px-5 py-3 font-bold">API health</a>
15
+ </div>
16
+ </main>
17
+ );
18
+ }
@@ -0,0 +1,17 @@
1
+ import Link from 'next/link';
2
+
3
+ // Everything under /admin is behind HTTP basic auth (src/proxy.ts, ADMIN_USER / ADMIN_PASSWORD).
4
+ export default function AdminLayout({ children }: LayoutProps<'/admin'>) {
5
+ return (
6
+ <div className="flex min-h-full flex-1">
7
+ <aside className="w-56 shrink-0 border-r border-border bg-surface p-5">
8
+ <div className="mb-6 text-lg font-black">__APP_NAME__ <span className="text-text-muted font-medium">admin</span></div>
9
+ <nav className="flex flex-col gap-1 text-sm">
10
+ <Link href="/admin" className="rounded-lg px-3 py-2 hover:bg-surface-alt">Dashboard</Link>
11
+ <Link href="/" className="rounded-lg px-3 py-2 hover:bg-surface-alt">View site</Link>
12
+ </nav>
13
+ </aside>
14
+ <main className="flex-1 p-8">{children}</main>
15
+ </div>
16
+ );
17
+ }
@@ -0,0 +1,32 @@
1
+ import { JOBS } from '@/server/jobs';
2
+
3
+ const cards = [
4
+ { label: 'Users', value: '—', hint: 'connect your database' },
5
+ { label: 'Active today', value: '—', hint: 'connect your database' },
6
+ { label: 'Scheduled jobs', value: String(Object.keys(JOBS).length), hint: 'src/server/jobs.ts' },
7
+ ];
8
+
9
+ export default function AdminHome() {
10
+ return (
11
+ <div className="flex flex-col gap-8">
12
+ <h1 className="text-3xl font-black">Dashboard</h1>
13
+ <div className="grid gap-4 sm:grid-cols-3">
14
+ {cards.map((c) => (
15
+ <div key={c.label} className="rounded-2xl border border-border bg-surface p-5">
16
+ <div className="text-sm text-text-muted">{c.label}</div>
17
+ <div className="mt-1 text-3xl font-black">{c.value}</div>
18
+ <div className="mt-2 text-xs text-text-muted">{c.hint}</div>
19
+ </div>
20
+ ))}
21
+ </div>
22
+ <div className="rounded-2xl border border-border bg-surface p-5">
23
+ <h2 className="mb-3 font-bold">Jobs</h2>
24
+ <ul className="flex flex-col gap-2 text-sm">
25
+ {Object.entries(JOBS).map(([name, job]) => (
26
+ <li key={name} className="flex justify-between gap-4"><code>/api/cron/{name}</code><span className="text-text-muted">{job.description}</span></li>
27
+ ))}
28
+ </ul>
29
+ </div>
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,17 @@
1
+ import { JOBS } from '@/server/jobs';
2
+
3
+ // Scheduled jobs. Vercel functions are short-lived, so an outside timer calls this:
4
+ // cron-job.org or a GitHub Actions schedule ->
5
+ // POST https://<your-app>/api/cron/<job> header x-cron-key: <CRON_API_KEY>
6
+ // Keep each run short; for big work, do it in batches and save where you stopped.
7
+ export async function POST(request: Request, ctx: RouteContext<'/api/cron/[job]'>) {
8
+ const key = process.env.CRON_API_KEY;
9
+ if (!key) return Response.json({ error: 'CRON_API_KEY is not set' }, { status: 503 });
10
+ if (request.headers.get('x-cron-key') !== key) return Response.json({ error: 'Not allowed' }, { status: 401 });
11
+ const { job } = await ctx.params;
12
+ const run = JOBS[job];
13
+ if (!run) return Response.json({ error: `Unknown job: ${job}` }, { status: 404 });
14
+ const started = Date.now();
15
+ const result = await run.handler();
16
+ return Response.json({ ok: true, job, ms: Date.now() - started, result });
17
+ }
@@ -0,0 +1,8 @@
1
+ import { connection } from 'next/server';
2
+ import { healthSchema } from '__SCOPE__/shared';
3
+
4
+ // GET /api/health. The response shape is the shared zod schema, so the phone app parses the same type.
5
+ export async function GET() {
6
+ await connection(); // run per request (not prerendered at build time)
7
+ return Response.json(healthSchema.parse({ ok: true, app: '__APP_SLUG__', time: new Date().toISOString() }));
8
+ }
@@ -0,0 +1,23 @@
1
+ @import "tailwindcss";
2
+
3
+ /* Colors are CSS variables written by app/layout.tsx from packages/theme.
4
+ Use them as Tailwind classes: bg-background, text-text, bg-brand, border-border… */
5
+ @theme inline {
6
+ --color-background: var(--color-background);
7
+ --color-surface: var(--color-surface);
8
+ --color-surface-alt: var(--color-surface-alt);
9
+ --color-border: var(--color-border);
10
+ --color-text: var(--color-text);
11
+ --color-text-muted: var(--color-text-muted);
12
+ --color-brand: var(--color-brand);
13
+ --color-brand-strong: var(--color-brand-strong);
14
+ --color-accent: var(--color-accent);
15
+ --color-on-brand: var(--color-on-brand);
16
+ --color-danger: var(--color-danger);
17
+ }
18
+
19
+ body {
20
+ background: var(--color-background);
21
+ color: var(--color-text);
22
+ font-family: var(--font-app, ui-sans-serif), system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
23
+ }
@@ -0,0 +1,24 @@
1
+ import type { Metadata } from 'next';
2
+ import { cssVariables } from '__SCOPE__/theme';
3
+ import { appFontClass } from './fonts';
4
+ import './globals.css';
5
+
6
+ export const metadata: Metadata = {
7
+ title: '__APP_NAME__',
8
+ description: '__APP_NAME__ by Techtiten',
9
+ };
10
+
11
+ // Theme colors come from packages/theme (same as the phone app), as CSS variables.
12
+ const toCss = (vars: Record<string, string>) => Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(';');
13
+ const themeCss = `:root{${toCss(cssVariables('light'))}}@media (prefers-color-scheme: dark){:root{${toCss(cssVariables('dark'))}}}`;
14
+
15
+ export default function RootLayout({ children }: LayoutProps<'/'>) {
16
+ return (
17
+ <html lang="en" className={`h-full antialiased ${appFontClass}`}>
18
+ <head>
19
+ <style dangerouslySetInnerHTML={{ __html: themeCss }} />
20
+ </head>
21
+ <body className="min-h-full flex flex-col">{children}</body>
22
+ </html>
23
+ );
24
+ }
@@ -0,0 +1,26 @@
1
+ import { NextResponse, type NextRequest } from 'next/server';
2
+
3
+ // HTTP basic auth for the admin panel and admin APIs. Set ADMIN_USER and ADMIN_PASSWORD
4
+ // in Vercel (and in apps/web/.env.local for development). Swap for real auth later if you need it.
5
+ export function proxy(request: NextRequest) {
6
+ const user = process.env.ADMIN_USER;
7
+ const pass = process.env.ADMIN_PASSWORD;
8
+ if (!user || !pass) return new NextResponse('Admin is locked: set ADMIN_USER and ADMIN_PASSWORD.', { status: 503 });
9
+
10
+ const header = request.headers.get('authorization') ?? '';
11
+ const [scheme, encoded] = header.split(' ');
12
+ if (scheme === 'Basic' && encoded) {
13
+ const [u, ...rest] = atob(encoded).split(':');
14
+ if (same(u, user) && same(rest.join(':'), pass)) return NextResponse.next();
15
+ }
16
+ return new NextResponse('Login required', { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="admin"' } });
17
+ }
18
+
19
+ // Compare without exiting early, so the time taken doesn't reveal how much matched.
20
+ function same(a: string, b: string) {
21
+ let diff = a.length ^ b.length;
22
+ for (let i = 0; i < Math.max(a.length, b.length); i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
23
+ return diff === 0;
24
+ }
25
+
26
+ export const config = { matcher: ['/admin/:path*', '/api/admin/:path*'] };
@@ -0,0 +1,9 @@
1
+ // Server-only jobs, called by /api/cron/[job]. Add yours here.
2
+ type Job = { description: string; handler: () => Promise<unknown> };
3
+
4
+ export const JOBS: Record<string, Job> = {
5
+ 'daily-summary': {
6
+ description: 'Example job: replace with your own (reminders, cleanup, reports).',
7
+ handler: async () => ({ processed: 0 }),
8
+ },
9
+ };