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

98
.gitignore vendored Normal file
View File

@@ -0,0 +1,98 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
dist/
bin/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# package manager
package-lock.json
yarn.lock
pnpm-lock.yaml
# semantic-release
.nyc_output
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# flutter
.dart_tool
build
GoogleService-Info.plist
repomix-output.xml
.serena/cache
plans/**/*
!plans/templates/*
screenshots/*
docs/screenshots/*
logs.txt
test-ck
__pycache__
# CK meta commands
prompt.md
ck.md
# Generated runtime layout for release/install smoke tests
/.claude/
# But track repo-level rules that Claude Code reads
!/.claude/rules/
# Local-only state inside tracked claude source
claude/agent-memory/
claude/hooks/.logs/
claude/session-state/latest.md
claude/session-state/archive/
claude/settings.bak.json
claude/skills/ai-multimodal/assets/demo-*.claude/session-state/*.tmp
# Gemini CLI settings (symlink to staged .claude/.mcp.json)
.gemini/settings.json
# Showoff/marketing assets (images, showcases)
assets/
# Exception: use-mcp ships a persistent tool catalog at assets/tools.json
!claude/skills/use-mcp/assets/
!claude/skills/use-mcp/assets/**
# External repos for study/reference
external/
# Git worktrees (local development only)
worktrees/

22
.repomixignore Normal file
View File

@@ -0,0 +1,22 @@
docs/*
plans/*
assets/*
dist/*
coverage/*
build/*
ios/*
android/*
tests/*
__tests__/*
__pycache__/*
node_modules/*
.opencode/*
.claude/*
.serena/*
.pnpm-store/*
.github/*
.dart_tool/*
.idea/*
.husky/*
.venv/*

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.

11
next.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com" },
],
},
};
export default nextConfig;

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "places",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "15.0.3",
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8",
"tailwindcss": "^4.0.0",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

8664
release-manifest.json Normal file

File diff suppressed because it is too large Load Diff

657
src/app/globals.css Normal file
View File

@@ -0,0 +1,657 @@
@import "tailwindcss";
/* ─────────────────────────────────────────────────────────
Places App — Design tokens (warm travel)
shadcn-flavoured CSS variables, OKLCH-based.
───────────────────────────────────────────────────────── */
:root {
/* Type */
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "SF Pro Text",
"Segoe UI", system-ui, sans-serif;
--font-display: "Newsreader", "Iowan Old Style", "Georgia", serif;
/* Radius scale (driven by --r) */
--r: 1;
--radius-sm: calc(6px * var(--r));
--radius-md: calc(10px * var(--r));
--radius-lg: calc(14px * var(--r));
--radius-xl: calc(20px * var(--r));
--radius-2xl: calc(28px * var(--r));
--radius-pill: 9999px;
/* Spacing (compact <-> comfy via density) */
--d: 1;
--pad-x: 16px;
--row-gap: calc(12px * var(--d));
--card-pad: calc(14px * var(--d));
/* Light palette — warm travel */
--background: oklch(98% 0.008 75);
--background-soft: oklch(96.5% 0.012 75);
--card: oklch(100% 0 0);
--foreground: oklch(22% 0.018 55);
--muted: oklch(95% 0.012 75);
--muted-foreground: oklch(48% 0.022 55);
--subtle-foreground: oklch(62% 0.018 55);
--border: oklch(91% 0.014 70);
--border-strong: oklch(86% 0.018 70);
--input: oklch(96% 0.01 75);
/* Accent (primary terracotta) */
--primary: oklch(58% 0.155 38);
--primary-soft: oklch(94% 0.04 38);
--primary-foreground: oklch(99% 0.005 75);
--ring: oklch(58% 0.155 38 / 0.4);
/* Semantic */
--success: oklch(58% 0.13 155);
--success-soft: oklch(94% 0.05 155);
--warning: oklch(72% 0.15 75);
--warning-soft: oklch(95% 0.06 75);
--danger: oklch(58% 0.18 25);
--danger-soft: oklch(95% 0.05 25);
--star: oklch(74% 0.15 75);
/* Category accents */
--cat-food: oklch(60% 0.16 35);
--cat-cafe: oklch(50% 0.07 55);
--cat-shopping: oklch(58% 0.14 320);
--cat-entertainment: oklch(55% 0.12 200);
--cat-other: oklch(55% 0.02 60);
/* Shadows */
--shadow-sm: 0 1px 2px rgba(28, 22, 14, 0.05);
--shadow-md: 0 1px 3px rgba(28, 22, 14, 0.06), 0 4px 10px rgba(28, 22, 14, 0.04);
--shadow-lg: 0 4px 12px rgba(28, 22, 14, 0.08), 0 16px 40px rgba(28, 22, 14, 0.08);
--shadow-sheet: 0 -8px 30px rgba(28, 22, 14, 0.12);
}
[data-theme="dark"] {
--background: oklch(16% 0.014 55);
--background-soft: oklch(19% 0.014 55);
--card: oklch(21% 0.015 55);
--foreground: oklch(96% 0.006 75);
--muted: oklch(24% 0.014 55);
--muted-foreground: oklch(68% 0.018 55);
--subtle-foreground: oklch(56% 0.018 55);
--border: oklch(28% 0.016 55);
--border-strong: oklch(34% 0.018 55);
--input: oklch(24% 0.014 55);
--primary: oklch(72% 0.135 40);
--primary-soft: oklch(28% 0.06 40);
--primary-foreground: oklch(14% 0.014 55);
--ring: oklch(72% 0.135 40 / 0.4);
--success: oklch(72% 0.12 155);
--success-soft: oklch(28% 0.05 155);
--warning: oklch(78% 0.13 75);
--warning-soft: oklch(28% 0.06 75);
--danger: oklch(70% 0.16 25);
--danger-soft: oklch(28% 0.06 25);
--star: oklch(80% 0.14 75);
--cat-food: oklch(72% 0.14 35);
--cat-cafe: oklch(68% 0.05 55);
--cat-shopping: oklch(72% 0.13 320);
--cat-entertainment: oklch(72% 0.11 200);
--cat-other: oklch(68% 0.02 60);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 10px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.45), 0 16px 40px rgba(0, 0, 0, 0.4);
--shadow-sheet: 0 -8px 30px rgba(0, 0, 0, 0.55);
}
/* ── Reset / base ── */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: var(--font-sans);
background: var(--background);
color: var(--foreground);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button { font-family: inherit; cursor: pointer; }
input, textarea { font-family: inherit; }
.no-scrollbar { scrollbar-width: none; }
.no-scrollbar::-webkit-scrollbar { display: none; }
/* ── App shell — mobile-first frame (max 480px), full-bleed below ── */
.app-frame {
position: relative;
width: 100%;
max-width: 480px;
margin: 0 auto;
min-height: 100dvh;
background: var(--background);
color: var(--foreground);
display: flex;
flex-direction: column;
overflow: hidden;
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.5;
}
@media (min-width: 481px) {
.app-frame {
height: 100dvh;
border-left: 0.5px solid var(--border);
border-right: 0.5px solid var(--border);
box-shadow: var(--shadow-lg);
}
}
.app-surface {
flex: 1;
min-height: 0;
background: var(--background);
color: var(--foreground);
display: flex;
flex-direction: column;
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.5;
}
.app-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
position: relative;
}
/* ── Sticky top header ── */
.app-header {
position: sticky;
top: 0;
z-index: 20;
background: color-mix(in oklch, var(--background) 92%, transparent);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
border-bottom: 0.5px solid var(--border);
}
/* ── Bottom tab bar ── */
.tabbar {
position: relative;
z-index: 30;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
height: 64px;
padding-bottom: env(safe-area-inset-bottom, 0);
background: color-mix(in oklch, var(--card) 88%, transparent);
-webkit-backdrop-filter: blur(24px) saturate(180%);
backdrop-filter: blur(24px) saturate(180%);
border-top: 0.5px solid var(--border);
}
.tabbar-btn {
appearance: none;
border: 0;
background: transparent;
height: 50px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: var(--subtle-foreground);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.01em;
transition: color 0.15s ease;
}
.tabbar-btn[data-active="true"] { color: var(--primary); }
.tabbar-fab {
position: absolute;
right: 16px;
bottom: 84px;
width: 56px;
height: 56px;
border-radius: 9999px;
background: var(--primary);
color: var(--primary-foreground);
display: flex;
align-items: center;
justify-content: center;
border: 0;
z-index: 40;
box-shadow: 0 2px 6px rgba(28, 22, 14, 0.18), 0 12px 28px rgba(28, 22, 14, 0.22);
transition: transform 0.18s cubic-bezier(.3, .7, .4, 1);
}
.tabbar-fab:active { transform: scale(0.94); }
[data-theme="dark"] .tabbar-fab {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5), 0 12px 28px rgba(0, 0, 0, 0.45);
}
/* ── Card ── */
.card {
background: var(--card);
border: 0.5px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
/* ── Filter pills ── */
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border-radius: 9999px;
background: var(--card);
border: 0.5px solid var(--border);
color: var(--foreground);
font-size: 14px;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
transition: all 0.15s ease;
}
.pill[data-active="true"] {
background: var(--foreground);
color: var(--background);
border-color: var(--foreground);
}
/* ── Badge ── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 8px;
border-radius: 9999px;
background: var(--muted);
color: var(--muted-foreground);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.01em;
white-space: nowrap;
}
.badge--outline {
background: transparent;
border: 0.5px solid var(--border-strong);
}
.badge--primary {
background: var(--primary-soft);
color: var(--primary);
}
.badge--success {
background: var(--success-soft);
color: var(--success);
}
/* ── Button ── */
.btn {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 44px;
padding: 0 16px;
border: 0;
border-radius: var(--radius-md);
background: var(--primary);
color: var(--primary-foreground);
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
transition: opacity 0.15s ease, transform 0.1s ease;
}
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.5; }
.btn--block { width: 100%; }
.btn--lg { height: 52px; font-size: 16px; }
.btn--ghost { background: var(--muted); color: var(--foreground); }
.btn--outline {
background: transparent;
border: 0.5px solid var(--border-strong);
color: var(--foreground);
}
.btn--icon { width: 44px; height: 44px; padding: 0; border-radius: 9999px; }
.btn--danger { background: var(--danger); color: white; }
/* ── Input / textarea ── */
.input {
display: flex;
align-items: center;
gap: 8px;
height: 48px;
padding: 0 14px;
border-radius: var(--radius-md);
background: var(--input);
border: 0.5px solid var(--border);
color: var(--foreground);
font-size: 16px;
width: 100%;
outline: none;
transition: border-color 0.15s ease, background 0.15s ease;
}
.input:focus-within {
border-color: var(--primary);
background: var(--card);
}
.input input,
.input textarea {
flex: 1;
border: 0;
background: transparent;
outline: none;
color: inherit;
font: inherit;
min-width: 0;
}
.input--multi {
height: auto;
min-height: 96px;
padding: 12px 14px;
align-items: flex-start;
}
/* ── Star rating ── */
.stars { display: inline-flex; gap: 2px; }
.stars button {
background: transparent;
border: 0;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Place card ── */
.place-card {
display: flex;
gap: 12px;
padding: var(--card-pad);
background: var(--card);
border-radius: var(--radius-lg);
border: 0.5px solid var(--border);
text-align: left;
width: 100%;
appearance: none;
color: inherit;
transition: transform 0.12s ease, box-shadow 0.15s ease;
}
.place-card:active { transform: scale(0.985); }
/* ── Category icon-tile ── */
.cat-tile {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: inherit;
background: color-mix(in oklch, var(--cat-color) 20%, var(--background-soft));
color: var(--cat-color);
}
/* ── Headline (display) ── */
.display {
font-family: var(--font-display);
font-weight: 500;
letter-spacing: -0.02em;
font-feature-settings: "ss01" on;
}
/* ── Toast ── */
.toast {
position: absolute;
left: 50%;
bottom: 96px;
transform: translateX(-50%);
padding: 12px 16px;
background: oklch(20% 0.018 55);
color: oklch(98% 0.005 75);
border-radius: 9999px;
font-size: 14px;
font-weight: 500;
box-shadow: var(--shadow-lg);
z-index: 100;
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
animation: toast-in 0.22s cubic-bezier(.3, .7, .4, 1);
}
@keyframes toast-in {
from { opacity: 0; transform: translate(-50%, 12px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
/* ── Overlay / sheet / dialog ── */
.overlay {
position: absolute;
inset: 0;
background: rgba(20, 14, 8, 0.4);
z-index: 200;
animation: fade-in 0.18s ease;
}
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
.sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 210;
background: var(--card);
border-top-left-radius: var(--radius-2xl);
border-top-right-radius: var(--radius-2xl);
box-shadow: var(--shadow-sheet);
max-height: 85%;
display: flex;
flex-direction: column;
animation: sheet-in 0.28s cubic-bezier(.3, .7, .4, 1);
padding-bottom: 24px;
}
@keyframes sheet-in {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.sheet-handle {
width: 36px;
height: 5px;
border-radius: 9999px;
background: var(--border-strong);
margin: 8px auto 4px;
}
.dialog {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 210;
background: var(--card);
border-radius: var(--radius-xl);
width: calc(100% - 32px);
max-width: 380px;
max-height: 90%;
box-shadow: var(--shadow-lg);
overflow: hidden;
display: flex;
flex-direction: column;
animation: dialog-in 0.22s cubic-bezier(.3, .7, .4, 1);
}
@keyframes dialog-in {
from { opacity: 0; transform: translate(-50%, -46%) scale(0.96); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
/* ── Page transition ── */
.page-enter { animation: page-in 0.2s ease; }
@keyframes page-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Avatar stack ── */
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: var(--muted);
color: var(--muted-foreground);
overflow: hidden;
font-weight: 600;
font-size: 13px;
flex-shrink: 0;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.avatar-stack { display: inline-flex; }
.avatar-stack > * {
margin-left: -8px;
box-shadow: 0 0 0 2px var(--card);
}
.avatar-stack > *:first-child { margin-left: 0; }
/* ── Tabs ── */
.tabs {
display: flex;
background: var(--muted);
border-radius: var(--radius-md);
padding: 4px;
gap: 2px;
}
.tabs button {
appearance: none;
flex: 1;
height: 36px;
border: 0;
background: transparent;
border-radius: calc(var(--radius-md) - 2px);
color: var(--muted-foreground);
font-size: 14px;
font-weight: 500;
transition: all 0.15s ease;
}
.tabs button[data-active="true"] {
background: var(--card);
color: var(--foreground);
box-shadow: var(--shadow-sm);
}
/* ── Progress bar ── */
.progress {
height: 6px;
border-radius: 9999px;
background: var(--muted);
overflow: hidden;
}
.progress > div {
height: 100%;
background: var(--primary);
border-radius: 9999px;
transition: width 0.4s cubic-bezier(.3, .7, .4, 1);
}
/* ── Divider ── */
.divider {
height: 0.5px;
background: var(--border);
margin: 0;
border: 0;
}
/* ── Skeleton for missing cover ── */
.cover-fallback {
background:
radial-gradient(ellipse at 30% 20%, color-mix(in oklch, var(--cat-color) 30%, transparent), transparent 60%),
radial-gradient(ellipse at 80% 90%, color-mix(in oklch, var(--cat-color) 18%, transparent), transparent 60%),
var(--background-soft);
}
/* ── Offline banner ── */
.offline-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--warning-soft);
color: oklch(38% 0.12 75);
font-size: 13px;
font-weight: 500;
border-bottom: 0.5px solid color-mix(in oklch, var(--warning) 30%, transparent);
}
[data-theme="dark"] .offline-banner { color: var(--warning); }
/* ── Checkbox ── */
.checkbox {
appearance: none;
width: 24px;
height: 24px;
border-radius: 7px;
border: 1.5px solid var(--border-strong);
background: var(--card);
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
color: transparent;
position: relative;
}
.checkbox[data-checked="true"] {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-foreground);
}
.checkbox svg { width: 14px; height: 14px; display: block; }
/* ── ToggleGroup ── */
.toggle-group {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.toggle-group button {
appearance: none;
border: 1px solid var(--border);
background: var(--card);
border-radius: var(--radius-md);
padding: 10px 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--muted-foreground);
font-size: 12px;
font-weight: 500;
height: 72px;
transition: all 0.15s ease;
}
.toggle-group button[data-active="true"] {
border-color: var(--primary);
background: var(--primary-soft);
color: var(--primary);
}
/* ── Detail page hero scrim ── */
.hero-scrim {
position: absolute;
inset: 0;
background: linear-gradient(180deg,
rgba(0, 0, 0, 0.25) 0%,
rgba(0, 0, 0, 0) 25%,
rgba(0, 0, 0, 0) 55%,
rgba(0, 0, 0, 0.7) 100%);
}
/* Focus rings */
:focus { outline: none; }
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}

39
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,39 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Places — Lưu địa điểm cùng nhóm nhỏ",
description:
"Lưu quán ăn, cà phê, địa điểm — cùng bạn bè và gia đình quản lý, tag, rate, và xem trên bản đồ.",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
themeColor: "#e8e2d6",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="vi">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link
href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>{children}</body>
</html>
);
}

5
src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { PlacesApp } from "./places-app";
export default function Page() {
return <PlacesApp />;
}

157
src/app/places-app.tsx Normal file
View File

@@ -0,0 +1,157 @@
"use client";
import { useEffect, useReducer } from "react";
import { COLLECTIONS } from "@/lib/mock-data";
import {
INITIAL_STATE,
reducer,
type Screen,
type Tab,
} from "@/lib/app-state";
import { TabBar } from "@/components/ui-primitives";
import { Icons } from "@/components/icons";
import { PlacesListScreen } from "@/screens/places-list-screen";
import { PlaceDetailScreen } from "@/screens/place-detail-screen";
import { CollectionsListScreen } from "@/screens/collections-list-screen";
import { CollectionDetailScreen } from "@/screens/collection-detail-screen";
import { ProfileScreen } from "@/screens/profile-screen";
import { AddPlaceSheet } from "@/sheets/add-place-sheet";
import { InviteDialog } from "@/sheets/invite-dialog";
import { MembersSheet, ConfirmDialog } from "@/sheets/members-sheet";
export function PlacesApp() {
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
useEffect(() => {
if (!state.toast) return;
const key = state.toastKey;
const id = setTimeout(
() => dispatch({ type: "CLEAR_TOAST", key }),
2200,
);
return () => clearTimeout(id);
}, [state.toast, state.toastKey]);
// Online/offline detection
useEffect(() => {
const sync = () =>
dispatch({ type: "SET_OFFLINE", value: !navigator.onLine });
sync();
window.addEventListener("online", sync);
window.addEventListener("offline", sync);
return () => {
window.removeEventListener("online", sync);
window.removeEventListener("offline", sync);
};
}, []);
const top = state.stack[state.stack.length - 1];
const activeTab: Tab =
top.screen === "place" || top.screen === "collection"
? state.tab
: (top.screen as Tab);
const onTab = (id: string) => {
if (id === "profile" || id === "collections" || id === "places") {
dispatch({ type: "TAB", tab: id });
}
};
const renderScreen = (screen: Screen) => {
if (screen === "places")
return <PlacesListScreen state={state} dispatch={dispatch} />;
if (screen === "collections")
return <CollectionsListScreen state={state} dispatch={dispatch} />;
if (screen === "profile")
return <ProfileScreen state={state} dispatch={dispatch} />;
if (screen === "place")
return (
<PlaceDetailScreen
state={{ ...state, placeId: top.placeId }}
dispatch={dispatch}
/>
);
if (screen === "collection")
return (
<CollectionDetailScreen
state={{ ...state, collectionId: top.collectionId }}
dispatch={dispatch}
/>
);
return null;
};
const m = state.modal;
const placeForDelete =
m?.type === "confirmDeletePlace"
? state.places.find((p) => p.id === m.placeId)
: null;
const collectionForDelete =
m?.type === "confirmDeleteCollection"
? COLLECTIONS.find((c) => c.id === m.collectionId)
: null;
return (
<div className="app-frame">
{renderScreen(top.screen)}
<TabBar
active={activeTab}
onTab={onTab}
onFab={() => dispatch({ type: "OPEN_ADD" })}
showFab={top.screen !== "profile"}
/>
{m?.type === "add" && (
<AddPlaceSheet
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
)}
{m?.type === "invite" && (
<InviteDialog
collectionId={m.collectionId}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
)}
{m?.type === "members" && (
<MembersSheet
collectionId={m.collectionId}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
)}
{m?.type === "confirmDeletePlace" && placeForDelete && (
<ConfirmDialog
title="Xóa địa điểm?"
body={`"${placeForDelete.name}" sẽ bị xóa khỏi tất cả bộ sưu tập. Không thể hoàn tác.`}
confirmLabel="Xóa"
onConfirm={() =>
dispatch({ type: "DELETE_PLACE", placeId: m.placeId })
}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
/>
)}
{m?.type === "confirmDeleteCollection" && collectionForDelete && (
<ConfirmDialog
title="Xóa bộ sưu tập?"
body={`"${collectionForDelete.name}" sẽ bị xóa. Các địa điểm bên trong vẫn còn ở "Địa điểm".`}
confirmLabel="Xóa"
onConfirm={() => {
dispatch({ type: "CLOSE_MODAL" });
dispatch({ type: "BACK" });
dispatch({ type: "TOAST", value: "Đã xóa" });
}}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
/>
)}
{state.toast && (
<div className="toast" key={state.toastKey}>
<Icons.Check size={14} stroke={2.5} />
{state.toast}
</div>
)}
</div>
);
}

66
src/components/avatar.tsx Normal file
View File

@@ -0,0 +1,66 @@
import type { CSSProperties } from "react";
import type { User } from "@/lib/types";
import { USERS } from "@/lib/mock-data";
export function Avatar({
user,
size = 32,
}: {
user?: User;
size?: number;
}) {
const u = user;
const style: CSSProperties = {
width: size,
height: size,
fontSize: Math.round(size * 0.42),
background: u?.color || "var(--muted)",
color: u?.color ? "rgba(255,255,255,0.95)" : "var(--muted-foreground)",
};
return (
<span className="avatar" style={style}>
{u?.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={u.avatar_url} alt={u.name} />
) : (
u?.initials || (u?.name || "?").slice(0, 2).toUpperCase()
)}
</span>
);
}
export function AvatarStack({
userIds,
max = 4,
size = 28,
extra = 0,
}: {
userIds: string[];
max?: number;
size?: number;
extra?: number;
}) {
const list = userIds.slice(0, max);
const more = userIds.length + extra - list.length;
return (
<span className="avatar-stack">
{list.map((id) => (
<Avatar key={id} user={USERS[id]} size={size} />
))}
{more > 0 && (
<span
className="avatar"
style={{
width: size,
height: size,
fontSize: Math.round(size * 0.38),
background: "var(--muted)",
color: "var(--muted-foreground)",
}}
>
+{more}
</span>
)}
</span>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useState, type CSSProperties } from "react";
import { CATEGORIES } from "@/lib/mock-data";
import type { CategoryId } from "@/lib/types";
import { Icons } from "./icons";
export function CategoryTile({
category,
size = "sm",
}: {
category: CategoryId;
size?: "sm" | "md" | "lg";
}) {
const cat = CATEGORIES[category] || CATEGORIES.other;
const Icon = Icons[cat.icon as keyof typeof Icons];
const iconSize = { sm: 22, md: 28, lg: 40 }[size];
return (
<div
className="cat-tile"
style={{ ["--cat-color" as string]: cat.color } as CSSProperties}
>
<Icon size={iconSize} stroke={1.6} />
</div>
);
}
export function CoverImage({
src,
alt,
category,
style,
}: {
src?: string | null;
alt?: string;
category: CategoryId;
style?: CSSProperties;
}) {
const [err, setErr] = useState(false);
if (!src || err) {
return (
<div
className="cover-fallback"
style={
{
...style,
"--cat-color": (CATEGORIES[category] || CATEGORIES.other).color,
display: "flex",
alignItems: "center",
justifyContent: "center",
} as CSSProperties
}
>
<CategoryTile category={category} size="md" />
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
onError={() => setErr(true)}
style={{ objectFit: "cover", ...style }}
/>
);
}

294
src/components/icons.tsx Normal file
View File

@@ -0,0 +1,294 @@
import type { SVGProps } from "react";
type IconProps = Omit<SVGProps<SVGSVGElement>, "stroke" | "fill"> & {
size?: number;
stroke?: number;
fill?: string;
};
const Icon = ({
size = 20,
stroke = 1.75,
children,
fill = "none",
noStroke = false,
...rest
}: IconProps & { children: React.ReactNode; noStroke?: boolean }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill={fill}
stroke={noStroke ? "none" : "currentColor"}
strokeWidth={stroke}
strokeLinecap="round"
strokeLinejoin="round"
{...rest}
>
{children}
</svg>
);
export const Icons = {
MapPin: (p: IconProps) => (
<Icon {...p}>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</Icon>
),
Search: (p: IconProps) => (
<Icon {...p}>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</Icon>
),
Plus: (p: IconProps) => (
<Icon {...p}>
<path d="M12 5v14M5 12h14" />
</Icon>
),
X: (p: IconProps) => (
<Icon {...p}>
<path d="M18 6 6 18M6 6l12 12" />
</Icon>
),
ChevronLeft: (p: IconProps) => (
<Icon {...p}>
<path d="m15 18-6-6 6-6" />
</Icon>
),
ChevronRight: (p: IconProps) => (
<Icon {...p}>
<path d="m9 18 6-6-6-6" />
</Icon>
),
ChevronDown: (p: IconProps) => (
<Icon {...p}>
<path d="m6 9 6 6 6-6" />
</Icon>
),
MoreHorizontal: (p: IconProps) => (
<Icon {...p}>
<circle cx="5" cy="12" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
</Icon>
),
MoreVertical: (p: IconProps) => (
<Icon {...p}>
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
</Icon>
),
Check: (p: IconProps) => (
<Icon {...p}>
<path d="M20 6 9 17l-5-5" />
</Icon>
),
Star: (p: IconProps) => (
<Icon {...p}>
<path d="M11.5 3.5 14 9l6 .9-4.4 4.3 1 6L11.5 17l-5.4 3.2 1-6L2.7 9.9 8.7 9z" />
</Icon>
),
StarFilled: (p: IconProps) => (
<Icon {...p} fill="currentColor" noStroke>
<path d="M11.5 2.4 14.5 8.5l6.7 1-4.9 4.8 1.1 6.7-6-3.2-6 3.2 1.1-6.7L2.6 9.5l6.7-1z" />
</Icon>
),
Heart: (p: IconProps) => (
<Icon {...p}>
<path
d="M19.5 12.6c2.5-2.6 2.5-6.5 0-9-2.4-2.5-6.2-2.5-8.7 0L10 4.4l-.8-.9c-2.5-2.5-6.4-2.4-8.7 0-2.5 2.6-2.4 6.5 0 9L10 22l9.5-9.4z"
transform="translate(2 0)"
/>
</Icon>
),
Bookmark: (p: IconProps) => (
<Icon {...p}>
<path d="M19 21V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v16l7-5z" />
</Icon>
),
Folder: (p: IconProps) => (
<Icon {...p}>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</Icon>
),
Plane: (p: IconProps) => (
<Icon {...p}>
<path d="M17.8 19.2 16 11l3.5-3.5a2.121 2.121 0 0 0-3-3L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1.1.4 1.4L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.5.9.7 1.4.4l.5-.3c.4-.2.6-.7.4-1.1z" />
</Icon>
),
User: (p: IconProps) => (
<Icon {...p}>
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</Icon>
),
Users: (p: IconProps) => (
<Icon {...p}>
<circle cx="9" cy="8" r="3.5" />
<path d="M2 20a7 7 0 0 1 14 0" />
<path d="M15 4.5a3.5 3.5 0 0 1 0 7" />
<path d="M16 13.5a7 7 0 0 1 6 6.5" />
</Icon>
),
Utensils: (p: IconProps) => (
<Icon {...p}>
<path d="M3 2v7c0 1.7 1.3 3 3 3v10" />
<path d="M6 2v10" />
<path d="M14 20V8c0-2 2-5 4-5v17" />
</Icon>
),
Coffee: (p: IconProps) => (
<Icon {...p}>
<path d="M17 8h1a4 4 0 1 1 0 8h-1" />
<path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4z" />
<path d="M6 1v3M10 1v3M14 1v3" />
</Icon>
),
ShoppingBag: (p: IconProps) => (
<Icon {...p}>
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<path d="M3 6h18" />
<path d="M16 10a4 4 0 0 1-8 0" />
</Icon>
),
Sparkles: (p: IconProps) => (
<Icon {...p}>
<path d="m12 3 2 5 5 2-5 2-2 5-2-5-5-2 5-2z" />
<path d="M19 14v3M19 21v0M22 17.5h-3M16 17.5h0" />
</Icon>
),
ExternalLink: (p: IconProps) => (
<Icon {...p}>
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h7" />
</Icon>
),
Lock: (p: IconProps) => (
<Icon {...p}>
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</Icon>
),
Link: (p: IconProps) => (
<Icon {...p}>
<path d="M10 14a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1" />
<path d="M14 10a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1" />
</Icon>
),
Copy: (p: IconProps) => (
<Icon {...p}>
<rect x="8" y="8" width="13" height="13" rx="2" />
<path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3" />
</Icon>
),
Mail: (p: IconProps) => (
<Icon {...p}>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m4 7 8 6 8-6" />
</Icon>
),
Send: (p: IconProps) => (
<Icon {...p}>
<path d="M22 2 11 13" />
<path d="M22 2l-7 20-4-9-9-4z" />
</Icon>
),
WifiOff: (p: IconProps) => (
<Icon {...p}>
<path d="M2 2 22 22" />
<path d="M8.5 16.5a5 5 0 0 1 7 0" />
<path d="M2 8.8a14 14 0 0 1 5-2.4" />
<path d="M22 8.8a14 14 0 0 0-3.5-2" />
<path d="M5.5 12.7a10 10 0 0 1 4-2" />
<path d="M18.5 12.7a10 10 0 0 0-3.5-1.8" />
<circle cx="12" cy="20" r="0.5" />
</Icon>
),
Settings: (p: IconProps) => (
<Icon {...p}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1A1.7 1.7 0 0 0 10 3.1V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" />
</Icon>
),
Edit: (p: IconProps) => (
<Icon {...p}>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4z" />
</Icon>
),
Edit2: (p: IconProps) => (
<Icon {...p}>
<path d="M17 3a2.8 2.8 0 1 1 4 4L7 21l-4 1 1-4z" />
</Icon>
),
Trash: (p: IconProps) => (
<Icon {...p}>
<path d="M3 6h18" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</Icon>
),
Share: (p: IconProps) => (
<Icon {...p}>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4" />
</Icon>
),
Crosshair: (p: IconProps) => (
<Icon {...p}>
<circle cx="12" cy="12" r="9" />
<path d="M22 12h-4M6 12H2M12 6V2M12 22v-4" />
</Icon>
),
Camera: (p: IconProps) => (
<Icon {...p}>
<path d="M23 17a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3l2-3h8l2 3h3a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</Icon>
),
LogOut: (p: IconProps) => (
<Icon {...p}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</Icon>
),
Bell: (p: IconProps) => (
<Icon {...p}>
<path d="M18 16v-5a6 6 0 0 0-12 0v5l-2 2v1h16v-1z" />
<path d="M10 21a2 2 0 0 0 4 0" />
</Icon>
),
Globe: (p: IconProps) => (
<Icon {...p}>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
</Icon>
),
Calendar: (p: IconProps) => (
<Icon {...p}>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M16 3v4M8 3v4M3 10h18" />
</Icon>
),
Eye: (p: IconProps) => (
<Icon {...p}>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z" />
<circle cx="12" cy="12" r="3" />
</Icon>
),
CircleCheck: (p: IconProps) => (
<Icon {...p}>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</Icon>
),
};
export type IconName = keyof typeof Icons;

View File

@@ -0,0 +1,206 @@
import type { ReactNode } from "react";
import { CATEGORIES } from "@/lib/mock-data";
import type { Place } from "@/lib/types";
import { Icons } from "./icons";
import { CoverImage } from "./cover-image";
export function PlaceCard({
place,
onTap,
trailing,
showCity = false,
}: {
place: Place;
onTap?: () => void;
trailing?: ReactNode;
showCity?: boolean;
}) {
const cat = CATEGORIES[place.category] || CATEGORIES.other;
const CatIcon = Icons[cat.icon as keyof typeof Icons];
return (
<button className="place-card" onClick={onTap}>
<div
style={{
width: 72,
height: 72,
borderRadius: 12,
overflow: "hidden",
flexShrink: 0,
background: "var(--muted)",
}}
>
<CoverImage
src={place.cover_url}
alt={place.name}
category={place.category}
style={{ width: "100%", height: "100%" }}
/>
</div>
<div
style={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 6 }}>
<span
style={{
fontSize: 16,
fontWeight: 600,
color: "var(--foreground)",
letterSpacing: "-0.01em",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: 0,
flex: 1,
}}
>
{place.name}
</span>
{place.visited && (
<span
style={{
color: "var(--success)",
display: "inline-flex",
alignItems: "center",
flexShrink: 0,
}}
>
<Icons.CircleCheck size={16} stroke={2} />
</span>
)}
</div>
<div
style={{
fontSize: 13,
color: "var(--muted-foreground)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span
style={{
color: cat.color,
display: "inline-flex",
alignItems: "center",
}}
>
<CatIcon size={13} stroke={2} />
</span>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{showCity ? place.city + " · " : ""}
{place.short_address}
</span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 2,
minHeight: 22,
flexWrap: "nowrap",
overflow: "hidden",
}}
>
{place.my_rating ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 3,
fontSize: 12,
color: "var(--foreground)",
fontWeight: 600,
flexShrink: 0,
}}
>
<Icons.StarFilled size={12} style={{ color: "var(--star)" }} />
{place.my_rating.toFixed(1)}
</span>
) : place.avg_rating ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 3,
fontSize: 12,
color: "var(--muted-foreground)",
fontWeight: 500,
flexShrink: 0,
}}
>
<Icons.Star
size={12}
style={{ color: "var(--muted-foreground)" }}
stroke={2}
/>
{place.avg_rating.toFixed(1)}
</span>
) : null}
<div
style={{
display: "flex",
gap: 6,
overflow: "hidden",
flexWrap: "nowrap",
minWidth: 0,
}}
>
{place.tags.slice(0, 2).map((t) => (
<span
key={t}
className="badge badge--outline"
style={{
fontSize: 11,
height: 20,
padding: "0 7px",
flexShrink: 0,
}}
>
{t}
</span>
))}
</div>
{place.tags.length > 2 && (
<span
style={{
fontSize: 12,
color: "var(--subtle-foreground)",
flexShrink: 0,
}}
>
+{place.tags.length - 2}
</span>
)}
</div>
</div>
{trailing && (
<div
style={{
display: "flex",
alignItems: "center",
flexShrink: 0,
}}
onClick={(e) => e.stopPropagation()}
>
{trailing}
</div>
)}
</button>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
import { Icons } from "./icons";
export function RatingStars({
value = 0,
size = 16,
readOnly = true,
onChange,
}: {
value?: number;
size?: number;
readOnly?: boolean;
onChange?: (v: number) => void;
}) {
const [hover, setHover] = useState(0);
const display = hover || value;
return (
<span className="stars" role={readOnly ? undefined : "radiogroup"}>
{[1, 2, 3, 4, 5].map((n) => {
const filled = n <= display;
const Icon = filled ? Icons.StarFilled : Icons.Star;
return (
<button
key={n}
type="button"
disabled={readOnly}
style={{
cursor: readOnly ? "default" : "pointer",
color: filled ? "var(--star)" : "var(--border-strong)",
padding: readOnly ? 0 : 4,
}}
onMouseEnter={() => !readOnly && setHover(n)}
onMouseLeave={() => !readOnly && setHover(0)}
onClick={() => !readOnly && onChange && onChange(n === value ? 0 : n)}
>
<Icon size={size} stroke={1.6} />
</button>
);
})}
</span>
);
}

View File

@@ -0,0 +1,391 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import { Icons, type IconName } from "./icons";
// ── Header (sticky top) ─────────────────────────────────
export function Header({
title,
subtitle,
left,
right,
big = false,
sticky = true,
}: {
title: string;
subtitle?: ReactNode;
left?: ReactNode;
right?: ReactNode;
big?: boolean;
sticky?: boolean;
}) {
if (big) {
return (
<div className={sticky ? "app-header" : ""}>
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
gap: 12,
padding: "12px 16px 14px",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<h1
style={{
margin: 0,
fontSize: 30,
fontWeight: 700,
letterSpacing: "-0.02em",
lineHeight: 1.1,
}}
>
{title}
</h1>
{subtitle && (
<div
style={{
marginTop: 4,
fontSize: 14,
color: "var(--muted-foreground)",
}}
>
{subtitle}
</div>
)}
</div>
{right && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
paddingBottom: 2,
}}
>
{right}
</div>
)}
</div>
</div>
);
}
return (
<div className={sticky ? "app-header" : ""}>
<div
style={{
height: 56,
padding: "0 8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
minWidth: 44,
}}
>
{left}
</div>
<div
style={{
flex: 1,
textAlign: left ? "center" : "left",
fontSize: 17,
fontWeight: 600,
letterSpacing: "-0.01em",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
minWidth: 44,
justifyContent: "flex-end",
}}
>
{right}
</div>
</div>
</div>
);
}
// ── Icon button (44×44, round) ─────────────────────────
export function IconBtn({
icon,
onClick,
label,
variant = "ghost",
size = 22,
stroke = 1.75,
...rest
}: {
icon: IconName;
onClick?: () => void;
label?: string;
variant?: "ghost" | "muted" | "glass" | "glass-dark";
size?: number;
stroke?: number;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const I = Icons[icon];
const bg =
variant === "ghost"
? "transparent"
: variant === "glass"
? "rgba(255,255,255,0.85)"
: variant === "glass-dark"
? "rgba(20,16,10,0.55)"
: "var(--muted)";
const fg =
variant === "glass-dark"
? "#fff"
: variant === "glass"
? "#1a1612"
: "var(--foreground)";
const backdrop = variant.startsWith("glass")
? "blur(20px) saturate(180%)"
: undefined;
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
style={{
width: 44,
height: 44,
borderRadius: 9999,
border: 0,
background: bg,
color: fg,
WebkitBackdropFilter: backdrop,
backdropFilter: backdrop,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
{...rest}
>
<I size={size} stroke={stroke} />
</button>
);
}
// ── Bottom tab bar ──────────────────────────────────────
export function TabBar({
active,
onTab,
onFab,
showFab = true,
}: {
active: string;
onTab: (id: string) => void;
onFab: () => void;
showFab?: boolean;
}) {
const tabs: { id: string; label: string; icon: IconName }[] = [
{ id: "places", label: "Địa điểm", icon: "MapPin" },
{ id: "collections", label: "Bộ sưu tập", icon: "Folder" },
{ id: "profile", label: "Hồ sơ", icon: "User" },
];
return (
<div className="tabbar">
{tabs.map((t) => {
const I = Icons[t.icon];
const isActive = active === t.id;
return (
<button
key={t.id}
className="tabbar-btn"
data-active={isActive}
onClick={() => onTab(t.id)}
>
<I size={22} stroke={isActive ? 2 : 1.75} />
<span>{t.label}</span>
</button>
);
})}
{showFab && (
<button
className="tabbar-fab"
onClick={onFab}
aria-label="Thêm địa điểm"
>
<Icons.Plus size={26} stroke={2.5} />
</button>
)}
</div>
);
}
// ── Offline banner ──────────────────────────────────────
export function OfflineBanner() {
return (
<div className="offline-banner">
<Icons.WifiOff size={14} stroke={2} />
<span>Đang xem bản offline. Một số thao tác bị tạm khóa.</span>
</div>
);
}
// ── Empty state ─────────────────────────────────────────
export function EmptyState({
icon = "MapPin",
title,
body,
cta,
}: {
icon?: IconName;
title: string;
body: string;
cta?: ReactNode;
}) {
const I = Icons[icon];
return (
<div
style={{
padding: "48px 24px",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
}}
>
<div
style={{
width: 64,
height: 64,
borderRadius: 9999,
background: "var(--muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted-foreground)",
}}
>
<I size={28} stroke={1.6} />
</div>
<div>
<div
style={{
fontSize: 17,
fontWeight: 600,
letterSpacing: "-0.01em",
}}
>
{title}
</div>
<div
style={{
marginTop: 4,
fontSize: 14,
color: "var(--muted-foreground)",
lineHeight: 1.45,
}}
>
{body}
</div>
</div>
{cta && <div style={{ marginTop: 8 }}>{cta}</div>}
</div>
);
}
// ── Checkbox ──────────────────────────────────────────
export function Checkbox({
checked,
onClick,
}: {
checked?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
className="checkbox"
data-checked={!!checked}
onClick={onClick}
>
<Icons.Check size={14} stroke={3} />
</button>
);
}
// ── Menu item (in sheet) ────────────────────────────────
export function MenuItem({
icon,
label,
onClick,
danger = false,
}: {
icon: IconName;
label: string;
onClick?: () => void;
danger?: boolean;
}) {
const I = Icons[icon];
return (
<button
onClick={onClick}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 14,
padding: "14px 20px",
background: "transparent",
border: 0,
color: danger ? "var(--danger)" : "var(--foreground)",
fontSize: 16,
fontWeight: 500,
textAlign: "left",
}}
>
<I size={20} stroke={1.75} />
{label}
</button>
);
}
// ── Field label (form) ──────────────────────────────────
export function FieldLabel({
children,
required,
}: {
children: ReactNode;
required?: boolean;
}) {
return (
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--muted-foreground)",
textTransform: "uppercase",
letterSpacing: "0.04em",
margin: "16px 0 8px",
display: "flex",
alignItems: "center",
gap: 4,
} as CSSProperties}
>
{children}
{required && <span style={{ color: "var(--primary)" }}>*</span>}
</div>
);
}

149
src/lib/app-state.ts Normal file
View File

@@ -0,0 +1,149 @@
import type { Place } from "./types";
import { PLACES } from "./mock-data";
export type Screen = "places" | "collections" | "profile" | "place" | "collection";
export type Tab = "places" | "collections" | "profile";
export type StackFrame = {
screen: Screen;
placeId?: string;
collectionId?: string;
};
export type Modal =
| { type: "add" }
| { type: "invite"; collectionId: string }
| { type: "members"; collectionId: string }
| { type: "confirmDeletePlace"; placeId: string }
| { type: "confirmDeleteCollection"; collectionId: string }
| null;
export type AppState = {
tab: Tab;
stack: StackFrame[];
filter: string;
search: string;
places: Place[];
modal: Modal;
toast: string | null;
toastKey: number;
offline: boolean;
};
export type Action =
| { type: "NAV"; screen: Screen; placeId?: string; collectionId?: string }
| { type: "BACK" }
| { type: "TAB"; tab: Tab }
| { type: "SET_FILTER"; value: string }
| { type: "SET_SEARCH"; value: string }
| { type: "TOGGLE_VISITED"; placeId: string }
| { type: "SET_RATING"; placeId: string; value: number }
| { type: "SET_NOTES"; placeId: string; value: string }
| { type: "ADD_PLACE"; place: Place }
| { type: "DELETE_PLACE"; placeId: string }
| { type: "TOAST"; value: string }
| { type: "CLEAR_TOAST"; key: number }
| { type: "OPEN_ADD" }
| { type: "OPEN_INVITE"; collectionId: string }
| { type: "OPEN_MEMBERS"; collectionId: string }
| { type: "CONFIRM_DELETE_PLACE"; placeId: string }
| { type: "CONFIRM_DELETE_COLLECTION"; collectionId: string }
| { type: "CLOSE_MODAL" }
| { type: "SET_OFFLINE"; value: boolean };
export const INITIAL_STATE: AppState = {
tab: "places",
stack: [{ screen: "places" }],
filter: "all",
search: "",
places: PLACES,
modal: null,
toast: null,
toastKey: 0,
offline: false,
};
export function reducer(state: AppState, action: Action): AppState {
switch (action.type) {
case "NAV": {
const stack = [
...state.stack,
{ screen: action.screen, placeId: action.placeId, collectionId: action.collectionId },
];
return { ...state, stack };
}
case "BACK": {
if (state.stack.length <= 1) return state;
return { ...state, stack: state.stack.slice(0, -1) };
}
case "TAB": {
return { ...state, tab: action.tab, stack: [{ screen: action.tab }] };
}
case "SET_FILTER":
return { ...state, filter: action.value };
case "SET_SEARCH":
return { ...state, search: action.value };
case "TOGGLE_VISITED": {
const places = state.places.map((p) =>
p.id === action.placeId
? {
...p,
visited: !p.visited,
visited_at: !p.visited
? new Date().toISOString().slice(0, 10)
: undefined,
}
: p,
);
return { ...state, places };
}
case "SET_RATING": {
const places = state.places.map((p) =>
p.id === action.placeId
? { ...p, my_rating: action.value || undefined }
: p,
);
return { ...state, places };
}
case "SET_NOTES": {
const places = state.places.map((p) =>
p.id === action.placeId ? { ...p, my_notes: action.value } : p,
);
return { ...state, places };
}
case "ADD_PLACE": {
return { ...state, places: [action.place, ...state.places] };
}
case "DELETE_PLACE": {
return {
...state,
places: state.places.filter((p) => p.id !== action.placeId),
stack: state.stack.length > 1 ? state.stack.slice(0, -1) : state.stack,
modal: null,
};
}
case "TOAST":
return { ...state, toast: action.value, toastKey: state.toastKey + 1 };
case "CLEAR_TOAST":
return state.toastKey === action.key ? { ...state, toast: null } : state;
case "OPEN_ADD":
return { ...state, modal: { type: "add" } };
case "OPEN_INVITE":
return { ...state, modal: { type: "invite", collectionId: action.collectionId } };
case "OPEN_MEMBERS":
return { ...state, modal: { type: "members", collectionId: action.collectionId } };
case "CONFIRM_DELETE_PLACE":
return { ...state, modal: { type: "confirmDeletePlace", placeId: action.placeId } };
case "CONFIRM_DELETE_COLLECTION":
return { ...state, modal: { type: "confirmDeleteCollection", collectionId: action.collectionId } };
case "CLOSE_MODAL":
return { ...state, modal: null };
case "SET_OFFLINE":
return { ...state, offline: action.value };
default:
return state;
}
}
export type Dispatch = (action: Action) => void;

19
src/lib/format.ts Normal file
View File

@@ -0,0 +1,19 @@
export function fmtDate(s?: string | null) {
if (!s) return "";
const d = new Date(s);
return `${d.getDate()}/${d.getMonth() + 1}/${d.getFullYear()}`;
}
export function fmtShortDate(s?: string | null) {
if (!s) return "";
const d = new Date(s);
return `${d.getDate()}/${d.getMonth() + 1}`;
}
export function tripDays(start?: string, end?: string) {
if (!start || !end) return "";
const s = new Date(start);
const e = new Date(end);
const days = Math.round((e.getTime() - s.getTime()) / (1000 * 60 * 60 * 24)) + 1;
return `${days} ngày`;
}

323
src/lib/mock-data.ts Normal file
View File

@@ -0,0 +1,323 @@
import type { CategoryId, CategoryMeta, Collection, Place, User } from "./types";
export const ME: User = {
id: "u_me",
name: "Minh Anh",
email: "minhanh@places.app",
avatar_url: null,
initials: "MA",
};
export const USERS: Record<string, User> = {
u_me: ME,
u_tung: { id: "u_tung", name: "Tùng Lâm", initials: "TL", avatar_url: null, color: "oklch(70% 0.12 145)" },
u_linh: { id: "u_linh", name: "Linh Đan", initials: "LD", avatar_url: null, color: "oklch(70% 0.13 320)" },
u_hung: { id: "u_hung", name: "Hùng Phạm", initials: "HP", avatar_url: null, color: "oklch(68% 0.12 245)" },
u_thao: { id: "u_thao", name: "Thảo Vy", initials: "TV", avatar_url: null, color: "oklch(72% 0.11 35)" },
};
export const PLACES: Place[] = [
{
id: "p_pho_gia_truyen",
name: "Phở Gia Truyền Bát Đàn",
address: "49 Bát Đàn, Hoàn Kiếm, Hà Nội",
short_address: "49 Bát Đàn · Hoàn Kiếm",
category: "food",
tags: ["phở bò", "sáng", "cổ truyền"],
cover_url: null,
created_by: "u_me",
created_at: "2026-04-12",
my_rating: 5,
my_notes: "Đi sớm trước 8h kẻo hết. Tái nạm gầu là chân ái.",
visited: true,
visited_at: "2026-04-14",
avg_rating: 4.6,
city: "Hà Nội",
},
{
id: "p_bun_cha_huong_lien",
name: "Bún chả Hương Liên",
address: "24 Lê Văn Hưu, Hai Bà Trưng, Hà Nội",
short_address: "24 Lê Văn Hưu · Hai Bà Trưng",
category: "food",
tags: ["bún chả", "combo Obama"],
cover_url: "https://images.unsplash.com/photo-1606851094291-6efae152bb87?w=600&q=80",
created_by: "u_tung",
created_at: "2026-04-08",
my_rating: 4,
my_notes: "",
visited: true,
visited_at: "2026-04-20",
avg_rating: 4.2,
city: "Hà Nội",
},
{
id: "p_cafe_giang",
name: "Cà phê Giảng",
address: "39 Nguyễn Hữu Huân, Hoàn Kiếm, Hà Nội",
short_address: "39 Nguyễn Hữu Huân · Hoàn Kiếm",
category: "cafe",
tags: ["cà phê trứng", "cổ điển"],
cover_url: "https://images.unsplash.com/photo-1442975631115-c4f7b05b8a2c?w=600&q=80",
created_by: "u_me",
created_at: "2026-04-02",
my_rating: 5,
my_notes: "Tầng 2 yên hơn. Trứng đánh bông kiểu cũ.",
visited: true,
visited_at: "2026-04-05",
avg_rating: 4.7,
city: "Hà Nội",
},
{
id: "p_the_note",
name: "The Note Coffee",
address: "64 Lương Văn Can, Hoàn Kiếm, Hà Nội",
short_address: "64 Lương Văn Can · Hoàn Kiếm",
category: "cafe",
tags: ["view hồ Gươm", "sticky notes"],
cover_url: "https://images.unsplash.com/photo-1521017432531-fbd92d768814?w=600&q=80",
created_by: "u_linh",
created_at: "2026-03-28",
my_rating: 4,
visited: false,
avg_rating: 4.1,
city: "Hà Nội",
},
{
id: "p_cong_nha_tho",
name: "Cộng Cà Phê — Nhà Thờ",
address: "27 Nhà Thờ, Hoàn Kiếm, Hà Nội",
short_address: "27 Nhà Thờ · Hoàn Kiếm",
category: "cafe",
tags: ["cốt dừa", "concept bao cấp"],
cover_url: "https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=600&q=80",
created_by: "u_me",
created_at: "2026-03-15",
my_rating: 4,
visited: true,
visited_at: "2026-03-15",
avg_rating: 4.0,
city: "Hà Nội",
},
{
id: "p_ta_hien",
name: "Bia hơi Tạ Hiện",
address: "Tạ Hiện, Hoàn Kiếm, Hà Nội",
short_address: "Tạ Hiện · Hoàn Kiếm",
category: "entertainment",
tags: ["phố tây", "tối"],
cover_url: "https://images.unsplash.com/photo-1514933651103-005eec06c04b?w=600&q=80",
created_by: "u_hung",
created_at: "2026-03-10",
visited: false,
avg_rating: 3.8,
city: "Hà Nội",
},
{
id: "p_trang_tien",
name: "Kem Tràng Tiền",
address: "35 Tràng Tiền, Hoàn Kiếm, Hà Nội",
short_address: "35 Tràng Tiền · Hoàn Kiếm",
category: "food",
tags: ["kem", "tráng miệng"],
cover_url: "https://images.unsplash.com/photo-1497034825429-c343d7c6a68f?w=600&q=80",
created_by: "u_thao",
created_at: "2026-03-05",
my_rating: 3,
visited: true,
visited_at: "2026-03-06",
avg_rating: 3.6,
city: "Hà Nội",
},
{
id: "p_banh_mi_huynh_hoa",
name: "Bánh mì Huỳnh Hoa",
address: "26 Lê Thị Riêng, Quận 1, TP. Hồ Chí Minh",
short_address: "26 Lê Thị Riêng · Q1",
category: "food",
tags: ["bánh mì", "pate"],
cover_url: null,
created_by: "u_me",
created_at: "2026-02-20",
my_rating: 5,
my_notes: "Béo, ngậy, đi 2 người ăn 1 ổ là vừa.",
visited: true,
visited_at: "2026-02-21",
avg_rating: 4.5,
city: "TP. HCM",
},
{
id: "p_workshop",
name: "The Workshop Coffee",
address: "27 Ngô Đức Kế, Quận 1, TP. Hồ Chí Minh",
short_address: "27 Ngô Đức Kế · Q1",
category: "cafe",
tags: ["specialty", "không gian rộng"],
cover_url: "https://images.unsplash.com/photo-1453614512568-c4024d13c247?w=600&q=80",
created_by: "u_linh",
created_at: "2026-02-18",
my_rating: 4,
visited: false,
avg_rating: 4.3,
city: "TP. HCM",
},
{
id: "p_pho_le",
name: "Phở Lệ",
address: "413 Nguyễn Trãi, Quận 5, TP. Hồ Chí Minh",
short_address: "413 Nguyễn Trãi · Q5",
category: "food",
tags: ["phở Nam", "mở khuya"],
cover_url: "https://images.unsplash.com/photo-1591814468924-caf88d1232e1?w=600&q=80",
created_by: "u_tung",
created_at: "2026-02-10",
visited: false,
avg_rating: 4.4,
city: "TP. HCM",
},
{
id: "p_ben_thanh",
name: "Chợ Bến Thành",
address: "Lê Lợi, Quận 1, TP. Hồ Chí Minh",
short_address: "Lê Lợi · Q1",
category: "shopping",
tags: ["chợ", "đặc sản"],
cover_url: "https://images.unsplash.com/photo-1555529669-e69e7aa0ba9a?w=600&q=80",
created_by: "u_me",
created_at: "2026-02-01",
visited: true,
visited_at: "2026-02-02",
my_rating: 3,
avg_rating: 3.5,
city: "TP. HCM",
},
{
id: "p_banh_mi_phuong",
name: "Bánh mì Phượng",
address: "2B Phan Châu Trinh, Hội An, Quảng Nam",
short_address: "2B Phan Châu Trinh · Hội An",
category: "food",
tags: ["bánh mì", "huyền thoại"],
cover_url: "https://images.unsplash.com/photo-1558030006-450675393462?w=600&q=80",
created_by: "u_me",
created_at: "2026-05-01",
visited: false,
avg_rating: 4.7,
city: "Hội An",
},
{
id: "p_reaching_out",
name: "Reaching Out Tea House",
address: "131 Trần Phú, Hội An, Quảng Nam",
short_address: "131 Trần Phú · Hội An",
category: "cafe",
tags: ["trà", "yên tĩnh"],
cover_url: "https://images.unsplash.com/photo-1556679343-c7306c1976bc?w=600&q=80",
created_by: "u_linh",
created_at: "2026-05-02",
visited: false,
avg_rating: 4.8,
city: "Hội An",
},
{
id: "p_my_quang",
name: "Mỳ Quảng Bà Mua",
address: "95 Nguyễn Tri Phương, Đà Nẵng",
short_address: "95 Nguyễn Tri Phương · Đà Nẵng",
category: "food",
tags: ["mỳ Quảng", "đặc sản"],
cover_url: "https://images.unsplash.com/photo-1569718212165-3a8278d5f624?w=600&q=80",
created_by: "u_tung",
created_at: "2026-05-03",
visited: false,
avg_rating: 4.3,
city: "Đà Nẵng",
},
{
id: "p_hoi_an_old_town",
name: "Phố cổ Hội An",
address: "Phố cổ, Hội An, Quảng Nam",
short_address: "Phố cổ · Hội An",
category: "entertainment",
tags: ["di sản", "đèn lồng", "tối"],
cover_url: "https://images.unsplash.com/photo-1540541338287-41700207dee6?w=600&q=80",
created_by: "u_me",
created_at: "2026-05-04",
visited: false,
avg_rating: 4.9,
city: "Hội An",
},
];
export const COLLECTIONS: Collection[] = [
{
id: "c_ha_noi",
name: "Hà Nội phải đi",
type: "folder",
owner_id: "u_me",
member_count: 3,
place_count: 7,
my_role: "owner",
cover_place_ids: ["p_pho_gia_truyen", "p_cafe_giang", "p_ta_hien"],
place_ids: ["p_pho_gia_truyen", "p_bun_cha_huong_lien", "p_cafe_giang", "p_the_note", "p_cong_nha_tho", "p_ta_hien", "p_trang_tien"],
members: ["u_me", "u_tung", "u_linh"],
},
{
id: "c_hoi_an",
name: "Hội An tháng 6",
type: "trip",
owner_id: "u_me",
trip_start: "2026-06-12",
trip_end: "2026-06-18",
member_count: 4,
place_count: 5,
my_role: "owner",
cover_place_ids: ["p_hoi_an_old_town", "p_banh_mi_phuong", "p_reaching_out"],
place_ids: ["p_banh_mi_phuong", "p_reaching_out", "p_my_quang", "p_hoi_an_old_town", "p_cong_nha_tho"],
members: ["u_me", "u_tung", "u_linh", "u_thao"],
},
{
id: "c_cafe",
name: "Quán cà phê cuối tuần",
type: "folder",
owner_id: "u_me",
member_count: 1,
place_count: 4,
my_role: "owner",
cover_place_ids: ["p_cafe_giang", "p_workshop", "p_the_note"],
place_ids: ["p_cafe_giang", "p_workshop", "p_the_note", "p_cong_nha_tho"],
members: ["u_me"],
},
{
id: "c_sg_short",
name: "Sài Gòn 2 ngày",
type: "trip",
owner_id: "u_hung",
trip_start: "2026-05-23",
trip_end: "2026-05-25",
member_count: 5,
place_count: 6,
my_role: "viewer",
cover_place_ids: ["p_banh_mi_huynh_hoa", "p_pho_le", "p_ben_thanh"],
place_ids: ["p_banh_mi_huynh_hoa", "p_workshop", "p_pho_le", "p_ben_thanh"],
members: ["u_hung", "u_me", "u_tung", "u_linh", "u_thao"],
},
];
export const CATEGORIES: Record<CategoryId, CategoryMeta> = {
food: { label: "Ăn uống", icon: "Utensils", color: "var(--cat-food)" },
cafe: { label: "Cà phê", icon: "Coffee", color: "var(--cat-cafe)" },
shopping: { label: "Mua sắm", icon: "ShoppingBag", color: "var(--cat-shopping)" },
entertainment: { label: "Giải trí", icon: "Sparkles", color: "var(--cat-entertainment)" },
other: { label: "Khác", icon: "MapPin", color: "var(--cat-other)" },
};
export const FILTERS: { id: string; label: string }[] = [
{ id: "all", label: "Tất cả" },
{ id: "food", label: "Ăn uống" },
{ id: "cafe", label: "Cà phê" },
{ id: "shopping", label: "Mua sắm" },
{ id: "entertainment", label: "Giải trí" },
{ id: "visited", label: "Đã đến" },
{ id: "unvisited", label: "Chưa đến" },
];

