This commit is contained in:
2026-05-20 14:00:51 +07:00
commit 230eb9010c
30 changed files with 14065 additions and 0 deletions

343
Claude.md Normal file
View File

@@ -0,0 +1,343 @@
# 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.