diff --git a/.env.example b/.env.example index e189117..515a268 100644 --- a/.env.example +++ b/.env.example @@ -1,25 +1,51 @@ -# Environment Variables Examples +# Found-U Environment Variables +# See README "Deploy โรงเรียนใหม่" for 1-click Vercel + Supabase setup + +# ═══════════════════════════════════════════════════════════════════ +# REQUIRED — auto-injected by Vercel + Supabase integration +# ═══════════════════════════════════════════════════════════════════ -# Supabase (Client-side / Server-side) NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY SUPABASE_SERVICE_ROLE_KEY=YOUR_SUPABASE_SERVICE_ROLE_KEY -# Cloudflare R2 API KEY +# Direct Postgres (first-boot schema hydration — Module 2) +# Vercel + Supabase: auto-synced. Local: Dashboard → Database or `vercel env pull` +# Alternative local: `bun run db:push` if schema is already complete +POSTGRES_URL_NON_POOLING=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:5432/postgres +POSTGRES_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres?pgbouncer=true + +# App identity (prompted on Vercel Deploy Button) +NEXT_PUBLIC_APP_URL=https://your-school.example.com +SCHOOL_AUTH_DOMAIN=your-school.ac.th + +# ═══════════════════════════════════════════════════════════════════ +# OPTIONAL — AI (Setup Wizard Step 2 or manual env; Module 5 reads DB first) +# ═══════════════════════════════════════════════════════════════════ + +GEMMA_API_KEY=YOUR_GEMMA_API_KEY +OPENROUTER_API_KEY=YOUR_OPENROUTER_API_KEY +OPENROUTER_MODEL=google/gemini-2.0-flash-exp:free + +# Optional encryption key override for wizard-stored AI keys (defaults to service role key) +# SETUP_SECRETS_KEY= + +# ═══════════════════════════════════════════════════════════════════ +# OPTIONAL — Storage (R2 for existing production; new deploys use Supabase Storage) +# ═══════════════════════════════════════════════════════════════════ + R2_ACCOUNT_ID=YOUR_R2_ACCOUNT_ID R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY R2_BUCKET_NAME=YOUR_R2_BUCKET_NAME R2_PUBLIC_BASE_URL=YOUR_R2_PUBLIC_BASE_URL -# Rate Limiting (Optional) +# ═══════════════════════════════════════════════════════════════════ +# OPTIONAL — Rate limiting & search tuning +# ═══════════════════════════════════════════════════════════════════ + RATE_LIMIT_MAX=100 RATE_LIMIT_WINDOW_MS=60000 - -# School login (synthetic Supabase auth email domain) -SCHOOL_AUTH_DOMAIN=your.domain.com -# App URL (ใช้โดเมนหลักใดก็ได้) -NEXT_PUBLIC_APP_URL=https://your.domain.com - -# Gemini API KEY -GEMMA_API_KEY=YOUR_GEMMA_API_KEY \ No newline at end of file +SEARCH_USE_TRGM=true +SEARCH_SIMILARITY_THRESHOLD=0.15 +AGENT_SEARCH_SIMILARITY_THRESHOLD=0.30 diff --git a/.gitignore b/.gitignore index 9a02c60..7f39dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -48,9 +48,11 @@ service-account*.json *.tsbuildinfo next-env.d.ts +# development docs/ .vscode/ .cursor/ +supabase/.temp/ ืtools/ *.csv tools/tools.txt @@ -60,4 +62,7 @@ screencapture/ Mermaid_Flowchart/ .audit/ .VSCodeCounter/ -tests/ \ No newline at end of file +tests/ +AGENTS.md +DESIGN.md +PRODUCT.md \ No newline at end of file diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 0000000..cce5e2d --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,125 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-07-09T16:30:00.000Z", + "title": "Design System: Found-U", + "extensions": { + "colorMeta": { + "line-green": { + "role": "primary", + "displayName": "Campus Green", + "canonical": "#06C755", + "tonalRamp": ["#024d20", "#036b2c", "#049c42", "#05b34d", "#06C755", "#3dd47a", "#6ee09a", "#e8f8ef"] + }, + "bg-secondary": { + "role": "neutral", + "displayName": "Hall Gray", + "canonical": "#F7F8FA", + "tonalRamp": ["#111111", "#1A1A1A", "#262626", "#374151", "#6B7280", "#9CA3AF", "#E5E7EB", "#F7F8FA"] + }, + "text-primary": { + "role": "neutral", + "displayName": "Ink", + "canonical": "#191919", + "tonalRamp": ["#0A0A0A", "#111111", "#191919", "#374151", "#4B5563", "#6B7280", "#9CA3AF", "#D1D5DB"] + } + }, + "typographyMeta": { + "display": { "displayName": "Display", "purpose": "Page greetings and primary headings." }, + "body": { "displayName": "Body", "purpose": "Forms, lists, assistant messages—always Ink on light surfaces." } + }, + "shadows": [ + { "name": "card-rest", "value": "0 1px 2px 0 rgba(0, 0, 0, 0.05)", "purpose": "Default card separation—minimal elevation only." }, + { "name": "shadow-md", "value": "0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05)", "purpose": "Modals and elevated overlays sparingly." } + ], + "motion": [ + { "name": "ease-standard", "value": "cubic-bezier(0.4, 0, 0.2, 1)", "purpose": "Default UI transitions (200ms)." }, + { "name": "ease-entrance", "value": "cubic-bezier(0.22, 1, 0.36, 1)", "purpose": "Scale-up entrance animations (280ms)." } + ], + "breakpoints": [ + { "name": "md", "value": "768px" } + ] + }, + "components": [ + { + "name": "Primary Button", + "kind": "button", + "refersTo": "button-primary", + "description": "Main CTA—report lost, submit, confirm match.", + "html": "", + "css": ".ds-btn-primary { background: #06C755; color: #fff; border: none; border-radius: 9999px; padding: 14px 32px; font-family: Kanit, sans-serif; font-size: 1rem; font-weight: 500; cursor: pointer; transition: background 0.2s ease, transform 0.2s ease; } .ds-btn-primary:hover { background: #05b34d; transform: translateY(-1px); } .ds-btn-primary:active { transform: translateY(0); } .ds-btn-primary:focus-visible { outline: 2px solid #e8f8ef; outline-offset: 2px; }" + }, + { + "name": "Secondary Button", + "kind": "button", + "refersTo": "button-secondary", + "description": "Cancel, back, low-emphasis actions.", + "html": "", + "css": ".ds-btn-secondary { background: #ECEEF1; color: #191919; border: none; border-radius: 9999px; padding: 14px 32px; font-family: Kanit, sans-serif; font-size: 1rem; font-weight: 500; cursor: pointer; transition: background 0.2s ease; } .ds-btn-secondary:hover { background: #e5e5e5; } .ds-btn-secondary:focus-visible { outline: 2px solid #e8f8ef; outline-offset: 2px; }" + }, + { + "name": "Text Field", + "kind": "input", + "refersTo": "input-field", + "description": "Filled input for forms—lost/found reports, search.", + "html": "", + "css": ".ds-input { width: 100%; background: #f5f5f5; border: none; border-radius: 0.75rem; padding: 14px 16px; font-family: Kanit, sans-serif; font-size: 1rem; color: #191919; transition: background 0.2s ease, box-shadow 0.2s ease; } .ds-input::placeholder { color: #9CA3AF; } .ds-input:focus { outline: none; background: #eeeeee; box-shadow: 0 0 0 2px #e8f8ef; }" + }, + { + "name": "Card Surface", + "kind": "card", + "refersTo": "card-surface", + "description": "List items, dashboard rows, content grouping.", + "html": "

กระเป๋าสตางค์สีดำ

พบที่ห้องสมุด ชั้น 2

", + "css": ".ds-card { background: #FFFFFF; border-radius: 1rem; box-shadow: 0 1px 2px 0 rgba(0,0,0,0.05); padding: 20px; } .ds-card-title { margin: 0 0 8px; font-family: Kanit, sans-serif; font-size: 1rem; font-weight: 500; color: #191919; } .ds-card-body { margin: 0; font-family: Kanit, sans-serif; font-size: 0.875rem; color: #6B7280; line-height: 1.5; }" + }, + { + "name": "Status Badge Found", + "kind": "chip", + "refersTo": "badge-found", + "description": "Found/success status—always with label text.", + "html": "พบแล้ว", + "css": ".ds-badge-found { display: inline-flex; align-items: center; padding: 6px 12px; border-radius: 9999px; font-family: Kanit, sans-serif; font-size: 0.75rem; font-weight: 500; background: #e8f8ef; color: #06C755; }" + }, + { + "name": "Bottom Nav Item Active", + "kind": "nav", + "refersTo": "nav-bottom-active", + "description": "Active tab in mobile bottom navigation.", + "html": "หน้าแรก", + "css": ".ds-nav-item { display: flex; flex-direction: column; align-items: center; padding: 8px 16px; text-decoration: none; color: #9CA3AF; font-family: Kanit, sans-serif; } .ds-nav-item--active { color: #06C755; } .ds-nav-icon-wrap { padding: 8px; border-radius: 9999px; } .ds-nav-item--active .ds-nav-icon-wrap { background: #e8f8ef; } .ds-nav-label { font-size: 0.75rem; font-weight: 500; margin-top: 4px; }" + } + ], + "narrative": { + "northStar": "The Campus Companion", + "overview": "Found-U looks and feels like a helpful classmate at the lost-and-found desk: warm enough to ease anxiety when something is missing, clear enough that a hurried student knows what to tap next. The visual language borrows messaging-app familiarity without copying LINE's brand. Surfaces stay light and legible for Thai text on phones between classes.", + "keyCharacteristics": [ + "Mobile-first density with fixed bottom nav", + "Campus Green as sole action accent", + "Kanit single-family typography", + "Pill-primary actions, 12–16px cards/inputs", + "Minimal elevation—tonal backgrounds over shadows", + "Color-blind-safe status labels" + ], + "rules": [ + { "name": "The One Green Rule", "body": "Campus Green appears on primary CTAs, active navigation, and success—not on decorative backgrounds.", "section": "colors" }, + { "name": "The Status Pair Rule", "body": "Lost, found, matched, and claimed states must include text label or icon—not hue alone.", "section": "colors" }, + { "name": "The Ink Body Rule", "body": "Body text uses Ink on light backgrounds—not Whisper—for WCAG AA.", "section": "typography" }, + { "name": "The Flat Button Rule", "body": "Buttons have no drop shadow; hover uses color shift only.", "section": "elevation" } + ], + "dos": [ + "Do use Campus Green for the single primary action per screen.", + "Do keep body copy in Ink with 1rem / 1.5 line-height for Thai readability.", + "Do pair status colors with labels.", + "Do respect prefers-reduced-motion.", + "Do use safe-area insets on bottom nav." + ], + "donts": [ + "Don't use over-hyped AI aesthetics—purple gradients, magic marketing copy.", + "Don't clone LINE literally.", + "Don't use childish illustration or cartoon mascots.", + "Don't pair 1px borders with wide soft drop shadows.", + "Don't use card radius above 16px on containers.", + "Don't rely on color alone for lost/found/matched states." + ] + } +} diff --git a/.impeccable/live/config.json b/.impeccable/live/config.json new file mode 100644 index 0000000..5959316 --- /dev/null +++ b/.impeccable/live/config.json @@ -0,0 +1,6 @@ +{ + "files": ["app/layout.tsx"], + "insertBefore": "", + "commentSyntax": "jsx", + "cspChecked": true +} diff --git a/README.md b/README.md index 7c4be96..1affe79 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ เว็บแอปสำหรับแจ้งของหาย–ของเจอภายในโรงเรียน -**เวอร์ชันปัจจุบัน:** `0.2b` +**เวอร์ชันปัจจุบัน:** `0.3` **Production:** [foundu.forum](https://foundu.forum) · [foundu.bodin2.ac.th](https://foundu.bodin2.ac.th) @@ -12,6 +12,16 @@ Found-U ช่วยให้ผู้ทำของหายและผู้พบเจอประสานงานผ่านระบบเดียว ลดขั้นตอนกระดาษและการติดตามที่ล่าช้า รองรับมือถือเป็นหลัก (mobile-first) และอัปเดตสถานะแบบ Real-time +### v0.3 — AI Agent & การค้นหาอัจฉริยะ + +- **ผู้ช่วย AI** (`/assistant`) แชทแบบ tool-calling — ค้นหา แจ้งของหาย/เจอ จับคู่ วิเคราะห์รูป และตรวจ tracking code +- รองรับ **Gemini** และ **OpenRouter** พร้อม fallback อัตโนมัติ และตั้งค่า routing ผ่านแผงแอดมิน +- **ค้นหาแบบ fuzzy** ด้วย `pg_trgm` (Supabase RPC) สำหรับรายการของหายและ Agent +- **ความเป็นส่วนตัว** — ซ่อนข้อมูลติดต่อของผู้อื่นในหน้าติดตามสถานะและใน Agent (เจ้าของรายการ/แอดมินเท่านั้นที่เห็น) +- ประวัติแชท Agent เก็บในเบราว์เซอร์ด้วย **IndexedDB (Dexie)** พร้อม session และ memory facts +- แผงแอดมิน **AI Center** ขยาย: ตั้งค่า Agent, Gemini pipeline, OpenRouter, และ **Agent Debug Log** +- ปรับ UX การโหลด Auth — ไม่แสดง skeleton ซ้ำเมื่อมี session อยู่แล้ว + ### v0.2b — Supabase Auth & Backend - ย้ายจาก **Firebase** มาใช้ **Supabase** (PostgreSQL + Auth + Realtime + RLS) @@ -41,7 +51,9 @@ Found-U ช่วยให้ผู้ทำของหายและผู้ - **แผนที่** ปักพิกัด และกำหนดขอบเขตโรงเรียน (ตรวจ GPS บนหน้าแจ้งของเจอ) - **AI วิเคราะห์รูป** (Vision) เดาชื่อ หมวดหมู่ สี ยี่ห้อ จากภาพถ่าย - **AI แยกข้อมูลจากข้อความ** (NER) สำหรับรายการของหาย -- **แผงผู้ดูแล** จัดการรายการ ผู้ใช้ การตั้งค่า moderation และทดสอบ AI +- **ผู้ช่วย AI (Agent)** สนทนาเพื่อค้นหา แจ้งรายการ จับคู่ และช่วยตรวจสอบสถานะ +- **ค้นหา fuzzy** ชื่อ/รายละเอียดสิ่งของด้วย `pg_trgm` +- **แผงผู้ดูแล** จัดการรายการ ผู้ใช้ การตั้งค่า moderation ทดสอบ AI และ debug log - **NFC Tag** ลงทะเบียนแท็ก สแกน/QR แจ้งพบ และฝากข้อความถึงเจ้าของ ## การยืนยันตัวตน (Auth) @@ -64,12 +76,15 @@ Found-U ช่วยให้ผู้ทำของหายและผู้ | ภาษา | [TypeScript](https://www.typescriptlang.org/) **5.9** | Strict typing | | สไตล์ | [Tailwind CSS](https://tailwindcss.com/) **4.1** | `@tailwindcss/postcss` | | Runtime / Package manager | [Bun](https://bun.sh/) **1.3** | แนะนำสำหรับ dev | -| Backend / DB | [Supabase](https://supabase.com/) | PostgreSQL, Auth, Realtime, RLS | +| Backend / DB | [Supabase](https://supabase.com/) | PostgreSQL, Auth, Realtime, RLS, `pg_trgm` | | Auth | Supabase Auth | Password, Passkeys, PIN, synthetic email domain | | WebAuthn client | `@simplewebauthn/browser` | พิธีการ Passkey ฝั่งเบราว์เซอร์ | | Validation | [Zod](https://zod.dev/) **4** | API request / input schemas | | แผนที่ | [Leaflet](https://leafletjs.com/) **1.9** | OpenStreetMap tiles | -| AI | Google Gemini API | Vision + NER (`GEMMA_API_KEY`) | +| AI (Pipeline) | Google Gemini API | Vision, NER, Matching (`GEMMA_API_KEY`) | +| AI (Agent) | [Vercel AI SDK](https://sdk.vercel.ai/) **7** | `@ai-sdk/google`, `@ai-sdk/openai`, tool loop | +| AI (Agent alt.) | [OpenRouter](https://openrouter.ai/) | Fallback / primary provider (`OPENROUTER_API_KEY`) | +| Chat storage | [Dexie](https://dexie.org/) **4** | IndexedDB สำหรับ session แชท Agent | | ที่เก็บไฟล์ | Cloudflare R2 | ผ่าน AWS S3 SDK | | อื่นๆ | `browser-image-compression`, `lucide-react`, `next-themes`, `framer-motion` | บีบอัดรูป, ไอคอน, dark mode, motion | @@ -77,15 +92,17 @@ Found-U ช่วยให้ผู้ทำของหายและผู้ ``` app/ - (app)/ หน้าหลักหลังล็อกอิน (home, found, lost, list, settings, …) + (app)/ หน้าหลักหลังล็อกอิน (home, assistant, found, lost, list, tracking, settings, …) admin/ แผงผู้ดูแล (items, users, students, matching, AI, NFC, …) - api/ REST API (auth, vision, ner, match, storage, nfc, …) + api/ REST API (auth, vision, ner, match, agent, storage, nfc, …) auth/callback/ Auth callback login/ ล็อกอิน เปลี่ยนรหัส รีเซ็ตรหัส nfc/ ลงทะเบียน/แท็กของฉัน/แจ้งพบ NFC -components/ UI, layout, map, camera, dialogs +components/ UI, layout, map, camera, agent, dialogs contexts/ auth, data (Realtime) lib/ + agent/ Agent tools, prompts, provider routing, privacy + chat/ Session/message storage, context window, memory database.ts CRUD + subscriptions (แทน Firestore เดิม) supabase/ client, server, admin, passkey-auth, auth-session auth-eligibility.ts กฎ secondary auth (Passkey / PIN) @@ -93,10 +110,35 @@ lib/ validations/ Zod schemas ``` +## Deploy โรงเรียนใหม่ (1-Click) + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fbodin2%2Ffound-u&project-name=found-u&repository-name=found-u&stores=%5B%7B%22type%22%3A%22integration%22%2C%22integrationSlug%22%3A%22supabase%22%2C%22productSlug%22%3A%22supabase%22%7D%5D&env=NEXT_PUBLIC_APP_URL%2CSCHOOL_AUTH_DOMAIN&envDescription=NEXT_PUBLIC_APP_URL%3A%20URL%20%E0%B9%82%E0%B8%94%E0%B9%80%E0%B8%A1%E0%B8%99%E0%B8%AB%E0%B8%A5%E0%B8%B1%E0%B8%81%E0%B8%82%E0%B8%AD%E0%B8%87%E0%B9%82%E0%B8%A3%E0%B8%87%E0%B9%80%E0%B8%A3%E0%B8%B5%E0%B8%A2%E0%B8%99%20(e.g.%20https%3A%2F%2Fyour-school.example.com)&envLink=https%3A%2F%2Fgithub.com%2Fbodin2%2Ffound-u%2Fblob%2Fmain%2F.env.example) + +ขั้นตอนสำหรับแอดมินโรงเรียน: + +1. กดปุ่ม **Deploy with Vercel** → เชื่อม GitHub → ติดตั้ง **Supabase** integration (สร้างโปรเจกต์ DB อัตโนมัติ) +2. กรอก `NEXT_PUBLIC_APP_URL` — URL หลักของโรงเรียน (เช่น `https://your-school.vercel.app` หรือ custom domain) +3. กรอก `SCHOOL_AUTH_DOMAIN` — โดเมนสังเคราะห์สำหรับอีเมลล็อกอิน (เช่น `your-school.ac.th`) +4. รอ deploy เสร็จ → เปิด URL → ทำ **Setup Wizard** 3 ขั้น (โลโก้โรงเรียน, AI ไม่บังคับ, สร้างแอดมิน) +5. ล็อกอินด้วยเลขแอดมินที่สร้าง → ใช้งานได้ทันที + +Supabase integration จะ inject `NEXT_PUBLIC_SUPABASE_*`, `SUPABASE_SERVICE_ROLE_KEY`, และ `POSTGRES_URL_NON_POOLING` ให้อัตโนมัติ — ไม่ต้อง copy เอง + +**ไม่บังคับตอน deploy:** `GEMMA_API_KEY`, `OPENROUTER_*`, `R2_*` — ตั้งผ่าน Setup Wizard หรือเพิ่มทีหลังใน Vercel env + +## ตัวแปรสภาพแวดล้อม (สำคัญ) + +ดูตัวอย่างครบใน [`.env.example`](.env.example) — จัดกลุ่ม Required / Optional แล้ว + +- **Required (Vercel + Supabase):** `NEXT_PUBLIC_SUPABASE_*`, `SUPABASE_SERVICE_ROLE_KEY`, `POSTGRES_URL_NON_POOLING`, `NEXT_PUBLIC_APP_URL`, `SCHOOL_AUTH_DOMAIN` +- **Optional — AI:** `GEMMA_API_KEY`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL` (หรือตั้งใน Setup Wizard) +- **Optional — Storage:** `R2_*` (production เดิมใช้ R2; deploy ใหม่ใช้ Supabase Storage อัตโนมัติถ้าไม่มี R2) +- **Search:** `SEARCH_USE_TRGM`, `SEARCH_SIMILARITY_THRESHOLD`, `AGENT_SEARCH_SIMILARITY_THRESHOLD` + ## ทีมของเรา - [Athivaratz](https://www.instagram.com/athivaratz) -- [Almond](https://www.instagram.com/athivaratz) +- [Almond](https://www.instagram.com/ohzzl_) - [Prim](https://www.instagram.com/aeridesrosea.v) ## ที่ปรึกษา @@ -115,10 +157,20 @@ lib/ Found-U is a smart school lost-and-found web app for finders and reporters with artificial intelligence included. -**Current version:** `0.2b` (Beta) +**Current version:** `0.3` **Production:** [foundu.forum](https://foundu.forum) · [foundu.bodin2.ac.th](https://foundu.bodin2.ac.th) +## What's New in v0.3 + +- **AI Agent** at `/assistant` with tool-calling (search, report, match, vision, tracking lookup) +- **Gemini + OpenRouter** providers with auto-fallback and admin routing controls +- **Fuzzy search** via `pg_trgm` (Supabase RPC) for items and agent queries +- **Privacy controls** — contact details hidden from non-owners on tracking and in agent responses +- **Local chat history** with Dexie (IndexedDB) sessions and memory facts +- Expanded **Admin AI Center** — agent settings, Gemini pipeline, OpenRouter, debug logs +- Smoother auth loading when a session is already available + ## What's New in v0.2b - Migrated from **Firebase** to **Supabase** (PostgreSQL, Auth, Realtime, RLS) @@ -140,18 +192,30 @@ Traditional school lost-and-found workflows are slow, fragmented, and hard to tr - **Maps** with optional school boundary enforcement on found reports - **AI vision** to suggest item fields from photos - **AI NER** to extract fields from free-text lost reports -- **Admin dashboard** for items, users, settings, moderation, and AI testing +- **AI Agent** conversational assistant for search, reports, matching, and status checks +- **Fuzzy search** for item names and descriptions +- **Admin dashboard** for items, users, settings, moderation, AI testing, and agent debug logs - **NFC tags** for register, scan/QR found reports, and owner messaging -## Tech Stack +## Deploy a New School (1-Click) -See the table in the Thai section above. Core: **Next.js 16**, **React 19**, **TypeScript 5.9**, **Tailwind CSS 4**, **Supabase**, **Leaflet**, **Gemini API**, **Cloudflare R2**, **Bun**. +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fbodin2%2Ffound-u&project-name=found-u&repository-name=found-u&stores=%5B%7B%22type%22%3A%22integration%22%2C%22integrationSlug%22%3A%22supabase%22%2C%22productSlug%22%3A%22supabase%22%7D%5D&env=NEXT_PUBLIC_APP_URL%2CSCHOOL_AUTH_DOMAIN&envDescription=NEXT_PUBLIC_APP_URL%3A%20Your%20school%27s%20primary%20URL&envLink=https%3A%2F%2Fgithub.com%2Fbodin2%2Ffound-u%2Fblob%2Fmain%2F.env.example) + +1. Click **Deploy with Vercel** → connect GitHub → install **Supabase** integration +2. Set `NEXT_PUBLIC_APP_URL` and `SCHOOL_AUTH_DOMAIN` +3. Wait for deploy → open the URL → complete the **3-step Setup Wizard** +4. Log in with the admin account you created + +Supabase env vars and `POSTGRES_URL_NON_POOLING` are injected automatically. + +## Tech Stack +See the table in the Thai section above. Core: **Next.js 16**, **React 19**, **TypeScript 5.9**, **Tailwind CSS 4**, **Supabase**, **Vercel AI SDK**, **Gemini + OpenRouter**, **Dexie**, **Leaflet**, **Cloudflare R2** (or Supabase Storage on new deploys), **Bun**. ## Our Team - [Athivaratz](https://www.instagram.com/athivaratz) -- [Almond](https://www.instagram.com/athivaratz) +- [Almond](https://www.instagram.com/ohzzl_) - [Prim](https://www.instagram.com/aeridesrosea.v) ## Adviser diff --git a/app/(app)/assistant/page.tsx b/app/(app)/assistant/page.tsx new file mode 100644 index 0000000..5210103 --- /dev/null +++ b/app/(app)/assistant/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { AssistantAppShell } from "@/components/layout/assistant-app-shell"; +import { AgentChatShell } from "@/components/agent/agent-chat-shell"; +import "@/app/agent-globals.css"; + +export default function AssistantPage() { + return ( + + + + ); +} diff --git a/app/(app)/found/page.tsx b/app/(app)/found/page.tsx index e9d5fdf..a84cc4f 100644 --- a/app/(app)/found/page.tsx +++ b/app/(app)/found/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Image from "next/image"; import { @@ -10,6 +10,7 @@ import { ImagePlus, Loader2, MapPin, + PenLine, Plus, Search, X, @@ -23,7 +24,7 @@ import MapCanvasLazy from "@/components/ui/map-canvas-lazy"; import { FormStepper, FormStepperActions } from "@/components/ui/form-stepper"; import { ResponsiveModal } from "@/components/ui/responsive-modal"; import { AnimatePresence, m } from "framer-motion"; -import { slideUp } from "@/lib/motion"; +import { slideUp, scaleIn, staggerContainer, staggerItem, fade, motionSafe, duration, easeOut } from "@/lib/motion"; import { useReducedMotion } from "@/hooks/use-reduced-motion"; import { type ContactInfo, @@ -41,10 +42,6 @@ import { } from "@/lib/utils"; import { addFoundItem, - subscribeToCategories, - subscribeToContactTypes, - type CategoryConfig, - type ContactTypeConfig, } from "@/lib/database"; import { uploadFoundItemImage } from "@/lib/storage"; import { @@ -59,11 +56,13 @@ import { watchGeolocationPermission, } from "@/lib/geolocation"; import { useAuth } from "@/contexts/auth-context"; +import { useCategories, useContactTypes } from "@/contexts/DataContext"; import { AUTH_ROUTES } from "@/lib/auth-routes"; import { useAppDialog } from "@/hooks/use-app-dialog"; import { useMapView } from "@/hooks/use-map-view"; import { resolveMapView } from "@/lib/map-utils"; import { logItemCreated } from "@/lib/logger"; +import type { MatchScore } from "@/lib/matching"; import { computeHandoverDeadlineFromNow, formatHandoverCountdown, @@ -72,6 +71,10 @@ import { isFoundHandoverDeadlineEnabled, } from "@/lib/found-handover"; import { triggerFoundHandoverExpirySweep } from "@/lib/found-handover-client"; +import { FieldValidationMessage } from "@/components/ui/field-validation-message"; +import { inputStateClass } from "@/components/ui/validated-field"; +import { ValidationSummaryGroup } from "@/components/ui/validation-summary"; +import { fieldErrorId, fieldId, recordToIssues } from "@/lib/feedback/types"; type ReportMode = "vision" | "manual"; @@ -86,17 +89,38 @@ const PERSONNEL_OFFICE_LABEL = getDropOffLocationLabel(DEFAULT_FOUND_DROP_OFF_LO export default function ReportFoundPage() { const router = useRouter(); const fileInputRef = useRef(null); - const { user, loading: authLoading, appSettings, appSettingsReady, isAdmin } = useAuth(); + const { user, loading: authLoading, authHydrating, appSettings, appSettingsReady, isAdmin } = useAuth(); + const authPending = authLoading || authHydrating; + const { categories, loading: categoriesLoading } = useCategories(); + const { contactTypes, loading: contactTypesLoading } = useContactTypes(); + const configLoading = categoriesLoading || contactTypesLoading; const { showAlert, dialog } = useAppDialog(); const reduced = useReducedMotion(); + const stepMotion = motionSafe(slideUp, reduced); + const panelMotion = motionSafe( + { + initial: { opacity: 0, y: 8 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -6 }, + transition: { duration: duration.fast, ease: easeOut }, + }, + reduced + ); + const successMotion = motionSafe(scaleIn, reduced); + const successIconMotion = motionSafe( + { + initial: { opacity: 0, scale: 0.88 }, + animate: { opacity: 1, scale: 1 }, + transition: { duration: duration.normal, ease: easeOut, delay: 0.06 }, + }, + reduced + ); + const reportModeLayoutId = "found-report-mode-indicator"; const [formStep, setFormStep] = useState(0); - const [categories, setCategories] = useState([]); - const [contactTypes, setContactTypes] = useState([]); - const [configLoading, setConfigLoading] = useState(true); - const [gpsLoading, setGpsLoading] = useState(false); const [locationVerified, setLocationVerified] = useState(null); + const [adminGpsBypassed, setAdminGpsBypassed] = useState(false); const [locationErrorType, setLocationErrorType] = useState< | "permission" | "timeout" @@ -138,39 +162,46 @@ export default function ReportFoundPage() { const [trackingCode, setTrackingCode] = useState(""); const [errors, setErrors] = useState>({}); const [warnings, setWarnings] = useState>({}); - const [showMatches, setShowMatches] = useState(false); - const [matches, setMatches] = useState([]); - const [submittedHandoverDeadline, setSubmittedHandoverDeadline] = useState(null); - const [handoverCountdownMs, setHandoverCountdownMs] = useState(null); - useEffect(() => { - let loadedCount = 0; - const checkLoaded = () => { - loadedCount++; - if (loadedCount >= 2) setConfigLoading(false); - }; + const stepFieldKeys = useMemo(() => { + if (formStep === 0) { + return { + errors: ["image", "description"], + warnings: ["itemName", "category", "color", "brand"], + }; + } + if (formStep === 1) { + return { errors: ["locationFound", "locationCoords"], warnings: [] as string[] }; + } + return { errors: [] as string[], warnings: ["contacts"] }; + }, [formStep]); - const unsubCategories = subscribeToCategories((cats) => { - setCategories(cats); - checkLoaded(); - }); + const stepErrorIssues = useMemo( + () => + recordToIssues(errors).filter((issue) => + stepFieldKeys.errors.includes(issue.fieldId.replace("field-", "")) + ), + [errors, stepFieldKeys.errors] + ); - const unsubContactTypes = subscribeToContactTypes((types) => { - setContactTypes(types); - checkLoaded(); - }); + const stepWarningIssues = useMemo( + () => + recordToIssues(warnings, "warning").filter((issue) => + stepFieldKeys.warnings.includes(issue.fieldId.replace("field-", "")) + ), + [warnings, stepFieldKeys.warnings] + ); - return () => { - unsubCategories(); - unsubContactTypes(); - }; - }, []); + const [showMatches, setShowMatches] = useState(false); + const [matches, setMatches] = useState([]); + const [submittedHandoverDeadline, setSubmittedHandoverDeadline] = useState(null); + const [handoverCountdownMs, setHandoverCountdownMs] = useState(null); useEffect(() => { - if (!authLoading && !user) { + if (!authPending && !user) { router.push(AUTH_ROUTES.hub); } - }, [user, authLoading, router]); + }, [user, authPending, router]); useEffect(() => { if (user) { @@ -213,11 +244,19 @@ export default function ReportFoundPage() { const enforcementRequired = appSettingsReady && !!appSettings.mapEnforceFoundInSchool; const waitingForSettings = !authLoading && !configLoading && !!user && !appSettingsReady; - const showLocationGate = waitingForSettings || (enforcementRequired && locationVerified !== true); + const showLocationGate = + waitingForSettings || + (enforcementRequired && !adminGpsBypassed && locationVerified !== true); - const mapFallbackCenter = appSettings.mapDefaultCenter || { lat: 13.7563, lng: 100.5018 }; + const mapFallbackCenter = useMemo( + () => appSettings.mapDefaultCenter || { lat: 13.7563, lng: 100.5018 }, + [appSettings.mapDefaultCenter] + ); const mapFallbackZoom = appSettings.mapDefaultZoom ?? 17; - const schoolPolygon = appSettings.mapSchoolBoundary || []; + const schoolPolygon = useMemo( + () => appSettings.mapSchoolBoundary || [], + [appSettings.mapSchoolBoundary] + ); const { center: formMapCenter, @@ -244,7 +283,7 @@ export default function ReportFoundPage() { [mapFallbackCenter, mapFallbackZoom, schoolPolygon, userCurrentCoords] ); - const verifyLocation = async () => { + const verifyLocation = useCallback(async () => { const polygon = appSettings.mapSchoolBoundary || []; if (!appSettings.mapEnforceFoundInSchool) { @@ -312,25 +351,28 @@ export default function ReportFoundPage() { setLocationErrorType("outside"); } setGpsLoading(false); - }; + }, [appSettings.mapEnforceFoundInSchool, appSettings.mapSchoolBoundary]); const boundaryString = JSON.stringify(appSettings?.mapSchoolBoundary || []); useEffect(() => { - if (!authLoading && !configLoading && appSettingsReady && user) { + if (adminGpsBypassed) return; + if (!authPending && !configLoading && appSettingsReady && user) { void verifyLocation(); } }, [ - authLoading, + adminGpsBypassed, + authPending, configLoading, appSettingsReady, user, appSettings.mapEnforceFoundInSchool, boundaryString, + verifyLocation, ]); useEffect(() => { - if (!enforcementRequired) return; + if (!enforcementRequired || adminGpsBypassed) return; const unwatch = watchGeolocationPermission((state) => { if (state === "denied") { @@ -343,7 +385,7 @@ export default function ReportFoundPage() { }); return unwatch; - }, [enforcementRequired]); + }, [enforcementRequired, adminGpsBypassed, locationVerified, verifyLocation]); const handleFormChange = ( e: React.ChangeEvent @@ -628,10 +670,10 @@ export default function ReportFoundPage() { } }; - if (authLoading || configLoading) { + if ((authLoading && !user) || configLoading) { return ( -
- +
+
); } @@ -654,45 +696,60 @@ export default function ReportFoundPage() { return (
-
-
- -
- -

+ + + + + +

ขอบคุณที่ช่วยส่งคืน!

-

+

ข้อมูลของคุณจะช่วยให้เจ้าของได้รับของคืน

-
-

รหัสอ้างอิง

-

+

+

รหัสอ้างอิง

+

{trackingCode}

-

+

ส่งรหัสนี้ให้เจ้าของเพื่อมารับของคืน

-
-

- ขั้นตอนถัดไป: นำของไปส่งที่ {PERSONNEL_OFFICE_LABEL} -

+
+
+

+ ขั้นตอนถัดไป: นำของไปส่งที่ {PERSONNEL_OFFICE_LABEL} +

+

+ เจ้าหน้าที่ห้องบุคคลจะยืนยันเมื่อรับของแล้ว จากนั้นเจ้าของจึงจะมารับคืนได้อย่างปลอดภัย +

+
{isFoundHandoverDeadlineEnabled(appSettings) && submittedHandoverDeadline && ( -
-

+

+

ภายใน {getFoundHandoverDeadlineMinutes(appSettings)} นาที ( {formatHandoverDeadlineThai(submittedHandoverDeadline)})

{handoverCountdownMs !== null && (

{handoverCountdownMs <= 0 @@ -700,36 +757,48 @@ export default function ReportFoundPage() { : `เหลือเวลาอีก ${formatHandoverCountdown(handoverCountdownMs)}`}

)} -

+

หากไม่นำของถึงห้องบุคคลภายในเวลาที่กำหนด คำขอแจ้งเจอของจะหมดอายุทันที

)} -

- เจ้าหน้าที่ห้องบุคคลจะยืนยันเมื่อรับของแล้ว จากนั้นเจ้าของจึงจะมารับคืนได้อย่างปลอดภัย -

-
- -
-

- สถานะปัจจุบัน: รอส่งห้องบุคคล +

+ สถานะปัจจุบัน: รอส่งห้องบุคคล

{showMatches && matches.length > 0 && ( -
-
+ +
- -

+ +

พบของหายที่อาจตรงกัน ({matches.length} รายการ)

-
- {matches.slice(0, 3).map((match: any) => ( -
+ {matches.slice(0, 3).map((match) => ( +
@@ -744,40 +813,43 @@ export default function ReportFoundPage() { className={cn( "text-xs px-2 py-0.5 rounded-full", match.confidence === "high" - ? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400" + ? "bg-line-green-light text-line-green" : match.confidence === "medium" - ? "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400" - : "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-400" + ? "bg-status-warning-light text-status-warning" + : "bg-bg-tertiary text-text-secondary" )} > - ความน่าจะเป็น {match.scorePercentage}% + ความน่าจะเป็น {match.scorePercentage ?? Math.round(match.score * 100)}%
-
+ ))} -
+
-
+ )}
-
+
); @@ -818,7 +891,12 @@ export default function ReportFoundPage() { return ( -
+
+ {formStep === 0 && ( <> -
+
+ {reportMode === "vision" && ( -
+
- -

ถ่ายรูปสิ่งของ

+ +

ถ่ายรูปสิ่งของ

{errors.image && ( -
{errors.image}
+ )} {visionQuota && visionQuota.enabled && ( -
+
เหลือ {visionQuota.userRemainingMinute}/{visionQuota.userLimitPerMinute || 5} ครั้ง/นาที
)} + {isAnalyzingVision && ( -
กำลังวิเคราะห์รูป...
+ + + กำลังวิเคราะห์รูป... + )} +
+ {visionError && ( -
{visionError}
+ + {visionError} + )} -
+ + )} {reportMode === "manual" && ( -
-
+ )} +
-
-
-
-
-