Compare commits

...
Sign in to create a new pull request.

36 commits

Author SHA1 Message Date
884ec8f791 Merge pull request 'master' (#8) from master into main
Reviewed-on: #8
2026-09-14 18:11:59 +00:00
1446cc33e4 Merge branch 'main' into master 2026-09-14 18:09:02 +00:00
shafi54
d0b74fb61a Merge branch 'master' of https://git.technocracy.ovh/shafi/freshyo 2026-09-14 10:05:46 +05:30
021dfdb9c8 Merge pull request 'DEAD_CODE_CLEAN' (#7) from DEAD_CODE_CLEAN into master
Reviewed-on: #7
2026-09-14 04:32:52 +00:00
9bca425f11 Merge pull request 'DEAD_CODE_CLEAN' (#6) from DEAD_CODE_CLEAN into main
Reviewed-on: #6
2026-09-14 04:29:35 +00:00
shafi54
af64049dce Update taste.md 2026-09-14 09:58:28 +05:30
shafi54
1e7db8f016 enh 2026-09-14 09:43:45 +05:30
shafi54
c4a33b91da enh 2026-09-13 00:29:26 +05:30
shafi54
5591afb57f enh 2026-09-13 00:23:19 +05:30
shafi54
faea152d5d first users only coupons. 2026-09-13 00:06:15 +05:30
shafi54
b68b2abe18 Create ANY_USAGE_REPORT.md 2026-09-12 23:03:05 +05:30
shafi54
8aaaa12ca0 enh 2026-09-12 11:45:07 +05:30
shafi54
ee60799a8b enh 2026-09-12 09:11:14 +05:30
shafi54
f8e7eec754 enh 2026-09-12 09:06:33 +05:30
shafi54
cc17653402 enh 2026-09-08 20:35:09 +05:30
shafi54
86792d1ba3 enhh 2026-09-08 13:55:59 +05:30
shafi54
02d307e5f2 Update app.ts 2026-09-08 13:41:09 +05:30
shafi54
c29fbb9a84 enh 2026-09-08 13:36:58 +05:30
shafi54
4aa7d8a1d4 enhh 2026-09-07 23:42:50 +05:30
shafi54
0bfd43c172 enh 2026-09-06 23:05:20 +05:30
shafi54
5d7648c6b0 enhh 2026-09-06 19:27:52 +05:30
shafi54
7537780caa enh 2026-09-06 19:24:55 +05:30
shafi54
b486b54e09 enh 2026-09-06 19:24:46 +05:30
shafi54
b5fd64e5ed enh 2026-09-06 18:47:21 +05:30
shafi54
ffcab51d98 enh 2026-09-06 18:05:20 +05:30
shafi54
1ff771355e types enh 2026-09-06 16:14:54 +05:30
shafi54
d58530075d enh 2026-09-05 11:45:08 +05:30
shafi54
bfb67e626b Icon type change. 2026-09-05 11:23:39 +05:30
shafi54
7ad1b81356 enh 2026-09-05 11:12:47 +05:30
shafi54
cfb84aae7a enh 2026-09-05 00:12:01 +05:30
shafi54
a825d5285b enh 2026-09-04 23:27:01 +05:30
shafi54
3ebbf989f0 enh 2026-09-03 20:10:42 +05:30
shafi54
be71f8eaa1 enh 2026-09-03 19:43:12 +05:30
shafi54
adb81a6b3d enh 2026-09-02 23:12:49 +05:30
shafi54
7064625cba enh 2026-09-02 21:44:05 +05:30
shafi54
54b265f459 enh 2026-08-10 20:20:42 +05:30
498 changed files with 42476 additions and 31102 deletions

View file

@ -32,7 +32,56 @@
"Shell(python3 -c import sys,json; d=json.load(sys.stdin); print('products:', len(d.get('products',[]))); print('tags:', [t['tagName'] for t in d.get('tags',[])][:12]) 2 >& 1)",
"Shell(curl -s http://localhost:4174/src/styles.css 2 > /dev/null)",
"Shell(printf:*)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/backend && python3 - <<'EOF' import re with open('dumps/local_8_aug.sql') as f: content = f.read() # Find all CREATE TABLE positions and all REFERENCES target tables create_positions = {} # table name -> line for m in re.finditer(r'CREATE TABLE(?: IF NOT EXISTS)? \"?([A-Za-z_]+)\"? \\(', content): create_positions[m.group(1)] = content.count('\\n', 0, m.start()) + 1 # Verify each REFERENCES target is defined before the referencing CREATE errors = [] for m in re.finditer(r'CREATE TABLE(?: IF NOT EXISTS)? \"?([A-Za-z_]+)\"? \\(([^;]*?)\\)', content, re.S): table = m.group(1) table_line = content.count('\\n', 0, m.start()) + 1 body = m.group(2) for ref in re.findall(r'REFERENCES `?([A-Za-z_]+)`?\\(', body): if ref in create_positions and create_positions[ref] > table_line: errors.append(f\"{table} (line {table_line}) references {ref} (defined line {create_positions[ref]})\") if errors: print(\"FAIL - out of order:\") for e in errors: print(\" \", e) else: print(\"PASS - all REFERENCES resolve to tables defined earlier\") EOF)"
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/backend && python3 - <<'EOF' import re with open('dumps/local_8_aug.sql') as f: content = f.read() # Find all CREATE TABLE positions and all REFERENCES target tables create_positions = {} # table name -> line for m in re.finditer(r'CREATE TABLE(?: IF NOT EXISTS)? \"?([A-Za-z_]+)\"? \\(', content): create_positions[m.group(1)] = content.count('\\n', 0, m.start()) + 1 # Verify each REFERENCES target is defined before the referencing CREATE errors = [] for m in re.finditer(r'CREATE TABLE(?: IF NOT EXISTS)? \"?([A-Za-z_]+)\"? \\(([^;]*?)\\)', content, re.S): table = m.group(1) table_line = content.count('\\n', 0, m.start()) + 1 body = m.group(2) for ref in re.findall(r'REFERENCES `?([A-Za-z_]+)`?\\(', body): if ref in create_positions and create_positions[ref] > table_line: errors.append(f\"{table} (line {table_line}) references {ref} (defined line {create_positions[ref]})\") if errors: print(\"FAIL - out of order:\") for e in errors: print(\" \", e) else: print(\"PASS - all REFERENCES resolve to tables defined earlier\") EOF)",
"Shell(find packages/db_helper_sqlite/src packages/db_helper_postgres/src -name \"*.ts\" | xargs wc -l | tail -5 && echo \"=== apps imports of db_helper ===\" && grep -rn \"db_helper\" apps packages --include=\"*.ts\" --include=\"*.tsx\" --include=\"*.json\" -l | grep -v node_modules | grep -v \"db_helper_sqlite/\\|db_helper_postgres/\\|\\.turbo\\|dist\" | sort -u)",
"Shell(cat apps/backend/wrangler.toml 2>/dev/null | head -30; echo \"=== package.json ===\"; cat apps/backend/package.json; echo \"=== how build/dev resolves ===\"; ls apps/backend/.wrangler/tmp/dev-*/worker.js 2>/dev/null | head -1 | xargs grep -o \"sqliteService\\|getAllSlotsWithProductsForCache\" 2>/dev/null | head -3; echo \"=== tsx alias config? ===\"; grep -rn \"tsx\\|alias\" apps/backend/package.json apps/backend/wrangler.toml 2>/dev/null | head; echo \"=== node_modules links ===\"; ls -la apps/backend/node_modules/ 2>/dev/null | grep -i \"db_helper\\|sqlite\\|postgres\" | head)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && for pkg in packages/db_helper_sqlite packages/db_helper_postgres; do echo \"########## $pkg ##########\"; for f in $(find $pkg/src -name \"*.ts\" | sort); do # relative imports only grep -oE \"from '(\\.\\.?/[^']+)'\" $f | sed \"s/from '//;s/'//\" | while read imp; do resolved=$(cd $(dirname $f) && node -e \"const p=require('path');try{const r=p.resolve('$imp');console.log(r.startsWith(process.cwd())?p.relative(process.cwd(),r):'')}catch(e){}\" 2>/dev/null) if [ -n \"$resolved\" ] && [ -f \"$resolved.ts\" ] || [ -n \"$resolved\" ] && [ -f \"$resolved\" ]; then echo \"$(echo $f | sed 's|^packages/||') -> $resolved\"; fi done; done; done 2>/dev/null | sort -u | grep -v \"db_index\\|schema.ts ->\" | head -80)",
"Shell(node -e const fs = require(\"fs\"), path = require(\"path\"); for (const pkg of [\"packages/db_helper_sqlite\", \"packages/db_helper_postgres\"]) { console.log(\"########## \" + pkg + \" ##########\"); const files = []; (function walk(d){ for (const e of fs.readdirSync(d)) { const p = path.join(d,e); if (fs.statSync(p).isDirectory()) walk(p); else if (e.endsWith(\".ts\")) files.push(p); } })(pkg); const edges = {}; for (const f of files) { const src = fs.readFileSync(f, \"utf8\"); const re = /from\\s+[\\x27\"](\\.\\.?\\/[^\\x27\"]+)[\\x27\"]/g; let m; while ((m = re.exec(src))) { const base = path.dirname(f); let target = path.resolve(base, m[1]); if (!fs.existsSync(target) && fs.existsSync(target + \".ts\")) target += \".ts\"; if (target.startsWith(process.cwd()) && fs.existsSync(target)) { const rel = path.relative(pkg, target); if (!rel.startsWith(\"..\")) { (edges[f] = edges[f] || []).push(rel); } } } } const allRel = files.map(f => path.relative(pkg, f)); const imported = new Set(Object.values(edges).flat()); const deadFiles = allRel.filter(r => r !== \"index.ts\" && !imported.has(r) && !r.includes(\"/db/seed\") && !r.includes(\"drizzle.config\")); // internal cross-file usage counts: who imports this rel path (excluding itself) for (const f of files) { const rel = path.relative(pkg, f); const importers = Object.entries(edges).filter(([src, tgts]) => tgts.includes(rel) && src !== f).map(([src]) => path.relative(pkg, src)); if (rel.endsWith(\".ts\") && importers.length) console.log(rel + \" <== imported by: \" + importers.join(\", \")); } console.log(\"--- files with NO internal importers (candidates for dead file): ---\"); console.log(deadFiles.join(\"\\n\")); } )",
"Shell(node -e const fs = require(\"fs\"), path = require(\"path\"); for (const pkg of [\"packages/db_helper_sqlite\", \"packages/db_helper_postgres\"]) { console.log(\"########## \" + pkg + \" ##########\"); const files = []; (function walk(d){ if (d.includes(\"node_modules\")) return; for (const e of fs.readdirSync(d)) { const p = path.join(d,e); if (fs.statSync(p).isDirectory()) walk(p); else if (e.endsWith(\".ts\")) files.push(p); } })(pkg); const imported = new Set(); const importers = {}; // target -> [sources] for (const f of files) { const src = fs.readFileSync(f, \"utf8\"); const re = /from\\s+[\\x27\"](\\.\\.?\\/[^\\x27\"]+)[\\x27\"]/g; let m; while ((m = re.exec(src))) { let target = path.resolve(path.dirname(f), m[1]); if (!fs.existsSync(target) && fs.existsSync(target + \".ts\")) target += \".ts\"; if (target.startsWith(pkg) && fs.existsSync(target)) { const rel = path.relative(pkg, target); imported.add(rel); (importers[rel] = importers[rel] || []).push(path.relative(pkg, f)); } } } const excluded = new Set([\"index.ts\",\"drizzle.config.ts\",\"src/db/seed.ts\",\"src/db/db_index.ts\",\"src/db/schema.ts\"]); const noImporter = files.map(f=>path.relative(pkg,f)).filter(r => !imported.has(r) && !excluded.has(r) && !r.includes(\"drizzle.config\")); console.log(\"Files never imported internally (incl. index.ts not importing them? checked separately):\"); for (const r of noImporter) console.log(\" \" + r); // now check which of those are imported by index.ts explicitly const idx = fs.readFileSync(path.join(pkg,\"index.ts\"),\"utf8\"); for (const r of noImporter) { const basename = path.basename(r, \".ts\"); if (!idx.includes(basename)) console.log(\" [also NOT in index.ts] \" + r); } } )",
"Shell(node -e const fs = require(\"fs\"), path = require(\"path\"); const pkg = \"packages/db_helper_sqlite\"; const idx = fs.readFileSync(path.join(pkg,\"index.ts\"),\"utf8\"); const re = /from\\s+[\\x27\"](\\.\\.?\\/[^\\x27\"]+)[\\x27\"]/g; let m; while ((m = re.exec(idx))) { const raw = m[1]; let target = path.resolve(pkg, raw); if (!fs.existsSync(target) && fs.existsSync(target+\".ts\")) target += \".ts\"; console.log(JSON.stringify(raw), \"exists:\", fs.existsSync(target), \"rel:\", path.relative(pkg, target), \"startsWithPkg:\", target.startsWith(pkg), \"target:\", target); } )",
"Shell(node -e const fs = require(\"fs\"), path = require(\"path\"); const cwd = process.cwd(); for (const pkg of [\"packages/db_helper_sqlite\", \"packages/db_helper_postgres\"]) { console.log(\"########## \" + pkg + \" ##########\"); const pkgAbs = path.resolve(pkg); const files = []; (function walk(d){ if (d.includes(\"node_modules\")) return; for (const e of fs.readdirSync(d)) { const p = path.join(d,e); if (fs.statSync(p).isDirectory()) walk(p); else if (e.endsWith(\".ts\")) files.push(p); } })(pkgAbs); const imported = new Set(); for (const f of files) { const src = fs.readFileSync(f, \"utf8\"); const re = /from\\s+[\\x27\"](\\.\\.?\\/[^\\x27\"]+)[\\x27\"]/g; let m; while ((m = re.exec(src))) { let target = path.resolve(path.dirname(f), m[1]); if (!fs.existsSync(target) && fs.existsSync(target + \".ts\")) target += \".ts\"; if (target.startsWith(pkgAbs) && fs.existsSync(target) && target !== f) { imported.add(path.relative(pkgAbs, target)); } } } const allRel = files.map(f => path.relative(pkgAbs, f)).sort(); console.log(\"Total ts files:\", allRel.length); for (const r of allRel) { if (r === \"index.ts\" || r.includes(\"drizzle.config\")) continue; if (!imported.has(r)) console.log(\" NEVER-IMPORTED FILE: \" + r); } } )",
"Shell(for:*)",
"Shell(do:*)",
"Shell(done:*)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && echo \"=== product.ts trpc import line & usage count ===\" && sed -n '1,40p' apps/backend/src/trpc/apis/admin-apis/apis/product.ts && echo \"=== count occurrences of each beyond import ===\" && for fn in checkUnitExists getProductImagesById replaceProductTags; do echo \"$fn: total=$(grep -c \"$fn\" apps/backend/src/trpc/apis/admin-apis/apis/product.ts)\"; done)",
"Shell(node -e const fs = require(\"fs\"); const parse = (p) => { const src = fs.readFileSync(p,\"utf8\"); const names = new Set(); // export { a, b as c, ... } from / re-export lists const re = /\\bexport\\s*\\{([^}]*)\\}/g; let m; while ((m = re.exec(src))) { const body = m[1]; for (let line of body.split(\",\")) { line = line.trim().replace(/\\/\\/.*$/,\"\").trim(); if (!line) continue; const asMatch = line.match(/^(.+?)\\s+as\\s+(.+)$/); if (asMatch) names.add(asMatch[2].trim()); else if (line.match(/^[A-Za-z_$][\\w$]*$/)) names.add(line); } } return names; }; const sqlite = parse(\"packages/db_helper_sqlite/index.ts\"); const pg = parse(\"packages/db_helper_postgres/index.ts\"); const onlySqlite = [...sqlite].filter(x=>!pg.has(x)).sort(); const onlyPg = [...pg].filter(x=>!sqlite.has(x)).sort(); console.log(\"ONLY IN SQLITE INDEX:\", onlySqlite.join(\", \")); console.log(\"ONLY IN POSTGRES INDEX:\", onlyPg.join(\", \")); )",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/packages/db_helper_postgres npx tsc --noEmit > /tmp/pg_tsc.log 2>&1 echo \"tsc exit: $?\" echo \"=== error count ===\"; grep -c \"error TS\" /tmp/pg_tsc.log echo \"=== errors touching shared types / banner / coupon / store / const / complaint / staff-user ===\" grep -E \"admin-apis/(banner|coupon|store|const|complaint|staff-user|slots|vendor-snippets|order)\\.ts\" /tmp/pg_tsc.log | head -20 echo \"=== total error files ===\" grep \"error TS\" /tmp/pg_tsc.log | grep -oE \"^[^(]+\" | sort -u)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/web-ui && npx tsc --noEmit > /tmp/me_tsc.txt 2>&1; echo \"exit=$?\"; wc -l /tmp/me_tsc.txt; head -20 /tmp/me_tsc.txt)",
"Shell(sleep:*)",
"Shell(for p in /me /me/orders /me/addresses /me/coupons /me/complaints /me/edit-profile /me/terms; do code=$(curl -s -o /dev/null -w \"%{http_code}\" \"http://localhost:4175$p\"); echo \"$p -> $code\"; done)",
"Shell(curl -s http://localhost:4175/me)",
"Shell(pkill -f \"vite dev\" 2>/dev/null; pkill -f \"web-ui\" 2>/dev/null; echo \"stopped\")",
"Shell(npx playwright install chromium 2 >& 1)",
"Shell(npx playwright --version)",
"Shell(npx playwright test --list 2 >& 1)",
"Shell(curl -s http://localhost:4175/login)",
"Shell(curl -s -o /dev/null -w %{http_code}\\n http://localhost:4175/login)",
"Shell(grep -aiE \"error|fail\" /tmp/adminweb_dev.log | head -10 || echo \"no errors\"; pkill -f \"vite dev\" 2>/dev/null; pkill -f \"admin-web\" 2>/dev/null; echo \"stopped\")",
"Shell(git:*)",
"Shell(bun:*)",
"Shell(bunx playwright test --list -c ../playwright.config.ts 2 >& 1)",
"Shell([:*)",
"Shell(sed:*)",
"Shell(curl -s -o /dev/null -w %{http_code}\\n --max-time 4 http://localhost:4175/login 2 > /dev/null)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && bun run test:e2e > /tmp/e2e_run.log 2>&1; echo \"exit=$?\")",
"Shell(ps:*)",
"Shell(npx tsc --noEmit -p tests 2 >& 1)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && bun run test:e2e > /tmp/e2e_run2.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run2.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && sed -n '/```yaml/,/```/p' \"test-results/auth.setup.ts-authenticate-staff-user-setup/error-context.md\")",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && bun run test:e2e > /tmp/e2e_run3.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run3.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run4.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run4.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && sed -n '/```yaml/,/```/p' \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | head -60)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run5.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run5.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && sed -n '/```yaml/,/```/p' \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | head -50)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && bun run test:e2e > /tmp/e2e_run6.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run6.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run7.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run7.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && sed -n '/```yaml/,/```/p' \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | head -45)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && grep -c \"add-slot-fab\" \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\"; grep -ao \"add-slot-fab\" \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | head; echo \"--- tail of snapshot:\"; sed -n '/```yaml/,/```/p' \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | tail -25)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run8.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run8.log)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run9.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run9.log)",
"Shell(DEBUG=pw:browser npx playwright test tests/specs/smoke.spec.ts 2 >& 1)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && git diff product_to_sku_c1 -- apps/user-ui/hooks/cart-query-hooks.tsx apps/user-ui/hooks/useUploadToObjectStore.ts apps/user-ui/eas.json apps/user-ui/'(drawer)' 2>/dev/null; git diff product_to_sku_c1 -- \"apps/user-ui/app/(drawer)/(tabs)/me/addresses/index.tsx\" \"apps/user-ui/app/(drawer)/(tabs)/me/my-orders/index.tsx\" | head -160)",
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/packages/db_helper_sqlite && npx tsc --noEmit 2>&1 | tail -8; echo \"sqlite_exit=$?\"; echo \"=== user-ui ===\"; cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/user-ui && npx tsc --noEmit 2>&1 | tee /tmp/uui_tsc.txt | tail -5; echo \"userui_errors=$(grep -cE 'error TS' /tmp/uui_tsc.txt)\")"
],
"deny": [],
"defaultMode": "default"

View file

