init
This commit is contained in:
98
.gitignore
vendored
Normal file
98
.gitignore
vendored
Normal 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
22
.repomixignore
Normal 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
343
Claude.md
Normal 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:00–22: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 -- 1–5, 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 -- 1–5, optional (tách biệt với rating trong user_place_data)
|
||||
created_at timestamp
|
||||
updated_at timestamp
|
||||
-- UNIQUE (place_id, collection_id, user_id): mỗi user 1 review per place per collection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Business Logic — Rules quan trọng
|
||||
|
||||
### Privacy — mặc định private
|
||||
|
||||
**Place:**
|
||||
- Mọi place mặc định **private** — chỉ `created_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]` và `/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` và `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 và collection đều private by default. RLS policy phải enforce: user chỉ thấy places của mình HOẶC places trong collections mà họ là member.
|
||||
- **Public collection route** `/c/[public_token]`: query không cần auth, chỉ trả về fields được phép (name, address, lat, lng, category, tags, avg_rating) — KHÔNG trả về notes, visited, member info.
|
||||
11
next.config.ts
Normal file
11
next.config.ts
Normal 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
25
package.json
Normal 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
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
8664
release-manifest.json
Normal file
8664
release-manifest.json
Normal file
File diff suppressed because it is too large
Load Diff
657
src/app/globals.css
Normal file
657
src/app/globals.css
Normal 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
39
src/app/layout.tsx
Normal 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
5
src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PlacesApp } from "./places-app";
|
||||
|
||||
export default function Page() {
|
||||
return <PlacesApp />;
|
||||
}
|
||||
157
src/app/places-app.tsx
Normal file
157
src/app/places-app.tsx
Normal 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
66
src/components/avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/cover-image.tsx
Normal file
67
src/components/cover-image.tsx
Normal 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
294
src/components/icons.tsx
Normal 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;
|
||||
206
src/components/place-card.tsx
Normal file
206
src/components/place-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/rating-stars.tsx
Normal file
44
src/components/rating-stars.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
391
src/components/ui-primitives.tsx
Normal file
391
src/components/ui-primitives.tsx
Normal 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
149
src/lib/app-state.ts
Normal 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
19
src/lib/format.ts
Normal 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
323
src/lib/mock-data.ts
Normal 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
53
src/lib/types.ts
Normal 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;
|
||||
};
|
||||
367
src/screens/collection-detail-screen.tsx
Normal file
367
src/screens/collection-detail-screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
src/screens/collections-list-screen.tsx
Normal file
248
src/screens/collections-list-screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
469
src/screens/place-detail-screen.tsx
Normal file
469
src/screens/place-detail-screen.tsx
Normal 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 tư
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
Có 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>
|
||||
);
|
||||
}
|
||||
150
src/screens/places-list-screen.tsx
Normal file
150
src/screens/places-list-screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
195
src/screens/profile-screen.tsx
Normal file
195
src/screens/profile-screen.tsx
Normal 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 }} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
433
src/sheets/add-place-sheet.tsx
Normal file
433
src/sheets/add-place-sheet.tsx
Normal 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 tư
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
336
src/sheets/invite-dialog.tsx
Normal file
336
src/sheets/invite-dialog.tsx
Normal 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 có link đều có thể tham gia
|
||||
</div>
|
||||
<div style={{ opacity: 0.8 }}>
|
||||
Link hết hạn sau 7 ngày. Tạo link mới sẽ vô hiệu link cũ.
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
204
src/sheets/members-sheet.tsx
Normal file
204
src/sheets/members-sheet.tsx
Normal 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
23
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user