mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-18 19:36:02 +00:00
Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abe6f12890 | |||
| d546e79500 | |||
| 80e7c2b3bb | |||
| bfc857d246 | |||
| 05269f0710 | |||
| 5152a5849f | |||
| 1fdc19712f | |||
| a83ecc4ffb | |||
| 60cbd20327 | |||
| 3686792ce8 | |||
| 2af292bd64 | |||
| 33fcbba414 | |||
| 3011cf6c2f | |||
| 7d87d87eec | |||
| 8014264b21 | |||
| baf156a36b | |||
| 59134ae38e | |||
| 6cdcc82d9c | |||
| 1e0feb93dd | |||
| f2972481fe | |||
| b9da2494d4 | |||
| fd5258129a | |||
| 56fee19d64 | |||
| 6be8a67c82 | |||
| 3198fd35eb | |||
| ef3f043e00 | |||
| 6a3e6ae083 | |||
| b8ceb081fa | |||
| 0c484959ce | |||
| f97e284592 | |||
| 0910c90e7f | |||
| de4077d461 | |||
| 68e101ed37 | |||
| 25b8d28574 | |||
| 74cace7c8f | |||
| 4e478722c3 | |||
| 5f9da5b72f | |||
| 783332cc0e | |||
| ec8509f2de | |||
| 37ef3a9d18 | |||
| a54503f26d | |||
| 4abb38b517 | |||
| 743b724cbc | |||
| 04f2ec72c6 | |||
| 7673aa52f2 | |||
| e56a901d82 | |||
| 641711322e | |||
| fac2393388 | |||
| 7eac5a5a02 | |||
| d6ed7a60a0 | |||
| ad64df42ed | |||
| 4af35b162e | |||
| 20c1858b23 | |||
| e986c9ab27 | |||
| 61ffdb553e | |||
| 1abc9b2bc7 | |||
| 8713443665 | |||
| c92c02e1b8 | |||
| 993d9bf713 | |||
| c7e4b2781b | |||
| a88cd772cf | |||
| 98d11d4267 | |||
| 6707dac4a9 | |||
| c552472b63 | |||
| 5fd66f4833 | |||
| 50609b078a | |||
| 42b45dcd82 | |||
| 9dd9057b7b | |||
| 23987c76bb | |||
| 7173e82fe8 | |||
| 72dfa2c60c | |||
| d19305bda4 | |||
| 7aa2f6e4f2 | |||
| 3e64cb86a6 | |||
| e4efcf0840 | |||
| e34f40b686 | |||
| 3701ab6cad | |||
| e91f592f22 | |||
| 1cc69fc22a | |||
| 4d131db9af | |||
| f5d03e7213 | |||
| 891171ce6c | |||
| 720edce2ee | |||
| b27793f99a | |||
| 813db0ca6e | |||
| 741639edf0 | |||
| bb8f4d4e5e | |||
| fac043c691 | |||
| a3f395e5ac | |||
| b6a414b79f | |||
| 200108b76a | |||
| a7334a9060 | |||
| 2cda779bc5 | |||
| 4742915389 | |||
| d6bba454e0 | |||
| 6f42e84183 | |||
| cb3f9f0021 | |||
| f24d44b4a3 | |||
| af90ba0911 | |||
| 8c941b52f9 | |||
| c7e8a5614d | |||
| c10b9cc202 | |||
| d1e024277f | |||
| 172cff57a2 | |||
| 0d6737726d | |||
| 6996a67670 | |||
| 84adc28684 | |||
| f206fa6dff | |||
| c3b3c278b8 | |||
| d09a62fcc8 | |||
| f4b2143a59 | |||
| 33f554b1bf | |||
| fc1f29bb29 | |||
| 01e5859564 | |||
| 6a70f4fc41 | |||
| 27fbc241e8 | |||
| 574c54c16c | |||
| 0cb0567d28 | |||
| 76447f4a73 | |||
| 55ff5c03dd | |||
| 3277965426 | |||
| d95d26e493 | |||
| 4abe96fe01 | |||
| 7bac753ff3 | |||
| 743397994e | |||
| 459426ed43 | |||
| b3fa87bdd6 | |||
| 519dc3b0d8 | |||
| c1d61c98f0 | |||
| c7f5694f63 | |||
| d0b4052c5d | |||
| 1c81e8b959 | |||
| 8f1c99a07a | |||
| 5fdd4aa153 | |||
| 22801938b5 | |||
| 8640100312 | |||
| e666313865 | |||
| aa72d527c9 | |||
| 684ac3b442 | |||
| f049229e25 | |||
| 38565c3c6d | |||
| a1cbc11169 | |||
| b859ae8b00 | |||
| ae14a6c860 | |||
| 41c541828f | |||
| 37f1fff367 | |||
| 0c1c534435 |
@@ -11,7 +11,6 @@ on:
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||
- 'plugin-sdk/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
name: Publish plugin-sdk to npm
|
||||
|
||||
# Publishes trek-plugin-sdk when a tag like `plugin-sdk-v1.2.0` is pushed.
|
||||
# One-time setup: add an npm automation token as the repo secret NPM_TOKEN.
|
||||
# The package's prepublishOnly hook builds + tests before publishing.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'plugin-sdk-v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: plugin-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
# 22 matches the TREK server runtime and has node:sqlite, which the
|
||||
# dev-server tests exercise.
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- run: npm ci
|
||||
- run: npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -1,136 +0,0 @@
|
||||
# Contributor Covenant 3.0 Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We pledge to make our community welcoming, safe, and equitable for all.
|
||||
|
||||
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all
|
||||
individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics,
|
||||
neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or
|
||||
religion, national or social origin, socio-economic position, level of education, or other status. The same privileges
|
||||
of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
|
||||
|
||||
## Encouraged Behaviors
|
||||
|
||||
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive
|
||||
behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture,
|
||||
background, or native language.
|
||||
|
||||
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared
|
||||
values, including:
|
||||
|
||||
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
|
||||
2. Engaging **kindly and honestly** with others.
|
||||
3. Respecting **different viewpoints** and experiences.
|
||||
4. **Taking responsibility** for our actions and contributions.
|
||||
5. Gracefully giving and accepting **constructive feedback**.
|
||||
6. Committing to **repairing harm** when it occurs.
|
||||
7. Behaving in other ways that promote and sustain the **well-being of our community**.
|
||||
|
||||
## Restricted Behaviors
|
||||
|
||||
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are
|
||||
violations of this Code of Conduct.
|
||||
|
||||
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any
|
||||
clear request to stop.
|
||||
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of
|
||||
people.
|
||||
3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable
|
||||
identities or traits.
|
||||
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or
|
||||
purpose of the community.
|
||||
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their
|
||||
permission.
|
||||
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
|
||||
7. Behaving in other ways that **threaten the well-being** of our community.
|
||||
|
||||
### Other Restrictions
|
||||
|
||||
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade
|
||||
enforcement actions.
|
||||
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
|
||||
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the
|
||||
community.
|
||||
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other
|
||||
restricted behaviors.
|
||||
|
||||
## Reporting an Issue
|
||||
|
||||
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict
|
||||
represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help
|
||||
avoid conflicts and minimize harm.
|
||||
|
||||
When an incident does occur, it is important to report it promptly. To report a possible violation, **send an email to
|
||||
report@liketrek.com**.
|
||||
|
||||
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They
|
||||
will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing
|
||||
witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as
|
||||
possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried
|
||||
out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon
|
||||
resolution.
|
||||
|
||||
## Addressing and Repairing Harm
|
||||
|
||||
****
|
||||
|
||||
If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following
|
||||
enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals
|
||||
involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be
|
||||
skipped.
|
||||
|
||||
1) Warning
|
||||
1) Event: A violation involving a single incident or series of incidents.
|
||||
2) Consequence: A private, written warning from the Community Moderators.
|
||||
3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking
|
||||
clarification on expectations.
|
||||
2) Temporarily Limited Activities
|
||||
1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a
|
||||
more serious violation.
|
||||
2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the
|
||||
seriousness of the situation and give the community members involved time to process the incident. The cooldown
|
||||
period may be limited to particular communication channels or interactions with particular community members.
|
||||
3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and
|
||||
impact, and being thoughtful about re-entering community spaces after the period is over.
|
||||
3) Temporary Suspension
|
||||
1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a
|
||||
single serious violation.
|
||||
2) Consequence: A private written warning with conditions for return from suspension. In general, temporary
|
||||
suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
|
||||
3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for
|
||||
return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
|
||||
4) Permanent Ban
|
||||
1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or
|
||||
a violation so serious that the Community Moderators determine there is no way to keep the community safe with
|
||||
this person as a member.
|
||||
2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent
|
||||
bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working
|
||||
through other remedies has failed to change the behavior.
|
||||
3) Repair: There is no possible repair in cases of this severity.
|
||||
|
||||
This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their
|
||||
discretion and judgment, in keeping with the best interests of our community.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing
|
||||
the community in public or other spaces. Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available
|
||||
at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
|
||||
|
||||
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy
|
||||
of this license,
|
||||
visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
|
||||
|
||||
For answers to common questions about Contributor Covenant, see the FAQ
|
||||
at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided
|
||||
at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional
|
||||
enforcement and community guideline resources can be found
|
||||
at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement
|
||||
ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion).
|
||||
|
||||
@@ -49,12 +49,12 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<div align="center">
|
||||
<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 · day plan & route" 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="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/collections.png"><img src="docs/screenshots/collections.png" alt="Collections · saved place lists" 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>
|
||||
|
||||
@@ -428,7 +428,6 @@ Caddy handles TLS and WebSockets automatically.
|
||||
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
|
||||
| **Other** | | |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.2.1
|
||||
version: 3.1.3
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.2.1"
|
||||
appVersion: "3.1.3"
|
||||
|
||||
@@ -63,12 +63,6 @@ spec:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: OIDC_CLIENT_SECRET
|
||||
optional: true
|
||||
- name: UNSPLASH_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: UNSPLASH_ACCESS_KEY
|
||||
optional: true
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
|
||||
@@ -17,9 +17,6 @@ data:
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||
@@ -47,7 +44,4 @@ stringData:
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -92,12 +92,6 @@ secretEnv:
|
||||
ADMIN_PASSWORD: ""
|
||||
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
|
||||
OIDC_CLIENT_SECRET: ""
|
||||
# Optional Unsplash Access Key for trip-cover and place-image search.
|
||||
# Without one, TREK uses Unsplash's unauthenticated endpoint, which some
|
||||
# datacenter/VPS IPs (including many Kubernetes clusters) are blocked from.
|
||||
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
|
||||
# in Admin > Settings; this value overrides that. Leave empty to disable.
|
||||
UNSPLASH_ACCESS_KEY: ""
|
||||
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||
generateEncryptionKey: false
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.2.1",
|
||||
"version": "3.1.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -52,7 +52,6 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { applyAppearance } from './theme/applyAppearance'
|
||||
import { useAddonStore } from './store/addonStore'
|
||||
import { usePluginStore } from './store/pluginStore'
|
||||
import PluginPage from './pages/PluginPage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
@@ -113,7 +111,6 @@ 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 { loadPlugins } = usePluginStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||
@@ -171,7 +168,6 @@ export default function App() {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
loadAddons()
|
||||
loadPlugins()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
@@ -288,14 +284,6 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/plugins/:pluginId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PluginPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/vacay"
|
||||
element={
|
||||
|
||||
@@ -460,19 +460,6 @@ export const adminApi = {
|
||||
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
|
||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
|
||||
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
|
||||
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
||||
pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) =>
|
||||
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
|
||||
pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data),
|
||||
pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data),
|
||||
pluginUpload: (file: File) => { const fd = new FormData(); fd.append('file', file); return apiClient.post('/admin/plugins/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) },
|
||||
pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data),
|
||||
pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data),
|
||||
// Local LLM (Ollama) management for the AI-parsing addon.
|
||||
llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> =>
|
||||
apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data),
|
||||
@@ -555,39 +542,6 @@ export const addonsApi = {
|
||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||
}
|
||||
|
||||
export const pluginsApi = {
|
||||
// Active plugins the client renders (page nav entries, dashboard widgets).
|
||||
active: () => apiClient.get('/plugins').then(r => r.data),
|
||||
// Extra place info contributed by placeDetailProvider plugins (#1429). Fail-safe:
|
||||
// the server skips any slow/failing provider, so this only ever adds rows.
|
||||
placeDetails: (placeId: number) =>
|
||||
apiClient.get(`/place-details/${placeId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }),
|
||||
// Validation/warning contributions from warningProvider plugins (#1429). Fail-safe.
|
||||
tripWarnings: (tripId: number) =>
|
||||
apiClient.get(`/trip-warnings/${tripId}`).then(r => r.data as { warnings: Array<{ pluginId: string; level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number }> }),
|
||||
// Call one of a plugin's own declared routes through the host proxy. `sub` is
|
||||
// supplied by untrusted plugin code (the trekBridge forwards it verbatim), so it
|
||||
// MUST stay inside the plugin's own /plugins/:id/ namespace. We resolve it with
|
||||
// the URL parser — which normalizes `../`, encoded traversal and backslashes the
|
||||
// same way the browser would before sending — and reject anything that escapes
|
||||
// the prefix or points off-origin. Without this a plugin could send
|
||||
// sub='/../../auth/me' and drive arbitrary authenticated /api routes as the user.
|
||||
invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => {
|
||||
const prefix = `/api/plugins/${id}/`
|
||||
let resolved: URL
|
||||
try {
|
||||
resolved = new URL(String(sub).replace(/^\/+/, ''), window.location.origin + prefix)
|
||||
} catch {
|
||||
return Promise.reject(new Error('invalid plugin route'))
|
||||
}
|
||||
if (resolved.origin !== window.location.origin || !resolved.pathname.startsWith(prefix)) {
|
||||
return Promise.reject(new Error('plugin route escapes its namespace'))
|
||||
}
|
||||
const url = resolved.pathname.slice('/api'.length) + resolved.search
|
||||
return apiClient.request({ url, method: init?.method || 'GET', data: init?.body }).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; writeEnabled?: boolean }) =>
|
||||
@@ -702,8 +656,8 @@ export const budgetApi = {
|
||||
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, 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; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).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),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, 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 } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||
@@ -859,14 +813,6 @@ export const shareApi = {
|
||||
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
|
||||
export const transitApi = {
|
||||
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
|
||||
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
|
||||
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
|
||||
apiClient.get('/transit/plan', { params }).then(r => r.data),
|
||||
}
|
||||
|
||||
// Trip invite links (#1143) — join a trip as an existing, logged-in user.
|
||||
export const tripInviteApi = {
|
||||
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/invite-link`).then(r => r.data),
|
||||
|
||||
@@ -18,9 +18,6 @@ import type {
|
||||
CollectionStatus,
|
||||
Collection,
|
||||
CollectionPlace,
|
||||
CollectionLabel,
|
||||
CollectionLabelCreateRequest,
|
||||
CollectionLabelUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
|
||||
const ax = apiClient
|
||||
@@ -98,15 +95,4 @@ export const collectionsApi = {
|
||||
ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data),
|
||||
availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> =>
|
||||
ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data),
|
||||
|
||||
createLabel: (collectionId: number, name: string, color?: string): Promise<CollectionLabel> =>
|
||||
ax.post(`${base}/labels`, { collection_id: collectionId, name, color } satisfies CollectionLabelCreateRequest).then((r: AxiosResponse) => r.data),
|
||||
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest): Promise<CollectionLabel> =>
|
||||
ax.patch(`${base}/labels/${labelId}`, body satisfies CollectionLabelUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
deleteLabel: (labelId: number): Promise<unknown> =>
|
||||
ax.delete(`${base}/labels/${labelId}`).then((r: AxiosResponse) => r.data),
|
||||
assignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
|
||||
ax.post(`${base}/labels/assign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
|
||||
unassignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
|
||||
ax.post(`${base}/labels/unassign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,8 @@ 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', 'KGS',
|
||||
'BDT', 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
|
||||
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
]
|
||||
|
||||
export const SYMBOLS: Record<string, string> = {
|
||||
@@ -13,8 +13,8 @@ export const SYMBOLS: Record<string, string> = {
|
||||
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: '₴', KGS: 'сом', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$',
|
||||
COP: 'CO$', PEN: 'S/.', ARS: 'AR$',
|
||||
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']
|
||||
|
||||
@@ -4,7 +4,6 @@ import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
|
||||
import CostsPanel from './CostsPanel'
|
||||
@@ -165,28 +164,6 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sums only unfinished expenses in the Outstanding amount card', async () => {
|
||||
// Display in the trip's own currency so FX conversion is an identity — keeps the asserted sum deterministic.
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
const paid = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 60, payers: [{ user_id: 1, amount: 60, username: 'alice' }], members: [{ user_id: 1, username: 'alice', paid: 1 }] }
|
||||
const unfinishedA = { ...buildBudgetItem({ trip_id: 1, category: 'lodging', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
const unfinishedB = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 30, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
const zero = { ...buildBudgetItem({ trip_id: 1, category: 'misc', name: 'Freebie' }), total_price: 0, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, unfinishedA, unfinishedB, zero] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
// Footer only shows the count once unfinished expenses have loaded.
|
||||
const foot = await screen.findByText('expenses need a payer')
|
||||
expect(foot).toHaveTextContent('2 expenses need a payer') // the two payer-less, non-zero expenses
|
||||
// Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded.
|
||||
// Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded.
|
||||
const card = screen.getByText('Outstanding amount').closest('div[style*="border-radius: 22"]')
|
||||
expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30
|
||||
})
|
||||
|
||||
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -131,8 +131,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [catFilter, setCatFilter] = useState('') // '' = all categories
|
||||
const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
|
||||
@@ -179,9 +177,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const myShare = shares[me] || 0
|
||||
return convert(myShare, curOf(e))
|
||||
}
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in.
|
||||
const isUnfinished = (e: BudgetItem) => baseTotal(e) > 0 && (e.payers || []).filter(p => p.amount > 0).length === 0
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||
@@ -189,9 +184,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
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)
|
||||
const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0)
|
||||
const outstandingCount = budgetItems.filter(isUnfinished).length
|
||||
return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount }
|
||||
return { totalSpend, myPaid, myShare, owe, owed }
|
||||
}, [budgetItems, settlement, me])
|
||||
|
||||
// ── filtering + day grouping ────────────────────────────────────────────
|
||||
@@ -199,27 +192,21 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
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)
|
||||
// catMeta normalises legacy/free-text categories to the fixed keys, so the
|
||||
// filter matches rows saved before the category rework too.
|
||||
if (catFilter) list = list.filter(e => catMeta(e.category).key === catFilter)
|
||||
if (dayFilter) list = list.filter(e => (e.expense_date || '') === dayFilter)
|
||||
const q = search.trim().toLowerCase()
|
||||
if (q) list = list.filter(e => e.name.toLowerCase().includes(q))
|
||||
return list
|
||||
}, [budgetItems, filter, search, catFilter, dayFilter, me])
|
||||
}, [budgetItems, filter, search, me])
|
||||
|
||||
// Settlements ("payments") shown inline in the ledger. They have no name, so a
|
||||
// text search hides them; they're excluded from the "owed" expense filter and,
|
||||
// under "mine", only show transfers I'm part of.
|
||||
const filteredSettlements = useMemo(() => {
|
||||
// Payments carry no name or category, so a text/category filter hides them.
|
||||
if (search.trim() || catFilter) return []
|
||||
if (search.trim()) return []
|
||||
if (filter === 'owed') return []
|
||||
let list = settlement?.settlements || []
|
||||
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
|
||||
if (dayFilter) list = list.filter(s => (s.created_at || '').slice(0, 10) === dayFilter)
|
||||
return list
|
||||
}, [settlement, filter, search, catFilter, dayFilter, me])
|
||||
}, [settlement, filter, search, me])
|
||||
|
||||
const dayGroups = useMemo(() => {
|
||||
const entries: LedgerEntry[] = [
|
||||
@@ -242,24 +229,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
return groups
|
||||
}, [filtered, filteredSettlements, locale, t])
|
||||
|
||||
// ── filter dropdown options (category + single day) ──────────────────────
|
||||
const categoryOptions = useMemo(() => [
|
||||
{ value: '', label: t('costs.filter.allCategories') },
|
||||
...COST_CATEGORY_LIST.map(c => ({ value: c.key, label: t(c.labelKey), icon: <c.Icon size={14} style={{ color: c.color }} /> })),
|
||||
], [t])
|
||||
|
||||
const dayOptions = useMemo(() => {
|
||||
const days = Array.from(new Set(budgetItems.map(e => e.expense_date).filter(Boolean) as string[])).sort((a, b) => b.localeCompare(a))
|
||||
const fmtDay = (d: string) => {
|
||||
try { return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return d }
|
||||
}
|
||||
return [{ value: '', label: t('costs.filter.allDays') }, ...days.map(d => ({ value: d, label: fmtDay(d) }))]
|
||||
}, [budgetItems, 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, currency: base })
|
||||
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount })
|
||||
loadSettlement()
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
@@ -270,7 +243,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
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, currency: base })
|
||||
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')) }
|
||||
}
|
||||
@@ -310,29 +283,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
return <>{parts.map((p, i) => <span key={i} style={isBig(p) ? undefined : { fontSize: smallSize, fontWeight: 500, color: mutedColor }}>{p.value}</span>)}</>
|
||||
}
|
||||
|
||||
// ── category + day filter controls (shared by both layouts) ──────────────
|
||||
const filterControls = (
|
||||
<>
|
||||
<CustomSelect value={catFilter} onChange={v => setCatFilter(String(v))} options={categoryOptions} size="sm" style={{ minWidth: 148 }} />
|
||||
<CustomSelect value={dayFilter} onChange={v => setDayFilter(String(v))} options={dayOptions} size="sm" searchable style={{ minWidth: 140 }} />
|
||||
</>
|
||||
)
|
||||
|
||||
// A prominent summary shown when a single day is selected: the day + its total.
|
||||
const dayFilterTotal = dayFilter ? filtered.reduce((a, e) => a + baseTotal(e), 0) : 0
|
||||
const dayFilterLabel = dayFilter
|
||||
? (() => { try { return new Date(dayFilter + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }) } catch { return dayFilter } })()
|
||||
: ''
|
||||
const dayBanner = dayFilter ? (
|
||||
<div className={cardCls} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, borderRadius: 16, padding: '16px 20px', marginBottom: 16 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.01em' }}>{dayFilterLabel}</div>
|
||||
<div className="text-content-muted" style={{ marginTop: 3, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('costs.expensesCount', { count: filtered.length })}</div>
|
||||
</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(26px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.02em', whiteSpace: 'nowrap' }}>{bigMoney(dayFilterTotal, 15, 'var(--text-muted)')}</div>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '40px 24px 48px' }}>
|
||||
{isMobile ? <MobileBody /> : (
|
||||
@@ -374,7 +324,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
|
||||
{/* ── Summary cards ── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 36 }} className="costs-summary">
|
||||
<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
|
||||
@@ -385,11 +335,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
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.outstanding')} sub={t('costs.outstandingSub')} amount={totals.outstanding} currency={base} locale={locale}
|
||||
icon={<AlertCircle size={18} />} tone="unfinished"
|
||||
foot={totals.outstandingCount > 0
|
||||
? <span><b>{totals.outstandingCount}</b> {t('costs.outstandingItems')}</span>
|
||||
: <span className="text-content-faint">{t('costs.allSettled')}</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>} />
|
||||
@@ -403,13 +348,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<h3 className="text-content" style={{ fontSize: 'calc(24px * var(--fs-scale-title, 1))', fontWeight: 600, letterSpacing: '-0.025em', margin: 0 }}>
|
||||
{t('costs.expenses')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<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: 'calc(13px * var(--fs-scale-body, 1))', width: 150, fontFamily: 'inherit' }} />
|
||||
</div>
|
||||
{filterControls}
|
||||
<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)}
|
||||
@@ -422,7 +366,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dayBanner}
|
||||
{dayGroups.length === 0 ? (
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
||||
@@ -431,11 +374,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
||||
return (
|
||||
<div key={g.day} style={{ marginBottom: 22 }}>
|
||||
{!dayFilter && (
|
||||
<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: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{g.entries.map(en => en.kind === 'expense'
|
||||
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
||||
@@ -485,7 +426,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
)}
|
||||
|
||||
{(editingSettlement || addingPayment) && (
|
||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} currency={base}
|
||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement}
|
||||
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
|
||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||
)}
|
||||
@@ -521,11 +462,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
.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 1fr !important; }
|
||||
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.costs-root .costs-summary { grid-template-columns: 1fr !important; }
|
||||
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
@@ -593,18 +531,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outstanding */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', background: '#d9770622', color: '#d97706', flexShrink: 0 }}><AlertCircle size={17} /></div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('costs.outstanding')}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('costs.outstandingSub')}</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', fontSize: 'calc(27px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', color: '#d97706' }}>{bigMoney(totals.outstanding, 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 }}>
|
||||
@@ -628,18 +554,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<CustomSelect value={catFilter} onChange={v => setCatFilter(String(v))} options={categoryOptions} size="sm" style={{ flex: 1, minWidth: 0 }} />
|
||||
<CustomSelect value={dayFilter} onChange={v => setDayFilter(String(v))} options={dayOptions} size="sm" searchable style={{ flex: 1, minWidth: 0 }} />
|
||||
</div>
|
||||
{dayBanner}
|
||||
{dayGroups.length === 0
|
||||
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
||||
: dayGroups.map(g => {
|
||||
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
||||
return (
|
||||
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{!dayFilter && <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: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>}
|
||||
<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: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
|
||||
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
||||
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
|
||||
@@ -670,19 +591,21 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const cur = curOf(e)
|
||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||
const unfinished = isUnfinished(e)
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in.
|
||||
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
|
||||
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={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
|
||||
<Icon size={21} />
|
||||
{isMobile && unfinished && (
|
||||
{isMobile && isUnfinished && (
|
||||
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
|
||||
)}
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{e.name}</span>
|
||||
{unfinished && !isMobile && (
|
||||
{isUnfinished && !isMobile && (
|
||||
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 800 }}>!</span>
|
||||
{t('costs.unfinished')}
|
||||
@@ -809,9 +732,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}
|
||||
|
||||
// ── 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' | 'unfinished' }) {
|
||||
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' : tone === 'unfinished' ? '#d97706' : undefined
|
||||
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.
|
||||
@@ -858,8 +781,8 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
|
||||
// Add or edit a settle-up payment (from / to / amount). Reachable inline from the
|
||||
// ledger row and from a manual "Add payment" button, so recording "I sent money to
|
||||
// X" works the same whether or not there's an outstanding expense behind it.
|
||||
function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: {
|
||||
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void
|
||||
function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
|
||||
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -876,7 +799,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency }
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt }
|
||||
try {
|
||||
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
|
||||
else await budgetApi.createSettlement(tripId, data)
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Check, Loader2, Settings2, Tags } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { CollectionLabel } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
interface BulkAssignLabelModalProps {
|
||||
isOpen: boolean
|
||||
labels: CollectionLabel[]
|
||||
/** Number of selected places the labels will be added to. */
|
||||
count: number
|
||||
onAssign: (labelIds: number[]) => Promise<void> | void
|
||||
/** Open the label manager to create labels first. */
|
||||
onManage: () => void
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one or more of the list's labels to add to every selected place. Additive
|
||||
* — it never removes labels a place already has. When the list has no labels yet,
|
||||
* it points the user at the label manager instead.
|
||||
*/
|
||||
export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign, onManage, onClose, t }: BulkAssignLabelModalProps): React.ReactElement {
|
||||
const [picked, setPicked] = useState<number[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const toggle = (id: number) => setPicked(picked.includes(id) ? picked.filter(x => x !== id) : [...picked, id])
|
||||
|
||||
const assign = async () => {
|
||||
if (picked.length === 0 || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onAssign(picked)
|
||||
setPicked([])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="sm">
|
||||
{labels.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Tags size={26} className="text-content-faint" />
|
||||
<p className="text-[13px] text-content-faint">{t('collections.labels.emptyHint')}</p>
|
||||
<button type="button" onClick={onManage} className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-edge text-[13px] text-content hover:bg-surface-hover">
|
||||
<Settings2 size={14} /> {t('collections.labels.manage')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1 max-h-[46vh] overflow-y-auto -mx-1 px-1">
|
||||
{labels.map(l => {
|
||||
const on = picked.includes(l.id)
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => toggle(l.id)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition-colors ${on ? 'border-accent bg-accent/10' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: l.color || '#6366f1' }} />
|
||||
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{l.name}</span>
|
||||
{on && <Check size={15} className="text-accent shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-edge">
|
||||
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
|
||||
<Settings2 size={14} /> {t('collections.labels.manage')}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={assign}
|
||||
disabled={picked.length === 0 || busy}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('collections.labels.assign')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -29,12 +29,6 @@ function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
|
||||
categoryOptions: CATEGORY_OPTIONS,
|
||||
onStatusFilter: vi.fn(),
|
||||
onCategoryFilter: vi.fn(),
|
||||
showLabels: false,
|
||||
labelOptions: [],
|
||||
labelFilter: [],
|
||||
onLabelFilter: vi.fn(),
|
||||
canManageLabels: false,
|
||||
onManageLabels: vi.fn(),
|
||||
showSelect: true,
|
||||
selectMode: false,
|
||||
onToggleSelect: vi.fn(),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare } from 'lucide-react'
|
||||
import { ChevronDown, Check, Layers, Tag, CheckSquare } from 'lucide-react'
|
||||
import type { StatusFilter } from '../../store/collectionStore'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CategoryOption, LabelOption } from '../../pages/collections/collectionsModel'
|
||||
import type { CategoryOption } from '../../pages/collections/collectionsModel'
|
||||
|
||||
interface Opt {
|
||||
key: string | number
|
||||
@@ -69,13 +69,6 @@ interface CollectionFilterBarProps {
|
||||
categoryOptions: CategoryOption[]
|
||||
onStatusFilter: (f: StatusFilter) => void
|
||||
onCategoryFilter: (f: number | 'all') => void
|
||||
// Per-collection labels (hidden on the "All saved" union).
|
||||
showLabels: boolean
|
||||
labelOptions: LabelOption[]
|
||||
labelFilter: number[]
|
||||
onLabelFilter: (ids: number[]) => void
|
||||
canManageLabels: boolean
|
||||
onManageLabels: () => void
|
||||
showSelect: boolean
|
||||
selectMode: boolean
|
||||
onToggleSelect: () => void
|
||||
@@ -89,7 +82,6 @@ interface CollectionFilterBarProps {
|
||||
*/
|
||||
export default function CollectionFilterBar({
|
||||
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter,
|
||||
showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels,
|
||||
showSelect, selectMode, onToggleSelect, t,
|
||||
}: CollectionFilterBarProps): React.ReactElement {
|
||||
const statusOpts: Opt[] = [
|
||||
@@ -120,33 +112,6 @@ export default function CollectionFilterBar({
|
||||
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
|
||||
</button>
|
||||
)}
|
||||
{showLabels && (labelOptions.length > 0 || canManageLabels) && (
|
||||
<div className="col-labelfilter">
|
||||
{labelOptions.map(l => {
|
||||
const on = labelFilter.includes(l.id)
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
className={`col-labelchip${on ? ' on' : ''}`}
|
||||
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
|
||||
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
|
||||
aria-pressed={on}
|
||||
>
|
||||
<span className="col-labelchip-dot" />
|
||||
<span className="col-filter-lbl">{l.name}</span>
|
||||
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{canManageLabels && (
|
||||
<button type="button" className="col-labelchip col-labelchip-manage" onClick={onManageLabels} title={t('collections.labels.manage')}>
|
||||
<Tags size={13} />
|
||||
<span className="col-filter-lbl">{labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ function renderList(over: Partial<{
|
||||
render(
|
||||
<CollectionList
|
||||
places={places}
|
||||
labels={[]}
|
||||
selectedPlaceId={over.selectedPlaceId ?? null}
|
||||
selectMode={over.selectMode ?? false}
|
||||
selectedIds={over.selectedIds ?? []}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { Check, MapPin } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
@@ -8,7 +8,6 @@ import StatusBadge from './StatusBadge'
|
||||
|
||||
interface CollectionListProps {
|
||||
places: CollectionPlace[]
|
||||
labels: CollectionLabel[]
|
||||
selectedPlaceId: number | null
|
||||
selectMode: boolean
|
||||
selectedIds: number[]
|
||||
@@ -24,9 +23,8 @@ interface CollectionListProps {
|
||||
* open the place (or toggle it in select mode).
|
||||
*/
|
||||
export default function CollectionList({
|
||||
places, labels, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
|
||||
places, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
|
||||
}: CollectionListProps): React.ReactElement {
|
||||
const labelsById = useMemo(() => new Map(labels.map(l => [l.id, l])), [labels])
|
||||
// Bring the selected row into view — e.g. when it was picked from the map.
|
||||
const selectedRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
@@ -38,7 +36,6 @@ export default function CollectionList({
|
||||
{places.map(place => {
|
||||
const selected = selectedIds.includes(place.id)
|
||||
const active = selectedPlaceId === place.id
|
||||
const placeLabels = (place.label_ids ?? []).map(id => labelsById.get(id)).filter(Boolean) as CollectionLabel[]
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
@@ -64,12 +61,6 @@ export default function CollectionList({
|
||||
)}
|
||||
</div>
|
||||
<div className="col-lrow-end">
|
||||
{placeLabels.slice(0, 2).map(l => (
|
||||
<span key={l.id} className="col-lrow-label" style={{ ['--label' as string]: l.color || 'var(--accent)' }} title={l.name}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
{placeLabels.length > 2 && <span className="col-lrow-label more" title={placeLabels.map(l => l.name).join(', ')}>+{placeLabels.length - 2}</span>}
|
||||
{place.category?.name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
|
||||
return (
|
||||
|
||||
@@ -39,7 +39,6 @@ function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
categories: [],
|
||||
labels: [],
|
||||
anchorRect: null,
|
||||
onClose: vi.fn(),
|
||||
onSetStatus: vi.fn(),
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useEffect, useRef, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLink } from '@trek/shared'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import MarkdownToolbar from '../Journey/MarkdownToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
@@ -22,13 +22,11 @@ interface CollectionPlaceDetailProps {
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
categories: Category[]
|
||||
/** The active list's custom labels, for the assign chips. */
|
||||
labels: CollectionLabel[]
|
||||
/** When set, dock the sheet over that column (desktop split) instead of centred. */
|
||||
anchorRect?: { left: number; width: number } | null
|
||||
onClose: () => void
|
||||
onSetStatus: (status: CollectionStatus) => void
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[] }) => Promise<void>
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null }) => Promise<void>
|
||||
onCopyToTrip: () => void
|
||||
onRemove: () => void
|
||||
t: TranslationFn
|
||||
@@ -58,7 +56,7 @@ function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet:
|
||||
* is an always-live segmented control (auto-saves).
|
||||
*/
|
||||
export default function CollectionPlaceDetail({
|
||||
place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
|
||||
place, canEdit, canDelete, categories, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
|
||||
}: CollectionPlaceDetailProps): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -66,7 +64,6 @@ export default function CollectionPlaceDetail({
|
||||
const [categoryId, setCategoryId] = useState<number | null>(place.category_id ?? null)
|
||||
const [description, setDescription] = useState(place.description ?? '')
|
||||
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
|
||||
const [labelIds, setLabelIds] = useState<number[]>(place.label_ids ?? [])
|
||||
const [saving, setSaving] = useState(false)
|
||||
// A higher-res photo pulled from the maps provider when the place has none of
|
||||
// its own — the list avatar's little thumbnail is too low-res for the cover.
|
||||
@@ -80,7 +77,6 @@ export default function CollectionPlaceDetail({
|
||||
setCategoryId(place.category_id ?? null)
|
||||
setDescription(place.description ?? '')
|
||||
setLinks(place.links ?? [])
|
||||
setLabelIds(place.label_ids ?? [])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [place.id])
|
||||
|
||||
@@ -100,15 +96,13 @@ export default function CollectionPlaceDetail({
|
||||
|
||||
const banner = place.image_url || fetchedPhoto
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id])
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []) }
|
||||
const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id))
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []) }
|
||||
|
||||
const save = async () => {
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds })
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId })
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
@@ -167,22 +161,6 @@ export default function CollectionPlaceDetail({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label"><Tags size={12} /> {t('collections.labels.title')}</div>
|
||||
<div className="col-detail-cats">
|
||||
{labels.map(l => {
|
||||
const on = labelIds.includes(l.id)
|
||||
return (
|
||||
<button key={l.id} type="button" onClick={() => toggleLabel(l.id)} className={`col-detail-cat${on ? ' on' : ''}`} style={{ ['--cat' as string]: l.color || '#6366f1' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Description */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label">{t('collections.description')}</div>
|
||||
@@ -206,15 +184,6 @@ export default function CollectionPlaceDetail({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{assignedLabels.length > 0 && (
|
||||
<div className="col-detail-labels">
|
||||
{assignedLabels.map(l => (
|
||||
<span key={l.id} className="col-labelchip on static" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{place.description && (
|
||||
<div className="col-detail-md collab-note-md">
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description}</Markdown>
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { CollectionLabel, CollectionLabelUpdateRequest } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
const SWATCHES = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#8b5cf6', '#64748b']
|
||||
|
||||
interface LabelManagerProps {
|
||||
isOpen: boolean
|
||||
labels: CollectionLabel[]
|
||||
onCreate: (name: string, color?: string) => Promise<void> | void
|
||||
onUpdate: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void> | void
|
||||
onDelete: (labelId: number) => Promise<void> | void
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/** Swatch row shared by the create form and each row's recolor control. */
|
||||
function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{SWATCHES.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onPick(c)}
|
||||
className={`w-5 h-5 rounded-full border transition-transform ${value.toLowerCase() === c ? 'border-content scale-110' : 'border-transparent'}`}
|
||||
style={{ background: c }}
|
||||
aria-label={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One existing label: inline rename (save on blur/Enter), recolor, delete. */
|
||||
function LabelRow({ label, onUpdate, onDelete, t }: {
|
||||
label: CollectionLabel
|
||||
onUpdate: LabelManagerProps['onUpdate']
|
||||
onDelete: LabelManagerProps['onDelete']
|
||||
t: TranslationFn
|
||||
}): React.ReactElement {
|
||||
const [name, setName] = useState(label.name)
|
||||
const [color, setColor] = useState(label.color || '#6366f1')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const commitName = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || trimmed === label.name) { setName(label.name); return }
|
||||
setBusy(true)
|
||||
try { await onUpdate(label.id, { name: trimmed }) } finally { setBusy(false) }
|
||||
}
|
||||
const pickColor = async (c: string) => {
|
||||
setColor(c)
|
||||
setBusy(true)
|
||||
try { await onUpdate(label.id, { color: c }) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2.5 py-2 rounded-xl border border-edge bg-surface-card">
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: color }} />
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onBlur={commitName}
|
||||
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||
maxLength={60}
|
||||
className="flex-1 min-w-0 bg-transparent text-[13px] text-content outline-none"
|
||||
aria-label={t('collections.labels.name')}
|
||||
/>
|
||||
<Swatches value={color} onPick={pickColor} />
|
||||
{busy && <Loader2 size={14} className="animate-spin text-content-faint shrink-0" />}
|
||||
<button type="button" onClick={() => onDelete(label.id)} className="p-1 text-content-faint hover:text-danger shrink-0" aria-label={t('common.delete')}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage a list's custom labels — create, rename, recolor and delete. Available
|
||||
* to any member who can edit the list; the labels are shared by the whole list.
|
||||
*/
|
||||
export default function LabelManager({ isOpen, labels, onCreate, onUpdate, onDelete, onClose, t }: LabelManagerProps): React.ReactElement {
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newColor, setNewColor] = useState(SWATCHES[0])
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const add = async () => {
|
||||
const trimmed = newName.trim()
|
||||
if (!trimmed || adding) return
|
||||
setAdding(true)
|
||||
try {
|
||||
await onCreate(trimmed, newColor)
|
||||
setNewName('')
|
||||
setNewColor(SWATCHES[0])
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.manage')} size="sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
{labels.length === 0 ? (
|
||||
<p className="text-center text-[13px] text-content-faint py-4">{t('collections.labels.empty')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 max-h-[46vh] overflow-y-auto -mx-1 px-1">
|
||||
{labels.map(l => <LabelRow key={l.id} label={l} onUpdate={onUpdate} onDelete={onDelete} t={t} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-3 border-t border-edge">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: newColor }} />
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') add() }}
|
||||
maxLength={60}
|
||||
placeholder={t('collections.labels.namePlaceholder')}
|
||||
className="flex-1 min-w-0 px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
disabled={!newName.trim() || adding}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{adding ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} {t('collections.labels.add')}
|
||||
</button>
|
||||
</div>
|
||||
<Swatches value={newColor} onPick={setNewColor} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
|
||||
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isWalletPass } from './FileManager.helpers';
|
||||
|
||||
describe('isWalletPass (#1447)', () => {
|
||||
it('detects by extension when the mime is unreliable', () => {
|
||||
// Browsers frequently send octet-stream / empty for .pkpass uploads
|
||||
expect(isWalletPass('application/octet-stream', 'boarding.pkpass')).toBe(true);
|
||||
expect(isWalletPass(null, 'multi.pkpasses')).toBe(true);
|
||||
expect(isWalletPass('', 'CAPS.PKPASS')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the wallet MIME types when there is no extension', () => {
|
||||
expect(isWalletPass('application/vnd.apple.pkpass', 'pass')).toBe(true);
|
||||
expect(isWalletPass('application/vnd.apple.pkpasses', null)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for non-wallet files', () => {
|
||||
expect(isWalletPass('application/pdf', 'report.pdf')).toBe(false);
|
||||
expect(isWalletPass('image/png', 'photo.png')).toBe(false);
|
||||
expect(isWalletPass('text/markdown', 'notes.md')).toBe(false);
|
||||
expect(isWalletPass(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -26,18 +26,6 @@ export function isMarkdown(mimeType?: string | null, name?: string | null) {
|
||||
return !!mimeType && (mimeType === 'text/markdown' || mimeType === 'text/x-markdown')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple Wallet pass (#1447). Detected by EXTENSION first — browsers often send an
|
||||
* empty / octet-stream MIME for .pkpass — falling back to the wallet MIME types.
|
||||
* Wallet passes must be downloaded so the OS hands them to Apple Wallet rather
|
||||
* than rendered in the in-app PDF preview.
|
||||
*/
|
||||
export function isWalletPass(mimeType?: string | null, name?: string | null) {
|
||||
const ext = (name || '').toLowerCase().split('.').pop()
|
||||
if (ext === 'pkpass' || ext === 'pkpasses') return true
|
||||
return !!mimeType && (mimeType === 'application/vnd.apple.pkpass' || mimeType === 'application/vnd.apple.pkpasses')
|
||||
}
|
||||
|
||||
export function getFileIcon(mimeType?: string | null) {
|
||||
if (!mimeType) return File
|
||||
if (mimeType === 'application/pdf') return FileText
|
||||
|
||||
@@ -15,15 +15,6 @@ vi.mock('../../api/authUrl', () => ({
|
||||
getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'),
|
||||
}));
|
||||
|
||||
// Mock the blob download/open helpers so we can assert wallet passes are
|
||||
// downloaded (#1447) rather than opened in the in-app PDF preview.
|
||||
vi.mock('../../utils/fileDownload', () => ({
|
||||
openFile: vi.fn().mockResolvedValue(undefined),
|
||||
downloadFile: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { openFile as openFileInTab } from '../../utils/fileDownload';
|
||||
|
||||
// Markdown pipeline mocked to render its children verbatim (the unified/ESM
|
||||
// pipeline is heavy in jsdom) — we only assert the markdown text reaches the modal.
|
||||
vi.mock('react-markdown', () => ({
|
||||
@@ -322,21 +313,6 @@ describe('FileManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-035: pkpass click downloads via blob helper, not the PDF preview (#1447)', async () => {
|
||||
const files = [buildFile({ id: 1, mime_type: 'application/octet-stream', original_name: 'boarding.pkpass', url: '/uploads/trips/1/boarding.pkpass' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByText('boarding.pkpass'));
|
||||
|
||||
// Blob helper is called with the file url + name — the OS hands it to Wallet
|
||||
await waitFor(() => {
|
||||
expect(openFileInTab).toHaveBeenCalledWith('/uploads/trips/1/boarding.pkpass', 'boarding.pkpass');
|
||||
});
|
||||
// No PDF preview modal — the filename appears only once (in the list row)
|
||||
expect(screen.getAllByText('boarding.pkpass').length).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => {
|
||||
const files = [buildFile({ uploaded_by_name: 'Alice Smith' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
|
||||
@@ -7,8 +7,7 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { isImage, isMedia, isWalletPass } from './FileManager.helpers'
|
||||
import { openFile as openFileInTab } from '../../utils/fileDownload'
|
||||
import { isImage, isMedia } from './FileManager.helpers'
|
||||
|
||||
export interface FileManagerProps {
|
||||
files?: TripFile[]
|
||||
@@ -192,10 +191,6 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
|
||||
if (isMedia(file.mime_type)) {
|
||||
const idx = mediaFiles.findIndex(f => f.id === file.id)
|
||||
setLightboxIndex(idx >= 0 ? idx : 0)
|
||||
} else if (isWalletPass(file.mime_type, file.original_name)) {
|
||||
// Download so the OS hands the pass to Apple Wallet (#1447) rather than
|
||||
// forcing it into the in-app PDF preview.
|
||||
openFileInTab(file.url, file.original_name).catch(() => {})
|
||||
} else {
|
||||
setPreviewFile(file)
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('BottomNav', () => {
|
||||
const user = userEvent.setup();
|
||||
sessionStorage.setItem('trip-tab-42', 'transports');
|
||||
render(<BottomNav />, { initialEntries: ['/trips/42'] });
|
||||
await user.click(screen.getByRole('button', { name: 'Transport' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Manual Transport' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=transport');
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark, Blocks } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
|
||||
@@ -53,7 +52,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
|
||||
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
|
||||
const pagePlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'page')
|
||||
|
||||
useEffect(() => {
|
||||
if (user) loadAddons()
|
||||
@@ -137,7 +135,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
{/* Centred liquid-glass tab menu (design handoff). Absolutely positioned so
|
||||
the left brand block and the right action cluster keep their layout. */}
|
||||
{(globalAddons.length > 0 || pagePlugins.length > 0) && !tripTitle && (
|
||||
{globalAddons.length > 0 && !tripTitle && (
|
||||
<div
|
||||
className="trek-nav-pill"
|
||||
style={{
|
||||
@@ -149,8 +147,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
}}
|
||||
>
|
||||
{[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
|
||||
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
|
||||
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: Blocks }))
|
||||
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays }))
|
||||
].map(tab => {
|
||||
const isActive = location.pathname === tab.path
|
||||
return (
|
||||
|
||||
@@ -29,9 +29,7 @@ vi.mock('react-leaflet', () => ({
|
||||
>
|
||||
<button
|
||||
data-testid="marker-hover-trigger"
|
||||
// A real mouseover never bubbles as a click to the marker, so the
|
||||
// hover-simulation must not trigger the marker's click handler.
|
||||
onClick={(e: any) => { e.stopPropagation(); eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } }) }}
|
||||
onClick={() => eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } })}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
@@ -247,44 +245,6 @@ describe('MapView', () => {
|
||||
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-021: clicking a marker clears the hover tooltip (#1404)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [buildMapPlace({ id: 3, name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
|
||||
render(<MapView places={places} onMarkerClick={vi.fn()} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
||||
// The recenter that follows the click moves the marker out from under the
|
||||
// cursor — no mouseout will ever fire, so the click itself must clear.
|
||||
fireEvent.click(screen.getByTestId('marker'))
|
||||
expect(screen.queryByTestId('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-022: camera movement clears the tooltip and suppresses re-show until it ends (#1404)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [buildMapPlace({ id: 4, name: 'Louvre', lat: 48.86, lng: 2.337 })]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
||||
|
||||
const findHandler = (event: string) =>
|
||||
mapMock.on.mock.calls.find(c => c[0] === event)?.[1] as (() => void) | undefined
|
||||
const start = findHandler('movestart zoomstart')
|
||||
const end = findHandler('moveend zoomend')
|
||||
expect(start).toBeTypeOf('function')
|
||||
expect(end).toBeTypeOf('function')
|
||||
|
||||
fireEvent.click(screen.getByTestId('marker-hover-trigger')) // ensure hover is showing
|
||||
start!()
|
||||
await waitFor(() => expect(screen.queryByTestId('tooltip')).toBeNull())
|
||||
// during the pan animation a mouseover must not re-show the card
|
||||
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.queryByTestId('tooltip')).toBeNull()
|
||||
// once the move ends, hover works again
|
||||
end!()
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-020: a day fit expands to include the route once it arrives (#1128)', async () => {
|
||||
const L = ((await import('leaflet')).default) as unknown as { latLngBounds: ReturnType<typeof vi.fn> }
|
||||
const dayPlaces = [
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import ReservationOverlay from './ReservationOverlay'
|
||||
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
|
||||
import type { Reservation } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
|
||||
@@ -140,23 +139,6 @@ function createPoiIcon(category: string) {
|
||||
return icon
|
||||
}
|
||||
|
||||
// Clears the hover tooltip the moment the camera starts moving and suppresses
|
||||
// re-showing it until the move ends: after a click-recenter the marker slides
|
||||
// away under a stationary cursor, so the browser never fires mouseout — and
|
||||
// mouseover/mousemove during the pan animation would immediately re-set the
|
||||
// tooltip we just cleared (#1404).
|
||||
function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: boolean }; onMoveStart: () => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
const start = () => { movingRef.current = true; onMoveStart() }
|
||||
const end = () => { movingRef.current = false }
|
||||
map.on('movestart zoomstart', start)
|
||||
map.on('moveend zoomend', end)
|
||||
return () => { map.off('movestart zoomstart', start); map.off('moveend zoomend', end) }
|
||||
}, [map, movingRef, onMoveStart])
|
||||
return null
|
||||
}
|
||||
|
||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||
// OSM places for the visible area.
|
||||
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
||||
@@ -446,7 +428,6 @@ export const MapView = memo(function MapView({
|
||||
reservations = [] as Reservation[],
|
||||
showReservationStats = false,
|
||||
visibleConnectionIds = [] as number[],
|
||||
showTransitRoutes = true,
|
||||
onReservationClick,
|
||||
pois = [] as Poi[],
|
||||
onPoiClick,
|
||||
@@ -464,13 +445,10 @@ export const MapView = memo(function MapView({
|
||||
</Marker>
|
||||
)), [pois, onPoiClick])
|
||||
const visibleReservations = useMemo(() => {
|
||||
const set = new Set(visibleConnectionIds || [])
|
||||
// Transit journeys ride the route toggle — they are part of the computed
|
||||
// day route, so hiding the route hides them too (#1065).
|
||||
return reservations.filter((r: Reservation) => (r.type === 'transit' && showTransitRoutes) || set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds, showTransitRoutes])
|
||||
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
|
||||
const transportRoutes = useTransportRoutes(visibleReservations)
|
||||
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
|
||||
const set = new Set(visibleConnectionIds)
|
||||
return reservations.filter((r: Reservation) => set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds])
|
||||
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
||||
const paddingOpts = useMemo((): L.FitBoundsOptions => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
@@ -485,10 +463,9 @@ export const MapView = memo(function MapView({
|
||||
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
|
||||
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
|
||||
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const mapMovingRef = useRef(false)
|
||||
|
||||
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
|
||||
if (hoverDisabled || mapMovingRef.current) return
|
||||
if (hoverDisabled) return
|
||||
setHoveredPlace(place)
|
||||
setTooltipPos({ x, y })
|
||||
}, [hoverDisabled])
|
||||
@@ -512,18 +489,9 @@ export const MapView = memo(function MapView({
|
||||
}, [hoveredPlace])
|
||||
|
||||
const handleMarkerClick = useCallback((id: number) => {
|
||||
// Clear the hover card right away: the recenter that follows moves the
|
||||
// marker out from under the cursor, so no mouseout will ever fire (#1404).
|
||||
setHoveredPlace(null)
|
||||
setTooltipPos(null)
|
||||
onMarkerClick?.(id)
|
||||
}, [onMarkerClick])
|
||||
|
||||
const clearHover = useCallback(() => {
|
||||
setHoveredPlace(null)
|
||||
setTooltipPos(null)
|
||||
}, [])
|
||||
|
||||
// photoUrls: only base64 thumbs for smooth map zoom
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
@@ -674,7 +642,6 @@ export const MapView = memo(function MapView({
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
|
||||
<ViewportController onViewportChange={onViewportChange} />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
@@ -715,7 +682,6 @@ export const MapView = memo(function MapView({
|
||||
showConnections
|
||||
showStats={showReservationStats}
|
||||
onEndpointClick={onReservationClick}
|
||||
roadRoutes={transportRoutes}
|
||||
/>
|
||||
|
||||
{poiMarkers}
|
||||
|
||||
@@ -6,10 +6,7 @@ import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
// Stable fake map so fitBounds call counts survive re-renders. The canvas
|
||||
// container is a single element so listeners registered by the component are
|
||||
// reachable from tests via dispatchEvent.
|
||||
const glCanvasContainer = vi.hoisted(() => document.createElement('div'))
|
||||
// Stable fake map so fitBounds call counts survive re-renders.
|
||||
const glMap = vi.hoisted(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
@@ -28,12 +25,10 @@ const glMap = vi.hoisted(() => ({
|
||||
setLayoutProperty: vi.fn(),
|
||||
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
||||
isStyleLoaded: vi.fn().mockReturnValue(true),
|
||||
getCanvasContainer: vi.fn(() => glCanvasContainer),
|
||||
getCanvasContainer: vi.fn(() => document.createElement('div')),
|
||||
getLayer: vi.fn().mockReturnValue(null),
|
||||
queryRenderedFeatures: vi.fn().mockReturnValue([]),
|
||||
querySourceFeatures: vi.fn().mockReturnValue([]),
|
||||
unproject: vi.fn(() => ({ lng: 2.3522, lat: 48.8566 })),
|
||||
getBounds: vi.fn(() => ({ getSouth: () => 0, getWest: () => 0, getNorth: () => 1, getEast: () => 1 })),
|
||||
easeTo: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -270,173 +265,4 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-circle' }))
|
||||
expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-count' }))
|
||||
})
|
||||
|
||||
function touchEvent(type: string, touches: Array<{ clientX: number; clientY: number }>) {
|
||||
const ev = new Event(type, { bubbles: true })
|
||||
Object.defineProperty(ev, 'touches', { value: touches })
|
||||
return ev
|
||||
}
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-006: touch long-press opens Add-Place at the held position (#1398)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
|
||||
vi.advanceTimersByTime(650)
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
expect(onContext.mock.calls[0][0].latlng).toEqual({ lat: 48.8566, lng: 2.3522 })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-007: a moving finger (pan) cancels the long-press (#1398)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchmove', [{ clientX: 60, clientY: 90 }]))
|
||||
vi.advanceTimersByTime(650)
|
||||
})
|
||||
expect(onContext).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-008: a second finger (pinch) cancels the long-press (#1398)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }, { clientX: 80, clientY: 40 }]))
|
||||
vi.advanceTimersByTime(650)
|
||||
})
|
||||
expect(onContext).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-009: a plain right-click (map contextmenu event) opens Add-Place, deduped (#1398)', async () => {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
const handler = glMap.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1] as (e: unknown) => void
|
||||
expect(handler).toBeTypeOf('function')
|
||||
act(() => {
|
||||
handler({ lngLat: { lat: 48.8566, lng: 2.3522 }, originalEvent: new MouseEvent('contextmenu') })
|
||||
// Android long-press fires the native contextmenu on top of our timer —
|
||||
// a second event inside the dedupe window must not open a second form.
|
||||
handler({ lngLat: { lat: 48.8566, lng: 2.3522 }, originalEvent: new MouseEvent('contextmenu') })
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
expect(onContext.mock.calls[0][0].latlng).toEqual({ lat: 48.8566, lng: 2.3522 })
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-012: a right-button rotate/pitch drag does not open Add-Place on release (#1398)', async () => {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
const handler = glMap.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1] as (e: unknown) => void
|
||||
act(() => {
|
||||
// mapbox-gl (unlike maplibre) still emits contextmenu after a right-drag
|
||||
// on Windows — the movement guard must drop it.
|
||||
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true }))
|
||||
handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 140, clientY: 90 }) })
|
||||
})
|
||||
expect(onContext).not.toHaveBeenCalled()
|
||||
// ...while a stationary right-click still fires.
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true }))
|
||||
handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 11, clientY: 10 }) })
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-013: a stale long-press suppression never swallows a later real tap (#1398)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onContext = vi.fn()
|
||||
const onMapClick = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} onMapClick={onMapClick} />)
|
||||
await act(async () => {})
|
||||
// Long-press fires (arms the suppression), but no click follows.
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
|
||||
vi.advanceTimersByTime(650)
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
// The NEXT gesture starts fresh: its tap must reach the map click handler.
|
||||
const clickHandler = glMap.on.mock.calls.find(c => c[0] === 'click')?.[1] as (e: unknown) => void
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 80, clientY: 90 }]))
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchend', []))
|
||||
clickHandler({ lngLat: { lat: 3, lng: 4 }, originalEvent: { target: glCanvasContainer } })
|
||||
})
|
||||
expect(onMapClick).toHaveBeenCalledWith({ latlng: { lat: 3, lng: 4 } })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-010: middle-click still opens Add-Place (#1398 regression guard)', async () => {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 1, bubbles: true }))
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-011: clicking a marker clears the hover card; movestart clears + suppresses it (#1404)', async () => {
|
||||
// Markers are only reconciled once the style has loaded — fire 'load' like GL-005 does.
|
||||
glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => {
|
||||
if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)()
|
||||
return glMap
|
||||
})
|
||||
const mapboxgl = (await import('mapbox-gl')).default
|
||||
const places = [buildMapPlace({ id: 7, lat: 48.8584, lng: 2.2945, name: 'Tour Eiffel' })]
|
||||
const { queryByTestId } = render(<MapViewGL places={places} fitKey={1} onMarkerClick={vi.fn()} />)
|
||||
await act(async () => {})
|
||||
|
||||
const markerCall = (mapboxgl.Marker as unknown as ReturnType<typeof vi.fn>).mock.calls
|
||||
.find(c => c[0]?.element)
|
||||
expect(markerCall).toBeTruthy()
|
||||
const el = markerCall![0].element as HTMLElement
|
||||
|
||||
// hover shows the card
|
||||
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
|
||||
expect(queryByTestId('tooltip')).toBeTruthy()
|
||||
|
||||
// click clears it (the flyTo that follows moves the marker away, no mouseleave will come)
|
||||
act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: false })) })
|
||||
expect(queryByTestId('tooltip')).toBeNull()
|
||||
|
||||
// hover again, then camera movement clears + suppresses
|
||||
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
|
||||
expect(queryByTestId('tooltip')).toBeTruthy()
|
||||
const moveStart = glMap.on.mock.calls.find(c => c[0] === 'movestart')?.[1] as () => void
|
||||
const moveEnds = glMap.on.mock.calls.filter(c => c[0] === 'moveend').map(c => c[1] as () => void)
|
||||
act(() => { moveStart() })
|
||||
expect(queryByTestId('tooltip')).toBeNull()
|
||||
// while the camera is moving, a re-fired mouseenter must not bring it back
|
||||
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
|
||||
expect(queryByTestId('tooltip')).toBeNull()
|
||||
// after the move ends, hover works again
|
||||
act(() => { moveEnds.forEach(fn => fn()) })
|
||||
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
|
||||
expect(queryByTestId('tooltip')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
@@ -71,7 +70,7 @@ interface Props {
|
||||
onMarkerClick?: (id: number) => void
|
||||
hoverDisabled?: boolean
|
||||
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
|
||||
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent | TouchEvent }) => void) | null
|
||||
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null
|
||||
center?: [number, number]
|
||||
zoom?: number
|
||||
fitKey?: number | null
|
||||
@@ -82,7 +81,6 @@ interface Props {
|
||||
hasDayDetail?: boolean
|
||||
reservations?: Reservation[]
|
||||
visibleConnectionIds?: number[]
|
||||
showTransitRoutes?: boolean
|
||||
showReservationStats?: boolean
|
||||
onReservationClick?: (reservationId: number) => void
|
||||
pois?: Poi[]
|
||||
@@ -201,7 +199,6 @@ export function MapViewGL({
|
||||
hasDayDetail = false,
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
showTransitRoutes = true,
|
||||
showReservationStats = false,
|
||||
onReservationClick,
|
||||
pois = [],
|
||||
@@ -215,7 +212,7 @@ export function MapViewGL({
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
|
||||
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
|
||||
const mapLang = useSettingsStore(s => s.settings.language)
|
||||
const isMapLibre = glProvider === 'maplibre-gl'
|
||||
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
|
||||
@@ -229,10 +226,6 @@ export function MapViewGL({
|
||||
const [hoverPlace, setHoverPlace] = useState<(Place & { category_color?: string | null; category_icon?: string | null; category_name?: string | null }) | null>(null)
|
||||
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const hoverIdRef = useRef<number | null>(null)
|
||||
// True while the camera is moving (flyTo after a click, pan, zoom). Marker
|
||||
// elements get rebuilt during the move and re-fire mouseenter under a
|
||||
// stationary cursor, which would re-show the card we just cleared (#1404).
|
||||
const camMovingRef = useRef(false)
|
||||
|
||||
// Selecting a place rebuilds its marker element, so the browser never fires
|
||||
// mouseleave on the removed node and the fixed-position hover card gets
|
||||
@@ -453,13 +446,7 @@ export function MapViewGL({
|
||||
setMapReady(true)
|
||||
})
|
||||
|
||||
// Set by the long-press handler below: the touchend tap that follows a
|
||||
// long-press must not count as a normal map click (#1398).
|
||||
let suppressNextClick = false
|
||||
map.on('click', (e) => {
|
||||
// The tap that ends a long-press would otherwise land here and clear
|
||||
// the selection right after the Add-Place form opened (#1398).
|
||||
if (suppressNextClick) { suppressNextClick = false; return }
|
||||
const t = e.originalEvent.target as HTMLElement
|
||||
if (t.closest('.mapboxgl-marker, .maplibregl-marker')) return // markers handle their own click
|
||||
// A click that lands on a cluster bubble is the cluster's to handle
|
||||
@@ -480,52 +467,19 @@ export function MapViewGL({
|
||||
}
|
||||
map.on('moveend', emitViewport)
|
||||
map.once('idle', emitViewport)
|
||||
// Clear the hover card (and the anchored POI popup) as soon as the camera
|
||||
// starts moving, and keep hover suppressed until it stops: the marker
|
||||
// slides away under a stationary cursor, so mouseleave never fires (#1404).
|
||||
const onCamStart = () => {
|
||||
camMovingRef.current = true
|
||||
hoverIdRef.current = null
|
||||
setHoverPlace(null)
|
||||
setHoverPos(null)
|
||||
popupRef.current?.remove()
|
||||
}
|
||||
const onCamEnd = () => { camMovingRef.current = false }
|
||||
map.on('movestart', onCamStart)
|
||||
map.on('moveend', onCamEnd)
|
||||
// "Add place here" on the GL map (#1398). Three routes into one handler:
|
||||
// middle-click (the original binding), a plain right-click via the map's
|
||||
// own contextmenu event — both GL libs suppress that event while the
|
||||
// right-button rotate/pitch drag is active, so it can't fight the gesture,
|
||||
// and it also covers Mac ctrl-click / two-finger tap — and a touch
|
||||
// long-press, which neither GL lib synthesizes into contextmenu (Leaflet
|
||||
// does, which is why the OSM map already worked on mobile).
|
||||
// In the GL map the right mouse button is reserved for the
|
||||
// built-in rotate/pitch gesture, so we bind the "add place" action
|
||||
// to the middle mouse button (button === 1) instead.
|
||||
const canvas = map.getCanvasContainer()
|
||||
let lastContextFire = 0
|
||||
const fireContext = (lngLat: { lat: number; lng: number }, originalEvent: MouseEvent | TouchEvent): boolean => {
|
||||
// Android fires a native contextmenu for a long-press on top of our own
|
||||
// timer — dedupe so the form doesn't open twice.
|
||||
if (Date.now() - lastContextFire < 700) return false
|
||||
lastContextFire = Date.now()
|
||||
onClickRefs.current.context?.({ latlng: { lat: lngLat.lat, lng: lngLat.lng }, originalEvent })
|
||||
return true
|
||||
}
|
||||
// MapLibre swallows the map contextmenu at the end of a right-button
|
||||
// rotate/pitch drag, but mapbox-gl does NOT — and on Windows the DOM
|
||||
// contextmenu arrives after mouseup, so every rotate would end by opening
|
||||
// the Add-Place form. Track the right-button press position and drop a
|
||||
// contextmenu whose pointer travelled like a drag rather than a click.
|
||||
let rightDownAt: { x: number; y: number } | null = null
|
||||
const onAuxDown = (ev: MouseEvent) => {
|
||||
if (ev.button === 2) {
|
||||
rightDownAt = { x: ev.clientX, y: ev.clientY }
|
||||
return
|
||||
}
|
||||
if (ev.button !== 1) return
|
||||
ev.preventDefault()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top])
|
||||
fireContext({ lat: lngLat.lat, lng: lngLat.lng }, ev)
|
||||
onClickRefs.current.context?.({
|
||||
latlng: { lat: lngLat.lat, lng: lngLat.lng },
|
||||
originalEvent: ev,
|
||||
})
|
||||
}
|
||||
// Also suppress the browser's native auxclick menu on middle-click.
|
||||
const onAuxClick = (ev: MouseEvent) => {
|
||||
@@ -533,49 +487,6 @@ export function MapViewGL({
|
||||
}
|
||||
canvas.addEventListener('mousedown', onAuxDown)
|
||||
canvas.addEventListener('auxclick', onAuxClick)
|
||||
map.on('contextmenu', (e: { lngLat: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
const down = rightDownAt
|
||||
rightDownAt = null
|
||||
if (down && Math.hypot(e.originalEvent.clientX - down.x, e.originalEvent.clientY - down.y) > 5) return
|
||||
fireContext(e.lngLat, e.originalEvent)
|
||||
})
|
||||
// Touch long-press: 600 ms hold (Leaflet's tapHold feel) with a 10 px
|
||||
// move tolerance so slow pans and pinches don't open the form.
|
||||
let lpTimer: number | null = null
|
||||
let lpStart: { x: number; y: number } | null = null
|
||||
const cancelLongPress = () => {
|
||||
if (lpTimer !== null) window.clearTimeout(lpTimer)
|
||||
lpTimer = null
|
||||
lpStart = null
|
||||
}
|
||||
const onTouchStart = (ev: TouchEvent) => {
|
||||
// A fresh gesture clears a stale suppression flag: not every long-press
|
||||
// is followed by a click (finger drag after the hold, Android's native
|
||||
// contextmenu path), and the flag must never swallow a later real tap.
|
||||
suppressNextClick = false
|
||||
if (ev.touches.length !== 1) { cancelLongPress(); return }
|
||||
if ((ev.target as HTMLElement).closest('.mapboxgl-marker, .maplibregl-marker')) return
|
||||
const t = ev.touches[0]
|
||||
lpStart = { x: t.clientX, y: t.clientY }
|
||||
lpTimer = window.setTimeout(() => {
|
||||
lpTimer = null
|
||||
if (!lpStart) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const lngLat = map.unproject([lpStart.x - rect.left, lpStart.y - rect.top])
|
||||
lpStart = null
|
||||
// Only suppress the tap when OUR fire opened the form — if the native
|
||||
// contextmenu beat us to it (dedupe), no click needs swallowing.
|
||||
if (fireContext({ lat: lngLat.lat, lng: lngLat.lng }, ev)) suppressNextClick = true
|
||||
}, 600)
|
||||
}
|
||||
const onTouchMove = (ev: TouchEvent) => {
|
||||
const t = ev.touches[0]
|
||||
if (lpStart && (!t || Math.hypot(t.clientX - lpStart.x, t.clientY - lpStart.y) > 10)) cancelLongPress()
|
||||
}
|
||||
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
|
||||
canvas.addEventListener('touchmove', onTouchMove, { passive: true })
|
||||
canvas.addEventListener('touchend', cancelLongPress)
|
||||
canvas.addEventListener('touchcancel', cancelLongPress)
|
||||
|
||||
// Drop follow mode if the user pans the map manually — matches the
|
||||
// Apple Maps behaviour where the blue dot stays but the map no longer
|
||||
@@ -624,11 +535,6 @@ export function MapViewGL({
|
||||
return () => {
|
||||
canvas.removeEventListener('mousedown', onAuxDown)
|
||||
canvas.removeEventListener('auxclick', onAuxClick)
|
||||
canvas.removeEventListener('touchstart', onTouchStart)
|
||||
canvas.removeEventListener('touchmove', onTouchMove)
|
||||
canvas.removeEventListener('touchend', cancelLongPress)
|
||||
canvas.removeEventListener('touchcancel', cancelLongPress)
|
||||
cancelLongPress()
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
|
||||
@@ -744,21 +650,16 @@ export function MapViewGL({
|
||||
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
// Clear the card right away — the flyTo that follows moves the marker
|
||||
// out from under the cursor and mouseleave never fires (#1404).
|
||||
hoverIdRef.current = null
|
||||
setHoverPlace(null)
|
||||
setHoverPos(null)
|
||||
onClickRefs.current.marker?.(place.id)
|
||||
})
|
||||
el.addEventListener('mouseenter', (ev) => {
|
||||
if (hoverDisabledRef.current || camMovingRef.current) return
|
||||
if (hoverDisabledRef.current) return
|
||||
hoverIdRef.current = place.id
|
||||
setHoverPlace(place as Place & { category_color?: string; category_icon?: string; category_name?: string })
|
||||
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
|
||||
})
|
||||
el.addEventListener('mousemove', (ev) => {
|
||||
if (hoverDisabledRef.current || camMovingRef.current) return
|
||||
if (hoverDisabledRef.current) return
|
||||
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
|
||||
})
|
||||
el.addEventListener('mouseleave', () => {
|
||||
@@ -899,13 +800,10 @@ export function MapViewGL({
|
||||
// DayPlanSidebar — nothing is rendered until the user enables a
|
||||
// booking's route, matching the Leaflet MapView's behaviour.
|
||||
const visibleReservations = useMemo(() => {
|
||||
const set = new Set(visibleConnectionIds || [])
|
||||
// Transit journeys ride the route toggle — they are part of the computed
|
||||
// day route, so hiding the route hides them too (#1065).
|
||||
return reservations.filter(r => (r.type === 'transit' && showTransitRoutes) || set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds, showTransitRoutes])
|
||||
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
|
||||
const transportRoutes = useTransportRoutes(visibleReservations)
|
||||
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
|
||||
const set = new Set(visibleConnectionIds)
|
||||
return reservations.filter(r => set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds])
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
@@ -923,8 +821,8 @@ export function MapViewGL({
|
||||
showStats: showReservationStats,
|
||||
showEndpointLabels,
|
||||
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
||||
}, transportRoutes)
|
||||
}, [visibleReservations, transportRoutes, showReservationStats, showEndpointLabels, mapReady, glProvider])
|
||||
})
|
||||
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady, glProvider])
|
||||
|
||||
// Fit bounds on fitKey change — matches the Leaflet BoundsController
|
||||
const paddingOpts = useMemo(() => {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Fragment, createElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
|
||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry'
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
@@ -13,8 +11,8 @@ const ENDPOINT_PANE = 'reservation-endpoints'
|
||||
const AIRPORT_BADGE_HALF_PX = 16
|
||||
const BADGE_GAP_PX = 5
|
||||
|
||||
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other'
|
||||
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other']
|
||||
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transport_other'
|
||||
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transport_other']
|
||||
|
||||
const TRANSPORT_COLOR = '#3b82f6'
|
||||
|
||||
@@ -27,7 +25,6 @@ const TYPE_META: Record<TransportType, { color: string; icon: typeof Plane; geod
|
||||
taxi: { color: TRANSPORT_COLOR, icon: CarTaxiFront, geodesic: false },
|
||||
bicycle: { color: TRANSPORT_COLOR, icon: Bike, geodesic: false },
|
||||
ferry: { color: TRANSPORT_COLOR, icon: Sailboat, geodesic: true },
|
||||
transit: { color: TRANSPORT_COLOR, icon: TramFront, geodesic: false },
|
||||
transport_other: { color: TRANSPORT_COLOR, icon: Route, geodesic: false },
|
||||
}
|
||||
|
||||
@@ -65,6 +62,41 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
||||
}
|
||||
|
||||
function toRad(d: number) { return d * Math.PI / 180 }
|
||||
function toDeg(r: number) { return r * 180 / Math.PI }
|
||||
|
||||
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
|
||||
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
|
||||
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
|
||||
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
||||
if (d === 0) return [a, b]
|
||||
const pts: [number, number][] = []
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = i / steps
|
||||
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
||||
const B = Math.sin(f * d) / Math.sin(d)
|
||||
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
||||
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
||||
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
||||
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
|
||||
const lng = Math.atan2(y, x)
|
||||
pts.push([toDeg(lat), toDeg(lng)])
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
function splitAntimeridian(points: [number, number][]): [number, number][][] {
|
||||
const segments: [number, number][][] = []
|
||||
let cur: [number, number][] = []
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
cur = []
|
||||
}
|
||||
cur.push(points[i])
|
||||
}
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
return segments
|
||||
}
|
||||
|
||||
function cleanName(name: string): string {
|
||||
return name.replace(/\s*\([^)]*\)/g, '').trim()
|
||||
@@ -129,7 +161,6 @@ interface TransportItem {
|
||||
waypoints: ReservationEndpoint[]
|
||||
type: TransportType
|
||||
arcs: [number, number][][]
|
||||
transitSegs: TransitMapSegment[]
|
||||
primaryArc: [number, number][]
|
||||
fallback: [number, number]
|
||||
mainLabel: string | null
|
||||
@@ -308,19 +339,16 @@ interface Props {
|
||||
showConnections: boolean
|
||||
showStats: boolean
|
||||
onEndpointClick?: (reservationId: number) => void
|
||||
// Real road-network geometry for car/bus/taxi/bicycle bookings, keyed by
|
||||
// reservation id. When present it is drawn instead of the straight arc.
|
||||
roadRoutes?: Map<number, [number, number][]>
|
||||
}
|
||||
|
||||
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick, roadRoutes }: Props) {
|
||||
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick }: Props) {
|
||||
useEndpointPane()
|
||||
const map = useMap()
|
||||
const [zoom, setZoom] = useState(() => map.getZoom())
|
||||
useMapEvents({
|
||||
zoomend: () => setZoom(map.getZoom()),
|
||||
})
|
||||
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
|
||||
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
|
||||
|
||||
const items = useMemo<TransportItem[]>(() => {
|
||||
const out: TransportItem[] = []
|
||||
@@ -344,7 +372,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
const a = waypoints[i]
|
||||
const b = waypoints[i + 1]
|
||||
const segArcs = isGeo
|
||||
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], true)
|
||||
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
||||
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
||||
arcs.push(...segArcs)
|
||||
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
||||
@@ -364,7 +392,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
const subParts = [duration, distance].filter(Boolean) as string[]
|
||||
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
||||
|
||||
out.push({ res: r, from, to, waypoints, type, arcs, transitSegs: type === 'transit' ? getTransitMapSegments(r) : [], primaryArc, fallback, mainLabel, subLabel })
|
||||
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, fallback, mainLabel, subLabel })
|
||||
}
|
||||
return out
|
||||
}, [reservations])
|
||||
@@ -383,7 +411,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
for (const item of visibleItems) {
|
||||
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
|
||||
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
|
||||
}
|
||||
return set
|
||||
@@ -393,41 +421,18 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleItems.map(item => {
|
||||
if (item.transitSegs.length > 0) {
|
||||
return item.transitSegs.map((seg, segIdx) => (
|
||||
<Fragment key={`transit-${item.res.id}-${segIdx}`}>
|
||||
{!seg.walk && (
|
||||
<Polyline
|
||||
positions={seg.coords}
|
||||
pathOptions={{ color: '#ffffff', weight: 6, opacity: 0.85, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>
|
||||
)}
|
||||
<Polyline
|
||||
positions={seg.coords}
|
||||
pathOptions={seg.walk
|
||||
? { color: '#64748b', weight: 3, opacity: 0.8, dashArray: '1, 7', lineCap: 'round' }
|
||||
: { color: seg.color || TYPE_META.transit.color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>
|
||||
</Fragment>
|
||||
))
|
||||
}
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = roadRoutes?.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map((seg, segIdx) => (
|
||||
<Polyline
|
||||
key={`line-${item.res.id}-${segIdx}`}
|
||||
positions={seg}
|
||||
pathOptions={{
|
||||
color: TYPE_META[item.type].color,
|
||||
weight: 2.5,
|
||||
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
|
||||
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
|
||||
}}
|
||||
/>
|
||||
))
|
||||
})}
|
||||
{visibleItems.map(item => item.arcs.map((seg, segIdx) => (
|
||||
<Polyline
|
||||
key={`line-${item.res.id}-${segIdx}`}
|
||||
positions={seg}
|
||||
pathOptions={{
|
||||
color: TYPE_META[item.type].color,
|
||||
weight: 2.5,
|
||||
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
|
||||
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
|
||||
}}
|
||||
/>
|
||||
)))}
|
||||
|
||||
{visibleItems.flatMap(item => item.waypoints.map((wp, wi) => (
|
||||
<Marker
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { greatCircle, unwrapLngs, geodesicArcs } from './flightGeodesy'
|
||||
|
||||
const YVR: [number, number] = [49.19, -123.18]
|
||||
const ICN: [number, number] = [37.46, 126.44]
|
||||
const FRA: [number, number] = [50.03, 8.57]
|
||||
const JFK: [number, number] = [40.64, -73.78]
|
||||
|
||||
const maxConsecutiveDeltaLng = (pts: [number, number][]) =>
|
||||
pts.reduce((max, p, i) => (i === 0 ? 0 : Math.max(max, Math.abs(p[1] - pts[i - 1][1]))), 0)
|
||||
|
||||
describe('flightGeodesy (#1411)', () => {
|
||||
it('a date-line crossing unwraps into one continuous arc plus a shifted copy for Leaflet', () => {
|
||||
const arcs = geodesicArcs(YVR, ICN, true)
|
||||
expect(arcs).toHaveLength(2)
|
||||
const [base, shifted] = arcs
|
||||
// continuous: no ±360 jump anywhere
|
||||
expect(maxConsecutiveDeltaLng(base)).toBeLessThan(180)
|
||||
// endpoints: starts at YVR, ends at ICN unwrapped westwards (126.44 - 360)
|
||||
expect(base[0][0]).toBeCloseTo(YVR[0], 5)
|
||||
expect(base[0][1]).toBeCloseTo(YVR[1], 5)
|
||||
expect(base[base.length - 1][1]).toBeCloseTo(ICN[1] - 360, 5)
|
||||
// the copy is the base shifted by exactly +360
|
||||
expect(shifted).toHaveLength(base.length)
|
||||
shifted.forEach(([lat, lng], i) => {
|
||||
expect(lat).toBe(base[i][0])
|
||||
expect(lng).toBeCloseTo(base[i][1] + 360, 10)
|
||||
})
|
||||
})
|
||||
|
||||
it('an eastbound crossing unwraps upwards and shifts by -360', () => {
|
||||
const arcs = geodesicArcs(ICN, YVR, true)
|
||||
expect(arcs).toHaveLength(2)
|
||||
const [base, shifted] = arcs
|
||||
expect(maxConsecutiveDeltaLng(base)).toBeLessThan(180)
|
||||
expect(base[base.length - 1][1]).toBeCloseTo(YVR[1] + 360, 5)
|
||||
expect(shifted[0][1]).toBeCloseTo(base[0][1] - 360, 10)
|
||||
})
|
||||
|
||||
it('a non-crossing flight stays a single in-range arc', () => {
|
||||
const arcs = geodesicArcs(FRA, JFK, true)
|
||||
expect(arcs).toHaveLength(1)
|
||||
for (const [, lng] of arcs[0]) {
|
||||
expect(lng).toBeGreaterThanOrEqual(-180)
|
||||
expect(lng).toBeLessThanOrEqual(180)
|
||||
}
|
||||
})
|
||||
|
||||
it('GL mode always returns exactly one continuous arc (world copies handle the wrap)', () => {
|
||||
const arcs = geodesicArcs(YVR, ICN, false)
|
||||
expect(arcs).toHaveLength(1)
|
||||
expect(maxConsecutiveDeltaLng(arcs[0])).toBeLessThan(180)
|
||||
})
|
||||
|
||||
it('a zero-distance leg passes the endpoints through', () => {
|
||||
expect(greatCircle(YVR, YVR)).toEqual([YVR, YVR])
|
||||
})
|
||||
|
||||
it('unwrapLngs keeps already-continuous input unchanged', () => {
|
||||
const pts: [number, number][] = [[0, 170], [0, 175], [0, 179]]
|
||||
expect(unwrapLngs(pts)).toEqual(pts)
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
// Great-circle geometry for transport routes (flights, cruises, ferries),
|
||||
// shared by the Leaflet and Mapbox/MapLibre renderers (#1411).
|
||||
|
||||
const toRad = (d: number) => d * Math.PI / 180
|
||||
const toDeg = (r: number) => r * 180 / Math.PI
|
||||
|
||||
export function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
|
||||
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
|
||||
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
|
||||
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
||||
if (d === 0) return [a, b]
|
||||
const pts: [number, number][] = []
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = i / steps
|
||||
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
||||
const B = Math.sin(f * d) / Math.sin(d)
|
||||
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
||||
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
||||
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
||||
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
|
||||
const lng = Math.atan2(y, x)
|
||||
pts.push([toDeg(lat), toDeg(lng)])
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the longitudes of a sampled arc continuous: atan2 normalizes every
|
||||
* sample to [-180, 180], so a date-line crossing shows up as a ±360 jump
|
||||
* between neighbours — which the renderers draw as a line across the whole
|
||||
* map. Carrying a running offset keeps each Δlng under 180°, at the cost of
|
||||
* longitudes leaving the [-180, 180] range (both map libraries project those
|
||||
* linearly, which is exactly what makes the wrap seamless).
|
||||
*/
|
||||
export function unwrapLngs(points: [number, number][]): [number, number][] {
|
||||
let offset = 0
|
||||
return points.map(([lat, lng], i) => {
|
||||
if (i === 0) return [lat, lng] as [number, number]
|
||||
const prev = points[i - 1][1] + offset
|
||||
let cur = lng + offset
|
||||
if (cur - prev > 180) { offset -= 360; cur -= 360 }
|
||||
else if (cur - prev < -180) { offset += 360; cur += 360 }
|
||||
return [lat, cur] as [number, number]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The polylines to draw for one leg. The base arc is continuous (unwrapped),
|
||||
* so panning across the antimeridian shows one unbroken line. With
|
||||
* `wrapCopies` (Leaflet — its vector layers don't repeat across world
|
||||
* copies), a ±360-shifted duplicate keeps both halves visible in the
|
||||
* standard [-180, 180] view; GL maps repeat features themselves
|
||||
* (renderWorldCopies), so the duplicate would just double the line opacity.
|
||||
*/
|
||||
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean): [number, number][][] {
|
||||
const arc = unwrapLngs(greatCircle(a, b))
|
||||
const crosses = arc.some(([, lng]) => lng < -180 || lng > 180)
|
||||
if (!crosses || !wrapCopies) return [arc]
|
||||
const shift = arc.some(([, lng]) => lng < -180) ? 360 : -360
|
||||
return [arc, arc.map(([lat, lng]) => [lat, lng + shift] as [number, number])]
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
// A minimal mapbox-gl stand-in: a persistent source that records the last
|
||||
// setData, and project() spreading points far enough apart to pass the
|
||||
// per-type pixel-distance visibility filter.
|
||||
function fakeMap() {
|
||||
const source = { setData: vi.fn() }
|
||||
return {
|
||||
_source: source,
|
||||
getSource: () => source,
|
||||
addSource: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
getLayer: () => undefined,
|
||||
removeLayer: vi.fn(),
|
||||
removeSource: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
getZoom: () => 12,
|
||||
project: ([lng, lat]: [number, number]) => ({ x: lng * 1000, y: lat * 1000 }),
|
||||
}
|
||||
}
|
||||
|
||||
const FakeMarker = vi.fn(function () {
|
||||
const marker = {
|
||||
setLngLat: () => marker,
|
||||
addTo: () => marker,
|
||||
remove: vi.fn(),
|
||||
getElement: () => document.createElement('div'),
|
||||
}
|
||||
return marker
|
||||
}) as unknown as new () => unknown
|
||||
|
||||
function carBooking(): Reservation {
|
||||
return {
|
||||
id: 1, type: 'car', status: 'confirmed',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 48.0, lng: 2.0, timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 48.2, lng: 2.3, timezone: null, local_time: null, local_date: null },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
}
|
||||
|
||||
const opts = { showConnections: true, showStats: false, showEndpointLabels: false }
|
||||
|
||||
function lastFeatureCoords(map: ReturnType<typeof fakeMap>) {
|
||||
const calls = map._source.setData.mock.calls
|
||||
const data = calls[calls.length - 1]?.[0] as { features: { geometry: { coordinates: [number, number][] } }[] }
|
||||
return data.features[0].geometry.coordinates
|
||||
}
|
||||
|
||||
describe('ReservationMapboxOverlay road routes (#1425)', () => {
|
||||
it('draws the real road geometry when a road route is supplied', () => {
|
||||
const map = fakeMap()
|
||||
const overlay = new ReservationMapboxOverlay(map as never, opts, FakeMarker as never)
|
||||
const road: [number, number][] = [[48.0, 2.0], [48.1, 2.15], [48.2, 2.3]]
|
||||
overlay.update([carBooking()], opts, new Map([[1, road]]))
|
||||
// GeoJSON is [lng, lat]; the routed 3-point line, not the straight 2-point arc.
|
||||
expect(lastFeatureCoords(map)).toEqual([[2.0, 48.0], [2.15, 48.1], [2.3, 48.2]])
|
||||
})
|
||||
|
||||
it('falls back to the straight arc when no road route is supplied', () => {
|
||||
const map = fakeMap()
|
||||
const overlay = new ReservationMapboxOverlay(map as never, opts, FakeMarker as never)
|
||||
overlay.update([carBooking()], opts)
|
||||
expect(lastFeatureCoords(map)).toEqual([[2.0, 48.0], [2.3, 48.2]])
|
||||
})
|
||||
|
||||
it('sets no line features while connections are hidden', () => {
|
||||
const map = fakeMap()
|
||||
const overlay = new ReservationMapboxOverlay(map as never, opts, FakeMarker as never)
|
||||
overlay.update([carBooking()], { ...opts, showConnections: false }, new Map([[1, [[48, 2], [48.2, 2.3]]]]))
|
||||
const calls = map._source.setData.mock.calls
|
||||
const data = calls[calls.length - 1]?.[0] as { features: unknown[] }
|
||||
expect(data.features).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -9,17 +9,15 @@
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
|
||||
import { getTransitMapSegments } from './transitGeometry'
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
export const RESERVATION_SOURCE_ID = 'trek-reservations'
|
||||
export const RESERVATION_LINE_LAYER_ID = 'trek-reservations-lines'
|
||||
|
||||
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other'
|
||||
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other']
|
||||
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transport_other'
|
||||
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transport_other']
|
||||
const TRANSPORT_COLOR = '#3b82f6'
|
||||
|
||||
const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }> = {
|
||||
@@ -31,12 +29,46 @@ const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }
|
||||
taxi: { icon: CarTaxiFront, geodesic: false },
|
||||
bicycle: { icon: Bike, geodesic: false },
|
||||
ferry: { icon: Sailboat, geodesic: true },
|
||||
transit: { icon: TramFront, geodesic: false },
|
||||
transport_other: { icon: Route, geodesic: false },
|
||||
}
|
||||
|
||||
// ── geometry helpers (shared with ReservationOverlay via flightGeodesy) ──
|
||||
// ── geometry helpers (ported from ReservationOverlay.tsx) ────────────────
|
||||
const toRad = (d: number) => d * Math.PI / 180
|
||||
const toDeg = (r: number) => r * 180 / Math.PI
|
||||
|
||||
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
|
||||
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
|
||||
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
|
||||
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
||||
if (d === 0) return [a, b]
|
||||
const pts: [number, number][] = []
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = i / steps
|
||||
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
||||
const B = Math.sin(f * d) / Math.sin(d)
|
||||
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
||||
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
||||
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
||||
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
|
||||
const lng = Math.atan2(y, x)
|
||||
pts.push([toDeg(lat), toDeg(lng)])
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
function splitAntimeridian(points: [number, number][]): [number, number][][] {
|
||||
const segments: [number, number][][] = []
|
||||
let cur: [number, number][] = []
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
cur = []
|
||||
}
|
||||
cur.push(points[i])
|
||||
}
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
return segments
|
||||
}
|
||||
|
||||
function haversineKm(a: [number, number], b: [number, number]): number {
|
||||
const R = 6371
|
||||
@@ -123,10 +155,7 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
|
||||
const a = waypoints[i]
|
||||
const b = waypoints[i + 1]
|
||||
const segArcs = isGeo
|
||||
// GL maps repeat features across world copies themselves, so one
|
||||
// continuous unwrapped arc is enough (a shifted duplicate would
|
||||
// coincide with the wrapped copy and double the line opacity).
|
||||
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], false)
|
||||
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
||||
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
||||
arcs.push(...segArcs)
|
||||
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
||||
@@ -203,7 +232,6 @@ type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string
|
||||
export class ReservationMapboxOverlay {
|
||||
private map: mapboxgl.Map
|
||||
private items: TransportItem[] = []
|
||||
private roadRoutes: Map<number, [number, number][]> = new Map()
|
||||
private opts: ReservationOverlayOptions
|
||||
private MarkerCtor: MarkerConstructor
|
||||
private endpointMarkers: GlMarker[] = []
|
||||
@@ -222,10 +250,9 @@ export class ReservationMapboxOverlay {
|
||||
map.on('render', this.updateStatsRotation)
|
||||
}
|
||||
|
||||
update(reservations: Reservation[], opts: ReservationOverlayOptions, roadRoutes?: Map<number, [number, number][]>) {
|
||||
update(reservations: Reservation[], opts: ReservationOverlayOptions) {
|
||||
this.opts = opts
|
||||
this.items = buildItems(reservations)
|
||||
this.roadRoutes = roadRoutes ?? new Map()
|
||||
this.render()
|
||||
}
|
||||
|
||||
@@ -248,25 +275,16 @@ export class ReservationMapboxOverlay {
|
||||
const map = this.map
|
||||
if (map.getSource(RESERVATION_SOURCE_ID)) return
|
||||
map.addSource(RESERVATION_SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
// White casing under real transit paths so the colored lines read cleanly.
|
||||
map.addLayer({
|
||||
id: RESERVATION_LINE_LAYER_ID + '-transit-casing',
|
||||
type: 'line',
|
||||
source: RESERVATION_SOURCE_ID,
|
||||
filter: ['all', ['==', ['get', 'transitPath'], true], ['!=', ['get', 'walk'], true]] as any,
|
||||
paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.85 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
map.addLayer({
|
||||
id: RESERVATION_LINE_LAYER_ID,
|
||||
type: 'line',
|
||||
source: RESERVATION_SOURCE_ID,
|
||||
paint: {
|
||||
'line-color': ['coalesce', ['get', 'color'], TRANSPORT_COLOR] as any,
|
||||
'line-width': ['case', ['==', ['get', 'transitPath'], true], ['case', ['==', ['get', 'walk'], true], 3, 3.5], 2.5] as any,
|
||||
// Confirmed = solid + 0.75; pending = dashed + 0.55; walks always dotted.
|
||||
'line-opacity': ['case', ['==', ['get', 'transitPath'], true], 0.95, ['case', ['==', ['get', 'status'], 'confirmed'], 0.75, 0.55]] as any,
|
||||
'line-dasharray': ['case', ['==', ['get', 'walk'], true], ['literal', [0.1, 2.5]], ['case', ['==', ['get', 'status'], 'confirmed'], ['literal', [1, 0]], ['literal', [3, 3]]]] as any,
|
||||
'line-color': TRANSPORT_COLOR,
|
||||
'line-width': 2.5,
|
||||
// Confirmed = solid + 0.75; pending = dashed + 0.55.
|
||||
'line-opacity': ['case', ['==', ['get', 'status'], 'confirmed'], 0.75, 0.55] as any,
|
||||
'line-dasharray': ['case', ['==', ['get', 'status'], 'confirmed'], ['literal', [1, 0]], ['literal', [3, 3]]] as any,
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
@@ -302,51 +320,25 @@ export class ReservationMapboxOverlay {
|
||||
const toPx = map.project([item.to.lng, item.to.lat])
|
||||
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
|
||||
if (dist >= minPx) labelVisibleIds.add(item.res.id)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── line features ───────────────────────────────────────────────
|
||||
const features = visibleItems.flatMap(item => {
|
||||
const transitSegs = item.type === 'transit' ? getTransitMapSegments(item.res) : []
|
||||
if (transitSegs.length > 0) {
|
||||
return transitSegs.map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
resId: item.res.id,
|
||||
type: item.type,
|
||||
status: item.res.status ?? 'pending',
|
||||
transitPath: true,
|
||||
walk: seg.walk,
|
||||
color: seg.walk ? '#64748b' : (seg.color || '#7c3aed'),
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: seg.coords.map(([lat, lng]) => [lng, lat]),
|
||||
},
|
||||
}))
|
||||
}
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = this.roadRoutes.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
resId: item.res.id,
|
||||
type: item.type,
|
||||
status: item.res.status ?? 'pending',
|
||||
transitPath: false,
|
||||
walk: false,
|
||||
color: null as string | null,
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: seg.map(([lat, lng]) => [lng, lat]),
|
||||
},
|
||||
}))
|
||||
})
|
||||
const features = visibleItems.flatMap(item => item.arcs.map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
resId: item.res.id,
|
||||
type: item.type,
|
||||
status: item.res.status ?? 'pending',
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: seg.map(([lat, lng]) => [lng, lat]),
|
||||
},
|
||||
})))
|
||||
const src = map.getSource(RESERVATION_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined
|
||||
src?.setData({ type: 'FeatureCollection', features })
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
/**
|
||||
* Real-path geometry for transit journeys on the map (#1065). MOTIS delivers
|
||||
* each leg's shape as a Google-encoded polyline; we store it on
|
||||
* metadata.transit.legs[].geometry and decode it here so the map can draw the
|
||||
* actual rail/bus alignment instead of a straight line.
|
||||
*/
|
||||
|
||||
export interface TransitMapSegment {
|
||||
coords: [number, number][]
|
||||
color: string | null
|
||||
walk: boolean
|
||||
}
|
||||
|
||||
/** Google polyline decoding with a configurable precision (MOTIS uses 6). */
|
||||
export function decodePolyline(encoded: string, precision = 6): [number, number][] {
|
||||
const factor = Math.pow(10, precision)
|
||||
const coords: [number, number][] = []
|
||||
let index = 0
|
||||
let lat = 0
|
||||
let lng = 0
|
||||
while (index < encoded.length) {
|
||||
for (const which of [0, 1] as const) {
|
||||
let result = 0
|
||||
let shift = 0
|
||||
let byte = 0x20
|
||||
while (byte >= 0x20) {
|
||||
if (index >= encoded.length) return coords
|
||||
byte = encoded.charCodeAt(index++) - 63
|
||||
result |= (byte & 0x1f) << shift
|
||||
shift += 5
|
||||
}
|
||||
const delta = result & 1 ? ~(result >> 1) : result >> 1
|
||||
if (which === 0) lat += delta
|
||||
else lng += delta
|
||||
}
|
||||
coords.push([lat / factor, lng / factor])
|
||||
}
|
||||
return coords
|
||||
}
|
||||
|
||||
/**
|
||||
* The decoded per-leg segments of a transit reservation, or [] when it has no
|
||||
* stored geometry (pre-geometry entries fall back to the straight line).
|
||||
*/
|
||||
export function getTransitMapSegments(res: Reservation): TransitMapSegment[] {
|
||||
if (res.type !== 'transit') return []
|
||||
let meta: any = res.metadata
|
||||
if (typeof meta === 'string') {
|
||||
try { meta = JSON.parse(meta) } catch { return [] }
|
||||
}
|
||||
const legs = meta?.transit?.legs
|
||||
if (!Array.isArray(legs)) return []
|
||||
const out: TransitMapSegment[] = []
|
||||
for (const leg of legs) {
|
||||
if (!leg?.geometry || typeof leg.geometry !== 'string') continue
|
||||
const coords = decodePolyline(leg.geometry, typeof leg.geometry_precision === 'number' ? leg.geometry_precision : 6)
|
||||
if (coords.length < 2) continue
|
||||
out.push({ coords, color: leg.line_color || null, walk: leg.mode === 'WALK' })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
|
||||
import { getFlightLegs } from '../../utils/flightLegs'
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
@@ -243,21 +243,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'train') {
|
||||
const legs = getTrainLegs(r)
|
||||
if (legs.length > 1) {
|
||||
// Multi-leg: one line per leg so every train number + segment route shows.
|
||||
subtitleLines = legs.map(l =>
|
||||
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
|
||||
.filter(Boolean).join(' · '))
|
||||
.filter(Boolean)
|
||||
} else {
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : ''
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
|
||||
@@ -71,36 +71,6 @@ describe('DayDetailPanel', () => {
|
||||
expect(screen.getByText('Day in Paris')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Inline rename (#1065 — moved here from the sidebar pencil) ──────────────
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-064: pencil next to the title renames the day (Enter commits)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUpdateDayTitle = vi.fn();
|
||||
render(<DayDetailPanel {...defaultProps} onUpdateDayTitle={onUpdateDayTitle} />);
|
||||
await user.click(screen.getByLabelText('Edit'));
|
||||
const input = await screen.findByDisplayValue('Day in Paris');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'New Title');
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onUpdateDayTitle).toHaveBeenCalledWith(1, 'New Title');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-065: Escape cancels the rename without saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUpdateDayTitle = vi.fn();
|
||||
render(<DayDetailPanel {...defaultProps} onUpdateDayTitle={onUpdateDayTitle} />);
|
||||
await user.click(screen.getByLabelText('Edit'));
|
||||
await screen.findByDisplayValue('Day in Paris');
|
||||
await user.keyboard('{Escape}');
|
||||
expect(onUpdateDayTitle).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Day in Paris')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-066: no rename pencil without the onUpdateDayTitle prop', () => {
|
||||
render(<DayDetailPanel {...defaultProps} />);
|
||||
expect(screen.queryByLabelText('Edit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-004: shows day number when title is null', () => {
|
||||
const untitled = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: null });
|
||||
render(<DayDetailPanel {...defaultProps} day={untitled} days={[untitled]} />);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Hash, Pencil, Plane, Utensils, Train, Car, Ship, Ticket, FileText, Users, ChevronsDown, ChevronsUp, TramFront } from 'lucide-react'
|
||||
import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Hash, Pencil, Plane, Utensils, Train, Car, Ship, Ticket, FileText, Users, ChevronsDown, ChevronsUp } from 'lucide-react'
|
||||
|
||||
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, transit: TramFront, event: Ticket, tour: Users, other: FileText }
|
||||
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', transit: '#7c3aed', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
||||
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
||||
import { weatherApi, accommodationsApi } from '../../api/client'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
@@ -60,11 +60,9 @@ interface DayDetailPanelProps {
|
||||
collapsed?: boolean
|
||||
onToggleCollapse?: () => void
|
||||
mobile?: boolean
|
||||
/** Rename the day from here — the sidebar pencil moved to the transit search (#1065). */
|
||||
onUpdateDayTitle?: (dayId: number, title: string) => void
|
||||
}
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false, onUpdateDayTitle }: DayDetailPanelProps) {
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false }: DayDetailPanelProps) {
|
||||
const { t, language, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
@@ -80,22 +78,6 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const unit = isFahrenheit ? '°F' : '°C'
|
||||
const collapsed = collapsedProp
|
||||
const toggleCollapse = () => onToggleCollapse?.()
|
||||
|
||||
// Inline day rename (#1065) — took over from the sidebar's pencil, which the
|
||||
// transit search button replaced.
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleDraft, setTitleDraft] = useState('')
|
||||
const titleInputRef = useRef<HTMLInputElement | null>(null)
|
||||
useEffect(() => { if (editingTitle) titleInputRef.current?.focus() }, [editingTitle])
|
||||
const startRename = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setTitleDraft(day?.title || '')
|
||||
setEditingTitle(true)
|
||||
}
|
||||
const commitRename = () => {
|
||||
setEditingTitle(false)
|
||||
if (day && onUpdateDayTitle) onUpdateDayTitle(day.id, titleDraft.trim())
|
||||
}
|
||||
const {
|
||||
weather, loading, accommodation, setAccommodation, dayAccommodations, setDayAccommodations,
|
||||
accommodations, setAccommodations, showHotelPicker, setShowHotelPicker,
|
||||
@@ -151,35 +133,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<Calendar size={collapsed ? 16 : 20} className="text-content" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{editingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
value={titleDraft}
|
||||
onChange={e => setTitleDraft(e.target.value)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTitle(false) }}
|
||||
placeholder={t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
|
||||
className="text-content"
|
||||
style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', padding: 0, fontFamily: 'inherit', fontSize: 15, fontWeight: 700, borderBottom: '1.5px solid var(--text-primary)' }}
|
||||
/>
|
||||
) : collapsed ? (
|
||||
<div className="text-content" style={{ fontSize: 13, fontWeight: 700, transition: 'font-size 0.15s ease' }}>
|
||||
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
|
||||
{formattedDate && <span className="text-content-muted" style={{ fontWeight: 500, marginLeft: 8 }}>{formattedDate}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 15, fontWeight: 700, transition: 'font-size 0.15s ease', minWidth: 0 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
|
||||
</span>
|
||||
{canEditDays && onUpdateDayTitle && (
|
||||
<button onClick={startRename} aria-label={t('common.edit')} title={t('common.edit')} className="text-content-faint" style={{ border: 'none', background: 'none', padding: 3, cursor: 'pointer', display: 'flex', flexShrink: 0 }}>
|
||||
<Pencil size={12} strokeWidth={1.8} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-content" style={{ fontSize: collapsed ? 13 : 15, fontWeight: 700, transition: 'font-size 0.15s ease' }}>
|
||||
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
|
||||
{collapsed && formattedDate && <span className="text-content-muted" style={{ fontWeight: 500, marginLeft: 8 }}>{formattedDate}</span>}
|
||||
</div>
|
||||
{!collapsed && formattedDate && <div className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 1 }}>{formattedDate}</div>}
|
||||
</div>
|
||||
<button onClick={(e) => { e.stopPropagation(); toggleCollapse() }} title={collapsed ? t('common.expand') : t('common.collapse')}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
|
||||
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
|
||||
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route, TramFront,
|
||||
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
|
||||
Wine, ParkingSquare, Fuel, Footprints, Mountain, Waves, Sun, Umbrella, Music, Landmark, Gift,
|
||||
} from 'lucide-react'
|
||||
|
||||
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transit: TramFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
|
||||
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
|
||||
|
||||
export const NOTE_ICONS = [
|
||||
{ id: 'FileText', Icon: FileText },
|
||||
@@ -50,4 +50,4 @@ export const TYPE_ICONS = {
|
||||
transport_other: '🧭', event: '🎫', other: '📋',
|
||||
}
|
||||
|
||||
export const TRANSPORT_DETAIL_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#059669', ferry: '#0d9488', bicycle: '#84cc16', taxi: '#ca8a04', car: '#6b7280', cruise: '#0ea5e9', transit: '#7c3aed', transport_other: '#6b7280' }
|
||||
export const TRANSPORT_DETAIL_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#059669', ferry: '#0d9488', bicycle: '#84cc16', taxi: '#ca8a04', car: '#6b7280', cruise: '#0ea5e9', transport_other: '#6b7280' }
|
||||
|
||||
@@ -129,7 +129,6 @@ beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
// Reset mutable day-notes state
|
||||
mockDayNotesState.noteUi = {}
|
||||
mockDayNotesState.dayNotes = {}
|
||||
@@ -298,92 +297,41 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.getByText('Louvre Museum')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Transit search button (#1065 — replaced the rename pencil; renaming
|
||||
// moved next to the day name in the day detail panel) ─────────────────
|
||||
// ── Day title editing ───────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-015: transit button opens the route search for the day', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-015: clicking edit button enters edit mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const onPlanTransit = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onPlanTransit })} />)
|
||||
await user.click(screen.getByLabelText('Public transit'))
|
||||
expect(onPlanTransit).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-016: transit button is absent without the onPlanTransit prop', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
expect(screen.queryByLabelText('Public transit')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-017: the day header no longer has a rename pencil (#1065)', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onPlanTransit: vi.fn() })} />)
|
||||
expect(screen.queryByLabelText('Edit')).not.toBeInTheDocument()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
await user.click(screen.getByLabelText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-104: a transit journey renders line chips and opens its itinerary view, not the edit form (#1065)', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-016: pressing Enter commits title', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEditTransport = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const res = {
|
||||
...buildReservation({
|
||||
id: 300, type: 'transit', title: 'Fernsehturm → Zoo',
|
||||
reservation_time: '2025-06-01T08:30:00', day_id: 10,
|
||||
}),
|
||||
metadata: {
|
||||
transit: {
|
||||
provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240,
|
||||
legs: [
|
||||
{ mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } },
|
||||
{ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', line_text_color: '#FFFFFF', headsign: 'Ruhleben', duration: 1440, stops: 6, from: { name: 'Alexanderplatz', time: '08:36' }, to: { name: 'Zoo', time: '09:00' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
const onOpenTransit = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [res as any], onEditTransport, onOpenTransit })} />)
|
||||
// Line chip + transfer summary render inline in the timeline row; the
|
||||
// title uses an arrow icon, so its parts are separate text nodes.
|
||||
expect(screen.getByText('U2')).toBeInTheDocument()
|
||||
// Transfer counts stay out of the compact row — the chips say it all.
|
||||
expect(screen.queryByText(/1 transfers/)).not.toBeInTheDocument()
|
||||
// Clicking the row opens the journey view — not the edit form.
|
||||
await user.click(screen.getByText('Fernsehturm'))
|
||||
expect(onEditTransport).not.toHaveBeenCalled()
|
||||
expect(onOpenTransit).toHaveBeenCalledWith(expect.objectContaining({ id: 300 }))
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||
const onUpdateDayTitle = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||
// Enter edit mode
|
||||
await user.click(screen.getByLabelText('Edit'))
|
||||
const input = await screen.findByDisplayValue('Original Title')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'New Title')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onUpdateDayTitle).toHaveBeenCalledWith(10, 'New Title')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-105: the transit row folds its itinerary out inline (#1065)', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-017: pressing Escape cancels edit', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const res = {
|
||||
...buildReservation({ id: 301, type: 'transit', title: 'A → B', reservation_time: '2025-06-01T08:30:00', day_id: 10 }),
|
||||
metadata: {
|
||||
transit: {
|
||||
provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240,
|
||||
legs: [
|
||||
{ mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } },
|
||||
{ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben', duration: 1440, stops: 6, from: { name: 'Alexanderplatz', time: '08:36', track: '2' }, to: { name: 'Zoo', time: '09:00' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_date: null, local_time: null },
|
||||
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 3, lng: 4, timezone: null, local_date: null, local_time: null },
|
||||
],
|
||||
}
|
||||
const onToggleConnection = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [res as any], onOpenTransit: vi.fn(), onToggleConnection, visibleConnectionIds: [] })} />)
|
||||
// No map-connections toggle on transit rows — the expander replaces it.
|
||||
expect(screen.queryByTitle(/connections/i)).not.toBeInTheDocument()
|
||||
// Collapsed: no stop names beyond the chips.
|
||||
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByLabelText('Expand'))
|
||||
expect(await screen.findByText('Alexanderplatz')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
|
||||
await user.click(screen.getByLabelText('Collapse'))
|
||||
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
await user.click(screen.getByLabelText('Edit'))
|
||||
const input = await screen.findByDisplayValue('Original Title')
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Original Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Day info button ─────────────────────────────────────────────────────
|
||||
@@ -694,7 +642,20 @@ describe('DayPlanSidebar', () => {
|
||||
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array)))
|
||||
})
|
||||
|
||||
// Day-title renaming moved to DayDetailPanel (#1065) — covered there.
|
||||
// ── Title blur commits ───────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-042: blurring title input commits the edit', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdateDayTitle = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||
await user.click(screen.getByLabelText('Edit'))
|
||||
const input = await screen.findByDisplayValue('Old Title')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'New Title')
|
||||
fireEvent.blur(input)
|
||||
await waitFor(() => expect(onUpdateDayTitle).toHaveBeenCalledWith(10, 'New Title'))
|
||||
})
|
||||
|
||||
// ── ICS export button ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark } from 'lucide-react'
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
@@ -37,7 +37,6 @@ import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
||||
import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal'
|
||||
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
|
||||
import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay'
|
||||
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
|
||||
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
|
||||
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||
@@ -85,10 +84,6 @@ interface DayPlanSidebarProps {
|
||||
onUndo?: () => void
|
||||
onRouteRefresh?: () => void
|
||||
onAddTransport?: (dayId: number) => void
|
||||
/** Opens the public-transit route search for a day (#1065). */
|
||||
onPlanTransit?: (dayId: number) => void
|
||||
/** Opens the journey view for a saved transit entry (#1065). */
|
||||
onOpenTransit?: (reservation: Reservation) => void
|
||||
onEditTransport?: (reservation: Reservation) => void
|
||||
onEditReservation?: (reservation: Reservation) => void
|
||||
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
||||
@@ -96,9 +91,6 @@ interface DayPlanSidebarProps {
|
||||
onScrollTopChange?: (top: number) => void
|
||||
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
|
||||
showRouteToolsWhenExpanded?: boolean
|
||||
/** Mobile: drag & drop reorder is disabled (touch-scroll hijack, #1432); the
|
||||
* grip handle is hidden and the arrow reorder buttons take over instead. */
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,15 +127,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onUndo,
|
||||
onRouteRefresh,
|
||||
onAddTransport,
|
||||
onPlanTransit,
|
||||
onOpenTransit,
|
||||
onEditTransport,
|
||||
onEditReservation,
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded = false,
|
||||
isMobile = false,
|
||||
} = props
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
@@ -157,7 +146,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
|
||||
const [expandedDays, setExpandedDays] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(`day-expanded-${tripId}`)
|
||||
const saved = sessionStorage.getItem(`day-expanded-${tripId}`)
|
||||
if (saved) return new Set<number>(JSON.parse(saved) as number[])
|
||||
} catch {}
|
||||
return new Set<number>(days.map(d => d.id))
|
||||
@@ -191,8 +180,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const [pdfHover, setPdfHover] = useState(false)
|
||||
const [icsHover, setIcsHover] = useState(false)
|
||||
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
|
||||
// Transit rows fold their itinerary out inline (#1065).
|
||||
const [expandedTransitIds, setExpandedTransitIds] = useState<Set<number>>(new Set())
|
||||
const [dropTargetKey, _setDropTargetKey] = useState(null)
|
||||
const dropTargetRef = useRef(null)
|
||||
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||
@@ -264,7 +251,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setExpandedDays(prev => {
|
||||
const n = new Set(prev)
|
||||
days.forEach(d => { if (!prev.has(d.id)) n.add(d.id) })
|
||||
try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
return n
|
||||
})
|
||||
}
|
||||
@@ -297,7 +284,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setExpandedDays(prev => {
|
||||
const n = new Set(prev)
|
||||
n.has(dayId) ? n.delete(dayId) : n.add(dayId)
|
||||
try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
return n
|
||||
})
|
||||
}
|
||||
@@ -1013,17 +1000,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onUndo,
|
||||
onRouteRefresh,
|
||||
onAddTransport,
|
||||
onPlanTransit,
|
||||
onOpenTransit,
|
||||
onEditTransport,
|
||||
expandedTransitIds,
|
||||
setExpandedTransitIds,
|
||||
onEditReservation,
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded,
|
||||
isMobile,
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
@@ -1179,17 +1161,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onUndo,
|
||||
onRouteRefresh,
|
||||
onAddTransport,
|
||||
onPlanTransit,
|
||||
onOpenTransit,
|
||||
onEditTransport,
|
||||
expandedTransitIds,
|
||||
setExpandedTransitIds,
|
||||
onEditReservation,
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded,
|
||||
isMobile,
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
@@ -1495,13 +1472,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const div = '1px solid var(--border-faint)'
|
||||
return (
|
||||
<div className="dp-day-actions" style={{ alignSelf: 'flex-start', flexShrink: 0, display: 'grid', gridTemplateColumns: '1fr 1fr', border: div, borderRadius: 9, overflow: 'hidden' }}>
|
||||
{/* Public transit search (#1065) — replaced the rename pencil,
|
||||
which moved next to the day name in the day detail view. */}
|
||||
{onPlanTransit ? (
|
||||
<button onClick={e => { e.stopPropagation(); onPlanTransit(day.id) }} title={t('transit.title')} aria-label={t('transit.title')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
|
||||
<TramFront size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
) : <div style={{ borderRight: div, borderBottom: div }} />}
|
||||
<button onClick={e => startEditTitle(day, e)} aria-label={t('common.edit')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
|
||||
<Pencil size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
{onAddTransport ? (
|
||||
<button onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
|
||||
<Plus size={14} strokeWidth={1.8} />
|
||||
@@ -1654,9 +1627,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
return (
|
||||
<React.Fragment key={`place-${assignment.id}`}>
|
||||
<div
|
||||
draggable={canEditDays && !isMobile}
|
||||
draggable={canEditDays}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays || isMobile) { e.preventDefault(); return }
|
||||
if (!canEditDays) { e.preventDefault(); return }
|
||||
e.dataTransfer.setData('assignmentId', String(assignment.id))
|
||||
e.dataTransfer.setData('fromDayId', String(day.id))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
@@ -1751,7 +1724,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
opacity: isDraggingThis ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && !isMobile && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div
|
||||
@@ -1959,10 +1932,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
if (res.__leg) {
|
||||
// One leg of a multi-leg flight/train — show this segment's own detail.
|
||||
const parts = res.type === 'train'
|
||||
? [res.__leg.train_number, res.__leg.platform ? `Gl. ${res.__leg.platform}` : '', res.__leg.seat ? `Sitz ${res.__leg.seat}` : ''].filter(Boolean)
|
||||
: [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||
// One leg of a multi-leg flight — show this segment's own route.
|
||||
const parts = [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||
if (res.__leg.from || res.__leg.to)
|
||||
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(' · ')
|
||||
@@ -1975,11 +1946,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
// A transit journey (#1065) renders its itinerary inline —
|
||||
// line badges in their colors instead of a plain subtitle,
|
||||
// so the connection is recognisable at a glance.
|
||||
const transitMeta = res.type === 'transit' && meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null
|
||||
|
||||
// Multi-day span phase (single-leg / non-flight only — a
|
||||
// multi-leg flight is shown as one row per leg, see below).
|
||||
const spanLabel = res.__leg ? null : getSpanLabel(res, spanPhase)
|
||||
@@ -1990,16 +1956,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
|
||||
<div
|
||||
onClick={() => {
|
||||
const target = reservations.find(x => x.id === res.id) ?? res
|
||||
// A transit journey opens its own journey view — the rich
|
||||
// stop-by-stop breakdown with its booking fields, never the
|
||||
// generic edit form (#1065).
|
||||
if (transitMeta) {
|
||||
if (onOpenTransit) onOpenTransit(target)
|
||||
else setTransportDetail(target)
|
||||
return
|
||||
}
|
||||
if (!canEditDays) return
|
||||
const target = reservations.find(x => x.id === res.id) ?? res
|
||||
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
|
||||
else onEditReservation?.(target)
|
||||
}}
|
||||
@@ -2011,9 +1969,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
|
||||
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
||||
}}
|
||||
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile}
|
||||
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays || spanPhase === 'middle' || res.__leg || isMobile) { e.preventDefault(); return }
|
||||
if (!canEditDays || spanPhase === 'middle' || res.__leg) { e.preventDefault(); return }
|
||||
// setData is required for the drag to start reliably (Firefox) and
|
||||
// matches how place/note items initiate their drag.
|
||||
e.dataTransfer.setData('reservationId', String(res.id))
|
||||
@@ -2057,12 +2015,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
|
||||
borderBottom: showDropLineAfter ? '2px solid var(--text-primary)' : undefined,
|
||||
background: `${color}08`,
|
||||
cursor: (transitMeta || (canEditDays && onEditTransport)) ? 'pointer' : 'default', userSelect: 'none',
|
||||
cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none',
|
||||
transition: 'background 0.1s',
|
||||
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile && (
|
||||
{canEditDays && spanPhase !== 'middle' && !res.__leg && (
|
||||
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
@@ -2083,8 +2041,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{spanLabel}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||
{transitMeta ? <TransitTitle title={res.title} iconSize={11} /> : res.title}
|
||||
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{res.title}
|
||||
</span>
|
||||
{(() => {
|
||||
const { time: dispTime } = splitReservationDateTime(displayTime)
|
||||
@@ -2101,44 +2059,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{transitMeta ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: 3 }}>
|
||||
<TransitLegChips legs={transitMeta.legs} size="sm" t={t} />
|
||||
</div>
|
||||
) : subtitle && (
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{transitMeta && (() => {
|
||||
const expanded = expandedTransitIds.has(res.id)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setExpandedTransitIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(res.id)) next.delete(res.id); else next.add(res.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
title={t(expanded ? 'common.collapse' : 'common.expand')}
|
||||
aria-label={t(expanded ? 'common.collapse' : 'common.expand')}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none', width: 26, height: 26, borderRadius: 6,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer', border: 'none',
|
||||
background: 'transparent', color: 'var(--text-faint)',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
{!transitMeta && onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
|
||||
{onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
|
||||
const active = visibleConnectionIds.includes(res.id)
|
||||
return (
|
||||
<button
|
||||
@@ -2162,11 +2089,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{transitMeta && expandedTransitIds.has(res.id) && (
|
||||
<div style={{ margin: '2px 8px 4px', padding: '9px 10px 9px 12px', borderRadius: 6, border: `1px solid ${color}26`, background: `${color}05` }}>
|
||||
<TransitItineraryInline legs={transitMeta.legs} t={t} />
|
||||
</div>
|
||||
)}
|
||||
{routeLegs[day.id]?.[res.id] && <RouteConnector seg={routeLegs[day.id]![res.id]} profile={routeProfile} />}
|
||||
</React.Fragment>
|
||||
)
|
||||
@@ -2179,8 +2101,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
return (
|
||||
<React.Fragment key={`note-${note.id}`}>
|
||||
<div
|
||||
draggable={canEditDays && !isMobile}
|
||||
onDragStart={e => { if (!canEditDays || isMobile) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
||||
draggable={canEditDays}
|
||||
onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
||||
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||
onDrop={e => {
|
||||
@@ -2248,7 +2170,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{canEditDays && !isMobile && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||
@@ -2469,7 +2391,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
transportDetail={transportDetail}
|
||||
setTransportDetail={setTransportDetail}
|
||||
onNavigateToFiles={onNavigateToFiles}
|
||||
onEdit={canEditDays && onEditTransport ? (res) => { setTransportDetail(null); onEditTransport(res) } : undefined}
|
||||
t={t}
|
||||
locale={locale}
|
||||
timeFormat={timeFormat}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Ticket, FileText, ExternalLink, Footprints, ArrowRight, Pencil } from 'lucide-react'
|
||||
import { Ticket, FileText, ExternalLink } from 'lucide-react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
@@ -13,15 +13,13 @@ interface DayPlanSidebarTransportDetailModalProps {
|
||||
transportDetail: Reservation | null
|
||||
setTransportDetail: (v: Reservation | null) => void
|
||||
onNavigateToFiles?: () => void
|
||||
/** Opens the edit form for this reservation (shown as a footer action). */
|
||||
onEdit?: (res: Reservation) => void
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
locale: string
|
||||
timeFormat: string
|
||||
}
|
||||
|
||||
export function DayPlanSidebarTransportDetailModal({
|
||||
transportDetail, setTransportDetail, onNavigateToFiles, onEdit, t, locale, timeFormat,
|
||||
transportDetail, setTransportDetail, onNavigateToFiles, t, locale, timeFormat,
|
||||
}: DayPlanSidebarTransportDetailModalProps) {
|
||||
if (!transportDetail) return null
|
||||
return ReactDOM.createPortal(
|
||||
@@ -119,69 +117,6 @@ export function DayPlanSidebarTransportDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Public-transit itinerary (#1065) — legs from the transit search */}
|
||||
{meta.transit?.legs && Array.isArray(meta.transit.legs) && meta.transit.legs.length > 0 && (
|
||||
<>
|
||||
{/* journey summary: duration · transfers · walking */}
|
||||
<div className="bg-surface-tertiary text-content" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, padding: '9px 12px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, flexWrap: 'wrap' }}>
|
||||
{meta.transit.duration > 0 && (
|
||||
<span>{Math.floor(meta.transit.duration / 3600) > 0 ? `${Math.floor(meta.transit.duration / 3600)} h ${Math.round((meta.transit.duration % 3600) / 60)} min` : t('transit.min', { count: Math.round(meta.transit.duration / 60) })}</span>
|
||||
)}
|
||||
<span className="text-content-faint">·</span>
|
||||
<span>{meta.transit.transfers > 0 ? t('transit.transfers', { count: meta.transit.transfers }) : t('transit.direct')}</span>
|
||||
{meta.transit.walk_seconds > 59 && (
|
||||
<>
|
||||
<span className="text-content-faint">·</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Footprints size={13} /> {t('transit.min', { count: Math.round(meta.transit.walk_seconds / 60) })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-surface-tertiary" style={{ padding: '10px 12px', borderRadius: 8 }}>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 8 }}>
|
||||
{t('transit.itinerary')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{meta.transit.legs.map((leg: { mode?: string; line?: string | null; line_color?: string | null; line_text_color?: string | null; headsign?: string | null; duration?: number; stops?: number; from?: { name?: string; time?: string | null }; to?: { name?: string; time?: string | null } }, i: number) => {
|
||||
const isWalk = leg.mode === 'WALK'
|
||||
const mins = leg.duration ? Math.round(leg.duration / 60) : null
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
{isWalk ? (
|
||||
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0, paddingTop: 1 }}>
|
||||
<Footprints size={12} />
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', background: leg.line_color || 'var(--bg-hover)', color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)', borderRadius: 5, padding: '1px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
|
||||
{leg.line || leg.mode}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{isWalk
|
||||
? <span className="text-content-muted">{t('transit.walkTo', { name: leg.to?.name || '' })}</span>
|
||||
: <>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.from?.name}</span>
|
||||
<ArrowRight size={10} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.to?.name}</span>
|
||||
</>}
|
||||
</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', marginTop: 1 }}>
|
||||
{[
|
||||
leg.from?.time && !isWalk ? `${leg.from.time}${leg.to?.time ? ` – ${leg.to.time}` : ''}` : null,
|
||||
mins ? t('transit.min', { count: mins }) : null,
|
||||
!isWalk && leg.stops ? t('transit.stops', { count: leg.stops }) : null,
|
||||
!isWalk && leg.headsign ? `→ ${leg.headsign}` : null,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Notizen */}
|
||||
{res.notes && (
|
||||
<div className="bg-surface-tertiary" style={{ padding: '8px 10px', borderRadius: 8 }}>
|
||||
@@ -227,17 +162,8 @@ export function DayPlanSidebarTransportDetailModal({
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Aktionen */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
{onEdit && (
|
||||
<button onClick={() => onEdit(res)} className="bg-surface-tertiary text-content" style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
border: 'none', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
}}>
|
||||
<Pencil size={12} /> {t('common.edit')}
|
||||
</button>
|
||||
)}
|
||||
{/* Schließen */}
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<button onClick={() => setTransportDetail(null)} className="bg-accent text-accent-text" style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||
|
||||
@@ -259,8 +259,8 @@ describe('PlaceInspector', () => {
|
||||
onRemoveAssignment={onRemoveAssignment}
|
||||
/>
|
||||
);
|
||||
// Find the remove button — it carries the "Remove from Day" label
|
||||
const removeBtn = screen.getByText('Remove from Day').closest('button')!;
|
||||
// Find the remove button — it has "Remove" text (sm:hidden span)
|
||||
const removeBtn = screen.getByText('Remove').closest('button')!;
|
||||
await user.click(removeBtn);
|
||||
// Component calls onRemoveAssignment(selectedDayId, assignmentInDay.id)
|
||||
expect(onRemoveAssignment).toHaveBeenCalledWith(1, 99);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import StatusBadge from '../Collections/StatusBadge'
|
||||
import { mapsApi, pluginsApi } from '../../api/client'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
@@ -16,8 +16,6 @@ import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import PluginFrame from '../Plugins/PluginFrame'
|
||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||
import type { CollectionStatus } from '@trek/shared'
|
||||
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||
@@ -144,21 +142,6 @@ export default function PlaceInspector({
|
||||
leftWidth = 0, rightWidth = 0,
|
||||
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList,
|
||||
}: PlaceInspectorProps) {
|
||||
// Plugins that declared a place-detail slot mount at the bottom of this panel,
|
||||
// scoped to the open place (trip mode only). Inline-filter like the other sites.
|
||||
const placeDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'place-detail')
|
||||
// Extra native rows contributed by placeDetailProvider plugins (#1429). Fail-safe:
|
||||
// any provider error/timeout is dropped server-side, so this only ever adds rows.
|
||||
const [providerDetails, setProviderDetails] = useState<Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }>>([])
|
||||
const placeIdForDetails = mode === 'trip' ? place?.id : undefined
|
||||
useEffect(() => {
|
||||
if (placeIdForDetails == null) { setProviderDetails([]); return }
|
||||
let cancelled = false
|
||||
pluginsApi.placeDetails(placeIdForDetails)
|
||||
.then((d) => { if (!cancelled) setProviderDetails((d.providers || []).filter((p) => Array.isArray(p.items) && p.items.length > 0)) })
|
||||
.catch(() => { if (!cancelled) setProviderDetails([]) })
|
||||
return () => { cancelled = true }
|
||||
}, [placeIdForDetails])
|
||||
const { t, locale, language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
@@ -373,34 +356,6 @@ export default function PlaceInspector({
|
||||
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading}
|
||||
distanceUnit={distanceUnit} />
|
||||
|
||||
{/* Extra native rows from placeDetailProvider plugins (#1429). */}
|
||||
{mode === 'trip' && providerDetails.length > 0 && (
|
||||
<div className="bg-surface-hover" style={{ borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{providerDetails.flatMap((p) => p.items.map((it, i) => (
|
||||
<div key={`${p.pluginId}-${i}`} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>
|
||||
<span className="text-content-secondary" style={{ fontWeight: 500, flexShrink: 0 }}>{it.label}</span>
|
||||
{it.url
|
||||
? <a href={it.url} target="_blank" rel="noreferrer noopener" className="text-accent" style={{ textDecoration: 'none', textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis' }}>{it.value ?? '↗'}</a>
|
||||
: <span className="text-content-muted" style={{ textAlign: 'right' }}>{it.value}</span>}
|
||||
</div>
|
||||
)))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Place-detail plugin slots (#1429): sandboxed, scoped to this place. */}
|
||||
{mode === 'trip' && placeDetailPlugins.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{placeDetailPlugins.map((p) => {
|
||||
const tid = (place as { trip_id?: number | string }).trip_id
|
||||
return (
|
||||
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
|
||||
<PluginFrame pluginId={p.id} tripId={tid != null ? String(tid) : null} placeId={String(place.id)} title={p.name} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
@@ -417,7 +372,7 @@ export default function PlaceInspector({
|
||||
{mode === 'trip' && selectedDayId && (
|
||||
assignmentInDay ? (
|
||||
<ActionButton onClick={() => onRemoveAssignment?.(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||
label={<span className="hidden sm:inline">{t('inspector.removeFromDay')}</span>} />
|
||||
label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">{t('inspector.remove')}</span></>} />
|
||||
) : (
|
||||
<ActionButton onClick={() => onAssignToDay?.(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
|
||||
)
|
||||
|
||||
@@ -23,13 +23,13 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
} = S
|
||||
return (
|
||||
<div
|
||||
onDragEnter={isMobile ? undefined : handleSidebarDragEnter}
|
||||
onDragOver={isMobile ? undefined : handleSidebarDragOver}
|
||||
onDragLeave={isMobile ? undefined : handleSidebarDragLeave}
|
||||
onDrop={isMobile ? undefined : handleSidebarDrop}
|
||||
onDragEnter={handleSidebarDragEnter}
|
||||
onDragOver={handleSidebarDragOver}
|
||||
onDragLeave={handleSidebarDragLeave}
|
||||
onDrop={handleSidebarDrop}
|
||||
style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "var(--font-system)", position: 'relative' }}
|
||||
>
|
||||
{!isMobile && sidebarDragOver && <PlacesDropOverlay {...S} />}
|
||||
{sidebarDragOver && <PlacesDropOverlay {...S} />}
|
||||
{/* Kopfbereich */}
|
||||
<PlacesHeader {...S} />
|
||||
|
||||
|
||||
@@ -36,9 +36,8 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
ref={element => registerPlaceRow(place.id, element)}
|
||||
aria-selected={isSelected}
|
||||
data-place-id={place.id}
|
||||
draggable={!selectMode && !isMobile}
|
||||
draggable={!selectMode}
|
||||
onDragStart={e => {
|
||||
if (isMobile) { e.preventDefault(); return }
|
||||
e.dataTransfer.setData('placeId', String(place.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
@@ -56,7 +55,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
cursor: selectMode || isMobile ? 'pointer' : 'grab',
|
||||
cursor: selectMode ? 'pointer' : 'grab',
|
||||
background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
|
||||
@@ -409,12 +409,6 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-029b: file input accepts pkpass (#1448)', () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
expect(fileInput.accept).toContain('.pkpass');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-030: hotel type — saving calls onSave with correct hotel shape', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
@@ -682,7 +682,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,.pkpass,.pkpasses,image/*,application/vnd.apple.pkpass,application/vnd.apple.pkpasses" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} className="text-content-faint" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
|
||||
@@ -9,10 +9,8 @@ import {
|
||||
Plane, Hotel, Utensils, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Ticket, FileText, MapPin,
|
||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle, Download,
|
||||
TramFront, Footprints, StickyNote,
|
||||
} from 'lucide-react'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import { TransitTitle, TransitLegChips, TransitMetaBadges, fmtTransitDuration } from './transitDisplay'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
@@ -39,7 +37,6 @@ const TYPE_OPTIONS = [
|
||||
{ value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike, color: '#84cc16' },
|
||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' },
|
||||
{ value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat, color: '#0d9488' },
|
||||
{ value: 'transit', labelKey: 'reservations.type.transit', Icon: TramFront, color: '#7c3aed' },
|
||||
{ value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route, color: '#6b7280' },
|
||||
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' },
|
||||
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' },
|
||||
@@ -112,7 +109,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
const hasCode = !!r.confirmation_number
|
||||
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
||||
|
||||
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
|
||||
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
||||
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
||||
const isHotel = r.type === 'hotel'
|
||||
// For a hotel linked to an accommodation, the accommodation's own start/end days are
|
||||
@@ -494,95 +491,6 @@ function Section({ title, count, children, defaultOpen = true, accent, storageKe
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A transit journey's own card (#1065) — leg chips + journey stats instead of
|
||||
* the generic booking layout. Clicking anywhere opens the journey view.
|
||||
*/
|
||||
function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit }: {
|
||||
r: Reservation
|
||||
days: Day[]
|
||||
onOpen: (r: Reservation) => void
|
||||
onDelete: (id: number) => void
|
||||
canEdit: boolean
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h'
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const meta = typeof r.metadata === 'string' ? (() => { try { return JSON.parse(r.metadata || '{}') } catch { return {} } })() : (r.metadata || {})
|
||||
const transit = meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null
|
||||
const { date, time } = splitReservationDateTime(r.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(r.reservation_end_time)
|
||||
const day = r.day_id ? days.find(d => d.id === r.day_id) : undefined
|
||||
const dateStr = date ? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) : null
|
||||
const mins = transit?.duration ? Math.round(transit.duration / 60) : null
|
||||
return (
|
||||
<div
|
||||
className="bg-surface-card"
|
||||
onClick={() => onOpen(r)}
|
||||
style={{ borderRadius: 12, border: '1px solid rgba(124,58,237,0.22)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 9, cursor: 'pointer', transition: 'box-shadow 0.15s ease' }}
|
||||
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
|
||||
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ width: 34, height: 34, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 10, background: 'rgba(124,58,237,0.1)' }}>
|
||||
<TramFront size={16} strokeWidth={1.8} color="#7c3aed" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<TransitTitle title={r.title} iconSize={12} />
|
||||
</div>
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<TransitMetaBadges size="sm" items={[
|
||||
{ text: day ? (day.title || t('dayplan.dayN', { n: day.day_number })) : '' },
|
||||
{ icon: Calendar, text: dateStr || '' },
|
||||
{ icon: Clock, text: time ? `${formatTime(time, locale, timeFormat)}${endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}` : '' },
|
||||
{ text: transit?.duration ? fmtTransitDuration(transit.duration, t) : '' },
|
||||
]} />
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setConfirmOpen(true) }}
|
||||
title={t('common.delete')}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{ appearance: 'none', border: 'none', width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center', cursor: 'pointer', flexShrink: 0 }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.08)'; e.currentTarget.style.color = '#ef4444' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{transit && (
|
||||
<div style={{ paddingLeft: 44 }}>
|
||||
<TransitLegChips legs={transit.legs} size="md" t={t} />
|
||||
</div>
|
||||
)}
|
||||
{r.notes && (
|
||||
<div className="text-content-faint" style={{ paddingLeft: 44, display: 'flex', alignItems: 'center', gap: 5, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', minWidth: 0 }}>
|
||||
<StickyNote size={11} style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]} allowedElements={['strong', 'em', 'del', 'code', 'a']} unwrapDisallowed>{r.notes.split('\n')[0]}</Markdown>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{confirmOpen && ReactDOM.createPortal(
|
||||
<div className="bg-[rgba(0,0,0,0.35)]" style={{ position: 'fixed', inset: 0, zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={e => { e.stopPropagation(); setConfirmOpen(false) }}>
|
||||
<div className="bg-surface-card" style={{ borderRadius: 14, padding: 20, width: 340, boxShadow: '0 16px 48px rgba(0,0,0,0.22)' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))', marginBottom: 6 }}>{t('reservations.confirm.deleteTitle')}</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', marginBottom: 14 }}>{t('reservations.confirm.deleteBody', { name: r.title })}</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={e => { e.stopPropagation(); setConfirmOpen(false) }} className="text-content-muted" style={{ padding: '7px 14px', borderRadius: 9, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={e => { e.stopPropagation(); setConfirmOpen(false); onDelete(r.id) }} style={{ padding: '7px 14px', borderRadius: 9, border: 'none', background: '#ef4444', color: '#fff', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ReservationsPanelProps {
|
||||
tripId: number
|
||||
reservations: Reservation[]
|
||||
@@ -631,12 +539,8 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)),
|
||||
[reservations, typeFilters])
|
||||
|
||||
// Automated public transit (#1065) gets its own section — journeys planned via
|
||||
// the transit search live alongside manual transports without mixing in.
|
||||
const transitEntries = filtered.filter(r => r.type === 'transit')
|
||||
const nonTransit = filtered.filter(r => r.type !== 'transit')
|
||||
const allPending = nonTransit.filter(r => r.status !== 'confirmed')
|
||||
const allConfirmed = nonTransit.filter(r => r.status === 'confirmed')
|
||||
const allPending = filtered.filter(r => r.status !== 'confirmed')
|
||||
const allConfirmed = filtered.filter(r => r.status === 'confirmed')
|
||||
const total = filtered.length
|
||||
|
||||
const usedTypes = useMemo(() => new Set(reservations.map(r => r.type)), [reservations])
|
||||
@@ -774,11 +678,6 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{transitEntries.length > 0 && (
|
||||
<Section title={t('transit.sectionTitle')} count={transitEntries.length} accent="gray" storageKey={`trek:bookings-transit-open:${tripId}`}>
|
||||
{transitEntries.map(r => <TransitJourneyCard key={r.id} r={r} days={days} onOpen={onEdit} onDelete={onDelete} canEdit={canEdit} />)}
|
||||
</Section>
|
||||
)}
|
||||
{allPending.length > 0 && (
|
||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray" storageKey={`trek:bookings-pending-open:${tripId}`}>
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
// FE-PLANNER-TRANSITJOURNEY-001 to 005 — the journey view for a saved transit entry.
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { buildUser, buildReservation } from '../../../tests/helpers/factories'
|
||||
import TransitJourneyModal from './TransitJourneyModal'
|
||||
|
||||
function makeReservation() {
|
||||
return {
|
||||
...buildReservation({ id: 7, type: 'transit', title: 'Fernsehturm → Zoo', reservation_time: '2025-06-01T08:30:00', status: 'confirmed' }),
|
||||
metadata: {
|
||||
transit: {
|
||||
provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240,
|
||||
legs: [
|
||||
{ mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } },
|
||||
{ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', line_text_color: '#FFFFFF', headsign: 'Ruhleben', agency: 'BVG', duration: 1440, stops: 6, from: { name: 'Alexanderplatz', time: '08:36', track: '2' }, to: { name: 'Zoo', time: '09:00' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.52, lng: 13.4, timezone: 'Europe/Berlin', local_date: null, local_time: null },
|
||||
{ role: 'to', sequence: 1, name: 'Zoo', code: null, lat: 52.5, lng: 13.33, timezone: 'Europe/Berlin', local_date: null, local_time: null },
|
||||
],
|
||||
} as any
|
||||
}
|
||||
|
||||
function makeProps(overrides = {}) {
|
||||
return {
|
||||
reservation: makeReservation(),
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn().mockResolvedValue({}),
|
||||
onDelete: vi.fn().mockResolvedValue({}),
|
||||
onChangeRoute: vi.fn(),
|
||||
canEdit: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
|
||||
})
|
||||
|
||||
describe('TransitJourneyModal', () => {
|
||||
it('FE-PLANNER-TRANSITJOURNEY-001: shows summary, line badge, platform and legs', () => {
|
||||
render(<TransitJourneyModal {...makeProps()} />)
|
||||
expect(screen.getByText('U2')).toBeInTheDocument()
|
||||
// stat tiles: value + caption
|
||||
expect(screen.getByText('Transfers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Walking')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Ruhleben/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/BVG/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-002: inline title rename + notes save as a minimal field payload', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = vi.fn().mockResolvedValue({})
|
||||
render(<TransitJourneyModal {...makeProps({ onSave })} />)
|
||||
// The title renames inline in the header via its pencil.
|
||||
await user.click(screen.getByLabelText('Edit'))
|
||||
const titleInput = screen.getByDisplayValue('Fernsehturm → Zoo')
|
||||
await user.clear(titleInput)
|
||||
await user.type(titleInput, 'Zum Zoo')
|
||||
await user.keyboard('{Enter}')
|
||||
await user.type(screen.getByPlaceholderText(/notes/i), 'Take **coffee**')
|
||||
await user.click(screen.getByRole('button', { name: /^Save$/ }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
expect(onSave).toHaveBeenCalledWith({ title: 'Zum Zoo', notes: 'Take **coffee**' })
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-006: no status or booking-code fields; notes support a markdown preview', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TransitJourneyModal {...makeProps()} />)
|
||||
expect(screen.queryByText('Status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Booking code|Confirmation/i)).not.toBeInTheDocument()
|
||||
await user.type(screen.getByPlaceholderText(/notes/i), '**bold** note')
|
||||
await user.click(screen.getByRole('button', { name: 'Preview' }))
|
||||
const bold = document.querySelector('.collab-note-md strong')
|
||||
expect(bold?.textContent).toBe('bold')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-008: existing notes open rendered as markdown, not raw text', () => {
|
||||
const res = { ...makeReservation(), notes: 'bring **wefwe** along' }
|
||||
render(<TransitJourneyModal {...makeProps({ reservation: res })} />)
|
||||
// Preview tab is active on open: bold is rendered, no raw asterisks visible.
|
||||
const bold = document.querySelector('.collab-note-md strong')
|
||||
expect(bold?.textContent).toBe('wefwe')
|
||||
expect(screen.queryByDisplayValue(/\*\*wefwe\*\*/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-007: the markdown toolbar wraps the note text', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TransitJourneyModal {...makeProps()} />)
|
||||
const area = screen.getByPlaceholderText(/notes/i) as HTMLTextAreaElement
|
||||
await user.type(area, 'coffee')
|
||||
area.setSelectionRange(0, 6)
|
||||
await user.click(screen.getByRole('button', { name: 'Bold' }))
|
||||
expect(area.value).toBe('**coffee**')
|
||||
await user.click(screen.getByRole('button', { name: 'Checklist' }))
|
||||
expect((screen.getByPlaceholderText(/notes/i) as HTMLTextAreaElement).value).toMatch(/^- \[ \] /)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-003: change route triggers onChangeRoute', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChangeRoute = vi.fn()
|
||||
render(<TransitJourneyModal {...makeProps({ onChangeRoute })} />)
|
||||
await user.click(screen.getByRole('button', { name: /Change route/ }))
|
||||
expect(onChangeRoute).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-004: delete asks for confirmation, then calls onDelete', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDelete = vi.fn().mockResolvedValue({})
|
||||
render(<TransitJourneyModal {...makeProps({ onDelete })} />)
|
||||
await user.click(screen.getByRole('button', { name: /^Delete$/ }))
|
||||
expect(onDelete).not.toHaveBeenCalled()
|
||||
// Confirm dialog appears — confirm it.
|
||||
const confirmBtns = await screen.findAllByRole('button', { name: /Delete/ })
|
||||
await user.click(confirmBtns[confirmBtns.length - 1])
|
||||
await waitFor(() => expect(onDelete).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-005: read-only without edit rights — no delete/save/change-route', () => {
|
||||
render(<TransitJourneyModal {...makeProps({ canEdit: false })} />)
|
||||
expect(screen.queryByRole('button', { name: /^Delete$/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Change route/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^Save$/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Close/ })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,403 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { ArrowRight, ArrowRightLeft, Bold, Clock, Code, Footprints, Heading2, Italic, Link2, List, ListChecks, MoveRight, Pencil, RefreshCw, Strikethrough, TramFront, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||
import { TransitTitle, TransitMetaBadges, TransitWalkDivider, fmtTransitDuration } from './transitDisplay'
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
/**
|
||||
* The journey view for an automated public-transit entry (#1065): a roomy modal
|
||||
* around the stop-by-stop itinerary. The title renames inline right in the
|
||||
* header, notes get the full width with markdown support, and "Change route"
|
||||
* re-enters the transit search pre-seeded with this journey's route.
|
||||
*/
|
||||
|
||||
interface TransitLegMeta {
|
||||
mode?: string
|
||||
line?: string | null
|
||||
line_color?: string | null
|
||||
line_text_color?: string | null
|
||||
headsign?: string | null
|
||||
agency?: string | null
|
||||
duration?: number
|
||||
stops?: number
|
||||
from?: { name?: string; time?: string | null; track?: string | null }
|
||||
to?: { name?: string; time?: string | null; track?: string | null }
|
||||
}
|
||||
|
||||
interface TransitJourneyModalProps {
|
||||
reservation: Reservation
|
||||
onClose: () => void
|
||||
/** Partial field update — endpoints + itinerary stay untouched. */
|
||||
onSave: (fields: { title: string; notes: string | null }) => Promise<unknown>
|
||||
onDelete: () => Promise<unknown>
|
||||
onChangeRoute: () => void
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
export default function TransitJourneyModal({ reservation, onClose, onSave, onDelete, onChangeRoute, canEdit }: TransitJourneyModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h'
|
||||
const res = reservation
|
||||
const meta = typeof res.metadata === 'string' ? (() => { try { return JSON.parse(res.metadata || '{}') } catch { return {} } })() : (res.metadata || {})
|
||||
const transit = meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null
|
||||
|
||||
const [title, setTitle] = useState(res.title || '')
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [notes, setNotes] = useState(res.notes || '')
|
||||
// Existing notes open rendered; the write tab is for editing.
|
||||
const [notesTab, setNotesTab] = useState<'write' | 'preview'>(() => (res.notes ? 'preview' : 'write'))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const titleInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const notesRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Markdown toolbar: wrap the selection / prefix the current lines, then
|
||||
// restore focus and a sensible cursor.
|
||||
const applyMd = (action: { wrap?: [string, string]; linePrefix?: string }) => {
|
||||
const el = notesRef.current
|
||||
if (!el) return
|
||||
const start = el.selectionStart ?? 0
|
||||
const end = el.selectionEnd ?? 0
|
||||
const value = notes
|
||||
let next = value
|
||||
let selStart = start
|
||||
let selEnd = end
|
||||
if (action.wrap) {
|
||||
const [pre, post] = action.wrap
|
||||
const selected = value.slice(start, end)
|
||||
next = value.slice(0, start) + pre + selected + post + value.slice(end)
|
||||
selStart = start + pre.length
|
||||
selEnd = selStart + selected.length
|
||||
} else if (action.linePrefix) {
|
||||
const prefix = action.linePrefix
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
const block = value.slice(lineStart, end)
|
||||
const prefixed = block.split('\n').map(l => prefix + l).join('\n')
|
||||
next = value.slice(0, lineStart) + prefixed + value.slice(end)
|
||||
selStart = start + prefix.length
|
||||
selEnd = end + (prefixed.length - block.length)
|
||||
}
|
||||
setNotes(next)
|
||||
requestAnimationFrame(() => {
|
||||
el.focus()
|
||||
el.setSelectionRange(selStart, selEnd)
|
||||
})
|
||||
}
|
||||
|
||||
const MD_TOOLS: { Icon: typeof Bold; label: string; action: { wrap?: [string, string]; linePrefix?: string } }[] = [
|
||||
{ Icon: Bold, label: 'Bold', action: { wrap: ['**', '**'] } },
|
||||
{ Icon: Italic, label: 'Italic', action: { wrap: ['*', '*'] } },
|
||||
{ Icon: Strikethrough, label: 'Strikethrough', action: { wrap: ['~~', '~~'] } },
|
||||
{ Icon: Heading2, label: 'Heading', action: { linePrefix: '## ' } },
|
||||
{ Icon: List, label: 'List', action: { linePrefix: '- ' } },
|
||||
{ Icon: ListChecks, label: 'Checklist', action: { linePrefix: '- [ ] ' } },
|
||||
{ Icon: Link2, label: 'Link', action: { wrap: ['[', '](https://)'] } },
|
||||
{ Icon: Code, label: 'Code', action: { wrap: ['`', '`'] } },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(res.title || '')
|
||||
setNotes(res.notes || '')
|
||||
setEditingTitle(false)
|
||||
setNotesTab(res.notes ? 'preview' : 'write')
|
||||
}, [res.id])
|
||||
|
||||
useEffect(() => { if (editingTitle) titleInputRef.current?.focus() }, [editingTitle])
|
||||
|
||||
const dirty = title !== (res.title || '') || notes !== (res.notes || '')
|
||||
|
||||
const save = async () => {
|
||||
if (!title.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ title: title.trim(), notes: notes.trim() || null })
|
||||
onClose()
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const { date, time } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
const dateStr = date ? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }) : ''
|
||||
|
||||
const statTiles = transit ? [
|
||||
{ Icon: Clock, value: transit.duration > 0 ? fmtTransitDuration(transit.duration, t) : '—', label: t('transit.durationLabel') },
|
||||
{ Icon: ArrowRightLeft, value: String(transit.transfers ?? 0), label: t('transit.transfersLabel') },
|
||||
{ Icon: Footprints, value: transit.walk_seconds > 59 ? t('transit.min', { count: Math.round(transit.walk_seconds / 60) }) : '—', label: t('transit.walkLabel') },
|
||||
] : []
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={t('transit.journey')}
|
||||
size="2xl"
|
||||
footer={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{canEdit && (
|
||||
<button onClick={() => setConfirmDelete(true)} aria-label={t('common.delete')} title={t('common.delete')} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5, padding: isMobile ? '9px 11px' : '8px 14px', borderRadius: 10,
|
||||
border: '1px solid rgba(239,68,68,0.3)', background: 'rgba(239,68,68,0.06)', color: '#ef4444',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Trash2 size={13} /> {!isMobile && t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{canEdit && (
|
||||
<button onClick={onChangeRoute} className="text-content-muted" style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 16px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<RefreshCw size={13} /> {t('transit.changeRoute')}
|
||||
</button>
|
||||
)}
|
||||
{canEdit ? (
|
||||
<button onClick={save} disabled={saving || !title.trim() || !dirty} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{
|
||||
padding: '8px 20px', borderRadius: 10, border: 'none',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
opacity: saving || !title.trim() || !dirty ? 0.5 : 1,
|
||||
}}>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={onClose} className="bg-accent text-accent-text" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, fontFamily: 'var(--font-system)' }}>
|
||||
{/* header: icon + inline-renamable title + date/time */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ width: isMobile ? 40 : 48, height: isMobile ? 40 : 48, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 13, background: '#7c3aed18' }}>
|
||||
<TramFront size={isMobile ? 19 : 23} strokeWidth={1.8} color="#7c3aed" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{editingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
onBlur={() => setEditingTitle(false)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') setEditingTitle(false); if (e.key === 'Escape') { setTitle(res.title || ''); setEditingTitle(false) } }}
|
||||
className="text-content"
|
||||
aria-label={t('reservations.titleLabel')}
|
||||
style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', padding: 0, fontFamily: 'inherit', fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.015em', borderBottom: '1.5px solid var(--text-primary)' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.015em', minWidth: 0 }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden' }}><TransitTitle title={title} iconSize={15} /></span>
|
||||
{canEdit && (
|
||||
<button onClick={() => setEditingTitle(true)} aria-label={t('common.edit')} title={t('common.edit')} className="text-content-faint" style={{ border: 'none', background: 'none', padding: 3, cursor: 'pointer', display: 'flex', flexShrink: 0 }}>
|
||||
<Pencil size={13} strokeWidth={1.8} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', marginTop: 3 }}>
|
||||
{[dateStr, time ? `${formatTime(time, locale, timeFormat)}${endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}` : ''].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{transit && (
|
||||
<>
|
||||
{/* journey stats — three full-width tiles; iconless and flat on mobile */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: isMobile ? 6 : 10 }}>
|
||||
{statTiles.map(({ Icon, value, label }, i) => (
|
||||
isMobile ? (
|
||||
<div key={i} className="bg-surface-tertiary" style={{ padding: '7px 6px 6px', borderRadius: 11, textAlign: 'center', minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, letterSpacing: '-0.01em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{value}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', marginTop: 1 }}>{label}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className="bg-surface-tertiary" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '13px 16px', borderRadius: 12 }}>
|
||||
<div className="bg-surface-card" style={{ width: 34, height: 34, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 10 }}>
|
||||
<Icon size={16} strokeWidth={1.9} className="text-content-muted" />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.01em', whiteSpace: 'nowrap' }}>{value}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* stop-by-stop itinerary */}
|
||||
<div className="bg-surface-tertiary" style={{ padding: isMobile ? '11px 10px' : '14px 16px', borderRadius: 12 }}>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 12 }}>
|
||||
{t('transit.itinerary')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{(transit.legs as TransitLegMeta[]).map((leg, i) => {
|
||||
if (leg.mode === 'WALK') return <TransitWalkDivider key={i} leg={leg} t={t} size={isMobile ? 'sm' : 'md'} />
|
||||
const mins = leg.duration ? Math.round(leg.duration / 60) : null
|
||||
if (isMobile) {
|
||||
// The wide from → to line plus the chip row doesn't fit a
|
||||
// phone: each leg becomes a depart / ride / arrive rail in
|
||||
// the line's color, meta as one quiet text line.
|
||||
const color = leg.line_color || 'var(--text-muted)'
|
||||
const metaLine = [
|
||||
mins ? t('transit.min', { count: mins }) : null,
|
||||
leg.stops ? t('transit.stops', { count: leg.stops }) : null,
|
||||
leg.agency || null,
|
||||
].filter(Boolean).join(' · ')
|
||||
// Names wrap instead of clipping; the platform gets its
|
||||
// own quiet line below so it never pushes the name out.
|
||||
const stopName = (stop: TransitLegMeta['from']) => (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, lineHeight: 1.35, overflowWrap: 'anywhere' }}>
|
||||
{stop?.name}
|
||||
</div>
|
||||
{stop?.track && (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>
|
||||
{t('transit.platform', { track: stop.track })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
const timeCell = (time?: string | null) => (
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, textAlign: 'right', paddingTop: 1 }}>
|
||||
{time || ''}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div key={i} style={{ display: 'grid', gridTemplateColumns: '34px 14px 1fr', columnGap: 8 }}>
|
||||
{timeCell(leg.from?.time)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: '50%', border: `2.5px solid ${color}`, background: 'var(--bg-tertiary)', flexShrink: 0, marginTop: 3 }} />
|
||||
<span style={{ flex: 1, width: 3, borderRadius: 2, background: color, marginTop: 2 }} />
|
||||
</div>
|
||||
{stopName(leg.from)}
|
||||
<div />
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<span style={{ width: 3, borderRadius: 2, background: color }} />
|
||||
</div>
|
||||
<div style={{ padding: '6px 0 8px', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', background: leg.line_color || 'var(--bg-hover)', color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)', borderRadius: 6, padding: '1px 7px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
|
||||
{leg.line || leg.mode}
|
||||
</span>
|
||||
{leg.headsign && (
|
||||
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', minWidth: 0 }}>
|
||||
<MoveRight size={10} style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{leg.headsign}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{metaLine && (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', marginTop: 3 }}>{metaLine}</div>
|
||||
)}
|
||||
</div>
|
||||
{timeCell(leg.to?.time)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: '50%', border: `2.5px solid ${color}`, background: 'var(--bg-tertiary)', marginTop: 3 }} />
|
||||
</div>
|
||||
{stopName(leg.to)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div className="text-content-muted" style={{ width: 44, flexShrink: 0, textAlign: 'right', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, paddingTop: 1 }}>
|
||||
{leg.from?.time || ''}
|
||||
</div>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', background: leg.line_color || 'var(--bg-hover)', color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)', borderRadius: 6, padding: '2px 8px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
|
||||
{leg.line || leg.mode}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.from?.name}</span>
|
||||
{leg.from?.track && <span className="text-content-faint" style={{ fontWeight: 500 }}>({t('transit.platform', { track: leg.from.track })})</span>}
|
||||
<ArrowRight size={12} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.to?.name}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 3 }}>
|
||||
<TransitMetaBadges items={[
|
||||
{ icon: Clock, text: leg.from?.time ? `${leg.from.time}${leg.to?.time ? ` – ${leg.to.time}` : ''}` : '' },
|
||||
{ text: mins ? t('transit.min', { count: mins }) : '' },
|
||||
{ text: leg.stops ? t('transit.stops', { count: leg.stops }) : '' },
|
||||
{ icon: MoveRight, text: leg.headsign || '' },
|
||||
{ text: leg.agency || '', dim: true },
|
||||
]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* notes — full width, markdown */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6, gap: 8, flexWrap: 'wrap' }}>
|
||||
<label className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.notes')}</label>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{notesTab === 'write' && (
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2, gap: 1 }}>
|
||||
{MD_TOOLS.map(({ Icon, label, action }) => (
|
||||
<button key={label} type="button" onClick={() => applyMd(action)} title={label} aria-label={label}
|
||||
className="text-content-muted"
|
||||
style={{ width: 26, height: 24, display: 'grid', placeItems: 'center', borderRadius: 6, border: 0, background: 'transparent', cursor: 'pointer' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}>
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2, gap: 2 }}>
|
||||
{([['write', t('common.edit')], ['preview', t('common.preview')]] as const).map(([tab, label]) => (
|
||||
<button key={tab} type="button" onClick={() => setNotesTab(tab)}
|
||||
className={notesTab === tab ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||
style={{ padding: '4px 12px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, borderRadius: 6, border: 0, cursor: 'pointer', fontFamily: 'inherit', background: notesTab === tab ? undefined : 'transparent' }}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && notesTab === 'write' ? (
|
||||
<textarea
|
||||
ref={notesRef}
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder={t('reservations.notesPlaceholder')}
|
||||
className="w-full border border-edge rounded-[10px] px-[12px] py-[10px] text-[13px] font-[inherit] outline-none box-border text-content bg-surface-input"
|
||||
style={{ minHeight: 130, resize: 'vertical', lineHeight: 1.55 }}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-surface-tertiary" style={{ borderRadius: 10, padding: '12px 14px', minHeight: canEdit ? 130 : undefined }}>
|
||||
{notes.trim()
|
||||
? <div className="collab-note-md text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{notes}</Markdown></div>
|
||||
: <span className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('reservations.notesPlaceholder')}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDelete}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onConfirm={async () => { setConfirmDelete(false); await onDelete(); onClose() }}
|
||||
title={t('reservations.confirm.deleteTitle')}
|
||||
message={t('reservations.confirm.deleteBody', { name: res.title })}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
// FE-PLANNER-TRANSIT-001 to FE-PLANNER-TRANSIT-006 — the transit search panel
|
||||
// (embedded as the TransportModal's Automated mode).
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { buildUser, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import TransitSearchPanel from './TransitSearchPanel'
|
||||
|
||||
const { transitApiMock } = vi.hoisted(() => ({
|
||||
transitApiMock: { geocode: vi.fn(), plan: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal() as object
|
||||
return { ...actual, transitApi: transitApiMock }
|
||||
})
|
||||
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn() }) }))
|
||||
|
||||
// Berlin, summer time (UTC+2): 06:30Z departs 08:30 local, 07:00Z arrives 09:00.
|
||||
const ITINERARY = {
|
||||
startTime: '2025-06-01T06:30:00Z',
|
||||
endTime: '2025-06-01T07:00:00Z',
|
||||
duration: 1800,
|
||||
transfers: 1,
|
||||
walkSeconds: 240,
|
||||
legs: [
|
||||
{ mode: 'WALK', from: { name: 'Start', lat: 52.52, lng: 13.4, time: '2025-06-01T06:30:00Z', scheduledTime: null, track: null }, to: { name: 'Alexanderplatz', lat: 52.521, lng: 13.41, time: '2025-06-01T06:34:00Z', scheduledTime: null, track: null }, duration: 240, distance: 300, headsign: null, line: null, lineColor: null, lineTextColor: null, agency: null, intermediateStops: 0 },
|
||||
{ mode: 'SUBWAY', from: { name: 'Alexanderplatz', lat: 52.521, lng: 13.41, time: '2025-06-01T06:36:00Z', scheduledTime: null, track: '2' }, to: { name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, time: '2025-06-01T07:00:00Z', scheduledTime: null, track: null }, duration: 1440, distance: null, headsign: 'Ruhleben', line: 'U2', lineColor: '#FF3300', lineTextColor: '#FFFFFF', agency: 'BVG', intermediateStops: 6 },
|
||||
],
|
||||
}
|
||||
|
||||
const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Day' })
|
||||
|
||||
function makeProps(overrides = {}) {
|
||||
return {
|
||||
day,
|
||||
days: [day],
|
||||
places: [buildPlace({ id: 1, name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 })],
|
||||
accommodations: [],
|
||||
onAdd: vi.fn().mockResolvedValue({}),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFromAndTo(user: ReturnType<typeof userEvent.setup>) {
|
||||
// Quick picks (the day's places) appear on focus with an empty query.
|
||||
const [fromInput, toInput] = screen.getAllByPlaceholderText('Search stop or station…')
|
||||
await user.click(fromInput)
|
||||
await user.click(await screen.findByText('Fernsehturm'))
|
||||
|
||||
transitApiMock.geocode.mockResolvedValueOnce({ results: [{ name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, type: 'STOP', area: 'Berlin' }] })
|
||||
await user.click(toInput)
|
||||
await user.type(toInput, 'Zoo')
|
||||
await user.click(await screen.findByText(/Zoologischer Garten/))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
|
||||
})
|
||||
|
||||
describe('TransitSearchPanel', () => {
|
||||
it('FE-PLANNER-TRANSIT-001: renders from/to pickers, modes and preferences', () => {
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2)
|
||||
expect(screen.getByText('Subway')).toBeInTheDocument()
|
||||
expect(screen.getByText('Fewer transfers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-002: searching lists itineraries with times, transfers and line badges', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndTo(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
// Local Berlin times, U2 badge, 1 transfer.
|
||||
expect(await screen.findByText(/08:30 – 09:00/)).toBeInTheDocument()
|
||||
expect(screen.getByText('U2')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 transfers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-003: adding a route builds a transport payload with local times + endpoints', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onAdd = vi.fn().mockResolvedValue({})
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
|
||||
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
|
||||
await pickFromAndTo(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
await user.click(await screen.findByText(/08:30 – 09:00/))
|
||||
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
|
||||
|
||||
await waitFor(() => expect(onAdd).toHaveBeenCalled())
|
||||
const payload = onAdd.mock.calls[0][0]
|
||||
expect(payload.type).toBe('transit') // first-class transit type (#1065)
|
||||
expect(payload.title).toBe('Fernsehturm → Zoologischer Garten')
|
||||
expect(payload.day_id).toBe(10)
|
||||
expect(payload.reservation_time).toBe('2025-06-01T08:30')
|
||||
expect(payload.reservation_end_time).toBe('2025-06-01T09:00')
|
||||
expect(payload.status).toBe('confirmed')
|
||||
// from + to endpoints (single transit leg → no transfer stops)
|
||||
expect(payload.endpoints).toHaveLength(2)
|
||||
expect(payload.endpoints[0]).toMatchObject({ role: 'from', name: 'Fernsehturm', timezone: 'Europe/Berlin' })
|
||||
expect(payload.endpoints[1]).toMatchObject({ role: 'to', name: 'Zoologischer Garten' })
|
||||
// compact itinerary stored for the detail modal
|
||||
expect(payload.metadata.transit.provider).toBe('transitous')
|
||||
expect(payload.metadata.transit.legs).toHaveLength(2)
|
||||
expect(payload.metadata.transit.legs[1]).toMatchObject({ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben' })
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockRejectedValueOnce(new Error('boom'))
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndTo(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
expect(await screen.findByText(/No connections found/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-005: preference "fewer transfers" re-ranks the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
const direct = { ...ITINERARY, startTime: '2025-06-01T06:40:00Z', endTime: '2025-06-01T07:20:00Z', duration: 2400, transfers: 0 }
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY, direct] })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndTo(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
await screen.findByText(/08:30 – 09:00/)
|
||||
await user.click(screen.getByText('Fewer transfers'))
|
||||
const cards = screen.getAllByText(/–/).filter(el => el.textContent?.match(/\d{2}:\d{2} – \d{2}:\d{2}/))
|
||||
// The direct (0-transfer) itinerary now ranks first.
|
||||
expect(cards[0].textContent).toContain('08:40')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-006: swap exchanges from and to', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…')
|
||||
await user.click(fromInput)
|
||||
await user.click(await screen.findByText('Fernsehturm'))
|
||||
await user.click(screen.getByLabelText('Swap'))
|
||||
const inputs = screen.getAllByPlaceholderText('Search stop or station…')
|
||||
expect((inputs[0] as HTMLInputElement).value).toBe('')
|
||||
expect((inputs[1] as HTMLInputElement).value).toBe('Fernsehturm')
|
||||
})
|
||||
})
|
||||
@@ -1,602 +0,0 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import tzlookup from 'tz-lookup'
|
||||
import { ArrowLeftRight, ArrowRight, Bus, CableCar, ChevronDown, ChevronUp, Clock, Footprints, MapPin, Sailboat, Search, Train, TramFront, TrainFront } from 'lucide-react'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { TransitMetaBadges } from './transitDisplay'
|
||||
import { transitApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Accommodation } from '../../types'
|
||||
|
||||
/**
|
||||
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
|
||||
* server proxy — no paid providers. Google-Maps-like flow in TREK's clean style:
|
||||
* pick from/to (stop search + the day's own places as quick picks), filter by
|
||||
* mode and preference, compare the returned itineraries, then add the chosen
|
||||
* one to the day. The result is saved as a regular transport reservation
|
||||
* (endpoints + metadata.transit), so timeline slotting, editing, deleting and
|
||||
* drag/drop all reuse the existing machinery.
|
||||
*/
|
||||
|
||||
// ── transit data shapes (mirrors the server's compact mapping) ──────────────
|
||||
|
||||
interface TransitLegStop { name: string; lat: number; lng: number; time: string | null; scheduledTime: string | null; track: string | null }
|
||||
interface TransitLeg {
|
||||
mode: string; from: TransitLegStop; to: TransitLegStop; duration: number; distance: number | null
|
||||
headsign: string | null; line: string | null; lineColor: string | null; lineTextColor: string | null
|
||||
agency: string | null; intermediateStops: number
|
||||
geometry?: string | null; geometryPrecision?: number
|
||||
}
|
||||
export interface TransitItinerary {
|
||||
startTime: string; endTime: string; duration: number; transfers: number; walkSeconds: number; legs: TransitLeg[]
|
||||
}
|
||||
|
||||
interface TransitPlaceResult { name: string; lat: number; lng: number; type: string; area: string | null }
|
||||
|
||||
export interface PickedPlace { name: string; lat: number; lng: number }
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [
|
||||
{ key: 'rail', labelKey: 'transit.mode.rail', Icon: Train, modes: 'HIGHSPEED_RAIL,LONG_DISTANCE,NIGHT_RAIL,REGIONAL_RAIL,SUBURBAN' },
|
||||
{ key: 'subway', labelKey: 'transit.mode.subway', Icon: TrainFront, modes: 'SUBWAY' },
|
||||
{ key: 'tram', labelKey: 'transit.mode.tram', Icon: TramFront, modes: 'TRAM' },
|
||||
{ key: 'bus', labelKey: 'transit.mode.bus', Icon: Bus, modes: 'BUS,COACH' },
|
||||
{ key: 'ferry', labelKey: 'transit.mode.ferry', Icon: Sailboat, modes: 'FERRY' },
|
||||
{ key: 'cable', labelKey: 'transit.mode.cable', Icon: CableCar, modes: 'FUNICULAR,AERIAL_LIFT' },
|
||||
]
|
||||
|
||||
function legIcon(mode: string) {
|
||||
if (mode === 'WALK') return Footprints
|
||||
if (mode === 'BUS' || mode === 'COACH') return Bus
|
||||
if (mode === 'TRAM') return TramFront
|
||||
if (mode === 'SUBWAY') return TrainFront
|
||||
if (mode === 'FERRY') return Sailboat
|
||||
if (mode === 'FUNICULAR' || mode === 'AERIAL_LIFT') return CableCar
|
||||
return Train
|
||||
}
|
||||
|
||||
function tzAt(lat: number, lng: number): string {
|
||||
try { return tzlookup(lat, lng) } catch { return 'UTC' }
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' + 'HH:mm' in an IANA zone → UTC ISO string. */
|
||||
function localToUtcIso(dateStr: string, timeStr: string, tz: string): string {
|
||||
const naive = Date.parse(`${dateStr}T${timeStr}:00Z`)
|
||||
const inTz = new Date(new Date(naive).toLocaleString('en-US', { timeZone: tz })).getTime()
|
||||
const inUtc = new Date(new Date(naive).toLocaleString('en-US', { timeZone: 'UTC' })).getTime()
|
||||
return new Date(naive - (inTz - inUtc)).toISOString()
|
||||
}
|
||||
|
||||
function fmtTimeInTz(iso: string | null, tz: string, is12h: boolean): string {
|
||||
if (!iso) return ''
|
||||
try { return new Intl.DateTimeFormat(is12h ? 'en-US' : 'en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h }).format(new Date(iso)) } catch { return '' }
|
||||
}
|
||||
|
||||
function timeHHmmInTz(iso: string, tz: string): string {
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(iso))
|
||||
}
|
||||
|
||||
function dateYMDInTz(iso: string, tz: string): string {
|
||||
return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(iso))
|
||||
}
|
||||
|
||||
function fmtDuration(seconds: number, t: (k: string, p?: Record<string, string | number>) => string): string {
|
||||
const mins = Math.round(seconds / 60)
|
||||
if (mins < 60) return t('transit.min', { count: mins })
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m > 0 ? `${h} h ${m} min` : `${h} h`
|
||||
}
|
||||
|
||||
// ── from/to stop picker ──────────────────────────────────────────────────────
|
||||
|
||||
function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: {
|
||||
label: string
|
||||
value: PickedPlace | null
|
||||
onPick: (p: PickedPlace | null) => void
|
||||
quickPicks: PickedPlace[]
|
||||
near: string | null
|
||||
placeholder: string
|
||||
}) {
|
||||
const { language } = useTranslation()
|
||||
const [text, setText] = useState('')
|
||||
const [results, setResults] = useState<TransitPlaceResult[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false) }
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, [])
|
||||
|
||||
const search = (q: string) => {
|
||||
setText(q)
|
||||
onPick(null)
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
if (q.trim().length < 2) { setResults([]); return }
|
||||
debounceRef.current = setTimeout(() => {
|
||||
transitApi.geocode(q, { lang: language, near: near || undefined })
|
||||
.then((d: { results: TransitPlaceResult[] }) => setResults(d.results || []))
|
||||
.catch(() => setResults([]))
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const display = value ? value.name : text
|
||||
|
||||
return (
|
||||
<div ref={rootRef} style={{ position: 'relative', flex: 1, minWidth: 0 }}>
|
||||
<label className="block text-[11px] font-semibold text-content-faint mb-[5px] uppercase tracking-[0.03em]">{label}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 7, borderRadius: 10, padding: '0 10px', height: 38 }}>
|
||||
<MapPin size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<input
|
||||
value={display}
|
||||
onChange={e => search(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className="text-content"
|
||||
style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', width: '100%', fontFamily: 'inherit' }}
|
||||
/>
|
||||
</div>
|
||||
{open && (results.length > 0 || (!value && text.trim().length < 2 && quickPicks.length > 0)) && (
|
||||
<div className="bg-surface-card border border-edge" style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 4, borderRadius: 10, boxShadow: '0 8px 32px rgba(0,0,0,0.14)', zIndex: 30, overflow: 'hidden', maxHeight: 240, overflowY: 'auto' }}>
|
||||
{results.length > 0
|
||||
? results.map((r, i) => (
|
||||
<button key={i} onClick={() => { onPick({ name: r.name, lat: r.lat, lng: r.lng }); setText(''); setResults([]); setOpen(false) }}
|
||||
className="text-content"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left', padding: '8px 10px', border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<MapPin size={13} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{r.name}
|
||||
{r.area && <span className="text-content-faint"> · {r.area}</span>}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
: quickPicks.map((p, i) => (
|
||||
<button key={i} onClick={() => { onPick(p); setText(''); setOpen(false) }}
|
||||
className="text-content"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left', padding: '8px 10px', border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<MapPin size={13} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── itinerary card ───────────────────────────────────────────────────────────
|
||||
|
||||
function LineBadge({ leg }: { leg: TransitLeg }) {
|
||||
const bg = leg.lineColor || 'var(--bg-tertiary)'
|
||||
const fg = leg.lineColor ? (leg.lineTextColor || '#fff') : 'var(--text-primary)'
|
||||
const Icon = legIcon(leg.mode)
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, background: bg, color: fg, borderRadius: 6, padding: '2px 7px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||
<Icon size={11} strokeWidth={2.2} />
|
||||
{leg.line || leg.mode}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, t }: {
|
||||
it: TransitItinerary
|
||||
tzFrom: string
|
||||
tzTo: string
|
||||
is12h: boolean
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
onAdd: () => void
|
||||
adding: boolean
|
||||
t: (k: string, p?: Record<string, string | number>) => string
|
||||
}) {
|
||||
const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
|
||||
const walkMins = Math.round(it.walkSeconds / 60)
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge" style={{ borderRadius: 14, overflow: 'hidden' }}>
|
||||
<button onClick={onToggle} style={{ display: 'block', width: '100%', textAlign: 'left', padding: '12px 14px', border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.01em' }}>
|
||||
{fmtTimeInTz(it.startTime, tzFrom, is12h)} – {fmtTimeInTz(it.endTime, tzTo, is12h)}
|
||||
</span>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{fmtDuration(it.duration, t)}</span>
|
||||
<span className="text-content-faint" style={{ marginLeft: 'auto', fontSize: 'calc(12px * var(--fs-scale-body, 1))', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
<span>{it.transfers === 0 ? t('transit.direct') : t('transit.transfers', { count: it.transfers })}</span>
|
||||
{walkMins > 0 && <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><Footprints size={12} />{t('transit.min', { count: walkMins })}</span>}
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
{/* signature: Walk › U2 › Bus 100 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginTop: 8, flexWrap: 'wrap' }}>
|
||||
{it.legs.map((leg, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="text-content-faint" style={{ fontSize: 10 }}>›</span>}
|
||||
{leg.mode === 'WALK'
|
||||
? <Footprints size={13} className="text-content-faint" />
|
||||
: <LineBadge leg={leg} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div style={{ borderTop: '1px solid var(--border-faint)', padding: '10px 14px 12px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{it.legs.map((leg, i) => {
|
||||
const color = leg.mode === 'WALK' ? 'var(--border-primary)' : (leg.lineColor || 'var(--text-muted)')
|
||||
return (
|
||||
<div key={i} style={{ display: 'grid', gridTemplateColumns: '44px 18px 1fr', gap: 8, alignItems: 'stretch' }}>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, paddingTop: 2, textAlign: 'right' }}>
|
||||
{leg.mode === 'WALK' ? '' : fmtTimeInTz(leg.from.time, tzAt(leg.from.lat, leg.from.lng), is12h)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', border: `2.5px solid ${color}`, background: 'var(--bg-card)', flexShrink: 0, marginTop: 4 }} />
|
||||
<span style={{ flex: 1, width: 3, borderRadius: 2, background: color, opacity: leg.mode === 'WALK' ? 0.45 : 1, margin: '2px 0', ...(leg.mode === 'WALK' ? { backgroundImage: 'repeating-linear-gradient(to bottom, var(--border-primary) 0 4px, transparent 4px 8px)', background: 'none' } : {}) }} />
|
||||
</div>
|
||||
<div style={{ paddingBottom: 14, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{leg.mode === 'WALK' ? t('transit.walkTo', { name: leg.to.name }) : leg.from.name}
|
||||
{leg.from.track && leg.mode !== 'WALK' && <span className="text-content-faint" style={{ fontWeight: 500 }}> · {t('transit.platform', { track: leg.from.track })}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{leg.mode === 'WALK' ? (
|
||||
<TransitMetaBadges size="sm" items={[
|
||||
{ icon: Footprints, text: fmtDuration(leg.duration, t) },
|
||||
{ text: leg.distance ? (leg.distance >= 1000 ? `${(leg.distance / 1000).toFixed(1)} km` : `${leg.distance} m`) : '' },
|
||||
]} />
|
||||
) : (
|
||||
<>
|
||||
<LineBadge leg={leg} />
|
||||
<TransitMetaBadges size="sm" items={[
|
||||
{ icon: ArrowRight, text: leg.headsign || '' },
|
||||
{ text: fmtDuration(leg.duration, t) },
|
||||
{ text: leg.intermediateStops > 0 ? t('transit.stops', { count: leg.intermediateStops }) : '' },
|
||||
]} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* arrival row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '44px 18px 1fr', gap: 8, alignItems: 'center' }}>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, textAlign: 'right' }}>
|
||||
{fmtTimeInTz(it.endTime, tzTo, is12h)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--text-primary)', flexShrink: 0 }} />
|
||||
</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{it.legs[it.legs.length - 1]?.to.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{transitLegs[0]?.agency && (
|
||||
<div className="text-content-faint" style={{ marginTop: 8, fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
|
||||
{[...new Set(transitLegs.map(l => l.agency).filter(Boolean))].join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onAdd}
|
||||
disabled={adding}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ marginTop: 12, width: '100%', border: 'none', borderRadius: 10, padding: '9px 0', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: adding ? 'default' : 'pointer', fontFamily: 'inherit', opacity: adding ? 0.6 : 1 }}
|
||||
>
|
||||
{adding ? t('transit.adding') : t('transit.addToDay')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── the search panel ─────────────────────────────────────────────────────────
|
||||
// Modal-less on purpose: it renders as the "Automated transport" mode inside
|
||||
// the TransportModal (and could embed anywhere else). The host owns the modal
|
||||
// chrome and closes itself once onAdd resolves.
|
||||
|
||||
interface TransitSearchPanelProps {
|
||||
day: Day
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
accommodations?: Accommodation[]
|
||||
/** Persist the built reservation payload; resolves when saved. */
|
||||
onAdd: (payload: Record<string, unknown>) => Promise<unknown>
|
||||
/** Pre-seed from/to — used by "change route" on an existing journey. */
|
||||
initialFrom?: PickedPlace | null
|
||||
initialTo?: PickedPlace | null
|
||||
}
|
||||
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null }: TransitSearchPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
|
||||
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
|
||||
const [to, setTo] = useState<PickedPlace | null>(initialTo)
|
||||
const [time, setTime] = useState('09:00')
|
||||
const [arriveBy, setArriveBy] = useState(false)
|
||||
const [activeModes, setActiveModes] = useState<Set<string>>(() => new Set(MODE_GROUPS.map(m => m.key)))
|
||||
const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best')
|
||||
const [itineraries, setItineraries] = useState<TransitItinerary[] | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
|
||||
const [addingIdx, setAddingIdx] = useState<number | null>(null)
|
||||
|
||||
// Quick picks: the day's located places, plus the trip's located accommodations.
|
||||
const quickPicks = useMemo<PickedPlace[]>(() => {
|
||||
const picks: PickedPlace[] = []
|
||||
for (const p of places) {
|
||||
if (p.lat != null && p.lng != null) picks.push({ name: p.name, lat: p.lat, lng: p.lng })
|
||||
}
|
||||
for (const a of accommodations) {
|
||||
const lat = (a as { place_lat?: number | null }).place_lat
|
||||
const lng = (a as { place_lng?: number | null }).place_lng
|
||||
const name = (a as { place_name?: string | null }).place_name
|
||||
if (lat != null && lng != null && name) picks.push({ name, lat, lng })
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
return picks.filter(p => { const k = `${p.name}:${p.lat}`; if (seen.has(k)) return false; seen.add(k); return true }).slice(0, 8)
|
||||
}, [places, accommodations])
|
||||
|
||||
const near = quickPicks.length > 0 ? `${quickPicks[0].lat},${quickPicks[0].lng}` : null
|
||||
|
||||
const toggleMode = (key: string) => {
|
||||
setActiveModes(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) { if (next.size > 1) next.delete(key) } else { next.add(key) }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const search = async () => {
|
||||
if (!from || !to || !day.date) return
|
||||
setLoading(true)
|
||||
setItineraries(null)
|
||||
setExpandedIdx(null)
|
||||
try {
|
||||
const tzFrom = tzAt(from.lat, from.lng)
|
||||
const timeIso = localToUtcIso(day.date, time, tzFrom)
|
||||
const allModes = activeModes.size === MODE_GROUPS.length
|
||||
const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',')
|
||||
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes })
|
||||
// MOTIS names the request coordinates START/END — swap in the places the
|
||||
// user actually picked so walks read "Walk to Zoologischer Garten".
|
||||
const cleanStop = (n: string) => (n === 'START' ? from.name : n === 'END' ? to.name : n)
|
||||
const cleaned = (d.itineraries || []).map((it: TransitItinerary) => ({
|
||||
...it,
|
||||
legs: it.legs.map(l => ({
|
||||
...l,
|
||||
from: { ...l.from, name: cleanStop(l.from.name) },
|
||||
to: { ...l.to, name: cleanStop(l.to.name) },
|
||||
})),
|
||||
}))
|
||||
setItineraries(cleaned)
|
||||
} catch {
|
||||
toast.error(t('transit.searchError'))
|
||||
setItineraries([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Preference is a client-side ranking over one result set — no extra API calls.
|
||||
const ranked = useMemo(() => {
|
||||
if (!itineraries) return null
|
||||
const list = itineraries.slice()
|
||||
if (pref === 'transfers') list.sort((a, b) => a.transfers - b.transfers || a.duration - b.duration)
|
||||
if (pref === 'walking') list.sort((a, b) => a.walkSeconds - b.walkSeconds || a.duration - b.duration)
|
||||
return list
|
||||
}, [itineraries, pref])
|
||||
|
||||
const addItinerary = async (it: TransitItinerary, idx: number) => {
|
||||
if (!from || !to || !day.date) return
|
||||
setAddingIdx(idx)
|
||||
try {
|
||||
const tzFrom = tzAt(from.lat, from.lng)
|
||||
const tzTo = tzAt(to.lat, to.lng)
|
||||
const depDate = dateYMDInTz(it.startTime, tzFrom)
|
||||
const depTime = timeHHmmInTz(it.startTime, tzFrom)
|
||||
const arrDate = dateYMDInTz(it.endTime, tzTo)
|
||||
const arrTime = timeHHmmInTz(it.endTime, tzTo)
|
||||
|
||||
// An after-midnight arrival lands on the next trip day when it exists.
|
||||
const endDay = arrDate !== depDate ? days.find(d2 => d2.date === arrDate) : null
|
||||
|
||||
// Endpoints: origin, each transfer stop, destination — the same shape
|
||||
// flights persist, so the map + connectors work unchanged.
|
||||
const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
|
||||
const endpoints: Record<string, unknown>[] = []
|
||||
endpoints.push({ role: 'from', sequence: 0, name: from.name, code: null, lat: from.lat, lng: from.lng, timezone: tzFrom, local_date: depDate, local_time: depTime })
|
||||
transitLegs.slice(0, -1).forEach((leg, i) => {
|
||||
const s = leg.to
|
||||
endpoints.push({ role: 'stop', sequence: i + 1, name: s.name, code: null, lat: s.lat, lng: s.lng, timezone: tzAt(s.lat, s.lng), local_date: s.time ? dateYMDInTz(s.time, tzAt(s.lat, s.lng)) : null, local_time: s.time ? timeHHmmInTz(s.time, tzAt(s.lat, s.lng)) : null })
|
||||
})
|
||||
endpoints.push({ role: 'to', sequence: endpoints.length, name: to.name, code: null, lat: to.lat, lng: to.lng, timezone: tzTo, local_date: arrDate, local_time: arrTime })
|
||||
|
||||
const payload = {
|
||||
title: `${from.name} → ${to.name}`,
|
||||
// Its own first-class type: transit journeys get their own icon, their
|
||||
// rich timeline row and the itinerary detail view instead of the
|
||||
// generic transport rendering.
|
||||
type: 'transit',
|
||||
status: 'confirmed',
|
||||
day_id: day.id,
|
||||
end_day_id: endDay ? endDay.id : day.id,
|
||||
reservation_time: `${depDate}T${depTime}`,
|
||||
reservation_end_time: `${arrDate}T${arrTime}`,
|
||||
location: null,
|
||||
confirmation_number: null,
|
||||
notes: null,
|
||||
metadata: {
|
||||
transit: {
|
||||
provider: 'transitous',
|
||||
duration: it.duration,
|
||||
transfers: it.transfers,
|
||||
walk_seconds: it.walkSeconds,
|
||||
legs: it.legs.map(l => ({
|
||||
mode: l.mode,
|
||||
line: l.line,
|
||||
line_color: l.lineColor,
|
||||
line_text_color: l.lineTextColor,
|
||||
headsign: l.headsign,
|
||||
agency: l.agency,
|
||||
duration: l.duration,
|
||||
stops: l.intermediateStops,
|
||||
from: { name: l.from.name, time: l.from.time ? timeHHmmInTz(l.from.time, tzAt(l.from.lat, l.from.lng)) : null, track: l.from.track },
|
||||
to: { name: l.to.name, time: l.to.time ? timeHHmmInTz(l.to.time, tzAt(l.to.lat, l.to.lng)) : null, track: l.to.track },
|
||||
geometry: l.geometry || null,
|
||||
geometry_precision: l.geometryPrecision ?? 6,
|
||||
})),
|
||||
},
|
||||
},
|
||||
endpoints,
|
||||
needs_review: false,
|
||||
}
|
||||
|
||||
await onAdd(payload)
|
||||
} catch {
|
||||
toast.error(t('common.unknownError'))
|
||||
} finally {
|
||||
setAddingIdx(null)
|
||||
}
|
||||
}
|
||||
|
||||
const tzFrom = from ? tzAt(from.lat, from.lng) : 'UTC'
|
||||
const tzTo = to ? tzAt(to.lat, to.lng) : tzFrom
|
||||
|
||||
const segBtn = (active: boolean): React.CSSProperties => ({
|
||||
padding: isMobile ? '6px 5px' : '6px 11px', fontSize: isMobile ? 'calc(11px * var(--fs-scale-body, 1))' : 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500,
|
||||
border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
|
||||
background: active ? 'var(--bg-card)' : 'transparent', color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
boxShadow: active ? '0 1px 4px rgba(0,0,0,0.08)' : 'none',
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, fontFamily: 'var(--font-system)' }}>
|
||||
{/* from / to — stacked tight on mobile, swap button on desktop only */}
|
||||
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 8, alignItems: isMobile ? 'stretch' : 'flex-end' }}>
|
||||
<StopPicker label={t('transit.from')} value={from} onPick={setFrom} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={() => { const f = from; setFrom(to); setTo(f) }}
|
||||
aria-label={t('transit.swap')}
|
||||
title={t('transit.swap')}
|
||||
className="bg-surface-secondary text-content-muted"
|
||||
style={{ border: 'none', borderRadius: 10, width: 38, height: 38, display: 'grid', placeItems: 'center', cursor: 'pointer', flexShrink: 0 }}
|
||||
>
|
||||
<ArrowLeftRight size={15} />
|
||||
</button>
|
||||
)}
|
||||
<StopPicker label={t('transit.to')} value={to} onPick={setTo} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
|
||||
</div>
|
||||
|
||||
{/* search options — one calm card: when + how on top, modes + go below */}
|
||||
<div className="bg-surface-tertiary" style={{ borderRadius: 14, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
|
||||
<button onClick={() => setArriveBy(false)} style={segBtn(!arriveBy)}>{t('transit.depart')}</button>
|
||||
<button onClick={() => setArriveBy(true)} style={segBtn(arriveBy)}>{t('transit.arrive')}</button>
|
||||
</div>
|
||||
<div style={{ width: 110 }}>
|
||||
<CustomTimePicker value={time} onChange={setTime} />
|
||||
</div>
|
||||
{day.date && (
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<Clock size={12} />
|
||||
{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3, width: isMobile ? '100%' : undefined }}>
|
||||
<button onClick={() => setPref('best')} style={{ ...segBtn(pref === 'best'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.best')}</button>
|
||||
<button onClick={() => setPref('transfers')} style={{ ...segBtn(pref === 'transfers'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.transfers')}</button>
|
||||
<button onClick={() => setPref('walking')} style={{ ...segBtn(pref === 'walking'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.walking')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border-faint)' }} />
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
|
||||
{MODE_GROUPS.map(m => {
|
||||
const active = activeModes.has(m.key)
|
||||
return (
|
||||
<button key={m.key} onClick={() => toggleMode(m.key)}
|
||||
className={active ? 'bg-surface-card text-content' : 'text-content-faint'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: active ? undefined : 'transparent',
|
||||
borderColor: active ? 'var(--border-primary)' : 'transparent',
|
||||
boxShadow: active ? '0 1px 3px rgba(0,0,0,0.06)' : 'none',
|
||||
opacity: active ? 1 : 0.75,
|
||||
}}>
|
||||
<m.Icon size={12} strokeWidth={2} />
|
||||
{t(m.labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={search}
|
||||
disabled={!from || !to || loading}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: (!from || !to || loading) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: (!from || !to || loading) ? 0.55 : 1, flexShrink: 0, width: isMobile ? '100%' : undefined }}
|
||||
>
|
||||
<Search size={14} strokeWidth={2.2} />
|
||||
{loading ? t('transit.searching') : t('transit.search')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* results */}
|
||||
{loading && (
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '28px 0' }}>
|
||||
<div style={{ width: 20, height: 20, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
|
||||
</div>
|
||||
)}
|
||||
{!loading && ranked && ranked.length === 0 && (
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '24px 0', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{t('transit.noResults')}
|
||||
</div>
|
||||
)}
|
||||
{!loading && ranked && ranked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{ranked.map((it, idx) => (
|
||||
<ItineraryCard
|
||||
key={`${it.startTime}-${it.endTime}-${idx}`}
|
||||
it={it}
|
||||
tzFrom={tzFrom}
|
||||
tzTo={tzTo}
|
||||
is12h={is12h}
|
||||
expanded={expandedIdx === idx}
|
||||
onToggle={() => setExpandedIdx(expandedIdx === idx ? null : idx)}
|
||||
onAdd={() => addItinerary(it, idx)}
|
||||
adding={addingIdx === idx}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', textAlign: 'center', marginTop: 2 }}>
|
||||
{t('transit.attribution')}{' '}
|
||||
<a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -71,12 +71,6 @@ describe('TransportModal', () => {
|
||||
expect(screen.getByText(/Add transport/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-002b: file input accepts pkpass (#1448)', () => {
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
expect(fileInput.accept).toContain('.pkpass');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => {
|
||||
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
||||
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||
@@ -330,134 +324,4 @@ describe('TransportModal', () => {
|
||||
expect(fd.get('reservation_id')).toBe('99');
|
||||
expect(fd.get('file')).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Transit itinerary preservation (#1065) ─────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-020: re-saving a transit reservation keeps metadata.transit + stop endpoints', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const res = buildReservation({ title: 'Fernsehturm → Zoo', type: 'bus' }) as any;
|
||||
res.metadata = { transit: { provider: 'transitous', transfers: 1, legs: [{ mode: 'BUS', line: '100' }] } };
|
||||
res.endpoints = [
|
||||
{ role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.5208, lng: 13.4094, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '08:30' },
|
||||
{ role: 'stop', sequence: 1, name: 'Alexanderplatz', code: null, lat: 52.521, lng: 13.41, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '08:40' },
|
||||
{ role: 'to', sequence: 2, name: 'Zoologischer Garten', code: null, lat: 52.507, lng: 13.332, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '09:00' },
|
||||
];
|
||||
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
|
||||
// Save without touching the route — the itinerary must survive.
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.metadata?.transit?.provider).toBe('transitous');
|
||||
expect(payload.metadata?.transit?.legs).toHaveLength(1);
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'to']);
|
||||
expect(payload.endpoints[1]).toMatchObject({ name: 'Alexanderplatz', lat: 52.521 });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-021: changing the destination drops the stale transit itinerary', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const res = buildReservation({ title: 'Fernsehturm → Zoo', type: 'bus' }) as any;
|
||||
res.metadata = { transit: { provider: 'transitous', legs: [{ mode: 'BUS' }] } };
|
||||
res.endpoints = [
|
||||
{ role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.5208, lng: 13.4094, timezone: 'Europe/Berlin', local_date: null, local_time: null },
|
||||
{ role: 'stop', sequence: 1, name: 'Alexanderplatz', code: null, lat: 52.521, lng: 13.41, timezone: 'Europe/Berlin', local_date: null, local_time: null },
|
||||
{ role: 'to', sequence: 2, name: 'Zoologischer Garten', code: null, lat: 52.507, lng: 13.332, timezone: 'Europe/Berlin', local_date: null, local_time: null },
|
||||
];
|
||||
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
|
||||
// Pick a different destination (mocked LocationSelect emits lat/lng 0,0).
|
||||
const locationInputs = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(locationInputs[1], { target: { value: 'Somewhere Else' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.metadata?.transit).toBeUndefined();
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
||||
});
|
||||
|
||||
// ── Manual / Automated creation switch (#1065) ─────────────────────────────
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-022: creating shows the Manual/Automated switch; Automated opens the transit search', async () => {
|
||||
render(<TransportModal {...defaultProps} places={[]} accommodations={[]} />);
|
||||
expect(screen.getByRole('button', { name: 'Manual transport' })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Automated transport' }));
|
||||
// No day selected in defaultProps (days: []) — the pick-day hint shows.
|
||||
expect(screen.getByText(/Pick a day/)).toBeInTheDocument();
|
||||
// The manual form is gone in automated mode.
|
||||
expect(screen.queryByPlaceholderText(/e\.g\. Lufthansa/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-023: initialAutomated opens straight in the transit search with the day preset', () => {
|
||||
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Day 1' }] as any;
|
||||
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} initialAutomated places={[]} accommodations={[]} />);
|
||||
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-024: editing shows no Manual/Automated switch', () => {
|
||||
const res = buildReservation({ title: 'My Train', type: 'train' });
|
||||
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||
expect(screen.queryByRole('button', { name: 'Automated transport' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Multi-leg trains (#1150) ───────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-025: a train with an added stop saves from/stop/to endpoints + metadata.legs', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Berlin → München');
|
||||
// Insert an intermediate station (2 → 3 stations = 2 legs).
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
const stations = screen.getAllByTestId('location-select');
|
||||
expect(stations).toHaveLength(3);
|
||||
fireEvent.change(stations[0], { target: { value: 'Berlin Hbf' } });
|
||||
fireEvent.change(stations[1], { target: { value: 'Frankfurt Hbf' } });
|
||||
fireEvent.change(stations[2], { target: { value: 'München Hbf' } });
|
||||
// Per-leg train number on the first station (placeholder ICE 123).
|
||||
const trainNumbers = screen.getAllByPlaceholderText('ICE 123');
|
||||
fireEvent.change(trainNumbers[0], { target: { value: 'ICE 100' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.type).toBe('train');
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'to']);
|
||||
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin Hbf', 'Frankfurt Hbf', 'München Hbf']);
|
||||
expect(payload.metadata.legs).toHaveLength(2);
|
||||
expect(payload.metadata.legs[0]).toMatchObject({ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100' });
|
||||
expect(payload.metadata.train_number).toBe('ICE 100'); // flat mirror of leg 0
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-027: a train with a day + train number but no geocoded station still saves them (#1150 regression)', async () => {
|
||||
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2026-08-01', title: 'Day 1' }] as any;
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE 599');
|
||||
// Fill the train number + a departure time, but never pick a geocoded station.
|
||||
fireEvent.change(screen.getAllByPlaceholderText('ICE 123')[0], { target: { value: 'ICE 599' } });
|
||||
fireEvent.change(screen.getAllByTestId('time-picker')[0], { target: { value: '08:00' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
// The day, time and train number survive even without any map-picked station.
|
||||
expect(payload.day_id).toBe(10);
|
||||
expect(payload.reservation_time).toBe('2026-08-01T08:00');
|
||||
expect(payload.metadata.train_number).toBe('ICE 599');
|
||||
expect(payload.endpoints).toEqual([]); // no geocoded station → no map endpoints, like before
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-026: a two-station train saves flat (no metadata.legs)', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Köln → Aachen');
|
||||
const stations = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(stations[0], { target: { value: 'Köln Hbf' } });
|
||||
fireEvent.change(stations[1], { target: { value: 'Aachen Hbf' } });
|
||||
fireEvent.change(screen.getAllByPlaceholderText('ICE 123')[0], { target: { value: 'RE 9' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
||||
expect(payload.metadata.legs).toBeUndefined();
|
||||
expect(payload.metadata.train_number).toBe('RE 9');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
@@ -13,15 +13,14 @@ import { useAddonStore } from '../../store/addonStore'
|
||||
import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/formatters'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import apiClient from '../../api/client'
|
||||
import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
|
||||
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
|
||||
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { BookingReviewDraft } from './parsedItemToDraft'
|
||||
import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel'
|
||||
import { typeToCostCategory } from '@trek/shared'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'] as const
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||
|
||||
interface EndpointPick {
|
||||
@@ -41,7 +40,7 @@ function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence:
|
||||
}
|
||||
}
|
||||
|
||||
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
return {
|
||||
role, sequence,
|
||||
name: l.name,
|
||||
@@ -88,24 +87,6 @@ function emptyWaypoint(dayId: string | number = ''): WaypointForm {
|
||||
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
|
||||
}
|
||||
|
||||
// ── Multi-leg train stations ───────────────────────────────────────────────
|
||||
// A train mirrors the flight route model, but its waypoints are STATIONS
|
||||
// (location search, no timezone) and each leg carries a train number + platform
|
||||
// instead of an airline + flight number. N stations = N-1 legs.
|
||||
interface StationWaypointForm {
|
||||
location: LocationPoint | null
|
||||
arrDayId: string | number
|
||||
arrTime: string
|
||||
depDayId: string | number
|
||||
depTime: string
|
||||
train_number: string
|
||||
platform: string
|
||||
seat: string
|
||||
}
|
||||
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
|
||||
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' }
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
@@ -149,16 +130,9 @@ interface TransportModalProps {
|
||||
// Pre-fill a brand-new transport booking from a parsed import item (review-
|
||||
// before-save); like `reservation` for the form but stays in create mode.
|
||||
prefill?: BookingReviewDraft | null
|
||||
/** Data for the Automated (public transit) mode's quick picks. */
|
||||
places?: Place[]
|
||||
accommodations?: Accommodation[]
|
||||
/** Open directly in the Automated public-transit mode (day-header tram button, "change route"). */
|
||||
initialAutomated?: boolean
|
||||
/** Pre-seed the transit search — used by "change route" on an existing journey. */
|
||||
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null } | null
|
||||
}
|
||||
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], accommodations = [], initialAutomated = false, transitPrefill = null }: TransportModalProps) {
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null }: TransportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
@@ -170,15 +144,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
// the post-save handler knows to open the Costs editor for the saved booking.
|
||||
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
||||
const [form, setForm] = useState({ ...defaultForm })
|
||||
// Manual vs Automated (public transit search) creation mode (#1065).
|
||||
const [automated, setAutomated] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
||||
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
|
||||
// Train route as an ordered list of stations (origin .. stops .. destination).
|
||||
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
@@ -191,7 +161,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
// Either way the init reads the same fields — `reservation` still decides
|
||||
// edit-vs-create at submit time.
|
||||
const src = (reservation ?? prefill) as Reservation | null
|
||||
if (src) setAutomated(initialAutomated)
|
||||
// On a review-import, seed the booking's Files with the parsed source document.
|
||||
setPendingFiles(!reservation && prefill?._sourceFiles ? prefill._sourceFiles : [])
|
||||
if (src) {
|
||||
@@ -257,57 +226,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
wps = [dep, arr]
|
||||
}
|
||||
setWaypoints(wps)
|
||||
} else if (type === 'train') {
|
||||
// Mirror the flight seeding with stations + per-leg train fields. A
|
||||
// current single-leg train (2 endpoints, no metadata.legs) round-trips
|
||||
// through the >=2 branch: the flat train_number/platform/seat land on
|
||||
// the first station, dep/arr day+time from src.day_id/end_day_id.
|
||||
const orderedEps = orderedEndpoints(src)
|
||||
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
|
||||
let wps: StationWaypointForm[]
|
||||
if (orderedEps.length >= 2) {
|
||||
wps = orderedEps.map((ep, i) => {
|
||||
const legInto = metaLegs[i - 1]
|
||||
const legOut = metaLegs[i]
|
||||
const isFirst = i === 0
|
||||
const isLast = i === orderedEps.length - 1
|
||||
return {
|
||||
location: locationFromEndpoint(ep),
|
||||
arrDayId: legInto?.arr_day_id ?? (isLast ? (src.end_day_id ?? '') : ''),
|
||||
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
|
||||
depDayId: legOut?.dep_day_id ?? (isFirst ? (src.day_id ?? '') : ''),
|
||||
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
||||
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
|
||||
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
|
||||
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const dep = emptyStationWaypoint(src.day_id ?? '')
|
||||
dep.location = locationFromEndpoint(from)
|
||||
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
|
||||
dep.train_number = meta.train_number ?? ''
|
||||
dep.platform = meta.platform ?? ''
|
||||
dep.seat = meta.seat ?? ''
|
||||
const arr = emptyStationWaypoint(src.end_day_id ?? src.day_id ?? '')
|
||||
arr.location = locationFromEndpoint(to)
|
||||
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
|
||||
wps = [dep, arr]
|
||||
}
|
||||
setTrainWaypoints(wps)
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
} else {
|
||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
}
|
||||
} else {
|
||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
||||
setAutomated(initialAutomated)
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
||||
setTrainWaypoints([emptyStationWaypoint(selectedDayId ?? ''), emptyStationWaypoint(selectedDayId ?? '')])
|
||||
}
|
||||
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
|
||||
|
||||
@@ -331,15 +258,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const flightWps = form.type === 'flight' ? waypoints.filter(w => w.airport) : []
|
||||
const firstWp = flightWps[0]
|
||||
const lastWp = flightWps[flightWps.length - 1]
|
||||
// Train route: the first/last waypoint drive the span + flat metadata
|
||||
// (day/time/train number are entered independently of geocoding, exactly
|
||||
// like the old form fields, so a train with no map-picked station still
|
||||
// saves its day/time/train number). Only geocoded stations become map
|
||||
// endpoints + legs, mirroring the old "push only if the location is set".
|
||||
const trainWps = form.type === 'train' ? trainWaypoints : []
|
||||
const firstTrainWp = trainWps[0]
|
||||
const lastTrainWp = trainWps[trainWps.length - 1]
|
||||
const trainStations = form.type === 'train' ? trainWaypoints.filter(w => w.location) : []
|
||||
// Per-leg day-plan positions are owned by the day planner, not this form — keep
|
||||
// them when re-saving so editing a flight doesn't reset where its legs sit.
|
||||
const origLegs: any[] = reservation ? (parseReservationMetadata(reservation).legs || []) : []
|
||||
@@ -378,48 +296,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
}
|
||||
if (firstWp?.seat) metadata.seat = firstWp.seat
|
||||
} else if (form.type === 'train') {
|
||||
// Flat keys mirror the first leg so legacy readers keep working; a
|
||||
// 2-station train emits exactly {train_number?,platform?,seat?} — the
|
||||
// same shape it saved before this feature.
|
||||
if (firstTrainWp?.train_number) metadata.train_number = firstTrainWp.train_number
|
||||
if (firstTrainWp?.platform) metadata.platform = firstTrainWp.platform
|
||||
if (firstTrainWp?.seat) metadata.seat = firstTrainWp.seat
|
||||
// Per-leg detail only for a true multi-leg train (>2 geocoded stations);
|
||||
// a simple train keeps the same flat metadata it saved before.
|
||||
if (trainStations.length > 2) {
|
||||
metadata.legs = trainStations.slice(0, -1).map((w, i) => {
|
||||
const next = trainStations[i + 1]
|
||||
return {
|
||||
from: w.location!.name,
|
||||
to: next.location!.name,
|
||||
...(w.train_number ? { train_number: w.train_number } : {}),
|
||||
...(w.platform ? { platform: w.platform } : {}),
|
||||
...(w.seat ? { seat: w.seat } : {}),
|
||||
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
||||
dep_time: w.depTime || null,
|
||||
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
||||
arr_time: next.arrTime || null,
|
||||
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
|
||||
// A transit itinerary (#1065) lives in metadata.transit + 'stop' endpoints,
|
||||
// neither of which this form shows or edits — so re-saving must not wipe
|
||||
// them. They're kept only while from/to are unchanged: picking a different
|
||||
// origin or destination invalidates the stored connection.
|
||||
const prevMeta = reservation ? parseReservationMetadata(reservation) : {}
|
||||
const prevEndpointsAll = reservation?.endpoints || []
|
||||
const prevFrom = prevEndpointsAll.find(ep => ep.role === 'from')
|
||||
const prevTo = prevEndpointsAll.find(ep => ep.role === 'to')
|
||||
const near = (a?: number | null, b?: number | null) => a != null && b != null && Math.abs(a - b) < 1e-6
|
||||
const keepTransit = !!(prevMeta.transit && form.type !== 'flight' &&
|
||||
prevFrom && prevTo && fromPick.location && toPick.location &&
|
||||
near(prevFrom.lat, fromPick.location.lat) && near(prevFrom.lng, fromPick.location.lng) &&
|
||||
near(prevTo.lat, toPick.location.lat) && near(prevTo.lng, toPick.location.lng))
|
||||
if (keepTransit) metadata.transit = prevMeta.transit
|
||||
|
||||
const startDate = startDay?.date ?? null
|
||||
const endDate = (endDay ?? startDay)?.date ?? null
|
||||
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||
@@ -432,56 +312,27 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const time = isLast ? w.arrTime : w.depTime
|
||||
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), time || null))
|
||||
})
|
||||
} else if (form.type === 'train') {
|
||||
trainStations.forEach((w, i) => {
|
||||
const isFirst = i === 0
|
||||
const isLast = i === trainStations.length - 1
|
||||
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
|
||||
const dId = isLast ? w.arrDayId : w.depDayId
|
||||
const time = isLast ? w.arrTime : w.depTime
|
||||
// The destination date falls back to the departure day (as the old flat
|
||||
// path did via `endDay ?? startDay`) when the arrival day is left blank.
|
||||
const date = dayDate(dId) ?? (isLast ? dayDate(firstTrainWp?.depDayId ?? '') : null)
|
||||
endpoints.push(endpointFromLocation(w.location!, role, i, date, time || null))
|
||||
})
|
||||
} else {
|
||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
||||
// Keep the itinerary's transfer stops while the route is unchanged (#1065).
|
||||
const stops = keepTransit
|
||||
? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
|
||||
: []
|
||||
stops.forEach((s, i) => endpoints.push({
|
||||
role: 'stop', sequence: i + 1, name: s.name, code: s.code ?? null,
|
||||
lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
|
||||
local_date: s.local_date ?? null, local_time: s.local_time ?? null,
|
||||
}))
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
|
||||
}
|
||||
|
||||
// Flights and trains derive their span from the first/last waypoint; other
|
||||
// transports keep using the single departure/arrival form fields unchanged.
|
||||
// Flights derive their span from the first/last waypoint; other transports
|
||||
// keep using the single departure/arrival form fields unchanged.
|
||||
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
|
||||
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : null
|
||||
const trainDepDay = firstTrainWp && firstTrainWp.depDayId ? Number(firstTrainWp.depDayId) : null
|
||||
const trainArrDay = lastTrainWp && lastTrainWp.arrDayId ? Number(lastTrainWp.arrDayId) : null
|
||||
const payload = {
|
||||
title: form.title,
|
||||
type: form.type,
|
||||
status: form.status,
|
||||
day_id: form.type === 'flight' ? flightDepDay : form.type === 'train' ? trainDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
|
||||
end_day_id: form.type === 'flight' ? flightArrDay : form.type === 'train' ? trainArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
|
||||
day_id: form.type === 'flight' ? flightDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
|
||||
end_day_id: form.type === 'flight' ? flightArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
|
||||
reservation_time: form.type === 'flight'
|
||||
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '')
|
||||
: form.type === 'train'
|
||||
? buildTime(days.find(d => d.id === trainDepDay), firstTrainWp?.depTime || '')
|
||||
: buildTime(startDay, form.departure_time),
|
||||
: buildTime(startDay, form.departure_time),
|
||||
reservation_end_time: form.type === 'flight'
|
||||
? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '')
|
||||
: form.type === 'train'
|
||||
// Fall back to the departure day so a same-day train (arrival day left
|
||||
// blank) still gets its date, matching the non-flight `endDay ?? startDay`.
|
||||
? buildTime(days.find(d => d.id === trainArrDay) ?? days.find(d => d.id === trainDepDay), lastTrainWp?.arrTime || '')
|
||||
: buildTime(endDay ?? startDay, form.arrival_time),
|
||||
: buildTime(endDay ?? startDay, form.arrival_time),
|
||||
location: null,
|
||||
confirmation_number: form.confirmation_number || null,
|
||||
notes: form.notes || null,
|
||||
@@ -588,71 +439,19 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={automated ? t('transit.title') : reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
||||
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
||||
size="2xl"
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button type="button" onClick={onClose} className="text-content-muted" style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
{!automated && (
|
||||
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Manual vs Automated creation switch (#1065) — creating only; editing a
|
||||
journey re-enters via "change route" with the switch hidden. */}
|
||||
{!reservation && (
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2, marginBottom: 14 }}>
|
||||
{([['manual', t('transport.modeManual')], ['automated', t('transport.modeAutomated')]] as const).map(([m, label]) => {
|
||||
const active = (m === 'automated') === automated
|
||||
return (
|
||||
<button key={m} type="button" onClick={() => setAutomated(m === 'automated')}
|
||||
className={active ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||
style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap', background: active ? undefined : 'transparent', boxShadow: active ? '0 1px 4px rgba(0,0,0,0.08)' : 'none' }}>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{automated ? (
|
||||
/* ── Automated: public transit search (#1065) ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{/* search header: what this is + the day it plans for */}
|
||||
<div className="bg-surface-tertiary" style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px', borderRadius: 14, flexWrap: 'wrap' }}>
|
||||
<div style={{ width: 42, height: 42, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 12, background: '#7c3aed18' }}>
|
||||
<TramFront size={20} strokeWidth={1.8} color="#7c3aed" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 180 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, letterSpacing: '-0.01em' }}>{t('transit.title')}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('transit.searchHint')}</div>
|
||||
</div>
|
||||
<div style={{ width: typeof window !== 'undefined' && window.innerWidth < 768 ? '100%' : 210, flexShrink: 0 }}>
|
||||
<CustomSelect value={form.start_day_id} onChange={v => set('start_day_id', v)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const transitDay = days.find(d => d.id === Number(form.start_day_id))
|
||||
if (!transitDay) return <div className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '4px 2px 12px' }}>{t('transit.pickDay')}</div>
|
||||
return (
|
||||
<TransitSearchPanel
|
||||
day={transitDay}
|
||||
days={days}
|
||||
places={places}
|
||||
accommodations={accommodations}
|
||||
onAdd={(payload) => onSave(payload as Record<string, any> & { title: string })}
|
||||
initialFrom={transitPrefill?.from ?? null}
|
||||
initialTo={transitPrefill?.to ?? null}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
|
||||
{/* Type selector */}
|
||||
@@ -765,80 +564,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : form.type === 'train' ? (
|
||||
/* ── Train route: ordered stations (origin · stops · destination) ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label className={labelClass}>{t('reservations.layover.route')}</label>
|
||||
{trainWaypoints.map((wp, i) => {
|
||||
const isFirst = i === 0
|
||||
const isLast = i === trainWaypoints.length - 1
|
||||
const updateWp = (patch: Partial<StationWaypointForm>) => setTrainWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
|
||||
const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop')
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="bg-surface-card" style={{ border: '1px solid var(--border-primary)', borderRadius: 10, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} />
|
||||
</div>
|
||||
{!isFirst && !isLast && (
|
||||
<button type="button" onClick={() => setTrainWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isFirst && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.arrivalDate')}</label>
|
||||
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.arrivalTime')}</label>
|
||||
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLast && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.departureDate')}</label>
|
||||
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.departureTime')}</label>
|
||||
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
|
||||
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.platform')}</label>
|
||||
<input type="text" value={wp.platform} onChange={e => updateWp({ platform: e.target.value })} placeholder="12" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
||||
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<button type="button" onClick={() => setTrainWaypoints(prev => [...prev.slice(0, i + 1), emptyStationWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}
|
||||
className="text-content-faint hover:text-content-secondary" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={12} /> {t('reservations.layover.addStop')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* From / To endpoints (non-flight) */}
|
||||
@@ -880,7 +605,25 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
)}
|
||||
|
||||
{/* Train-specific fields */}
|
||||
{/* Train number / platform / seat are per-leg now (in the route above). */}
|
||||
{form.type === 'train' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
|
||||
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
|
||||
placeholder="ICE 123" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.platform')}</label>
|
||||
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
|
||||
placeholder="12" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
||||
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
|
||||
placeholder="42A" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking Code + Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
@@ -946,7 +689,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,.pkpass,.pkpasses,image/*,application/vnd.apple.pkpass,application/vnd.apple.pkpasses" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} className="text-content-faint" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
@@ -1012,7 +755,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
)}
|
||||
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertTriangle, Info, AlertCircle } from 'lucide-react'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
|
||||
/**
|
||||
* Shows validation/warning contributions from `warningProvider` plugins (#1429).
|
||||
* Self-contained + fail-safe: the server skips any slow/failing provider, so this
|
||||
* only ever adds rows; it renders nothing (and takes no space) when there are none.
|
||||
*/
|
||||
type Warning = { pluginId: string; level: 'info' | 'warning' | 'error'; message: string }
|
||||
|
||||
const STYLE = {
|
||||
info: { Icon: Info, color: 'var(--info)', bg: 'var(--info-soft)' },
|
||||
warning: { Icon: AlertTriangle, color: 'var(--warning)', bg: 'var(--warning-soft)' },
|
||||
error: { Icon: AlertCircle, color: 'var(--danger)', bg: 'var(--danger-soft)' },
|
||||
} as const
|
||||
|
||||
export default function TripWarningsBanner({ tripId }: { tripId: number }) {
|
||||
const [warnings, setWarnings] = useState<Warning[]>([])
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(tripId)) { setWarnings([]); return }
|
||||
let cancelled = false
|
||||
pluginsApi.tripWarnings(tripId)
|
||||
.then((d) => { if (!cancelled) setWarnings(d.warnings || []) })
|
||||
.catch(() => { if (!cancelled) setWarnings([]) })
|
||||
return () => { cancelled = true }
|
||||
}, [tripId])
|
||||
|
||||
if (warnings.length === 0) return null
|
||||
// A non-blocking overlay pinned to the top of the (fixed) planner content region:
|
||||
// the wrapper ignores pointer events so it never covers the map/panels, and only
|
||||
// the warning pills themselves are interactive.
|
||||
return (
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 6, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 6, padding: '8px 16px' }}>
|
||||
{warnings.map((w, i) => {
|
||||
const s = STYLE[w.level] ?? STYLE.warning
|
||||
return (
|
||||
<div key={`${w.pluginId}-${i}`} style={{ pointerEvents: 'auto', display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 10, background: s.bg, color: s.color, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, boxShadow: 'var(--shadow-card)' }}>
|
||||
<s.Icon size={15} style={{ flexShrink: 0 }} />
|
||||
<span>{w.message}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export function parsedItemToDraft(item: BookingImportPreviewItem): BookingReview
|
||||
}
|
||||
|
||||
/** Transport types route to the TransportModal; everything else to the ReservationModal. */
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
||||
export function isTransportItem(item: BookingImportPreviewItem): boolean {
|
||||
return TRANSPORT_TYPES.has(item.type)
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Footprints, MoveRight, type LucideIcon } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Shared display bits for public-transit entries (#1065) — the timeline row,
|
||||
* the Transports-tab card and the journey modal all render the same language:
|
||||
* "A → B" titles with a real arrow icon, and the leg sequence as chips where
|
||||
* walks carry their minutes (🚶 3 › ⟨U2⟩ › 🚶 3) instead of a detached summary.
|
||||
*/
|
||||
|
||||
export interface TransitLegDisplay {
|
||||
mode?: string
|
||||
line?: string | null
|
||||
line_color?: string | null
|
||||
line_text_color?: string | null
|
||||
duration?: number
|
||||
headsign?: string | null
|
||||
stops?: number
|
||||
from?: { name?: string; time?: string | null; track?: string | null }
|
||||
to?: { name?: string; time?: string | null; track?: string | null }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A walk leg as a one-line centred divider — dashed rules left and right,
|
||||
* the walk itself in the middle (🚶 Walk to X · 4 min). Used by the journey
|
||||
* modal and the day-plan inline itinerary.
|
||||
*/
|
||||
export function TransitWalkDivider({ leg, t, size = 'md' }: {
|
||||
leg: TransitLegDisplay
|
||||
t: (k: string, p?: Record<string, string | number>) => string
|
||||
size?: 'sm' | 'md'
|
||||
}) {
|
||||
const mins = leg.duration ? Math.round(leg.duration / 60) : null
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
// Hairlines that fade towards the outer edges — strongest next to the text.
|
||||
const rule = (dir: 'left' | 'right'): React.CSSProperties => ({
|
||||
flex: 1, height: 1, minWidth: 12, borderRadius: 1,
|
||||
background: `linear-gradient(to ${dir}, var(--text-faint), transparent)`,
|
||||
opacity: 0.55,
|
||||
})
|
||||
// On phones the minutes lead so a long stop name only ever clips the name.
|
||||
const text = isMobile
|
||||
? `${mins ? `${t('transit.min', { count: mins })} · ` : ''}${t('transit.walkTo', { name: leg.to?.name || '' })}`
|
||||
: `${t('transit.walkTo', { name: leg.to?.name || '' })}${mins ? ` · ${t('transit.min', { count: mins })}` : ''}`
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: size === 'sm' ? '1px 0' : '2px 0' }}>
|
||||
<span style={rule('left')} />
|
||||
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: size === 'sm' ? 'calc(10px * var(--fs-scale-caption, 1))' : 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: isMobile ? '85%' : '75%' }}>
|
||||
<Footprints size={size === 'sm' ? 11 : 12} style={{ flexShrink: 0 }} />
|
||||
{text}
|
||||
</span>
|
||||
<span style={rule('right')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The itinerary folded out right inside the day-plan row (#1065): one compact
|
||||
* line per leg — time, badge or foot icon, stations — sized for the sidebar.
|
||||
*/
|
||||
export function TransitItineraryInline({ legs, t }: {
|
||||
legs: TransitLegDisplay[]
|
||||
t: (k: string, p?: Record<string, string | number>) => string
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{legs.map((leg, i) => {
|
||||
if (leg.mode === 'WALK') return <TransitWalkDivider key={i} leg={leg} t={t} size="sm" />
|
||||
const mins = leg.duration ? Math.round(leg.duration / 60) : null
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||
<span className="text-content-muted" style={{ width: 34, flexShrink: 0, textAlign: 'right', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, paddingTop: 1 }}>
|
||||
{leg.from?.time || ''}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', borderRadius: 4, padding: '0 5px',
|
||||
fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', fontWeight: 700, lineHeight: '15px', flexShrink: 0,
|
||||
background: leg.line_color || 'var(--bg-tertiary)',
|
||||
color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)',
|
||||
}}>
|
||||
{leg.line || leg.mode}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4, minWidth: 0 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{leg.from?.name}</span>
|
||||
<MoveRight size={10} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{leg.to?.name}</span>
|
||||
</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', marginTop: 1 }}>
|
||||
{[
|
||||
mins ? t('transit.min', { count: mins }) : null,
|
||||
leg.stops ? t('transit.stops', { count: leg.stops }) : null,
|
||||
leg.from?.track ? t('transit.platform', { track: leg.from.track }) : null,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/** "1 h 9 min" / "42 min" from seconds — shared by all transit surfaces. */
|
||||
export function fmtTransitDuration(seconds: number, t: (k: string, p?: Record<string, string | number>) => string): string {
|
||||
const mins = Math.round(seconds / 60)
|
||||
if (mins < 60) return t('transit.min', { count: mins })
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m > 0 ? `${h} h ${m} min` : `${h} h`
|
||||
}
|
||||
|
||||
export interface TransitMetaItem {
|
||||
icon?: LucideIcon
|
||||
text: string
|
||||
/** De-emphasised (operator names and the like). */
|
||||
dim?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A quiet badge row replacing dot-joined meta text ("21:06 - 22:15 - 69 min -
|
||||
* 14 stops ..."): each fact becomes a small chip, optional icon in front.
|
||||
*/
|
||||
export function TransitMetaBadges({ items, size = 'md' }: { items: TransitMetaItem[]; size?: 'sm' | 'md' }) {
|
||||
const font = size === 'sm' ? 'calc(10px * var(--fs-scale-caption, 1))' : 'calc(10.5px * var(--fs-scale-caption, 1))'
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
|
||||
{items.filter(i => i.text).map(({ icon: Icon, text, dim }, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={dim ? 'bg-surface-card text-content-faint' : 'bg-surface-card text-content-muted'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: size === 'sm' ? '1px 6px' : '2px 8px', borderRadius: 6,
|
||||
fontSize: font, fontWeight: 500, whiteSpace: 'nowrap',
|
||||
border: '1px solid var(--border-faint)',
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon size={size === 'sm' ? 9 : 10} strokeWidth={2} />}
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Renders "From → To" titles with an arrow icon instead of the text glyph. */
|
||||
export function TransitTitle({ title, iconSize = 12 }: { title: string; iconSize?: number }) {
|
||||
const parts = title.split(' → ')
|
||||
if (parts.length < 2) return <>{title}</>
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 0, maxWidth: '100%' }}>
|
||||
{parts.map((p, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <MoveRight size={iconSize} style={{ flexShrink: 0, opacity: 0.55 }} />}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The leg sequence as chips. Walk legs show their minutes right at the foot
|
||||
* icon (sub-minute walks are dropped); transit legs are line badges in their
|
||||
* colors. Optionally appends "· N transfers" — never a redundant "direct".
|
||||
*/
|
||||
export function TransitLegChips({ legs, transfers, size = 'sm', t }: {
|
||||
legs: TransitLegDisplay[]
|
||||
transfers?: number
|
||||
size?: 'sm' | 'md'
|
||||
t: (k: string, p?: Record<string, string | number>) => string
|
||||
}) {
|
||||
const badgeFont = size === 'sm' ? 'calc(9.5px * var(--fs-scale-caption, 1))' : 'calc(10.5px * var(--fs-scale-caption, 1))'
|
||||
const walkIcon = size === 'sm' ? 10 : 12
|
||||
const shown = legs.filter(l => l.mode !== 'WALK' || (l.duration || 0) >= 60)
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: size === 'sm' ? 4 : 5, flexWrap: 'wrap' }}>
|
||||
{shown.map((leg, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="text-content-faint" style={{ fontSize: size === 'sm' ? 9 : 10 }}>›</span>}
|
||||
{leg.mode === 'WALK' ? (
|
||||
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: badgeFont, fontWeight: 600 }}>
|
||||
<Footprints size={walkIcon} />
|
||||
{Math.round((leg.duration || 0) / 60)}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', borderRadius: size === 'sm' ? 4 : 5,
|
||||
padding: size === 'sm' ? '0 5px' : '1px 7px', lineHeight: size === 'sm' ? '15px' : undefined,
|
||||
fontSize: badgeFont, fontWeight: 700,
|
||||
background: leg.line_color || 'var(--bg-tertiary)',
|
||||
color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)',
|
||||
}}>
|
||||
{leg.line || leg.mode}
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{typeof transfers === 'number' && transfers > 0 && (
|
||||
<span className="text-content-faint" style={{ fontSize: badgeFont, marginLeft: 2 }}>
|
||||
· {t('transit.transfers', { count: transfers })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// FE-PLUGINS-FRAME-001 to 004
|
||||
import { render, cleanup, waitFor } from '@testing-library/react';
|
||||
import PluginFrame from './PluginFrame';
|
||||
|
||||
const navigate = vi.fn();
|
||||
const toast = { info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn() };
|
||||
const invoke = vi.fn((..._args: unknown[]) => Promise.resolve({ ok: true }));
|
||||
|
||||
vi.mock('react-router-dom', () => ({ useNavigate: () => navigate }));
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en' }) }));
|
||||
vi.mock('../../store/authStore', () => ({ useAuthStore: (sel: (s: unknown) => unknown) => sel({ user: { id: 7, username: 'ada', avatar_url: null, role: 'admin' } }) }));
|
||||
vi.mock('../../store/settingsStore', () => ({ useSettingsStore: (sel: (s: unknown) => unknown) => sel({ settings: { default_currency: 'EUR', time_format: '24h', distance_unit: 'metric', temperature_unit: 'celsius' } }) }));
|
||||
vi.mock('../../api/client', () => ({ pluginsApi: { invoke: (id: string, sub: string, init?: unknown) => invoke(id, sub, init) } }));
|
||||
|
||||
function fromFrame(frame: HTMLIFrameElement, data: unknown) {
|
||||
window.dispatchEvent(new MessageEvent('message', { source: frame.contentWindow, data } as MessageEventInit));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
navigate.mockClear();
|
||||
Object.values(toast).forEach((f) => f.mockClear());
|
||||
invoke.mockClear();
|
||||
});
|
||||
|
||||
describe('PluginFrame', () => {
|
||||
it('FE-PLUGINS-FRAME-001: renders an opaque sandboxed iframe (no allow-same-origin)', () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
expect(iframe.getAttribute('src')).toBe('/plugin-frame/demo/index.html');
|
||||
const sandbox = iframe.getAttribute('sandbox') || '';
|
||||
expect(sandbox).toContain('allow-scripts');
|
||||
expect(sandbox).not.toContain('allow-same-origin');
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-002: authenticates messages by sender window — a foreign source is ignored', () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
// message NOT from our iframe -> ignored
|
||||
window.dispatchEvent(new MessageEvent('message', { source: window, data: { type: 'trek:navigate', to: '/admin' } }));
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
// message from our iframe -> handled
|
||||
fromFrame(iframe, { type: 'trek:navigate', to: '/dashboard' });
|
||||
expect(navigate).toHaveBeenCalledWith('/dashboard');
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-003: blocks unsafe navigation targets and renders notifications as text', () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
fromFrame(iframe, { type: 'trek:navigate', to: '//evil.example' }); // protocol-relative
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
fromFrame(iframe, { type: 'trek:notify', level: 'success', message: 'saved' });
|
||||
expect(toast.success).toHaveBeenCalledWith('saved');
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-004: trek:invoke calls the host proxy and replies to the frame', async () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
const posted: unknown[] = [];
|
||||
// capture host->frame messages
|
||||
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m);
|
||||
|
||||
fromFrame(iframe, { type: 'trek:invoke', requestId: 'r1', sub: '/status', method: 'GET' });
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith('demo', '/status', { method: 'GET', body: undefined }));
|
||||
await waitFor(() => expect(posted.some((m) => (m as { type?: string }).type === 'trek:response')).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-005: context carries theme tokens, formats and non-secret display identity', () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
const posted: Array<Record<string, unknown>> = [];
|
||||
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
|
||||
|
||||
fromFrame(iframe, { type: 'trek:context:request' });
|
||||
|
||||
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
|
||||
expect(ctx).toBeTruthy();
|
||||
expect(ctx!.tokens).toBeTruthy(); // resolved design tokens (empty {} in jsdom, but present)
|
||||
expect(ctx!.formats).toMatchObject({ currency: 'EUR', timeFormat: '24h', distanceUnit: 'metric' });
|
||||
// Display identity is present but carries NO secret (no email, role only as a boolean).
|
||||
expect(ctx!.user).toMatchObject({ name: 'ada', isAdmin: true });
|
||||
expect(JSON.stringify(ctx)).not.toContain('@'); // no email leaked
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-006: context mirrors the host appearance state (scheme/density/flags)', () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
const posted: Array<Record<string, unknown>> = [];
|
||||
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
|
||||
|
||||
fromFrame(iframe, { type: 'trek:context:request' });
|
||||
|
||||
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
|
||||
expect(ctx).toBeTruthy();
|
||||
// A plugin can honour the same accent/density/accessibility choices as the host.
|
||||
expect(ctx!.appearance).toMatchObject({
|
||||
scheme: 'default',
|
||||
density: 'comfortable',
|
||||
noTransparency: false,
|
||||
reducedMotion: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,251 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
|
||||
// The design-token contract handed to plugins (#4 richer context): non-secret CSS
|
||||
// values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and
|
||||
// re-match on a theme toggle / accent change) instead of hard-coding a mirror of
|
||||
// the palette that drifts. Names mirror index.css so a plugin can apply them
|
||||
// verbatim as CSS variables. This is the whole GLOBAL (:root/.dark) palette — the
|
||||
// part that a user can recolour via appearance settings (accent scheme, custom
|
||||
// accent, high-contrast) flows through here live. The glassy `.trek-dash` layer
|
||||
// (--glass-*/--r-*/--sh-*) is intentionally NOT read here: it is scoped to the
|
||||
// dashboard subtree, so it resolves EMPTY at documentElement — the SDK design kit
|
||||
// bakes those values instead (they don't vary with the accent, only light/dark).
|
||||
const TOKEN_VARS = [
|
||||
// surfaces
|
||||
'--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-elevated',
|
||||
'--bg-card', '--bg-input', '--bg-hover', '--bg-selected', '--bg-inverse',
|
||||
// text
|
||||
'--text-primary', '--text-secondary', '--text-muted', '--text-faint', '--text-inverse',
|
||||
// borders
|
||||
'--border-primary', '--border-secondary', '--border-faint',
|
||||
// accent (recoloured by the chosen scheme / custom accent)
|
||||
'--accent', '--accent-text', '--accent-on', '--accent-hover', '--accent-subtle',
|
||||
// semantic + soft fills
|
||||
'--success', '--success-soft', '--danger', '--danger-soft',
|
||||
'--warning', '--warning-soft', '--info', '--info-soft',
|
||||
// shadows
|
||||
'--shadow-card', '--shadow-elevated', '--shadow-sm', '--shadow-md', '--shadow-lg',
|
||||
// radii, type, misc
|
||||
'--radius-sm', '--radius-md', '--radius-lg', '--radius-xl',
|
||||
'--font-system', '--font-subtext', '--overlay', '--ease-out-quint',
|
||||
]
|
||||
function readThemeTokens(): Record<string, string> {
|
||||
const cs = getComputedStyle(document.documentElement)
|
||||
const out: Record<string, string> = {}
|
||||
for (const v of TOKEN_VARS) {
|
||||
const val = cs.getPropertyValue(v).trim()
|
||||
if (val) out[v] = val
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's current appearance state, mirrored from the attributes applyAppearance
|
||||
* writes on <html>, so a plugin can honour the same accessibility/appearance choices
|
||||
* inside its own sandboxed document (it can't read the parent DOM). All booleans/enums
|
||||
* — nothing secret.
|
||||
*/
|
||||
function readAppearance() {
|
||||
const el = document.documentElement
|
||||
return {
|
||||
scheme: el.dataset.scheme || 'default',
|
||||
density: el.dataset.density === 'compact' ? 'compact' : 'comfortable',
|
||||
noTransparency: el.hasAttribute('data-no-transparency'),
|
||||
reducedMotion:
|
||||
el.hasAttribute('data-reduce-motion') ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a plugin's sandboxed page/widget iframe and hosts the trekBridge
|
||||
* (#plugins, M3).
|
||||
*
|
||||
* The frame is served same-origin from /plugin-frame/:id but sandboxed WITHOUT
|
||||
* allow-same-origin, so it runs at an OPAQUE origin: no access to the trek_session
|
||||
* cookie, no parent DOM, no credentialed fetch. Its only channel is postMessage,
|
||||
* and we authenticate every inbound message by the SENDER WINDOW IDENTITY
|
||||
* (event.source === our iframe), never by a claimed id or by origin (which is
|
||||
* "null" for opaque frames). Data reads go through the host (app-origin, session
|
||||
* cookie) so the plugin never handles credentials.
|
||||
*/
|
||||
|
||||
interface PluginFrameProps {
|
||||
pluginId: string
|
||||
tripId?: string | null
|
||||
/** The place in view — set for a place-detail slot so the plugin can scope to it. */
|
||||
placeId?: string | null
|
||||
className?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
type Inbound =
|
||||
| { type: 'trek:ready' }
|
||||
| { type: 'trek:context:request' }
|
||||
| { type: 'trek:navigate'; to: string }
|
||||
| { type: 'trek:notify'; level?: 'info' | 'success' | 'warning' | 'error'; message?: string }
|
||||
| { type: 'trek:resize'; height?: number }
|
||||
| { type: 'trek:invoke'; requestId: string; sub: string; method?: string; body?: unknown }
|
||||
|
||||
export default function PluginFrame({ pluginId, tripId = null, placeId = null, className, title }: PluginFrameProps) {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null)
|
||||
// A sandboxed frame may navigate ITSELF (connect-src can't stop that), and its
|
||||
// window identity keeps matching our iframe afterwards. Track loads and refuse
|
||||
// the bridge once a second document loads. NOTE: this is best-effort — the load
|
||||
// event fires at end-of-document, so a navigated attacker doc that posts during
|
||||
// its own load (or holds it open) can still reach the bridge for one exchange.
|
||||
// The exposure is bounded (only this plugin's own routes + the trek:context
|
||||
// ids the plugin already had; never the httpOnly cookie); fully closing it
|
||||
// would require not running plugin client JS at all.
|
||||
const loadsRef = useRef(0)
|
||||
const { locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const userId = useAuthStore((s) => s.user?.id)
|
||||
const userName = useAuthStore((s) => s.user?.username ?? null)
|
||||
const userAvatar = useAuthStore((s) => s.user?.avatar_url ?? null)
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
|
||||
const settings = useSettingsStore((s) => s.settings)
|
||||
const [height, setHeight] = useState<number | null>(null)
|
||||
|
||||
// opaque frame -> targetOrigin must be '*'. Hoisted so the iframe's onLoad can
|
||||
// deliver the context too: the trek:ready handshake alone is racy — if the frame
|
||||
// boots before the effect's listener attaches, the plugin never learns the theme
|
||||
// and falls back to the OS scheme (dark mode looking "off" until a toggle).
|
||||
const postFrame = useCallback((msg: unknown) => frameRef.current?.contentWindow?.postMessage(msg, '*'), [])
|
||||
const buildContext = useCallback(() => ({
|
||||
type: 'trek:context',
|
||||
tripId,
|
||||
placeId,
|
||||
userId: userId != null ? String(userId) : null,
|
||||
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
||||
locale,
|
||||
hostOrigin: window.location.origin,
|
||||
// #4 richer context — non-secret display data so plugins render natively:
|
||||
// who the user is (name/avatar/isAdmin — never email/role beyond a boolean),
|
||||
// how TREK formats things, the resolved theme tokens, and the appearance state
|
||||
// (accent scheme, density, reduced-motion / no-transparency) so a plugin can
|
||||
// mirror the same look and accessibility choices as the host.
|
||||
user: userName != null ? { name: userName, avatar: userAvatar, isAdmin } : null,
|
||||
appearance: readAppearance(),
|
||||
formats: {
|
||||
locale,
|
||||
currency: settings.default_currency,
|
||||
timeFormat: settings.time_format,
|
||||
distanceUnit: settings.distance_unit,
|
||||
temperatureUnit: settings.temperature_unit,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
tokens: readThemeTokens(),
|
||||
}), [tripId, placeId, userId, locale, userName, userAvatar, isAdmin, settings])
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current
|
||||
if (!frame) return
|
||||
|
||||
const post = postFrame
|
||||
const context = buildContext
|
||||
|
||||
const onMessage = async (ev: MessageEvent) => {
|
||||
// The ONLY trusted identity: the message came from OUR iframe's window.
|
||||
if (ev.source !== frame.contentWindow) return
|
||||
// …AND that window still holds the original plugin document (loaded once).
|
||||
// A 2nd load means the frame navigated elsewhere — stop bridging to it.
|
||||
if (loadsRef.current > 1) return
|
||||
const msg = ev.data as Inbound
|
||||
if (!msg || typeof msg !== 'object') return
|
||||
|
||||
switch (msg.type) {
|
||||
case 'trek:ready':
|
||||
case 'trek:context:request':
|
||||
post(context())
|
||||
break
|
||||
case 'trek:navigate': {
|
||||
const to = typeof msg.to === 'string' ? msg.to : ''
|
||||
// In-app paths only; block protocol-relative and admin unless allowed by the app itself.
|
||||
if (/^\/[a-zA-Z0-9/_?=&%.-]*$/.test(to) && !to.startsWith('//')) navigate(to)
|
||||
break
|
||||
}
|
||||
case 'trek:notify': {
|
||||
const text = String(msg.message ?? '').slice(0, 200)
|
||||
const level = msg.level ?? 'info'
|
||||
if (text) (toast[level] ?? toast.info)(text)
|
||||
break
|
||||
}
|
||||
case 'trek:resize':
|
||||
if (typeof msg.height === 'number' && msg.height > 0) setHeight(Math.min(msg.height, 2000))
|
||||
break
|
||||
case 'trek:invoke': {
|
||||
// The plugin's own route, called host-side with the user's session.
|
||||
try {
|
||||
const data = await pluginsApi.invoke(pluginId, msg.sub, { method: msg.method, body: msg.body })
|
||||
post({ type: 'trek:response', requestId: msg.requestId, data })
|
||||
} catch (e) {
|
||||
const err = e as { response?: { status?: number }; message?: string }
|
||||
post({ type: 'trek:error', requestId: msg.requestId, code: err.response?.status ?? 'error', message: err.message ?? 'invoke failed' })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', onMessage)
|
||||
|
||||
// The frame is opaque-origin and can't read our DOM, and we otherwise send the
|
||||
// context (incl. theme + tokens) only once on trek:ready — so a plugin can't
|
||||
// follow an in-app appearance change. Watch the <html> element for anything
|
||||
// applyAppearance touches (the `dark` class, the data-* appearance attributes,
|
||||
// and inline style for the custom-accent vars) and re-post the context when the
|
||||
// resulting look actually changes, so plugins restyle live. A compact signature
|
||||
// dedupes: unrelated mutations don't trigger a repost. (Plugins re-apply on
|
||||
// trek:context.)
|
||||
const htmlEl = document.documentElement
|
||||
const appearanceSig = () => {
|
||||
const cs = getComputedStyle(htmlEl)
|
||||
return [
|
||||
htmlEl.classList.contains('dark'),
|
||||
htmlEl.dataset.scheme || '',
|
||||
htmlEl.dataset.density || '',
|
||||
htmlEl.hasAttribute('data-no-transparency'),
|
||||
htmlEl.hasAttribute('data-reduce-motion'),
|
||||
cs.getPropertyValue('--accent').trim(),
|
||||
].join('|')
|
||||
}
|
||||
let prevSig = appearanceSig()
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
const sig = appearanceSig()
|
||||
if (sig === prevSig) return
|
||||
prevSig = sig
|
||||
if (loadsRef.current <= 1) post(context())
|
||||
})
|
||||
themeObserver.observe(htmlEl, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'data-scheme', 'data-density', 'data-no-transparency', 'data-reduce-motion'],
|
||||
})
|
||||
|
||||
return () => { window.removeEventListener('message', onMessage); themeObserver.disconnect() }
|
||||
}, [pluginId, navigate, toast, postFrame, buildContext])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
src={`/plugin-frame/${pluginId}/index.html`}
|
||||
// Deliver the context as soon as the document is parsed (the plugin sets up its
|
||||
// message listener during parse), closing the trek:ready race so the theme is
|
||||
// right on first paint. A 2nd load is a self-navigation — don't bridge to it.
|
||||
onLoad={() => { loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
title={title || pluginId}
|
||||
className={className}
|
||||
style={{ width: '100%', height: height ?? '100%', border: 0 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Blocks } from 'lucide-react'
|
||||
import PluginFrame from './PluginFrame'
|
||||
import type { ActivePlugin } from '../../store/pluginStore'
|
||||
|
||||
/**
|
||||
* Renders active `widget` plugins as dashboard cards (#plugins, M8). Each is a
|
||||
* sandboxed PluginFrame; the widget talks to TREK only over the bridge.
|
||||
*
|
||||
* The card mirrors the native dashboard tools (glassy `.tool` surface + uppercase
|
||||
* title) so plugins sit alongside them seamlessly, and the body auto-sizes to the
|
||||
* height the widget reports over trek:resize — no fixed height that would clip a
|
||||
* taller widget's controls.
|
||||
*/
|
||||
export default function PluginWidgets({ plugins, tripId = null }: { plugins: ActivePlugin[]; tripId?: string | null }) {
|
||||
if (plugins.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
{plugins.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
style={{
|
||||
background: 'var(--glass-bg)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: 'var(--r-xl, 20px)',
|
||||
boxShadow: 'var(--glass-shadow), var(--glass-highlight)',
|
||||
backdropFilter: 'var(--glass-blur)',
|
||||
WebkitBackdropFilter: 'var(--glass-blur)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '16px 20px 8px',
|
||||
fontSize: 13, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.14em',
|
||||
color: 'var(--ink-3)',
|
||||
}}
|
||||
>
|
||||
<Blocks size={14} style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
</div>
|
||||
{/* min-height is just a pre-resize floor; trek:resize drives the real height. */}
|
||||
<div style={{ minHeight: 60 }}>
|
||||
<PluginFrame pluginId={p.id} tripId={tripId} title={p.name} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -457,8 +457,7 @@ function SliderRow({ label, value, onChange }: { label: string; value: number; o
|
||||
step={0.05}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="trek-range"
|
||||
style={{ '--fill': `${((value - APPEARANCE_SCALE_MIN) / (APPEARANCE_SCALE_MAX - APPEARANCE_SCALE_MIN)) * 100}%` } as React.CSSProperties}
|
||||
style={{ width: '100%', accentColor: 'var(--accent)', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -483,8 +482,7 @@ function SizeRow({ sampleClass, name, example, sample, value, onChange }: { samp
|
||||
step={0.05}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="trek-range"
|
||||
style={{ '--fill': `${((value - APPEARANCE_SCALE_MIN) / (APPEARANCE_SCALE_MAX - APPEARANCE_SCALE_MIN)) * 100}%` } as React.CSSProperties}
|
||||
style={{ width: '100%', accentColor: 'var(--accent)', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -247,8 +247,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: (settings.map_booking_labels === true) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (settings.map_booking_labels === true) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
border: (settings.map_booking_labels !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (settings.map_booking_labels !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function Modal({
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-start sm:items-center justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
|
||||
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
|
||||
style={{ paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getDayBookendHotels } from '../utils/dayOrder'
|
||||
import type { TripStoreState } from '../store/tripStore'
|
||||
import type { RouteSegment, RouteResult, Accommodation } from '../types'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
|
||||
|
||||
const NO_ACCOMMODATIONS: Accommodation[] = []
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Reservation, ReservationEndpoint } from '../types'
|
||||
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||
|
||||
/**
|
||||
* Real road-network geometry for road-based transport bookings (car, bus, taxi,
|
||||
* bicycle), so their map lines follow actual streets instead of a straight
|
||||
* as-the-crow-flies line — the same idea as the real transit paths (#1065),
|
||||
* but routed on demand rather than stored on the reservation.
|
||||
*
|
||||
* Trains and "other transport" keep their straight line (rail/unknown modes
|
||||
* aren't road-routable); flights/cruises/ferries use the great-circle arc.
|
||||
* Any routing failure falls back to the straight line the overlay already draws.
|
||||
*/
|
||||
|
||||
const ROAD_PROFILE: Record<string, 'driving' | 'cycling'> = {
|
||||
car: 'driving',
|
||||
bus: 'driving',
|
||||
taxi: 'driving',
|
||||
bicycle: 'cycling',
|
||||
}
|
||||
|
||||
// Beyond this straight-line distance a car/taxi/bike (or even coach) booking is
|
||||
// almost always a data quirk or an inter-continental hop the road router can't
|
||||
// resolve — keep the straight line and don't hammer the public OSRM demo.
|
||||
const MAX_ROUTE_KM = 2000
|
||||
|
||||
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
const R = 6371
|
||||
const dLat = (b.lat - a.lat) * Math.PI / 180
|
||||
const dLng = (b.lng - a.lng) * Math.PI / 180
|
||||
const la1 = a.lat * Math.PI / 180
|
||||
const la2 = b.lat * Math.PI / 180
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLng / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(h))
|
||||
}
|
||||
|
||||
function orderedWaypoints(r: Reservation): ReservationEndpoint[] {
|
||||
return (r.endpoints || [])
|
||||
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
||||
.slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of reservation id → routed [lat, lng] polyline for the
|
||||
* road-based bookings among `reservations`. Missing entries mean "not routed
|
||||
* (yet / not routable)" — the caller should draw its straight line for those.
|
||||
* Routing runs once per reservation waypoint-set and is cached across the app
|
||||
* by RouteCalculator, so day switches and re-renders don't re-fetch.
|
||||
*/
|
||||
export function useTransportRoutes(reservations: Reservation[]): Map<number, [number, number][]> {
|
||||
const [routes, setRoutes] = useState<Map<number, [number, number][]>>(new Map())
|
||||
// id → waypoint signature already fetched/attempted, so an unchanged booking
|
||||
// is never re-requested even as the reservations array identity churns.
|
||||
const attemptedRef = useRef<Map<number, string>>(new Map())
|
||||
// Aborted only on unmount — a reservations change must not cancel an
|
||||
// in-flight route we still want.
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
if (!abortRef.current) abortRef.current = new AbortController()
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
useEffect(() => {
|
||||
const jobs: { id: number; profile: 'driving' | 'cycling'; points: { lat: number; lng: number }[] }[] = []
|
||||
for (const r of reservations) {
|
||||
const profile = ROAD_PROFILE[r.type]
|
||||
if (!profile) continue
|
||||
const wps = orderedWaypoints(r)
|
||||
if (wps.length < 2) continue
|
||||
let dist = 0
|
||||
for (let i = 0; i < wps.length - 1; i++) dist += haversineKm(wps[i], wps[i + 1])
|
||||
if (dist > MAX_ROUTE_KM) continue
|
||||
const key = `${profile}:${wps.map(w => `${w.lat},${w.lng}`).join('|')}`
|
||||
if (attemptedRef.current.get(r.id) === key) continue
|
||||
attemptedRef.current.set(r.id, key)
|
||||
jobs.push({ id: r.id, profile, points: wps.map(w => ({ lat: w.lat, lng: w.lng })) })
|
||||
}
|
||||
if (!jobs.length) return
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
// Sequential to stay gentle on the shared public router.
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const result = await calculateRouteWithLegs(job.points, { signal: abortRef.current?.signal, profile: job.profile })
|
||||
if (cancelled) return
|
||||
if (result.coordinates.length >= 2) {
|
||||
setRoutes(prev => {
|
||||
const next = new Map(prev)
|
||||
next.set(job.id, result.coordinates)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Leave it unrouted — the overlay keeps the straight line.
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [reservations])
|
||||
|
||||
return routes
|
||||
}
|
||||
@@ -1093,36 +1093,3 @@ img[alt="TREK"] {
|
||||
.dp-day-header:hover .dp-day-actions,
|
||||
.dp-day-header[data-selected="true"] .dp-day-actions { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── TREK range slider — thin filled track + soft round thumb ─────────────── */
|
||||
.trek-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(to right, var(--accent) var(--fill, 50%), var(--border-faint) var(--fill, 50%));
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.trek-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.trek-range::-webkit-slider-thumb:hover { transform: scale(1.1); }
|
||||
.trek-range::-webkit-slider-thumb:active { transform: scale(1.18); }
|
||||
.trek-range::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.trek-range::-moz-range-track { height: 5px; border-radius: 999px; background: transparent; }
|
||||
|
||||
@@ -703,8 +703,7 @@ describe('AdminPage', () => {
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
||||
|
||||
// Maps is the first 'Enter key...' input (Unsplash is the second).
|
||||
const keyInput = (await screen.findAllByPlaceholderText('Enter key...'))[0];
|
||||
const keyInput = await screen.findByPlaceholderText('Enter key...');
|
||||
|
||||
// Type a value — covers the onChange handler
|
||||
fireEvent.change(keyInput, { target: { value: 'test-api-key-abc123' } });
|
||||
@@ -979,35 +978,6 @@ describe('AdminPage', () => {
|
||||
expect(capturedBody).toMatchObject({ maps_api_key: 'test-maps-key-123' });
|
||||
});
|
||||
});
|
||||
|
||||
it('typing in the Unsplash API key and clicking Save sends unsplash_api_key', async () => {
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
http.put('/api/auth/me/api-keys', async ({ request }) => {
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
render(<AdminPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
||||
|
||||
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
|
||||
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
|
||||
|
||||
// The Unsplash key is the second 'Enter key...' input (after Maps).
|
||||
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
|
||||
fireEvent.change(keyInputs[1], { target: { value: 'test-unsplash-key' } });
|
||||
|
||||
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^save$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody?.unsplash_api_key).toBe('test-unsplash-key');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-ADMIN-044: Validate API key in Settings tab', () => {
|
||||
|
||||
@@ -11,8 +11,7 @@ import AddonManager from '../components/Admin/AddonManager'
|
||||
import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
|
||||
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
||||
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
|
||||
import AdminPluginsPanel from '../components/Admin/AdminPluginsPanel'
|
||||
import { Users, Map, Briefcase, Shield, FileText, SlidersHorizontal, UserCog, Puzzle, Blocks, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug } from 'lucide-react'
|
||||
import { Users, Map, Briefcase, Shield, FileText, SlidersHorizontal, UserCog, Puzzle, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug } from 'lucide-react'
|
||||
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
|
||||
import { useAdmin } from './admin/useAdmin'
|
||||
import AdminUpdateBanner from './admin/AdminUpdateBanner'
|
||||
@@ -46,7 +45,6 @@ export default function AdminPage(): React.ReactElement {
|
||||
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal, group: gConfig },
|
||||
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon, group: gConfig },
|
||||
{ id: 'addons', label: t('admin.tabs.addons'), icon: Puzzle, group: gConfig },
|
||||
{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Blocks, group: gConfig },
|
||||
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell, group: gIntegration },
|
||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: KeyRound, group: gIntegration }] : []),
|
||||
{ id: 'github', label: t('admin.tabs.github'), icon: GitBranch, group: gIntegration },
|
||||
@@ -159,8 +157,6 @@ export default function AdminPage(): React.ReactElement {
|
||||
|
||||
{activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />}
|
||||
|
||||
{activeTab === 'plugins' && <AdminPluginsPanel />}
|
||||
|
||||
{activeTab === 'github' && <GitHubPanel isPrerelease={updateInfo?.is_prerelease ?? false} />}
|
||||
|
||||
{activeTab === 'defaults' && <DefaultUserSettingsTab />}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus, Tags } from 'lucide-react'
|
||||
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus } from 'lucide-react'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import Modal from '../components/shared/Modal'
|
||||
import ListsRail from '../components/Collections/ListsRail'
|
||||
@@ -13,8 +13,6 @@ import MoveToListModal from '../components/Collections/MoveToListModal'
|
||||
import ShareCollectionModal from '../components/Collections/ShareCollectionModal'
|
||||
import AddPlaceToCollectionModal from '../components/Collections/AddPlaceToCollectionModal'
|
||||
import CollectionPlaceDetail from '../components/Collections/CollectionPlaceDetail'
|
||||
import LabelManager from '../components/Collections/LabelManager'
|
||||
import BulkAssignLabelModal from '../components/Collections/BulkAssignLabelModal'
|
||||
import { useCollections } from './collections/useCollections'
|
||||
import '../styles/dashboard.css'
|
||||
import '../styles/collections.css'
|
||||
@@ -43,9 +41,6 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
const hasPlaces = c.places.length > 0
|
||||
const noLists = !c.loading && c.collections.length === 0
|
||||
const showSelect = c.isAllSaved || c.activeCollection != null
|
||||
// Labels are per-collection, so only on a real (non "All saved") list.
|
||||
const isRealList = !c.isAllSaved && typeof c.activeId === 'number'
|
||||
const canManageLabels = isRealList && c.canEdit
|
||||
|
||||
// Selecting a place toggles it, so clicking it again — or the map background —
|
||||
// clears it. Below the desktop breakpoint the list and map are separate views;
|
||||
@@ -71,7 +66,6 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
const listEl = (
|
||||
<CollectionList
|
||||
places={c.visiblePlaces}
|
||||
labels={c.labels}
|
||||
selectedPlaceId={c.selectedPlaceId}
|
||||
selectMode={c.selectMode}
|
||||
selectedIds={c.selectedIds}
|
||||
@@ -109,12 +103,6 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
categoryOptions={c.categoryOptions}
|
||||
onStatusFilter={c.setStatusFilter}
|
||||
onCategoryFilter={c.setCategoryFilter}
|
||||
showLabels={isRealList}
|
||||
labelOptions={c.labelOptions}
|
||||
labelFilter={c.labelFilter}
|
||||
onLabelFilter={c.setLabelFilter}
|
||||
canManageLabels={canManageLabels}
|
||||
onManageLabels={() => c.setShowLabelManager(true)}
|
||||
showSelect={showSelect}
|
||||
selectMode={c.selectMode}
|
||||
onToggleSelect={() => c.setSelectMode(!c.selectMode)}
|
||||
@@ -243,11 +231,6 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
</button>
|
||||
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
|
||||
<div className="col-toolbar-spacer" />
|
||||
{c.canEdit && isRealList && (
|
||||
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
||||
<Tags size={14} /> {t('collections.labels.assign')}
|
||||
</button>
|
||||
)}
|
||||
{c.canEdit && (
|
||||
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
||||
<FolderInput size={14} /> {t('collections.moveToList')}
|
||||
@@ -299,7 +282,6 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
canEdit={c.canEdit}
|
||||
canDelete={c.canDelete}
|
||||
categories={c.categories}
|
||||
labels={c.labels}
|
||||
anchorRect={desktopSplit ? c.listColRect : null}
|
||||
onClose={c.handleCloseDetail}
|
||||
onSetStatus={c.handleDetailStatus}
|
||||
@@ -362,32 +344,6 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
{/* Create / edit a list — name, colour, cover, description, links */}
|
||||
<ListEditorModal target={c.editorTarget} onClose={() => c.setEditorTarget(null)} onCreated={c.handleEditorCreated} onRequestDelete={c.setConfirmDeleteList} t={t} />
|
||||
|
||||
{/* Manage the list's custom labels (editor+) */}
|
||||
{canManageLabels && (
|
||||
<LabelManager
|
||||
isOpen={c.showLabelManager}
|
||||
labels={c.labels}
|
||||
onCreate={c.handleCreateLabel}
|
||||
onUpdate={c.handleUpdateLabel}
|
||||
onDelete={c.handleDeleteLabel}
|
||||
onClose={() => c.setShowLabelManager(false)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk-assign labels to the current selection */}
|
||||
{canManageLabels && (
|
||||
<BulkAssignLabelModal
|
||||
isOpen={c.labelPickerOpen}
|
||||
labels={c.labels}
|
||||
count={c.selectedIds.length}
|
||||
onAssign={c.handleBulkAssignLabels}
|
||||
onManage={() => { c.setLabelPickerOpen(false); c.setShowLabelManager(true) }}
|
||||
onClose={() => c.setLabelPickerOpen(false)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete-list confirm */}
|
||||
<Modal
|
||||
isOpen={c.confirmDeleteList != null}
|
||||
|
||||
@@ -901,38 +901,5 @@ describe('DashboardPage', () => {
|
||||
expect(s.dashboard_fx_to).toBe('CHF');
|
||||
expect(s.dashboard_timezones).toEqual(['America/New_York']);
|
||||
});
|
||||
|
||||
it('keeps the legacy localStorage keys when the server write fails, so nothing is lost (#1311)', async () => {
|
||||
// The write to persist the migrated values fails (server briefly unavailable during a
|
||||
// docker upgrade). The legacy localStorage source must survive so the next load retries —
|
||||
// the old code deleted it unconditionally, permanently losing the values.
|
||||
server.use(
|
||||
http.put('/api/settings', () => new HttpResponse(null, { status: 500 })),
|
||||
http.post('/api/settings/bulk', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
localStorage.setItem('trek_fx_from', 'CAD');
|
||||
localStorage.setItem('trek_fx_to', 'CHF');
|
||||
localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York']));
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
|
||||
render(<DashboardPage />);
|
||||
// The optimistic store update proves the migration effect ran; the failed write must NOT
|
||||
// have removed the localStorage source.
|
||||
await waitFor(() => {
|
||||
expect(useSettingsStore.getState().settings.dashboard_fx_from).toBe('CAD');
|
||||
});
|
||||
expect(localStorage.getItem('trek_fx_from')).toBe('CAD');
|
||||
expect(localStorage.getItem('trek_fx_to')).toBe('CHF');
|
||||
expect(localStorage.getItem('trek_dashboard_tz')).toBe(JSON.stringify(['America/New_York']));
|
||||
});
|
||||
|
||||
it('drops a malformed legacy timezone value instead of retrying forever (#1311)', async () => {
|
||||
localStorage.setItem('trek_dashboard_tz', 'not-json');
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
|
||||
render(<DashboardPage />);
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
|
||||
});
|
||||
expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,9 +20,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal'
|
||||
import CollectionsWidget from '../components/Dashboard/CollectionsWidget'
|
||||
import PluginWidgets from '../components/Plugins/PluginWidgets'
|
||||
import PluginFrame from '../components/Plugins/PluginFrame'
|
||||
import { usePluginStore } from '../store/pluginStore'
|
||||
import { formatTime, splitReservationDateTime } from '../utils/formatters'
|
||||
import { convertDistance, getDistanceUnitLabel } from '../utils/units'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
@@ -128,8 +125,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
const isAddonEnabled = useAddonStore(s => s.isEnabled)
|
||||
const showCollections = isAddonEnabled('collections') && sideWidgets.collections
|
||||
// Desktop has a master toggle for the whole right column; off → centered layout.
|
||||
const widgetPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot !== 'hero')
|
||||
const sidebarVisible = (isMobile || dashCfg.desktop.sidebar) && (showCurrency || showCollections || showTimezones || showUpcoming || widgetPlugins.length > 0)
|
||||
const sidebarVisible = (isMobile || dashCfg.desktop.sidebar) && (showCurrency || showCollections || showTimezones || showUpcoming)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -238,7 +234,6 @@ export default function DashboardPage(): React.ReactElement {
|
||||
{showCollections && <CollectionsWidget onOpen={() => navigate('/collections')} />}
|
||||
{showTimezones && <TimezoneTool locale={locale} />}
|
||||
{showUpcoming && <UpcomingTool items={upcoming} locale={locale} onOpen={(tripId) => navigate(`/trips/${tripId}`)} />}
|
||||
<PluginWidgets plugins={widgetPlugins} tripId={spotlight ? String(spotlight.id) : null} />
|
||||
</aside>
|
||||
)}
|
||||
</main>
|
||||
@@ -294,7 +289,6 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const mobile = useIsMobile()
|
||||
const heroPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot === 'hero')
|
||||
const stop = (e: React.MouseEvent, fn: () => void) => { e.stopPropagation(); fn() }
|
||||
const status = getTripStatus(trip)
|
||||
const start = splitDate(trip.start_date, locale)
|
||||
@@ -410,16 +404,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
|
||||
</div>
|
||||
|
||||
{!mobile && (
|
||||
<div className="hero-pass-wrap">
|
||||
{heroPlugins.length > 0 && (
|
||||
<div className="hero-pass-overlay" aria-hidden="true">
|
||||
{heroPlugins.map(p => (
|
||||
<PluginFrame key={p.id} pluginId={p.id} tripId={String(trip.id)} title={p.name} className="hero-overlay-frame" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="hero-pass" onClick={(e) => { e.stopPropagation(); onOpen() }}>{passCells}</div>
|
||||
</div>
|
||||
<div className="hero-pass" onClick={(e) => { e.stopPropagation(); onOpen() }}>{passCells}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -618,15 +603,10 @@ function CurrencyTool(): React.ReactElement {
|
||||
const lf = localStorage.getItem('trek_fx_from')
|
||||
const lt = localStorage.getItem('trek_fx_to')
|
||||
if (!lf && !lt) return
|
||||
const writes: Promise<void>[] = []
|
||||
if (lf) writes.push(updateSetting('dashboard_fx_from', lf))
|
||||
if (lt) writes.push(updateSetting('dashboard_fx_to', lt))
|
||||
// Only drop the localStorage source once the server has durably stored the values, so a
|
||||
// failed write during a (docker) upgrade can't destroy the only copy (#1311). Retry next load.
|
||||
Promise.all(writes).then(() => {
|
||||
localStorage.removeItem('trek_fx_from')
|
||||
localStorage.removeItem('trek_fx_to')
|
||||
}).catch(() => { /* keep localStorage; retry on next load */ })
|
||||
if (lf) updateSetting('dashboard_fx_from', lf).catch(() => {})
|
||||
if (lt) updateSetting('dashboard_fx_to', lt).catch(() => {})
|
||||
localStorage.removeItem('trek_fx_from')
|
||||
localStorage.removeItem('trek_fx_to')
|
||||
}, [isLoaded, updateSetting])
|
||||
|
||||
const currencies = rates ? Object.keys(rates).sort() : FX_FALLBACK
|
||||
@@ -702,15 +682,11 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
|
||||
if (!isLoaded) return
|
||||
const raw = localStorage.getItem('trek_dashboard_tz')
|
||||
if (!raw) return
|
||||
let parsed: unknown
|
||||
// A malformed/non-array value can never be written, so drop it now to avoid retrying forever.
|
||||
try { parsed = JSON.parse(raw) } catch { localStorage.removeItem('trek_dashboard_tz'); return }
|
||||
if (!Array.isArray(parsed)) { localStorage.removeItem('trek_dashboard_tz'); return }
|
||||
// Only drop the localStorage source once the server has durably stored the value, so a failed
|
||||
// write during a (docker) upgrade can't destroy the only copy (#1311). Retry next load.
|
||||
updateSetting('dashboard_timezones', parsed)
|
||||
.then(() => { localStorage.removeItem('trek_dashboard_tz') })
|
||||
.catch(() => { /* keep localStorage; retry on next load */ })
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) updateSetting('dashboard_timezones', parsed).catch(() => {})
|
||||
} catch { /* ignore malformed storage */ }
|
||||
localStorage.removeItem('trek_dashboard_tz')
|
||||
}, [isLoaded, updateSetting])
|
||||
|
||||
const allZones = React.useMemo<string[]>(() => {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import PageShell from '../components/Layout/PageShell'
|
||||
import PluginFrame from '../components/Plugins/PluginFrame'
|
||||
import { usePluginStore } from '../store/pluginStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
|
||||
/**
|
||||
* Full-page host for a `page` plugin (#plugins, M3). Thin by design (no state /
|
||||
* effects — the page-pattern rule): it resolves the plugin from the store and
|
||||
* renders its sandboxed iframe; PluginFrame owns the bridge.
|
||||
*/
|
||||
export default function PluginPage(): React.ReactElement {
|
||||
const { pluginId = '' } = useParams()
|
||||
const { t } = useTranslation()
|
||||
const plugin = usePluginStore((s) => s.getById(pluginId))
|
||||
const loaded = usePluginStore((s) => s.loaded)
|
||||
|
||||
if (loaded && (!plugin || plugin.type !== 'page')) {
|
||||
return (
|
||||
<PageShell background="var(--bg-secondary)">
|
||||
<div className="w-full px-6 py-16 text-center text-sm text-content-faint">{t('plugins.notFound')}</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell background="var(--bg-secondary)" navOffset="var(--nav-h, 56px)">
|
||||
<div style={{ height: 'calc(100vh - var(--nav-h, 56px))' }}>
|
||||
<PluginFrame pluginId={pluginId} title={plugin?.name} className="w-full h-full" />
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs'
|
||||
import { getFlightLegs } from '../utils/flightLegs'
|
||||
import { splitReservationDateTime } from '../utils/formatters'
|
||||
|
||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
@@ -226,14 +226,7 @@ export default function SharedTripPage() {
|
||||
sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'train') {
|
||||
if (r.__leg) {
|
||||
// One leg of a multi-leg train — show this segment's own train/route.
|
||||
sub = [r.__leg.train_number, r.__leg.platform ? `Gl. ${r.__leg.platform}` : '', (r.__leg.from || r.__leg.to) ? [r.__leg.from, r.__leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' · ')
|
||||
} else {
|
||||
sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<div key={r.__leg ? `t-${r.id}-leg${r.__leg.index}` : `t-${r.id}`} className="bg-[rgba(59,130,246,0.06)]" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, border: '1px solid rgba(59,130,246,0.15)' }}>
|
||||
<div className="bg-[rgba(59,130,246,0.12)]" style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
@@ -305,11 +298,8 @@ export default function SharedTripPage() {
|
||||
? getFlightLegs(r).map((leg, i) => (
|
||||
<span key={i}>{[leg.airline, leg.flight_number, (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
|
||||
))
|
||||
: r.type === 'train'
|
||||
? getTrainLegs(r).map((leg, i) => (
|
||||
<span key={i}>{[leg.train_number, leg.platform ? `${t('reservations.meta.platform')} ${leg.platform}` : '', (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
|
||||
))
|
||||
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||
{meta.train_number && <span>{meta.train_number}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className={r.status === 'confirmed' ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', padding: '2px 8px', borderRadius: 20, fontWeight: 600 }}>
|
||||
|
||||
@@ -17,7 +17,6 @@ import SlidingTabs from '../components/shared/SlidingTabs'
|
||||
import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||
import { TransportModal } from '../components/Planner/TransportModal'
|
||||
import TransitJourneyModal from '../components/Planner/TransitJourneyModal'
|
||||
import BookingImportModal from '../components/Planner/BookingImportModal'
|
||||
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
|
||||
// MemoriesPanel moved to Journey addon
|
||||
@@ -30,8 +29,6 @@ import CostsPanel, { ExpenseModal, type ExpensePrefill } from '../components/Bud
|
||||
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
|
||||
import type { BudgetItem } from '../types'
|
||||
import CollabPanel from '../components/Collab/CollabPanel'
|
||||
import PluginFrame from '../components/Plugins/PluginFrame'
|
||||
import TripWarningsBanner from '../components/Planner/TripWarningsBanner'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
@@ -197,7 +194,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
bookingForAssignmentId, setBookingForAssignmentId,
|
||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||
transportModalDayId, setTransportModalDayId,
|
||||
transportModalAutomated, setTransportModalAutomated, transitPrefill, setTransitPrefill, transitJourney, setTransitJourney,
|
||||
reservationPrefill, transportPrefill, importReviewActive, advanceImportReview,
|
||||
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
@@ -300,16 +296,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
{/* Offset by navbar + tab bar (44px) */}
|
||||
<div style={{ position: 'fixed', top: 'calc(var(--nav-h) + 44px)', left: 0, right: 0, bottom: 0, overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
||||
|
||||
{/* Plugin validation/warning contributions (#1429) — non-blocking overlay. */}
|
||||
<TripWarningsBanner tripId={tripId} />
|
||||
|
||||
{activeTab === 'plan' && (
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<MapView
|
||||
places={mapPlaces}
|
||||
dayPlaces={dayPlaces}
|
||||
route={route}
|
||||
showTransitRoutes={routeShown}
|
||||
routeSegments={routeSegments}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
@@ -381,7 +373,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
opacity: leftCollapsed ? 0 : 1,
|
||||
}}>
|
||||
<DayPlanSidebar
|
||||
isMobile={isMobile}
|
||||
tripId={tripId}
|
||||
trip={trip}
|
||||
days={days}
|
||||
@@ -405,9 +396,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
externalTransportDetail={mapTransportDetail}
|
||||
onExternalTransportDetailHandled={() => setMapTransportDetail(null)}
|
||||
onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }}
|
||||
onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) } : undefined}
|
||||
onOpenTransit={(r) => setTransitJourney(r)}
|
||||
onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined}
|
||||
onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true) } : undefined}
|
||||
onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined}
|
||||
onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined}
|
||||
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
|
||||
@@ -537,7 +526,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
collapsed={dayDetailCollapsed}
|
||||
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
|
||||
mobile={isMobile}
|
||||
onUpdateDayTitle={handleUpdateDayTitle}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
@@ -630,7 +618,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
@@ -649,12 +637,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
days={days}
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
onAdd={() => { setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) }}
|
||||
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
|
||||
onImport={() => setShowBookingImport(true)}
|
||||
bookingImportAvailable={bookingImportAvailable}
|
||||
onAirTrailImport={() => setShowAirTrailImport(true)}
|
||||
airTrailAvailable={airTrailAvailable}
|
||||
onEdit={(r) => { if (r.type === 'transit') { setTransitJourney(r) } else { setEditingTransport(r); setTransportModalAutomated(false); setShowTransportModal(true) } }}
|
||||
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
|
||||
onDelete={handleDeleteReservation}
|
||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||
titleKey="transport.title"
|
||||
@@ -715,12 +703,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<CollabPanel tripId={tripId} tripMembers={tripMembers} collabFeatures={collabFeatures} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab.startsWith('plugin:') && (
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
|
||||
<PluginFrame pluginId={activeTab.slice('plugin:'.length)} tripId={String(tripId)} className="w-full h-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} />
|
||||
@@ -733,34 +715,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
/>
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
|
||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null); setTransportModalAutomated(false); setTransitPrefill(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} places={places} accommodations={tripAccommodations} initialAutomated={transportModalAutomated} transitPrefill={transitPrefill} />}
|
||||
{/* Journey view for a saved public-transit entry (#1065) */}
|
||||
{transitJourney && (
|
||||
<TransitJourneyModal
|
||||
reservation={reservations.find(r => r.id === transitJourney.id) ?? transitJourney}
|
||||
canEdit={can('day_edit', trip)}
|
||||
onClose={() => setTransitJourney(null)}
|
||||
onSave={async (fields) => { await tripActions.updateReservation(tripId, transitJourney.id, fields); setTransitJourney(null) }}
|
||||
onDelete={async () => { await handleDeleteReservation(transitJourney.id); setTransitJourney(null) }}
|
||||
onChangeRoute={() => {
|
||||
// Re-enter the transit search seeded with this journey's route; the
|
||||
// existing reservation is REPLACED on save (editingTransport drives
|
||||
// handleSaveTransport's update path).
|
||||
const eps = transitJourney.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
setTransitPrefill({
|
||||
from: from ? { name: from.name, lat: from.lat, lng: from.lng } : null,
|
||||
to: to ? { name: to.name, lat: to.lat, lng: to.lng } : null,
|
||||
})
|
||||
setEditingTransport(transitJourney)
|
||||
setTransportModalDayId(transitJourney.day_id ?? null)
|
||||
setTransportModalAutomated(true)
|
||||
setTransitJourney(null)
|
||||
setShowTransportModal(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
|
||||
{bookingExpense && (
|
||||
<ExpenseModal
|
||||
tripId={tripId}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
passkeyLogin, setPasskeyLogin, passkeyConfigured,
|
||||
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
|
||||
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
||||
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, showKeys, savingKeys, validating, validation,
|
||||
mapsKey, setMapsKey, showKeys, savingKeys, validating, validation,
|
||||
setShowRotateJwtModal,
|
||||
handleToggleAuthSetting, handleToggleRequireMfa,
|
||||
toggleKey, handleSaveApiKeys, handleValidateKey,
|
||||
@@ -304,30 +304,6 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unsplash Key */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('admin.unsplashKey')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKeys.unsplash ? 'text' : 'password'}
|
||||
value={unsplashKey}
|
||||
onChange={e => setUnsplashKey(e.target.value)}
|
||||
placeholder={t('settings.keyPlaceholder')}
|
||||
className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleKey('unsplash')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.unsplash ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.unsplashKeyHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Place Photos Toggle */}
|
||||
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
|
||||
<div>
|
||||
|
||||
@@ -97,7 +97,6 @@ export function useAdmin() {
|
||||
// API Keys
|
||||
const [mapsKey, setMapsKey] = useState<string>('')
|
||||
const [weatherKey, setWeatherKey] = useState<string>('')
|
||||
const [unsplashKey, setUnsplashKey] = useState<string>('')
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
|
||||
const [savingKeys, setSavingKeys] = useState<boolean>(false)
|
||||
const [validating, setValidating] = useState<Record<string, boolean>>({})
|
||||
@@ -167,7 +166,6 @@ export function useAdmin() {
|
||||
const data = await authApi.getSettings()
|
||||
setMapsKey(data.settings?.maps_api_key || '')
|
||||
setWeatherKey(data.settings?.openweather_api_key || '')
|
||||
setUnsplashKey(data.settings?.unsplash_api_key || '')
|
||||
} catch (err: unknown) {
|
||||
// ignore
|
||||
}
|
||||
@@ -222,7 +220,6 @@ export function useAdmin() {
|
||||
await updateApiKeys({
|
||||
maps_api_key: mapsKey,
|
||||
openweather_api_key: weatherKey,
|
||||
unsplash_api_key: unsplashKey,
|
||||
})
|
||||
toast.success(t('admin.keySaved'))
|
||||
} catch (err: unknown) {
|
||||
@@ -236,7 +233,7 @@ export function useAdmin() {
|
||||
setValidating({ maps: true, weather: true })
|
||||
try {
|
||||
// Save first so validation uses the current values
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey })
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
|
||||
const result = await authApi.validateKeys()
|
||||
setValidation(result)
|
||||
} catch (err: unknown) {
|
||||
@@ -250,7 +247,7 @@ export function useAdmin() {
|
||||
setValidating(prev => ({ ...prev, [keyType]: true }))
|
||||
try {
|
||||
// Save first so validation uses the current values
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey })
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
|
||||
const result = await authApi.validateKeys()
|
||||
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
|
||||
} catch (err: unknown) {
|
||||
@@ -381,7 +378,7 @@ export function useAdmin() {
|
||||
invites, setInvites, inviteTrips, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
||||
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
||||
smtpValues, setSmtpValues, smtpLoaded,
|
||||
mapsKey, setMapsKey, weatherKey, setWeatherKey, unsplashKey, setUnsplashKey,
|
||||
mapsKey, setMapsKey, weatherKey, setWeatherKey,
|
||||
showKeys, setShowKeys, savingKeys, validating, validation,
|
||||
updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal,
|
||||
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
sortPlaces,
|
||||
statusCounts,
|
||||
presentCategories,
|
||||
presentLabels,
|
||||
mappablePlaces,
|
||||
normalizeLinkUrl,
|
||||
} from './collectionsModel';
|
||||
import type { CollectionLabel } from '@trek/shared';
|
||||
|
||||
// ── Inline CollectionPlace-ish builder ────────────────────────────────────────
|
||||
// Only the fields the helpers actually read are meaningful; the rest satisfy the
|
||||
@@ -34,7 +32,6 @@ interface PlaceLike {
|
||||
notes?: string | null;
|
||||
sort_order?: number;
|
||||
created_at?: string;
|
||||
label_ids?: number[];
|
||||
}
|
||||
function cp(overrides: PlaceLike): CollectionPlace {
|
||||
return {
|
||||
@@ -322,46 +319,4 @@ describe('collectionsModel', () => {
|
||||
expect(normalizeLinkUrl('//booking.com')).toBe('https://booking.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('labels', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'Berlin gate', status: 'idea', label_ids: [10] }),
|
||||
cp({ id: 2, name: 'Hamburg port', status: 'idea', label_ids: [11] }),
|
||||
cp({ id: 3, name: 'Both', status: 'idea', label_ids: [10, 11] }),
|
||||
cp({ id: 4, name: 'None', status: 'idea', label_ids: [] }),
|
||||
];
|
||||
const labels: CollectionLabel[] = [
|
||||
{ id: 10, collection_id: 1, name: 'Berlin', color: '#f00' },
|
||||
{ id: 11, collection_id: 1, name: 'Hamburg', color: '#00f' },
|
||||
{ id: 12, collection_id: 1, name: 'Unused', color: null },
|
||||
];
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-031: an empty label filter keeps every place', () => {
|
||||
expect(filterPlaces(places, 'all', '', 'all', [])).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-032: a single label keeps places carrying it (incl. multi-label)', () => {
|
||||
const out = filterPlaces(places, 'all', '', 'all', [10]).map(p => p.id);
|
||||
expect(out).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-033: multiple labels are OR — any match passes', () => {
|
||||
const out = filterPlaces(places, 'all', '', 'all', [10, 11]).map(p => p.id);
|
||||
expect(out).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-034: the label filter composes with status + search', () => {
|
||||
const mixed = [
|
||||
cp({ id: 5, name: 'Museum', status: 'visited', label_ids: [10] }),
|
||||
cp({ id: 6, name: 'Museum', status: 'idea', label_ids: [10] }),
|
||||
];
|
||||
const out = filterPlaces(mixed, 'visited', 'mus', 'all', [10]).map(p => p.id);
|
||||
expect(out).toEqual([5]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-035: presentLabels keeps definition order with per-label counts, incl. zero', () => {
|
||||
const opts = presentLabels(labels, places);
|
||||
expect(opts.map(o => [o.id, o.count])).toEqual([[10, 2], [11, 2], [12, 0]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Circle, Bookmark, CheckCircle2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import { COLLECTION_STATUSES } from '@trek/shared'
|
||||
import type { StatusFilter } from '../../store/collectionStore'
|
||||
|
||||
@@ -44,23 +44,18 @@ export function sortPlaces(places: CollectionPlace[]): CollectionPlace[] {
|
||||
})
|
||||
}
|
||||
|
||||
/** Apply the active status filter + free-text search (name/address/notes) +
|
||||
* category + per-collection label filter. The label filter is OR semantics: a
|
||||
* place matches if it carries ANY of the selected labels. This one function
|
||||
* drives BOTH the list and the map, so every filter stays in lockstep. */
|
||||
/** Apply the active status filter + free-text search (name/address/notes). */
|
||||
export function filterPlaces(
|
||||
places: CollectionPlace[],
|
||||
statusFilter: StatusFilter,
|
||||
search: string,
|
||||
categoryFilter: number | 'all' = 'all',
|
||||
labelFilter: number[] = [],
|
||||
): CollectionPlace[] {
|
||||
const q = search.trim().toLowerCase()
|
||||
return places.filter(p => {
|
||||
if (!p) return false
|
||||
if (statusFilter !== 'all' && p.status !== statusFilter) return false
|
||||
if (categoryFilter !== 'all' && (p.category_id ?? null) !== categoryFilter) return false
|
||||
if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false
|
||||
if (!q) return true
|
||||
return (
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
@@ -91,20 +86,6 @@ export function presentCategories(places: CollectionPlace[]): CategoryOption[] {
|
||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export interface LabelOption { id: number; name: string; color: string | null; count: number }
|
||||
|
||||
/** The collection's labels in definition order, each with how many of the given
|
||||
* places carry it. Zero-count labels are kept so the manager/filter still lists
|
||||
* a freshly-created label. */
|
||||
export function presentLabels(labels: CollectionLabel[], places: CollectionPlace[]): LabelOption[] {
|
||||
const counts = new Map<number, number>()
|
||||
for (const p of places) {
|
||||
if (!p) continue
|
||||
for (const id of p.label_ids ?? []) counts.set(id, (counts.get(id) ?? 0) + 1)
|
||||
}
|
||||
return labels.map(l => ({ id: l.id, name: l.name, color: l.color ?? null, count: counts.get(l.id) ?? 0 }))
|
||||
}
|
||||
|
||||
/** Only the places that can render on a map. */
|
||||
export function mappablePlaces(places: CollectionPlace[]): CollectionPlace[] {
|
||||
return places.filter(p => p && typeof p.lat === 'number' && typeof p.lng === 'number')
|
||||
|
||||
@@ -13,8 +13,7 @@ import type { ActiveCollectionId } from '../../store/collectionStore'
|
||||
import { categoriesApi } from '../../api/client'
|
||||
import type { Collection, CollectionStatus } from '@trek/shared'
|
||||
import type { Category, Place } from '../../types'
|
||||
import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories, presentLabels } from './collectionsModel'
|
||||
import type { CollectionLabelUpdateRequest } from '@trek/shared'
|
||||
import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories } from './collectionsModel'
|
||||
|
||||
/**
|
||||
* Collections page logic — owns the page-local UI state (new/edit-list forms,
|
||||
@@ -49,16 +48,15 @@ export function useCollections() {
|
||||
|
||||
const store = useCollectionStore()
|
||||
const {
|
||||
collections, activeId, places, members, labels, incomingInvites,
|
||||
view, statusFilter, categoryFilter, labelFilter, search, selectedPlaceId, selectMode, selectedIds,
|
||||
collections, activeId, places, members, incomingInvites,
|
||||
view, statusFilter, categoryFilter, search, selectedPlaceId, selectMode, selectedIds,
|
||||
loading, placesLoading,
|
||||
loadAll, setActive, refreshActive, loadCollection,
|
||||
deleteCollection,
|
||||
setStatus, updatePlace, deletePlace, deleteMany, copyToTrip, clearSelection,
|
||||
moveToList, duplicateToList, setSelectedIds,
|
||||
createLabel, updateLabel, deleteLabel, assignLabels,
|
||||
acceptInvite, declineInvite,
|
||||
setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
setView, setStatusFilter, setCategoryFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
} = store
|
||||
|
||||
// ── Page-local UI state ─────────────────────────────────────────────
|
||||
@@ -165,15 +163,12 @@ export function useCollections() {
|
||||
const ownedLists = useMemo(() => collections.filter(c => c.is_owner !== false), [collections])
|
||||
const sharedLists = useMemo(() => collections.filter(c => c.is_owner === false), [collections])
|
||||
|
||||
// Labels are per-collection, so never apply them on the "All saved" union.
|
||||
const visiblePlaces = useMemo(
|
||||
() => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter, isAllSaved ? [] : labelFilter)),
|
||||
[places, statusFilter, search, categoryFilter, isAllSaved, labelFilter],
|
||||
() => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter)),
|
||||
[places, statusFilter, search, categoryFilter],
|
||||
)
|
||||
// Categories actually present in this list, for the category filter dropdown.
|
||||
const categoryOptions = useMemo(() => presentCategories(places), [places])
|
||||
// The active list's labels (with per-label counts) for the filter + manager.
|
||||
const labelOptions = useMemo(() => presentLabels(labels, places), [labels, places])
|
||||
// Stable reference so the map doesn't tear down + rebuild every marker on each
|
||||
// unrelated re-render (which would swallow marker clicks mid-rebuild).
|
||||
const mappable = useMemo(() => mappablePlaces(visiblePlaces), [visiblePlaces])
|
||||
@@ -259,43 +254,6 @@ export function useCollections() {
|
||||
}
|
||||
}, [selectedIds, duplicateToList, toast, t])
|
||||
|
||||
// ── Labels ──────────────────────────────────────────────────────────
|
||||
const [showLabelManager, setShowLabelManager] = useState(false)
|
||||
const [labelPickerOpen, setLabelPickerOpen] = useState(false)
|
||||
|
||||
const handleCreateLabel = useCallback(async (name: string, color?: string) => {
|
||||
try { await createLabel(name, color) }
|
||||
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
|
||||
}, [createLabel, toast, t])
|
||||
|
||||
const handleUpdateLabel = useCallback(async (labelId: number, body: CollectionLabelUpdateRequest) => {
|
||||
try { await updateLabel(labelId, body) }
|
||||
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
|
||||
}, [updateLabel, toast, t])
|
||||
|
||||
const handleDeleteLabel = useCallback(async (labelId: number) => {
|
||||
try { await deleteLabel(labelId) }
|
||||
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
|
||||
}, [deleteLabel, toast, t])
|
||||
|
||||
// Bulk-assign one or more labels to the current selection.
|
||||
const handleBulkAssignLabels = useCallback(async (labelIds: number[]) => {
|
||||
if (selectedIds.length === 0 || labelIds.length === 0) return
|
||||
try {
|
||||
await assignLabels(labelIds, selectedIds)
|
||||
toast.success(t('collections.labels.assignedCount', { count: selectedIds.length }))
|
||||
setLabelPickerOpen(false)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}, [assignLabels, selectedIds, toast, t])
|
||||
|
||||
// Replace a single place's labels (from the detail sheet chips).
|
||||
const handleAssignPlaceLabels = useCallback(async (placeId: number, labelIds: number[]) => {
|
||||
try { await updatePlace(placeId, { label_ids: labelIds }) }
|
||||
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
|
||||
}, [updatePlace, toast, t])
|
||||
|
||||
const handleAcceptInvite = useCallback(async (collectionId: number) => {
|
||||
try {
|
||||
await acceptInvite(collectionId)
|
||||
@@ -379,14 +337,10 @@ export function useCollections() {
|
||||
canShare, shareMemberCount,
|
||||
activeId, places, visiblePlaces, mappable, members, incomingInvites, counts,
|
||||
view, statusFilter, categoryFilter, categoryOptions, search, selectedPlaceId, selectMode, selectedIds,
|
||||
labels, labelFilter, labelOptions,
|
||||
loading, placesLoading,
|
||||
// store setters
|
||||
setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
setView, setStatusFilter, setCategoryFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
updatePlace,
|
||||
// labels
|
||||
showLabelManager, setShowLabelManager, labelPickerOpen, setLabelPickerOpen,
|
||||
handleCreateLabel, handleUpdateLabel, handleDeleteLabel, handleBulkAssignLabels, handleAssignPlaceLabels,
|
||||
// local UI state
|
||||
editorTarget, setEditorTarget, handleEditorCreated,
|
||||
showAddPlace, setShowAddPlace, handlePlaceAdded,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getCached, fetchPhoto } from '../../services/photoService'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Blocks } from 'lucide-react'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
|
||||
import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
|
||||
@@ -21,7 +21,6 @@ import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
||||
import { resolvePoolAssignmentId } from './tripPlannerModel'
|
||||
|
||||
@@ -43,9 +42,6 @@ export function useTripPlanner() {
|
||||
const toast = useToast()
|
||||
const { t, language } = useTranslation()
|
||||
const { settings } = useSettingsStore()
|
||||
// trip-page plugins mount as tabs inside this trip planner (tripId-scoped).
|
||||
const allPlugins = usePluginStore(s => s.plugins)
|
||||
const pluginsLoaded = usePluginStore(s => s.loaded)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const days = useTripStore(s => s.days)
|
||||
@@ -96,10 +92,7 @@ export function useTripPlanner() {
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
|
||||
|
||||
const tripPagePlugins = allPlugins.filter(p => p.type === 'trip-page')
|
||||
const tripPluginIds = tripPagePlugins.map(p => p.id).join(',')
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
||||
|
||||
const TRIP_TABS = [
|
||||
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
|
||||
@@ -109,7 +102,6 @@ export function useTripPlanner() {
|
||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
|
||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []),
|
||||
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []),
|
||||
...tripPagePlugins.map(p => ({ id: `plugin:${p.id}`, label: p.name, icon: Blocks })),
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||
@@ -118,14 +110,12 @@ export function useTripPlanner() {
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Don't evict a saved plugin tab before the plugin feed has loaded.
|
||||
if (activeTab.startsWith('plugin:') && !pluginsLoaded) return
|
||||
const validTabIds = TRIP_TABS.map(t => t.id)
|
||||
if (!validTabIds.includes(activeTab)) {
|
||||
setActiveTab('plan')
|
||||
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
|
||||
}
|
||||
}, [enabledAddons, tripPluginIds, pluginsLoaded])
|
||||
}, [enabledAddons])
|
||||
|
||||
const handleTabChange = (tabId: string): void => {
|
||||
setActiveTab(tabId)
|
||||
@@ -172,11 +162,6 @@ export function useTripPlanner() {
|
||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
|
||||
// Public transit (#1065): open the TransportModal in its Automated mode, seed
|
||||
// the search (change-route), and show the journey view for a saved entry.
|
||||
const [transportModalAutomated, setTransportModalAutomated] = useState<boolean>(false)
|
||||
const [transitPrefill, setTransitPrefill] = useState<{ from?: { name: string; lat: number; lng: number } | null; to?: { name: string; lat: number; lng: number } | null } | null>(null)
|
||||
const [transitJourney, setTransitJourney] = useState<Reservation | null>(null)
|
||||
|
||||
// The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs
|
||||
// it opens the booking / transport modal via ?create=reservation|transport
|
||||
@@ -874,7 +859,6 @@ export function useTripPlanner() {
|
||||
bookingForAssignmentId, setBookingForAssignmentId,
|
||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||
transportModalDayId, setTransportModalDayId,
|
||||
transportModalAutomated, setTransportModalAutomated, transitPrefill, setTransitPrefill, transitJourney, setTransitJourney,
|
||||
reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview,
|
||||
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
|
||||
@@ -10,8 +10,6 @@ import type {
|
||||
CollectionCreateRequest,
|
||||
CollectionUpdateRequest,
|
||||
CollectionPlaceUpdateRequest,
|
||||
CollectionLabel,
|
||||
CollectionLabelUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
|
||||
/** A pending invitation the current user has received (derived server-side). */
|
||||
@@ -29,12 +27,10 @@ interface CollectionState {
|
||||
activeId: ActiveCollectionId
|
||||
places: CollectionPlace[]
|
||||
members: CollectionMember[]
|
||||
labels: CollectionLabel[]
|
||||
incomingInvites: IncomingCollectionInvite[]
|
||||
view: CollectionView
|
||||
statusFilter: StatusFilter
|
||||
categoryFilter: number | 'all'
|
||||
labelFilter: number[]
|
||||
search: string
|
||||
selectedPlaceId: number | null
|
||||
selectMode: boolean
|
||||
@@ -61,11 +57,6 @@ interface CollectionState {
|
||||
moveToList: (placeIds: number[], targetId: number) => Promise<void>
|
||||
duplicateToList: (placeIds: number[], targetId: number) => Promise<void>
|
||||
|
||||
createLabel: (name: string, color?: string) => Promise<void>
|
||||
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void>
|
||||
deleteLabel: (labelId: number) => Promise<void>
|
||||
assignLabels: (labelIds: number[], placeIds: number[], remove?: boolean) => Promise<void>
|
||||
|
||||
invite: (collectionId: number, userId: number, role?: CollectionRole) => Promise<void>
|
||||
setMemberRole: (collectionId: number, userId: number, role: CollectionRole) => Promise<void>
|
||||
acceptInvite: (collectionId: number) => Promise<void>
|
||||
@@ -77,7 +68,6 @@ interface CollectionState {
|
||||
setView: (view: CollectionView) => void
|
||||
setStatusFilter: (filter: StatusFilter) => void
|
||||
setCategoryFilter: (filter: number | 'all') => void
|
||||
setLabelFilter: (labelIds: number[]) => void
|
||||
setSearch: (search: string) => void
|
||||
setSelectedPlaceId: (id: number | null) => void
|
||||
setSelectMode: (on: boolean) => void
|
||||
@@ -91,12 +81,10 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
activeId: null,
|
||||
places: [],
|
||||
members: [],
|
||||
labels: [],
|
||||
incomingInvites: [],
|
||||
view: 'list',
|
||||
statusFilter: 'all',
|
||||
categoryFilter: 'all',
|
||||
labelFilter: [],
|
||||
search: '',
|
||||
selectedPlaceId: null,
|
||||
selectMode: false,
|
||||
@@ -122,28 +110,26 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
activeId: id,
|
||||
places: data.places,
|
||||
members: data.collection.members ?? [],
|
||||
labels: data.collection.labels ?? [],
|
||||
})
|
||||
} catch {
|
||||
// The list may have been left / removed / deleted out from under us (a WS
|
||||
// event or the URL sync can re-request an id we just lost access to). Clear
|
||||
// it instead of leaving an uncaught 403/404 rejection; the route sync then
|
||||
// bounces to /collections.
|
||||
if (get().activeId === id) set({ activeId: null, places: [], members: [], labels: [] })
|
||||
if (get().activeId === id) set({ activeId: null, places: [], members: [] })
|
||||
} finally {
|
||||
set({ placesLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
setActive: async (id: ActiveCollectionId) => {
|
||||
// Labels are per-collection, so their filter can't carry across lists.
|
||||
set({ selectMode: false, selectedIds: [], selectedPlaceId: null, labelFilter: [] })
|
||||
set({ selectMode: false, selectedIds: [], selectedPlaceId: null })
|
||||
if (id === null) {
|
||||
set({ activeId: null, places: [], members: [], labels: [] })
|
||||
set({ activeId: null, places: [], members: [] })
|
||||
return
|
||||
}
|
||||
if (id === ALL_SAVED) {
|
||||
set({ activeId: ALL_SAVED, members: [], labels: [], placesLoading: true })
|
||||
set({ activeId: ALL_SAVED, members: [], placesLoading: true })
|
||||
try {
|
||||
// Client-side union of every list the user owns or co-owns (no server change).
|
||||
// On first load the lists may not be fetched yet (loadAll still in flight),
|
||||
@@ -291,51 +277,6 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
await get().loadAll()
|
||||
},
|
||||
|
||||
createLabel: async (name: string, color?: string) => {
|
||||
const active = get().activeId
|
||||
if (typeof active !== 'number') return
|
||||
await collectionsApi.createLabel(active, name, color)
|
||||
await get().loadCollection(active)
|
||||
},
|
||||
|
||||
updateLabel: async (labelId: number, body: CollectionLabelUpdateRequest) => {
|
||||
// optimistic recolor/rename
|
||||
set({ labels: get().labels.map(l => (l.id === labelId ? { ...l, ...body } : l)) })
|
||||
await collectionsApi.updateLabel(labelId, body)
|
||||
const active = get().activeId
|
||||
if (typeof active === 'number') await get().loadCollection(active)
|
||||
},
|
||||
|
||||
deleteLabel: async (labelId: number) => {
|
||||
// optimistic: drop the label + its assignments + any active filter on it
|
||||
set({
|
||||
labels: get().labels.filter(l => l.id !== labelId),
|
||||
labelFilter: get().labelFilter.filter(id => id !== labelId),
|
||||
places: get().places.map(p => ({ ...p, label_ids: (p.label_ids ?? []).filter(id => id !== labelId) })),
|
||||
})
|
||||
await collectionsApi.deleteLabel(labelId)
|
||||
const active = get().activeId
|
||||
if (typeof active === 'number') await get().loadCollection(active)
|
||||
},
|
||||
|
||||
assignLabels: async (labelIds: number[], placeIds: number[], remove = false) => {
|
||||
const idSet = new Set(placeIds)
|
||||
// optimistic per-place label_ids update
|
||||
set({
|
||||
places: get().places.map(p => {
|
||||
if (!idSet.has(p.id)) return p
|
||||
const current = new Set(p.label_ids ?? [])
|
||||
if (remove) labelIds.forEach(id => current.delete(id))
|
||||
else labelIds.forEach(id => current.add(id))
|
||||
return { ...p, label_ids: [...current] }
|
||||
}),
|
||||
})
|
||||
if (remove) await collectionsApi.unassignLabels(labelIds, placeIds)
|
||||
else await collectionsApi.assignLabels(labelIds, placeIds)
|
||||
const active = get().activeId
|
||||
if (typeof active === 'number') await get().loadCollection(active)
|
||||
},
|
||||
|
||||
invite: async (collectionId: number, userId: number, role?: CollectionRole) => {
|
||||
await collectionsApi.invite(collectionId, userId, role)
|
||||
if (get().activeId === collectionId) await get().loadCollection(collectionId)
|
||||
@@ -375,7 +316,6 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
setView: (view: CollectionView) => set({ view }),
|
||||
setStatusFilter: (filter: StatusFilter) => set({ statusFilter: filter }),
|
||||
setCategoryFilter: (filter: number | 'all') => set({ categoryFilter: filter }),
|
||||
setLabelFilter: (labelIds: number[]) => set({ labelFilter: labelIds }),
|
||||
setSearch: (search: string) => set({ search }),
|
||||
setSelectedPlaceId: (id: number | null) => set({ selectedPlaceId: id }),
|
||||
setSelectMode: (on: boolean) => set({ selectMode: on, selectedIds: on ? get().selectedIds : [] }),
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// FE-STORE-PLUGIN-001 to 004
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { usePluginStore } from './pluginStore';
|
||||
|
||||
const initial = usePluginStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
usePluginStore.setState(initial, true);
|
||||
});
|
||||
|
||||
describe('pluginStore', () => {
|
||||
it('FE-STORE-PLUGIN-001: loads active plugins and splits pages/widgets', async () => {
|
||||
server.use(
|
||||
http.get('/api/plugins', () =>
|
||||
HttpResponse.json({
|
||||
plugins: [
|
||||
{ id: 'flights', name: 'Flights', type: 'widget', icon: 'Plane' },
|
||||
{ id: 'report', name: 'Report', type: 'page', icon: 'FileText' },
|
||||
{ id: 'diary', name: 'Diary', type: 'trip-page', icon: 'Book' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await usePluginStore.getState().loadPlugins();
|
||||
|
||||
const s = usePluginStore.getState();
|
||||
expect(s.loaded).toBe(true);
|
||||
expect(s.plugins).toHaveLength(3);
|
||||
expect(s.pages().map((p) => p.id)).toEqual(['report']);
|
||||
expect(s.widgets().map((p) => p.id)).toEqual(['flights']);
|
||||
expect(s.tripPages().map((p) => p.id)).toEqual(['diary']);
|
||||
expect(s.getById('flights')?.name).toBe('Flights');
|
||||
expect(s.getById('nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-STORE-PLUGIN-002: a failed fetch still marks the store loaded (no crash)', async () => {
|
||||
server.use(http.get('/api/plugins', () => HttpResponse.error()));
|
||||
await usePluginStore.getState().loadPlugins();
|
||||
expect(usePluginStore.getState().loaded).toBe(true);
|
||||
expect(usePluginStore.getState().plugins).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-STORE-PLUGIN-003: tolerates a missing plugins array', async () => {
|
||||
server.use(http.get('/api/plugins', () => HttpResponse.json({})));
|
||||
await usePluginStore.getState().loadPlugins();
|
||||
expect(usePluginStore.getState().plugins).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { pluginsApi } from '../api/client'
|
||||
|
||||
/**
|
||||
* Active plugins the client renders (#plugins, M3). Page plugins become nav
|
||||
* entries + a full-page iframe route; widget plugins mount on the dashboard.
|
||||
* Cloned from addonStore — plugins have their own feed and lifecycle, so they
|
||||
* don't overload the addon store.
|
||||
*/
|
||||
export interface ActivePlugin {
|
||||
id: string
|
||||
name: string
|
||||
type: 'integration' | 'page' | 'widget' | 'trip-page'
|
||||
icon: string | null
|
||||
slot?: 'sidebar' | 'hero' | 'place-detail'
|
||||
}
|
||||
|
||||
interface PluginState {
|
||||
plugins: ActivePlugin[]
|
||||
loaded: boolean
|
||||
loadPlugins: () => Promise<void>
|
||||
getById: (id: string) => ActivePlugin | undefined
|
||||
pages: () => ActivePlugin[]
|
||||
widgets: () => ActivePlugin[]
|
||||
heroWidgets: () => ActivePlugin[]
|
||||
tripPages: () => ActivePlugin[]
|
||||
placeDetailWidgets: () => ActivePlugin[]
|
||||
}
|
||||
|
||||
export const usePluginStore = create<PluginState>((set, get) => ({
|
||||
plugins: [],
|
||||
loaded: false,
|
||||
|
||||
loadPlugins: async () => {
|
||||
try {
|
||||
const data = await pluginsApi.active()
|
||||
set({ plugins: (data.plugins as ActivePlugin[]) || [], loaded: true })
|
||||
} catch {
|
||||
set({ loaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
getById: (id) => get().plugins.find((p) => p.id === id),
|
||||
pages: () => get().plugins.filter((p) => p.type === 'page'),
|
||||
widgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot !== 'hero' && p.slot !== 'place-detail'),
|
||||
heroWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'hero'),
|
||||
tripPages: () => get().plugins.filter((p) => p.type === 'trip-page'),
|
||||
placeDetailWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'place-detail'),
|
||||
}))
|
||||
@@ -270,20 +270,6 @@
|
||||
.trek-dash .col-lrow-cat svg { flex-shrink: 0; }
|
||||
.trek-dash .col-lrow-div { width: 1px; height: 18px; flex-shrink: 0; background: var(--line-2); }
|
||||
|
||||
/* Per-collection label chips — list rows, filter bar and detail read view */
|
||||
.trek-dash .col-lrow-label { display: inline-flex; align-items: center; gap: 5px; max-width: 120px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); }
|
||||
.trek-dash .col-lrow-label.more { color: var(--ink-3); background: var(--surface-2); }
|
||||
.trek-dash .col-labelchip-dot { width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0; background: var(--label, var(--accent)); }
|
||||
.trek-dash .col-labelfilter { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
|
||||
.trek-dash .col-labelchip:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.trek-dash .col-labelchip .col-filter-lbl { overflow: hidden; text-overflow: ellipsis; }
|
||||
.trek-dash .col-labelchip.on { color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--label, var(--accent)) 42%, transparent); }
|
||||
.trek-dash .col-labelchip.on .col-labelchip-dot { background: var(--label, var(--accent)); }
|
||||
.trek-dash .col-labelchip.static { cursor: default; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); border-color: transparent; }
|
||||
.trek-dash .col-labelchip-manage { color: var(--ink-3); border-style: dashed; }
|
||||
.trek-dash .col-detail-labels { display: flex; flex-wrap: wrap; gap: 7px; margin: 2px 0 10px; }
|
||||
|
||||
/* ----------------- filter bar (status + category dropdowns) ----------------- */
|
||||
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||
.trek-dash .col-filter { position: relative; }
|
||||
|
||||
@@ -262,19 +262,6 @@
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none;
|
||||
}
|
||||
|
||||
/* Hero-slot plugin overlay: a transparent strip whose bottom edge sits on the
|
||||
pass's top edge, so a plugin (e.g. the mascot) appears to stand ON the bar.
|
||||
pointer-events none — it must never block the pass or hero interactions. */
|
||||
.trek-dash .hero-pass-wrap { position: relative; }
|
||||
.trek-dash .hero-pass-overlay {
|
||||
position: absolute; left: 16px; right: 16px; bottom: 100%; height: 110px;
|
||||
pointer-events: none; z-index: 3; overflow: hidden;
|
||||
}
|
||||
.trek-dash .hero-overlay-frame {
|
||||
position: absolute; inset: 0; width: 100%; height: 100% !important;
|
||||
border: 0; background: transparent; color-scheme: light;
|
||||
}
|
||||
.trek-dash .pass-cell {
|
||||
padding: 4px 18px; position: relative; display: flex; flex-direction: column;
|
||||
justify-content: center; align-items: center; text-align: center; gap: 6px; flex: 1; min-width: 0;
|
||||
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
declare module 'tz-lookup' {
|
||||
/** Returns the IANA time zone for a coordinate, throwing on invalid input. */
|
||||
export default function tzlookup(lat: number, lng: number): string;
|
||||
}
|
||||
@@ -128,32 +128,6 @@ describe('getTransportForDay', () => {
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('expands a multi-leg TRAIN into one row per leg with train detail on __leg (#1150)', () => {
|
||||
const reservations = [{
|
||||
id: 40, type: 'train', day_id: 1, end_day_id: 2,
|
||||
metadata: JSON.stringify({
|
||||
train_number: 'ICE 100',
|
||||
legs: [
|
||||
{ from: 'Berlin', to: 'Frankfurt', train_number: 'ICE 100', platform: '5', dep_day_id: 1, dep_time: '08:00', arr_day_id: 1, arr_time: '12:00' },
|
||||
{ from: 'Frankfurt', to: 'München', train_number: 'ICE 500', platform: '9', dep_day_id: 2, dep_time: '09:00', arr_day_id: 2, arr_time: '12:00' },
|
||||
],
|
||||
}),
|
||||
}]
|
||||
const day1 = getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })
|
||||
expect(day1).toHaveLength(1)
|
||||
expect(day1[0].__leg).toMatchObject({ index: 0, total: 2, from: 'Berlin', to: 'Frankfurt', train_number: 'ICE 100', platform: '5' })
|
||||
const day2 = getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })
|
||||
expect(day2).toHaveLength(1)
|
||||
expect(day2[0].__leg).toMatchObject({ index: 1, from: 'Frankfurt', to: 'München', train_number: 'ICE 500', platform: '9' })
|
||||
})
|
||||
|
||||
it('leaves a single-leg train untouched (no __leg)', () => {
|
||||
const reservations = [{ id: 41, type: 'train', day_id: 1, end_day_id: 1, metadata: JSON.stringify({ train_number: 'RE 1' }) }]
|
||||
const rows = getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].__leg).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMergedItems', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
|
||||
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
||||
|
||||
export interface MergedItem {
|
||||
type: 'place' | 'note' | 'transport'
|
||||
@@ -66,23 +66,23 @@ export function getDisplayTimeForDay(
|
||||
return r.reservation_time || null
|
||||
}
|
||||
|
||||
/** Per-leg detail of a multi-leg flight or train, or null for single-leg / other. */
|
||||
function parseMultiLegs(r: any): any[] | null {
|
||||
if (r?.type !== 'flight' && r?.type !== 'train') return null
|
||||
/** Per-leg detail of a multi-leg flight, or null for single-leg / non-flight. */
|
||||
function parseFlightLegs(r: any): any[] | null {
|
||||
if (r?.type !== 'flight') return null
|
||||
let meta = r.metadata
|
||||
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
|
||||
// Defensive: recover metadata that was accidentally double-encoded by an earlier
|
||||
// bug (a JSON string of a JSON string) so already-saved bookings heal on read.
|
||||
// bug (a JSON string of a JSON string) so already-saved flights heal on read.
|
||||
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
|
||||
if (meta && Array.isArray(meta.legs) && meta.legs.length > 1) return meta.legs
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a multi-leg flight/train into one synthetic reservation per leg that
|
||||
* touches `dayId`, each with its own day span + departure/arrival time so it
|
||||
* slots into the timeline independently. A single-leg booking (or any other
|
||||
* reservation) is returned untouched, so existing behaviour is unchanged.
|
||||
* Expand a multi-leg flight into one synthetic reservation per leg that touches
|
||||
* `dayId`, each with its own day span + departure/arrival time so it slots into
|
||||
* the timeline independently. A single-leg flight (or any other reservation) is
|
||||
* returned untouched, so existing behaviour is unchanged.
|
||||
*/
|
||||
export function expandFlightLegsForDay(
|
||||
r: any,
|
||||
@@ -90,7 +90,7 @@ export function expandFlightLegsForDay(
|
||||
getDayOrder: (id: number) => number,
|
||||
days: Array<{ id: number; date?: string | null }>
|
||||
): any[] {
|
||||
const legs = parseMultiLegs(r)
|
||||
const legs = parseFlightLegs(r)
|
||||
if (!legs) return [r]
|
||||
const dateOf = (id: number | null): string | null => (id == null ? null : (days.find(d => d.id === id)?.date ?? null))
|
||||
const thisOrder = getDayOrder(dayId)
|
||||
@@ -114,14 +114,7 @@ export function expandFlightLegsForDay(
|
||||
// dropped between legs and persist; absent → falls back to time ordering.
|
||||
day_positions: leg.day_positions || undefined,
|
||||
day_plan_position: undefined,
|
||||
__leg: {
|
||||
index: i, total: legs.length,
|
||||
from: leg.from ?? null, to: leg.to ?? null,
|
||||
airline: leg.airline ?? null, flight_number: leg.flight_number ?? null,
|
||||
// Train legs carry their own per-leg detail; added only for trains so a
|
||||
// flight's __leg object stays byte-identical to before.
|
||||
...(r.type === 'train' ? { train_number: leg.train_number ?? null, platform: leg.platform ?? null, seat: leg.seat ?? null } : {}),
|
||||
},
|
||||
__leg: { index: i, total: legs.length, from: leg.from ?? null, to: leg.to ?? null, airline: leg.airline ?? null, flight_number: leg.flight_number ?? null },
|
||||
})
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getFlightLegs, getTrainLegs, isMultiLegTrain } from './flightLegs'
|
||||
import type { Reservation } from '../types'
|
||||
|
||||
function res(partial: Partial<Reservation>): Reservation {
|
||||
return { id: 1, type: 'train', status: 'confirmed', ...partial } as unknown as Reservation
|
||||
}
|
||||
|
||||
const ep = (role: 'from' | 'to' | 'stop', seq: number, name: string, extra: Record<string, unknown> = {}) =>
|
||||
({ role, sequence: seq, name, code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null, ...extra })
|
||||
|
||||
describe('getTrainLegs (#1150)', () => {
|
||||
it('reads ordered legs from metadata.legs', () => {
|
||||
const r = res({
|
||||
metadata: JSON.stringify({
|
||||
train_number: 'ICE 100', platform: '5',
|
||||
legs: [
|
||||
{ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100', platform: '5', dep_time: '08:00', arr_time: '12:00' },
|
||||
{ from: 'Frankfurt Hbf', to: 'München Hbf', train_number: 'ICE 500', platform: '9', dep_time: '12:30', arr_time: '15:30' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
const legs = getTrainLegs(r)
|
||||
expect(legs).toHaveLength(2)
|
||||
expect(legs[0]).toMatchObject({ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100', platform: '5' })
|
||||
expect(legs[1]).toMatchObject({ from: 'Frankfurt Hbf', to: 'München Hbf', train_number: 'ICE 500', platform: '9' })
|
||||
expect(isMultiLegTrain(r)).toBe(true)
|
||||
})
|
||||
|
||||
it('derives a single leg from endpoints + flat metadata (legacy train)', () => {
|
||||
const r = res({
|
||||
day_id: 3, end_day_id: 3,
|
||||
metadata: JSON.stringify({ train_number: 'RE 42', platform: '2' }),
|
||||
endpoints: [ep('from', 0, 'Köln Hbf', { local_time: '09:00' }), ep('to', 1, 'Aachen Hbf', { local_time: '10:00' })],
|
||||
})
|
||||
const legs = getTrainLegs(r)
|
||||
expect(legs).toHaveLength(1)
|
||||
expect(legs[0]).toMatchObject({ from: 'Köln Hbf', to: 'Aachen Hbf', train_number: 'RE 42', platform: '2', dep_time: '09:00', arr_time: '10:00' })
|
||||
expect(isMultiLegTrain(r)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns [] for a train with no stations and no train number', () => {
|
||||
expect(getTrainLegs(res({ metadata: '{}' }))).toEqual([])
|
||||
})
|
||||
|
||||
it('does not disturb getFlightLegs for flights', () => {
|
||||
const flight = res({ type: 'flight', metadata: JSON.stringify({ departure_airport: 'FRA', arrival_airport: 'JFK', airline: 'LH', flight_number: 'LH 400' }) })
|
||||
const legs = getFlightLegs(flight)
|
||||
expect(legs).toHaveLength(1)
|
||||
expect(legs[0]).toMatchObject({ from: 'FRA', to: 'JFK', airline: 'LH', flight_number: 'LH 400' })
|
||||
})
|
||||
})
|
||||
@@ -84,71 +84,11 @@ export function getFlightLegs(r: Reservation): FlightLeg[] {
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
* A train booking mirrors the flight leg model (#1150), but its stops are
|
||||
* STATIONS (labels, not IATA codes) and each leg carries a train number +
|
||||
* platform instead of an airline + flight number.
|
||||
*/
|
||||
export interface TrainLeg {
|
||||
from: string | null // station label (or null)
|
||||
to: string | null
|
||||
train_number?: string
|
||||
platform?: string
|
||||
seat?: string
|
||||
dep_day_id?: number | null
|
||||
dep_time?: string | null
|
||||
arr_day_id?: number | null
|
||||
arr_time?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered legs of a train booking. Prefers `metadata.legs`; otherwise derives a
|
||||
* single leg from the endpoints + flat metadata, so single-leg trains — and
|
||||
* trains created before this feature — still work.
|
||||
*/
|
||||
export function getTrainLegs(r: Reservation): TrainLeg[] {
|
||||
const meta = parseReservationMetadata(r)
|
||||
if (Array.isArray(meta.legs) && meta.legs.length > 0) {
|
||||
return meta.legs.map((l: any): TrainLeg => ({
|
||||
from: l.from ?? null,
|
||||
to: l.to ?? null,
|
||||
train_number: l.train_number || undefined,
|
||||
platform: l.platform || undefined,
|
||||
seat: l.seat || undefined,
|
||||
dep_day_id: l.dep_day_id ?? null,
|
||||
dep_time: l.dep_time ?? null,
|
||||
arr_day_id: l.arr_day_id ?? null,
|
||||
arr_time: l.arr_time ?? null,
|
||||
}))
|
||||
}
|
||||
const eps = orderedEndpoints(r)
|
||||
const first = eps[0]
|
||||
const last = eps[eps.length - 1]
|
||||
const fromLabel = first ? (first.code || first.name) : null
|
||||
const toLabel = last ? (last.code || last.name) : null
|
||||
if (!fromLabel && !toLabel && !meta.train_number) return []
|
||||
return [{
|
||||
from: fromLabel,
|
||||
to: toLabel,
|
||||
train_number: meta.train_number || undefined,
|
||||
platform: meta.platform || undefined,
|
||||
seat: meta.seat || undefined,
|
||||
dep_day_id: r.day_id ?? null,
|
||||
dep_time: first?.local_time ?? null,
|
||||
arr_day_id: r.end_day_id ?? r.day_id ?? null,
|
||||
arr_time: last?.local_time ?? null,
|
||||
}]
|
||||
}
|
||||
|
||||
/** Number of flight segments. 1 for a simple from -> to booking. */
|
||||
export function legCount(r: Reservation): number {
|
||||
return getFlightLegs(r).length
|
||||
}
|
||||
|
||||
export function isMultiLegTrain(r: Reservation): boolean {
|
||||
return r.type === 'train' && getTrainLegs(r).length > 1
|
||||
}
|
||||
|
||||
export function isMultiLegFlight(r: Reservation): boolean {
|
||||
return r.type === 'flight' && legCount(r) > 1
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitReservationDateTime, resolveDayId, formatMoney } from './formatters'
|
||||
import { CURRENCIES, SYMBOLS } from '../components/Budget/BudgetPanel.constants'
|
||||
import { splitReservationDateTime, resolveDayId } from './formatters'
|
||||
import type { Day } from '../types'
|
||||
|
||||
const days = [
|
||||
@@ -26,19 +25,6 @@ describe('resolveDayId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('KGS currency (#1400)', () => {
|
||||
it('is selectable everywhere the shared currency list feeds', () => {
|
||||
expect(CURRENCIES).toContain('KGS')
|
||||
expect(SYMBOLS.KGS).toBeTruthy()
|
||||
})
|
||||
it('formats without throwing', () => {
|
||||
// Lenient: older ICU builds may lack ru-KG/KGS display data and fall back.
|
||||
const out = formatMoney(1234.56, 'KGS', 'en')
|
||||
expect(out).toMatch(/сом|KGS/)
|
||||
expect(out).toContain('234')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitReservationDateTime', () => {
|
||||
it('parses full ISO datetime', () => {
|
||||
expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||
|
||||
@@ -52,9 +52,9 @@ const CURRENCY_LOCALE: Record<string, string> = {
|
||||
PHP: 'en-PH', SGD: 'en-SG', KRW: 'ko-KR', CNY: 'zh-CN', HKD: 'en-HK',
|
||||
TWD: 'zh-TW', ZAR: 'en-ZA', AED: 'en-AE', SAR: 'en-SA', ILS: 'he-IL',
|
||||
EGP: 'en-EG', MAD: 'fr-MA', HUF: 'hu-HU', RON: 'ro-RO', BGN: 'bg-BG',
|
||||
HRK: 'hr-HR', ISK: 'is-IS', RUB: 'ru-RU', UAH: 'uk-UA', KGS: 'ru-KG',
|
||||
BDT: 'en-BD', LKR: 'en-LK', VND: 'vi-VN', CLP: 'es-CL', COP: 'es-CO',
|
||||
PEN: 'es-PE', ARS: 'es-AR',
|
||||
HRK: 'hr-HR', ISK: 'is-IS', RUB: 'ru-RU', UAH: 'uk-UA', BDT: 'en-BD',
|
||||
LKR: 'en-LK', VND: 'vi-VN', CLP: 'es-CL', COP: 'es-CO', PEN: 'es-PE',
|
||||
ARS: 'es-AR',
|
||||
}
|
||||
|
||||
export function currencyLocale(currency: string): string {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Reservation } from '../../../src/types'
|
||||
|
||||
const { calculateRouteWithLegs } = vi.hoisted(() => ({ calculateRouteWithLegs: vi.fn() }))
|
||||
vi.mock('../../../src/components/Map/RouteCalculator', () => ({ calculateRouteWithLegs }))
|
||||
|
||||
import { useTransportRoutes } from '../../../src/hooks/useTransportRoutes'
|
||||
|
||||
function booking(id: number, type: string, from: [number, number], to: [number, number]): Reservation {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
status: 'confirmed',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'A', code: null, lat: from[0], lng: from[1], timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'B', code: null, lat: to[0], lng: to[1], timezone: null, local_time: null, local_date: null },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
}
|
||||
|
||||
const PARIS: [number, number] = [48.8566, 2.3522]
|
||||
const VERSAILLES: [number, number] = [48.8049, 2.1204]
|
||||
|
||||
beforeEach(() => {
|
||||
calculateRouteWithLegs.mockReset()
|
||||
calculateRouteWithLegs.mockResolvedValue({ coordinates: [PARIS, [48.83, 2.24], VERSAILLES], distance: 20000, duration: 1500, legs: [] })
|
||||
})
|
||||
|
||||
describe('useTransportRoutes (#1425 real road routes)', () => {
|
||||
it('routes a car booking with the driving profile and returns its geometry', async () => {
|
||||
const res = [booking(1, 'car', PARIS, VERSAILLES)]
|
||||
const { result } = renderHook(() => useTransportRoutes(res))
|
||||
await waitFor(() => expect(result.current.get(1)).toBeTruthy())
|
||||
expect(result.current.get(1)).toHaveLength(3)
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ profile: 'driving' }))
|
||||
})
|
||||
|
||||
it('routes a bicycle booking with the cycling profile', async () => {
|
||||
const res = [booking(2, 'bicycle', PARIS, VERSAILLES)]
|
||||
renderHook(() => useTransportRoutes(res))
|
||||
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ profile: 'cycling' }))
|
||||
})
|
||||
|
||||
it('does not route non-road types (flight, train, transit)', async () => {
|
||||
const res = [
|
||||
booking(3, 'flight', PARIS, VERSAILLES),
|
||||
booking(4, 'train', PARIS, VERSAILLES),
|
||||
booking(5, 'transit', PARIS, VERSAILLES),
|
||||
]
|
||||
renderHook(() => useTransportRoutes(res))
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips routing beyond the sanity distance cap (keeps the straight line)', async () => {
|
||||
// Paris → Tokyo as a "car" booking: ~9700 km, well past the 2000 km cap.
|
||||
const res = [booking(6, 'car', PARIS, [35.68, 139.69])]
|
||||
renderHook(() => useTransportRoutes(res))
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back silently (no entry) when routing throws', async () => {
|
||||
calculateRouteWithLegs.mockRejectedValueOnce(new Error('OSRM down'))
|
||||
const res = [booking(7, 'taxi', PARIS, VERSAILLES)]
|
||||
const { result } = renderHook(() => useTransportRoutes(res))
|
||||
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(result.current.get(7)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not re-request an unchanged booking when the array identity changes', async () => {
|
||||
const res1 = [booking(8, 'car', PARIS, VERSAILLES)]
|
||||
const { rerender } = renderHook(({ r }) => useTransportRoutes(r), { initialProps: { r: res1 } })
|
||||
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1))
|
||||
// New array, same booking coordinates → no second fetch.
|
||||
rerender({ r: [booking(8, 'car', PARIS, VERSAILLES)] })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user