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

14 KiB
Raw Permalink Blame History

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:0022: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   -- 15, 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           -- 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_tokenpublic_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

// 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]/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=vnAccept-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.