mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7fa676199 | |||
| 1547258c0c | |||
| a1ad512064 | |||
| 25324108cb | |||
| 9f5d2f6488 | |||
| 40253d2fdf | |||
| 910631c1ff | |||
| 5b41cab898 | |||
| bf969ee80d | |||
| 2d413c99cf | |||
| 58c7bd831a | |||
| 8d1e7dded0 | |||
| 127a92c8f5 | |||
| 1ed00b67ad | |||
| 4d072b4cb8 | |||
| 028e3e0a84 | |||
| 39b5af790e | |||
| 1eb2cb8eb2 | |||
| bcd2c8c959 | |||
| 5a9c14fc8e | |||
| 5500405f2f | |||
| 0a794583d7 | |||
| 4188f67ab7 | |||
| 8077ffab34 | |||
| 3e9626fce9 | |||
| 3398da633b | |||
| 31f99f0e4e | |||
| 56655d53b4 | |||
| f91721c73e | |||
| 0a58e3270b | |||
| e224befde7 | |||
| f46cc8a98e | |||
| 1378c95078 | |||
| bb477645a3 | |||
| e65acb3de7 | |||
| 3c040fab11 | |||
| 49b3af8b0d | |||
| 093e069ccc | |||
| 070ef01328 | |||
| a876fb2634 | |||
| 247433fb2a | |||
| 6ef3c7ae6b | |||
| abe1c549bd | |||
| 10bea35a91 | |||
| a77ee4b4d5 | |||
| 9bec97fc19 | |||
| 20791a29a7 | |||
| 6d2dd37414 | |||
| 0d2657ee37 | |||
| 0a8fb1f53b | |||
| 2fe6657edd | |||
| 5f964b9524 | |||
| 8bda980028 | |||
| 831a4fd478 | |||
| 4ff4435f8b | |||
| 69b699c9bf | |||
| 98032fda0c | |||
| e04ceeb1ee | |||
| e5000ff7dd | |||
| 126f2df21b | |||
| 324d930ca3 | |||
| e050814c42 | |||
| b25eb18ea4 | |||
| 8410d7c4a5 |
@@ -0,0 +1,53 @@
|
||||
name: Lint & Prettier
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run lint & format check
|
||||
id: checks
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd shared
|
||||
npm run lint
|
||||
npm run format:check
|
||||
|
||||
- name: Comment on PR if checks failed
|
||||
if: steps.checks.outcome == 'failure'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: [
|
||||
'## ❌ Lint & Prettier check failed',
|
||||
'',
|
||||
'Please fix the issues locally by running the following commands inside the `shared` package:',
|
||||
'',
|
||||
'```bash',
|
||||
'cd shared',
|
||||
'npm run lint',
|
||||
'npm run format',
|
||||
'```',
|
||||
'',
|
||||
'Then commit and push the changes.',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
- name: Fail the job if checks failed
|
||||
if: steps.checks.outcome == 'failure'
|
||||
run: exit 1
|
||||
@@ -34,4 +34,5 @@ jobs:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
only-severities: critical,high
|
||||
only-fixed: true
|
||||
exit-code: true
|
||||
|
||||
@@ -13,6 +13,20 @@ on:
|
||||
- '.github/workflows/test.yml'
|
||||
|
||||
jobs:
|
||||
i18n-parity:
|
||||
name: i18n Key Parity
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Check i18n key parity
|
||||
run: node shared/scripts/i18n-parity.mjs --strict
|
||||
|
||||
shared-contracts:
|
||||
name: Shared Contracts (Zod)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -49,7 +63,16 @@ jobs:
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared && npm ci --workspace server
|
||||
run: npm ci
|
||||
|
||||
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
||||
# The lockfile was generated on Windows and omits @swc/core's Linux
|
||||
# optional native binary, so npm ci/install skips it on the runner.
|
||||
# Install the matching version explicitly so the server's SWC transform
|
||||
# (server/vitest.config.ts) can load.
|
||||
run: |
|
||||
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
|
||||
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
@@ -57,12 +80,12 @@ jobs:
|
||||
- name: Build server (tsc -> dist)
|
||||
run: cd server && npm run build
|
||||
|
||||
- name: Typecheck (informational)
|
||||
# Pre-existing type errors in the NestJS rewrite; surfaces them without
|
||||
# blocking CI. Ratchet to blocking once the legacy code is cleaned up.
|
||||
continue-on-error: true
|
||||
- name: Typecheck
|
||||
run: cd server && npm run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: cd server && npm run lint:check
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && npm run test:coverage
|
||||
|
||||
@@ -93,6 +116,15 @@ jobs:
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Typecheck
|
||||
run: cd client && npm run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: cd client && npm run lint:check
|
||||
|
||||
- name: Page pattern check
|
||||
run: cd client && npm run lint:pages
|
||||
|
||||
- name: Run tests
|
||||
run: cd client && npm run test:coverage
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 455 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
@@ -1,524 +0,0 @@
|
||||
<img width="5292" height="1404" alt="Release 2 9 0 (2)" src="https://github.com/user-attachments/assets/6ff67226-3535-444e-991f-0bc0352e22e7" />
|
||||
|
||||
# TREK 3.0.0
|
||||
|
||||
<video src="https://github.com/mauriceboe/trek-media/raw/main/.github/assets/TREK1.mp4" controls width="100%"></video>
|
||||
|
||||
> **The biggest TREK release to date.** A new Journey addon turns your trips into rich travel journals. Mapbox GL joins Leaflet as a first-class renderer. MCP gets a full OAuth 2.1 authorization server. Offline-first PWA, self-service password reset, and a dashboard redesigned from the ground up. Fifteen languages, top to bottom.
|
||||
|
||||
---
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
### Photos moved from Trip Planner to Journey
|
||||
|
||||
In previous versions, Immich and Synology Photos were integrated directly into the Trip Planner via a "Photos" tab. **This tab has been removed.** Photos are now part of the new **Journey addon**, which is purpose-built for documenting your travels with stories, photos, and maps.
|
||||
|
||||
**What this means for you:**
|
||||
- **No photos are lost.** The previous integration was read-only — TREK never uploaded to or deleted from your Immich/Synology library. Your photos remain untouched in your photo provider.
|
||||
- **Previously linked trip photos are no longer displayed in the Trip Planner.** To view and organize your travel photos, enable the Journey addon (Settings > Addons) and create a Journey linked to your trip.
|
||||
- **Journey brings a much richer photo experience:** upload photos directly to TREK, browse and import from Immich/Synology with duplicate detection, reorder photos, view EXIF metadata, and export everything as a PDF photo book.
|
||||
|
||||
### New Immich API Key Permissions Required
|
||||
|
||||
Journey introduces **photo upload sync** — when you upload a photo to a Journey entry, TREK can optionally sync it to your Immich library. This requires an additional Immich API permission that was not needed before.
|
||||
|
||||
**Previous versions required:**
|
||||
| Permission | Used for |
|
||||
|---|---|
|
||||
| `user.read` | Connection test |
|
||||
| `asset.read` | Browse photos by date, search |
|
||||
| `asset.view` | Stream thumbnails |
|
||||
| `asset.download` | Stream originals |
|
||||
| `album.read` | List and browse albums |
|
||||
| `timeline.read` | Browse timeline buckets |
|
||||
|
||||
**New in 3.0.0 — additionally required:**
|
||||
| Permission | Used for |
|
||||
|---|---|
|
||||
| `asset.upload` | Sync uploaded Journey photos to Immich |
|
||||
|
||||
> **How to update your Immich API key:** Go to your Immich instance > User Settings > API Keys. Edit your existing TREK key (or create a new one) and ensure `asset.upload` is enabled in addition to the existing permissions. If you don't plan to use Journey's upload sync, the old key will continue to work — the upload simply won't sync to Immich.
|
||||
|
||||
**No changes needed for Synology Photos** — Synology uses session-based authentication which inherits the user's full permissions.
|
||||
|
||||
### OIDC_ONLY deprecated
|
||||
|
||||
The `OIDC_ONLY` environment variable is deprecated. Replace with `DISABLE_LOCAL_LOGIN=true` + `DISABLE_LOCAL_REGISTRATION=true` for equivalent behavior. The old variable still works but will be removed in a future release.
|
||||
|
||||
---
|
||||
|
||||
<img width="5292" height="1404" alt="Release 2 9 0 (3)" src="https://github.com/user-attachments/assets/76976c02-dd81-49ab-83f5-e2221d6b018b" />
|
||||
|
||||
## Journey Addon — Travel Journal
|
||||
|
||||
The headline feature of 3.0.0. Journey is a new global addon that transforms your trips into magazine-style travel stories.
|
||||
|
||||
### Core
|
||||
- **5-table schema** — journeys, entries, photos, trips, contributors with full relational integrity
|
||||
- **Trip-to-Journey sync engine** — link one or more trips to a journey; skeleton entries and photos are synced automatically
|
||||
- **Timeline, Gallery, and Map views** — browse entries chronologically, as a photo grid, or on an interactive map with SVG pin markers
|
||||
- **Entry editor** — markdown toolbar, custom date picker, location search (Nominatim/Google Maps), mood (Amazing/Good/Neutral/Rough), weather (Sunny to Snowy), and Pros & Cons sections
|
||||
- **Entry reorder** — move-up / move-down arrows on each entry (desktop), skipped on skeleton suggestions
|
||||
- **Hide skeletons toggle** — per-contributor setting to focus on the written entries only
|
||||
|
||||
### Photos
|
||||
- **Immich & Synology browser** — browse by trip dates, custom range, or album with duplicate detection
|
||||
- **Photo upload** — direct upload with drag-and-drop, reorder (Make 1st), and delete
|
||||
- **EXIF metadata** — displayed in lightbox for Immich photos
|
||||
- **Thumbnail to original fallback** — seamless resolution upgrade everywhere
|
||||
- **HEIC rendering fix** — serve fullsize thumbnail for original to fix HEIC rendering on non-Safari browsers
|
||||
- **Contributor photo access** — invited contributors can view all journey photos even without their own Immich/Synology connection (owner credentials are used for the proxy)
|
||||
- **Safari gallery picker fix** — repaired grid layout collapse on Safari (#717)
|
||||
|
||||
### Sharing & Export
|
||||
- **Public share links** — token-based access with language picker, no login required
|
||||
- **Public photo proxy** — validates share token instead of auth for photo streaming
|
||||
- **Thumbnail size in public gallery** — grid loads thumbnails instead of originals, lightbox keeps originals (cuts bandwidth on shared links significantly)
|
||||
- **PDF photo book export** — Polarsteps-inspired layout with cover, day chapters, photo grids, and stories
|
||||
|
||||
### Collaboration
|
||||
- **Contributors** — invite users as editors or viewers
|
||||
- **Trip linking/unlinking** — manage synced trips from Journey Settings and Desktop Sidebar
|
||||
- **Cover image** — upload or pick from journey photos
|
||||
|
||||
### Frontend
|
||||
- **JourneyPage** — frontpage with hero card, active journey stats, trip suggestions ("Trip just ended — turn it into a Journey")
|
||||
- **JourneyDetailPage** — full timeline/gallery/map with inline entry editing
|
||||
- **JourneyPublicPage** — public share view with language picker and read-only timeline
|
||||
|
||||
---
|
||||
|
||||
## Mapbox GL as a First-Class Renderer
|
||||
|
||||
Leaflet gets a sibling. Users can now switch the trip planner map to **Mapbox GL JS** for a proper 3D globe, terrain, and 3D buildings.
|
||||
|
||||
- **Settings toggle** — choose between Leaflet and Mapbox GL in Settings > Map
|
||||
- **Globe projection** — smooth rotating globe when zoomed out, mercator when zoomed in
|
||||
- **3D terrain and buildings** — enabled on Standard and Satellite styles, with custom 3D buildings in dark/light mode
|
||||
- **Trip route, GPX geometries, place markers** — full feature parity with the Leaflet renderer
|
||||
- **Transport reservations overlay** — great-circle arcs for flights/cruises, straight lines for trains/cars, clickable endpoint badges with IATA codes, rotating mid-arc stats label for flights. Honours the per-booking "show route" toggle in DayPlanSidebar
|
||||
- **Auto-fit on load** — planner map zooms to the trip's places on initial render
|
||||
- **Booking route label toggle** — separate setting to hide IATA labels on endpoint markers
|
||||
- **Infrastructure** — WebAssembly allowed in CSP for Mapbox GL's 3D engine, PWA precache limit raised so the mapbox-gl bundle builds, Mapbox endpoints allowed in `connect-src` / `img-src`
|
||||
|
||||
---
|
||||
|
||||
## MCP: OAuth 2.1 & Granular Scopes
|
||||
|
||||
MCP authentication has been completely rebuilt around the OAuth 2.1 specification.
|
||||
|
||||
- **OAuth 2.1 authorization server** — full PKCE flow with authorization codes, access tokens, refresh tokens, and token rotation with replay detection
|
||||
- **Granular scopes** — 24 scopes across 11 groups (trips, places, atlas, packing, todos, budget, reservations, collab, notifications, vacay, geo/weather) with per-scope read/write/delete control
|
||||
- **Dynamic Client Registration (DCR)** — RFC 7591 endpoint at `POST /oauth/register`, with strict redirect_uri validation (HTTPS / loopback / reverse-DNS private-use schemes only; rejects `javascript:` / `data:` / `file:` / etc.)
|
||||
- **RFC 9728 Protected Resource Metadata** — `/.well-known/oauth-protected-resource` exposes the MCP endpoint's auth requirements for client auto-discovery
|
||||
- **RFC 8707 audience binding** — tokens are audience-bound to `<app_url>/mcp` by default and validated on every MCP request
|
||||
- **Consent screen** — user-facing scope selection with grouped permission display
|
||||
- **Admin panel** — OAuth sessions management in MCP Access panel with collapsible scope lists
|
||||
- **Per-client rate limiting** — configurable rate limits per OAuth client
|
||||
- **Addon gating** — MCP tools are only registered when their corresponding addon is enabled
|
||||
- **Compound tools** — single-call multi-step workflows (e.g. create day with places in one tool call, fetch full trip context) to reduce MCP round-trips
|
||||
- **Surface alignment** — MCP tool schemas and responses kept in sync with the current app state (fewer drifted fields, correct enum sets)
|
||||
- **Static token deprecation** — existing MCP tokens still work but surface deprecation notices; migration path to OAuth is documented
|
||||
- **Collab sub-feature gating** — MCP tools for chat/notes/polls respect the admin-level collab sub-feature toggles
|
||||
|
||||
---
|
||||
|
||||
## Self-Service Password Reset
|
||||
|
||||
Users can now reset their own password without admin intervention.
|
||||
|
||||
- **Email-based flow** — `/forgot-password` issues a single-use reset token delivered via SMTP (or logged to the server console if SMTP is not configured)
|
||||
- **MFA-aware** — if the user has MFA enabled, the reset endpoint additionally verifies a TOTP code or backup code before rotating the password
|
||||
- **Session invalidation** — resetting the password bumps `users.password_version`, which kicks every existing JWT, MCP static token, and OAuth bearer token for that user out in one shot
|
||||
- **Server-side URL building** — the reset link is built from `APP_URL` / `ALLOWED_ORIGINS`, not from request headers, so a spoofed `Host` / `Origin` cannot redirect the link to an attacker-controlled domain
|
||||
- **Rate limiting + audit** — per-IP rate limit on `/forgot-password`, all requests audited (including "no such user" so abuse is visible)
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Redesign
|
||||
|
||||
The dashboard has been rebuilt with a mobile-first design language.
|
||||
|
||||
### Mobile
|
||||
- **Greeting header** — "Good morning, {username}" with notification bell and avatar
|
||||
- **Spotlight hero card** — the next upcoming or ongoing trip as a full-width hero with cover image, progress bar (for live trips), stats grid, and frosted-glass action buttons
|
||||
- **Quick Actions** — New Trip, Currency Converter, Timezone as icon cards
|
||||
- **Trip cards** — cover image with title overlay, status badge (In X days / Starts today / Ongoing / Completed), bottom stats (starts, duration, places, buddies)
|
||||
|
||||
### Desktop
|
||||
- **Unified header toolbar** — the dashboard, planner, vacay, and journey now share the same toolbar style
|
||||
- **Unified card design** — desktop grid cards now match the mobile card style (cover + title overlay + stats)
|
||||
- **Hero card** — SpotlightCard with progress bar for ongoing trips, countdown for upcoming, stats grid
|
||||
- **Hover actions** — edit/copy/archive/delete buttons appear on hover as frosted-glass icons
|
||||
- **Status badges** — CircleCheck icon for completed trips, Clock for upcoming, pulsing dot for ongoing
|
||||
|
||||
### Both
|
||||
- **BottomNav profile sheet** — slide-up sheet with user info, settings, admin, and logout
|
||||
- **Dark mode** — full dark mode support across all new components
|
||||
- **Shared PageSidebar** — Settings and Admin pages share a single sidebar component for layout consistency
|
||||
|
||||
---
|
||||
|
||||
## PWA Offline Mode
|
||||
|
||||
TREK now works offline as a Progressive Web App with full data synchronization.
|
||||
|
||||
- **IndexedDB (Dexie) storage** — trips, places, assignments, categories, tags, accommodations, reservations, budget items, packing items, files, and trip members cached locally
|
||||
- **Offline mutation queue** — changes made offline are queued with monotonic timestamps and replayed on reconnect (FIFO)
|
||||
- **Offline dashboard** — trip list loaded from Dexie when network is unavailable
|
||||
- **Offline trip planner** — full planner functionality with cached data
|
||||
- **Repo layer** — all data access routed through repository layer that falls back to offline storage
|
||||
- **Offline banner** — visible indicator with safe-area-inset support for iOS PWA
|
||||
- **Idempotency keys** — prevents duplicate mutations on replay, scoped by `(key, user_id, method, path)` so the same key on different endpoints can't leak cached bodies
|
||||
- **Offline document downloads** — document downloads work from the PWA cache when the network is unavailable
|
||||
|
||||
---
|
||||
|
||||
## Transport Reservations: Multi-Day + Map Visualization
|
||||
|
||||
- **Multi-day transport reservations** — flights, trains, cruises, car rentals can span multiple days with a dedicated modal and automatic route segmentation across the affected days (#384, #587)
|
||||
- **Map visualization** — transport endpoints render on both Leaflet and Mapbox GL maps as clickable badges with IATA codes, great-circle arcs for flights/cruises, straight lines for trains/cars, and a rotating mid-arc stats label (IATA → IATA · distance · duration) on flights
|
||||
- **Per-booking route toggle** — each booking in DayPlanSidebar has a "Show booking routes" button; connections only render when toggled on
|
||||
- **Check-in time ranges** — hotel bookings now support a check-in window (e.g. "15:00 -- 22:00") with a new `check_in_end` field (#366)
|
||||
- **Cascaded delete** — deleting a reservation now cleans up related budget items, file links, and trip_items
|
||||
|
||||
---
|
||||
|
||||
## Reservations Redesign
|
||||
|
||||
The reservations panel has been completely redesigned with a modern, unified layout.
|
||||
|
||||
- **Unified toolbar** — title, type filter pills with count badges, and add button in one row with muted background
|
||||
- **Type filters** — multi-select filter buttons (Flight, Hotel, Restaurant, etc.) with per-type count badges, persisted in sessionStorage
|
||||
- **Responsive grid** — auto-fill layout with max 3 columns that fills full width
|
||||
- **Card redesign** — status + type badge in header, labeled fields in rounded boxes, hover shadow
|
||||
- **Mobile responsive** — filters hidden on mobile, booking code on separate row, weekday hidden in dates, reduced padding
|
||||
|
||||
---
|
||||
|
||||
## Apple Wallet pkpass Support
|
||||
|
||||
- **.pkpass MIME type** — server correctly serves `application/vnd.apple.pkpass` with the right Content-Type
|
||||
- **Upload + download** — .pkpass files can be attached to bookings or places and opened directly in Apple Wallet on iOS
|
||||
|
||||
---
|
||||
|
||||
## Todo Due-Date Reminders
|
||||
|
||||
- **Scheduler** — a new background scheduler scans todos with upcoming due dates and sends one reminder per item (default lead: 3 days)
|
||||
- **No spam** — `todo_items.reminded_at` prevents re-sending a reminder for the same item on subsequent scheduler runs
|
||||
- **Notification channel aware** — reminders respect the user's notification channel preferences (email, webhook, ntfy)
|
||||
|
||||
---
|
||||
|
||||
## Collab Sub-Feature Toggles
|
||||
|
||||
Individual collab sections can now be toggled on/off from the admin addons page (#604).
|
||||
|
||||
- **Admin UI** — sub-toggles for Chat, Notes, Polls, and What's Next under the Collab addon, with icons matching the collab panel tabs
|
||||
- **Dynamic desktop layout** — Chat always stays at fixed 380px width; remaining active panels share space equally
|
||||
- **Mobile** — disabled tabs are hidden from the tab bar
|
||||
- **API** — GET/PUT /admin/collab-features endpoints stored in app_settings
|
||||
|
||||
---
|
||||
|
||||
## Place Import: KMZ/KML + Naver Maps + Selective GPX
|
||||
|
||||
Three ways to import places into your trips.
|
||||
|
||||
### KMZ/KML Import
|
||||
- **Unified file import modal** — drag-and-drop or file picker for KML, KMZ, and GPX files
|
||||
- **KMZ unpacking** — extracts KML from ZIP archive with 50MB decompressed size limit
|
||||
- **Folder-to-category mapping** — KML folders are automatically matched to TREK categories
|
||||
- **Place deduplication** — skips places that already exist in the trip (by name + coordinates)
|
||||
|
||||
### Naver Maps List Import
|
||||
- **Always enabled** — no longer requires addon toggle, available alongside Google Maps list import
|
||||
- **Shortlink resolution** — resolves naver.me shortlinks to full list URLs
|
||||
- **Pagination support** — handles large Naver Maps lists with automatic pagination
|
||||
|
||||
### Selective GPX/KML Element Import
|
||||
- **Pick what to import** — import modal now lets you choose individual waypoints / tracks / folders instead of an all-or-nothing dump
|
||||
- **Performance** — larger files (thousands of points) parse and render without freezing the UI
|
||||
|
||||
---
|
||||
|
||||
## Search Autocomplete
|
||||
|
||||
- **Real-time suggestions** — autocomplete suggestions appear as you type in the place search field
|
||||
- **Google Places API** — primary autocomplete provider with location bias
|
||||
- **Nominatim fallback** — free fallback when Google API key is not configured
|
||||
- **Bounding box bias** — search results biased to the current map viewport
|
||||
|
||||
---
|
||||
|
||||
## ntfy Notification Channel
|
||||
|
||||
- **ntfy as first-class channel** — push notifications via any ntfy server (self-hosted or ntfy.sh)
|
||||
- **Admin configuration** — server URL and topic configuration in admin panel with clear token button
|
||||
- **Per-user opt-in** — users can enable/disable ntfy in their notification preferences
|
||||
- **Full i18n** — ntfy strings translated in all 15 languages
|
||||
|
||||
---
|
||||
|
||||
## Login & Language
|
||||
|
||||
- **Language dropdown on login page** — users can select their preferred language before logging in
|
||||
- **Browser auto-detection** — language is automatically detected from browser settings on first visit
|
||||
- **DEFAULT_LANGUAGE env var** — configurable default language for the instance, documented across all deployment configs (Docker, Helm, Synology)
|
||||
|
||||
---
|
||||
|
||||
## Granular Auth Toggles
|
||||
|
||||
- **OIDC_ONLY replaced** — split into `DISABLE_LOCAL_LOGIN`, `DISABLE_LOCAL_REGISTRATION`, and `DISABLE_PASSWORD_CHANGE` for fine-grained control over authentication methods
|
||||
- Allows mixed setups (e.g., OIDC + local admin account, or OIDC-only with no local registration)
|
||||
|
||||
---
|
||||
|
||||
## Synology Photos: OTP, SSL Skip & Session Management
|
||||
|
||||
- **OTP support** — one-time password field for 2FA-enabled Synology NAS
|
||||
- **Skip SSL verification** — toggle for self-signed certificates
|
||||
- **Device ID persistence** — prevents repeated 2FA prompts
|
||||
- **Session-cleared notification** — routed through unified notification system
|
||||
- **Provider URL hint** — contextual help text for Synology URL format
|
||||
- **Thumbnail size bump** — default thumbnail size raised from `sm` (240 px) to `m` (320 px) so grids no longer look pixelated on retina
|
||||
- **Passphrase support** — shared-album links with passphrases work from the browse UI (#689)
|
||||
|
||||
---
|
||||
|
||||
## Atlas Improvements
|
||||
|
||||
- **Scoped region matching** — region name matching is now scoped by country to prevent cross-country false matches
|
||||
- **Expanded country lookup tables** — more countries and regions recognized correctly, including A3 fallback for invalid ISO_A2 codes
|
||||
- **Nominatim rate limiting** — shared throttle prevents 429 errors, background region fill, fetch timeout
|
||||
- **Stadia Maps fix** — resolved 401 errors on journey and atlas maps
|
||||
|
||||
---
|
||||
|
||||
## i18n: Full 15-Language Coverage
|
||||
|
||||
- **Indonesian added** — complete translation with full parity to English, bringing the total to 15 languages (EN, DE, FR, ES, IT, NL, PL, RU, ZH, ZH-TW, BR, CS, HU, AR, ID)
|
||||
- **Comprehensive audit** — every key translated natively, no English fallbacks
|
||||
- **OAuth scope labels** — all 24 scopes have localized names and descriptions
|
||||
- **Journey addon** — complete coverage for all journal, editor, sharing, and PDF export strings
|
||||
- **Mapbox GL settings** — localized labels for renderer toggle, style picker, 3D / quality switches
|
||||
- **Ellipsis standardization** — all ellipsis characters normalized to three dots (...)
|
||||
|
||||
---
|
||||
|
||||
## Vacay Improvements
|
||||
|
||||
- **Trip indicator dots** — small blue dots on calendar days where trips are scheduled
|
||||
- **Configurable week start** — choose Monday or Sunday as first day of the week (#224)
|
||||
- **Holiday overlap** — vacations can now be placed on public holidays
|
||||
- **Today marker** — visual indicator for the current day in the calendar
|
||||
- **Unified toolbar** — same header style as planner/dashboard/journey
|
||||
- **Bottom padding fix** — toolbar no longer overlaps the last row (#533)
|
||||
|
||||
---
|
||||
|
||||
## iCal Export Improvements
|
||||
|
||||
- **Day activities and notes** — iCal export now includes daily activities and notes, not just the trip dates (#375)
|
||||
|
||||
---
|
||||
|
||||
## Budget Improvements
|
||||
|
||||
- **Drag-and-drop reorder** — budget categories and individual items can be reordered via drag-and-drop (#479)
|
||||
- **Category legend redesign** — prevents overflow on small screens (#564)
|
||||
- **Comma decimal support** — pasting numbers with comma separators works correctly
|
||||
- **Table alignment fix** — budget data rows and the "New Entry" row now share column widths (#759)
|
||||
|
||||
---
|
||||
|
||||
## Packing List Improvements
|
||||
|
||||
- **Bulk import + template apply without full reload** — new items appear in place instead of triggering the trip loading screen (#760)
|
||||
- **Reservation link cleanup** — packing items linked to deleted reservations stay in the list without the dangling reference
|
||||
- **Bag tracking** — keep track of which items live in which bag, with optional weight tracking and per-bag totals
|
||||
|
||||
---
|
||||
|
||||
## Planner & UX Improvements
|
||||
|
||||
- **Emil-style polish pass** — consistent transitions/animations across cards, hover states, and drawer sheets; shared components for toolbars and section headers
|
||||
- **Planner drag-and-drop jank fix** — dragging places across days is smooth again on long trips
|
||||
- **Unified toolbar header** — dashboard, planner, vacay, and journey share a single toolbar style for visual consistency
|
||||
- **Places sidebar polish** — filter counts, compact select UI, tooltip component, "No Category" / "Uncategorized" filter (#607)
|
||||
- **Dayplan toolbar polish** — cleaner alignment, weather archive fallback for past trips
|
||||
- **Unplanned filter sync** — unplanned filter properly syncs with map markers (#385)
|
||||
- **Place notes** — notes textarea in place edit form with proper display in inspector (#596)
|
||||
- **Place deduplication** — Google Maps list re-import skips existing places (#543)
|
||||
- **File download button** — all file views now include a download button
|
||||
- **Note modal** — no longer closes on outside click (#480)
|
||||
- **Google Maps links** — use place name + google_place_id for accurate links (#554)
|
||||
- **Packing list menu** — no longer cut off by overflow (#557)
|
||||
- **Trip date change** — preserving day content when date range changes
|
||||
- **PDF export** — render restaurant, event, tour, and other reservation types
|
||||
|
||||
---
|
||||
|
||||
## Admin Panel Improvements
|
||||
|
||||
- **Collab sub-feature toggles** — individual toggles for Chat, Notes, Polls, What's Next
|
||||
- **Photo provider icons** — Immich and Synology Photos SVG brand icons in addon manager
|
||||
- **Bag tracking icon** — Luggage icon for the bag tracking sub-toggle
|
||||
- **Naver List Import** — now always enabled, removed from addon toggles
|
||||
- **Shared PageSidebar** — admin pages use the same sidebar layout as Settings
|
||||
|
||||
---
|
||||
|
||||
## Mobile Improvements
|
||||
|
||||
- **Bottom nav fix** — prevent clipping of scrollable content and dialogs
|
||||
- **Journey mobile** — compact add-entry button, scrollable settings dialog, iOS PWA fixes, drop hero / inline tab-bar, eager map tiles, trimmed picker labels
|
||||
- **Dashboard mobile** — spotlight trip in hero, smaller badges, check icon for completed
|
||||
- **Bottom nav dark mode** — consistent dark mode styling
|
||||
- **Safe area support** — proper insets for iOS PWA
|
||||
|
||||
---
|
||||
|
||||
## Documentation & Wiki
|
||||
|
||||
- **Full GitHub Wiki** — 74 pages covering setup, deployment, addon docs, troubleshooting, API reference, and MCP
|
||||
- **CI sync workflow** — `./wiki/**` in the main repo is auto-synced to the GitHub Wiki on push to `main`
|
||||
- **README redesign** — Apple-style hero with animated video, feature tiles, and a screenshot gallery; hero video hosted externally so the repo stays lightweight
|
||||
- **MCP compound tools doc** — `MCP.md` documents the compound / multi-step tools
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
Fifth-pass internal audit. Critical + High + Medium findings addressed in one bundled PR:
|
||||
|
||||
- **JWT password_version gate** — a single `verifyJwtAndLoadUser` helper is now used by every auth surface (web session, MCP bearer, file download token, photo route, MFA policy). A password reset bumps `password_version` and invalidates every outstanding session/token for the user in one shot.
|
||||
- **MFA policy via cookie** — `require_mfa` now applies to cookie-authenticated SPA sessions too (previously only the `Authorization` header was checked, so the whole SPA bypassed it).
|
||||
- **OIDC id_token verification** — full JWKS-based signature verification (iss, aud, exp, nbf) plus `userinfo.sub == id_token.sub` cross-check. `kid` match is strict — no fallback to an arbitrary key.
|
||||
- **OIDC invite redemption** — invite-token increment and user INSERT run in a single `db.transaction`; concurrent callbacks cannot double-redeem a single-use invite.
|
||||
- **OAuth 2.1 DCR** — redirect_uri allowlist rejects `javascript:` / `data:` / `vbscript:` / `file:` / `blob:` / `about:` / `chrome:` and requires private-use schemes to be reverse-DNS (RFC 8252 §7.1).
|
||||
- **OAuth audience binding** — `audience` defaults to the MCP endpoint when no `resource` parameter is sent, so new tokens always carry the correct audience claim.
|
||||
- **HSTS on in production** — `NODE_ENV=production` is enough to enable HSTS (previously required `FORCE_HTTPS=true`). `includeSubDomains` stays off by default to avoid breaking apex-domain setups; opt in with `HSTS_INCLUDE_SUBDOMAINS=true`.
|
||||
- **Cookie Secure behind proxies** — `trek_session` Secure flag is now derived from `req.secure` (Express's `trust proxy`-aware field), so instances behind Traefik / Caddy / Cloudflare Tunnel get Secure cookies without `FORCE_HTTPS`.
|
||||
- **Share-token expiry** — public share tokens default to 90-day TTL. Existing tokens stay NULL (no expiry) so already-distributed links keep working.
|
||||
- **Photo route scoping** — share tokens can only unlock photos that belong to the same trip as the token.
|
||||
- **Bcrypt MFA backup codes** — backup codes are now bcrypt-hashed at rest. Legacy SHA-256 codes keep working until the user regenerates.
|
||||
- **Demo-mode guards** — single `DEMO_EMAILS` registry fixes the drift where `demoUploadBlock` only matched the pre-rename `demo@nomad.app` string.
|
||||
- **Filesystem safety** — `permanentDeleteFile` / `emptyTrash` / avatar cleanup use async `fs.promises.rm({ force: true })` and only drop the DB row when the on-disk unlink actually succeeded.
|
||||
- **Idempotency store hardening** — key length capped at 128 chars, response bodies over 256 KiB not cached, primary key widened to `(key, user_id, method, path)` so the same key on a different endpoint does not replay an unrelated response.
|
||||
- **Permissions cache invalidation** — `restoreFromZip` now drops the permissions cache after a DB swap.
|
||||
- **Reset-URL source** — password-reset email URL is built from server-side `APP_URL` / `ALLOWED_ORIGINS`, never from request headers.
|
||||
- **Critical DB indexes** — added `trips(user_id)`, `trips(created_at DESC)`, `photos(day_id/place_id)`, `reservations(day_id)`, `share_tokens(token)` and conditional `day_accommodations` / `notifications` indexes.
|
||||
|
||||
Upstream CVEs patched:
|
||||
|
||||
- **hono** 4.12.9 to 4.12.12 — directory traversal (CVE-2026-39407, CVE-2026-39408), HTTP response splitting, improper input validation (CVE-2026-39410), IP restriction bypass (CVE-2026-39409)
|
||||
- **@hono/node-server** 1.19.11 to 1.19.13 — directory traversal (CVE-2026-39406)
|
||||
- **nodemailer** 8.0.4 to 8.0.5 — CRLF injection
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fixed OIDC-only mode login/logout loop (#491)
|
||||
- Fixed dayplan duplicate reservation display, date off-by-one, and missing day_id on edit
|
||||
- Fixed booking date handling and file auth bugs
|
||||
- Fixed dayplan time-based auto-sort for places and free reorder for untimed
|
||||
- Fixed streaming response end on client disconnect during asset pipe
|
||||
- Fixed per-day transport positions for multi-day reservations
|
||||
- Fixed stale budget category reset when category no longer exists
|
||||
- Fixed trip redirect to plan tab when active tab addon is disabled
|
||||
- Fixed reservation price/budget field visibility when budget addon disabled
|
||||
- Fixed HEIC photo rendering on non-Safari browsers
|
||||
- Fixed CSP path matching for paths ending in /
|
||||
- Fixed avatar URLs in notifications, admin panel, and budget
|
||||
- Fixed budget member avatars lost after updating item fields
|
||||
- Fixed budget table column alignment broken by `display: flex` on `<td>` (#759)
|
||||
- Fixed collab notes line break preservation (#608)
|
||||
- Fixed weather archive date handling for future trips (#599)
|
||||
- Fixed duplicate skeleton entries for multi-day places (#606)
|
||||
- Fixed ghost Gallery / `[Trip Photos]` entries in journal timeline and public share (#764)
|
||||
- Fixed journey reorder arrows rendering on skeleton suggestions (#763)
|
||||
- Fixed journey map OSM tile warning (#627)
|
||||
- Fixed journey gallery picker grid collapse on Safari (#717)
|
||||
- Fixed content divider placement in journal entries (#624)
|
||||
- Fixed local photos wrong provider label (#625)
|
||||
- Fixed Synology pagination and album scroll leak (#644)
|
||||
- Fixed Stadia Maps 401 on journey and atlas maps (#640)
|
||||
- Fixed Nominatim User-Agent and error diagnostics
|
||||
- Fixed map tooltips, journey creation, and contributor avatars
|
||||
- Fixed notifications SMTP error surfacing, webhook button label, backup timestamp (#537)
|
||||
- Fixed stale accommodation_id on reservation update (#522)
|
||||
- Fixed hardcoded Immich in toast — now uses provider_name
|
||||
- Fixed MCP safeBroadcast recursive call bug
|
||||
- Fixed MCP Zod v4 `z.record()` API compatibility in transport tool schemas
|
||||
- Fixed Vite module preload polyfill CSP inline script violation
|
||||
- Fixed PWA offline session redirect and file download auth (#505, #541)
|
||||
- Fixed `FORCE_HTTPS` redirect applying to `/api/health`, breaking container health-checks
|
||||
- Fixed journey bugs reported by @roel-de-vries (#722–#736)
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- **Prerelease workflow** — automated prerelease pipeline with major version support, version propagation, and race/orphan tag protection
|
||||
- **Helm chart** — moved to `charts/trek/`, published via helm-publisher action to `gh-pages`, `appVersion` used as default image tag
|
||||
- **Docker** — workflow improvements, tag management cleanup, `server/data/airports.json` properly included in image after assets refactor
|
||||
- **CI** — contributor workflow automation, `npm audit` removal from install steps, manual trigger for prerelease, client test job added alongside server tests with split coverage artifacts
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- **Backend** — expanded to ~87% coverage with comprehensive tests for OAuth, MCP tools, addon gating, services, and session management
|
||||
- **Frontend** — expanded to ~82% coverage with tests for dashboard, planner, settings, admin panels, and component interactions
|
||||
- **Journey** — 89.5% new code coverage
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who contributed to this release:
|
||||
|
||||
- @mauriceboe
|
||||
- @jubnl
|
||||
- @gravitysc
|
||||
- @luojiyin1987
|
||||
- @marco783
|
||||
- @isaiastavares
|
||||
- @tiquis0290
|
||||
- @xenocent
|
||||
- @gfrcsd
|
||||
- @roel-de-vries
|
||||
|
||||
---
|
||||
|
||||
## Stats
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Commits | 500+ |
|
||||
| Merged PRs | 130+ |
|
||||
| Files changed | 700+ |
|
||||
| Lines added | 120,000+ |
|
||||
| Contributors | 12+ |
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/trek:3.0.0
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Migrations run automatically on startup. No manual steps required.
|
||||
|
||||
**Checklist:**
|
||||
1. Update your Immich API key to include `asset.upload` (optional, only needed for Journey upload sync)
|
||||
2. If using `OIDC_ONLY`, migrate to `DISABLE_LOCAL_LOGIN` + `DISABLE_LOCAL_REGISTRATION`
|
||||
3. Enable the Journey addon in Settings > Addons to start using the travel journal
|
||||
4. Try the Mapbox GL renderer in Settings > Map if you want 3D terrain and a proper globe view (requires a free Mapbox access token)
|
||||
@@ -1,405 +0,0 @@
|
||||
|
||||
<img width="5292" height="1404" alt="Release 2 9 0 (2)" src="https://github.com/user-attachments/assets/6ff67226-3535-444e-991f-0bc0352e22e7" />
|
||||
|
||||
# TREK 3.0.0
|
||||
|
||||
> **This is the biggest TREK release to date.** Journey turns your trips into rich travel journals. MCP gets full OAuth 2.1 security. The dashboard has been redesigned for mobile-first. And every corner of the app now speaks 15 languages natively.
|
||||
|
||||
---
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
### Photos moved from Trip Planner to Journey
|
||||
|
||||
In previous versions, Immich and Synology Photos were integrated directly into the Trip Planner via a "Photos" tab. **This tab has been removed.** Photos are now part of the new **Journey addon**, which is purpose-built for documenting your travels with stories, photos, and maps.
|
||||
|
||||
**What this means for you:**
|
||||
- **No photos are lost.** The previous integration was read-only — TREK never uploaded to or deleted from your Immich/Synology library. Your photos remain untouched in your photo provider.
|
||||
- **Previously linked trip photos are no longer displayed in the Trip Planner.** To view and organize your travel photos, enable the Journey addon (Settings > Addons) and create a Journey linked to your trip.
|
||||
- **Journey brings a much richer photo experience:** upload photos directly to TREK, browse and import from Immich/Synology with duplicate detection, reorder photos, view EXIF metadata, and export everything as a PDF photo book.
|
||||
|
||||
### New Immich API Key Permissions Required
|
||||
|
||||
Journey introduces **photo upload sync** — when you upload a photo to a Journey entry, TREK can optionally sync it to your Immich library. This requires an additional Immich API permission that was not needed before.
|
||||
|
||||
**Previous versions required:**
|
||||
| Permission | Used for |
|
||||
|---|---|
|
||||
| `user.read` | Connection test |
|
||||
| `asset.read` | Browse photos by date, search |
|
||||
| `asset.view` | Stream thumbnails |
|
||||
| `asset.download` | Stream originals |
|
||||
| `album.read` | List and browse albums |
|
||||
| `timeline.read` | Browse timeline buckets |
|
||||
|
||||
**New in 3.0.0 — additionally required:**
|
||||
| Permission | Used for |
|
||||
|---|---|
|
||||
| `asset.upload` | Sync uploaded Journey photos to Immich |
|
||||
|
||||
> **How to update your Immich API key:** Go to your Immich instance > User Settings > API Keys. Edit your existing TREK key (or create a new one) and ensure `asset.upload` is enabled in addition to the existing permissions. If you don't plan to use Journey's upload sync, the old key will continue to work — the upload simply won't sync to Immich.
|
||||
|
||||
**No changes needed for Synology Photos** — Synology uses session-based authentication which inherits the user's full permissions.
|
||||
|
||||
### OIDC_ONLY deprecated
|
||||
|
||||
The `OIDC_ONLY` environment variable is deprecated. Replace with `DISABLE_LOCAL_LOGIN=true` + `DISABLE_LOCAL_REGISTRATION=true` for equivalent behavior. The old variable still works but will be removed in a future release.
|
||||
|
||||
---
|
||||
<img width="5292" height="1404" alt="Release 2 9 0 (3)" src="https://github.com/user-attachments/assets/76976c02-dd81-49ab-83f5-e2221d6b018b" />
|
||||
|
||||
## Journey Addon — Travel Journal
|
||||
|
||||
The headline feature of 3.0.0. Journey is a new global addon that transforms your trips into magazine-style travel stories.
|
||||
|
||||
### Core
|
||||
- **5-table schema** — journeys, entries, photos, trips, contributors with full relational integrity
|
||||
- **Trip-to-Journey sync engine** — link one or more trips to a journey; skeleton entries and photos are synced automatically
|
||||
- **Timeline, Gallery, and Map views** — browse entries chronologically, as a photo grid, or on an interactive map with SVG pin markers
|
||||
- **Entry editor** — markdown toolbar, custom date picker, location search (Nominatim/Google Maps), mood (Amazing/Good/Neutral/Rough), weather (Sunny to Snowy), and Pros & Cons sections
|
||||
|
||||
### Photos
|
||||
- **Immich & Synology browser** — browse by trip dates, custom range, or album with duplicate detection
|
||||
- **Photo upload** — direct upload with drag-and-drop, reorder (Make 1st), and delete
|
||||
- **EXIF metadata** — displayed in lightbox for Immich photos
|
||||
- **Thumbnail to original fallback** — seamless resolution upgrade everywhere
|
||||
- **HEIC rendering fix** — serve fullsize thumbnail for original to fix HEIC rendering on non-Safari browsers
|
||||
- **Contributor photo access** — invited contributors can view all journey photos even without their own Immich/Synology connection (owner credentials are used for the proxy)
|
||||
|
||||
### Sharing & Export
|
||||
- **Public share links** — token-based access with language picker, no login required
|
||||
- **Public photo proxy** — validates share token instead of auth for photo streaming
|
||||
- **PDF photo book export** — Polarsteps-inspired layout with cover, day chapters, photo grids, and stories
|
||||
|
||||
### Collaboration
|
||||
- **Contributors** — invite users as editors or viewers
|
||||
- **Trip linking/unlinking** — manage synced trips from Journey Settings and Desktop Sidebar
|
||||
- **Cover image** — upload or pick from journey photos
|
||||
|
||||
### Frontend
|
||||
- **JourneyPage** — frontpage with hero card, active journey stats, trip suggestions ("Trip just ended — turn it into a Journey")
|
||||
- **JourneyDetailPage** — full timeline/gallery/map with inline entry editing
|
||||
- **JourneyPublicPage** — public share view with language picker and read-only timeline
|
||||
|
||||
---
|
||||
|
||||
## MCP: OAuth 2.1 & Granular Scopes
|
||||
|
||||
MCP authentication has been completely rebuilt around the OAuth 2.1 specification.
|
||||
|
||||
- **OAuth 2.1 authorization server** — full PKCE flow with authorization codes, access tokens, refresh tokens, and token rotation with replay detection
|
||||
- **Granular scopes** — 24 scopes across 11 groups (trips, places, atlas, packing, todos, budget, reservations, collab, notifications, vacay, geo/weather) with per-scope read/write/delete control
|
||||
- **Dynamic Client Registration (DCR)** — RFC 7591 endpoint at POST /oauth/register for browser-initiated and public clients
|
||||
- **Consent screen** — user-facing scope selection with grouped permission display
|
||||
- **Admin panel** — OAuth sessions management in MCP Access panel with collapsible scope lists
|
||||
- **Per-client rate limiting** — configurable rate limits per OAuth client
|
||||
- **Addon gating** — MCP tools are only registered when their corresponding addon is enabled
|
||||
- **Static token deprecation** — existing MCP tokens still work but surface deprecation notices; migration path to OAuth is documented
|
||||
- **Security hardening** — Critical + High + Medium findings addressed (token storage, PKCE enforcement, scope validation)
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Redesign
|
||||
|
||||
The dashboard has been rebuilt with a mobile-first design language.
|
||||
|
||||
### Mobile
|
||||
- **Greeting header** — "Good morning, {username}" with notification bell and avatar
|
||||
- **Spotlight hero card** — the next upcoming or ongoing trip as a full-width hero with cover image, progress bar (for live trips), stats grid, and frosted-glass action buttons
|
||||
- **Quick Actions** — New Trip, Currency Converter, Timezone as icon cards
|
||||
- **Trip cards** — cover image with title overlay, status badge (In X days / Starts today / Ongoing / Completed), bottom stats (starts, duration, places, buddies)
|
||||
|
||||
### Desktop
|
||||
- **Unified card design** — desktop grid cards now match the mobile card style (cover + title overlay + stats)
|
||||
- **Hero card** — SpotlightCard with progress bar for ongoing trips, countdown for upcoming, stats grid
|
||||
- **Hover actions** — edit/copy/archive/delete buttons appear on hover as frosted-glass icons
|
||||
- **Status badges** — CircleCheck icon for completed trips, Clock for upcoming, pulsing dot for ongoing
|
||||
|
||||
### Both
|
||||
- **BottomNav profile sheet** — slide-up sheet with user info, settings, admin, and logout
|
||||
- **Dark mode** — full dark mode support across all new components
|
||||
|
||||
---
|
||||
|
||||
## PWA Offline Mode
|
||||
|
||||
TREK now works offline as a Progressive Web App with full data synchronization.
|
||||
|
||||
- **IndexedDB (Dexie) storage** — trips, places, assignments, categories, tags, accommodations, reservations, budget items, packing items, files, and trip members cached locally
|
||||
- **Offline mutation queue** — changes made offline are queued with monotonic timestamps and replayed on reconnect (FIFO)
|
||||
- **Offline dashboard** — trip list loaded from Dexie when network is unavailable
|
||||
- **Offline trip planner** — full planner functionality with cached data
|
||||
- **Repo layer** — all data access routed through repository layer that falls back to offline storage
|
||||
- **Offline banner** — visible indicator with safe-area-inset support for iOS PWA
|
||||
- **Idempotency keys** — prevents duplicate mutations on replay (Migration 100)
|
||||
|
||||
---
|
||||
|
||||
## Reservations Redesign
|
||||
|
||||
The reservations panel has been completely redesigned with a modern, unified layout.
|
||||
|
||||
- **Unified toolbar** — title, type filter pills with count badges, and add button in one row with muted background
|
||||
- **Type filters** — multi-select filter buttons (Flight, Hotel, Restaurant, etc.) with per-type count badges, persisted in sessionStorage
|
||||
- **Responsive grid** — auto-fill layout with max 3 columns that fills full width
|
||||
- **Card redesign** — status + type badge in header, labeled fields in rounded boxes, hover shadow
|
||||
- **Check-in time ranges** — hotel bookings now support a check-in window (e.g. "15:00 -- 22:00") with a new check_in_end field (#366)
|
||||
- **Mobile responsive** — filters hidden on mobile, booking code on separate row, weekday hidden in dates, reduced padding
|
||||
|
||||
---
|
||||
|
||||
## Collab Sub-Feature Toggles
|
||||
|
||||
Individual collab sections can now be toggled on/off from the admin addons page (#604).
|
||||
|
||||
- **Admin UI** — sub-toggles for Chat, Notes, Polls, and What's Next under the Collab addon, with icons matching the collab panel tabs
|
||||
- **Dynamic desktop layout** — Chat always stays at fixed 380px width; remaining active panels share space equally
|
||||
- **Mobile** — disabled tabs are hidden from the tab bar
|
||||
- **API** — GET/PUT /admin/collab-features endpoints stored in app_settings
|
||||
|
||||
---
|
||||
|
||||
## Place Import: KMZ/KML & Naver Maps
|
||||
|
||||
Two new ways to import places into your trips.
|
||||
|
||||
### KMZ/KML Import
|
||||
- **Unified file import modal** — drag-and-drop or file picker for KML, KMZ, and GPX files
|
||||
- **KMZ unpacking** — extracts KML from ZIP archive with 50MB decompressed size limit
|
||||
- **Folder-to-category mapping** — KML folders are automatically matched to TREK categories
|
||||
- **Place deduplication** — skips places that already exist in the trip (by name + coordinates)
|
||||
|
||||
### Naver Maps List Import
|
||||
- **Always enabled** — no longer requires addon toggle, available alongside Google Maps list import
|
||||
- **Shortlink resolution** — resolves naver.me shortlinks to full list URLs
|
||||
- **Pagination support** — handles large Naver Maps lists with automatic pagination
|
||||
|
||||
---
|
||||
|
||||
## Search Autocomplete
|
||||
|
||||
- **Real-time suggestions** — autocomplete suggestions appear as you type in the place search field
|
||||
- **Google Places API** — primary autocomplete provider with location bias
|
||||
- **Nominatim fallback** — free fallback when Google API key is not configured
|
||||
- **Bounding box bias** — search results biased to the current map viewport
|
||||
|
||||
---
|
||||
|
||||
## ntfy Notification Channel
|
||||
|
||||
- **ntfy as first-class channel** — push notifications via any ntfy server (self-hosted or ntfy.sh)
|
||||
- **Admin configuration** — server URL and topic configuration in admin panel with clear token button
|
||||
- **Per-user opt-in** — users can enable/disable ntfy in their notification preferences
|
||||
- **Full i18n** — ntfy strings translated in all 15 languages
|
||||
|
||||
---
|
||||
|
||||
## Login & Language
|
||||
|
||||
- **Language dropdown on login page** — users can select their preferred language before logging in
|
||||
- **Browser auto-detection** — language is automatically detected from browser settings on first visit
|
||||
- **DEFAULT_LANGUAGE env var** — configurable default language for the instance, documented across all deployment configs (Docker, Helm, Synology)
|
||||
|
||||
---
|
||||
|
||||
## Granular Auth Toggles
|
||||
|
||||
- **OIDC_ONLY replaced** — split into DISABLE_LOCAL_LOGIN, DISABLE_LOCAL_REGISTRATION, and DISABLE_PASSWORD_CHANGE for fine-grained control over authentication methods
|
||||
- Allows mixed setups (e.g., OIDC + local admin account, or OIDC-only with no local registration)
|
||||
|
||||
---
|
||||
|
||||
## Synology Photos: OTP, SSL Skip & Session Management
|
||||
|
||||
- **OTP support** — one-time password field for 2FA-enabled Synology NAS
|
||||
- **Skip SSL verification** — toggle for self-signed certificates
|
||||
- **Device ID persistence** — prevents repeated 2FA prompts
|
||||
- **Session-cleared notification** — routed through unified notification system
|
||||
- **Provider URL hint** — contextual help text for Synology URL format
|
||||
|
||||
---
|
||||
|
||||
## Atlas Improvements
|
||||
|
||||
- **Scoped region matching** — region name matching is now scoped by country to prevent cross-country false matches
|
||||
- **Expanded country lookup tables** — more countries and regions recognized correctly, including A3 fallback for invalid ISO_A2 codes
|
||||
- **Nominatim rate limiting** — shared throttle prevents 429 errors, background region fill, fetch timeout
|
||||
- **Stadia Maps fix** — resolved 401 errors on journey and atlas maps
|
||||
|
||||
---
|
||||
|
||||
## i18n: Full 15-Language Coverage
|
||||
|
||||
- **Indonesian added** — complete translation with full parity to English, bringing the total to 15 languages (EN, DE, FR, ES, IT, NL, PL, RU, ZH, ZH-TW, BR, CS, HU, AR, ID)
|
||||
- **Comprehensive audit** — every key translated natively, no English fallbacks
|
||||
- **OAuth scope labels** — all 24 scopes have localized names and descriptions
|
||||
- **Journey addon** — complete coverage for all journal, editor, sharing, and PDF export strings
|
||||
- **Ellipsis standardization** — all ellipsis characters normalized to three dots (...)
|
||||
|
||||
---
|
||||
|
||||
## Vacay Improvements
|
||||
|
||||
- **Trip indicator dots** — small blue dots on calendar days where trips are scheduled
|
||||
- **Configurable week start** — choose Monday or Sunday as first day of the week (#224)
|
||||
- **Holiday overlap** — vacations can now be placed on public holidays
|
||||
- **Today marker** — visual indicator for the current day in the calendar
|
||||
- **Bottom padding fix** — toolbar no longer overlaps the last row (#533)
|
||||
|
||||
---
|
||||
|
||||
## iCal Export Improvements
|
||||
|
||||
- **Day activities and notes** — iCal export now includes daily activities and notes, not just the trip dates (#375)
|
||||
|
||||
---
|
||||
|
||||
## Budget Improvements
|
||||
|
||||
- **Drag-and-drop reorder** — budget categories and individual items can be reordered via drag-and-drop (#479)
|
||||
- **Category legend redesign** — prevents overflow on small screens (#564)
|
||||
- **Comma decimal support** — pasting numbers with comma separators works correctly
|
||||
|
||||
---
|
||||
|
||||
## Planner & UX Improvements
|
||||
|
||||
- **Collapsible day detail panel** — day detail panel can be collapsed/expanded in the planner
|
||||
- **Uncategorized filter** — "No Category" option in category dropdown to find places without a category (#607)
|
||||
- **Map multi-category filter** — filter syncs with map view for uncategorized places
|
||||
- **Unplanned filter sync** — unplanned filter properly syncs with map markers (#385)
|
||||
- **Place notes** — notes textarea in place edit form with proper display in inspector (#596)
|
||||
- **Place deduplication** — Google Maps list re-import skips existing places (#543)
|
||||
- **File download button** — all file views now include a download button
|
||||
- **Note modal** — no longer closes on outside click (#480)
|
||||
- **Google Maps links** — use place name + google_place_id for accurate links (#554)
|
||||
- **Packing list menu** — no longer cut off by overflow (#557)
|
||||
- **Trip date change** — preserving day content when date range changes
|
||||
- **PDF export** — render restaurant, event, tour, and other reservation types
|
||||
|
||||
---
|
||||
|
||||
## Admin Panel Improvements
|
||||
|
||||
- **Collab sub-feature toggles** — individual toggles for Chat, Notes, Polls, What's Next
|
||||
- **Photo provider icons** — Immich and Synology Photos SVG brand icons in addon manager
|
||||
- **Bag tracking icon** — Luggage icon for the bag tracking sub-toggle
|
||||
- **Naver List Import** — now always enabled, removed from addon toggles
|
||||
|
||||
---
|
||||
|
||||
## Mobile Improvements
|
||||
|
||||
- **Bottom nav fix** — prevent clipping of scrollable content and dialogs
|
||||
- **Journey mobile** — compact add-entry button, scrollable settings dialog, iOS PWA fixes
|
||||
- **Dashboard mobile** — spotlight trip in hero, smaller badges, check icon for completed
|
||||
- **Bottom nav dark mode** — consistent dark mode styling
|
||||
- **Safe area support** — proper insets for iOS PWA
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- **Backend** — expanded to ~87% coverage with comprehensive tests for OAuth, MCP tools, addon gating, services, and session management
|
||||
- **Frontend** — expanded to ~82% coverage with tests for dashboard, planner, settings, admin panels, and component interactions
|
||||
- **Journey** — 89.5% new code coverage
|
||||
- **CI** — client test job added alongside server tests with split coverage artifacts
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fixed OIDC-only mode login/logout loop (#491)
|
||||
- Fixed dayplan duplicate reservation display, date off-by-one, and missing day_id on edit
|
||||
- Fixed booking date handling and file auth bugs
|
||||
- Fixed dayplan time-based auto-sort for places and free reorder for untimed
|
||||
- Fixed streaming response end on client disconnect during asset pipe
|
||||
- Fixed per-day transport positions for multi-day reservations
|
||||
- Fixed stale budget category reset when category no longer exists
|
||||
- Fixed trip redirect to plan tab when active tab addon is disabled
|
||||
- Fixed reservation price/budget field visibility when budget addon disabled
|
||||
- Fixed HEIC photo rendering on non-Safari browsers
|
||||
- Fixed CSP path matching for paths ending in /
|
||||
- Fixed avatar URLs in notifications, admin panel, and budget
|
||||
- Fixed budget member avatars lost after updating item fields
|
||||
- Fixed collab notes line break preservation (#608)
|
||||
- Fixed weather archive date handling for future trips (#599)
|
||||
- Fixed duplicate skeleton entries for multi-day places (#606)
|
||||
- Fixed ghost Gallery entries in journal timeline and public share
|
||||
- Fixed journey map OSM tile warning (#627)
|
||||
- Fixed content divider placement in journal entries (#624)
|
||||
- Fixed local photos wrong provider label (#625)
|
||||
- Fixed Synology pagination and album scroll leak (#644)
|
||||
- Fixed Stadia Maps 401 on journey and atlas maps (#640)
|
||||
- Fixed Nominatim User-Agent and error diagnostics
|
||||
- Fixed map tooltips, journey creation, and contributor avatars
|
||||
- Fixed notifications SMTP error surfacing, webhook button label, backup timestamp (#537)
|
||||
- Fixed stale accommodation_id on reservation update (#522)
|
||||
- Fixed hardcoded Immich in toast — now uses provider_name
|
||||
- Fixed MCP safeBroadcast recursive call bug
|
||||
- Fixed Vite module preload polyfill CSP inline script violation
|
||||
- Fixed PWA offline session redirect and file download auth (#505, #541)
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- **hono** 4.12.9 to 4.12.12 — fixes directory traversal (CVE-2026-39407, CVE-2026-39408), HTTP response splitting, improper input validation (CVE-2026-39410), and IP restriction bypass (CVE-2026-39409)
|
||||
- **@hono/node-server** 1.19.11 to 1.19.13 — fixes directory traversal (CVE-2026-39406)
|
||||
- **nodemailer** 8.0.4 to 8.0.5 — fixes CRLF injection
|
||||
- **OAuth 2.1 hardening** — token storage, PKCE enforcement, scope intersection validation
|
||||
- **Google Maps regex** — replaced too-permissive regex with safer utility function
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- **Prerelease workflow** — automated prerelease pipeline with major version support, version propagation, and race/orphan tag protection
|
||||
- **Helm chart** — moved to charts/trek/, published via helm-publisher action to gh-pages, appVersion used as default image tag
|
||||
- **Docker** — workflow improvements, tag management cleanup
|
||||
- **CI** — contributor workflow automation, npm audit removal from install steps, manual trigger for prerelease
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who contributed to this release:
|
||||
|
||||
- @mauriceboe
|
||||
- @jubnl
|
||||
- @gravitysc
|
||||
- @luojiyin1987
|
||||
- @marco783
|
||||
- @isaiastavares
|
||||
- @tiquis0290
|
||||
- @xenocent
|
||||
- @gfrcsd
|
||||
|
||||
---
|
||||
|
||||
## Stats
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Commits | 280+ |
|
||||
| Merged PRs | 49 |
|
||||
| Files changed | 500+ |
|
||||
| Lines added | 108,000+ |
|
||||
| Contributors | 12 |
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/trek:3.0.0
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Migrations run automatically on startup. No manual steps required.
|
||||
|
||||
**Checklist:**
|
||||
1. Update your Immich API key to include `asset.upload` (optional, only needed for Journey upload sync)
|
||||
2. If using `OIDC_ONLY`, migrate to `DISABLE_LOCAL_LOGIN` + `DISABLE_LOCAL_REGISTRATION`
|
||||
3. Enable the Journey addon in Settings > Addons to start using the travel journal
|
||||
|
||||
+46
-6
@@ -1,3 +1,10 @@
|
||||
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
|
||||
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
|
||||
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
|
||||
# The binary and its runtime behaviour are identical to the apt package.
|
||||
FROM golang:1.25-alpine AS gosu-build
|
||||
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
|
||||
|
||||
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS shared-builder
|
||||
WORKDIR /app
|
||||
@@ -31,7 +38,7 @@ COPY server/ ./server/
|
||||
RUN npm run build --workspace=server
|
||||
|
||||
# ── Stage 4: production runtime ──────────────────────────────────────────────
|
||||
FROM node:24-alpine
|
||||
FROM node:24-trixie-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Workspace manifests only — source never enters this stage.
|
||||
@@ -39,13 +46,43 @@ COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
# better-sqlite3 native addon requires build tools; purged after install.
|
||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||
# better-sqlite3 native addon requires build tools (purged after compile).
|
||||
# kitinerary-extractor for booking-confirmation import:
|
||||
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
||||
# arm64 — apt package (KDE publishes no arm64 static binary)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
apk del python3 make g++ && \
|
||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
ARCH=$(dpkg --print-architecture) && \
|
||||
if [ "$ARCH" = "amd64" ]; then \
|
||||
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.2.tgz && \
|
||||
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \
|
||||
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
|
||||
rm /tmp/ki.tgz; \
|
||||
else \
|
||||
apt-get install -y --no-install-recommends libkitinerary-bin && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
fi && \
|
||||
apt-get purge -y python3 build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
|
||||
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
|
||||
|
||||
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
||||
# Prevent Qt from probing for a display in headless containers.
|
||||
ENV QT_QPA_PLATFORM=offscreen
|
||||
# Fixed path for both amd64 (static binary) and arm64 (symlink to apt binary).
|
||||
# Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere.
|
||||
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
|
||||
|
||||
COPY --from=server-builder /app/server/dist ./server/dist
|
||||
# Runtime data assets read from server/assets at runtime: airports.json (flight
|
||||
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
|
||||
# only emits dist, so these must be copied explicitly or the features silently
|
||||
# degrade to empty in the image.
|
||||
COPY --from=server-builder /app/server/assets ./server/assets
|
||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||
COPY server/tsconfig.json ./server/
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
@@ -68,5 +105,8 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
# Preflight: if the app code is missing, a volume was almost certainly mounted
|
||||
# over /app (it hides the image's node_modules + dist). Fail with actionable
|
||||
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
|
||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec su-exec node node --require tsconfig-paths/register dist/index.js"]
|
||||
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Third-party data & attributions
|
||||
|
||||
TREK bundles and uses third-party data that requires attribution.
|
||||
|
||||
## geoBoundaries — country & sub-national boundaries
|
||||
|
||||
The Atlas map's administrative boundaries (admin-0 countries and admin-1
|
||||
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
|
||||
`server/assets/atlas/admin1.geojson.gz` and generated by
|
||||
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
|
||||
|
||||
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
|
||||
> administrative boundaries. PLoS ONE 15(4): e0231866.
|
||||
> https://doi.org/10.1371/journal.pone.0231866
|
||||
|
||||
geoBoundaries is licensed under **CC BY 4.0**
|
||||
(https://creativecommons.org/licenses/by/4.0/). Source: https://www.geoboundaries.org/
|
||||
|
||||
The bundled files are simplified (coordinate-quantized) and re-tagged with the
|
||||
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
|
||||
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
|
||||
(gbOpen) release.
|
||||
|
||||
## OpenStreetMap — geocoding
|
||||
|
||||
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
|
||||
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
|
||||
https://www.openstreetmap.org/copyright
|
||||
|
||||
## OurAirports — airport reference data
|
||||
|
||||
`server/assets/airports.json` is built from **OurAirports**
|
||||
(https://ourairports.com/data/), released into the public domain.
|
||||
@@ -51,10 +51,10 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
|
||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||
@@ -89,8 +90,8 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
#### 🧳 Travel management
|
||||
|
||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files
|
||||
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
||||
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
|
||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||
@@ -108,6 +109,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **Invite links** — one-time or reusable links with expiry
|
||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **2FA** — TOTP + backup codes
|
||||
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
|
||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||
|
||||
</td>
|
||||
@@ -128,13 +130,13 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### 🧩 Addons (admin-toggleable)
|
||||
|
||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
||||
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
|
||||
- **Documents** — file attachments on trips, places, and reservations
|
||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
|
||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||
|
||||
</td>
|
||||
@@ -156,8 +158,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### ⚙️ Admin & customisation
|
||||
|
||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
|
||||
</td>
|
||||
@@ -191,9 +194,9 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -202,7 +205,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
|
||||
</div>
|
||||
|
||||
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
|
||||
<br />
|
||||
|
||||
@@ -263,7 +266,7 @@ Then:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -311,6 +314,9 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
|
||||
|
||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
|
||||
|
||||
<h3>Rotating the Encryption Key</h3>
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||
@@ -397,12 +403,12 @@ Caddy handles TLS and WebSockets automatically.
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
@@ -437,6 +443,13 @@ Caddy handles TLS and WebSockets automatically.
|
||||
|
||||
<br />
|
||||
|
||||
## Data sources
|
||||
|
||||
The Atlas map's country and sub-national (province/county) boundaries come from
|
||||
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
|
||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
|
||||
for full third-party attributions.
|
||||
|
||||
## License
|
||||
|
||||
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Playwright E2E (FE7)
|
||||
e2e/.tmp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test as setup, expect } from '@playwright/test'
|
||||
|
||||
// Relative to the config dir (client/), matching `storageState` in
|
||||
// playwright.config.ts. Playwright runs from the client workspace root.
|
||||
const stateFile = 'e2e/.tmp/state.json'
|
||||
|
||||
// Credentials match e2e/server-launch.mjs (ADMIN_EMAIL/ADMIN_PASSWORD). The
|
||||
// seeded admin is created with must_change_password=1, so the first login goes
|
||||
// through the forced change-password step before reaching the dashboard.
|
||||
const EMAIL = 'e2e@trek.local'
|
||||
const SEED_PW = 'E2eTest12345!'
|
||||
const NEW_PW = 'E2eChanged12345!'
|
||||
|
||||
setup('authenticate the seeded admin (incl. forced password change)', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.locator('input[type="email"]').fill(EMAIL)
|
||||
await page.locator('input[type="password"]').fill(SEED_PW)
|
||||
await page.locator('button[type="submit"]').click()
|
||||
|
||||
// must_change_password=1 → the change-password step renders two password
|
||||
// fields (new + confirm). Selector-agnostic of the UI language.
|
||||
const pw = page.locator('input[type="password"]')
|
||||
await expect(pw).toHaveCount(2)
|
||||
await pw.nth(0).fill(NEW_PW)
|
||||
await pw.nth(1).fill(NEW_PW)
|
||||
await page.locator('button[type="submit"]').click()
|
||||
|
||||
await page.waitForURL('**/dashboard', { timeout: 30_000 })
|
||||
|
||||
// Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders
|
||||
// asynchronously (after the notices fetch), so wait for it before clicking.
|
||||
// Dismissal is recorded server-side against this user, so clearing it here
|
||||
// keeps it cleared for every authenticated flow in the run (shared test DB).
|
||||
const ok = page.getByRole('button', { name: 'OK', exact: true })
|
||||
await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {})
|
||||
for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) {
|
||||
await ok.click()
|
||||
await page.waitForTimeout(400)
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: stateFile })
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the
|
||||
// trip, submit, and confirm it shows up on the dashboard. Exercises the whole
|
||||
// authenticated stack — dashboard → TripFormModal → POST /api/trips → store →
|
||||
// re-render — against the real backend + isolated test DB.
|
||||
test('create a trip and see it on the dashboard', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// The "+ New Trip" card is always rendered in the default (planned) filter.
|
||||
await page.locator('.add-trip-card').click()
|
||||
|
||||
// Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit
|
||||
// button (the primary action lives in the footer), so click it explicitly
|
||||
// rather than pressing Enter. The Create button is the slate primary button;
|
||||
// Cancel is the bordered one.
|
||||
const modal = page.locator('.modal-backdrop')
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const title = `E2E Trip ${Date.now()}`
|
||||
await modal.locator('input[type="text"]').first().fill(title)
|
||||
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||
|
||||
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Authenticated smoke: the stored session lands on the dashboard and the
|
||||
// app chrome (navbar) renders instead of bouncing back to /login.
|
||||
test('authenticated session reaches the dashboard', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(page).toHaveURL(/\/dashboard/)
|
||||
// The shared Navbar shows the TREK brand once authenticated.
|
||||
await expect(page.getByRole('img', { name: 'TREK' }).first()).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Infra smoke + first unauthenticated flow: the app boots, the backend is
|
||||
// reachable through the Vite proxy, and the login screen renders its form.
|
||||
test('login screen renders with a password field', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await expect(page.locator('input[type="password"]')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
// Boots the TREK backend for the Playwright E2E run against a fresh, isolated
|
||||
// SQLite database. The DB file is deleted first so every run starts clean, then
|
||||
// the server's own startup seeds a known admin from ADMIN_EMAIL/ADMIN_PASSWORD.
|
||||
//
|
||||
// The server is built once and launched as a SINGLE node process (not the
|
||||
// watch-mode `npm run dev`, which spawns tsc -w + node --watch grandchildren
|
||||
// that survive Playwright's teardown and then linger on :3001 with stale DB
|
||||
// state). A single child is killed cleanly when Playwright tears the run down.
|
||||
import { rmSync } from 'node:fs'
|
||||
import { spawn, execSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const dbFile = path.join(here, '.tmp', 'e2e.db')
|
||||
const serverDir = path.join(here, '..', '..', 'server')
|
||||
|
||||
for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
|
||||
try { rmSync(f, { force: true }) } catch {}
|
||||
}
|
||||
|
||||
// Build once (no watcher) — the resulting process is a single killable node.
|
||||
execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
TREK_DB_FILE: dbFile,
|
||||
ADMIN_EMAIL: 'e2e@trek.local',
|
||||
ADMIN_PASSWORD: 'E2eTest12345!',
|
||||
PORT: '3001',
|
||||
NODE_ENV: 'development',
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, ['--require', 'tsconfig-paths/register', 'dist/index.js'], {
|
||||
cwd: serverDir,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
const stop = () => { try { child.kill() } catch {} }
|
||||
process.on('SIGINT', stop)
|
||||
process.on('SIGTERM', stop)
|
||||
process.on('exit', stop)
|
||||
child.on('exit', code => process.exit(code ?? 0))
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Open a trip into the planner: create a trip, open it from the dashboard, and
|
||||
// confirm the trip planner (TripPlannerPage — the app's largest page) actually
|
||||
// mounts, proving the day-plan/map shell renders rather than crashing on load.
|
||||
test('open a trip and land in the planner with a map', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// Create a trip to open.
|
||||
await page.locator('.add-trip-card').click()
|
||||
const modal = page.locator('.modal-backdrop')
|
||||
await expect(modal).toBeVisible()
|
||||
const title = `E2E Planner ${Date.now()}`
|
||||
await modal.locator('input[type="text"]').first().fill(title)
|
||||
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||
|
||||
// Open it from the dashboard.
|
||||
await page.getByText(title).first().click()
|
||||
|
||||
await expect(page).toHaveURL(/\/trips\/\d+/)
|
||||
// The planner shows a Leaflet map once mounted (past the splash screen).
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import gitignore from 'eslint-config-flat-gitignore'
|
||||
|
||||
export default defineConfig([
|
||||
gitignore({ strict: false }),
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
// Route files always export both `Route` (non-component) and the page component — expected pattern.
|
||||
{
|
||||
files: ['src/routes/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
// shadcn UI primitives export variant helpers alongside components — generated files, don't modify.
|
||||
// ThemeProvider exports both the provider component and the useTheme hook — standard pattern.
|
||||
{
|
||||
files: ['src/components/ui/**/*.{ts,tsx}', 'src/components/theme/ThemeProvider.tsx'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,78 @@
|
||||
import js from '@eslint/js';
|
||||
|
||||
import gitignore from 'eslint-config-flat-gitignore';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
// Minimal stub so the existing `// eslint-disable-next-line react/no-danger`
|
||||
// directive in src/i18n/TransHtml.tsx resolves without pulling in the full
|
||||
// eslint-plugin-react (not a dependency here). The rule is a no-op.
|
||||
const reactStub = {
|
||||
rules: {
|
||||
'no-danger': {
|
||||
meta: { schema: [] },
|
||||
create() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default tseslint.config(
|
||||
gitignore({ strict: false }),
|
||||
{
|
||||
ignores: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
'coverage',
|
||||
'public',
|
||||
'test-results',
|
||||
'playwright-report',
|
||||
'e2e/**',
|
||||
'scripts/**',
|
||||
'**/*.config.js',
|
||||
'**/*.config.ts',
|
||||
'**/*.config.mjs',
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
react: reactStub,
|
||||
},
|
||||
rules: {
|
||||
'react/no-danger': 'off',
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
|
||||
// --- Severities tuned to keep CI green on a codebase that was never linted ---
|
||||
// (each rule below has pre-existing violations; surfaced as warnings, not blockers)
|
||||
|
||||
// rules-of-hooks has one conditional-hook violation in PlaceInspector.tsx -> warn (not error).
|
||||
'react-hooks/rules-of-hooks': 'warn',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-unused-expressions': 'warn',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'warn',
|
||||
'@typescript-eslint/no-this-alias': 'warn',
|
||||
'@typescript-eslint/no-non-null-asserted-optional-chain': 'warn',
|
||||
|
||||
// js.recommended rules with pre-existing hits.
|
||||
'no-empty': 'warn',
|
||||
'no-useless-escape': 'warn',
|
||||
'no-useless-assignment': 'warn',
|
||||
'preserve-caught-error': 'warn',
|
||||
},
|
||||
},
|
||||
);
|
||||
+30
-19
@@ -8,18 +8,26 @@
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint .",
|
||||
"lint:check": "eslint .",
|
||||
"lint:pages": "node scripts/check-page-pattern.mjs",
|
||||
"e2e": "playwright test",
|
||||
"e2e:report": "playwright show-report",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"heic-to": "^1.4.2",
|
||||
@@ -27,11 +35,11 @@
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"marked": "^18.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-leaflet-cluster": "^4.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-window": "^2.2.7",
|
||||
@@ -43,34 +51,37 @@
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/react": "^18.2.61",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^29.0.1",
|
||||
"msw": "^2.13.0",
|
||||
"postcss": "^8.4.35",
|
||||
"sharp": "^0.33.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-pwa": "^0.21.0",
|
||||
"vitest": "^3.2.4",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"typescript-eslint": "^8.58.2"
|
||||
"sharp": "^0.33.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-pwa": "^0.21.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* E2E harness for TREK's critical user flows (FE7).
|
||||
*
|
||||
* Two web servers are orchestrated: the Express/Nest backend on :3001 against an
|
||||
* isolated throwaway SQLite DB (e2e/server-launch.mjs sets TREK_DB_FILE + seeds a
|
||||
* known admin), and the Vite dev server on :5173 which proxies /api, /uploads,
|
||||
* /ws to the backend. Tests run serially against one worker so they share the
|
||||
* single seeded database deterministically.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 15_000 },
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
// Unauthenticated flows (login, register, public share) — no stored session.
|
||||
{ name: 'public', testMatch: /\.public\.spec\.ts/, use: { ...devices['Desktop Chrome'] } },
|
||||
// One-time login that persists a session for the authenticated flows.
|
||||
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
|
||||
{
|
||||
name: 'app',
|
||||
testMatch: /\.spec\.ts/,
|
||||
testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/,
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
// Always start our own backend (never reuse) so the isolated test DB is
|
||||
// reset + reseeded on every run, regardless of any stray dev server.
|
||||
command: 'node e2e/server-launch.mjs',
|
||||
port: 3001,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
{
|
||||
command: 'npm run dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
// Guards the "Page = wiring container + data hook" convention (see
|
||||
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
|
||||
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
|
||||
//
|
||||
// We scan only the default-export component body (from `export default function`
|
||||
// up to the next top-level `function` declaration or EOF), so presentational
|
||||
// sub-components and helper hooks living in the same file are not flagged.
|
||||
// Context hooks like useTranslation/useParams are fine; the smell is stateful
|
||||
// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef.
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
|
||||
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
|
||||
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`)
|
||||
|
||||
const violations = []
|
||||
for (const file of readdirSync(pagesDir)) {
|
||||
if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue
|
||||
const src = readFileSync(join(pagesDir, file), 'utf8')
|
||||
const lines = src.split('\n')
|
||||
const start = lines.findIndex(l => /export default function/.test(l))
|
||||
if (start === -1) continue
|
||||
// The page body ends at the next top-level declaration (a `function` at
|
||||
// column 0) — everything after that is a sub-component or helper.
|
||||
let end = lines.length
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
|
||||
}
|
||||
for (let i = start; i < end; i++) {
|
||||
if (bannedRe.test(lines[i])) {
|
||||
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('Page-pattern violations — move this state/effect logic into the page\'s use<Page>() hook:\n')
|
||||
for (const v of violations) console.error(' ' + v)
|
||||
console.error(`\n${violations.length} violation(s). See src/pages/PATTERN.md.`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('Page pattern OK — no state/effect logic in page containers.')
|
||||
+182
-161
@@ -1,30 +1,31 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildSettings, buildUser } from '../tests/helpers/factories';
|
||||
import { server } from '../tests/helpers/msw/server';
|
||||
import { resetAllStores } from '../tests/helpers/store';
|
||||
import App from './App';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { useSettingsStore } from './store/settingsStore';
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../tests/helpers/msw/server'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { resetAllStores } from '../tests/helpers/store'
|
||||
import { buildUser, buildSettings } from '../tests/helpers/factories'
|
||||
import App from './App'
|
||||
|
||||
// ── Mock page components ───────────────────────────────────────────────────────
|
||||
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }));
|
||||
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }));
|
||||
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>TripPlanner</div> }));
|
||||
vi.mock('./pages/FilesPage', () => ({ default: () => <div>Files</div> }));
|
||||
vi.mock('./pages/AdminPage', () => ({ default: () => <div>Admin</div> }));
|
||||
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings</div> }));
|
||||
vi.mock('./pages/VacayPage', () => ({ default: () => <div>Vacay</div> }));
|
||||
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>Atlas</div> }));
|
||||
vi.mock('./pages/SharedTripPage', () => ({ default: () => <div>SharedTrip</div> }));
|
||||
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>Notifications</div> }));
|
||||
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }))
|
||||
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }))
|
||||
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>TripPlanner</div> }))
|
||||
vi.mock('./pages/FilesPage', () => ({ default: () => <div>Files</div> }))
|
||||
vi.mock('./pages/AdminPage', () => ({ default: () => <div>Admin</div> }))
|
||||
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings</div> }))
|
||||
vi.mock('./pages/VacayPage', () => ({ default: () => <div>Vacay</div> }))
|
||||
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>Atlas</div> }))
|
||||
vi.mock('./pages/SharedTripPage', () => ({ default: () => <div>SharedTrip</div> }))
|
||||
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>Notifications</div> }))
|
||||
|
||||
// Prevent WebSocket side effects from the notification listener
|
||||
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
|
||||
useInAppNotificationListener: vi.fn(),
|
||||
}));
|
||||
}))
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -33,7 +34,7 @@ function renderApp(initialPath = '/') {
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,64 +49,64 @@ function seedAuth(overrides: Record<string, unknown> = {}) {
|
||||
appRequireMfa: false,
|
||||
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
document.documentElement.classList.remove('dark');
|
||||
});
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
document.documentElement.classList.remove('dark')
|
||||
})
|
||||
|
||||
// ── RootRedirect ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('RootRedirect', () => {
|
||||
it('FE-COMP-APP-001: / redirects to /login when not authenticated', async () => {
|
||||
seedAuth({ isAuthenticated: false });
|
||||
renderApp('/');
|
||||
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
|
||||
});
|
||||
seedAuth({ isAuthenticated: false })
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-002: / redirects to /dashboard when authenticated', async () => {
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() });
|
||||
renderApp('/');
|
||||
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument());
|
||||
});
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-003: / shows loading spinner while auth is loading', () => {
|
||||
seedAuth({ isLoading: true, isAuthenticated: false });
|
||||
renderApp('/');
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Login')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
seedAuth({ isLoading: true, isAuthenticated: false })
|
||||
renderApp('/')
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Login')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ── ProtectedRoute — unauthenticated ──────────────────────────────────────────
|
||||
|
||||
describe('ProtectedRoute — unauthenticated', () => {
|
||||
it('FE-COMP-APP-004: /dashboard redirects to /login with redirect param when not authenticated', async () => {
|
||||
seedAuth({ isAuthenticated: false });
|
||||
renderApp('/dashboard');
|
||||
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
|
||||
});
|
||||
seedAuth({ isAuthenticated: false })
|
||||
renderApp('/dashboard')
|
||||
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-005: /trips/42 redirects to /login when not authenticated', async () => {
|
||||
seedAuth({ isAuthenticated: false });
|
||||
renderApp('/trips/42');
|
||||
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
seedAuth({ isAuthenticated: false })
|
||||
renderApp('/trips/42')
|
||||
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
// ── ProtectedRoute — loading ───────────────────────────────────────────────────
|
||||
|
||||
describe('ProtectedRoute — loading state', () => {
|
||||
it('FE-COMP-APP-006: protected route shows loading spinner while isLoading is true', () => {
|
||||
seedAuth({ isLoading: true, isAuthenticated: false });
|
||||
renderApp('/dashboard');
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
seedAuth({ isLoading: true, isAuthenticated: false })
|
||||
renderApp('/dashboard')
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ── ProtectedRoute — MFA enforcement ──────────────────────────────────────────
|
||||
|
||||
@@ -115,32 +116,32 @@ describe('ProtectedRoute — MFA enforcement', () => {
|
||||
isAuthenticated: true,
|
||||
appRequireMfa: true,
|
||||
user: buildUser({ mfa_enabled: false }),
|
||||
});
|
||||
renderApp('/dashboard');
|
||||
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument());
|
||||
});
|
||||
})
|
||||
renderApp('/dashboard')
|
||||
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-008: does NOT redirect when already on /settings even with MFA required', async () => {
|
||||
seedAuth({
|
||||
isAuthenticated: true,
|
||||
appRequireMfa: true,
|
||||
user: buildUser({ mfa_enabled: false }),
|
||||
});
|
||||
renderApp('/settings');
|
||||
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Login')).not.toBeInTheDocument();
|
||||
});
|
||||
})
|
||||
renderApp('/settings')
|
||||
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Login')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-009: does NOT redirect when user has MFA enabled', async () => {
|
||||
seedAuth({
|
||||
isAuthenticated: true,
|
||||
appRequireMfa: true,
|
||||
user: buildUser({ mfa_enabled: true }),
|
||||
});
|
||||
renderApp('/dashboard');
|
||||
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
})
|
||||
renderApp('/dashboard')
|
||||
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
// ── ProtectedRoute — admin role ────────────────────────────────────────────────
|
||||
|
||||
@@ -149,153 +150,173 @@ describe('ProtectedRoute — admin role check', () => {
|
||||
seedAuth({
|
||||
isAuthenticated: true,
|
||||
user: buildUser({ role: 'user' }),
|
||||
});
|
||||
renderApp('/admin');
|
||||
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
|
||||
});
|
||||
})
|
||||
renderApp('/admin')
|
||||
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-011: /admin is accessible for admin user', async () => {
|
||||
seedAuth({
|
||||
isAuthenticated: true,
|
||||
user: buildUser({ role: 'admin' }),
|
||||
});
|
||||
renderApp('/admin');
|
||||
await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
})
|
||||
renderApp('/admin')
|
||||
await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
// ── Public routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Public routes', () => {
|
||||
it('FE-COMP-APP-012: /login is accessible without authentication', async () => {
|
||||
seedAuth({ isAuthenticated: false });
|
||||
renderApp('/login');
|
||||
expect(screen.getByText('Login')).toBeInTheDocument();
|
||||
});
|
||||
seedAuth({ isAuthenticated: false })
|
||||
renderApp('/login')
|
||||
expect(screen.getByText('Login')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-013: /shared/:token is accessible without authentication', async () => {
|
||||
seedAuth({ isAuthenticated: false });
|
||||
renderApp('/shared/sometoken');
|
||||
expect(screen.getByText('SharedTrip')).toBeInTheDocument();
|
||||
});
|
||||
seedAuth({ isAuthenticated: false })
|
||||
renderApp('/shared/sometoken')
|
||||
expect(screen.getByText('SharedTrip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-014: unknown routes redirect to / which then redirects to /login', async () => {
|
||||
seedAuth({ isAuthenticated: false });
|
||||
renderApp('/does-not-exist');
|
||||
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
seedAuth({ isAuthenticated: false })
|
||||
renderApp('/does-not-exist')
|
||||
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
// ── App — on-mount effects ─────────────────────────────────────────────────────
|
||||
|
||||
describe('App — on-mount effects', () => {
|
||||
it('FE-COMP-APP-015: loadUser is called on mount for non-shared paths', async () => {
|
||||
const loadUser = vi.fn().mockResolvedValue(undefined);
|
||||
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser });
|
||||
renderApp('/dashboard');
|
||||
expect(loadUser).toHaveBeenCalled();
|
||||
});
|
||||
const loadUser = vi.fn().mockResolvedValue(undefined)
|
||||
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser })
|
||||
renderApp('/dashboard')
|
||||
expect(loadUser).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-016: loadUser is NOT called on /shared/ paths', async () => {
|
||||
const loadUser = vi.fn().mockResolvedValue(undefined);
|
||||
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser });
|
||||
renderApp('/shared/token123');
|
||||
expect(loadUser).not.toHaveBeenCalled();
|
||||
});
|
||||
const loadUser = vi.fn().mockResolvedValue(undefined)
|
||||
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser })
|
||||
renderApp('/shared/token123')
|
||||
expect(loadUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-017: GET /api/auth/app-config is called on mount', async () => {
|
||||
let configCalled = false;
|
||||
let configCalled = false
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => {
|
||||
configCalled = true;
|
||||
return HttpResponse.json({});
|
||||
configCalled = true
|
||||
return HttpResponse.json({})
|
||||
})
|
||||
);
|
||||
seedAuth();
|
||||
renderApp('/');
|
||||
await waitFor(() => expect(configCalled).toBe(true));
|
||||
});
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(configCalled).toBe(true))
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-018: setDemoMode(true) is called when config returns demo_mode: true', async () => {
|
||||
server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ demo_mode: true })));
|
||||
const setDemoMode = vi.fn();
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => HttpResponse.json({ demo_mode: true }))
|
||||
)
|
||||
const setDemoMode = vi.fn()
|
||||
useAuthStore.setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||
setDemoMode,
|
||||
});
|
||||
renderApp('/');
|
||||
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true));
|
||||
});
|
||||
})
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true))
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
|
||||
const loadSettings = vi.fn().mockResolvedValue(undefined);
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() });
|
||||
useSettingsStore.setState({ loadSettings });
|
||||
renderApp('/dashboard');
|
||||
await waitFor(() => expect(loadSettings).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
const loadSettings = vi.fn().mockResolvedValue(undefined)
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
useSettingsStore.setState({ loadSettings })
|
||||
renderApp('/dashboard')
|
||||
await waitFor(() => expect(loadSettings).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
|
||||
// ── Dark mode effects ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('Dark mode effects', () => {
|
||||
it('FE-COMP-APP-020: adds dark class to documentElement when dark_mode is true', async () => {
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() });
|
||||
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) });
|
||||
renderApp('/dashboard');
|
||||
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(true));
|
||||
});
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) })
|
||||
renderApp('/dashboard')
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-021: removes dark class when dark_mode is false', async () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() });
|
||||
useSettingsStore.setState({ settings: buildSettings({ dark_mode: false }) });
|
||||
renderApp('/dashboard');
|
||||
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false));
|
||||
});
|
||||
document.documentElement.classList.add('dark')
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
useSettingsStore.setState({ settings: buildSettings({ dark_mode: false }) })
|
||||
renderApp('/dashboard')
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-022: forces light mode on /shared/ path even when dark_mode is true', async () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) });
|
||||
seedAuth({ isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined) });
|
||||
renderApp('/shared/tok');
|
||||
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false));
|
||||
});
|
||||
document.documentElement.classList.add('dark')
|
||||
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) })
|
||||
seedAuth({ isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined) })
|
||||
renderApp('/shared/tok')
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-023: auto mode applies dark based on matchMedia result', async () => {
|
||||
// matchMedia stub returns matches: false by default (from setup.ts)
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() });
|
||||
useSettingsStore.setState({ settings: buildSettings({ dark_mode: 'auto' as any }) });
|
||||
renderApp('/dashboard');
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
useSettingsStore.setState({ settings: buildSettings({ dark_mode: 'auto' as any }) })
|
||||
renderApp('/dashboard')
|
||||
// With matches: false, dark should NOT be added
|
||||
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false));
|
||||
});
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Version cache-busting ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Version cache-busting', () => {
|
||||
it('FE-COMP-APP-024: stores version in localStorage when config returns a version', async () => {
|
||||
server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })));
|
||||
seedAuth();
|
||||
renderApp('/');
|
||||
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10'));
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
await waitFor(() =>
|
||||
expect(localStorage.getItem('trek_app_version')).toBe('2.9.10')
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-025: calls window.location.reload() when version changes', async () => {
|
||||
localStorage.setItem('trek_app_version', '2.9.9');
|
||||
const reload = vi.fn();
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
const reload = vi.fn()
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { ...window.location, reload },
|
||||
});
|
||||
})
|
||||
|
||||
server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })));
|
||||
seedAuth();
|
||||
renderApp('/');
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
|
||||
+134
-168
@@ -1,242 +1,208 @@
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { authApi } from './api/client';
|
||||
import BottomNav from './components/Layout/BottomNav';
|
||||
import OfflineBanner from './components/Layout/OfflineBanner';
|
||||
import { ToastContainer } from './components/shared/Toast';
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js';
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts';
|
||||
import { TranslationProvider, useTranslation } from './i18n';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
import AtlasPage from './pages/AtlasPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import FilesPage from './pages/FilesPage';
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage';
|
||||
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx';
|
||||
import JourneyDetailPage from './pages/JourneyDetailPage';
|
||||
import JourneyPage from './pages/JourneyPage';
|
||||
import JourneyPublicPage from './pages/JourneyPublicPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import OAuthAuthorizePage from './pages/OAuthAuthorizePage';
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import SharedTripPage from './pages/SharedTripPage';
|
||||
import TripPlannerPage from './pages/TripPlannerPage';
|
||||
import VacayPage from './pages/VacayPage';
|
||||
import { useAddonStore } from './store/addonStore';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { PermissionLevel, usePermissionsStore } from './store/permissionsStore';
|
||||
import { useSettingsStore } from './store/settingsStore';
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers';
|
||||
import React, { useEffect, ReactNode } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { useAddonStore } from './store/addonStore'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TripPlannerPage from './pages/TripPlannerPage'
|
||||
import FilesPage from './pages/FilesPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import VacayPage from './pages/VacayPage'
|
||||
import AtlasPage from './pages/AtlasPage'
|
||||
import JourneyPage from './pages/JourneyPage'
|
||||
import JourneyDetailPage from './pages/JourneyDetailPage'
|
||||
import JourneyPublicPage from './pages/JourneyPublicPage'
|
||||
import SharedTripPage from './pages/SharedTripPage'
|
||||
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import BottomNav from './components/Layout/BottomNav'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
// Notice action registrations (side-effect imports):
|
||||
import './pages/Trips/noticeActions.js';
|
||||
import './pages/Trips/noticeActions.js'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
adminRequired?: boolean;
|
||||
addonId?: string;
|
||||
children: ReactNode
|
||||
adminRequired?: boolean
|
||||
addonId?: string
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const appRequireMfa = useAuthStore((s) => s.appRequireMfa);
|
||||
const addonStore = useAddonStore();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isLoading = useAuthStore((s) => s.isLoading)
|
||||
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
|
||||
const addonStore = useAddonStore()
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-slate-900"></div>
|
||||
<p className="text-sm text-slate-500">{t('common.loading')}</p>
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
|
||||
<p className="text-slate-500 text-sm">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash);
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />;
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||
}
|
||||
|
||||
if (appRequireMfa && user && !user.mfa_enabled && location.pathname !== '/settings') {
|
||||
return <Navigate to="/settings?mfa=required" replace />;
|
||||
if (
|
||||
appRequireMfa &&
|
||||
user &&
|
||||
!user.mfa_enabled &&
|
||||
location.pathname !== '/settings'
|
||||
) {
|
||||
return <Navigate to="/settings?mfa=required" replace />
|
||||
}
|
||||
|
||||
if (adminRequired && user && user.role !== 'admin') {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col md:block md:h-auto">
|
||||
<div className="flex flex-col h-screen md:block md:h-auto">
|
||||
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function RootRedirect() {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
const { isAuthenticated, isLoading } = useAuthStore()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-slate-900"></div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return <Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />;
|
||||
return <Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
loadUser,
|
||||
isAuthenticated,
|
||||
demoMode,
|
||||
setDemoMode,
|
||||
setDevMode,
|
||||
setIsPrerelease,
|
||||
setAppVersion,
|
||||
setHasMapsKey,
|
||||
setServerTimezone,
|
||||
setAppRequireMfa,
|
||||
setTripRemindersEnabled,
|
||||
setPlacesPhotosEnabled,
|
||||
setPlacesAutocompleteEnabled,
|
||||
setPlacesDetailsEnabled,
|
||||
} = useAuthStore();
|
||||
const { loadSettings } = useSettingsStore();
|
||||
const { loadAddons } = useAddonStore();
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!location.pathname.startsWith('/shared/') &&
|
||||
!location.pathname.startsWith('/public/') &&
|
||||
!location.pathname.startsWith('/login')
|
||||
) {
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||
// If the persist snapshot already has an authenticated user, validate
|
||||
// silently so the PWA shell renders immediately without a spinner.
|
||||
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated
|
||||
if (alreadyAuthenticated) {
|
||||
useAuthStore.setState({ isLoading: false });
|
||||
loadUser({ silent: true });
|
||||
useAuthStore.setState({ isLoading: false })
|
||||
loadUser({ silent: true })
|
||||
} else {
|
||||
loadUser();
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi
|
||||
.getAppConfig()
|
||||
.then(
|
||||
async (config: {
|
||||
demo_mode?: boolean;
|
||||
dev_mode?: boolean;
|
||||
is_prerelease?: boolean;
|
||||
has_maps_key?: boolean;
|
||||
version?: string;
|
||||
timezone?: string;
|
||||
require_mfa?: boolean;
|
||||
trip_reminders_enabled?: boolean;
|
||||
places_photos_enabled?: boolean;
|
||||
places_autocomplete_enabled?: boolean;
|
||||
places_details_enabled?: boolean;
|
||||
permissions?: Record<string, PermissionLevel>;
|
||||
}) => {
|
||||
if (config?.demo_mode) setDemoMode(true);
|
||||
if (config?.dev_mode) setDevMode(true);
|
||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease);
|
||||
if (config?.version) setAppVersion(config.version);
|
||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key);
|
||||
if (config?.timezone) setServerTimezone(config.timezone);
|
||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa);
|
||||
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled);
|
||||
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled);
|
||||
if (config?.places_autocomplete_enabled !== undefined)
|
||||
setPlacesAutocompleteEnabled(config.places_autocomplete_enabled);
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled);
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions);
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||
if (config?.version) setAppVersion(config.version)
|
||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||
if (config?.timezone) setServerTimezone(config.timezone)
|
||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
|
||||
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
if (config?.version) {
|
||||
const storedVersion = localStorage.getItem('trek_app_version');
|
||||
if (storedVersion && storedVersion !== config.version) {
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys();
|
||||
await Promise.all(names.map((n) => caches.delete(n)));
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(regs.map((r) => r.unregister()));
|
||||
}
|
||||
} catch {}
|
||||
localStorage.setItem('trek_app_version', config.version);
|
||||
window.location.reload();
|
||||
return;
|
||||
if (config?.version) {
|
||||
const storedVersion = localStorage.getItem('trek_app_version')
|
||||
if (storedVersion && storedVersion !== config.version) {
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys()
|
||||
await Promise.all(names.map(n => caches.delete(n)))
|
||||
}
|
||||
localStorage.setItem('trek_app_version', config.version);
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(regs.map(r => r.unregister()))
|
||||
}
|
||||
} catch {}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const { settings } = useSettingsStore();
|
||||
const { settings } = useSettingsStore()
|
||||
|
||||
useInAppNotificationListener();
|
||||
useInAppNotificationListener()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSettings();
|
||||
loadAddons();
|
||||
loadSettings()
|
||||
loadAddons()
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
registerSyncTriggers();
|
||||
return () => unregisterSyncTriggers();
|
||||
}, []);
|
||||
registerSyncTriggers()
|
||||
return () => unregisterSyncTriggers()
|
||||
}, [])
|
||||
|
||||
const location = useLocation();
|
||||
const isSharedPage = location.pathname.startsWith('/shared/');
|
||||
const location = useLocation()
|
||||
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||
|
||||
useEffect(() => {
|
||||
// Shared page always forces light mode
|
||||
if (isSharedPage) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', '#ffffff');
|
||||
return;
|
||||
document.documentElement.classList.remove('dark')
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) meta.setAttribute('content', '#ffffff')
|
||||
return
|
||||
}
|
||||
|
||||
const mode = settings.dark_mode;
|
||||
const mode = settings.dark_mode
|
||||
const applyDark = (isDark: boolean) => {
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff');
|
||||
};
|
||||
document.documentElement.classList.toggle('dark', isDark)
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
|
||||
}
|
||||
|
||||
if (mode === 'auto') {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
applyDark(mq.matches);
|
||||
const handler = (e: MediaQueryListEvent) => applyDark(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
applyDark(mq.matches)
|
||||
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}
|
||||
applyDark(mode === true || mode === 'dark');
|
||||
}, [settings.dark_mode, isSharedPage]);
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [settings.dark_mode, isSharedPage])
|
||||
|
||||
const isAuthPage =
|
||||
location.pathname.startsWith('/login') ||
|
||||
location.pathname.startsWith('/register') ||
|
||||
location.pathname.startsWith('/forgot-password') ||
|
||||
location.pathname.startsWith('/reset-password');
|
||||
const isAuthPage = location.pathname.startsWith('/login')
|
||||
|| location.pathname.startsWith('/register')
|
||||
|| location.pathname.startsWith('/forgot-password')
|
||||
|| location.pathname.startsWith('/reset-password')
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
@@ -336,5 +302,5 @@ export default function App() {
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</TranslationProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
+245
-104
@@ -1,32 +1,112 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { WeatherResult } from '@trek/shared'
|
||||
import type { z } from 'zod'
|
||||
import {
|
||||
weatherResultSchema, type WeatherResult,
|
||||
inAppListResultSchema, type InAppListResult,
|
||||
unreadCountResultSchema, type UnreadCountResult,
|
||||
channelTestResultSchema,
|
||||
mapsSearchResultSchema, mapsAutocompleteResultSchema, mapsPlaceDetailsResultSchema,
|
||||
mapsPlacePhotoResultSchema, mapsReverseResultSchema, mapsResolveUrlResultSchema,
|
||||
type NotificationRespondRequest,
|
||||
type SettingUpsertRequest, type SettingsBulkRequest,
|
||||
type JourneyCreateRequest, type JourneyAddTripRequest,
|
||||
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
|
||||
type JourneyShareLinkRequest,
|
||||
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
||||
type ResetPasswordRequest, type ChangePasswordRequest,
|
||||
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
|
||||
type TripAddMemberRequest, type AssignmentReorderRequest,
|
||||
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
||||
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
|
||||
type PlaceCreateRequest, type PlaceUpdateRequest,
|
||||
type ReservationCreateRequest, type ReservationUpdateRequest,
|
||||
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
||||
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
|
||||
type PackingCreateItemRequest, type PackingUpdateItemRequest,
|
||||
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
|
||||
type PlaceBulkDeleteRequest,
|
||||
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
||||
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
|
||||
type PackingCategoryAssigneesRequest,
|
||||
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
|
||||
type TodoCategoryAssigneesRequest,
|
||||
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
|
||||
type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest,
|
||||
type FileUpdateRequest, type FileLinkRequest,
|
||||
type CreateTagRequest, type UpdateTagRequest,
|
||||
type CreateCategoryRequest, type UpdateCategoryRequest,
|
||||
type PlaceImportListRequest,
|
||||
type BookingImportPreviewItem,
|
||||
type BookingImportPreviewResponse,
|
||||
type BookingImportConfirmResponse,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
import en from '../i18n/translations/en'
|
||||
import br from '../i18n/translations/br'
|
||||
import de from '../i18n/translations/de'
|
||||
import es from '../i18n/translations/es'
|
||||
import fr from '../i18n/translations/fr'
|
||||
import it from '../i18n/translations/it'
|
||||
import nl from '../i18n/translations/nl'
|
||||
import pl from '../i18n/translations/pl'
|
||||
import cs from '../i18n/translations/cs'
|
||||
import hu from '../i18n/translations/hu'
|
||||
import ru from '../i18n/translations/ru'
|
||||
import zh from '../i18n/translations/zh'
|
||||
import zhTw from '../i18n/translations/zhTw'
|
||||
import ar from '../i18n/translations/ar'
|
||||
|
||||
const rateLimitTranslations: Record<string, Record<string, string | unknown>> = {
|
||||
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar,
|
||||
/**
|
||||
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||
* dev, and never throwing. A drift between the server contract and the client's
|
||||
* expected shape is surfaced as a console warning during development; in
|
||||
* production (and on any mismatch) the data passes through untouched, so adding
|
||||
* validation can never break a working call. This is the typed-request helper
|
||||
* the FE adopts per domain as each backend module lands on @trek/shared.
|
||||
*/
|
||||
const API_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV)
|
||||
export function parseInDev<S extends z.ZodTypeAny>(schema: S, data: unknown, label: string): z.infer<S> {
|
||||
if (API_DEV) {
|
||||
const result = schema.safeParse(data)
|
||||
if (!result.success) {
|
||||
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
|
||||
}
|
||||
}
|
||||
return data as z.infer<S>
|
||||
}
|
||||
|
||||
/**
|
||||
* Same dev-only drift check as parseInDev, but passes the payload straight
|
||||
* through with its original inferred type instead of the schema type. Use this
|
||||
* for endpoints whose existing consumers rely on the loose `r.data` type — it
|
||||
* adds the development contract-drift warning without retyping the public
|
||||
* surface (so it can never break a consumer that worked before).
|
||||
*/
|
||||
function checkInDev<T>(schema: z.ZodTypeAny, data: T, label: string): T {
|
||||
if (API_DEV) {
|
||||
const result = schema.safeParse(data)
|
||||
if (!result.success) {
|
||||
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||
en: 'Too many attempts. Please try again later.',
|
||||
de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
|
||||
es: 'Demasiados intentos. Inténtelo de nuevo más tarde.',
|
||||
fr: 'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||
hu: 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
|
||||
nl: 'Te veel pogingen. Probeer het later opnieuw.',
|
||||
br: 'Muitas tentativas. Tente novamente mais tarde.',
|
||||
cs: 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
|
||||
pl: 'Zbyt wiele prób. Spróbuj ponownie później.',
|
||||
ru: 'Слишком много попыток. Попробуйте позже.',
|
||||
zh: '尝试次数过多,请稍后再试。',
|
||||
'zh-TW': '嘗試次數過多,請稍後再試。',
|
||||
it: 'Troppi tentativi. Riprova più tardi.',
|
||||
tr: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.',
|
||||
ar: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||
id: 'Terlalu banyak percobaan. Coba lagi nanti.',
|
||||
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
||||
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
||||
}
|
||||
|
||||
function translateRateLimit(): string {
|
||||
const fallback = 'Too many attempts. Please try again later.'
|
||||
const fallback = RATE_LIMIT_MESSAGES['en']!
|
||||
try {
|
||||
const lang = localStorage.getItem('app_language') || 'en'
|
||||
const table = rateLimitTranslations[lang] || rateLimitTranslations.en
|
||||
return (table['common.tooManyAttempts'] as string) || (rateLimitTranslations.en['common.tooManyAttempts'] as string) || fallback
|
||||
return RATE_LIMIT_MESSAGES[lang] ?? fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
@@ -152,12 +232,12 @@ apiClient.interceptors.response.use(
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||
register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
|
||||
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||
login: (data: LoginRequest) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||
verifyMfaLogin: (data: MfaVerifyLoginRequest) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
||||
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
|
||||
mfaEnable: (data: MfaEnableRequest) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
|
||||
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
|
||||
me: () => apiClient.get('/auth/me').then(r => r.data),
|
||||
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||
@@ -171,16 +251,34 @@ export const authApi = {
|
||||
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
|
||||
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
||||
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
||||
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||
forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
|
||||
resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
|
||||
changePassword: (data: ChangePasswordRequest) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||
forgotPassword: (data: ForgotPasswordRequest) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
|
||||
resetPassword: (data: ResetPasswordRequest) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
|
||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||
mcpTokens: {
|
||||
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
|
||||
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
|
||||
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
||||
},
|
||||
passkey: {
|
||||
registerOptions: (password: string) => apiClient.post('/auth/passkey/register/options', { password }).then(r => r.data),
|
||||
registerVerify: (attestationResponse: unknown, name?: string) => apiClient.post('/auth/passkey/register/verify', { attestationResponse, name }).then(r => r.data),
|
||||
loginOptions: () => apiClient.post('/auth/passkey/login/options', {}).then(r => r.data),
|
||||
loginVerify: (assertionResponse: unknown) => apiClient.post('/auth/passkey/login/verify', { assertionResponse }).then(r => r.data as { token: string; user: Record<string, unknown> }),
|
||||
list: () => apiClient.get('/auth/passkey/credentials').then(r => r.data as { credentials: PasskeyCredential[] }),
|
||||
rename: (id: number, name: string) => apiClient.patch(`/auth/passkey/credentials/${id}`, { name }).then(r => r.data),
|
||||
delete: (id: number, password: string) => apiClient.delete(`/auth/passkey/credentials/${id}`, { data: { password } }).then(r => r.data),
|
||||
},
|
||||
}
|
||||
|
||||
export interface PasskeyCredential {
|
||||
id: number
|
||||
name: string | null
|
||||
device_type: string | null
|
||||
backed_up: boolean
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
export const oauthApi = {
|
||||
@@ -224,32 +322,33 @@ export const oauthApi = {
|
||||
|
||||
export const tripsApi = {
|
||||
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
|
||||
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
|
||||
create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data),
|
||||
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
|
||||
update: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
||||
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
||||
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
||||
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
||||
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data),
|
||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const daysApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const placesApi = {
|
||||
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
||||
@@ -267,65 +366,66 @@ export const placesApi = {
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
|
||||
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds } satisfies AssignmentReorderRequest).then(r => r.data),
|
||||
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
|
||||
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
||||
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data),
|
||||
updateTime: (tripId: number | string, id: number, times: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
|
||||
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||
}
|
||||
|
||||
export const packingApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: PackingCreateItemRequest) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items } satisfies PackingImportRequest).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
|
||||
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
|
||||
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
||||
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
||||
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data),
|
||||
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
|
||||
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
||||
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
||||
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
||||
createBag: (tripId: number | string, data: PackingCreateBagRequest) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
||||
updateBag: (tripId: number | string, bagId: number, data: PackingUpdateBagRequest) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
||||
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const todoApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: TodoCreateItemRequest) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds } satisfies TodoReorderRequest).then(r => r.data),
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies TodoCategoryAssigneesRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const tagsApi = {
|
||||
list: () => apiClient.get('/tags').then(r => r.data),
|
||||
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
|
||||
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
|
||||
create: (data: CreateTagRequest) => apiClient.post('/tags', data).then(r => r.data),
|
||||
update: (id: number, data: UpdateTagRequest) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const categoriesApi = {
|
||||
list: () => apiClient.get('/categories').then(r => r.data),
|
||||
create: (data: Record<string, unknown>) => apiClient.post('/categories', data).then(r => r.data),
|
||||
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
|
||||
create: (data: CreateCategoryRequest) => apiClient.post('/categories', data).then(r => r.data),
|
||||
update: (id: number, data: UpdateCategoryRequest) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
@@ -334,6 +434,7 @@ export const adminApi = {
|
||||
createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
|
||||
updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
|
||||
deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
|
||||
resetUserPasskeys: (id: number) => apiClient.delete(`/admin/users/${id}/passkeys`).then(r => r.data),
|
||||
stats: () => apiClient.get('/admin/stats').then(r => r.data),
|
||||
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
|
||||
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
|
||||
@@ -386,9 +487,23 @@ export const addonsApi = {
|
||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||
}
|
||||
|
||||
export const airtrailApi = {
|
||||
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
|
||||
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
||||
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
|
||||
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
|
||||
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
||||
apiClient.post('/integrations/airtrail/test', data).then(r => r.data),
|
||||
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
|
||||
// flights + import are added with the trip-planner import (P2)
|
||||
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
|
||||
import: (tripId: number, flightIds: string[]) =>
|
||||
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const journeyApi = {
|
||||
list: () => apiClient.get('/journeys').then(r => r.data),
|
||||
create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data),
|
||||
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
||||
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
|
||||
update: (id: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data),
|
||||
@@ -397,7 +512,7 @@ export const journeyApi = {
|
||||
availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data),
|
||||
|
||||
// Trips (sync sources)
|
||||
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data),
|
||||
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId } satisfies JourneyAddTripRequest).then(r => r.data),
|
||||
removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data),
|
||||
|
||||
// Entries
|
||||
@@ -405,7 +520,7 @@ export const journeyApi = {
|
||||
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
||||
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
||||
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds } satisfies JourneyReorderEntriesRequest).then(r => r.data),
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
@@ -422,7 +537,7 @@ export const journeyApi = {
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
|
||||
@@ -444,19 +559,24 @@ export const journeyApi = {
|
||||
|
||||
// Share
|
||||
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
|
||||
createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
|
||||
createShareLink: (id: number, perms: JourneyShareLinkRequest) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
|
||||
deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data),
|
||||
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
@@ -466,15 +586,18 @@ export const airportsApi = {
|
||||
|
||||
export const budgetApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: BudgetCreateItemRequest) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
|
||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
|
||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
|
||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data),
|
||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
|
||||
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
|
||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories }).then(r => r.data),
|
||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
@@ -482,28 +605,40 @@ export const filesApi = {
|
||||
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
}).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: FileUpdateRequest) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
|
||||
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
|
||||
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
|
||||
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
|
||||
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
||||
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||
addLink: (tripId: number | string, fileId: number, data: FileLinkRequest) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
|
||||
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const reservationsApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data),
|
||||
create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
||||
importBookingPreview: (tripId: number | string, files: File[]): Promise<BookingImportPreviewResponse> => {
|
||||
const fd = new FormData()
|
||||
for (const f of files) fd.append('files', f)
|
||||
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const healthApi = {
|
||||
features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
@@ -513,40 +648,46 @@ export const configApi = {
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => apiClient.get('/settings').then(r => r.data),
|
||||
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
|
||||
setBulk: (settings: Record<string, unknown>) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
|
||||
set: (key: string, value: unknown) => {
|
||||
const body: SettingUpsertRequest = { key, value }
|
||||
return apiClient.put('/settings', body).then(r => r.data)
|
||||
},
|
||||
setBulk: (settings: Record<string, unknown>) => {
|
||||
const body: SettingsBulkRequest = { settings }
|
||||
return apiClient.post('/settings/bulk', body).then(r => r.data)
|
||||
},
|
||||
}
|
||||
|
||||
export const accommodationsApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const dayNotesApi = {
|
||||
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: DayNoteCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, id: number, data: DayNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const collabApi = {
|
||||
getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
|
||||
createNote: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
|
||||
updateNote: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
|
||||
createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
|
||||
updateNote: (tripId: number | string, id: number, data: CollabNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
|
||||
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
||||
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
||||
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
||||
createPoll: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data),
|
||||
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
|
||||
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
||||
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
||||
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data),
|
||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
|
||||
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
|
||||
}
|
||||
|
||||
@@ -587,16 +728,16 @@ export const shareApi = {
|
||||
export const notificationsApi = {
|
||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
|
||||
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
|
||||
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
|
||||
}
|
||||
|
||||
export const inAppNotificationsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||
unreadCount: () =>
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }): Promise<InAppListResult> =>
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => parseInDev(inAppListResultSchema, r.data, 'notifications.list')),
|
||||
unreadCount: (): Promise<UnreadCountResult> =>
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => parseInDev(unreadCountResultSchema, r.data, 'notifications.unreadCount')),
|
||||
markRead: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||
markUnread: (id: number) =>
|
||||
@@ -607,7 +748,7 @@ export const inAppNotificationsApi = {
|
||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||
deleteAll: () =>
|
||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||
respond: (id: number, response: 'positive' | 'negative') =>
|
||||
respond: (id: number, response: NotificationRespondRequest['response']) =>
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ export function getSocketId(): string | null {
|
||||
return mySocketId
|
||||
}
|
||||
|
||||
/** Trip ids the app currently has open (joined). Used to re-hydrate the active
|
||||
* trip's store after the network comes back via the `online` event. */
|
||||
export function getActiveTrips(): string[] {
|
||||
return Array.from(activeTrips)
|
||||
}
|
||||
|
||||
export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import AddonManager from './AddonManager';
|
||||
|
||||
@@ -36,7 +36,9 @@ beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: { dark_mode: false } });
|
||||
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
||||
server.use(http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })));
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -47,7 +49,7 @@ describe('AddonManager', () => {
|
||||
it('FE-ADMIN-ADDON-001: loading spinner shown while fetching', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ addons: [] });
|
||||
})
|
||||
);
|
||||
@@ -93,20 +95,19 @@ describe('AddonManager', () => {
|
||||
it('FE-ADMIN-ADDON-005: toggle enables a disabled addon (optimistic update)', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })),
|
||||
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
|
||||
),
|
||||
http.put('/api/admin/addons/todo', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
// Get toggle button - use getAllByRole since there might be multiple buttons
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const toggleBtn = buttons.find((b) => b.classList.contains('rounded-full'));
|
||||
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
|
||||
expect(toggleBtn).toBeInTheDocument();
|
||||
|
||||
// Before click - disabled state (border-primary bg)
|
||||
@@ -119,19 +120,18 @@ describe('AddonManager', () => {
|
||||
it('FE-ADMIN-ADDON-006: toggle rolls back on API failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })),
|
||||
http.put('/api/admin/addons/todo', () => HttpResponse.error())
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
|
||||
),
|
||||
http.put('/api/admin/addons/todo', () =>
|
||||
HttpResponse.error()
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const toggleBtn = buttons.find((b) => b.classList.contains('rounded-full'));
|
||||
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
|
||||
await user.click(toggleBtn!);
|
||||
|
||||
// Error toast appears
|
||||
@@ -148,18 +148,19 @@ describe('AddonManager', () => {
|
||||
const user = userEvent.setup();
|
||||
const mockToggle = vi.fn();
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }))
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
|
||||
)
|
||||
);
|
||||
render(
|
||||
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />
|
||||
);
|
||||
render(<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />);
|
||||
await screen.findByText('Bag Tracking');
|
||||
const bagTrackingToggle = screen
|
||||
.getAllByRole('button')
|
||||
.find(
|
||||
(b) =>
|
||||
b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking')
|
||||
);
|
||||
const bagTrackingToggle = screen.getAllByRole('button').find(b =>
|
||||
b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking')
|
||||
);
|
||||
// Click the bag tracking toggle button (the h-6 w-11 button near "Bag Tracking")
|
||||
const allBtns = screen.getAllByRole('button').filter((b) => b.classList.contains('rounded-full'));
|
||||
const allBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||
// There should be two toggle buttons: one for the addon, one for bag tracking
|
||||
await user.click(allBtns[allBtns.length - 1]);
|
||||
expect(mockToggle).toHaveBeenCalled();
|
||||
@@ -171,14 +172,18 @@ describe('AddonManager', () => {
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: false })] })
|
||||
)
|
||||
);
|
||||
render(<AddonManager bagTrackingEnabled={false} onToggleBagTracking={vi.fn()} />);
|
||||
render(
|
||||
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={vi.fn()} />
|
||||
);
|
||||
await screen.findByText('Lists');
|
||||
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-009: bag tracking hidden when onToggleBagTracking prop not provided', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }))
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
|
||||
)
|
||||
);
|
||||
render(<AddonManager bagTrackingEnabled={false} />);
|
||||
await screen.findByText('Lists');
|
||||
@@ -208,7 +213,7 @@ describe('AddonManager', () => {
|
||||
expect(screen.getByText('Journey')).toBeInTheDocument();
|
||||
|
||||
// Toggle buttons: journey toggle + 2 provider toggles
|
||||
const toggleBtns = screen.getAllByRole('button').filter((b) => b.classList.contains('rounded-full'));
|
||||
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||
expect(toggleBtns.length).toBe(3);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,248 +1,173 @@
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
CalendarDays,
|
||||
Compass,
|
||||
FileText,
|
||||
Globe,
|
||||
Image,
|
||||
Link2,
|
||||
ListChecks,
|
||||
Luggage,
|
||||
MessageCircle,
|
||||
Puzzle,
|
||||
Sparkles,
|
||||
StickyNote,
|
||||
Terminal,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { useEffect, useState } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks,
|
||||
Wallet,
|
||||
FileText,
|
||||
CalendarDays,
|
||||
Puzzle,
|
||||
Globe,
|
||||
Briefcase,
|
||||
Image,
|
||||
Terminal,
|
||||
Link2,
|
||||
Compass,
|
||||
BookOpen,
|
||||
};
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
||||
<path
|
||||
d="M11.986.27c-2.409 0-5.207 1.09-5.207 3.894v.152c1.343.597 2.935 1.663 4.412 2.971 1.571 1.391 2.838 2.882 3.653 4.287 1.4-2.503 2.336-5.478 2.347-7.373V4.164c0-2.803-2.796-3.894-5.205-3.894m7.512 4.49c-.378-.008-.775.05-1.192.186l-.144.047c-.153 1.461-.676 3.304-1.463 5.113-.837 1.924-1.863 3.59-2.947 4.799 2.813.558 5.93.527 7.736-.047l.035-.01c2.667-.866 2.84-3.863 2.096-6.154-.628-1.933-2.081-3.89-4.121-3.934m-14.996.04c-2.04.043-3.493 1.997-4.121 3.93-.744 2.291-.571 5.288 2.096 6.155l.144.046c.982-1.092 2.488-2.276 4.188-3.277 1.809-1.065 3.619-1.808 5.207-2.148-1.949-2.105-4.489-3.914-6.287-4.51l-.036-.012c-.416-.135-.813-.193-1.191-.185m4.672 6.758c-2.604 1.202-5.109 3.06-6.233 4.586l-.021.029c-1.648 2.268-.027 4.795 1.922 6.211 1.949 1.416 4.852 2.177 6.5-.092.023-.031.054-.07.09-.121-.736-1.272-1.396-3.072-1.822-4.998-.454-2.05-.603-4-.436-5.615m1.072 3.338c.339 2.848 1.332 5.804 2.436 7.344l.021.029c1.648 2.268 4.551 1.508 6.5.092 1.949-1.416 3.57-3.943 1.922-6.211-.023-.031-.052-.073-.088-.123-1.437.307-3.352.38-5.316.19-2.089-.202-3.99-.663-5.475-1.321"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M11.986.27c-2.409 0-5.207 1.09-5.207 3.894v.152c1.343.597 2.935 1.663 4.412 2.971 1.571 1.391 2.838 2.882 3.653 4.287 1.4-2.503 2.336-5.478 2.347-7.373V4.164c0-2.803-2.796-3.894-5.205-3.894m7.512 4.49c-.378-.008-.775.05-1.192.186l-.144.047c-.153 1.461-.676 3.304-1.463 5.113-.837 1.924-1.863 3.59-2.947 4.799 2.813.558 5.93.527 7.736-.047l.035-.01c2.667-.866 2.84-3.863 2.096-6.154-.628-1.933-2.081-3.89-4.121-3.934m-14.996.04c-2.04.043-3.493 1.997-4.121 3.93-.744 2.291-.571 5.288 2.096 6.155l.144.046c.982-1.092 2.488-2.276 4.188-3.277 1.809-1.065 3.619-1.808 5.207-2.148-1.949-2.105-4.489-3.914-6.287-4.51l-.036-.012c-.416-.135-.813-.193-1.191-.185m4.672 6.758c-2.604 1.202-5.109 3.06-6.233 4.586l-.021.029c-1.648 2.268-.027 4.795 1.922 6.211 1.949 1.416 4.852 2.177 6.5-.092.023-.031.054-.07.09-.121-.736-1.272-1.396-3.072-1.822-4.998-.454-2.05-.603-4-.436-5.615m1.072 3.338c.339 2.848 1.332 5.804 2.436 7.344l.021.029c1.648 2.268 4.551 1.508 6.5.092 1.949-1.416 3.57-3.943 1.922-6.211-.023-.031-.052-.073-.088-.123-1.437.307-3.352.38-5.316.19-2.089-.202-3.99-.663-5.475-1.321" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function SynologyIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
||||
<path
|
||||
d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<string, React.FC<{ size?: number }>> = {
|
||||
immich: ImmichIcon,
|
||||
synologyphotos: SynologyIcon,
|
||||
};
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ProviderOption {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
toggle: () => Promise<void>;
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
toggle: () => Promise<void>
|
||||
}
|
||||
|
||||
interface AddonIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
name: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
const Icon = ICON_MAP[name] || Puzzle;
|
||||
return <Icon size={size} />;
|
||||
const Icon = ICON_MAP[name] || Puzzle
|
||||
return <Icon size={size} />
|
||||
}
|
||||
|
||||
interface CollabFeatures {
|
||||
chat: boolean;
|
||||
notes: boolean;
|
||||
polls: boolean;
|
||||
whatsnext: boolean;
|
||||
}
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{
|
||||
key: 'whatsnext',
|
||||
icon: Sparkles,
|
||||
titleKey: 'admin.collab.whatsnext.title',
|
||||
subtitleKey: 'admin.collab.whatsnext.subtitle',
|
||||
},
|
||||
] as const;
|
||||
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||
] as const
|
||||
|
||||
export default function AddonManager({
|
||||
bagTrackingEnabled,
|
||||
onToggleBagTracking,
|
||||
collabFeatures,
|
||||
onToggleCollabFeature,
|
||||
}: {
|
||||
bagTrackingEnabled?: boolean;
|
||||
onToggleBagTracking?: () => void;
|
||||
collabFeatures?: CollabFeatures;
|
||||
onToggleCollabFeature?: (key: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const dm = useSettingsStore((s) => s.settings.dark_mode);
|
||||
const dark =
|
||||
dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const toast = useToast();
|
||||
const refreshGlobalAddons = useAddonStore((s) => s.loadAddons);
|
||||
const [addons, setAddons] = useState<Addon[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const toast = useToast()
|
||||
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
|
||||
const [addons, setAddons] = useState<Addon[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons();
|
||||
}, []);
|
||||
loadAddons()
|
||||
}, [])
|
||||
|
||||
const loadAddons = async () => {
|
||||
setLoading(true);
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.addons();
|
||||
setAddons(data.addons);
|
||||
const data = await adminApi.addons()
|
||||
setAddons(data.addons)
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('admin.addons.toast.error'));
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleToggle = async (addon: Addon) => {
|
||||
const newEnabled = !addon.enabled;
|
||||
const newEnabled = !addon.enabled
|
||||
// Optimistic update
|
||||
setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: newEnabled } : a)));
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||
try {
|
||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled });
|
||||
refreshGlobalAddons();
|
||||
toast.success(t('admin.addons.toast.updated'));
|
||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch (err: unknown) {
|
||||
// Rollback
|
||||
setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: !newEnabled } : a)));
|
||||
toast.error(t('admin.addons.toast.error'));
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const isPhotoProviderAddon = (addon: Addon) => {
|
||||
return addon.type === 'photo_provider';
|
||||
};
|
||||
return addon.type === 'photo_provider'
|
||||
}
|
||||
|
||||
const isPhotosAddon = (addon: Addon) => {
|
||||
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase();
|
||||
return (
|
||||
addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
|
||||
);
|
||||
};
|
||||
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
|
||||
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
|
||||
}
|
||||
|
||||
const handleTogglePhotoProvider = async (providerAddon: Addon) => {
|
||||
const enableProvider = !providerAddon.enabled;
|
||||
const prev = addons;
|
||||
const enableProvider = !providerAddon.enabled
|
||||
const prev = addons
|
||||
|
||||
setAddons((current) => current.map((a) => (a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a)));
|
||||
setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
|
||||
|
||||
try {
|
||||
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider });
|
||||
refreshGlobalAddons();
|
||||
toast.success(t('admin.addons.toast.updated'));
|
||||
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch {
|
||||
setAddons(prev);
|
||||
toast.error(t('admin.addons.toast.error'));
|
||||
setAddons(prev)
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const photoProviderAddons = addons.filter(isPhotoProviderAddon);
|
||||
const photosAddon = addons.filter((a) => a.type === 'trip').find(isPhotosAddon);
|
||||
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
|
||||
const globalAddons = addons.filter((a) => a.type === 'global');
|
||||
const integrationAddons = addons.filter((a) => a.type === 'integration');
|
||||
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
|
||||
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
|
||||
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
|
||||
const globalAddons = addons.filter(a => a.type === 'global')
|
||||
const integrationAddons = addons.filter(a => a.type === 'integration')
|
||||
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
description: provider.description,
|
||||
enabled: provider.enabled,
|
||||
toggle: () => handleTogglePhotoProvider(provider),
|
||||
}));
|
||||
const photosDerivedEnabled = providerOptions.some((p) => p.enabled);
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
description: provider.description,
|
||||
enabled: provider.enabled,
|
||||
toggle: () => handleTogglePhotoProvider(provider),
|
||||
}))
|
||||
const photosDerivedEnabled = providerOptions.some(p => p.enabled)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<div
|
||||
className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-slate-200 border-t-slate-900"
|
||||
style={{ borderTopColor: 'var(--text-primary)' }}
|
||||
></div>
|
||||
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" style={{ borderTopColor: 'var(--text-primary)' }}></div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="overflow-hidden rounded-xl border"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<div className="border-b px-6 py-4" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('admin.addons.title')}
|
||||
</h2>
|
||||
<p
|
||||
className="mt-1 text-xs"
|
||||
style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}
|
||||
>
|
||||
{t('admin.addons.subtitleBefore')}
|
||||
<img
|
||||
src={dark ? '/text-light.svg' : '/text-dark.svg'}
|
||||
alt="TREK"
|
||||
style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }}
|
||||
/>
|
||||
{t('admin.addons.subtitleAfter')}
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="px-6 py-4 border-b border-edge-secondary">
|
||||
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{addons.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm" style={{ color: 'var(--text-faint)' }}>
|
||||
<div className="p-8 text-center text-sm text-content-faint">
|
||||
{t('admin.addons.noAddons')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -250,100 +175,61 @@ export default function AddonManager({
|
||||
{/* Trip Addons */}
|
||||
{tripAddons.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 border-b px-6 py-2.5"
|
||||
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}
|
||||
>
|
||||
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Briefcase size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
||||
</span>
|
||||
</div>
|
||||
{tripAddons.map((addon) => (
|
||||
{tripAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||
<div
|
||||
className="flex items-center gap-4 border-b px-6 py-3"
|
||||
style={{
|
||||
borderColor: 'var(--border-secondary)',
|
||||
background: 'var(--bg-secondary)',
|
||||
paddingLeft: 70,
|
||||
}}
|
||||
>
|
||||
<Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<Luggage size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('admin.bagTracking.title')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('admin.bagTracking.subtitle')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className="hidden text-xs font-medium sm:inline"
|
||||
style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={onToggleBagTracking}
|
||||
<button onClick={onToggleBagTracking}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
||||
<div
|
||||
className="border-b px-6 py-3"
|
||||
style={{
|
||||
borderColor: 'var(--border-secondary)',
|
||||
background: 'var(--bg-secondary)',
|
||||
paddingLeft: 70,
|
||||
}}
|
||||
>
|
||||
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{COLLAB_SUB_FEATURES.map((feat) => {
|
||||
const enabled = collabFeatures[feat.key];
|
||||
const Icon = feat.icon;
|
||||
{COLLAB_SUB_FEATURES.map(feat => {
|
||||
const enabled = collabFeatures[feat.key]
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<Icon size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t(feat.titleKey)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{t(feat.subtitleKey)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className="hidden text-xs font-medium sm:inline"
|
||||
style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onToggleCollabFeature(feat.key)}
|
||||
<button onClick={() => onToggleCollabFeature(feat.key)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,68 +242,43 @@ export default function AddonManager({
|
||||
{/* Global Addons */}
|
||||
{globalAddons.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-t px-6 py-2.5"
|
||||
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}
|
||||
>
|
||||
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Globe size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
||||
</span>
|
||||
</div>
|
||||
{globalAddons.map((addon) => (
|
||||
{globalAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{/* Memories providers as sub-items under Journey addon */}
|
||||
{addon.id === 'journey' && providerOptions.length > 0 && (
|
||||
<div
|
||||
className="border-b px-6 py-3"
|
||||
style={{
|
||||
borderColor: 'var(--border-secondary)',
|
||||
background: 'var(--bg-secondary)',
|
||||
paddingLeft: 70,
|
||||
}}
|
||||
>
|
||||
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map((provider) => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key];
|
||||
{providerOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
{ProviderIcon && (
|
||||
<span style={{ color: 'var(--text-faint)' }}>
|
||||
<ProviderIcon size={14} />
|
||||
</span>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{provider.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{provider.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className="hidden text-xs font-medium sm:inline"
|
||||
style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}
|
||||
>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{
|
||||
background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
{ProviderIcon && <span className="text-content-faint"><ProviderIcon size={14} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
|
||||
</div>
|
||||
);
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -430,16 +291,13 @@ export default function AddonManager({
|
||||
{/* Integration Addons */}
|
||||
{integrationAddons.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-t px-6 py-2.5"
|
||||
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}
|
||||
>
|
||||
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Link2 size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
||||
</span>
|
||||
</div>
|
||||
{integrationAddons.map((addon) => (
|
||||
{integrationAddons.map(addon => (
|
||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||
))}
|
||||
</div>
|
||||
@@ -448,122 +306,79 @@ export default function AddonManager({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
interface AddonRowProps {
|
||||
addon: Addon;
|
||||
onToggle: (addon: Addon) => void;
|
||||
t: (key: string) => string;
|
||||
statusOverride?: boolean;
|
||||
hideToggle?: boolean;
|
||||
addon: Addon
|
||||
onToggle: (addon: Addon) => void
|
||||
t: (key: string) => string
|
||||
statusOverride?: boolean
|
||||
hideToggle?: boolean
|
||||
}
|
||||
|
||||
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||
const nameKey = `admin.addons.catalog.${addon.id}.name`;
|
||||
const descKey = `admin.addons.catalog.${addon.id}.description`;
|
||||
const translatedName = t(nameKey);
|
||||
const translatedDescription = t(descKey);
|
||||
const nameKey = `admin.addons.catalog.${addon.id}.name`
|
||||
const descKey = `admin.addons.catalog.${addon.id}.description`
|
||||
const translatedName = t(nameKey)
|
||||
const translatedDescription = t(descKey)
|
||||
|
||||
return {
|
||||
name: translatedName !== nameKey ? translatedName : addon.name,
|
||||
description: translatedDescription !== descKey ? translatedDescription : addon.description,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function AddonRow({
|
||||
addon,
|
||||
onToggle,
|
||||
t,
|
||||
nameOverride,
|
||||
descriptionOverride,
|
||||
statusOverride,
|
||||
hideToggle,
|
||||
}: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
|
||||
const isComingSoon = false;
|
||||
const label = getAddonLabel(t, addon);
|
||||
const displayName = nameOverride || label.name;
|
||||
const displayDescription = descriptionOverride || label.description;
|
||||
const enabledState = statusOverride ?? addon.enabled;
|
||||
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
|
||||
const isComingSoon = false
|
||||
const label = getAddonLabel(t, addon)
|
||||
const displayName = nameOverride || label.name
|
||||
const displayDescription = descriptionOverride || label.description
|
||||
const enabledState = statusOverride ?? addon.enabled
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-4 border-b px-6 py-4 transition-colors hover:opacity-95"
|
||||
style={{
|
||||
borderColor: 'var(--border-secondary)',
|
||||
opacity: isComingSoon ? 0.5 : 1,
|
||||
pointerEvents: isComingSoon ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
|
||||
<AddonIcon name={addon.icon} size={20} />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-content">{displayName}</span>
|
||||
{isComingSoon && (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[9px] font-semibold"
|
||||
style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}
|
||||
>
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full text-content-faint bg-surface-tertiary">
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="rounded-full px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
>
|
||||
{addon.type === 'global'
|
||||
? t('admin.addons.type.global')
|
||||
: addon.type === 'integration'
|
||||
? t('admin.addons.type.integration')
|
||||
: t('admin.addons.type.trip')}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-surface-secondary text-content-muted">
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
{displayDescription}
|
||||
</p>
|
||||
<p className="text-xs mt-0.5 text-content-muted">{displayDescription}</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className="hidden text-xs font-medium sm:inline"
|
||||
style={{ color: enabledState && !isComingSoon ? 'var(--text-primary)' : 'var(--text-faint)' }}
|
||||
>
|
||||
{isComingSoon
|
||||
? t('admin.addons.disabled')
|
||||
: enabledState
|
||||
? t('admin.addons.enabled')
|
||||
: t('admin.addons.disabled')}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${(enabledState && !isComingSoon) ? 'text-content' : 'text-content-faint'}`}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
{!hideToggle && (
|
||||
<button
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{
|
||||
background: enabledState && !isComingSoon ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
cursor: isComingSoon ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform bg-surface-card"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
transform: enabledState && !isComingSoon ? 'translateX(22px)' : 'translateX(4px)',
|
||||
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import AdminMcpTokensPanel from './AdminMcpTokensPanel';
|
||||
@@ -39,7 +39,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
it('FE-ADMIN-MCP-001: loading spinner shown on mount', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ tokens: [] });
|
||||
})
|
||||
);
|
||||
@@ -53,7 +53,11 @@ describe('AdminMcpTokensPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
expect(screen.getByText('Ops Token')).toBeInTheDocument();
|
||||
@@ -65,7 +69,11 @@ describe('AdminMcpTokensPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-004: "Never" shown when last_used_at is null', async () => {
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
expect(screen.getByText('Never')).toBeInTheDocument();
|
||||
@@ -73,7 +81,11 @@ describe('AdminMcpTokensPanel', () => {
|
||||
|
||||
it('FE-ADMIN-MCP-005: delete confirmation dialog opens', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
@@ -88,7 +100,11 @@ describe('AdminMcpTokensPanel', () => {
|
||||
|
||||
it('FE-ADMIN-MCP-006: cancel closes confirmation dialog without deleting', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
@@ -105,7 +121,11 @@ describe('AdminMcpTokensPanel', () => {
|
||||
|
||||
it('FE-ADMIN-MCP-007: backdrop click closes dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
@@ -125,15 +145,14 @@ describe('AdminMcpTokensPanel', () => {
|
||||
it('FE-ADMIN-MCP-008: successful delete removes token from list', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })),
|
||||
http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AdminMcpTokensPanel />
|
||||
</>
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
),
|
||||
http.delete('/api/admin/mcp-tokens/:id', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
@@ -151,15 +170,14 @@ describe('AdminMcpTokensPanel', () => {
|
||||
it('FE-ADMIN-MCP-009: failed delete shows error toast and keeps list unchanged', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })),
|
||||
http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AdminMcpTokensPanel />
|
||||
</>
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
),
|
||||
http.delete('/api/admin/mcp-tokens/:id', () =>
|
||||
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
@@ -171,20 +189,19 @@ describe('AdminMcpTokensPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-010: load failure shows error toast', async () => {
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AdminMcpTokensPanel />
|
||||
</>
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Failed to load tokens');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ sessions: [] });
|
||||
})
|
||||
);
|
||||
@@ -193,7 +210,11 @@ describe('AdminMcpTokensPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => {
|
||||
server.use(http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ sessions: [] })));
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({ sessions: [] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('No active OAuth sessions');
|
||||
});
|
||||
@@ -223,19 +244,13 @@ describe('AdminMcpTokensPanel', () => {
|
||||
it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => {
|
||||
const user = userEvent.setup();
|
||||
// 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears
|
||||
const scopes = [
|
||||
'trips:read',
|
||||
'trips:write',
|
||||
'places:read',
|
||||
'places:write',
|
||||
'budget:read',
|
||||
'budget:write',
|
||||
'packing:read',
|
||||
];
|
||||
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' }],
|
||||
sessions: [
|
||||
{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -255,24 +270,15 @@ describe('AdminMcpTokensPanel', () => {
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{
|
||||
id: 5,
|
||||
client_name: 'Revoke Me',
|
||||
username: 'carol',
|
||||
scopes: ['trips:read'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
{ id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/admin/oauth-sessions/5', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AdminMcpTokensPanel />
|
||||
</>
|
||||
http.delete('/api/admin/oauth-sessions/5', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Revoke Me');
|
||||
|
||||
// Click the revoke (trash) button next to the session
|
||||
@@ -283,7 +289,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
|
||||
// Confirm — find the modal's Delete button (has no title, unlike the trash icon)
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find((b) => !b.title);
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
|
||||
@@ -296,30 +302,21 @@ describe('AdminMcpTokensPanel', () => {
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{
|
||||
id: 6,
|
||||
client_name: 'Error Session',
|
||||
username: 'dave',
|
||||
scopes: ['trips:read'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
{ id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/admin/oauth-sessions/6', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AdminMcpTokensPanel />
|
||||
</>
|
||||
http.delete('/api/admin/oauth-sessions/6', () =>
|
||||
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Error Session');
|
||||
|
||||
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||
await user.click(deleteBtn);
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find((b) => !b.title);
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
await screen.findByText('Failed to revoke session');
|
||||
});
|
||||
|
||||
@@ -1,212 +1,158 @@
|
||||
import { Key, Loader2, Shield, Trash2, User } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface AdminOAuthSession {
|
||||
id: number;
|
||||
client_id: string;
|
||||
client_name: string;
|
||||
user_id: number;
|
||||
username: string;
|
||||
scopes: string[];
|
||||
access_token_expires_at: string;
|
||||
refresh_token_expires_at: string;
|
||||
created_at: string;
|
||||
id: number
|
||||
client_id: string
|
||||
client_name: string
|
||||
user_id: number
|
||||
username: string
|
||||
scopes: string[]
|
||||
access_token_expires_at: string
|
||||
refresh_token_expires_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface AdminMcpToken {
|
||||
id: number;
|
||||
name: string;
|
||||
token_prefix: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
user_id: number;
|
||||
username: string;
|
||||
id: number
|
||||
name: string
|
||||
token_prefix: string
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
user_id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
const SCOPES_PREVIEW = 6;
|
||||
const SCOPES_PREVIEW = 6
|
||||
|
||||
export default function AdminMcpTokensPanel() {
|
||||
const [sessions, setSessions] = useState<AdminOAuthSession[]>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true);
|
||||
const [tokens, setTokens] = useState<AdminMcpToken[]>([]);
|
||||
const [tokensLoading, setTokensLoading] = useState(true);
|
||||
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set());
|
||||
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
|
||||
const [sessions, setSessions] = useState<AdminOAuthSession[]>([])
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
|
||||
const [tokensLoading, setTokensLoading] = useState(true)
|
||||
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set())
|
||||
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
|
||||
|
||||
const toggleScopes = (id: number) =>
|
||||
setExpandedScopes((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
const toast = useToast();
|
||||
const { t, locale } = useTranslation();
|
||||
setExpandedScopes(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
return next
|
||||
})
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
adminApi
|
||||
.oauthSessions()
|
||||
.then((d) => setSessions(d.sessions || []))
|
||||
adminApi.oauthSessions()
|
||||
.then(d => setSessions(d.sessions || []))
|
||||
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
|
||||
.finally(() => setSessionsLoading(false));
|
||||
.finally(() => setSessionsLoading(false))
|
||||
|
||||
adminApi
|
||||
.mcpTokens()
|
||||
.then((d) => setTokens(d.tokens || []))
|
||||
adminApi.mcpTokens()
|
||||
.then(d => setTokens(d.tokens || []))
|
||||
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
|
||||
.finally(() => setTokensLoading(false));
|
||||
}, []);
|
||||
.finally(() => setTokensLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleRevoke = async (id: number) => {
|
||||
try {
|
||||
await adminApi.revokeOAuthSession(id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
setRevokeConfirmId(null);
|
||||
toast.success(t('admin.oauthSessions.revokeSuccess'));
|
||||
await adminApi.revokeOAuthSession(id)
|
||||
setSessions(prev => prev.filter(s => s.id !== id))
|
||||
setRevokeConfirmId(null)
|
||||
toast.success(t('admin.oauthSessions.revokeSuccess'))
|
||||
} catch {
|
||||
toast.error(t('admin.oauthSessions.revokeError'));
|
||||
toast.error(t('admin.oauthSessions.revokeError'))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await adminApi.deleteMcpToken(id);
|
||||
setTokens((prev) => prev.filter((tk) => tk.id !== id));
|
||||
setDeleteConfirmId(null);
|
||||
toast.success(t('admin.mcpTokens.deleteSuccess'));
|
||||
await adminApi.deleteMcpToken(id)
|
||||
setTokens(prev => prev.filter(tk => tk.id !== id))
|
||||
setDeleteConfirmId(null)
|
||||
toast.success(t('admin.mcpTokens.deleteSuccess'))
|
||||
} catch {
|
||||
toast.error(t('admin.mcpTokens.deleteError'));
|
||||
toast.error(t('admin.mcpTokens.deleteError'))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('admin.mcpTokens.title')}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('admin.mcpTokens.subtitle')}
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold text-content">{t('admin.mcpTokens.title')}</h2>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* OAuth Sessions */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('admin.oauthSessions.sectionTitle')}
|
||||
</h3>
|
||||
<div
|
||||
className="overflow-hidden rounded-xl border"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.oauthSessions.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
{sessionsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12">
|
||||
<Shield className="h-8 w-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('admin.oauthSessions.empty')}
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Shield className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.oauthSessions.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 border-b px-4 py-2.5 text-xs font-medium"
|
||||
style={{
|
||||
color: 'var(--text-tertiary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
background: 'var(--bg-secondary)',
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span>{t('admin.oauthSessions.clientName')}</span>
|
||||
<span>{t('admin.oauthSessions.owner')}</span>
|
||||
<span className="text-right">{t('admin.oauthSessions.created')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{sessions.map((session, i) => {
|
||||
const expanded = expandedScopes.has(session.id);
|
||||
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW);
|
||||
const hidden = session.scopes.length - SCOPES_PREVIEW;
|
||||
const expanded = expandedScopes.has(session.id)
|
||||
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
|
||||
const hidden = session.scopes.length - SCOPES_PREVIEW
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3"
|
||||
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}
|
||||
>
|
||||
<div key={session.id}
|
||||
className={`grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3 ${i < sessions.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{session.client_name}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{visible.map((scope) => (
|
||||
<span
|
||||
key={scope}
|
||||
className="inline-flex items-center rounded px-1.5 py-0.5 font-mono text-xs"
|
||||
style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
color: 'var(--text-tertiary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium truncate text-content">{session.client_name}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{visible.map(scope => (
|
||||
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-surface-secondary border border-edge"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
{!expanded && hidden > 0 && (
|
||||
<button
|
||||
onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium transition-colors hover:opacity-80"
|
||||
style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
+{hidden} more
|
||||
</button>
|
||||
)}
|
||||
{expanded && hidden > 0 && (
|
||||
<button
|
||||
onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium transition-colors hover:opacity-80"
|
||||
style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
show less
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1.5 pt-0.5 text-sm"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<User className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<div className="flex items-center gap-1.5 text-sm pt-0.5 text-content-secondary">
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{session.username}</span>
|
||||
</div>
|
||||
<span
|
||||
className="whitespace-nowrap pt-0.5 text-right text-xs"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(session.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setRevokeConfirmId(session.id)}
|
||||
className="rounded-lg p-1.5 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<button onClick={() => setRevokeConfirmId(session.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
@@ -215,34 +161,21 @@ export default function AdminMcpTokensPanel() {
|
||||
|
||||
{/* MCP Tokens */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('admin.mcpTokens.sectionTitle')}
|
||||
</h3>
|
||||
<div
|
||||
className="overflow-hidden rounded-xl border"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.mcpTokens.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
{tokensLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12">
|
||||
<Key className="h-8 w-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('admin.mcpTokens.empty')}
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 border-b px-4 py-2.5 text-xs font-medium"
|
||||
style={{
|
||||
color: 'var(--text-tertiary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
background: 'var(--bg-secondary)',
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||
<span>{t('admin.mcpTokens.owner')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||
@@ -250,38 +183,26 @@ export default function AdminMcpTokensPanel() {
|
||||
<span></span>
|
||||
</div>
|
||||
{tokens.map((token, i) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
||||
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}
|
||||
>
|
||||
<div key={token.id}
|
||||
className={`grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3 ${i < tokens.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{token.name}
|
||||
</p>
|
||||
<p className="mt-0.5 font-mono text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.token_prefix}...
|
||||
</p>
|
||||
<p className="text-sm font-medium truncate text-content">{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<User className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<div className="flex items-center gap-1.5 text-sm text-content-secondary">
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{token.username}</span>
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-right text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(token.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-right text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at
|
||||
? new Date(token.last_used_at).toLocaleDateString(locale)
|
||||
: t('admin.mcpTokens.never')}
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="rounded-lg p-1.5 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<button onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -292,32 +213,18 @@ export default function AdminMcpTokensPanel() {
|
||||
|
||||
{/* Revoke OAuth session modal */}
|
||||
{revokeConfirmId !== null && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setRevokeConfirmId(null);
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-sm space-y-4 rounded-xl p-6 shadow-xl" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('admin.oauthSessions.revokeTitle')}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('admin.oauthSessions.revokeMessage')}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setRevokeConfirmId(null)}
|
||||
className="rounded-lg border px-4 py-2 text-sm"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setRevokeConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRevoke(revokeConfirmId)}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
<button onClick={() => handleRevoke(revokeConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -327,32 +234,18 @@ export default function AdminMcpTokensPanel() {
|
||||
|
||||
{/* Delete MCP token modal */}
|
||||
{deleteConfirmId !== null && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setDeleteConfirmId(null);
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-sm space-y-4 rounded-xl p-6 shadow-xl" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('admin.mcpTokens.deleteTitle')}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('admin.mcpTokens.deleteMessage')}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
className="rounded-lg border px-4 py-2 text-sm"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
<button onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -360,5 +253,5 @@ export default function AdminMcpTokensPanel() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-ADMIN-AUDIT-001 to FE-ADMIN-AUDIT-010
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import AuditLogPanel from './AuditLogPanel';
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('AuditLogPanel', () => {
|
||||
http.get('/api/admin/audit-log', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json({ entries: [], total: 0 });
|
||||
})
|
||||
}),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
@@ -52,14 +52,22 @@ describe('AuditLogPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-002: empty state shown when no entries', async () => {
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [], total: 0 })));
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [], total: 0 }),
|
||||
),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('No audit entries yet.');
|
||||
expect(document.querySelector('table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-003: table renders all columns with data', async () => {
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 })));
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [ENTRY_1], total: 1 }),
|
||||
),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('trip.create');
|
||||
expect(screen.getByText('Time')).toBeInTheDocument();
|
||||
@@ -81,7 +89,11 @@ describe('AuditLogPanel', () => {
|
||||
{ ...ENTRY_1, id: 12, username: null, user_email: null, user_id: 7, action: 'a.id' },
|
||||
{ ...ENTRY_1, id: 13, username: null, user_email: null, user_id: null, action: 'a.none' },
|
||||
];
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries, total: 4 })));
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries, total: 4 }),
|
||||
),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('a.username');
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
@@ -109,7 +121,9 @@ describe('AuditLogPanel', () => {
|
||||
details: {},
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }))
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }),
|
||||
),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('a.nulls');
|
||||
@@ -119,7 +133,11 @@ describe('AuditLogPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-006: showing count text reflects count and total', async () => {
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 50 })));
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [ENTRY_1], total: 50 }),
|
||||
),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('trip.create');
|
||||
expect(screen.getByText('1 loaded · 50 total')).toBeInTheDocument();
|
||||
@@ -134,7 +152,7 @@ describe('AuditLogPanel', () => {
|
||||
return HttpResponse.json({ entries: [ENTRY_1], total: 2 });
|
||||
}
|
||||
return HttpResponse.json({ entries: [ENTRY_2], total: 2 });
|
||||
})
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
@@ -148,7 +166,11 @@ describe('AuditLogPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-008: "Load more" hidden when all entries loaded', async () => {
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 })));
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 }),
|
||||
),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('trip.create');
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||
@@ -169,7 +191,7 @@ describe('AuditLogPanel', () => {
|
||||
return HttpResponse.json({ entries: [PAGE2_ENTRY], total: 2 });
|
||||
}
|
||||
return HttpResponse.json({ entries: [REFRESH_ENTRY], total: 1 });
|
||||
})
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
@@ -192,7 +214,7 @@ describe('AuditLogPanel', () => {
|
||||
http.get('/api/admin/audit-log', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json({ entries: [], total: 0 });
|
||||
})
|
||||
}),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
const refreshBtn = screen.getByText('Refresh');
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
import { ClipboardList, RefreshCw } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { RefreshCw, ClipboardList } from 'lucide-react'
|
||||
|
||||
interface AuditEntry {
|
||||
id: number;
|
||||
created_at: string;
|
||||
user_id: number | null;
|
||||
username: string | null;
|
||||
user_email: string | null;
|
||||
action: string;
|
||||
resource: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
ip: string | null;
|
||||
id: number
|
||||
created_at: string
|
||||
user_id: number | null
|
||||
username: string | null
|
||||
user_email: string | null
|
||||
action: string
|
||||
resource: string | null
|
||||
details: Record<string, unknown> | null
|
||||
ip: string | null
|
||||
}
|
||||
|
||||
interface AuditLogPanelProps {
|
||||
serverTimezone?: string;
|
||||
serverTimezone?: string
|
||||
}
|
||||
|
||||
export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation();
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const limit = 100;
|
||||
const { t, locale } = useTranslation()
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const limit = 100
|
||||
|
||||
const loadFirstPage = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = (await adminApi.auditLog({ limit, offset: 0 })) as {
|
||||
entries: AuditEntry[];
|
||||
total: number;
|
||||
};
|
||||
setEntries(data.entries || []);
|
||||
setTotal(data.total ?? 0);
|
||||
setOffset(0);
|
||||
const data = await adminApi.auditLog({ limit, offset: 0 }) as {
|
||||
entries: AuditEntry[]
|
||||
total: number
|
||||
}
|
||||
setEntries(data.entries || [])
|
||||
setTotal(data.total ?? 0)
|
||||
setOffset(0)
|
||||
} catch {
|
||||
setEntries([]);
|
||||
setTotal(0);
|
||||
setOffset(0);
|
||||
setEntries([])
|
||||
setTotal(0)
|
||||
setOffset(0)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const nextOffset = offset + limit;
|
||||
setLoading(true);
|
||||
const nextOffset = offset + limit
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = (await adminApi.auditLog({ limit, offset: nextOffset })) as {
|
||||
entries: AuditEntry[];
|
||||
total: number;
|
||||
};
|
||||
setEntries((prev) => [...prev, ...(data.entries || [])]);
|
||||
setTotal(data.total ?? 0);
|
||||
setOffset(nextOffset);
|
||||
const data = await adminApi.auditLog({ limit, offset: nextOffset }) as {
|
||||
entries: AuditEntry[]
|
||||
total: number
|
||||
}
|
||||
setEntries((prev) => [...prev, ...(data.entries || [])])
|
||||
setTotal(data.total ?? 0)
|
||||
setOffset(nextOffset)
|
||||
} catch {
|
||||
/* keep existing */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}, [offset]);
|
||||
}, [offset])
|
||||
|
||||
useEffect(() => {
|
||||
loadFirstPage();
|
||||
}, [loadFirstPage]);
|
||||
loadFirstPage()
|
||||
}, [loadFirstPage])
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
try {
|
||||
@@ -74,113 +74,79 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
timeZone: serverTimezone || undefined,
|
||||
});
|
||||
})
|
||||
} catch {
|
||||
return iso;
|
||||
return iso
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const fmtDetails = (d: Record<string, unknown> | null) => {
|
||||
if (!d || Object.keys(d).length === 0) return '—';
|
||||
if (!d || Object.keys(d).length === 0) return '—'
|
||||
try {
|
||||
return JSON.stringify(d);
|
||||
return JSON.stringify(d)
|
||||
} catch {
|
||||
return '—';
|
||||
return '—'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const userLabel = (e: AuditEntry) => {
|
||||
if (e.username) return e.username;
|
||||
if (e.user_email) return e.user_email;
|
||||
if (e.user_id != null) return `#${e.user_id}`;
|
||||
return '—';
|
||||
};
|
||||
if (e.username) return e.username
|
||||
if (e.user_email) return e.user_email
|
||||
if (e.user_id != null) return `#${e.user_id}`
|
||||
return '—'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="m-0 flex items-center gap-2 text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
<h2 className="font-semibold text-lg m-0 flex items-center gap-2 text-content">
|
||||
<ClipboardList size={20} />
|
||||
{t('admin.tabs.audit')}
|
||||
</h2>
|
||||
<p className="m-0 mt-1 text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
{t('admin.audit.subtitle')}
|
||||
</p>
|
||||
<p className="text-sm m-0 mt-1 text-content-muted">{t('admin.audit.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadFirstPage()}
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-opacity disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50 border-edge text-content bg-surface-card"
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
{t('admin.audit.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="m-0 text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
<p className="text-xs m-0 text-content-faint">
|
||||
{t('admin.audit.showing', { count: entries.length, total })}
|
||||
</p>
|
||||
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
<div className="py-12 text-center text-sm text-content-muted">{t('common.loading')}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
{t('admin.audit.empty')}
|
||||
</div>
|
||||
<div className="py-12 text-center text-sm text-content-muted">{t('admin.audit.empty')}</div>
|
||||
) : (
|
||||
<div
|
||||
className="overflow-x-auto rounded-xl border"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
>
|
||||
<table className="w-full min-w-[720px] border-collapse text-sm">
|
||||
<div className="rounded-xl border overflow-x-auto border-edge bg-surface-card">
|
||||
<table className="w-full text-sm border-collapse min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('admin.audit.col.time')}
|
||||
</th>
|
||||
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('admin.audit.col.user')}
|
||||
</th>
|
||||
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('admin.audit.col.action')}
|
||||
</th>
|
||||
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('admin.audit.col.resource')}
|
||||
</th>
|
||||
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('admin.audit.col.ip')}
|
||||
</th>
|
||||
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('admin.audit.col.details')}
|
||||
</th>
|
||||
<tr className="border-b text-left border-edge-secondary">
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.time')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.user')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.action')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.resource')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.ip')}</th>
|
||||
<th className="p-3 font-semibold text-content-secondary">{t('admin.audit.col.details')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<td className="whitespace-nowrap p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>
|
||||
{fmtTime(e.created_at)}
|
||||
</td>
|
||||
<td className="p-3" style={{ color: 'var(--text-primary)' }}>
|
||||
{userLabel(e)}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>
|
||||
{e.action}
|
||||
</td>
|
||||
<td className="max-w-[140px] break-all p-3 font-mono text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
{e.resource || '—'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap p-3 font-mono text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
{e.ip || '—'}
|
||||
</td>
|
||||
<td className="max-w-[280px] break-all p-3 font-mono text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{fmtDetails(e.details)}
|
||||
</td>
|
||||
<tr key={e.id} className="border-b align-top border-edge-secondary">
|
||||
<td className="p-3 whitespace-nowrap font-mono text-xs text-content">{fmtTime(e.created_at)}</td>
|
||||
<td className="p-3 text-content">{userLabel(e)}</td>
|
||||
<td className="p-3 font-mono text-xs text-content">{e.action}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[140px] text-content-muted">{e.resource || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs whitespace-nowrap text-content-muted">{e.ip || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[280px] text-content-faint">{fmtDetails(e.details)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -193,12 +159,11 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadMore()}
|
||||
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50 text-content-secondary"
|
||||
>
|
||||
{t('admin.audit.loadMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import BackupPanel from './BackupPanel';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import BackupPanel from './BackupPanel'
|
||||
import { ToastContainer } from '../shared/Toast'
|
||||
|
||||
const manualBackup = {
|
||||
filename: 'backup-2025-01-15.zip',
|
||||
created_at: '2025-01-15T10:00:00Z',
|
||||
size: 2048000,
|
||||
};
|
||||
}
|
||||
const autoBackup = {
|
||||
filename: 'auto-backup-2025-02-01.zip',
|
||||
created_at: '2025-02-01T02:00:00Z',
|
||||
size: 1024000,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultBackupHandlers() {
|
||||
return [
|
||||
@@ -26,300 +26,288 @@ function defaultBackupHandlers() {
|
||||
HttpResponse.json({
|
||||
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: 'UTC',
|
||||
})
|
||||
}),
|
||||
),
|
||||
];
|
||||
]
|
||||
}
|
||||
|
||||
function getToggleButton() {
|
||||
// The enable toggle is a <button> inside a <label> that contains "Enable auto-backup"
|
||||
const label = screen.getByText('Enable auto-backup').closest('label') as HTMLElement;
|
||||
return label.querySelector('button') as HTMLElement;
|
||||
const label = screen.getByText('Enable auto-backup').closest('label') as HTMLElement
|
||||
return label.querySelector('button') as HTMLElement
|
||||
}
|
||||
|
||||
describe('BackupPanel', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
server.use(...defaultBackupHandlers());
|
||||
});
|
||||
resetAllStores()
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
server.use(...defaultBackupHandlers())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
server.resetHandlers();
|
||||
});
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
server.resetHandlers()
|
||||
})
|
||||
|
||||
// BKP-001: Loading state
|
||||
it('FE-ADMIN-BKP-001: shows loading spinner while fetching backups', async () => {
|
||||
server.use(
|
||||
http.get('/api/backup/list', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return HttpResponse.json({ backups: [] });
|
||||
})
|
||||
);
|
||||
render(<BackupPanel />);
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
return HttpResponse.json({ backups: [] })
|
||||
}),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-002: Empty state
|
||||
it('FE-ADMIN-BKP-002: shows empty state when no backups exist', async () => {
|
||||
server.use(http.get('/api/backup/list', () => HttpResponse.json({ backups: [] })));
|
||||
render(<BackupPanel />);
|
||||
server.use(
|
||||
http.get('/api/backup/list', () => HttpResponse.json({ backups: [] })),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No backups yet')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Create first backup')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('No backups yet')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('Create first backup')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-003: Backup list renders filename, size, and date
|
||||
it('FE-ADMIN-BKP-003: renders filename, formatted size, and date for a backup', async () => {
|
||||
render(<BackupPanel />);
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('2.0 MB')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('2.0 MB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-004: Auto-backup badge shown for auto-backup filenames
|
||||
it('FE-ADMIN-BKP-004: shows Auto badge for auto-backup filenames', async () => {
|
||||
server.use(http.get('/api/backup/list', () => HttpResponse.json({ backups: [autoBackup] })));
|
||||
render(<BackupPanel />);
|
||||
server.use(
|
||||
http.get('/api/backup/list', () => HttpResponse.json({ backups: [autoBackup] })),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('auto-backup-2025-02-01.zip')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Auto')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('auto-backup-2025-02-01.zip')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('Auto')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-005: Create backup success
|
||||
it('FE-ADMIN-BKP-005: creates backup and shows success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.post('/api/backup/create', () => HttpResponse.json({ success: true })),
|
||||
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<BackupPanel />
|
||||
</>
|
||||
);
|
||||
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] })),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByTitle('Create Backup'));
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByTitle('Create Backup'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Backup created successfully')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
expect(screen.getByText('Backup created successfully')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// BKP-006: Restore opens confirmation modal
|
||||
it('FE-ADMIN-BKP-006: clicking Restore opens confirmation modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BackupPanel />);
|
||||
const user = userEvent.setup()
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getAllByText('Restore')[0]);
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restore Backup?')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getAllByText('backup-2025-01-15.zip').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Yes, restore')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getAllByText('backup-2025-01-15.zip').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('Yes, restore')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-007: Cancel dismisses modal without calling restore API
|
||||
it('FE-ADMIN-BKP-007: cancel dismisses the restore modal without calling the API', async () => {
|
||||
const user = userEvent.setup();
|
||||
let restoreCalled = false;
|
||||
const user = userEvent.setup()
|
||||
let restoreCalled = false
|
||||
server.use(
|
||||
http.post('/api/backup/restore/:filename', () => {
|
||||
restoreCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
render(<BackupPanel />);
|
||||
restoreCalled = true
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getAllByText('Restore')[0]);
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restore Backup?')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText('Cancel'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(restoreCalled).toBe(false);
|
||||
});
|
||||
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(restoreCalled).toBe(false)
|
||||
})
|
||||
|
||||
// BKP-008: Backdrop click dismisses modal
|
||||
it('FE-ADMIN-BKP-008: clicking the backdrop dismisses the restore modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BackupPanel />);
|
||||
const user = userEvent.setup()
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getAllByText('Restore')[0]);
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restore Backup?')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
|
||||
})
|
||||
// Click the backdrop overlay (the fixed-position div)
|
||||
const backdrop = document.querySelector('[style*="position: fixed"]') as HTMLElement;
|
||||
expect(backdrop).toBeTruthy();
|
||||
fireEvent.click(backdrop!);
|
||||
const backdrop = document.querySelector('[style*="position: fixed"]') as HTMLElement
|
||||
expect(backdrop).toBeTruthy()
|
||||
fireEvent.click(backdrop!)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// BKP-009: Successful restore calls API and reloads after 1500ms
|
||||
it('FE-ADMIN-BKP-009: successful restore shows toast and reloads after 1500ms', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/backup/restore/:filename', () => HttpResponse.json({ success: true })));
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<BackupPanel />
|
||||
</>
|
||||
);
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.post('/api/backup/restore/:filename', () => HttpResponse.json({ success: true })),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Stub reload AFTER initial data load so we don't corrupt window.location during setup
|
||||
const reloadMock = vi.fn();
|
||||
vi.stubGlobal('location', { ...window.location, reload: reloadMock });
|
||||
const reloadMock = vi.fn()
|
||||
vi.stubGlobal('location', { ...window.location, reload: reloadMock })
|
||||
|
||||
await user.click(screen.getAllByText('Restore')[0]);
|
||||
await waitFor(() => expect(screen.getByText('Restore Backup?')).toBeInTheDocument());
|
||||
await user.click(screen.getByText('Yes, restore'));
|
||||
await waitFor(() => expect(screen.getByText('Backup restored. Page will reload…')).toBeInTheDocument());
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
await waitFor(() => expect(screen.getByText('Restore Backup?')).toBeInTheDocument())
|
||||
await user.click(screen.getByText('Yes, restore'))
|
||||
await waitFor(() => expect(screen.getByText('Backup restored. Page will reload…')).toBeInTheDocument())
|
||||
|
||||
// Wait for the 1500ms reload timer to fire
|
||||
await new Promise((resolve) => setTimeout(resolve, 1600));
|
||||
expect(reloadMock).toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
}, 20000);
|
||||
await new Promise(resolve => setTimeout(resolve, 1600))
|
||||
expect(reloadMock).toHaveBeenCalled()
|
||||
vi.unstubAllGlobals()
|
||||
}, 20000)
|
||||
|
||||
// BKP-010: Delete backup with confirm dialog
|
||||
it('FE-ADMIN-BKP-010: deletes backup after confirm and shows success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.delete('/api/backup/:filename', () => HttpResponse.json({ success: true })));
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<BackupPanel />
|
||||
</>
|
||||
);
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.delete('/api/backup/:filename', () => HttpResponse.json({ success: true })),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
|
||||
});
|
||||
const trashBtn = Array.from(document.querySelectorAll('button')).find((b) =>
|
||||
b.querySelector('svg.lucide-trash2')
|
||||
) as HTMLElement;
|
||||
expect(trashBtn).toBeTruthy();
|
||||
await user.click(trashBtn!);
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
const trashBtn = Array.from(document.querySelectorAll('button')).find(
|
||||
b => b.querySelector('svg.lucide-trash2'),
|
||||
) as HTMLElement
|
||||
expect(trashBtn).toBeTruthy()
|
||||
await user.click(trashBtn!)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Backup deleted')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Backup deleted')).toBeInTheDocument()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('backup-2025-01-15.zip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
expect(screen.queryByText('backup-2025-01-15.zip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// BKP-011: Auto-backup enable toggle shows interval controls
|
||||
it('FE-ADMIN-BKP-011: enabling auto-backup shows interval controls', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BackupPanel />);
|
||||
const user = userEvent.setup()
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Hourly')).not.toBeInTheDocument();
|
||||
await user.click(getToggleButton());
|
||||
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Hourly')).not.toBeInTheDocument()
|
||||
await user.click(getToggleButton())
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Hourly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Daily')).toBeInTheDocument();
|
||||
expect(screen.getByText('Weekly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Monthly')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
expect(screen.getByText('Hourly')).toBeInTheDocument()
|
||||
expect(screen.getByText('Daily')).toBeInTheDocument()
|
||||
expect(screen.getByText('Weekly')).toBeInTheDocument()
|
||||
expect(screen.getByText('Monthly')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// BKP-012: Weekly interval shows day-of-week picker
|
||||
it('FE-ADMIN-BKP-012: weekly interval shows day-of-week picker', async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: 'UTC',
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<BackupPanel />);
|
||||
}),
|
||||
),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Weekly')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Sun')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByText('Weekly'));
|
||||
expect(screen.getByText('Weekly')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Sun')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByText('Weekly'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sun')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sat')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Day of month')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Sun')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sat')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Day of month')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-013: Save auto-settings calls API and shows toast
|
||||
it('FE-ADMIN-BKP-013: saving auto-settings calls API and shows success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: 'UTC',
|
||||
})
|
||||
}),
|
||||
),
|
||||
http.put('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
})
|
||||
)
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<BackupPanel />
|
||||
</>
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Weekly')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText('Weekly'));
|
||||
expect(screen.getByText('Weekly')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText('Weekly'))
|
||||
await waitFor(() => {
|
||||
const saveBtn = screen.getByRole('button', { name: /^save$/i });
|
||||
expect(saveBtn).not.toBeDisabled();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
const saveBtn = screen.getByRole('button', { name: /^save$/i })
|
||||
expect(saveBtn).not.toBeDisabled()
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Auto-backup settings saved')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
expect(screen.getByText('Auto-backup settings saved')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// BKP-014: Save button disabled until settings changed
|
||||
it('FE-ADMIN-BKP-014: save button is disabled until settings are changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BackupPanel />);
|
||||
const user = userEvent.setup()
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument();
|
||||
});
|
||||
const saveBtn = screen.getByRole('button', { name: /^save$/i });
|
||||
expect(saveBtn).toBeDisabled();
|
||||
await user.click(getToggleButton());
|
||||
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
|
||||
})
|
||||
const saveBtn = screen.getByRole('button', { name: /^save$/i })
|
||||
expect(saveBtn).toBeDisabled()
|
||||
await user.click(getToggleButton())
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,38 +1,27 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Clock,
|
||||
Download,
|
||||
HardDrive,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { backupApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { getApiErrorMessage } from '../../types';
|
||||
import CustomSelect from '../shared/CustomSelect';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { backupApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
|
||||
{ value: 'daily', labelKey: 'backup.interval.daily' },
|
||||
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
|
||||
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
|
||||
{ value: 'daily', labelKey: 'backup.interval.daily' },
|
||||
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
|
||||
{ value: 'monthly', labelKey: 'backup.interval.monthly' },
|
||||
];
|
||||
]
|
||||
|
||||
const KEEP_OPTIONS = [
|
||||
{ value: 1, labelKey: 'backup.keep.1day' },
|
||||
{ value: 3, labelKey: 'backup.keep.3days' },
|
||||
{ value: 7, labelKey: 'backup.keep.7days' },
|
||||
{ value: 1, labelKey: 'backup.keep.1day' },
|
||||
{ value: 3, labelKey: 'backup.keep.3days' },
|
||||
{ value: 7, labelKey: 'backup.keep.7days' },
|
||||
{ value: 14, labelKey: 'backup.keep.14days' },
|
||||
{ value: 30, labelKey: 'backup.keep.30days' },
|
||||
{ value: 0, labelKey: 'backup.keep.forever' },
|
||||
];
|
||||
{ value: 0, labelKey: 'backup.keep.forever' },
|
||||
]
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: 0, labelKey: 'backup.dow.sunday' },
|
||||
@@ -42,205 +31,193 @@ const DAYS_OF_WEEK = [
|
||||
{ value: 4, labelKey: 'backup.dow.thursday' },
|
||||
{ value: 5, labelKey: 'backup.dow.friday' },
|
||||
{ value: 6, labelKey: 'backup.dow.saturday' },
|
||||
];
|
||||
]
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1);
|
||||
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
|
||||
|
||||
export default function BackupPanel() {
|
||||
const [backups, setBackups] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [restoringFile, setRestoringFile] = useState(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [autoSettings, setAutoSettings] = useState({
|
||||
enabled: false,
|
||||
interval: 'daily',
|
||||
keep_days: 7,
|
||||
hour: 2,
|
||||
day_of_week: 0,
|
||||
day_of_month: 1,
|
||||
});
|
||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false);
|
||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false);
|
||||
const [serverTimezone, setServerTimezone] = useState('');
|
||||
const [restoreConfirm, setRestoreConfirm] = useState(null); // { type: 'file'|'upload', filename, file? }
|
||||
const fileInputRef = useRef(null);
|
||||
const toast = useToast();
|
||||
const { t, language, locale } = useTranslation();
|
||||
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
|
||||
const [backups, setBackups] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [restoringFile, setRestoringFile] = useState(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
|
||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||
const [serverTimezone, setServerTimezone] = useState('')
|
||||
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
||||
const fileInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
|
||||
const loadBackups = async () => {
|
||||
setIsLoading(true);
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await backupApi.list();
|
||||
setBackups(data.backups || []);
|
||||
const data = await backupApi.list()
|
||||
setBackups(data.backups || [])
|
||||
} catch {
|
||||
toast.error(t('backup.toast.loadError'));
|
||||
toast.error(t('backup.toast.loadError'))
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const loadAutoSettings = async () => {
|
||||
try {
|
||||
const data = await backupApi.getAutoSettings();
|
||||
setAutoSettings(data.settings);
|
||||
if (data.timezone) setServerTimezone(data.timezone);
|
||||
const data = await backupApi.getAutoSettings()
|
||||
setAutoSettings(data.settings)
|
||||
if (data.timezone) setServerTimezone(data.timezone)
|
||||
} catch {}
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadBackups();
|
||||
loadAutoSettings();
|
||||
}, []);
|
||||
useEffect(() => { loadBackups(); loadAutoSettings() }, [])
|
||||
|
||||
const handleCreate = async () => {
|
||||
setIsCreating(true);
|
||||
setIsCreating(true)
|
||||
try {
|
||||
await backupApi.create();
|
||||
toast.success(t('backup.toast.created'));
|
||||
await loadBackups();
|
||||
await backupApi.create()
|
||||
toast.success(t('backup.toast.created'))
|
||||
await loadBackups()
|
||||
} catch {
|
||||
toast.error(t('backup.toast.createError'));
|
||||
toast.error(t('backup.toast.createError'))
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
setIsCreating(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleRestore = (filename) => {
|
||||
setRestoreConfirm({ type: 'file', filename });
|
||||
};
|
||||
setRestoreConfirm({ type: 'file', filename })
|
||||
}
|
||||
|
||||
const handleUploadRestore = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
setRestoreConfirm({ type: 'upload', filename: file.name, file });
|
||||
};
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
setRestoreConfirm({ type: 'upload', filename: file.name, file })
|
||||
}
|
||||
|
||||
const executeRestore = async () => {
|
||||
if (!restoreConfirm) return;
|
||||
const { type, filename, file } = restoreConfirm;
|
||||
setRestoreConfirm(null);
|
||||
if (!restoreConfirm) return
|
||||
const { type, filename, file } = restoreConfirm
|
||||
setRestoreConfirm(null)
|
||||
|
||||
if (type === 'file') {
|
||||
setRestoringFile(filename);
|
||||
setRestoringFile(filename)
|
||||
try {
|
||||
await backupApi.restore(filename);
|
||||
toast.success(t('backup.toast.restored'));
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
await backupApi.restore(filename)
|
||||
toast.success(t('backup.toast.restored'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')));
|
||||
setRestoringFile(null);
|
||||
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')))
|
||||
setRestoringFile(null)
|
||||
}
|
||||
} else {
|
||||
setIsUploading(true);
|
||||
setIsUploading(true)
|
||||
try {
|
||||
await backupApi.uploadRestore(file);
|
||||
toast.success(t('backup.toast.restored'));
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
await backupApi.uploadRestore(file)
|
||||
toast.success(t('backup.toast.restored'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')));
|
||||
setIsUploading(false);
|
||||
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')))
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDelete = async (filename) => {
|
||||
if (!confirm(t('backup.confirm.delete', { name: filename }))) return;
|
||||
if (!confirm(t('backup.confirm.delete', { name: filename }))) return
|
||||
try {
|
||||
await backupApi.delete(filename);
|
||||
toast.success(t('backup.toast.deleted'));
|
||||
setBackups((prev) => prev.filter((b) => b.filename !== filename));
|
||||
await backupApi.delete(filename)
|
||||
toast.success(t('backup.toast.deleted'))
|
||||
setBackups(prev => prev.filter(b => b.filename !== filename))
|
||||
} catch {
|
||||
toast.error(t('backup.toast.deleteError'));
|
||||
toast.error(t('backup.toast.deleteError'))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleAutoSettingsChange = (key, value) => {
|
||||
setAutoSettings((prev) => ({ ...prev, [key]: value }));
|
||||
setAutoSettingsDirty(true);
|
||||
};
|
||||
setAutoSettings(prev => ({ ...prev, [key]: value }))
|
||||
setAutoSettingsDirty(true)
|
||||
}
|
||||
|
||||
const handleSaveAutoSettings = async () => {
|
||||
setAutoSettingsSaving(true);
|
||||
setAutoSettingsSaving(true)
|
||||
try {
|
||||
const data = await backupApi.setAutoSettings(autoSettings);
|
||||
setAutoSettings(data.settings);
|
||||
setAutoSettingsDirty(false);
|
||||
toast.success(t('backup.toast.settingsSaved'));
|
||||
const data = await backupApi.setAutoSettings(autoSettings)
|
||||
setAutoSettings(data.settings)
|
||||
setAutoSettingsDirty(false)
|
||||
toast.success(t('backup.toast.settingsSaved'))
|
||||
} catch {
|
||||
toast.error(t('backup.toast.settingsError'));
|
||||
toast.error(t('backup.toast.settingsError'))
|
||||
} finally {
|
||||
setAutoSettingsSaving(false);
|
||||
setAutoSettingsSaving(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
if (serverTimezone) opts.timeZone = serverTimezone;
|
||||
return new Date(dateStr).toLocaleString(locale, opts);
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}
|
||||
if (serverTimezone) opts.timeZone = serverTimezone
|
||||
return new Date(dateStr).toLocaleString(locale, opts)
|
||||
} catch { return dateStr }
|
||||
}
|
||||
|
||||
const isAuto = (filename) => filename.startsWith('auto-backup-');
|
||||
const isAuto = (filename) => filename.startsWith('auto-backup-')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Manual Backups */}
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="h-5 w-5 text-gray-400" />
|
||||
<HardDrive className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('backup.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
{t('backup.subtitle')}
|
||||
</p>
|
||||
<h2 className="font-semibold text-content">{t('backup.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('backup.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={loadBackups}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100"
|
||||
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title={t('backup.refresh')}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Upload & Restore */}
|
||||
<input ref={fileInputRef} type="file" accept=".zip" className="hidden" onChange={handleUploadRestore} />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={handleUploadRestore}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60"
|
||||
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
|
||||
title={isUploading ? t('backup.uploading') : t('backup.upload')}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
||||
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
<Upload className="w-4 h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
|
||||
</button>
|
||||
@@ -248,13 +225,13 @@ export default function BackupPanel() {
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating}
|
||||
className="flex items-center gap-2 rounded-lg bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-900 disabled:opacity-60 dark:bg-slate-100 dark:text-slate-900 sm:px-4"
|
||||
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
|
||||
title={isCreating ? t('backup.creating') : t('backup.create')}
|
||||
>
|
||||
{isCreating ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
<Plus className="w-4 h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{isCreating ? t('backup.creating') : t('backup.create')}</span>
|
||||
</button>
|
||||
@@ -263,69 +240,63 @@ export default function BackupPanel() {
|
||||
|
||||
{isLoading && backups.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-gray-400">
|
||||
<div className="mr-2 h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-slate-700" />
|
||||
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-700 rounded-full animate-spin mr-2" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-400">
|
||||
<HardDrive className="mx-auto mb-3 h-10 w-10 opacity-40" />
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
|
||||
<p className="text-sm">{t('backup.empty')}</p>
|
||||
<button onClick={handleCreate} className="mt-4 text-sm text-slate-700 hover:underline">
|
||||
<button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
|
||||
{t('backup.createFirst')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{backups.map((backup) => (
|
||||
{backups.map(backup => (
|
||||
<div key={backup.filename} className="flex items-center gap-4 py-3">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gray-100">
|
||||
{isAuto(backup.filename) ? (
|
||||
<RefreshCw className="h-4 w-4 text-blue-500" />
|
||||
) : (
|
||||
<HardDrive className="h-4 w-4 text-gray-500" />
|
||||
)}
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||
{isAuto(backup.filename)
|
||||
? <RefreshCw className="w-4 h-4 text-blue-500" />
|
||||
: <HardDrive className="w-4 h-4 text-gray-500" />
|
||||
}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-gray-900">{backup.filename}</p>
|
||||
<p className="font-medium text-sm text-gray-900 truncate">{backup.filename}</p>
|
||||
{isAuto(backup.filename) && (
|
||||
<span className="whitespace-nowrap rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-xs text-blue-600">
|
||||
Auto
|
||||
</span>
|
||||
<span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded-full px-2 py-0.5 whitespace-nowrap">Auto</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<span className="text-xs text-gray-400">{formatDate(backup.created_at)}</span>
|
||||
<span className="text-xs text-gray-400">{formatSize(backup.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() =>
|
||||
backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-50"
|
||||
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t('backup.download')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRestore(backup.filename)}
|
||||
disabled={restoringFile === backup.filename}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-amber-200 px-3 py-1.5 text-xs text-amber-700 hover:bg-amber-50 disabled:opacity-60"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
|
||||
>
|
||||
{restoringFile === backup.filename ? (
|
||||
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-amber-400 border-t-transparent" />
|
||||
) : (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{restoringFile === backup.filename
|
||||
? <div className="w-3.5 h-3.5 border-2 border-amber-400 border-t-transparent rounded-full animate-spin" />
|
||||
: <RotateCcw className="w-3.5 h-3.5" />
|
||||
}
|
||||
{t('backup.restore')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(backup.filename)}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,35 +306,29 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
|
||||
{/* Auto-Backup Settings */}
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-6">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Clock className="h-5 w-5 text-gray-400" />
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Clock className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('backup.auto.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
{t('backup.auto.subtitle')}
|
||||
</p>
|
||||
<h2 className="font-semibold text-content">{t('backup.auto.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('backup.auto.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Enable toggle */}
|
||||
<label className="flex cursor-pointer items-center justify-between gap-4">
|
||||
<label className="flex items-center justify-between gap-4 cursor-pointer">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
|
||||
<p className="mt-0.5 text-xs text-gray-500">{t('backup.auto.enableHint')}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||
className="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors"
|
||||
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
@@ -371,16 +336,16 @@ export default function BackupPanel() {
|
||||
<>
|
||||
{/* Interval */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.interval')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{INTERVAL_OPTIONS.map((opt) => (
|
||||
{INTERVAL_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('interval', opt.value)}
|
||||
className={`rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.interval === opt.value
|
||||
? 'border-slate-700 bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
|
||||
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
@@ -392,26 +357,25 @@ export default function BackupPanel() {
|
||||
{/* Hour picker (for daily, weekly, monthly) */}
|
||||
{autoSettings.interval !== 'hourly' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.hour')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.hour)}
|
||||
onChange={(v) => handleAutoSettingsChange('hour', parseInt(v, 10))}
|
||||
onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={HOURS.map((h) => {
|
||||
let label: string;
|
||||
options={HOURS.map(h => {
|
||||
let label: string
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM';
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
label = `${h12}:00 ${period}`;
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
label = `${h12}:00 ${period}`
|
||||
} else {
|
||||
label = `${String(h).padStart(2, '0')}:00`;
|
||||
label = `${String(h).padStart(2, '0')}:00`
|
||||
}
|
||||
return { value: String(h), label };
|
||||
return { value: String(h), label }
|
||||
})}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}
|
||||
{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -419,16 +383,16 @@ export default function BackupPanel() {
|
||||
{/* Day of week (for weekly) */}
|
||||
{autoSettings.interval === 'weekly' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.dayOfWeek')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS_OF_WEEK.map((opt) => (
|
||||
{DAYS_OF_WEEK.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.day_of_week === opt.value
|
||||
? 'border-slate-700 bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
|
||||
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
@@ -441,29 +405,29 @@ export default function BackupPanel() {
|
||||
{/* Day of month (for monthly) */}
|
||||
{autoSettings.interval === 'monthly' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.dayOfMonth')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.day_of_month)}
|
||||
onChange={(v) => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={DAYS_OF_MONTH.map((d) => ({ value: String(d), label: String(d) }))}
|
||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">{t('backup.auto.dayOfMonthHint')}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keep duration */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.keepLabel')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{KEEP_OPTIONS.map((opt) => (
|
||||
{KEEP_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
|
||||
className={`rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.keep_days === opt.value
|
||||
? 'border-slate-700 bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
|
||||
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
@@ -475,17 +439,16 @@ export default function BackupPanel() {
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end border-t border-gray-100 pt-2">
|
||||
<div className="flex justify-end pt-2 border-t border-gray-100">
|
||||
<button
|
||||
onClick={handleSaveAutoSettings}
|
||||
disabled={autoSettingsSaving || !autoSettingsDirty}
|
||||
className="flex items-center gap-2 rounded-lg bg-slate-900 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-slate-900 disabled:opacity-50 dark:bg-slate-100 dark:text-slate-900"
|
||||
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{autoSettingsSaving ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
{autoSettingsSaving
|
||||
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
: <Check className="w-4 h-4" />
|
||||
}
|
||||
{autoSettingsSaving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -495,53 +458,25 @@ export default function BackupPanel() {
|
||||
{/* Restore Warning Modal */}
|
||||
{restoreConfirm && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
}}
|
||||
className="bg-[rgba(0,0,0,0.5)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||
className="border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{/* Red header */}
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #dc2626, #b91c1c)',
|
||||
padding: '20px 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={20} style={{ color: 'white' }} />
|
||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div className="bg-[rgba(255,255,255,0.2)]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<AlertTriangle size={20} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
|
||||
<h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>
|
||||
{t('backup.restoreConfirmTitle')}
|
||||
</h3>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
|
||||
{restoreConfirm.filename}
|
||||
</p>
|
||||
</div>
|
||||
@@ -553,9 +488,8 @@ export default function BackupPanel() {
|
||||
{t('backup.restoreWarning')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="border border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-300"
|
||||
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
|
||||
>
|
||||
{t('backup.restoreTip')}
|
||||
</div>
|
||||
@@ -565,34 +499,17 @@ export default function BackupPanel() {
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
className="text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
style={{
|
||||
padding: '9px 20px',
|
||||
borderRadius: 10,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={executeRestore}
|
||||
style={{
|
||||
padding: '9px 20px',
|
||||
borderRadius: 10,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
background: '#dc2626',
|
||||
color: 'white',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#b91c1c')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = '#dc2626')}
|
||||
className="bg-[#dc2626] text-white"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||
>
|
||||
{t('backup.restoreConfirm')}
|
||||
</button>
|
||||
@@ -601,5 +518,5 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
// FE-COMP-CAT-001 to FE-COMP-CAT-012
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildCategory, buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildCategory } from '../../../tests/helpers/factories';
|
||||
import CategoryManager from './CategoryManager';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.use(http.get('/api/categories', () => HttpResponse.json({ categories: [] })));
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [] })
|
||||
),
|
||||
);
|
||||
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
|
||||
});
|
||||
|
||||
@@ -48,7 +52,10 @@ describe('CategoryManager', () => {
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({
|
||||
categories: [buildCategory({ name: 'Museum' }), buildCategory({ name: 'Restaurant' })],
|
||||
categories: [
|
||||
buildCategory({ name: 'Museum' }),
|
||||
buildCategory({ name: 'Restaurant' }),
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -63,18 +70,13 @@ describe('CategoryManager', () => {
|
||||
server.use(
|
||||
http.post('/api/categories', async ({ request }) => {
|
||||
postCalled = true;
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({
|
||||
category: buildCategory({ name: String(body.name) }),
|
||||
});
|
||||
})
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<CategoryManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('New Category');
|
||||
await user.click(screen.getByText('New Category'));
|
||||
const nameInput = screen.getByPlaceholderText('Category name');
|
||||
@@ -86,7 +88,9 @@ describe('CategoryManager', () => {
|
||||
it('FE-COMP-CAT-008: edit button shows form for existing category', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels' })] }))
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels' })] })
|
||||
)
|
||||
);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Hotels');
|
||||
@@ -94,7 +98,7 @@ describe('CategoryManager', () => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// Buttons: [New Category, ...action buttons for the category]
|
||||
// The edit button is the first action button in the category row (Edit2 icon)
|
||||
const actionBtns = buttons.filter((b) => !b.textContent?.includes('New Category'));
|
||||
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[0]);
|
||||
// Name input pre-filled with category name
|
||||
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
|
||||
@@ -104,23 +108,20 @@ describe('CategoryManager', () => {
|
||||
const user = userEvent.setup();
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })
|
||||
),
|
||||
http.delete('/api/categories/9', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<CategoryManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('Parks');
|
||||
// Delete button is icon-only (Trash2, no title) — find the second action button
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const actionBtns = buttons.filter((b) => !b.textContent?.includes('New Category'));
|
||||
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[1]);
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -1,160 +1,144 @@
|
||||
import { Edit2, Pipette, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { categoriesApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { getApiErrorMessage } from '../../types';
|
||||
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { categoriesApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Plus, Edit2, Trash2, Pipette } from 'lucide-react'
|
||||
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#6366f1',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#ef4444',
|
||||
'#f97316',
|
||||
'#f59e0b',
|
||||
'#10b981',
|
||||
'#06b6d4',
|
||||
'#3b82f6',
|
||||
'#84cc16',
|
||||
'#6b7280',
|
||||
'#1f2937',
|
||||
];
|
||||
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
|
||||
'#f59e0b', '#10b981', '#06b6d4', '#3b82f6', '#84cc16',
|
||||
'#6b7280', '#1f2937',
|
||||
]
|
||||
|
||||
const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP);
|
||||
const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP)
|
||||
|
||||
export default function CategoryManager() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState({ name: '', color: '#6366f1', icon: 'MapPin' });
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const colorInputRef = useRef(null);
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation();
|
||||
const [categories, setCategories] = useState([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [form, setForm] = useState({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const colorInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
useEffect(() => { loadCategories() }, [])
|
||||
|
||||
const loadCategories = async () => {
|
||||
setIsLoading(true);
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await categoriesApi.list();
|
||||
setCategories(data.categories || []);
|
||||
const data = await categoriesApi.list()
|
||||
setCategories(data.categories || [])
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('categories.toast.loadError'));
|
||||
toast.error(t('categories.toast.loadError'))
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleStartEdit = (cat) => {
|
||||
setEditingId(cat.id);
|
||||
setForm({ name: cat.name, color: cat.color || '#6366f1', icon: cat.icon || 'MapPin' });
|
||||
setShowForm(false);
|
||||
};
|
||||
setEditingId(cat.id)
|
||||
setForm({ name: cat.name, color: cat.color || '#6366f1', icon: cat.icon || 'MapPin' })
|
||||
setShowForm(false)
|
||||
}
|
||||
|
||||
const handleStartCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm({ name: '', color: '#6366f1', icon: 'MapPin' });
|
||||
setShowForm(true);
|
||||
};
|
||||
setEditingId(null)
|
||||
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
};
|
||||
setShowForm(false)
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error(t('categories.toast.nameRequired'));
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
if (!form.name.trim()) { toast.error(t('categories.toast.nameRequired')); return }
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (editingId) {
|
||||
const result = await categoriesApi.update(editingId, form);
|
||||
setCategories((prev) => prev.map((c) => (c.id === editingId ? result.category : c)));
|
||||
setEditingId(null);
|
||||
toast.success(t('categories.toast.updated'));
|
||||
const result = await categoriesApi.update(editingId, form)
|
||||
setCategories(prev => prev.map(c => c.id === editingId ? result.category : c))
|
||||
setEditingId(null)
|
||||
toast.success(t('categories.toast.updated'))
|
||||
} else {
|
||||
const result = await categoriesApi.create(form);
|
||||
setCategories((prev) => [...prev, result.category]);
|
||||
setShowForm(false);
|
||||
toast.success(t('categories.toast.created'));
|
||||
const result = await categoriesApi.create(form)
|
||||
setCategories(prev => [...prev, result.category])
|
||||
setShowForm(false)
|
||||
toast.success(t('categories.toast.created'))
|
||||
}
|
||||
setForm({ name: '', color: '#6366f1', icon: 'MapPin' });
|
||||
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')));
|
||||
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')))
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsSaving(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm(t('categories.confirm.delete'))) return;
|
||||
if (!confirm(t('categories.confirm.delete'))) return
|
||||
try {
|
||||
await categoriesApi.delete(id);
|
||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
||||
toast.success(t('categories.toast.deleted'));
|
||||
await categoriesApi.delete(id)
|
||||
setCategories(prev => prev.filter(c => c.id !== id))
|
||||
toast.success(t('categories.toast.deleted'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')));
|
||||
toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const isPresetColor = PRESET_COLORS.includes(form.color);
|
||||
const PreviewIcon = getCategoryIcon(form.icon);
|
||||
const isPresetColor = PRESET_COLORS.includes(form.color)
|
||||
const PreviewIcon = getCategoryIcon(form.icon)
|
||||
|
||||
const categoryForm = (
|
||||
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-3 border border-gray-200">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t('categories.namePlaceholder')}
|
||||
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">{t('categories.icon')}</label>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">{t('categories.icon')}</label>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<div className="flex flex-wrap gap-1.5 px-1.5 py-1.5">
|
||||
{ICON_NAMES.map((name) => {
|
||||
const Icon = CATEGORY_ICON_MAP[name];
|
||||
const isSelected = form.icon === name;
|
||||
{ICON_NAMES.map(name => {
|
||||
const Icon = CATEGORY_ICON_MAP[name]
|
||||
const isSelected = form.icon === name
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
title={ICON_LABELS[name] || name}
|
||||
onClick={() => setForm((prev) => ({ ...prev, icon: name }))}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-lg transition-all ${
|
||||
isSelected ? 'ring-2 ring-slate-700 ring-offset-1' : 'hover:bg-gray-200'
|
||||
onClick={() => setForm(prev => ({ ...prev, icon: name }))}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${
|
||||
isSelected
|
||||
? 'ring-2 ring-offset-1 ring-slate-700'
|
||||
: 'hover:bg-gray-200'
|
||||
}`}
|
||||
style={{ background: isSelected ? `${form.color}18` : undefined }}
|
||||
>
|
||||
<Icon size={17} strokeWidth={1.8} color={isSelected ? form.color : '#374151'} />
|
||||
</button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600">{t('categories.color')}</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, color }))}
|
||||
className={`h-7 w-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'scale-110 ring-2 ring-gray-400 ring-offset-2' : ''}`}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1.5">{t('categories.color')}</label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{PRESET_COLORS.map(color => (
|
||||
<button key={color} type="button" onClick={() => setForm(prev => ({ ...prev, color }))}
|
||||
className={`w-7 h-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''}`}
|
||||
style={{ backgroundColor: color }} />
|
||||
))}
|
||||
|
||||
{/* Custom color button */}
|
||||
@@ -162,72 +146,57 @@ export default function CategoryManager() {
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
value={form.color}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, color: e.target.value }))}
|
||||
onChange={e => setForm(prev => ({ ...prev, color: e.target.value }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title={t('categories.customColor')}
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-full border-2 transition-transform hover:scale-110 ${
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center border-2 transition-transform hover:scale-110 ${
|
||||
!isPresetColor
|
||||
? 'scale-110 border-transparent ring-2 ring-gray-400 ring-offset-2'
|
||||
? 'ring-2 ring-offset-2 ring-gray-400 scale-110 border-transparent'
|
||||
: 'border-dashed border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
style={!isPresetColor ? { backgroundColor: form.color } : undefined}
|
||||
>
|
||||
{isPresetColor && <Pipette className="h-3 w-3 text-gray-400" />}
|
||||
{isPresetColor && <Pipette className="w-3 h-3 text-gray-400" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">{t('categories.preview')}:</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-sm font-medium"
|
||||
style={{ backgroundColor: `${form.color}20`, color: form.color }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm px-2.5 py-1 rounded-full font-medium"
|
||||
style={{ backgroundColor: `${form.color}20`, color: form.color }}>
|
||||
<PreviewIcon size={14} strokeWidth={1.8} />
|
||||
{form.name || t('categories.defaultName')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
<button type="button" onClick={handleCancel}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !form.name.trim()}
|
||||
className="rounded-lg bg-slate-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-slate-700 disabled:opacity-60"
|
||||
>
|
||||
<button type="button" onClick={handleSave} disabled={isSaving || !form.name.trim()}
|
||||
className="px-4 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium">
|
||||
{isSaving ? t('common.saving') : editingId ? t('categories.update') : t('categories.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('categories.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
{t('categories.subtitle')}
|
||||
</p>
|
||||
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStartCreate}
|
||||
className="flex items-center gap-2 rounded-lg bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-700 sm:px-4"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<button onClick={handleStartCreate}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('categories.new')}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -236,60 +205,52 @@ export default function CategoryManager() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-gray-400">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-slate-600" />
|
||||
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-400">
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<p className="text-sm">{t('categories.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{categories.map((cat) => {
|
||||
const Icon = getCategoryIcon(cat.icon);
|
||||
{categories.map(cat => {
|
||||
const Icon = getCategoryIcon(cat.icon)
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
{editingId === cat.id ? (
|
||||
<div className="mb-2">{categoryForm}</div>
|
||||
) : (
|
||||
<div className="group flex items-center gap-3 rounded-xl border border-gray-100 p-3 hover:border-gray-200">
|
||||
<div
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl"
|
||||
style={{ backgroundColor: `${cat.color}20` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 p-3 border border-gray-100 rounded-xl hover:border-gray-200 group">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: `${cat.color}20` }}>
|
||||
<Icon size={18} strokeWidth={1.8} color={cat.color} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">{cat.name}</span>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-xs"
|
||||
style={{ backgroundColor: `${cat.color}20`, color: cat.color }}
|
||||
>
|
||||
<span className="font-medium text-gray-900 text-sm">{cat.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: `${cat.color}20`, color: cat.color }}>
|
||||
{cat.color}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={() => handleStartEdit(cat)}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => handleStartEdit(cat)}
|
||||
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(cat.id)}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<button onClick={() => handleDelete(cat.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { Place } from '../../types';
|
||||
import { MapView } from '../Map/MapView';
|
||||
import Section from '../Settings/Section';
|
||||
import CustomSelect from '../shared/CustomSelect';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import Section from '../Settings/Section'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
@@ -14,31 +14,48 @@ const MAP_PRESETS = [
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
];
|
||||
]
|
||||
|
||||
type Defaults = {
|
||||
temperature_unit?: string;
|
||||
dark_mode?: string | boolean;
|
||||
time_format?: string;
|
||||
route_calculation?: boolean;
|
||||
blur_booking_codes?: boolean;
|
||||
map_tile_url?: string;
|
||||
};
|
||||
temperature_unit?: string
|
||||
dark_mode?: string | boolean
|
||||
time_format?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
mapbox_3d_enabled?: boolean
|
||||
mapbox_quality_mode?: boolean
|
||||
}
|
||||
|
||||
function OptionRow({ label, hint, children }: { label: React.ReactNode; hint?: string; children: React.ReactNode }) {
|
||||
const MAPBOX_STYLE_PRESETS = [
|
||||
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
|
||||
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' },
|
||||
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' },
|
||||
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
|
||||
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
|
||||
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
|
||||
]
|
||||
|
||||
function OptionRow({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
hint?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">
|
||||
{label}
|
||||
</label>
|
||||
{hint && (
|
||||
<p className="mb-2 text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">{children}</div>
|
||||
{hint && <p className="text-xs mb-2 text-content-faint">{hint}</p>}
|
||||
<div className="flex gap-3 flex-wrap">{children}</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function OptionButton({
|
||||
@@ -46,23 +63,17 @@ function OptionButton({
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '10px 20px',
|
||||
borderRadius: 10,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
@@ -71,132 +82,111 @@ function OptionButton({
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [defaults, setDefaults] = useState<Defaults>({});
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [mapTileUrl, setMapTileUrl] = useState('');
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
adminApi
|
||||
.getDefaultUserSettings()
|
||||
.then((data: Defaults) => {
|
||||
setDefaults(data);
|
||||
setMapTileUrl(data.map_tile_url || '');
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => setLoaded(true));
|
||||
}, []);
|
||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||
setDefaults(data)
|
||||
setMapTileUrl(data.map_tile_url || '')
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setMapboxStyle(data.mapbox_style || '')
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
}, [])
|
||||
|
||||
const save = async (patch: Partial<Defaults>) => {
|
||||
try {
|
||||
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>);
|
||||
setDefaults(updated);
|
||||
toast.success(t('admin.defaultSettings.saved'));
|
||||
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>)
|
||||
setDefaults(updated)
|
||||
toast.success(t('admin.defaultSettings.saved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'));
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const reset = async (key: keyof Defaults) => {
|
||||
try {
|
||||
const updated = await adminApi.updateDefaultUserSettings({ [key]: null });
|
||||
setDefaults(updated);
|
||||
if (key === 'map_tile_url') setMapTileUrl('');
|
||||
toast.success(t('admin.defaultSettings.reset'));
|
||||
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'mapbox_style') setMapboxStyle('')
|
||||
toast.success(t('admin.defaultSettings.reset'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'));
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const isSet = (key: keyof Defaults) => defaults[key] !== undefined;
|
||||
const isSet = (key: keyof Defaults) => defaults[key] !== undefined
|
||||
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button
|
||||
onClick={() => reset(field)}
|
||||
className="ml-2 text-xs"
|
||||
style={{
|
||||
color: 'var(--text-faint)',
|
||||
textDecoration: 'underline',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
className="text-xs ml-2 text-content-faint underline"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
) : null;
|
||||
) : null
|
||||
|
||||
const mapPreviewPlaces = useMemo(
|
||||
(): Place[] => [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: 'Preview center',
|
||||
description: null,
|
||||
notes: null,
|
||||
lat: 48.8566,
|
||||
lng: 2.3522,
|
||||
address: null,
|
||||
category_id: null,
|
||||
icon: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: null,
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: Date(),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
const mapPreviewPlaces = useMemo((): Place[] => [{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: 'Preview center',
|
||||
description: null,
|
||||
notes: null,
|
||||
lat: 48.8566,
|
||||
lng: 2.3522,
|
||||
address: null,
|
||||
category_id: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: null,
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: Date(),
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading…</p>;
|
||||
return <p className="text-content-faint" style={{ fontSize: 12, fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
}
|
||||
|
||||
const darkMode = defaults.dark_mode;
|
||||
const darkMode = defaults.dark_mode
|
||||
|
||||
return (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<p className="text-sm" style={{ color: 'var(--text-faint)', marginTop: -8 }}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
|
||||
{/* Color Mode */}
|
||||
<OptionRow
|
||||
label={
|
||||
<>
|
||||
{t('settings.colorMode')} <ResetButton field="dark_mode" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'auto', label: t('settings.auto') },
|
||||
] as const
|
||||
).map((opt) => (
|
||||
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
{([
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'auto', label: t('settings.auto') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={
|
||||
darkMode === opt.value ||
|
||||
(opt.value === 'light' && darkMode === false) ||
|
||||
(opt.value === 'dark' && darkMode === true)
|
||||
}
|
||||
active={darkMode === opt.value || (opt.value === 'light' && darkMode === false) || (opt.value === 'dark' && darkMode === true)}
|
||||
onClick={() => save({ dark_mode: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
@@ -205,19 +195,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</OptionRow>
|
||||
|
||||
{/* Temperature */}
|
||||
<OptionRow
|
||||
label={
|
||||
<>
|
||||
{t('settings.temperature')} <ResetButton field="temperature_unit" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
] as const
|
||||
).map((opt) => (
|
||||
<OptionRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
{([
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.temperature_unit === opt.value}
|
||||
@@ -229,19 +211,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</OptionRow>
|
||||
|
||||
{/* Time Format */}
|
||||
<OptionRow
|
||||
label={
|
||||
<>
|
||||
{t('settings.timeFormat')} <ResetButton field="time_format" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
] as const
|
||||
).map((opt) => (
|
||||
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
{([
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.time_format === opt.value}
|
||||
@@ -252,44 +226,12 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Route Calculation */}
|
||||
<OptionRow
|
||||
label={
|
||||
<>
|
||||
{t('settings.routeCalculation')} <ResetButton field="route_calculation" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const
|
||||
).map((opt) => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.route_calculation === opt.value}
|
||||
onClick={() => save({ route_calculation: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<OptionRow
|
||||
label={
|
||||
<>
|
||||
{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const
|
||||
).map((opt) => (
|
||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.blur_booking_codes === opt.value}
|
||||
@@ -302,20 +244,15 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.mapTemplate')}
|
||||
<ResetButton field="map_tile_url" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => {
|
||||
if (value) {
|
||||
setMapTileUrl(value);
|
||||
save({ map_tile_url: value });
|
||||
}
|
||||
}}
|
||||
onChange={(value: string) => { if (value) { setMapTileUrl(value); save({ map_tile_url: value }) } }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
@@ -325,11 +262,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
onBlur={() => save({ map_tile_url: mapTileUrl })}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-slate-400"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('settings.mapDefaultHint')}
|
||||
</p>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
@@ -352,6 +287,94 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
|
||||
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
|
||||
<OptionRow
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
||||
hint={t('admin.defaultSettings.mapProviderHint')}
|
||||
>
|
||||
{([
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={(defaults.map_provider || 'leaflet') === opt.value}
|
||||
onClick={() => save({ map_provider: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{defaults.map_provider === 'mapbox-gl' && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
<ResetButton field="mapbox_access_token" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
|
||||
onBlur={() => save({ mapbox_access_token: mapboxToken })}
|
||||
placeholder="pk.eyJ…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxStyle')}
|
||||
<ResetButton field="mapbox_style" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
|
||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||
onBlur={() => save({ mapbox_style: mapboxStyle })}
|
||||
placeholder="mapbox://styles/mapbox/standard"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
@@ -22,22 +22,12 @@ afterEach(() => {
|
||||
|
||||
describe('DevNotificationsPanel', () => {
|
||||
it('FE-ADMIN-DEVNOTIF-001: "DEV ONLY" badge is always visible', () => {
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
expect(screen.getByText('DEV ONLY')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-002: four section titles render after data loads', async () => {
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
// Wait for async data to populate conditional sections
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
await screen.findByText('User-Scoped Events');
|
||||
@@ -46,52 +36,37 @@ describe('DevNotificationsPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-003: trip selector populated from API', async () => {
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
const options = Array.from(tripSelect.querySelectorAll('option'));
|
||||
const labels = options.map((o) => o.textContent);
|
||||
const labels = options.map(o => o.textContent);
|
||||
expect(labels).toContain('Paris Adventure');
|
||||
expect(labels).toContain('Tokyo Trip');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-004: user selector populated from API', async () => {
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('User-Scoped Events');
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
// Second combobox is the user selector (first is trip selector)
|
||||
const userSelect = selects[1];
|
||||
const options = Array.from(userSelect.querySelectorAll('option'));
|
||||
const labels = options.map((o) => o.textContent ?? '');
|
||||
expect(labels.some((l) => l.includes('admin'))).toBe(true);
|
||||
expect(labels.some((l) => l.includes('alice'))).toBe(true);
|
||||
const labels = options.map(o => o.textContent ?? '');
|
||||
expect(labels.some(l => l.includes('admin'))).toBe(true);
|
||||
expect(labels.some(l => l.includes('alice'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-005: clicking "Simple → Me" fires sendTestNotification with correct payload', async () => {
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ ok: true });
|
||||
})
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await waitFor(() => expect(capturedBody).toBeDefined());
|
||||
@@ -103,14 +78,13 @@ describe('DevNotificationsPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-006: success toast shown after fire', async () => {
|
||||
server.use(http.post('/api/admin/dev/test-notification', () => HttpResponse.json({ ok: true })));
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ ok: true }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await screen.findByText('Sent: simple-me');
|
||||
@@ -121,15 +95,10 @@ describe('DevNotificationsPanel', () => {
|
||||
http.post('/api/admin/dev/test-notification', async () => {
|
||||
await new Promise(() => {}); // never resolves — simulates in-flight
|
||||
return HttpResponse.json({ ok: true });
|
||||
})
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
// Fire the click but do not await — handler never resolves so sending stays true
|
||||
@@ -137,23 +106,18 @@ describe('DevNotificationsPanel', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((btn) => expect(btn).toBeDisabled());
|
||||
buttons.forEach(btn => expect(btn).toBeDisabled());
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ message: 'Server error' }, { status: 500 })
|
||||
)
|
||||
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await screen.findByText(/failed|error/i);
|
||||
@@ -163,21 +127,18 @@ describe('DevNotificationsPanel', () => {
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ ok: true });
|
||||
})
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find((o) => o.textContent === 'Tokyo Trip')!;
|
||||
const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find(
|
||||
o => o.textContent === 'Tokyo Trip',
|
||||
)!;
|
||||
const tokyoId = Number(tokyoOption.value);
|
||||
|
||||
await user.selectOptions(tripSelect, 'Tokyo Trip');
|
||||
@@ -188,13 +149,10 @@ describe('DevNotificationsPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-010: Trip-Scoped section absent when no trips', async () => {
|
||||
server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [] })));
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [] })),
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
// Wait for user data to confirm async effects have settled
|
||||
await screen.findByText('User-Scoped Events');
|
||||
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,173 +1,122 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { adminApi, tripsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import {
|
||||
Bell,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
Image,
|
||||
MapPin,
|
||||
MessageSquare,
|
||||
Navigation,
|
||||
Tag,
|
||||
UserPlus,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { adminApi, tripsApi } from '../../api/client';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
Bell, Zap, ArrowRight, CheckCircle, Navigation, User,
|
||||
Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
|
||||
Download, MapPin,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Trip {
|
||||
id: number;
|
||||
title: string;
|
||||
id: number
|
||||
title: string
|
||||
}
|
||||
|
||||
interface AppUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export default function DevNotificationsPanel(): React.ReactElement {
|
||||
const toast = useToast();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [sending, setSending] = useState<string | null>(null);
|
||||
const [trips, setTrips] = useState<Trip[]>([]);
|
||||
const [selectedTripId, setSelectedTripId] = useState<number | null>(null);
|
||||
const [users, setUsers] = useState<AppUser[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||
const toast = useToast()
|
||||
const user = useAuthStore(s => s.user)
|
||||
const [sending, setSending] = useState<string | null>(null)
|
||||
const [trips, setTrips] = useState<Trip[]>([])
|
||||
const [selectedTripId, setSelectedTripId] = useState<number | null>(null)
|
||||
const [users, setUsers] = useState<AppUser[]>([])
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
tripsApi
|
||||
.list()
|
||||
.then((data) => {
|
||||
const list = (data.trips || data || []) as Trip[];
|
||||
setTrips(list);
|
||||
if (list.length > 0) setSelectedTripId(list[0].id);
|
||||
})
|
||||
.catch(() => {});
|
||||
adminApi
|
||||
.users()
|
||||
.then((data) => {
|
||||
const list = (data.users || data || []) as AppUser[];
|
||||
setUsers(list);
|
||||
if (list.length > 0) setSelectedUserId(list[0].id);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
tripsApi.list().then(data => {
|
||||
const list = (data.trips || data || []) as Trip[]
|
||||
setTrips(list)
|
||||
if (list.length > 0) setSelectedTripId(list[0].id)
|
||||
}).catch(() => {})
|
||||
adminApi.users().then(data => {
|
||||
const list = (data.users || data || []) as AppUser[]
|
||||
setUsers(list)
|
||||
if (list.length > 0) setSelectedUserId(list[0].id)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const fire = async (label: string, payload: Record<string, unknown>) => {
|
||||
setSending(label);
|
||||
setSending(label)
|
||||
try {
|
||||
await adminApi.sendTestNotification(payload);
|
||||
toast.success(`Sent: ${label}`);
|
||||
await adminApi.sendTestNotification(payload)
|
||||
toast.success(`Sent: ${label}`)
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed');
|
||||
toast.error(err.message || 'Failed')
|
||||
} finally {
|
||||
setSending(null);
|
||||
setSending(null)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const selectedTrip = trips.find((t) => t.id === selectedTripId);
|
||||
const selectedUser = users.find((u) => u.id === selectedUserId);
|
||||
const username = user?.username || 'Admin';
|
||||
const tripTitle = selectedTrip?.title || 'Test Trip';
|
||||
const selectedTrip = trips.find(t => t.id === selectedTripId)
|
||||
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||
const username = user?.username || 'Admin'
|
||||
const tripTitle = selectedTrip?.title || 'Test Trip'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const Btn = ({
|
||||
id,
|
||||
label,
|
||||
sub,
|
||||
icon: Icon,
|
||||
color,
|
||||
onClick,
|
||||
id, label, sub, icon: Icon, color, onClick,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
sub: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
onClick: () => void;
|
||||
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={sending !== null}
|
||||
className="flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-card)';
|
||||
}}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||
>
|
||||
<div
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ background: `${color}20`, color }}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${color}20`, color }}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="truncate text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{sub}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-content">{label}</p>
|
||||
<p className="text-xs truncate text-content-faint">{sub}</p>
|
||||
</div>
|
||||
{sending === id && (
|
||||
<div className="h-4 w-4 flex-shrink-0 animate-spin rounded-full border-2 border-slate-200 border-t-indigo-500" />
|
||||
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
)
|
||||
|
||||
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||
<h3 className="mb-3 text-sm font-semibold" style={{ color: 'var(--text-secondary)' }}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
<h3 className="text-sm font-semibold mb-3 text-content-secondary">{children}</h3>
|
||||
)
|
||||
|
||||
const TripSelector = () => (
|
||||
<select
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={(e) => setSelectedTripId(Number(e.target.value))}
|
||||
className="mb-3 w-full rounded-lg border px-3 py-2 text-sm"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
|
||||
>
|
||||
{trips.map((trip) => (
|
||||
<option key={trip.id} value={trip.id}>
|
||||
{trip.title}
|
||||
</option>
|
||||
))}
|
||||
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||
</select>
|
||||
);
|
||||
)
|
||||
|
||||
const UserSelector = () => (
|
||||
<select
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={(e) => setSelectedUserId(Number(e.target.value))}
|
||||
className="mb-3 w-full rounded-lg border px-3 py-2 text-sm"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
|
||||
>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||
</select>
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="rounded px-2 py-0.5 font-mono text-xs font-bold"
|
||||
style={{ background: '#fbbf24', color: '#000' }}
|
||||
>
|
||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold bg-[#fbbf24] text-[#000]">
|
||||
DEV ONLY
|
||||
</div>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
<span className="text-sm font-medium text-content">
|
||||
Notification Testing
|
||||
</span>
|
||||
</div>
|
||||
@@ -175,74 +124,46 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Type Testing</SectionTitle>
|
||||
<p className="mb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Test how each in-app notification type renders, sent to yourself.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Btn
|
||||
id="simple-me"
|
||||
label="Simple → Me"
|
||||
sub="test_simple · user"
|
||||
icon={Bell}
|
||||
color="#6366f1"
|
||||
onClick={() =>
|
||||
fire('simple-me', {
|
||||
event: 'test_simple',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
|
||||
onClick={() => fire('simple-me', {
|
||||
event: 'test_simple',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn
|
||||
id="boolean-me"
|
||||
label="Boolean → Me"
|
||||
sub="test_boolean · user"
|
||||
icon={CheckCircle}
|
||||
color="#10b981"
|
||||
onClick={() =>
|
||||
fire('boolean-me', {
|
||||
event: 'test_boolean',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
inApp: {
|
||||
type: 'boolean',
|
||||
positiveCallback: { action: 'test_approve', payload: {} },
|
||||
negativeCallback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
})
|
||||
}
|
||||
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
|
||||
onClick={() => fire('boolean-me', {
|
||||
event: 'test_boolean',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
inApp: {
|
||||
type: 'boolean',
|
||||
positiveCallback: { action: 'test_approve', payload: {} },
|
||||
negativeCallback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Btn
|
||||
id="navigate-me"
|
||||
label="Navigate → Me"
|
||||
sub="test_navigate · user"
|
||||
icon={Navigation}
|
||||
color="#f59e0b"
|
||||
onClick={() =>
|
||||
fire('navigate-me', {
|
||||
event: 'test_navigate',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
|
||||
onClick={() => fire('navigate-me', {
|
||||
event: 'test_navigate',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn
|
||||
id="simple-admins"
|
||||
label="Simple → All Admins"
|
||||
sub="test_simple · admin"
|
||||
icon={Zap}
|
||||
color="#ef4444"
|
||||
onClick={() =>
|
||||
fire('simple-admins', {
|
||||
event: 'test_simple',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
|
||||
onClick={() => fire('simple-admins', {
|
||||
event: 'test_simple',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,101 +172,50 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
{trips.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>Trip-Scoped Events</SectionTitle>
|
||||
<p className="mb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires each trip event to all members of the selected trip (excluding yourself).
|
||||
</p>
|
||||
<TripSelector />
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Btn
|
||||
id="booking_change"
|
||||
label="booking_change"
|
||||
sub="navigate · trip"
|
||||
icon={Calendar}
|
||||
color="#6366f1"
|
||||
onClick={() =>
|
||||
selectedTripId &&
|
||||
fire('booking_change', {
|
||||
event: 'booking_change',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: {
|
||||
actor: username,
|
||||
trip: tripTitle,
|
||||
booking: 'Test Hotel',
|
||||
type: 'hotel',
|
||||
tripId: String(selectedTripId),
|
||||
},
|
||||
})
|
||||
}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
|
||||
onClick={() => selectedTripId && fire('booking_change', {
|
||||
event: 'booking_change',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn
|
||||
id="trip_reminder"
|
||||
label="trip_reminder"
|
||||
sub="navigate · trip"
|
||||
icon={Clock}
|
||||
color="#10b981"
|
||||
onClick={() =>
|
||||
selectedTripId &&
|
||||
fire('trip_reminder', {
|
||||
event: 'trip_reminder',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { trip: tripTitle, tripId: String(selectedTripId) },
|
||||
})
|
||||
}
|
||||
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
|
||||
onClick={() => selectedTripId && fire('trip_reminder', {
|
||||
event: 'trip_reminder',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { trip: tripTitle, tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn
|
||||
id="photos_shared"
|
||||
label="photos_shared"
|
||||
sub="navigate · trip"
|
||||
icon={Image}
|
||||
color="#f59e0b"
|
||||
onClick={() =>
|
||||
selectedTripId &&
|
||||
fire('photos_shared', {
|
||||
event: 'photos_shared',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
|
||||
})
|
||||
}
|
||||
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
|
||||
onClick={() => selectedTripId && fire('photos_shared', {
|
||||
event: 'photos_shared',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn
|
||||
id="collab_message"
|
||||
label="collab_message"
|
||||
sub="navigate · trip"
|
||||
icon={MessageSquare}
|
||||
color="#8b5cf6"
|
||||
onClick={() =>
|
||||
selectedTripId &&
|
||||
fire('collab_message', {
|
||||
event: 'collab_message',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: {
|
||||
actor: username,
|
||||
trip: tripTitle,
|
||||
preview: 'This is a test message preview.',
|
||||
tripId: String(selectedTripId),
|
||||
},
|
||||
})
|
||||
}
|
||||
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
|
||||
onClick={() => selectedTripId && fire('collab_message', {
|
||||
event: 'collab_message',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn
|
||||
id="packing_tagged"
|
||||
label="packing_tagged"
|
||||
sub="navigate · trip"
|
||||
icon={Tag}
|
||||
color="#ec4899"
|
||||
onClick={() =>
|
||||
selectedTripId &&
|
||||
fire('packing_tagged', {
|
||||
event: 'packing_tagged',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
|
||||
})
|
||||
}
|
||||
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
|
||||
onClick={() => selectedTripId && fire('packing_tagged', {
|
||||
event: 'packing_tagged',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,31 +225,23 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
{users.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>User-Scoped Events</SectionTitle>
|
||||
<p className="mb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires each user event to the selected recipient.
|
||||
</p>
|
||||
<UserSelector />
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn
|
||||
id={`trip_invite-${selectedUserId}`}
|
||||
label="trip_invite"
|
||||
sub="navigate · user"
|
||||
icon={UserPlus}
|
||||
color="#06b6d4"
|
||||
onClick={() =>
|
||||
selectedUserId &&
|
||||
fire(`trip_invite-${selectedUserId}`, {
|
||||
event: 'trip_invite',
|
||||
scope: 'user',
|
||||
targetId: selectedUserId,
|
||||
params: {
|
||||
actor: username,
|
||||
trip: tripTitle,
|
||||
invitee: selectedUser?.email || '',
|
||||
tripId: String(selectedTripId ?? 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, {
|
||||
event: 'trip_invite',
|
||||
scope: 'user',
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
|
||||
})}
|
||||
/>
|
||||
<Btn
|
||||
id={`vacay_invite-${selectedUserId}`}
|
||||
@@ -387,15 +249,12 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
sub="navigate · user"
|
||||
icon={MapPin}
|
||||
color="#f97316"
|
||||
onClick={() =>
|
||||
selectedUserId &&
|
||||
fire(`vacay_invite-${selectedUserId}`, {
|
||||
event: 'vacay_invite',
|
||||
scope: 'user',
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, planId: '1' },
|
||||
})
|
||||
}
|
||||
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
|
||||
event: 'vacay_invite',
|
||||
scope: 'user',
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, planId: '1' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -404,27 +263,20 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Admin-Scoped Events</SectionTitle>
|
||||
<p className="mb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires to all admin users.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Btn
|
||||
id="version_available"
|
||||
label="version_available"
|
||||
sub="navigate · admin"
|
||||
icon={Download}
|
||||
color="#64748b"
|
||||
onClick={() =>
|
||||
fire('version_available', {
|
||||
event: 'version_available',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: { version: '9.9.9-test' },
|
||||
})
|
||||
}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
|
||||
onClick={() => fire('version_available', {
|
||||
event: 'version_available',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: { version: '9.9.9-test' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-ADMIN-GH-001 to FE-ADMIN-GH-016
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import GitHubPanel from './GitHubPanel';
|
||||
|
||||
@@ -21,12 +21,18 @@ function buildRelease(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
const PAGE_1 = Array.from({ length: 10 }, (_, i) => buildRelease({ id: i + 1, tag_name: `v1.${i}.0` }));
|
||||
const PAGE_2 = Array.from({ length: 5 }, (_, i) => buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` }));
|
||||
const PAGE_1 = Array.from({ length: 10 }, (_, i) =>
|
||||
buildRelease({ id: i + 1, tag_name: `v1.${i}.0` }),
|
||||
);
|
||||
const PAGE_2 = Array.from({ length: 5 }, (_, i) =>
|
||||
buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` }),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([])));
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([])),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -36,7 +42,9 @@ afterEach(() => {
|
||||
describe('GitHubPanel', () => {
|
||||
it('FE-ADMIN-GH-001: support link cards always render', async () => {
|
||||
render(<GitHubPanel />);
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Ko-fi')).toBeInTheDocument();
|
||||
expect(screen.getByText('Buy Me a Coffee')).toBeInTheDocument();
|
||||
expect(screen.getByText('Discord')).toBeInTheDocument();
|
||||
@@ -70,7 +78,7 @@ describe('GitHubPanel', () => {
|
||||
http.get('/api/admin/github-releases', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json([]);
|
||||
})
|
||||
}),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
// The Loader2 spinner is rendered while loading=true
|
||||
@@ -81,8 +89,8 @@ describe('GitHubPanel', () => {
|
||||
it('FE-ADMIN-GH-004: error state shown on API failure', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () =>
|
||||
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 })
|
||||
)
|
||||
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('Failed to load releases');
|
||||
@@ -93,7 +101,9 @@ describe('GitHubPanel', () => {
|
||||
it('FE-ADMIN-GH-005: releases render in timeline', async () => {
|
||||
const r1 = buildRelease({ id: 1, tag_name: 'v1.0.0', author: { login: 'mauriceboe' } });
|
||||
const r2 = buildRelease({ id: 2, tag_name: 'v1.1.0', author: { login: 'mauriceboe' } });
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])));
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.0.0');
|
||||
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
|
||||
@@ -102,16 +112,16 @@ describe('GitHubPanel', () => {
|
||||
expect(authorLabels.length).toBeGreaterThan(0);
|
||||
// Some date should be visible (non-empty)
|
||||
const dateEls = document.querySelectorAll('[class*="text-"]');
|
||||
const dateTexts = Array.from(dateEls)
|
||||
.map((el) => el.textContent)
|
||||
.filter((t) => t && t.match(/\d{4}/));
|
||||
const dateTexts = Array.from(dateEls).map(el => el.textContent).filter(t => t && t.match(/\d{4}/));
|
||||
expect(dateTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-006: latest badge shown only on first release', async () => {
|
||||
const r1 = buildRelease({ id: 1, tag_name: 'v2.0.0' });
|
||||
const r2 = buildRelease({ id: 2, tag_name: 'v1.9.0' });
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])));
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v2.0.0');
|
||||
const latestBadges = screen.getAllByText('Latest');
|
||||
@@ -120,7 +130,9 @@ describe('GitHubPanel', () => {
|
||||
|
||||
it('FE-ADMIN-GH-007: prerelease badge shown', async () => {
|
||||
const r = buildRelease({ id: 10, tag_name: 'v3.0.0-beta.1', prerelease: true });
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
render(<GitHubPanel isPrerelease={true} />);
|
||||
await screen.findByText('v3.0.0-beta.1');
|
||||
expect(screen.getByText('Pre-release')).toBeInTheDocument();
|
||||
@@ -132,7 +144,9 @@ describe('GitHubPanel', () => {
|
||||
tag_name: 'v1.5.0',
|
||||
body: '- Fixed bug\n- Another fix',
|
||||
});
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.5.0');
|
||||
@@ -150,7 +164,9 @@ describe('GitHubPanel', () => {
|
||||
|
||||
// Collapse
|
||||
await user.click(screen.getByText('Hide details'));
|
||||
await waitFor(() => expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Show details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -160,7 +176,9 @@ describe('GitHubPanel', () => {
|
||||
tag_name: 'v1.6.0',
|
||||
body: '- list item\n- **bold text**\n- `inline code`',
|
||||
});
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.6.0');
|
||||
@@ -183,14 +201,18 @@ describe('GitHubPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-010: "Load more" button visible when full page returned', async () => {
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_1)));
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_1)),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText(`v1.0.0`);
|
||||
expect(screen.getByText('Load more')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-011: "Load more" hidden when partial page returned', async () => {
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_2)));
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_2)),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v0.0.0');
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||
@@ -202,7 +224,9 @@ describe('GitHubPanel', () => {
|
||||
tag_name: 'v1.7.0',
|
||||
body: 'This is a plain paragraph without any markdown syntax.',
|
||||
});
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.7.0');
|
||||
@@ -216,7 +240,9 @@ describe('GitHubPanel', () => {
|
||||
tag_name: 'v1.8.0',
|
||||
body: '- [click here](https://example.com)',
|
||||
});
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.8.0');
|
||||
@@ -231,7 +257,9 @@ describe('GitHubPanel', () => {
|
||||
tag_name: 'v1.9.0',
|
||||
body: '- [evil](javascript:alert(1))',
|
||||
});
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.9.0');
|
||||
@@ -283,7 +311,7 @@ describe('GitHubPanel', () => {
|
||||
return HttpResponse.json(PAGE_2);
|
||||
}
|
||||
return HttpResponse.json(PAGE_1);
|
||||
})
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
|
||||
@@ -1,570 +1,377 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Bug,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Coffee,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import apiClient from '../../api/client';
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n';
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import apiClient from '../../api/client'
|
||||
|
||||
const REPO = 'mauriceboe/TREK';
|
||||
const PER_PAGE = 10;
|
||||
const REPO = 'mauriceboe/TREK'
|
||||
const PER_PAGE = 10
|
||||
|
||||
interface GithubRelease {
|
||||
id: number;
|
||||
prerelease: boolean;
|
||||
[key: string]: unknown;
|
||||
id: number
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
name: string | null
|
||||
body: string | null
|
||||
published_at: string | null
|
||||
created_at: string
|
||||
author: { login: string } | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||
const { t, language } = useTranslation();
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const { t, language } = useTranslation()
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const fetchReleases = async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } });
|
||||
const data = Array.isArray(res.data) ? res.data : [];
|
||||
setReleases((prev) => (append ? [...prev, ...data] : data));
|
||||
setHasMore(data.length === PER_PAGE);
|
||||
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||
const data = Array.isArray(res.data) ? res.data : []
|
||||
setReleases(prev => append ? [...prev, ...data] : data)
|
||||
setHasMore(data.length === PER_PAGE)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchReleases(1).finally(() => setLoading(false));
|
||||
}, []);
|
||||
setLoading(true)
|
||||
fetchReleases(1).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
const next = page + 1;
|
||||
setLoadingMore(true);
|
||||
await fetchReleases(next, true);
|
||||
setPage(next);
|
||||
setLoadingMore(false);
|
||||
};
|
||||
const next = page + 1
|
||||
setLoadingMore(true)
|
||||
await fetchReleases(next, true)
|
||||
setPage(next)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }))
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
};
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||
const renderBody = (body) => {
|
||||
if (!body) return null;
|
||||
const lines = body.split('\n');
|
||||
const elements = [];
|
||||
let listItems = [];
|
||||
if (!body) return null
|
||||
const lines = body.split('\n')
|
||||
const elements = []
|
||||
let listItems = []
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="my-2 space-y-1">
|
||||
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
<span
|
||||
className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full"
|
||||
style={{ background: 'var(--text-faint)' }}
|
||||
/>
|
||||
<li key={i} className="flex gap-2 text-xs text-content-muted">
|
||||
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
listItems = [];
|
||||
)
|
||||
listItems = []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const escapeHtml = (str) =>
|
||||
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const escapeHtml = (str) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
const inlineFormat = (text) => {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(
|
||||
/`(.+?)`/g,
|
||||
'<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>'
|
||||
)
|
||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#';
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`;
|
||||
});
|
||||
};
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
|
||||
})
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) { flushList(); continue }
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList();
|
||||
flushList()
|
||||
elements.push(
|
||||
<h4
|
||||
key={elements.length}
|
||||
className="mb-1 mt-3 text-xs font-semibold"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1 text-content">
|
||||
{trimmed.slice(4)}
|
||||
</h4>
|
||||
);
|
||||
)
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList();
|
||||
flushList()
|
||||
elements.push(
|
||||
<h3
|
||||
key={elements.length}
|
||||
className="mb-1 mt-3 text-sm font-semibold"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1 text-content">
|
||||
{trimmed.slice(3)}
|
||||
</h3>
|
||||
);
|
||||
)
|
||||
} else if (/^[-*] /.test(trimmed)) {
|
||||
listItems.push(trimmed.slice(2));
|
||||
listItems.push(trimmed.slice(2))
|
||||
} else {
|
||||
flushList();
|
||||
flushList()
|
||||
elements.push(
|
||||
<p
|
||||
key={elements.length}
|
||||
className="my-1 text-xs"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
<p key={elements.length} className="text-xs my-1 text-content-muted"
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
flushList();
|
||||
return elements;
|
||||
};
|
||||
flushList()
|
||||
return elements
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Support cards */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ff5e5b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: '#ff5e5b15',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Coffee size={20} style={{ color: '#ff5e5b' }} />
|
||||
<div className="bg-[#ff5e5b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Coffee size={20} className="text-[#ff5e5b]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Ko-fi
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('admin.github.support')}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-content">Ko-fi</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ffdd00';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: '#ffdd0015',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Heart size={20} style={{ color: '#ffdd00' }} />
|
||||
<div className="bg-[#ffdd0015]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Heart size={20} className="text-[#ffdd00]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Buy Me a Coffee
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('admin.github.support')}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#5865F2';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: '#5865F215',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
<div className="bg-[#5865F215]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Discord
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
Join the community
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-content">Discord</div>
|
||||
<div className="text-xs text-content-faint">Join the community</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ef4444';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: '#ef444415',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Bug size={20} style={{ color: '#ef4444' }} />
|
||||
<div className="bg-[#ef444415]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Bug size={20} className="text-[#ef4444]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('settings.about.reportBug')}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('settings.about.reportBugHint')}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#f59e0b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: '#f59e0b15',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
|
||||
<div className="bg-[#f59e0b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Lightbulb size={20} className="text-[#f59e0b]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('settings.about.featureRequest')}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('settings.about.featureRequestHint')}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: '#6366f115',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={20} style={{ color: '#6366f1' }} />
|
||||
<div className="bg-[#6366f115]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<BookOpen size={20} className="text-[#6366f1]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Wiki
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('settings.about.wikiHint')}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-content">Wiki</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
{loading ? (
|
||||
<div
|
||||
className="overflow-hidden rounded-xl border"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="p-8 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-content-muted" />
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className="overflow-hidden rounded-xl border"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
{t('admin.github.error')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{error}
|
||||
</p>
|
||||
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
|
||||
<p className="text-xs mt-1 text-content-faint">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="overflow-hidden rounded-xl border"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between border-b px-5 py-4"
|
||||
style={{ borderColor: 'var(--border-secondary)' }}
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between border-edge-secondary">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
|
||||
<p className="text-xs mt-0.5 text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||
>
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('admin.github.title')}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('admin.github.subtitle').replace('{repo}', REPO)}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="px-5 py-4">
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div
|
||||
className="absolute bottom-3 left-[11px] top-3 w-px"
|
||||
style={{ background: 'var(--border-primary)' }}
|
||||
/>
|
||||
|
||||
<div className="space-y-0">
|
||||
{(isPrerelease ? releases : releases.filter((r) => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0;
|
||||
const isExpanded = expanded[release.id];
|
||||
|
||||
return (
|
||||
<div key={release.id} className="relative pb-5 pl-8">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute left-0 top-1 flex h-[23px] w-[23px] items-center justify-center rounded-full border-2"
|
||||
style={{
|
||||
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||
</div>
|
||||
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{release.tag_name}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}
|
||||
>
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}
|
||||
>
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="mt-0.5 text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
|
||||
{release.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 rounded-lg p-3" style={{ background: 'var(--bg-secondary)' }}>
|
||||
{renderBody(release.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="pt-2 text-center">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="px-5 py-4">
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
|
||||
|
||||
<div className="space-y-0">
|
||||
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0
|
||||
const isExpanded = expanded[release.id]
|
||||
|
||||
return (
|
||||
<div key={release.id} className="relative pl-8 pb-5">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute left-0 top-1 w-[23px] h-[23px] rounded-full flex items-center justify-center border-2"
|
||||
style={{
|
||||
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||
</div>
|
||||
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{release.tag_name}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.12)] text-[#16a34a]">
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(245,158,11,0.12)] text-[#d97706]">
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="text-xs font-medium mt-0.5 text-content-muted">
|
||||
{release.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="flex items-center gap-1 text-[11px] text-content-faint">
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px] text-content-faint">
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors text-content-muted"
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 p-3 rounded-lg bg-surface-secondary">
|
||||
{renderBody(release.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import PackingTemplateManager from './PackingTemplateManager';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
|
||||
const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' };
|
||||
const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' };
|
||||
const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' }
|
||||
const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' }
|
||||
|
||||
const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 };
|
||||
const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 };
|
||||
const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 };
|
||||
const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 }
|
||||
const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 }
|
||||
const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 }
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
@@ -22,7 +22,7 @@ describe('PackingTemplateManager', () => {
|
||||
it('FE-ADMIN-PKG-001: shows loading spinner on mount', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', async () => {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
return HttpResponse.json({ templates: [] });
|
||||
})
|
||||
);
|
||||
@@ -37,7 +37,11 @@ describe('PackingTemplateManager', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-003: template list renders names and counts', async () => {
|
||||
server.use(http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1, tmpl2] })));
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1, tmpl2] })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
@@ -63,12 +67,7 @@ describe('PackingTemplateManager', () => {
|
||||
return HttpResponse.json({ template: { id: 99, name: 'New Template' } });
|
||||
})
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<PackingTemplateManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('No templates created yet');
|
||||
await user.click(screen.getByRole('button', { name: /new template/i }));
|
||||
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
|
||||
@@ -102,8 +101,12 @@ describe('PackingTemplateManager', () => {
|
||||
it('FE-ADMIN-PKG-007: expanding a template loads and displays its categories and items', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1, item2] }))
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
@@ -116,8 +119,12 @@ describe('PackingTemplateManager', () => {
|
||||
it('FE-ADMIN-PKG-008: collapsing an expanded template hides its content', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1, item2] }))
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
@@ -135,25 +142,22 @@ describe('PackingTemplateManager', () => {
|
||||
const user = userEvent.setup();
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1, tmpl2] })),
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1, tmpl2] })
|
||||
),
|
||||
http.delete('/api/admin/packing-templates/1', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<PackingTemplateManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('Beach Trip');
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
|
||||
// Find all Trash2 (delete) buttons — there are 2 (one per template)
|
||||
const deleteButtons = screen
|
||||
.getAllByRole('button')
|
||||
.filter((b) => b.className.includes('hover:bg-red-50') || b.querySelector('svg'));
|
||||
const deleteButtons = screen.getAllByRole('button').filter(b =>
|
||||
b.className.includes('hover:bg-red-50') || b.querySelector('svg')
|
||||
);
|
||||
// Click the delete button for "Beach Trip" (first template row's trash button)
|
||||
// The buttons layout in each row: [chevron, edit, delete]
|
||||
// We find rows first
|
||||
@@ -164,7 +168,7 @@ describe('PackingTemplateManager', () => {
|
||||
} else {
|
||||
// Fallback: find all red-hover buttons and click first
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const redBtns = allBtns.filter((b) => b.className.includes('hover:bg-red-50'));
|
||||
const redBtns = allBtns.filter(b => b.className.includes('hover:bg-red-50'));
|
||||
await user.click(redBtns[0]);
|
||||
}
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
@@ -177,7 +181,9 @@ describe('PackingTemplateManager', () => {
|
||||
const user = userEvent.setup();
|
||||
let putCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.put('/api/admin/packing-templates/1', async () => {
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
@@ -195,7 +201,7 @@ describe('PackingTemplateManager', () => {
|
||||
} else {
|
||||
// Fallback: find all slate-100-hover buttons
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const editBtns = allBtns.filter((b) => b.className.includes('hover:bg-slate-100'));
|
||||
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
|
||||
await user.click(editBtns[0]);
|
||||
}
|
||||
|
||||
@@ -209,8 +215,12 @@ describe('PackingTemplateManager', () => {
|
||||
it('FE-ADMIN-PKG-011: adding a category to an expanded template', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [], items: [] })),
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [], items: [] })
|
||||
),
|
||||
http.post('/api/admin/packing-templates/1/categories', async () =>
|
||||
HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Electronics', sort_order: 1 } })
|
||||
)
|
||||
@@ -229,8 +239,12 @@ describe('PackingTemplateManager', () => {
|
||||
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [] })
|
||||
),
|
||||
http.post('/api/admin/packing-templates/1/categories/10/items', async () =>
|
||||
HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 2 } })
|
||||
)
|
||||
@@ -255,9 +269,15 @@ describe('PackingTemplateManager', () => {
|
||||
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
|
||||
http.put('/api/admin/packing-templates/1/categories/10', async () => HttpResponse.json({ success: true }))
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [] })
|
||||
),
|
||||
http.put('/api/admin/packing-templates/1/categories/10', async () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
@@ -266,8 +286,8 @@ describe('PackingTemplateManager', () => {
|
||||
|
||||
// Find the Edit2 button in the Clothing category header
|
||||
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||
const editBtns = Array.from(clothingHeader.querySelectorAll('button')).filter((b) =>
|
||||
b.className.includes('hover:text-slate-700')
|
||||
const editBtns = Array.from(clothingHeader.querySelectorAll('button')).filter(
|
||||
b => b.className.includes('hover:text-slate-700')
|
||||
);
|
||||
// Second button (after Plus) is Edit2
|
||||
await user.click(editBtns[1]);
|
||||
@@ -281,11 +301,15 @@ describe('PackingTemplateManager', () => {
|
||||
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||
),
|
||||
http.delete('/api/admin/packing-templates/1/categories/10', () => HttpResponse.json({ success: true }))
|
||||
http.delete('/api/admin/packing-templates/1/categories/10', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
@@ -307,9 +331,15 @@ describe('PackingTemplateManager', () => {
|
||||
it('FE-ADMIN-PKG-015: renaming an item inline updates its name', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
|
||||
http.put('/api/admin/packing-templates/1/items/100', async () => HttpResponse.json({ success: true }))
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1] })
|
||||
),
|
||||
http.put('/api/admin/packing-templates/1/items/100', async () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
@@ -318,9 +348,9 @@ describe('PackingTemplateManager', () => {
|
||||
|
||||
// Find the Edit2 button in the T-shirt item row (opacity-0 group-hover buttons)
|
||||
const itemRow = screen.getByText('T-shirt').closest('div')!;
|
||||
const editBtn = Array.from(itemRow.querySelectorAll('button')).find((b) => b.className.includes('opacity-0')) as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
const editBtn = Array.from(itemRow.querySelectorAll('button')).find(
|
||||
b => b.className.includes('opacity-0')
|
||||
) as HTMLElement | undefined;
|
||||
if (editBtn) {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
@@ -338,11 +368,15 @@ describe('PackingTemplateManager', () => {
|
||||
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||
),
|
||||
http.delete('/api/admin/packing-templates/1/items/100', () => HttpResponse.json({ success: true }))
|
||||
http.delete('/api/admin/packing-templates/1/items/100', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
@@ -352,7 +386,9 @@ describe('PackingTemplateManager', () => {
|
||||
|
||||
// Find the Trash2 button in the T-shirt row
|
||||
const itemRow = screen.getByText('T-shirt').closest('div')!;
|
||||
const trashBtns = Array.from(itemRow.querySelectorAll('button')).filter((b) => b.className.includes('opacity-0'));
|
||||
const trashBtns = Array.from(itemRow.querySelectorAll('button')).filter(
|
||||
b => b.className.includes('opacity-0')
|
||||
);
|
||||
// Second opacity-0 button is the delete (trash) button
|
||||
const trashBtn = trashBtns[1] || trashBtns[0];
|
||||
await user.click(trashBtn as HTMLElement);
|
||||
@@ -365,8 +401,12 @@ describe('PackingTemplateManager', () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [], items: [] })),
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [], items: [] })
|
||||
),
|
||||
http.post('/api/admin/packing-templates/1/categories', async () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Ignored', sort_order: 1 } });
|
||||
@@ -379,7 +419,9 @@ describe('PackingTemplateManager', () => {
|
||||
await user.click(screen.getByText('Add category'));
|
||||
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
|
||||
await user.type(catInput, 'Test{Escape}');
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument()
|
||||
);
|
||||
expect(postCalled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -387,8 +429,12 @@ describe('PackingTemplateManager', () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [] })
|
||||
),
|
||||
http.post('/api/admin/packing-templates/1/categories/10/items', async () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Ignored', sort_order: 2 } });
|
||||
@@ -405,7 +451,9 @@ describe('PackingTemplateManager', () => {
|
||||
|
||||
const itemInput = screen.getByPlaceholderText('Item name');
|
||||
await user.type(itemInput, 'Test{Escape}');
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument()
|
||||
);
|
||||
expect(postCalled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -413,7 +461,9 @@ describe('PackingTemplateManager', () => {
|
||||
const user = userEvent.setup();
|
||||
let putCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.put('/api/admin/packing-templates/1', async () => {
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
@@ -429,7 +479,7 @@ describe('PackingTemplateManager', () => {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const editBtns = allBtns.filter((b) => b.className.includes('hover:bg-slate-100'));
|
||||
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
|
||||
await user.click(editBtns[0]);
|
||||
}
|
||||
|
||||
@@ -450,7 +500,8 @@ describe('PackingTemplateManager', () => {
|
||||
|
||||
// Find the X (cancel) button in the create row — it's the last button in the create row
|
||||
const createRow = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)').closest('div')!;
|
||||
const cancelBtn = Array.from(createRow.querySelectorAll('button')).at(-1) as HTMLElement;
|
||||
const createRowButtons = Array.from(createRow.querySelectorAll('button'));
|
||||
const cancelBtn = createRowButtons[createRowButtons.length - 1] as HTMLElement;
|
||||
await user.click(cancelBtn);
|
||||
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -1,414 +1,260 @@
|
||||
import { Check, ChevronDown, ChevronRight, Edit2, FolderPlus, Package, Plus, Trash2, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react'
|
||||
|
||||
interface TemplateCategory {
|
||||
id: number;
|
||||
template_id: number;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
}
|
||||
interface TemplateItem {
|
||||
id: number;
|
||||
category_id: number;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
}
|
||||
interface Template {
|
||||
id: number;
|
||||
name: string;
|
||||
item_count: number;
|
||||
category_count: number;
|
||||
created_by_name: string;
|
||||
}
|
||||
interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number }
|
||||
interface TemplateItem { id: number; category_id: number; name: string; sort_order: number }
|
||||
interface Template { id: number; name: string; item_count: number; category_count: number; created_by_name: string }
|
||||
|
||||
export default function PackingTemplateManager() {
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createName, setCreateName] = useState('')
|
||||
|
||||
// Expanded template state
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [categories, setCategories] = useState<TemplateCategory[]>([]);
|
||||
const [items, setItems] = useState<TemplateItem[]>([]);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
const [categories, setCategories] = useState<TemplateCategory[]>([])
|
||||
const [items, setItems] = useState<TemplateItem[]>([])
|
||||
|
||||
// Editing states
|
||||
const [editingTemplate, setEditingTemplate] = useState<number | null>(null);
|
||||
const [editTemplateName, setEditTemplateName] = useState('');
|
||||
const [editingCatId, setEditingCatId] = useState<number | null>(null);
|
||||
const [editCatName, setEditCatName] = useState('');
|
||||
const [editingItemId, setEditingItemId] = useState<number | null>(null);
|
||||
const [editItemName, setEditItemName] = useState('');
|
||||
const [editingTemplate, setEditingTemplate] = useState<number | null>(null)
|
||||
const [editTemplateName, setEditTemplateName] = useState('')
|
||||
const [editingCatId, setEditingCatId] = useState<number | null>(null)
|
||||
const [editCatName, setEditCatName] = useState('')
|
||||
const [editingItemId, setEditingItemId] = useState<number | null>(null)
|
||||
const [editItemName, setEditItemName] = useState('')
|
||||
|
||||
// Adding states
|
||||
const [addingCategory, setAddingCategory] = useState(false);
|
||||
const [newCatName, setNewCatName] = useState('');
|
||||
const [addingItemToCatId, setAddingItemToCatId] = useState<number | null>(null);
|
||||
const [newItemName, setNewItemName] = useState('');
|
||||
const addItemRef = useRef<HTMLInputElement>(null);
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const [addingItemToCatId, setAddingItemToCatId] = useState<number | null>(null)
|
||||
const [newItemName, setNewItemName] = useState('')
|
||||
const addItemRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
useEffect(() => { loadTemplates() }, [])
|
||||
|
||||
const loadTemplates = async () => {
|
||||
setIsLoading(true);
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await adminApi.packingTemplates();
|
||||
setTemplates(data.templates || []);
|
||||
} catch {
|
||||
toast.error(t('admin.packingTemplates.loadError'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
const data = await adminApi.packingTemplates()
|
||||
setTemplates(data.templates || [])
|
||||
} catch { toast.error(t('admin.packingTemplates.loadError')) }
|
||||
finally { setIsLoading(false) }
|
||||
}
|
||||
|
||||
const toggleExpand = async (id: number) => {
|
||||
if (expandedId === id) {
|
||||
setExpandedId(null);
|
||||
return;
|
||||
}
|
||||
setExpandedId(id);
|
||||
setAddingCategory(false);
|
||||
setAddingItemToCatId(null);
|
||||
if (expandedId === id) { setExpandedId(null); return }
|
||||
setExpandedId(id)
|
||||
setAddingCategory(false)
|
||||
setAddingItemToCatId(null)
|
||||
try {
|
||||
const data = await adminApi.getPackingTemplate(id);
|
||||
setCategories(data.categories || []);
|
||||
setItems(data.items || []);
|
||||
} catch {
|
||||
toast.error(t('admin.packingTemplates.loadError'));
|
||||
}
|
||||
};
|
||||
const data = await adminApi.getPackingTemplate(id)
|
||||
setCategories(data.categories || [])
|
||||
setItems(data.items || [])
|
||||
} catch { toast.error(t('admin.packingTemplates.loadError')) }
|
||||
}
|
||||
|
||||
// Template CRUD
|
||||
const handleCreateTemplate = async () => {
|
||||
if (!createName.trim()) return;
|
||||
if (!createName.trim()) return
|
||||
try {
|
||||
const data = await adminApi.createPackingTemplate({ name: createName.trim() });
|
||||
setTemplates((prev) => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev]);
|
||||
setCreateName('');
|
||||
setShowCreate(false);
|
||||
setExpandedId(data.template.id);
|
||||
setCategories([]);
|
||||
setItems([]);
|
||||
toast.success(t('admin.packingTemplates.created'));
|
||||
} catch {
|
||||
toast.error(t('admin.packingTemplates.createError'));
|
||||
}
|
||||
};
|
||||
const data = await adminApi.createPackingTemplate({ name: createName.trim() })
|
||||
setTemplates(prev => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev])
|
||||
setCreateName(''); setShowCreate(false)
|
||||
setExpandedId(data.template.id); setCategories([]); setItems([])
|
||||
toast.success(t('admin.packingTemplates.created'))
|
||||
} catch { toast.error(t('admin.packingTemplates.createError')) }
|
||||
}
|
||||
|
||||
const handleDeleteTemplate = async (id: number) => {
|
||||
try {
|
||||
await adminApi.deletePackingTemplate(id);
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||
if (expandedId === id) setExpandedId(null);
|
||||
toast.success(t('admin.packingTemplates.deleted'));
|
||||
} catch {
|
||||
toast.error(t('admin.packingTemplates.deleteError'));
|
||||
}
|
||||
};
|
||||
await adminApi.deletePackingTemplate(id)
|
||||
setTemplates(prev => prev.filter(t => t.id !== id))
|
||||
if (expandedId === id) setExpandedId(null)
|
||||
toast.success(t('admin.packingTemplates.deleted'))
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||
}
|
||||
|
||||
const handleRenameTemplate = async (id: number) => {
|
||||
if (!editTemplateName.trim()) {
|
||||
setEditingTemplate(null);
|
||||
return;
|
||||
}
|
||||
if (!editTemplateName.trim()) { setEditingTemplate(null); return }
|
||||
try {
|
||||
await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() });
|
||||
setTemplates((prev) => prev.map((t) => (t.id === id ? { ...t, name: editTemplateName.trim() } : t)));
|
||||
setEditingTemplate(null);
|
||||
} catch {
|
||||
toast.error(t('admin.packingTemplates.saveError'));
|
||||
}
|
||||
};
|
||||
await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() })
|
||||
setTemplates(prev => prev.map(t => t.id === id ? { ...t, name: editTemplateName.trim() } : t))
|
||||
setEditingTemplate(null)
|
||||
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||
}
|
||||
|
||||
// Category CRUD
|
||||
const handleAddCategory = async () => {
|
||||
if (!newCatName.trim() || !expandedId) return;
|
||||
if (!newCatName.trim() || !expandedId) return
|
||||
try {
|
||||
const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() });
|
||||
setCategories((prev) => [...prev, data.category]);
|
||||
setNewCatName('');
|
||||
setAddingCategory(false);
|
||||
} catch {
|
||||
toast.error(t('admin.packingTemplates.saveError'));
|
||||
}
|
||||
};
|
||||
const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() })
|
||||
setCategories(prev => [...prev, data.category])
|
||||
setNewCatName(''); setAddingCategory(false)
|
||||
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||
}
|
||||
|
||||
const handleRenameCategory = async (catId: number) => {
|
||||
if (!editCatName.trim() || !expandedId) {
|
||||
setEditingCatId(null);
|
||||
return;
|
||||
}
|
||||
if (!editCatName.trim() || !expandedId) { setEditingCatId(null); return }
|
||||
try {
|
||||
await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() });
|
||||
setCategories((prev) => prev.map((c) => (c.id === catId ? { ...c, name: editCatName.trim() } : c)));
|
||||
setEditingCatId(null);
|
||||
} catch {
|
||||
toast.error(t('admin.packingTemplates.saveError'));
|
||||
}
|
||||
};
|
||||
await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() })
|
||||
setCategories(prev => prev.map(c => c.id === catId ? { ...c, name: editCatName.trim() } : c))
|
||||
setEditingCatId(null)
|
||||
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||
}
|
||||
|
||||
const handleDeleteCategory = async (catId: number) => {
|
||||
if (!expandedId) return;
|
||||
if (!expandedId) return
|
||||
try {
|
||||
await adminApi.deleteTemplateCategory(expandedId, catId);
|
||||
setCategories((prev) => prev.filter((c) => c.id !== catId));
|
||||
setItems((prev) => prev.filter((i) => i.category_id !== catId));
|
||||
} catch {
|
||||
toast.error(t('admin.packingTemplates.deleteError'));
|
||||
}
|
||||
};
|
||||
await adminApi.deleteTemplateCategory(expandedId, catId)
|
||||
setCategories(prev => prev.filter(c => c.id !== catId))
|
||||
setItems(prev => prev.filter(i => i.category_id !== catId))
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||
}
|
||||
|
||||
// Item CRUD
|
||||
const handleAddItem = async (catId: number) => {
|
||||
if (!newItemName.trim() || !expandedId) return;
|
||||
if (!newItemName.trim() || !expandedId) return
|
||||
try {
|
||||
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() });
|
||||
setItems((prev) => [...prev, data.item]);
|
||||
setNewItemName('');
|
||||
setTimeout(() => addItemRef.current?.focus(), 30);
|
||||
} catch {
|
||||
toast.error(t('admin.packingTemplates.saveError'));
|
||||
}
|
||||
};
|
||||
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
|
||||
setItems(prev => [...prev, data.item])
|
||||
setNewItemName('')
|
||||
setTimeout(() => addItemRef.current?.focus(), 30)
|
||||
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||
}
|
||||
|
||||
const handleRenameItem = async (itemId: number) => {
|
||||
if (!editItemName.trim() || !expandedId) {
|
||||
setEditingItemId(null);
|
||||
return;
|
||||
}
|
||||
if (!editItemName.trim() || !expandedId) { setEditingItemId(null); return }
|
||||
try {
|
||||
await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() });
|
||||
setItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, name: editItemName.trim() } : i)));
|
||||
setEditingItemId(null);
|
||||
} catch {
|
||||
toast.error(t('admin.packingTemplates.saveError'));
|
||||
}
|
||||
};
|
||||
await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() })
|
||||
setItems(prev => prev.map(i => i.id === itemId ? { ...i, name: editItemName.trim() } : i))
|
||||
setEditingItemId(null)
|
||||
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||
}
|
||||
|
||||
const handleDeleteItem = async (itemId: number) => {
|
||||
if (!expandedId) return;
|
||||
if (!expandedId) return
|
||||
try {
|
||||
await adminApi.deleteTemplateItem(expandedId, itemId);
|
||||
setItems((prev) => prev.filter((i) => i.id !== itemId));
|
||||
} catch {
|
||||
toast.error(t('admin.packingTemplates.deleteError'));
|
||||
}
|
||||
};
|
||||
await adminApi.deleteTemplateItem(expandedId, itemId)
|
||||
setItems(prev => prev.filter(i => i.id !== itemId))
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||
}
|
||||
|
||||
const inputStyle =
|
||||
'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none';
|
||||
const btnIcon = 'p-1.5 rounded-lg transition-colors';
|
||||
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
|
||||
const btnIcon = 'p-1.5 rounded-lg transition-colors'
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white">
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-slate-100 p-5">
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
|
||||
<p className="mt-1 text-xs text-slate-400">{t('admin.packingTemplates.subtitle')}</p>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-slate-900 px-3 py-1.5 text-sm text-white transition-colors hover:bg-slate-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
|
||||
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create template */}
|
||||
{showCreate && (
|
||||
<div className="flex items-center gap-3 border-b border-slate-100 px-5 py-3">
|
||||
<Package size={16} className="flex-shrink-0 text-slate-400" />
|
||||
<input
|
||||
autoFocus
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateTemplate();
|
||||
if (e.key === 'Escape') setShowCreate(false);
|
||||
}}
|
||||
placeholder={t('admin.packingTemplates.namePlaceholder')}
|
||||
className={inputStyle}
|
||||
/>
|
||||
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<div className="px-5 py-3 border-b border-slate-100 flex items-center gap-3">
|
||||
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
|
||||
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
|
||||
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
|
||||
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template list */}
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-slate-200 border-t-slate-900" />
|
||||
</div>
|
||||
<div className="p-8 text-center"><div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" /></div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-slate-400">{t('admin.packingTemplates.empty')}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{templates.map((tmpl) => (
|
||||
{templates.map(tmpl => (
|
||||
<div key={tmpl.id}>
|
||||
{/* Template row */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-slate-50">
|
||||
<button
|
||||
onClick={() => toggleExpand(tmpl.id)}
|
||||
className="flex-shrink-0 cursor-pointer border-none bg-transparent p-0 text-slate-400"
|
||||
>
|
||||
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
|
||||
<button onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
|
||||
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
</button>
|
||||
<Package size={16} className="flex-shrink-0 text-slate-400" />
|
||||
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||
{editingTemplate === tmpl.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editTemplateName}
|
||||
onChange={(e) => setEditTemplateName(e.target.value)}
|
||||
<input autoFocus value={editTemplateName} onChange={e => setEditTemplateName(e.target.value)}
|
||||
onBlur={() => handleRenameTemplate(tmpl.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameTemplate(tmpl.id);
|
||||
if (e.key === 'Escape') setEditingTemplate(null);
|
||||
}}
|
||||
className="flex-1 rounded border border-slate-300 px-2 py-0.5 text-sm"
|
||||
/>
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||||
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
|
||||
) : (
|
||||
<span
|
||||
onClick={() => toggleExpand(tmpl.id)}
|
||||
className="flex-1 cursor-pointer text-sm font-medium text-slate-700"
|
||||
>
|
||||
{tmpl.name}
|
||||
</span>
|
||||
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
|
||||
)}
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-400">
|
||||
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count}{' '}
|
||||
{t('admin.packingTemplates.items')}
|
||||
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
|
||||
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingTemplate(tmpl.id);
|
||||
setEditTemplateName(tmpl.name);
|
||||
}}
|
||||
className={`${btnIcon} text-slate-400 hover:bg-slate-100 hover:text-slate-700`}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||
className={`${btnIcon} text-slate-400 hover:bg-red-50 hover:text-red-500`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<button onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
|
||||
<button onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expandedId === tmpl.id && (
|
||||
<div className="ml-8 space-y-3 px-5 pb-4">
|
||||
{categories.map((cat) => {
|
||||
const catItems = items.filter((i) => i.category_id === cat.id);
|
||||
<div className="px-5 pb-4 ml-8 space-y-3">
|
||||
{categories.map(cat => {
|
||||
const catItems = items.filter(i => i.category_id === cat.id)
|
||||
return (
|
||||
<div key={cat.id} className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
{/* Category header */}
|
||||
<div className="flex items-center gap-2 bg-slate-50 px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50">
|
||||
{editingCatId === cat.id ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={editCatName}
|
||||
onChange={(e) => setEditCatName(e.target.value)}
|
||||
<input autoFocus value={editCatName} onChange={e => setEditCatName(e.target.value)}
|
||||
onBlur={() => handleRenameCategory(cat.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameCategory(cat.id);
|
||||
if (e.key === 'Escape') setEditingCatId(null);
|
||||
}}
|
||||
className="flex-1 rounded border border-slate-300 px-2 py-0.5 text-sm font-semibold"
|
||||
/>
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameCategory(cat.id); if (e.key === 'Escape') setEditingCatId(null) }}
|
||||
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm font-semibold" />
|
||||
</>
|
||||
) : (
|
||||
<span className="flex-1 text-xs font-bold uppercase tracking-wider text-slate-500">
|
||||
{cat.name}
|
||||
</span>
|
||||
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400">{catItems.length}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id);
|
||||
setNewItemName('');
|
||||
setTimeout(() => addItemRef.current?.focus(), 30);
|
||||
}}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingCatId(cat.id);
|
||||
setEditCatName(cat.name);
|
||||
}}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteCategory(cat.id)}
|
||||
className={`${btnIcon} text-slate-400 hover:text-red-500`}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
<button onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
|
||||
<button onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
|
||||
<button onClick={() => handleDeleteCategory(cat.id)}
|
||||
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
|
||||
<div className="divide-y divide-slate-50">
|
||||
{catItems.map((item) => (
|
||||
<div key={item.id} className="group flex items-center gap-3 px-4 py-2">
|
||||
{catItems.map(item => (
|
||||
<div key={item.id} className="flex items-center gap-3 px-4 py-2 group">
|
||||
{editingItemId === item.id ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={editItemName}
|
||||
onChange={(e) => setEditItemName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameItem(item.id);
|
||||
if (e.key === 'Escape') setEditingItemId(null);
|
||||
}}
|
||||
className="flex-1 rounded-lg border border-slate-200 px-2 py-1 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRenameItem(item.id)}
|
||||
className="p-1 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<Check size={13} />
|
||||
</button>
|
||||
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400">
|
||||
<X size={13} />
|
||||
</button>
|
||||
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
|
||||
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingItemId(item.id);
|
||||
setEditItemName(item.name);
|
||||
}}
|
||||
className="rounded p-1 text-slate-400 opacity-0 transition-all hover:text-slate-700 group-hover:opacity-100"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteItem(item.id)}
|
||||
className="rounded p-1 text-slate-400 opacity-0 transition-all hover:text-red-500 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<button onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
|
||||
<button onClick={() => handleDeleteItem(item.id)}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -417,79 +263,35 @@ export default function PackingTemplateManager() {
|
||||
{/* Add item inline */}
|
||||
{addingItemToCatId === cat.id && (
|
||||
<div className="flex items-center gap-2 px-4 py-2">
|
||||
<input
|
||||
ref={addItemRef}
|
||||
value={newItemName}
|
||||
onChange={(e) => setNewItemName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id);
|
||||
if (e.key === 'Escape') {
|
||||
setAddingItemToCatId(null);
|
||||
setNewItemName('');
|
||||
}
|
||||
}}
|
||||
<input ref={addItemRef} value={newItemName} onChange={e => setNewItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
|
||||
placeholder={t('admin.packingTemplates.itemName')}
|
||||
className="flex-1 rounded-lg border border-slate-200 px-2 py-1 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleAddItem(cat.id)}
|
||||
disabled={!newItemName.trim()}
|
||||
className="rounded-lg bg-slate-900 p-1.5 text-white transition-colors hover:bg-slate-700 disabled:bg-slate-300"
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAddingItemToCatId(null);
|
||||
setNewItemName('');
|
||||
}}
|
||||
className="p-1 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
|
||||
<button onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Add category button */}
|
||||
{addingCategory ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={newCatName}
|
||||
onChange={(e) => setNewCatName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleAddCategory();
|
||||
if (e.key === 'Escape') {
|
||||
setAddingCategory(false);
|
||||
setNewCatName('');
|
||||
}
|
||||
}}
|
||||
<input autoFocus value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||
placeholder={t('admin.packingTemplates.categoryName')}
|
||||
className="flex-1 rounded-lg border border-slate-200 px-3 py-2 text-sm"
|
||||
/>
|
||||
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}>
|
||||
<Check size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAddingCategory(false);
|
||||
setNewCatName('');
|
||||
}}
|
||||
className={`${btnIcon} text-slate-400`}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
|
||||
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
|
||||
<button onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setAddingCategory(true)}
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-dashed border-slate-200 px-3 py-2.5 text-sm text-slate-400 transition-colors hover:border-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<button onClick={() => setAddingCategory(true)}
|
||||
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
|
||||
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
|
||||
</button>
|
||||
)}
|
||||
@@ -500,5 +302,5 @@ export default function PackingTemplateManager() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-ADMIN-PERM-001 to FE-ADMIN-PERM-010
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import PermissionsPanel from './PermissionsPanel';
|
||||
@@ -41,7 +41,7 @@ function renderPanel() {
|
||||
<>
|
||||
<ToastContainer />
|
||||
<PermissionsPanel />
|
||||
</>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,11 @@ function renderPanel() {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
// Override the default handler (returns object) with correct array shape
|
||||
server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: SAMPLE_PERMISSIONS })));
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -65,7 +69,7 @@ describe('PermissionsPanel', () => {
|
||||
http.get('/api/admin/permissions', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json({ permissions: [] });
|
||||
})
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
@@ -91,7 +95,11 @@ describe('PermissionsPanel', () => {
|
||||
buildPermission('trip_create', 'admin', 'trip_member'), // level ≠ default → badge
|
||||
buildPermission('trip_edit', 'trip_member', 'trip_member'), // level === default → no badge
|
||||
];
|
||||
server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: perms })));
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ permissions: perms }),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
// Badge should appear once (for trip_create)
|
||||
@@ -142,9 +150,13 @@ describe('PermissionsPanel', () => {
|
||||
it('FE-ADMIN-PERM-006: Reset button restores values to defaultLevel and enables Save', async () => {
|
||||
const perms = [
|
||||
buildPermission('trip_create', 'admin', 'trip_member'), // customized
|
||||
...SAMPLE_PERMISSIONS.filter((p) => p.key !== 'trip_create'),
|
||||
...SAMPLE_PERMISSIONS.filter(p => p.key !== 'trip_create'),
|
||||
];
|
||||
server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: perms })));
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ permissions: perms }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
@@ -167,7 +179,11 @@ describe('PermissionsPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-007: successful save calls PUT and shows success toast', async () => {
|
||||
server.use(http.put('/api/admin/permissions', () => HttpResponse.json({ permissions: SAMPLE_PERMISSIONS })));
|
||||
server.use(
|
||||
http.put('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
@@ -188,7 +204,11 @@ describe('PermissionsPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-008: failed save shows error toast and keeps Save enabled', async () => {
|
||||
server.use(http.put('/api/admin/permissions', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
|
||||
server.use(
|
||||
http.put('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
@@ -211,13 +231,12 @@ describe('PermissionsPanel', () => {
|
||||
it('FE-ADMIN-PERM-009: Save button is disabled while save is in-flight', async () => {
|
||||
let resolvePut!: () => void;
|
||||
server.use(
|
||||
http.put(
|
||||
'/api/admin/permissions',
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolvePut = () => resolve(HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }) as unknown as Response);
|
||||
})
|
||||
)
|
||||
http.put('/api/admin/permissions', () =>
|
||||
new Promise<Response>(resolve => {
|
||||
resolvePut = () =>
|
||||
resolve(HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }) as unknown as Response);
|
||||
}),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
@@ -244,7 +263,11 @@ describe('PermissionsPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-010: load failure shows error toast', async () => {
|
||||
server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
await screen.findByText('Error');
|
||||
});
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Loader2, RotateCcw, Save } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { PermissionLevel, usePermissionsStore } from '../../store/permissionsStore';
|
||||
import CustomSelect from '../shared/CustomSelect';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Save, Loader2, RotateCcw } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
interface PermissionEntry {
|
||||
key: string;
|
||||
level: PermissionLevel;
|
||||
defaultLevel: PermissionLevel;
|
||||
allowedLevels: PermissionLevel[];
|
||||
key: string
|
||||
level: PermissionLevel
|
||||
defaultLevel: PermissionLevel
|
||||
allowedLevels: PermissionLevel[]
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<string, string> = {
|
||||
@@ -18,7 +18,7 @@ const LEVEL_LABELS: Record<string, string> = {
|
||||
trip_owner: 'perm.level.tripOwner',
|
||||
trip_member: 'perm.level.tripMember',
|
||||
everybody: 'perm.level.everybody',
|
||||
};
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
|
||||
@@ -26,82 +26,82 @@ const CATEGORIES = [
|
||||
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
|
||||
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
|
||||
];
|
||||
]
|
||||
|
||||
export default function PermissionsPanel(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [entries, setEntries] = useState<PermissionEntry[]>([]);
|
||||
const [values, setValues] = useState<Record<string, PermissionLevel>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [entries, setEntries] = useState<PermissionEntry[]>([])
|
||||
const [values, setValues] = useState<Record<string, PermissionLevel>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadPermissions();
|
||||
}, []);
|
||||
loadPermissions()
|
||||
}, [])
|
||||
|
||||
const loadPermissions = async () => {
|
||||
setLoading(true);
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.getPermissions();
|
||||
setEntries(data.permissions);
|
||||
const vals: Record<string, PermissionLevel> = {};
|
||||
for (const p of data.permissions) vals[p.key] = p.level;
|
||||
setValues(vals);
|
||||
setDirty(false);
|
||||
const data = await adminApi.getPermissions()
|
||||
setEntries(data.permissions)
|
||||
const vals: Record<string, PermissionLevel> = {}
|
||||
for (const p of data.permissions) vals[p.key] = p.level
|
||||
setValues(vals)
|
||||
setDirty(false)
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleChange = (key: string, level: PermissionLevel) => {
|
||||
setValues((prev) => ({ ...prev, [key]: level }));
|
||||
setDirty(true);
|
||||
};
|
||||
setValues(prev => ({ ...prev, [key]: level }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = await adminApi.updatePermissions(values);
|
||||
const data = await adminApi.updatePermissions(values)
|
||||
if (data.permissions) {
|
||||
usePermissionsStore.getState().setPermissions(data.permissions);
|
||||
usePermissionsStore.getState().setPermissions(data.permissions)
|
||||
}
|
||||
setDirty(false);
|
||||
toast.success(t('perm.saved'));
|
||||
setDirty(false)
|
||||
toast.success(t('perm.saved'))
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSaving(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
const defaults: Record<string, PermissionLevel> = {};
|
||||
for (const p of entries) defaults[p.key] = p.defaultLevel;
|
||||
setValues(defaults);
|
||||
setDirty(true);
|
||||
};
|
||||
const defaults: Record<string, PermissionLevel> = {}
|
||||
for (const p of entries) defaults[p.key] = p.defaultLevel
|
||||
setValues(defaults)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const entryMap = useMemo(() => new Map(entries.map((e) => [e.key, e])), [entries]);
|
||||
const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-slate-200 border-t-slate-900" />
|
||||
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('perm.title')}</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{t('perm.subtitle')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -109,50 +109,50 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
disabled={saving}
|
||||
title={t('perm.resetDefaults')}
|
||||
aria-label={t('perm.resetDefaults')}
|
||||
className="flex w-8 items-center justify-center gap-1.5 rounded-lg border border-slate-300 px-0 py-1.5 text-sm transition-colors hover:bg-slate-50 disabled:opacity-40 sm:w-auto sm:px-3"
|
||||
className="flex items-center justify-center gap-1.5 px-0 sm:px-3 py-1.5 text-sm w-8 sm:w-auto border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-slate-900 px-3 py-1.5 text-sm text-white transition-colors hover:bg-slate-700 disabled:bg-slate-400"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
|
||||
>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{CATEGORIES.map((cat) => (
|
||||
{CATEGORIES.map(cat => (
|
||||
<div key={cat.id} className="px-6 py-4">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||
{t(`perm.cat.${cat.id}`)}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{cat.keys.map((key) => {
|
||||
const entry = entryMap.get(key);
|
||||
if (!entry) return null;
|
||||
const currentLevel = values[key] || entry.defaultLevel;
|
||||
const isDefault = currentLevel === entry.defaultLevel;
|
||||
{cat.keys.map(key => {
|
||||
const entry = entryMap.get(key)
|
||||
if (!entry) return null
|
||||
const currentLevel = values[key] || entry.defaultLevel
|
||||
const isDefault = currentLevel === entry.defaultLevel
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-700">{t(`perm.action.${key}`)}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{t(`perm.actionHint.${key}`)}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t(`perm.actionHint.${key}`)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isDefault && (
|
||||
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
|
||||
{t('perm.customized')}
|
||||
</span>
|
||||
)}
|
||||
<CustomSelect
|
||||
value={currentLevel}
|
||||
onChange={(val) => handleChange(key, val as PermissionLevel)}
|
||||
options={entry.allowedLevels.map((l) => ({
|
||||
options={entry.allowedLevels.map(l => ({
|
||||
value: l,
|
||||
label: t(LEVEL_LABELS[l] || l),
|
||||
}))}
|
||||
@@ -160,7 +160,7 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,5 +168,5 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const CURRENCIES = [
|
||||
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
|
||||
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
|
||||
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
|
||||
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
|
||||
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
]
|
||||
|
||||
export const SYMBOLS: Record<string, string> = {
|
||||
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
|
||||
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
|
||||
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
|
||||
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
|
||||
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
|
||||
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
|
||||
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
|
||||
PEN: 'S/.', ARS: 'AR$',
|
||||
}
|
||||
|
||||
export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||
|
||||
export const SPLIT_COLORS = [
|
||||
{ solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' },
|
||||
{ solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' },
|
||||
{ solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' },
|
||||
{ solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' },
|
||||
{ solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' },
|
||||
{ solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' },
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { SYMBOLS, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
|
||||
export function widgetTheme(dark: boolean) {
|
||||
if (dark) return {
|
||||
bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
|
||||
border: 'rgba(255,255,255,0.07)',
|
||||
text: '#ffffff',
|
||||
sub: 'rgba(255,255,255,0.6)',
|
||||
faint: 'rgba(255,255,255,0.4)',
|
||||
track: 'rgba(255,255,255,0.04)',
|
||||
divider: 'rgba(255,255,255,0.07)',
|
||||
iconBg: 'rgba(255,255,255,0.08)',
|
||||
iconBorder: 'rgba(255,255,255,0.12)',
|
||||
iconColor: 'rgba(255,255,255,0.9)',
|
||||
centerBg: '#17171d',
|
||||
flowBg: 'rgba(255,255,255,0.05)',
|
||||
flowBorder: 'rgba(255,255,255,0.07)',
|
||||
flowHoverBg: 'rgba(255,255,255,0.08)',
|
||||
flowHoverBorder: 'rgba(255,255,255,0.12)',
|
||||
rowHover: 'rgba(255,255,255,0.03)',
|
||||
shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
|
||||
donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
|
||||
}
|
||||
return {
|
||||
bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)',
|
||||
border: 'rgba(15,23,42,0.08)',
|
||||
text: '#111827',
|
||||
sub: 'rgba(17,24,39,0.6)',
|
||||
faint: 'rgba(17,24,39,0.4)',
|
||||
track: 'rgba(15,23,42,0.05)',
|
||||
divider: 'rgba(15,23,42,0.08)',
|
||||
iconBg: 'rgba(15,23,42,0.05)',
|
||||
iconBorder: 'rgba(15,23,42,0.1)',
|
||||
iconColor: 'rgba(17,24,39,0.75)',
|
||||
centerBg: '#ffffff',
|
||||
flowBg: 'rgba(15,23,42,0.03)',
|
||||
flowBorder: 'rgba(15,23,42,0.08)',
|
||||
flowHoverBg: 'rgba(15,23,42,0.06)',
|
||||
flowHoverBorder: 'rgba(15,23,42,0.14)',
|
||||
rowHover: 'rgba(15,23,42,0.04)',
|
||||
shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)',
|
||||
donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))',
|
||||
}
|
||||
}
|
||||
|
||||
export function hexLighten(hex: string, amount: number): string {
|
||||
const m = hex.replace('#', '').match(/.{2}/g)
|
||||
if (!m || m.length !== 3) return hex
|
||||
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
|
||||
const [r, g, b] = m.map(x => parseInt(x, 16))
|
||||
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
export const fmtNum = (v: number | null | undefined, locale: string, cur: string) => {
|
||||
if (v == null || isNaN(v)) return '-'
|
||||
const d = currencyDecimals(cur)
|
||||
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
|
||||
}
|
||||
|
||||
type NumOrNull = number | null | undefined
|
||||
|
||||
export const calcPP = (p: NumOrNull, n: NumOrNull) => (n! > 0 ? (p as number) / (n as number) : null)
|
||||
export const calcPD = (p: NumOrNull, d: NumOrNull) => (d! > 0 ? (p as number) / (d as number) : null)
|
||||
export const calcPPD = (p: NumOrNull, n: NumOrNull, d: NumOrNull) => (n! > 0 && d! > 0 ? (p as number) / ((n as number) * (d as number)) : null)
|
||||
|
||||
export function splitColorFor(userId: number, order: number) {
|
||||
return SPLIT_COLORS[order % SPLIT_COLORS.length]
|
||||
}
|
||||
|
||||
export function colorForUserId(userId: number) {
|
||||
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildBudgetItem, buildSettings, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
|
||||
import BudgetPanel from './BudgetPanel';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
// Settlement and per-person APIs needed by BudgetPanel
|
||||
server.use(
|
||||
http.get('/api/trips/:id/budget/settlement', () => HttpResponse.json({ balances: [], flows: [] })),
|
||||
http.get('/api/trips/:id/budget/per-person', () => HttpResponse.json({ summary: [] }))
|
||||
http.get('/api/trips/:id/budget/settlement', () =>
|
||||
HttpResponse.json({ balances: [], flows: [] })
|
||||
),
|
||||
http.get('/api/trips/:id/budget/per-person', () =>
|
||||
HttpResponse.json({ summary: [] })
|
||||
),
|
||||
);
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
@@ -24,62 +28,82 @@ beforeEach(() => {
|
||||
|
||||
describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-001: renders empty state when no budget items', async () => {
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('No budget created yet');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-002: shows empty state text body', async () => {
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText(/Create categories and entries/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-003: shows category input in empty state when user can edit', async () => {
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('Enter category name...');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-004: renders budget items from store after load', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, name: 'Hotel Paris', category: 'Accommodation' });
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Hotel Paris');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-005: renders category section header', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, name: 'Flight to Rome', category: 'Transport' });
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Transport');
|
||||
// 'Transport' appears in the category section header and the spend breakdown chart.
|
||||
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Name');
|
||||
await screen.findByText('Total');
|
||||
// 'Total' appears both as a table header and in the chart total label.
|
||||
expect((await screen.findAllByText('Total')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Budget');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-008: shows CSV export button', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('CSV');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-009: add item row visible in table', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('New Entry');
|
||||
});
|
||||
@@ -90,7 +114,7 @@ describe('BudgetPanel', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
const item = buildBudgetItem({ trip_id: 1, name: String(body.name || 'New Item'), category: 'Food' });
|
||||
return HttpResponse.json({ item });
|
||||
})
|
||||
@@ -105,7 +129,9 @@ describe('BudgetPanel', () => {
|
||||
|
||||
it('FE-COMP-BUDGET-011: delete button present for items when user can edit', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Test Item' });
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Test Item');
|
||||
// Delete button has title="Delete"
|
||||
@@ -130,7 +156,9 @@ describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-013: multiple items in same category all render', async () => {
|
||||
const item1 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel A' });
|
||||
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel B' });
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Hotel A');
|
||||
await screen.findByText('Hotel B');
|
||||
@@ -139,15 +167,20 @@ describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-014: items from different categories render separate sections', async () => {
|
||||
const item1 = buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' });
|
||||
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' });
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Transport');
|
||||
await screen.findByText('Hotels');
|
||||
// Each category appears in its section header and again in the breakdown chart.
|
||||
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
|
||||
expect((await screen.findAllByText('Hotels')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ default_currency: 'USD' }) });
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
// Component renders even in empty state
|
||||
await screen.findByText('No budget created yet');
|
||||
@@ -156,7 +189,9 @@ describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-016: trip currency EUR is shown in header for item rows', async () => {
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc', total_price: 50 });
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Misc');
|
||||
// Row exists - EUR formatting would appear in values
|
||||
@@ -164,15 +199,20 @@ describe('BudgetPanel', () => {
|
||||
|
||||
it('FE-COMP-BUDGET-017: Delete Category button shown in category header', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'ToDelete', name: 'Item' });
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('ToDelete');
|
||||
// 'ToDelete' appears in the category header and the breakdown chart.
|
||||
expect((await screen.findAllByText('ToDelete')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-018: renders add item button (+ icon) in add row', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('New Entry');
|
||||
// The add button is present
|
||||
@@ -185,7 +225,7 @@ describe('BudgetPanel', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
const item = buildBudgetItem({ trip_id: 1, name: String(body.name), category: 'Food' });
|
||||
return HttpResponse.json({ item });
|
||||
})
|
||||
@@ -197,7 +237,9 @@ describe('BudgetPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-020: component renders without crashing with empty tripMembers', async () => {
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} tripMembers={[]} />);
|
||||
await screen.findByText('No budget created yet');
|
||||
});
|
||||
@@ -205,7 +247,9 @@ describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-021: inline edit name cell — clicking a name cell makes it editable', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ id: 21, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 };
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Old Name');
|
||||
await user.click(screen.getByText('Old Name'));
|
||||
@@ -221,7 +265,7 @@ describe('BudgetPanel', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.put('/api/trips/1/budget/10', async ({ request }) => {
|
||||
const b = (await request.json()) as Record<string, unknown>;
|
||||
const b = await request.json() as Record<string, unknown>;
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ item: { ...item, name: b.name } });
|
||||
})
|
||||
@@ -237,11 +281,10 @@ describe('BudgetPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-023: total price is shown formatted with currency symbol', async () => {
|
||||
const item = {
|
||||
...buildBudgetItem({ id: 23, trip_id: 1, category: 'Restaurants', name: 'Dinner' }),
|
||||
total_price: 45.5,
|
||||
};
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
const item = { ...buildBudgetItem({ id: 23, trip_id: 1, category: 'Restaurants', name: 'Dinner' }), total_price: 45.5 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Dinner');
|
||||
// The formatted number appears in the InlineEditCell for total price (and grand total card)
|
||||
@@ -252,10 +295,7 @@ describe('BudgetPanel', () => {
|
||||
|
||||
it('FE-COMP-BUDGET-024: delete category button removes all items in that category', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = {
|
||||
...buildBudgetItem({ id: 24, trip_id: 1, category: 'Flights', name: 'Flight to Paris' }),
|
||||
total_price: 200,
|
||||
};
|
||||
const item = { ...buildBudgetItem({ id: 24, trip_id: 1, category: 'Flights', name: 'Flight to Paris' }), total_price: 200 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.delete('/api/trips/1/budget/24', () => HttpResponse.json({ success: true }))
|
||||
@@ -275,7 +315,9 @@ describe('BudgetPanel', () => {
|
||||
vi.spyOn(URL, 'createObjectURL').mockImplementation(createObjectURL);
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc' }), total_price: 10 };
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('CSV');
|
||||
await user.click(screen.getByText('CSV'));
|
||||
@@ -286,7 +328,9 @@ describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-026: category total row shows sum of items in category', async () => {
|
||||
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 20 };
|
||||
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 30 };
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Lunch');
|
||||
// The category header shows subtotal formatted as "50.00 €" (also appears in pie legend)
|
||||
@@ -294,7 +338,9 @@ describe('BudgetPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-027: add new category input is visible in empty state', async () => {
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('Enter category name...');
|
||||
});
|
||||
@@ -304,9 +350,7 @@ describe('BudgetPanel', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.post('/api/trips/1/budget', () =>
|
||||
HttpResponse.json({
|
||||
item: { ...buildBudgetItem({ category: 'Souvenirs', name: 'New Entry' }), total_price: 0 },
|
||||
})
|
||||
HttpResponse.json({ item: { ...buildBudgetItem({ category: 'Souvenirs', name: 'New Entry' }), total_price: 0 } })
|
||||
)
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
@@ -350,7 +394,7 @@ describe('BudgetPanel', () => {
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
|
||||
total_price: 75,
|
||||
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: false }],
|
||||
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: 0 }],
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
@@ -370,7 +414,9 @@ describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-032: grand total row shows sum across all categories', async () => {
|
||||
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' }), total_price: 100 };
|
||||
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' }), total_price: 200 };
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Flight');
|
||||
await screen.findByText('Hotel');
|
||||
@@ -383,9 +429,11 @@ describe('BudgetPanel', () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
// Use a user with id != 1 so they're not the owner
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Read Only Item');
|
||||
// In read-only mode the Delete button should not be visible
|
||||
@@ -395,13 +443,11 @@ describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }),
|
||||
total_price: 30,
|
||||
expense_date: '2025-06-15',
|
||||
};
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Train');
|
||||
// expense_date is rendered as plain text in read-only mode
|
||||
@@ -419,16 +465,10 @@ describe('BudgetPanel', () => {
|
||||
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
|
||||
],
|
||||
flows: [
|
||||
{
|
||||
from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
|
||||
to: { username: 'bob', avatar_url: null },
|
||||
amount: 30,
|
||||
},
|
||||
],
|
||||
flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
|
||||
})
|
||||
),
|
||||
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] }))
|
||||
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
|
||||
);
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
|
||||
@@ -448,13 +488,11 @@ describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }),
|
||||
total_price: 5,
|
||||
expense_date: null,
|
||||
};
|
||||
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Snack');
|
||||
// When expense_date is null, the fallback '—' is shown
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
|
||||
interface AddItemRowProps {
|
||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
const [persons, setPersons] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [expenseDate, setExpenseDate] = useState('')
|
||||
const nameRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim()) return
|
||||
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
|
||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
|
||||
setTimeout(() => nameRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
|
||||
|
||||
return (
|
||||
<tr className="bg-surface-secondary">
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder={t('budget.newEntry')} style={inp} />
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }}
|
||||
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden lg:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
|
||||
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
|
||||
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
|
||||
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
|
||||
import { Trash2, Pencil, GripVertical } from 'lucide-react'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
|
||||
import InlineEditCell from './BudgetPanelInlineEditCell'
|
||||
import AddItemRow from './BudgetPanelAddItemRow'
|
||||
import BudgetMemberChips, { type TripMember } from './BudgetPanelMemberChips'
|
||||
import type { EditingCat, AddItemData } from './useBudgetPanel'
|
||||
|
||||
interface BudgetCategoryTableProps {
|
||||
cat: string
|
||||
grouped: Map<string, BudgetItem[]>
|
||||
categoryColor: (cat: string) => string
|
||||
canEdit: boolean
|
||||
editingCat: EditingCat | null
|
||||
setEditingCat: Dispatch<SetStateAction<EditingCat | null>>
|
||||
dragCat: string | null
|
||||
setDragCat: Dispatch<SetStateAction<string | null>>
|
||||
dragOverCat: string | null
|
||||
setDragOverCat: Dispatch<SetStateAction<string | null>>
|
||||
dragItem: number | null
|
||||
setDragItem: Dispatch<SetStateAction<number | null>>
|
||||
dragOverItem: number | null
|
||||
setDragOverItem: Dispatch<SetStateAction<number | null>>
|
||||
dragItemCat: string | null
|
||||
setDragItemCat: Dispatch<SetStateAction<string | null>>
|
||||
categoryNames: string[]
|
||||
reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise<void>
|
||||
reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||
handleRenameCategory: (oldName: string, newName: string) => Promise<void>
|
||||
handleDeleteCategory: (cat: string) => Promise<void>
|
||||
handleDeleteItem: (id: number) => Promise<void>
|
||||
handleUpdateField: (id: number, field: string, value: unknown) => Promise<void>
|
||||
handleAddItem: (category: string, data: AddItemData) => Promise<void>
|
||||
tripId: number
|
||||
currency: string
|
||||
locale: string
|
||||
t: (key: string) => string
|
||||
fmt: (v: number | null | undefined, cur: string) => string
|
||||
hasMultipleMembers: boolean
|
||||
tripMembers: TripMember[]
|
||||
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: unknown; item: unknown }>
|
||||
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
|
||||
th: CSSProperties
|
||||
td: CSSProperties
|
||||
}
|
||||
|
||||
export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat,
|
||||
dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem,
|
||||
dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems,
|
||||
handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem,
|
||||
tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: BudgetCategoryTableProps) {
|
||||
const items = grouped.get(cat) || []
|
||||
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
|
||||
const color = categoryColor(cat)
|
||||
return (
|
||||
<div key={cat} data-drag-cat={cat} style={{
|
||||
marginBottom: 16, opacity: dragCat === cat ? 0.4 : 1,
|
||||
transition: 'opacity 0.15s',
|
||||
position: 'relative',
|
||||
}}
|
||||
onDragOver={e => {
|
||||
if (!dragCat || dragCat === cat || dragItem) return
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverCat(cat)
|
||||
}}
|
||||
onDragLeave={e => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null)
|
||||
}}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
if (dragCat && dragCat !== cat) {
|
||||
const newOrder = [...categoryNames]
|
||||
const fromIdx = newOrder.indexOf(dragCat)
|
||||
const toIdx = newOrder.indexOf(cat)
|
||||
newOrder.splice(fromIdx, 1)
|
||||
newOrder.splice(toIdx, 0, dragCat)
|
||||
reorderBudgetCategories(tripId, newOrder)
|
||||
}
|
||||
setDragCat(null); setDragOverCat(null)
|
||||
}}
|
||||
>
|
||||
{dragOverCat === cat && <div style={{ position: 'absolute', top: -2, left: 0, right: 0, height: 4, background: 'var(--accent)', borderRadius: 2, zIndex: 10 }} />}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff',
|
||||
borderRadius: '10px 10px 0 0', padding: '9px 14px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||
{canEdit && (
|
||||
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }}
|
||||
onDragEnd={() => { setDragCat(null); setDragOverCat(null) }}
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||
{canEdit && editingCat?.name === cat ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingCat.value}
|
||||
onChange={e => setEditingCat({ ...editingCat, value: e.target.value })}
|
||||
onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }}
|
||||
style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}
|
||||
onDragOver={e => { if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
|
||||
<th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 160 }}>{t('budget.table.persons')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 55 }}>{t('budget.table.days')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 100 }}>{t('budget.table.perPerson')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perDay')}</th>
|
||||
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, width: 90, maxWidth: 90 }}>{t('budget.table.date')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 150 }}>{t('budget.table.note')}</th>
|
||||
<th style={{ ...th, width: 36 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => {
|
||||
const pp = calcPP(item.total_price, item.persons)
|
||||
const pd = calcPD(item.total_price, item.days)
|
||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||
const hasMembers = (item.members?.length ?? 0) > 0
|
||||
return (
|
||||
<tr key={item.id}
|
||||
style={{
|
||||
transition: 'background 0.1s, opacity 0.15s',
|
||||
opacity: dragItem === item.id ? 0.4 : 1,
|
||||
boxShadow: dragOverItem === item.id ? 'inset 4px 0 0 0 var(--accent)' : 'none',
|
||||
}}
|
||||
onDragOver={e => {
|
||||
if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return }
|
||||
if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) }
|
||||
}}
|
||||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }}
|
||||
onDrop={e => {
|
||||
if (dragItem && dragItemCat === cat && dragItem !== item.id) {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const ids = items.map(i => i.id)
|
||||
const fromIdx = ids.indexOf(dragItem)
|
||||
const toIdx = ids.indexOf(item.id)
|
||||
ids.splice(fromIdx, 1)
|
||||
ids.splice(toIdx, 0, dragItem)
|
||||
reorderBudgetItems(tripId, ids)
|
||||
setDragItem(null); setDragOverItem(null); setDragItemCat(null)
|
||||
}
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={td}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{canEdit && (
|
||||
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
|
||||
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
|
||||
{hasMultipleMembers && (
|
||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
compact={false}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||
{hasMultipleMembers ? (
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
|
||||
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, padding: '2px 6px', width: 90, maxWidth: 90, textAlign: 'center' }}>
|
||||
{canEdit ? (
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface InlineEditCellProps {
|
||||
value: string | number | null | undefined
|
||||
onSave: (value: string | number | null) => void
|
||||
type?: 'text' | 'number'
|
||||
style?: React.CSSProperties
|
||||
placeholder?: string
|
||||
decimals?: number
|
||||
locale: string
|
||||
editTooltip?: string
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function InlineEditCell({ value, onSave, type = 'text', style = {} as React.CSSProperties, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }: InlineEditCellProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState<string | number>(value ?? '')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
|
||||
|
||||
const save = () => {
|
||||
setEditing(false)
|
||||
let v: string | number | null = editValue
|
||||
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
|
||||
if (v !== value) onSave(v)
|
||||
}
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
if (type !== 'number') return
|
||||
e.preventDefault()
|
||||
let text = e.clipboardData.getData('text').trim()
|
||||
// Strip everything except digits, dots, commas, minus
|
||||
text = text.replace(/[^\d.,-]/g, '')
|
||||
// Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal
|
||||
const lastComma = text.lastIndexOf(',')
|
||||
const lastDot = text.lastIndexOf('.')
|
||||
const decimalPos = Math.max(lastComma, lastDot)
|
||||
if (decimalPos > -1) {
|
||||
const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '')
|
||||
const decPart = text.substring(decimalPos + 1)
|
||||
text = intPart + '.' + decPart
|
||||
} else {
|
||||
text = text.replace(/[.,]/g, '')
|
||||
}
|
||||
setEditValue(text)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste}
|
||||
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
|
||||
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
|
||||
placeholder={placeholder} />
|
||||
}
|
||||
|
||||
const display = type === 'number' && value != null
|
||||
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
: (value || '')
|
||||
|
||||
return (
|
||||
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
|
||||
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
|
||||
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
||||
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||
{display || placeholder || '-'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Pencil, Users, Check } from 'lucide-react'
|
||||
import type { BudgetItemMember } from '../../types'
|
||||
|
||||
export interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||
interface ChipWithTooltipProps {
|
||||
label: string
|
||||
avatarUrl: string | null
|
||||
size?: number
|
||||
paid?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onEnter = () => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}
|
||||
|
||||
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
|
||||
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: label?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
{hover && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{label}
|
||||
{paid && (
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
|
||||
textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||
}}>Paid</span>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Budget Member Chips (for Persons column) ────────────────────────────────
|
||||
interface BudgetMemberChipsProps {
|
||||
members?: BudgetItemMember[]
|
||||
tripMembers?: TripMember[]
|
||||
onSetMembers: (memberIds: number[]) => void
|
||||
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||
compact?: boolean
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
|
||||
const chipSize = compact ? 20 : 30
|
||||
const btnSize = compact ? 18 : 28
|
||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [dropPos, setDropPos] = useState({ top: 0, left: 0 })
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
if (btnRef.current) {
|
||||
const rect = btnRef.current.getBoundingClientRect()
|
||||
setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setShowDropdown(v => !v)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return
|
||||
const close = (e: MouseEvent) => {
|
||||
if (dropRef.current && dropRef.current.contains(e.target as Node)) return
|
||||
if (btnRef.current && btnRef.current.contains(e.target as Node)) return
|
||||
setShowDropdown(false)
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [showDropdown])
|
||||
|
||||
const memberIds = members.map(m => m.user_id)
|
||||
|
||||
const toggleMember = (userId: number) => {
|
||||
const newIds = memberIds.includes(userId)
|
||||
? memberIds.filter(id => id !== userId)
|
||||
: [...memberIds, userId]
|
||||
onSetMembers(newIds)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{members.map(m => (
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
||||
paid={!!m.paid}
|
||||
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||
/>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
)}
|
||||
{showDropdown && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 150,
|
||||
}}>
|
||||
{tripMembers.map(tm => {
|
||||
const isActive = memberIds.includes(tm.id)
|
||||
return (
|
||||
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{tm.avatar_url
|
||||
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: tm.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1 }}>{tm.username}</span>
|
||||
{isActive && <Check size={12} color="var(--text-primary)" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { fmtNum, colorForUserId, widgetTheme } from './BudgetPanel.helpers'
|
||||
import RingAvatar from './BudgetPanelRingAvatar'
|
||||
|
||||
interface PerPersonSummaryEntry {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
total_assigned: number
|
||||
}
|
||||
|
||||
interface PerPersonInlineProps {
|
||||
tripId: number
|
||||
budgetItems: BudgetItem[]
|
||||
currency: string
|
||||
locale: string
|
||||
}
|
||||
|
||||
export default function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType<typeof widgetTheme> }) {
|
||||
const [data, setData] = useState<PerPersonSummaryEntry[] | null>(null)
|
||||
const fmt = (v: number) => fmtNum(v, locale, currency)
|
||||
|
||||
useEffect(() => {
|
||||
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
||||
}, [tripId, budgetItems])
|
||||
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
const people = data.map(p => ({ ...p, color: colorForUserId(p.user_id) }))
|
||||
|
||||
return (
|
||||
<>
|
||||
{grandTotal > 0 && (
|
||||
<div style={{ display: 'flex', height: 6, borderRadius: 999, overflow: 'hidden', marginTop: 8, marginBottom: 4, gap: 3 }}>
|
||||
{people.map(p => (
|
||||
<div key={p.user_id} style={{
|
||||
height: '100%', borderRadius: 999,
|
||||
flex: Math.max(p.total_assigned || 0, 0.01),
|
||||
background: p.color.gradient,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 14, paddingTop: 14, borderTop: `1px solid ${theme.divider}`, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{people.map(p => {
|
||||
const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0
|
||||
return (
|
||||
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}>
|
||||
<RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
|
||||
<div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Wallet } from 'lucide-react'
|
||||
|
||||
interface PieSegment {
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
|
||||
interface PieChartProps {
|
||||
segments: PieSegment[]
|
||||
size?: number
|
||||
totalLabel: string
|
||||
}
|
||||
|
||||
export default function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
||||
if (!segments.length) return null
|
||||
|
||||
const total = segments.reduce((s, x) => s + x.value, 0)
|
||||
if (total === 0) return null
|
||||
|
||||
let cumDeg = 0
|
||||
const stops = segments.map(seg => {
|
||||
const start = cumDeg
|
||||
const deg = (seg.value / total) * 360
|
||||
cumDeg += deg
|
||||
return `${seg.color} ${start}deg ${start + deg}deg`
|
||||
}).join(', ')
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
||||
<div
|
||||
className="trek-pie-reveal"
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: `conic-gradient(${stops})`,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: size * 0.55, height: size * 0.55,
|
||||
borderRadius: '50%', background: 'var(--bg-card)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { colorForUserId } from './BudgetPanel.helpers'
|
||||
|
||||
export default function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) {
|
||||
const color = colorForUserId(userId)
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
||||
padding: 2, background: color.gradient,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%', height: '100%', borderRadius: '50%',
|
||||
background: innerBg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
|
||||
}}>
|
||||
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { Wallet, Info, ChevronDown, ChevronRight, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { SYMBOLS } from './BudgetPanel.constants'
|
||||
import { hexLighten, widgetTheme } from './BudgetPanel.helpers'
|
||||
import RingAvatar from './BudgetPanelRingAvatar'
|
||||
import PerPersonInline from './BudgetPanelPerPersonInline'
|
||||
import type { SettlementData, PieSegment } from './useBudgetPanel'
|
||||
|
||||
interface BudgetSummaryProps {
|
||||
theme: ReturnType<typeof widgetTheme>
|
||||
currency: string
|
||||
locale: string
|
||||
grandTotal: number
|
||||
hasMultipleMembers: boolean
|
||||
budgetItems: BudgetItem[]
|
||||
settlement: SettlementData | null
|
||||
settlementOpen: boolean
|
||||
setSettlementOpen: Dispatch<SetStateAction<boolean>>
|
||||
pieSegments: PieSegment[]
|
||||
isDark: boolean
|
||||
tripId: number
|
||||
t: (key: string) => string
|
||||
fmt: (v: number | null | undefined, cur: string) => string
|
||||
}
|
||||
|
||||
export default function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems,
|
||||
settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: BudgetSummaryProps) {
|
||||
return (
|
||||
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
|
||||
<div style={{
|
||||
background: theme.bg,
|
||||
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: theme.shadow,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 12,
|
||||
background: theme.iconBg,
|
||||
border: `1px solid ${theme.iconBorder}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: theme.iconColor, flexShrink: 0,
|
||||
}}>
|
||||
<Wallet size={20} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const decimals = currencyDecimals(currency)
|
||||
const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
const sep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||
<span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
|
||||
{decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
|
||||
<span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{currency}</span>
|
||||
</div>
|
||||
|
||||
{hasMultipleMembers && (budgetItems || []).some(i => (i.members?.length ?? 0) > 0) && (
|
||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} grandTotal={grandTotal} theme={theme} />
|
||||
)}
|
||||
|
||||
{/* Settlement dropdown inside the total card */}
|
||||
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
||||
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
||||
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||
}}>
|
||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{t('budget.settlement')}
|
||||
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
|
||||
<span style={{ display: 'flex', cursor: 'help' }}
|
||||
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
|
||||
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Info size={11} strokeWidth={2} />
|
||||
</span>
|
||||
<div style={{
|
||||
display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||
}}>
|
||||
{t('budget.settlementInfo')}
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{settlementOpen && (
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{settlement.flows.map((flow, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '12px 14px', borderRadius: 14,
|
||||
background: theme.flowBg,
|
||||
border: `1px solid ${theme.flowBorder}`,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }}
|
||||
>
|
||||
<RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
|
||||
{fmt(flow.amount, currency)}
|
||||
</span>
|
||||
<div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', right: -1, top: '50%', transform: 'translateY(-50%)', width: 0, height: 0, borderLeft: '6px solid rgba(239,68,68,0.55)', borderTop: '4px solid transparent', borderBottom: '4px solid transparent' }} />
|
||||
</div>
|
||||
</div>
|
||||
<RingAvatar userId={flow.to.user_id} username={flow.to.username} avatarUrl={flow.to.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
||||
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
|
||||
{t('budget.netBalances')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => {
|
||||
const positive = b.balance > 0
|
||||
const Trend = positive ? TrendingUp : TrendingDown
|
||||
return (
|
||||
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}>
|
||||
<RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
<span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{b.username}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '4px 10px', borderRadius: 8,
|
||||
fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
|
||||
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
|
||||
color: positive ? '#10b981' : '#ef4444',
|
||||
}}>
|
||||
<Trend size={11} strokeWidth={3} />
|
||||
{positive ? '+' : ''}{fmt(b.balance, currency)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pieSegments.length > 0 && (() => {
|
||||
const decimals = currencyDecimals(currency)
|
||||
const total = pieSegments.reduce((s, x) => s + x.value, 0)
|
||||
const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||
const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, '']
|
||||
const R = 80
|
||||
const CIRC = 2 * Math.PI * R
|
||||
let dashOffset = 0
|
||||
return (
|
||||
<div style={{
|
||||
background: theme.bg,
|
||||
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: theme.shadow,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||
<div style={{
|
||||
width: 38, height: 38, borderRadius: 11,
|
||||
background: theme.iconBg,
|
||||
border: `1px solid ${theme.iconBorder}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: theme.iconColor, flexShrink: 0,
|
||||
}}>
|
||||
<PieChartIcon size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', display: 'flex', justifyContent: 'center', margin: '4px 0 16px' }}>
|
||||
<svg width={200} height={200} viewBox="0 0 200 200" style={{ transform: 'rotate(-90deg)', filter: theme.donutShadow }}>
|
||||
<defs>
|
||||
{pieSegments.map((seg, i) => {
|
||||
const c2 = hexLighten(seg.color, 0.2)
|
||||
return (
|
||||
<linearGradient key={`grad-${i}`} id={`cat-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={seg.color} />
|
||||
<stop offset="100%" stopColor={c2} />
|
||||
</linearGradient>
|
||||
)
|
||||
})}
|
||||
</defs>
|
||||
<circle cx={100} cy={100} r={R} fill="none" stroke={theme.track} strokeWidth={22} />
|
||||
{pieSegments.map((seg, i) => {
|
||||
const segLen = total > 0 ? (seg.value / total) * CIRC : 0
|
||||
const circle = (
|
||||
<circle key={i}
|
||||
cx={100} cy={100} r={R}
|
||||
fill="none" strokeLinecap="round" strokeWidth={22}
|
||||
stroke={`url(#cat-grad-${i})`}
|
||||
strokeDasharray={`${segLen} ${CIRC}`}
|
||||
strokeDashoffset={-dashOffset}
|
||||
/>
|
||||
)
|
||||
dashOffset += segLen
|
||||
return circle
|
||||
})}
|
||||
</svg>
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
|
||||
<div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||
<span>{totalInt}</span>
|
||||
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: `1px solid ${theme.divider}`, paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{pieSegments.map((seg, i) => {
|
||||
const pct = total > 0 ? (seg.value / total) * 100 : 0
|
||||
const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%'
|
||||
const c2 = hexLighten(seg.color, 0.2)
|
||||
const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color
|
||||
return (
|
||||
<div key={seg.name} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 8px', borderRadius: 12,
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = theme.rowHover}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<div style={{
|
||||
width: 10, height: 10, borderRadius: 3, flexShrink: 0,
|
||||
background: `linear-gradient(135deg, ${seg.color}, ${c2})`,
|
||||
boxShadow: `0 0 12px ${seg.color}80`,
|
||||
}} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
|
||||
<div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
|
||||
</div>
|
||||
<span style={{
|
||||
flexShrink: 0,
|
||||
padding: '4px 9px', borderRadius: 7,
|
||||
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
|
||||
background: `${seg.color}26`,
|
||||
border: `1px solid ${seg.color}40`,
|
||||
color: chipColor,
|
||||
}}>{pctLabel}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/formatters'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { SYMBOLS, CURRENCIES, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
|
||||
interface CostsPanelProps {
|
||||
tripId: number
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
|
||||
interface Settlement {
|
||||
id: number
|
||||
from_user_id: number
|
||||
to_user_id: number
|
||||
amount: number
|
||||
created_at?: string
|
||||
from_username?: string
|
||||
to_username?: string
|
||||
}
|
||||
interface SettlementData {
|
||||
balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[]
|
||||
flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[]
|
||||
settlements: Settlement[]
|
||||
}
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
|
||||
|
||||
export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps) {
|
||||
const { trip, budgetItems, deleteBudgetItem, loadBudgetItems } = useTripStore()
|
||||
const me = useAuthStore(s => s.user?.id ?? -1)
|
||||
const can = useCanDo()
|
||||
const canEdit = can('budget_edit', trip)
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
// Display/base currency = the user's preferred currency (Settings), falling back
|
||||
// to the trip's own currency. Everything in Costs is converted to and shown in it.
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
// Pre-rework rows stored currency = NULL, meaning "the trip's own currency".
|
||||
const tripCurrency = (trip?.currency || base).toUpperCase()
|
||||
const { convert } = useExchangeRates(base)
|
||||
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
|
||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [histOpen, setHistOpen] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||
|
||||
const people = tripMembers
|
||||
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
|
||||
const personName = useCallback((id: number) => id === me ? t('costs.you') : (personById(id)?.username || '?'), [me, personById, t])
|
||||
const colorFor = useCallback((id: number) => {
|
||||
const idx = people.findIndex(p => p.id === id)
|
||||
return SPLIT_COLORS[(idx >= 0 ? idx : 0) % SPLIT_COLORS.length].gradient
|
||||
}, [people])
|
||||
const initial = useCallback((id: number) => id === me ? t('costs.youShort') : (personById(id)?.username || '?').charAt(0).toUpperCase(), [me, personById, t])
|
||||
|
||||
const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale])
|
||||
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
|
||||
|
||||
const loadSettlement = useCallback(() => {
|
||||
budgetApi.settlement(tripId, base).then(setSettlement).catch(() => {})
|
||||
}, [tripId, base])
|
||||
|
||||
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId])
|
||||
useEffect(() => { loadSettlement() }, [budgetItems.length, base])
|
||||
|
||||
// The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === 'expense') {
|
||||
setEditing(null); setModalOpen(true)
|
||||
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// ── derived expense maths (everything converted to the base currency) ────
|
||||
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
|
||||
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
|
||||
const myShareOf = (e: BudgetItem) => {
|
||||
const n = (e.members || []).length
|
||||
if (!n || !(e.members || []).some(m => m.user_id === me)) return 0
|
||||
return baseTotal(e) / n
|
||||
}
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||
const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0)
|
||||
const myShare = budgetItems.reduce((a, e) => a + myShareOf(e), 0)
|
||||
const owe = (settlement?.flows || []).filter(f => f.from.user_id === me).reduce((a, f) => a + f.amount, 0)
|
||||
const owed = (settlement?.flows || []).filter(f => f.to.user_id === me).reduce((a, f) => a + f.amount, 0)
|
||||
return { totalSpend, myPaid, myShare, owe, owed }
|
||||
}, [budgetItems, settlement, me])
|
||||
|
||||
// ── filtering + day grouping ────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
let list = budgetItems.slice()
|
||||
if (filter === 'mine') list = list.filter(e => myPaidOf(e) > 0)
|
||||
if (filter === 'owed') list = list.filter(e => round2(myPaidOf(e) - myShareOf(e)) > 0)
|
||||
const q = search.trim().toLowerCase()
|
||||
if (q) list = list.filter(e => e.name.toLowerCase().includes(q))
|
||||
return list
|
||||
}, [budgetItems, filter, search, me])
|
||||
|
||||
const dayGroups = useMemo(() => {
|
||||
const groups: { day: string; items: BudgetItem[] }[] = []
|
||||
const labelOf = (e: BudgetItem) => {
|
||||
if (!e.expense_date) return t('costs.noDate')
|
||||
try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
|
||||
}
|
||||
const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
|
||||
for (const e of sorted) {
|
||||
const day = labelOf(e)
|
||||
let g = groups.find(x => x.day === day)
|
||||
if (!g) { g = { day, items: [] }; groups.push(g) }
|
||||
g.items.push(e)
|
||||
}
|
||||
return groups
|
||||
}, [filtered, locale, t])
|
||||
|
||||
// ── settle actions ──────────────────────────────────────────────────────
|
||||
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
||||
try {
|
||||
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount })
|
||||
loadSettlement()
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
const undoSettlement = async (id: number) => {
|
||||
try { await budgetApi.deleteSettlement(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
const settleAll = async () => {
|
||||
const flows = settlement?.flows || []
|
||||
if (!flows.length) return
|
||||
try {
|
||||
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount })
|
||||
loadSettlement()
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const dateMeta = useMemo(() => {
|
||||
if (!trip?.start_date || !trip?.end_date) return null
|
||||
try {
|
||||
const s = new Date(trip.start_date + 'T00:00:00Z'), e = new Date(trip.end_date + 'T00:00:00Z')
|
||||
const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1
|
||||
const opt = { day: 'numeric', month: 'short', timeZone: 'UTC' } as const
|
||||
return { range: `${s.toLocaleDateString(locale, opt)} – ${e.toLocaleDateString(locale, opt)}`, days }
|
||||
} catch { return null }
|
||||
}, [trip?.start_date, trip?.end_date, locale])
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try { await deleteBudgetItem(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// ── small presentational helpers ────────────────────────────────────────
|
||||
const Avatar = ({ id, size = 24 }: { id: number; size?: number }) => {
|
||||
const url = personById(id)?.avatar_url
|
||||
if (url) return <img src={url} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, display: 'block' }} />
|
||||
return <span style={{ width: size, height: size, borderRadius: '50%', background: colorFor(id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: size * 0.4, fontWeight: 700, flexShrink: 0 }}>{initial(id)}</span>
|
||||
}
|
||||
|
||||
const cardCls = 'bg-surface-card border border-edge'
|
||||
const labelCls = 'text-[11px] font-semibold uppercase tracking-[0.12em] text-content-faint'
|
||||
|
||||
// Big money number with the design's muted symbol/decimals, locale-correct via Intl.
|
||||
const bigMoney = (amount: number, smallSize: number, mutedColor: string) => {
|
||||
let parts: Intl.NumberFormatPart[] | null = null
|
||||
try {
|
||||
const d = currencyDecimals(base)
|
||||
parts = new Intl.NumberFormat(currencyLocale(base), { style: 'currency', currency: base, minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0)
|
||||
} catch { return <>{formatMoney(amount, base, locale)}</> }
|
||||
const isBig = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign'
|
||||
return <>{parts.map((p, i) => <span key={i} style={isBig(p) ? undefined : { fontSize: smallSize, fontWeight: 500, color: mutedColor }}>{p.value}</span>)}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '40px 24px 48px' }}>
|
||||
{isMobile ? <MobileBody /> : (
|
||||
<div style={{ maxWidth: '100%', margin: '0 auto' }}>
|
||||
{/* ── Header bar ── */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 28, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{dateMeta && (
|
||||
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 999, fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||
{dateMeta.range} · <b className="text-content">{t('costs.daysCount', { count: dateMeta.days })}</b>
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px 8px 10px', borderRadius: 999, fontSize: 13, fontWeight: 500 }}>
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
{people.slice(0, 4).map((p, i) => {
|
||||
const common = { width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--bg-card)', marginLeft: i ? -8 : 0, flexShrink: 0 } as const
|
||||
return p.avatar_url
|
||||
? <img key={p.id} src={p.avatar_url} alt="" style={{ ...common, objectFit: 'cover', display: 'block' }} />
|
||||
: <span key={p.id} style={{ ...common, background: colorFor(p.id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>
|
||||
})}
|
||||
</span>
|
||||
<b className="text-content">{t('costs.travelers', { count: people.length })}</b>
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={settleAll} disabled={!(settlement?.flows || []).length}
|
||||
className="bg-surface-card border border-edge text-content disabled:opacity-40"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 16px', borderRadius: 12, fontSize: 14, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Check size={16} /> {t('costs.settleUp')}
|
||||
</button>
|
||||
<button onClick={() => { setEditing(null); setModalOpen(true) }}
|
||||
className="bg-[var(--text-primary)] text-[var(--bg-primary)]"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 14, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={16} /> {t('costs.addExpense')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Summary cards ── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.15fr', gap: 16, marginBottom: 36 }} className="costs-summary">
|
||||
<SummaryCard label={t('costs.youOwe')} sub={t('costs.youOweSub')} amount={totals.owe} currency={base} locale={locale}
|
||||
icon={<ArrowDown size={18} />} tone="owe"
|
||||
foot={totals.owe > 0.01
|
||||
? <FlowPills ids={(settlement?.flows || []).filter(f => f.from.user_id === me).map(f => f.to.user_id)} lead={t('costs.to')} Avatar={Avatar} name={personName} />
|
||||
: <span className="text-content-faint">{t('costs.allSettled')}</span>} />
|
||||
<SummaryCard label={t('costs.youreOwed')} sub={t('costs.youreOwedSub')} amount={totals.owed} currency={base} locale={locale}
|
||||
icon={<ArrowUp size={18} />} tone="owed"
|
||||
foot={totals.owed > 0.01
|
||||
? <FlowPills ids={(settlement?.flows || []).filter(f => f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} />
|
||||
: <span className="text-content-faint">{t('costs.nothingOwed')}</span>} />
|
||||
<SummaryCard label={t('costs.totalSpend')} sub={t('costs.totalSpendSub')} amount={totals.totalSpend} currency={base} locale={locale}
|
||||
icon={<BarChart3 size={18} />} tone="total"
|
||||
foot={<span style={{ display: 'flex', gap: 16 }}><span>{t('costs.yourShare')} · <b>{fmt0(totals.myShare)}</b></span><span>{t('costs.youPaid')} · <b>{fmt0(totals.myPaid)}</b></span></span>} />
|
||||
</div>
|
||||
|
||||
{/* ── Main grid ── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 380px', gap: 32, alignItems: 'start' }} className="costs-grid">
|
||||
{/* expenses */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}>
|
||||
<h3 className="text-content" style={{ fontSize: 24, fontWeight: 600, letterSpacing: '-0.025em', margin: 0 }}>
|
||||
{t('costs.expenses')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 6, borderRadius: 10, padding: '0 10px', height: 34 }}>
|
||||
<Search size={15} className="text-content-faint" />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')}
|
||||
className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 13, width: 150, fontFamily: 'inherit' }} />
|
||||
</div>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
|
||||
{(['all', 'mine', 'owed'] as const).map(f => (
|
||||
<button key={f} onClick={() => setFilter(f)}
|
||||
className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||
style={{ padding: '6px 11px', fontSize: 12, borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('costs.filter.' + f)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dayGroups.length === 0 ? (
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
||||
</div>
|
||||
) : dayGroups.map(g => {
|
||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
||||
return (
|
||||
<div key={g.day} style={{ marginBottom: 22 }}>
|
||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
|
||||
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* sidebar */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* settle up */}
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
|
||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
|
||||
className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
<SettleFlows />
|
||||
</div>
|
||||
|
||||
{/* balances */}
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
|
||||
<BalancesList balances={settlement?.balances || []} />
|
||||
</div>
|
||||
|
||||
{/* by category */}
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
||||
<CategoryBreakdown />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{modalOpen && (
|
||||
<ExpenseModal tripId={tripId} base={base} people={people} me={me} editing={editing}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
|
||||
)}
|
||||
|
||||
<Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
|
||||
<SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
|
||||
</Modal>
|
||||
|
||||
<style>{`
|
||||
.costs-root {
|
||||
--c-bg: #f8fafc; --c-bg2: oklch(0.965 0.01 70);
|
||||
--c-surface: #ffffff; --c-surface2: oklch(0.985 0.006 78);
|
||||
--c-ink: oklch(0.22 0.012 65); --c-ink2: oklch(0.42 0.012 65); --c-ink3: oklch(0.62 0.01 65);
|
||||
--c-line: oklch(0.92 0.008 70);
|
||||
}
|
||||
html.dark .costs-root {
|
||||
--c-bg: #121215; --c-bg2: #18181c;
|
||||
--c-surface: #1a1a1e; --c-surface2: #202027;
|
||||
--c-ink: #f4f4f5; --c-ink2: #a1a1aa; --c-ink3: #71717a;
|
||||
--c-line: #2a2a31;
|
||||
}
|
||||
.costs-root .bg-surface-card { background: var(--c-surface) !important; }
|
||||
.costs-root .bg-surface-secondary, .costs-root .bg-surface-input { background: var(--c-surface2) !important; }
|
||||
.costs-root .border-edge { border-color: var(--c-line) !important; }
|
||||
/* dark = neutral zinc + a touch of liquid glass, matching the dashboard */
|
||||
html.dark .costs-root .bg-surface-card {
|
||||
background: rgba(255,255,255,0.035) !important;
|
||||
border-color: rgba(255,255,255,0.08) !important;
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
}
|
||||
html.dark .costs-root .bg-surface-secondary,
|
||||
html.dark .costs-root .bg-surface-input { background: rgba(255,255,255,0.05) !important; }
|
||||
html.dark .costs-root .border-edge { border-color: rgba(255,255,255,0.08) !important; }
|
||||
.costs-root .text-content { color: var(--c-ink) !important; }
|
||||
.costs-root .text-content-muted { color: var(--c-ink2) !important; }
|
||||
.costs-root .text-content-faint { color: var(--c-ink3) !important; }
|
||||
.costs-root .exp-actions { opacity: 1; }
|
||||
@media (max-width: 1100px) {
|
||||
.costs-root .costs-summary { grid-template-columns: 1fr !important; }
|
||||
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── shared settle-flow list ──────────────────────────────────────────────
|
||||
function SettleFlows() {
|
||||
const flows = settlement?.flows || []
|
||||
if (flows.length === 0) return (
|
||||
<div style={{ textAlign: 'center', padding: '14px 8px' }}>
|
||||
<div style={{ width: 46, height: 46, borderRadius: '50%', margin: '0 auto 10px', display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><Check size={22} /></div>
|
||||
<div className="text-content" style={{ fontSize: 14.5, fontWeight: 600 }}>{t('costs.everyoneSquare')}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('costs.nothingOutstanding')}</div>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{flows.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${personName(f.from.user_id)} → ${f.to.user_id === me ? t('costs.youLower') : personName(f.to.user_id)}`}>
|
||||
<Avatar id={f.from.user_id} size={32} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={f.to.user_id} size={32} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 700 }}>{fmt(f.amount)}</span>
|
||||
{canEdit && <button onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 12, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── mobile layout (Budget1Mobile.html): single flat column, total card on top ──
|
||||
function MobileBody() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
|
||||
{/* Total card */}
|
||||
<section style={{ background: 'linear-gradient(135deg,#1f2937,#111827)', color: '#fff', borderRadius: 22, padding: '20px 20px 16px', boxShadow: '0 8px 24px -8px rgba(0,0,0,0.28)' }}>
|
||||
<div style={{ fontSize: 11.5, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'rgba(255,255,255,0.6)', fontWeight: 600 }}>{t('costs.totalSpend')}</div>
|
||||
<div style={{ fontSize: 44, fontWeight: 700, letterSpacing: '-0.04em', lineHeight: 1, marginTop: 8, display: 'flex', alignItems: 'baseline' }}>{bigMoney(totals.totalSpend, 24, 'rgba(255,255,255,0.6)')}</div>
|
||||
<div style={{ display: 'flex', gap: 18, marginTop: 12, fontSize: 12, color: 'rgba(255,255,255,0.6)', flexWrap: 'wrap' }}>
|
||||
<span>{t('costs.yourShare')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myShare)}</b></span>
|
||||
<span>{t('costs.youPaid')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myPaid)}</b></span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={17} /> {t('costs.addExpense')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Owe / Owed */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#dc262622', color: '#dc2626' }}><ArrowDown size={17} /></div>
|
||||
<div className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youOwe')}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youOweSub')}</div>
|
||||
<div style={{ fontSize: 27, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#dc2626' }}>{bigMoney(totals.owe, 16, 'var(--c-ink3)')}</div>
|
||||
</div>
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#16a34a22', color: '#16a34a' }}><ArrowUp size={17} /></div>
|
||||
<div className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youreOwed')}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youreOwedSub')}</div>
|
||||
<div style={{ fontSize: 27, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#16a34a' }}>{bigMoney(totals.owed, 16, 'var(--c-ink3)')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settle up */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
||||
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
|
||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)} className="text-content-muted bg-surface-card border border-edge disabled:opacity-40" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><History size={13} /> {t('costs.history')}</button>
|
||||
</div>
|
||||
<SettleFlows />
|
||||
</div>
|
||||
|
||||
{/* Expenses */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div>
|
||||
<div className="bg-surface-card border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 8, borderRadius: 12, padding: '0 12px', height: 42 }}>
|
||||
<Search size={16} className="text-content-faint" />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 14, width: '100%', fontFamily: 'inherit' }} />
|
||||
</div>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2 }}>
|
||||
{(['all', 'mine', 'owed'] as const).map(f => (
|
||||
<button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 12.5, fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
|
||||
))}
|
||||
</div>
|
||||
{dayGroups.length === 0
|
||||
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
||||
: dayGroups.map(g => {
|
||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
||||
return (
|
||||
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Balances */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
|
||||
<BalancesList balances={settlement?.balances || []} />
|
||||
</div>
|
||||
|
||||
{/* By category */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
||||
<CategoryBreakdown />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── inline subcomponents (close over helpers) ────────────────────────────
|
||||
function ExpenseRow({ e }: { e: BudgetItem }) {
|
||||
const c = catMeta(e.category)
|
||||
const Icon = c.Icon
|
||||
const cur = curOf(e)
|
||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{e.name}</div>
|
||||
{payers.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
||||
{payers.map(p => (
|
||||
<span key={p.user_id} className="bg-surface-secondary border border-edge" title={personName(p.user_id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 11.5 }}>
|
||||
<Avatar id={p.user_id} size={18} />
|
||||
<span className="text-content" style={{ fontWeight: 700 }}>{fmt(convert(p.amount, cur))}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<div className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)} → ${fmt(baseTotal(e))}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
||||
{(e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
||||
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
||||
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
||||
<button title={t('common.edit')} onClick={() => { setEditing(e); setModalOpen(true) }} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
<button title={t('common.delete')} onClick={() => handleDelete(e.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
|
||||
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
|
||||
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{rows.map(r => {
|
||||
const pct = Math.min(100, Math.abs(r.balance) / max * 100)
|
||||
const pos = r.balance > 0.01, neg = r.balance < -0.01
|
||||
return (
|
||||
<div key={r.user_id} style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto', gap: 10, alignItems: 'center' }}>
|
||||
<Avatar id={r.user_id} size={28} />
|
||||
<div>
|
||||
<div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{personName(r.user_id)}</div>
|
||||
<div className="bg-surface-secondary" style={{ height: 5, borderRadius: 3, marginTop: 5, position: 'relative', overflow: 'hidden' }}>
|
||||
<span style={{ position: 'absolute', left: '50%', top: -1, bottom: -1, width: 1, background: 'var(--border-primary)' }} />
|
||||
{pos && <span style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#16a34a', borderRadius: 3 }} />}
|
||||
{neg && <span style={{ position: 'absolute', right: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#dc2626', borderRadius: 3 }} />}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, textAlign: 'right', color: pos ? '#16a34a' : neg ? '#dc2626' : 'var(--text-faint)' }}>
|
||||
{pos ? '+' + fmt(r.balance) : neg ? '−' + fmt(-r.balance) : fmt(0)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryBreakdown() {
|
||||
const tot: Record<string, number> = {}
|
||||
let grand = 0
|
||||
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
|
||||
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
||||
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rows.map(c => {
|
||||
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
|
||||
return (
|
||||
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
||||
<span className="text-content" style={{ fontSize: 13, fontWeight: 500 }}>{t(c.labelKey)}</span>
|
||||
<span className="text-content-muted" style={{ fontSize: 13, fontWeight: 600 }}>{fmt0(v)}</span>
|
||||
<div className="bg-surface-secondary" style={{ gridColumn: '1 / -1', height: 5, borderRadius: 3, overflow: 'hidden', marginTop: -2 }}>
|
||||
<span style={{ display: 'block', height: '100%', width: pct + '%', background: c.color, borderRadius: 3 }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── pure subcomponents ─────────────────────────────────────────────────────
|
||||
function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' }) {
|
||||
const total = tone === 'total'
|
||||
const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : undefined
|
||||
const muted = total ? 'rgba(255,255,255,0.55)' : 'var(--text-faint)'
|
||||
// formatToParts keeps the design's "big integer + muted symbol/decimals" styling
|
||||
// while letting Intl place the symbol and pick separators per locale + currency.
|
||||
let parts: Intl.NumberFormatPart[] | null = null
|
||||
try {
|
||||
const d = currencyDecimals(currency)
|
||||
parts = new Intl.NumberFormat(currencyLocale(currency), { style: 'currency', currency: (currency || 'EUR').toUpperCase(), minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0)
|
||||
} catch { parts = null }
|
||||
const big = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign'
|
||||
return (
|
||||
<div className={total ? '' : 'bg-surface-card border border-edge'}
|
||||
style={{ borderRadius: 22, padding: '26px 28px', position: 'relative', overflow: 'hidden', ...(total ? { background: 'linear-gradient(135deg,#1f2937,#111827)', color: '#fff' } : {}) }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
|
||||
<span style={{ width: 36, height: 36, borderRadius: 11, display: 'grid', placeItems: 'center', background: total ? 'rgba(255,255,255,0.12)' : (accent + '22'), color: total ? '#fff' : accent }}>{icon}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }} className={total ? '' : 'text-content'}>{label}</div>
|
||||
<div style={{ fontSize: 12, opacity: total ? 0.6 : 1 }} className={total ? '' : 'text-content-faint'}>{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 46, fontWeight: 600, letterSpacing: '-0.035em', lineHeight: 1, marginTop: 20, display: 'flex', alignItems: 'baseline', color: total ? '#fff' : accent }}>
|
||||
{parts
|
||||
? parts.map((p, i) => <span key={i} style={big(p) ? undefined : { fontSize: 26, fontWeight: 500, color: muted }}>{p.value}</span>)
|
||||
: <span>{formatMoney(amount, currency, locale)}</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 16, fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', opacity: total ? 0.85 : 1 }}>{foot}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string }) {
|
||||
const uniq = Array.from(new Set(ids))
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className="text-content-faint">{lead}</span>
|
||||
{uniq.map(id => (
|
||||
<span key={id} className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 12, fontWeight: 600 }}>
|
||||
<Avatar id={id} size={18} />{name(id)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
|
||||
settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
|
||||
const total = settlements.reduce((a, s) => a + s.amount, 0)
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, marginBottom: 14, background: 'rgba(22,163,74,0.1)', color: '#16a34a', fontWeight: 600, fontSize: 13 }}>
|
||||
<span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{settlements.map(s => (
|
||||
<div key={s.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderRadius: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${name(s.from_user_id)} → ${name(s.to_user_id)}`}>
|
||||
<Avatar id={s.from_user_id} size={30} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={s.to_user_id} size={30} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{fmt(s.amount)}</span>
|
||||
{canEdit && <button onClick={() => onUndo(s.id)} className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><RotateCcw size={12} /> {t('costs.undo')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Add / edit expense modal ───────────────────────────────────────────────
|
||||
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { addBudgetItem, updateBudgetItem } = useTripStore()
|
||||
const { convert } = useExchangeRates(base)
|
||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||
|
||||
const [name, setName] = useState(editing?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||
const [payers, setPayers] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
|
||||
return m
|
||||
})
|
||||
const [split, setSplit] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
|
||||
const each = split.size > 0 ? payersTotal / split.size : 0
|
||||
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
|
||||
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0)
|
||||
const data = {
|
||||
name: name.trim(), category: cat,
|
||||
// Store the actual currency the amounts were entered in; conversion to the
|
||||
// viewer's display currency happens live (real rates), no manual rate.
|
||||
currency,
|
||||
payers: payerList, member_ids: [...split],
|
||||
expense_date: day || null,
|
||||
}
|
||||
try {
|
||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||
else await addBudgetItem(tripId, data)
|
||||
onSaved()
|
||||
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const inputCls = 'w-full bg-surface-input border border-edge text-content'
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size="2xl"
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
|
||||
</div>
|
||||
}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.whatFor')}</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none' }} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className={labelCls}>{t('costs.currency')}</label>
|
||||
<CustomSelect value={currency} onChange={v => setCurrency(String(v))} searchable
|
||||
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className={labelCls}>{t('costs.day')}</label>
|
||||
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currency !== base && payersTotal > 0 && (
|
||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{formatMoney(payersTotal, currency, locale)}</span>
|
||||
<span className="text-content-faint">≈</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
|
||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.category')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
||||
{COST_CATEGORY_LIST.map(c => {
|
||||
const Icon = c.Icon; const on = cat === c.key
|
||||
return (
|
||||
<button key={c.key} onClick={() => setCat(c.key)}
|
||||
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 11px 6px 7px', borderRadius: 999, fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
||||
<span style={{ width: 20, height: 20, borderRadius: 6, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={12} /></span>
|
||||
{t(c.labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{people.map(p => (
|
||||
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10 }}>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 500 }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={payers[p.id] || ''}
|
||||
onChange={e => setPayers(prev => ({ ...prev, [p.id]: e.target.value }))}
|
||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.splitBetween')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
||||
{people.map(p => {
|
||||
const on = split.has(p.id)
|
||||
return (
|
||||
<button key={p.id} onClick={() => setSplit(prev => { const n = new Set(prev); n.has(p.id) ? n.delete(p.id) : n.add(p.id); return n })}
|
||||
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '6px 13px 6px 7px', borderRadius: 999, fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
||||
{p.avatar_url
|
||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', opacity: on ? 1 : 0.45 }} />
|
||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[people.findIndex(x => x.id === p.id) % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||
{p.id === me ? t('costs.you') : p.username}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
|
||||
{split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The fixed Costs categories. Users can't add their own — every expense maps to
|
||||
* one of these. Category colour is the one place an accent is allowed (it
|
||||
* visualises the category); everything else stays black/white. The label comes
|
||||
* from i18n (`costs.cat.*`).
|
||||
*/
|
||||
export interface CostCategoryMeta {
|
||||
key: CostCategory
|
||||
labelKey: string
|
||||
Icon: LucideIcon
|
||||
color: string
|
||||
}
|
||||
|
||||
export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
||||
accommodation: { key: 'accommodation', labelKey: 'costs.cat.accommodation', Icon: Hotel, color: '#16a34a' },
|
||||
food: { key: 'food', labelKey: 'costs.cat.food', Icon: Utensils, color: '#ea580c' },
|
||||
groceries: { key: 'groceries', labelKey: 'costs.cat.groceries', Icon: ShoppingCart, color: '#65a30d' },
|
||||
transport: { key: 'transport', labelKey: 'costs.cat.transport', Icon: Bus, color: '#2563eb' },
|
||||
flights: { key: 'flights', labelKey: 'costs.cat.flights', Icon: Plane, color: '#0ea5e9' },
|
||||
activities: { key: 'activities', labelKey: 'costs.cat.activities', Icon: Ticket, color: '#9333ea' },
|
||||
sightseeing: { key: 'sightseeing', labelKey: 'costs.cat.sightseeing', Icon: Camera, color: '#db2777' },
|
||||
shopping: { key: 'shopping', labelKey: 'costs.cat.shopping', Icon: ShoppingBag, color: '#e11d48' },
|
||||
fees: { key: 'fees', labelKey: 'costs.cat.fees', Icon: FileText, color: '#475569' },
|
||||
health: { key: 'health', labelKey: 'costs.cat.health', Icon: HeartPulse, color: '#dc2626' },
|
||||
tips: { key: 'tips', labelKey: 'costs.cat.tips', Icon: Coins, color: '#d97706' },
|
||||
other: { key: 'other', labelKey: 'costs.cat.other', Icon: MoreHorizontal, color: '#6b7280' },
|
||||
}
|
||||
|
||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||
|
||||
/** Map any stored category (incl. legacy free-text values) to a known meta. */
|
||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
return COST_CAT_META.other
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
|
||||
import { PIE_COLORS } from './BudgetPanel.constants'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
|
||||
function useIsDark(): boolean {
|
||||
const [dark, setDark] = useState<boolean>(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => mo.disconnect()
|
||||
}, [])
|
||||
return dark
|
||||
}
|
||||
|
||||
export interface EditingCat {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface SettlementPerson {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
interface SettlementFlow {
|
||||
from: SettlementPerson
|
||||
to: SettlementPerson
|
||||
amount: number
|
||||
}
|
||||
|
||||
interface SettlementBalance {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
balance: number
|
||||
}
|
||||
|
||||
export interface SettlementData {
|
||||
balances: SettlementBalance[]
|
||||
flows: SettlementFlow[]
|
||||
}
|
||||
|
||||
export interface PieSegment {
|
||||
name: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface AddItemData {
|
||||
name: string
|
||||
total_price: number
|
||||
persons: number | null
|
||||
days: number | null
|
||||
note: string | null
|
||||
expense_date: string | null
|
||||
}
|
||||
|
||||
export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const isDark = useIsDark()
|
||||
const theme = useMemo(() => widgetTheme(isDark), [isDark])
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingCat, setEditingCat] = useState<EditingCat | null>(null) // { name, value }
|
||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||
const [settlementOpen, setSettlementOpen] = useState(false)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
const canEdit = can('budget_edit', trip)
|
||||
|
||||
const fmt = (v: number | null | undefined, cur: string) => fmtNum(v, locale, cur)
|
||||
const hasMultipleMembers = tripMembers.length > 1
|
||||
|
||||
// Drag state for categories
|
||||
const [dragCat, setDragCat] = useState<string | null>(null)
|
||||
const [dragOverCat, setDragOverCat] = useState<string | null>(null)
|
||||
// Drag state for items within a category
|
||||
const [dragItem, setDragItem] = useState<number | null>(null)
|
||||
const [dragOverItem, setDragOverItem] = useState<number | null>(null)
|
||||
const [dragItemCat, setDragItemCat] = useState<string | null>(null)
|
||||
|
||||
// Load settlement data whenever budget items change
|
||||
useEffect(() => {
|
||||
if (!hasMultipleMembers) return
|
||||
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
|
||||
}, [tripId, budgetItems, hasMultipleMembers])
|
||||
|
||||
const setCurrency = (cur: string) => {
|
||||
if (tripId) updateTrip(tripId, { currency: cur })
|
||||
}
|
||||
|
||||
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, BudgetItem[]>()
|
||||
for (const item of (budgetItems || [])) {
|
||||
const cat = item.category || 'Other'
|
||||
if (!map.has(cat)) map.set(cat, [])
|
||||
map.get(cat)!.push(item)
|
||||
}
|
||||
return map
|
||||
}, [budgetItems])
|
||||
|
||||
const categoryNames = Array.from(grouped.keys())
|
||||
|
||||
// Stable color mapping: assign index-based colors once, never reassign on reorder
|
||||
const colorMapRef = useRef(new Map<string, string>())
|
||||
const categoryColor = useCallback((cat: string) => {
|
||||
const map = colorMapRef.current
|
||||
if (!map.has(cat)) {
|
||||
map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length])
|
||||
}
|
||||
return map.get(cat)!
|
||||
}, [])
|
||||
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
|
||||
|
||||
const pieSegments = useMemo<PieSegment[]>(() =>
|
||||
categoryNames.map((cat, i) => ({
|
||||
name: cat,
|
||||
value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0),
|
||||
color: categoryColor(cat),
|
||||
})).filter(s => s.value > 0)
|
||||
, [grouped, categoryNames])
|
||||
|
||||
const handleAddItem = async (category: string, data: AddItemData) => { try { await addBudgetItem(tripId, { ...data, category }) } catch { toast.error(t('common.error')) } }
|
||||
const handleUpdateField = async (id: number, field: string, value: unknown) => { try { await updateBudgetItem(tripId, id, { [field]: value } as Partial<BudgetItem>) } catch { toast.error(t('common.error')) } }
|
||||
const handleDeleteItem = async (id: number) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } }
|
||||
const handleDeleteCategory = async (cat: string) => {
|
||||
const items = grouped.get(cat) || []
|
||||
try { for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) }
|
||||
catch { toast.error(t('common.error')) }
|
||||
}
|
||||
const handleRenameCategory = async (oldName: string, newName: string) => {
|
||||
if (!newName.trim() || newName.trim() === oldName) return
|
||||
const items = grouped.get(oldName) || []
|
||||
try { for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) }
|
||||
catch { toast.error(t('common.error')) }
|
||||
}
|
||||
const handleAddCategory = () => {
|
||||
if (!newCategoryName.trim()) return
|
||||
Promise.resolve(addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }))
|
||||
.catch(() => toast.error(t('common.error')))
|
||||
setNewCategoryName('')
|
||||
}
|
||||
|
||||
const handleExportCsv = () => {
|
||||
const sep = ';'
|
||||
const esc = (v: unknown) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
|
||||
const d = currencyDecimals(currency)
|
||||
const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
|
||||
|
||||
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
|
||||
const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
|
||||
const rows = [header.join(sep)]
|
||||
|
||||
for (const cat of categoryNames) {
|
||||
for (const item of (grouped.get(cat) || [])) {
|
||||
const pp = calcPP(item.total_price, item.persons)
|
||||
const pd = calcPD(item.total_price, item.days)
|
||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||
rows.push([
|
||||
esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')),
|
||||
fmtPrice(item.total_price), item.persons ?? '', item.days ?? '',
|
||||
fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd),
|
||||
esc(item.note || ''),
|
||||
].join(sep))
|
||||
}
|
||||
}
|
||||
|
||||
const bom = ''
|
||||
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim()
|
||||
a.download = `budget-${safeName}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const th: CSSProperties = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
||||
const td: CSSProperties = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
||||
|
||||
return {
|
||||
trip, budgetItems,
|
||||
setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories,
|
||||
t, locale, isDark, theme,
|
||||
newCategoryName, setNewCategoryName,
|
||||
editingCat, setEditingCat,
|
||||
settlement, settlementOpen, setSettlementOpen,
|
||||
currency, canEdit, fmt, hasMultipleMembers,
|
||||
dragCat, setDragCat, dragOverCat, setDragOverCat,
|
||||
dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat,
|
||||
setCurrency,
|
||||
grouped, categoryNames, categoryColor, grandTotal, pieSegments,
|
||||
handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv,
|
||||
th, td,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const EMOJI_CATEGORIES = {
|
||||
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
|
||||
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
|
||||
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
|
||||
}
|
||||
|
||||
// Reaction Quick Menu (right-click)
|
||||
export const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
|
||||
|
||||
export const URL_REGEX = /https?:\/\/[^\s<>"']+/g
|
||||
@@ -0,0 +1,42 @@
|
||||
// ── Twemoji helper (Apple-style emojis via CDN) ──
|
||||
export function emojiToCodepoint(emoji) {
|
||||
const codepoints = []
|
||||
for (const c of emoji) {
|
||||
const cp = c.codePointAt(0)
|
||||
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
|
||||
}
|
||||
return codepoints.join('-')
|
||||
}
|
||||
|
||||
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
|
||||
export function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
|
||||
|
||||
export function formatTime(isoString, is12h) {
|
||||
const d = parseUTC(isoString)
|
||||
const h = d.getHours()
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${mm} ${period}`
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${mm}`
|
||||
}
|
||||
|
||||
export function formatDateSeparator(isoString, t) {
|
||||
const d = parseUTC(isoString)
|
||||
const now = new Date()
|
||||
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
|
||||
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
|
||||
|
||||
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function shouldShowDateSeparator(msg, prevMsg) {
|
||||
if (!prevMsg) return true
|
||||
const d1 = parseUTC(msg.created_at).toDateString()
|
||||
const d2 = parseUTC(prevMsg.created_at).toDateString()
|
||||
return d1 !== d2
|
||||
}
|
||||
@@ -15,17 +15,17 @@ vi.mock('../../api/websocket', () => ({
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen, waitFor, act, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { addListener } from '../../api/websocket';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||
import CollabChat from './CollabChat';
|
||||
import { addListener } from '../../api/websocket';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser' });
|
||||
|
||||
@@ -36,7 +36,11 @@ const defaultProps = {
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.use(http.get('/api/trips/1/collab/messages', () => HttpResponse.json({ messages: [], total: 0 })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({ messages: [], total: 0 })
|
||||
),
|
||||
);
|
||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||
});
|
||||
@@ -71,21 +75,11 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: currentUser.id,
|
||||
username: 'testuser',
|
||||
avatar_url: null,
|
||||
text: 'Hello world!',
|
||||
created_at: '2025-06-01T10:00:00.000Z',
|
||||
reactions: {},
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser',
|
||||
avatar_url: null, text: 'Hello world!', created_at: '2025-06-01T10:00:00.000Z',
|
||||
reactions: {}, reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
@@ -110,17 +104,9 @@ describe('CollabChat', () => {
|
||||
http.post('/api/trips/1/collab/messages', async () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({
|
||||
id: 2,
|
||||
trip_id: 1,
|
||||
user_id: 1,
|
||||
username: 'testuser',
|
||||
avatar_url: null,
|
||||
text: 'New message',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: {},
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
id: 2, trip_id: 1, user_id: 1, username: 'testuser',
|
||||
avatar_url: null, text: 'New message', created_at: new Date().toISOString(),
|
||||
reactions: {}, reply_to: null, deleted: false, edited: false,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -153,32 +139,8 @@ describe('CollabChat', () => {
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 1,
|
||||
username: 'testuser',
|
||||
avatar_url: null,
|
||||
text: 'First message',
|
||||
created_at: '2025-06-01T10:00:00.000Z',
|
||||
reactions: {},
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: 'Second message',
|
||||
created_at: '2025-06-01T10:01:00.000Z',
|
||||
reactions: {},
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
{ id: 1, trip_id: 1, user_id: 1, username: 'testuser', avatar_url: null, text: 'First message', created_at: '2025-06-01T10:00:00.000Z', reactions: {}, reply_to: null, deleted: false, edited: false },
|
||||
{ id: 2, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null, text: 'Second message', created_at: '2025-06-01T10:01:00.000Z', reactions: {}, reply_to: null, deleted: false, edited: false },
|
||||
],
|
||||
total: 2,
|
||||
})
|
||||
@@ -201,21 +163,11 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: 'Hello world!',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Hello world!', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
@@ -249,21 +201,11 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: 'some text',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: true,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'some text', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: true, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
@@ -278,21 +220,12 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: 'React to me',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'React to me', created_at: new Date().toISOString(),
|
||||
reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
|
||||
reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
@@ -315,16 +248,9 @@ describe('CollabChat', () => {
|
||||
type: 'collab:message:created',
|
||||
tripId: 1,
|
||||
message: {
|
||||
id: 99,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
text: 'WS message',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
id: 99, trip_id: 1, user_id: 2, username: 'alice',
|
||||
text: 'WS message', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -336,21 +262,11 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: 'To remove',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'To remove', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
@@ -373,7 +289,7 @@ describe('CollabChat', () => {
|
||||
await screen.findByText('Start the conversation');
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// The send button is the ArrowUp button — it has disabled attr when text is empty
|
||||
const sendButton = buttons.find((b) => b.hasAttribute('disabled'));
|
||||
const sendButton = buttons.find(b => b.hasAttribute('disabled'));
|
||||
expect(sendButton).toBeTruthy();
|
||||
expect(sendButton).toBeDisabled();
|
||||
});
|
||||
@@ -382,23 +298,13 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: 'Reply here',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
reply_text: 'Original message',
|
||||
reply_username: 'alice',
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Reply here', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null,
|
||||
reply_text: 'Original message', reply_username: 'alice',
|
||||
deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
@@ -412,21 +318,11 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: currentUser.id,
|
||||
username: 'testuser',
|
||||
avatar_url: null,
|
||||
text: 'My own message',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser', avatar_url: null,
|
||||
text: 'My own message', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
@@ -459,17 +355,9 @@ describe('CollabChat', () => {
|
||||
http.post('/api/trips/1/collab/messages', async () =>
|
||||
HttpResponse.json({
|
||||
message: {
|
||||
id: 2,
|
||||
trip_id: 1,
|
||||
user_id: 1,
|
||||
username: 'testuser',
|
||||
avatar_url: null,
|
||||
text: 'Sent message',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
id: 2, trip_id: 1, user_id: 1, username: 'testuser',
|
||||
avatar_url: null, text: 'Sent message', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -487,19 +375,15 @@ describe('CollabChat', () => {
|
||||
|
||||
it('FE-COMP-CHAT-024: load earlier messages button appears when 100+ messages exist', async () => {
|
||||
const messages = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: `Message ${i + 1}`,
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
id: i + 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: `Message ${i + 1}`, created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}));
|
||||
server.use(http.get('/api/trips/1/collab/messages', () => HttpResponse.json({ messages, total: 100 })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({ messages, total: 100 })
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Message 1');
|
||||
const loadMoreBtn = await screen.findByRole('button', { name: /load/i });
|
||||
@@ -510,21 +394,11 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: 'Reply to me',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Reply to me', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
@@ -538,7 +412,7 @@ describe('CollabChat', () => {
|
||||
// Reply preview banner renders <strong>{username}</strong> — unique to the banner
|
||||
await waitFor(() => {
|
||||
const aliceEls = screen.queryAllByText('alice');
|
||||
expect(aliceEls.some((el) => el.tagName === 'STRONG')).toBe(true);
|
||||
expect(aliceEls.some(el => el.tagName === 'STRONG')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -546,21 +420,11 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: 'Cancel reply test',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Cancel reply test', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
@@ -572,10 +436,10 @@ describe('CollabChat', () => {
|
||||
// Wait for reply preview <strong> to appear
|
||||
await waitFor(() => {
|
||||
const aliceEls = screen.queryAllByText('alice');
|
||||
expect(aliceEls.some((el) => el.tagName === 'STRONG')).toBe(true);
|
||||
expect(aliceEls.some(el => el.tagName === 'STRONG')).toBe(true);
|
||||
});
|
||||
// Find the X button inside the reply preview — the <strong> is inside a <span> inside the preview div
|
||||
const strongEl = screen.getAllByText('alice').find((el) => el.tagName === 'STRONG')!;
|
||||
const strongEl = screen.getAllByText('alice').find(el => el.tagName === 'STRONG')!;
|
||||
const previewDiv = strongEl.closest('div[style]');
|
||||
const xBtn = previewDiv?.querySelector('button');
|
||||
expect(xBtn).toBeTruthy();
|
||||
@@ -583,7 +447,7 @@ describe('CollabChat', () => {
|
||||
await waitFor(() => {
|
||||
// After cancel, no <strong>alice</strong> in reply preview
|
||||
const remaining = screen.queryAllByText('alice');
|
||||
expect(remaining.every((el) => el.tagName !== 'STRONG')).toBe(true);
|
||||
expect(remaining.every(el => el.tagName !== 'STRONG')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -593,7 +457,7 @@ describe('CollabChat', () => {
|
||||
await screen.findByText('Start the conversation');
|
||||
// Smile button is the only non-disabled button when input is empty
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
const smileBtn = allButtons.find((b) => !b.hasAttribute('disabled'));
|
||||
const smileBtn = allButtons.find(b => !b.hasAttribute('disabled'));
|
||||
expect(smileBtn).toBeTruthy();
|
||||
await user.click(smileBtn!);
|
||||
// EmojiPicker renders category tabs
|
||||
@@ -606,12 +470,12 @@ describe('CollabChat', () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
const smileBtn = allButtons.find((b) => !b.hasAttribute('disabled'));
|
||||
const smileBtn = allButtons.find(b => !b.hasAttribute('disabled'));
|
||||
await user.click(smileBtn!);
|
||||
// Wait for picker to open
|
||||
await screen.findByText('Smileys');
|
||||
// Click the first emoji in the grid (😀 is the first in Smileys)
|
||||
const emojiImg = screen.getAllByRole('img').find((img) => img.getAttribute('alt') === '😀');
|
||||
const emojiImg = screen.getAllByRole('img').find(img => img.getAttribute('alt') === '😀');
|
||||
expect(emojiImg).toBeTruthy();
|
||||
await user.click(emojiImg!.closest('button')!);
|
||||
// Emoji should be appended to textarea
|
||||
@@ -623,21 +487,11 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: 'Right click me',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Right click me', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
@@ -648,9 +502,9 @@ describe('CollabChat', () => {
|
||||
fireEvent.contextMenu(messageBubble!);
|
||||
// ReactionMenu renders quick reactions (❤️ is the first)
|
||||
await waitFor(() => {
|
||||
const reactionImgs = screen
|
||||
.getAllByRole('img')
|
||||
.filter((img) => ['❤️', '😂', '👍'].includes(img.getAttribute('alt') || ''));
|
||||
const reactionImgs = screen.getAllByRole('img').filter(img =>
|
||||
['❤️', '😂', '👍'].includes(img.getAttribute('alt') || '')
|
||||
);
|
||||
expect(reactionImgs.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -660,29 +514,17 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: 'React to this',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'React to this', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
),
|
||||
http.post('/api/trips/1/collab/messages/1/react', async () => {
|
||||
reactCalled = true;
|
||||
return HttpResponse.json({
|
||||
reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 1, username: 'testuser' }] }],
|
||||
});
|
||||
return HttpResponse.json({ reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 1, username: 'testuser' }] }] });
|
||||
})
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
@@ -701,21 +543,11 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: 'Reacted message',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Reacted message', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
@@ -737,17 +569,9 @@ describe('CollabChat', () => {
|
||||
|
||||
it('FE-COMP-CHAT-032: clicking "Load older messages" loads paginated results', async () => {
|
||||
const initialMessages = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 100,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: `New ${i + 100}`,
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
id: i + 100, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: `New ${i + 100}`, created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}));
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
@@ -757,21 +581,11 @@ describe('CollabChat', () => {
|
||||
return HttpResponse.json({ messages: initialMessages, total: 120 });
|
||||
}
|
||||
return HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: 'Older message',
|
||||
created_at: '2020-01-01T10:00:00.000Z',
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Older message', created_at: '2020-01-01T10:00:00.000Z',
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 120,
|
||||
});
|
||||
})
|
||||
@@ -788,25 +602,17 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: currentUser.id,
|
||||
username: 'testuser',
|
||||
avatar_url: null,
|
||||
text: 'Delete me',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser', avatar_url: null,
|
||||
text: 'Delete me', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
),
|
||||
http.delete('/api/trips/1/collab/messages/1', () => HttpResponse.json({ success: true }))
|
||||
http.delete('/api/trips/1/collab/messages/1', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Delete me');
|
||||
@@ -814,28 +620,21 @@ describe('CollabChat', () => {
|
||||
const deleteBtn = screen.getByTitle('Delete');
|
||||
fireEvent.click(deleteBtn);
|
||||
// handleDelete uses a 400ms setTimeout before calling the API
|
||||
await waitFor(() => expect(screen.getByText(/deleted/i)).toBeInTheDocument(), { timeout: 1500 });
|
||||
await waitFor(
|
||||
() => expect(screen.getByText(/deleted/i)).toBeInTheDocument(),
|
||||
{ timeout: 1500 }
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-034: single-emoji message renders as big emoji', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: '👍',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: '👍', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
@@ -862,21 +661,11 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: 'Time format test',
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Time format test', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
@@ -895,21 +684,12 @@ describe('CollabChat', () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
avatar_url: null,
|
||||
text: `Check this out ${uniqueUrl}`,
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
deleted: false,
|
||||
edited: false,
|
||||
},
|
||||
],
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: `Check this out ${uniqueUrl}`,
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
),
|
||||
@@ -919,6 +699,9 @@ describe('CollabChat', () => {
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText(/Check this out/);
|
||||
await waitFor(() => expect(screen.getByText('Preview Title')).toBeInTheDocument(), { timeout: 3000 });
|
||||
await waitFor(
|
||||
() => expect(screen.getByText('Preview Title')).toBeInTheDocument(),
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
export interface ChatReaction {
|
||||
emoji: string
|
||||
count: number
|
||||
users: { id: number; username: string }[]
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number
|
||||
trip_id: number
|
||||
user_id: number
|
||||
text: string
|
||||
reply_to_id: number | null
|
||||
reactions: ChatReaction[]
|
||||
created_at: string
|
||||
user?: { username: string; avatar_url: string | null }
|
||||
reply_to?: ChatMessage | null
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { EMOJI_CATEGORIES } from './CollabChat.constants'
|
||||
import { TwemojiImg } from './CollabChatTwemojiImg'
|
||||
|
||||
/* ── Emoji Picker ── */
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
anchorRef: React.RefObject<HTMLElement | null>
|
||||
containerRef: React.RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
|
||||
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
|
||||
const ref = useRef(null)
|
||||
|
||||
const getPos = () => {
|
||||
const container = containerRef?.current
|
||||
const anchor = anchorRef?.current
|
||||
if (container && anchor) {
|
||||
const cRect = container.getBoundingClientRect()
|
||||
const aRect = anchor.getBoundingClientRect()
|
||||
return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 }
|
||||
}
|
||||
return { bottom: 80, left: 0 }
|
||||
}
|
||||
const pos = getPos()
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e) => {
|
||||
if (ref.current && ref.current.contains(e.target)) return
|
||||
if (anchorRef?.current && anchorRef.current.contains(e.target)) return
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [onClose, anchorRef])
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
|
||||
}}>
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
|
||||
{Object.keys(EMOJI_CATEGORIES).map(c => (
|
||||
<button key={c} onClick={() => setCat(c)} style={{
|
||||
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: cat === c ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Emoji grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
|
||||
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
|
||||
<button key={i} onClick={() => onSelect(emoji)} style={{
|
||||
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
|
||||
padding: 2, transition: 'transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={20} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { collabApi } from '../../api/client'
|
||||
|
||||
/* ── Link Preview ── */
|
||||
const previewCache = {}
|
||||
|
||||
interface LinkPreviewProps {
|
||||
url: string
|
||||
tripId: number
|
||||
own: boolean
|
||||
onLoad: (() => void) | undefined
|
||||
}
|
||||
|
||||
export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
||||
const [data, setData] = useState(previewCache[url] || null)
|
||||
const [loading, setLoading] = useState(!previewCache[url])
|
||||
|
||||
useEffect(() => {
|
||||
if (previewCache[url]) return
|
||||
collabApi.linkPreview(tripId, url).then(d => {
|
||||
previewCache[url] = d
|
||||
setData(d)
|
||||
setLoading(false)
|
||||
if (d?.title || d?.description || d?.image) onLoad?.()
|
||||
}).catch(() => setLoading(false))
|
||||
}, [url, tripId])
|
||||
|
||||
if (loading || !data || (!data.title && !data.description && !data.image)) return null
|
||||
|
||||
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })()
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{
|
||||
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
|
||||
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
|
||||
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
|
||||
maxWidth: 280, transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
{data.image && (
|
||||
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
|
||||
onError={e => e.currentTarget.style.display = 'none'} />
|
||||
)}
|
||||
<div style={{ padding: '8px 10px' }}>
|
||||
{domain && (
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
||||
{data.site_name || domain}
|
||||
</div>
|
||||
)}
|
||||
{data.title && (
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.title}
|
||||
</div>
|
||||
)}
|
||||
{data.description && (
|
||||
<div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
|
||||
/* ── Message Text with clickable URLs ── */
|
||||
interface MessageTextProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
export function MessageText({ text }: MessageTextProps) {
|
||||
const parts = text.split(URL_REGEX)
|
||||
const urls = text.match(URL_REGEX) || []
|
||||
const result = []
|
||||
parts.forEach((part, i) => {
|
||||
if (part) result.push(part)
|
||||
if (urls[i]) result.push(
|
||||
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
|
||||
{urls[i]}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
return <>{result}</>
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import React from 'react'
|
||||
import { Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react'
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
|
||||
import { MessageText } from './CollabChatMessageText'
|
||||
import { LinkPreview } from './CollabChatLinkPreview'
|
||||
import { ReactionBadge } from './CollabChatReactionBadge'
|
||||
|
||||
export function ChatMessages(props: any) {
|
||||
const { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = props
|
||||
return (
|
||||
<>
|
||||
{/* Messages */}
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32, textAlign: 'center' }}>
|
||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||
<span style={{ fontSize: 12, opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
||||
display: 'flex', flexDirection: 'column', gap: 1,
|
||||
}}>
|
||||
{hasMore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
|
||||
<button onClick={handleLoadMore} disabled={loadingMore} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
|
||||
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<ChevronUp size={13} />
|
||||
{loadingMore ? '...' : t('collab.chat.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, idx) => {
|
||||
const own = isOwn(msg)
|
||||
const prevMsg = messages[idx - 1]
|
||||
const nextMsg = messages[idx + 1]
|
||||
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
|
||||
const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id)
|
||||
const showDate = shouldShowDateSeparator(msg, prevMsg)
|
||||
const showAvatar = !own && isLastInGroup
|
||||
const bigEmoji = isEmojiOnly(msg.text)
|
||||
const hasReply = msg.reply_text || msg.reply_to
|
||||
// Deleted message placeholder
|
||||
if (msg._deleted) {
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
// Bubble border radius — iMessage style tails
|
||||
const br = own
|
||||
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
|
||||
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
|
||||
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{/* Date separator */}
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
|
||||
letterSpacing: 0.3, textTransform: 'uppercase',
|
||||
}}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
|
||||
flexDirection: own ? 'row-reverse' : 'row',
|
||||
gap: 6, marginTop: isNewGroup ? 10 : 1,
|
||||
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
|
||||
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
|
||||
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
|
||||
}}>
|
||||
{/* Avatar slot for others */}
|
||||
{!own && (
|
||||
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
|
||||
{showAvatar && (
|
||||
msg.user_avatar ? (
|
||||
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
|
||||
}}>
|
||||
{(msg.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
|
||||
{/* Username for others at group start */}
|
||||
{!own && isNewGroup && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
||||
{msg.username}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setHoveredId(msg.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||
onTouchEnd={e => {
|
||||
const now = Date.now()
|
||||
const lastTap = Number(e.currentTarget.dataset.lastTap) || 0
|
||||
if (now - lastTap < 300 && canEdit) {
|
||||
e.preventDefault()
|
||||
const touch = e.changedTouches?.[0]
|
||||
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
||||
}
|
||||
e.currentTarget.dataset.lastTap = String(now)
|
||||
}}
|
||||
>
|
||||
{bigEmoji ? (
|
||||
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: own ? '#007AFF' : 'var(--bg-secondary)',
|
||||
color: own ? '#fff' : 'var(--text-primary)',
|
||||
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
||||
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{/* Inline reply quote */}
|
||||
{hasReply && (
|
||||
<div style={{
|
||||
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
|
||||
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
|
||||
fontSize: 12, lineHeight: 1.3,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
|
||||
{msg.reply_username || ''}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{(msg.reply_text || '').slice(0, 80)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasReply ? (
|
||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||
) : <MessageText text={msg.text} />}
|
||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover actions */}
|
||||
<div style={{
|
||||
position: 'absolute', top: -14,
|
||||
display: 'flex', gap: 2,
|
||||
opacity: hoveredId === msg.id ? 1 : 0,
|
||||
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
|
||||
transition: 'opacity .1s',
|
||||
...(own ? { left: -6 } : { right: -6 }),
|
||||
}}>
|
||||
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<Reply size={11} />
|
||||
</button>
|
||||
{own && canEdit && (
|
||||
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reactions — iMessage style floating badge */}
|
||||
{msg.reactions?.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
|
||||
justifyContent: own ? 'flex-end' : 'flex-start',
|
||||
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
|
||||
position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
|
||||
borderRadius: 99, background: 'var(--bg-card)',
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{msg.reactions.map(r => {
|
||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||
return (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp — only on last message of group */}
|
||||
{isLastInGroup && (
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||
{formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { TwemojiImg } from './CollabChatTwemojiImg'
|
||||
import type { ChatReaction } from './CollabChat.types'
|
||||
|
||||
/* ── Reaction Badge with NOMAD tooltip ── */
|
||||
interface ReactionBadgeProps {
|
||||
reaction: ChatReaction
|
||||
currentUserId: number
|
||||
onReact: () => void
|
||||
}
|
||||
|
||||
export function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef(null)
|
||||
const names = reaction.users.map(u => u.username).join(', ')
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={ref} onClick={onReact}
|
||||
onMouseEnter={() => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 3px',
|
||||
borderRadius: 99, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: 'transparent', transition: 'transform 0.1s',
|
||||
}}
|
||||
>
|
||||
<TwemojiImg emoji={reaction.emoji} size={16} />
|
||||
{reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
|
||||
</button>
|
||||
{hover && names && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{names}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { QUICK_REACTIONS } from './CollabChat.constants'
|
||||
import { TwemojiImg } from './CollabChatTwemojiImg'
|
||||
|
||||
/* ── Reaction Quick Menu (right-click) ── */
|
||||
interface ReactionMenuProps {
|
||||
x: number
|
||||
y: number
|
||||
onReact: (emoji: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() }
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [onClose])
|
||||
|
||||
// Clamp to viewport
|
||||
const menuWidth = 156
|
||||
const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8))
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', top: y - 80, left: clampedLeft, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '6px 8px',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
|
||||
}}>
|
||||
{QUICK_REACTIONS.map(emoji => (
|
||||
<button key={emoji} onClick={() => onReact(emoji)} style={{
|
||||
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
|
||||
padding: 3, transition: 'transform 0.1s, background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={18} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState } from 'react'
|
||||
import { emojiToCodepoint } from './CollabChat.helpers'
|
||||
|
||||
export function TwemojiImg({ emoji, size = 20, style = {} }) {
|
||||
const cp = emojiToCodepoint(emoji)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
if (failed) {
|
||||
return <span style={{ fontSize: size, lineHeight: 1, display: 'inline-block', verticalAlign: 'middle', ...style }}>{emoji}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
|
||||
alt={emoji}
|
||||
draggable={false}
|
||||
style={{ width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const FONT = "var(--font-system)"
|
||||
|
||||
export const NOTE_COLORS = [
|
||||
{ value: '#6366f1', label: 'Indigo' },
|
||||
{ value: '#ef4444', label: 'Red' },
|
||||
{ value: '#f59e0b', label: 'Amber' },
|
||||
{ value: '#10b981', label: 'Emerald' },
|
||||
{ value: '#3b82f6', label: 'Blue' },
|
||||
{ value: '#8b5cf6', label: 'Violet' },
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
// Pure formatting helper for note timestamps. Falls back to translated
|
||||
// relative labels for recent timestamps and a localized short date beyond a week.
|
||||
export const formatTimestamp = (ts, t, locale) => {
|
||||
if (!ts) return ''
|
||||
const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z')
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
if (diffMins < 1) return t('collab.chat.justNow') || 'just now'
|
||||
if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago`
|
||||
const diffHrs = Math.floor(diffMins / 60)
|
||||
if (diffHrs < 24) return t('collab.chat.hoursAgo', { n: diffHrs }) || `${diffHrs}h ago`
|
||||
const diffDays = Math.floor(diffHrs / 24)
|
||||
if (diffDays < 7) return t('collab.notes.daysAgo', { n: diffDays }) || `${diffDays}d ago`
|
||||
return d.toLocaleDateString(locale || undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
export interface NoteFile {
|
||||
id: number
|
||||
filename: string
|
||||
original_name: string
|
||||
mime_type: string
|
||||
file_size?: number | null
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface CollabNote {
|
||||
id: number
|
||||
trip_id: number
|
||||
title: string
|
||||
content: string
|
||||
category: string
|
||||
website: string | null
|
||||
pinned: boolean
|
||||
color: string | null
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
avatar: string | null
|
||||
user_id: number
|
||||
created_at: string
|
||||
author?: { username: string; avatar: string | null }
|
||||
user?: { username: string; avatar: string | null }
|
||||
files?: NoteFile[]
|
||||
// Wire field: collabService embeds note files as `attachments` (with url).
|
||||
attachments?: NoteFile[]
|
||||
}
|
||||
|
||||
export interface NoteAuthor {
|
||||
username: string
|
||||
avatar?: string | null
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
|
||||
export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
|
||||
const [authSrc, setAuthSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||
}, [src])
|
||||
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { Trash2, Pin, PinOff, Pencil, Maximize2 } from 'lucide-react'
|
||||
import { FONT } from './CollabNotes.constants'
|
||||
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||
import { UserAvatar } from './CollabNotesUserAvatar'
|
||||
import { WebsiteThumbnail } from './CollabNotesWebsiteThumbnail'
|
||||
import type { CollabNote, NoteFile } from './CollabNotes.types'
|
||||
import type { User } from '../../types'
|
||||
|
||||
// ── Note Card ───────────────────────────────────────────────────────────────
|
||||
interface NoteCardProps {
|
||||
note: CollabNote
|
||||
currentUser: User
|
||||
canEdit: boolean
|
||||
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
||||
onDelete: (noteId: number) => void
|
||||
onEdit: (note: CollabNote) => void
|
||||
onView: (note: CollabNote) => void
|
||||
onPreviewFile: (file: NoteFile) => void
|
||||
getCategoryColor: (category: string) => string
|
||||
tripId: number
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
||||
const color = getCategoryColor ? getCategoryColor(note.category) : (note.color || '#6366f1')
|
||||
|
||||
const handleTogglePin = useCallback(() => {
|
||||
onUpdate(note.id, { pinned: !note.pinned })
|
||||
}, [note.id, note.pinned, onUpdate])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete(note.id)
|
||||
}, [note.id, onDelete])
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${note.pinned ? color + '40' : color + '25'}`,
|
||||
background: note.pinned ? `${color}08` : 'var(--bg-card)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: FONT,
|
||||
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||
...(hovered ? { transform: 'translateY(-1px)', boxShadow: '0 4px 16px rgba(0,0,0,0.08)' } : {}),
|
||||
}}
|
||||
>
|
||||
{/* Header bar — like reservation cards */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 10px',
|
||||
background: `${color}0d`,
|
||||
}}>
|
||||
{!!note.pinned && <Pin size={9} color={color} style={{ flexShrink: 0 }} />}
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden', flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{note.title}
|
||||
</span>
|
||||
{note.category && (
|
||||
<span style={{ fontSize: 8, fontWeight: 600, color, background: `${color}18`, padding: '2px 6px', borderRadius: 99, flexShrink: 0, letterSpacing: '0.02em', textTransform: 'uppercase' }}>
|
||||
{note.category}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Hover actions in header */}
|
||||
{(
|
||||
<div style={{
|
||||
display: 'flex', gap: 2,
|
||||
}}>
|
||||
{note.content && (
|
||||
<button onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Maximize2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = color}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
||||
</button>}
|
||||
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={10} />
|
||||
</button>}
|
||||
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={10} />
|
||||
</button>}
|
||||
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
||||
{/* Author avatar */}
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}
|
||||
onMouseEnter={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '1' }}
|
||||
onMouseLeave={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '0' }}>
|
||||
<UserAvatar user={author} size={16} />
|
||||
<div data-tip style={{
|
||||
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
marginBottom: 6, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.12s',
|
||||
whiteSpace: 'nowrap', zIndex: 10,
|
||||
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{author.username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card body */}
|
||||
<div style={{
|
||||
padding: '8px 12px 10px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{note.content && (
|
||||
<div className="collab-note-md" style={{
|
||||
fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
|
||||
maxHeight: '4.5em', overflow: 'hidden',
|
||||
wordBreak: 'break-word', fontFamily: FONT,
|
||||
}}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Right: website + attachment thumbnails */}
|
||||
{(note.website || (note.attachments?.length ?? 0) > 0) && (
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'flex-start' }}>
|
||||
{/* Website */}
|
||||
{note.website && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span>
|
||||
<WebsiteThumbnail url={note.website} tripId={tripId} color={color} />
|
||||
</div>
|
||||
)}
|
||||
{/* Files */}
|
||||
{(note.attachments || []).length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>{t('files.title')}</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(note.attachments || []).slice(0, note.website ? 1 : 2).map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
return isImage ? (
|
||||
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
|
||||
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
||||
onClick={() => onPreviewFile?.(a)}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
|
||||
) : (
|
||||
<div key={a.id} title={a.original_name} onClick={() => onPreviewFile?.(a)}
|
||||
style={{
|
||||
width: 48, height: 48, borderRadius: 8, cursor: 'pointer',
|
||||
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
|
||||
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{(note.attachments?.length || 0) > (note.website ? 1 : 2) && (
|
||||
<span style={{ fontSize: 8, color: 'var(--text-faint)', textAlign: 'center' }}>+{(note.attachments?.length || 0) - (note.website ? 1 : 2)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState } from 'react'
|
||||
import { Plus, Trash2, X } from 'lucide-react'
|
||||
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
|
||||
import { EditableCatName } from './CollabNotesEditableCatName'
|
||||
|
||||
// ── Category Settings Modal ──────────────────────────────────────────────────
|
||||
interface CategorySettingsModalProps {
|
||||
onClose: () => void
|
||||
categories: string[]
|
||||
categoryColors: Record<string, string>
|
||||
onSave: (colors: Record<string, string>) => void
|
||||
onRenameCategory: (oldName: string, newName: string) => Promise<void>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }: CategorySettingsModalProps) {
|
||||
const [localColors, setLocalColors] = useState({ ...categoryColors })
|
||||
const [renames, setRenames] = useState<Record<string, string>>({}) // { oldName: newName }
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
|
||||
const handleColorChange = (cat, color) => {
|
||||
setLocalColors(prev => ({ ...prev, [cat]: color }))
|
||||
}
|
||||
|
||||
const handleAddCategory = () => {
|
||||
if (!newCatName.trim() || localColors[newCatName.trim()]) return
|
||||
setLocalColors(prev => ({ ...prev, [newCatName.trim()]: NOTE_COLORS[Object.keys(prev).length % NOTE_COLORS.length].value }))
|
||||
setNewCatName('')
|
||||
}
|
||||
|
||||
const handleRemoveCategory = (cat) => {
|
||||
setLocalColors(prev => { const n = { ...prev }; delete n[cat]; return n })
|
||||
}
|
||||
|
||||
const handleRenameCategory = (oldName, newName) => {
|
||||
if (!newName.trim() || newName.trim() === oldName || localColors[newName.trim()]) return
|
||||
// Track rename for saving to DB later
|
||||
const originalName = Object.entries(renames).find(([, v]) => v === oldName)?.[0] || oldName
|
||||
setRenames(prev => ({ ...prev, [originalName]: newName.trim() }))
|
||||
setLocalColors(prev => {
|
||||
const n = {}
|
||||
for (const [k, v] of Object.entries(prev)) {
|
||||
n[k === oldName ? newName.trim() : k] = v
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// Apply renames to notes in DB
|
||||
for (const [oldName, newName] of Object.entries(renames)) {
|
||||
if (oldName !== newName) await onRenameCategory(oldName, newName)
|
||||
}
|
||||
await onSave(localColors)
|
||||
onClose()
|
||||
}
|
||||
|
||||
// Merge existing categories from notes with saved colors
|
||||
const allCats = [...new Set([...categories, ...Object.keys(localColors)])]
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
|
||||
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT,
|
||||
}} onClick={onClose}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 420,
|
||||
maxHeight: '80vh', overflow: 'auto', border: '1px solid var(--border-faint)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
|
||||
{t('collab.notes.categorySettings') || 'Category Settings'}
|
||||
</h3>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Categories list */}
|
||||
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{allCats.length === 0 && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}>
|
||||
{t('collab.notes.noCategoriesYet') || 'No categories yet'}
|
||||
</p>
|
||||
)}
|
||||
{allCats.map(cat => (
|
||||
<div key={cat} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{/* Color swatches */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{NOTE_COLORS.map(c => (
|
||||
<button key={c.value} onClick={() => handleColorChange(cat, c.value)} style={{
|
||||
width: 20, height: 20, borderRadius: 6, background: c.value, border: 'none', cursor: 'pointer', padding: 0,
|
||||
outline: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? '2px solid var(--text-primary)' : '2px solid transparent',
|
||||
outlineOffset: 1, transition: 'transform 0.1s',
|
||||
transform: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? 'scale(1.1)' : 'scale(1)',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
{/* Category name — editable */}
|
||||
<EditableCatName name={cat} onRename={(newName) => handleRenameCategory(cat, newName)} />
|
||||
{/* Delete */}
|
||||
<button onClick={() => handleRemoveCategory(cat)} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 3, display: 'flex',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add new */}
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||
<input value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
placeholder={t('collab.notes.newCategory')}
|
||||
style={{
|
||||
flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px',
|
||||
fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
|
||||
}} />
|
||||
<button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
|
||||
background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)',
|
||||
border: 'none', borderRadius: 10, padding: '8px 14px', cursor: newCatName.trim() ? 'pointer' : 'default',
|
||||
display: 'flex', alignItems: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<button onClick={handleSave} style={{
|
||||
width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
|
||||
}}>
|
||||
{t('collab.notes.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface EditableCatNameProps {
|
||||
name: string
|
||||
onRename: (newName: string) => void
|
||||
}
|
||||
|
||||
export function EditableCatName({ name, onRename }: EditableCatNameProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState(name)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
|
||||
|
||||
const save = () => {
|
||||
setEditing(false)
|
||||
if (value.trim() && value.trim() !== name) onRename(value.trim())
|
||||
else setValue(name)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return <input ref={inputRef} value={value} onChange={e => setValue(e.target.value)}
|
||||
onBlur={save} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setValue(name); setEditing(false) } }}
|
||||
style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '2px 8px', background: 'var(--bg-input)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={() => { setValue(name); setEditing(true) }}
|
||||
style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
|
||||
title="Click to rename">
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import type { NoteFile } from './CollabNotes.types'
|
||||
|
||||
// ── File Preview Portal ─────────────────────────────────────────────────────
|
||||
interface FilePreviewPortalProps {
|
||||
file: NoteFile | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
const [authUrl, setAuthUrl] = useState('')
|
||||
const rawUrl = file?.url || ''
|
||||
useEffect(() => {
|
||||
setAuthUrl('')
|
||||
if (!rawUrl) return
|
||||
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
|
||||
}, [rawUrl])
|
||||
|
||||
if (!file) return null
|
||||
const isImage = file.mime_type?.startsWith('image/')
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
const isTxt = file.mime_type?.startsWith('text/')
|
||||
|
||||
const openInNewTab = () => openFile(rawUrl).catch(() => {})
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
||||
{isImage ? (
|
||||
/* Image lightbox — floating controls */
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
{authUrl
|
||||
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
: <Loader2 size={32} className="animate-spin text-[rgba(255,255,255,0.5)]" />
|
||||
}
|
||||
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Document viewer — card with header */
|
||||
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{(isPdf || isTxt) ? (
|
||||
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
|
||||
</p>
|
||||
</object>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download {file.original_name}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useRef } from 'react'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { FONT } from './CollabNotes.constants'
|
||||
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||
import type { CollabNote } from './CollabNotes.types'
|
||||
|
||||
// ── New Note Modal (portal to body) ─────────────────────────────────────────
|
||||
interface NoteFormModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: { title: string; content: string; category: string | null; website: string | null; color?: string | null; _pendingFiles?: File[]; files?: File[] }) => Promise<void>
|
||||
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
|
||||
existingCategories: string[]
|
||||
categoryColors: Record<string, string>
|
||||
getCategoryColor: (category: string) => string
|
||||
note: CollabNote | null
|
||||
tripId: number
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
const canUploadFiles = can('file_upload', tripObj)
|
||||
const isEdit = !!note
|
||||
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
|
||||
|
||||
const [title, setTitle] = useState(note?.title || '')
|
||||
const [content, setContent] = useState(note?.content || '')
|
||||
const [category, setCategory] = useState(note?.category || allCategories[0] || '')
|
||||
const [website, setWebsite] = useState(note?.website || '')
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const [existingAttachments, setExistingAttachments] = useState(note?.attachments || [])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const fileRef = useRef(null)
|
||||
|
||||
const finalCategory = category
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onSubmit({
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
category: finalCategory || null,
|
||||
color: getCategoryColor(finalCategory),
|
||||
website: website.trim() || null,
|
||||
_pendingFiles: pendingFiles,
|
||||
})
|
||||
onClose()
|
||||
} catch {
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (fileId) => {
|
||||
if (onDeleteFile && note) {
|
||||
await onDeleteFile(note.id, fileId)
|
||||
setExistingAttachments(prev => prev.filter(a => a.id !== fileId))
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = title.trim() && !submitting
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
|
||||
backdropFilter: 'blur(6px)',
|
||||
WebkitBackdropFilter: 'blur(6px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
padding: 16,
|
||||
fontFamily: FONT,
|
||||
}}
|
||||
>
|
||||
<form
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
borderRadius: 16,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
border: '1px solid var(--border-faint)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onPaste={e => {
|
||||
if (!canUploadFiles) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (file) setPendingFiles(prev => [...prev, file])
|
||||
return
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{/* Modal header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '14px 16px 12px',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
margin: 0,
|
||||
fontFamily: FONT,
|
||||
}}>
|
||||
{isEdit ? t('collab.notes.edit') : t('collab.notes.new')}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-faint)',
|
||||
padding: 2,
|
||||
borderRadius: 6,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal body */}
|
||||
<div style={{
|
||||
padding: '14px 16px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}>
|
||||
{/* Title */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 4,
|
||||
fontFamily: FONT,
|
||||
}}>
|
||||
{t('collab.notes.title')}
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder={t('collab.notes.titlePlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 4,
|
||||
fontFamily: FONT,
|
||||
}}>
|
||||
{t('collab.notes.contentPlaceholder')}
|
||||
</div>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder={t('collab.notes.contentPlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
resize: 'vertical',
|
||||
minHeight: 180,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category pills */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6, fontFamily: FONT }}>
|
||||
{t('collab.notes.category')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{allCategories.map(cat => {
|
||||
const c = getCategoryColor(cat)
|
||||
const active = category === cat
|
||||
return (
|
||||
<button key={cat} type="button" onClick={() => setCategory(cat)}
|
||||
style={{ padding: '4px 12px', borderRadius: 99, border: active ? `1.5px solid ${c}` : '1px solid var(--border-faint)', background: active ? `${c}18` : 'transparent', color: active ? c : 'var(--text-muted)', fontSize: 11, fontWeight: 600, cursor: 'pointer', fontFamily: FONT }}>
|
||||
{cat}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.website')}
|
||||
</div>
|
||||
<input value={website} onChange={e => setWebsite(e.target.value)}
|
||||
placeholder={t('collab.notes.websitePlaceholder')}
|
||||
style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
{/* File attachments */}
|
||||
{canUploadFiles && <div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.attachFiles')}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Existing attachments (edit mode) */}
|
||||
{existingAttachments.map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
return (
|
||||
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
|
||||
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
|
||||
<button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* New pending files */}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div key={`new-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{f.name.length > 20 ? f.name.slice(0, 17) + '...' : f.name}
|
||||
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 0, display: 'flex' }}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => fileRef.current?.click()}
|
||||
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Plus size={11} /> {t('files.attach') || 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 99,
|
||||
padding: '7px 14px',
|
||||
background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
|
||||
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fontFamily: FONT,
|
||||
border: 'none',
|
||||
cursor: canSubmit ? 'pointer' : 'default',
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{submitting ? '...' : isEdit ? t('collab.notes.save') : t('collab.notes.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { FONT } from './CollabNotes.constants'
|
||||
import type { NoteAuthor } from './CollabNotes.types'
|
||||
|
||||
// ── Avatar ──────────────────────────────────────────────────────────────────
|
||||
interface UserAvatarProps {
|
||||
user: NoteAuthor | null
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function UserAvatar({ user, size = 14 }: UserAvatarProps) {
|
||||
if (!user) return null
|
||||
if (user.avatar) {
|
||||
return (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.username}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0,
|
||||
background: 'var(--bg-tertiary)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const initials = (user.username || '?').slice(0, 1)
|
||||
return (
|
||||
<div style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--bg-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: size * 0.45,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
flexShrink: 0,
|
||||
textTransform: 'uppercase',
|
||||
fontFamily: FONT,
|
||||
}}>
|
||||
{initials}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
|
||||
// ── Website Thumbnail (fetches OG image) ────────────────────────────────────
|
||||
const ogCache = {}
|
||||
|
||||
interface WebsiteThumbnailProps {
|
||||
url: string
|
||||
tripId: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) {
|
||||
const [data, setData] = useState(ogCache[url] || null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (ogCache[url]) { setData(ogCache[url]); return }
|
||||
collabApi.linkPreview(tripId, url).then(d => { ogCache[url] = d; setData(d) }).catch(() => setFailed(true))
|
||||
}, [url, tripId])
|
||||
|
||||
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return 'link' } })()
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" title={data?.title || url}
|
||||
style={{
|
||||
width: 48, height: 48, borderRadius: 8, cursor: 'pointer', overflow: 'hidden',
|
||||
background: data?.image ? 'none' : 'var(--bg-tertiary)', border: 'none',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 2,
|
||||
textDecoration: 'none', transition: 'transform 0.12s, box-shadow 0.12s', flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||
{data?.image && !failed ? (
|
||||
<img src={data.image} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} onError={() => setFailed(true)} />
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink size={14} color="var(--text-muted)" />
|
||||
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-muted)', maxWidth: 42, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
|
||||
{domain}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser } from '../../../tests/helpers/factories'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }));
|
||||
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }));
|
||||
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }));
|
||||
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }));
|
||||
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }))
|
||||
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }))
|
||||
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }))
|
||||
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }))
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
@@ -16,130 +16,130 @@ vi.mock('../../api/websocket', () => ({
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
}))
|
||||
|
||||
import CollabPanel from './CollabPanel';
|
||||
import CollabPanel from './CollabPanel'
|
||||
|
||||
let originalInnerWidth: number;
|
||||
let originalInnerWidth: number
|
||||
|
||||
function setViewport(width: number) {
|
||||
Object.defineProperty(window, 'innerWidth', { value: width, writable: true, configurable: true });
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
Object.defineProperty(window, 'innerWidth', { value: width, writable: true, configurable: true })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
}
|
||||
|
||||
describe('CollabPanel', () => {
|
||||
beforeEach(() => {
|
||||
originalInnerWidth = window.innerWidth;
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildUser() });
|
||||
});
|
||||
originalInnerWidth = window.innerWidth
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: buildUser() })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true });
|
||||
});
|
||||
Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true })
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-001
|
||||
it('desktop layout renders all four panels', () => {
|
||||
setViewport(1280);
|
||||
render(<CollabPanel tripId={1} />);
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument();
|
||||
});
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-002
|
||||
it('mobile layout renders tab bar, not all panels at once', () => {
|
||||
setViewport(375);
|
||||
render(<CollabPanel tripId={1} />);
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
// Tab buttons exist
|
||||
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument()
|
||||
// Only chat visible by default
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-003
|
||||
it('mobile: clicking Notes tab switches to CollabNotes', () => {
|
||||
setViewport(375);
|
||||
render(<CollabPanel tripId={1} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /notes/i }));
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument();
|
||||
});
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /notes/i }))
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-004
|
||||
it('mobile: clicking Polls tab switches to CollabPolls', () => {
|
||||
setViewport(375);
|
||||
render(<CollabPanel tripId={1} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /polls/i }));
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument();
|
||||
});
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /polls/i }))
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-005
|
||||
it("mobile: clicking What's Next tab shows WhatsNextWidget", () => {
|
||||
setViewport(375);
|
||||
render(<CollabPanel tripId={1} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /what.?s next/i }));
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument();
|
||||
});
|
||||
it('mobile: clicking What\'s Next tab shows WhatsNextWidget', () => {
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /what.?s next/i }))
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-006
|
||||
it('mobile: active tab button has accent background style', () => {
|
||||
setViewport(375);
|
||||
render(<CollabPanel tripId={1} />);
|
||||
const chatButton = screen.getByRole('button', { name: /chat/i });
|
||||
expect(chatButton.style.background).toBe('var(--accent)');
|
||||
const notesButton = screen.getByRole('button', { name: /notes/i });
|
||||
expect(notesButton.style.background).toBe('transparent');
|
||||
});
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
const chatButton = screen.getByRole('button', { name: /chat/i })
|
||||
expect(chatButton.style.background).toBe('var(--accent)')
|
||||
const notesButton = screen.getByRole('button', { name: /notes/i })
|
||||
expect(notesButton.style.background).toBe('transparent')
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-007
|
||||
it('mobile: default active tab is Chat', () => {
|
||||
setViewport(375);
|
||||
render(<CollabPanel tripId={1} />);
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
|
||||
});
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-008
|
||||
it('tripMembers prop is forwarded to WhatsNextWidget', () => {
|
||||
setViewport(1280);
|
||||
render(<CollabPanel tripId={1} tripMembers={[{ id: 5, username: 'alice', avatar_url: null }]} />);
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument();
|
||||
});
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} tripMembers={[{ id: 5, username: 'alice', avatar_url: null }]} />)
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-009
|
||||
it('tripId prop is forwarded to child components', () => {
|
||||
setViewport(1280);
|
||||
render(<CollabPanel tripId={1} />);
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
// All children render without errors, confirming props were forwarded
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-010
|
||||
it('resize from desktop to mobile hides side-by-side layout', () => {
|
||||
setViewport(1280);
|
||||
const { rerender } = render(<CollabPanel tripId={1} />);
|
||||
setViewport(1280)
|
||||
const { rerender } = render(<CollabPanel tripId={1} />)
|
||||
// All four panels visible on desktop
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
|
||||
// Switch to mobile
|
||||
setViewport(375);
|
||||
rerender(<CollabPanel tripId={1} />);
|
||||
setViewport(375)
|
||||
rerender(<CollabPanel tripId={1} />)
|
||||
|
||||
// Tab bar appears, only chat visible
|
||||
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,95 +1,81 @@
|
||||
import { BarChart3, MessageCircle, Sparkles, StickyNote } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import CollabChat from './CollabChat';
|
||||
import CollabNotes from './CollabNotes';
|
||||
import CollabPolls from './CollabPolls';
|
||||
import WhatsNextWidget from './WhatsNextWidget';
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
import CollabChat from './CollabChat'
|
||||
import CollabNotes from './CollabNotes'
|
||||
import CollabPolls from './CollabPolls'
|
||||
import WhatsNextWidget from './WhatsNextWidget'
|
||||
|
||||
function useIsDesktop(breakpoint = 1024) {
|
||||
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint);
|
||||
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
|
||||
useEffect(() => {
|
||||
const check = () => setIsDesktop(window.innerWidth >= breakpoint);
|
||||
window.addEventListener('resize', check);
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, [breakpoint]);
|
||||
return isDesktop;
|
||||
const check = () => setIsDesktop(window.innerWidth >= breakpoint)
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [breakpoint])
|
||||
return isDesktop
|
||||
}
|
||||
|
||||
const card = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--bg-card)',
|
||||
borderRadius: 16,
|
||||
border: '1px solid var(--border-faint)',
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
};
|
||||
const cardClass = 'flex flex-col bg-surface-card rounded-2xl border border-edge-faint overflow-hidden min-h-0'
|
||||
|
||||
interface TripMember {
|
||||
id: number;
|
||||
username: string;
|
||||
avatar_url?: string | null;
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface CollabFeatures {
|
||||
chat: boolean;
|
||||
notes: boolean;
|
||||
polls: boolean;
|
||||
whatsnext: boolean;
|
||||
chat: boolean
|
||||
notes: boolean
|
||||
polls: boolean
|
||||
whatsnext: boolean
|
||||
}
|
||||
|
||||
interface CollabPanelProps {
|
||||
tripId: number;
|
||||
tripMembers?: TripMember[];
|
||||
collabFeatures?: CollabFeatures;
|
||||
tripId: number
|
||||
tripMembers?: TripMember[]
|
||||
collabFeatures?: CollabFeatures
|
||||
}
|
||||
|
||||
const ALL_TABS = [
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
|
||||
{
|
||||
id: 'next',
|
||||
featureKey: 'whatsnext' as const,
|
||||
labelKey: 'collab.whatsNext.title',
|
||||
fallback: "What's Next",
|
||||
icon: Sparkles,
|
||||
},
|
||||
];
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
|
||||
]
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
const isDesktop = useIsDesktop();
|
||||
const { user } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true };
|
||||
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
|
||||
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
ALL_TABS.filter((tab) => features[tab.featureKey]).map((tab) => ({
|
||||
...tab,
|
||||
label: t(tab.labelKey) || tab.fallback,
|
||||
})),
|
||||
[features, t]
|
||||
);
|
||||
const tabs = useMemo(() =>
|
||||
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
|
||||
...tab,
|
||||
label: t(tab.labelKey) || tab.fallback,
|
||||
})),
|
||||
[features, t])
|
||||
|
||||
const [mobileTab, setMobileTab] = useState(() => tabs[0]?.id || 'chat');
|
||||
const [mobileTab, setMobileTab] = useState(() => tabs[0]?.id || 'chat')
|
||||
|
||||
// If active tab gets disabled, switch to first available
|
||||
useEffect(() => {
|
||||
if (tabs.length > 0 && !tabs.some((t) => t.id === mobileTab)) {
|
||||
setMobileTab(tabs[0].id);
|
||||
if (tabs.length > 0 && !tabs.some(t => t.id === mobileTab)) {
|
||||
setMobileTab(tabs[0].id)
|
||||
}
|
||||
}, [tabs, mobileTab]);
|
||||
}, [tabs, mobileTab])
|
||||
|
||||
const chatOn = features.chat;
|
||||
const rightPanels = [features.notes && 'notes', features.polls && 'polls', features.whatsnext && 'whatsnext'].filter(
|
||||
Boolean
|
||||
) as string[];
|
||||
const chatOn = features.chat
|
||||
const rightPanels = [
|
||||
features.notes && 'notes',
|
||||
features.polls && 'polls',
|
||||
features.whatsnext && 'whatsnext',
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
if (isDesktop) {
|
||||
// Chat always 380px fixed when on. Right panels share remaining space.
|
||||
@@ -98,46 +84,45 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
// Only chat
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (chatOn) {
|
||||
// Chat left (380px) + right panels
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: '0 0 380px' }}>
|
||||
<div className={cardClass} style={{ flex: '0 0 380px' }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{rightPanels.length === 1 && (
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
)}
|
||||
{rightPanels.length === 2 &&
|
||||
rightPanels.map((p) => (
|
||||
<div key={p} style={{ ...card, flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
))}
|
||||
{rightPanels.length === 2 && rightPanels.map(p => (
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
))}
|
||||
{rightPanels.length === 3 && (
|
||||
<>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,85 +130,57 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// Chat off — remaining panels share full width
|
||||
const panels = rightPanels;
|
||||
const panels = rightPanels
|
||||
if (panels.length === 1) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{panels.map((p) => (
|
||||
<div key={p} style={{ ...card, flex: 1 }}>
|
||||
{panels.map(p => (
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile: tab bar + single panel (only enabled tabs)
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
background: 'var(--bg-card)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = mobileTab === tab.id;
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'absolute', inset: 0 }}>
|
||||
<div style={{
|
||||
display: 'flex', gap: 2, padding: '8px 12px', borderBottom: '1px solid var(--border-faint)',
|
||||
background: 'var(--bg-card)', flexShrink: 0,
|
||||
}}>
|
||||
{tabs.map(tab => {
|
||||
const active = mobileTab === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setMobileTab(tab.id)}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
padding: '8px 0',
|
||||
borderRadius: 10,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -234,5 +191,5 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,16 +10,16 @@ vi.mock('../../api/websocket', () => ({
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { addListener } from '../../api/websocket';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||
import CollabPolls from './CollabPolls';
|
||||
import { addListener } from '../../api/websocket';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser' });
|
||||
|
||||
@@ -43,9 +43,13 @@ const defaultProps = { tripId: 1, currentUser };
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [] }),
|
||||
),
|
||||
);
|
||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 1 }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
|
||||
});
|
||||
|
||||
describe('CollabPolls', () => {
|
||||
@@ -59,21 +63,31 @@ describe('CollabPolls', () => {
|
||||
http.get('/api/trips/1/collab/polls', async () => {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return HttpResponse.json({ polls: [] });
|
||||
})
|
||||
}),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
// The spinner is a div with animation style
|
||||
expect(document.querySelector('[style*="animation"]')).toBeInTheDocument();
|
||||
expect(
|
||||
document.querySelector('[style*="animation"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-003: renders poll question from API', async () => {
|
||||
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll()] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll()] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-004: renders poll options', async () => {
|
||||
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll()] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll()] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Paris');
|
||||
expect(screen.getByText('Rome')).toBeInTheDocument();
|
||||
@@ -83,7 +97,9 @@ describe('CollabPolls', () => {
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
// Wait for loading to finish
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /new/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-006: clicking New Poll button opens the create modal', async () => {
|
||||
@@ -124,8 +140,8 @@ describe('CollabPolls', () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ poll: buildPoll({ id: 99, question: 'Where to eat?' }) })
|
||||
)
|
||||
HttpResponse.json({ poll: buildPoll({ id: 99, question: 'Where to eat?' }) }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
@@ -143,23 +159,20 @@ describe('CollabPolls', () => {
|
||||
it('FE-COMP-POLLS-009: voting on an option calls POST vote API', async () => {
|
||||
let voteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll()] })),
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll()] }),
|
||||
),
|
||||
http.post('/api/trips/1/collab/polls/1/vote', () => {
|
||||
voteCalled = true;
|
||||
return HttpResponse.json({
|
||||
poll: buildPoll({
|
||||
options: [
|
||||
{
|
||||
id: 1,
|
||||
text: 'Paris',
|
||||
label: 'Paris',
|
||||
voters: [{ user_id: 1, username: 'testuser', avatar_url: null }],
|
||||
},
|
||||
{ id: 1, text: 'Paris', label: 'Paris', voters: [{ user_id: 1, username: 'testuser', avatar_url: null }] },
|
||||
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
})
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
@@ -170,7 +183,9 @@ describe('CollabPolls', () => {
|
||||
|
||||
it('FE-COMP-POLLS-010: closed poll shows "Closed" badge', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }))
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/closed/i);
|
||||
@@ -178,7 +193,9 @@ describe('CollabPolls', () => {
|
||||
|
||||
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }))
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Paris');
|
||||
@@ -189,11 +206,13 @@ describe('CollabPolls', () => {
|
||||
it('FE-COMP-POLLS-012: delete button calls DELETE API and removes poll', async () => {
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll({ id: 5 })] })),
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ id: 5 })] }),
|
||||
),
|
||||
http.delete('/api/trips/1/collab/polls/5', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
@@ -204,7 +223,9 @@ describe('CollabPolls', () => {
|
||||
await user.click(deleteBtn);
|
||||
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
await waitFor(() => expect(screen.queryByText('Best destination?')).not.toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-013: WebSocket collab:poll:created event adds poll', async () => {
|
||||
@@ -219,14 +240,20 @@ describe('CollabPolls', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-014: WebSocket collab:poll:deleted event removes poll', async () => {
|
||||
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll({ id: 3 })] })));
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ id: 3 })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
|
||||
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
listener({ type: 'collab:poll:deleted', pollId: 3 });
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Best destination?')).not.toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-015: adding a third option in create modal', async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,27 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import WhatsNextWidget from './WhatsNextWidget';
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import WhatsNextWidget from './WhatsNextWidget'
|
||||
import { afterEach, beforeEach, describe, it, expect } from 'vitest'
|
||||
|
||||
// Dynamic date helpers
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
function getFutureDate(daysAhead: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + daysAhead);
|
||||
return d.toISOString().split('T')[0];
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + daysAhead)
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function getPastDate(daysBack: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - daysBack);
|
||||
return d.toISOString().split('T')[0];
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - daysBack)
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
const tomorrow = getFutureDate(1);
|
||||
const yesterday = getPastDate(1);
|
||||
const tomorrow = getFutureDate(1)
|
||||
const yesterday = getPastDate(1)
|
||||
|
||||
function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}, participants: unknown[] = []) {
|
||||
return {
|
||||
@@ -32,169 +32,147 @@ function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}
|
||||
notes: null,
|
||||
place: {
|
||||
id,
|
||||
trip_id: 1,
|
||||
name: `Place ${id}`,
|
||||
description: null,
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
address: null,
|
||||
category_id: null,
|
||||
icon: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
duration_minutes: 60,
|
||||
notes: null,
|
||||
transport_mode: 'walking',
|
||||
website: null,
|
||||
phone: null,
|
||||
...placeOverrides,
|
||||
},
|
||||
participants,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('WhatsNextWidget', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } });
|
||||
});
|
||||
resetAllStores()
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-001: renders empty state when no days exist', () => {
|
||||
seedStore(useTripStore, { days: [], assignments: {} });
|
||||
render(<WhatsNextWidget />);
|
||||
seedStore(useTripStore, { days: [], assignments: {} })
|
||||
render(<WhatsNextWidget />)
|
||||
// Translation resolves to "No upcoming activities"
|
||||
expect(screen.getByText(/no upcoming/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Place 1')).toBeNull();
|
||||
});
|
||||
expect(screen.getByText(/no upcoming/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Place 1')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-001b: empty state element is rendered', () => {
|
||||
seedStore(useTripStore, { days: [], assignments: {} });
|
||||
render(<WhatsNextWidget />);
|
||||
seedStore(useTripStore, { days: [], assignments: {} })
|
||||
render(<WhatsNextWidget />)
|
||||
// collab.whatsNext.empty key is rendered as text in test env
|
||||
const allText = document.body.textContent || '';
|
||||
const allText = document.body.textContent || ''
|
||||
// No assignment time/name visible — just the header and empty hint
|
||||
expect(allText).not.toContain('14:30');
|
||||
});
|
||||
expect(allText).not.toContain('14:30')
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
date: yesterday,
|
||||
title: 'Old Day',
|
||||
order: 0,
|
||||
assignments: [],
|
||||
notes_items: [],
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(10, { place_time: '08:00' })],
|
||||
},
|
||||
});
|
||||
render(<WhatsNextWidget />);
|
||||
expect(screen.queryByText('08:00')).toBeNull();
|
||||
expect(screen.queryByText('Place 10')).toBeNull();
|
||||
});
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.queryByText('08:00')).toBeNull()
|
||||
expect(screen.queryByText('Place 10')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [
|
||||
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
|
||||
],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(20, { name: 'Eiffel Tower' })],
|
||||
},
|
||||
});
|
||||
render(<WhatsNextWidget />);
|
||||
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument();
|
||||
});
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [
|
||||
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
|
||||
],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(21, { name: 'Museum' })],
|
||||
},
|
||||
});
|
||||
render(<WhatsNextWidget />);
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
// The label text comes from t('collab.whatsNext.tomorrow') which falls back to 'Tomorrow'
|
||||
expect(screen.getByText(/tomorrow/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/tomorrow/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })],
|
||||
},
|
||||
});
|
||||
render(<WhatsNextWidget />);
|
||||
expect(screen.getByText(/today/i)).toBeInTheDocument();
|
||||
});
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText(/today/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } });
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
|
||||
seedStore(useTripStore, {
|
||||
days: [
|
||||
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
|
||||
],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })],
|
||||
},
|
||||
});
|
||||
render(<WhatsNextWidget />);
|
||||
expect(screen.getByText('14:30')).toBeInTheDocument();
|
||||
});
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('14:30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '12h' } });
|
||||
seedStore(useSettingsStore, { settings: { time_format: '12h' } })
|
||||
seedStore(useTripStore, {
|
||||
days: [
|
||||
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
|
||||
],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })],
|
||||
},
|
||||
});
|
||||
render(<WhatsNextWidget />);
|
||||
expect(screen.getByText('2:30 PM')).toBeInTheDocument();
|
||||
});
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('2:30 PM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [
|
||||
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
|
||||
],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(32, { name: 'Free Time', place_time: null })],
|
||||
},
|
||||
});
|
||||
render(<WhatsNextWidget />);
|
||||
expect(screen.getByText('TBD')).toBeInTheDocument();
|
||||
});
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('TBD')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [
|
||||
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
|
||||
],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })],
|
||||
},
|
||||
});
|
||||
render(<WhatsNextWidget />);
|
||||
expect(screen.getByText('123 Rue de Rivoli')).toBeInTheDocument();
|
||||
});
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('123 Rue de Rivoli')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-010: caps list at 8 items', () => {
|
||||
const days = Array.from({ length: 5 }, (_, i) => ({
|
||||
@@ -202,110 +180,100 @@ describe('WhatsNextWidget', () => {
|
||||
trip_id: 1,
|
||||
date: getFutureDate(i + 1),
|
||||
title: null,
|
||||
order: i,
|
||||
day_number: i,
|
||||
assignments: [],
|
||||
notes_items: [],
|
||||
notes: null,
|
||||
}));
|
||||
}))
|
||||
|
||||
const assignments: Record<string, unknown[]> = {};
|
||||
let placeId = 100;
|
||||
const assignments: Record<string, unknown[]> = {}
|
||||
let placeId = 100
|
||||
for (const day of days) {
|
||||
assignments[String(day.id)] = [
|
||||
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '10:00' }),
|
||||
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '11:00' }),
|
||||
];
|
||||
]
|
||||
}
|
||||
|
||||
seedStore(useTripStore, { days, assignments });
|
||||
render(<WhatsNextWidget />);
|
||||
seedStore(useTripStore, { days, assignments })
|
||||
render(<WhatsNextWidget />)
|
||||
|
||||
// 10 items seeded, only 8 should appear — count "TBD" or time occurrences
|
||||
const timeElements = screen.getAllByText('10:00');
|
||||
const timeElements = screen.getAllByText('10:00')
|
||||
// At most 4 days * 1 morning slot = up to 4 "10:00" entries, but capped at 8 total items
|
||||
// We verify total rendered items is at most 8 by counting both time slots
|
||||
const allTimes = screen.getAllByText(/10:00|11:00/);
|
||||
expect(allTimes.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
const allTimes = screen.getAllByText(/10:00|11:00/)
|
||||
expect(allTimes.length).toBeLessThanOrEqual(8)
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [
|
||||
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
|
||||
],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])],
|
||||
},
|
||||
});
|
||||
render(<WhatsNextWidget />);
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
});
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('alice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [
|
||||
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
|
||||
],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(41, { name: 'Park' }, [])],
|
||||
},
|
||||
});
|
||||
render(<WhatsNextWidget tripMembers={[{ id: 7, username: 'bob', avatar_url: null }]} />);
|
||||
expect(screen.getByText('bob')).toBeInTheDocument();
|
||||
});
|
||||
})
|
||||
render(<WhatsNextWidget tripMembers={[{ id: 7, username: 'bob', avatar_url: null }]} />)
|
||||
expect(screen.getByText('bob')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [
|
||||
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
|
||||
],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })],
|
||||
},
|
||||
});
|
||||
render(<WhatsNextWidget />);
|
||||
expect(screen.getByText('19:00')).toBeInTheDocument();
|
||||
expect(screen.getByText('21:30')).toBeInTheDocument();
|
||||
});
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('19:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('21:30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [
|
||||
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
|
||||
],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [
|
||||
makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }),
|
||||
makeAssignment(61, { name: 'Lunch', place_time: '12:00' }),
|
||||
],
|
||||
},
|
||||
});
|
||||
render(<WhatsNextWidget />);
|
||||
const tomorrowHeaders = screen.getAllByText(/tomorrow/i);
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
const tomorrowHeaders = screen.getAllByText(/tomorrow/i)
|
||||
// Only one day header for tomorrow
|
||||
expect(tomorrowHeaders).toHaveLength(1);
|
||||
expect(screen.getByText('Breakfast')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lunch')).toBeInTheDocument();
|
||||
});
|
||||
expect(tomorrowHeaders).toHaveLength(1)
|
||||
expect(screen.getByText('Breakfast')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lunch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-015: today past-time event is excluded', () => {
|
||||
// If it's not midnight, a past-time event today should not appear
|
||||
const now = new Date();
|
||||
const now = new Date()
|
||||
if (now.getHours() > 0) {
|
||||
const pastTime = '00:01'; // Very early — will be past for most of the day
|
||||
const pastTime = '00:01' // Very early — will be past for most of the day
|
||||
seedStore(useTripStore, {
|
||||
days: [
|
||||
{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null },
|
||||
],
|
||||
days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })],
|
||||
},
|
||||
});
|
||||
render(<WhatsNextWidget />);
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
// If current time > 00:01, the item should not appear
|
||||
if (now.getHours() > 0 || now.getMinutes() > 1) {
|
||||
expect(screen.queryByText('Early Bird')).toBeNull();
|
||||
expect(screen.queryByText('Early Bird')).toBeNull()
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,66 +1,62 @@
|
||||
import { Calendar, MapPin, Sparkles } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MapPin, Clock, Calendar, Users, Sparkles } from 'lucide-react'
|
||||
|
||||
function formatTime(timeStr, is12h) {
|
||||
if (!timeStr) return '';
|
||||
const [h, m] = timeStr.split(':').map(Number);
|
||||
if (!timeStr) return ''
|
||||
const [h, m] = timeStr.split(':').map(Number)
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM';
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`;
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDayLabel(date, t, locale) {
|
||||
const now = new Date();
|
||||
const nowDate = now.toISOString().split('T')[0];
|
||||
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
|
||||
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0];
|
||||
const now = new Date()
|
||||
const nowDate = now.toISOString().split('T')[0]
|
||||
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
|
||||
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
|
||||
|
||||
if (date === nowDate) return t('collab.whatsNext.today') || 'Today';
|
||||
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow';
|
||||
if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
|
||||
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
|
||||
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
interface TripMember {
|
||||
id: number;
|
||||
username: string;
|
||||
avatar_url?: string | null;
|
||||
id: number
|
||||
username: string
|
||||
avatar?: string | null
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface WhatsNextWidgetProps {
|
||||
tripMembers?: TripMember[];
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
|
||||
export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetProps) {
|
||||
const { days, assignments } = useTripStore();
|
||||
const { t, locale } = useTranslation();
|
||||
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
|
||||
const { days, assignments } = useTripStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
const now = new Date();
|
||||
const nowDate = now.toISOString().split('T')[0];
|
||||
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
const items = [];
|
||||
const now = new Date()
|
||||
const nowDate = now.toISOString().split('T')[0]
|
||||
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
const items = []
|
||||
|
||||
for (const day of days || []) {
|
||||
if (!day.date) continue;
|
||||
const dayAssignments = assignments[String(day.id)] || [];
|
||||
for (const day of (days || [])) {
|
||||
if (!day.date) continue
|
||||
const dayAssignments = assignments[String(day.id)] || []
|
||||
for (const a of dayAssignments) {
|
||||
if (!a.place) continue;
|
||||
if (!a.place) continue
|
||||
// Include: today (future times) + all future days
|
||||
const isFutureDay = day.date > nowDate;
|
||||
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime);
|
||||
const isFutureDay = day.date > nowDate
|
||||
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime)
|
||||
if (isFutureDay || isTodayFuture) {
|
||||
items.push({
|
||||
id: a.id,
|
||||
@@ -70,47 +66,32 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
date: day.date,
|
||||
dayTitle: day.title,
|
||||
category: a.place.category,
|
||||
participants:
|
||||
a.participants && a.participants.length > 0
|
||||
? a.participants
|
||||
: tripMembers.map((m) => ({ user_id: m.id, username: m.username, avatar: m.avatar })),
|
||||
participants: (a.participants && a.participants.length > 0)
|
||||
? a.participants
|
||||
: tripMembers.map(m => ({ user_id: m.id, username: m.username, avatar: m.avatar })),
|
||||
address: a.place.address,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => {
|
||||
const da = a.date + (a.time || '99:99');
|
||||
const db = b.date + (b.time || '99:99');
|
||||
return da.localeCompare(db);
|
||||
});
|
||||
const da = a.date + (a.time || '99:99')
|
||||
const db = b.date + (b.time || '99:99')
|
||||
return da.localeCompare(db)
|
||||
})
|
||||
|
||||
return items.slice(0, 8);
|
||||
}, [days, assignments, tripMembers]);
|
||||
return items.slice(0, 8)
|
||||
}, [days, assignments, tripMembers])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0,
|
||||
}}>
|
||||
<Sparkles size={14} color="var(--text-faint)" />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: 0.3,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{t('collab.whatsNext.title') || "What's Next"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -118,104 +99,48 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
{/* List */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
|
||||
{upcoming.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
padding: '48px 20px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}>
|
||||
<Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('collab.whatsNext.empty')}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{upcoming.map((item, idx) => {
|
||||
const prevItem = upcoming[idx - 1];
|
||||
const showDayHeader = !prevItem || prevItem.date !== item.date;
|
||||
const prevItem = upcoming[idx - 1]
|
||||
const showDayHeader = !prevItem || prevItem.date !== item.date
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{showDayHeader && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.5,
|
||||
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
|
||||
}}>
|
||||
{formatDayLabel(item.date, t, locale)}
|
||||
{item.dayTitle ? ` — ${item.dayTitle}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 10,
|
||||
background: 'var(--bg-secondary)',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--bg-secondary)')}
|
||||
<div style={{
|
||||
display: 'flex', gap: 10, padding: '8px 10px', borderRadius: 10,
|
||||
background: 'var(--bg-secondary)', transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||
>
|
||||
{/* Time column */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 44,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{item.time ? formatTime(item.time, is12h) : 'TBD'}
|
||||
</span>
|
||||
{item.endTime && (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 7,
|
||||
color: 'var(--text-faint)',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.3,
|
||||
margin: '2px 0',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 7, color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
|
||||
{t('collab.whatsNext.until') || 'bis'}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{formatTime(item.endTime, is12h)}
|
||||
</span>
|
||||
</>
|
||||
@@ -223,43 +148,17 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
style={{
|
||||
width: 1,
|
||||
alignSelf: 'stretch',
|
||||
background: 'var(--border-faint)',
|
||||
flexShrink: 0,
|
||||
margin: '2px 0',
|
||||
}}
|
||||
/>
|
||||
<div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border-faint)', flexShrink: 0, margin: '2px 0' }} />
|
||||
|
||||
{/* Details */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.3,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.address && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
|
||||
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--text-faint)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.address}
|
||||
</span>
|
||||
</div>
|
||||
@@ -268,47 +167,23 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
{/* Participants */}
|
||||
{item.participants.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 5 }}>
|
||||
{item.participants.map((p) => (
|
||||
<div
|
||||
key={p.user_id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 8px 2px 3px',
|
||||
borderRadius: 99,
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-faint)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--bg-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 7,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-muted)',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{p.avatar ? (
|
||||
<img
|
||||
src={`/uploads/avatars/${p.avatar}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
p.username?.[0]?.toUpperCase()
|
||||
)}
|
||||
{item.participants.map(p => (
|
||||
<div key={p.user_id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 3px',
|
||||
borderRadius: 99, background: 'var(--bg-tertiary)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{p.avatar
|
||||
? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: p.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>
|
||||
{p.username}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -316,11 +191,11 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
export function useCollabChat(tripId: any, currentUser: any) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
|
||||
const [messages, setMessages] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [text, setText] = useState('')
|
||||
const [replyTo, setReplyTo] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [showEmoji, setShowEmoji] = useState(false)
|
||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { deleteTimersRef.current.forEach(clearTimeout) }
|
||||
}, [])
|
||||
|
||||
const containerRef = useRef(null)
|
||||
const messagesRef = useRef(messages)
|
||||
messagesRef.current = messages
|
||||
const scrollRef = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
const emojiBtnRef = useRef(null)
|
||||
const isAtBottom = useRef(true)
|
||||
|
||||
const scrollToBottom = useCallback((behavior = 'auto') => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior }))
|
||||
}, [])
|
||||
|
||||
const checkAtBottom = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
||||
}, [])
|
||||
|
||||
/* ── load messages ── */
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
collabApi.getMessages(tripId).then(data => {
|
||||
if (cancelled) return
|
||||
const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
||||
setMessages(msgs)
|
||||
setHasMore(msgs.length >= 100)
|
||||
setLoading(false)
|
||||
setTimeout(() => scrollToBottom(), 30)
|
||||
}).catch(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [tripId, scrollToBottom])
|
||||
|
||||
/* ── load more ── */
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (loadingMore || messages.length === 0) return
|
||||
setLoadingMore(true)
|
||||
const el = scrollRef.current
|
||||
const prevHeight = el ? el.scrollHeight : 0
|
||||
try {
|
||||
const data = await collabApi.getMessages(tripId, messages[0]?.id)
|
||||
const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
||||
if (older.length === 0) { setHasMore(false) }
|
||||
else {
|
||||
setMessages(prev => [...older, ...prev])
|
||||
setHasMore(older.length >= 100)
|
||||
requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight })
|
||||
}
|
||||
} catch {} finally { setLoadingMore(false) }
|
||||
}, [tripId, loadingMore, messages])
|
||||
|
||||
/* ── websocket ── */
|
||||
useEffect(() => {
|
||||
const handler = (event) => {
|
||||
if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
|
||||
setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message])
|
||||
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30)
|
||||
}
|
||||
if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
|
||||
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m))
|
||||
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
}
|
||||
if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) {
|
||||
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId, scrollToBottom])
|
||||
|
||||
/* ── auto-resize textarea ── */
|
||||
const handleTextChange = useCallback((e) => {
|
||||
setText(e.target.value)
|
||||
const ta = textareaRef.current
|
||||
if (ta) {
|
||||
ta.style.height = 'auto'
|
||||
const h = Math.min(ta.scrollHeight, 100)
|
||||
ta.style.height = h + 'px'
|
||||
ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── send ── */
|
||||
const handleSend = useCallback(async () => {
|
||||
const body = text.trim()
|
||||
if (!body || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
const payload: { text: string; reply_to?: number } = { text: body }
|
||||
if (replyTo) payload.reply_to = replyTo.id
|
||||
const data = await collabApi.sendMessage(tripId, payload)
|
||||
if (data?.message) {
|
||||
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
|
||||
}
|
||||
setText(''); setReplyTo(null); setShowEmoji(false)
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
isAtBottom.current = true
|
||||
setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
} catch { toast.error(t('common.error')) } finally { setSending(false) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||
}, [handleSend])
|
||||
|
||||
const handleDelete = useCallback(async (msgId) => {
|
||||
const msg = messages.find(m => m.id === msgId)
|
||||
requestAnimationFrame(() => {
|
||||
setDeletingIds(prev => new Set(prev).add(msgId))
|
||||
})
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
await collabApi.deleteMessage(tripId, msgId)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
|
||||
} catch { toast.error(t('common.error')) }
|
||||
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
|
||||
}, 400)
|
||||
deleteTimersRef.current.push(timer)
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleReact = useCallback(async (msgId, emoji) => {
|
||||
setReactMenu(null)
|
||||
try {
|
||||
const data = await collabApi.reactMessage(tripId, msgId, emoji)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleEmojiSelect = useCallback((emoji) => {
|
||||
setText(prev => prev + emoji)
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
|
||||
|
||||
// Check if message is only emoji (1-3 emojis, no other text)
|
||||
const isEmojiOnly = (text) => {
|
||||
const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[️]?(?:\p{Extended_Pictographic}[️]?)*){1,3}$/u
|
||||
return emojiRegex.test(text.trim())
|
||||
}
|
||||
|
||||
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
import { ArrowRightLeft, RefreshCw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import CustomSelect from '../shared/CustomSelect';
|
||||
|
||||
const CURRENCIES = [
|
||||
'AED',
|
||||
'AFN',
|
||||
'ALL',
|
||||
'AMD',
|
||||
'ANG',
|
||||
'AOA',
|
||||
'ARS',
|
||||
'AUD',
|
||||
'AWG',
|
||||
'AZN',
|
||||
'BAM',
|
||||
'BBD',
|
||||
'BDT',
|
||||
'BGN',
|
||||
'BHD',
|
||||
'BIF',
|
||||
'BMD',
|
||||
'BND',
|
||||
'BOB',
|
||||
'BRL',
|
||||
'BSD',
|
||||
'BTN',
|
||||
'BWP',
|
||||
'BYN',
|
||||
'BZD',
|
||||
'CAD',
|
||||
'CDF',
|
||||
'CHF',
|
||||
'CLF',
|
||||
'CLP',
|
||||
'CNH',
|
||||
'CNY',
|
||||
'COP',
|
||||
'CRC',
|
||||
'CUP',
|
||||
'CVE',
|
||||
'CZK',
|
||||
'DJF',
|
||||
'DKK',
|
||||
'DOP',
|
||||
'DZD',
|
||||
'EGP',
|
||||
'ERN',
|
||||
'ETB',
|
||||
'EUR',
|
||||
'FJD',
|
||||
'FKP',
|
||||
'FOK',
|
||||
'GBP',
|
||||
'GEL',
|
||||
'GGP',
|
||||
'GHS',
|
||||
'GIP',
|
||||
'GMD',
|
||||
'GNF',
|
||||
'GTQ',
|
||||
'GYD',
|
||||
'HKD',
|
||||
'HNL',
|
||||
'HRK',
|
||||
'HTG',
|
||||
'HUF',
|
||||
'IDR',
|
||||
'ILS',
|
||||
'IMP',
|
||||
'INR',
|
||||
'IQD',
|
||||
'ISK',
|
||||
'JEP',
|
||||
'JMD',
|
||||
'JOD',
|
||||
'JPY',
|
||||
'KES',
|
||||
'KGS',
|
||||
'KHR',
|
||||
'KID',
|
||||
'KMF',
|
||||
'KRW',
|
||||
'KWD',
|
||||
'KYD',
|
||||
'KZT',
|
||||
'LAK',
|
||||
'LBP',
|
||||
'LKR',
|
||||
'LRD',
|
||||
'LSL',
|
||||
'LYD',
|
||||
'MAD',
|
||||
'MDL',
|
||||
'MGA',
|
||||
'MKD',
|
||||
'MMK',
|
||||
'MNT',
|
||||
'MOP',
|
||||
'MRU',
|
||||
'MUR',
|
||||
'MVR',
|
||||
'MWK',
|
||||
'MXN',
|
||||
'MYR',
|
||||
'MZN',
|
||||
'NAD',
|
||||
'NGN',
|
||||
'NIO',
|
||||
'NOK',
|
||||
'NPR',
|
||||
'NZD',
|
||||
'OMR',
|
||||
'PAB',
|
||||
'PEN',
|
||||
'PGK',
|
||||
'PHP',
|
||||
'PKR',
|
||||
'PLN',
|
||||
'PYG',
|
||||
'QAR',
|
||||
'RON',
|
||||
'RSD',
|
||||
'RUB',
|
||||
'RWF',
|
||||
'SAR',
|
||||
'SBD',
|
||||
'SCR',
|
||||
'SDG',
|
||||
'SEK',
|
||||
'SGD',
|
||||
'SHP',
|
||||
'SLE',
|
||||
'SOS',
|
||||
'SRD',
|
||||
'SSP',
|
||||
'STN',
|
||||
'SYP',
|
||||
'SZL',
|
||||
'THB',
|
||||
'TJS',
|
||||
'TMT',
|
||||
'TND',
|
||||
'TOP',
|
||||
'TRY',
|
||||
'TTD',
|
||||
'TVD',
|
||||
'TWD',
|
||||
'TZS',
|
||||
'UAH',
|
||||
'UGX',
|
||||
'USD',
|
||||
'UYU',
|
||||
'UZS',
|
||||
'VES',
|
||||
'VND',
|
||||
'VUV',
|
||||
'WST',
|
||||
'XAF',
|
||||
'XCD',
|
||||
'XDR',
|
||||
'XOF',
|
||||
'XPF',
|
||||
'YER',
|
||||
'ZAR',
|
||||
'ZMW',
|
||||
'ZWL',
|
||||
];
|
||||
|
||||
const CURRENCY_OPTIONS = CURRENCIES.map((c) => ({ value: c, label: c }));
|
||||
|
||||
export default function CurrencyWidget() {
|
||||
const { t, locale } = useTranslation();
|
||||
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR');
|
||||
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD');
|
||||
const [amount, setAmount] = useState('100');
|
||||
const [rate, setRate] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchRate = useCallback(async () => {
|
||||
if (from === to) {
|
||||
setRate(1);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`);
|
||||
const data = await resp.json();
|
||||
setRate(data.rates?.[to] || null);
|
||||
} catch {
|
||||
setRate(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [from, to]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRate();
|
||||
}, [fetchRate]);
|
||||
useEffect(() => {
|
||||
localStorage.setItem('currency_from', from);
|
||||
}, [from]);
|
||||
useEffect(() => {
|
||||
localStorage.setItem('currency_to', to);
|
||||
}, [to]);
|
||||
|
||||
const swap = () => {
|
||||
setFrom(to);
|
||||
setTo(from);
|
||||
};
|
||||
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null;
|
||||
const formatNumber = (num) => {
|
||||
if (!num || num === '—') return '—';
|
||||
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
const result = rawResult;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-4"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('dashboard.currency')}
|
||||
</span>
|
||||
<button onClick={fetchRate} className="rounded-md p-1 transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div
|
||||
className="mb-3 rounded-xl px-4 py-3"
|
||||
style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* From / Swap / To */}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
||||
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
|
||||
</div>
|
||||
<button
|
||||
onClick={swap}
|
||||
className="shrink-0 rounded-lg p-1.5 transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<ArrowRightLeft size={13} />
|
||||
</button>
|
||||
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
||||
<CustomSelect value={to} onChange={setTo} options={CURRENCY_OPTIONS} searchable size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
|
||||
{formatNumber(result)}{' '}
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>
|
||||
{to}
|
||||
</span>
|
||||
</p>
|
||||
{rate && (
|
||||
<p className="mt-0.5 text-[10px]" style={{ color: 'var(--text-faint)' }}>
|
||||
1 {from} = {rate.toFixed(4)} {to}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import TimezoneWidget from './TimezoneWidget';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any);
|
||||
});
|
||||
|
||||
describe('TimezoneWidget', () => {
|
||||
it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => {
|
||||
render(<TimezoneWidget />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
expect(screen.getByText('New York')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TIMEZONE-002: shows local time text', () => {
|
||||
render(<TimezoneWidget />);
|
||||
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/);
|
||||
expect(timeElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
|
||||
render(<TimezoneWidget />);
|
||||
expect(screen.getByText(/timezones/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
|
||||
localStorage.clear();
|
||||
render(<TimezoneWidget />);
|
||||
expect(screen.getByText('New York')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => {
|
||||
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]));
|
||||
render(<TimezoneWidget />);
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument();
|
||||
expect(screen.queryByText('New York')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimezoneWidget />);
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
await user.click(allButtons[0]);
|
||||
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimezoneWidget />);
|
||||
// Open add panel
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
await user.click(allButtons[0]);
|
||||
// Find and click Berlin in the popular zones list
|
||||
const berlinButton = await screen.findByRole('button', { name: /Berlin/i });
|
||||
await user.click(berlinButton);
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument();
|
||||
// Panel should be closed
|
||||
expect(screen.queryByText('Custom Timezone')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimezoneWidget />);
|
||||
// Open add panel
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
await user.click(allButtons[0]);
|
||||
// Type label and timezone
|
||||
const labelInput = screen.getByPlaceholderText('Label (optional)');
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York');
|
||||
await user.type(labelInput, 'My City');
|
||||
await user.type(tzInput, 'Europe/Paris');
|
||||
// Click Add
|
||||
const addButton = screen.getByRole('button', { name: 'Add' });
|
||||
await user.click(addButton);
|
||||
expect(await screen.findByText('My City')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimezoneWidget />);
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
await user.click(allButtons[0]);
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York');
|
||||
await user.type(tzInput, 'Invalid/Timezone');
|
||||
const addButton = screen.getByRole('button', { name: 'Add' });
|
||||
await user.click(addButton);
|
||||
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimezoneWidget />);
|
||||
// Default zones include New York (America/New_York)
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
await user.click(allButtons[0]);
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York');
|
||||
await user.type(tzInput, 'America/New_York');
|
||||
const addButton = screen.getByRole('button', { name: 'Add' });
|
||||
await user.click(addButton);
|
||||
expect(await screen.findByText(/already added/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimezoneWidget />);
|
||||
expect(screen.getByText('New York')).toBeInTheDocument();
|
||||
// The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM)
|
||||
// There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total
|
||||
// Remove buttons for New York and Tokyo come after the Plus button
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
// allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo
|
||||
await user.click(allButtons[1]);
|
||||
expect(screen.queryByText('New York')).toBeNull();
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimezoneWidget />);
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
await user.click(allButtons[0]);
|
||||
const berlinButton = await screen.findByRole('button', { name: /Berlin/i });
|
||||
await user.click(berlinButton);
|
||||
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]');
|
||||
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimezoneWidget />);
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
await user.click(allButtons[0]);
|
||||
const labelInput = screen.getByPlaceholderText('Label (optional)');
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York');
|
||||
await user.type(labelInput, 'Singapore');
|
||||
await user.type(tzInput, 'Asia/Singapore');
|
||||
await user.keyboard('{Enter}');
|
||||
expect(await screen.findByText('Singapore')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,246 +0,0 @@
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
const POPULAR_ZONES = [
|
||||
{ label: 'New York', tz: 'America/New_York' },
|
||||
{ label: 'London', tz: 'Europe/London' },
|
||||
{ label: 'Berlin', tz: 'Europe/Berlin' },
|
||||
{ label: 'Paris', tz: 'Europe/Paris' },
|
||||
{ label: 'Dubai', tz: 'Asia/Dubai' },
|
||||
{ label: 'Mumbai', tz: 'Asia/Kolkata' },
|
||||
{ label: 'Bangkok', tz: 'Asia/Bangkok' },
|
||||
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
||||
{ label: 'Sydney', tz: 'Australia/Sydney' },
|
||||
{ label: 'Los Angeles', tz: 'America/Los_Angeles' },
|
||||
{ label: 'Chicago', tz: 'America/Chicago' },
|
||||
{ label: 'São Paulo', tz: 'America/Sao_Paulo' },
|
||||
{ label: 'Istanbul', tz: 'Europe/Istanbul' },
|
||||
{ label: 'Singapore', tz: 'Asia/Singapore' },
|
||||
{ label: 'Hong Kong', tz: 'Asia/Hong_Kong' },
|
||||
{ label: 'Seoul', tz: 'Asia/Seoul' },
|
||||
{ label: 'Moscow', tz: 'Europe/Moscow' },
|
||||
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
||||
];
|
||||
|
||||
function getTime(tz, locale, is12h) {
|
||||
try {
|
||||
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h });
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
function getOffset(tz) {
|
||||
try {
|
||||
const now = new Date();
|
||||
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }));
|
||||
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }));
|
||||
const diff = (remote - local) / 3600000;
|
||||
const sign = diff >= 0 ? '+' : '';
|
||||
return `${sign}${diff}h`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export default function TimezoneWidget() {
|
||||
const { t, locale } = useTranslation();
|
||||
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
|
||||
const [zones, setZones] = useState(() => {
|
||||
const saved = localStorage.getItem('dashboard_timezones');
|
||||
return saved
|
||||
? JSON.parse(saved)
|
||||
: [
|
||||
{ label: 'New York', tz: 'America/New_York' },
|
||||
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
||||
];
|
||||
});
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [customLabel, setCustomLabel] = useState('');
|
||||
const [customTz, setCustomTz] = useState('');
|
||||
const [customError, setCustomError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const i = setInterval(() => setNow(Date.now()), 10000);
|
||||
return () => clearInterval(i);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('dashboard_timezones', JSON.stringify(zones));
|
||||
}, [zones]);
|
||||
|
||||
const isValidTz = (tz: string) => {
|
||||
try {
|
||||
Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const addCustomZone = () => {
|
||||
const tz = customTz.trim();
|
||||
if (!tz) {
|
||||
setCustomError(t('dashboard.timezoneCustomErrorEmpty'));
|
||||
return;
|
||||
}
|
||||
if (!isValidTz(tz)) {
|
||||
setCustomError(t('dashboard.timezoneCustomErrorInvalid'));
|
||||
return;
|
||||
}
|
||||
if (zones.find((z) => z.tz === tz)) {
|
||||
setCustomError(t('dashboard.timezoneCustomErrorDuplicate'));
|
||||
return;
|
||||
}
|
||||
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz;
|
||||
setZones([...zones, { label, tz }]);
|
||||
setCustomLabel('');
|
||||
setCustomTz('');
|
||||
setCustomError('');
|
||||
setShowAdd(false);
|
||||
};
|
||||
|
||||
const addZone = (zone) => {
|
||||
if (!zones.find((z) => z.tz === zone.tz)) {
|
||||
setZones([...zones, zone]);
|
||||
}
|
||||
setShowAdd(false);
|
||||
};
|
||||
|
||||
const removeZone = (tz) => setZones(zones.filter((z) => z.tz !== tz));
|
||||
|
||||
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h });
|
||||
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const localZone = rawZone.split('/').pop().replace(/_/g, ' ');
|
||||
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
||||
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-4"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('dashboard.timezone')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
className="rounded-md p-1 transition-colors"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Local time */}
|
||||
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
|
||||
{localTime}
|
||||
</p>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>
|
||||
{localZone} ({tzAbbr}) · {t('dashboard.localTime')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Zone list */}
|
||||
<div className="space-y-2">
|
||||
{zones.map((z) => (
|
||||
<div key={z.tz} className="group flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>
|
||||
{getTime(z.tz, locale, is12h)}
|
||||
</p>
|
||||
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>
|
||||
{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeZone(z.tz)}
|
||||
className="rounded p-1 opacity-0 transition-all group-hover:opacity-100"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add zone dropdown */}
|
||||
{showAdd && (
|
||||
<div className="mt-2 max-h-[280px] overflow-auto rounded-xl p-2" style={{ background: 'var(--bg-secondary)' }}>
|
||||
{/* Custom timezone */}
|
||||
<div className="mb-2 rounded-lg px-2 py-2" style={{ background: 'var(--bg-card)' }}>
|
||||
<p
|
||||
className="mb-2 text-[10px] font-semibold uppercase tracking-wide"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
>
|
||||
{t('dashboard.timezoneCustomTitle')}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<input
|
||||
value={customLabel}
|
||||
onChange={(e) => setCustomLabel(e.target.value)}
|
||||
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
|
||||
className="w-full rounded-lg px-2 py-1.5 text-xs outline-none"
|
||||
style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border-secondary)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
value={customTz}
|
||||
onChange={(e) => {
|
||||
setCustomTz(e.target.value);
|
||||
setCustomError('');
|
||||
}}
|
||||
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
|
||||
className="w-full rounded-lg px-2 py-1.5 text-xs outline-none"
|
||||
style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
color: 'var(--text-primary)',
|
||||
border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}`,
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') addCustomZone();
|
||||
}}
|
||||
/>
|
||||
{customError && (
|
||||
<p className="text-[10px]" style={{ color: '#ef4444' }}>
|
||||
{customError}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={addCustomZone}
|
||||
className="w-full rounded-lg py-1.5 text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}
|
||||
>
|
||||
{t('dashboard.timezoneCustomAdd')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Popular zones */}
|
||||
{POPULAR_ZONES.filter((z) => !zones.find((existing) => existing.tz === z.tz)).map((z) => (
|
||||
<button
|
||||
key={z.tz}
|
||||
onClick={() => addZone(z)}
|
||||
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left text-xs transition-colors"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span className="font-medium">{z.label}</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>
|
||||
{getTime(z.tz, locale, is12h)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
||||
@@ -0,0 +1,44 @@
|
||||
import { FileText, FileImage, File, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||
import { downloadFile } from '../../utils/fileDownload'
|
||||
|
||||
export function isImage(mimeType?: string | null) {
|
||||
if (!mimeType) return false
|
||||
return mimeType.startsWith('image/')
|
||||
}
|
||||
|
||||
export function getFileIcon(mimeType?: string | null) {
|
||||
if (!mimeType) return File
|
||||
if (mimeType === 'application/pdf') return FileText
|
||||
if (isImage(mimeType)) return FileImage
|
||||
return File
|
||||
}
|
||||
|
||||
export function formatSize(bytes?: number | null) {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function triggerDownload(url: string, filename: string) {
|
||||
downloadFile(url, filename).catch(() => {})
|
||||
}
|
||||
|
||||
export function formatDateWithLocale(dateStr?: string | null, locale?: string) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
export function transportIcon(type: string) {
|
||||
if (type === 'train') return Train
|
||||
if (type === 'bus') return Bus
|
||||
if (type === 'car') return Car
|
||||
if (type === 'taxi') return CarTaxiFront
|
||||
if (type === 'bicycle') return Bike
|
||||
if (type === 'cruise') return Ship
|
||||
if (type === 'ferry') return Sailboat
|
||||
if (type === 'transport_other') return Route
|
||||
return Plane
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
// FE-COMP-FILEMANAGER-001 to FE-COMP-FILEMANAGER-012
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||
import type { TripFile } from '../../types';
|
||||
import FileManager from './FileManager';
|
||||
|
||||
// Mock getAuthUrl
|
||||
@@ -36,20 +37,21 @@ vi.mock('../../api/client', async (importOriginal) => {
|
||||
|
||||
import { filesApi } from '../../api/client';
|
||||
|
||||
const buildFile = (overrides = {}) => ({
|
||||
const buildFile = (overrides: Partial<TripFile> = {}): TripFile => ({
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
filename: 'report.pdf',
|
||||
original_name: 'report.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_size: 51200,
|
||||
created_at: '2025-01-10T08:00:00Z',
|
||||
url: '/uploads/trips/1/report.pdf',
|
||||
starred: false,
|
||||
starred: 0,
|
||||
deleted_at: null,
|
||||
place_id: null,
|
||||
reservation_id: null,
|
||||
day_id: null,
|
||||
uploaded_by: 1,
|
||||
uploader_name: 'Alice',
|
||||
uploaded_by_name: 'Alice',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -81,7 +83,7 @@ beforeEach(() => {
|
||||
return HttpResponse.json({ files: [] });
|
||||
}
|
||||
return HttpResponse.json({ files: [] });
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Stub window.confirm
|
||||
@@ -144,8 +146,7 @@ describe('FileManager', () => {
|
||||
it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => {
|
||||
// filesApi.list is mocked — configure it to return trash files when called with trash=true
|
||||
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
|
||||
if (trash)
|
||||
return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||
return Promise.resolve({ files: [] });
|
||||
});
|
||||
|
||||
@@ -162,8 +163,7 @@ describe('FileManager', () => {
|
||||
|
||||
it('FE-COMP-FILEMANAGER-007: restore button calls filesApi.restore', async () => {
|
||||
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
|
||||
if (trash)
|
||||
return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||
return Promise.resolve({ files: [] });
|
||||
});
|
||||
|
||||
@@ -184,8 +184,7 @@ describe('FileManager', () => {
|
||||
|
||||
it('FE-COMP-FILEMANAGER-008: permanent delete calls filesApi.permanentDelete after confirm', async () => {
|
||||
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
|
||||
if (trash)
|
||||
return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||
return Promise.resolve({ files: [] });
|
||||
});
|
||||
|
||||
@@ -205,8 +204,7 @@ describe('FileManager', () => {
|
||||
|
||||
it('FE-COMP-FILEMANAGER-009: empty trash calls filesApi.emptyTrash', async () => {
|
||||
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
|
||||
if (trash)
|
||||
return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||
return Promise.resolve({ files: [] });
|
||||
});
|
||||
|
||||
@@ -225,7 +223,9 @@ describe('FileManager', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-010: image file click opens lightbox', async () => {
|
||||
const files = [buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' })];
|
||||
const files = [
|
||||
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
|
||||
];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -240,7 +240,9 @@ describe('FileManager', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-011: escape key closes lightbox', async () => {
|
||||
const files = [buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' })];
|
||||
const files = [
|
||||
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
|
||||
];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -320,8 +322,8 @@ describe('FileManager', () => {
|
||||
|
||||
it('FE-COMP-FILEMANAGER-018: starred filter shows only starred files', async () => {
|
||||
const files = [
|
||||
buildFile({ id: 1, original_name: 'starred.pdf', starred: true }),
|
||||
buildFile({ id: 2, original_name: 'normal.pdf', starred: false }),
|
||||
buildFile({ id: 1, original_name: 'starred.pdf', starred: 1 }),
|
||||
buildFile({ id: 2, original_name: 'normal.pdf', starred: 0 }),
|
||||
];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
@@ -382,13 +384,13 @@ describe('FileManager', () => {
|
||||
// Close via X button in the modal (second X button — first might be something else)
|
||||
const closeButtons = screen.getAllByRole('button', { name: '' });
|
||||
// Find a close button near the modal header — click the last X-like button
|
||||
const xBtn = closeButtons.find((btn) => btn.closest('[style*="z-index: 10000"]'));
|
||||
const xBtn = closeButtons.find(btn => btn.closest('[style*="z-index: 10000"]'));
|
||||
if (xBtn) await user.click(xBtn);
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-023: assign modal shows reservations list', async () => {
|
||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const reservation = buildReservation({ id: 20, name: 'Hotel Paris' });
|
||||
const reservation = buildReservation({ id: 20, title: 'Hotel Paris' });
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -418,7 +420,7 @@ describe('FileManager', () => {
|
||||
|
||||
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
|
||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
|
||||
const reservation = buildReservation({ id: 20, title: 'Train Ticket' });
|
||||
const file = buildFile({ id: 1 });
|
||||
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
@@ -436,7 +438,7 @@ describe('FileManager', () => {
|
||||
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
|
||||
const { buildPlace, buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const place = buildPlace({ id: 10, name: 'Notre Dame' });
|
||||
const reservation = buildReservation({ id: 20, name: 'Airbnb' });
|
||||
const reservation = buildReservation({ id: 20, title: 'Airbnb' });
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -489,9 +491,7 @@ describe('FileManager', () => {
|
||||
const day = buildDay({ id: 5, date: '2025-06-01', day_number: 1 });
|
||||
const assignments = { '5': [{ id: 1, day_id: 5, place_id: 10, order_index: 0, place }] };
|
||||
|
||||
render(
|
||||
<FileManager {...defaultProps} files={[buildFile()]} places={[place]} days={[day]} assignments={assignments} />
|
||||
);
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} days={[day]} assignments={assignments} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
@@ -529,7 +529,7 @@ describe('FileManager', () => {
|
||||
|
||||
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
|
||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
|
||||
const reservation = buildReservation({ id: 20, title: 'Museum Pass' });
|
||||
// File already has reservation_id set to 20
|
||||
const file = buildFile({ id: 1, reservation_id: 20 });
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user