@ -1,4 +1,5 @@
# Taste
- Defines tunable/magic numbers as named module-level constants in the SAME file where they are used (e.g., `const TOKENS_PER_QUEUE_MESSAGE = 5` next to the code it configures, with a short explanatory comment) instead of inline literals or a shared/global config — explicitly stated: "Define 5 as a constant in the same file." Confidence: 0.7
- Prefers that the agent not run typechecks; instead, read the project's AGENTS.md for instructions on what to do/not do before finalizing work. Confidence: 0.9
- Judges completion by seeing the feature live in the running app ("I still don't see it on the /home route page"), so for data-driven features the agent must verify the app's actual runtime data source (API base URL, cache/backend) has the required data — not just that code builds and typechecks. Confirmed when the user signaled success only after the fix (pointing web-ui at the local backend with the tags data) made the section visible in the running app, and reconfirmed with the admin-ui product selector showing no products because it points at the production worker. Confidence: 0.9
- Prefers detailed analysis and explicit error/mistake checking before executing destructive operations, rather than just performing the action — e.g., before a git merge, or before deleting a row directly from the DB (asked "Can I simply delete the row from db. Will it cause any problems" about an unclaimed reserved coupon, expecting the agent to trace all referencing tables/FKs — couponUsages, couponApplicableUsers/Products — before answering). Confidence: 0.7
@ -22,14 +23,15 @@
- Prefers test plans written for a non-technical audience, using plain-language, click-by-click instructions ("tap this", "type that", "check there") rather than technical terms or API names. Confidence: 0.9
- When updating or creating a document, prefers the agent first compare it against existing source documents, identify missing items or gaps, and add them in the same established format. Confidence: 0.9
- Dislikes nested headers in mobile/drawer navigation; prefers a single, shared header (e.g., the drawer header) and relies on device back buttons or gestures for returning to previous screens. Confidence: 0.9
- After reviewing a presented plan, prefers brief, action-oriented approval (e.g., "nice. go ahead", "go ahead and implement") before implementation proceeds, expecting the agent to autonomously execute the already-presented plan. Confidence: 0.85
- After reviewing a presented plan, prefers brief, action-oriented approval (e.g., "nice. go ahead", "go ahead and implement") before implementation proceeds, expecting the agent to autonomously execute the already-presented plan. Confidence: 0.9
- Wants feature parity maintained between the web-ui and user-ui apps (port logic/data patterns, rebuild UI per platform) — e.g., when he asks for a link in the cart page "at the coupons section" of @apps/user-ui/components/cart-page.tsx, he means mirror user-ui's coupons section (card, header, icon) in web-ui and add the requested link there; and conversely asks for features added in one app to be added to the sibling app "too" (e.g., "add the coupon redirection on the user-ui cart-page too"), expecting the agent to locate the equivalent component/route and replicate it. Confidence: 0.98
- Wants the web-ui home page section/component order to match the reference user-ui home page exactly (e.g., "on the home page have the order same as that of @apps/user-ui/.../home/index.tsx"), including when deciding placement of sections like Explore, Stores, Slots, and All Products. Confidence: 0.7
- Wants brand/logo spots to use the single real brand logo asset (shared across apps, e.g., the Freshyo splash logo) displayed as an image, not a generic/decorative icon (e.g., a "meat piece" Meat/Beef icon) — web-ui should be on par with user-ui and show the same logo; applies to favicon, PWA icons, and every in-UI branding spot (sidebar, topbar, login, home banner). Confidence: 0.85
- Prefers dead-code audits to be documented in a markdown file. Confidence: 0.8
- Prefers dead-code audits to be documented in a markdown file. Confidence: 0.9
- Prefers detailed technical documentation of system architecture, data models, flows, and integrations in markdown format. Confidence: 0.8
- Wants edge cases explicitly enumerated when documenting or analyzing existing code/systems. Confidence: 0.8
- Values end-to-end analysis of features (architecture, data model, flows, integrations, and edge cases) when asked to explain how something works. Confidence: 0.7
- When creating a clone/mirror of an app on a different platform (e.g., a web version of a React Native app), expects EXACT replication — functionality and looks must match the reference app with zero changes ("not even a slight change is acceptable"). The tech stack is inherited from the existing clone, not the original. The exactness requirement applies per section and down to every sub-page: naming a section of the RN app (e.g., "the 'me' section and all its sub pages") means the entire subtree must be an exact replica of the mobile screens, and he will flag remaining gaps ("I see a lot of gaps") if any sub-screen diverges — so the agent should audit every sub-page, not just the section hub, against the RN source of truth. Confidence: 0.95
- Prefers unifying parallel data structures into a single instance rather than maintaining separate ones (e.g., one cart for flash and regular items instead of separate flash/regular carts). Confidence: 0.9
- Prefers using sentinel values in existing fields (e.g., slotId = 0) to distinguish special-case items rather than creating separate fields or structures. Confidence: 0.9
- Prefers handling special-case logic locally in the relevant component/file rather than globally or via parallel flows. Confidence: 0.85
@ -90,6 +92,38 @@ er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the
- For bulk/sequential generation from a code pattern (e.g., ABC12 → ABC13...), expects numbering to continue from the HIGHEST existing value so regeneration never re-emits already-entered codes — after entering ABC12, the next batch must start at ABC13, not restart at 12 (the first matching code). Confidence: 0.6
- Dislikes UI that renders transient loading states as definitive business states — e.g., the home page showed every item as "out of stock" until slot data loaded, because the out-of-stock check treated "no slot yet" (data not loaded) as genuinely out of stock. Expects a "data loaded" guard so the UI never flashes a wrong definitive status (out of stock) before data arrives, while still applying real out-of-stock detection once loaded. Confidence: 0.7
- When deleting a record, prefers the existing soft-delete/invalidate path over hard-deleting rows: the supported delete already sets `isInvalidated: true` (keeping history/audit and avoiding broken FK joins), and a raw hard delete should only be considered when confirmed nothing references the row (no usage records), with related rows (couponApplicableUsers/Products) removed transactionally to avoid orphans. Confidence: 0.75
- When commissioning a dead-code audit of an app/package, expects a "close analysis" that is exhaustive across every category — dead, unreachable, and unused code: orphaned files, unreachable/unlinked routes, dormant or disabled-by-flag features, dead branches and commented-out blocks, and unused imports/exports — finding "every such code which serves no purpose" rather than only obviously-unreferenced files; findings should distinguish verified-dead items from merely dormant/judgment-call items. Confidence: 0.65
- When an analysis/audit has been produced for one app in the monorepo, expects the same analysis run for the sibling apps too — after the user-ui dead-code audit he immediately asked "now do the same for the @apps/admin-ui" — applying the identical exhaustive methodology and document style to each named app. Confidence: 0.85
- For analysis documents, expects a consistent naming pattern following the established convention (`user_ui_dpsk.md``admin_ui_dpsk.md`, i.e., `{app}_dpsk.md` at the repo root) rather than an ad-hoc filename — "make another md file with same naming pattern"; the `_dpsk.md` suffix also applies to cross-cutting topic analyses (e.g., `repeat_types_dpsk.md` for the repeated-types scan). Confidence: 0.9
- When commissioning a cross-cutting audit/analysis, expects the scan to span the entire monorepo in one pass — every app, the backend, AND the shared packages (e.g., "scan the entire @apps/user-ui/ @apps/admin-ui/ @apps/backend/ and also entire @packages/") — not just one app or one area. Confidence: 0.65
- Values type-level hygiene alongside runtime structures: wants repeated type declarations across the monorepo hunted down and classified (identical duplicates, near-identical/unifiable shapes, same-name-different-shape collisions) and documented in a report before any refactor, rather than fixing types piecemeal. Confidence: 0.6
- Requires the parallel DB-helper packages (db_helper_sqlite and db_helper_postgres) to not duplicate type definitions: they must share one canonical set of types and emit exactly the same data shape, since they represent the same business entities on different backends — stated directly: "they shouldn't duplicate types. They should emit exactly same type of data. They should share the types." Confidence: 0.9
- When the two DB-helper implementations diverge at the data-model level (sqlite is SKU-based with skuIds; the dormant postgres collapsed the SKU tier into productIds), the live/canonical model wins: the sqlite/SKU model — which packages/shared already standardizes on — is the source of truth and the dormant postgres helper is to be aligned/migrated to it (up to and including schema-level changes so even row types match), not the reverse. Confidence: 0.75
- The dedup directive is general, not limited to DB helpers: for types that are "exactly same but repeated" anywhere in the monorepo, move them into a shared package (@packages/shared), organize them there with proper comments, and export/reuse them from that one source — "Do this for all the types which are same and repeated" (stated when asking to address the duplicate-type spread repo-wide). Confidence: 0.9
- When organizing a shared type package, prefers to keep similar types together — all types that have identical properties should stay grouped/collocated so exact duplicates live side by side (e.g., clustering same-shaped types together in the shared files rather than scattering them). Confidence: 0.75
- When he introduces a processing/batching parameter (e.g., a batch size such as "5 tokens per queue message"), expects it enforced end-to-end on BOTH sides of the pipeline — the write/enqueue producer AND the read/consumer/processing path — and follows up to confirm the read side honors the same limit ("we've also changed the code to read and process 5 tokens at a time right?"); a limit applied on only one side is treated as an incomplete change. Confidence: 0.6
- Scrollable content (e.g., a product grid) must never be buried beneath a persistent floating UI element like the floating cart bar: pages that scroll above such a bar need sufficient bottom padding on the content container so the last row of items can scroll fully clear of the bar ("There should be enough padding in bottom. I see the last row of products buried beneath the floating cart bar. They should be scrollable"). Errs toward GENEROUS bottom spacing and iterates for more if it still feels tight — after the initial fix (matching siblings' mobile `pb-24`) he asked to bump it again ("it's improved but add some more padding"), landing at mobile `pb-32` / desktop `md:pb-16`. Expects the fix to follow the sibling pages' bottom-padding pattern but lean roomier rather than matching the minimum. Confidence: 0.85
- Prefers an incremental "load more"/"Show More" reveal model over rendering the entire product list at once on listing/dashboard pages: show a fixed batch at a time (the user-ui dashboard's All Products list shows 21 items and adds another 21 per click, hiding the button once all are visible) and expects the same model replicated in the sibling web-ui app ("we aren't showing all the items at once. We have a load more model. Implement the same on the @apps/web-ui too"), matching the reference's batch size and button behavior. Confidence: 0.8
- For browser/E2E test automation, prefers Playwright and wants the suite placed in a `tests/` folder at the project ROOT (not inside the app it targets) — "At the project root add a folder tests. I want playwright based tests". Confidence: 0.85
- Wants E2E automation organized around long, multi-step, real user workflows executed in a SINGLE browser instance/session (e.g., add a product → update it → create a slot for it → suspend it), rather than split into isolated per-step tests — "I want to automate long tasks in a single instance". Confidence: 0.85
- For the E2E harness itself: tests should drive whatever UI URL is targeted (no backend management from the harness), pull staff credentials from environment variables (not committed inline), and NOT auto-start the dev server — the app is assumed to be running. Confidence: 0.6
- Wants automation tests (Playwright) to run with a VISIBLE browser, not headless — "I want to see the browser, I don't want it in headless mode" — so local runs should default to headed (CI may stay headless), with a convenience script/flag (e.g. `test:e2e:headed`, `HEADED=0/1`) to switch. Confidence: 0.85
- Prefers names/labels displayed in FULL rather than truncated — e.g., product names in the cart drawer should wrap and show completely (text wrap / `break-words` instead of `truncate`) — "show the name full, if needed do text wrap". Confidence: 0.55
- Wants the floating cart bar HIDDEN on routes where it is redundant — the cart page (including the flash cart alias) and the entire "me" section — and expects the condition to cover ALL child routes via a path-prefix check (`pathname.startsWith('/me')`) rather than matching only the parent route. Confidence: 0.6
- Uses Bun as his package manager / package runner ("I use bun"), so whenever giving or documenting commands he expects `bun run <script>` / `bunx <tool>` rather than `npm run` / `npx` (including inside subfolders, e.g. `bun run --cwd .. <script>`). Confidence: 0.9
- Expects generated test automation to be actually EXECUTED and passing, not just written/typechecking — he asks the agent to run the suite and report how many tests succeed ("try to run the script and see how many tests succeed"), treating a suite that was never run against a live app as unverified. Confidence: 0.65
- Treats reports/audits (e.g., DEAD_CODE_REPORT.md) as claims to be independently verified against actual code usage, not trusted at face value: he asks the agent to read a report and confirm "if the pointed code is actually dead", expecting per-claim caller searches and explicit flags of false positives. Confidence: 0.75
- Before shipping/publishing a branch, wants a pre-flight risk assessment rather than just execution: he names the last published reference branch and asks "Tell me if I'm going to face any problems" — expecting the agent to verify the current branch is a superset of what was published (no feature/migration/commit lost), that removed files and assets have no dangling references or build-config dependencies, and that build/typecheck integrity holds, then present blockers vs. non-blocking caveats (e.g., committed secrets) before he deploys. Confidence: 0.7
- After performing removals himself, expects the agent to re-scan the codebase and independently confirm each item is actually gone (and that the removals didn't break anything) — "I've removed them. Check and tell me if all are gone" — treating his own "done" statement as a claim to verify rather than assuming completion. Confidence: 0.6
- When asking to compare branches/versions (e.g., "compare the current branch's @apps/user-ui with the product_to_sku_c1 branch"), wants the answer framed "functionality wise" — a semantic/behavioral summary of what actually changed for the app and its users (new logic, changed API calls, changed UI, removed screens), explicitly distinguishing real functional changes from no-op churn (pure type imports, dead-code deletions, config tweaks which he should be told are non-functional), rather than a raw file/line diff dump. Confidence: 0.6
- Wants order-item rows to display the pack size / unit notation (e.g., `2 × 500g · ₹250`) alongside quantity and unit price, not quantity alone — composed via the existing shared `composeUnitNotation` helper and surfaced through the shared item type to both user-ui and web-ui. Confidence: 0.6
- Prefers that disruptive user-facing dialogs be reserved for genuinely important events: the OTA update prompt should NOT be shown for every update — only for a few crucial releases ("I don't want to show this for all the updates. Only a few updates are so important"), while routine updates apply silently in the background. Confidence: 0.7
- Uses "quantity" loosely to refer to the pack size / unit notation on order items: he reported "we aren't showing quantity" about a row that already rendered the numeric count, then clarified "yeah, show the pack size" — so when he says a quantity isn't shown, check whether he actually means the unit/pack-size notation is missing. Confidence: 0.55
- For new operational switches/flags, prefers a backend constant in the admin-editable constants store (CONST_KEYS + seed + exposed via essentialConsts, editable in Customize App) over build/publish-time config (e.g. an `expo.extra` flag in app.json) or hardcoding — chosen when offered the trade-off ("let's introduce a new constant"), because it can be changed remotely with no rebuild or republish. Confidence: 0.6
e changes. Confidence: 0.95
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.9
als) rather than introducing new brand palettes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
@ -100,3 +134,11 @@ er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the
y, including details like item counts and container styling. Confidence: 0.9
tes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.9
olor or theme changes. Confidence: 0.95
roducing new brand palettes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.9
tes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.9
y, including details like item counts and container styling. Confidence: 0.9
tes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.9

1
.gitignore vendored
View file

@ -132,3 +132,4 @@ dist
.pnp.*
type-clusters/*

View file

@ -48,3 +48,6 @@ react-native. They are available in the common-ui as MyText, MyTextInput, MyTouc
## Important Notes
- Don't do anything with git. Don't do git add or git commit. That will be managed entirely by the user
## Change Log
- there should be a change log file. unless explicitly specified, assume change-log.txt as the name of the log file. Ensure the file exists. If it doesn't exist create it. Before changing any file which is not a md file or txt file write the change to that file exactly. Write the diff to it and then start making changes. for every change add the current time stamp.

313
ANY_USAGE_REPORT.md Normal file
View file

@ -0,0 +1,313 @@
# `any` Usage Audit — what each one should be
**Branch:** `DEAD_CODE_CLEAN`
**Scope:** all `any` keyword usages in `apps/*` and `packages/*` source (`.ts`/`.tsx`), excluding `node_modules`, `dist`, `.wrangler`, `.output`, `.expo`, `.turbo`, `.tanstack`, dumps, `*.sql`, and generated route trees (`routeTree.gen.ts`).
**Nature:** documentation only — no code changed.
## Totals (type-level `any`, excluding prose/comments/generated)
| Area | Count | Dominant cause |
|---|---|---|
| `apps/backend` | ~85 | `as any` casts on JWT/Drizzle/error shapes; `: any` where a shared type exists |
| `packages/db_helper_sqlite` + `_postgres` | ~105 | `(x: any)` on already-typed Drizzle results; `Promise<any>` helpers |
| `apps/admin-ui` | ~161 | `onError/catch (e: any)`; `router.push(... as any)`; entity `useState<any>` |
| `apps/user-ui` + `apps/fallback-ui` | ~137 | `catch/onError`; Expo/TanStack `as any`; catalog/cart `item: any` |
| `apps/web-ui` | ~138 | `Record<number, any>`/`(p: any)` product+slot callbacks; `onError` |
| `apps/admin-web` | ~128 | `onError/catch` (36); domain rows; `navigate({ to: ... as any })` |
| `packages/shared` + `web-components` + `ui` + `migrator` | ~28 | untyped component props; RN style/asset props; migrator rows |
| **Total** | **~780** | |
Regex-based raw totals are a bit higher because the word "any" also appears in comments/prose and `tsconfig` files; those are excluded here.
## The replacement playbook ("what it should have been")
1. **`catch (e: any)` / `onError: (e: any)``unknown`** (and narrow with `e instanceof Error ? e.message : '…'`). This is the single largest mechanical category (~100 sites across the repo).
2. **`onError: (error: any)` on tRPC hooks → delete the annotation.** tRPC v11 already infers `TRPCClientError<AppRouter>`; `.message` keeps working.
3. **Callback params on typed Drizzle/array results → delete `: any`.** Because `db_index.ts` passes the full `schema` to `drizzle(...)`, `db.query.*` and `.select()` results are fully inferred; `(x: any) => …` is pure noise.
4. **`(item: any)` / `(product: any)` in catalog/cart lists → the real entity type:** `MergedProduct` (catalog), `CartItem` (carts), `AdminSku`, `AdminSlotWithProducts`, `AdminOrderDetails`, `UserOrderSummary`, etc.
5. **`router.push(… as any)` → typed routes** (`Href` from `expo-router`; `FileRouteTypes['to']`/generated union for TanStack Router). Static routes can simply drop the cast.
6. **`Record<string, any>``Record<string, unknown>`** for heterogeneous bags.
7. **`z.any()``z.unknown()`** in Zod schemas (or a precise union).
8. **RN/Expo props → their real types:** `StyleProp<ViewStyle>` / `StyleProp<ImageStyle>`, `NativeSyntheticEvent<NativeScrollEvent>`, `TextStyle['fontWeight']`, `ImagePicker.ImagePickerAsset`.
9. **Icon `name={x as any}` → `React.ComponentProps<typeof MaterialIcons>['name']`** (the glyph-key union).
10. **`Promise<any>` db-helper returns → `InferSelectModel<typeof table>` / explicit `Pick<Row, …>`.**
## Highest-leverage shared fixes (fix once, removes many)
These untyped *shared* declarations force `any` at dozens of call sites — tighten them first:
| Fix | File | Impact |
|---|---|---|
| `value: any` → a `ConstValueType \| ConstValueType[]` union (union already defined at line 18) | `packages/shared/types/const.types.ts:8` | Removes `value.map((id: any) => …)` in admin-ui **and** admin-web reorder screens |
| `ListItemProps.item: any`, `CompactProductCardProps.item: any`, `OffersSectionProps.products: any[]`, `AddedToCartProduct.product: any` → hoist a shared structural product type (or make the props generic) | `packages/shared/types/app-common.types.ts:60,78,87,96` | Caps how far web-ui/user-ui flash/offers/complaints props can be tightened |
| `queryParams?: Record<string, any>``Record<string, unknown>` | `packages/shared/types/app-common.types.ts:20` | Consumer only does `String(value)` |
| `setFile: (file: any) => void``ImagePickerAsset \| ImagePickerAsset[] \| null` | `packages/ui/src/components/use-pick-image.tsx:13` | Type flows into admin-ui `setFile: (assets: any)` (3 sites) and `profile-image`/`ImageUploaderNeo` |
| Export app-local entity types: `MergedProduct`, `CartItem`, `SlotInfo`/`ProductSlotMap` (web-ui/user-ui); `ProductFormData`, `Variant`, `Attribute`, `CouponFormValues` (admin-ui/admin-web) | respective hook/component files | Removes dozens of `values: any` / `item: any` in mutations and list callbacks |
| Backend procedures returning `any[]` (coupon getAll/getReservedCoupons, admin `getUserIncidents`, store) → concrete shared types | `apps/backend/.../admin-apis/apis/{coupon,user,store}.ts` | Removes the downstream `Coupon`/`UserIncident` `any`s in both admin frontends |
---
## apps/backend
### Summary
- **85** `any` keywords across **83 lines / 31 files**.
- **Top files:** `coupon.ts` & `const-store.ts` (7 each), `admin/user.ts`, `post-order-handler.ts`, `app.ts` (6 each), `admin/product.ts` (5), `auth.ts`, `cache-creator.ts` (4).
- **Dominant patterns:** (1) `x as any` around JWT payloads, Drizzle `json` columns, and untyped db-helper returns; (2) `: any` params/locals where a concrete shared type exists (`OrderWithFullData`, `AdminVendorSnippet`, `TagBasicData`, `StaffRole`); (3) generic defaults `<T = any>` and catch clauses.
- **10 of the 85 are inside commented-out "Old implementation" blocks** → delete, don't retype.
| Location | Current code | Should be | Why |
|---|---|---|---|
| `app.ts:57`, `middleware/auth.middleware.ts:19` | `const decoded = payload as any` | `AppJwtPayload` = `{ userId?: number; staffId?: number; name?: string }` | jose `JWTPayload`; tokens are signed as `SignJWT({ userId })` / `SignJWT({ staffId, name })` |
| `app.ts:131,132` | `(err as any).statusCode` | `err instanceof ApiError ? err.statusCode` | `ApiError` is the only error with `statusCode` |
| `app.ts:134,135` | `(err as any).status` | `err instanceof HTTPException ? err.status` | Hono `HTTPException` |
| `app.ts:141` | `c.json({ message }, status as any)` | `status as StatusCode` | Hono status union |
| `types/hono.d.ts:5`, `trpc/trpc-index.ts:7` | `user?: any` | `AppJwtPayload` | consumed as `ctx.user?.userId` |
| `trpc/trpc-index.ts:37` | `const err = error as any` | `unknown` + narrow | catch var; reads name/message/code/stack/meta/sql |
| `jobs/cache-creator.ts:10,13` | `state: any` | `DurableObjectState` | uses storage/alarm |
| `jobs/cache-creator.ts:11,13` | `env: any` | `WorkerEnv` (needs domain input) | passed to `ensureWorkerInit` |
| `jobs/payment-status-checker.ts:3,4,5` | `payment:any; order:any; slot:any` | `typeof payments.$inferSelect` / `{id;userId}` / row types | stub interface; only `order.id/.userId` read |
| `lib/queue-consumer.ts:4,27,40` | `batch: any` | `MessageBatch<NotifQueueMessage>` / `<{orderIds:number[]}>` / `<CancellationMessage>` | pushed message shapes are known |
| `lib/const-store.ts:34,58` | `<T = any>` | `<T = unknown>` | generic default |
| `lib/const-store.ts:90,102,107` | `Record<…, any>` | `Record<…, unknown>` | opaque KV values |
| `lib/api-error.ts:3,5` | `details?: any` | `unknown` | never consumed typed |
| `lib/notif-job.ts:11,22` | `notificationQueue:any`, `notificationWorker:any` | bullmq `Queue`/`Worker` or `Record<string, never>` | stubbed (`{}`) |
| `lib/notif-job.ts:91` | `scheduleNotification(userId, payload: any, …)` | `{ title; body; type; orderId? }` | callers pass exactly this |
| `lib/worker-init.ts:3,4` | `env: any`, `(globalThis as any).ENV` | `WorkerEnv` / typed global | matches existing typed-global pattern |
| `lib/post-order-handler.ts:44,52,95,167,168` | `ordersData: any[]`, `(item: any)`, `createdOrders: any[]`, `Map<…, any[]>` | `OrderWithFullData[]`, `OrderWithFullData['orderItems'][number]`, `PlacedOrder[]` | db-helper return types |
| `lib/post-order-handler.ts:86` | `orderData: any` | `OrderWithCancellationData & { refundStatus: string }` | known shape |
| `lib/flash-delivery-cron.ts:12` | `env: any` | `WorkerEnv` | |
| `lib/env-exporter.ts:62` | `(globalThis as any).ENV \|\| (globalThis as any).process?.env` | typed structural cast | matches rest of codebase |
| `lib/notif-job.ts:91`, `trpc/.../staff-user.ts:64,112` | mixture | `AdminVendorSnippet`, `StaffRole` | concrete shared types exist |
| `admin-apis/apis/store.ts:17,37` | `stores: any[]`, `store: any` | `Store & { owner: User }` | query includes `owner` |
| `admin-apis/apis/banner.ts:59`, `user-apis/apis/auth.ts:274` | `catch (e: any)` | `unknown` + narrow | |
| `admin-apis/apis/const.ts:38` | `z.any()` | `z.unknown()` | |
| `admin-apis/apis/slots.ts:74` | `deliverySequence: z.any()` | `Record<string, number[]>` union | shared `AdminDeliverySequence` |
| `user-apis/apis/payments.ts:129` | `payload as any` | `Record<string, unknown>` | schema is `jsonText<unknown>` |
| `admin-apis/apis/product.ts:265,357,754` | `as any` casts / `product: any` | `CreateProductInput` / `AdminProductTagWithProducts` | helpers/return types exist |
| `user-apis/apis/user.ts:61`, `auth.ts:110,374` | `new Date(userDetail.dateOfBirth as any)` | drop cast | field is `Date \| null` |
| `user-apis/apis/order.ts:293` | `slotId: null as any` | `null` | param is `number \| null` |
| `admin-apis/apis/coupon.ts:129,141,153,154,351,366,440` | `coupons: any[]`, `Promise<any>`, `(au:any)`, `(ap:any)`, `coupon: any` | `Coupon & { applicableUsers; applicableProducts }` / `ReservedCoupon` (needs domain input) | relations not modelled in shared |
| `common-apis/common.ts:79,84,85` | `(id:any)`, `(tag:any)`, `orderedTags:any[]` | `number`, `TagBasicData`, `TagBasicData[]` | `getConstant<T>` default; `getAllTagsForCache()` |
| `admin-apis/apis/user.ts:48,60,98,117,170,258` | `(u:any)` etc | `Pick<User,…>`, `Pick<PlacedOrder,…>`, `UserIncident` (needs domain input) | select shapes |
| Dead-code `any`s (delete) | `product.ts:565,700`, `banner.ts:194`, `slots.ts:176,347`, `address.ts:192`, `admin/order.ts:384`, `vendor-snippets.ts:260`, `const-store.ts:91,97` | — | commented-out old impls |
**Easy wins:** drop redundant `as any` (`product.ts:265/357`, `order.ts:293`, `user.ts:61`, `auth.ts:110/374`, `payments.ts:129`); `: any``unknown` (`api-error`, `const-store` generics, `payment-status-checker`, `payments-utils`); `catch``unknown` (`banner.ts:59`, `auth.ts:274`); `z.any()``z.unknown()`; delete dead `any`s; define `AppJwtPayload` once.
---
## packages/db_helper_sqlite + db_helper_postgres
**Summary:** **105** tokens across **98 lines / 17 files**. Top: `sqlite/admin-apis/product.ts` (27), `sqlite/admin-apis/order.ts` (19), `run-batched.ts` (7), `admin-apis/user.ts` (7 each pkg), `admin-apis/coupon.ts` (6 each). Dominant: `(x: any)` on already-typed Drizzle results (~60) — just delete. `Row<T>` = `InferSelectModel<typeof T>`, `Insert<T>` = `InferInsertModel<typeof T>`.
| Location | Current code | Should be | Why |
|---|---|---|---|
| `sqlite/admin-apis/product.ts:182,235,385,483,530` | `(ci\|f: any)` | `CreateComboItemInput` / `SkuFeatureLike` | element types of typed relations |
| `product.ts:185,238` | `(f: any)` | `Row<skuFeatures>` | |
| `product.ts:596` | `(tag: any)` | inferred (cast at 594) | |
| `product.ts:598,669,709` | `(assignment: any)` | `Row<productTags> & { sku: … }` | |
| `product.ts:756` | `(review: any)` | inferred select shape | |
| `product.ts:817,822` | `(group:any)`, `(membership:any)` | inferred | |
| `product.ts:367` | `updateProduct(id, input: any)` | `UpdateProductInput` (domain) | |
| `product.ts:39,41,114,340,498,545` | `any[]`/`(ci:any)` | `CreateComboItemInput` / `AdminProductComboItem[]` | |
| `product.ts:384,398,401` | `(sku: any)` | `CreateSkuInput & { id?: number }` | |
| `product.ts:956` | `const updateData: any` | `Partial<Insert<productMarketStats>>` | |
| `sqlite/admin-apis/order.ts:174,239,250,364,369,378,388,412,523,528,538,549,554,577,615,616,622,643` | `(u\|item\|f\|order\|acc: any)` | delete → inferred | typed query results |
| `sqlite/admin-apis/slots.ts:122,125,133,199` | `(item\|s\|sku: any)` | delete → inferred | |
| `slots.ts:194` | `(item: any)` + reads `item.isDeleted` | inferred; **add `isDeleted: true` to `columns` at :191** | hidden bug surfaced by typing |
| `slots.ts:56` | `fetchExistingSkuIds(tx: any, …)` | Drizzle tx type (`Parameters<Parameters<typeof db.transaction>[0]>[0]`) | |
| `stores/store-helpers.ts:312,315,326` | `(item\|s\|sku: any)` | delete → inferred | |
| `admin-apis/store.ts:7,17` (both pkgs) | `Promise<any[]>` / `Promise<any\|null>` | `Array<Row<storeInfo> & { owner: Row<staffUsers>\|null }>` | |
| `admin-apis/staff-user.ts:22,78` (both) | `Promise<any[]>` | inferred relation / `StaffRole[]` | |
| `admin-apis/user.ts:5,28,95,122,184,224,245` (both) | `Promise<any…>` | `Row<users>`, `Pick<Row<users>,…>`, `Row<userIncidents>`, etc. | select shapes |
| `admin-apis/coupon.ts:10,53,175,211,213,300` (both) | `any[]`/`Promise<any…>`/`input: any` | `Row<coupons> & {…}`, `Row<reservedCoupons>`, `ReservedCouponInput` | InferInsertModel |
| `user-apis/auth.ts:102,119` (both) | `const userUpdate: any` | `Partial<Insert<users>>` / `Partial<Insert<userDetails>>` | |
| `user-apis/auth.ts:184` (both) | `catch (error: any)` | `unknown` + narrow | `23505` is pg-only |
| `lib/run-batched.ts:3,19` | `(cb: (tx: infer Tx) => any, ...args: any[]) => any` | `unknown` | unused callback return/args |
| `lib/automated-jobs.ts:25` (both) | `value: any` | `unknown` | |
**Easy wins:** delete `: any` from ~60 map/filter callbacks; `run-batched` `any``unknown`; `automated-jobs` `value: unknown`; object literals → `Partial<Insert<…>>`; `catch``unknown`; **free bug fix** at `slots.ts:191/194`; ~35 `Promise<any>` return annotations via `Row`/`Insert`.
---
## apps/admin-ui
**Summary:** **161** code occurrences / 49 files. Top: `delivery-sequences/index.tsx` (10), `prices-overview` (11), `products/add+edit` (14), `_layout.tsx` (13). Dominant: `catch/onError (error: any)` (35) and `router.* as any` (36) — both mechanical.
| Location | Current code | Should be | Why |
|---|---|---|---|
| `_layout.tsx:53…137`, `dashboard/index.tsx:32,99,242`, banners/products/slots/stores/prices/manage-orders (≈18 files) | `router.push("…" as any)` | `as Href` (`expo-router`); drop cast for static routes | typed routes |
| `dashboard/index.tsx:39,250,275`, `manage-orders/index.tsx:77` | `name={item.icon as any}` | `keyof typeof MaterialIcons.glyphMap` | glyph union |
| `TagMenu.tsx:62`, `stores/edit:28`, `send-notifications:48`, `coupons/*`, `prices:286`, `SlotForm:92,107`, `delivery-sequences`, `order-details`, `orders` (≈24) | `onError: (error: any)` | delete annotation | tRPC infers |
| VendorSnippet/ProductGroupForm/products/coupons/stores/vendor-snippets/product-tags/send-notifications (≈11) | `catch (error: any)` | `unknown` + narrow | |
| `coupons/index.tsx:11,111` | `item: any` | `Coupon & {relations}` / reserved (domain) | backend returns `any[]` |
| `stores/index.tsx:15` | `item: any` | `Store` | |
| `products/index.tsx:148` | `{ item: any }` | `Omit<AdminProductWithRelations,'skus'> & { skus: SerializedAdminSku[] }` | |
| `slots/index.tsx:11`, `rebalance-orders:10` | `item: any` | `AdminSlotWithProducts` | |
| `slots/slot-details.tsx:24`, `product-groupings:130`, `products/detail:146` | `useState<any…>` | `AdminSlotProductSummary[]`, `AdminSku[]`, `AdminProductReviewWithSignedUrls` | |
| `slots/index:238`, `rebalance:123` | `useState<any[]>` | `string[]` | product names |
| `products/add:15,64,29,36,80`, `edit` (same) | `values: any`, `(variant:any)`, `(a:any)`, `(ci:any)` | `ProductFormData`, `Variant`, `Attribute`, `CreateComboItemInput` (export from ProductForm) | |
| `BannerForm:66`, `StoreForm:72`, `products/detail:32` | `setFile: async (assets: any)` | `ImagePicker.ImagePickerAsset \| ImagePickerAsset[]` | |
| `TagForm.tsx:40` | `forwardRef<any,…>` | `unknown` | no imperative handle |
| `TagForm.tsx:57` | `(sku: any)` | `SkuSummary` | |
| `toaster.tsx:39` | `data: any` | `{ rideId?; carId? } \| null` (domain) | |
| `CouponForm.tsx:60,66,156,191,192,205,237,238` | `function(value:any)`, `(values as any)`, `(errors.couponCodes as any)` | `CouponFormValues`, narrow error shapes | |
| `ProductGroupForm:13`, `SnippetOrdersView:21`, `SlotForm:37,48,191` | `products: any[]`, `sequence: any[]`, `(snippet/p: any)` | `AdminSku[]`, `number[]`, `AdminVendorSnippetWithAccess`, `AdminSlotProductSummary` | |
| `UserIncidentsView:141` | `(incident: any)` | `UserIncident` (domain) | |
| `customize-app/index:9,58,59,60` | `Record<string,any>`, `value:any`, `setFieldValue(any)`, `router:any` | `Record<string,unknown>`, `unknown`, `Router` | |
| `popular-items:139`, `all-items-order:111`, `product-tags/order:102` | `value.map((id: any) => parseInt(id))` | `number` (root: `Constant.value`) | |
| `prices-overview:25,28,29,30,46,153,204,224,270` | `sku:any`, `Record<string,any>`, `const update:any` | local `PriceSku`, existing `PendingChange`, mutation input | |
| `delivery-sequences:53,354,373,389,438,588,622` | `orders:any[]`, `(deliverySequence as any)` | `AdminSlotOrder[]`, drop cast (`AdminDeliverySequence`) | |
| `order-details:148` | `const mutationData:any` | `initiateRefund` input | |
| `coupons/create:27` | `values: any` | `CouponFormValues` | |
**Easy wins:** drop `: any` on 24 `onError`; `catch``unknown` (11); `as any``as Href` (36); icon glyph types (4); drop `deliverySequence as any` (6); remove `products/edit:180 as any`; `triggerStyle``StyleProp<ViewStyle>`; export `ProductFormData/Variant/Attribute`.
---
## apps/user-ui + apps/fallback-ui
**Summary:** **137** real type-level / 46 files (4 non-type "any" words ignored). Top: `home/index.tsx` (20), `cart-page` (11), `ProductDetail` (11). Dominant: `catch/onError` (29); router casts (23); catalog/cart `item: any` (30+).
| Location | Current code | Should be | Why |
|---|---|---|---|
| login/register/edit-profile/ComplaintForm/OrderMenu/ProductDetail/registration-form + fallback create-coupon/demo | `catch (error: any)` | `unknown` + narrow | |
| many `onError: (error: any)` (14+) | `onError: (error: any)` | `unknown` / delete (tRPC infers) | |
| `me/index`, `SlotSpecificView`, `cart-page` (≈12) | `router.replace/push(target as any)` | `Href` | |
| fallback AuthWrapper/home/login/super-admin/user-home (≈13) | `navigate({ to: '…' as any })` | drop cast | routes registered |
| `me/index:137`, `my-orders:111,278` | icon `as any` | `ComponentProps<typeof Ionicons/MaterialIcons>['name']` | |
| `my-orders:68,69` | `getStatusColor(): any` | typed `StatusColor` | |
| `home/index.tsx` (many) | `slot: any`, `(p: any)`, `dashboardTags:any[]`, `Record<number,any[]>`, `useRef<any>`, `onLayout(e:any)`, `useState<any[]>` | `UserSlotWithProducts`, `UserSlotProduct`, `AllProductsApiType['tags']`, `MergedProduct[]`, `ComponentRef<typeof ScrollView>`, `LayoutChangeEvent` | catalog types exist |
| `ProductDetail:48,75,76,79,87,147,710` | `useState<any[]>`, `(slot/p/a/b/item:any)`, `setFile(assets:any)` | review/cart/slot types + `ImagePickerAsset` | shared `UserProductReviewWithSignedUrls` missing `adminResponse`/`signedAdminImageUrls` (domain) |
| `cart-page:60` | `coupon: any` | `UserCouponWithRelations` | |
| `registration-form:29` | `useState<any>` | `ImagePicker.ImagePickerAsset` | |
| `useUploadToObjectStore:26` | `contextString as any` | drop cast | shared `ContextString` assignable |
| `notif-context:45,46` | `useRef<any>` | listener return types | |
| `toaster:5` | `data: any` | `Record<string, unknown>` | |
| `AddToCartDialog` (6) | `(item/slot/p: any)`, `Record<number,any>` | `CartItem`, `UserSlotWithProducts`, `UserSlotProduct` | |
| `AuthContext:14` | `Record<string,any>` | tighten shared `AuthRedirectOptions.queryParams` | |
| `getCurrentUserId:8` | `jwtDecode(token)` any | `jwtDecode<{id?;userId?}>` | |
| fallback `order-details:33,51,223`, `user-details:44,45,141`, `login:23`, `demo:11,76`, `inauguration:74` | `as any` / `any[]` / `any` | `AdminOrderDetails`, inferred, `ReturnType<typeof setInterval>`, local `DemoResponse` | |
| `ProductCard:22,29,84,93,94` | `item:any`, `ComponentType<any>`, `(cartItem:any)`, `Record<number,any>`, `(slot:any)` | `MergedProduct`, `ComponentType<PropsWithChildren>`, `CartItem`, `UserSlotWithProducts` | |
| `SlotSpecificView:232,252,457` | `item:any`, `(cartItem:any)`, `(p:any)` | `MergedProduct`, `CartItem` | |
| `PaymentAndOrderComponent:17,24,25` | `cartItems:any[]`, `constsData:any`, `selectedCoupons:any[]` | `CartItem[]`, `EssentialConstsApiType`, `EligibleCoupon[]` | |
| `hooks/useProductSlotIdentifier:18,25`, `prominent-api-hooks:127` | `(slot/a/b/p: any)` | drop → inferred | |
**Easy wins:** bulk `unknown` (29); delete fallback `as any` casts (≈18 incl. `contextString`); catalog/cart annotations (≈30) → `MergedProduct`/`CartItem`; icon types (4); trivial typed `any`s (jwt, setInterval, notif refs, toaster, styles).
---
## apps/web-ui
**Summary:** **138** annotations / 35 files (2 prose excluded). Top: `slot-view` (20), `home.index` (18), `flash` (10), `home.product.$id` (9). Dominant: untyped list callbacks over cached API data (`MergedProduct`, `UserSlotWithProducts`), then `(error: any)`.
| Location | Current code | Should be | Why |
|---|---|---|---|
| `FloatingCartBar:43,44`, `PaymentAndOrderComponent:34,35`, `cart:18,19`, `checkout:38,39`, `home.cart:18,19` | `Record<number,any>` / `(p:any)` | `Record<number, MergedProduct>` / `(p: MergedProduct)` | from `useAllProducts()` |
| `AddToCartDialog:45,78`, `ProductCard:38`, `home.product.$id:63`, `flash:205` | `(item:any)` | `CartItem` | `useGetCart()` |
| `slot-view:399` | `(cartItem:any).productId` | `(cartItem: CartItem).skuId` | **latent bug** |
| `AddToCartDialog:53,57,59,143`, `usePopulateCentralStores:15,30,31,43`, `home.product.$id:51,52,55,94,316,397`, `slot-view:58…153`, `home.index:623` | `(slot:any)`, `slot.products.forEach((p:any)`, slot maps, `(a/b:any)` | `UserSlotWithProducts`, `UserSlotProduct`, `Record<number, UserSlotWithProducts>` | `useSlots()` |
| `usePopulateCentralStores:15` (+4348) | extra fields `deliveryDate/displayDate/displayTime` | **domain**: not on `UserSlotWithProducts` but required by `SlotInfo` | stale/dead-field mismatch |
| `usePopulateCentralStores:25` | `Record<number,any>` | `ProductSlotMap[number]` (export) | |
| `flash:38,67,77,83,166`, `home.index:154,155,225,326`, `home.search:87,166`, `offers:32,67,68`, `slot-view:139…238`, `stores.$storeId:44,125,163`, `usePopulateCentralStores:28`, `prominent-api-hooks:167` | `(product/p/a/b:any)` | `MergedProduct` | |
| `stores.$storeId:28,38` | `new Map<number,any>`, `(product:any)` | `Map<number, MergedProduct>`, `(product: UserStoreProduct)` | |
| `home.index:133,135,142,143` | `Record<number,any[]>`, `Map<number,any>`, `const ordered/rest: any[]` | `MergedProduct[]` etc | |
| `ProductCard:11` | `item:any` | `MergedProduct` | |
| `flash:36,54,124,156`, `home.index:378,530`, `slot-view:228,294,339`, `stores:78,104`, `stores:161` | `(store:any)`, `stores:any[]`, `(product:any)` | `UserStoreSummary`, `UserStoreSampleProduct` | |
| `checkout-hooks:16,44,67`, `CheckoutAddressSelector:11,72`, `checkout:31`, `flash.checkout:74`, `home.checkout:75` | `useState<any>`, `(addr/address:any)` | `UserAddress` | |
| `AddressForm:48,59,90,92`, `MeOrderMenu:68,95`, `PaymentAndOrderComponent:67`, `me.addresses:121,149`, `me.edit-profile:46,56,103`, `me.orders.$id:42,54` | `onError/catch (e:any)`, `(error:any)` | delete / `unknown`; `AddressForm` narrow `Yup.ValidationError` | |
| `me.orders.$id:99` | `order as any` | drop (`UserOrderDetail`) | |
| `me.orders.$id:117,301`, `me.orders:189,75,325` | `(item/product:any)`, `useState<any[]>` | `UserOrderItemSummary`, `UserOrderSummary` | |
| `BottomNavigation:72`, `Sidebar:81`, `me:167` | `to: … as any` | generated route union | |
| `Topbar:27` | `'/home/search' as any` | drop cast | |
| `home.index:122,291,472,474,513` | `(t/tag/b/_:any)`, `banners:any[]` | tag type, `UserBanner`, `string` | |
| `home.product.$id:379` | `(deal:any)` | `SpecialDealCore` | |
| `lib/auth-context:15,24,110` | `userDetails:any`, `(details:any)` | local `User` | |
| `flash-cart-store:4,5` | `any \| null` | `MergedProduct \| null` (domain) | |
| `useUploadToObjectStorage:26` | `contextString as any` | drop | |
| `ProductCard:47,48`, `PaymentAndOrderComponent:13`, `slot-view:101,104`, `me.complaints:168` | `Record<number,any>`, `(slot:any)`, `cartItems:any[]`, `as any` time, `(complaint:any)` | `SlotInfo`, `CartItem[]`, widen helper, `UserComplaint` | |
**Easy wins:** drop redundant annotations on tRPC `onError` (10) and `cartData.items.find` (5); replace 5-file `productsById` boilerplate (10); `catch``unknown` (4); export `CartItem` + `SlotInfo`/`ProductSlotMap`; drop `navigate` casts; **fix `slot-view:399` bug**.
---
## apps/admin-web
**Summary:** **128** annotations (3 prose excluded) / 50 files. Top: `orders.sequence` (11), `prices` (10), `products.edit` (9). Dominant: `onError/catch` (36 = 28%), untyped rows, route casts.
| Location | Current code | Should be | Why |
|---|---|---|---|
| 24 sites (`stores.edit:35`, `orders.sequence:471,623,665`, `orders.list:71,171`, `coupons.*`, `slots:51`, `complaints:81`, `SlotForm:91,106`, `notifications:35`, `TagMenu:56`, `UserIncidentsView:31`, `staff-auth:62`, `orders.$id:68,84,95,105`, `CancelOrderDialog:34`, `prices:281`, `stores.new:27`) | `onError: (error:any)` | delete | tRPC infers |
| 13 sites (`product-tags.new:50`, `product-tags.edit:65`, `coupons.new:57`, `products.$id:81`, `notifications:72`, `vendor-snippets:54,296`, `OrderOptionsMenu:69`, `products.new:106`, ProductGroupForm:68, VendorSnippetForm:87, products.edit:189, stores:114) | `catch (error:any)` | `unknown` + narrow | |
| `orders.sequence:363,382,398,456,603,635` | `(deliverySequence as any)` | drop → `AdminDeliverySequence` | |
| `slots.$id.edit:48`, `slots:28`, `rebalance:28`, `slots.new:43`, `SlotForm:47` | `(p:any)` | `AdminSlotProductSummary` | |
| `SlotForm:36` | `(snippet:any)` | `AdminVendorSnippetWithAccess` (shape mismatch, domain) | |
| `SlotForm:189`, `TagForm:114`, `prices:41` | `(prod/sku/f:any)` | `AdminSku`, `SkuSummary`, `AdminSkuFeature` | |
| `product-tags.edit:92` | `(p:any)` | `AdminProductTagAssignment` | |
| `ProductGroupForm:12`, `product-groupings:25,108` | `products:any[]`, `useState<any[]>` | `AdminSku[]` | |
| `UserIncidentsView:142` | `(incident:any)` | `UserIncident` (domain) | |
| `products:149` | `(product:any)` | `AdminProductWithRelations` | |
| `rebalance:13`, `slots:14` | `item:any` | `AdminSlotWithProducts` | |
| `rebalance:16,128`, `slots:15,242` | `Dispatch<…any[]>`, `useState<any[]>` | `string[]` | |
| `slots.detail:31` | `useState<any[]>` | domain (`AdminSlotProductSummary` lacks `shortDescription`) | |
| `orders.sequence:69` | `orders:any[]` | `AdminSlotOrder[]` | |
| `prices:20,23,24,25,148,199,219,265` | `sku:any`, `Record<string,any>`, `const update:any` | `SkuRow`, existing `PendingChange`, mutation input | |
| `vendor-snippets:233` | `useState<any>` | `{ orders; snippetCode }` (align `totalAmount` number/string) | |
| `SnippetOrdersView:20` | `sequence:any[]` | `unknown`/`number[]` | shared is `unknown` |
| `coupons.new:30`, `products.edit:87`, `products.new:19` | `values: any` | export `CouponFormValues` / `ProductFormData` | |
| `CouponForm:78,84,171,206,207,220,252,253` | `function(value:any)`, `(values as any)`, `(errors as any)` | `CouponFormValues`, narrow | |
| `coupons.tsx:14,17,114,220,363` | `item/coupon:any` | `Coupon & {relations}` / reserved (domain) | |
| `MultiSelect:25`, `SearchableSelect:23`, `coupons:302,430` | `triggerComponent?: any` | `(props:{onClick:()=>void}) => ReactNode` | |
| `SnippetMenu:20`, `TagMenu:11` | `triggerStyle?: any` | `string \| CSSProperties` | |
| `TagForm:96` | `forwardRef<any,…>` | `unknown` (ref unused) | |
| `customize-app:12,61,62,63` | `Record<string,any>`, `value:any`, `setFieldValue(any)`, `router:any` | `Record<string,unknown>`, `unknown`, local router shape | |
| `customize-app.ordering:113`, `popular:126`, `product-tags.order:106` | `value.map((id:any)=>parseInt(id))` | root: `Constant.value` | |
| `stores.edit:33`, `dashboard.index:82`, `customize-app:173`, `orders.tsx:24`, `dashboard.tsx:108` | `navigate({to: … as any})` | generated route union / drop | |
| `products.edit:66,101,108,139,162,166,183`, `products.new:33,40,68,84,88` | `(variant/ci/a/attr:any)`, `as any` | `Variant`, `AdminProductComboItem`, `SkuFeatureLike`, `CreateComboItemInput` | |
| `orders.$id:159` | `const mutationData:any` | `initiateRefund` input | |
| `toaster:16`, `event-bus:6,11` | `_data:any`, `...args: any[]` | `unknown` | |
**Easy wins:** drop 23 `onError`; 13 `catch``unknown`; type slot rows (9); `prices` `SkuRow`+`PendingChange` (10); export `ProductFormData/Variant/Attribute/CouponFormValues`; fix shared `Constant.value`; typed routes (5).
---
## packages/shared + web-components + ui + migrator
**Summary:** **28** tokens / 17 files. Top: `app-common.types.ts` (5), `migrator/sqliteToPostgres` (4), `web-components/data-table.tsx` (4). Dominant: untyped component props + RN style/asset props. (No `catch (e: any)` in scope.)
| Location | Current code | Should be | Why |
|---|---|---|---|
| `packages/shared/types/app-common.types.ts:20` | `queryParams?: Record<string, any>` | `Record<string, unknown>` | only `String(value)` |
| `app-common.types.ts:60` | `item: any` | generic `ListItemProps<T>` (domain) | shared list renderer |
| `app-common.types.ts:78` | `item: any` | hoist shared product type (domain) | consumers use id/images/name/flashPrice/incrementStep |
| `app-common.types.ts:87` | `products: any[]` | shared product type (domain) | offers section |
| `app-common.types.ts:96` | `product: any` | shared product type (domain) | AddToCartDialog |
| `packages/shared/types/const.types.ts:8` | `value: any` | `ConstValueType \| ConstValueType[]` (defined line 18) | consumers branch/narrow |
| `migrator/sqliteToPostgres/index.ts:47` | `.all() as any[]` | named `SqliteColumnRow` | PRAGMA shape fixed |
| `migrator/.../index.ts:113` | `parseValue(value:any): any` | `unknown` | returns JSON.parse/raw |
| `migrator/.../index.ts:181` | `.all() as any[]` | `Record<string, unknown>[]` | dynamic `SELECT *` |
| `packages/ui/src/services/axios.ts:56` | `(err as any).original = error` | `Object.assign` / typed `Error & { original: unknown }` | |
| `packages/ui/src/lib/refresh-context.tsx:7` | `queryClient: any` | `QueryClient` | dep exists |
| `packages/ui/src/lib/tailwind.ts:6` | `create(tailwindConfig as any)` | `as TwConfig` (`twrnc`) | |
| `packages/ui/src/components/image-viewer.tsx:16` | `style?: any` | `StyleProp<ImageStyle>` | |
| `packages/ui/src/components/info-dialog.tsx:15`, `dropdown.tsx:17` | `style?: any` | `StyleProp<ViewStyle>` | |
| `packages/ui/src/components/search-bar.tsx:21` | `forwardRef<any,…>` | `React.ElementRef<typeof PaperTextInput>` | |
| `packages/ui/src/components/text.tsx:25` | `let fontWeight: any` | `TextStyle['fontWeight']` | |
| `packages/ui/src/components/profile-image.tsx:11,25` | `file?: any`, `setFile:(file:any)` | `ImagePickerAsset & { name }` | |
| `packages/ui/src/components/use-pick-image.tsx:13` | `setFile: (file: any) => void` | `ImagePickerAsset \| ImagePickerAsset[] \| null` | root of many `assets: any` |
| `packages/ui/src/components/ImageUploaderNeo.tsx:22` | `(files: any)` | `ImagePickerAsset \| ImagePickerAsset[] \| null` | |
| `packages/ui/src/components/ImageCarousel.tsx:27` | `(event: any)` | `NativeSyntheticEvent<NativeScrollEvent>` | |
| `packages/web-components/.../my-text-input.tsx:45` | `{...(props as any)}` | `TextareaHTMLAttributes` union | spread onto `<textarea>` |
| `packages/web-components/.../data-table.tsx:7,12,13` | `(value:any,row:any)`, `data:any[]`, `(row:any,index)` | generic `Column<T>` / `DataTableProps<T>`, `T extends Record<string, unknown>` | |
**Easy wins:** `queryParams`/`Constant.value`; migrator `unknown` + typed PRAGMA row; `QueryClient`/`TwConfig`; RN prop types (10); `Object.assign`; make `DataTable` generic. **4 domain items:** product-shaped props in `app-common.types.ts`.
---
## Suggested remediation order
1. **Mechanical, huge payoff (~200 sites, no domain knowledge):** `catch`/`onError``unknown`; delete tRPC `onError` annotations; `Record<string, any>``unknown`; `z.any()``z.unknown()`; delete `: any` from typed Drizzle callbacks.
2. **Shared root causes (removes dozens downstream):** `Constant.value`, `app-common.types.ts`, `use-pick-image.tsx`, export `CartItem`/`SlotInfo`/`MergedProduct` and `ProductFormData`/`CouponFormValues`.
3. **Backend result typing:** replace `Promise<any…>` db-helper returns and `any[]` procedures with `InferSelectModel`/shared types — unblocks both admin frontends.
4. **Typed routes:** Expo `Href` / TanStack generated union (removes ~50 `as any`).
5. **Domain-input types to define:** `UserIncident`, reserved-coupon/client-coupon-with-relations, `RefundInput`, shared product type, `NotificationToast` payload.
6. **Bugs masked by `any` (fix while typing):** `web-ui/src/routes/slot-view.tsx:399` (`cartItem.productId``skuId`), `db_helper_sqlite/src/admin-apis/slots.ts:191/194` (`isDeleted` not selected), `usePopulateCentralStores.ts:15` (`deliveryDate`/`displayDate`/`displayTime` fields not on the slot type).

162
DEAD_CODE_REPORT.md Normal file
View file

@ -0,0 +1,162 @@
# Dead Code Report — branch `DEAD_CODE_CLEAN`
> **Status: EXECUTED.** The dead code listed below has been removed (see "Removal status" at the bottom). The one exception is the Drizzle relation exports, which were kept after verification.
Static-analysis sweep for reachable-code / caller-less code across the monorepo.
**Method:** ripgrep-based caller search + manual verification.
- **tRPC endpoints:** every procedure in `admin`/`user`/`common` routers was enumerated, then searched for callers (`trpc.<path>` and `trpcClient.<path>`) in `apps/admin-ui`, `apps/user-ui`, `apps/web-ui`, `apps/admin-web`, `apps/fallback-ui`, `apps/info-site`.
- **Methods/functions:** exported + local `function`/`const` definitions in `apps/backend/src` and `packages/db_helper_sqlite/src` were searched repo-wide; classified as *truly uncalled* only when they appear nowhere except their definition (and pure re-export files).
- **Files:** inbound-reference scan by module name.
- Excludes `node_modules`, `dist`, `.wrangler`, `.output`, `.expo`, `.turbo`, `.tanstack`.
> Caveats at the bottom — some of these may be intentional (manual tooling, external API consumers, planned features). Verify before deleting.
---
## 1. Dead tRPC endpoints (15)
No caller in any client app (native or web). Each is still registered on the router, so it is reachable over HTTP but unused by this repo.
| Endpoint | File | Line | Note |
|---|---|---|---|
| `trpc.admin.coupon.validate` | `apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts` | 265 | Admin coupon validation |
| `trpc.admin.vendorSnippets.getById` | `.../admin-apis/apis/vendor-snippets.ts` | 184 | Single snippet fetch |
| `trpc.admin.vendorSnippets.getVendorOrders` | `.../admin-apis/apis/vendor-snippets.ts` | 464 | Vendor orders |
| `trpc.admin.slots.getSlotsProductIds` | `.../admin-apis/apis/slots.ts` | 138 | Dup of product.* variant |
| `trpc.admin.slots.updateSlotProducts` | `.../admin-apis/apis/slots.ts` | 194 | Dup of product.* variant |
| `trpc.admin.slots.getSlots` | `.../admin-apis/apis/slots.ts` | 387 | Only `trpc.user.slots.getSlots` is used |
| `trpc.admin.slots.deleteSlot` | `.../admin-apis/apis/slots.ts` | 583 | No delete-slot UI |
| `trpc.admin.product.updateSlotProducts` | `.../admin-apis/apis/product.ts` | 392 | Duplicate router entry |
| `trpc.admin.product.getSlotsProductIds` | `.../admin-apis/apis/product.ts` | 462 | Duplicate router entry |
| `trpc.admin.staffUser.getUsers` | `.../admin-apis/apis/staff-user.ts` | 79 | `admin.user.getAllUsers` is the used one |
| `trpc.admin.staffUser.getUserDetails` | `.../admin-apis/apis/staff-user.ts` | 104 | `admin.user.getUserDetails` is used |
| `trpc.admin.staffUser.updateUserSuspension` | `.../admin-apis/apis/staff-user.ts` | 128 | `admin.user.updateUserSuspension` is used |
| `trpc.admin.user.createUserByMobile` | `.../admin-apis/apis/user.ts` | 29 | No caller |
| `trpc.common.product.getDashboardTags` | `.../common-apis/common.ts` | 144 | Home reads tags from `products.json`, not this |
| `trpc.hello` | `apps/backend/src/trpc/router.ts` | 14 | Scaffold sample endpoint |
**Notable:** the `slots``product` routers have **four duplicated** procedures (`getSlotsProductIds`, `updateSlotProducts`) — both copies are dead. The `staffUser` router duplicates three user-management procedures that are only ever called via the `admin.user` router.
---
## 2. Dead backend functions / constants (truly uncalled)
Defined/exported but never referenced anywhere else in the repo.
### Env / config
| Symbol | File |
|---|---|
| `getJwtSecret` | `apps/backend/src/lib/env-exporter.ts` |
| `getRedisUrl` | `apps/backend/src/lib/env-exporter.ts` |
| `getPhonePeBaseUrl` | `apps/backend/src/lib/env-exporter.ts` |
| `getPhonePeClientId` | `apps/backend/src/lib/env-exporter.ts` |
| `getPhonePeClientVersion` | `apps/backend/src/lib/env-exporter.ts` |
| `getPhonePeClientSecret` | `apps/backend/src/lib/env-exporter.ts` |
| `getPhonePeMerchantId` | `apps/backend/src/lib/env-exporter.ts` |
| `READABLE_ORDER_ID_KEY` | `apps/backend/src/lib/const-strings.ts` |
| `WELCOME_MESSAGE` | `apps/backend/src/lib/const-strings.ts` |
| `defaultRole` | `apps/backend/src/lib/roles-manager.ts` |
### Notifications / jobs
| Symbol | File | Note |
|---|---|---|
| `sendPushNotificationsMany` | `apps/backend/src/lib/expo-service.ts` | Whole file has no importers (see §4) |
| `sendOrderPlacedNotification` | `apps/backend/src/lib/notif-job.ts` | Only an unused import + a commented call in `user-apis/order.ts` |
| `sendOrderCancelledNotification` | `apps/backend/src/lib/notif-job.ts` | Same — imported, call commented |
| `sendPaymentFailedNotification` | `apps/backend/src/lib/notif-job.ts` | No reference |
| `sendOrderOutForDeliveryNotification` | `apps/backend/src/lib/notif-job.ts` | No reference |
| `sendRefundInitiatedNotification` | `apps/backend/src/lib/notif-job.ts` | No reference |
*(Alive: `sendAdminNotification` via `queue-consumer.ts``worker.ts`; `sendOrderPackagedNotification` and `sendOrderDeliveredNotification` via admin order api; `scheduleNotification` via those.)*
### Stores / cache / misc
| Symbol | File |
|---|---|
| `getOrderDetailsWrapper` | `apps/backend/src/dbService.ts` |
| `clearAllCache` | `apps/backend/src/lib/cloud_cache.ts` |
| `getAllBanners` | `apps/backend/src/stores/banner-store.ts` |
| `getTagById` | `apps/backend/src/stores/product-tag-store.ts` |
| `getAllTags` | `apps/backend/src/stores/product-tag-store.ts` |
| `getProductSlots` | `apps/backend/src/stores/slot-store.ts` |
| `getAllProductsSlots` | `apps/backend/src/stores/slot-store.ts` |
| `getUserNegativity` | `apps/backend/src/stores/user-negativity-store.ts` |
| `createTRPCRouter` | `apps/backend/src/trpc/trpc-index.ts` (alias re-export, unused) |
---
## 3. Dead exports in `db_helper_sqlite`
| Symbol | File | Note |
|---|---|---|
| `mergeDuplicateProducts` | `packages/db_helper_sqlite/src/admin-apis/merge-duplicate-products.ts` | Exported via index + `sqliteImporter`, but **no code calls it** (function had a "how to use manually" doc comment → manual utility). **Removed.** |
`productSkusRelations`, `productMarketStatsRelations`, `skuFeaturesRelations`, `productCombosRelations` (`src/db/schema.ts`) looked unreferenced but are **NOT dead** — Drizzle registers relation exports via `import * as schema` in `db_index.ts`, and `db.query.*.findMany({ with: … })` depends on them. **Kept.**
Used only inside their own file (helpers of live code — **not dead**): `parseDuplicateProductsMd` (deleted with the merge utility), `splitQuantityFeature`, `cleanFeatureValue`, `productTypeEnum`.
---
## 4. Dead / unreachable files & endpoints
| Item | File | Note |
|---|---|---|
| Expo push service | `apps/backend/src/lib/expo-service.ts` | No module imports it; its only export is uncalled |
| `av-router` | `apps/backend/src/apis/admin-apis/apis/av-router.ts` | Mounted at `/api/v1/av` but registers **zero routes** (staff-auth middleware only) |
| REST product summary | `apps/backend/src/apis/common-apis/apis/common-product.controller.ts` (`getAllProductsSummary`) | Reachable at `/api/v1/cm/products/summary`, but **no client calls it** (apps use tRPC) |
`/api/test` (`test-controller.ts`) is referenced only by `apps/fallback-ui/src/routes/demo.tsx` (a demo page).
---
## 5. Unused imports that point at dead code
| File | Unused import | Reality |
|---|---|---|
| `apps/backend/src/trpc/apis/user-apis/apis/order.ts` | `sendOrderPlacedNotification` (line 29) | Call is commented out (line 235) |
| `apps/backend/src/trpc/apis/user-apis/apis/order.ts` | `sendOrderCancelledNotification` (line 30) | Call is commented out (line 598) |
---
## 6. Commented-out / unreachable code blocks
Not "callers-less" but unreachable. Many "Old implementation" blocks were kept across the backend; the most notable registered-looking ones:
- `apps/backend/src/trpc/apis/common-apis/common.ts:160-176``getStoresSummary` and `healthCheck` procedures inside a `/* … */` block (NOT registered; the live ones are in `common-trpc-index.ts`).
- `apps/backend/src/main-router.ts``// router.route('/av', avRouter)` (route disabled).
- Numerous `// Old implementation - direct DB queries:` blocks in `admin-apis/*`, `user-apis/*`, and db helpers (historical only).
---
## 7. Checked and NOT dead (for reference)
- `apps/admin-ui` / `user-ui` / `web-ui` exports: **0 truly-unused**. 18 shared exports (`AdminOrderItemCore`, `SkuFlagCore`, `UserStoreSummaryCore`, …) are referenced only within their own file to compose other types — that's normal, not dead.
- Backend functions used only inside their own file (e.g. `cloud_cache` internals `createProductsFileInternal`, `constructCacheUrl`, `clearUrlCache`; `slot-store` `transformSlotToStoreSlot`/`extractSlotInfo`/`fetchAllTransformedSlots`; `trpc-index` `createCallerFactory`) — all reachable via their parent functions, **not dead**.
- `queue-consumer.ts` — although it imports only-in-backend, it IS used by `worker.ts`, so alive.
---
## Caveats
1. **Static analysis only.** A tRPC procedure can still be called by an external/undisclosed client (mobile build, partner integration). Confirm before removing public procedures.
2. **`mergeDuplicateProducts`** and **`expo-service`** look intentionally kept for manual/one-off use — decide whether to delete or document.
3. The `admin-web` tree is present on disk but **untracked** on this branch; it was included in the caller search.
4. Some "dead" management procedures (`staffUser.*`, duplicated `slots`/`product` entries) may be leftovers from older UI screens; safe to remove once confirmed.
## Removal status (executed)
**Removed — 15 tRPC procedures:** `admin.coupon.validate`; `admin.vendorSnippets.getById`, `getVendorOrders`; `admin.slots.getSlotsProductIds`, `updateSlotProducts`, `getSlots`, `deleteSlot`; `admin.product.updateSlotProducts`, `getSlotsProductIds`; `admin.staffUser.getUsers`, `getUserDetails`, `updateUserSuspension`; `admin.user.createUserByMobile`; `common.product.getDashboardTags`; `trpc.hello`.
**Removed — backend functions/constants:** `getJwtSecret`, `getRedisUrl`, `getExpoAccessToken`, `getPhonePeBaseUrl/ClientId/ClientVersion/ClientSecret/MerchantId`, `READABLE_ORDER_ID_KEY`, `WELCOME_MESSAGE`, `defaultRole`, `getOrderDetailsWrapper`, `clearAllCache`, `getAllBanners`, `getTagById`, `getAllTags`, `getDashboardTags`, `getProductSlots`, `getAllProductsSlots`, `getUserNegativity`, `createTRPCRouter`, `createCallerFactory`, and the notification senders `sendOrderPlacedNotification`, `sendPaymentFailedNotification`, `sendOrderOutForDeliveryNotification`, `sendOrderCancelledNotification`, `sendRefundInitiatedNotification` + their message constants.
**Removed — DB helper functions orphaned by the procedures above (from BOTH `db_helper_sqlite` and `db_helper_postgres`, plus their barrel exports and `sqliteImporter` re-exports):**
`createUserByMobile`, `deleteSlotById`, `getVendorOrders`, `updateSlotProducts`, `getSlotsProductIds` (the five behind the deleted endpoints) **and** `getActiveSlots`, `getAllUsers`, `getUserWithDetails`, `validateCoupon`, `checkUnitExists`, `getProductImagesById`, `replaceProductTags` (same class, missed by the first scan because their names also exist in the parallel package).
**Removed — files:** `apps/backend/src/lib/expo-service.ts`, `apps/backend/src/middleware/staff-auth.ts` (only used by the empty `av-router`), `apps/backend/src/apis/admin-apis/apis/av-router.ts` (empty router), `packages/db_helper_sqlite/src/admin-apis/merge-duplicate-products.ts`.
**Removed — wiring:** `avRouter` import/route from `main-router.ts` + `v1-router.ts`; `mergeDuplicateProducts` exports from `db_helper_sqlite/index.ts` and `sqliteImporter.ts`; unused `sendOrder*Notification` import + commented calls in `user-apis/order.ts`; orphaned imports in all touched files.
**Kept (false positives / intentional):** the four Drizzle relation exports (implicitly registered); `mergeDuplicateProducts` was deleted per §3.
**Verification:** `tsc` clean for `apps/backend`, `packages/db_helper_sqlite`, `apps/web-ui`, `apps/admin-ui`, `apps/user-ui`, `apps/admin-web`. (`apps/fallback-ui` has one pre-existing `vite.config.ts` error unrelated to this change.)

View file

@ -1,83 +0,0 @@
/** @type {Detox.DetoxConfig} */
module.exports = {
testRunner: {
args: {
'$0': 'jest',
config: 'e2e/jest.config.js'
},
jest: {
setupTimeout: 120000
}
},
apps: {
'ios.debug': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/YOUR_APP.app',
build: 'xcodebuild -workspace ios/YOUR_APP.xcworkspace -scheme YOUR_APP -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build'
},
'ios.release': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/YOUR_APP.app',
build: 'xcodebuild -workspace ios/YOUR_APP.xcworkspace -scheme YOUR_APP -configuration Release -sdk iphonesimulator -derivedDataPath ios/build'
},
'android.debug': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
reversePorts: [
8081
]
},
'android.release': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release'
}
},
devices: {
simulator: {
type: 'ios.simulator',
device: {
type: 'iPhone 15'
}
},
attached: {
type: 'android.attached',
device: {
adbName: '.*'
}
},
emulator: {
type: 'android.emulator',
device: {
avdName: 'Pixel_3a_API_30_x86'
}
}
},
configurations: {
'ios.sim.debug': {
device: 'simulator',
app: 'ios.debug'
},
'ios.sim.release': {
device: 'simulator',
app: 'ios.release'
},
'android.att.debug': {
device: 'attached',
app: 'android.debug'
},
'android.att.release': {
device: 'attached',
app: 'android.release'
},
'android.emu.debug': {
device: 'emulator',
app: 'android.debug'
},
'android.emu.release': {
device: 'emulator',
app: 'android.release'
}
}
};

