14 KiB
14 KiB
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
id uuid PK
email text unique
name text
avatar_url text nullable
created_at timestamp
places
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
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
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
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
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
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_bythấ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)
- URL:
invite_token≠public_token: invite → join làm member | public → chỉ xem
Place ownership
- Place thuộc về
created_byuser, 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_bymới được xóa
Multi-collection
- 1 place có thể thuộc nhiều collection cùng lúc (qua
collection_places) sort_orderlà per-collection, không phải global
Per-user data (user_place_data)
notes,rating,visitedhoà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_datarow 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)
- URL:
- 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
localStoragekeyplaces_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
// 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
// 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
classNameprop hoặcvariants - Dùng
Sheetvớiside="bottom"thayDialogcho 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=vnvàAccept-Language: viheader 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.