Compare commits
36 commits
product_to
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 884ec8f791 | |||
| 1446cc33e4 | |||
|
|
d0b74fb61a | ||
| 021dfdb9c8 | |||
| 9bca425f11 | |||
|
|
af64049dce | ||
|
|
1e7db8f016 | ||
|
|
c4a33b91da | ||
|
|
5591afb57f | ||
|
|
faea152d5d | ||
|
|
b68b2abe18 | ||
|
|
8aaaa12ca0 | ||
|
|
ee60799a8b | ||
|
|
f8e7eec754 | ||
|
|
cc17653402 | ||
|
|
86792d1ba3 | ||
|
|
02d307e5f2 | ||
|
|
c29fbb9a84 | ||
|
|
4aa7d8a1d4 | ||
|
|
0bfd43c172 | ||
|
|
5d7648c6b0 | ||
|
|
7537780caa | ||
|
|
b486b54e09 | ||
|
|
b5fd64e5ed | ||
|
|
ffcab51d98 | ||
|
|
1ff771355e | ||
|
|
d58530075d | ||
|
|
bfb67e626b | ||
|
|
7ad1b81356 | ||
|
|
cfb84aae7a | ||
|
|
a825d5285b | ||
|
|
3ebbf989f0 | ||
|
|
be71f8eaa1 | ||
|
|
adb81a6b3d | ||
|
|
7064625cba | ||
|
|
54b265f459 |
494 changed files with 42316 additions and 30934 deletions
|
|
@ -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(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(curl -s http://localhost:4174/src/styles.css 2 > /dev/null)",
|
||||||
"Shell(printf:*)",
|
"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": [],
|
"deny": [],
|
||||||
"defaultMode": "default"
|
"defaultMode": "default"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
# Taste
|
# 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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 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 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
|
- 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
|
- 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
|
- 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
|
- 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 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 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
|
- 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
|
- 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
|
- 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 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
|
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
|
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
|
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
|
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
|
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
|
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
1
.gitignore
vendored
|
|
@ -132,3 +132,4 @@ dist
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
|
||||||
|
type-clusters/*
|
||||||
|
|
@ -48,3 +48,6 @@ react-native. They are available in the common-ui as MyText, MyTextInput, MyTouc
|
||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
- Don't do anything with git. Don't do git add or git commit. That will be managed entirely by the user
|
- 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
313
ANY_USAGE_REPORT.md
Normal 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` (+43–48) | 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
162
DEAD_CODE_REPORT.md
Normal 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.)
|
||||||
|
|
||||||
|
|
@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
6
apps/admin-ui/.expo/types/router.d.ts
vendored
6
apps/admin-ui/.expo/types/router.d.ts
vendored
File diff suppressed because one or more lines are too long
|
|
@ -63,7 +63,7 @@ export default function Complaints() {
|
||||||
if (!selectedComplaintId) return;
|
if (!selectedComplaintId) return;
|
||||||
|
|
||||||
resolveComplaint.mutate(
|
resolveComplaint.mutate(
|
||||||
{ id: String(selectedComplaintId), response },
|
{ id: String(selectedComplaintId), response: response ?? '' },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
Alert.alert("Success", "Complaint marked as resolved");
|
Alert.alert("Success", "Complaint marked as resolved");
|
||||||
|
|
|
||||||
|
|
@ -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 “{searchQuery}”</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import type { StoreProductCard } from '@packages/shared'
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Alert,
|
Alert,
|
||||||
|
|
@ -27,20 +28,12 @@ const { width: screenWidth } = Dimensions.get("window");
|
||||||
const itemWidth = screenWidth - 48; // 24px padding each side
|
const itemWidth = screenWidth - 48; // 24px padding each side
|
||||||
const itemHeight = 80;
|
const itemHeight = 80;
|
||||||
|
|
||||||
interface Product {
|
// Reorder row — anchored to the shared store-card core
|
||||||
id: number;
|
type Product = Pick<StoreProductCard, 'id' | 'name' | 'images' | 'isOutOfStock'>
|
||||||
name: string;
|
|
||||||
images: string[];
|
|
||||||
isOutOfStock: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProductItemProps {
|
import type { DragOrderItemProps } from '@/types/drag-order-item';
|
||||||
item: Product;
|
|
||||||
drag: () => void;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProductItem: React.FC<ProductItemProps> = ({
|
const ProductItem: React.FC<DragOrderItemProps<Product>> = ({
|
||||||
item,
|
item,
|
||||||
drag,
|
drag,
|
||||||
isActive,
|
isActive,
|
||||||
|
|
@ -101,7 +94,7 @@ export default function AllItemsOrder() {
|
||||||
|
|
||||||
// Get current order from constants
|
// Get current order from constants
|
||||||
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery();
|
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();
|
const updateConstants = trpc.admin.const.updateConstants.useMutation();
|
||||||
|
|
||||||
// Initialize products from constants
|
// Initialize products from constants
|
||||||
|
|
|
||||||
|
|
@ -24,26 +24,22 @@ import { useRouter } from "expo-router";
|
||||||
import { trpc } from "../../../../src/trpc-client";
|
import { trpc } from "../../../../src/trpc-client";
|
||||||
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { StoreProductCard } from '@packages/shared';
|
||||||
|
import type { DragOrderItemProps } from '@/types/drag-order-item';
|
||||||
|
|
||||||
interface PopularProduct {
|
// Popular-items row = shared store card core with numeric (form) prices
|
||||||
id: number;
|
type PopularProduct = Omit<
|
||||||
name: string;
|
StoreProductCard,
|
||||||
shortDescription: string | null;
|
'price' | 'marketPrice' | 'unitNotation' | 'images'
|
||||||
|
> & {
|
||||||
price: number;
|
price: number;
|
||||||
marketPrice: number | null;
|
marketPrice: number | null;
|
||||||
unit: string;
|
images: string[] | null;
|
||||||
incrementStep: number;
|
|
||||||
productQuantity: number;
|
|
||||||
storeId: number | null;
|
storeId: number | null;
|
||||||
isOutOfStock: boolean;
|
|
||||||
nextDeliveryDate: string | null;
|
nextDeliveryDate: string | null;
|
||||||
images: string[];
|
};
|
||||||
}
|
|
||||||
|
|
||||||
interface ProductItemProps {
|
interface ProductItemProps extends DragOrderItemProps<PopularProduct> {
|
||||||
item: PopularProduct;
|
|
||||||
drag: () => void;
|
|
||||||
isActive: boolean;
|
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,7 +121,7 @@ export default function CustomizePopularItems() {
|
||||||
|
|
||||||
// Get current popular items from constants
|
// Get current popular items from constants
|
||||||
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery();
|
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();
|
const updateConstants = trpc.admin.const.updateConstants.useMutation();
|
||||||
|
|
||||||
// Initialize popular products from constants
|
// Initialize popular products from constants
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,20 @@ import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { FormikHelpers } from 'formik';
|
import { FormikHelpers } from 'formik';
|
||||||
import BannerForm, { BannerFormData } from '@/components/BannerForm';
|
import BannerForm, { BannerFormData } from '@/components/BannerForm';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import type { Banner as SharedBanner } from '@packages/shared';
|
||||||
|
|
||||||
interface Banner {
|
// Form/hardened view of the shared banner (string dates, non-null editable fields)
|
||||||
id: number;
|
type Banner = Omit<
|
||||||
name: string;
|
SharedBanner,
|
||||||
imageUrl: string;
|
'description' | 'skuIds' | 'redirectUrl' | 'serialNum' | 'createdAt' | 'lastUpdated'
|
||||||
|
> & {
|
||||||
description?: string;
|
description?: string;
|
||||||
skuIds?: number[];
|
skuIds?: number[];
|
||||||
redirectUrl?: string;
|
redirectUrl?: string;
|
||||||
serialNum: number;
|
serialNum: number;
|
||||||
isActive: boolean;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastUpdated: string;
|
lastUpdated: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function EditBanner() {
|
export default function EditBanner() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,13 @@ import { AppContainer, MyText, tw, MyTouchableOpacity } from 'common-ui';
|
||||||
import { trpc } from '../../../../src/trpc-client';
|
import { trpc } from '../../../../src/trpc-client';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
|
import type { Banner as SharedBanner } from '@packages/shared';
|
||||||
|
|
||||||
interface Banner {
|
// Serialized banner row (API JSON carries string dates) anchored to the shared shape
|
||||||
id: number;
|
type Banner = Omit<SharedBanner, 'createdAt' | 'lastUpdated'> & {
|
||||||
name: string;
|
|
||||||
imageUrl: string;
|
|
||||||
description: string | null;
|
|
||||||
skuIds: number[] | null;
|
|
||||||
redirectUrl: string | null;
|
|
||||||
serialNum: number | null;
|
|
||||||
isActive: boolean;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastUpdated: string;
|
lastUpdated: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function DashboardBanners() {
|
export default function DashboardBanners() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { View, ScrollView, Pressable } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { MyText, tw } from 'common-ui';
|
import { MyText, tw } from 'common-ui';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
|
||||||
import { theme } from 'common-ui/src/theme';
|
import { theme } from 'common-ui/src/theme';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import { useNavigationTarget } from 'common-ui/hooks/useNavigationTarget';
|
import { useNavigationTarget } from 'common-ui/hooks/useNavigationTarget';
|
||||||
|
|
|
||||||
|
|
@ -537,11 +537,6 @@ export default function DeliverySequences() {
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={tw`flex-1`}>
|
<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
|
<DraggableFlatList
|
||||||
data={localOrderedOrders}
|
data={localOrderedOrders}
|
||||||
renderItem={({ item, drag, isActive }) => (
|
renderItem={({ item, drag, isActive }) => (
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Entypo } from '@expo/vector-icons';
|
||||||
import CancelOrderDialog from '@/components/CancelOrderDialog';
|
import CancelOrderDialog from '@/components/CancelOrderDialog';
|
||||||
import { OrderOptionsMenu } from '@/components/OrderOptionsMenu';
|
import { OrderOptionsMenu } from '@/components/OrderOptionsMenu';
|
||||||
import * as Location from 'expo-location';
|
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 AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId: string; existingNotes?: string | null; onClose: () => void; refetch: () => void }) => {
|
||||||
const [notesText, setNotesText] = useState(existingNotes || '');
|
const [notesText, setNotesText] = useState(existingNotes || '');
|
||||||
|
|
@ -51,43 +52,32 @@ const AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId:
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
interface OrderType {
|
// Order list row — derived from shared AdminOrderListItem (serialized dates,
|
||||||
id: number;
|
// coupon display fields, optional flags)
|
||||||
orderId: string;
|
type OrderItemRow = Omit<
|
||||||
readableId: number;
|
AdminOrderListItemProduct,
|
||||||
customerName: string | null;
|
'id' | 'skuName' | 'features' | 'isPackaged' | 'isPackageVerified'
|
||||||
customerMobile?: string | null;
|
> & {
|
||||||
address: string;
|
|
||||||
addressId: number;
|
|
||||||
latitude: number | null;
|
|
||||||
longitude: number | null;
|
|
||||||
totalAmount: number;
|
|
||||||
deliveryCharge: number;
|
|
||||||
items: {
|
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
amount: number;
|
|
||||||
unit: string;
|
|
||||||
isPackaged?: boolean;
|
isPackaged?: boolean;
|
||||||
isPackageVerified?: 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;
|
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;
|
adminNotes?: string | null;
|
||||||
userNotes?: string | null;
|
userNotes?: string | null;
|
||||||
userNegativityScore?: number;
|
userNegativityScore?: number;
|
||||||
}
|
couponCode?: string;
|
||||||
|
couponDescription?: string;
|
||||||
|
discountAmount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => {
|
const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => {
|
||||||
const id = order.orderId;
|
const id = order.orderId;
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,7 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
|
import type { ProductGroup } from "@/components/ProductGroupForm";
|
||||||
interface ProductGroup {
|
|
||||||
id: number;
|
|
||||||
groupName: string;
|
|
||||||
description: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
products: any[];
|
|
||||||
productCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GroupItem = ({
|
const GroupItem = ({
|
||||||
group,
|
group,
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,7 @@ import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
|
||||||
import TagForm from '@/src/components/TagForm';
|
import TagForm from '@/src/components/TagForm';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||||
|
import type { TagFormData } from '@/src/components/TagForm';
|
||||||
interface TagFormData {
|
|
||||||
tagName: string;
|
|
||||||
tagDescription: string;
|
|
||||||
isDashboardTag: boolean;
|
|
||||||
relatedStores: number[];
|
|
||||||
productIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddTag() {
|
export default function AddTag() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,12 @@ import { View, Alert } from 'react-native';
|
||||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
|
import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
|
||||||
import TagForm from '@/src/components/TagForm';
|
import TagForm from '@/src/components/TagForm';
|
||||||
|
import type { TagFormData as BaseTagFormData } from '@/src/components/TagForm';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||||
|
|
||||||
interface TagFormData {
|
// Edit screen adds the current image to the canonical TagForm data
|
||||||
tagName: string;
|
type TagFormData = BaseTagFormData & { existingImageUrl?: string };
|
||||||
tagDescription: string;
|
|
||||||
isDashboardTag: boolean;
|
|
||||||
relatedStores: number[];
|
|
||||||
productIds: number[];
|
|
||||||
existingImageUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditTag() {
|
export default function EditTag() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import type { ProductTagCore } from '@packages/shared'
|
||||||
import { View, TouchableOpacity, Alert, RefreshControl } from 'react-native';
|
import { View, TouchableOpacity, Alert, RefreshControl } from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
|
|
@ -7,13 +8,12 @@ import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'c
|
||||||
import { TagMenu } from '@/src/components/TagMenu';
|
import { TagMenu } from '@/src/components/TagMenu';
|
||||||
import { trpc } from '@/src/trpc-client';
|
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;
|
id: number;
|
||||||
tagName: string;
|
|
||||||
tagDescription: string | null;
|
tagDescription: string | null;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
isDashboardTag: boolean;
|
isDashboardTag: boolean;
|
||||||
relatedStores?: unknown;
|
|
||||||
createdAt: string | Date;
|
createdAt: string | Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { useRouter } from 'expo-router';
|
||||||
import { trpc } from '../../../../src/trpc-client';
|
import { trpc } from '../../../../src/trpc-client';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { DragOrderItemProps } from '@/types/drag-order-item';
|
||||||
|
|
||||||
const { width: screenWidth } = Dimensions.get('window');
|
const { width: screenWidth } = Dimensions.get('window');
|
||||||
const itemWidth = screenWidth - 48;
|
const itemWidth = screenWidth - 48;
|
||||||
|
|
@ -32,13 +33,7 @@ interface Tag {
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TagItemProps {
|
const TagItem: React.FC<DragOrderItemProps<Tag>> = ({ item, drag, isActive }) => {
|
||||||
item: Tag;
|
|
||||||
drag: () => void;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TagItem: React.FC<TagItemProps> = ({ item, drag, isActive }) => {
|
|
||||||
return (
|
return (
|
||||||
<ScaleDecorator>
|
<ScaleDecorator>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ export default function AddProduct() {
|
||||||
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], _deletedImageKeys?: string[]) => {
|
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], _deletedImageKeys?: string[]) => {
|
||||||
try {
|
try {
|
||||||
for (const variant of values.variants) {
|
for (const variant of values.variants) {
|
||||||
const price = parseFloat(variant.price)
|
const price = variant.price
|
||||||
if (isNaN(price) || price <= 0) {
|
if (price == null || price <= 0) {
|
||||||
Alert.alert('Error', 'Please enter a valid price for every variant')
|
Alert.alert('Error', 'Please enter a valid price for every variant')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -24,15 +24,15 @@ export default function AddProduct() {
|
||||||
|
|
||||||
const seenSignatures = new Set<string>()
|
const seenSignatures = new Set<string>()
|
||||||
for (const variant of values.variants) {
|
for (const variant of values.variants) {
|
||||||
const attributes = variant.attributes || []
|
const features = variant.features || []
|
||||||
const hasQuantity = attributes.some(
|
const hasQuantity = features.some(
|
||||||
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||||
)
|
)
|
||||||
if (!hasQuantity) {
|
if (!hasQuantity) {
|
||||||
Alert.alert('Error', 'Every SKU must have a quantity feature')
|
Alert.alert('Error', 'Every SKU must have a quantity feature')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const signature = attributes
|
const signature = features
|
||||||
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
||||||
.sort()
|
.sort()
|
||||||
.join('|')
|
.join('|')
|
||||||
|
|
@ -68,21 +68,21 @@ export default function AddProduct() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: variant.name || null,
|
name: variant.name || null,
|
||||||
price: parseFloat(variant.price),
|
price: variant.price as number,
|
||||||
marketPrice: variant.marketPrice ? parseFloat(variant.marketPrice) : undefined,
|
marketPrice: variant.marketPrice ?? undefined,
|
||||||
images: variantUrls,
|
images: variantUrls,
|
||||||
isFlashAvailable: variant.isFlashAvailable || false,
|
isFlashAvailable: variant.isFlashAvailable || false,
|
||||||
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
|
flashPrice: variant.flashPrice ?? undefined,
|
||||||
isOffer: variant.isOffer || false,
|
isOffer: variant.isOffer || false,
|
||||||
isComboOnly: variant.isComboOnly || false,
|
isComboOnly: variant.isComboOnly || false,
|
||||||
isDeleted: variant.isDeleted || false,
|
isDeleted: variant.isDeleted || false,
|
||||||
isSuspended: variant.isSuspended || false,
|
isSuspended: variant.isSuspended || false,
|
||||||
features: variant.attributes.map((attr: any) => ({
|
features: variant.features.map((attr: any) => ({
|
||||||
featureName: attr.featureName,
|
featureName: attr.featureName,
|
||||||
featureValue: attr.featureValue,
|
featureValue: attr.featureValue,
|
||||||
})),
|
})),
|
||||||
comboItems: (variant.comboItems || []).map((ci: any) => ({
|
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,
|
id: undefined as number | undefined,
|
||||||
name: '',
|
name: '',
|
||||||
price: '',
|
price: undefined,
|
||||||
marketPrice: '',
|
marketPrice: null,
|
||||||
isFlashAvailable: false,
|
isFlashAvailable: false,
|
||||||
flashPrice: '',
|
flashPrice: null,
|
||||||
isOffer: false,
|
isOffer: false,
|
||||||
isComboOnly: false,
|
isComboOnly: false,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
attributes: [{ featureName: 'quantity', featureValue: '' }],
|
features: [{ featureName: 'quantity', featureValue: '' }],
|
||||||
comboItems: [] as { skuId: number | string }[],
|
comboItems: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export default function EditProduct() {
|
||||||
shortDescription: '',
|
shortDescription: '',
|
||||||
longDescription: '',
|
longDescription: '',
|
||||||
storeId: 0,
|
storeId: 0,
|
||||||
|
productType: 'item' as const,
|
||||||
variants: [],
|
variants: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -45,20 +46,20 @@ export default function EditProduct() {
|
||||||
variants: (productData.skus || []).map((sku) => ({
|
variants: (productData.skus || []).map((sku) => ({
|
||||||
id: sku.id,
|
id: sku.id,
|
||||||
name: sku.name || '',
|
name: sku.name || '',
|
||||||
price: sku.price || '',
|
price: sku.price ? parseFloat(sku.price) : undefined,
|
||||||
marketPrice: sku.marketPrice || '',
|
marketPrice: sku.marketPrice ? parseFloat(sku.marketPrice) : null,
|
||||||
isFlashAvailable: sku.isFlashAvailable || false,
|
isFlashAvailable: sku.isFlashAvailable || false,
|
||||||
flashPrice: sku.flashPrice || '',
|
flashPrice: sku.flashPrice ? parseFloat(sku.flashPrice) : null,
|
||||||
isOffer: sku.isOffer || false,
|
isOffer: sku.isOffer || false,
|
||||||
isComboOnly: sku.isComboOnly || false,
|
isComboOnly: sku.isComboOnly || false,
|
||||||
isDeleted: sku.isDeleted || false,
|
isDeleted: sku.isDeleted || false,
|
||||||
isSuspended: sku.isSuspended || false,
|
isSuspended: sku.isSuspended || false,
|
||||||
attributes: (sku.features || []).map((f) => ({
|
features: (sku.features || []).map((f) => ({
|
||||||
featureName: f.featureName,
|
featureName: f.featureName,
|
||||||
featureValue: f.featureValue,
|
featureValue: f.featureValue,
|
||||||
})),
|
})),
|
||||||
comboItems: (sku.comboItems || []).map((ci: any) => ({
|
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[]) => {
|
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => {
|
||||||
try {
|
try {
|
||||||
for (const variant of values.variants) {
|
for (const variant of values.variants) {
|
||||||
const price = parseFloat(variant.price)
|
const price = variant.price
|
||||||
if (isNaN(price) || price <= 0) {
|
if (price == null || price <= 0) {
|
||||||
Alert.alert('Error', 'Please enter a valid price for every variant')
|
Alert.alert('Error', 'Please enter a valid price for every variant')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -90,15 +91,15 @@ export default function EditProduct() {
|
||||||
|
|
||||||
const seenSignatures = new Set<string>()
|
const seenSignatures = new Set<string>()
|
||||||
for (const variant of values.variants) {
|
for (const variant of values.variants) {
|
||||||
const attributes = variant.attributes || []
|
const features = variant.features || []
|
||||||
const hasQuantity = attributes.some(
|
const hasQuantity = features.some(
|
||||||
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||||
)
|
)
|
||||||
if (!hasQuantity) {
|
if (!hasQuantity) {
|
||||||
Alert.alert('Error', 'Every SKU must have a quantity feature')
|
Alert.alert('Error', 'Every SKU must have a quantity feature')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const signature = attributes
|
const signature = features
|
||||||
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
||||||
.sort()
|
.sort()
|
||||||
.join('|')
|
.join('|')
|
||||||
|
|
@ -146,21 +147,21 @@ export default function EditProduct() {
|
||||||
return {
|
return {
|
||||||
id: variant.id,
|
id: variant.id,
|
||||||
name: variant.name || null,
|
name: variant.name || null,
|
||||||
price: parseFloat(variant.price),
|
price: variant.price as number,
|
||||||
marketPrice: variant.marketPrice ? parseFloat(variant.marketPrice) : undefined,
|
marketPrice: variant.marketPrice ?? undefined,
|
||||||
images: allUrls,
|
images: allUrls,
|
||||||
isFlashAvailable: variant.isFlashAvailable || false,
|
isFlashAvailable: variant.isFlashAvailable || false,
|
||||||
flashPrice: variant.flashPrice ? parseFloat(variant.flashPrice) : undefined,
|
flashPrice: variant.flashPrice ?? undefined,
|
||||||
isOffer: variant.isOffer || false,
|
isOffer: variant.isOffer || false,
|
||||||
isComboOnly: variant.isComboOnly || false,
|
isComboOnly: variant.isComboOnly || false,
|
||||||
isDeleted: variant.isDeleted || false,
|
isDeleted: variant.isDeleted || false,
|
||||||
isSuspended: variant.isSuspended || false,
|
isSuspended: variant.isSuspended || false,
|
||||||
features: variant.attributes.map((attr: any) => ({
|
features: variant.features.map((attr: any) => ({
|
||||||
featureName: attr.featureName,
|
featureName: attr.featureName,
|
||||||
featureValue: attr.featureValue,
|
featureValue: attr.featureValue,
|
||||||
})),
|
})),
|
||||||
comboItems: (variant.comboItems || []).map((ci: any) => ({
|
comboItems: (variant.comboItems || []).map((ci: any) => ({
|
||||||
skuId: typeof ci.skuId === 'string' ? parseInt(ci.skuId) : ci.skuId,
|
skuId: ci.skuId,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ import type { AdminSku } from '@packages/shared';
|
||||||
|
|
||||||
type FilterType = 'all' | 'in-stock' | 'out-of-stock';
|
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
|
return product.skus?.[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,7 +131,6 @@ export default function Products() {
|
||||||
<SearchBar
|
<SearchBar
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChangeText={setSearchTerm}
|
onChangeText={setSearchTerm}
|
||||||
onSearch={() => {}}
|
|
||||||
placeholder="Search products..."
|
placeholder="Search products..."
|
||||||
containerStyle={tw`mb-0`}
|
containerStyle={tw`mb-0`}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,13 @@ import {
|
||||||
tw,
|
tw,
|
||||||
MyTextInput,
|
MyTextInput,
|
||||||
BottomDropdown,
|
BottomDropdown,
|
||||||
ImageUploader,
|
|
||||||
} from 'common-ui';
|
} from 'common-ui';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import type { UserMiniInfo } from '@packages/shared';
|
||||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
|
||||||
|
|
||||||
interface User {
|
// Notification recipient = shared mini user + eligibility flag
|
||||||
id: number;
|
type User = Omit<UserMiniInfo, 'name'> & {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
mobile: string | null;
|
|
||||||
isEligibleForNotif: boolean;
|
isEligibleForNotif: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,8 +29,6 @@ export default function SendNotifications() {
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [message, setMessage] = 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('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// Query users eligible for notifications
|
// Query users eligible for notifications
|
||||||
|
|
@ -41,8 +36,6 @@ export default function SendNotifications() {
|
||||||
search: searchQuery,
|
search: searchQuery,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { uploadSingle } = useUploadToObjectStorage();
|
|
||||||
|
|
||||||
// Send notification mutation
|
// Send notification mutation
|
||||||
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -51,8 +44,6 @@ export default function SendNotifications() {
|
||||||
setSelectedUserIds([]);
|
setSelectedUserIds([]);
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
setSelectedImage(null);
|
|
||||||
setDisplayImage(null);
|
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
Alert.alert('Error', error.message || 'Failed to send notification');
|
Alert.alert('Error', error.message || 'Failed to send notification');
|
||||||
|
|
@ -66,29 +57,6 @@ export default function SendNotifications() {
|
||||||
value: user.id,
|
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 () => {
|
const handleSend = async () => {
|
||||||
if (title.trim().length === 0) {
|
if (title.trim().length === 0) {
|
||||||
Alert.alert('Error', 'Please enter a title');
|
Alert.alert('Error', 'Please enter a title');
|
||||||
|
|
@ -117,20 +85,11 @@ export default function SendNotifications() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let imageUrl: string | undefined;
|
|
||||||
|
|
||||||
// Upload image if selected
|
|
||||||
if (selectedImage) {
|
|
||||||
const { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification');
|
|
||||||
imageUrl = key;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send notification
|
// Send notification
|
||||||
await sendNotification.mutateAsync({
|
await sendNotification.mutateAsync({
|
||||||
userIds: selectedUserIds,
|
userIds: selectedUserIds,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
text: message.trim(),
|
text: message.trim(),
|
||||||
imageUrl,
|
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
Alert.alert('Error', error.message || 'Failed to send notification');
|
Alert.alert('Error', error.message || 'Failed to send notification');
|
||||||
|
|
@ -196,17 +155,6 @@ export default function SendNotifications() {
|
||||||
/>
|
/>
|
||||||
</View>
|
</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 */}
|
{/* User Selection */}
|
||||||
<View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
|
<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>
|
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Select Users (Optional)</MyText>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
import type { OrderCardActionProps } from '@packages/shared'
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
|
@ -28,9 +29,7 @@ interface Order {
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrderItemProps {
|
interface OrderItemProps extends OrderCardActionProps<Order> {
|
||||||
order: Order;
|
|
||||||
onPress: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import type { OrderDialogBaseProps } from '@packages/shared'
|
||||||
import { View, TouchableOpacity } from 'react-native';
|
import { View, TouchableOpacity } from 'react-native';
|
||||||
import { MyText, tw, BottomDialog, MyTextInput } from 'common-ui';
|
import { MyText, tw, BottomDialog, MyTextInput } from 'common-ui';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native';
|
||||||
|
|
||||||
interface CancelOrderDialogProps {
|
interface CancelOrderDialogProps extends OrderDialogBaseProps {
|
||||||
orderId: number;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -5,15 +5,13 @@ import { MyText, tw, MyTextInput, MyTouchableOpacity, theme, BottomDropdown } fr
|
||||||
import ProductsSelector from './ProductsSelector';
|
import ProductsSelector from './ProductsSelector';
|
||||||
import { trpc } from '../src/trpc-client';
|
import { trpc } from '../src/trpc-client';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import type { AdminProductGroup as SharedAdminProductGroup } from '@packages/shared';
|
||||||
|
|
||||||
interface ProductGroup {
|
// Serialized product group (string date, loosely typed products) anchored to shared
|
||||||
id: number;
|
export type ProductGroup = Omit<SharedAdminProductGroup, 'createdAt' | 'products'> & {
|
||||||
groupName: string;
|
|
||||||
description: string | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
products: any[];
|
products: any[];
|
||||||
productCount: number;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
interface ProductGroupFormProps {
|
interface ProductGroupFormProps {
|
||||||
group?: ProductGroup | null;
|
group?: ProductGroup | null;
|
||||||
|
|
@ -27,7 +25,7 @@ const ProductGroupForm: React.FC<ProductGroupFormProps> = ({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}) => {
|
}) => {
|
||||||
// Fetch products
|
// 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 createGroup = trpc.admin.product.createGroup.useMutation();
|
||||||
const updateGroup = trpc.admin.product.updateGroup.useMutation();
|
const updateGroup = trpc.admin.product.updateGroup.useMutation();
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, ScrollView } from 'react-native';
|
import { View, ScrollView } from 'react-native';
|
||||||
import { BottomDialog, MyText, tw } from 'common-ui';
|
import { BottomDialog, MyText, tw } from 'common-ui';
|
||||||
|
import type { IdName } from '@packages/shared';
|
||||||
|
|
||||||
interface VendorSnippetProduct {
|
type VendorSnippetProduct = IdName;
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProductListDialogProps {
|
interface ProductListDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,10 @@ import { View } from 'react-native';
|
||||||
import BottomDropdown, { DropdownOption } from 'common-ui/src/components/bottom-dropdown';
|
import BottomDropdown, { DropdownOption } from 'common-ui/src/components/bottom-dropdown';
|
||||||
import { trpc } from '../src/trpc-client';
|
import { trpc } from '../src/trpc-client';
|
||||||
import { tw } from 'common-ui';
|
import { tw } from 'common-ui';
|
||||||
|
import type { SkuSummary as SharedSkuSummary } from '@packages/shared';
|
||||||
|
|
||||||
interface SkuSummary {
|
// Selector works with skus that may not have images resolved yet
|
||||||
id: number;
|
type SkuSummary = Omit<SharedSkuSummary, 'images'>
|
||||||
productId: number;
|
|
||||||
productName: string;
|
|
||||||
label: string;
|
|
||||||
storeId: number | null;
|
|
||||||
price: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Group {
|
interface Group {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -50,7 +45,7 @@ export default function ProductsSelector({
|
||||||
selectedGroupIds = [],
|
selectedGroupIds = [],
|
||||||
onGroupChange,
|
onGroupChange,
|
||||||
}: ProductsSelectorProps) {
|
}: ProductsSelectorProps) {
|
||||||
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery({});
|
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery();
|
||||||
const allSkus: SkuSummary[] = skusData?.skus || [];
|
const allSkus: SkuSummary[] = skusData?.skus || [];
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import type { SlotSnippetInput } from '@packages/shared'
|
||||||
import { View, Text, TouchableOpacity, Alert } from 'react-native';
|
import { View, Text, TouchableOpacity, Alert } from 'react-native';
|
||||||
import { Formik, FieldArray } from 'formik';
|
import { Formik, FieldArray } from 'formik';
|
||||||
import DateTimePickerMod from 'common-ui/src/components/date-time-picker';
|
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 { trpc } from '../src/trpc-client';
|
||||||
import ProductsSelector from '../components/ProductsSelector';
|
import ProductsSelector from '../components/ProductsSelector';
|
||||||
|
|
||||||
interface VendorSnippet {
|
// Snippet form row — single source in @packages/shared (complete payload)
|
||||||
name: string;
|
type VendorSnippet = SlotSnippetInput
|
||||||
groupIds: number[];
|
|
||||||
skuIds: number[];
|
|
||||||
validTill?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SlotFormProps {
|
interface SlotFormProps {
|
||||||
onSlotAdded?: () => void;
|
onSlotAdded?: () => void;
|
||||||
|
|
@ -41,7 +38,7 @@ export default function SlotForm({
|
||||||
name: snippet.name || '',
|
name: snippet.name || '',
|
||||||
groupIds: snippet.groupIds || [],
|
groupIds: snippet.groupIds || [],
|
||||||
skuIds: snippet.skuIds || [],
|
skuIds: snippet.skuIds || [],
|
||||||
validTill: snippet.validTill || undefined,
|
validTill: snippet.validTill || null,
|
||||||
})) as VendorSnippet[];
|
})) as VendorSnippet[];
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
|
|
@ -80,7 +77,7 @@ export default function SlotForm({
|
||||||
vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({
|
vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({
|
||||||
name: snippet.name,
|
name: snippet.name,
|
||||||
skuIds: snippet.skuIds,
|
skuIds: snippet.skuIds,
|
||||||
validTill: snippet.validTill,
|
validTill: snippet.validTill ?? undefined,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,25 @@ import React from 'react';
|
||||||
import { View, ScrollView, TouchableOpacity } from 'react-native';
|
import { View, ScrollView, TouchableOpacity } from 'react-native';
|
||||||
import { MyText, tw, AppContainer } from 'common-ui';
|
import { MyText, tw, AppContainer } from 'common-ui';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import type {
|
||||||
|
AdminVendorSnippetOrderProduct,
|
||||||
|
AdminVendorSnippetOrderSummary as SharedSnippetOrderSummary,
|
||||||
|
} from '@packages/shared';
|
||||||
|
|
||||||
interface OrderProduct {
|
// Line item + order summary anchored to the shared vendor-order contract
|
||||||
productId: number;
|
// (string totalAmount + any[] sequence at this view layer)
|
||||||
productName: string;
|
type OrderProduct = Pick<
|
||||||
quantity: number;
|
AdminVendorSnippetOrderProduct,
|
||||||
price: number;
|
'productId' | 'productName' | 'quantity' | 'price' | 'unit' | 'subtotal'
|
||||||
unit: string;
|
>
|
||||||
subtotal: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SnippetOrder {
|
type SnippetOrder = Omit<SharedSnippetOrderSummary, 'totalAmount' | 'slotInfo' | 'products'> & {
|
||||||
orderId: string;
|
totalAmount: string
|
||||||
orderDate: string;
|
|
||||||
customerName: string;
|
|
||||||
totalAmount: string;
|
|
||||||
slotInfo: {
|
slotInfo: {
|
||||||
time: string;
|
time: string;
|
||||||
sequence: any[];
|
sequence: any[];
|
||||||
} | null;
|
} | null;
|
||||||
products: OrderProduct[];
|
products: OrderProduct[];
|
||||||
matchedProducts: number[];
|
|
||||||
snippetCode: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SnippetOrdersViewProps {
|
interface SnippetOrdersViewProps {
|
||||||
|
|
|
||||||
|
|
@ -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 { View, TouchableOpacity, Alert } from 'react-native';
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
@ -7,18 +7,13 @@ import ProductsSelector from './ProductsSelector';
|
||||||
import { trpc } from '../src/trpc-client';
|
import { trpc } from '../src/trpc-client';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore';
|
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore';
|
||||||
|
import type { CreateStoreInput } from '@packages/shared';
|
||||||
|
|
||||||
export interface StoreFormData {
|
// Store form values — required description + product selection on top of CreateStoreInput
|
||||||
name: string;
|
export type StoreFormData = Omit<CreateStoreInput, 'description'> & {
|
||||||
description: string;
|
description: string;
|
||||||
imageUrl?: string;
|
|
||||||
owner: number;
|
|
||||||
products: number[];
|
products: number[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface StoreFormRef {
|
|
||||||
// Add methods if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoreFormProps {
|
interface StoreFormProps {
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
|
|
@ -36,8 +31,7 @@ const validationSchema = Yup.object().shape({
|
||||||
products: Yup.array().of(Yup.number()),
|
products: Yup.array().of(Yup.number()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
function StoreForm({ mode, initialValues, onSubmit, isLoading, storeId }: StoreFormProps) {
|
||||||
const { mode, initialValues, onSubmit, isLoading, storeId } = props;
|
|
||||||
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery();
|
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery();
|
||||||
const { data: productsData } = trpc.admin.product.getProducts.useQuery();
|
const { data: productsData } = trpc.admin.product.getProducts.useQuery();
|
||||||
|
|
||||||
|
|
@ -199,8 +193,6 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
||||||
}}
|
}}
|
||||||
</Formik>
|
</Formik>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
StoreForm.displayName = 'StoreForm';
|
|
||||||
|
|
||||||
export default StoreForm;
|
export default StoreForm;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import { AppContainer } from "common-ui";
|
|
||||||
|
|
||||||
export default AppContainer;
|
|
||||||
|
|
@ -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;
|
|
||||||
// };
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -5,11 +5,9 @@ import { DeviceEventEmitter } from "react-native";
|
||||||
import { FORCE_LOGOUT_EVENT } from "common-ui/src/lib/const-strs";
|
import { FORCE_LOGOUT_EVENT } from "common-ui/src/lib/const-strs";
|
||||||
import { trpc } from "@/src/trpc-client";
|
import { trpc } from "@/src/trpc-client";
|
||||||
import { saveJWT, getJWT, deleteJWT } from "@/hooks/useJWT";
|
import { saveJWT, getJWT, deleteJWT } from "@/hooks/useJWT";
|
||||||
|
import type { IdName } from '@packages/shared';
|
||||||
|
|
||||||
interface Staff {
|
type Staff = IdName;
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StaffAuthContextType {
|
interface StaffAuthContextType {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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} />;
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -12,6 +12,10 @@
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
"channel": "dev"
|
"channel": "dev"
|
||||||
},
|
},
|
||||||
|
"shafi-dev": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"channel": "shafi-dev"
|
||||||
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
"channel": "preview"
|
"channel": "preview"
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { useColorScheme } from 'react-native';
|
|
||||||
|
|
@ -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';
|
|
||||||
}
|
|
||||||
|
|
@ -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};
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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 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 async function saveJWT(token: string) {
|
export async function saveJWT(token: string) {
|
||||||
await StorageService.setItem(JWT_KEY, token);
|
await StorageService.setItem(JWT_KEY, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getJWT() {
|
export async function getJWT() {
|
||||||
return await StorageService.getItem(JWT_KEY);
|
return await StorageService.getItem(JWT_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteJWT() {
|
export async function deleteJWT() {
|
||||||
await StorageService.removeItem(JWT_KEY);
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { trpc } from '../src/trpc-client';
|
import { trpc } from '../src/trpc-client';
|
||||||
|
import type { ContextString, UploadInput, UploadBatchInput, UploadResult } from '@packages/shared';
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUploadToObjectStorage() {
|
export function useUploadToObjectStorage() {
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -152,7 +152,6 @@ export default function CouponForm({ onSubmit, isLoading, initialValues }: Coupo
|
||||||
const newSelection = current.includes(userId)
|
const newSelection = current.includes(userId)
|
||||||
? current.filter(id => id !== userId)
|
? current.filter(id => id !== userId)
|
||||||
: [...current, userId];
|
: [...current, userId];
|
||||||
console.log('Toggling user:', userId, 'New selection:', newSelection);
|
|
||||||
setFieldValue('applicableUsers', newSelection);
|
setFieldValue('applicableUsers', newSelection);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,25 +5,29 @@ import * as Yup from 'yup'
|
||||||
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload, Checkbox, InfoDialog } from 'common-ui'
|
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload, Checkbox, InfoDialog } from 'common-ui'
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
|
||||||
import { trpc } from '../trpc-client'
|
import { trpc } from '../trpc-client'
|
||||||
|
import type { CreateComboItemInput, CreateSkuInput } from '@packages/shared'
|
||||||
|
import type { SkuFeatureLike } from '@packages/shared'
|
||||||
|
|
||||||
interface Attribute {
|
// Feature input shape — single source: shared SkuFeatureLike
|
||||||
featureName: string | null
|
type Attribute = SkuFeatureLike
|
||||||
featureValue: string
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
id?: number
|
||||||
name: string
|
name: string
|
||||||
price: string
|
price?: number
|
||||||
marketPrice: string
|
marketPrice: number | null
|
||||||
|
flashPrice: number | null
|
||||||
isFlashAvailable: boolean
|
isFlashAvailable: boolean
|
||||||
flashPrice: string
|
|
||||||
isOffer: boolean
|
isOffer: boolean
|
||||||
isComboOnly: boolean
|
isComboOnly: boolean
|
||||||
isDeleted?: boolean
|
|
||||||
isSuspended: boolean
|
isSuspended: boolean
|
||||||
attributes: Attribute[]
|
features: Attribute[]
|
||||||
comboItems: { skuId: number | string }[]
|
comboItems: CreateComboItemInput[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductFormData {
|
interface ProductFormData {
|
||||||
|
|
@ -59,15 +63,15 @@ const isQuantityFeature = (attr: Attribute): boolean =>
|
||||||
const defaultVariant = (): Variant => ({
|
const defaultVariant = (): Variant => ({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
name: '',
|
name: '',
|
||||||
price: '',
|
price: undefined,
|
||||||
marketPrice: '',
|
marketPrice: null,
|
||||||
isFlashAvailable: false,
|
isFlashAvailable: false,
|
||||||
flashPrice: '',
|
flashPrice: null,
|
||||||
isOffer: false,
|
isOffer: false,
|
||||||
isComboOnly: false,
|
isComboOnly: false,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
attributes: [quantityAttribute()],
|
features: [quantityAttribute()],
|
||||||
comboItems: [],
|
comboItems: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -101,7 +105,7 @@ const productValidationSchema = Yup.object().shape({
|
||||||
.nullable()
|
.nullable()
|
||||||
.transform((value, originalValue) => (originalValue === '' ? null : value))
|
.transform((value, originalValue) => (originalValue === '' ? null : value))
|
||||||
.optional(),
|
.optional(),
|
||||||
attributes: Yup.array()
|
features: Yup.array()
|
||||||
.min(1, 'At least one attribute is required')
|
.min(1, 'At least one attribute is required')
|
||||||
.of(
|
.of(
|
||||||
Yup.object().shape({
|
Yup.object().shape({
|
||||||
|
|
@ -114,7 +118,7 @@ const productValidationSchema = Yup.object().shape({
|
||||||
if (!Array.isArray(variants)) return true
|
if (!Array.isArray(variants)) return true
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
const attrs = variant?.attributes || []
|
const attrs = variant?.features || []
|
||||||
const signature = variantSignature(attrs as Attribute[])
|
const signature = variantSignature(attrs as Attribute[])
|
||||||
if (seen.has(signature)) {
|
if (seen.has(signature)) {
|
||||||
return this.createError({ message: 'Two variants have the same attributes' })
|
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) {
|
.test('quantity-feature', 'Each SKU must have exactly one quantity feature', function (variants) {
|
||||||
if (!Array.isArray(variants)) return true
|
if (!Array.isArray(variants)) return true
|
||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
const attrs = variant?.attributes || []
|
const attrs = variant?.features || []
|
||||||
const quantityCount = attrs.filter(
|
const quantityCount = attrs.filter(
|
||||||
(a) => ((a as Attribute)?.featureName ?? '').trim().toLowerCase() === 'quantity'
|
(a) => ((a as Attribute)?.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||||
).length
|
).length
|
||||||
|
|
@ -178,10 +182,10 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
value: store.id,
|
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) => ({
|
const skuOptions = (skusData?.skus || []).map((sku) => ({
|
||||||
label: sku.label,
|
label: sku.label,
|
||||||
value: sku.id.toString(),
|
value: sku.id,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Make sure every SKU starts with a 'quantity' feature (auto-added at the
|
// Make sure every SKU starts with a 'quantity' feature (auto-added at the
|
||||||
|
|
@ -190,11 +194,11 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
return {
|
return {
|
||||||
...initialValues,
|
...initialValues,
|
||||||
variants: (initialValues.variants || []).map((v) => {
|
variants: (initialValues.variants || []).map((v) => {
|
||||||
const hasQuantity = (v.attributes || []).some(
|
const hasQuantity = (v.features || []).some(
|
||||||
(a) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
(a) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||||
)
|
)
|
||||||
if (hasQuantity) return v
|
if (hasQuantity) return v
|
||||||
return { ...v, attributes: [quantityAttribute(), ...(v.attributes || [])] }
|
return { ...v, features: [quantityAttribute(), ...(v.features || [])] }
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}, [initialValues])
|
}, [initialValues])
|
||||||
|
|
@ -208,11 +212,21 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
// 'quantity' feature must always go out lowercase (case-insensitive
|
// 'quantity' feature must always go out lowercase (case-insensitive
|
||||||
// matching means 'Quantity'/'QUANTITY' are treated the same), and
|
// matching means 'Quantity'/'QUANTITY' are treated the same), and
|
||||||
// names are trimmed like the backend does.
|
// 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 = {
|
const normalizedValues: ProductFormData = {
|
||||||
...values,
|
...values,
|
||||||
variants: values.variants.map((v) => ({
|
variants: values.variants.map((v) => ({
|
||||||
...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,
|
...a,
|
||||||
featureName: isQuantityFeature(a) ? 'quantity' : (a.featureName ?? '').trim() || null,
|
featureName: isQuantityFeature(a) ? 'quantity' : (a.featureName ?? '').trim() || null,
|
||||||
})),
|
})),
|
||||||
|
|
@ -348,7 +362,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FieldArray name={`variants.${vIndex}.attributes`}>
|
<FieldArray name={`variants.${vIndex}.features`}>
|
||||||
{({ push: pushAttr, remove: removeAttr }) => (
|
{({ push: pushAttr, remove: removeAttr }) => (
|
||||||
<View style={tw`mb-3`}>
|
<View style={tw`mb-3`}>
|
||||||
<View style={tw`flex-row justify-between items-center mb-2`}>
|
<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>
|
<MyText style={tw`text-gray-600 text-xs ml-0.5`}>Add</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
{variant.attributes.map((attr, aIndex) => {
|
{variant.features.map((attr, aIndex) => {
|
||||||
const quantityCount = variant.attributes.filter(isQuantityFeature).length
|
const quantityCount = variant.features.filter(isQuantityFeature).length
|
||||||
// Quantity features are locked (non-editable), but if there
|
// Quantity features are locked (non-editable), but if there
|
||||||
// are duplicates (e.g. 'quantity' + 'Quantity') the user must
|
// are duplicates (e.g. 'quantity' + 'Quantity') the user must
|
||||||
// be able to delete the extras to fix the form.
|
// 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 (
|
return (
|
||||||
<View key={aIndex} style={tw`flex-row items-center mb-2 gap-2`}>
|
<View key={aIndex} style={tw`flex-row items-center mb-2 gap-2`}>
|
||||||
<View style={tw`flex-1`}>
|
<View style={tw`flex-1`}>
|
||||||
|
|
@ -375,7 +389,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
value={attr.featureName ?? ''}
|
value={attr.featureName ?? ''}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setFieldValue(
|
setFieldValue(
|
||||||
`variants.${vIndex}.attributes.${aIndex}.featureName`,
|
`variants.${vIndex}.features.${aIndex}.featureName`,
|
||||||
text.trim().toLowerCase() === 'quantity' ? 'quantity' : text
|
text.trim().toLowerCase() === 'quantity' ? 'quantity' : text
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -387,7 +401,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
<MyTextInput
|
<MyTextInput
|
||||||
placeholder={isQuantityFeature(attr) ? 'e.g. 0.5 kg' : 'Value'}
|
placeholder={isQuantityFeature(attr) ? 'e.g. 0.5 kg' : 'Value'}
|
||||||
value={attr.featureValue}
|
value={attr.featureValue}
|
||||||
onChangeText={handleChange(`variants.${vIndex}.attributes.${aIndex}.featureValue`)}
|
onChangeText={handleChange(`variants.${vIndex}.features.${aIndex}.featureValue`)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
|
|
@ -421,7 +435,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
topLabel="Market Price"
|
topLabel="Market Price"
|
||||||
placeholder="MRP"
|
placeholder="MRP"
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
value={variant.marketPrice}
|
value={variant.marketPrice?.toString() ?? ''}
|
||||||
onChangeText={handleChange(`variants.${vIndex}.marketPrice`)}
|
onChangeText={handleChange(`variants.${vIndex}.marketPrice`)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -430,7 +444,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
topLabel="Our Price"
|
topLabel="Our Price"
|
||||||
placeholder="Selling price"
|
placeholder="Selling price"
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
value={variant.price}
|
value={variant.price?.toString() ?? ''}
|
||||||
onChangeText={handleChange(`variants.${vIndex}.price`)}
|
onChangeText={handleChange(`variants.${vIndex}.price`)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -441,7 +455,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
checked={variant.isFlashAvailable}
|
checked={variant.isFlashAvailable}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setFieldValue(`variants.${vIndex}.isFlashAvailable`, !variant.isFlashAvailable)
|
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`}
|
style={tw`mr-3`}
|
||||||
/>
|
/>
|
||||||
|
|
@ -484,7 +498,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
topLabel="Flash Price"
|
topLabel="Flash Price"
|
||||||
placeholder="Enter flash price"
|
placeholder="Enter flash price"
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
value={variant.flashPrice}
|
value={variant.flashPrice?.toString() ?? ''}
|
||||||
onChangeText={handleChange(`variants.${vIndex}.flashPrice`)}
|
onChangeText={handleChange(`variants.${vIndex}.flashPrice`)}
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -516,7 +530,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const items = variant.comboItems || []
|
const items = variant.comboItems || []
|
||||||
items.push({ skuId: '' })
|
items.push({ skuId: 0 })
|
||||||
setFieldValue(`variants.${vIndex}.comboItems`, items)
|
setFieldValue(`variants.${vIndex}.comboItems`, items)
|
||||||
}}
|
}}
|
||||||
style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`}
|
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
|
<BottomDropdown
|
||||||
label="SKU"
|
label="SKU"
|
||||||
value={ci.skuId}
|
value={ci.skuId}
|
||||||
options={skuOptions.map((opt: { label: string; value: string }) => ({
|
options={skuOptions.map((opt: { label: string; value: number }) => ({
|
||||||
...opt,
|
...opt,
|
||||||
// Disable SKUs already picked in another row of this combo.
|
// Disable SKUs already picked in another row of this combo.
|
||||||
disabled: (variant.comboItems || []).some(
|
disabled: (variant.comboItems || []).some(
|
||||||
|
|
@ -547,7 +561,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
Alert.alert('Duplicate', 'This product is already in the combo')
|
Alert.alert('Duplicate', 'This product is already in the combo')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, val)
|
setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, Number(val))
|
||||||
}}
|
}}
|
||||||
placeholder="Select SKU"
|
placeholder="Select SKU"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,11 @@ import DraggableFlatList, { ScaleDecorator } from 'react-native-draggable-flatli
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import ProductsSelector from '@/components/ProductsSelector';
|
import ProductsSelector from '@/components/ProductsSelector';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import type { IdName } from '@packages/shared';
|
||||||
|
|
||||||
interface StoreOption {
|
type StoreOption = IdName;
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TagFormData {
|
export interface TagFormData {
|
||||||
tagName: string;
|
tagName: string;
|
||||||
tagDescription: string;
|
tagDescription: string;
|
||||||
isDashboardTag: boolean;
|
isDashboardTag: boolean;
|
||||||
|
|
|
||||||
6
apps/admin-ui/types/drag-order-item.ts
Normal file
6
apps/admin-ui/types/drag-order-item.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export interface VendorSnippetProduct {
|
import type { IdName } from '@packages/shared';
|
||||||
id: number;
|
import type { AdminVendorSnippetInput } from '@packages/shared'
|
||||||
name: string;
|
|
||||||
}
|
export type VendorSnippetProduct = IdName;
|
||||||
|
|
||||||
export interface VendorSnippet {
|
export interface VendorSnippet {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -23,12 +23,10 @@ export interface VendorSnippet {
|
||||||
} | null;
|
} | 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;
|
id: number;
|
||||||
snippetCode: string;
|
|
||||||
slotId: number;
|
slotId: number;
|
||||||
isPermanent: boolean;
|
|
||||||
skuIds: number[];
|
|
||||||
validTill: string | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -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
19
apps/admin-web/.cta.json
Normal 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
15
apps/admin-web/README.md
Normal 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.
|
||||||
9
apps/admin-web/change-log.txt
Normal file
9
apps/admin-web/change-log.txt
Normal 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
6596
apps/admin-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
59
apps/admin-web/package.json
Normal file
59
apps/admin-web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/admin-web/public/favicon.png
Normal file
BIN
apps/admin-web/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/admin-web/public/freshyo-logo.png
Normal file
BIN
apps/admin-web/public/freshyo-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/admin-web/public/logo192.png
Normal file
BIN
apps/admin-web/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
BIN
apps/admin-web/public/logo512.png
Normal file
BIN
apps/admin-web/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
25
apps/admin-web/public/manifest.json
Normal file
25
apps/admin-web/public/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
3
apps/admin-web/public/robots.txt
Normal file
3
apps/admin-web/public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
4
apps/admin-web/src/client.tsx
Normal file
4
apps/admin-web/src/client.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { hydrateRoot } from 'react-dom/client'
|
||||||
|
import { StartClient } from '@tanstack/react-start/client'
|
||||||
|
|
||||||
|
hydrateRoot(document, <StartClient />)
|
||||||
225
apps/admin-web/src/components/BannerForm.tsx
Normal file
225
apps/admin-web/src/components/BannerForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
apps/admin-web/src/components/CancelOrderDialog.tsx
Normal file
85
apps/admin-web/src/components/CancelOrderDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
558
apps/admin-web/src/components/CouponForm.tsx
Normal file
558
apps/admin-web/src/components/CouponForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
apps/admin-web/src/components/DateInput.tsx
Normal file
31
apps/admin-web/src/components/DateInput.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
apps/admin-web/src/components/DateTimeInput.tsx
Normal file
81
apps/admin-web/src/components/DateTimeInput.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
152
apps/admin-web/src/components/MultiSelect.tsx
Normal file
152
apps/admin-web/src/components/MultiSelect.tsx
Normal 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
|
||||||
70
apps/admin-web/src/components/OrderNotesForm.tsx
Normal file
70
apps/admin-web/src/components/OrderNotesForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
327
apps/admin-web/src/components/OrderOptionsMenu.tsx
Normal file
327
apps/admin-web/src/components/OrderOptionsMenu.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
660
apps/admin-web/src/components/ProductForm.tsx
Normal file
660
apps/admin-web/src/components/ProductForm.tsx
Normal 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
|
||||||
140
apps/admin-web/src/components/ProductGroupForm.tsx
Normal file
140
apps/admin-web/src/components/ProductGroupForm.tsx
Normal 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
|
||||||
39
apps/admin-web/src/components/ProductListDialog.tsx
Normal file
39
apps/admin-web/src/components/ProductListDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
181
apps/admin-web/src/components/ProductsSelector.tsx
Normal file
181
apps/admin-web/src/components/ProductsSelector.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
apps/admin-web/src/components/SearchableSelect.tsx
Normal file
114
apps/admin-web/src/components/SearchableSelect.tsx
Normal 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
|
||||||
232
apps/admin-web/src/components/SlotForm.tsx
Normal file
232
apps/admin-web/src/components/SlotForm.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
import React from 'react'
|
||||||
|
import type { SlotSnippetInput } from '@packages/shared'
|
||||||
|
import { Formik, FieldArray } from 'formik'
|
||||||
|
import DateTimeInput from './DateTimeInput'
|
||||||
|
import { p as P, pInput as PInput } from 'web-components'
|
||||||
|
import { trpc } from '@/lib/trpc-client'
|
||||||
|
import ProductsSelector from './ProductsSelector'
|
||||||
|
|
||||||
|
// Snippet form row — single source in @packages/shared (complete payload)
|
||||||
|
type VendorSnippet = SlotSnippetInput
|
||||||
|
|
||||||
|
interface SlotFormProps {
|
||||||
|
onSlotAdded?: () => void
|
||||||
|
initialDeliveryTime?: Date | null
|
||||||
|
initialFreezeTime?: Date | null
|
||||||
|
initialIsActive?: boolean
|
||||||
|
slotId?: number
|
||||||
|
initialProductIds?: number[]
|
||||||
|
initialGroupIds?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SlotForm({
|
||||||
|
onSlotAdded,
|
||||||
|
initialDeliveryTime,
|
||||||
|
initialFreezeTime,
|
||||||
|
initialIsActive = true,
|
||||||
|
slotId,
|
||||||
|
initialProductIds = [],
|
||||||
|
initialGroupIds = [],
|
||||||
|
}: SlotFormProps) {
|
||||||
|
const { data: slotData } = trpc.admin.slots.getSlotById.useQuery(
|
||||||
|
{ id: slotId! },
|
||||||
|
{ enabled: !!slotId }
|
||||||
|
)
|
||||||
|
|
||||||
|
const vendorSnippetsFromSlot = (slotData?.slot?.vendorSnippets || []).map((snippet: any) => ({
|
||||||
|
name: snippet.name || '',
|
||||||
|
groupIds: snippet.groupIds || [],
|
||||||
|
skuIds: snippet.skuIds || [],
|
||||||
|
validTill: snippet.validTill || null,
|
||||||
|
})) as VendorSnippet[]
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
deliveryTime: initialDeliveryTime || (slotData?.slot?.deliveryTime ? new Date(slotData.slot.deliveryTime) : null),
|
||||||
|
freezeTime: initialFreezeTime || (slotData?.slot?.freezeTime ? new Date(slotData.slot.freezeTime) : null),
|
||||||
|
selectedGroupIds: initialGroupIds.length > 0 ? initialGroupIds : (slotData?.slot?.groupIds || []),
|
||||||
|
selectedSkuIds: initialProductIds.length > 0 ? initialProductIds : (slotData?.slot?.products?.map((p: any) => p.id) || []),
|
||||||
|
vendorSnippetList: vendorSnippetsFromSlot,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mutate: createSlot, isPending: isCreating } = trpc.admin.slots.createSlot.useMutation()
|
||||||
|
const { mutate: updateSlot, isPending: isUpdating } = trpc.admin.slots.updateSlot.useMutation()
|
||||||
|
|
||||||
|
const isEditMode = !!slotId
|
||||||
|
const isPending = isCreating || isUpdating
|
||||||
|
|
||||||
|
const { data: groupsData } = trpc.admin.product.getGroups.useQuery()
|
||||||
|
|
||||||
|
const handleFormSubmit = (values: typeof initialValues) => {
|
||||||
|
if (!values.deliveryTime || !values.freezeTime) {
|
||||||
|
window.alert('Error: Please fill all fields')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.freezeTime > values.deliveryTime) {
|
||||||
|
window.alert('Error: Freeze time must be before or equal to delivery time')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotInputData = {
|
||||||
|
deliveryTime: values.deliveryTime.toISOString(),
|
||||||
|
freezeTime: values.freezeTime.toISOString(),
|
||||||
|
isActive: initialIsActive,
|
||||||
|
groupIds: values.selectedGroupIds,
|
||||||
|
skuIds: values.selectedSkuIds,
|
||||||
|
vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({
|
||||||
|
name: snippet.name,
|
||||||
|
skuIds: snippet.skuIds,
|
||||||
|
validTill: snippet.validTill ?? undefined,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditMode && slotId) {
|
||||||
|
updateSlot(
|
||||||
|
{ id: slotId, ...slotInputData },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
window.alert('Success: Slot updated successfully!')
|
||||||
|
onSlotAdded?.()
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.log({msg: JSON.stringify(error.message)})
|
||||||
|
|
||||||
|
window.alert(`Error: ${error.message || 'Failed to update slot'}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
createSlot(
|
||||||
|
slotInputData,
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
window.alert('Success: Slot created successfully!')
|
||||||
|
onSlotAdded?.()
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
window.alert(`Error: ${error.message || 'Failed to create slot'}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
>
|
||||||
|
{({ handleSubmit, values, setFieldValue }) => {
|
||||||
|
const mappedGroups = (groupsData?.groups || []).map(group => ({
|
||||||
|
...group,
|
||||||
|
products: group.products.map(product => ({
|
||||||
|
...product,
|
||||||
|
id: product.id,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<P className="text-xl font-bold mb-6 text-center block">
|
||||||
|
{isEditMode ? 'Edit Slot' : 'Create New Slot'}
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<P className="text-lg font-semibold mb-2 block">Delivery Date & Time</P>
|
||||||
|
<DateTimeInput
|
||||||
|
testID="slot-delivery-datetime"
|
||||||
|
value={values.deliveryTime}
|
||||||
|
setValue={(value) => setFieldValue('deliveryTime', value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<P className="text-lg font-semibold mb-2 block">Freeze Date & Time</P>
|
||||||
|
<DateTimeInput
|
||||||
|
testID="slot-freeze-datetime"
|
||||||
|
value={values.freezeTime}
|
||||||
|
setValue={(value) => setFieldValue('freezeTime', value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<ProductsSelector
|
||||||
|
testID="slot-products-selector"
|
||||||
|
value={values.selectedSkuIds}
|
||||||
|
onChange={(newSkuIds) => setFieldValue('selectedSkuIds', newSkuIds)}
|
||||||
|
groups={mappedGroups}
|
||||||
|
selectedGroupIds={values.selectedGroupIds}
|
||||||
|
onGroupChange={(newGroupIds) => setFieldValue('selectedGroupIds', newGroupIds)}
|
||||||
|
label="Select Products"
|
||||||
|
placeholder="Select products for this slot"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vendor Snippets */}
|
||||||
|
<FieldArray name="vendorSnippetList">
|
||||||
|
{({ push, remove }) => (
|
||||||
|
<div className="mb-4">
|
||||||
|
<P className="text-lg font-semibold mb-4 block">Vendor Snippets</P>
|
||||||
|
{values.vendorSnippetList.map((snippet: VendorSnippet, index: number) => (
|
||||||
|
<div key={index} className="bg-gray-50 p-4 rounded-lg mb-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<PInput
|
||||||
|
topLabel="Snippet Name *"
|
||||||
|
placeholder="Enter snippet name"
|
||||||
|
value={snippet.name}
|
||||||
|
onChange={(e) => setFieldValue(`vendorSnippetList.${index}.name`, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<ProductsSelector
|
||||||
|
value={snippet.skuIds || []}
|
||||||
|
onChange={(newSkuIds) => setFieldValue(`vendorSnippetList.${index}.skuIds`, newSkuIds)}
|
||||||
|
groups={mappedGroups.filter(group =>
|
||||||
|
values.selectedGroupIds.includes(group.id)
|
||||||
|
).map(group => ({
|
||||||
|
...group,
|
||||||
|
products: group.products.filter((prod: any) => values.selectedSkuIds.includes(prod.id))
|
||||||
|
}))}
|
||||||
|
selectedGroupIds={snippet.groupIds || []}
|
||||||
|
onGroupChange={(newGroupIds) => setFieldValue(`vendorSnippetList.${index}.groupIds`, newGroupIds)}
|
||||||
|
label="Select Products"
|
||||||
|
placeholder="Select products for snippet"
|
||||||
|
isDisabled={(sku) => !values.selectedSkuIds.includes(sku.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
className="bg-red-500 px-4 py-2 rounded-lg self-end text-white font-medium"
|
||||||
|
>
|
||||||
|
Remove Snippet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => push({ name: '', groupIds: [], skuIds: [], validTill: '' })}
|
||||||
|
className="bg-blue-500 px-4 py-3 rounded-lg items-center text-white font-medium w-full"
|
||||||
|
>
|
||||||
|
Add Vendor Snippet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FieldArray>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="create-slot-button"
|
||||||
|
aria-label="create-slot-button"
|
||||||
|
onClick={() => handleSubmit()}
|
||||||
|
disabled={isPending}
|
||||||
|
className={`${isPending ? 'bg-brand-100' : 'bg-brand-500'} p-3 rounded-lg items-center mt-6 pb-4 text-white text-base font-bold w-full disabled:opacity-70`}
|
||||||
|
>
|
||||||
|
{isPending ? (isEditMode ? 'Updating...' : 'Creating...') : (isEditMode ? 'Update Slot' : 'Create Slot')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}}
|
||||||
|
</Formik>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue