Compare commits
36 Commits
main
...
41c8e67229
| Author | SHA1 | Date | |
|---|---|---|---|
| 41c8e67229 | |||
| 9f60efeb6d | |||
| 0152e7014d | |||
| 964a72514f | |||
| 7f426c8e46 | |||
| 414fac3e72 | |||
| e5606e64e5 | |||
| 464a4019f1 | |||
| a1bf0d2c08 | |||
| 277f647e40 | |||
| f39750af72 | |||
| ff4dc9bb48 | |||
| 175668da35 | |||
| 7ab1342690 | |||
| 2f4b24c41c | |||
| 1417f40dde | |||
| eaf79eaf8f | |||
| 3fa80b69bf | |||
| a7e569d9b4 | |||
| 3bc7c8a160 | |||
| 9fae6afaac | |||
| 614054f35d | |||
| 796487ef76 | |||
| 813b41933b | |||
| 59153cffc2 | |||
| 4ee46371fa | |||
| 400a3d098d | |||
| 5f49070a98 | |||
| accda67b22 | |||
| fee2aed2e6 | |||
| ee42bfcefa | |||
| b5b6082d26 | |||
| d8bbb22dbd | |||
| 4da00b222f | |||
| cfb97d976f | |||
| 6d90a47c24 |
@@ -0,0 +1,14 @@
|
|||||||
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/travel_db?schema=public"
|
||||||
|
PORT=3001
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=yotripadmin@gmail.com
|
||||||
|
SMTP_PASS="ahhh suif kfcp waie"
|
||||||
|
GOOGLE_CLIENT_ID=639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com
|
||||||
|
FRONTEND_URL=https://yotrip.labz.io.vn
|
||||||
|
ADMIN_SECRET_KEY=Yotrip@Bitabub1
|
||||||
|
# Development: Backend URL for vite dev server proxy (only used with npm run dev)
|
||||||
|
# For production: Nginx handles proxying, this is not used
|
||||||
|
VITE_BACKEND_URL=http://localhost:3001
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
.env
|
|
||||||
node_modules
|
node_modules
|
||||||
server/dist
|
server/dist
|
||||||
dist
|
dist
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
# To AI Agent: Implement Smart Date-Based Accordion Auto-Expansion and Smooth Viewport Auto-Scroll
|
|
||||||
|
|
||||||
## 1. Context & UI/UX Requirements
|
|
||||||
We are enhancing the routing UX between `MemberDashboard.tsx` and `ItineraryTimeline.tsx` based on `image_cc31a4.png`.
|
|
||||||
|
|
||||||
### Functional Specifications:
|
|
||||||
1. **Date-Matching Evaluation:** When a user clicks "Chi tiết hành trình" on a tour card, calculate if the current user system local date falls inside any Stage/Leg timeline block.
|
|
||||||
2. **Auto-Expansion State:** On timeline page load, the matched Leg accordion must be expanded by default (`expandedStageId === leg.id`).
|
|
||||||
3. **Smart Auto-Scroll Layout:** The viewport must smoothly auto-scroll so that the expanded Leg's title bar lands **exactly below the sticky navigation tab bar** ("Lộ trình", "Chi phí", etc.), making it fully visible at the top of the viewport without layout clipping.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Technical Architecture & Layout Constraints
|
|
||||||
|
|
||||||
- **The Sticky Header Challenge:** Since the top navigation tab bar uses sticky/fixed positioning, a naive `scrollIntoView({ block: 'start' })` will cause the Leg's title to crawl *underneath* the tabs, hiding it.
|
|
||||||
- **The Solution:** We will inject a dynamic CSS scroll-margin-top parameter (`scroll-mt-[70px]` or matching header height) on each Leg container, and trigger a minor delayed layout effect pool using a React `setTimeout` to wait for the DOM accordion expansion reflow before pulling the scroll trigger.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Code Refactoring Blueprint
|
|
||||||
|
|
||||||
### Step 1: Update Navigation Payload Handler in Dashboard Component
|
|
||||||
Ensure the `MemberDashboard.tsx` accurately packages the matched target identifier into the client route state bucket:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleNavigateToItinerary = (tour: any) => {
|
|
||||||
let targetLegId = null;
|
|
||||||
|
|
||||||
if (tour?.legs && tour.legs.length > 0) {
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
for (const leg of tour.legs) {
|
|
||||||
const rawStart = leg.startDate || leg.plannedStart || leg.date;
|
|
||||||
const rawEnd = leg.endDate || leg.plannedEnd || leg.date;
|
|
||||||
|
|
||||||
if (rawStart) {
|
|
||||||
const startBound = new Date(rawStart);
|
|
||||||
startBound.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const endBound = rawEnd ? new Date(rawEnd) : new Date(rawStart);
|
|
||||||
endBound.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
if (today >= startBound && today <= endBound) {
|
|
||||||
targetLegId = leg.id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetLegId) {
|
|
||||||
targetLegId = tour.legs[0].id; // Fallback to Chặng 1 if out of tour bounds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate(`/tour/${tour.id}`, {
|
|
||||||
state: { defaultExpandedLegId: targetLegId, shouldScrollToTarget: true }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
### Step 2: Inject Safe Identifiers and Scroll Margin inside ItineraryTimeline
|
|
||||||
Locate the top-level outer container div of each Leg item inside the timeline loop (currentTour.legs.map). Assign a unique id and a scroll margin class utility:
|
|
||||||
|
|
||||||
{currentTour.legs.map((leg: any, legIdx: number) => (
|
|
||||||
<div
|
|
||||||
key={leg.id}
|
|
||||||
id={`leg-anchor-node-${leg.id}`}
|
|
||||||
/* CRITICAL: scroll-mt-[70px] leaves a 70px buffer space at the top.
|
|
||||||
Adjust '70px' to match the exact height of your white Tour Navigation Tabs bar!
|
|
||||||
*/
|
|
||||||
className="scroll-mt-[70px] transition-all w-full mb-4"
|
|
||||||
>
|
|
||||||
{/* Accordion Header Title ("Chặng 2: Dạo chơi ở xứ hoa vàng...") */}
|
|
||||||
<div className="flex items-center justify-between ...">
|
|
||||||
...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
### Step 3: Implement Lifecycle Interaction Trigger Engine
|
|
||||||
Inside ItineraryTimeline.tsx, listen to the passed history route parameters. Set the state, then handle the async smooth scrolling action:
|
|
||||||
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
const [expandedStageId, setExpandedStageId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (location.state?.defaultExpandedLegId) {
|
|
||||||
const targetId = location.state.defaultExpandedLegId;
|
|
||||||
|
|
||||||
// 1. Instantly trigger the accordion state layout expansion
|
|
||||||
setExpandedStageId(targetId);
|
|
||||||
|
|
||||||
// 2. Schedule a deferred macro-task callback to allow DOM re-renders to finish
|
|
||||||
if (location.state?.shouldScrollToTarget) {
|
|
||||||
const scrollTimer = setTimeout(() => {
|
|
||||||
const targetElement = document.getElementById(`leg-anchor-node-${targetId}`);
|
|
||||||
if (targetElement) {
|
|
||||||
targetElement.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'start'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear history router state token flags to prevent repeating scroll on subsequent reload shifts
|
|
||||||
window.history.replaceState({}, document.title);
|
|
||||||
}, 150); // 150ms ensures smooth accordion height deployment transition finishes
|
|
||||||
|
|
||||||
return () => clearTimeout(scrollTimer);
|
|
||||||
}
|
|
||||||
} else if (currentTour?.legs && currentTour.legs.length > 0 && !expandedStageId) {
|
|
||||||
setExpandedStageId(currentTour.legs[0].id);
|
|
||||||
}
|
|
||||||
}, [location.state, currentTour]);
|
|
||||||
|
|
||||||
## 4. Quality Control & Acceptance Verification
|
|
||||||
[ ] Date Matching Accuracy: Set today's date context matching a target Leg configuration profile. Tap the transition interface button from dashboard. The page must route directly and expand the target container block node.
|
|
||||||
|
|
||||||
[ ] Flush Viewport Ceiling Test: The animated target header segment node must slide upwards smoothly. It must lock positions cleanly right below the lowest baseline layer shadow boundary of the Tab Controller without passing behind it.
|
|
||||||
|
|
||||||
[ ] State Cleanliness Check: Refreshing or navigating back and forth within the active Timeline panel after the initial landing should not lock or force layout views to keep jumping scroll heights automatically.
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# To AI Agent: Fix Infinite Re-render Loop and Flickering "ĐANG TÌM ĐƯỜNG TỐI ƯU..." Label
|
|
||||||
|
|
||||||
## 1. Context & Layout Bug Analysis
|
|
||||||
We are resolving a critical performance and UI bug inside the Map routing engine interface (`image.png`):
|
|
||||||
- **The Issue:** The floating loading indicator badge reading `"ĐANG TÌM ĐƯỜNG TỐI ƯU..."` keeps flashing, flickering, or appearing and disappearing in an infinite execution loop.
|
|
||||||
- **Root Cause:** This is caused by a broken React lifecycle loop. Every time the map triggers a directions route query, it toggles a loading state variable (`isSearching: true`). Once the route loads, the state updates (`isSearching: false`), forcing a component re-render. If the coordinates object (`origin`, `destination`) or the map instance ref inside the `useEffect` dependency array changes its reference pointer on every render, the hook fires *again*, creating an endless loop of API fetching and component flashing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Technical Execution Strategy
|
|
||||||
To terminate this flickering cycle, we must enforce a strict guard rail on the network/calculation trigger pipeline:
|
|
||||||
1. **Coordinate Reference Stability:** Deconstruct the input latitude/longitude objects into raw primitive string values (e.g., `origin.lat`, `origin.lng`) inside the hook dependency array to avoid object reference mutations triggering re-renders.
|
|
||||||
2. **Locking Ref Mechanism:** Implement a mutable React tracking reference (`const queryInProgress = useRef(false)`) to lock the execution window. If a fetch operation is active, block subsequent duplicate queries from firing.
|
|
||||||
3. **Clean Loading State Termination:** Turn off the search state flag explicitly only *after* the route geometry polyline has completely finished rendering onto the map viewport layout.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Code Refactoring Blueprint
|
|
||||||
|
|
||||||
Locate your map navigation layer or modal component (e.g., `LocationNavigationModal.tsx`) and refactor the execution lifecycle loop as defined below:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import React, { useEffect, useState, useRef } from 'react';
|
|
||||||
|
|
||||||
// Inside your Map Navigation Modal / Layer component wrapper:
|
|
||||||
export const LocationNavigationModal = ({ isOpen, routeData }) => {
|
|
||||||
const [isSearchingRoute, setIsSearchingRoute] = useState(false);
|
|
||||||
const mapInstanceRef = useRef<any>(null);
|
|
||||||
|
|
||||||
// CRITICAL FIX 2: Guard mechanism to block concurrent duplicated queries
|
|
||||||
const fetchLockRef = useRef(false);
|
|
||||||
|
|
||||||
// Deconstruct coordinate primitives to secure a stable dependency array
|
|
||||||
const originLat = routeData?.origin?.lat;
|
|
||||||
const originLng = routeData?.origin?.lng;
|
|
||||||
const destLat = routeData?.destination?.lat;
|
|
||||||
const destLng = routeData?.destination?.lng;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen || !originLat || !originLng || !destLat || !destLng) return;
|
|
||||||
|
|
||||||
// If an operation is already locked and active, bail out immediately to prevent loops
|
|
||||||
if (fetchLockRef.current) return;
|
|
||||||
|
|
||||||
const calculateOptimalRoute = async () => {
|
|
||||||
try {
|
|
||||||
// 1. Activate loading feedback banner
|
|
||||||
setIsSearchingRoute(true);
|
|
||||||
fetchLockRef.current = true; // Engage execution lock
|
|
||||||
|
|
||||||
console.log("Fetching route coordinates exactly once...");
|
|
||||||
|
|
||||||
// --- YOUR MAP COMPONENT ROUTING LOGIC START ---
|
|
||||||
// Example: const response = await directionsService.route({...});
|
|
||||||
// await mapInstanceRef.current.drawPolyline(response);
|
|
||||||
// --- YOUR MAP COMPONENT ROUTING LOGIC END ---
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to compile route optimization maps:", error);
|
|
||||||
} finally {
|
|
||||||
// 2. Safe, definitive termination of the tracking states
|
|
||||||
setIsSearchingRoute(false);
|
|
||||||
fetchLockRef.current = false; // Disengage execution lock
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
calculateOptimalRoute();
|
|
||||||
|
|
||||||
// Cleanup phase: Reset execution parameters when inputs dismantle or modal closes
|
|
||||||
return () => {
|
|
||||||
fetchLockRef.current = false;
|
|
||||||
setIsSearchingRoute(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* CRITICAL FIX 1: Explicitly tracking primitives only.
|
|
||||||
Do NOT pass full 'routeData', 'mapInstanceRef' or object literals here!
|
|
||||||
*/
|
|
||||||
}, [isOpen, originLat, originLng, destLat, destLng]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full">
|
|
||||||
{/* Map Content Target Canvas */}
|
|
||||||
<div id="navigation-viewport-map-canvas" className="w-full h-full" />
|
|
||||||
|
|
||||||
{/* RENDER CONTROLLER: Only mount the label if routing calculations are actively processing */}
|
|
||||||
{isSearchingRoute && (
|
|
||||||
<div className="absolute top-16 left-4 z-30 bg-white/95 dark:bg-slate-900/95 border border-slate-200 dark:border-slate-800 px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 animate-pulse">
|
|
||||||
{/* Circular Loading Spinner Element */}
|
|
||||||
<div className="w-3.5 h-3.5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
|
||||||
<span className="text-[11px] font-bold text-slate-700 dark:text-slate-200 uppercase tracking-wider">
|
|
||||||
ĐANG TÌM ĐƯỜNG TỐI ƯU...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
## 4. Verification & Quality Acceptance Criteria
|
|
||||||
[ ] Single Instance Trigger: Check console telemetry outputs. When the map modal mounts, the route compilation routine must print its trace log exactly once.
|
|
||||||
|
|
||||||
[ ] Flicker Nullification: The "ĐANG TÌM ĐƯỜNG TỐI ƯU..." badge must display smoothly with an animation pulse. It must not shake, blink, flash, or rapid-cycle on and off.
|
|
||||||
|
|
||||||
[ ] Deterministic Hiding: As soon as the blue route line draws completely across the map terrain grid layout, the loading badge must cleanly unmount and disappear from view without reappearing unless a new destination node button is clicked.
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# To AI Agent: Global Mobile Viewport Optimization Plan for `frontend/src/pages`
|
||||||
|
|
||||||
|
## 1. Context & Architectural Objective
|
||||||
|
We are launching a comprehensive mobile responsive refactoring across all core application pages inside `frontend/src/pages/`. Currently, several layouts suffer from desktop-first design assumptions, leading to sideways horizontal scrolling, squished sidebars, text wrapping collisions, and clipped viewports on mobile browsers.
|
||||||
|
|
||||||
|
**Objective:** Inspect and refactor all page-level layout components to guarantee an impeccable, fluid mobile UX (screen widths under 640px) while preserving the current widescreen layout using Tailwind CSS responsive breakpoints (`sm:`, `md:`, `lg:`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core Mobile-Responsive Design Rules for Pages
|
||||||
|
|
||||||
|
When auditing and refactoring page containers, strictly enforce these implementation guardrails:
|
||||||
|
|
||||||
|
1. **Fluid Heights over Sticky Viewports:** Avoid hardcoding page heights to `h-screen`. On mobile browsers, the address bar dynamically expands and collapses, causing layout jumps. Use **`h-auto`** or the new dynamic viewport utilities **`h-dvh`** / **`min-h-dvh`** instead.
|
||||||
|
2. **Horizontal Overflow Elimination:** Ensure the root wrapper of every page enforces `w-full overflow-x-hidden`. Any element causing a horizontal scrollbar must be converted to a flex-wrap, horizontal scroll grid, or dynamic stack.
|
||||||
|
3. **Flex/Grid Stacking:** Multi-column dashboard layouts must stack vertically on mobile and separate into side-by-side structures on desktop:
|
||||||
|
- Use `flex flex-col md:flex-row`
|
||||||
|
- Use `grid grid-cols-1 md:grid-cols-3`
|
||||||
|
4. **Touch Target & Spacing Downscaling:** Mobile views require higher breathing margins but smaller typography. Reduce text sizes (`text-base` ➔ `text-xs/sm`) and scale down paddings (`p-6` ➔ `p-3/4`) on mobile screens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Targeted Page-by-Page Refactoring Guide
|
||||||
|
|
||||||
|
### 3.1. `MemberDashboard.tsx` (Trang chính / Danh sách Tour)
|
||||||
|
- **The Issue:** The grid grid-cols-2 or grid-cols-3 arrangement squishes Tour Card components on small viewports.
|
||||||
|
- **Refactor Spec:** - Change main wrapper grid to `grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4`.
|
||||||
|
- Force individual Tour Cards to occupy 100% width on mobile, stacking all actions ("Trò chuyện", "Chi tiết hành trình") into standard full-width rows or equal flex pairs (`flex-1`).
|
||||||
|
|
||||||
|
### 3.2. `ItineraryTimeline.tsx` (Trang quản lý Lộ trình Chi tiết)
|
||||||
|
- **The Issue:** The sub-navigation tabs ribbon ("Lộ trình, Chi phí, Ảnh...") gets compressed, causing word overlapping. Timeline lines and node circles clip when left padding is too wide.
|
||||||
|
- **Refactor Spec:**
|
||||||
|
- Convert the sub-navigation menu container into a smooth horizontally scrollable ribbon on mobile:
|
||||||
|
```jsx
|
||||||
|
className="flex items-center gap-2 overflow-x-auto whitespace-nowrap scrollbar-none pb-2 md:overflow-x-visible md:whitespace-normal"
|
||||||
|
```
|
||||||
|
- Reduce the absolute left offset tracking of the timeline vertical axis indicator from `left-[32px]` down to a safe margin fitting tight spaces.
|
||||||
|
|
||||||
|
### 3.3. `PhotoGallery.tsx` / `GalleryPage.tsx` (Thư viện ảnh Tour)
|
||||||
|
- **The Issue:** Widescreen image matrices cause layout bleeding or weird masonry columns.
|
||||||
|
- **Refactor Spec:**
|
||||||
|
- Force image grid wrappers to adopt `grid-cols-2` or `grid-cols-3` on mobile browsers instead of desktop 4-5 structures.
|
||||||
|
- Ensure the modal lightboxes or floating overlays use full-width settings (`w-screen h-screen`) with zero perimeter radius limits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Code Refactoring Reference Standard
|
||||||
|
|
||||||
|
### Layout Component Conversion Pattern:
|
||||||
|
Apply this fluid adaptation standard on your root page return templates:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{/* ❌ BEFORE: RIGID DESKTOP-FIRST PAGE WRAPPER */}
|
||||||
|
<div className="w-screen h-screen bg-slate-950 flex p-6 gap-6">
|
||||||
|
<aside className="w-64 bg-slate-900">Sidebar</aside>
|
||||||
|
<main className="flex-1 overflow-y-auto">Main Dashboard Content</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ AFTER: MOBILE-FIRST FULLY RESPONSIVE LAYOUT SHEET */}
|
||||||
|
<div className="w-full min-h-dvh bg-slate-950 flex flex-col md:flex-row p-3 sm:p-6 gap-4 sm:gap-6 overflow-x-hidden">
|
||||||
|
|
||||||
|
{/* Sticky Navigation or Drawer Menu on Mobile, Fixed Sidebar on Desktop */}
|
||||||
|
<aside className="w-full md:w-64 shrink-0 bg-slate-900 rounded-xl p-4 md:sticky md:top-6 md:h-[calc(100vh-3rem)]">
|
||||||
|
Sidebar/Menu Content
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Scrollable Core Content Space */}
|
||||||
|
<main className="w-full flex-1 overflow-y-visible md:overflow-y-auto">
|
||||||
|
<div className="space-y-4 max-w-7xl mx-auto">
|
||||||
|
{/* Grid elements stack on mobile (1 col) and expand on desktop */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{/* Card components populate here */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 5. Automated Verification Checklist for AI Agent
|
||||||
|
|
||||||
|
[ ] Zero Pixel Width Hardcodes: Scan all code blocks in frontend/src/pages/. Ensure no outer boundaries utilize structural fixed layouts like w-[1200px] or w-[800px] without a breakpoint utility prefix (e.g., lg:w-[1200px]).
|
||||||
|
[ ] Viewport Axis Lock: Simulate viewport checks at 360px, 390px, and 412px widths. Verify that horizontal browser layout shifting is fully neutralized (window.scrollX === 0).
|
||||||
|
[ ] Dynamic Viewport Heights Verification: Confirm that full-page dashboards replace static h-screen classes with h-auto or dynamic min-h-dvh settings to avoid layout bugs when the mobile address bar shifts.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# To AI Agent: Restore Smart Android App Download Banner Below Top-Bar Header
|
||||||
|
|
||||||
|
## 1. Context & Feature Objective
|
||||||
|
Previously, the codebase had a promotional banner encouraging mobile users to download the native Android `.apk` file. However, during the recent overhaul of the Top-Bar header and Member Dropdown Menu configurations, this banner component was accidentally unmounted or hidden.
|
||||||
|
|
||||||
|
**Objective:** Restore and refactor this promotional frame (`AppDownloadBanner.tsx`). It must **ONLY** appear when a user accesses the web application via a **Mobile Android Browser** (Chrome, Samsung Internet, Opera Mobile, etc.). It must be positioned dynamically as a small, clean horizontal frame pinned directly underneath the main Top-Bar header layout, without conflicting with the new absolute Profile Dropdown Menu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Visual & Behavioral Layout Specifications
|
||||||
|
- **Placement Context:** Directly underneath the Top-Bar layer, shifting the core page content (Map Canvas or Landing Hero) down proportionally (`relative` or `sticky` stack). It must not overlay or block map navigation tools.
|
||||||
|
- **Conditional Trigger Logic:** The banner must evaluate `navigator.userAgent`. If the user agent includes `'android'` **AND** the user is running inside a standard web browser (not inside the compiled wrapper app itself), display the banner.
|
||||||
|
- **Dismissible Interaction:** Include a small close button (`X`). Clicking it should temporarily store a flag in `sessionStorage` or `localStorage` to prevent the banner from bugging the user repeatedly during their session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Technical Implementation Blueprint
|
||||||
|
|
||||||
|
### Step 1: Create the Responsive Banner Component (`AppDownloadBanner.tsx`)
|
||||||
|
Create or re-engineer the banner layout within `frontend/src/components/layout/AppDownloadBanner.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Download } from 'lucide-react';
|
||||||
|
|
||||||
|
export const AppDownloadBanner: React.FC = () => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const APK_DOWNLOAD_URL = `${import.meta.env.VITE_BACKEND_URL || window.location.origin}/downloads/yotrip-latest.apk`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
|
const isAndroidBrowser = userAgent.includes('android') && !window.location.origin.includes('capacitor://') && !window.location.origin.includes('localhost:80');
|
||||||
|
const isBannerDismissed = localStorage.getItem('yotrip_apk_banner_dismissed') === 'true';
|
||||||
|
|
||||||
|
// ✅ Target condition match: User is on Android mobile browser and hasn't closed it yet
|
||||||
|
if (isAndroidBrowser && !isBannerDismissed) {
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
localStorage.setItem('yotrip_apk_banner_dismissed', 'true');
|
||||||
|
setIsVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-gradient-to-r from-blue-900 to-indigo-950 border-b border-blue-800 px-4 py-2 flex items-center justify-between text-white text-[11px] font-medium z-40 relative animate-fade-in shrink-0">
|
||||||
|
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
||||||
|
<div className="p-1 bg-blue-500/20 rounded-lg text-blue-400 shrink-0">
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-slate-200">
|
||||||
|
Trải nghiệm mượt mà hơn với ứng dụng <span className="text-white font-bold">YoTrip cho Android</span>!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 ml-2 shrink-0">
|
||||||
|
{/* Direct Download Trigger Target Link */}
|
||||||
|
<a
|
||||||
|
href={APK_DOWNLOAD_URL}
|
||||||
|
download="yotrip.apk"
|
||||||
|
className="bg-blue-600 hover:bg-blue-500 px-2.5 py-1 rounded font-bold text-white transition-colors shadow-sm active:scale-95"
|
||||||
|
>
|
||||||
|
Tải APK
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Close Button Trigger */}
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="p-1 hover:bg-slate-800 rounded text-slate-400 hover:text-slate-200 transition-colors"
|
||||||
|
title="Đóng thông báo"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
### Step 2: Integrate into Layout Hierarchies (LandingPage.tsx & ExplorerMap.tsx)
|
||||||
|
Mount the verified download banner right below your primary header or top-bar row item array context:
|
||||||
|
|
||||||
|
{/* ❌ BEFORE STRUCTURAL INTEGRATION */}
|
||||||
|
<div className="w-screen h-screen flex flex-col">
|
||||||
|
<TopBarHeader />
|
||||||
|
<MapCanvasWorkspace />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ AFTER STRUCTURAL INTEGRATION: Stacked relative context */}
|
||||||
|
<div className="w-screen h-screen flex flex-col overflow-hidden">
|
||||||
|
{/* 1. Main Application Header */}
|
||||||
|
<TopBarHeader />
|
||||||
|
|
||||||
|
{/* 2. THE RESTORED ANDROID APP PROMOTIONAL BANNER */}
|
||||||
|
<AppDownloadBanner />
|
||||||
|
|
||||||
|
{/* 3. Primary Workspace Area - Shifts down cleanly when banner initializes */}
|
||||||
|
<div className="flex-1 relative min-h-0 w-full">
|
||||||
|
<MapCanvasWorkspace />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 4. Verification & Quality Acceptance Criteria for AI Agent
|
||||||
|
[ ] Targeted UserAgent Isolation: Emulate a desktop display width (iPhone or Desktop layouts). Verify the banner remains completely hidden. Toggle the network responsive preview device model to Android (e.g., Pixel 7) and refresh. Confirm the download bar pops up seamlessly.
|
||||||
|
|
||||||
|
[ ] Absolute Dropdown Overlay Preservation: Expand the Member Avatar menu row dropdown list while the banner is visible. Confirm that the dropdown box displays layered ON TOP of the banner context, without layout shifting or text clipping.
|
||||||
|
|
||||||
|
[ ] State Dismissal Memory: Click the X button on the banner, then refresh the browser session. Confirm the banner remains hidden and respects the localStorage condition toggle.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# To AI Agent: Deprecate MemberDashboard Page and Re-architect All User Features into Dedicated Modals on LandingPage
|
||||||
|
|
||||||
|
## 1. Architectural Strategy & Goal
|
||||||
|
We are completely removing the standalone `MemberDashboard` route/page. When a user logs in successfully, they must **remain directly on the `LandingPage`** (or `ExplorerMap`), with their authentication state shifting cleanly to display the member avatar dropdown menu in the top-bar header.
|
||||||
|
|
||||||
|
Every core feature previously hosted on the dashboard page must now be converted into a high-performance, responsive **Modal Overlay Component**. Clicking an item in the avatar dropdown menu will toggle the visibility state of its respective modal over the current viewport, ensuring zero page-redirection disruption.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Route Deletion & Auth Redirection Clean-up
|
||||||
|
### 1. **Route Removal:** Open your router configuration (`App.tsx` or `routes.tsx`) and permanently delete the `<Route path="/dashboard" ... />` node.
|
||||||
|
### 2. **Auth Hook Modification:** Inside the login handler lifecycle (e.g., `LoginModal.tsx` or `AuthContext.tsx`), replace `Maps('/dashboard')` with a simple state closer that preserves the current page instance:
|
||||||
|
```typescript
|
||||||
|
// ❌ OLD: navigate('/dashboard');
|
||||||
|
// ✅ NEW: Keep user on context page, close login overlay, update profile states
|
||||||
|
setIsLoginModalOpen(false);
|
||||||
|
|
||||||
|
## 3. Technical Specifications for Each Target Modal
|
||||||
|
Implement the following modal architectures inside frontend/src/components/modals/:
|
||||||
|
|
||||||
|
### 3.1. Modal Chat Trực Tiếp (LiveChatModal.tsx)
|
||||||
|
Layout Architecture: A wide responsive viewport split into two main functional vertical columns (flex flex-col md:flex-row h-[80vh]).
|
||||||
|
|
||||||
|
Left Column (w-full md:w-80 border-r border-slate-800): Interactive scrollable contact strip displaying the User's Friend List with online status indicators and latest message snippets.
|
||||||
|
|
||||||
|
Right Column (flex-1 flex flex-col bg-slate-950): Active conversation window.
|
||||||
|
|
||||||
|
Bottom Input Tray Wrapper: A rich-text typing container bar fixed at the baseline containing:
|
||||||
|
|
||||||
|
Text input area field.
|
||||||
|
|
||||||
|
Attachment Trigger buttons: Icon for Image uploads (accept="image/*") and an Icon for transmitting spatial GPS Coordinates/Locations directly into the text stream.
|
||||||
|
|
||||||
|
### 3.2. Modal Hành Trình Của Tôi (MyToursModal.tsx)
|
||||||
|
Layout Architecture: A dynamic card explorer view featuring structural chronological timeline tabs at the top:
|
||||||
|
|
||||||
|
Tabs Grid: Đang thực hiện (Ongoing), Sắp khởi hành (Upcoming), and Đã hoàn thành (Past).
|
||||||
|
|
||||||
|
Core Content Panel: Clicking a tab renders a fluid inner grid loop of compact trip tiles. Each tile houses a banner photo, progress indicators, and quick action links to open the standalone TourNavigationPage or route maps directly.
|
||||||
|
|
||||||
|
### 3.3. Modal Thư Viện Ảnh (PhotoGalleryModal.tsx)
|
||||||
|
Layout Architecture: A dedicated media repository browser displaying all images uploaded by the member.
|
||||||
|
|
||||||
|
Filtering Header Matrix: Dual filter select boxes pinned at the top:
|
||||||
|
|
||||||
|
Filter 1: Filter by Itinerary/Trip (Theo hành trình).
|
||||||
|
|
||||||
|
Filter 2: Filter by Media Hashtags (Theo tags).
|
||||||
|
|
||||||
|
Core Workspace: A masonry-style gallery layout grid with hover micro-interactions enabling the user to view full resolution views, edit asset tagging descriptions, or delete pictures directly.
|
||||||
|
|
||||||
|
### 3.4. Modal Danh Sách Bạn Bè (FriendsManagerModal.tsx)
|
||||||
|
Layout Architecture: A centralized social dashboard layout built to handle user connections.
|
||||||
|
|
||||||
|
Functional Subsections:
|
||||||
|
|
||||||
|
Search engine bar to lookup new profiles via display name or telephone metrics.
|
||||||
|
|
||||||
|
Tab views separating Danh sách bạn bè (Active Friends) and Lời mời kết bạn (Pending Requests).
|
||||||
|
|
||||||
|
Row items equipped with direct context action triggers: Hủy kết bạn (Unfriend), Chấp nhận (Accept), or Nhắn tin (Quick Message - which bridges states to auto-toggle the Chat Modal).
|
||||||
|
|
||||||
|
## 4. Top-Bar Profile Dropdown Structure Integration
|
||||||
|
Refactor the items list container within MapProfileDropdown.tsx to match the localized modal toggles state logic:
|
||||||
|
|
||||||
|
TypeScript
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Compass, Map, Image, Settings, ShieldAlert, Users, LogOut } from 'lucide-react';
|
||||||
|
|
||||||
|
export const HeaderMemberDropdown = ({ openModal }) => {
|
||||||
|
return (
|
||||||
|
<div className="absolute right-0 top-14 w-64 bg-slate-900 border border-slate-800 rounded-xl p-1.5 shadow-2xl flex flex-col space-y-0.5 text-xs text-slate-200 z-[999999]">
|
||||||
|
|
||||||
|
{/* 1. Nút "Tạo tour" */}
|
||||||
|
<button onClick={() => openModal('create_tour')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
|
||||||
|
<Compass className="w-4 h-4 text-amber-400" />
|
||||||
|
<span className="font-medium">Tạo tour</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 2. Nút "Hành trình của tôi" */}
|
||||||
|
<button onClick={() => openModal('my_tours')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
|
||||||
|
<Map className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="font-medium">Hành trình của tôi</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 3. Nút "Thư viện ảnh" */}
|
||||||
|
<button onClick={() => openModal('gallery')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
|
||||||
|
<Image className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span className="font-medium">Thư viện ảnh</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 4. Nút "Cài đặt" */}
|
||||||
|
<button onClick={() => openModal('settings')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
|
||||||
|
<Settings className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="font-medium">Cài đặt</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 5. Nút "Báo cáo vi phạm" */}
|
||||||
|
<button onClick={() => openModal('reports')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
|
||||||
|
<ShieldAlert className="w-4 h-4 text-rose-400" />
|
||||||
|
<span className="font-medium">Báo cáo vi phạm</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-[1px] bg-slate-800/80 my-1 mx-2" />
|
||||||
|
|
||||||
|
{/* 6. Nút "Danh sách bạn bè" */}
|
||||||
|
<button onClick={() => openModal('friends')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
|
||||||
|
<Users className="w-4 h-4 text-indigo-400" />
|
||||||
|
<span className="font-medium">Danh sách bạn bè</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 7. Nút "Đăng xuất" */}
|
||||||
|
<button onClick={() => openModal('logout')} className="flex items-center gap-3 w-full px-4 py-2.5 text-rose-400 hover:bg-slate-800 rounded-lg text-left font-bold transition-colors">
|
||||||
|
<LogOut className="w-4 h-4 text-rose-500" />
|
||||||
|
<span>Đăng xuất</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
## 5. Automation Checklists for Quality Verification
|
||||||
|
[ ] Redirection Invalidation Check: Perform a successful user login sweep. Verify the active URL hash parameter remains exactly / or /map, completely dropping dashboard transitions.
|
||||||
|
|
||||||
|
[ ] Chat Modal Layout Sizing: Trigger the Live Chat button. Ensure the workspace scales to an explicit split-view pane framework (Left: Contacts / Right: Dialog Thread) with working attachment trays.
|
||||||
|
|
||||||
|
[ ] Gallery Filter Interception: Confirm that filtering options inside the Photo modal dynamically re-index asset cards based on itinerary tags or custom upload parameters.
|
||||||
|
|
||||||
|
[ ] Global Z-Index Verification: All new modals must enforce a strict z-[999999] utility layer rule to pop up cleanly above the underlying map viewport layer without clip cutting.
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# To AI Agent: Fix EXIF GPS Extraction, Implement Client-Side 2K Image Resizing, and Fix Android Fullscreen Lightbox Alignment
|
||||||
|
|
||||||
|
## 1. Context & Feature Objectives
|
||||||
|
We are addressing three crucial image-handling and layout bugs on the mobile/Android web wrapper:
|
||||||
|
1. **Fix (Missing EXIF Location):** When users upload photos, the system fails to extract the geographic coordinates (Latitude/Longitude) embedded within the image metadata. We need to parse EXIF data completely on the client side before submission.
|
||||||
|
2. **Feat (Native Save & 2K Downscale):** Whether the user shoots a new photo via the Camera or picks one from the Gallery, the original image must remain safely stored in the phone's native album (handled by native webview permissions). Before uploading the file to our Debian server, the frontend must dynamically resize/downscale the image to a maximum resolution of **2K (2048px on its longest edge)** to optimize network bandwidth and server storage.
|
||||||
|
3. **Bug (Fullscreen Viewer Displacement):** When clicking an image inside the gallery/photo manager view to preview it in fullscreen mode on an Android device, the image incorrectly aligns to the absolute bottom edge of the viewport instead of centering beautifully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Technical Execution Strategy
|
||||||
|
|
||||||
|
### 2.1. Client-Side EXIF Processing & Metadata Preservation
|
||||||
|
Standard browser file inputs often strip EXIF headers during dynamic manipulation or fail to parse them natively. We will introduce `exif-js` or use a standard binary array buffer scanner to extract the `GPSLatitude` and `GPSLongitude` headers right before resizing occurs, attaching them to the final multipart upload payload.
|
||||||
|
|
||||||
|
### 2.2. Downscaling to 2K via HTML5 Canvas
|
||||||
|
To achieve hardware-accelerated image scaling on mobile devices without losing core image visibility, the source image will be rendered onto an offscreen `<canvas>` container configured to enforce a `max-dimension` of `2048px`, maintaining the original aspect ratio.
|
||||||
|
|
||||||
|
### 2.3. Flexbox/Absolute Centering Fix for Android Lightbox
|
||||||
|
The bottom-displacement bug is tied to incorrect layout bounds calculations on mobile screens when toolbars or navigation rows shift view heights. We will refactor the Lightbox container modal to use rigid viewport configurations (`fixed inset-0`) along with standard vertical centering mechanics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Code Refactoring Blueprint
|
||||||
|
|
||||||
|
### Step 1: Implement Image Metadata Picker & 2K Resizer Logic (`imageProcessor.ts`)
|
||||||
|
Create a utility service at `frontend/src/utils/imageProcessor.ts` to handle metadata extraction and canvas downscaling sequentially:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import EXIF from 'exif-js';
|
||||||
|
|
||||||
|
interface ProcessedImageResult {
|
||||||
|
file: Blob;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert EXIF rational coordinates to standard decimal degrees
|
||||||
|
const convertDMSToDD = (dms: number[], ref: string): number => {
|
||||||
|
if (!dms || dms.length < 3) return 0;
|
||||||
|
const degrees = dms[0] + dms[1] / 60 + dms[2] / 3600;
|
||||||
|
return ref === 'S' || ref === 'W' ? -degrees : degrees;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const processAndResizeImage = (file: File): Promise<ProcessedImageResult> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let latitude: number | null = null;
|
||||||
|
let longitude: number | null = null;
|
||||||
|
|
||||||
|
// 1. EXTRACT EXIF METADATA BEFORE CANVAS CLEARING
|
||||||
|
EXIF.getData(file as any, function (this: any) {
|
||||||
|
const allTags = EXIF.getAllTags(this);
|
||||||
|
if (allTags.GPSLatitude && allTags.GPSLatitudeRef) {
|
||||||
|
latitude = convertDMSToDD(allTags.GPSLatitude, allTags.GPSLatitudeRef);
|
||||||
|
}
|
||||||
|
if (allTags.GPSLongitude && allTags.GPSLongitudeRef) {
|
||||||
|
longitude = convertDMSToDD(allTags.GPSLongitude, allTags.GPSLongitudeRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📸 Extracted EXIF Metadata - Lat: ${latitude}, Lng: ${longitude}`);
|
||||||
|
|
||||||
|
// Proceed directly to resizing stage
|
||||||
|
proceedToResize();
|
||||||
|
});
|
||||||
|
|
||||||
|
function proceedToResize() {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = event.target?.result as string;
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
let width = img.width;
|
||||||
|
let height = img.height;
|
||||||
|
const MAX_SIZE = 2048; // Enforce rigid 2K maximum boundary limit
|
||||||
|
|
||||||
|
// Calculate ideal bounding proportions
|
||||||
|
if (width > height) {
|
||||||
|
if (width > MAX_SIZE) {
|
||||||
|
height = Math.round((height * MAX_SIZE) / width);
|
||||||
|
width = MAX_SIZE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (height > MAX_SIZE) {
|
||||||
|
width = Math.round((width * MAX_SIZE) / height);
|
||||||
|
height = MAX_SIZE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return resolve({ file, latitude, longitude });
|
||||||
|
|
||||||
|
// Render image onto downscaled dimensions canvas bounding box
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve({
|
||||||
|
file: blob,
|
||||||
|
latitude,
|
||||||
|
longitude
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve({ file, latitude, longitude });
|
||||||
|
}
|
||||||
|
}, 'image/jpeg', 0.88); // 88% quality compression sweet-spot
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
### Step 2: Update Image Upload Handler Layer
|
||||||
|
Integrate the processor wrapper inside your central upload function (e.g., ImageUploader.tsx or your form submission handler):
|
||||||
|
|
||||||
|
import { processAndResizeImage } from '../../utils/imageProcessor';
|
||||||
|
|
||||||
|
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const targetFile = event.target.files?.[0];
|
||||||
|
if (!targetFile) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Process image: Reads EXIF data and forces 2K resizing on device memory
|
||||||
|
const { file, latitude, longitude } = await processAndResizeImage(targetFile);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file, 'yotrip_upload.jpg');
|
||||||
|
|
||||||
|
// 2. Append coordinates safely to standard server fields
|
||||||
|
if (latitude !== null && longitude !== null) {
|
||||||
|
formData.append('latitude', latitude.toString());
|
||||||
|
formData.append('longitude', longitude.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Post to API endpoint
|
||||||
|
const response = await api.post('/media/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ Media successfully synchronized with server backend:", response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to safely prepare media stream:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
### Step 3: Fix Fullscreen Image Alignment Layout (ImageLightbox.tsx)
|
||||||
|
Locate your photo viewer overlay or modal drawer component. Overhaul the tailwind utilities to guarantee true vertical and horizontal centering layout balance on Android devices:
|
||||||
|
|
||||||
|
{/* ❌ BEFORE: Faulty container pinning images to device bottom edges */}
|
||||||
|
<div className="fixed inset-0 bg-black flex items-end justify-center">
|
||||||
|
|
||||||
|
{/* ✅ AFTER: True viewport overlay centering bounding context */}
|
||||||
|
<div className="fixed inset-0 bg-black/95 backdrop-blur-sm flex flex-col items-center justify-center z-[999999] overflow-hidden animate-fade-in">
|
||||||
|
{/* Close Button Top Tracker Bar Container */}
|
||||||
|
<div className="absolute top-4 right-4 z-50">
|
||||||
|
<button className="p-2.5 bg-slate-900/60 rounded-full text-white">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image wrapper frame context forcing clean alignment metrics */}
|
||||||
|
<div className="w-full h-full flex items-center justify-center p-4">
|
||||||
|
<img
|
||||||
|
src={currentImageUrl}
|
||||||
|
alt="YoTrip Preview"
|
||||||
|
className="max-w-full max-h-full object-contain select-none pointer-events-auto"
|
||||||
|
style={{
|
||||||
|
/* Prevent Android webviews from accidental shifting behaviors */
|
||||||
|
transform: 'translate3d(0, 0, 0)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 4. Automated Verification Checklist for AI Agent
|
||||||
|
[ ] EXIF Validation Verification: Test uploading a photo embedded with active geolocation values. Inspect the API outgoing transmission payload in the network panel; latitude and longitude fields must contain accurate decimal metrics instead of blank string indicators.
|
||||||
|
|
||||||
|
[ ] Longest-Edge Constraint Check: Upload a ultra-high resolution image (e.g., 4000px wide). Verify that the processed file size shrinks significantly, and confirm through terminal logging that the generated canvas asset limits width/height strictly to 2048px.
|
||||||
|
|
||||||
|
[ ] Android Centering Success: Activate the image preview mode inside the Android Simulator. The image layout must align mathematically dead-center vertically, leaving symmetric padding bars on both the top header and bottom system navigation boundaries.
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
# Plan: Build ứng dụng Android 11+ từ Codebase hiện tại
|
||||||
|
|
||||||
|
## Tổng quan
|
||||||
|
|
||||||
|
Codebase hiện tại là một **web app React + Vite** (frontend) và **NestJS** (backend) theo mô hình monorepo. Chiến lược được đề xuất là dùng **Capacitor.js** để đóng gói web app thành native Android APK/AAB mà **không cần viết lại code**, đồng thời bổ sung các tính năng native (camera, GPS, notifications...) qua Capacitor plugins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## So sánh các lựa chọn
|
||||||
|
|
||||||
|
| Phương pháp | Ưu điểm | Nhược điểm | Phù hợp? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Capacitor.js** | Tái sử dụng 100% React code, hỗ trợ Vite, ít học thêm | Cần build native mỗi lần release | ✅ **Phù hợp nhất** |
|
||||||
|
| React Native | Performance tốt hơn, native feel | Phải viết lại toàn bộ UI/components | ❌ Tốn quá nhiều công |
|
||||||
|
| PWA (Add to Home Screen) | Không cần build APK | Bị giới hạn API trình duyệt, không lên Google Play | ❌ Không phù hợp |
|
||||||
|
| Cordova/PhoneGap | Tương tự Capacitor | Cũ hơn, ít được duy trì | ❌ Không nên dùng |
|
||||||
|
|
||||||
|
**→ Quyết định: Sử dụng [Capacitor.js](https://capacitorjs.com/)** (do Ionic team phát triển), hỗ trợ Android API 30+ (Android 11).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kiến trúc triển khai
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Android APK / AAB │
|
||||||
|
│ ┌─────────────────────────────────────────────┐ │
|
||||||
|
│ │ Capacitor WebView │ │
|
||||||
|
│ │ (Chứa toàn bộ frontend React/Vite) │ │
|
||||||
|
│ └──────────────────┬──────────────────────────┘ │
|
||||||
|
│ │ HTTPS API calls │
|
||||||
|
└─────────────────────┼───────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌────────────────────────┐
|
||||||
|
│ NestJS Backend │
|
||||||
|
│ (Deployed server / │
|
||||||
|
│ localhost dev) │
|
||||||
|
└────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Thông tin đã xác nhận
|
||||||
|
|
||||||
|
| Hạng mục | Quyết định |
|
||||||
|
|---|---|
|
||||||
|
| **Tên app** | YoTrip |
|
||||||
|
| **App ID** | `com.yotrip.app` |
|
||||||
|
| **Phát hành** | Google Play Store |
|
||||||
|
| **Camera** | Native (chụp ảnh trực tiếp từ app) |
|
||||||
|
| **Backend** | Docker trên Debian Homelab, proxy qua Nginx |
|
||||||
|
| **Domain** | `yotrip.labz.io.vn` |
|
||||||
|
| **DNS động** | Cloudflare DDNS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Điều kiện tiên quyết
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Trước khi thực hiện, cần chuẩn bị:
|
||||||
|
> 1. **Java JDK 17+** — Android build tool yêu cầu
|
||||||
|
> 2. **Android Studio** — để build APK, quản lý Android SDK và tạo/chạy Emulator
|
||||||
|
> 3. **Android SDK** với API Level 30+ (Android 11 / API 30)
|
||||||
|
> 4. **Android Virtual Device (AVD)** — tạo trong Android Studio AVD Manager
|
||||||
|
> 5. **Domain + HTTPS cho Homelab** — bắt buộc cho Google Play (xem Phần Bên dưới)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **Google Play bắt buộc HTTPS** — Android 9+ chặn HTTP rõ ràng mặc định. Homelab phải có SSL certificate hợp lệ (Let's Encrypt qua Nginx) và domain name trỏ vào homelab server.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Trên emulator, `localhost` của **emulator** khác với `localhost` của máy tính. Phải dùng địa chỉ đặc biệt `10.0.2.2` thay thế khi test emulator (xem Phase 5b).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Changes
|
||||||
|
|
||||||
|
### Phase 1a: Cấu hình Backend Homelab (Docker + Nginx)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Đây là điều kiện tiên quyết của toàn bộ kế hoạch. App không thể gửi lên Google Play nếu API chưa có HTTPS.
|
||||||
|
|
||||||
|
Homelab của bạn chạy Docker + Nginx là **đủ điều kiện kết nối**, nhưng cần đảm bảo:
|
||||||
|
|
||||||
|
#### Checklist Homelab/Nginx
|
||||||
|
|
||||||
|
| Yêu cầu | Mô tả |
|
||||||
|
|---|---|
|
||||||
|
| **Domain name** | `yotrip.labz.io.vn` — đã xác nhận |
|
||||||
|
| **Port forwarding** | Router cài đặt forward port 80 và 443 vào Nginx server |
|
||||||
|
| **SSL Certificate** | Let's Encrypt (miễn phí) qua Certbot: `certbot --nginx -d yotrip.labz.io.vn` |
|
||||||
|
| **Nginx reverse proxy** | Forward HTTPS → NestJS container port 3001 |
|
||||||
|
| **Docker Compose** | Backend container luôn restart khi Debian reboot |
|
||||||
|
| **CORS** | Backend phải cho phép origin từ app Capacitor (có thể mở rộng `*` ban đầu) |
|
||||||
|
| **IP động** | Dùng **Cloudflare DDNS** để giữ domain `yotrip.labz.io.vn` luôn trỏ đúng IP |
|
||||||
|
|
||||||
|
#### Mẫu cấu hình Nginx reverse proxy
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# /etc/nginx/sites-available/yotrip-api
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name yotrip.labz.io.vn;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/yotrip.labz.io.vn/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/yotrip.labz.io.vn/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
# Cần thiết cho WebSocket (Socket.IO)
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect HTTP sang HTTPS
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name yotrip.labz.io.vn;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### File cấu hình môi trường sẽ dùng
|
||||||
|
|
||||||
|
```env
|
||||||
|
# frontend/.env.production
|
||||||
|
VITE_API_BASE_URL=https://yotrip.labz.io.vn
|
||||||
|
|
||||||
|
# frontend/.env.emulator (test local)
|
||||||
|
VITE_API_BASE_URL=http://10.0.2.2:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1b: Tập trung API URL trong Frontend
|
||||||
|
|
||||||
|
#### [MODIFY] [vite.config.ts](file:///home/locpham/travelplanning/frontend/vite.config.ts)
|
||||||
|
- Dev proxy hiện tại trỏ về `http://localhost:3001` — chỉ hoạt động khi chạy trên browser máy tính.
|
||||||
|
- Tạo biến môi trường `VITE_API_BASE_URL` để frontend biết trỏ đến đâu.
|
||||||
|
|
||||||
|
#### [NEW] `.env.production` trong `frontend/`
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=https://your-backend-server.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### [NEW] `.env.development` trong `frontend/`
|
||||||
|
```env
|
||||||
|
# Dùng ngrok hoặc IP máy tính cho mobile dev
|
||||||
|
VITE_API_BASE_URL=http://192.168.x.x:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
#### [MODIFY] Toàn bộ các file gọi `/api/v1/...`
|
||||||
|
- Thay `fetch('/api/v1/...')` bằng `fetch(\`${import.meta.env.VITE_API_BASE_URL}/api/v1/...\`)`
|
||||||
|
- **Ưu tiên**: Tạo một file `src/lib/api.ts` (helper) để tập trung URL, tránh sửa từng file.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/api.ts
|
||||||
|
export const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
|
||||||
|
|
||||||
|
export const apiFetch = (path: string, options?: RequestInit) =>
|
||||||
|
fetch(`${API_BASE}${path}`, options);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Tích hợp Capacitor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Cài đặt Capacitor vào frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Trong thư mục frontend/
|
||||||
|
npm install @capacitor/core @capacitor/cli
|
||||||
|
npx cap init "YoTrip" "com.yotrip.app" --web-dir dist
|
||||||
|
npm install @capacitor/android
|
||||||
|
npx cap add android
|
||||||
|
```
|
||||||
|
|
||||||
|
#### [NEW] `frontend/capacitor.config.ts`
|
||||||
|
```typescript
|
||||||
|
import { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'com.yotrip.app', // ✅ App ID chính thức
|
||||||
|
appName: 'YoTrip', // ✅ Tên app
|
||||||
|
webDir: 'dist',
|
||||||
|
server: {
|
||||||
|
// Production: không cần cấu hình, dùng VITE_API_BASE_URL trong build
|
||||||
|
// Dev/Emulator: uncomment dòng dưới
|
||||||
|
// url: 'http://10.0.2.2:3002',
|
||||||
|
// cleartext: true,
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
minSdkVersion: 30, // Android 11 (API 30)
|
||||||
|
targetSdkVersion: 34, // Android 14
|
||||||
|
buildOptions: {
|
||||||
|
keystorePath: 'release-key.keystore',
|
||||||
|
keystoreAlias: 'yotrip',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
SplashScreen: {
|
||||||
|
launchAutoHide: false,
|
||||||
|
backgroundColor: '#0f172a',
|
||||||
|
androidSplashResourceName: 'splash',
|
||||||
|
},
|
||||||
|
// Camera plugin config (bắt buộc vì app cần chụp ảnh)
|
||||||
|
Camera: {
|
||||||
|
presentationStyle: 'fullscreen',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Android Project Setup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Build flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bước 1: Build React app thành static files
|
||||||
|
npm run build -w frontend
|
||||||
|
|
||||||
|
# Bước 2: Copy static files vào Android project
|
||||||
|
npx cap copy android
|
||||||
|
|
||||||
|
# Bước 3: Sync plugins và dependencies
|
||||||
|
npx cap sync android
|
||||||
|
|
||||||
|
# Bước 4: Mở Android Studio để build APK/AAB
|
||||||
|
npx cap open android
|
||||||
|
```
|
||||||
|
|
||||||
|
#### [MODIFY] `android/app/src/main/AndroidManifest.xml` (tự sinh bởi Capacitor)
|
||||||
|
Thêm các permissions cần thiết:
|
||||||
|
```xml
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
|
<!-- Android 11+ scoped storage -->
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Native Plugins (**Bắt buộc**)
|
||||||
|
|
||||||
|
| Plugin | Mức độ | Mục đích | Package |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `@capacitor/camera` | ✅ **Bắt buộc** | Chụp ảnh trực tiếp từ camera + gallery | `@capacitor/camera` |
|
||||||
|
| `@capacitor/geolocation` | ✅ **Bắt buộc** | GPS thiết bị cho bản đồ | `@capacitor/geolocation` |
|
||||||
|
| `@capacitor/splash-screen` | ✅ **Bắt buộc** | Màn hình khởi động | `@capacitor/splash-screen` |
|
||||||
|
| `@capacitor/status-bar` | ✅ **Bắt buộc** | Màu status bar đồng bộ UI | `@capacitor/status-bar` |
|
||||||
|
| `@capacitor/push-notifications` | 🔲 Tùy chọn | Thông báo bình luận, tour | `@capacitor/push-notifications` |
|
||||||
|
| `@capacitor/network` | 🔲 Tùy chọn | Kiểm tra kết nối mạng | `@capacitor/network` |
|
||||||
|
|
||||||
|
#### Cách dùng Camera plugin trong code (thay thế input file)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Thay thế <input type="file"> bằng Capacitor Camera API
|
||||||
|
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
|
||||||
|
|
||||||
|
const takePhoto = async () => {
|
||||||
|
const photo = await Camera.getPhoto({
|
||||||
|
resultType: CameraResultType.DataUrl, // Hoặc Uri cho hiệu năng tốt hơn
|
||||||
|
source: CameraSource.Prompt, // Hỏi: Camera hay Gallery?
|
||||||
|
quality: 85,
|
||||||
|
});
|
||||||
|
// photo.dataUrl → upload lên backend
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Điều chỉnh UI cho Mobile
|
||||||
|
|
||||||
|
Một số thành phần hiện tại đã có responsive CSS, nhưng cần kiểm tra thêm:
|
||||||
|
|
||||||
|
- **`PublicPhotoModal.tsx`**: Đã có kế hoạch fix mobile layout (từ plan cũ), cần implement trước khi build.
|
||||||
|
- **`ExploreMap.tsx`**: Leaflet map cần đảm bảo touch events hoạt động (thường OK trên mobile).
|
||||||
|
- **`TourDetailPage.tsx`**: Kiểm tra scroll behavior, fixed headers.
|
||||||
|
- **Safe Area Insets**: Dùng `env(safe-area-inset-*)` cho các thiết bị có notch/dynamic island.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Thêm vào index.css */
|
||||||
|
:root {
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5b: Test trên Android Emulator (AVD)
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Đây là bước bắt buộc trước khi test trên thiết bị thật. Emulator giúp phát hiện lỗi layout, API call, và native permissions mà không cần thiết bị vật lý.
|
||||||
|
|
||||||
|
#### Tạo Android Virtual Device (AVD)
|
||||||
|
1. Mở **Android Studio → Tools → Device Manager → Create Device**
|
||||||
|
2. Chọn **Pixel 6** (hoặc tương đương) → chọn hệ thống **Android 11.0 (API 30)**
|
||||||
|
3. Cấp RAM 2GB+, Storage 4GB+ cho emulator
|
||||||
|
4. Khởi động emulator, đảm bảo hiện **"Emulator is running"**
|
||||||
|
|
||||||
|
#### Địa chỉ đặc biệt trong Emulator
|
||||||
|
|
||||||
|
```
|
||||||
|
10.0.2.2 → Trỏ đến localhost (127.0.0.1) của máy tính host
|
||||||
|
```
|
||||||
|
|
||||||
|
Khi chạy trong emulator, backend ở `localhost:3001` của máy tính phải được gọi bằng `10.0.2.2:3001`.
|
||||||
|
|
||||||
|
#### Cấu hình `capacitor.config.ts` cho Emulator Dev
|
||||||
|
```typescript
|
||||||
|
// Tạm thời uncomment khi test trên emulator
|
||||||
|
server: {
|
||||||
|
url: 'http://10.0.2.2:3002', // Trỏ đến Vite dev server trên máy host
|
||||||
|
cleartext: true, // Cho phép HTTP (không dùng trong production)
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Hoặc dùng biến môi trường `.env.emulator`:
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=http://10.0.2.2:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Chạy app trên Emulator
|
||||||
|
```bash
|
||||||
|
# Bước 1: Đảm bảo emulator đang chạy
|
||||||
|
adb devices
|
||||||
|
# Phải thấy: emulator-5554 device
|
||||||
|
|
||||||
|
# Bước 2: Build & sync
|
||||||
|
npm run build -w frontend
|
||||||
|
npx cap copy android && npx cap sync android
|
||||||
|
|
||||||
|
# Bước 3: Chạy trực tiếp trên emulator
|
||||||
|
npx cap run android
|
||||||
|
# hoặc trong Android Studio: Run ▶ chọn emulator
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Checklist kiểm tra trên Emulator
|
||||||
|
|
||||||
|
| Chức năng | Test case | Kết quả mong đợi |
|
||||||
|
|---|---|---|
|
||||||
|
| Khởi động | Mở app | Splash screen → Landing page |
|
||||||
|
| Đăng nhập | Nhập email/password | Vào ExploreMap |
|
||||||
|
| Bản đồ | Zoom/pan trên emulator | Leaflet map hoạt động |
|
||||||
|
| Upload ảnh | Chọn ảnh từ gallery giả lập | Ảnh được upload thành công |
|
||||||
|
| Bình luận | Nhập & gửi bình luận | Hiện real-time qua WebSocket |
|
||||||
|
| Safe area | Xoay màn hình | Layout không bị che |
|
||||||
|
| Responsive | Portrait/Landscape | UI không bị vỡ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: Test trên thiết bị Android thật
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Chỉ tiến hành Phase này sau khi toàn bộ Phase 5b đã pass. Thiết bị thật giúp phát hiện lỗi về hiệu năng, cảm biến thực tế, và hành vi network.
|
||||||
|
|
||||||
|
#### Kết nối thiết bị
|
||||||
|
```bash
|
||||||
|
# Bật Developer Mode + USB Debugging trên điện thoại
|
||||||
|
# Cắm cáp USB → xác nhận "Allow USB Debugging"
|
||||||
|
adb devices
|
||||||
|
# Phải thấy: <serial_number> device
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cấu hình backend cho thiết bị thật
|
||||||
|
Thiết bị thật phải dùng IP LAN hoặc ngrok (không thể dùng `10.0.2.2`):
|
||||||
|
```bash
|
||||||
|
# Tùy chọn A: Dùng IP LAN (điện thoại và máy tính cùng WiFi)
|
||||||
|
VITE_API_BASE_URL=http://192.168.x.x:3001
|
||||||
|
|
||||||
|
# Tùy chọn B: Dùng ngrok (tiện hơn, không cần cùng mạng)
|
||||||
|
ngrok http 3001
|
||||||
|
# → VITE_API_BASE_URL=https://xxxx.ngrok-free.app
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cài debug APK lên thiết bị thật
|
||||||
|
```bash
|
||||||
|
# Build debug APK
|
||||||
|
cd frontend/android && ./gradlew assembleDebug
|
||||||
|
|
||||||
|
# Cài APK lên thiết bị
|
||||||
|
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7: Ký APK và phát hành
|
||||||
|
|
||||||
|
#### Debug build (dev testing)
|
||||||
|
```bash
|
||||||
|
cd android && ./gradlew assembleDebug
|
||||||
|
# Output: android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Release build (production)
|
||||||
|
```bash
|
||||||
|
# Tạo keystore lần đầu
|
||||||
|
keytool -genkey -v -keystore release-key.keystore -alias yotrip \
|
||||||
|
-keyalg RSA -keysize 2048 -validity 10000
|
||||||
|
|
||||||
|
# Build release
|
||||||
|
cd android && ./gradlew bundleRelease
|
||||||
|
# Output: .aab file để upload Google Play
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist thực hiện
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 - Backend URL
|
||||||
|
[ ] Tạo file src/lib/api.ts (centralised fetch helper)
|
||||||
|
[ ] Refactor tất cả fetch('/api/v1/...) sang apiFetch()
|
||||||
|
[ ] Tạo .env.production với VITE_API_BASE_URL
|
||||||
|
[ ] Tạo .env.emulator với VITE_API_BASE_URL=http://10.0.2.2:3001
|
||||||
|
|
||||||
|
Phase 2 - Capacitor Setup
|
||||||
|
[ ] npm install @capacitor/core @capacitor/cli @capacitor/android
|
||||||
|
[ ] npx cap init với App ID và tên app
|
||||||
|
[ ] Tạo capacitor.config.ts
|
||||||
|
[ ] npx cap add android
|
||||||
|
|
||||||
|
Phase 3 - Build & Sync
|
||||||
|
[ ] npm run build -w frontend (kiểm tra không có lỗi TypeScript)
|
||||||
|
[ ] npx cap copy android && npx cap sync android
|
||||||
|
[ ] Cấu hình AndroidManifest.xml với permissions
|
||||||
|
|
||||||
|
Phase 4 - Native Plugins
|
||||||
|
[ ] Cài @capacitor/geolocation (thay navigator.geolocation)
|
||||||
|
[ ] Cài @capacitor/camera (upload ảnh từ điện thoại)
|
||||||
|
[ ] Cài @capacitor/splash-screen, @capacitor/status-bar
|
||||||
|
|
||||||
|
Phase 5 - Mobile UI Polish
|
||||||
|
[ ] Implement mobile layout fixes cho PublicPhotoModal.tsx
|
||||||
|
[ ] Kiểm tra safe-area-inset cho Android
|
||||||
|
|
||||||
|
Phase 5b - Test trên Android Emulator (AVD)
|
||||||
|
[ ] Tạo AVD Android 11 (API 30) trong Android Studio
|
||||||
|
[ ] Cấu hình capacitor.config.ts server.url = http://10.0.2.2:3002
|
||||||
|
[ ] Khởi động emulator → adb devices xác nhận kết nối
|
||||||
|
[ ] npx cap run android → kiểm tra app khởi động
|
||||||
|
[ ] Kiểm tra toàn bộ checklist: Login, Map, Upload, Comment, SafeArea
|
||||||
|
[ ] Sửa tất cả lỗi phát sinh trên emulator
|
||||||
|
|
||||||
|
Phase 6 - Test trên thiết bị Android 11 thật
|
||||||
|
[ ] Bật Developer Mode + USB Debugging trên điện thoại
|
||||||
|
[ ] adb devices xác nhận thiết bị kết nối
|
||||||
|
[ ] Cấu hình VITE_API_BASE_URL = IP LAN hoặc ngrok
|
||||||
|
[ ] Build debug APK → adb install
|
||||||
|
[ ] Kiểm tra lại toàn bộ checklist trên thiết bị thật
|
||||||
|
[ ] Kiểm tra performance, pin, cảm biến GPS thực tế
|
||||||
|
|
||||||
|
Phase 7 - Build Release
|
||||||
|
[ ] Build debug APK để test
|
||||||
|
[ ] Tạo keystore và build release AAB
|
||||||
|
[ ] Chuẩn bị lên Google Play (nếu cần)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
### Automated
|
||||||
|
- `npm run build -w frontend` — không có lỗi TypeScript/build
|
||||||
|
- `adb devices` — xác nhận emulator/thiết bị thật đang kết nối
|
||||||
|
|
||||||
|
### Stage 1: Test trên Android Emulator
|
||||||
|
1. App khởi động không crash, hiện Landing page đúng.
|
||||||
|
2. Đăng nhập / Đăng ký hoạt động (kết nối `10.0.2.2:3001`).
|
||||||
|
3. Bản đồ Leaflet zoom/pan bằng cảm ứng mô phỏng.
|
||||||
|
4. Upload ảnh từ gallery giả lập của emulator.
|
||||||
|
5. Bình luận real-time qua WebSocket hoạt động.
|
||||||
|
6. Layout không bị vỡ khi xoay màn hình (portrait/landscape).
|
||||||
|
7. Safe-area-inset không bị che bởi status bar.
|
||||||
|
|
||||||
|
### Stage 2: Test trên thiết bị Android 11 thật
|
||||||
|
1. Lặp lại toàn bộ Stage 1 trên thiết bị thật.
|
||||||
|
2. GPS thực tế hoạt động và hiện đúng vị trí trên bản đồ.
|
||||||
|
3. Camera native chụp và upload ảnh thành công.
|
||||||
|
4. Performance mượt mà (scroll, animation không giật).
|
||||||
|
5. WebSocket giữ kết nối ổn định trên mobile network (4G/WiFi).
|
||||||
|
6. Kiểm tra pin consumption không bất thường.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Các quyết định đã xác nhận
|
||||||
|
|
||||||
|
| Câu hỏi | Trả lời |
|
||||||
|
|---|---|
|
||||||
|
| Backend deploy ở đâu? | Docker trên Debian homelab, proxy qua Nginx |
|
||||||
|
| Tên app và App ID? | `YoTrip` — `com.yotrip.app` |
|
||||||
|
| Domain? | `yotrip.labz.io.vn` (Cloudflare DDNS) |
|
||||||
|
| Phát hành? | Đưa lên **Google Play Store** |
|
||||||
|
| Camera? | **Native camera** — chụp ảnh trực tiếp từ app |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **Lưu ý quan trọng về Homelab + Google Play:**
|
||||||
|
> - Homelab + Docker + Nginx là **đủ điều kiện kết nối** cho app Android.
|
||||||
|
> - Domain `yotrip.labz.io.vn` dùng **Cloudflare DDNS** — IP homelab thay đổi sẽ được cập nhật tự động.
|
||||||
|
> - **WebSocket (Socket.IO)** cần Nginx được cấu hình `proxy_set_header Upgrade` (xem Phase 1a).
|
||||||
|
> - Certbot cấp SSL cho `yotrip.labz.io.vn`: `certbot --nginx -d yotrip.labz.io.vn`
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../.env
|
||||||
@@ -33,6 +33,7 @@ COPY package*.json ./
|
|||||||
COPY --from=build /usr/src/app/node_modules ./node_modules
|
COPY --from=build /usr/src/app/node_modules ./node_modules
|
||||||
COPY --from=build /usr/src/app/dist ./dist
|
COPY --from=build /usr/src/app/dist ./dist
|
||||||
COPY --from=build /usr/src/app/prisma ./prisma
|
COPY --from=build /usr/src/app/prisma ./prisma
|
||||||
|
COPY --from=build /usr/src/app/public ./public
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["node", "dist/src/main.js"]
|
CMD ["node", "dist/src/main.js"]
|
||||||
|
|||||||
@@ -154,6 +154,17 @@ async function bootstrap() {
|
|||||||
app.useStaticAssets(UPLOAD_ROOT, {
|
app.useStaticAssets(UPLOAD_ROOT, {
|
||||||
prefix: '/uploads/',
|
prefix: '/uploads/',
|
||||||
});
|
});
|
||||||
|
const downloadsDir = path.join(process.cwd(), 'public/downloads');
|
||||||
|
if (!fs.existsSync(downloadsDir)) {
|
||||||
|
fs.mkdirSync(downloadsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
app.useStaticAssets(downloadsDir, {
|
||||||
|
prefix: '/downloads/',
|
||||||
|
setHeaders: (res) => {
|
||||||
|
res.set('Content-Type', 'application/vnd.android.package-archive');
|
||||||
|
res.set('Content-Disposition', 'attachment; filename="yotrip-latest.apk"');
|
||||||
|
}
|
||||||
|
});
|
||||||
const prisma = app.get(prisma_service_1.PrismaService);
|
const prisma = app.get(prisma_service_1.PrismaService);
|
||||||
startAutoCleanup(prisma);
|
startAutoCleanup(prisma);
|
||||||
await app.listen(3001);
|
await app.listen(3001);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import exifr from 'exifr';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const searchDir = '.';
|
||||||
|
|
||||||
|
async function walk(dir) {
|
||||||
|
let files = [];
|
||||||
|
const list = fs.readdirSync(dir);
|
||||||
|
for (const file of list) {
|
||||||
|
if (file === 'node_modules' || file === '.git' || file === '.vscode') continue;
|
||||||
|
const fullPath = path.join(dir, file);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
files = files.concat(await walk(fullPath));
|
||||||
|
} else {
|
||||||
|
if (['.jpg', '.jpeg', '.png'].includes(path.extname(file).toLowerCase())) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const images = await walk(searchDir);
|
||||||
|
console.log(`Found ${images.length} images to scan...`);
|
||||||
|
for (const img of images) {
|
||||||
|
try {
|
||||||
|
const gps = await exifr.gps(img);
|
||||||
|
if (gps) {
|
||||||
|
console.log(`FOUND IMAGE WITH GPS: ${img}`, gps);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Scan completed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import EXIF from 'exif-js';
|
||||||
|
import exifr from 'exifr';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const files = [
|
||||||
|
'./node_modules/exif-js/example/dsc_09827.jpg',
|
||||||
|
'./node_modules/exif-js/example/DSCN0614_small.jpg',
|
||||||
|
'./node_modules/exif-js/example/Bloated-Hero.jpg',
|
||||||
|
'./node_modules/exif-js/example/Bush-dog.jpg'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
console.log(`--- Testing file: ${f} ---`);
|
||||||
|
try {
|
||||||
|
const gpsExifr = await exifr.gps(f);
|
||||||
|
console.log(' exifr.gps:', gpsExifr);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' exifr error:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(f);
|
||||||
|
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
||||||
|
const parsed = await exifr.parse(arrayBuffer);
|
||||||
|
console.log(' exifr.parse output:', parsed ? {
|
||||||
|
latitude: parsed.latitude,
|
||||||
|
longitude: parsed.longitude,
|
||||||
|
DateTimeOriginal: parsed.DateTimeOriginal,
|
||||||
|
CreateDate: parsed.CreateDate,
|
||||||
|
ModifyDate: parsed.ModifyDate
|
||||||
|
} : 'null');
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' exifr.parse error:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test();
|
||||||
@@ -114,6 +114,21 @@ async function bootstrap() {
|
|||||||
prefix: '/uploads/',
|
prefix: '/uploads/',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tự động tạo thư mục downloads nếu chưa tồn tại
|
||||||
|
const downloadsDir = path.join(process.cwd(), 'public/downloads');
|
||||||
|
if (!fs.existsSync(downloadsDir)) {
|
||||||
|
fs.mkdirSync(downloadsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Khai báo thư mục lưu trữ APK tải về
|
||||||
|
app.useStaticAssets(downloadsDir, {
|
||||||
|
prefix: '/downloads/',
|
||||||
|
setHeaders: (res) => {
|
||||||
|
res.set('Content-Type', 'application/vnd.android.package-archive');
|
||||||
|
res.set('Content-Disposition', 'attachment; filename="yotrip-latest.apk"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const prisma = app.get(PrismaService);
|
const prisma = app.get(PrismaService);
|
||||||
startAutoCleanup(prisma);
|
startAutoCleanup(prisma);
|
||||||
|
|
||||||
@@ -2169,12 +2184,7 @@ class PhotoController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uploaderId = req.user.id;
|
const uploaderId = req.user.id;
|
||||||
const isAnonymous = req.user.isAnonymous;
|
// Cả người dùng đã đăng ký và khách ẩn danh đều được dùng endpoint này để chia sẻ ảnh công khai lên bản đồ
|
||||||
|
|
||||||
// Chỉ người dùng ẩn danh mới được dùng endpoint này
|
|
||||||
if (!isAnonymous) {
|
|
||||||
throw new ForbiddenException('Chỉ người dùng khách mới có thể sử dụng tính năng này.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
@@ -2309,7 +2319,7 @@ class PhotoController {
|
|||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
async updatePhoto(
|
async updatePhoto(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() body: { title?: string; description?: string; latitude?: number; longitude?: number },
|
@Body() body: { title?: string; description?: string; latitude?: number; longitude?: number; tags?: string[] },
|
||||||
@Req() req: any
|
@Req() req: any
|
||||||
) {
|
) {
|
||||||
const photo = await this.prisma.photo.findUnique({
|
const photo = await this.prisma.photo.findUnique({
|
||||||
@@ -2332,6 +2342,7 @@ class PhotoController {
|
|||||||
lng: body.longitude !== undefined ? body.longitude : currentMetadata.lng,
|
lng: body.longitude !== undefined ? body.longitude : currentMetadata.lng,
|
||||||
title: body.title !== undefined ? body.title : currentMetadata.title,
|
title: body.title !== undefined ? body.title : currentMetadata.title,
|
||||||
description: body.description !== undefined ? body.description : currentMetadata.description,
|
description: body.description !== undefined ? body.description : currentMetadata.description,
|
||||||
|
tags: body.tags !== undefined ? body.tags : currentMetadata.tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.prisma.photo.update({
|
return this.prisma.photo.update({
|
||||||
@@ -2463,7 +2474,7 @@ class UserController {
|
|||||||
return this.prisma.user.update({
|
return this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true }
|
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, avatar: true, phone: true, address: true }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 868 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 844 KiB |
|
Before Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 422 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"web":{"client_id":"639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com","project_id":"yotrip-web","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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"}}
|
||||||
@@ -31,7 +31,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/uploads:/usr/src/app/uploads
|
- /mnt/storage/yotrip/uploads:/usr/src/app/uploads
|
||||||
|
- ./frontend/android/app/build/outputs/apk/debug/app-debug.apk:/usr/src/app/public/downloads/yotrip-latest.apk
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
|
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
|
||||||
REDIS_URL: "redis://redis:6379"
|
REDIS_URL: "redis://redis:6379"
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Kế hoạch triển khai: Cấu hình Docker để Chạy Server & Phát triển (Hot-reload)
|
||||||
|
|
||||||
|
Kế hoạch này phác thảo cách cấu hình Docker và Docker Compose cho dự án YoTrip. Cấu hình này sẽ đáp ứng đồng thời hai nhu cầu:
|
||||||
|
1. **Môi trường Phát triển (Development)**: Đồng bộ mã nguồn trực tiếp (bind mounts) từ máy local vào container, hỗ trợ hot-reload cho cả backend (NestJS watch) và frontend (Vite HMR).
|
||||||
|
2. **Môi trường Triển khai (Production)**: Đóng gói tối ưu thành các image độc lập, sử dụng Nginx để phục vụ frontend tĩnh và tối ưu hóa hiệu năng NestJS backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Review Required
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> - **Biến môi trường trong Docker**: Khi chạy trong Docker Compose, địa chỉ kết nối cơ sở dữ liệu (`DATABASE_URL`) và Redis (`REDIS_URL`) phải trỏ đến tên các service của container (ví dụ: `postgres` thay vì `localhost`). Chúng tôi sẽ cấu hình Docker Compose ghi đè (override) các biến này một cách tự động để tránh làm hỏng cấu hình chạy trực tiếp bằng `npm run start:dev` trên máy local của bạn.
|
||||||
|
> - **Cổng mạng (Ports)**:
|
||||||
|
> - Backend: Cổng `3001` được mở ra ngoài.
|
||||||
|
> - Frontend: Cổng `5173` (cho Dev) và cổng `3002` (cho Production thông qua Nginx).
|
||||||
|
> - Database: Cổng `5432` (mở để truy cập quản trị nếu cần).
|
||||||
|
> - Redis: Cổng `6379`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Changes
|
||||||
|
|
||||||
|
### 1. Dockerfile cho Backend
|
||||||
|
|
||||||
|
#### [NEW] [Dockerfile](file:///home/locpham/travelplanning/backend/Dockerfile)
|
||||||
|
- Thiết lập môi trường chạy Node.js (phiên bản `20-alpine`).
|
||||||
|
- Cài đặt các gói phụ thuộc hệ thống cần thiết (như openssl cho Prisma).
|
||||||
|
- Cấu hình chạy chế độ phát triển (sử dụng volume mounts để hot-reload) và chế độ production (build code JS).
|
||||||
|
- Chạy Prisma client generation lúc build.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Dockerfile cho Frontend
|
||||||
|
|
||||||
|
#### [NEW] [Dockerfile](file:///home/locpham/travelplanning/frontend/Dockerfile)
|
||||||
|
- Sử dụng chiến lược Multi-stage build để tối ưu hóa dung lượng:
|
||||||
|
- **Stage 1 (Build)**: Cài đặt dependencies và build mã nguồn React/Vite thành thư mục tĩnh `dist`.
|
||||||
|
- **Stage 2 (Nginx)**: Copy thư mục `dist` vào container chạy Nginx để phục vụ các file tĩnh ở cổng `80` (phục vụ môi trường production).
|
||||||
|
- Hỗ trợ chạy Node.js trực tiếp cho môi trường phát triển để dùng Vite dev server và HMR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Docker Compose cho Phát triển & Sửa Code (Hot-reload)
|
||||||
|
|
||||||
|
#### [NEW] [docker-compose.yml](file:///home/locpham/travelplanning/docker-compose.yml)
|
||||||
|
- Khởi tạo 4 services chính:
|
||||||
|
1. `postgres`: Cơ sở dữ liệu PostgreSQL 15, lưu trữ dữ liệu bền vững qua volume `pg_data`.
|
||||||
|
2. `redis`: Caching và WebSockets.
|
||||||
|
3. `backend`: Mount thư mục `backend/` vào container, chạy lệnh `npm run start:dev` để tự động reload khi sửa code trên máy host.
|
||||||
|
4. `frontend`: Mount thư mục `frontend/` vào container, chạy lệnh `npm run dev -- --host` để phục vụ Vite dev server hỗ trợ HMR (Hot Module Replacement).
|
||||||
|
- Đồng bộ hóa các volume ẩn `node_modules` để tránh xung đột hệ điều hành giữa máy host và container.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Docker Compose cho Triển khai lên Server (Production)
|
||||||
|
|
||||||
|
#### [NEW] [docker-compose.prod.yml](file:///home/locpham/travelplanning/docker-compose.prod.yml)
|
||||||
|
- Cấu hình tối ưu để triển khai lên server cloud:
|
||||||
|
- Builds production image cho `backend` và chạy trực tiếp file JS đã build (`dist/src/main.js`).
|
||||||
|
- Builds production image cho `frontend` sử dụng Nginx để phục vụ client, tối ưu hóa tốc độ tải trang và bảo mật.
|
||||||
|
- Tự động restart dịch vụ nếu gặp sự cố (`restart: always`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
### Automated Tests
|
||||||
|
- Kiểm tra tính hợp lệ của cấu hình docker-compose:
|
||||||
|
```bash
|
||||||
|
docker compose config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
1. **Kiểm tra Môi trường Phát triển (Sửa code trực tiếp)**:
|
||||||
|
- Chạy lệnh khởi động môi trường dev:
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
- Truy cập giao diện tại `http://localhost:5173`.
|
||||||
|
- Sửa đổi một dòng văn bản trong frontend (ví dụ: nhãn nút ở `LandingPage.tsx`) hoặc backend và kiểm tra xem container có tự động tải lại (hot-reload) tức thì hay không.
|
||||||
|
2. **Kiểm tra Môi trường Production (Triển khai server)**:
|
||||||
|
- Chạy lệnh khởi động môi trường prod:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up --build -d
|
||||||
|
```
|
||||||
|
- Xác nhận mọi service khởi chạy ngầm thành công.
|
||||||
|
- Truy cập ứng dụng qua cổng `80` (http://localhost) và xác nhận hoạt động bình thường.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_BACKEND_URL=https://yotrip.labz.io.vn
|
||||||
|
VITE_GOOGLE_CLIENT_ID=639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_BACKEND_URL=https://yotrip.labz.io.vn
|
||||||
|
VITE_GOOGLE_CLIENT_ID=639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com
|
||||||
@@ -15,7 +15,7 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
|||||||
FROM base AS build
|
FROM base AS build
|
||||||
ARG VITE_GOOGLE_CLIENT_ID
|
ARG VITE_GOOGLE_CLIENT_ID
|
||||||
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
||||||
RUN npm install
|
RUN npm install --legacy-peer-deps
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "com.yotrip.app"
|
||||||
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.yotrip.app"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
implementation project(':capacitor-camera')
|
||||||
|
implementation project(':capacitor-geolocation')
|
||||||
|
implementation project(':capacitor-local-notifications')
|
||||||
|
implementation project(':codetrix-studio-capacitor-google-auth')
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-feature android:name="android.hardware.location.gps" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.media.action.IMAGE_CAPTURE" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.yotrip.app;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {}
|
||||||
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 202 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 212 B |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 147 B |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 175 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 285 B |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 431 B |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 558 B |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">YoTrip</string>
|
||||||
|
<string name="title_activity_main">YoTrip</string>
|
||||||
|
<string name="package_name">com.yotrip.app</string>
|
||||||
|
<string name="custom_url_scheme">com.yotrip.app</string>
|
||||||
|
<string name="server_client_id">639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
public class ExampleUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addition_isCorrect() throws Exception {
|
||||||
|
assertEquals(4, 2 + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.7.2'
|
||||||
|
classpath 'com.google.gms:google-services:4.4.2'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "variables.gradle"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
configurations.all {
|
||||||
|
resolutionStrategy {
|
||||||
|
force "androidx.core:core-ktx:${rootProject.ext.androidxCoreVersion}"
|
||||||
|
force "androidx.core:core:${rootProject.ext.androidxCoreVersion}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
include ':capacitor-android'
|
||||||
|
project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':capacitor-camera'
|
||||||
|
project(':capacitor-camera').projectDir = new File('../../node_modules/@capacitor/camera/android')
|
||||||
|
|
||||||
|
include ':capacitor-geolocation'
|
||||||
|
project(':capacitor-geolocation').projectDir = new File('../../node_modules/@capacitor/geolocation/android')
|
||||||
|
|
||||||
|
include ':capacitor-local-notifications'
|
||||||
|
project(':capacitor-local-notifications').projectDir = new File('../../node_modules/@capacitor/local-notifications/android')
|
||||||
|
|
||||||
|
include ':codetrix-studio-capacitor-google-auth'
|
||||||
|
project(':codetrix-studio-capacitor-google-auth').projectDir = new File('../../node_modules/@codetrix-studio/capacitor-google-auth/android')
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx1536m
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||