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.
Files changed (45) hide show
  1. package/README.md +48 -2
  2. package/bin/index.mjs +210 -0
  3. package/package.json +15 -4
  4. package/templates/mobile/ads/src/ads/Banner.tsx +14 -0
  5. package/templates/mobile/ads/src/ads/Banner.web.tsx +4 -0
  6. package/templates/mobile/common/app/_layout.tsx +36 -0
  7. package/templates/mobile/common/app/index.tsx +48 -0
  8. package/templates/mobile/common/app/settings.tsx +22 -0
  9. package/templates/mobile/common/app.config.ts +36 -0
  10. package/templates/mobile/common/config/plugins.json +1 -0
  11. package/templates/mobile/common/distribution/whatsnew/whatsnew-en-US +1 -0
  12. package/templates/mobile/common/plugins/withSigning.js +63 -0
  13. package/templates/mobile/common/src/api/client.ts +16 -0
  14. package/templates/mobile/common/src/theme/index.tsx +17 -0
  15. package/templates/mobile/common/src/ui/index.tsx +37 -0
  16. package/templates/mobile/common/tsconfig.json +8 -0
  17. package/templates/mobile/noads/src/ads/Banner.tsx +4 -0
  18. package/templates/mobile/redux/src/state/Providers.tsx +13 -0
  19. package/templates/mobile/redux/src/state/index.ts +16 -0
  20. package/templates/mobile/redux/src/state/store.ts +33 -0
  21. package/templates/mobile/zustand/src/state/Providers.tsx +7 -0
  22. package/templates/mobile/zustand/src/state/index.ts +25 -0
  23. package/templates/packages/shared/package.json +7 -0
  24. package/templates/packages/shared/src/format.ts +18 -0
  25. package/templates/packages/shared/src/index.ts +5 -0
  26. package/templates/packages/shared/src/money.ts +13 -0
  27. package/templates/packages/shared/src/schemas.ts +9 -0
  28. package/templates/packages/theme/package.json +7 -0
  29. package/templates/packages/theme/src/index.ts +53 -0
  30. package/templates/root/AGENTS.md +16 -0
  31. package/templates/root/README.md +22 -0
  32. package/templates/root/STATUS.md +13 -0
  33. package/templates/root/_github/workflows/android.yml +79 -0
  34. package/templates/root/_github/workflows/web-check.yml +19 -0
  35. package/templates/root/_gitignore +33 -0
  36. package/templates/root/package.json +13 -0
  37. package/templates/web/src/app/(site)/page.tsx +18 -0
  38. package/templates/web/src/app/admin/layout.tsx +17 -0
  39. package/templates/web/src/app/admin/page.tsx +32 -0
  40. package/templates/web/src/app/api/cron/[job]/route.ts +17 -0
  41. package/templates/web/src/app/api/health/route.ts +8 -0
  42. package/templates/web/src/app/globals.css +23 -0
  43. package/templates/web/src/app/layout.tsx +23 -0
  44. package/templates/web/src/proxy.ts +26 -0
  45. package/templates/web/src/server/jobs.ts +9 -0