File diff suppressed because one or more lines are too long

View file

@ -63,7 +63,7 @@ export default function Complaints() {
if (!selectedComplaintId) return;
resolveComplaint.mutate(
{ id: String(selectedComplaintId), response },
{ id: String(selectedComplaintId), response: response ?? '' },
{
onSuccess: () => {
Alert.alert("Success", "Complaint marked as resolved");

View file

@ -89,6 +89,7 @@ export default function EditCoupon() {
maxValue: coupon.maxValue ? parseFloat(coupon.maxValue) : undefined,
validTill: coupon.validTill ? dayjs(coupon.validTill).format('YYYY-MM-DD') : undefined,
maxLimitForUser: coupon.maxLimitForUser || undefined,
isFirstOrderOnly: coupon.isFirstOrderOnly,
skuIds: coupon.skuIds,
isReservedCoupon: false, // Normal coupons
};

View file

@ -1,248 +0,0 @@
import React, { useState, useCallback, useMemo } from 'react';
import { View, TouchableOpacity, Alert, RefreshControl } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { tw, MyButton, MyText, SearchBar, MyFlatList, useMarkDataFetchers, MyTouchableOpacity, BottomDropdown } from 'common-ui';
import useManualRefresh from 'common-ui/hooks/useManualRefresh';
import { trpc } from '@/src/trpc-client';
import { useRouter } from 'expo-router';
import { useInfiniteQuery } from '@tanstack/react-query';
const ReservedCouponItem = ({ item }: { item: any }) => {
const getStatus = () => {
if (item.isRedeemed) return 'redeemed';
if (item.validTill && new Date(item.validTill) <= new Date()) return 'expired';
return 'active';
};
const status = getStatus();
const getBorderColor = () => {
if (status === 'active') return 'border-green-500';
if (status === 'expired') return 'border-yellow-500';
return 'border-blue-500';
};
const getBgColor = () => {
if (status === 'active') return 'bg-green-100';
if (status === 'expired') return 'bg-yellow-100';
return 'bg-blue-100';
};
const getTextColor = () => {
if (status === 'active') return 'text-green-600';
if (status === 'expired') return 'text-yellow-600';
return 'text-blue-600';
};
const getIconColor = () => {
if (status === 'active') return '#10b981';
if (status === 'expired') return '#f59e0b';
return '#3b82f6';
};
return (
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-lg border-l-4 ${getBorderColor()}`}>
<View style={tw`flex-row items-center mb-3`}>
<View style={tw`w-10 h-10 rounded-full ${getBgColor()} items-center justify-center mr-3`}>
<MaterialCommunityIcons
name={item.discountPercent ? "percent" : "currency-inr"}
size={20}
color={getIconColor()}
/>
</View>
<View style={tw`flex-1`}>
<MyText style={tw`text-lg font-bold text-gray-800`}>{item.secretCode}</MyText>
<MyText style={tw`text-sm text-gray-500`}>Coupon: {item.couponCode}</MyText>
</View>
<View style={tw`px-2 py-1 rounded-full ${getBgColor()}`}>
<MyText style={tw`text-xs font-semibold ${getTextColor()}`}>
{status === 'active' ? 'Active' : status === 'expired' ? 'Expired' : 'Redeemed'}
</MyText>
</View>
</View>
<View style={tw`bg-gray-50 p-3 rounded-lg mb-3`}>
<MyText style={tw`text-base font-semibold mb-1 text-gray-800`}>
Discount: {item.discountPercent ? `${item.discountPercent}% off` : item.flatDiscount ? `${item.flatDiscount} off` : 'N/A'}
</MyText>
<View style={tw`flex-row justify-between`}>
<MyText style={tw`text-sm text-gray-600`}>Min Order: {item.minOrder ? `${item.minOrder}` : 'None'}</MyText>
<MyText style={tw`text-sm text-gray-600`}>Max: {item.maxValue ? `${item.maxValue}` : 'None'}</MyText>
</View>
<MyText style={tw`text-sm text-gray-600 mt-1`}>
Valid Till: {item.validTill ? new Date(item.validTill).toLocaleDateString() : 'No expiry'}
</MyText>
</View>
{item.isRedeemed && item.redeemedUser && (
<View style={tw`mb-3`}>
<MyText style={tw`text-sm text-gray-700`}>
<MaterialCommunityIcons name="account-check" size={14} color="#6b7280" /> Redeemed by: {item.redeemedUser.name || 'Unknown'} ({item.redeemedUser.mobile})
</MyText>
<MyText style={tw`text-sm text-gray-600`}>
Redeemed on: {item.redeemedAt ? new Date(item.redeemedAt).toLocaleDateString() : 'N/A'}
</MyText>
</View>
)}
<View style={tw`flex-row justify-between items-center mt-3`}>
<MyText style={tw`text-sm text-gray-700`}>
<MaterialCommunityIcons name="account" size={14} color="#6b7280" /> Created by: {item.creator?.name || 'Unknown'}
</MyText>
</View>
</View>
);
};
export default function ReservedCoupons() {
const router = useRouter();
const [refreshing, setRefreshing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilters, setStatusFilters] = useState<string[]>([]);
const {
data,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
refetch,
} = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
{ limit: 50 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const coupons = data?.pages.flatMap(page => page.coupons) || [];
const getStatus = (coupon: any) => {
if (coupon.isRedeemed) return 'redeemed';
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired';
return 'active';
};
const filteredCoupons = useMemo(() => {
let filtered = coupons.filter(coupon =>
coupon.secretCode.toLowerCase().includes(searchQuery.toLowerCase()) ||
coupon.couponCode.toLowerCase().includes(searchQuery.toLowerCase())
);
if (statusFilters.length > 0) {
filtered = filtered.filter(coupon => statusFilters.includes(getStatus(coupon)));
}
return filtered;
}, [coupons, searchQuery, statusFilters]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
}, [refetch]);
useManualRefresh(() => refetch());
useMarkDataFetchers(() => {
refetch();
});
if (isLoading) {
return (
<View style={tw`flex-1 justify-center items-center bg-white`}>
<View style={tw`w-16 h-16 bg-blue-100 rounded-full items-center justify-center mb-4`}>
<MaterialCommunityIcons name="loading" size={32} color="#3b82f6" />
</View>
<MyText style={tw`text-lg font-semibold text-gray-600`}>Loading Reserved Coupons...</MyText>
</View>
);
}
return (
<View style={tw`flex-1 bg-white`}>
<View style={tw`flex-row items-center px-4 py-2`}>
<View style={tw`flex-1 mr-2`}>
<SearchBar
value={searchQuery}
onChangeText={setSearchQuery}
placeholder="Search reserved coupons..."
/>
</View>
<BottomDropdown
label="Filter by Status"
value={statusFilters}
options={[
{ label: 'Active', value: 'active' },
{ label: 'Expired', value: 'expired' },
{ label: 'Redeemed', value: 'redeemed' },
]}
onValueChange={(value) => setStatusFilters(value as string[])}
multiple={true}
triggerComponent={({ onPress }) => (
<TouchableOpacity onPress={onPress} style={tw`p-2`}>
<MaterialCommunityIcons name="filter-variant" size={24} color="#6b7280" />
</TouchableOpacity>
)}
/>
</View>
<MyFlatList
data={filteredCoupons}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => <ReservedCouponItem item={item} />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
contentContainerStyle={tw`px-4 pb-4`}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListEmptyComponent={
searchQuery ? (
<View style={tw`flex-1 justify-center items-center py-20`}>
<View style={tw`w-20 h-20 bg-gray-100 rounded-full items-center justify-center mb-4`}>
<MaterialCommunityIcons name="magnify" size={40} color="#9ca3af" />
</View>
<MyText style={tw`text-xl font-semibold text-gray-600 mb-2`}>No Results</MyText>
<MyText style={tw`text-gray-500 text-center mb-4`}>No reserved coupons match &ldquo;{searchQuery}&rdquo;</MyText>
<MyButton onPress={() => setSearchQuery('')} style={tw`bg-gray-500`}>
<MyText style={tw`text-white font-semibold`}>Clear Search</MyText>
</MyButton>
</View>
) : (
<View style={tw`flex-1 justify-center items-center py-20`}>
<View style={tw`w-20 h-20 bg-gray-100 rounded-full items-center justify-center mb-4`}>
<MaterialCommunityIcons name="ticket-percent-outline" size={40} color="#9ca3af" />
</View>
<MyText style={tw`text-xl font-semibold text-gray-600 mb-2`}>No Reserved Coupons Yet</MyText>
<MyText style={tw`text-gray-500 text-center mb-4`}>Create your first reserved coupon to start offering secret discounts</MyText>
<MyButton onPress={() => router.push('/(drawer)/dashboard/coupons/create')} style={tw`bg-blue-500`}>
<View style={tw`flex-row items-center`}>
<MaterialCommunityIcons name="plus" size={16} color="white" />
<MyText style={tw`text-white font-semibold ml-1`}>Create Reserved Coupon</MyText>
</View>
</MyButton>
</View>
)
}
/>
{/* FAB for Add New Reserved Coupon */}
<MyTouchableOpacity
onPress={() => router.push('/(drawer)/dashboard/coupons/create')}
activeOpacity={0.95}
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
>
<LinearGradient
colors={['#F83758', '#E91E63']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg shadow-pink300`}
>
<MaterialCommunityIcons name="plus" size={32} color="white" />
</LinearGradient>
</MyTouchableOpacity>
</View>
);
}

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from "react";
import type { StoreProductCard } from '@packages/shared'
import {
View,
Alert,
@ -27,20 +28,12 @@ const { width: screenWidth } = Dimensions.get("window");
const itemWidth = screenWidth - 48; // 24px padding each side
const itemHeight = 80;
interface Product {
id: number;
name: string;
images: string[];
isOutOfStock: boolean;
}
// Reorder row — anchored to the shared store-card core
type Product = Pick<StoreProductCard, 'id' | 'name' | 'images' | 'isOutOfStock'>
interface ProductItemProps {
item: Product;
drag: () => void;
isActive: boolean;
}
import type { DragOrderItemProps } from '@/types/drag-order-item';
const ProductItem: React.FC<ProductItemProps> = ({
const ProductItem: React.FC<DragOrderItemProps<Product>> = ({
item,
drag,
isActive,
@ -101,7 +94,7 @@ export default function AllItemsOrder() {
// Get current order from constants
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery();
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery({});
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery();
const updateConstants = trpc.admin.const.updateConstants.useMutation();
// Initialize products from constants

View file

@ -24,26 +24,22 @@ import { useRouter } from "expo-router";
import { trpc } from "../../../../src/trpc-client";
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { useQueryClient } from "@tanstack/react-query";
import type { StoreProductCard } from '@packages/shared';
import type { DragOrderItemProps } from '@/types/drag-order-item';
interface PopularProduct {
id: number;
name: string;
shortDescription: string | null;
// Popular-items row = shared store card core with numeric (form) prices
type PopularProduct = Omit<
StoreProductCard,
'price' | 'marketPrice' | 'unitNotation' | 'images'
> & {
price: number;
marketPrice: number | null;
unit: string;
incrementStep: number;
productQuantity: number;
images: string[] | null;
storeId: number | null;
isOutOfStock: boolean;
nextDeliveryDate: string | null;
images: string[];
}
};
interface ProductItemProps {
item: PopularProduct;
drag: () => void;
isActive: boolean;
interface ProductItemProps extends DragOrderItemProps<PopularProduct> {
onDelete: (id: number) => void;
}
@ -125,7 +121,7 @@ export default function CustomizePopularItems() {
// Get current popular items from constants
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery();
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery({});
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery();
const updateConstants = trpc.admin.const.updateConstants.useMutation();
// Initialize popular products from constants

View file

@ -6,19 +6,20 @@ import { useRouter, useLocalSearchParams } from 'expo-router';
import { FormikHelpers } from 'formik';
import BannerForm, { BannerFormData } from '@/components/BannerForm';
import { trpc } from '@/src/trpc-client';
import type { Banner as SharedBanner } from '@packages/shared';
interface Banner {
id: number;
name: string;
imageUrl: string;
// Form/hardened view of the shared banner (string dates, non-null editable fields)
type Banner = Omit<
SharedBanner,
'description' | 'skuIds' | 'redirectUrl' | 'serialNum' | 'createdAt' | 'lastUpdated'
> & {
description?: string;
skuIds?: number[];
redirectUrl?: string;
serialNum: number;
isActive: boolean;
createdAt: string;
lastUpdated: string;
}
};
export default function EditBanner() {
const router = useRouter();

View file

@ -4,19 +4,13 @@ import { AppContainer, MyText, tw, MyTouchableOpacity } from 'common-ui';
import { trpc } from '../../../../src/trpc-client';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useRouter } from 'expo-router';
import type { Banner as SharedBanner } from '@packages/shared';
interface Banner {
id: number;
name: string;
imageUrl: string;
description: string | null;
skuIds: number[] | null;
redirectUrl: string | null;
serialNum: number | null;
isActive: boolean;
// Serialized banner row (API JSON carries string dates) anchored to the shared shape
type Banner = Omit<SharedBanner, 'createdAt' | 'lastUpdated'> & {
createdAt: string;
lastUpdated: string;
}
};
export default function DashboardBanners() {
const router = useRouter();

View file

@ -3,7 +3,6 @@ import { View, ScrollView, Pressable } from 'react-native';
import { useRouter } from 'expo-router';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { MyText, tw } from 'common-ui';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from 'common-ui/src/theme';
import { trpc } from '@/src/trpc-client';
import { useNavigationTarget } from 'common-ui/hooks/useNavigationTarget';

View file

@ -537,11 +537,6 @@ export default function DeliverySequences() {
</View>
) : (
<View style={tw`flex-1`}>
{/* <View style={tw`bg-blue-50 px-4 py-2 mb-2`}>
<MyText style={tw`text-blue-700 text-xs text-center`}>
Long press an item to drag and reorder
</MyText>
</View> */}
<DraggableFlatList
data={localOrderedOrders}
renderItem={({ item, drag, isActive }) => (

View file

@ -9,6 +9,7 @@ import { Entypo } from '@expo/vector-icons';
import CancelOrderDialog from '@/components/CancelOrderDialog';
import { OrderOptionsMenu } from '@/components/OrderOptionsMenu';
import * as Location from 'expo-location';
import type { AdminOrderListItem, AdminOrderListItemProduct } from '@packages/shared';
const AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId: string; existingNotes?: string | null; onClose: () => void; refetch: () => void }) => {
const [notesText, setNotesText] = useState(existingNotes || '');
@ -51,43 +52,32 @@ const AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId:
};
interface OrderType {
id: number;
orderId: string;
readableId: number;
customerName: string | null;
customerMobile?: string | null;
address: string;
addressId: number;
latitude: number | null;
longitude: number | null;
totalAmount: number;
deliveryCharge: number;
items: {
// Order list row — derived from shared AdminOrderListItem (serialized dates,
// coupon display fields, optional flags)
type OrderItemRow = Omit<
AdminOrderListItemProduct,
'id' | 'skuName' | 'features' | 'isPackaged' | 'isPackageVerified'
> & {
id?: number;
name: string;
quantity: number;
price: number;
amount: number;
unit: string;
isPackaged?: boolean;
isPackageVerified?: boolean;
productSize: number;
}[];
};
type OrderType = Omit<
AdminOrderListItem,
'customerName' | 'customerMobile' | 'items' | 'createdAt' | 'adminNotes' | 'userNotes' | 'userNegativityScore'
> & {
customerName: string | null;
customerMobile?: string | null;
items: OrderItemRow[];
createdAt: string;
deliveryTime: string | null;
status: 'pending' | 'delivered' | 'cancelled';
isPackaged: boolean;
isDelivered: boolean;
isCod: boolean;
isFlashDelivery: boolean;
couponCode?: string;
couponDescription?: string;
discountAmount?: number;
adminNotes?: string | null;
userNotes?: string | null;
userNegativityScore?: number;
}
couponCode?: string;
couponDescription?: string;
discountAmount?: number;
};
const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => {
const id = order.orderId;

View file

@ -15,15 +15,7 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { LinearGradient } from "expo-linear-gradient";
import dayjs from "dayjs";
import { useRouter } from "expo-router";
interface ProductGroup {
id: number;
groupName: string;
description: string | null;
createdAt: string;
products: any[];
productCount: number;
}
import type { ProductGroup } from "@/components/ProductGroupForm";
const GroupItem = ({
group,

View file

@ -5,14 +5,7 @@ import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
import TagForm from '@/src/components/TagForm';
import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
interface TagFormData {
tagName: string;
tagDescription: string;
isDashboardTag: boolean;
relatedStores: number[];
productIds: number[];
}
import type { TagFormData } from '@/src/components/TagForm';
export default function AddTag() {
const router = useRouter();

View file

@ -3,17 +3,12 @@ import { View, Alert } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
import TagForm from '@/src/components/TagForm';
import type { TagFormData as BaseTagFormData } from '@/src/components/TagForm';
import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
interface TagFormData {
tagName: string;
tagDescription: string;
isDashboardTag: boolean;
relatedStores: number[];
productIds: number[];
existingImageUrl?: string;
}
// Edit screen adds the current image to the canonical TagForm data
type TagFormData = BaseTagFormData & { existingImageUrl?: string };
export default function EditTag() {
const router = useRouter();

View file

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import type { ProductTagCore } from '@packages/shared'
import { View, TouchableOpacity, Alert, RefreshControl } from 'react-native';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
@ -7,13 +8,12 @@ import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'c
import { TagMenu } from '@/src/components/TagMenu';
import { trpc } from '@/src/trpc-client';
interface TagItemData {
// Tag list row — anchored to the shared tag core (relatedStores now typed)
interface TagItemData extends ProductTagCore {
id: number;
tagName: string;
tagDescription: string | null;
imageUrl: string | null;
isDashboardTag: boolean;
relatedStores?: unknown;
createdAt: string | Date;
}

View file

@ -21,6 +21,7 @@ import { useRouter } from 'expo-router';
import { trpc } from '../../../../src/trpc-client';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useQueryClient } from '@tanstack/react-query';
import type { DragOrderItemProps } from '@/types/drag-order-item';
const { width: screenWidth } = Dimensions.get('window');
const itemWidth = screenWidth - 48;
@ -32,13 +33,7 @@ interface Tag {
imageUrl: string | null;
}
interface TagItemProps {
item: Tag;
drag: () => void;
isActive: boolean;
}
const TagItem: React.FC<TagItemProps> = ({ item, drag, isActive }) => {
const TagItem: React.FC<DragOrderItemProps<Tag>> = ({ item, drag, isActive }) => {
return (
<ScaleDecorator>
<TouchableOpacity

View file

@ -15,8 +15,8 @@ export default function AddProduct() {
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], _deletedImageKeys?: string[]) => {
try {
for (const variant of values.variants) {
const price = parseFloat(variant.price)
if (isNaN(price) || price <= 0) {
const price = variant.price
if (price == null || price <= 0) {
Alert.alert('Error', 'Please enter a valid price for every variant')
return
}
@ -24,15 +24,15 @@ export default function AddProduct() {
const seenSignatures = new Set<string>()
for (const variant of values.variants) {
const attributes = variant.attributes || []
const hasQuantity = attributes.some(
const features = variant.features || []
const hasQuantity = features.some(
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
)
if (!hasQuantity) {
Alert.alert('Error', 'Every SKU must have a quantity feature')
return
}
const signature = attributes
const signature = features
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
.sort()
.join('|')
@ -68,21 +68,21 @@ export default function AddProduct() {
return {
name: variant.name || null,
price: parseFloat(variant.price),
marketPrice: variant.marketPrice ? parseFloat(variant.marketPrice) : undefined,
price: variant.price as number,
marketPrice: variant.marketPrice ?? undefined,
images: variantUrls,
isFlashAvailable: variant.isFlashAvailable || false,
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
flashPrice: variant.flashPrice ?? undefined,
isOffer: variant.isOffer || false,
isComboOnly: variant.isComboOnly || false,
isDeleted: variant.isDeleted || false,
isSuspended: variant.isSuspended || false,
features: variant.attributes.map((attr: any) => ({
features: variant.features.map((attr: any) => ({
featureName: attr.featureName,
featureValue: attr.featureValue,
})),
comboItems: (variant.comboItems || []).map((ci: any) => ({
skuId: typeof ci.skuId === 'string' ? parseInt(ci.skuId) : ci.skuId,
skuId: ci.skuId,
})),
}
})
@ -114,16 +114,16 @@ export default function AddProduct() {
{
id: undefined as number | undefined,
name: '',
price: '',
marketPrice: '',
price: undefined,
marketPrice: null,
isFlashAvailable: false,
flashPrice: '',
flashPrice: null,
isOffer: false,
isComboOnly: false,
isDeleted: false,
isSuspended: false,
attributes: [{ featureName: 'quantity', featureValue: '' }],
comboItems: [] as { skuId: number | string }[],
features: [{ featureName: 'quantity', featureValue: '' }],
comboItems: [],
},
],
}

View file

@ -33,6 +33,7 @@ export default function EditProduct() {
shortDescription: '',
longDescription: '',
storeId: 0,
productType: 'item' as const,
variants: [],
}
}
@ -45,20 +46,20 @@ export default function EditProduct() {
variants: (productData.skus || []).map((sku) => ({
id: sku.id,
name: sku.name || '',
price: sku.price || '',
marketPrice: sku.marketPrice || '',
price: sku.price ? parseFloat(sku.price) : undefined,
marketPrice: sku.marketPrice ? parseFloat(sku.marketPrice) : null,
isFlashAvailable: sku.isFlashAvailable || false,
flashPrice: sku.flashPrice || '',
flashPrice: sku.flashPrice ? parseFloat(sku.flashPrice) : null,
isOffer: sku.isOffer || false,
isComboOnly: sku.isComboOnly || false,
isDeleted: sku.isDeleted || false,
isSuspended: sku.isSuspended || false,
attributes: (sku.features || []).map((f) => ({
features: (sku.features || []).map((f) => ({
featureName: f.featureName,
featureValue: f.featureValue,
})),
comboItems: (sku.comboItems || []).map((ci: any) => ({
skuId: ci.skuId.toString(),
skuId: ci.skuId,
})),
})),
}
@ -81,8 +82,8 @@ export default function EditProduct() {
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => {
try {
for (const variant of values.variants) {
const price = parseFloat(variant.price)
if (isNaN(price) || price <= 0) {
const price = variant.price
if (price == null || price <= 0) {
Alert.alert('Error', 'Please enter a valid price for every variant')
return
}
@ -90,15 +91,15 @@ export default function EditProduct() {
const seenSignatures = new Set<string>()
for (const variant of values.variants) {
const attributes = variant.attributes || []
const hasQuantity = attributes.some(
const features = variant.features || []
const hasQuantity = features.some(
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
)
if (!hasQuantity) {
Alert.alert('Error', 'Every SKU must have a quantity feature')
return
}
const signature = attributes
const signature = features
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
.sort()
.join('|')
@ -146,21 +147,21 @@ export default function EditProduct() {
return {
id: variant.id,
name: variant.name || null,
price: parseFloat(variant.price),
marketPrice: variant.marketPrice ? parseFloat(variant.marketPrice) : undefined,
price: variant.price as number,
marketPrice: variant.marketPrice ?? undefined,
images: allUrls,
isFlashAvailable: variant.isFlashAvailable || false,
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
flashPrice: variant.flashPrice ?? undefined,
isOffer: variant.isOffer || false,
isComboOnly: variant.isComboOnly || false,
isDeleted: variant.isDeleted || false,
isSuspended: variant.isSuspended || false,
features: variant.attributes.map((attr: any) => ({
features: variant.features.map((attr: any) => ({
featureName: attr.featureName,
featureValue: attr.featureValue,
})),
comboItems: (variant.comboItems || []).map((ci: any) => ({
skuId: typeof ci.skuId === 'string' ? parseInt(ci.skuId) : ci.skuId,
skuId: ci.skuId,
})),
}
})

View file

@ -6,11 +6,14 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { AppContainer, MyText, tw, MyButton, useManualRefresh, SearchBar, useMarkDataFetchers, MyFlatList } from 'common-ui';
import { trpc } from '@/src/trpc-client';
import { SuccessToast, ErrorToast } from '@/services/toaster';
import type { AdminSku } from '@packages/shared';
type FilterType = 'all' | 'in-stock' | 'out-of-stock';
function getDefaultSku(product: { skus: AdminSku[] }): AdminSku | null {
type SerializedAdminSku = Omit<AdminSku, 'createdAt'> & { createdAt: string | Date }
function getDefaultSku(product: { skus: SerializedAdminSku[] }): SerializedAdminSku | null {
return product.skus?.[0] ?? null
}
@ -22,6 +25,16 @@ export default function Products() {
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery();
const toggleOutOfStock = trpc.admin.product.toggleOutOfStock.useMutation({
onSuccess: async (res) => {
await refetch();
SuccessToast(res.message);
},
onError: (err) => {
ErrorToast(err.message || 'Failed to update stock status');
},
});
useManualRefresh(refetch);
useMarkDataFetchers(() => {
@ -59,6 +72,10 @@ export default function Products() {
router.push(`/(drawer)/dashboard/products/detail/${productId}` as any);
};
const handleToggleStock = (product: { id: number }) => {
toggleOutOfStock.mutate({ id: product.id });
};
const FilterButton = ({ filter, label, count }: { filter: FilterType; label: string; count: number }) => (
<TouchableOpacity
onPress={() => setActiveFilter(filter)}
@ -114,7 +131,6 @@ export default function Products() {
<SearchBar
value={searchTerm}
onChangeText={setSearchTerm}
onSearch={() => {}}
placeholder="Search products..."
containerStyle={tw`mb-0`}
/>
@ -192,6 +208,17 @@ export default function Products() {
<MaterialIcons name="edit" size={16} color="white" />
<MyText style={tw`text-white font-semibold ml-1`}>Edit</MyText>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleToggleStock(product)}
disabled={toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id}
style={tw`flex-1 ${isOut ? 'bg-green-500' : 'bg-orange-500'} p-3 rounded-lg flex-row items-center justify-center ${toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id ? 'opacity-50' : ''}`}
>
<MaterialIcons name={isOut ? 'check-circle' : 'block'} size={16} color="white" />
<MyText style={tw`text-white font-semibold ml-1`}>
{isOut ? 'Stock' : 'Out'}
</MyText>
</TouchableOpacity>
</View>
</View>
</View>

View file

@ -14,16 +14,13 @@ import {
tw,
MyTextInput,
BottomDropdown,
ImageUploader,
} from 'common-ui';
import { trpc } from '@/src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
import type { UserMiniInfo } from '@packages/shared';
interface User {
id: number;
// Notification recipient = shared mini user + eligibility flag
type User = Omit<UserMiniInfo, 'name'> & {
name: string | null;
mobile: string | null;
isEligibleForNotif: boolean;
}
@ -32,8 +29,6 @@ export default function SendNotifications() {
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [title, setTitle] = useState('');
const [message, setMessage] = useState('');
const [selectedImage, setSelectedImage] = useState<{ blob: Blob; mimeType: string } | null>(null);
const [displayImage, setDisplayImage] = useState<{ uri?: string } | null>(null);
const [searchQuery, setSearchQuery] = useState('');
// Query users eligible for notifications
@ -41,8 +36,6 @@ export default function SendNotifications() {
search: searchQuery,
});
const { uploadSingle } = useUploadToObjectStorage();
// Send notification mutation
const sendNotification = trpc.admin.user.sendNotification.useMutation({
onSuccess: () => {
@ -51,8 +44,6 @@ export default function SendNotifications() {
setSelectedUserIds([]);
setTitle('');
setMessage('');
setSelectedImage(null);
setDisplayImage(null);
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to send notification');
@ -66,29 +57,6 @@ export default function SendNotifications() {
value: user.id,
}));
const handleImagePick = usePickImage({
setFile: async (assets: any) => {
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
setSelectedImage(null);
setDisplayImage(null);
return;
}
const file = Array.isArray(assets) ? assets[0] : assets;
const response = await fetch(file.uri);
const blob = await response.blob();
setSelectedImage({ blob, mimeType: file.mimeType || 'image/jpeg' });
setDisplayImage({ uri: file.uri });
},
multiple: false,
});
const handleRemoveImage = () => {
setSelectedImage(null);
setDisplayImage(null);
};
const handleSend = async () => {
if (title.trim().length === 0) {
Alert.alert('Error', 'Please enter a title');
@ -117,20 +85,11 @@ export default function SendNotifications() {
}
try {
let imageUrl: string | undefined;
// Upload image if selected
if (selectedImage) {
const { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification');
imageUrl = key;
}
// Send notification
await sendNotification.mutateAsync({
userIds: selectedUserIds,
title: title.trim(),
text: message.trim(),
imageUrl,
});
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to send notification');
@ -196,17 +155,6 @@ export default function SendNotifications() {
/>
</View>
{/* Image Upload - Hidden for now */}
{/* <View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Image (Optional)</MyText>
<ImageUploader
images={displayImage ? [displayImage] : []}
existingImageUrls={[]}
onAddImage={handleImagePick}
onRemoveImage={handleRemoveImage}
/>
</View> */}
{/* User Selection */}
<View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Select Users (Optional)</MyText>

View file

@ -1,4 +1,5 @@
import React, { useCallback } from 'react';
import type { OrderCardActionProps } from '@packages/shared'
import {
View,
TouchableOpacity,
@ -28,9 +29,7 @@ interface Order {
itemCount: number;
}
interface OrderItemProps {
order: Order;
onPress: () => void;
interface OrderItemProps extends OrderCardActionProps<Order> {
}
const getStatusColor = (status: string) => {

View file

@ -1,64 +0,0 @@
import React from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import { View, Text, TouchableOpacity } from 'react-native'
import { MyTextInput, BottomDropdown, tw } from 'common-ui'
import { trpc } from '@/src/trpc-client'
interface AddressPlaceFormProps {
onSubmit: (values: { placeName: string; zoneId: number | null }) => void
onClose: () => void
}
const AddressPlaceForm: React.FC<AddressPlaceFormProps> = ({ onSubmit, onClose }) => {
const { data: zones } = trpc.admin.address.getZones.useQuery()
const validationSchema = Yup.object({
placeName: Yup.string().required('Place name is required'),
zoneId: Yup.number().optional(),
})
const zoneOptions = zones?.map(z => ({ label: z.zoneName, value: z.id })) || []
return (
<View style={tw`p-4`}>
<Text style={tw`text-lg font-semibold mb-4`}>Add Place</Text>
<Formik
initialValues={{ placeName: '', zoneId: null as number | null }}
validationSchema={validationSchema}
onSubmit={(values) => {
onSubmit(values)
onClose()
}}
>
{({ handleChange, setFieldValue, handleSubmit, values, errors, touched }) => (
<View>
<MyTextInput
label="Place Name"
value={values.placeName}
onChangeText={handleChange('placeName')}
error={!!(touched.placeName && errors.placeName)}
/>
<BottomDropdown
label="Zone (Optional)"
value={values.zoneId as any}
options={zoneOptions}
onValueChange={(value) => setFieldValue('zoneId', value as number | undefined)}
placeholder="Select Zone"
/>
<View style={tw`flex-row justify-between mt-4`}>
<TouchableOpacity style={tw`bg-gray2 px-4 py-2 rounded`} onPress={onClose}>
<Text style={tw`text-gray-900`}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={() => handleSubmit()}>
<Text style={tw`text-white`}>Create</Text>
</TouchableOpacity>
</View>
</View>
)}
</Formik>
</View>
)
}
export default AddressPlaceForm

View file

@ -1,51 +0,0 @@
import React from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import { View, Text, TouchableOpacity } from 'react-native'
import { MyTextInput, tw } from 'common-ui'
interface AddressZoneFormProps {
onSubmit: (values: { zoneName: string }) => void
onClose: () => void
}
const AddressZoneForm: React.FC<AddressZoneFormProps> = ({ onSubmit, onClose }) => {
const validationSchema = Yup.object({
zoneName: Yup.string().required('Zone name is required'),
})
return (
<View style={tw`p-4`}>
<Text style={tw`text-lg font-semibold mb-4`}>Add Zone</Text>
<Formik
initialValues={{ zoneName: '' }}
validationSchema={validationSchema}
onSubmit={(values) => {
onSubmit(values)
onClose()
}}
>
{({ handleChange, handleSubmit, values, errors, touched }) => (
<View>
<MyTextInput
label="Zone Name"
value={values.zoneName}
onChangeText={handleChange('zoneName')}
error={!!(touched.zoneName && errors.zoneName)}
/>
<View style={tw`flex-row justify-between mt-4`}>
<TouchableOpacity style={tw`bg-gray2 px-4 py-2 rounded`} onPress={onClose}>
<Text style={tw`text-gray-900`}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={() => handleSubmit()}>
<Text style={tw`text-white`}>Create</Text>
</TouchableOpacity>
</View>
</View>
)}
</Formik>
</View>
)
}
export default AddressZoneForm

View file

@ -1,14 +1,12 @@
import React, { useState } from 'react';
import type { OrderDialogBaseProps } from '@packages/shared'
import { View, TouchableOpacity } from 'react-native';
import { MyText, tw, BottomDialog, MyTextInput } from 'common-ui';
import { trpc } from '@/src/trpc-client';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { Alert } from 'react-native';
interface CancelOrderDialogProps {
orderId: number;
open: boolean;
onClose: () => void;
interface CancelOrderDialogProps extends OrderDialogBaseProps {
onSuccess?: () => void;
}

View file

@ -1,45 +0,0 @@
import React from "react";
import { ScrollView, View, StyleSheet } from "react-native";
import { ImageViewerURI } from "common-ui";
interface HorizontalImageScrollerProps {
urls: string[];
imageHeight?: number;
imageWidth?: number;
}
const HorizontalImageScroller: React.FC<HorizontalImageScrollerProps> = ({
urls,
imageHeight = 128,
imageWidth = 128,
}) => {
if (!urls || urls.length === 0) return null;
return (
<View style={styles.container}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{urls.map((url, idx) => (
<View key={idx} style={{ marginRight: 12 }}>
<ImageViewerURI
uri={url}
style={{
height: imageHeight,
width: imageWidth,
borderRadius: 12,
}}
/>
</View>
))}
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginVertical: 8,
},
});
export default HorizontalImageScroller;

View file

@ -5,15 +5,13 @@ import { MyText, tw, MyTextInput, MyTouchableOpacity, theme, BottomDropdown } fr
import ProductsSelector from './ProductsSelector';
import { trpc } from '../src/trpc-client';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import type { AdminProductGroup as SharedAdminProductGroup } from '@packages/shared';
interface ProductGroup {
id: number;
groupName: string;
description: string | null;
// Serialized product group (string date, loosely typed products) anchored to shared
export type ProductGroup = Omit<SharedAdminProductGroup, 'createdAt' | 'products'> & {
createdAt: string;
products: any[];
productCount: number;
}
};
interface ProductGroupFormProps {
group?: ProductGroup | null;
@ -27,7 +25,7 @@ const ProductGroupForm: React.FC<ProductGroupFormProps> = ({
onSuccess,
}) => {
// Fetch products
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery();
const createGroup = trpc.admin.product.createGroup.useMutation();
const updateGroup = trpc.admin.product.updateGroup.useMutation();

View file

@ -1,11 +1,9 @@
import React from 'react';
import { View, ScrollView } from 'react-native';
import { BottomDialog, MyText, tw } from 'common-ui';
import type { IdName } from '@packages/shared';
interface VendorSnippetProduct {
id: number;
name: string;
}
type VendorSnippetProduct = IdName;
interface ProductListDialogProps {
open: boolean;

View file

@ -3,15 +3,10 @@ import { View } from 'react-native';
import BottomDropdown, { DropdownOption } from 'common-ui/src/components/bottom-dropdown';
import { trpc } from '../src/trpc-client';
import { tw } from 'common-ui';
import type { SkuSummary as SharedSkuSummary } from '@packages/shared';
interface SkuSummary {
id: number;
productId: number;
productName: string;
label: string;
storeId: number | null;
price: string;
}
// Selector works with skus that may not have images resolved yet
type SkuSummary = Omit<SharedSkuSummary, 'images'>
interface Group {
id: number;
@ -50,7 +45,7 @@ export default function ProductsSelector({
selectedGroupIds = [],
onGroupChange,
}: ProductsSelectorProps) {
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery({});
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery();
const allSkus: SkuSummary[] = skusData?.skus || [];
const [searchQuery, setSearchQuery] = useState('');

View file

@ -1,4 +1,5 @@
import React from 'react';
import type { SlotSnippetInput } from '@packages/shared'
import { View, Text, TouchableOpacity, Alert } from 'react-native';
import { Formik, FieldArray } from 'formik';
import DateTimePickerMod from 'common-ui/src/components/date-time-picker';
@ -6,12 +7,8 @@ import { tw, MyTextInput } from 'common-ui';
import { trpc } from '../src/trpc-client';
import ProductsSelector from '../components/ProductsSelector';
interface VendorSnippet {
name: string;
groupIds: number[];
skuIds: number[];
validTill?: string;
}
// Snippet form row — single source in @packages/shared (complete payload)
type VendorSnippet = SlotSnippetInput
interface SlotFormProps {
onSlotAdded?: () => void;
@ -41,7 +38,7 @@ export default function SlotForm({
name: snippet.name || '',
groupIds: snippet.groupIds || [],
skuIds: snippet.skuIds || [],
validTill: snippet.validTill || undefined,
validTill: snippet.validTill || null,
})) as VendorSnippet[];
const initialValues = {
@ -80,7 +77,7 @@ export default function SlotForm({
vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({
name: snippet.name,
skuIds: snippet.skuIds,
validTill: snippet.validTill,
validTill: snippet.validTill ?? undefined,
})),
};

View file

@ -2,28 +2,25 @@ import React from 'react';
import { View, ScrollView, TouchableOpacity } from 'react-native';
import { MyText, tw, AppContainer } from 'common-ui';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import type {
AdminVendorSnippetOrderProduct,
AdminVendorSnippetOrderSummary as SharedSnippetOrderSummary,
} from '@packages/shared';
interface OrderProduct {
productId: number;
productName: string;
quantity: number;
price: number;
unit: string;
subtotal: number;
}
// Line item + order summary anchored to the shared vendor-order contract
// (string totalAmount + any[] sequence at this view layer)
type OrderProduct = Pick<
AdminVendorSnippetOrderProduct,
'productId' | 'productName' | 'quantity' | 'price' | 'unit' | 'subtotal'
>
interface SnippetOrder {
orderId: string;
orderDate: string;
customerName: string;
totalAmount: string;
type SnippetOrder = Omit<SharedSnippetOrderSummary, 'totalAmount' | 'slotInfo' | 'products'> & {
totalAmount: string
slotInfo: {
time: string;
sequence: any[];
} | null;
products: OrderProduct[];
matchedProducts: number[];
snippetCode: string;
}
interface SnippetOrdersViewProps {

View file

@ -1,4 +1,4 @@
import React, { forwardRef, useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { View, TouchableOpacity, Alert } from 'react-native';
import { Formik } from 'formik';
import * as Yup from 'yup';
@ -7,18 +7,13 @@ import ProductsSelector from './ProductsSelector';
import { trpc } from '../src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore';
import type { CreateStoreInput } from '@packages/shared';
export interface StoreFormData {
name: string;
// Store form values — required description + product selection on top of CreateStoreInput
export type StoreFormData = Omit<CreateStoreInput, 'description'> & {
description: string;
imageUrl?: string;
owner: number;
products: number[];
}
export interface StoreFormRef {
// Add methods if needed
}
};
interface StoreFormProps {
mode: 'create' | 'edit';
@ -36,8 +31,7 @@ const validationSchema = Yup.object().shape({
products: Yup.array().of(Yup.number()),
});
const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
const { mode, initialValues, onSubmit, isLoading, storeId } = props;
function StoreForm({ mode, initialValues, onSubmit, isLoading, storeId }: StoreFormProps) {
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery();
const { data: productsData } = trpc.admin.product.getProducts.useQuery();
@ -199,8 +193,6 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
}}
</Formik>
);
});
StoreForm.displayName = 'StoreForm';
}
export default StoreForm;

View file

@ -1,37 +0,0 @@
import React from 'react';
import { View, TouchableOpacity } from 'react-native';
import { MyText , tw } from "common-ui";
interface TabNavigationProps {
tabs: { key: string; title: string }[];
activeTab: string;
onTabChange: (tabKey: string) => void;
}
const TabNavigation: React.FC<TabNavigationProps> = ({ tabs, activeTab, onTabChange }) => {
return (
<View style={tw`flex-row bg-gray-100 rounded-xl p-1 mb-4`}>
{tabs.map((tab) => (
<TouchableOpacity
key={tab.key}
style={[
tw`flex-1 py-3 px-4 rounded-xl`,
activeTab === tab.key ? tw`bg-white shadow` : tw``
]}
onPress={() => onTabChange(tab.key)}
>
<MyText
style={[
tw`text-center font-medium`,
activeTab === tab.key ? tw`text-blue-600` : tw`text-gray-500`
]}
>
{tab.title}
</MyText>
</TouchableOpacity>
))}
</View>
);
};
export default TabNavigation;

View file

@ -1,109 +0,0 @@
import React, { useState } from 'react';
import { View, TouchableOpacity } from 'react-native';
import { MyText, tw, BottomDialog, MyTextInput } from 'common-ui';
import { trpc } from '@/src/trpc-client';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { Alert } from 'react-native';
interface UserIncidentDialogProps {
orderId: number;
userId: number;
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export default function UserIncidentDialog({ orderId, userId, open, onClose, onSuccess }: UserIncidentDialogProps) {
const [adminComment, setAdminComment] = useState('');
const [negativityScore, setNegativityScore] = useState('');
const addIncidentMutation = trpc.admin.user.addUserIncident.useMutation({
onSuccess: () => {
Alert.alert('Success', 'Incident added successfully');
setAdminComment('');
setNegativityScore('');
onClose();
onSuccess?.();
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to add incident');
},
});
const handleAddIncident = () => {
const score = negativityScore ? parseInt(negativityScore) : undefined;
if (!adminComment.trim() && !negativityScore) {
Alert.alert('Error', 'Please enter a comment or negativity score');
return;
}
addIncidentMutation.mutate({
userId,
orderId,
adminComment: adminComment || undefined,
negativityScore: score,
});
};
return (
<BottomDialog open={open} onClose={onClose}>
<View style={tw`p-6`}>
<View style={tw`items-center mb-6`}>
<View style={tw`w-12 h-12 bg-amber-100 rounded-full items-center justify-center mb-3`}>
<MaterialIcons name="warning" size={24} color="#D97706" />
</View>
<MyText style={tw`text-xl font-bold text-gray-900 text-center`}>
Add User Incident
</MyText>
<MyText style={tw`text-gray-500 text-center mt-2 text-sm leading-5`}>
Record an incident for this user. This will be visible in their profile.
</MyText>
</View>
<MyTextInput
topLabel="Admin Comment"
value={adminComment}
onChangeText={setAdminComment}
placeholder="Enter details about the incident..."
multiline
style={tw`h-24`}
/>
<MyTextInput
topLabel="Negativity Score (Optional)"
value={negativityScore}
onChangeText={setNegativityScore}
placeholder="0"
keyboardType="numeric"
style={tw`mt-4`}
/>
<View style={tw`bg-amber-50 p-4 rounded-xl border border-amber-100 mb-6 mt-4 flex-row items-start`}>
<MaterialIcons name="info-outline" size={20} color="#D97706" style={tw`mt-0.5`} />
<MyText style={tw`text-sm text-amber-800 ml-2 flex-1 leading-5`}>
Higher negativity scores indicate more serious incidents (e.g., repeated cancellations, abusive behavior).
</MyText>
</View>
<View style={tw`flex-row gap-3`}>
<TouchableOpacity
style={tw`flex-1 bg-gray-100 py-3.5 rounded-xl items-center`}
onPress={onClose}
>
<MyText style={tw`text-gray-700 font-bold`}>Cancel</MyText>
</TouchableOpacity>
<TouchableOpacity
style={tw`flex-1 bg-amber-500 py-3.5 rounded-xl items-center shadow-sm ${addIncidentMutation.isPending ? 'opacity-50' : ''}`}
onPress={handleAddIncident}
disabled={addIncidentMutation.isPending}
>
<MyText style={tw`text-white font-bold`}>
{addIncidentMutation.isPending ? 'Adding...' : 'Add Incident'}
</MyText>
</TouchableOpacity>
</View>
</View>
</BottomDialog>
);
}

View file

@ -1,3 +0,0 @@
import { AppContainer } from "common-ui";
export default AppContainer;

View file

@ -1,270 +0,0 @@
// import React, {
// createContext,
// useContext,
// useEffect,
// useState,
// ReactNode,
// } from "react";
// import {
// getJWT,
// deleteJWT,
// getRoles,
// saveJWT,
// saveRoles,
// saveUserId,
// getUserId,
// } from "../../hooks/useJWT";
// import { useFocusEffect, usePathname, useRouter } from "expo-router";
// import queryClient from "@/utils/queryClient";
// import { DeviceEventEmitter } from "react-native";
// import { FORCE_LOGOUT_EVENT, SESSION_EXPIRED_MSG } from "common-ui/src/lib/const-strs";
// import { useLogin, useLogout } from "@/api-hooks/auth.api";
// import {
// useUserResponsibilities,
// UserResponsibilities,
// } from "@/api-hooks/user.api";
// import { InfoToast, SuccessToast } from "@/services/toaster";
// import { useNotification } from "@/services/notif-service/notif-context";
// interface LoginFormInputs {
// login: string;
// password: string;
// useUsername?: boolean;
// expoPushToken?: string | null;
// }
// interface AuthContextType {
// isLoggedIn: boolean;
// setIsLoggedIn: (value: boolean) => void;
// logout: ({
// isSessionExpired,
// }: {
// isSessionExpired?: boolean;
// }) => Promise<void>;
// responsibilities: UserResponsibilities | null;
// responsibilitiesLoading: boolean;
// responsibilitiesError: Error | null;
// roles: string[] | null;
// setRoles: (roles: string[] | null) => void;
// refreshRoles: () => Promise<void>;
// loginFunc: (payload: LoginFormInputs) => Promise<void>;
// userId: number | null;
// isLoggingIn: boolean;
// loginError?: string;
// }
// const defaultResponsibilities: UserResponsibilities = {
// hospitalAdminFor: null,
// secretaryFor: [],
// };
// export const AuthContext = createContext<AuthContextType | undefined>(
// undefined
// );
// export const AuthProvider = ({ children }: { children: ReactNode }) => {
// const { mutate: loginApi, isPending: isLoggingIn, error: loginError } = useLogin();
// const [isLoggedIn, setIsLoggedIn] = useState(false);
// const [roles, setRoles] = useState<string[] | null>(null);
// const [userId, setUserId] = useState<number | null>(null);
// const refreshRoles = async () => {
// const r = await getRoles();
// setRoles(r);
// };
// useEffect(() => {
// refreshRoles();
// }, []);
// const { mutate: logoutApi } = useLogout();
// const router = useRouter();
// const [responsibilitiesError, setResponsibilitiesError] =
// useState<Error | null>(null);
// const {
// data: responsibilities,
// isLoading: responsibilitiesLoading,
// isFetching: responsibilitiesFetching,
// refetch: refetchResponsibilities,
// error: queryError,
// } = useUserResponsibilities(userId);
// React.useEffect(() => {
// (async () => {
// const token = await getJWT();
// setIsLoggedIn(!!token);
// if (!token) {
// if (!pathname.includes("login")) {
// router.replace("/login" as any);
// }
// } else {
// refetchResponsibilities();
// router.replace("/(drawer)/dashboard");
// const userId = await getUserId();
// setUserId(userId ? parseInt(userId) : null);
// }
// })();
// }, []);
// const pathname = usePathname();
// const logout = async ({
// isSessionExpired,
// }: {
// isSessionExpired?: boolean;
// }) => {
// const pageConditon =
// pathname.includes("/login") ||
// pathname.includes("/signup") ||
// pathname === "/";
// if (!isSessionExpired) {
// logoutApi({} as any, {
// onSuccess: () => {},
// onSettled: () => {
// if (!pageConditon) {
// router.replace({
// pathname: "/login" as any,
// params: isSessionExpired ? { message: SESSION_EXPIRED_MSG } : {},
// });
// }
// deleteJWT();
// },
// });
// setIsLoggedIn(false);
// } else {
// deleteJWT();
// setIsLoggedIn(false);
// InfoToast("Session expired. Please log in again.");
// if (!pageConditon) {
// router.replace({
// pathname: "/login" as any,
// params: { message: SESSION_EXPIRED_MSG },
// });
// }
// }
// queryClient.clear();
// };
// const loginFunc = async (data: LoginFormInputs) => {
// loginApi(data, {
// onSuccess: async (result) => {
// // refetchUserId();
// // await refetchUserData();
// await saveUserId(result.user.id.toString());
// setUserId(result.user.id);
// await saveJWT(result.token);
// // Update login state in auth context
// setIsLoggedIn(true);
// // Handle roles if available
// if (result.user.roles) {
// await saveRoles(result.user.roles);
// await refreshRoles();
// }
// await refetchResponsibilities();
// // Clear the 'message' search param from the URL after login
// router.replace({
// pathname: "/(drawer)/dashboard",
// params: {},
// });
// },
// onError: (e: any) => {
// // setError("login", {
// // type: "manual",
// // message: e.message || "Login failed",
// // });
// },
// });
// };
// React.useEffect(() => {
// const subscription = DeviceEventEmitter.addListener(
// FORCE_LOGOUT_EVENT,
// () => {
// logout({ isSessionExpired: true });
// }
// );
// return () => {
// subscription.remove();
// };
// }, []);
// return (
// <AuthContext.Provider
// value={{
// isLoggedIn,
// setIsLoggedIn,
// logout,
// responsibilities: responsibilities || defaultResponsibilities,
// responsibilitiesLoading,
// responsibilitiesError,
// roles,
// setRoles,
// refreshRoles,
// loginFunc,
// userId,
// isLoggingIn,
// loginError: loginError?.message
// }}
// >
// {children}
// </AuthContext.Provider>
// );
// };
// export const useAuth = () => {
// const context = useContext(AuthContext);
// if (!context) {
// throw new Error("useAuth must be used within an AuthProvider");
// }
// return context;
// };
// /**
// * Hook to check if the current user is a hospital admin
// * @param hospitalId Hospital ID to check against
// * @returns Boolean indicating if user is admin for that hospital
// */
// export const useIsHospitalAdmin = (hospitalId?: number | string): boolean => {
// const { responsibilities } = useAuth();
// // If no hospitalId provided, return false
// if (hospitalId === undefined) {
// return false;
// }
// // Check if user is admin for the specified hospital
// return responsibilities?.hospitalAdminFor === hospitalId;
// };
// /**
// * Hook to check if the current user is a secretary for a specific doctor
// * @param doctorId Doctor ID to check against
// * @returns Boolean indicating if user is a secretary for that doctor
// */
// export const useIsDoctorSecretary = (doctorId?: number): boolean => {
// const { responsibilities } = useAuth();
// // If no doctorId provided, return false
// if (doctorId === undefined) {
// return false;
// }
// // Check if user is a secretary for the specified doctor
// return responsibilities?.secretaryFor?.includes(doctorId) || false;
// };

View file

@ -1,43 +0,0 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { AuthContext } from "./auth-context";
import { ROLE_NAMES } from "common-ui";
// import { getRoles, saveRoles, deleteRoles } from "../../hooks/useJWT";
// import { ROLE_NAMES } from "../../lib/constants";
// interface RolesContextType {
// roles: string[] | null;
// setRoles: (roles: string[] | null) => void;
// refreshRoles: () => Promise<void>;
// }
// const RolesContext = createContext<RolesContextType | undefined>(undefined);
// export const RolesProvider = ({ children }: { children: ReactNode }) => {
// const [roles, setRoles] = useState<string[] | null>(null);
// const refreshRoles = async () => {
// const r = await getRoles();
// setRoles(r);
// };
// useEffect(() => {
// refreshRoles();
// }, []);
// return (
// <RolesContext.Provider value={{ roles, setRoles, refreshRoles }}>
// {children}
// </RolesContext.Provider>
// );
// };
export const useRoles = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useRoles must be used within a RolesProvider");
return ctx.roles;
};
export const useIsAdmin = () => {
const roles = useRoles();
return roles?.includes(ROLE_NAMES.ADMIN);
}

View file

@ -5,11 +5,9 @@ import { DeviceEventEmitter } from "react-native";
import { FORCE_LOGOUT_EVENT } from "common-ui/src/lib/const-strs";
import { trpc } from "@/src/trpc-client";
import { saveJWT, getJWT, deleteJWT } from "@/hooks/useJWT";
import type { IdName } from '@packages/shared';
interface Staff {
id: number;
name: string;
}
type Staff = IdName;
interface StaffAuthContextType {
isLoggedIn: boolean;

View file

@ -1,115 +0,0 @@
import React from 'react';
import { View, TouchableOpacity, Animated, Easing } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MyText , tw , colors } from "common-ui";
import { Ionicons } from '@expo/vector-icons';
import { useThemeColor } from '@/hooks/useThemeColor';
import { IconButton } from 'react-native-paper';
interface DashboardHeaderProps {
onMenuPress: () => void;
onNotificationsPress: () => void;
onProfilePress: () => void;
onRefreshPress?: () => void;
refreshing?: boolean;
}
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
onMenuPress,
onNotificationsPress,
onProfilePress,
onRefreshPress,
refreshing = false
}) => {
const accentColor = useThemeColor({ light: '#4f46e5', dark: '#818cf8' }, 'tint');
// For the refresh animation
const spinAnim = React.useRef(new Animated.Value(0)).current;
// Update animation when refreshing prop changes
React.useEffect(() => {
let animation: Animated.CompositeAnimation | null = null;
if (refreshing) {
animation = Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 1000,
easing: Easing.linear,
useNativeDriver: true,
})
);
animation.start();
} else {
spinAnim.stopAnimation();
spinAnim.setValue(0);
}
return () => {
if (animation) {
animation.stop();
}
};
}, [refreshing]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
return (
<SafeAreaView edges={['top']} style={tw`mb-2 bg-white dark:bg-gray-900`}>
<View style={tw`flex-row justify-between items-center px-4 py-2`}>
{/* Menu Button */}
<TouchableOpacity
style={tw`w-10 h-10 rounded-full bg-white dark:bg-gray-800 items-center justify-center shadow-sm`}
onPress={onMenuPress}
>
<Ionicons name="menu" size={24} color={accentColor} />
</TouchableOpacity>
{/* Logo/App Name */}
<MyText style={tw`text-xl font-bold text-gray-800 dark:text-white`}>
HealthPetal
</MyText>
{/* Right Actions */}
<View style={tw`flex-row`}>
{/* Refresh Button */}
{onRefreshPress && (
<Animated.View style={{ transform: [{ rotate: spin }], marginRight: 8 }}>
<IconButton
icon="refresh"
size={20}
onPress={onRefreshPress}
accessibilityLabel="Refresh"
disabled={refreshing}
iconColor={colors.blue1}
/>
</Animated.View>
)}
{/* Notifications */}
<TouchableOpacity
style={tw`w-10 h-10 rounded-full bg-white dark:bg-gray-800 items-center justify-center shadow-sm mr-2 relative`}
onPress={onNotificationsPress}
>
<Ionicons name="notifications" size={20} color="#6b7280" />
{/* Notification Badge */}
<View style={tw`absolute top-1 right-1 w-3 h-3 bg-red-500 rounded-full`}></View>
</TouchableOpacity>
{/* Profile */}
<TouchableOpacity
style={tw`w-10 h-10 rounded-full bg-white dark:bg-gray-800 items-center justify-center shadow-sm`}
onPress={onProfilePress}
>
<Ionicons name="person" size={20} color="#6b7280" />
</TouchableOpacity>
</View>
</View>
</SafeAreaView>
);
};
export default DashboardHeader;

View file

@ -1,278 +0,0 @@
// import { useTheme } from "@/hooks/theme-context";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import DateTimePicker, {
AndroidNativeProps,
DateTimePickerAndroid,
DateTimePickerEvent,
} from "@react-native-community/datetimepicker";
import React, { useState } from "react";
import {
Modal,
Platform,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useTheme, MyText } from "common-ui";
interface Props {
value: Date | null;
setValue: (date: Date | null) => void;
showLabels?: boolean; // Optional prop to control label visibility
timeOnly?: boolean; // Optional prop to show only time picker
}
type Mode = "date" | "time" | "datetime";
function DateTimePickerMod(props: Props) {
const { value, setValue, showLabels = true, timeOnly = false } = props;
const [show, setShow] = useState<boolean>(false);
const [mode, setMode] = useState<Mode>("date");
const onChange = (event: DateTimePickerEvent, selectedDate?: Date) => {
const currentDate = selectedDate || value;
if (Platform.OS === "ios") setShow(false);
setValue(currentDate);
};
const showMode = (currentMode: Mode) => {
if (Platform.OS === "android") {
DateTimePickerAndroid.open({
value: value || new Date(),
onChange: onChange,
mode: currentMode,
is24Hour: true,
display: "default",
} as AndroidNativeProps);
} else {
setShow(true);
setMode(currentMode);
}
};
const showDatepicker = () => {
showMode("date");
};
const showTimepicker = () => {
showMode("time");
};
const { theme } = useTheme();
return (
<View style={styles.container}>
{timeOnly ? (
<TouchableOpacity onPress={showTimepicker}>
{showLabels && <MyText>Select Time</MyText>}
<View
style={{
borderColor: theme.colors.gray1,
borderWidth: 0.5,
borderRadius: 4,
paddingVertical: 4,
paddingHorizontal: 4,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 4,
}}
>
<Text style={[styles.timeText, { opacity: value ? 1 : 0.5 }]}>
{value?.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}) || "Select Time"}
</Text>
<MaterialCommunityIcons
name="clock"
size={24}
color={theme.colors.gray1}
/>
</View>
</TouchableOpacity>
) : (
<View
style={{ width: "100%", flexDirection: "row", alignItems: "stretch" }}
>
<TouchableOpacity
onPress={showDatepicker}
style={{
width: "50%",
}}
>
{showLabels && <MyText>Select Date</MyText>}
<View
style={{
flexDirection: "row",
alignItems: "center",
borderColor: theme.colors.gray1,
borderWidth: 0.5,
borderRadius: 4,
paddingVertical: 4,
paddingHorizontal: 4,
justifyContent: "space-between",
}}
>
<Text style={[styles.dateText, { opacity: value ? 1 : 0.5 }]}>{value?.toLocaleDateString() || "Select Date"}</Text>
<MaterialCommunityIcons
name="calendar"
size={24}
color={theme.colors.gray1}
/>
</View>
</TouchableOpacity>
<View style={styles.spacerHorizontalSmall} />
<TouchableOpacity
onPress={showTimepicker}
style={{
width: "50%",
}}
>
{showLabels && <MyText>Select Time</MyText>}
<View
style={{
borderColor: theme.colors.gray1,
borderWidth: 0.5,
borderRadius: 4,
paddingVertical: 4,
paddingHorizontal: 4,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 4,
}}
>
<Text style={[styles.timeText, { opacity: value ? 1 : 0.5 }]}>
{value?.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}) || "Select Time"}
</Text>
<MaterialCommunityIcons
name="clock"
size={24}
color={theme.colors.gray1}
/>
</View>
</TouchableOpacity>
</View>
)}
{/* Conditional rendering for iOS, as it uses the declarative API */}
{show && Platform.OS === "ios" && (
<Modal
transparent
animationType="fade"
visible={show}
onRequestClose={() => setShow(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.pickerContainer}>
<DateTimePicker
testID="dateTimePicker"
value={value || new Date()}
mode={mode}
is24Hour={true}
display="default"
onChange={onChange}
/>
<TouchableOpacity
onPress={() => setShow(false)}
style={styles.doneButton}
>
<Text style={styles.doneButtonText}>Done</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
)}
</View>
);
}
export default DateTimePickerMod;
const styles = StyleSheet.create({
container: {
// flex: 1, // Remove flex to avoid taking up extra space
justifyContent: "center",
alignItems: "flex-start",
padding: 0, // Reduce padding for compactness
marginBottom: 16, // Add margin for spacing in forms
},
iconRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginBottom: 20,
},
iconRowSingleLine: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginBottom: 0,
},
iconNoBg: {
backgroundColor: "transparent",
borderRadius: 0,
padding: 0,
elevation: 0,
},
spacerHorizontal: {
width: 30,
},
spacerHorizontalSmall: {
width: 8,
},
spacer: {
height: 20, // Add some space between buttons
},
selectedText: {
marginTop: 30,
fontSize: 18,
fontWeight: "bold",
textAlign: "center",
},
timeTextContainer: {
justifyContent: "center",
alignItems: "center",
},
timeText: {
fontSize: 15,
fontWeight: "500",
color: "#333",
marginLeft: 2,
},
dateText: {
fontSize: 15,
fontWeight: "500",
color: "#333",
marginLeft: 2,
marginRight: 2,
},
modalOverlay: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
pickerContainer: {
width: "80%",
backgroundColor: "white",
borderRadius: 10,
padding: 20,
elevation: 5,
},
doneButton: {
marginTop: 10,
backgroundColor: "#007bff",
borderRadius: 5,
padding: 10,
alignItems: "center",
},
doneButtonText: {
color: "white",
fontWeight: "bold",
},
});

View file

@ -1,77 +0,0 @@
import React from 'react';
import { View } from 'react-native';
import { MyText , tw } from "common-ui";
import { Ionicons } from '@expo/vector-icons';
// Define the types
export interface DoctorWiseCount {
doctorName: string;
doctorId: number;
fee: number;
issuedTokens: number;
totalAmount: number;
}
export interface DayAccountData {
doctorWiseCount: DoctorWiseCount[];
date: string;
totalAmount: number;
settled: boolean;
}
interface DayAccountViewProps {
dayData: DayAccountData;
}
const DayAccountView: React.FC<DayAccountViewProps> = ({ dayData }) => {
return (
<View
style={tw`mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl shadow border border-gray-200 dark:border-gray-700`}
>
<View style={tw`flex-row justify-between items-center mb-3`}>
<MyText style={tw`text-lg font-bold text-gray-800 dark:text-white`}>
{dayData.date}
</MyText>
<View style={tw`flex-row items-center`}>
<MyText style={tw`text-lg font-bold text-gray-800 dark:text-white mr-2`}>
{dayData.totalAmount}
</MyText>
{dayData.settled ? (
<View style={tw`flex-row items-center bg-green-100 dark:bg-green-900/30 px-2 py-1 rounded-full`}>
<Ionicons name="checkmark-circle" size={16} color="#16a34a" />
<MyText style={tw`text-green-700 dark:text-green-400 ml-1 text-xs`}>Settled</MyText>
</View>
) : (
<View style={tw`flex-row items-center bg-yellow-100 dark:bg-yellow-900/30 px-2 py-1 rounded-full`}>
<Ionicons name="alert-circle" size={16} color="#f59e0b" />
<MyText style={tw`text-yellow-700 dark:text-yellow-400 ml-1 text-xs`}>Pending</MyText>
</View>
)}
</View>
</View>
<View style={tw`mt-3`}>
{dayData.doctorWiseCount.map((doctor, idx) => (
<View
key={idx}
style={tw`flex-row justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg mb-2`}
>
<View>
<MyText style={tw`font-medium text-gray-800 dark:text-white`}>
{doctor.doctorName}
</MyText>
<MyText style={tw`text-xs text-gray-600 dark:text-gray-400`}>
{doctor.fee} × {doctor.issuedTokens} tokens
</MyText>
</View>
<MyText style={tw`font-bold text-gray-800 dark:text-white`}>
{doctor.totalAmount}
</MyText>
</View>
))}
</View>
</View>
);
};
export default DayAccountView;

View file

@ -1,32 +0,0 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}

View file

@ -1,41 +0,0 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}

View file

@ -1,19 +0,0 @@
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { BlurView } from 'expo-blur';
import { StyleSheet } from 'react-native';
export default function BlurTabBarBackground() {
return (
<BlurView
// System chrome material automatically adapts to the system's theme
// and matches the native tab bar appearance on iOS.
tint="systemChromeMaterial"
intensity={100}
style={StyleSheet.absoluteFill}
/>
);
}
export function useBottomTabOverflow() {
return useBottomTabBarHeight();
}

View file

@ -1,6 +0,0 @@
// This is a shim for web and Android where the tab bar is generally opaque.
export default undefined;
export function useBottomTabOverflow() {
return 0;
}

View file

@ -1,26 +0,0 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
const tintColorLight = '#0a7ea4';
const tintColorDark = '#fff';
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
tabIconSelected: tintColorLight,
},
dark: {
text: '#ECEDEE',
background: '#151718',
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark,
},
};

View file

@ -1,12 +0,0 @@
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
rootDir: '..',
testMatch: ['<rootDir>/e2e/**/*.test.js'],
testTimeout: 120000,
maxWorkers: 1,
globalSetup: 'detox/runners/jest/globalSetup',
globalTeardown: 'detox/runners/jest/globalTeardown',
reporters: ['detox/runners/jest/reporter'],
testEnvironment: 'detox/runners/jest/testEnvironment',
verbose: true,
};

View file

@ -1,23 +0,0 @@
describe('Example', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('should have welcome screen', async () => {
await expect(element(by.id('welcome'))).toBeVisible();
});
it('should show hello screen after tap', async () => {
await element(by.id('hello_button')).tap();
await expect(element(by.text('Hello!!!'))).toBeVisible();
});
it('should show world screen after tap', async () => {
await element(by.id('world_button')).tap();
await expect(element(by.text('World!!!'))).toBeVisible();
});
});

View file

@ -12,6 +12,10 @@
"distribution": "internal",
"channel": "dev"
},
"shafi-dev": {
"distribution": "internal",
"channel": "shafi-dev"
},
"preview": {
"distribution": "internal",
"channel": "preview"

View file

@ -1 +0,0 @@
export { useColorScheme } from 'react-native';

View file

@ -1,21 +0,0 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}

View file

@ -1,13 +0,0 @@
import React, { useEffect, useState } from 'react';
import { getCurrentUserId } from '@/utils/getCurrentUserId';
export function useCurrentUserId(): {userId:number|null, refetchUserId: () => void} {
const [userId, setUserId] = useState<number | null>(null);
const refetchUserId = React.useCallback(() => {
getCurrentUserId().then(setUserId);
},[])
useEffect(() => {
getCurrentUserId().then(setUserId);
}, []);
return {userId,refetchUserId};
}

View file

@ -1,26 +0,0 @@
import { useFocusEffect, useNavigation } from "expo-router";
import React from "react";
function useHideDrawerHeader() {
const navigation = useNavigation();
useFocusEffect(() => {
let drawerNav = navigation.getParent();
const drawerNavList:any = [];
// Collect all parent navigators
while (drawerNav) {
drawerNavList.push(drawerNav);
drawerNav = drawerNav.getParent();
}
drawerNavList.at(-3)?.setOptions({ headerShown: false });
return () => {
drawerNavList.at(-3)?.setOptions({ headerShown: true });
};
});
return null;
}
export default useHideDrawerHeader;

View file

@ -1,49 +1,15 @@
// import { StorageService } from '@/lib/StorageService';
import {StorageService} from 'common-ui';
import { StorageService } from 'common-ui'
export const JWT_KEY = 'jwt_token';
export const ROLES_KEY = 'user_roles';
export const USER_ID_KEY = 'userId';
export async function saveUserId(userId:string) {
await StorageService.setItem(USER_ID_KEY, userId);
}
export async function getUserId() {
return await StorageService.getItem(USER_ID_KEY);
}
export const JWT_KEY = 'jwt_token'
export async function saveJWT(token: string) {
await StorageService.setItem(JWT_KEY, token);
await StorageService.setItem(JWT_KEY, token)
}
export async function getJWT() {
return await StorageService.getItem(JWT_KEY);
return await StorageService.getItem(JWT_KEY)
}
export async function deleteJWT() {
await StorageService.removeItem(JWT_KEY);
}
export async function saveRoles(roles: string[]) {
await StorageService.setItem(ROLES_KEY, JSON.stringify(roles));
}
export async function getRoles(): Promise<string[] | null> {
const jwt = await getJWT();
if (!jwt) {
StorageService.removeItem(ROLES_KEY);
return null;
}
const rolesStr = await StorageService.getItem(ROLES_KEY);
if (!rolesStr) return null;
try {
return JSON.parse(rolesStr);
} catch {
return null;
}
}
export async function deleteRoles() {
await StorageService.removeItem(ROLES_KEY);
await StorageService.removeItem(JWT_KEY)
}

View file

@ -1,31 +0,0 @@
import { usePhonepeCreds } from '@/api-hooks/payment.api';
import { useEffect } from 'react';
import PhonePePaymentSDK from 'react-native-phonepe-pg';
export function usePhonepeSdk() {
const { data: creds, isLoading, isError } = usePhonepeCreds();
useEffect(() => {
if (creds && creds.clientId && creds.clientVersion) {
PhonePePaymentSDK.init('SANDBOX', creds.clientId, creds.clientId, true);
}
}, [creds]);
const startTransaction = async (orderId: string, token: string) => {
try {
const request = {
orderId,
token,
merchantId: creds?.merchantId,
paymentMode: { type: 'PAY_PAGE' }
};
const stringReq = JSON.stringify(request);
const response = await PhonePePaymentSDK.startTransaction(stringReq, null);
return response;
} catch (error) {
throw error;
}
};
return { startTransaction, isLoading, isError, creds };
}

View file

@ -1,21 +0,0 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}

View file

@ -1,22 +1,6 @@
import { useState } from 'react';
import { trpc } from '../src/trpc-client';
type ContextString = 'review' | 'product_info' | 'notification' | 'store' | 'complaint' | 'profile' | 'tags';
interface UploadInput {
blob: Blob;
mimeType: string;
}
interface UploadBatchInput {
images: UploadInput[];
contextString: ContextString;
}
interface UploadResult {
keys: string[];
presignedUrls: string[];
}
import type { ContextString, UploadInput, UploadBatchInput, UploadResult } from '@packages/shared';
export function useUploadToObjectStorage() {
const [isUploading, setIsUploading] = useState(false);

View file

@ -1,112 +0,0 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
const exampleDir = "app-example";
const newAppDir = "app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);

View file

@ -1,59 +0,0 @@
import axiosParent from 'axios';
import { FORCE_LOGOUT_EVENT } from 'common-ui/src/lib/const-strs';
import { DeviceEventEmitter } from 'react-native'
import { getJWT } from '@/hooks/useJWT';
import { BASE_API_URL } from 'common-ui';
// export const API_BASE_URL = 'http://192.168.100.95:4000'; // Change to your API base URL
// const API_BASE_URL = 'https://www.technocracy.ovh/mf'; // Change to your API base URL
// const API_BASE_URL = 'http://10.195.26.42:4000'; // Change to your API base URL
// const API_BASE_URL = 'http://localhost:4000/api/mobile/'; // Change to your API base URL
// const API_BASE_URL = 'https://car-safar.com/api/mobile/'; // Change to your API base URL
const axios = axiosParent.create({
baseURL: BASE_API_URL + '/api/v1',
timeout: 60000,
// headers: {
// 'Content-Type': 'application/json',
// },
});
axios.interceptors.request.use(
async (config) => {
const token = await getJWT();
if (token) {
config.headers = config.headers || {};
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
axios.interceptors.response.use(
(response) => response,
(error) => {
const status = error?.status;
const msg = error.response?.data?.error;
if (status === 401 && msg.startsWith('Access denied')) {
// Handle unauthorized access
DeviceEventEmitter.emit(FORCE_LOGOUT_EVENT);
}
const message = error?.response?.data?.error;
if (msg) {
// Optionally, you can attach the message to the error object or throw a new error
const err = new Error(msg);
// Optionally attach the original error for debugging
(err as any).original = error;
return Promise.reject(err);
}
return Promise.reject(error);
}
);
export default axios;

View file

@ -1,71 +0,0 @@
import {useAddPushToken, useHasPushToken } from "@/api-hooks/user.api";
import React from "react";
import { useNotification } from "./notif-context";
import { BottomDialog } from "common-ui";
import { MyText } from "common-ui";
import { View, Linking } from "react-native";
import { tw } from "common-ui";
import { MyButton } from "common-ui";
import { useAuth } from "@/components/context/auth-context";
interface Props {}
function NotifChecker(props: Props) {
const {} = props;
const [showPermissionDialog, setShowPermissionDialog] = React.useState(false);
const {isLoggedIn} = useAuth();
const { data: hasPushToken, isLoading, isError } = useHasPushToken({enabled: isLoggedIn});
const { mutate: addPushToken } = useAddPushToken();
const { notifPermission, expoPushToken } = useNotification();
React.useEffect(() => {
if(isLoggedIn && !hasPushToken && notifPermission =='granted') {
addPushToken(expoPushToken!);
}
},[isLoggedIn, hasPushToken])
React.useEffect(() => {
if (notifPermission === "denied") {
setShowPermissionDialog(true);
}
}, [notifPermission]);
return (
<>
<BottomDialog
open={showPermissionDialog}
onClose={() => setShowPermissionDialog(false)}
>
<View style={tw`flex flex-col h-64 p-4`}>
<MyText weight="semibold" color="red1" style={tw`mb-2 text-lg`}>
Notification Permission Denied
</MyText>
<MyText>
It seems you have denied notification permissions. Please enable
them in your device settings.
</MyText>
<View style={tw`flex flex-row gap-3 mt-auto justify-center`}>
<MyButton
fillColor="red1"
onPress={() => setShowPermissionDialog(false)}
style={tw`flex-1`}
>
Cancel
</MyButton>
<MyButton
fillColor="blue1"
onPress={() => {
Linking.openSettings();
}}
style={tw`flex-1`}
>
Settings
</MyButton>
</View>
</View>
</BottomDialog>
</>
);
}
export default NotifChecker;

View file

@ -1,111 +0,0 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useRef,
ReactNode,
} from "react";
import * as Notifications from "expo-notifications";
import { registerForPushNotificationsAsync } from "./notif-register";
import { useRouter } from "expo-router";
import { NotificationToast } from "../toaster";
import { NOTIF_PERMISSION_DENIED } from "common-ui/src/lib/const-strs";
interface NotificationContextType {
expoPushToken: string | null;
notification: Notifications.Notification | null;
error: Error | null;
notifPermission: 'pending' | 'granted' | 'denied'
}
export const NotificationContext = createContext<
NotificationContextType | undefined
>(undefined);
export const useNotification = () => {
const context = useContext(NotificationContext);
if (context === undefined) {
throw new Error(
"useNotification must be used within a NotificationProvider"
);
}
return context;
};
interface NotificationProviderProps {
children: ReactNode;
}
export const NotificationProvider: React.FC<NotificationProviderProps> = ({
children,
}) => {
const [expoPushToken, setExpoPushToken] = useState<string | null>(null);
const [notification, setNotification] =
useState<Notifications.Notification | null>(null);
const [error, setError] = useState<Error | null>(null);
const [notifPermission, setNotifPermission] = React.useState<NotificationContextType["notifPermission"]>("pending");
const notificationListener = useRef<any>(null);
const responseListener = useRef<any>(null);
const router = useRouter();
useEffect(() => {
registerForPushNotificationsAsync()
.then((token) => {
setExpoPushToken(token);
setNotifPermission("granted");
})
.catch((errorRaw) => {
const err = String(errorRaw).slice(7); //remove the "Error: " string component in beginning
if (err === NOTIF_PERMISSION_DENIED) {
setNotifPermission("denied");
}
});
notificationListener.current =
Notifications.addNotificationReceivedListener((notification) => {
setNotification(notification);
// Show a visible toast when app is in foreground
const content = notification.request?.content;
if (content) {
NotificationToast(
content.title || "Notification",
content.body || "",
content.data || {}
);
}
});
responseListener.current =
Notifications.addNotificationResponseReceivedListener((response) => {
const data = response.notification.request.content.data;
if (data && data.doctorId) {
router.navigate(`/(drawer)/dashboard`);
} else if (data && data.tokenId) {
router.navigate(`/(drawer)/dashboard`);
}
});
return () => {
if (notificationListener.current) {
Notifications.removeNotificationSubscription(
notificationListener.current
);
}
if (responseListener.current) {
Notifications.removeNotificationSubscription(responseListener.current);
}
};
}, []);
return (
<NotificationContext.Provider
value={{ expoPushToken, notification, error, notifPermission }}
>
{children}
</NotificationContext.Provider>
);
};

View file

@ -1,51 +0,0 @@
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import Constants from "expo-constants";
import { Platform } from "react-native";
import { NOTIF_PERMISSION_DENIED } from "common-ui/src/lib/const-strs";
export async function registerForPushNotificationsAsync() {
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
});
}
if (Device.isDevice) {
const { status: existingStatus } =
await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== "granted") {
throw new Error(
NOTIF_PERMISSION_DENIED
);
}
const projectId =
Constants?.expoConfig?.extra?.eas?.projectId ??
Constants?.easConfig?.projectId;
if (!projectId) {
throw new Error("Project ID not found");
}
try {
const pushTokenString = (
await Notifications.getExpoPushTokenAsync({
projectId,
})
).data;
console.log(pushTokenString);
return pushTokenString;
} catch (e: unknown) {
throw new Error(`${e}`);
}
} else {
throw new Error("Must use physical device for push notifications");
}
}

View file

@ -1,26 +0,0 @@
// Types
export interface Banner {
id: number;
name: string;
imageUrl: string;
description?: string;
skuIds?: number[];
redirectUrl?: string;
serialNum: number;
isActive: boolean;
createdAt: string;
lastUpdated: string;
}
export interface CreateBannerPayload {
name: string;
imageUrl: string;
description?: string;
skuIds?: number[];
redirectUrl?: string;
serialNum: number;
}
export interface UpdateBannerPayload extends Partial<CreateBannerPayload> {
isActive?: boolean;
}

View file

@ -42,6 +42,7 @@ const couponValidationSchema = Yup.object().shape({
validTill: Yup.date().optional(),
maxLimitForUser: Yup.number().min(1, 'Must be at least 1').optional(),
exclusiveApply: Yup.boolean().optional(),
isFirstOrderOnly: Yup.boolean().optional(),
isUserBased: Yup.boolean(),
isApplyForAll: Yup.boolean(),
applicableUsers: Yup.array().of(Yup.number()).optional(),
@ -133,6 +134,7 @@ export default function CouponForm({ onSubmit, isLoading, initialValues }: Coupo
applicableUsers: [],
applicableProducts: [],
exclusiveApply: false,
isFirstOrderOnly: false,
isReservedCoupon: false,
};
@ -150,7 +152,6 @@ export default function CouponForm({ onSubmit, isLoading, initialValues }: Coupo
const newSelection = current.includes(userId)
? current.filter(id => id !== userId)
: [...current, userId];
console.log('Toggling user:', userId, 'New selection:', newSelection);
setFieldValue('applicableUsers', newSelection);
};
@ -379,6 +380,20 @@ export default function CouponForm({ onSubmit, isLoading, initialValues }: Coupo
</TouchableOpacity>
</View>
{/* First Order Only */}
<View style={tw`mb-4`}>
<Text style={tw`text-base mb-2`}>First Order Only</Text>
<TouchableOpacity
onPress={() => setFieldValue('isFirstOrderOnly', !values.isFirstOrderOnly)}
style={tw`flex-row items-center`}
>
<View style={tw`w-5 h-5 border-2 border-gray-300 rounded mr-3 ${values.isFirstOrderOnly ? 'bg-blue-500 border-blue-500' : ''}`}>
{values.isFirstOrderOnly && <Text style={tw`text-white text-center`}></Text>}
</View>
<Text style={tw`text-gray-700`}>Valid only on the customer&apos;s first order</Text>
</TouchableOpacity>
</View>
{/* Target Audience */}
<Text style={tw`text-base font-bold mb-2 ${isReserved ? 'text-gray-400' : ''}`}>
Target Audience {isReserved ? '(Disabled for Reserved Coupons)' : ''}

View file

@ -5,25 +5,29 @@ import * as Yup from 'yup'
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload, Checkbox, InfoDialog } from 'common-ui'
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
import { trpc } from '../trpc-client'
import type { CreateComboItemInput, CreateSkuInput } from '@packages/shared'
import type { SkuFeatureLike } from '@packages/shared'
interface Attribute {
featureName: string | null
featureValue: string
}
// Feature input shape — single source: shared SkuFeatureLike
type Attribute = SkuFeatureLike
interface Variant {
// Variant form state — anchored to the shared CreateSkuInput (numeric prices,
// same field names). Images are managed separately via variantImages state.
type Variant = Omit<
CreateSkuInput,
'name' | 'price' | 'marketPrice' | 'flashPrice' | 'images' | 'features' | 'comboItems'
> & {
id?: number
name: string
price: string
marketPrice: string
price?: number
marketPrice: number | null
flashPrice: number | null
isFlashAvailable: boolean
flashPrice: string
isOffer: boolean
isComboOnly: boolean
isDeleted?: boolean
isSuspended: boolean
attributes: Attribute[]
comboItems: { skuId: number | string }[]
features: Attribute[]
comboItems: CreateComboItemInput[]
}
interface ProductFormData {
@ -59,15 +63,15 @@ const isQuantityFeature = (attr: Attribute): boolean =>
const defaultVariant = (): Variant => ({
id: undefined,
name: '',
price: '',
marketPrice: '',
price: undefined,
marketPrice: null,
isFlashAvailable: false,
flashPrice: '',
flashPrice: null,
isOffer: false,
isComboOnly: false,
isDeleted: false,
isSuspended: false,
attributes: [quantityAttribute()],
features: [quantityAttribute()],
comboItems: [],
})
@ -101,7 +105,7 @@ const productValidationSchema = Yup.object().shape({
.nullable()
.transform((value, originalValue) => (originalValue === '' ? null : value))
.optional(),
attributes: Yup.array()
features: Yup.array()
.min(1, 'At least one attribute is required')
.of(
Yup.object().shape({
@ -114,7 +118,7 @@ const productValidationSchema = Yup.object().shape({
if (!Array.isArray(variants)) return true
const seen = new Set<string>()
for (const variant of variants) {
const attrs = variant?.attributes || []
const attrs = variant?.features || []
const signature = variantSignature(attrs as Attribute[])
if (seen.has(signature)) {
return this.createError({ message: 'Two variants have the same attributes' })
@ -126,7 +130,7 @@ const productValidationSchema = Yup.object().shape({
.test('quantity-feature', 'Each SKU must have exactly one quantity feature', function (variants) {
if (!Array.isArray(variants)) return true
for (const variant of variants) {
const attrs = variant?.attributes || []
const attrs = variant?.features || []
const quantityCount = attrs.filter(
(a) => ((a as Attribute)?.featureName ?? '').trim().toLowerCase() === 'quantity'
).length
@ -178,10 +182,10 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
value: store.id,
})) || []
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery({})
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery()
const skuOptions = (skusData?.skus || []).map((sku) => ({
label: sku.label,
value: sku.id.toString(),
value: sku.id,
}))
// Make sure every SKU starts with a 'quantity' feature (auto-added at the
@ -190,11 +194,11 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
return {
...initialValues,
variants: (initialValues.variants || []).map((v) => {
const hasQuantity = (v.attributes || []).some(
const hasQuantity = (v.features || []).some(
(a) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
)
if (hasQuantity) return v
return { ...v, attributes: [quantityAttribute(), ...(v.attributes || [])] }
return { ...v, features: [quantityAttribute(), ...(v.features || [])] }
}),
}
}, [initialValues])
@ -208,11 +212,21 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
// 'quantity' feature must always go out lowercase (case-insensitive
// matching means 'Quantity'/'QUANTITY' are treated the same), and
// names are trimmed like the backend does.
// TextInputs emit strings — coerce price fields to the numeric types
// the backend zod schema requires (price required; market/flash nullable).
const toNumber = (value: unknown): number | null => {
if (value == null || value === '') return null
const n = Number(value)
return Number.isFinite(n) ? n : null
}
const normalizedValues: ProductFormData = {
...values,
variants: values.variants.map((v) => ({
...v,
attributes: v.attributes.map((a) => ({
price: toNumber(v.price) ?? undefined,
marketPrice: toNumber(v.marketPrice),
flashPrice: toNumber(v.flashPrice),
features: v.features.map((a) => ({
...a,
featureName: isQuantityFeature(a) ? 'quantity' : (a.featureName ?? '').trim() || null,
})),
@ -348,7 +362,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
style={{ marginBottom: 12 }}
/>
<FieldArray name={`variants.${vIndex}.attributes`}>
<FieldArray name={`variants.${vIndex}.features`}>
{({ push: pushAttr, remove: removeAttr }) => (
<View style={tw`mb-3`}>
<View style={tw`flex-row justify-between items-center mb-2`}>
@ -361,12 +375,12 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<MyText style={tw`text-gray-600 text-xs ml-0.5`}>Add</MyText>
</TouchableOpacity>
</View>
{variant.attributes.map((attr, aIndex) => {
const quantityCount = variant.attributes.filter(isQuantityFeature).length
{variant.features.map((attr, aIndex) => {
const quantityCount = variant.features.filter(isQuantityFeature).length
// Quantity features are locked (non-editable), but if there
// are duplicates (e.g. 'quantity' + 'Quantity') the user must
// be able to delete the extras to fix the form.
const canDelete = variant.attributes.length > 1 && (!isQuantityFeature(attr) || quantityCount > 1)
const canDelete = variant.features.length > 1 && (!isQuantityFeature(attr) || quantityCount > 1)
return (
<View key={aIndex} style={tw`flex-row items-center mb-2 gap-2`}>
<View style={tw`flex-1`}>
@ -375,7 +389,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
value={attr.featureName ?? ''}
onChangeText={(text) =>
setFieldValue(
`variants.${vIndex}.attributes.${aIndex}.featureName`,
`variants.${vIndex}.features.${aIndex}.featureName`,
text.trim().toLowerCase() === 'quantity' ? 'quantity' : text
)
}
@ -387,7 +401,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<MyTextInput
placeholder={isQuantityFeature(attr) ? 'e.g. 0.5 kg' : 'Value'}
value={attr.featureValue}
onChangeText={handleChange(`variants.${vIndex}.attributes.${aIndex}.featureValue`)}
onChangeText={handleChange(`variants.${vIndex}.features.${aIndex}.featureValue`)}
/>
</View>
{canDelete && (
@ -421,7 +435,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
topLabel="Market Price"
placeholder="MRP"
keyboardType="numeric"
value={variant.marketPrice}
value={variant.marketPrice?.toString() ?? ''}
onChangeText={handleChange(`variants.${vIndex}.marketPrice`)}
/>
</View>
@ -430,7 +444,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
topLabel="Our Price"
placeholder="Selling price"
keyboardType="numeric"
value={variant.price}
value={variant.price?.toString() ?? ''}
onChangeText={handleChange(`variants.${vIndex}.price`)}
/>
</View>
@ -441,7 +455,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
checked={variant.isFlashAvailable}
onPress={() => {
setFieldValue(`variants.${vIndex}.isFlashAvailable`, !variant.isFlashAvailable)
if (variant.isFlashAvailable) setFieldValue(`variants.${vIndex}.flashPrice`, '')
if (variant.isFlashAvailable) setFieldValue(`variants.${vIndex}.flashPrice`, null)
}}
style={tw`mr-3`}
/>
@ -484,7 +498,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
topLabel="Flash Price"
placeholder="Enter flash price"
keyboardType="numeric"
value={variant.flashPrice}
value={variant.flashPrice?.toString() ?? ''}
onChangeText={handleChange(`variants.${vIndex}.flashPrice`)}
style={{ marginBottom: 12 }}
/>
@ -516,7 +530,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<TouchableOpacity
onPress={() => {
const items = variant.comboItems || []
items.push({ skuId: '' })
items.push({ skuId: 0 })
setFieldValue(`variants.${vIndex}.comboItems`, items)
}}
style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`}
@ -531,7 +545,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<BottomDropdown
label="SKU"
value={ci.skuId}
options={skuOptions.map((opt: { label: string; value: string }) => ({
options={skuOptions.map((opt: { label: string; value: number }) => ({
...opt,
// Disable SKUs already picked in another row of this combo.
disabled: (variant.comboItems || []).some(
@ -547,7 +561,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
Alert.alert('Duplicate', 'This product is already in the combo')
return
}
setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, val)
setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, Number(val))
}}
placeholder="Select SKU"
/>

View file

@ -9,13 +9,11 @@ import DraggableFlatList, { ScaleDecorator } from 'react-native-draggable-flatli
import { Image } from 'expo-image';
import ProductsSelector from '@/components/ProductsSelector';
import { trpc } from '@/src/trpc-client';
import type { IdName } from '@packages/shared';
interface StoreOption {
id: number;
name: string;
}
type StoreOption = IdName;
interface TagFormData {
export interface TagFormData {
tagName: string;
tagDescription: string;
isDashboardTag: boolean;

View file

@ -0,0 +1,6 @@
// Common props for draggable reorder rows (customize-app / product-tags order screens)
export interface DragOrderItemProps<T> {
item: T
drag: () => void
isActive: boolean
}

View file

@ -1,7 +1,7 @@
export interface VendorSnippetProduct {
id: number;
name: string;
}
import type { IdName } from '@packages/shared';
import type { AdminVendorSnippetInput } from '@packages/shared'
export type VendorSnippetProduct = IdName;
export interface VendorSnippet {
id: number;
@ -23,12 +23,10 @@ export interface VendorSnippet {
} | null;
}
export interface VendorSnippetForm {
// Vendor-snippet form row — anchored to the shared input (wire validTill);
// form requires slotId and carries the serialized createdAt.
export type VendorSnippetForm = Omit<AdminVendorSnippetInput, 'slotId'> & {
id: number;
snippetCode: string;
slotId: number;
isPermanent: boolean;
skuIds: number[];
validTill: string | null;
createdAt: string;
}

View file

@ -1,14 +0,0 @@
import {jwtDecode} from 'jwt-decode';
import { getJWT } from '@/hooks/useJWT';
export async function getCurrentUserId(): Promise<number | null> {
const token = await getJWT();
if (!token) return null;
try {
const decoded: any = jwtDecode(token);
// Adjust this if your JWT uses a different field for user id
return decoded.id || decoded.userId || null;
} catch {
return null;
}
}

19
apps/admin-web/.cta.json Normal file
View file

@ -0,0 +1,19 @@
{
"projectName": "web-ui",
"mode": "file-router",
"typescript": true,
"tailwind": true,
"packageManager": "bun",
"git": true,
"install": true,
"intent": true,
"addOnOptions": {},
"includeExamples": false,
"envVarValues": {},
"routerOnly": false,
"version": 1,
"framework": "react",
"chosenAddOns": [
"nitro"
]
}

15
apps/admin-web/README.md Normal file
View file

@ -0,0 +1,15 @@
# Freshyo Admin Web
Web clone of `apps/admin-ui` — same screens, same functionality, same looks —
built on the exact stack of `apps/web-ui` (TanStack Start + Vite + Tailwind v4 +
tRPC + TanStack Router).
```bash
npm install
npm run dev # http://localhost:4175
npm run build # production build
npm run typecheck
```
Backend: same as admin-ui (`https://devapi.freshyo.in`); override with
`VITE_API_URL` env var.

View file

@ -0,0 +1,9 @@
[2026-09-06 00:10:00] admin-web integration fixes batch: (1) dashboard.notifications.tsx SearchableSelect(number[] mismatch) -> MultiSelect (matches original multi BottomDropdown); (2) TagMenu.tsx router.history.push string nav -> typed navigate({to search}); (3) dashboard.orders.$id.tsx user tap /dashboard/users -> /dashboard/users/$id params nav (route exists); (4) dashboard.users.$id.tsx order nav as-any -> typed params nav; (5) remove temp as-any on now-registered navigate literals (dashboard.index, orders hub, slots, banners, stores, prices, products.edit/groupings casts if tsc-clean); (6) regen routeTree.gen.ts via build.
[2026-09-06 00:20:00] admin-web README: replaced TanStack boilerplate with Freshyo Admin Web run/build notes (npm install/dev/build, port 4175, VITE_API_URL override).
[2026-09-06 00:20:00] COMPLETED apps/admin-web: exact web clone of apps/admin-ui on apps/web-ui stack (TanStack Start + Vite + Tailwind v4 + tRPC + TanStack Router, port 4175, worker admin-web). 38 routes + 21 components + infra, tsc clean, vite build clean, SSR smoke 200s.
Routes: index gate, login, dashboard shell (sidebar/header/titles/refresh) + hub, complaints, coupons(+new,+$id/edit), customize-app(+popular,+ordering), banners(+new,+$id/edit), orders(+list,+sequence,+$id), prices, product-groupings(+new,+$id/edit), product-tags(+new,+edit?tagId,+order), products(+new,+edit?id,+$id), rebalance, notifications, slots(+new?baseslot,+$id/edit,+detail?slotId), stores(+new,+edit?id), users(+$id), vendor-snippets.
New local primitives: MultiSelect, SearchableSelect, DateTimeInput, DateInput (mirror RN BottomDropdown-multi/search + DateTimePickerMod/DatePicker APIs).
Verification: tsc 0 errors; 54/54 file pairs tRPC-procedure parity (2 apparent gaps proven dead code in original: updateAddressCoords declaration, refund commented block); build ok; dev SSR / /login /dashboard /dashboard/coupons 200, /dashboard/orders/sequence 307 redirect, 0 error refs.
Known platform deltas (no web equivalent): Alert.alert->window.alert, destructive confirms->window.confirm, pull-to-refresh->header refresh button, infinite scroll->Load-more buttons, expo-image-picker->file inputs, native date/time pickers->date+time inputs, haptics/blur/share-target->dropped or navigator.share w/ clipboard fallback, push-notification token acquisition dropped (send flow intact).

6596
apps/admin-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,59 @@
{
"name": "admin-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "npm run build && wrangler dev",
"typecheck": "tsc --noEmit",
"deploy": "npm run build && wrangler deploy",
"cf-typegen": "wrangler types"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.10.2",
"@tanstack/react-query": "^5.100.0",
"@tanstack/react-query-devtools": "^5.100.0",
"@tanstack/react-router": "^1.167.0",
"@tanstack/react-router-devtools": "^1.166.0",
"@tanstack/react-router-ssr-query": "^1.166.0",
"@tanstack/react-start": "^1.167.0",
"@tanstack/router-plugin": "^1.167.0",
"@trpc/client": "^11.17.0",
"@trpc/react-query": "^11.17.0",
"@trpc/server": "^11.17.0",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"date-fns": "^4.1.0",
"formik": "^2.4.9",
"fuse.js": "^7.3.0",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.400.0",
"nitro": "npm:nitro-nightly@latest",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^1.7.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18",
"yup": "^1.7.1",
"zustand": "^5.0.13"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.36.3",
"@tanstack/devtools-vite": "^0.6.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
"typescript": "^5.8.3",
"vite": "^8.0.0",
"vitest": "^4.1.5",
"wrangler": "^4.90.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

View file

@ -0,0 +1,25 @@
{
"short_name": "Freshyo",
"name": "Freshyo",
"icons": [
{
"src": "favicon.png",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/png"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#0033CC",
"background_color": "#ffffff"
}

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View file

@ -0,0 +1,4 @@
import { hydrateRoot } from 'react-dom/client'
import { StartClient } from '@tanstack/react-start/client'
hydrateRoot(document, <StartClient />)

View file

@ -0,0 +1,225 @@
import { useRef, useState, type ChangeEvent } from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import { p as P, pInput as PInput, ImageUploader } from 'web-components'
import ProductsSelector from './ProductsSelector'
import { trpc } from '@/lib/trpc-client'
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
export interface BannerFormData {
name: string
imageUrl: string
description: string
skuIds: number[]
redirectUrl: string
// serialNum removed - will be assigned automatically by backend
}
interface BannerFormProps {
initialValues: BannerFormData
onSubmit: (values: BannerFormData, imageUrl?: string) => Promise<void> | void
onCancel: () => void
submitButtonText?: string
isSubmitting?: boolean
existingImageUrl?: string
mode?: 'create' | 'edit'
}
const validationSchema = Yup.object().shape({
name: Yup.string().trim().required('Banner name is required').max(255),
description: Yup.string().max(500),
skuIds: Yup.array()
.of(Yup.number())
.optional(),
redirectUrl: Yup.string()
.url('Please enter a valid URL')
.optional(),
// serialNum validation removed - assigned automatically by backend
})
export default function BannerForm({
initialValues,
onSubmit,
onCancel,
submitButtonText = 'Create Banner',
isSubmitting = false,
existingImageUrl,
mode = 'create',
}: BannerFormProps) {
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([])
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const { uploadSingle } = useUploadToObjectStorage()
// Fetch products for dropdown
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery()
const products = productsData?.products || []
const handleImagePick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) {
setSelectedImages([])
setDisplayImages([])
return
}
const file = files[0]
setSelectedImages([{ blob: file, mimeType: file.type || 'image/jpeg' }])
setDisplayImages([{ uri: URL.createObjectURL(file) }])
e.target.value = ''
}
const handleRemoveImage = (uri: string) => {
const index = displayImages.findIndex(img => img.uri === uri)
if (index !== -1) {
const newDisplay = displayImages.filter((_, i) => i !== index)
const newFiles = selectedImages.filter((_, i) => i !== index)
setDisplayImages(newDisplay)
setSelectedImages(newFiles)
}
}
const handleFormikSubmit = async (values: BannerFormData) => {
try {
let imageUrl: string | undefined
if (selectedImages.length > 0) {
const { blob, mimeType } = selectedImages[0]
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store')
imageUrl = presignedUrl
}
await onSubmit(values, imageUrl)
} catch (error) {
console.error('Upload error:', error)
window.alert('Error: Failed to upload image')
}
}
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={handleFormikSubmit}
>
{({
handleChange,
handleBlur,
handleSubmit,
values,
errors,
touched,
isValid,
dirty,
setFieldValue,
}) => (
<div className="flex-1">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
<div className="flex-1 overflow-auto px-6 py-6">
<PInput
topLabel="Banner Name"
placeholder="Enter banner name"
value={values.name}
onChange={handleChange('name')}
onBlur={handleBlur('name')}
style={{ marginBottom: errors.name && touched.name ? 8 : 16 }}
/>
{errors.name && touched.name && (
<P className="text-red-500 text-xs mb-2 -mt-2">{errors.name}</P>
)}
<div className="mb-4">
<P className="text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider">Banner Image</P>
<ImageUploader
images={displayImages}
existingImageUrls={existingImageUrl ? [existingImageUrl] : []}
onAddImage={handleImagePick}
onRemoveImage={handleRemoveImage}
onRemoveExistingImage={() => {
// Handle removing existing image in edit mode
}}
allowMultiple={false}
/>
</div>
<PInput
topLabel="Description"
placeholder="Enter banner description (optional)"
value={values.description}
onChange={handleChange('description')}
onBlur={handleBlur('description')}
multiline
numberOfLines={3}
style={{ marginBottom: errors.description && touched.description ? 8 : 16 }}
/>
{errors.description && touched.description && (
<P className="text-red-500 text-xs mb-2 -mt-2">{errors.description}</P>
)}
<div className="mb-4">
<ProductsSelector
value={values.skuIds}
onChange={(value) => {
const selectedValues = Array.isArray(value) ? value : [value]
setFieldValue('skuIds', selectedValues.map(v => Number(v)))
}}
multiple={true}
label="Select Products"
placeholder="Select products for banner (optional)"
labelFormat={(product) => `${product.productName} (₹${product.price})`}
/>
</div>
<PInput
topLabel="Redirect URL (Optional)"
placeholder="https://example.com/redirect"
value={values.redirectUrl}
onChange={handleChange('redirectUrl')}
onBlur={handleBlur('redirectUrl')}
type="url"
style={{ marginBottom: errors.redirectUrl && touched.redirectUrl ? 8 : 16 }}
/>
{errors.redirectUrl && touched.redirectUrl && (
<P className="text-red-500 text-xs mb-2 -mt-2">{errors.redirectUrl}</P>
)}
{/* Action Buttons */}
<div className="flex flex-row gap-4 mb-8">
<button
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="flex-1 bg-gray-100 rounded-lg py-4 items-center text-gray-700 font-semibold disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={() => handleSubmit()}
disabled={isSubmitting || !isValid || !dirty}
className={`flex-1 rounded-lg py-4 items-center text-white font-semibold disabled:opacity-70 ${
isSubmitting || !isValid || !dirty
? 'bg-blue-400'
: 'bg-blue-600'
}`}
>
{isSubmitting ? 'Saving...' : submitButtonText}
</button>
</div>
</div>
</div>
)}
</Formik>
)
}

View file

@ -0,0 +1,85 @@
import React, { useState } from 'react'
import type { OrderDialogBaseProps } from '@packages/shared'
import { p as P, BottomDialog, pInput as PInput } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { XCircle } from 'lucide-react'
interface CancelOrderDialogProps extends OrderDialogBaseProps {
onSuccess?: () => void
}
export default function CancelOrderDialog({ orderId, open, onClose, onSuccess }: CancelOrderDialogProps) {
const [cancelReason, setCancelReason] = useState('')
const cancelOrderMutation = trpc.admin.order.cancelOrder.useMutation()
const handleCancel = () => {
if (!cancelReason.trim()) {
window.alert('Error: Please enter a cancellation reason')
return
}
const confirmed = window.confirm(
'Cancel Order? Are you sure you want to cancel this order? This action cannot be undone.'
)
if (!confirmed) return
cancelOrderMutation.mutate(
{ orderId, reason: cancelReason },
{
onSuccess: () => {
onClose()
setCancelReason('')
onSuccess?.()
},
onError: (error: any) => {
window.alert(`Error: ${error.message || 'Failed to cancel order'}`)
},
}
)
}
return (
<BottomDialog open={open} onClose={onClose}>
<div className="p-6">
<div className="items-center mb-6 flex flex-col">
<div className="w-12 h-12 bg-red-100 rounded-full items-center justify-center mb-3 flex">
<XCircle className="h-6 w-6 text-red-600" />
</div>
<P className="text-xl font-bold text-gray-900 text-center">
Cancel Order
</P>
<P className="text-gray-500 text-center mt-2 text-sm leading-5">
This will cancel the order and mark it as cancelled by admin. A refund record will be created.
</P>
</div>
<PInput
topLabel="Cancellation Reason *"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="Enter reason for cancellation..."
multiline
className="h-24"
/>
<div className="flex flex-row gap-3 mt-6">
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-100 py-3.5 rounded-xl items-center text-gray-700 font-bold"
>
Keep Order
</button>
<button
type="button"
onClick={handleCancel}
disabled={cancelOrderMutation.isPending || !cancelReason.trim()}
className={`flex-1 bg-red-500 py-3.5 rounded-xl items-center shadow-sm text-white font-bold disabled:opacity-50 ${cancelOrderMutation.isPending ? 'opacity-50' : ''}`}
>
{cancelOrderMutation.isPending ? 'Cancelling...' : 'Cancel Order'}
</button>
</div>
</div>
</BottomDialog>
)
}

View file

@ -0,0 +1,558 @@
import { useState, useEffect, type ChangeEvent } from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import { pInput as PInput, MyButton, AppContainer, Checkbox, BottomDialog, p as P, MyFlatList } from 'web-components'
import DateTimeInput from './DateTimeInput'
import { trpc } from '@/lib/trpc-client'
// Local copy of common-ui/shared-types CreateCouponPayload (web cannot import
// the RN common-ui package; @packages/shared does not export this type).
export interface CreateCouponPayload {
couponCodes: string[]
discountPercent?: number
flatDiscount?: number
minOrder?: number
maxValue?: number
validTill?: string
maxLimitForUser?: number
isApplyForAll: boolean
isUserBased: boolean
targetUsers?: number[]
productIds?: number[]
skuIds?: number[]
applicableUsers?: number[]
applicableProducts?: number[]
exclusiveApply?: boolean
}
const USERS_PAGE_SIZE = 10
interface CouponFormValues extends CreateCouponPayload {
isReservedCoupon?: boolean
couponCodes: string[]
skuIds?: number[]
}
interface CouponFormProps {
onSubmit: (values: CouponFormValues) => void
isLoading: boolean
initialValues?: Partial<CouponFormValues>
}
const couponValidationSchema = Yup.object().shape({
isReservedCoupon: Yup.boolean().optional(),
couponCodes: Yup.array().of(
Yup.string()
.required('Code is required')
.min(3, 'Code must be at least 3 characters')
.max(50, 'Code cannot exceed 50 characters')
.matches(/^[A-Z0-9_-]+$/, 'Code can only contain uppercase letters, numbers, underscores, and hyphens')
),
discountPercent: Yup.number()
.min(0, 'Must be positive')
.max(100, 'Cannot exceed 100%')
.optional(),
flatDiscount: Yup.number()
.min(0, 'Must be positive')
.optional(),
minOrder: Yup.number().min(0, 'Must be positive').optional(),
maxValue: Yup.number().min(0, 'Must be positive').optional(),
validTill: Yup.date().optional(),
maxLimitForUser: Yup.number().min(1, 'Must be at least 1').optional(),
exclusiveApply: Yup.boolean().optional(),
isUserBased: Yup.boolean(),
isApplyForAll: Yup.boolean(),
applicableUsers: Yup.array().of(Yup.number()).optional(),
}).test('discount-validation', 'Must provide exactly one discount type with valid value', function(value) {
const { discountPercent, flatDiscount } = value
const hasPercent = discountPercent !== undefined && discountPercent > 0
const hasFlat = flatDiscount !== undefined && flatDiscount > 0
if (hasPercent && hasFlat) {
return this.createError({ message: 'Cannot have both percentage and flat discount' })
}
if (!hasPercent && !hasFlat) {
return this.createError({ message: 'Must provide either percentage or flat discount' })
}
return true
}).test('codes-required', 'At least one coupon code is required', function(value: any) {
const codes = (value.couponCodes || []).filter((c: string) => c && c.trim())
if (codes.length === 0) {
return this.createError({ path: 'couponCodes', message: 'Add at least one coupon code' })
}
return true
}).test('codes-unique', 'Coupon codes must be unique', function(value: any) {
const codes = (value.couponCodes || []).map((c: string) => c.trim()).filter(Boolean)
if (new Set(codes).size !== codes.length) {
return this.createError({ path: 'couponCodes', message: 'Coupon codes must be unique' })
}
return true
})
export default function CouponForm({ onSubmit, isLoading, initialValues }: CouponFormProps) {
// User dropdown states
const [userSearchQuery, setUserSearchQuery] = useState('')
const [userOffset, setUserOffset] = useState(0)
const [allUsers, setAllUsers] = useState<{ id: number; name: string; mobile: string | null }[]>([])
const [hasMoreUsers, setHasMoreUsers] = useState(true)
const [usersDropdownOpen, setUsersDropdownOpen] = useState(false)
// Add-multiple dialog states (for pattern codes like ABC12)
const [multiDialogOpen, setMultiDialogOpen] = useState(false)
const [multiCount, setMultiCount] = useState('')
const { data: usersData, isFetching: isFetchingUsers } = trpc.admin.coupon.getUsersMiniInfo.useQuery(
{ search: userSearchQuery, limit: USERS_PAGE_SIZE, offset: userOffset },
{ enabled: usersDropdownOpen }
)
useEffect(() => {
if (usersData?.users) {
if (userOffset === 0) {
setAllUsers(usersData.users)
} else {
setAllUsers(prev => [...prev, ...usersData.users])
}
setHasMoreUsers(usersData.users.length === USERS_PAGE_SIZE)
}
}, [usersData, userOffset])
useEffect(() => {
setUserOffset(0)
setHasMoreUsers(true)
}, [userSearchQuery])
useEffect(() => {
if (usersDropdownOpen) {
setUserOffset(0)
setAllUsers([])
setHasMoreUsers(true)
setUserSearchQuery('')
}
}, [usersDropdownOpen])
// User search functionality will be inside Formik
const defaultValues: CouponFormValues = {
couponCodes: [''],
isUserBased: false,
isApplyForAll: false,
targetUsers: [],
discountPercent: undefined,
flatDiscount: undefined,
minOrder: undefined,
maxValue: undefined,
validTill: undefined,
maxLimitForUser: undefined,
skuIds: undefined,
applicableUsers: [],
applicableProducts: [],
exclusiveApply: false,
isReservedCoupon: false,
}
return (
<Formik<CouponFormValues>
initialValues={(initialValues || defaultValues) as CouponFormValues}
validationSchema={couponValidationSchema}
onSubmit={onSubmit}
>
{({ values, errors, touched, setFieldValue, handleSubmit }) => {
const toggleUserSelection = (userId: number) => {
const current = values.applicableUsers || []
const newSelection = current.includes(userId)
? current.filter(id => id !== userId)
: [...current, userId]
setFieldValue('applicableUsers', newSelection)
}
const isReserved = (values as any).isReservedCoupon
// Detect pattern codes like ABC12 (ends in digits) for bulk generation.
// Continue numbering from the highest code with the same prefix.
const patternCode = (values.couponCodes || []).find(
(c: string) => /^[A-Z0-9_-]*\d+$/.test(c.trim())
)
const patternPrefix = patternCode ? patternCode.replace(/\d+$/, '') : ''
const patternStart = (values.couponCodes || []).reduce((max, c) => {
const trimmed = c.trim()
if (!patternPrefix || !trimmed.startsWith(patternPrefix)) return max
const m = trimmed.match(/\d+$/)
if (!m) return max
return Math.max(max, parseInt(m[0], 10))
}, patternCode ? parseInt((patternCode.match(/\d+$/) || ['0'])[0], 10) : 0)
const handleAddMultiple = () => {
const count = parseInt(multiCount, 10)
if (!count || count < 1 || count > 100) return
// Start from the next number after the highest existing code
const newCodes = Array.from(
{ length: count },
(_, i) => `${patternPrefix}${patternStart + 1 + i}`
)
const existing = (values.couponCodes || []).filter((c: string) => c && c.trim())
setFieldValue('couponCodes', [...existing, ...newCodes])
setMultiDialogOpen(false)
setMultiCount('')
}
return (
<AppContainer>
{/* Is Reserved Coupon Checkbox */}
<div className="mb-4 flex flex-row items-center">
<Checkbox
checked={(values as any).isReservedCoupon || false}
onPress={() => setFieldValue('isReservedCoupon', !(values as any).isReservedCoupon)}
/>
<P className="ml-2 text-sm font-medium text-gray-700">Is Reserved Coupon</P>
</div>
{/* Coupon Codes */}
<div className="mb-4">
<P className="text-base font-bold mb-1 block">Coupon Codes</P>
<P className="text-xs text-gray-500 mb-2 block">
Each code creates a separate coupon with the same settings.
</P>
{(values.couponCodes || []).map((code: string, index: number) => {
const codeError = Array.isArray(errors.couponCodes)
? (errors.couponCodes as any)[index]
: errors.couponCodes
return (
<div key={index} className="flex flex-row items-center mb-2">
<div className="flex-1">
<PInput
topLabel={`Code ${index + 1}`}
placeholder="e.g., SECRET10"
value={code}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const next = [...(values.couponCodes || [])]
next[index] = e.target.value.toUpperCase()
setFieldValue('couponCodes', next)
}}
error={!!codeError}
/>
{codeError && <P className="text-red-500 text-xs mt-1 block">{codeError}</P>}
</div>
<button
type="button"
onClick={() => {
const next = (values.couponCodes || []).filter((_: string, i: number) => i !== index)
setFieldValue('couponCodes', next.length > 0 ? next : [''])
}}
className="ml-2 p-2 text-red-500 font-bold text-lg"
aria-label={`Remove code ${index + 1}`}
>
</button>
</div>
)
})}
{(errors.couponCodes as any)?.message && (
<P className="text-red-500 text-xs mb-2 block">{(errors.couponCodes as any).message}</P>
)}
<div className="mt-2 flex flex-row self-start">
<button
type="button"
onClick={() => setFieldValue('couponCodes', [...(values.couponCodes || []), ''])}
className="py-2 px-3 bg-blue-50 rounded-lg border border-blue-200 text-blue-600 font-medium"
>
+ Add Code
</button>
{patternCode && (
<button
type="button"
onClick={() => setMultiDialogOpen(true)}
className="ml-2 py-2 px-3 bg-green-50 rounded-lg border border-green-200 text-green-600 font-medium"
>
Add Multiple
</button>
)}
</div>
</div>
{/* Discount Type Selection */}
<P className="text-base font-bold mb-2 block">
Discount Type *
</P>
<div className="flex flex-row mb-4">
<button
type="button"
onClick={() => {
setFieldValue('discountPercent', values.discountPercent || 0)
setFieldValue('flatDiscount', undefined)
}}
className={`flex-1 p-3 border rounded-lg mr-2 ${
values.discountPercent !== undefined ? 'border-blue-500' : 'border-gray-300'
}`}
>
<P className="block text-center">Percentage</P>
</button>
<button
type="button"
onClick={() => {
setFieldValue('flatDiscount', values.flatDiscount || 0)
setFieldValue('discountPercent', undefined)
}}
className={`flex-1 p-3 border rounded-lg ${
values.flatDiscount !== undefined ? 'border-blue-500' : 'border-gray-300'
}`}
>
<P className="block text-center">Flat Amount</P>
</button>
</div>
{/* Discount Value */}
{values.discountPercent !== undefined && (
<div className="mb-4">
<PInput
topLabel="Discount Percentage *"
placeholder="e.g., 10"
value={values.discountPercent?.toString() || ''}
onChange={(e) => setFieldValue('discountPercent', parseFloat(e.target.value) || 0)}
type="number"
inputMode="numeric"
error={!!(touched.discountPercent && errors.discountPercent)}
/>
</div>
)}
{values.flatDiscount !== undefined && (
<div className="mb-4">
<PInput
topLabel="Flat Discount Amount *"
placeholder="e.g., 50"
value={values.flatDiscount?.toString() || ''}
onChange={(e) => setFieldValue('flatDiscount', parseFloat(e.target.value) || 0)}
type="number"
inputMode="numeric"
error={!!(touched.flatDiscount && errors.flatDiscount)}
/>
</div>
)}
{/* Minimum Order */}
<div className="mb-4">
<PInput
topLabel="Minimum Order Amount"
placeholder="e.g., 100"
value={values.minOrder?.toString() || ''}
onChange={(e) => setFieldValue('minOrder', parseFloat(e.target.value) || undefined)}
type="number"
inputMode="numeric"
error={!!(touched.minOrder && errors.minOrder)}
/>
</div>
{/* Maximum Discount Value - only for percentage discounts */}
{values.flatDiscount === undefined && (
<div className="mb-4">
<PInput
topLabel="Maximum Discount Value"
placeholder="e.g., 200"
value={values.maxValue?.toString() || ''}
onChange={(e) => setFieldValue('maxValue', parseFloat(e.target.value) || undefined)}
type="number"
inputMode="numeric"
error={!!(touched.maxValue && errors.maxValue)}
/>
</div>
)}
{/* Validity Period */}
<div className="mb-4">
<P className="text-base mb-2 block">Valid Till</P>
<DateTimeInput
value={values.validTill ? new Date(values.validTill) : null}
setValue={(date) => {
setFieldValue('validTill', date?.toISOString())
}}
/>
</div>
{/* Usage Limit */}
<div className="mb-4">
<PInput
topLabel="Max Uses Per User"
placeholder="e.g., 5"
value={values.maxLimitForUser?.toString() || ''}
onChange={(e) => setFieldValue('maxLimitForUser', parseInt(e.target.value) || undefined)}
type="number"
inputMode="numeric"
error={!!(touched.maxLimitForUser && errors.maxLimitForUser)}
/>
</div>
{/* Exclusive Apply */}
<div className="mb-4">
<P className="text-base mb-2 block">Exclusive Apply</P>
<button
type="button"
onClick={() => setFieldValue('exclusiveApply', !values.exclusiveApply)}
className="flex flex-row items-center"
>
<div className={`w-5 h-5 border-2 border-gray-300 rounded mr-3 flex items-center justify-center ${values.exclusiveApply ? 'bg-blue-500 border-blue-500' : ''}`}>
{values.exclusiveApply && <P className="text-white text-center"></P>}
</div>
<P className="text-gray-700">Exclusive coupon (cannot be combined with other coupons)</P>
</button>
</div>
{/* Target Audience */}
<P className={`text-base font-bold mb-2 block ${isReserved ? 'text-gray-400' : ''}`}>
Target Audience {isReserved ? '(Disabled for Reserved Coupons)' : ''}
</P>
<div className="flex flex-row mb-4">
<button
type="button"
disabled={isReserved}
onClick={isReserved ? undefined : () => {
setFieldValue('isApplyForAll', true)
setFieldValue('isUserBased', false)
setFieldValue('targetUsers', [])
}}
className={`flex-1 p-3 border rounded-lg mr-2 ${
values.isApplyForAll ? 'border-blue-500' : 'border-gray-300'
} ${isReserved ? 'opacity-50' : ''}`}
>
<P className="block text-center" style={{ color: isReserved ? '#9CA3AF' : '#000' }}>All Users</P>
</button>
<button
type="button"
disabled={isReserved}
onClick={isReserved ? undefined : () => {
setFieldValue('isUserBased', true)
setFieldValue('isApplyForAll', false)
}}
className={`flex-1 p-3 border rounded-lg ${
values.isUserBased ? 'border-blue-500' : 'border-gray-300'
} ${isReserved ? 'opacity-50' : ''}`}
>
<P className="block text-center" style={{ color: isReserved ? '#9CA3AF' : '#000' }}>Specific User</P>
</button>
</div>
{/* Applicable User Selection */}
<div className="mb-4">
<P className={`text-base mb-2 block ${isReserved ? 'text-gray-400' : ''}`}>Applicable Users (Optional)</P>
<button
type="button"
disabled={isReserved}
onClick={isReserved ? undefined : () => setUsersDropdownOpen(true)}
className={`border border-gray-300 rounded p-3 bg-white w-full text-left ${isReserved ? 'opacity-50' : ''}`}
>
<P className={isReserved ? 'text-gray-400' : 'text-gray-700'}>
{values.applicableUsers?.length ? `${values.applicableUsers.length} users selected` : 'Select users...'}
</P>
</button>
</div>
<BottomDialog open={usersDropdownOpen} onClose={() => setUsersDropdownOpen(false)}>
<div className="p-4">
<P className="text-lg font-semibold mb-4 block">Select Applicable Users</P>
<PInput
placeholder="Search users by name or mobile..."
value={userSearchQuery}
onChange={(e) => setUserSearchQuery(e.target.value)}
/>
<div className="overflow-auto" style={{ maxHeight: 240 }}>
<MyFlatList
data={allUsers}
keyExtractor={(item) => item.id.toString()}
renderItem={(item) => (
<button
type="button"
onClick={() => toggleUserSelection(item.id)}
className="flex flex-row items-center p-3 border-b border-gray-200 w-full text-left"
>
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={values.applicableUsers?.includes(item.id) || false}
onPress={() => toggleUserSelection(item.id)}
/>
</span>
<P className="ml-3 text-base">{item.mobile} - {item.name}</P>
</button>
)}
ListFooterComponent={
<>
{isFetchingUsers ? <P className="text-center p-3 block">Loading...</P> : null}
{hasMoreUsers && !isFetchingUsers ? (
<button
type="button"
onClick={() => setUserOffset(prev => prev + USERS_PAGE_SIZE)}
className="w-full p-3 text-center text-blue-600 font-medium"
>
Load more
</button>
) : null}
</>
}
/>
</div>
<button
type="button"
onClick={() => setUsersDropdownOpen(false)}
className="mt-4 bg-blue-500 p-3 rounded text-white text-center font-semibold w-full"
>
Done
</button>
</div>
</BottomDialog>
{/* Add Multiple Coupons Dialog */}
<BottomDialog open={multiDialogOpen} onClose={() => setMultiDialogOpen(false)}>
<div className="p-4">
<P className="text-lg font-semibold mb-1 block">Add Multiple Coupons</P>
<P className="text-sm text-gray-500 mb-4 block">
Generate {patternCode ? `${patternPrefix}${patternStart + 1}, ${patternPrefix}${patternStart + 2}, ...` : 'sequential'} codes from your pattern
</P>
<PInput
topLabel="How many?"
placeholder="e.g., 10"
value={multiCount}
onChange={(e) => setMultiCount(e.target.value)}
type="number"
inputMode="numeric"
/>
<div className="flex flex-row mt-4">
<button
type="button"
onClick={() => {
setMultiDialogOpen(false)
setMultiCount('')
}}
className="flex-1 p-3 rounded bg-gray-100 mr-2 text-gray-700 text-center font-semibold"
>
Cancel
</button>
<button
type="button"
onClick={handleAddMultiple}
className="flex-1 p-3 rounded bg-green-500 text-white text-center font-semibold"
>
Generate
</button>
</div>
</div>
</BottomDialog>
{/* Submit Button */}
<MyButton
onClick={() => handleSubmit()}
disabled={isLoading}
textContent={isLoading ? 'Creating...' : 'Create Coupon'}
/>
</AppContainer>
)
}}
</Formik>
)
}

View file

@ -0,0 +1,31 @@
import { p as P } from 'web-components'
interface DateInputProps {
value: Date | null
setValue: (d: Date | null) => void
showLabel?: boolean
placeholder?: string
}
function toDateStr(d: Date | null): string {
if (!d) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export default function DateInput({ value, setValue, showLabel = true, placeholder = 'Select Date' }: DateInputProps) {
return (
<div className="w-full">
{showLabel ? <P className="text-xs mb-1">{placeholder}</P> : null}
<input
type="date"
value={toDateStr(value)}
onChange={(e) => setValue(e.target.value ? new Date(`${e.target.value}T00:00:00`) : null)}
placeholder={placeholder}
className="w-full rounded border border-gray-300 px-2 py-2 text-sm font-medium text-gray-800"
/>
</div>
)
}

View file

@ -0,0 +1,81 @@
import { p as P } from 'web-components'
interface DateTimeInputProps {
value: Date | null
setValue: (d: Date | null) => void
showLabels?: boolean
timeOnly?: boolean
testID?: string
}
function toDateStr(d: Date | null): string {
if (!d) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function toTimeStr(d: Date | null): string {
if (!d) return ''
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
export default function DateTimeInput({ value, setValue, showLabels = true, timeOnly = false, testID }: DateTimeInputProps) {
const handleDateChange = (dateStr: string) => {
if (!dateStr) {
setValue(null)
return
}
const base = value ? new Date(value) : new Date()
const [y, m, day] = dateStr.split('-').map((n) => parseInt(n, 10))
base.setFullYear(y, m - 1, day)
setValue(base)
}
const handleTimeChange = (timeStr: string) => {
if (!timeStr) return
const base = value ? new Date(value) : new Date()
const [h, min] = timeStr.split(':').map((n) => parseInt(n, 10))
base.setHours(h, min, 0, 0)
setValue(base)
}
if (timeOnly) {
return (
<div className="w-full mb-4" data-testid={testID}>
{showLabels ? <P className="text-xs mb-1">Select Time</P> : null}
<input
type="time"
value={toTimeStr(value)}
onChange={(e) => handleTimeChange(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm font-medium text-gray-800"
/>
</div>
)
}
return (
<div className="w-full flex flex-row items-stretch mb-4" data-testid={testID}>
<div className="w-1/2">
{showLabels ? <P className="text-xs mb-1">Select Date</P> : null}
<input
type="date"
value={toDateStr(value)}
onChange={(e) => handleDateChange(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm font-medium text-gray-800"
/>
</div>
<div className="w-2" />
<div className="w-1/2">
{showLabels ? <P className="text-xs mb-1">Select Time</P> : null}
<input
type="time"
value={toTimeStr(value)}
onChange={(e) => handleTimeChange(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm font-medium text-gray-800"
/>
</div>
</div>
)
}

View file

@ -0,0 +1,152 @@
import { useMemo, useState } from 'react'
import { BottomDialog, Checkbox, SearchBar, p as P } from 'web-components'
import { Check, ChevronDown } from 'lucide-react'
export interface MultiSelectOption {
label: string
value: string | number
disabled?: boolean
}
interface MultiSelectProps {
label?: string
topLabel?: string
options: MultiSelectOption[]
value: (string | number)[]
onValueChange: (v: (string | number)[]) => void
placeholder?: string
disabled?: boolean
onSearch?: (q: string) => void
error?: boolean
className?: string
testID?: string
// Tolerated for screen compatibility (RN BottomDropdown leftovers) — ignored on web
multiple?: boolean
triggerComponent?: any
}
export function MultiSelect({
label,
topLabel,
options,
value,
onValueChange,
placeholder,
disabled = false,
onSearch,
error = false,
className,
testID,
}: MultiSelectProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const selectedSet = useMemo(() => new Set(value.map((v) => String(v))), [value])
const displayText = useMemo(() => {
if (value.length === 0) return placeholder ?? label ?? 'Select...'
if (value.length === 1) {
const found = options.find((o) => String(o.value) === String(value[0]))
return found ? found.label : (placeholder ?? label ?? 'Select...')
}
return `${value.length} selected`
}, [value, options, placeholder, label])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return options
return options.filter((o) => o.label.toLowerCase().includes(q))
}, [options, query])
const toggle = (optionValue: string | number) => {
const key = String(optionValue)
if (selectedSet.has(key)) {
onValueChange(value.filter((v) => String(v) !== key))
} else {
onValueChange([...value, optionValue])
}
}
const heading = topLabel ?? label
return (
<div className={className} data-testid={testID}>
{heading ? (
<P className="mb-1 text-sm text-gray-500 font-medium">{heading}</P>
) : null}
<button
type="button"
disabled={disabled}
onClick={() => setOpen(true)}
className={`flex w-full items-center justify-between rounded-md border bg-white px-3 py-2 text-sm text-left disabled:cursor-not-allowed disabled:opacity-50 ${error ? 'border-red-500' : 'border-gray-300'}`}
>
<span className={`truncate ${value.length === 0 ? 'text-gray-400' : 'text-gray-800'}`}>
{displayText}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-gray-400" />
</button>
<BottomDialog open={open} onClose={() => setOpen(false)}>
<div className="p-4">
<P className="text-lg font-semibold mb-4">{heading ?? placeholder ?? 'Select'}</P>
<SearchBar
placeholder="Search..."
value={query}
onChange={(q) => {
setQuery(q)
onSearch?.(q)
}}
onSearch={onSearch}
className="mb-3"
/>
<div className="overflow-y-auto" style={{ maxHeight: 320 }}>
{filtered.map((option) => {
const checked = selectedSet.has(String(option.value))
return (
<div
key={String(option.value)}
data-testid="multiselect-option"
onClick={() => {
if (!option.disabled) toggle(option.value)
}}
className={`flex flex-row items-center p-3 border-b border-gray-100 ${option.disabled ? 'opacity-50' : 'cursor-pointer hover:bg-gray-50'}`}
>
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={checked}
onPress={() => {
if (!option.disabled) toggle(option.value)
}}
/>
</span>
<span className="ml-3 text-sm text-gray-800 flex-1">{option.label}</span>
{checked ? <Check className="h-4 w-4 text-blue-600" /> : null}
</div>
)
})}
{filtered.length === 0 ? (
<P className="text-center text-gray-500 text-sm p-4">No options found</P>
) : null}
</div>
<div className="flex flex-row gap-2 mt-4">
<button
type="button"
onClick={() => onValueChange([])}
className="flex-1 p-3 rounded-lg bg-gray-100 text-gray-700 text-center font-semibold text-sm"
>
Clear
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="flex-1 p-3 rounded-lg bg-blue-500 text-white text-center font-semibold text-sm"
>
Done
</button>
</div>
</div>
</BottomDialog>
</div>
)
}
export default MultiSelect

View file

@ -0,0 +1,70 @@
import { useState, type FC } from 'react'
import { p as P, MyButton, pInput as PInput } from 'web-components'
import { trpc } from '@/lib/trpc-client'
interface OrderNotesFormProps {
orderId: number
initialNotes?: string
onSuccess?: () => void
onCancel?: () => void
}
export const OrderNotesForm: FC<OrderNotesFormProps> = ({
orderId,
initialNotes = '',
onSuccess,
onCancel,
}) => {
const [notes, setNotes] = useState(initialNotes)
const updateNotesMutation = trpc.admin.order.updateNotes.useMutation()
const handleSubmit = async () => {
if (!notes.trim()) {
window.alert('Error: Please enter some notes')
return
}
try {
await updateNotesMutation.mutateAsync({
orderId,
adminNotes: notes.trim(),
})
window.alert('Success: Notes updated successfully')
onSuccess?.()
} catch (error) {
window.alert('Error: Failed to update notes')
}
}
return (
<div className="p-5">
<P className="text-lg font-bold mb-4">
Add Order Notes
</P>
<PInput
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Enter notes for this order..."
multiline
numberOfLines={4}
className="min-h-24"
/>
<div className="flex flex-row justify-between mt-5 gap-3">
<MyButton
variant="blue"
className="bg-gray-400 hover:bg-gray-500"
textContent="Cancel"
onClick={onCancel}
/>
<MyButton
variant="blue"
textContent={updateNotesMutation.isPending ? 'Saving...' : 'Save Notes'}
onClick={handleSubmit}
disabled={updateNotesMutation.isPending}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,327 @@
import React from 'react'
import { p as P, BottomDialog, Checkbox } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { ChevronRight, Eye, Pencil, XCircle, MapPin, Map, MessageCircle, Phone } from 'lucide-react'
interface OrderOptionsMenuProps {
open: boolean
onClose: () => void
order: {
id: number
readableId: number
isPackaged: boolean
isDelivered: boolean
isFlashDelivery?: boolean
address: string
addressId: number
adminNotes?: string | null
userNotes?: string | null
latitude?: number | null
longitude?: number | null
status?: string
}
onViewDetails: () => void
onTogglePackaged: () => void
onToggleDelivered: () => void
onOpenAdminNotes: () => void
onCancelOrder: () => void
onAttachLocation: () => void
onWhatsApp: () => void
onDial: () => void
}
function getCurrentPosition(): Promise<GeolocationPosition> {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation is not supported by this browser'))
return
}
navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true })
})
}
export function OrderOptionsMenu({
open,
onClose,
order,
onViewDetails,
onTogglePackaged,
onToggleDelivered,
onOpenAdminNotes,
onCancelOrder,
onAttachLocation,
onWhatsApp,
onDial,
}: OrderOptionsMenuProps) {
const updateAddressCoordsMutation = trpc.admin.order.updateAddressCoords.useMutation()
const handleAttachLocation = async () => {
try {
const location = await getCurrentPosition()
const { latitude, longitude } = location.coords
await updateAddressCoordsMutation.mutateAsync({
addressId: order.addressId,
latitude,
longitude,
})
window.alert('Success: Location attached to address successfully.')
onAttachLocation()
} catch (error: any) {
if (error?.code === 1) {
window.alert('Permission Denied: Location permission is required to attach coordinates.')
return
}
window.alert('Error: Failed to attach location. Please try again.')
}
}
const extractPhone = (address: string) => {
const phoneMatch = address.match(/Phone: (\d+)/)
return phoneMatch ? phoneMatch[1] : null
}
const handleWhatsApp = () => {
const phone = extractPhone(order.address)
if (phone) {
window.open(`https://wa.me/91${phone}`, '_blank')
} else {
window.alert('No phone number found')
}
}
const handleDial = () => {
const phone = extractPhone(order.address)
if (phone) {
window.open(`tel:${phone}`, '_blank')
} else {
window.alert('No phone number found')
}
}
const handleOpenInMaps = () => {
if (order.latitude && order.longitude) {
const url = `https://www.google.com/maps/search/?api=1&query=${order.latitude},${order.longitude}`
window.open(url, '_blank')
} else {
window.alert('No location coordinates available')
}
}
const hasCoordinates = order.latitude !== null && order.latitude !== undefined &&
order.longitude !== null && order.longitude !== undefined
return (
<BottomDialog open={open} onClose={onClose}>
<div style={{ maxHeight: '70vh' }} className="overflow-hidden flex flex-col">
<div className="overflow-auto grow">
<div className="pb-8 pt-2 px-4">
<div className="items-center mb-6 flex flex-col">
<div className="w-12 h-1.5 bg-gray-200 rounded-full mb-4" />
<P className="text-lg font-bold text-gray-900">
Order #{order.readableId}
</P>
<P className="text-sm text-gray-500">
Select an action to perform
</P>
</div>
<button
type="button"
onClick={() => {
onViewDetails()
onClose()
}}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
>
<div className="w-10 h-10 rounded-full bg-purple-50 items-center justify-center mr-4 flex shrink-0">
<Eye className="h-5 w-5 text-purple-600" />
</div>
<div>
<P className="font-semibold text-gray-800 text-base block">
View Details
</P>
<P className="text-gray-500 text-xs block">
See full order information
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
<div className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm">
<button type="button" onClick={onTogglePackaged} className="p-1" aria-label="Toggle packaged">
<Checkbox
checked={order.isPackaged}
onPress={onTogglePackaged}
fillColor={order.isPackaged ? '#10B981' : '#1570EF'}
/>
</button>
<button type="button" onClick={onTogglePackaged} className="ml-3 flex-1 text-left">
<P className="font-semibold text-gray-800 text-base block">
Packaged
</P>
<P className="text-gray-500 text-xs block">
{order.isPackaged ? 'Mark as not packaged' : 'Mark as packaged'}
</P>
</button>
</div>
<div className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm">
<button type="button" onClick={onToggleDelivered} className="p-1" aria-label="Toggle delivered">
<Checkbox
checked={order.isDelivered}
onPress={onToggleDelivered}
fillColor={order.isDelivered ? '#10B981' : '#1570EF'}
/>
</button>
<button type="button" onClick={onToggleDelivered} className="ml-3 flex-1 text-left">
<P className="font-semibold text-gray-800 text-base block">
Delivered
</P>
<P className="text-gray-500 text-xs block">
{order.isDelivered ? 'Mark as not delivered' : 'Mark as delivered'}
</P>
</button>
</div>
<button
type="button"
onClick={() => {
onOpenAdminNotes()
onClose()
}}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
>
<div className="w-10 h-10 rounded-full bg-yellow-50 items-center justify-center mr-4 flex shrink-0">
<Pencil className="h-5 w-5 text-yellow-600" />
</div>
<div>
<P className="font-semibold text-gray-800 text-base block">
Admin Notes
</P>
<P className="text-gray-500 text-xs block">
{order.adminNotes ? 'Edit existing notes' : 'Add admin notes'}
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
{order.status !== 'cancelled' && (
<button
type="button"
onClick={() => {
onCancelOrder()
onClose()
}}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
>
<div className="w-10 h-10 rounded-full bg-red-50 items-center justify-center mr-4 flex shrink-0">
<XCircle className="h-5 w-5 text-red-600" />
</div>
<div>
<P className="font-semibold text-red-700 text-base block">
Cancel Order
</P>
<P className="text-red-500 text-xs block">
Cancel and provide reason
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
)}
<button
type="button"
onClick={() => {
handleAttachLocation()
onClose()
}}
disabled={updateAddressCoordsMutation.isPending}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left disabled:opacity-50"
>
<div className="w-10 h-10 rounded-full bg-orange-50 items-center justify-center mr-4 flex shrink-0">
<MapPin className="h-5 w-5 text-orange-600" />
</div>
<div>
<P className="font-semibold text-gray-800 text-base block">
Attach Location
</P>
<P className="text-gray-500 text-xs block">
Save GPS coordinates to address
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
{hasCoordinates && (
<button
type="button"
onClick={() => {
handleOpenInMaps()
onClose()
}}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
>
<div className="w-10 h-10 rounded-full bg-blue-50 items-center justify-center mr-4 flex shrink-0">
<Map className="h-5 w-5 text-blue-600" />
</div>
<div>
<P className="font-semibold text-gray-800 text-base block">
Open in Maps
</P>
<P className="text-gray-500 text-xs block">
View delivery location on Google Maps
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
)}
<button
type="button"
onClick={() => {
handleWhatsApp()
onClose()
}}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
>
<div className="w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4 flex shrink-0">
<MessageCircle className="h-5 w-5 text-green-600" />
</div>
<div>
<P className="font-semibold text-gray-800 text-base block">
Message On WhatsApp
</P>
<P className="text-gray-500 text-xs block">
Send message via WhatsApp
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
<button
type="button"
onClick={() => {
handleDial()
onClose()
}}
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
>
<div className="w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4 flex shrink-0">
<Phone className="h-5 w-5 text-green-600" />
</div>
<div>
<P className="font-semibold text-gray-800 text-base block">
Dial Mobile Number
</P>
<P className="text-gray-500 text-xs block">
Call customer directly
</P>
</div>
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
</button>
</div>
</div>
</div>
</BottomDialog>
)
}

View file

@ -0,0 +1,660 @@
import { useState, useImperativeHandle, forwardRef, useMemo } from 'react'
import { Formik, FieldArray } from 'formik'
import * as Yup from 'yup'
import { pInput as PInput, Dropdown, p as P, Checkbox, BottomDialog, ImageUploaderNeo } from 'web-components'
import type { ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'web-components'
import SearchableSelect from './SearchableSelect'
import { Plus, Trash2, X, RotateCcw, Info } from 'lucide-react'
import { trpc } from '@/lib/trpc-client'
import type { CreateComboItemInput, CreateSkuInput } from '@packages/shared'
import type { SkuFeatureLike } from '@packages/shared'
// Feature input shape — single source: shared SkuFeatureLike
type Attribute = SkuFeatureLike
// Variant form state — anchored to the shared CreateSkuInput (numeric prices,
// same field names). Images are managed separately via variantImages state.
type Variant = Omit<
CreateSkuInput,
'name' | 'price' | 'marketPrice' | 'flashPrice' | 'images' | 'features' | 'comboItems'
> & {
id?: number
name: string
price?: number
marketPrice: number | null
flashPrice: number | null
isFlashAvailable: boolean
isOffer: boolean
isComboOnly: boolean
isSuspended: boolean
features: Attribute[]
comboItems: CreateComboItemInput[]
}
interface ProductFormData {
name: string
shortDescription: string
longDescription: string
storeId: number
productType: 'item' | 'combo'
variants: Variant[]
}
export interface ProductFormRef {
clearImages: () => void
}
interface ProductFormProps {
mode: 'create' | 'edit'
initialValues: ProductFormData
onSubmit: (values: ProductFormData, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => void
isLoading: boolean
existingVariantImages?: ImageUploaderNeoItem[][]
existingVariantImageKeys?: string[][]
}
// Web rebuild of common-ui InfoDialog: info icon button + BottomDialog + p
function InfoDialog({ message, className }: { message?: string; className?: string }) {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)} className={className} aria-label="More info">
<Info className="h-5 w-5 text-gray-500" />
</button>
<BottomDialog open={open} onClose={() => setOpen(false)}>
<div className="py-4 px-2">
<P className="text-gray-700 text-sm leading-6">{message ?? ''}</P>
</div>
</BottomDialog>
</>
)
}
const defaultAttribute = (): Attribute => ({ featureName: '', featureValue: '' })
// The quantity feature is auto-added as the first attribute of every SKU.
const quantityAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' })
const isQuantityFeature = (attr: Attribute): boolean =>
(attr.featureName ?? '').trim().toLowerCase() === 'quantity'
const defaultVariant = (): Variant => ({
id: undefined,
name: '',
price: undefined,
marketPrice: null,
isFlashAvailable: false,
flashPrice: null,
isOffer: false,
isComboOnly: false,
isDeleted: false,
isSuspended: false,
features: [quantityAttribute()],
comboItems: [],
})
const variantSignature = (attributes: Attribute[]): string =>
attributes
.map((a) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
.sort()
.join('|')
const productValidationSchema = Yup.object().shape({
name: Yup.string().required('Product name is required'),
storeId: Yup.number().required('Store is required').min(1, 'Store is required'),
productType: Yup.string().oneOf(['item', 'combo'], 'Product type is required').required('Product type is required'),
variants: Yup.array()
.min(1, 'At least one variant is required')
.of(
Yup.object().shape({
price: Yup.number()
.typeError('Price must be a number')
.positive('Price must be a positive number')
.required('Price is required'),
marketPrice: Yup.number()
.typeError('Market price must be a number')
.min(0, 'Market price cannot be negative')
.nullable()
.transform((value, originalValue) => (originalValue === '' ? null : value))
.optional(),
flashPrice: Yup.number()
.typeError('Flash price must be a number')
.min(0, 'Flash price cannot be negative')
.nullable()
.transform((value, originalValue) => (originalValue === '' ? null : value))
.optional(),
features: Yup.array()
.min(1, 'At least one attribute is required')
.of(
Yup.object().shape({
featureValue: Yup.string().required('Value is required'),
})
),
})
)
.test('unique-variants', 'Two variants have the same attributes', function (variants) {
if (!Array.isArray(variants)) return true
const seen = new Set<string>()
for (const variant of variants) {
const attrs = variant?.features || []
const signature = variantSignature(attrs as Attribute[])
if (seen.has(signature)) {
return this.createError({ message: 'Two variants have the same attributes' })
}
seen.add(signature)
}
return true
})
.test('quantity-feature', 'Each SKU must have exactly one quantity feature', function (variants) {
if (!Array.isArray(variants)) return true
for (const variant of variants) {
const attrs = variant?.features || []
const quantityCount = attrs.filter(
(a) => ((a as Attribute)?.featureName ?? '').trim().toLowerCase() === 'quantity'
).length
if (quantityCount === 0) {
return this.createError({ message: 'Every SKU must have a quantity feature' })
}
if (quantityCount > 1) {
return this.createError({ message: 'Each SKU can have only one quantity feature' })
}
}
return true
}),
})
// Collect every string message from a Formik errors tree (objects, arrays, strings).
// Variant-level errors (e.g. 'Each SKU can have only one quantity feature') can get
// hidden by Formik when index-level errors are also present, so we walk everything.
const collectErrorMessages = (errors: unknown, depth = 0): string[] => {
if (depth > 6) return []
if (typeof errors === 'string') return [errors]
if (Array.isArray(errors)) return errors.flatMap((e) => collectErrorMessages(e, depth + 1))
if (errors && typeof errors === 'object') {
return Object.values(errors).flatMap((e) => collectErrorMessages(e, depth + 1))
}
return []
}
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
mode,
initialValues,
onSubmit,
isLoading,
existingVariantImages = [],
existingVariantImageKeys = [],
}, ref) => {
const [variantImages, setVariantImages] = useState<ImageUploaderNeoItem[][]>(() =>
initialValues.variants.length > 0
? initialValues.variants.map((_, i) => existingVariantImages[i] || [])
: [[]]
)
useImperativeHandle(ref, () => ({
clearImages: () => setVariantImages(initialValues.variants.map(() => [])),
}), [initialValues.variants])
const { data: storesData } = trpc.common.getStoresSummary.useQuery()
const storeOptions = storesData?.stores.map((store) => ({
label: store.name,
value: store.id,
})) || []
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery()
const skuOptions = (skusData?.skus || []).map((sku) => ({
label: sku.label,
value: sku.id,
}))
// Make sure every SKU starts with a 'quantity' feature (auto-added at the
// beginning if the provided initial values don't have one).
const formInitialValues = useMemo(() => {
return {
...initialValues,
variants: (initialValues.variants || []).map((v) => {
const hasQuantity = (v.features || []).some(
(a) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
)
if (hasQuantity) return v
return { ...v, features: [quantityAttribute(), ...(v.features || [])] }
}),
}
}, [initialValues])
return (
<Formik
initialValues={formInitialValues}
validationSchema={productValidationSchema}
onSubmit={(values) => {
// Normalize feature names before sending to backend: the mandatory
// 'quantity' feature must always go out lowercase (case-insensitive
// matching means 'Quantity'/'QUANTITY' are treated the same), and
// names are trimmed like the backend does.
// TextInputs emit strings — coerce price fields to the numeric types
// the backend zod schema requires (price required; market/flash nullable).
const toNumber = (value: unknown): number | null => {
if (value == null || value === '') return null
const n = Number(value)
return Number.isFinite(n) ? n : null
}
const normalizedValues: ProductFormData = {
...values,
variants: values.variants.map((v) => ({
...v,
price: toNumber(v.price) ?? undefined,
marketPrice: toNumber(v.marketPrice),
flashPrice: toNumber(v.flashPrice),
features: v.features.map((a) => ({
...a,
featureName: isQuantityFeature(a) ? 'quantity' : (a.featureName ?? '').trim() || null,
})),
})),
}
const images = variantImages.map((imgs) =>
imgs.map((img) => ({ url: img.imgUrl, mimeType: img.mimeType }))
)
const deletedKeys: string[] = []
if (mode === 'edit') {
variantImages.forEach((currentImgs, vIndex) => {
const existing = existingVariantImages[vIndex] || []
existing.forEach((existingImg) => {
if (!currentImgs.some((cur) => cur.imgUrl === existingImg.imgUrl)) {
const key = existingVariantImageKeys[vIndex]?.[existingVariantImages[vIndex]?.indexOf(existingImg)]
if (key) deletedKeys.push(key)
}
})
})
}
onSubmit(normalizedValues, images, deletedKeys)
}}
enableReinitialize
>
{({ handleChange, handleSubmit, values, setFieldValue, validateForm }) => {
return (
<div className="flex-1 overflow-auto pb-10">
<PInput
topLabel="Product Name"
placeholder="Enter product name"
value={values.name}
onChange={handleChange('name')}
style={{ marginBottom: 16 }}
/>
<PInput
topLabel="Short Description"
placeholder="Enter short description"
multiline
numberOfLines={2}
value={values.shortDescription}
onChange={handleChange('shortDescription')}
style={{ marginBottom: 16 }}
/>
<PInput
topLabel="Long Description"
placeholder="Enter detailed description"
multiline
numberOfLines={4}
value={values.longDescription}
onChange={handleChange('longDescription')}
style={{ marginBottom: 16 }}
/>
<div className="mb-4" data-testid="product-store-select">
<P className="mb-1 text-sm text-gray-500 font-medium">Store</P>
<Dropdown
label="Select store"
value={values.storeId}
options={storeOptions}
onValueChange={(value) => setFieldValue('storeId', Number(value))}
className="w-full"
/>
</div>
<div className="mb-4" data-testid="product-type-select">
<P className="mb-1 text-sm text-gray-500 font-medium">Product Type</P>
<Dropdown
label="Select product type"
value={values.productType}
options={[
{ label: 'Item', value: 'item' },
{ label: 'Combo', value: 'combo' },
]}
onValueChange={(value) => setFieldValue('productType', value as 'item' | 'combo')}
className="w-full"
/>
</div>
<FieldArray name="variants">
{({ push, remove }) => (
<div>
<div className="flex flex-row justify-between items-center mb-3">
<P className="text-lg font-bold text-gray-800">Variants</P>
<button
type="button"
onClick={() => {
push(defaultVariant())
setVariantImages((prev) => [...prev, []])
}}
className="bg-blue-500 px-3 py-1 rounded-lg flex flex-row items-center text-white font-semibold"
>
<Plus className="h-4 w-4" />
<span className="ml-1">Add Variant</span>
</button>
</div>
{values.variants.map((variant, vIndex) => {
const isExistingSku = variant.id != null
const isMarkedDeleted = !!variant.isDeleted
return (
<div key={vIndex} className={`border border-gray-300 rounded-xl p-4 mb-4 ${isMarkedDeleted ? 'border-red-300 bg-red-50 opacity-80' : ''}`}>
<div className="flex flex-row justify-between items-center mb-3">
<div className="flex flex-row items-center">
<P className="font-bold text-gray-700">Variant {vIndex + 1}</P>
{isMarkedDeleted && (
<div className="ml-2 px-2 py-0.5 rounded-full bg-red-100 border border-red-200">
<P className="text-xs font-bold text-red-700 uppercase">Deleted</P>
</div>
)}
</div>
{(values.variants.length > 1 || isExistingSku) && (
<button
type="button"
onClick={() => {
if (isExistingSku) {
// Mark existing SKU as deleted (sent to backend on save).
setFieldValue(`variants.${vIndex}.isDeleted`, !isMarkedDeleted)
} else {
remove(vIndex)
setVariantImages((prev) => prev.filter((_, i) => i !== vIndex))
}
}}
aria-label={isMarkedDeleted ? 'Restore variant' : 'Delete variant'}
>
{isMarkedDeleted ? (
<RotateCcw className="h-5 w-5 text-green-500" />
) : (
<Trash2 className="h-5 w-5 text-red-500" />
)}
</button>
)}
</div>
<PInput
topLabel="SKU Name (optional)"
placeholder="Overrides the auto-generated name"
value={variant.name ?? ''}
onChange={handleChange(`variants.${vIndex}.name`)}
style={{ marginBottom: 12 }}
/>
<FieldArray name={`variants.${vIndex}.features`}>
{({ push: pushAttr, remove: removeAttr }) => (
<div className="mb-3">
<div className="flex flex-row justify-between items-center mb-2">
<P className="font-medium text-gray-600">Attributes</P>
<button
type="button"
onClick={() => pushAttr(defaultAttribute())}
className="bg-gray-200 px-2 py-0.5 rounded flex flex-row items-center text-gray-600 text-xs"
>
<Plus className="h-3.5 w-3.5 text-gray-600" />
<span className="ml-0.5">Add</span>
</button>
</div>
{variant.features.map((attr, aIndex) => {
const quantityCount = variant.features.filter(isQuantityFeature).length
// Quantity features are locked (non-editable), but if there
// are duplicates (e.g. 'quantity' + 'Quantity') the user must
// be able to delete the extras to fix the form.
const canDelete = variant.features.length > 1 && (!isQuantityFeature(attr) || quantityCount > 1)
return (
<div key={aIndex} className="flex flex-row items-center mb-2 gap-2">
<div className="flex-1">
<PInput
placeholder="Name"
value={attr.featureName ?? ''}
onChange={(e) =>
setFieldValue(
`variants.${vIndex}.features.${aIndex}.featureName`,
e.target.value.trim().toLowerCase() === 'quantity' ? 'quantity' : e.target.value
)
}
disabled={isQuantityFeature(attr)}
className={isQuantityFeature(attr) ? 'text-gray-400' : undefined}
/>
</div>
<div className="flex-1">
<PInput
placeholder={isQuantityFeature(attr) ? 'e.g. 0.5 kg' : 'Value'}
value={attr.featureValue}
onChange={handleChange(`variants.${vIndex}.features.${aIndex}.featureValue`)}
/>
</div>
{canDelete && (
<button type="button" onClick={() => removeAttr(aIndex)} aria-label="Remove attribute">
<X className="h-4 w-4 text-red-500" />
</button>
)}
</div>
)
})}
<div className="flex flex-row items-center self-start">
<button
type="button"
onClick={() => pushAttr(defaultAttribute())}
className="bg-gray-200 px-2 py-0.5 rounded flex flex-row items-center text-gray-600 text-xs"
>
<Plus className="h-3.5 w-3.5 text-gray-600" />
<span className="ml-0.5">Add Feature</span>
</button>
<InfoDialog
message="Quantity is mandatory feature. Add other features optionally with name. Features become part of name — ex: a feature with Mini as value becomes <ItemName> Mini"
className="ml-1"
/>
</div>
</div>
)}
</FieldArray>
<div className="flex flex-row gap-2 mb-3">
<div className="flex-1">
<PInput
topLabel="Market Price"
placeholder="MRP"
type="number"
inputMode="numeric"
value={variant.marketPrice?.toString() ?? ''}
onChange={handleChange(`variants.${vIndex}.marketPrice`)}
/>
</div>
<div className="flex-1">
<PInput
topLabel="Our Price"
placeholder="Selling price"
type="number"
inputMode="numeric"
value={variant.price?.toString() ?? ''}
onChange={handleChange(`variants.${vIndex}.price`)}
/>
</div>
</div>
<div className="flex flex-row items-center mb-3">
<Checkbox
checked={variant.isFlashAvailable}
onPress={() => {
setFieldValue(`variants.${vIndex}.isFlashAvailable`, !variant.isFlashAvailable)
if (variant.isFlashAvailable) setFieldValue(`variants.${vIndex}.flashPrice`, null)
}}
/>
<P className="text-gray-700 font-medium ml-3">Flash Available</P>
</div>
<div className="flex flex-row items-center mb-3">
<Checkbox
checked={variant.isOffer}
onPress={() => setFieldValue(`variants.${vIndex}.isOffer`, !variant.isOffer)}
/>
<P className="text-gray-700 font-medium ml-3">Offer SKU</P>
</div>
{!(values.productType === 'combo' && (variant.comboItems?.length || 0) > 0) && (
<div className="flex flex-row items-center mb-3">
<Checkbox
checked={variant.isComboOnly}
onPress={() => setFieldValue(`variants.${vIndex}.isComboOnly`, !variant.isComboOnly)}
/>
<P className="text-gray-700 font-medium ml-3">Combo Only SKU</P>
</div>
)}
{mode === 'edit' && (
<div className="flex flex-row items-center mb-3" data-testid={`variant-suspend-row-${vIndex}`}>
<Checkbox
checked={variant.isSuspended}
onPress={() => setFieldValue(`variants.${vIndex}.isSuspended`, !variant.isSuspended)}
/>
<P className="text-gray-700 font-medium ml-3">Suspend SKU</P>
</div>
)}
{variant.isFlashAvailable && (
<PInput
topLabel="Flash Price"
placeholder="Enter flash price"
type="number"
inputMode="numeric"
value={variant.flashPrice?.toString() ?? ''}
onChange={handleChange(`variants.${vIndex}.flashPrice`)}
style={{ marginBottom: 12 }}
/>
)}
<ImageUploaderNeo
images={variantImages[vIndex] || []}
onImageAdd={(payloads) =>
setVariantImages((prev) => {
const next = [...prev]
next[vIndex] = [...(next[vIndex] || []), ...payloads.map((pl) => ({ imgUrl: pl.url, mimeType: pl.mimeType }))]
return next
})
}
onImageRemove={(payload) =>
setVariantImages((prev) => {
const next = [...prev]
next[vIndex] = (next[vIndex] || []).filter((img) => img.imgUrl !== payload.url)
return next
})
}
allowMultiple={true}
/>
{values.productType === 'combo' && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="flex flex-row justify-between items-center mb-2">
<P className="font-medium text-gray-600">Included Items</P>
<button
type="button"
onClick={() => {
const items = variant.comboItems || []
items.push({ skuId: 0 })
setFieldValue(`variants.${vIndex}.comboItems`, items)
}}
className="bg-gray-200 px-2 py-0.5 rounded flex flex-row items-center text-gray-600 text-xs"
>
<Plus className="h-3.5 w-3.5 text-gray-600" />
<span className="ml-0.5">Add</span>
</button>
</div>
{variant.comboItems?.map((ci, cIndex) => (
<div key={cIndex} className="flex flex-row items-center gap-2 mb-2">
<div className="flex-1">
<SearchableSelect
label="SKU"
value={ci.skuId}
options={skuOptions.map((opt: { label: string; value: number }) => ({
...opt,
// Disable SKUs already picked in another row of this combo.
disabled: (variant.comboItems || []).some(
(item, idx) => idx !== cIndex && String(item.skuId) === String(opt.value)
),
}))}
onValueChange={(val) => {
const current = variant.comboItems || []
const isDuplicate = current.some(
(item, idx) => idx !== cIndex && String(item.skuId) === String(val)
)
if (isDuplicate) {
window.alert('Duplicate: This product is already in the combo')
return
}
setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, Number(val))
}}
placeholder="Select SKU"
/>
</div>
<button
type="button"
onClick={() => {
const items = variant.comboItems.filter((_, i) => i !== cIndex)
setFieldValue(`variants.${vIndex}.comboItems`, items)
}}
aria-label="Remove combo item"
>
<X className="h-4 w-4 text-red-500" />
</button>
</div>
))}
</div>
)}
</div>
)
})}
<button
type="button"
onClick={() => {
push(defaultVariant())
setVariantImages((prev) => [...prev, []])
}}
className="bg-blue-500 px-3 py-2 rounded-lg flex flex-row items-center justify-center mb-4 text-white font-semibold w-full"
>
<Plus className="h-4 w-4" />
<span className="ml-1">Add Variant</span>
</button>
</div>
)}
</FieldArray>
<button
type="button"
data-testid="product-submit-button"
onClick={async () => {
const validationErrors = await validateForm()
if (Object.keys(validationErrors).length > 0) {
const variantsMessages = collectErrorMessages(validationErrors.variants)
const allMessages = collectErrorMessages(validationErrors)
const message = variantsMessages[0] || allMessages[0] || 'Please fix the highlighted fields'
window.alert(`Check your form: ${String(message)}`)
return
}
handleSubmit()
}}
disabled={isLoading}
className={`px-4 py-3 rounded-lg shadow-lg items-center mt-4 text-white text-lg font-bold w-full disabled:opacity-70 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
>
{(() => {
if (mode === 'edit') {
return isLoading ? 'Saving...' : 'Save Changes'
}
return isLoading ? 'Creating...' : 'Create Product'
})()}
</button>
</div>
)
}}
</Formik>
)
})
ProductForm.displayName = 'ProductForm'
export default ProductForm

View file

@ -0,0 +1,140 @@
import { type FC } from 'react'
import { useFormik } from 'formik'
import { p as P, pInput as PInput } from 'web-components'
import ProductsSelector from './ProductsSelector'
import { trpc } from '@/lib/trpc-client'
import { X } from 'lucide-react'
import type { AdminProductGroup as SharedAdminProductGroup } from '@packages/shared'
// Serialized product group (string date, loosely typed products) anchored to shared
export type ProductGroup = Omit<SharedAdminProductGroup, 'createdAt' | 'products'> & {
createdAt: string
products: any[]
}
interface ProductGroupFormProps {
group?: ProductGroup | null
onClose: () => void
onSuccess: () => void
}
const ProductGroupForm: FC<ProductGroupFormProps> = ({
group,
onClose,
onSuccess,
}) => {
// Fetch products
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery()
const createGroup = trpc.admin.product.createGroup.useMutation()
const updateGroup = trpc.admin.product.updateGroup.useMutation()
const isEditing = !!group
const products = productsData?.products || []
const formik = useFormik({
initialValues: {
group_name: group?.groupName || '',
description: group?.description || '',
product_ids: group?.products?.map(p => p.id) || [],
},
validate: (values) => {
const errors: {[key: string]: string} = {}
if (!values.group_name.trim()) {
errors.group_name = 'Group name is required'
}
return errors
},
onSubmit: async (values) => {
try {
if (isEditing) {
await updateGroup.mutateAsync({
id: group.id,
group_name: values.group_name,
description: values.description,
product_ids: values.product_ids,
})
} else {
await createGroup.mutateAsync({
group_name: values.group_name,
description: values.description,
product_ids: values.product_ids,
})
}
onSuccess()
} catch (error: any) {
window.alert(`Error: ${error.message || 'Failed to save group'}`)
}
},
})
return (
<div className="flex-1 bg-white">
{/* Header */}
<div className="flex flex-row items-center justify-between p-6 border-b border-gray-200">
<P className="text-xl font-bold text-gray-900">
{isEditing ? 'Edit Product Group' : 'Create Product Group'}
</P>
<button type="button" onClick={onClose} className="p-2" aria-label="Close">
<X className="h-6 w-6 text-gray-500" />
</button>
</div>
<div className="flex-1 overflow-auto px-6 py-4">
{/* Group Name */}
<PInput
topLabel="Group Name *"
placeholder="Enter group name"
value={formik.values.group_name}
onChange={(e) => formik.setFieldValue('group_name', e.target.value)}
style={{ marginBottom: 16 }}
/>
{formik.errors.group_name && (
<P className="text-red-500 text-sm mt-1 mb-4">{formik.errors.group_name}</P>
)}
{/* Description */}
<PInput
topLabel="Description"
placeholder="Enter description (optional)"
multiline
numberOfLines={3}
value={formik.values.description}
onChange={(e) => formik.setFieldValue('description', e.target.value)}
style={{ marginBottom: 16 }}
/>
{/* Products Selection */}
<ProductsSelector
value={formik.values.product_ids}
onChange={(value) => formik.setFieldValue('product_ids', value as number[])}
multiple={true}
label="Products"
placeholder="Select products"
labelFormat={(product) => product.label}
/>
{/* Actions */}
<div className="flex flex-row gap-4">
<div
onClick={onClose}
className="flex-1 bg-gray-200 rounded-lg py-4 items-center text-center text-gray-700 font-medium cursor-pointer"
>
Cancel
</div>
<div
onClick={() => formik.handleSubmit()}
className="flex-1 bg-brand-600 rounded-lg py-4 items-center text-center text-white font-medium cursor-pointer"
>
{createGroup.isPending || updateGroup.isPending ? 'Saving...' : 'Save'}
</div>
</div>
</div>
</div>
)
}
export default ProductGroupForm

View file

@ -0,0 +1,39 @@
import type { FC } from 'react'
import { BottomDialog, p as P } from 'web-components'
import type { IdName } from '@packages/shared'
type VendorSnippetProduct = IdName
interface ProductListDialogProps {
open: boolean
onClose: () => void
products: VendorSnippetProduct[]
}
export const ProductListDialog: FC<ProductListDialogProps> = ({
open,
onClose,
products,
}) => {
return (
<BottomDialog open={open} onClose={onClose}>
<div className="pt-4 pb-8">
<P className="text-lg font-bold mb-4 text-slate-900 block">
Products ({products.length})
</P>
<div className="overflow-auto" style={{ maxHeight: 400 }}>
{products.map((product, index) => (
<div
key={product.id}
className={`py-3 border-b border-slate-100 ${index === products.length - 1 ? 'border-b-0' : ''}`}
>
<P className="text-sm text-slate-800 font-medium">
{product.name}
</P>
</div>
))}
</div>
</div>
</BottomDialog>
)
}

View file

@ -0,0 +1,181 @@
import React, { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc-client'
import type { SkuSummary as SharedSkuSummary } from '@packages/shared'
import MultiSelect from './MultiSelect'
import SearchableSelect from './SearchableSelect'
// Selector works with skus that may not have images resolved yet
type SkuSummary = Omit<SharedSkuSummary, 'images'>
interface Group {
id: number
groupName: string
products: { id: number }[]
}
interface ProductsSelectorProps {
value: number | number[]
onChange: (value: number | number[]) => void
multiple?: boolean
label?: string
placeholder?: string
disabled?: boolean
error?: boolean
isDisabled?: (product: SkuSummary) => boolean
labelFormat?: (product: SkuSummary) => string
groups?: Group[]
selectedGroupIds?: number[]
onGroupChange?: (groupIds: number[]) => void
showGroups?: boolean
testID?: string
}
export default function ProductsSelector({
value,
onChange,
multiple = true,
label = 'Select Products',
placeholder = 'Select products',
disabled = false,
error = false,
isDisabled,
labelFormat,
groups = [],
showGroups = true,
selectedGroupIds = [],
onGroupChange,
testID,
}: ProductsSelectorProps) {
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery()
const allSkus: SkuSummary[] = skusData?.skus || []
const [searchQuery, setSearchQuery] = useState('')
// Handle group selection changes
const handleGroupChange = (newGroupIds: number[]) => {
if (!onGroupChange) return
const previousGroupIds = selectedGroupIds
const addedGroups = newGroupIds.filter(id => !previousGroupIds.includes(id))
const removedGroups = previousGroupIds.filter(id => !newGroupIds.includes(id))
let currentSkus = Array.isArray(value) ? [...value] : value ? [value] : []
const addedSkus = addedGroups.flatMap(groupId => {
const group = groups.find(g => g.id === groupId)
if (!group) return []
const productIds = new Set(group.products.map(p => p.id))
return allSkus.filter(s => productIds.has(s.productId)).map(s => s.id)
})
const removedSkus = removedGroups.flatMap(groupId => {
const group = groups.find(g => g.id === groupId)
if (!group) return []
const productIds = new Set(group.products.map(p => p.id))
return allSkus.filter(s => productIds.has(s.productId)).map(s => s.id)
})
currentSkus = [...new Set([...currentSkus, ...addedSkus])]
currentSkus = currentSkus.filter(id => !removedSkus.includes(id))
onGroupChange(newGroupIds)
if (multiple) {
onChange(currentSkus.length > 0 ? currentSkus : [])
} else {
onChange(currentSkus.length > 0 ? currentSkus[0] : 0)
}
}
// Filter products based on search query
const filteredSkus = useMemo(() => {
if (!searchQuery.trim()) return allSkus
const query = searchQuery.toLowerCase()
return allSkus.filter(sku =>
sku.label.toLowerCase().includes(query) ||
sku.productName.toLowerCase().includes(query)
)
}, [allSkus, searchQuery])
// Build dropdown options
const productOptions = useMemo(() => {
return filteredSkus.map((sku) => {
const isFromGroup = selectedGroupIds.length > 0 && groups.some(group => {
if (!selectedGroupIds.includes(group.id)) return false
return group.products.some(p => p.id === sku.productId)
})
const isProductDisabled = isDisabled ? isDisabled(sku) : false
const displayLabel = labelFormat
? labelFormat(sku)
: sku.label
return {
label: `${displayLabel}${isFromGroup ? ' (from group)' : ''}`,
value: sku.id.toString(),
disabled: isProductDisabled,
}
})
}, [filteredSkus, selectedGroupIds, groups, isDisabled, labelFormat])
// Build group options if groups are provided
const groupOptions = useMemo(() => {
return groups.map(group => ({
label: group.groupName,
value: group.id.toString(),
}))
}, [groups])
return (
<div className="w-full">
{showGroups && groups.length > 0 && (
<div className="mb-4">
<MultiSelect
testID="product-groups-dropdown"
label="Select Product Groups"
options={groupOptions}
value={selectedGroupIds.map(id => id.toString())}
onValueChange={(selectedValue) => {
const selectedValues = Array.isArray(selectedValue) ? selectedValue : typeof selectedValue === 'string' ? [selectedValue] : []
const newGroupIds = selectedValues.map(v => parseInt(v as string))
handleGroupChange(newGroupIds)
}}
placeholder="Select product groups (optional)"
/>
</div>
)}
{multiple ? (
<MultiSelect
label={label}
testID={testID}
options={productOptions}
value={Array.isArray(value) ? value.map(id => id.toString()) : []}
onValueChange={(selectedValue) => {
const selectedValues = Array.isArray(selectedValue) ? selectedValue : typeof selectedValue === 'string' ? [selectedValue] : []
onChange(selectedValues.map(v => Number(v)))
}}
placeholder={placeholder}
disabled={disabled}
error={error}
onSearch={setSearchQuery}
/>
) : (
<SearchableSelect
label={label}
testID={testID}
options={productOptions}
value={value ? value.toString() : ''}
onValueChange={(selectedValue) => {
onChange(Number(selectedValue))
}}
placeholder={placeholder}
disabled={disabled}
error={error}
onSearch={setSearchQuery}
/>
)}
</div>
)
}

View file

@ -0,0 +1,114 @@
import { useMemo, useState } from 'react'
import { BottomDialog, SearchBar, p as P } from 'web-components'
import { Check, ChevronDown } from 'lucide-react'
export interface SearchableSelectOption {
label: string
value: string | number
disabled?: boolean
}
interface SearchableSelectProps {
label: string
options: SearchableSelectOption[]
value: string | number
onValueChange: (v: string | number) => void
onSearch?: (q: string) => void
placeholder?: string
disabled?: boolean
error?: boolean
className?: string
testID?: string
// Tolerated for screen compatibility (RN BottomDropdown leftover) — ignored on web
triggerComponent?: any
}
export function SearchableSelect({
label,
options,
value,
onValueChange,
onSearch,
placeholder,
disabled = false,
error = false,
className,
testID,
}: SearchableSelectProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const selectedLabel = useMemo(() => {
const found = options.find((o) => String(o.value) === String(value))
return found ? found.label : null
}, [options, value])
const displayText = selectedLabel ?? placeholder ?? label
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return options
return options.filter((o) => o.label.toLowerCase().includes(q))
}, [options, query])
const handleSelect = (optionValue: string | number) => {
onValueChange(optionValue)
setOpen(false)
}
return (
<div className={className} data-testid={testID}>
<P className="mb-1 text-sm text-gray-500 font-medium">{label}</P>
<button
type="button"
disabled={disabled}
onClick={() => setOpen(true)}
className={`flex w-full items-center justify-between rounded-md border bg-white px-3 py-2 text-sm text-left disabled:cursor-not-allowed disabled:opacity-50 ${error ? 'border-red-500' : 'border-gray-300'}`}
>
<span className={`truncate ${selectedLabel ? 'text-gray-800' : 'text-gray-400'}`}>
{displayText}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-gray-400" />
</button>
<BottomDialog open={open} onClose={() => setOpen(false)}>
<div className="p-4">
<P className="text-lg font-semibold mb-4">{label}</P>
<SearchBar
placeholder="Search..."
value={query}
onChange={(q) => {
setQuery(q)
onSearch?.(q)
}}
onSearch={onSearch}
className="mb-3"
/>
<div className="overflow-y-auto" style={{ maxHeight: 320 }}>
{filtered.map((option) => {
const selected = String(option.value) === String(value)
return (
<button
key={String(option.value)}
type="button"
disabled={option.disabled}
onClick={() => handleSelect(option.value)}
className={`flex flex-row items-center w-full p-3 border-b border-gray-100 text-left ${option.disabled ? 'opacity-50' : 'cursor-pointer hover:bg-gray-50'} ${selected ? 'bg-blue-50' : ''}`}
>
<span className={`text-sm flex-1 ${selected ? 'font-semibold text-blue-700' : 'text-gray-800'}`}>
{option.label}
</span>
{selected ? <Check className="h-4 w-4 text-blue-600" /> : null}
</button>
)
})}
{filtered.length === 0 ? (
<P className="text-center text-gray-500 text-sm p-4">No options found</P>
) : null}
</div>
</div>
</BottomDialog>
</div>
)
}
export default SearchableSelect

Some files were not shown because too many files have changed in this diff Show more