343 lines
14 KiB
Markdown
343 lines
14 KiB
Markdown
# 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. |