# Places App — Project Context ## What this is Collaborative place-saving web app (mobile-first) cho nhóm nhỏ (bạn bè, gia đình). Lưu quán ăn, cà phê, địa điểm — cùng nhau quản lý, tag, rate, và xem trên bản đồ. --- ## Tech Stack | Layer | Choice | |---|---| | Framework | Next.js (App Router) | | Styling | Tailwind CSS v4 | | UI Components | shadcn/ui (Radix UI + Tailwind) | | Backend / DB | Supabase (Postgres + Auth + Realtime + Storage) | | Auth | Supabase Auth (magic link email) | | Geocoding | Nominatim (OpenStreetMap) — free, hoạt động ở VN | | Map view | Leaflet + OpenStreetMap (Phase 3) | | External navigation | Google Maps link `https://maps.google.com/?q={lat},{lng}` — chỉ open external, KHÔNG dùng Google Maps API | | Deploy | Vercel | > ⚠️ Google Maps API KHÔNG hoạt động ở Việt Nam. Chỉ dùng external link để mở Google Maps app/web. Mọi geocoding dùng Nominatim. --- ## Database Schema ### `users` ```sql id uuid PK email text unique name text avatar_url text nullable created_at timestamp ``` ### `places` ```sql id uuid PK created_by uuid FK → users name text address text lat float lng float category enum ('food', 'cafe', 'shopping', 'entertainment', 'other') tags text[] -- max 10 tags cover_url text nullable -- 1 ảnh cover, resize client-side trước upload, max 1MB phone text nullable website text nullable -- website hoặc Facebook page URL price_range enum ('$', '$$', '$$$') nullable opening_hours text nullable -- free text, vd: "7:00–22:00 hàng ngày" permanently_closed boolean -- default false created_at timestamp ``` ### `user_place_data` — per-user metadata ```sql user_id uuid FK → users place_id uuid FK → places notes text nullable -- riêng tư, chỉ mình thấy (không bao giờ share) rating int nullable -- 1–5, per-user visited boolean -- default false visited_at timestamp nullable PRIMARY KEY (user_id, place_id) ``` ### `collections` ```sql id uuid PK owner_id uuid FK → users name text type enum ('folder', 'trip') trip_start date nullable -- chỉ dùng khi type = 'trip' trip_end date nullable invite_token text unique nullable token_expires_at timestamp nullable -- expiry 7 ngày public_token text unique nullable -- null = private | có token = public read-only (không expire) created_at timestamp ``` ### `collection_members` ```sql collection_id uuid FK → collections user_id uuid FK → users role enum ('owner', 'editor', 'viewer') joined_at timestamp PRIMARY KEY (collection_id, user_id) ``` ### `collection_places` — junction table ```sql collection_id uuid FK → collections place_id uuid FK → places added_by uuid FK → users sort_order int -- sắp xếp thủ công trong collection added_at timestamp PRIMARY KEY (collection_id, place_id) ``` ### `place_reviews` — review shared trong collection ```sql id uuid PK place_id uuid FK → places collection_id uuid FK → collections -- review gắn với context collection user_id uuid FK → users body text -- nội dung review rating int nullable -- 1–5, optional (tách biệt với rating trong user_place_data) created_at timestamp updated_at timestamp -- UNIQUE (place_id, collection_id, user_id): mỗi user 1 review per place per collection ``` --- ## Business Logic — Rules quan trọng ### Privacy — mặc định private **Place:** - Mọi place mặc định **private** — chỉ `created_by` thấy - Place chỉ visible với người khác khi nó được add vào 1 collection mà họ là member **Collection:** - Mọi collection mặc định **private** — chỉ members thấy - Owner có thể bật **public read-only** bằng cách tạo `public_token` - URL: `/c/[public_token]` — ai có link đều xem được, không cần đăng nhập - Chỉ xem places + tên + địa chỉ + category + tags + avg_rating - KHÔNG thấy: notes riêng tư, visited status, thông tin members - Owner revoke bất cứ lúc nào (set `public_token = null`) - `invite_token` ≠ `public_token`: invite → join làm member | public → chỉ xem ### Place ownership - Place thuộc về `created_by` user, không thuộc collection - Xóa place khỏi collection = xóa row trong `collection_places` (KHÔNG xóa place) - Xóa hẳn place = xóa trong place detail screen, chỉ `created_by` mới được xóa ### Multi-collection - 1 place có thể thuộc nhiều collection cùng lúc (qua `collection_places`) - `sort_order` là per-collection, không phải global ### Per-user data (`user_place_data`) - `notes`, `rating`, `visited` hoàn toàn riêng tư — không ai khác xem được, kể cả trong shared collection - Rating trong `user_place_data`: dùng để tính avg_rating hiển thị "Bạn ★4 · Nhóm ★3.5" - `user_place_data` row tạo lazy (chỉ tạo khi user thực sự set giá trị) ### Review (`place_reviews`) - Review là **shared trong context 1 collection** — members cùng collection mới thấy - Mỗi user chỉ có **1 review per place per collection** (có thể edit) - Review có rating riêng (optional) — độc lập với rating trong `user_place_data` - Review hiển thị: avatar + tên + body + rating + ngày viết - Viewer được xem review, KHÔNG được viết - Khi place thuộc nhiều collection: review của collection A không hiện trong collection B - Public link (`/c/[public_token]`): hiện reviews nhưng ẩn tên người viết (chỉ hiện "Thành viên") ### Notes vs Review — phân biệt rõ | | Notes | Review | |---|---|---| | Visibility | Chỉ mình thấy | Members trong collection | | Mục đích | Nhắc nhở cá nhân | Chia sẻ trải nghiệm | | Có rating | Không | Có (optional) | | Scope | Global (cross-collection) | Per-collection | | Placeholder UI | "Ghi chú riêng tư..." + icon 🔒 | "Chia sẻ trải nghiệm của bạn..." | ### Permission model | Action | Owner | Editor | Viewer | |---|---|---|---| | Xem places trong collection | ✓ | ✓ | ✓ | | Xem reviews | ✓ | ✓ | ✓ | | Viết / sửa review của mình | ✓ | ✓ | ✗ | | Xóa review của mình | ✓ | ✓ | ✗ | | Xóa review của người khác | ✓ | ✗ | ✗ | | Thêm place vào collection | ✓ | ✓ | ✗ | | Sửa / xóa place (của mình) | ✓ | ✓ | ✗ | | Invite member | ✓ | ✗ | ✗ | | Đổi role member | ✓ | ✗ | ✗ | | Xóa collection | ✓ | ✗ | ✗ | ### Invite flow - **Link invite**: tạo `invite_token` (uuid), expiry 7 ngày, owner chọn role khi tạo link - URL: `/invite/[token]` - Owner có thể revoke (set `invite_token = null`) - **Email invite**: nhập email → Supabase gửi magic link kèm redirect về `/invite/accept?collection={id}&role={role}` ### Offline - Khi load collection: snapshot data vào `localStorage` key `places_cache_{collection_id}` - Khi mất mạng: đọc từ cache, hiển thị banner "Đang xem bản offline" - Viewer-only khi offline (không cho thêm/sửa dù là editor) --- ## Geocoding — Nominatim ```typescript // Autocomplete địa chỉ — debounce 500ms bắt buộc const searchAddress = async (query: string) => { const res = await fetch( `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=5&countrycodes=vn`, { headers: { 'Accept-Language': 'vi' } } ) return res.json() } // Reverse geocode từ lat/lng const reverseGeocode = async (lat: number, lng: number) => { const res = await fetch( `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`, { headers: { 'Accept-Language': 'vi' } } ) return res.json() } ``` > Rate limit: 1 req/s. Luôn debounce, không spam request. ## External Maps ```typescript // Mở Google Maps theo lat/lng const openGoogleMaps = (lat: number, lng: number, name?: string) => { const url = name ? `https://maps.google.com/maps?q=${encodeURIComponent(name)}&ll=${lat},${lng}` : `https://maps.google.com/?q=${lat},${lng}` window.open(url, '_blank') } ``` --- ## Build Phases ### Phase 1 — Core (build trước) - [ ] Auth: Supabase magic link, session management - [ ] CRUD places: thêm/sửa/xóa, GPS auto-fill, Nominatim autocomplete - [ ] Upload ảnh cover: resize client-side (canvas API), upload lên Supabase Storage - [ ] Danh mục cố định + tag tự do (max 10) - [ ] Collection: tạo folder / trip - [ ] Add/remove place ↔ collection (junction) - [ ] Visited status + per-user notes + rating - [ ] Tìm kiếm + lọc theo tên / tag / category - [ ] Nút "Mở Google Maps" ### Phase 2 — Collaborate - [ ] Invite link (token + expiry + revoke) - [ ] Invite email (Supabase Auth) - [ ] Permission enforcement theo role (Owner/Editor/Viewer) - [ ] Quản lý members (đổi role, kick) - [ ] Realtime sync (Supabase Realtime) - [ ] Offline read cache (localStorage) ### Phase 3 — Discovery - [ ] Map view: Leaflet + OpenStreetMap, pin cluster theo collection - [ ] Near me: lọc places trong bán kính X km - [ ] Public link read-only (xem collection không cần đăng nhập) --- ## Project Structure (đề xuất) ``` src/ app/ # Next.js App Router (auth)/ login/ (app)/ places/ collections/ [id]/ invite/ [token]/ components/ places/ # PlaceCard, PlaceForm, PlaceDetail collections/ # CollectionCard, CollectionForm, MemberList map/ # MapView (Phase 3) ui/ # Button, Input, Modal, ... shared components lib/ supabase/ client.ts # browser client server.ts # server client middleware.ts nominatim.ts # geocoding helpers maps.ts # openGoogleMaps helper hooks/ use-places.ts use-collections.ts use-offline-cache.ts types/ index.ts # DB types generated từ Supabase ``` --- ## UI / UX — Mobile First ### Nguyên tắc - **Mobile-first**: design cho màn hình 390px trước, responsive lên tablet/desktop sau - Mọi touch target tối thiểu **44×44px** (Apple HIG) — không nhỏ hơn - Spacing theo bội số 4px (4, 8, 12, 16, 24, 32…) - Ưu tiên bottom sheet / drawer hơn center modal trên mobile ### shadcn component mapping | Use case | Component | |---|---| | Form thêm/sửa place | `Sheet` (slide from bottom) | | Autocomplete địa chỉ Nominatim | `Command` + `Popover` | | Confirm xóa | `AlertDialog` | | Filter / sort | `Sheet` (bottom) hoặc `Popover` | | Invite member | `Dialog` | | Role picker | `Select` | | Tag input | `Badge` + custom input | | Rating | Custom star component (không có sẵn trong shadcn) | | Category picker | `ToggleGroup` | | Date range (trip) | `Calendar` + `Popover` | | Toast / feedback | `Sonner` (shadcn toast) | | Bottom navigation | Custom — không có trong shadcn | ### Navigation pattern - **Bottom tab bar** cố định: Danh sách · Collections · Thêm (FAB) · Hồ sơ - FAB (Floating Action Button) ở giữa bottom bar để thêm place nhanh - Back navigation dùng header với nút back trái — không dùng browser back - Deep link support cho `/collections/[id]` và `/invite/[token]` ### Component conventions - Không override shadcn component source trực tiếp — extend qua `className` prop hoặc `variants` - Dùng `Sheet` với `side="bottom"` thay `Dialog` cho mọi form trên mobile - Dùng `Drawer` (Vaul) khi cần swipe-to-dismiss gesture - `cn()` utility cho conditional classNames — không dùng template literal thuần --- ## Conventions - **Xóa**: luôn soft-confirm trước khi xóa place khỏi collection. Xóa hẳn place phải có dialog confirm rõ ràng. - **Rating display**: `Bạn: ★4 · Nhóm: ★3.5` — rating từ `user_place_data`, hiển thị cả hai khi trong shared collection. - **Review**: hiển thị dạng card — avatar + tên + body text + rating (nếu có) + thời gian. Viewer thấy nhưng không có input box. Empty state: "Chưa có review nào. Chia sẻ trải nghiệm của bạn!" - **Notes vs Review**: 2 section tách biệt trong Place Detail. Notes có icon khóa 🔒, placeholder "Ghi chú riêng tư...". Review section chỉ hiện khi đang xem trong context 1 collection cụ thể. - **Visited**: checkbox đơn giản, khi tick tự set `visited_at = now()`. - **Image upload**: resize về max 1200px và compress về <1MB trước khi upload, dùng canvas API ở client. - **Offline banner**: khi `navigator.onLine === false`, hiển thị banner cố định ở top, disable các action write. - **Nominatim**: luôn thêm `countrycodes=vn` và `Accept-Language: vi` header cho kết quả tốt hơn ở VN. - **Supabase RLS**: bật RLS trên tất cả các bảng, không exception. - **Privacy default**: place và collection đều private by default. RLS policy phải enforce: user chỉ thấy places của mình HOẶC places trong collections mà họ là member. - **Public collection route** `/c/[public_token]`: query không cần auth, chỉ trả về fields được phép (name, address, lat, lng, category, tags, avg_rating) — KHÔNG trả về notes, visited, member info.