@@ -0,0 +1,16 @@
1
+ import { useDispatch, useSelector } from 'react-redux';
2
+ import { actions, type Appearance, type AppDispatch, type RootState } from './store';
3
+
4
+ export type { Appearance } from './store';
5
+ export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
6
+ export const useAppSelector = useSelector.withTypes<RootState>();
7
+
8
+ // The hooks screens use (same names in the Zustand version of this template).
9
+ export function useAppearance() {
10
+ const dispatch = useAppDispatch();
11
+ return { appearance: useAppSelector((s) => s.settings.appearance), setAppearance: (a: Appearance) => dispatch(actions.setAppearance(a)) };
12
+ }
13
+ export function useTaps() {
14
+ const dispatch = useAppDispatch();
15
+ return { taps: useAppSelector((s) => s.taps.count), addTap: () => dispatch(actions.addTap()) };
16
+ }
@@ -0,0 +1,33 @@
1
+ // App state with Redux Toolkit. Server data (API calls) lives in TanStack Query instead.
2
+ import AsyncStorage from '@react-native-async-storage/async-storage';
3
+ import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit';
4
+
5
+ export type Appearance = 'system' | 'light' | 'dark';
6
+
7
+ const settings = createSlice({
8
+ name: 'settings',
9
+ initialState: { appearance: 'system' as Appearance },
10
+ reducers: { setAppearance: (s, a: PayloadAction<Appearance>) => { s.appearance = a.payload; } },
11
+ });
12
+
13
+ const taps = createSlice({
14
+ name: 'taps',
15
+ initialState: { count: 0 },
16
+ reducers: { addTap: (s) => { s.count += 1; } },
17
+ });
18
+
19
+ export const actions = { ...settings.actions, ...taps.actions };
20
+ export const store = configureStore({ reducer: { settings: settings.reducer, taps: taps.reducer } });
21
+ export type RootState = ReturnType<typeof store.getState>;
22
+ export type AppDispatch = typeof store.dispatch;
23
+
24
+ // Persist settings on this phone (small and explicit; add redux-persist if you need more).
25
+ const KEY = 'settings';
26
+ AsyncStorage.getItem(KEY)
27
+ .then((raw) => { const s = raw ? JSON.parse(raw) : null; if (s?.appearance) store.dispatch(actions.setAppearance(s.appearance)); })
28
+ .catch(() => {});
29
+ let last = store.getState().settings;
30
+ store.subscribe(() => {
31
+ const now = store.getState().settings;
32
+ if (now !== last) { last = now; AsyncStorage.setItem(KEY, JSON.stringify(now)).catch(() => {}); }
33
+ });
@@ -0,0 +1,7 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { useState, type ReactNode } from 'react';
3
+
4
+ export function Providers({ children }: { children: ReactNode }) {
5
+ const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } } }));
6
+ return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
7
+ }
@@ -0,0 +1,25 @@
1
+ // App state with Zustand. One small store per concern; `persist` saves to the phone.
2
+ // Server data (API calls) lives in TanStack Query instead (see src/api/client.ts).
3
+ import AsyncStorage from '@react-native-async-storage/async-storage';
4
+ import { create } from 'zustand';
5
+ import { createJSONStorage, persist } from 'zustand/middleware';
6
+
7
+ export type Appearance = 'system' | 'light' | 'dark';
8
+
9
+ type SettingsState = { appearance: Appearance; setAppearance: (a: Appearance) => void };
10
+ export const useSettingsStore = create<SettingsState>()(
11
+ persist((set) => ({ appearance: 'system', setAppearance: (appearance) => set({ appearance }) }), {
12
+ name: 'settings',
13
+ storage: createJSONStorage(() => AsyncStorage),
14
+ }),
15
+ );
16
+
17
+ type TapsState = { taps: number; addTap: () => void };
18
+ export const useTapsStore = create<TapsState>()((set) => ({ taps: 0, addTap: () => set((s) => ({ taps: s.taps + 1 })) }));
19
+
20
+ // The hooks screens use (same names in the Redux version of this template).
21
+ export const useAppearance = () => ({
22
+ appearance: useSettingsStore((s) => s.appearance),
23
+ setAppearance: useSettingsStore((s) => s.setAppearance),
24
+ });
25
+ export const useTaps = () => ({ taps: useTapsStore((s) => s.taps), addTap: useTapsStore((s) => s.addTap) });
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "__SCOPE__/shared",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts"
7
+ }
@@ -0,0 +1,18 @@
1
+ export function formatCurrency(amount: number, currency = 'INR', locale = 'en-IN'): string {
2
+ return new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 2 }).format(amount);
3
+ }
4
+
5
+ export function formatDate(d: Date | number | string, locale = 'en-IN'): string {
6
+ return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', year: 'numeric' }).format(new Date(d));
7
+ }
8
+
9
+ /** "just now", "5 min ago", "3 h ago", "2 d ago" */
10
+ export function timeAgo(d: Date | number | string, now = Date.now()): string {
11
+ const s = Math.max(0, Math.round((now - new Date(d).getTime()) / 1000));
12
+ if (s < 45) return 'just now';
13
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
14
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
15
+ return `${Math.round(s / 86400)} d ago`;
16
+ }
17
+
18
+ export const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
@@ -0,0 +1,5 @@
1
+ // Logic both apps use: money/date helpers, shared types and zod schemas.
2
+ // The same schema validates input in the API and parses responses in the app.
3
+ export * from './format';
4
+ export * from './money';
5
+ export * from './schemas';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Split an amount (in paise/cents, an integer) into `parts` shares that add up exactly.
3
+ * splitEvenly(1000, 3) -> [334, 333, 333]
4
+ */
5
+ export function splitEvenly(total: number, parts: number): number[] {
6
+ if (!Number.isInteger(total) || !Number.isInteger(parts) || parts <= 0) throw new Error('splitEvenly: integers only, parts > 0');
7
+ const base = Math.floor(total / parts);
8
+ const extra = total - base * parts;
9
+ return Array.from({ length: parts }, (_, i) => base + (i < extra ? 1 : 0));
10
+ }
11
+
12
+ export const toMinor = (amount: number) => Math.round(amount * 100);
13
+ export const fromMinor = (minor: number) => minor / 100;
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod';
2
+
3
+ /** GET /api/health response. Used by the API (to build it) and the app (to parse it). */
4
+ export const healthSchema = z.object({
5
+ ok: z.boolean(),
6
+ app: z.string(),
7
+ time: z.string(),
8
+ });
9
+ export type Health = z.infer<typeof healthSchema>;
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "__SCOPE__/theme",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts"
7
+ }
@@ -0,0 +1,53 @@
1
+ // One theme for the phone app and the website. Change a color here and both update.
2
+ export type Mode = 'light' | 'dark';
3
+
4
+ export const palette = {
5
+ brand: '#00C389',
6
+ brandStrong: '#00A372',
7
+ accent: '#7C5CFF',
8
+ danger: '#E5484D',
9
+ warning: '#E9A049',
10
+ success: '#2EAD5B',
11
+ } as const;
12
+
13
+ export const colors = {
14
+ light: {
15
+ background: '#F7F8FA',
16
+ surface: '#FFFFFF',
17
+ surfaceAlt: '#EEF0F3',
18
+ border: '#E2E5EA',
19
+ text: '#0B0D12',
20
+ textMuted: '#5B6270',
21
+ onBrand: '#FFFFFF',
22
+ ...palette,
23
+ },
24
+ dark: {
25
+ background: '#0B0B12',
26
+ surface: '#15151F',
27
+ surfaceAlt: '#1D1D2A',
28
+ border: '#2A2A38',
29
+ text: '#ECEDF2',
30
+ textMuted: '#9A9CAB',
31
+ onBrand: '#04110C',
32
+ ...palette,
33
+ },
34
+ } as const;
35
+
36
+ export type ColorName = keyof (typeof colors)['light'];
37
+ export type Colors = Record<ColorName, string>;
38
+
39
+ export const spacing = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32 } as const;
40
+ export const radius = { sm: 8, md: 12, lg: 16, xl: 24, full: 999 } as const;
41
+ export const fontSize = { xs: 12, sm: 14, md: 16, lg: 18, xl: 22, xxl: 28, display: 36 } as const;
42
+ export const fontWeight = { regular: '400', medium: '500', bold: '700', black: '800' } as const;
43
+
44
+ export const theme = (mode: Mode) => ({ mode, colors: colors[mode] as Colors, spacing, radius, fontSize, fontWeight });
45
+ export type Theme = ReturnType<typeof theme>;
46
+
47
+ /** CSS custom properties for the web app: `--color-brand`, `--color-text`… */
48
+ export function cssVariables(mode: Mode): Record<string, string> {
49
+ const out: Record<string, string> = {};
50
+ for (const [k, v] of Object.entries(colors[mode])) out[`--color-${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}`] = v;
51
+ for (const [k, v] of Object.entries(radius)) out[`--radius-${k}`] = `${v}px`;
52
+ return out;
53
+ }
@@ -0,0 +1,16 @@
1
+ # __APP_NAME__: rules for every agent (Claude Code, Cowork, Codex, Cursor)
2
+
3
+ **Start of a session:** read `STATUS.md`, then check `git status`, the branch and HEAD vs `origin/main`. Say so if it doesn't match.
4
+ **End of a session:** update `STATUS.md` (Now, Next, to-dos, one dated Log line).
5
+
6
+ ## Layout
7
+ - `apps/mobile`: Expo (Expo Router). Screens in `app/`, logic in `src/`. No `android/`/`ios/` in git: they're generated (`npx expo prebuild`).
8
+ - `apps/web`: Next.js App Router. Website `src/app/(site)`, admin `src/app/admin`, API `src/app/api`. Runs on Vercel (serverless: short requests, no websockets; heavy jobs go in batches or a separate worker).
9
+ - `packages/theme` and `packages/shared`: used by both apps. Put colors and shared logic here, never copy-paste between apps.
10
+
11
+ ## Rules
12
+ - Ship flow: branch from `origin/main` → PR → merge. **A merge to `main` deploys** (Vercel for web; the Android workflow for mobile, only when `apps/mobile/**` or `packages/**` change).
13
+ - `git add` exact files, never `-A`. Never commit `.env*`, keystores, `credentials/`, `google-services.json` or service-account JSON.
14
+ - Expo and Next.js here are newer than most training data: read the versioned docs (`node_modules/next/dist/docs/`, docs.expo.dev for this SDK) before writing API code.
15
+ - Native packages need a new store build; they can't go out as an OTA update.
16
+ - Add Expo packages with `npx expo install`, so versions match the SDK.
@@ -0,0 +1,22 @@
1
+ # __APP_NAME__
2
+
3
+ Made with `create-techtiten-app`.
4
+
5
+ | Folder | What |
6
+ |---|---|
7
+ | `apps/mobile` | Expo app (Android/iOS), Expo Router, __STATE__ |
8
+ | `apps/web` | Next.js: website, `/admin` panel, `/api` backend (if enabled) |
9
+ | `packages/theme` | Colors, spacing, radius, font sizes: one source for both apps |
10
+ | `packages/shared` | Functions, types and zod schemas used by both apps |
11
+
12
+ ## Run
13
+ ```
14
+ npm install
15
+ npm run mobile # Expo (press a for Android)
16
+ npm run web # Next.js on http://localhost:3000
17
+ npm run typecheck # every workspace
18
+ ```
19
+
20
+ ## Add a package
21
+ - Native/Expo package: `cd apps/mobile && npx expo install <pkg>` (picks the SDK-matched version)
22
+ - Anything else: `npm install <pkg> -w __SCOPE__/mobile` (or `__SCOPE__/web`, `__SCOPE__/shared`)
@@ -0,0 +1,13 @@
1
+ # STATUS: __APP_NAME__
2
+
3
+ ## Now
4
+ - Fresh project from create-techtiten-app.
5
+
6
+ ## Next
7
+ - [ ] Replace the starter screens.
8
+ - [ ] Set GitHub secrets for the Android release (see `.github/workflows/android.yml`).
9
+
10
+ ## Open to-dos
11
+
12
+ ## Log
13
+ - __YEAR__: project created.
@@ -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,13 @@
1
+ {
2
+ "name": "__APP_SLUG__",
3
+ "private": true,
4
+ "workspaces": ["apps/*", "packages/*"],
5
+ "scripts": {
6
+ "mobile": "npm run start -w __SCOPE__/mobile",
7
+ "android": "npm run android -w __SCOPE__/mobile",
8
+ "web": "npm run dev -w __SCOPE__/web",
9
+ "build:web": "npm run build -w __SCOPE__/web",
10
+ "typecheck": "npm run typecheck --workspaces --if-present"
11
+ },
12
+ "engines": { "node": ">=20.19" }
13
+ }
@@ -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: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
23
+ }
@@ -0,0 +1,23 @@
1
+ import type { Metadata } from 'next';
2
+ import { cssVariables } from '__SCOPE__/theme';
3
+ import './globals.css';
4
+
5
+ export const metadata: Metadata = {
6
+ title: '__APP_NAME__',
7
+ description: '__APP_NAME__ by Techtiten',
8
+ };
9
+
10
+ // Theme colors come from packages/theme (same as the phone app), as CSS variables.
11
+ const toCss = (vars: Record<string, string>) => Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(';');
12
+ const themeCss = `:root{${toCss(cssVariables('light'))}}@media (prefers-color-scheme: dark){:root{${toCss(cssVariables('dark'))}}}`;
13
+
14
+ export default function RootLayout({ children }: LayoutProps<'/'>) {
15
+ return (
16
+ <html lang="en" className="h-full antialiased">
17
+ <head>
18
+ <style dangerouslySetInnerHTML={{ __html: themeCss }} />
19
+ </head>
20
+ <body className="min-h-full flex flex-col">{children}</body>
21
+ </html>
22
+ );
23
+ }
@@ -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
+ };