53
src/lib/types.ts Normal file
View File

@@ -0,0 +1,53 @@
export type CategoryId = "food" | "cafe" | "shopping" | "entertainment" | "other";
export type CollectionType = "folder" | "trip";
export type Role = "owner" | "editor" | "viewer";
export type User = {
id: string;
name: string;
email?: string;
avatar_url?: string | null;
initials: string;
color?: string;
};
export type Place = {
id: string;
name: string;
address: string;
short_address: string;
category: CategoryId;
tags: string[];
cover_url?: string | null;
created_by: string;
created_at: string;
my_rating?: number;
my_notes?: string;
visited: boolean;
visited_at?: string;
avg_rating?: number;
city: string;
};
export type Collection = {
id: string;
name: string;
type: CollectionType;
owner_id: string;
trip_start?: string;
trip_end?: string;
member_count: number;
place_count: number;
my_role: Role;
cover_place_ids: string[];
place_ids: string[];
members: string[];
};
export type CategoryMeta = {
label: string;
icon: string;
color: string;
};

View File

@@ -0,0 +1,367 @@
"use client";
import { useState } from "react";
import { COLLECTIONS } from "@/lib/mock-data";
import { fmtShortDate, tripDays } from "@/lib/format";
import type { AppState, Dispatch } from "@/lib/app-state";
import {
Header,
IconBtn,
Checkbox,
MenuItem,
EmptyState,
} from "@/components/ui-primitives";
import { PlaceCard } from "@/components/place-card";
import { AvatarStack } from "@/components/avatar";
import { Icons } from "@/components/icons";
export function CollectionDetailScreen({
state,
dispatch,
}: {
state: AppState & { collectionId?: string };
dispatch: Dispatch;
}) {
const c = COLLECTIONS.find((x) => x.id === state.collectionId);
const [vfilter, setVfilter] = useState<"all" | "visited" | "unvisited">(
"all",
);
const [menuOpen, setMenuOpen] = useState(false);
if (!c) return null;
const places = c.place_ids
.map((id) => state.places.find((p) => p.id === id))
.filter((p): p is NonNullable<typeof p> => Boolean(p));
const filtered = places.filter((p) => {
if (vfilter === "visited") return p.visited;
if (vfilter === "unvisited") return !p.visited;
return true;
});
const visitedCount = places.filter((p) => p.visited).length;
const isTrip = c.type === "trip";
const isViewer = c.my_role === "viewer";
return (
<div className="app-surface">
<Header
title={c.name}
left={
<IconBtn
icon="ChevronLeft"
label="Quay lại"
onClick={() => dispatch({ type: "BACK" })}
/>
}
right={
<IconBtn
icon="MoreHorizontal"
label="Thêm"
onClick={() => setMenuOpen(true)}
/>
}
/>
<div className="app-scroll">
{/* Hero */}
<div
style={{
margin: "8px 16px 0",
padding: "20px",
background: isTrip
? "linear-gradient(135deg, color-mix(in oklch, var(--primary) 14%, var(--card)), var(--card))"
: "var(--card)",
border: "0.5px solid var(--border)",
borderRadius: "var(--radius-xl)",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 8,
}}
>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
color: isTrip ? "var(--primary)" : "var(--muted-foreground)",
fontSize: 12,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
{isTrip ? (
<Icons.Plane size={13} stroke={2} />
) : (
<Icons.Folder size={13} stroke={2} />
)}
{isTrip ? "Chuyến đi" : "Thư mục"}
</span>
{isViewer && (
<span
className="badge"
style={{ height: 20, fontSize: 11, padding: "0 8px" }}
>
<Icons.Eye size={10} stroke={2} />
Chỉ xem
</span>
)}
</div>
<div
className={isTrip ? "display" : ""}
style={{
fontSize: isTrip ? 32 : 26,
fontWeight: 700,
letterSpacing: "-0.025em",
lineHeight: 1.1,
}}
>
{c.name}
</div>
{isTrip ? (
<>
<div
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 10,
fontSize: 14,
color: "var(--muted-foreground)",
}}
>
<Icons.Calendar size={14} stroke={2} />
{fmtShortDate(c.trip_start)} {fmtShortDate(c.trip_end)}
<span style={{ opacity: 0.5 }}>·</span>
{tripDays(c.trip_start, c.trip_end)}
</div>
<div style={{ marginTop: 16 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 13,
marginBottom: 6,
}}
>
<span
style={{
color: "var(--muted-foreground)",
fontWeight: 500,
}}
>
Đã ghé
</span>
<span style={{ fontWeight: 600 }}>
{visitedCount}/{places.length}
</span>
</div>
<div className="progress">
<div
style={{
width: `${places.length ? (visitedCount / places.length) * 100 : 0}%`,
}}
/>
</div>
</div>
</>
) : (
<div
style={{
marginTop: 6,
fontSize: 14,
color: "var(--muted-foreground)",
}}
>
{places.length} đa điểm · {visitedCount} đã đến
</div>
)}
<div
style={{
marginTop: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<button
onClick={() =>
dispatch({ type: "OPEN_MEMBERS", collectionId: c.id })
}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 0,
background: "transparent",
border: 0,
color: "inherit",
}}
>
<AvatarStack userIds={c.members} max={4} size={28} />
<span
style={{
fontSize: 13,
color: "var(--muted-foreground)",
fontWeight: 500,
}}
>
{c.member_count} thành viên
</span>
</button>
{!isViewer && (
<button
className="btn btn--outline"
style={{ height: 36, padding: "0 14px", fontSize: 13 }}
onClick={() =>
dispatch({ type: "OPEN_INVITE", collectionId: c.id })
}
>
<Icons.Users size={15} stroke={2} />
Mời
</button>
)}
</div>
</div>
{/* Filter pills */}
<div
className="no-scrollbar"
style={{
display: "flex",
gap: 8,
padding: "20px 16px 12px",
overflowX: "auto",
}}
>
{(
[
{ id: "all", label: `Tất cả ${places.length}` },
{
id: "unvisited",
label: `Chưa đến ${places.length - visitedCount}`,
},
{ id: "visited", label: `Đã đến ${visitedCount}` },
] as const
).map((f) => (
<button
key={f.id}
className="pill"
data-active={vfilter === f.id}
onClick={() => setVfilter(f.id)}
>
{f.label}
</button>
))}
</div>
{/* Place list */}
<div
style={{
padding: "0 16px 24px",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
{filtered.length === 0 ? (
<EmptyState
icon="MapPin"
title="Chưa có địa điểm"
body={
isViewer
? "Người chia sẻ chưa thêm địa điểm nào."
: "Bấm + để thêm địa điểm đầu tiên vào bộ sưu tập này."
}
/>
) : (
filtered.map((p) => (
<PlaceCard
key={p.id}
place={p}
onTap={() =>
dispatch({ type: "NAV", screen: "place", placeId: p.id })
}
trailing={
<Checkbox
checked={p.visited}
onClick={() =>
dispatch({ type: "TOGGLE_VISITED", placeId: p.id })
}
/>
}
/>
))
)}
</div>
</div>
{menuOpen && (
<>
<div className="overlay" onClick={() => setMenuOpen(false)} />
<div className="sheet" style={{ maxHeight: "60%" }}>
<div className="sheet-handle" />
<div style={{ padding: "8px 0 16px" }}>
{!isViewer && (
<MenuItem
icon="Edit"
label="Sửa thông tin"
onClick={() => {
setMenuOpen(false);
dispatch({ type: "TOAST", value: "Sửa (demo)" });
}}
/>
)}
{!isViewer && (
<MenuItem
icon="Users"
label="Mời thành viên"
onClick={() => {
setMenuOpen(false);
dispatch({ type: "OPEN_INVITE", collectionId: c.id });
}}
/>
)}
<MenuItem
icon="User"
label={`Thành viên (${c.member_count})`}
onClick={() => {
setMenuOpen(false);
dispatch({ type: "OPEN_MEMBERS", collectionId: c.id });
}}
/>
<MenuItem
icon="Share"
label="Chia sẻ bộ sưu tập"
onClick={() => {
setMenuOpen(false);
dispatch({ type: "TOAST", value: "Đã sao chép liên kết" });
}}
/>
{c.my_role === "owner" && (
<MenuItem
icon="Trash"
label="Xóa bộ sưu tập"
danger
onClick={() => {
setMenuOpen(false);
dispatch({
type: "CONFIRM_DELETE_COLLECTION",
collectionId: c.id,
});
}}
/>
)}
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,248 @@
"use client";
import { type CSSProperties, useState } from "react";
import { COLLECTIONS, PLACES } from "@/lib/mock-data";
import { fmtShortDate } from "@/lib/format";
import type { AppState, Dispatch } from "@/lib/app-state";
import type { Collection } from "@/lib/types";
import { Header, IconBtn } from "@/components/ui-primitives";
import { CoverImage } from "@/components/cover-image";
import { AvatarStack } from "@/components/avatar";
import { Icons } from "@/components/icons";
export function CollectionsListScreen({
dispatch,
}: {
state: AppState;
dispatch: Dispatch;
}) {
const [tab, setTab] = useState<"all" | "trips" | "folders">("all");
const filtered = COLLECTIONS.filter((c) => {
if (tab === "all") return true;
if (tab === "trips") return c.type === "trip";
return c.type === "folder";
});
return (
<div className="app-surface">
<Header
big
title="Bộ sưu tập"
subtitle={`${COLLECTIONS.length} bộ sưu tập · ${COLLECTIONS.filter((c) => c.my_role !== "owner").length} được chia sẻ`}
right={
<IconBtn
icon="Plus"
label="Tạo mới"
variant="muted"
onClick={() =>
dispatch({ type: "TOAST", value: "Tạo bộ sưu tập mới (demo)" })
}
/>
}
/>
<div style={{ padding: "0 16px 12px" }}>
<div className="tabs">
<button data-active={tab === "all"} onClick={() => setTab("all")}>
Tất cả
</button>
<button
data-active={tab === "trips"}
onClick={() => setTab("trips")}
>
Chuyến đi
</button>
<button
data-active={tab === "folders"}
onClick={() => setTab("folders")}
>
Thư mục
</button>
</div>
</div>
<div className="app-scroll">
<div
style={{
padding: "4px 16px 24px",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
{filtered.map((c, i) => (
<CollectionCard
key={c.id}
c={c}
onTap={() =>
dispatch({
type: "NAV",
screen: "collection",
collectionId: c.id,
})
}
style={{ animationDelay: `${i * 30}ms` }}
/>
))}
</div>
</div>
</div>
);
}
function CollectionCard({
c,
onTap,
style,
}: {
c: Collection;
onTap: () => void;
style?: CSSProperties;
}) {
const places = c.cover_place_ids
.map((id) => PLACES.find((p) => p.id === id))
.filter((p): p is NonNullable<typeof p> => Boolean(p));
const isTrip = c.type === "trip";
return (
<button
onClick={onTap}
className="page-enter"
style={{
...style,
appearance: "none",
background: "var(--card)",
border: "0.5px solid var(--border)",
borderRadius: "var(--radius-xl)",
overflow: "hidden",
padding: 0,
textAlign: "left",
color: "inherit",
boxShadow: "var(--shadow-sm)",
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "2fr 1fr",
gridTemplateRows: "1fr 1fr",
gap: 2,
height: 140,
background: "var(--muted)",
}}
>
<div
style={{
gridRow: "1 / 3",
overflow: "hidden",
position: "relative",
}}
>
<CoverImage
src={places[0]?.cover_url}
alt=""
category={places[0]?.category || "other"}
style={{
width: "100%",
height: "100%",
position: "absolute",
inset: 0,
}}
/>
</div>
<div style={{ overflow: "hidden", position: "relative" }}>
<CoverImage
src={places[1]?.cover_url}
alt=""
category={places[1]?.category || "other"}
style={{
width: "100%",
height: "100%",
position: "absolute",
inset: 0,
}}
/>
</div>
<div style={{ overflow: "hidden", position: "relative" }}>
<CoverImage
src={places[2]?.cover_url}
alt=""
category={places[2]?.category || "other"}
style={{
width: "100%",
height: "100%",
position: "absolute",
inset: 0,
}}
/>
</div>
</div>
<div
style={{
padding: 14,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
color: isTrip ? "var(--primary)" : "var(--muted-foreground)",
fontSize: 12,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
{isTrip ? (
<Icons.Plane size={12} stroke={2} />
) : (
<Icons.Folder size={12} stroke={2} />
)}
{isTrip ? "Chuyến đi" : "Thư mục"}
</span>
{c.my_role === "viewer" && (
<span
className="badge"
style={{ height: 18, fontSize: 11, padding: "0 6px" }}
>
<Icons.Eye size={10} stroke={2} />
Chỉ xem
</span>
)}
</div>
<div
style={{
fontSize: isTrip ? 22 : 19,
fontWeight: 700,
letterSpacing: "-0.02em",
lineHeight: 1.15,
fontFamily: isTrip ? "var(--font-display)" : "var(--font-sans)",
}}
>
{c.name}
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: 13,
color: "var(--muted-foreground)",
}}
>
<span>
{isTrip && c.trip_start
? `${fmtShortDate(c.trip_start)}${fmtShortDate(c.trip_end)}`
: `${c.place_count} địa điểm`}
{isTrip && ` · ${c.place_count} chỗ`}
</span>
<AvatarStack userIds={c.members} max={3} size={22} />
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,469 @@
"use client";
import { useState } from "react";
import { CATEGORIES, COLLECTIONS, USERS } from "@/lib/mock-data";
import { fmtDate } from "@/lib/format";
import type { AppState, Dispatch } from "@/lib/app-state";
import { IconBtn, Checkbox, MenuItem } from "@/components/ui-primitives";
import { CoverImage } from "@/components/cover-image";
import { RatingStars } from "@/components/rating-stars";
import { Avatar } from "@/components/avatar";
import { Icons } from "@/components/icons";
export function PlaceDetailScreen({
state,
dispatch,
}: {
state: AppState & { placeId?: string };
dispatch: Dispatch;
}) {
const place = state.places.find((p) => p.id === state.placeId);
const [notes, setNotes] = useState(place?.my_notes || "");
const [menuOpen, setMenuOpen] = useState(false);
if (!place) return null;
const cat = CATEGORIES[place.category];
const CatIcon = Icons[cat.icon as keyof typeof Icons];
const collectionsContaining = COLLECTIONS.filter((c) =>
c.place_ids.includes(place.id),
);
const creator = USERS[place.created_by];
return (
<div className="app-surface" style={{ background: "var(--background)" }}>
<div className="app-scroll" style={{ scrollbarWidth: "none" }}>
{/* Hero */}
<div
style={{
position: "relative",
aspectRatio: "16 / 11",
background: "var(--muted)",
overflow: "hidden",
}}
>
<CoverImage
src={place.cover_url}
alt={place.name}
category={place.category}
style={{
width: "100%",
height: "100%",
position: "absolute",
inset: 0,
}}
/>
<div className="hero-scrim" />
<div
style={{
position: "absolute",
top: 12,
left: 12,
right: 12,
display: "flex",
justifyContent: "space-between",
zIndex: 2,
}}
>
<IconBtn
icon="ChevronLeft"
label="Quay lại"
variant="glass"
onClick={() => dispatch({ type: "BACK" })}
/>
<div style={{ display: "flex", gap: 8 }}>
<IconBtn
icon="Share"
label="Chia sẻ"
variant="glass"
onClick={() =>
dispatch({ type: "TOAST", value: "Đã sao chép liên kết" })
}
/>
<IconBtn
icon="MoreHorizontal"
label="Thêm"
variant="glass"
onClick={() => setMenuOpen((v) => !v)}
/>
</div>
</div>
<div
style={{
position: "absolute",
left: 16,
right: 16,
bottom: 16,
color: "#fff",
zIndex: 2,
}}
>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
background: "rgba(20,16,10,0.55)",
backdropFilter: "blur(12px)",
padding: "4px 10px",
borderRadius: 9999,
fontSize: 12,
fontWeight: 600,
marginBottom: 8,
}}
>
<CatIcon size={13} stroke={2} />
{cat.label}
{place.visited && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<Icons.Check size={13} stroke={2.5} />
Đã đến
</>
)}
</div>
<h1
style={{
margin: 0,
fontSize: 26,
fontWeight: 700,
letterSpacing: "-0.02em",
lineHeight: 1.15,
textShadow: "0 2px 12px rgba(0,0,0,0.35)",
}}
>
{place.name}
</h1>
</div>
</div>
{/* Body */}
<div
style={{
padding: "20px 16px 32px",
display: "flex",
flexDirection: "column",
gap: 18,
}}
>
{place.tags.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{place.tags.map((t) => (
<span
key={t}
className="badge badge--outline"
style={{ height: 26 }}
>
{t}
</span>
))}
</div>
)}
{/* Address row */}
<button
onClick={() =>
dispatch({ type: "TOAST", value: "Đang mở Google Maps..." })
}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: 14,
background: "var(--card)",
border: "0.5px solid var(--border)",
borderRadius: "var(--radius-lg)",
textAlign: "left",
width: "100%",
color: "inherit",
}}
>
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
background:
"color-mix(in oklch, var(--primary) 12%, var(--background-soft))",
color: "var(--primary)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Icons.MapPin size={22} stroke={1.8} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{ fontSize: 13, color: "var(--muted-foreground)" }}
>
Đa chỉ
</div>
<div
style={{
fontSize: 15,
fontWeight: 500,
lineHeight: 1.3,
}}
>
{place.address}
</div>
</div>
<Icons.ExternalLink
size={18}
stroke={1.75}
style={{ color: "var(--muted-foreground)", flexShrink: 0 }}
/>
</button>
{/* Visited + Rating */}
<div
style={{
background: "var(--card)",
border: "0.5px solid var(--border)",
borderRadius: "var(--radius-lg)",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "14px 16px",
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 500 }}>Đã đến đây</div>
<div
style={{ fontSize: 13, color: "var(--muted-foreground)" }}
>
{place.visited && place.visited_at
? `Đánh dấu vào ${fmtDate(place.visited_at)}`
: "Bấm khi bạn đã ghé"}
</div>
</div>
<Checkbox
checked={place.visited}
onClick={() =>
dispatch({ type: "TOGGLE_VISITED", placeId: place.id })
}
/>
</div>
<div className="divider" />
<div style={{ padding: "14px 16px" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ fontSize: 15, fontWeight: 500 }}>
Đánh giá của bạn
</div>
<RatingStars
value={place.my_rating || 0}
readOnly={false}
size={22}
onChange={(v) =>
dispatch({
type: "SET_RATING",
placeId: place.id,
value: v,
})
}
/>
</div>
<div
style={{
marginTop: 6,
fontSize: 13,
color: "var(--muted-foreground)",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
{place.my_rating ? (
<>
Bạn{" "}
<b style={{ color: "var(--foreground)" }}>
{place.my_rating}
</b>
</>
) : (
"Chưa đánh giá"
)}
{place.avg_rating && (
<>
<span style={{ opacity: 0.5 }}>·</span>
Nhóm{" "}
<b style={{ color: "var(--foreground)" }}>
{place.avg_rating.toFixed(1)}
</b>
</>
)}
</div>
</div>
</div>
{/* Notes */}
<div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 13,
fontWeight: 600,
color: "var(--muted-foreground)",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 8,
}}
>
<Icons.Lock size={13} stroke={2} />
Ghi chú riêng
</div>
<div className="input input--multi">
<textarea
value={notes}
placeholder="Thêm ghi chú riêng — chỉ mình bạn thấy..."
onChange={(e) => setNotes(e.target.value)}
onBlur={() =>
dispatch({
type: "SET_NOTES",
placeId: place.id,
value: notes,
})
}
style={{ resize: "none", minHeight: 72 }}
/>
</div>
</div>
{/* Collections */}
{collectionsContaining.length > 0 && (
<div>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--muted-foreground)",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 8,
}}
>
trong bộ sưu tập
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{collectionsContaining.map((c) => (
<button
key={c.id}
className="badge"
onClick={() =>
dispatch({
type: "NAV",
screen: "collection",
collectionId: c.id,
})
}
style={{
height: 32,
padding: "0 12px",
border: 0,
background: "var(--primary-soft)",
color: "var(--primary)",
fontWeight: 600,
fontSize: 13,
}}
>
{c.type === "trip" ? (
<Icons.Plane size={13} stroke={2} />
) : (
<Icons.Folder size={13} stroke={2} />
)}
{c.name}
</button>
))}
</div>
</div>
)}
{/* Created by */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
paddingTop: 8,
color: "var(--subtle-foreground)",
fontSize: 12,
}}
>
<Avatar user={creator} size={24} />
<span>
Thêm bởi{" "}
<b style={{ color: "var(--foreground)", fontWeight: 600 }}>
{creator?.name}
</b>{" "}
· {fmtDate(place.created_at)}
</span>
</div>
</div>
</div>
{menuOpen && (
<>
<div className="overlay" onClick={() => setMenuOpen(false)} />
<div className="sheet" style={{ maxHeight: "50%" }}>
<div className="sheet-handle" />
<div style={{ padding: "8px 0 16px" }}>
<MenuItem
icon="Edit"
label="Sửa địa điểm"
onClick={() => {
setMenuOpen(false);
dispatch({ type: "TOAST", value: "Tính năng sửa (demo)" });
}}
/>
<MenuItem
icon="Bookmark"
label="Lưu vào bộ sưu tập"
onClick={() => {
setMenuOpen(false);
dispatch({
type: "TOAST",
value: 'Đã thêm vào "Hà Nội phải đi"',
});
}}
/>
<MenuItem
icon="Share"
label="Chia sẻ"
onClick={() => {
setMenuOpen(false);
dispatch({ type: "TOAST", value: "Đã sao chép liên kết" });
}}
/>
{place.created_by === "u_me" && (
<MenuItem
icon="Trash"
label="Xóa địa điểm"
danger
onClick={() => {
setMenuOpen(false);
dispatch({
type: "CONFIRM_DELETE_PLACE",
placeId: place.id,
});
}}
/>
)}
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import { useMemo, useState } from "react";
import { FILTERS } from "@/lib/mock-data";
import type { AppState, Dispatch } from "@/lib/app-state";
import { Header, IconBtn, OfflineBanner, EmptyState } from "@/components/ui-primitives";
import { PlaceCard } from "@/components/place-card";
import { Icons } from "@/components/icons";
export function PlacesListScreen({
state,
dispatch,
}: {
state: AppState;
dispatch: Dispatch;
}) {
const { filter, search, places, offline } = state;
const [searchOpen, setSearchOpen] = useState(false);
const filtered = useMemo(() => {
let out = places;
if (filter !== "all") {
if (filter === "visited") out = out.filter((p) => p.visited);
else if (filter === "unvisited") out = out.filter((p) => !p.visited);
else out = out.filter((p) => p.category === filter);
}
if (search.trim()) {
const s = search.toLowerCase();
out = out.filter(
(p) =>
p.name.toLowerCase().includes(s) ||
p.address.toLowerCase().includes(s) ||
p.tags.some((t) => t.toLowerCase().includes(s)),
);
}
return out;
}, [filter, search, places]);
return (
<div className="app-surface">
<Header
big
title="Địa điểm"
subtitle={`${places.length} chỗ đã lưu · ${places.filter((p) => p.visited).length} đã đến`}
right={
<IconBtn
icon="Search"
label="Tìm"
onClick={() => setSearchOpen((v) => !v)}
variant="muted"
/>
}
/>
{offline && <OfflineBanner />}
{searchOpen && (
<div style={{ padding: "0 16px 12px" }}>
<div className="input" style={{ height: 44 }}>
<Icons.Search
size={18}
stroke={1.75}
style={{ color: "var(--muted-foreground)" }}
/>
<input
autoFocus
placeholder="Tên quán, địa chỉ, tag..."
value={search}
onChange={(e) =>
dispatch({ type: "SET_SEARCH", value: e.target.value })
}
/>
{search && (
<button
type="button"
onClick={() => dispatch({ type: "SET_SEARCH", value: "" })}
style={{
background: "transparent",
border: 0,
color: "var(--muted-foreground)",
}}
>
<Icons.X size={16} stroke={2} />
</button>
)}
</div>
</div>
)}
<div
className="no-scrollbar"
style={{
display: "flex",
gap: 8,
padding: "0 16px 12px",
overflowX: "auto",
overflowY: "hidden",
scrollSnapType: "x proximity",
}}
>
{FILTERS.map((f) => (
<button
key={f.id}
className="pill"
data-active={filter === f.id}
onClick={() => dispatch({ type: "SET_FILTER", value: f.id })}
>
{f.label}
</button>
))}
</div>
<div className="app-scroll">
{filtered.length === 0 ? (
<EmptyState
icon={search ? "Search" : "MapPin"}
title={search ? "Không tìm thấy gì" : "Chưa có địa điểm nào"}
body={
search
? `Không có địa điểm nào khớp với "${search}".`
: "Bấm nút + để lưu địa điểm đầu tiên — quán cà phê quen, chỗ ăn ngon, hay nơi muốn đến."
}
/>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 10,
padding: "4px 16px 24px",
}}
>
{filtered.map((p, i) => (
<div
key={p.id}
className="page-enter"
style={{ animationDelay: `${i * 18}ms` }}
>
<PlaceCard
place={p}
onTap={() =>
dispatch({ type: "NAV", screen: "place", placeId: p.id })
}
/>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,195 @@
"use client";
import { COLLECTIONS, ME } from "@/lib/mock-data";
import type { AppState, Dispatch } from "@/lib/app-state";
import { Header, IconBtn } from "@/components/ui-primitives";
import { Icons, type IconName } from "@/components/icons";
export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch }) {
const stats = [
{ label: "Địa điểm", value: state.places.length },
{ label: "Đã đến", value: state.places.filter((p) => p.visited).length },
{ label: "Bộ sưu tập", value: COLLECTIONS.length },
];
return (
<div className="app-surface">
<Header big title="Hồ sơ" />
<div className="app-scroll">
<div
style={{
padding: "4px 16px 24px",
display: "flex",
flexDirection: "column",
gap: 18,
}}
>
{/* Identity */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: 16,
background: "var(--card)",
border: "0.5px solid var(--border)",
borderRadius: "var(--radius-xl)",
}}
>
<div
style={{
width: 64,
height: 64,
borderRadius: 9999,
background:
"linear-gradient(135deg, var(--primary), color-mix(in oklch, var(--primary) 60%, oklch(60% 0.12 320)))",
color: "var(--primary-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 24,
fontWeight: 700,
}}
>
{ME.initials}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 18,
fontWeight: 700,
letterSpacing: "-0.01em",
}}
>
{ME.name}
</div>
<div
style={{ fontSize: 13, color: "var(--muted-foreground)" }}
>
{ME.email}
</div>
</div>
<IconBtn icon="Edit2" label="Sửa" />
</div>
{/* Stats */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 8,
}}
>
{stats.map((s) => (
<div
key={s.label}
style={{
padding: "14px 12px",
background: "var(--card)",
border: "0.5px solid var(--border)",
borderRadius: "var(--radius-lg)",
}}
>
<div
style={{
fontSize: 24,
fontWeight: 700,
letterSpacing: "-0.02em",
}}
>
{s.value}
</div>
<div
style={{
fontSize: 12,
color: "var(--muted-foreground)",
marginTop: 2,
}}
>
{s.label}
</div>
</div>
))}
</div>
{/* Settings list */}
<div
style={{
background: "var(--card)",
border: "0.5px solid var(--border)",
borderRadius: "var(--radius-xl)",
overflow: "hidden",
}}
>
<SettingRow icon="Bell" label="Thông báo" detail="Bật" />
<SettingRow icon="Globe" label="Ngôn ngữ" detail="Tiếng Việt" />
<SettingRow icon="Settings" label="Cài đặt" />
<SettingRow icon="LogOut" label="Đăng xuất" danger last />
</div>
<div
style={{
textAlign: "center",
color: "var(--subtle-foreground)",
fontSize: 12,
paddingTop: 8,
}}
>
Places · 1.0 · made for nhóm nhỏ
</div>
</div>
</div>
</div>
);
}
function SettingRow({
icon,
label,
detail,
last,
danger,
}: {
icon: IconName;
label: string;
detail?: string;
last?: boolean;
danger?: boolean;
}) {
const I = Icons[icon];
return (
<>
<button
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 12,
padding: "14px 16px",
background: "transparent",
border: 0,
textAlign: "left",
color: danger ? "var(--danger)" : "var(--foreground)",
}}
>
<I size={20} stroke={1.75} />
<span style={{ flex: 1, fontSize: 15, fontWeight: 500 }}>{label}</span>
{detail && (
<span
style={{ fontSize: 13, color: "var(--muted-foreground)" }}
>
{detail}
</span>
)}
{!danger && (
<Icons.ChevronRight
size={16}
stroke={1.75}
style={{ color: "var(--subtle-foreground)" }}
/>
)}
</button>
{!last && <div className="divider" style={{ marginLeft: 48 }} />}
</>
);
}

