Files
places/Claude.md
2026-05-20 14:00:51 +07:00

343 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:0022: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 -- 15, 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 -- 15, 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]``/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` `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 collection đều private by default. RLS policy phải enforce: user chỉ thấy places của mình HOẶC places trong collections họ 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.