fix: lỗi font Bold của xuất pdf
This commit is contained in:
-105
@@ -1,105 +0,0 @@
|
|||||||
Markdown
|
|
||||||
# To AI Agent: Implement Automatic Tour Note Generation & Dynamic Timeline Quick Note Insertion
|
|
||||||
|
|
||||||
## 1. Context & Feature Overview
|
|
||||||
We are implementing an interconnected Note System for the Yotrip Travel Planner application. The feature spans across three core components: `TourDetailPage.tsx`, `MyNotePage.tsx`, and `ItineraryTimeline.tsx`.
|
|
||||||
|
|
||||||
### Core Requirements:
|
|
||||||
1. **Auto-Note Initialization:** When a new Tour is created in `TourDetailPage.tsx`, the system must automatically instantiate a matching master note record inside the `MyNotePage.tsx` system linked by `tourId`.
|
|
||||||
2. **Strict Document Schema Hierarchy:** The note content must format itself using structured headings reflecting the tour's legs (stages) and location points.
|
|
||||||
3. **Timeline Quick Note Insertion:** Inside `ItineraryTimeline.tsx`, each location node must feature a "Quick Note Button". Clicking it must immediately stringify location metadata (Timestamp + Reverse-Geocoded Address + User Text) and `INSERT` it dynamically under the correct Stage section inside the master note.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Note Structure & Markdown Payload Template
|
|
||||||
|
|
||||||
When initializing a tour or syncing content, the document data payload within the database/state framework must stringify exactly to this structural template:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# [Tên Tour]
|
|
||||||
|
|
||||||
## Ghi chú chung
|
|
||||||
*(Nội dung ghi chú tổng quan của chuyến đi...)*
|
|
||||||
|
|
||||||
## Ghi chú: [Tên Chặng 1]
|
|
||||||
#### 📅 [Ngày giờ] | 📍 [Tên địa điểm - Phân giải từ tọa độ]
|
|
||||||
- **Nhật ký:** [Nội dung người dùng nhập vào ở Điểm này trên Timeline]
|
|
||||||
|
|
||||||
## Ghi chú: [Tên Chặng 2]
|
|
||||||
#### ...
|
|
||||||
|
|
||||||
## 3. Detailed Logic & Component Specifications
|
|
||||||
### Step 1: TourDetailPage.tsx - Creation Hook Handler
|
|
||||||
When the user submits the "Create Tour" form successfully, inject an asynchronous action handler to create the accompanying node container:
|
|
||||||
|
|
||||||
TypeScript
|
|
||||||
// Blueprint for Tour Creation Action Hook
|
|
||||||
const handleCreateTour = async (tourData: any) => {
|
|
||||||
const newTour = await api.tours.create(tourData);
|
|
||||||
|
|
||||||
if (newTour) {
|
|
||||||
// Generate the initial markdown note layout template
|
|
||||||
const initialNoteBody = `# ${newTour.title}\n\n## Ghi chú chung\n\n`;
|
|
||||||
|
|
||||||
await api.notes.create({
|
|
||||||
tourId: newTour.id,
|
|
||||||
title: `Ghi chú: ${newTour.title}`,
|
|
||||||
content: initialNoteBody,
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
### Step 2: ItineraryTimeline.tsx - Quick Note Component Injection
|
|
||||||
Locate the location list renderer (leg.locations.map) adjacent to the white circular checkpoint nodes. Insert a small "Quick Note" button (utilizing Lucide icon FileText or similar).
|
|
||||||
|
|
||||||
JSX Target Insertion Blueprint
|
|
||||||
JavaScript
|
|
||||||
|
|
||||||
{/* Quick Note Button Interface sitting right near the action buttons of the card */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleQuickNoteInsert(leg.id, location.id, location)}
|
|
||||||
className="p-1.5 text-yotripLight-steel hover:text-yotripLight-text dark:text-yotripDark-muted dark:hover:text-yotripDark-lime rounded-lg hover:bg-gray-100 dark:hover:bg-yotripDark-surface-muted transition-colors"
|
|
||||||
title="Ghi chú nhanh điểm này"
|
|
||||||
>
|
|
||||||
<FileText className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
### Step 3: Coordinate Resolution & String Insertion Engine
|
|
||||||
Implement the core method to reverse-geocode latitudes/longitudes, construct the standard string layout, and push the patch to the existing text repository of the note.
|
|
||||||
|
|
||||||
TypeScript
|
|
||||||
|
|
||||||
const handleQuickNoteInsert = async (legId: string, locationId: string, locationData: any) => {
|
|
||||||
// 1. Resolve coordinates to a readable address string
|
|
||||||
const resolvedAddress = await reverseGeocode(locationData.lat, locationData.lng) || locationData.addressName;
|
|
||||||
|
|
||||||
// 2. Format localized Date/Time
|
|
||||||
const formattedTime = locationData.plannedStart
|
|
||||||
? new Date(locationData.plannedStart).toLocaleString('vi-VN')
|
|
||||||
: "Thời gian tùy hứng";
|
|
||||||
|
|
||||||
// 3. Extract user text inputted on the timeline card
|
|
||||||
const userInputText = locationData.timelineComment || "Không có ghi chú thêm.";
|
|
||||||
|
|
||||||
// 4. Synthesize the injection block
|
|
||||||
const noteSnippet = `#### 📅 ${formattedTime} | 📍 ${resolvedAddress}\n- **Nhật ký:** ${userInputText}\n\n`;
|
|
||||||
|
|
||||||
// 5. Trigger API/State update to find the master note by tourId,
|
|
||||||
// locate or append the '## Ghi chú: [Tên Chặng]' marker, and inject the snippet directly below it.
|
|
||||||
await api.notes.insertSectionByTourId(tourId, legId, noteSnippet);
|
|
||||||
};
|
|
||||||
|
|
||||||
## 4. UI Style Integration Constraints
|
|
||||||
Apply the application design system colors directly to the new buttons.
|
|
||||||
|
|
||||||
In Light mode, buttons must remain a clean #28536b (Yale Blue) or soft #7ea8be (Steel Blue).
|
|
||||||
|
|
||||||
In Dark mode, hover interactions must transition to #bce784 (Lime Cream) with a smooth transition-colors duration-200 modifier.
|
|
||||||
|
|
||||||
## 5. Acceptance Criteria for Verification
|
|
||||||
[ ] Instantiation Check: Create a test Tour titled "Hoa vàng cỏ xanh 2026". Navigate to MyNotePage and confirm a note document with the exact title has been instantly spawned.
|
|
||||||
|
|
||||||
[ ] Geocoding & String Format Check: Click the Quick Note button on a location card with coordinates (e.g., 15.56, 108.49). The string appended to the note must fully resolve the address (e.g., "Tam Kỳ, Quảng Nam") instead of dumping raw coordinate numbers.
|
|
||||||
|
|
||||||
[ ] Structural Targeting: Ensure that inserting a quick note from Chặng 2 appends the text directly beneath the ## Ghi chú: Chặng 2 markdown anchor block, without scrambling or overwriting the text blocks of Chặng 1.
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# To AI Agent: Fix Overlapping Text and Layout Overflow in MyNotePage Note Cards
|
|
||||||
|
|
||||||
## 1. Context & Layout Bug Analysis
|
|
||||||
We are fixing a severe rendering and layout bug inside the Note Card component of `MyNotePage.tsx` as captured in `image.png`:
|
|
||||||
|
|
||||||
- **Bug 1 (Chữ đè chồng lên nhau):** The text lines inside the note body (headings, dates, journal contents) are collapsing vertically and rendering directly on top of each other. This is caused by rogue `absolute` positioning or a broken flex/grid system on inner content elements.
|
|
||||||
- **Bug 2 (Tràn ra khỏi Board chứa):** Long text strings (such as resolved address lines with "Đà Nẵng, Vietnam" or "Tuy Hòa, Phú Yên") are breaking out horizontally and vertically beyond the rounded border bounds of the card.
|
|
||||||
- **Goal:** Clean up the typography layout engine so that elements stack naturally in a vertical flow (`block` or `flex-col`), wrap text cleanly when boundaries are met, and respect the container's height/scroll rules.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Refactoring Instructions
|
|
||||||
|
|
||||||
### Step 1: Clean Up the Note Content Wrapper Class
|
|
||||||
Locate the container rendering the inner markdown text inside the note board card. Force it to follow a regular vertical block layout flow and ensure text wrapping is active.
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
{/* ❌ OLD COLLAPSED CONTAINER (Dự đoán đang bị sai class) */}
|
|
||||||
<div className="absolute ..."> or <div className="h-full ...">
|
|
||||||
|
|
||||||
{/* ✅ NEW FIXED CONTAINER STRUCTURE */}
|
|
||||||
<div className="flex flex-col gap-3 w-full text-left overflow-y-auto max-h-[250px] pr-2 scrollbar-thin">
|
|
||||||
{/* Ensure markdown rendering outputs elements as block-level */}
|
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words whitespace-pre-wrap text-yotripDark-text-primary">
|
|
||||||
{/* Inside here, headings (##, ####) and lists (-) must stack naturally */}
|
|
||||||
{note.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Step 2: Fix Inner Line Items Styles (If parsing strings manually)
|
|
||||||
If you are split-parsing the lines manually (e.g., parsing 📅 and 📍 into individual rows), ensure NO element inside uses absolute. Every line item must be relative or static.
|
|
||||||
|
|
||||||
/* Inject these explicit fixes into index.css under @layer components if needed */
|
|
||||||
.note-content-line {
|
|
||||||
position: relative !important; /* Force breakout from any absolute parent trap */
|
|
||||||
display: block !important; /* Clear any inline overlap */
|
|
||||||
width: 100% !important;
|
|
||||||
word-break: break-word !important; /* Force text to wrap instead of bleeding out */
|
|
||||||
white-space: normal !important; /* Overwrite any nowrap rule */
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
## 3. Full Component JSX Layout Optimization Blueprint
|
|
||||||
Update the single Note Card render template inside MyNotePage.tsx to match this stable layout standard:
|
|
||||||
|
|
||||||
export const NoteCard = ({ note }) => {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full bg-yotripDark-surface border border-yotripDark-border rounded-2xl p-5 shadow-md hover:shadow-lg transition-all flex flex-col gap-4">
|
|
||||||
|
|
||||||
{/* 1. Header Zone (Title & Date) */}
|
|
||||||
<div className="flex flex-col gap-1 border-b border-yotripDark-border pb-3">
|
|
||||||
<h3 className="text-lg font-bold text-white break-words pr-8">
|
|
||||||
{note.title}
|
|
||||||
</h3>
|
|
||||||
<span className="text-xs text-yotripDark-muted flex items-center gap-1.5">
|
|
||||||
<Calendar className="w-3.5 h-3.5" />
|
|
||||||
{note.date}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 2. Content Zone - FIXES THE BUG IN IMAGE */}
|
|
||||||
<div className="w-full flex flex-col gap-3 overflow-y-auto max-h-[280px] text-sm text-slate-200 pr-1">
|
|
||||||
{/* Dynamic content rendering with forced layout safety rails */}
|
|
||||||
<div className="space-y-3 break-words whitespace-pre-wrap text-left select-text">
|
|
||||||
{/* Ensure raw strings or processed lines flow down smoothly */}
|
|
||||||
{note.content.split('\n').map((line, index) => {
|
|
||||||
if (line.trim() === '') return null;
|
|
||||||
return (
|
|
||||||
<p key={index} className="leading-relaxed text-slate-200 block w-full m-0 p-0">
|
|
||||||
{line}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
## 4. Verification Checklist for AI Agent
|
|
||||||
[ ] No Overlap: Verify that the "## Ghi chú chung...", "Ghi chú: Xuất phát", and "📅 Thời gian tùy hứng" text elements sit on unique vertical lines. They must never occupy the same space.
|
|
||||||
|
|
||||||
[ ] Horizontal Bound Test: Long addresses (e.g., Apec mandala Victor condotel, Hùng Vương, Phường Tuy Hòa...) must trigger a soft wrap onto line 2 and line 3 instead of punching through the card's right border.
|
|
||||||
|
|
||||||
[ ] Scroll Containment: If the text length exceeds the card's designated size, it must elegantly clip inside and provide a clean vertical scrollbar, rather than dripping out underneath the board container.
|
|
||||||
-129
@@ -1,129 +0,0 @@
|
|||||||
# NOTE_EDIT.md
|
|
||||||
|
|
||||||
## MyNotePage.tsx
|
|
||||||
**Đường dẫn:** `frontend/src/pages/MyNotePage.tsx`
|
|
||||||
|
|
||||||
### Mục đích
|
|
||||||
Trang quản lý ghi chú cá nhân (My Notes) của người dùng trong một tour. Hỗ trợ tạo, sửa, xóa, tìm kiếm ghi chú và đồng bộ dữ liệu giữa client và server.
|
|
||||||
|
|
||||||
### Cấu trúc & Luồng hoạt động chính
|
|
||||||
- **State quản lý:**
|
|
||||||
- `notes`: danh sách ghi chú hiện tại
|
|
||||||
- `isCreating`: điều khiển hiển thị form tạo/sửa ghi chú
|
|
||||||
- `editingNoteId`: xác định đang sửa ghi chú nào
|
|
||||||
- `noteForm`: lưu trữ form data `{ title, content }`
|
|
||||||
- `searchQuery`: từ khóa tìm kiếm
|
|
||||||
- **Render có 3 nhánh:**
|
|
||||||
1. Không tạo ghi chú + không có ghi chú: hiển thị màn hình trống + nút "Tạo ghi chú mới"
|
|
||||||
2. `isCreating = true`: hiển thị form nhập tiêu đề + ReactQuill + nút Lưu/Hủy
|
|
||||||
3. `filteredNotes.length > 0`: hiển thị danh sách ghi chú dạng grid card
|
|
||||||
- **Lưu ghi chú (handleSaveNote):**
|
|
||||||
- Nếu đang `edit`: cập nhật note trong `notes` gọi API PUT
|
|
||||||
- Nếu `create` mới: tạo `temp_` id, thêm vào đầu mảng `notes`, gọi API POST để lấy id thật
|
|
||||||
- Sau khi API thành công: cập nhật `id` thật và đánh dấu `synced: true`
|
|
||||||
- **Xóa ghi chú:** hỏi confirm → đánh dấu `deletedLocally: true` → gọi API DELETE → xóa khỏi UI
|
|
||||||
- **Đồng bộ offline (syncLocalNotesToServer):**
|
|
||||||
- Xử lý tuần tự 3 nhóm: `deletedLocally` → `temp_` (chưa đồng bộ) → `edited` (synced: false)
|
|
||||||
- Mỗi nhóm có retry riêng, lỗi chỉ log console
|
|
||||||
- **Fetch & Cache (fetchServerNotes + useEffect):**
|
|
||||||
- Khi load trang: đọc `localStorage` key `my_journey_notes_{tourId}` làm cache ban đầu
|
|
||||||
- Gọi API GET `/api/v1/tours/${tourId}/notes` để lấy dữ liệu server
|
|
||||||
- Merge: giữ `unsyncedNotes` (temp_, synced:false, deletedLocally) + lọc khỏi server
|
|
||||||
- Sort theo `createdAt` giảm dần (mới nhất lên đầu)
|
|
||||||
- Lưu lại `localStorage` sau mỗi thay đổi
|
|
||||||
- **Tìm kiếm (filteredNotes):**
|
|
||||||
- Lọc theo title HOẶC content (strip HTML tag)
|
|
||||||
- Không lọc note đã `deletedLocally`
|
|
||||||
|
|
||||||
### ReactQuill configuration
|
|
||||||
- Module: table, header(1,2), bold/italic/underline/strike, align, indent, bullet/check list, link/image/table/clean
|
|
||||||
- Custom Icons: gán SVG từ `lucide-react` vào `Quill.import('ui/icons')`
|
|
||||||
- Table handler: dùng `prompt` nhập số hàng/cột → gọi `quill.getModule('table').insertTable()`
|
|
||||||
|
|
||||||
### Theme/Class cần lưu ý
|
|
||||||
- Wrapper ReactQuill: `bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm min-h-[500px] flex flex-col`
|
|
||||||
- CSS class ở ReactQuill đã fix: `.ql-container` lấy `h-full, min-h-0`; `.ql-editor` lấy `flex-1, overflow-y-auto` để nội dung dài cuộn trong khung
|
|
||||||
- Note card: `bg-[var(--surface)] dark:bg-[var(--surface-muted)] p-5 rounded-3xl`
|
|
||||||
- Màu chính: `text-amber-500` cho buttons, header
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TourDetailPage.tsx
|
|
||||||
**Đường dẫn:** `frontend/src/pages/TourDetailPage.tsx`
|
|
||||||
|
|
||||||
### Mục đích
|
|
||||||
Trang chi tiết tour, tổng hợp toàn bộ thông tin: bản đồ interactive, lộ trình, gallery ảnh, manage members, quản lý chi phí, chat, và chức năng share. Trang này rất nặng (~3400 dòng) nên cần chú ý các captured props và callback pattern.
|
|
||||||
|
|
||||||
### Cấu trúc chính
|
|
||||||
- **Header:** sticky top bar có các tab: Timeline | Spot / Location | Expenses | Chat
|
|
||||||
- **Tabs switch:** điều khiển hiển thị `ItineraryTimeline`, `ExpenseManager`, `TourChat`
|
|
||||||
- **Map section:** leaflet map kết hợp MarkerCluster, Polyline vẽ lộ trình OSRM, Recenter button
|
|
||||||
|
|
||||||
### Key logic/Data flow
|
|
||||||
- `useTourStore` là state chính: lưu `currentTour`, `legs`, `locations`, `userRole`, `mapCenter`
|
|
||||||
- **OSRM Routes:** kết quả từ backend đa segment, `combineSegmentRoutes()` gộp thành 1 polyline
|
|
||||||
- **Sync data:** `addLocation`, `editLocation`, `deleteLocation` gọi API và cập nhật store
|
|
||||||
- **PDF Export:** dùng `jsPDF` + `jspdf-autotable` xuất hóa đơn/nhật ký tour
|
|
||||||
- **ShareJourney button:** tạo public link hoặc mở share modal
|
|
||||||
- **Socket.IO:** `io()` kết nối realtime cho chat và comment
|
|
||||||
- **Offline-first:** nhiều actions gọi API nhưng không block UI; có try/catch và fallback
|
|
||||||
|
|
||||||
### Các modal/components con được gọi
|
|
||||||
- `AddLocationModal`, `MembersTab`, `ExpenseManager`, `CommentModal`, `AddPhotoModal`, `TourChat`
|
|
||||||
- `MapContextMenu`: menu right-click trên map để bắt đầu/kết thúc/add to leg
|
|
||||||
- `MapRotationHandler`: xoay map theo hướng di chuyển (cumulative rotation để tránh nhảy -360→0)
|
|
||||||
- `MapHoverTip`: tooltip xuất hiện sau 3s khi hover trên map (cho user biết tip right-click)
|
|
||||||
|
|
||||||
### Theme & Styling cần lưu ý
|
|
||||||
- Map marker icon: dùng `unpkg.com/leaflet@1.9.4` (fixed URL) do Vite bundler không nhận asset mặc định
|
|
||||||
- Màu primary tour: `text-blue-600`, accent: `text-amber-500`
|
|
||||||
- Sticky header: `bg-[var(--surface)]/80 backdrop-blur-md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ItineraryTimeline.tsx
|
|
||||||
**Đường dẫn:** `frontend/src/components/ItineraryTimeline.tsx`
|
|
||||||
|
|
||||||
### Mục đích
|
|
||||||
Component hiển thị lộ trình đa chặng (multi-leg itinerary) dạng timeline accordion. Mỗi `leg` là 1 section có thể expand/collapse độc lập (exclusive mode).
|
|
||||||
|
|
||||||
### Cấu trúc chính
|
|
||||||
- **props:**
|
|
||||||
- `onAddLocation(legId, isStart?, isEnd?)`: callback mở modal thêm địa điểm
|
|
||||||
- `onEditLocation(location)`: callback mở modal sửa địa điểm
|
|
||||||
- `onQuickNote(name)`: callback mở modal ghi chú nhanh
|
|
||||||
- `onNavigate(location)`: callback điều hướng trên map
|
|
||||||
- `onSuccess()`: callback sau khi xóa/edit thành công
|
|
||||||
- `isPublicView`: boolean, hide edit/delete buttons khi true
|
|
||||||
- **State:**
|
|
||||||
- `expandedStageId`: id của leg đang mở (chỉ 1 mở tại 1 thời điểm) → mặc định leg đầu tiên
|
|
||||||
- `isEditModalOpen`, `editingLegData`: modal sửa chặng
|
|
||||||
- `isCommentModalOpen`, `commentLocationId`, `commentLocationName`: modal comment
|
|
||||||
|
|
||||||
### Logic đặc biệt
|
|
||||||
- **Exclusive expansion:** `toggleStageExpanded` đảm bảo chỉ 1 leg mở tại 1 thời điểm (click lại để đóng)
|
|
||||||
- **Distance & Travel time:**
|
|
||||||
- Tính haversine giữa 2 điểm liên tiếp (`allLocations` flat)
|
|
||||||
- Convert giữa các chặng: lấy `prevLegLastLoc` → tính khoảng cách → hiển thị "Tiếp nối từ ..."
|
|
||||||
- `leg.totalDistance` + `formatTravelTime()` để đổi sang "X giờ Y phút"
|
|
||||||
- **Dwell time:** thời gian dừng tại mỗi điểm = `plannedEnd - plannedStart`
|
|
||||||
- **Marker milestones:**
|
|
||||||
- Start point: `plannedStart` timestamp = 0
|
|
||||||
- End point: `plannedEnd` timestamp = 0
|
|
||||||
- `TimeVariance`: so sánh actual vs planned, hiển thị "Trễ X phút" / "Đúng giờ" / "Sớm X phút"
|
|
||||||
- **Accordion CSS:** dùng `grid-template-rows: 0fr → 1fr` transition (`folder-child-content-box`) để animate mở/đóng mượt
|
|
||||||
- **Comment count:** `handleCommentIncrement/Decrement` trực tiếp mutate `useTourStore` để cập nhật UI gần như tức thì mà không cần re-fetch API
|
|
||||||
|
|
||||||
### Giao tiếp với cha (TourDetailPage)
|
|
||||||
- `onAddLocation?.(leg.id, isFirst, isLast)` ↔ mở `AddLocationModal`
|
|
||||||
- `onEditLocation(loc)` ↔ pre-fill modal edit
|
|
||||||
- `onNavigate(loc)` ↔ mở notification/guide "Đang điều hướng" rồi gọi map navigate
|
|
||||||
- `onSuccess?.()` gọi sau delete/edit thành công để refresh từ cha
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Lưu ý chung
|
|
||||||
- Cả 3 file đều dùng Tailwind CSS + theme variables từ `index.css`
|
|
||||||
- Offline/sync-first: ghi chú lưu localStorage trước, API sau
|
|
||||||
- Khi gọi API từ các hook, header gắn `Authorization: Bearer ${localStorage.getItem('token')}`
|
|
||||||
- `react-quill-new` dùng thay cho `react-quill` do tương thích React 18+
|
|
||||||
-116
@@ -1,116 +0,0 @@
|
|||||||
# To AI Agent: Implement Unified Dual-Theme System (Light & Dark) for Yotrip App
|
|
||||||
|
|
||||||
## 1. Context & Design System Overview
|
|
||||||
We are building a robust, high-contrast, yet eye-friendly dual-theme system (Light and Dark modes) for our mobile application (`yotrip.labz.io.vn`).
|
|
||||||
- **Dark Theme Constraint:** Must use the pre-configured smoky grape base (`Vintage Grape` & `Dusty Grape`) with vivid lime/aquatic accents. No pitch-black.
|
|
||||||
- **Light Theme Constraint:** Must use the soft parchment base (`Parchment`) to eliminate screen glare, using deep academic blue (`Yale Blue`) for high-contrast readability. No pure blazing white backgrounds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Comprehensive Color Token Mapping
|
|
||||||
|
|
||||||
### A. LIGHT THEME PALETTE ("Classic Heritage & Soft Sand")
|
|
||||||
- **`Parchment` (`#f6f0ed`):** Map to **Global Page Backgrounds**. A warm, ancient-manuscript off-white that eliminates mobile screen glare.
|
|
||||||
- **`Yale Blue` (`#28536b`):** Map to **Primary Text, Main Titles, and Primary Action Buttons**. Provides dependable trust and sharp contrast.
|
|
||||||
- **`Steel Blue` (`#7ea8be`):** Map to **Active Navigation Tabs, Active Icons, and Secondary Actions**.
|
|
||||||
- **`Khaki Beige` (`#bbb193`):** Map to **Borders, Dividers, and Deactivated/Muted States**.
|
|
||||||
- **`Rosy Taupe` (`#c2948a`):** Map to **Special Highlight Badges, Notification Banners, or Warm Accent Elements**.
|
|
||||||
|
|
||||||
### B. DARK THEME PALETTE ("Vintage Velvet & Aquatic Zest")
|
|
||||||
- **`Vintage Grape` (`#513b56`):** Map to **Global Page Backgrounds**.
|
|
||||||
- **`Dusty Grape` (`#525174`):** Map to **Component Surfaces (Cards, Chat Bubbles, Accordion Rows)**.
|
|
||||||
- **`Lime Cream` (`#bce784`):** Map to **Brand Text Highlights, Active Icons**.
|
|
||||||
- **`Bondi Blue` (`#348aa7`):** Map to **Primary Action Buttons, Links**.
|
|
||||||
- **`Emerald` (`#5dd39e`):** Map to **Success Utilities & "Tối ưu" badges**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Technical Global Configuration
|
|
||||||
|
|
||||||
### Option A: Clean CSS Variables (`global.css`)
|
|
||||||
Replace or update the root variable tokens inside your main stylesheet:
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* --- THEME SÁNG (Mềm mại, Tương phản cao, Không chói) --- */
|
|
||||||
:root {
|
|
||||||
--background: #f6f0ed; /* Parchment */
|
|
||||||
--surface: #ffffff; /* Pure White for crisp card layers */
|
|
||||||
--surface-muted: #bbb193; /* Khaki Beige */
|
|
||||||
|
|
||||||
--border: #bbb193; /* Khaki Beige */
|
|
||||||
|
|
||||||
--text-primary: #28536b; /* Yale Blue (High contrast text) */
|
|
||||||
--text-secondary: #7ea8be; /* Steel Blue */
|
|
||||||
--text-accent: #c2948a; /* Rosy Taupe */
|
|
||||||
|
|
||||||
--primary: #28536b; /* Yale Blue for main buttons */
|
|
||||||
--primary-hover: #1f4154;
|
|
||||||
--secondary-active: #7ea8be; /* Steel Blue */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- THEME TỐI (Dịu mắt, Sang trọng, Không đen kịt) --- */
|
|
||||||
:root.dark {
|
|
||||||
--background: #513b56; /* Vintage Grape */
|
|
||||||
--surface: #525174; /* Dusty Grape */
|
|
||||||
--surface-muted: #626186;
|
|
||||||
|
|
||||||
--border: #626186;
|
|
||||||
|
|
||||||
--text-primary: #f8fafc; /* Soft White */
|
|
||||||
--text-secondary: #94a3b8; /* Muted Slate */
|
|
||||||
--text-brand: #bce784; /* Lime Cream */
|
|
||||||
|
|
||||||
--primary: #348aa7; /* Bondi Blue */
|
|
||||||
--primary-hover: #296f86;
|
|
||||||
--success-accent: #5dd39e; /* Emerald */
|
|
||||||
}
|
|
||||||
|
|
||||||
### Option B: Tailwind Extension Configuration (tailwind.config.js)
|
|
||||||
Expose these custom design tokens cleanly into the utility library framework:
|
|
||||||
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
yotripLight: {
|
|
||||||
bg: '#f6f0ed', // Parchment
|
|
||||||
text: '#28536b', // Yale Blue
|
|
||||||
steel: '#7ea8be', // Steel Blue
|
|
||||||
khaki: '#bbb193', // Khaki Beige
|
|
||||||
rosy: '#c2948a', // Rosy Taupe
|
|
||||||
},
|
|
||||||
yotripDark: {
|
|
||||||
bg: '#513b56', // Vintage Grape
|
|
||||||
surface: '#525174', // Dusty Grape
|
|
||||||
lime: '#bce784', // Lime Cream
|
|
||||||
bondi: '#348aa7', // Bondi Blue
|
|
||||||
emerald: '#5dd39e', // Emerald
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
## 4. UI Component Application Reference
|
|
||||||
Apply these unified color classes to ensure the interface flips seamlessly:
|
|
||||||
|
|
||||||
Chat Wrapper & Itinerary Timelines: - Light Mode: Background is yotripLight-bg, General Text is yotripLight-text.
|
|
||||||
|
|
||||||
Dark Mode: Background is yotripDark-bg, General Text is #f8fafc.
|
|
||||||
|
|
||||||
Navigation Tabs Menu:
|
|
||||||
|
|
||||||
Light Mode: Inactive text is yotripLight-steel. Active tab gets a yotripLight-text highlight indicator.
|
|
||||||
|
|
||||||
Dark Mode: Inactive text is yotripDark-surface. Active tab gets a yotripDark-lime highlight indicator.
|
|
||||||
|
|
||||||
Main Action Call-To-Actions (e.g., "Thêm địa điểm"):
|
|
||||||
|
|
||||||
Light Mode: Background is yotripLight-text (Yale Blue) with clean white text.
|
|
||||||
|
|
||||||
Dark Mode: Background is yotripDark-bondi (Bondi Blue) with clean white text.
|
|
||||||
|
|
||||||
## 5. Acceptance Criteria for Verification
|
|
||||||
[ ] Ensure that toggling the .dark class on the <html> root triggers a global transition without style flashes (transition-colors duration-200).
|
|
||||||
|
|
||||||
[ ] Text legibility inside Light Mode satisfies standard WCAG accessibility contrast limits on mobile displays out in the sun.
|
|
||||||
|
|
||||||
[ ] The background of the expanded folder nodes/chat bubbles maps cleanly to their respective surface tokens across both system states.
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import { jsPDF } from 'jspdf';
|
import { jsPDF } from 'jspdf';
|
||||||
import autoTable from 'jspdf-autotable';
|
import autoTable from 'jspdf-autotable';
|
||||||
import { robotoBase64 } from '../utils/pdfFont';
|
import { robotoBase64, robotoBoldBase64 } from '../utils/pdfFont';
|
||||||
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
||||||
import { ExpenseManager } from '../components/ExpenseManager';
|
import { ExpenseManager } from '../components/ExpenseManager';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
@@ -486,12 +486,12 @@ export const TourDetailPage = ({
|
|||||||
const handleExportPDF = async () => {
|
const handleExportPDF = async () => {
|
||||||
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
||||||
|
|
||||||
// 1. Thêm font vào Virtual File System của jsPDF để hỗ trợ tiếng Việt
|
|
||||||
doc.addFileToVFS("Roboto-Regular.ttf", robotoBase64);
|
doc.addFileToVFS("Roboto-Regular.ttf", robotoBase64);
|
||||||
doc.addFont("Roboto-Regular.ttf", "Roboto", "normal");
|
doc.addFont("Roboto-Regular.ttf", "Roboto", "normal");
|
||||||
|
doc.addFileToVFS("Roboto-Bold.ttf", robotoBoldBase64);
|
||||||
|
doc.addFont("Roboto-Bold.ttf", "Roboto", "bold");
|
||||||
doc.setFont("Roboto");
|
doc.setFont("Roboto");
|
||||||
|
|
||||||
// 2. Vẽ Tiêu đề & Thông tin Tour ở đầu trang
|
|
||||||
const titleText = `LỊCH TRÌNH TOUR: ${currentTour?.title?.toUpperCase() || 'HÀNH TRÌNH TOUR'}`;
|
const titleText = `LỊCH TRÌNH TOUR: ${currentTour?.title?.toUpperCase() || 'HÀNH TRÌNH TOUR'}`;
|
||||||
doc.setFontSize(16);
|
doc.setFontSize(16);
|
||||||
doc.text(titleText, 148.5, 15, { align: 'center' });
|
doc.text(titleText, 148.5, 15, { align: 'center' });
|
||||||
@@ -508,9 +508,9 @@ export const TourDetailPage = ({
|
|||||||
doc.text(dateRangeStr, 148.5, startY, { align: 'center' });
|
doc.text(dateRangeStr, 148.5, startY, { align: 'center' });
|
||||||
startY += 8;
|
startY += 8;
|
||||||
|
|
||||||
// 3. Chuẩn bị dữ liệu bảng
|
|
||||||
const tableColumn = ["STT", "Ngày giờ", "Chặng", "Địa điểm", "Ghi chú"];
|
const tableColumn = ["STT", "Ngày giờ", "Chặng", "Địa điểm", "Ghi chú"];
|
||||||
const tableRows: any[] = [];
|
const tableRows: any[] = [];
|
||||||
|
const legRowRanges: Record<string, { start: number; count: number }> = {};
|
||||||
|
|
||||||
const formatDateTime = (dateStr: string) => {
|
const formatDateTime = (dateStr: string) => {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
@@ -525,30 +525,43 @@ export const TourDetailPage = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
let stt = 1;
|
let stt = 1;
|
||||||
|
let globalRowIndex = 0;
|
||||||
|
|
||||||
if (currentTour?.legs && currentTour.legs.length > 0) {
|
if (currentTour?.legs && currentTour.legs.length > 0) {
|
||||||
currentTour.legs.forEach((leg: any, legIdx: number) => {
|
currentTour.legs.forEach((leg: any, legIdx: number) => {
|
||||||
const legName = leg.note || `Chặng ${legIdx + 1}`;
|
const legName = leg.note || `Chặng ${legIdx + 1}`;
|
||||||
if (leg.locations && leg.locations.length > 0) {
|
const legLocations = (leg.locations || []).filter((loc: any) =>
|
||||||
leg.locations.forEach((loc: any) => {
|
loc && (loc.plannedStart || loc.plannedEnd || loc.name)
|
||||||
const currentStt = stt++;
|
);
|
||||||
|
|
||||||
const arrivalStr = loc.arrivalTime ? `Đến: ${formatDateTime(loc.arrivalTime)}` : '';
|
if (legLocations.length > 0) {
|
||||||
const departureStr = loc.departureTime ? `Đi: ${formatDateTime(loc.departureTime)}` : '';
|
const startRow = globalRowIndex;
|
||||||
const timeText = [arrivalStr, departureStr].filter(Boolean).join('\n');
|
legRowRanges[leg.id] = { start: startRow, count: legLocations.length };
|
||||||
|
|
||||||
const locName = loc.name;
|
legLocations.forEach((loc: any) => {
|
||||||
const addressStr = loc.address ? `\n📍 Địa chỉ: ${loc.address}` : '';
|
const timeStr = loc.plannedStart
|
||||||
const locationText = `${locName}${addressStr}`;
|
? formatDateTime(loc.plannedStart)
|
||||||
|
: loc.plannedEnd
|
||||||
const noteText = loc.notes || '';
|
? formatDateTime(loc.plannedEnd)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const locName = loc.name || '';
|
||||||
|
const addressStr = loc.address || '';
|
||||||
|
const coordStr = loc.latitude && loc.longitude
|
||||||
|
? `\n${loc.latitude}, ${loc.longitude}`
|
||||||
|
: '';
|
||||||
|
const locationText = [locName, addressStr, coordStr].filter(Boolean).join('\n');
|
||||||
|
|
||||||
|
const noteText = loc.note || '';
|
||||||
|
|
||||||
tableRows.push([
|
tableRows.push([
|
||||||
currentStt,
|
stt++,
|
||||||
timeText,
|
timeStr,
|
||||||
legName,
|
legName,
|
||||||
locationText,
|
locationText,
|
||||||
noteText
|
noteText
|
||||||
]);
|
]);
|
||||||
|
globalRowIndex++;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -558,22 +571,67 @@ export const TourDetailPage = ({
|
|||||||
tableRows.push(["-", "-", "-", "Chưa có chặng hoặc địa điểm nào trong hành trình.", "-"]);
|
tableRows.push(["-", "-", "-", "Chưa có chặng hoặc địa điểm nào trong hành trình.", "-"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Vẽ bảng dùng autoTable
|
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
head: [tableColumn],
|
head: [tableColumn],
|
||||||
|
headStyles: {
|
||||||
|
fillColor: [37, 99, 235],
|
||||||
|
textColor: [255, 255, 255],
|
||||||
|
fontStyle: 'bold',
|
||||||
|
halign: 'center',
|
||||||
|
valign: 'middle'
|
||||||
|
},
|
||||||
body: tableRows,
|
body: tableRows,
|
||||||
startY: startY,
|
startY: startY,
|
||||||
theme: 'grid',
|
theme: 'grid',
|
||||||
headStyles: { fillColor: [37, 99, 235], textColor: [255, 255, 255], fontStyle: 'normal' },
|
styles: {
|
||||||
styles: { font: "Roboto", fontSize: 9, cellPadding: 3, overflow: 'linebreak' },
|
font: "Roboto",
|
||||||
columnStyles: {
|
fontSize: 9,
|
||||||
0: { cellWidth: 12, halign: 'center' }, // STT
|
cellPadding: 3,
|
||||||
1: { cellWidth: 50 }, // Ngày giờ
|
overflow: 'linebreak',
|
||||||
2: { cellWidth: 45 }, // Chặng
|
valign: 'top'
|
||||||
3: { cellWidth: 90 }, // Địa điểm
|
|
||||||
4: { cellWidth: 70 } // Ghi chú
|
|
||||||
},
|
},
|
||||||
margin: { left: 15, right: 15 }
|
columnStyles: {
|
||||||
|
0: { cellWidth: 12, halign: 'center', valign: 'middle' },
|
||||||
|
1: { cellWidth: 50, halign: 'center', valign: 'middle' },
|
||||||
|
2: { cellWidth: 45, halign: 'center', valign: 'middle' },
|
||||||
|
3: { cellWidth: 90, valign: 'top' },
|
||||||
|
4: { cellWidth: 70, valign: 'top' }
|
||||||
|
},
|
||||||
|
margin: { left: 15, right: 15 },
|
||||||
|
didParseCell: (cell: any) => {
|
||||||
|
if (cell.section === 'body') {
|
||||||
|
if (cell.column.index === 2) {
|
||||||
|
let assigned = false;
|
||||||
|
for (const leg of currentTour?.legs || []) {
|
||||||
|
const range = legRowRanges[leg.id];
|
||||||
|
if (range && cell.row.index >= range.start && cell.row.index < range.start + range.count) {
|
||||||
|
if (cell.row.index === range.start) {
|
||||||
|
cell.rowSpan = range.count;
|
||||||
|
} else {
|
||||||
|
cell.rowSpan = 0;
|
||||||
|
}
|
||||||
|
assigned = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!assigned) {
|
||||||
|
cell.rowSpan = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.column.index === 3) {
|
||||||
|
const rowData = tableRows[cell.row.index];
|
||||||
|
if (rowData && rowData[3]) {
|
||||||
|
const coordMatch = rowData[3].match(/([\d.]+),\s*([\d.]+)/);
|
||||||
|
if (coordMatch) {
|
||||||
|
const lat = coordMatch[1];
|
||||||
|
const lng = coordMatch[2];
|
||||||
|
cell.cell.link = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user