View File

@@ -0,0 +1,433 @@
"use client";
import { useState } from "react";
import { CATEGORIES } from "@/lib/mock-data";
import type { CategoryId } from "@/lib/types";
import type { Dispatch } from "@/lib/app-state";
import { FieldLabel } from "@/components/ui-primitives";
import { RatingStars } from "@/components/rating-stars";
import { Icons } from "@/components/icons";
const ADDRESS_SUGG = [
{ addr: "12 Nhà Thờ, Hoàn Kiếm, Hà Nội", sub: "Hà Nội · 0.8 km" },
{ addr: "49 Bát Đàn, Hoàn Kiếm, Hà Nội", sub: "Hà Nội · 1.2 km" },
{ addr: "128 Mã Mây, Hoàn Kiếm, Hà Nội", sub: "Hà Nội · 1.5 km" },
{ addr: "24 Lý Quốc Sư, Hoàn Kiếm, Hà Nội", sub: "Hà Nội · 1.8 km" },
];
export function AddPlaceSheet({
onClose,
dispatch,
}: {
onClose: () => void;
dispatch: Dispatch;
}) {
const [name, setName] = useState("");
const [address, setAddress] = useState("");
const [showSugg, setShowSugg] = useState(false);
const [category, setCategory] = useState<CategoryId>("food");
const [tags, setTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState("");
const [notes, setNotes] = useState("");
const [rating, setRating] = useState(0);
const [cover, setCover] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const suggestions = address
? ADDRESS_SUGG.filter((s) =>
s.addr.toLowerCase().includes(address.toLowerCase()),
)
: ADDRESS_SUGG;
const addTag = (raw: string) => {
const t = raw.trim().replace(/,$/, "");
if (t && !tags.includes(t) && tags.length < 10) {
setTags([...tags, t]);
}
setTagInput("");
};
const removeTag = (t: string) => setTags(tags.filter((x) => x !== t));
const isValid = name.trim() && address.trim() && category;
const submit = () => {
if (!isValid) return;
setSubmitting(true);
setTimeout(() => {
dispatch({
type: "ADD_PLACE",
place: {
id: "p_new_" + Date.now(),
name: name.trim(),
address: address.trim(),
short_address: address.trim().split(",").slice(0, 2).join(" · "),
category,
tags,
cover_url: cover,
created_by: "u_me",
created_at: new Date().toISOString().slice(0, 10),
my_rating: rating || undefined,
my_notes: notes || undefined,
visited: false,
avg_rating: undefined,
city: address.split(",").pop()?.trim() || "",
},
});
onClose();
dispatch({ type: "TOAST", value: `Đã lưu "${name}"` });
}, 500);
};
return (
<>
<div className="overlay" onClick={onClose} />
<div className="sheet" style={{ height: "92%" }}>
<div className="sheet-handle" />
<div
style={{
padding: "6px 12px 8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<button
onClick={onClose}
style={{
background: "transparent",
border: 0,
color: "var(--muted-foreground)",
fontSize: 15,
fontWeight: 500,
padding: "8px 12px",
}}
>
Hủy
</button>
<div style={{ fontSize: 16, fontWeight: 600 }}>Thêm đa điểm</div>
<div style={{ width: 70 }} />
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px" }}>
{/* Cover upload */}
<button
onClick={() =>
setCover(
"https://images.unsplash.com/photo-1559925393-8be0ec4767c8?w=600&q=80",
)
}
style={{
width: "100%",
aspectRatio: "16 / 9",
borderRadius: "var(--radius-lg)",
background: cover ? "transparent" : "var(--muted)",
border: cover ? "0" : "1.5px dashed var(--border-strong)",
color: "var(--muted-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
overflow: "hidden",
position: "relative",
padding: 0,
fontSize: 14,
fontWeight: 500,
}}
>
{cover ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={cover}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
<button
onClick={(e) => {
e.stopPropagation();
setCover(null);
}}
style={{
position: "absolute",
top: 8,
right: 8,
width: 32,
height: 32,
borderRadius: 9999,
border: 0,
background: "rgba(20,16,10,0.55)",
color: "#fff",
backdropFilter: "blur(12px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Icons.X size={16} stroke={2} />
</button>
</>
) : (
<>
<Icons.Camera size={20} stroke={1.75} />
Thêm nh
</>
)}
</button>
{/* Name */}
<FieldLabel required>Tên đa điểm</FieldLabel>
<div className="input">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="VD: Phở Gia Truyền Bát Đàn"
/>
</div>
{/* Address */}
<FieldLabel required>Đa chỉ</FieldLabel>
<div className="input">
<Icons.MapPin
size={18}
stroke={1.75}
style={{ color: "var(--muted-foreground)" }}
/>
<input
value={address}
onChange={(e) => {
setAddress(e.target.value);
setShowSugg(true);
}}
onFocus={() => setShowSugg(true)}
placeholder="Số nhà, đường, quận, tỉnh"
/>
<button
onClick={() => {
setAddress("Vị trí hiện tại · 39 Nguyễn Hữu Huân, Hoàn Kiếm");
setShowSugg(false);
}}
style={{
background: "var(--muted)",
border: 0,
width: 32,
height: 32,
borderRadius: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--primary)",
}}
aria-label="Lấy vị trí hiện tại"
>
<Icons.Crosshair size={16} stroke={2} />
</button>
</div>
{showSugg && address && (
<div
style={{
marginTop: 6,
background: "var(--card)",
border: "0.5px solid var(--border)",
borderRadius: "var(--radius-md)",
overflow: "hidden",
boxShadow: "var(--shadow-md)",
}}
>
{suggestions.slice(0, 4).map((s, i) => (
<button
key={i}
onClick={() => {
setAddress(s.addr);
setShowSugg(false);
}}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
background: "transparent",
border: 0,
textAlign: "left",
borderBottom: i < 3 ? "0.5px solid var(--border)" : 0,
}}
>
<Icons.MapPin
size={16}
stroke={1.75}
style={{
color: "var(--muted-foreground)",
flexShrink: 0,
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 14,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{s.addr}
</div>
<div
style={{
fontSize: 12,
color: "var(--muted-foreground)",
}}
>
{s.sub}
</div>
</div>
</button>
))}
</div>
)}
{/* Category */}
<FieldLabel required>Danh mục</FieldLabel>
<div className="toggle-group">
{(Object.entries(CATEGORIES) as [CategoryId, typeof CATEGORIES[CategoryId]][]).map(
([k, c]) => {
const I = Icons[c.icon as keyof typeof Icons];
return (
<button
key={k}
data-active={category === k}
onClick={() => setCategory(k)}
>
<I size={20} stroke={1.75} />
<span>{c.label}</span>
</button>
);
},
)}
</div>
{/* Tags */}
<FieldLabel>
Thẻ{" "}
<span
style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}
>
· {tags.length}/10
</span>
</FieldLabel>
<div
className="input"
style={{
flexWrap: "wrap",
height: "auto",
minHeight: 48,
padding: "6px 10px",
alignItems: "center",
gap: 6,
}}
>
{tags.map((t) => (
<span
key={t}
className="badge"
style={{
height: 26,
padding: "0 8px 0 10px",
background: "var(--primary-soft)",
color: "var(--primary)",
}}
>
{t}
<button
onClick={() => removeTag(t)}
style={{
background: "transparent",
border: 0,
color: "inherit",
padding: 2,
marginLeft: 2,
}}
>
<Icons.X size={12} stroke={2.5} />
</button>
</span>
))}
<input
value={tagInput}
onChange={(e) => {
const v = e.target.value;
if (v.endsWith(",")) addTag(v);
else setTagInput(v);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag(tagInput);
} else if (e.key === "Backspace" && !tagInput && tags.length) {
setTags(tags.slice(0, -1));
}
}}
placeholder={tags.length ? "" : "Enter để thêm thẻ"}
style={{ flex: 1, minWidth: 80, height: 32 }}
/>
</div>
{/* Notes */}
<FieldLabel>
<Icons.Lock size={12} stroke={2.5} style={{ marginRight: 4 }} />
Ghi chú riêng
</FieldLabel>
<div className="input input--multi">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Chỉ mình bạn thấy..."
style={{ resize: "none", minHeight: 72 }}
/>
</div>
{/* Rating */}
<FieldLabel>
Đánh giá{" "}
<span
style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}
>
· tuỳ chọn
</span>
</FieldLabel>
<div style={{ padding: "8px 0" }}>
<RatingStars
value={rating}
readOnly={false}
size={28}
onChange={(v) => setRating(v)}
/>
</div>
<div style={{ height: 8 }} />
</div>
{/* Footer */}
<div
style={{
padding: "12px 16px 4px",
borderTop: "0.5px solid var(--border)",
background: "color-mix(in oklch, var(--card) 90%, transparent)",
}}
>
<button
className="btn btn--block btn--lg"
disabled={!isValid || submitting}
onClick={submit}
>
{submitting ? "Đang lưu..." : "Lưu địa điểm"}
</button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,336 @@
"use client";
import { useState } from "react";
import { COLLECTIONS } from "@/lib/mock-data";
import type { Dispatch } from "@/lib/app-state";
import { FieldLabel, IconBtn } from "@/components/ui-primitives";
import { Icons } from "@/components/icons";
type RoleId = "viewer" | "editor";
function RolePicker({
value,
onChange,
}: {
value: RoleId;
onChange: (v: RoleId) => void;
}) {
const roles: { id: RoleId; label: string; desc: string }[] = [
{ id: "viewer", label: "Chỉ xem", desc: "Chỉ xem được" },
{ id: "editor", label: "Sửa được", desc: "Thêm và sửa" },
];
return (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
}}
>
{roles.map((r) => (
<button
key={r.id}
onClick={() => onChange(r.id)}
style={{
padding: 12,
borderRadius: "var(--radius-md)",
border:
value === r.id
? "1.5px solid var(--primary)"
: "1px solid var(--border)",
background: value === r.id ? "var(--primary-soft)" : "var(--card)",
color: value === r.id ? "var(--primary)" : "var(--foreground)",
textAlign: "left",
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<div style={{ fontSize: 14, fontWeight: 600 }}>{r.label}</div>
<div style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
{r.desc}
</div>
</button>
))}
</div>
);
}
export function InviteDialog({
collectionId,
onClose,
dispatch,
}: {
collectionId: string;
onClose: () => void;
dispatch: Dispatch;
}) {
const c = COLLECTIONS.find((x) => x.id === collectionId);
const [tab, setTab] = useState<"link" | "email">("link");
const [role, setRole] = useState<RoleId>("editor");
const [email, setEmail] = useState("");
const [copied, setCopied] = useState(false);
const [pending, setPending] = useState<{ email: string; role: RoleId }[]>([
{ email: "bao.tran@gmail.com", role: "editor" },
]);
const link = `places.app/invite/${c?.id || "demo"}-7k2x9`;
const copy = () => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const sendInvite = () => {
if (!email.trim() || !email.includes("@")) return;
setPending([...pending, { email: email.trim(), role }]);
setEmail("");
dispatch({ type: "TOAST", value: "Đã gửi lời mời" });
};
return (
<>
<div className="overlay" onClick={onClose} />
<div className="dialog">
<div
style={{
padding: "20px 20px 0",
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div>
<div
style={{
fontSize: 18,
fontWeight: 700,
letterSpacing: "-0.01em",
}}
>
Mời vào bộ sưu tập
</div>
<div
style={{
fontSize: 13,
color: "var(--muted-foreground)",
marginTop: 2,
}}
>
{c?.name}
</div>
</div>
<IconBtn icon="X" label="Đóng" onClick={onClose} />
</div>
<div style={{ padding: "14px 20px 0" }}>
<div className="tabs">
<button
data-active={tab === "link"}
onClick={() => setTab("link")}
>
<Icons.Link
size={14}
stroke={2}
style={{ verticalAlign: "middle", marginRight: 6 }}
/>
Bằng link
</button>
<button
data-active={tab === "email"}
onClick={() => setTab("email")}
>
<Icons.Mail
size={14}
stroke={2}
style={{ verticalAlign: "middle", marginRight: 6 }}
/>
Bằng email
</button>
</div>
</div>
<div style={{ padding: "16px 20px 20px", overflowY: "auto" }}>
{tab === "link" ? (
<>
<FieldLabel>Liên kết mời</FieldLabel>
<div style={{ display: "flex", gap: 8 }}>
<div className="input" style={{ flex: 1, fontSize: 14 }}>
<Icons.Link
size={16}
stroke={1.75}
style={{ color: "var(--muted-foreground)" }}
/>
<input readOnly value={link} style={{ fontSize: 13 }} />
</div>
<button
onClick={copy}
className={copied ? "btn" : "btn btn--ghost"}
style={{
width: 88,
height: 48,
borderRadius: "var(--radius-md)",
}}
>
{copied ? (
<>
<Icons.Check size={16} stroke={2.5} />
Đã copy
</>
) : (
<>
<Icons.Copy size={16} stroke={1.75} />
Copy
</>
)}
</button>
</div>
<FieldLabel>Vai trò</FieldLabel>
<RolePicker value={role} onChange={setRole} />
<div
style={{
marginTop: 14,
padding: 12,
background: "var(--warning-soft)",
borderRadius: "var(--radius-md)",
display: "flex",
gap: 10,
fontSize: 12,
color: "oklch(40% 0.12 75)",
}}
>
<Icons.Eye
size={14}
stroke={2}
style={{ flexShrink: 0, marginTop: 1 }}
/>
<div>
<div style={{ fontWeight: 600, marginBottom: 2 }}>
Ai link đu thể tham gia
</div>
<div style={{ opacity: 0.8 }}>
Link hết hạn sau 7 ngày. Tạo link mới sẽ hiệu link .
</div>
</div>
</div>
<button
className="btn btn--outline btn--block"
style={{ marginTop: 14, height: 44 }}
onClick={() =>
dispatch({ type: "TOAST", value: "Đã tạo link mới" })
}
>
Tạo link mới
</button>
</>
) : (
<>
<FieldLabel>Email người đưc mời</FieldLabel>
<div className="input">
<Icons.Mail
size={18}
stroke={1.75}
style={{ color: "var(--muted-foreground)" }}
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ten@email.com"
type="email"
/>
</div>
<FieldLabel>Vai trò</FieldLabel>
<RolePicker value={role} onChange={setRole} />
<button
className="btn btn--block"
style={{ marginTop: 14, height: 44 }}
disabled={!email.includes("@")}
onClick={sendInvite}
>
<Icons.Send size={16} stroke={2} />
Gửi lời mời
</button>
{pending.length > 0 && (
<>
<FieldLabel>Lời mời đang chờ · {pending.length}</FieldLabel>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{pending.map((p, i) => (
<div
key={i}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
background: "var(--muted)",
borderRadius: "var(--radius-md)",
}}
>
<div
style={{
width: 32,
height: 32,
borderRadius: 9999,
background: "var(--card)",
color: "var(--muted-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Icons.Mail size={15} stroke={1.75} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{p.email}
</div>
<div
style={{
fontSize: 11,
color: "var(--muted-foreground)",
textTransform: "capitalize",
}}
>
{p.role === "editor" ? "Sửa được" : "Chỉ xem"}
</div>
</div>
<button
onClick={() =>
setPending(pending.filter((_, j) => j !== i))
}
style={{
background: "transparent",
border: 0,
color: "var(--muted-foreground)",
padding: 6,
}}
>
<Icons.X size={16} stroke={2} />
</button>
</div>
))}
</div>
</>
)}
</>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
import { COLLECTIONS, USERS } from "@/lib/mock-data";
import type { Dispatch } from "@/lib/app-state";
import { Avatar } from "@/components/avatar";
import { Icons } from "@/components/icons";
export function MembersSheet({
collectionId,
onClose,
dispatch,
}: {
collectionId: string;
onClose: () => void;
dispatch: Dispatch;
}) {
const c = COLLECTIONS.find((x) => x.id === collectionId);
if (!c) return null;
const owner = c.owner_id;
return (
<>
<div className="overlay" onClick={onClose} />
<div className="sheet" style={{ maxHeight: "75%" }}>
<div className="sheet-handle" />
<div
style={{
padding: "6px 16px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<button
onClick={onClose}
style={{
background: "transparent",
border: 0,
color: "var(--muted-foreground)",
fontSize: 15,
fontWeight: 500,
padding: "8px 0",
}}
>
Đóng
</button>
<div style={{ fontSize: 16, fontWeight: 600 }}>
Thành viên · {c.members.length}
</div>
<div style={{ width: 48 }} />
</div>
<div style={{ overflowY: "auto", padding: "0 16px 16px" }}>
{c.my_role !== "viewer" && (
<button
className="btn btn--ghost btn--block"
style={{
marginBottom: 12,
height: 48,
justifyContent: "flex-start",
}}
onClick={() => {
onClose();
dispatch({ type: "OPEN_INVITE", collectionId: c.id });
}}
>
<div
style={{
width: 32,
height: 32,
borderRadius: 9999,
background: "var(--primary-soft)",
color: "var(--primary)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Icons.Plus size={18} stroke={2.5} />
</div>
Mời thêm thành viên
</button>
)}
{c.members.map((id) => {
const u = USERS[id];
const role = id === owner ? "owner" : "editor";
const isMe = id === "u_me";
return (
<div
key={id}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "10px 4px",
borderBottom: "0.5px solid var(--border)",
}}
>
<Avatar user={u} size={40} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 500 }}>
{u?.name}
{isMe && (
<span
style={{
color: "var(--muted-foreground)",
fontWeight: 400,
}}
>
{" "}
· bạn
</span>
)}
</div>
<div
style={{
fontSize: 12,
color: "var(--muted-foreground)",
}}
>
{role === "owner" ? "Chủ sở hữu" : "Sửa được"}
</div>
</div>
{role !== "owner" && c.my_role === "owner" && (
<button
style={{
background: "transparent",
border: 0,
color: "var(--muted-foreground)",
padding: 8,
}}
>
<Icons.MoreHorizontal size={18} stroke={2} />
</button>
)}
</div>
);
})}
</div>
</div>
</>
);
}
export function ConfirmDialog({
title,
body,
confirmLabel = "Xóa",
danger = true,
onConfirm,
onClose,
}: {
title: string;
body: string;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => void;
onClose: () => void;
}) {
return (
<>
<div className="overlay" onClick={onClose} />
<div className="dialog" style={{ maxWidth: 320 }}>
<div style={{ padding: "24px 20px 16px", textAlign: "center" }}>
<div
style={{
fontSize: 17,
fontWeight: 700,
letterSpacing: "-0.01em",
}}
>
{title}
</div>
<div
style={{
marginTop: 6,
fontSize: 14,
color: "var(--muted-foreground)",
lineHeight: 1.5,
}}
>
{body}
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
padding: "0 16px 16px",
}}
>
<button className="btn btn--ghost" onClick={onClose}>
Hủy
</button>
<button
className={danger ? "btn btn--danger" : "btn"}
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</>
);
}

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}