mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc36c21c08 | |||
| 175413b3c1 | |||
| 030918cfa9 | |||
| 75d65ccb1b | |||
| def8b8a24d | |||
| f4e524f3c3 | |||
| 682ae19143 | |||
| 255607b750 | |||
| ce7437a46f | |||
| 36d8cc2e7b | |||
| dc76efc418 | |||
| 314b087f98 | |||
| 2f689ea9ca | |||
| 41edbcf67a | |||
| 6945b53c10 | |||
| 7ade99c8ce | |||
| 593df53993 | |||
| aa41c67a20 | |||
| c10a698b9f | |||
| e2756dab22 | |||
| 330644e88b | |||
| 0b86a13b51 | |||
| 8d8629516b | |||
| 5822910f40 | |||
| ce5ec7132f | |||
| 7f2a3b6039 | |||
| 3e860dd9ca | |||
| c28604b891 | |||
| aba329b1b1 | |||
| 04458bee12 | |||
| f01ffc2c67 | |||
| 5cfe925b78 | |||
| a2414685ad | |||
| a1ca1aae44 | |||
| 56536ae803 | |||
| 59154b34c9 | |||
| b64f4535fd | |||
| ad2916903c | |||
| 71cd6c2de4 | |||
| 3f2dec6c47 | |||
| 668df8c62e | |||
| 294ba0a4fe | |||
| 36c1599a09 | |||
| 65abea6910 | |||
| 18dc121132 | |||
| 013aa3954d | |||
| 182ed56fa4 | |||
| f56f6ae11e | |||
| c94ff7421a | |||
| 2dbcc39832 | |||
| 47dad114df | |||
| 7277e35a96 | |||
| 70f27085b9 | |||
| 156dc4a6c2 | |||
| d159aec78b | |||
| 547f0b84b0 | |||
| 8d10bff616 | |||
| e5048643f8 | |||
| a156f5be32 | |||
| 431122d253 | |||
| bbc8f141e3 | |||
| 01681f57c7 | |||
| 553808eb83 | |||
| 324ee69151 | |||
| c4c63ffd62 | |||
| 209f8fe5cc | |||
| 9f155a3191 | |||
| 067ab89430 | |||
| 019c668744 | |||
| b953ece4b3 | |||
| 94a9cbf070 | |||
| 8eebcea9f5 |
@@ -1,6 +1,7 @@
|
||||
node_modules
|
||||
client/node_modules
|
||||
server/node_modules
|
||||
shared/node_modules
|
||||
client/dist
|
||||
shared/dist
|
||||
data
|
||||
|
||||
@@ -38,6 +38,10 @@ on:
|
||||
description: "Type MAJOR (all caps) to confirm a major release"
|
||||
type: string
|
||||
default: ''
|
||||
ci_from:
|
||||
description: 'Take ci-ok from this earlier commit instead of the one on main, when main differs from it only under .github/ (a fix to a release workflow needs no second test run)'
|
||||
type: string
|
||||
default: ''
|
||||
allow_stale_dialect:
|
||||
description: 'Ship without a dialect of its own even if the one on main is the original one or has under 90 days left'
|
||||
type: boolean
|
||||
@@ -87,6 +91,7 @@ jobs:
|
||||
# finished last to be green, so a later green run supersedes a flaky or
|
||||
# cancelled one. It prints the link of every run it counted.
|
||||
- name: Require a green ci-ok on the commit being released
|
||||
if: inputs.ci_from == ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -96,6 +101,36 @@ jobs:
|
||||
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
|
||||
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
|
||||
|
||||
# ci_from: main carries only workflow changes on top of a commit that
|
||||
# already passed. That commit must be an ancestor of main, everything
|
||||
# outside .github/ must be identical, and one of its ci-ok runs must be
|
||||
# green (its dev push, typically).
|
||||
- name: Require a green ci-ok on the commit main's code comes from
|
||||
if: inputs.ci_from != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CI_FROM: ${{ inputs.ci_from }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
from="$(git rev-parse --verify "$CI_FROM^{commit}")"
|
||||
if ! git merge-base --is-ancestor "$from" HEAD; then
|
||||
echo "::error::$from is not an ancestor of main."
|
||||
exit 1
|
||||
fi
|
||||
outside="$(git diff --name-only "$from" HEAD -- . ':(exclude).github/**')"
|
||||
if [ -n "$outside" ]; then
|
||||
echo "::error::main differs from $from outside .github/:"
|
||||
echo "$outside"
|
||||
exit 1
|
||||
fi
|
||||
green="$(gh api "repos/$GITHUB_REPOSITORY/commits/$from/check-runs?check_name=ci-ok&filter=all&per_page=100" \
|
||||
--jq '[.check_runs[] | select(.status == "completed" and .conclusion == "success")] | length')"
|
||||
if [ "$green" -lt 1 ]; then
|
||||
echo "::error::$from has no green ci-ok."
|
||||
exit 1
|
||||
fi
|
||||
echo "main differs from $from only under .github/, and $from has $green green ci-ok run(s)."
|
||||
|
||||
- name: Determine bump type and version
|
||||
id: bump
|
||||
env:
|
||||
|
||||
@@ -120,7 +120,9 @@ jobs:
|
||||
run: |
|
||||
set -uo pipefail
|
||||
cp "$FILE" src/nest/service-identity/registration-dialect.ts
|
||||
printf '%s\n' '{"extends": "./tsconfig.strict.json", "include": [],' \
|
||||
# TypeScript 6 includes no @types by default and the dialect imports nothing
|
||||
# from node, so checked on its own it would not know Buffer: name node here.
|
||||
printf '%s\n' '{"extends": "./tsconfig.strict.json", "compilerOptions": {"types": ["node"]}, "include": [],' \
|
||||
'"files": ["src/nest/service-identity/registration-dialect.ts"]}' > tsconfig.dialect-check.json
|
||||
if ! npx tsc -p tsconfig.dialect-check.json --noEmit --pretty false; then
|
||||
echo "::warning::The issuer's registration dialect for $VERSION does not type-check under the strict config."
|
||||
|
||||
@@ -1263,11 +1263,31 @@ jobs:
|
||||
--set 'extraEnv[0].name=SESSION_DURATION' \
|
||||
--set 'extraEnv[0].value=7d' \
|
||||
--set 'extraEnvFrom[0].secretRef.name=trek-smtp' \
|
||||
--set 'extraVolumes[0].name=kitinerary-extractors' \
|
||||
--set 'extraVolumes[0].configMap.name=trek-kitinerary-extractors' \
|
||||
--set 'extraVolumeMounts[0].name=kitinerary-extractors' \
|
||||
--set 'extraVolumeMounts[0].mountPath=/usr/local/share/kitinerary/extractors' \
|
||||
--set 'extraVolumeMounts[0].readOnly=true' \
|
||||
--set probes.liveness=null \
|
||||
> "$out/variants.yaml"
|
||||
helm template trek charts/trek \
|
||||
--set httpRoute.enabled=true \
|
||||
--set 'httpRoute.parentRefs[0].name=gateway' \
|
||||
> "$out/http-route.yaml"
|
||||
helm template trek charts/trek \
|
||||
--set ingress.enabled=true \
|
||||
--set fullnameOverride=custom-trek \
|
||||
--set service.port=8080 \
|
||||
--set-json 'httpRoute={"enabled":true,"parentRefs":[{"name":"gateway","namespace":"gateway-system","sectionName":"https"}],"hostnames":["trek.example.com"],"paths":["/","/api"],"annotations":{"example.com/note":"custom route"},"timeouts":{"request":"0s","backendRequest":"0s"}}' \
|
||||
> "$out/http-route-variants.yaml"
|
||||
|
||||
- name: Check the manifests against the Kubernetes schemas
|
||||
run: kubeconform -strict -summary "$RUNNER_TEMP"/rendered/*.yaml
|
||||
# The pinned Gateway API schema adds HTTPRoute validation without skipping unknown resources.
|
||||
run: >-
|
||||
kubeconform -strict -summary
|
||||
-schema-location default
|
||||
-schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/041baf4c1d3740ca21461cab765e7432ecd073ef/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json'
|
||||
"$RUNNER_TEMP"/rendered/*.yaml
|
||||
|
||||
- name: Check every compose file
|
||||
run: |
|
||||
|
||||
+2
-1
@@ -91,7 +91,8 @@ ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
|
||||
|
||||
COPY --chown=node:node --from=server-builder /app/server/dist ./server/dist
|
||||
# Runtime data assets read from server/assets at runtime: airports.json (flight
|
||||
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
|
||||
# transport search) and atlas/*.geojson.gz + atlas/admin1/*.geojson.gz (Atlas
|
||||
# country/region map). The build
|
||||
# only emits dist, so these must be copied explicitly or the features silently
|
||||
# degrade to empty in the image.
|
||||
COPY --chown=node:node --from=server-builder /app/server/assets ./server/assets
|
||||
|
||||
@@ -5,8 +5,9 @@ TREK bundles and uses third-party data that requires attribution.
|
||||
## geoBoundaries — country & sub-national boundaries
|
||||
|
||||
The Atlas map's administrative boundaries (admin-0 countries and admin-1
|
||||
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
|
||||
`server/assets/atlas/admin1.geojson.gz` and generated by
|
||||
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz`,
|
||||
`server/assets/atlas/admin1.geojson.gz` and `server/assets/atlas/admin1/<ISO_A2>.geojson.gz`
|
||||
(the same regions, one file per country) and generated by
|
||||
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
|
||||
|
||||
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
|
||||
|
||||
@@ -36,7 +36,7 @@ TREK has grown from a travel planner into a whole ecosystem for trips: plan toge
|
||||
|
||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=for-the-badge&label=Stars&color=6B7280" /></a>
|
||||
|
||||
<sub>PUBLIC SERVICES PROUDLY SPONSORED BY</sub><br /><a href="https://cloud.dataforest.net/en?mtm_campaign=trek"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" /><source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" /><img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="30" /></picture></a>
|
||||
<sub>PUBLIC SERVICES PROUDLY SPONSORED BY</sub><br /><a href="https://cloud.dataforest.net/?mtm_campaign=trek"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" /><source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" /><img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="30" /></picture></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -200,7 +200,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
- **Admin panel**: users and the permission matrix, defaults for new users, categories and templates, addons, plugins, storage, notifications, MCP tokens, backups, audit log and releases
|
||||
- **Backups and storage**: scheduled backups with retention that restore their own secrets, and local or S3-compatible storage per content category with replicas
|
||||
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, density, reduced motion and text size
|
||||
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
|
||||
- **28 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), he (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
|
||||
- **Help center**: a ? in the top bar with 381 step-by-step guides with screenshots, searched together with the wiki that ships in the image
|
||||
|
||||
</td>
|
||||
@@ -224,7 +224,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://cloud.dataforest.net/en?mtm_campaign=trek">
|
||||
<a href="https://cloud.dataforest.net/?mtm_campaign=trek">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" />
|
||||
@@ -243,7 +243,7 @@ Thank you, dataforest, for backing open source this generously. 💚
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://cloud.dataforest.net/en?mtm_campaign=trek"><img alt="Visit dataforest cloud" src="https://img.shields.io/badge/Visit-dataforest_cloud-02C382?style=for-the-badge" /></a>
|
||||
<a href="https://cloud.dataforest.net/?mtm_campaign=trek"><img alt="Visit dataforest cloud" src="https://img.shields.io/badge/Visit-dataforest_cloud-02C382?style=for-the-badge" /></a>
|
||||
|
||||
Looking for a server for your own TREK? dataforest cloud is where TREK's own services live.
|
||||
|
||||
@@ -277,7 +277,7 @@ Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine.
|
||||
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
|
||||
|
||||
> [!TIP]
|
||||
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
|
||||
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
+5
-3
@@ -5,9 +5,10 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
## Features
|
||||
- Deploys the TREK container
|
||||
- Exposes port 3000 via Service
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`, plus extra volumes and mounts
|
||||
- Configurable environment variables and secrets
|
||||
- Optional generic Ingress support
|
||||
- Optional Gateway API HTTPRoute support
|
||||
- Configurable health checks (liveness/readiness probes) on `/api/health`
|
||||
|
||||
## Helm Repository
|
||||
@@ -27,7 +28,7 @@ helm install trek trek/trek
|
||||
Or install directly from the local chart:
|
||||
|
||||
```sh
|
||||
helm install trek ./chart \
|
||||
helm install trek ./charts/trek \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=yourdomain.com
|
||||
```
|
||||
@@ -47,7 +48,7 @@ See `values.yaml` for more options.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
- If exposing TREK through an Ingress or HTTPRoute, set `env.ALLOWED_ORIGINS` and `env.APP_URL` to the public URL. The chart does not sync these automatically.
|
||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||
- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed.
|
||||
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||
@@ -55,3 +56,4 @@ See `values.yaml` for more options.
|
||||
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
|
||||
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
|
||||
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
||||
- Use `extraVolumes` and `extraVolumeMounts` to add your own volumes to the pod. To give the booking import more KItinerary extractors, mount a ConfigMap at `/usr/local/share/kitinerary/extractors`, with one `.json` and one `.js` key for each extractor; see the example in `values.yaml`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 5.0.1
|
||||
version: 5.1.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "5.0.1"
|
||||
appVersion: "5.1.0"
|
||||
|
||||
@@ -31,3 +31,12 @@
|
||||
The other values for that volume (size, storageClassName, annotations) are then ignored.
|
||||
- With `persistence.enabled=false` the volumes use an emptyDir — storage is ephemeral and is lost
|
||||
when the pod restarts. Use only for testing.
|
||||
{{- if .Values.httpRoute.enabled }}
|
||||
|
||||
6. HTTPRoute:
|
||||
- The chart creates an HTTPRoute v1 to the existing TREK Service. The Gateway, GatewayClass,
|
||||
listeners, TLS certificates and allowedRoutes are managed by your cluster operator.
|
||||
- Ingress and HTTPRoute may be enabled together.
|
||||
- Timeout fields are Standard Channel in Gateway API v1.2, but controller support varies. Omit
|
||||
them to use controller defaults; `0s` disables the corresponding timeout where supported.
|
||||
{{- end }}
|
||||
|
||||
@@ -112,6 +112,9 @@ data:
|
||||
{{- if .Values.env.TREK_PLACES_URL }}
|
||||
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_PLACES_CONTRIBUTE }}
|
||||
TREK_PLACES_CONTRIBUTE: {{ .Values.env.TREK_PLACES_CONTRIBUTE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_SERVICES }}
|
||||
TREK_SERVICES: {{ .Values.env.TREK_SERVICES | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -141,6 +141,9 @@ spec:
|
||||
mountPath: /app/data
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
{{- with .Values.extraVolumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with (default dict .Values.probes).startup }}
|
||||
startupProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
@@ -168,3 +171,6 @@ spec:
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- with .Values.extraVolumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{{- if .Values.httpRoute.enabled -}}
|
||||
{{- if not .Values.httpRoute.parentRefs }}
|
||||
{{- fail "httpRoute.parentRefs must include at least one parent" }}
|
||||
{{- end }}
|
||||
{{- if not .Values.httpRoute.paths }}
|
||||
{{- fail "httpRoute.paths must include at least one path" }}
|
||||
{{- end }}
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.httpRoute.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
parentRefs:
|
||||
{{- toYaml .Values.httpRoute.parentRefs | nindent 4 }}
|
||||
{{- with .Values.httpRoute.hostnames }}
|
||||
hostnames:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range $path := .Values.httpRoute.paths }}
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: {{ $path | quote }}
|
||||
backendRefs:
|
||||
- name: {{ include "trek.fullname" $ }}
|
||||
port: {{ $.Values.service.port }}
|
||||
{{- with $.Values.httpRoute.timeouts }}
|
||||
timeouts:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
+36
-1
@@ -36,7 +36,7 @@ env:
|
||||
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
|
||||
# Supported: ar, az, br, ca, cs, de, en, es, et, fr, gr, hu, id, it, ja, ko, nl, pl, ru, sk, sv, th, tr, uk, vi, zh, zh-TW
|
||||
# ALLOWED_ORIGINS: ""
|
||||
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||
# NOTE: If using ingress or HTTPRoute, ensure env.ALLOWED_ORIGINS matches the public domains for proper CORS configuration.
|
||||
# APP_URL: "https://trek.example.com"
|
||||
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
|
||||
# Also used as the base URL for links in email notifications and other external links.
|
||||
@@ -113,6 +113,10 @@ env:
|
||||
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
|
||||
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
|
||||
# Defaults to https://places.liketrek.com.
|
||||
# TREK_PLACES_CONTRIBUTE: "off"
|
||||
# Whether an admin may let this installation send the place details its users typed in (website, phone, e-mail,
|
||||
# opening hours, description, picture) to the TREK Places API. "admin" leaves the choice to the admin panel, where
|
||||
# it stays off until an admin agrees; "off" takes it away. Defaults to admin.
|
||||
# TREK_SERVICES: "off"
|
||||
# Places search identifies this installation with a key it registers by itself on first use and keeps in the
|
||||
# database. "off" sends no key; searches then use the shared pool. A key issued to you goes under
|
||||
@@ -260,6 +264,20 @@ extraEnv: []
|
||||
# name: trek-smtp
|
||||
extraEnvFrom: []
|
||||
|
||||
# Further volumes for the pod. The chart does not create the objects they name.
|
||||
# extraVolumes:
|
||||
# - name: kitinerary-extractors
|
||||
# configMap:
|
||||
# name: trek-kitinerary-extractors
|
||||
extraVolumes: []
|
||||
# Where the trek container mounts them. For example, KItinerary
|
||||
# loads extractors for the booking import from this path:
|
||||
# extraVolumeMounts:
|
||||
# - name: kitinerary-extractors
|
||||
# mountPath: /usr/local/share/kitinerary/extractors
|
||||
# readOnly: true
|
||||
extraVolumeMounts: []
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
@@ -280,3 +298,20 @@ ingress:
|
||||
# - secretName: chart-example-tls
|
||||
# hosts:
|
||||
# - chart-example.local
|
||||
|
||||
httpRoute:
|
||||
enabled: false
|
||||
annotations: {}
|
||||
parentRefs: []
|
||||
# - name: gateway
|
||||
# namespace: gateway-system
|
||||
# sectionName: https
|
||||
hostnames: []
|
||||
# - trek.example.com
|
||||
paths:
|
||||
- /
|
||||
# Timeout fields are Standard Channel in Gateway API v1.2; controller support varies.
|
||||
# Leave empty for controller defaults. Use "0s" to disable the corresponding timeout.
|
||||
timeouts: {}
|
||||
# request: "0s"
|
||||
# backendRequest: "0s"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "5.0.1",
|
||||
"version": "5.1.0",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<svg width="310" height="48" viewBox="0 0 310 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M39.9361 44.5592H37.3496V40.5946H38.7949C39.6016 40.5946 40.1393 40.0905 40.1393 38.9477V32.7663C40.1393 30.1462 41.3157 28.7345 43.4989 28.0302C41.3157 27.3243 40.1393 25.9142 40.1393 23.2941V17.1127C40.1393 15.9699 39.6016 15.4673 38.7949 15.4673H37.3496V11.5027H39.9361C42.8252 11.5027 44.8403 13.2825 44.8403 16.1059V22.5898C44.8403 24.8738 46.2184 26.0166 48.5696 26.0166V30.0484C46.2184 30.0484 44.8403 31.1912 44.8403 33.4752V39.9591C44.8403 42.7809 42.8252 44.5607 39.9361 44.5607V44.5592Z" fill="white"/>
|
||||
<path d="M0 30.0453V26.0135C2.35125 26.0135 3.72931 24.8708 3.72931 22.5867V16.1028C3.72931 13.281 5.74445 11.4996 8.63347 11.4996H11.22V15.4642H9.77472C8.96806 15.4642 8.43028 15.9684 8.43028 17.1096V23.291C8.43028 25.9112 7.25389 27.3228 5.07069 28.0271C7.25389 28.733 8.43028 30.1431 8.43028 32.7633V38.9446C8.43028 40.0874 8.96806 40.5916 9.77472 40.5916H11.22V44.5562H8.63347C5.74445 44.5562 3.72931 42.7763 3.72931 39.9545V33.4706C3.72931 31.1866 2.35125 30.0438 0 30.0438V30.0453Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.2855 0.076416L32.7769 8.5678L28.8169 12.5263L24.284 7.99336L19.7511 12.5263L15.7926 8.5678L24.284 0.076416H24.2855Z" fill="#02C382"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.2855 11.7119L34.4055 21.8319L30.4471 25.7904L24.2855 19.6289L18.124 25.7904L14.1655 21.8319L24.2855 11.7119Z" fill="#02C382"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.2856 23.3475L36.0342 35.0961L32.0757 39.0561L24.2871 31.2675L16.4985 39.0561L12.54 35.0961L24.2887 23.3475H24.2856Z" fill="#02C382"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.4867 47.8958V37.3129H27.086V47.8958H21.4867Z" fill="white"/>
|
||||
<path d="M216.808 36.4329H211.635V23.9357H208.444V19.635H211.635V14.4283H216.808V19.635H220V23.9357H216.808V36.4329Z" fill="white"/>
|
||||
<path d="M192.671 31.1239H197.575C197.643 32.3003 198.516 33.0046 200.095 33.0046C201.673 33.0046 202.446 32.4332 202.446 31.5593C202.446 31.0552 202.177 30.5174 200.933 30.2485L197.843 29.5763C194.752 28.904 193.241 27.5611 193.241 24.6049C193.241 21.1108 196.231 19.0957 200.263 19.0957C204.294 19.0957 206.881 21.3797 206.881 24.7729H201.977C201.977 23.6302 201.271 22.8586 200.029 22.8586C198.853 22.8586 198.115 23.4636 198.115 24.3711C198.115 25.1442 198.753 25.5811 199.928 25.85L203.052 26.5222C205.807 27.1272 207.319 28.4029 207.319 31.0918C207.319 34.6867 204.262 36.8699 199.894 36.8699C195.527 36.8699 192.671 34.5186 192.671 31.1254V31.1239Z" fill="white"/>
|
||||
<path d="M182.351 36.8699C177.279 36.8699 173.65 33.1742 173.65 28.0011C173.65 22.8281 177.178 19.0988 182.25 19.0988C187.322 19.0988 190.816 22.5592 190.816 28.0347V29.3456L178.622 29.4128C178.924 31.6296 180.168 32.6715 182.452 32.6715C184.4 32.6715 185.778 31.9657 186.147 30.7572H190.884C190.279 34.4865 186.953 36.8714 182.351 36.8714V36.8699ZM182.284 23.2971C180.235 23.2971 179.092 24.2046 178.689 26.2197H185.677C185.677 24.4383 184.366 23.2971 182.284 23.2971Z" fill="white"/>
|
||||
<path d="M172.433 19.6351V24.5056H170.787C167.831 24.5056 166.051 25.8164 166.051 29.1424V36.433H160.878V19.6687H165.749L166.017 22.1544C166.723 20.5074 168.201 19.3998 170.453 19.3998C171.058 19.3998 171.728 19.467 172.434 19.6351H172.433Z" fill="white"/>
|
||||
<path d="M139.367 28.0011C139.367 22.6936 143.264 19.1323 148.639 19.1323C154.014 19.1323 157.878 22.6936 157.878 28.0011C157.878 33.3086 153.98 36.8362 148.639 36.8362C143.298 36.8362 139.367 33.3086 139.367 28.0011ZM144.574 27.9675C144.574 30.5204 146.219 32.2009 148.639 32.2009C151.059 32.2009 152.671 30.5219 152.671 27.9675C152.671 25.413 151.025 23.7676 148.639 23.7676C146.253 23.7676 144.574 25.4466 144.574 27.9675Z" fill="white"/>
|
||||
<path d="M126.917 19.635H129.235V18.2906C129.235 13.2856 131.855 11.1008 136.122 11.1008C136.828 11.1008 137.634 11.1681 138.44 11.3025V15.7376H136.994C134.878 15.7376 134.408 16.8468 134.408 18.2906V19.635H138.44V23.9357H134.408V36.4329H129.235V23.9357H126.917V19.635Z" fill="white"/>
|
||||
<path d="M114.606 36.8699C111.045 36.8699 108.828 34.7875 108.828 31.4951C108.828 28.4045 111.011 26.4901 115.043 26.1876L119.746 25.8179V25.549C119.746 23.9021 118.737 23.0297 116.89 23.0297C114.707 23.0297 113.531 23.87 113.531 25.381H109.23C109.23 21.5844 112.354 19.0988 117.158 19.0988C121.961 19.0988 124.783 21.8197 124.783 26.6246V36.4344H120.215L119.879 34.2176C119.341 35.7622 117.124 36.8714 114.605 36.8714L114.606 36.8699ZM116.42 33.0397C118.401 33.0397 119.779 32.065 119.779 30.1507V29.2432L117.159 29.4785C114.909 29.6801 114.102 30.1843 114.102 31.2583C114.102 32.4683 114.842 33.0382 116.42 33.0382V33.0397Z" fill="white"/>
|
||||
<path d="M104.017 36.4329H98.8441V23.9357H95.6526V19.635H98.8441V14.4283H104.017V19.635H107.209V23.9357H104.017V36.4329Z" fill="white"/>
|
||||
<path d="M83.5052 36.8699C79.944 36.8699 77.7272 34.7875 77.7272 31.4951C77.7272 28.4045 79.9104 26.4901 83.9422 26.1876L88.6462 25.8179V25.549C88.6462 23.9021 87.6379 23.0297 85.7908 23.0297C83.6076 23.0297 82.4312 23.87 82.4312 25.381H78.1305C78.1305 21.5844 81.2548 19.0988 86.0581 19.0988C90.8615 19.0988 93.6833 21.8197 93.6833 26.6246V36.4344H89.1137L88.7776 34.2176C88.2398 35.7622 86.023 36.8714 83.5037 36.8714L83.5052 36.8699ZM85.3187 33.0397C87.3002 33.0397 88.6783 32.065 88.6783 30.1507V29.2432L86.0581 29.4785C83.8077 29.6801 83.0011 30.1843 83.0011 31.2583C83.0011 32.4683 83.7405 33.0382 85.3187 33.0382V33.0397Z" fill="white"/>
|
||||
<path d="M63.9986 36.8699C59.1617 36.8699 56.0374 33.4431 56.0374 28.1356C56.0374 22.8281 59.2289 19.0988 64.3347 19.0988C66.4171 19.0988 68.4001 19.9391 69.3733 21.1812V11.1024H74.5464V36.433H69.6758L69.4069 34.2834C68.4994 35.828 66.4171 36.8699 63.9986 36.8699ZM65.2407 32.1323C67.6592 32.1323 69.3397 30.4533 69.3397 27.9324C69.3397 25.4116 67.6607 23.7326 65.2407 23.7326C62.8207 23.7326 61.2425 25.4452 61.2425 27.9324C61.2425 30.4196 62.7871 32.1323 65.2407 32.1323Z" fill="white"/>
|
||||
<path d="M228.377 28.3024C228.377 26.6 228.702 25.1104 229.351 23.8336C230.023 22.5344 230.942 21.5264 232.107 20.8096C233.294 20.0704 234.671 19.7008 236.239 19.7008C238.121 19.7008 239.7 20.2048 240.977 21.2128C242.276 22.2208 243.083 23.5648 243.396 25.2448H241.078C240.764 24.1248 240.171 23.2624 239.297 22.6576C238.423 22.0528 237.415 21.7504 236.273 21.7504C235.198 21.7504 234.235 22.0192 233.383 22.5568C232.532 23.0944 231.86 23.856 231.367 24.8416C230.897 25.8048 230.662 26.9472 230.662 28.2688C230.662 29.568 230.886 30.6992 231.334 31.6624C231.782 32.6256 232.409 33.376 233.215 33.9136C234.044 34.4512 235.007 34.72 236.105 34.72C237.337 34.72 238.401 34.4176 239.297 33.8128C240.193 33.1856 240.798 32.3456 241.111 31.2928H243.463C243.217 32.3904 242.747 33.3536 242.052 34.1824C241.358 35.0112 240.495 35.6608 239.465 36.1312C238.457 36.5792 237.337 36.8032 236.105 36.8032C234.559 36.8032 233.204 36.456 232.039 35.7616C230.897 35.0448 230.001 34.048 229.351 32.7712C228.702 31.4944 228.377 30.0048 228.377 28.3024Z" fill="white"/>
|
||||
<path d="M249.395 36.4H247.076V11.9056H249.395V36.4Z" fill="white"/>
|
||||
<path d="M253.183 28.2688C253.183 26.5888 253.542 25.1104 254.258 23.8336C254.975 22.5568 255.961 21.5488 257.215 20.8096C258.47 20.0704 259.881 19.7008 261.449 19.7008C263.039 19.7008 264.45 20.0704 265.682 20.8096C266.937 21.5488 267.922 22.5568 268.639 23.8336C269.356 25.1104 269.714 26.5888 269.714 28.2688C269.714 29.904 269.356 31.3712 268.639 32.6704C267.922 33.9472 266.937 34.9552 265.682 35.6944C264.45 36.4336 263.039 36.8032 261.449 36.8032C259.881 36.8032 258.47 36.4336 257.215 35.6944C255.961 34.9552 254.975 33.9472 254.258 32.6704C253.542 31.3712 253.183 29.904 253.183 28.2688ZM255.535 28.2352C255.535 29.512 255.782 30.6432 256.274 31.6288C256.79 32.592 257.484 33.3536 258.358 33.9136C259.254 34.4512 260.284 34.72 261.449 34.72C262.614 34.72 263.633 34.4512 264.506 33.9136C265.402 33.3536 266.097 32.592 266.59 31.6288C267.105 30.6432 267.362 29.512 267.362 28.2352C267.362 26.9808 267.105 25.872 266.59 24.9088C266.097 23.9456 265.402 23.184 264.506 22.624C263.633 22.064 262.614 21.784 261.449 21.784C260.284 21.784 259.254 22.064 258.358 22.624C257.484 23.184 256.79 23.9456 256.274 24.9088C255.782 25.872 255.535 26.9808 255.535 28.2352Z" fill="white"/>
|
||||
<path d="M284.453 20.1376H286.771V36.4H284.755L284.453 33.6448C284.005 34.5856 283.254 35.3472 282.202 35.9296C281.171 36.512 280.018 36.8032 278.741 36.8032C276.837 36.8032 275.37 36.1984 274.339 34.9888C273.331 33.7792 272.827 32.1888 272.827 30.2176V20.104H275.179V29.4784C275.179 31.4048 275.571 32.76 276.355 33.544C277.162 34.328 278.203 34.72 279.48 34.72C281.048 34.72 282.269 34.216 283.142 33.208C284.016 32.1776 284.453 30.6992 284.453 28.7728V20.1376Z" fill="white"/>
|
||||
<path d="M297.656 36.8032C296.088 36.8032 294.744 36.4336 293.624 35.6944C292.504 34.9552 291.642 33.9472 291.037 32.6704C290.432 31.3712 290.13 29.9152 290.13 28.3024C290.13 26.6672 290.432 25.2 291.037 23.9008C291.642 22.6016 292.504 21.5824 293.624 20.8432C294.767 20.0816 296.144 19.7008 297.757 19.7008C299.101 19.7008 300.288 19.992 301.319 20.5744C302.372 21.1568 303.178 22.0416 303.738 23.2288V11.9056H306.056V36.4H304.04L303.805 33.0736C303.223 34.328 302.394 35.2688 301.319 35.896C300.244 36.5008 299.023 36.8032 297.656 36.8032ZM298.06 34.6864C299.269 34.6864 300.288 34.4176 301.117 33.88C301.946 33.32 302.584 32.5584 303.032 31.5952C303.48 30.6096 303.704 29.4896 303.704 28.2352C303.704 26.9808 303.48 25.872 303.032 24.9088C302.584 23.9456 301.946 23.184 301.117 22.624C300.288 22.064 299.269 21.784 298.06 21.784C296.895 21.784 295.887 22.064 295.036 22.624C294.207 23.184 293.568 23.9456 293.12 24.9088C292.695 25.872 292.482 26.9808 292.482 28.2352C292.482 29.4896 292.695 30.6096 293.12 31.5952C293.568 32.5584 294.207 33.32 295.036 33.88C295.887 34.4176 296.895 34.6864 298.06 34.6864Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg width="258" height="40" viewBox="0 0 258 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M33.28 37.1326H31.1245V33.8288H32.3289C33.0011 33.8288 33.4493 33.4087 33.4493 32.4564V27.3052C33.4493 25.1218 34.4296 23.9454 36.2489 23.3584C34.4296 22.7703 33.4493 21.5951 33.4493 19.4117V14.2605C33.4493 13.3082 33.0011 12.8894 32.3289 12.8894H31.1245V9.58553H33.28C35.6875 9.58553 37.3668 11.0688 37.3668 13.4215V18.8248C37.3668 20.7281 38.5151 21.6804 40.4745 21.6804V25.0403C38.5151 25.0403 37.3668 25.9926 37.3668 27.896V33.2992C37.3668 35.6507 35.6875 37.1339 33.28 37.1339V37.1326Z" fill="#1E302D"/>
|
||||
<path d="M0 25.0377V21.6779C1.95937 21.6779 3.10775 20.7256 3.10775 18.8222V13.419C3.10775 11.0675 4.78704 9.58298 7.19456 9.58298H9.35V12.8868H8.1456C7.47338 12.8868 7.02523 13.3069 7.02523 14.258V19.4091C7.02523 21.5926 6.04491 22.769 4.22558 23.3559C6.04491 23.9441 7.02523 25.1192 7.02523 27.3027V32.4538C7.02523 33.4061 7.47338 33.8263 8.1456 33.8263H9.35V37.1301H7.19456C4.78704 37.1301 3.10775 35.6469 3.10775 33.2954V27.8921C3.10775 25.9888 1.95937 25.0365 0 25.0365V25.0377Z" fill="#1E302D"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.2381 0.063694L27.3142 7.13985L24.0142 10.4386L20.2368 6.66115L16.4594 10.4386L13.1606 7.13985L20.2368 0.063694H20.2381Z" fill="#02C382"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.238 9.75996L28.6714 18.1933L25.3726 21.492L20.238 16.3574L15.1034 21.492L11.8047 18.1933L20.238 9.75996Z" fill="#02C382"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.2382 19.4563L30.0287 29.2468L26.7299 32.5468L20.2394 26.0562L13.7489 32.5468L10.4502 29.2468L20.2407 19.4563H20.2382Z" fill="#02C382"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.9058 39.9132V31.0941H22.5718V39.9132H17.9058Z" fill="#1E302D"/>
|
||||
<path d="M180.674 30.3608H176.363V19.9464H173.703V16.3625H176.363V12.0236H180.674V16.3625H183.333V19.9464H180.674V30.3608Z" fill="#1E302D"/>
|
||||
<path d="M160.559 25.9366H164.646C164.702 26.9169 165.43 27.5038 166.745 27.5038C168.06 27.5038 168.705 27.0277 168.705 26.2994C168.705 25.8793 168.481 25.4311 167.444 25.2071L164.869 24.6469C162.293 24.0867 161.034 22.9676 161.034 20.5041C161.034 17.5924 163.526 15.9131 166.885 15.9131C170.245 15.9131 172.401 17.8164 172.401 20.6441H168.314C168.314 19.6918 167.726 19.0488 166.691 19.0488C165.71 19.0488 165.095 19.553 165.095 20.3093C165.095 20.9535 165.627 21.3176 166.607 21.5417L169.21 22.1019C171.506 22.606 172.766 23.6691 172.766 25.9098C172.766 28.9056 170.218 30.7249 166.579 30.7249C162.939 30.7249 160.559 28.7655 160.559 25.9378V25.9366Z" fill="#1E302D"/>
|
||||
<path d="M151.959 30.7249C147.732 30.7249 144.708 27.6451 144.708 23.3343C144.708 19.0234 147.648 15.9156 151.875 15.9156C156.102 15.9156 159.014 18.7993 159.014 23.3623V24.4546L148.851 24.5107C149.103 26.358 150.14 27.2263 152.043 27.2263C153.666 27.2263 154.815 26.6381 155.123 25.631H159.07C158.565 28.7388 155.794 30.7262 151.959 30.7262V30.7249ZM151.903 19.4142C150.196 19.4142 149.243 20.1705 148.907 21.8498H154.731C154.731 20.3653 153.638 19.4142 151.903 19.4142Z" fill="#1E302D"/>
|
||||
<path d="M143.694 16.3625V20.4213H142.323C139.859 20.4213 138.376 21.5137 138.376 24.2853V30.3608H134.065V16.3905H138.124L138.348 18.4619C138.936 17.0895 140.167 16.1664 142.044 16.1664C142.548 16.1664 143.107 16.2225 143.695 16.3625H143.694Z" fill="#1E302D"/>
|
||||
<path d="M116.139 23.3343C116.139 18.9113 119.387 15.9436 123.866 15.9436C128.345 15.9436 131.565 18.9113 131.565 23.3343C131.565 27.7572 128.317 30.6969 123.866 30.6969C119.415 30.6969 116.139 27.7572 116.139 23.3343ZM120.478 23.3062C120.478 25.4337 121.849 26.8341 123.866 26.8341C125.883 26.8341 127.226 25.435 127.226 23.3062C127.226 21.1775 125.855 19.8064 123.866 19.8064C121.877 19.8064 120.478 21.2056 120.478 23.3062Z" fill="#1E302D"/>
|
||||
<path d="M105.764 16.3625H107.696V15.2421C107.696 11.0713 109.879 9.2507 113.435 9.2507C114.023 9.2507 114.695 9.30672 115.366 9.41875V13.1147H114.162C112.399 13.1147 112.006 14.039 112.006 15.2421V16.3625H115.366V19.9464H112.006V30.3608H107.696V19.9464H105.764V16.3625Z" fill="#1E302D"/>
|
||||
<path d="M95.505 30.7249C92.5373 30.7249 90.6899 28.9896 90.6899 26.246C90.6899 23.6704 92.5093 22.0751 95.8691 21.823L99.7879 21.5149V21.2909C99.7879 19.9184 98.9476 19.1914 97.4083 19.1914C95.589 19.1914 94.6087 19.8917 94.6087 21.1508H91.0248C91.0248 17.987 93.6284 15.9156 97.6311 15.9156C101.634 15.9156 103.985 18.1831 103.985 22.1872V30.362H100.179L99.8986 28.5147C99.4505 29.8019 97.6031 30.7262 95.5037 30.7262L95.505 30.7249ZM97.0162 27.5331C98.6675 27.5331 99.8159 26.7208 99.8159 25.1256V24.3693L97.6324 24.5654C95.7571 24.7335 95.0848 25.1536 95.0848 26.0486C95.0848 27.0569 95.7011 27.5318 97.0162 27.5318V27.5331Z" fill="#1E302D"/>
|
||||
<path d="M86.6809 30.3608H82.37V19.9464H79.7104V16.3625H82.37V12.0236H86.6809V16.3625H89.3405V19.9464H86.6809V30.3608Z" fill="#1E302D"/>
|
||||
<path d="M69.5875 30.7249C66.6198 30.7249 64.7725 28.9896 64.7725 26.246C64.7725 23.6704 66.5918 22.0751 69.9516 21.823L73.8716 21.5149V21.2909C73.8716 19.9184 73.0314 19.1914 71.4921 19.1914C69.6728 19.1914 68.6925 19.8917 68.6925 21.1508H65.1086C65.1086 17.987 67.7122 15.9156 71.7149 15.9156C75.7177 15.9156 78.0692 18.1831 78.0692 22.1872V30.362H74.2612L73.9811 28.5147C73.533 29.8019 71.6857 30.7262 69.5862 30.7262L69.5875 30.7249ZM71.0987 27.5331C72.75 27.5331 73.8984 26.7208 73.8984 25.1256V24.3693L71.7149 24.5654C69.8396 24.7335 69.1674 25.1536 69.1674 26.0486C69.1674 27.0569 69.7836 27.5318 71.0987 27.5318V27.5331Z" fill="#1E302D"/>
|
||||
<path d="M53.3321 30.7249C49.3013 30.7249 46.6978 27.8692 46.6978 23.4463C46.6978 19.0234 49.3574 15.9156 53.6122 15.9156C55.3475 15.9156 57.0001 16.6159 57.8111 17.6509V9.25197H62.1219V30.3608H58.0631L57.8391 28.5695C57.0828 29.8566 55.3475 30.7249 53.3321 30.7249ZM54.3672 26.7769C56.3826 26.7769 57.7831 25.3777 57.7831 23.277C57.7831 21.1763 56.3839 19.7771 54.3672 19.7771C52.3505 19.7771 51.0354 21.2043 51.0354 23.277C51.0354 25.3497 52.3225 26.7769 54.3672 26.7769Z" fill="#1E302D"/>
|
||||
<path d="M190.314 23.5854C190.314 22.1667 190.585 20.9254 191.126 19.8614C191.686 18.7787 192.451 17.9387 193.422 17.3414C194.411 16.7254 195.559 16.4174 196.866 16.4174C198.434 16.4174 199.75 16.8374 200.814 17.6774C201.897 18.5174 202.569 19.6374 202.83 21.0374H200.898C200.637 20.104 200.142 19.3854 199.414 18.8814C198.686 18.3774 197.846 18.1254 196.894 18.1254C195.998 18.1254 195.195 18.3494 194.486 18.7974C193.777 19.2454 193.217 19.88 192.806 20.7014C192.414 21.504 192.218 22.456 192.218 23.5574C192.218 24.64 192.405 25.5827 192.778 26.3854C193.151 27.188 193.674 27.8134 194.346 28.2614C195.037 28.7094 195.839 28.9334 196.754 28.9334C197.781 28.9334 198.667 28.6814 199.414 28.1774C200.161 27.6547 200.665 26.9547 200.926 26.0774H202.886C202.681 26.992 202.289 27.7947 201.71 28.4854C201.131 29.176 200.413 29.7174 199.554 30.1094C198.714 30.4827 197.781 30.6694 196.754 30.6694C195.466 30.6694 194.337 30.38 193.366 29.8014C192.414 29.204 191.667 28.3734 191.126 27.3094C190.585 26.2454 190.314 25.004 190.314 23.5854Z" fill="#1E302D"/>
|
||||
<path d="M207.829 30.3334H205.897V9.92135H207.829V30.3334Z" fill="#1E302D"/>
|
||||
<path d="M210.986 23.5574C210.986 22.1574 211.285 20.9254 211.882 19.8614C212.479 18.7974 213.301 17.9574 214.346 17.3414C215.391 16.7254 216.567 16.4174 217.874 16.4174C219.199 16.4174 220.375 16.7254 221.402 17.3414C222.447 17.9574 223.269 18.7974 223.866 19.8614C224.463 20.9254 224.762 22.1574 224.762 23.5574C224.762 24.92 224.463 26.1427 223.866 27.2254C223.269 28.2894 222.447 29.1294 221.402 29.7454C220.375 30.3614 219.199 30.6694 217.874 30.6694C216.567 30.6694 215.391 30.3614 214.346 29.7454C213.301 29.1294 212.479 28.2894 211.882 27.2254C211.285 26.1427 210.986 24.92 210.986 23.5574ZM212.946 23.5294C212.946 24.5934 213.151 25.536 213.562 26.3574C213.991 27.16 214.57 27.7947 215.298 28.2614C216.045 28.7094 216.903 28.9334 217.874 28.9334C218.845 28.9334 219.694 28.7094 220.422 28.2614C221.169 27.7947 221.747 27.16 222.158 26.3574C222.587 25.536 222.802 24.5934 222.802 23.5294C222.802 22.484 222.587 21.56 222.158 20.7574C221.747 19.9547 221.169 19.32 220.422 18.8534C219.694 18.3867 218.845 18.1534 217.874 18.1534C216.903 18.1534 216.045 18.3867 215.298 18.8534C214.57 19.32 213.991 19.9547 213.562 20.7574C213.151 21.56 212.946 22.484 212.946 23.5294Z" fill="#1E302D"/>
|
||||
<path d="M237.044 16.7814H238.976V30.3334H237.296L237.044 28.0374C236.67 28.8214 236.045 29.456 235.168 29.9414C234.309 30.4267 233.348 30.6694 232.284 30.6694C230.697 30.6694 229.474 30.1654 228.616 29.1574C227.776 28.1494 227.356 26.824 227.356 25.1814V16.7534H229.316V24.5654C229.316 26.1707 229.642 27.3 230.296 27.9534C230.968 28.6067 231.836 28.9334 232.9 28.9334C234.206 28.9334 235.224 28.5134 235.952 27.6734C236.68 26.8147 237.044 25.5827 237.044 23.9774V16.7814Z" fill="#1E302D"/>
|
||||
<path d="M248.047 30.6694C246.74 30.6694 245.62 30.3614 244.687 29.7454C243.754 29.1294 243.035 28.2894 242.531 27.2254C242.027 26.1427 241.775 24.9294 241.775 23.5854C241.775 22.2227 242.027 21 242.531 19.9174C243.035 18.8347 243.754 17.9854 244.687 17.3694C245.639 16.7347 246.787 16.4174 248.131 16.4174C249.251 16.4174 250.24 16.66 251.099 17.1454C251.976 17.6307 252.648 18.368 253.115 19.3574V9.92135H255.047V30.3334H253.367L253.171 27.5614C252.686 28.6067 251.995 29.3907 251.099 29.9134C250.203 30.4174 249.186 30.6694 248.047 30.6694ZM248.383 28.9054C249.391 28.9054 250.24 28.6814 250.931 28.2334C251.622 27.7667 252.154 27.132 252.527 26.3294C252.9 25.508 253.087 24.5747 253.087 23.5294C253.087 22.484 252.9 21.56 252.527 20.7574C252.154 19.9547 251.622 19.32 250.931 18.8534C250.24 18.3867 249.391 18.1534 248.383 18.1534C247.412 18.1534 246.572 18.3867 245.863 18.8534C245.172 19.32 244.64 19.9547 244.267 20.7574C243.912 21.56 243.735 22.484 243.735 23.5294C243.735 24.5747 243.912 25.508 244.267 26.3294C244.64 27.132 245.172 27.7667 245.863 28.2334C246.572 28.6814 247.412 28.9054 248.383 28.9054Z" fill="#1E302D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 382,
|
||||
"@typescript-eslint/no-non-null-assertion": 380,
|
||||
"@typescript-eslint/no-misused-promises": 847,
|
||||
"no-console": 88,
|
||||
"complexity": 218,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 129,
|
||||
"@typescript-eslint/no-explicit-any": 144,
|
||||
"trek/no-swallowed-catch": 128,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"react-hooks/set-state-in-effect": 193,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
|
||||
@@ -899,7 +899,6 @@
|
||||
"src/components/Roadtrip/useVehicleRange.test.ts",
|
||||
"src/components/Roadtrip/useVehicleRange.ts",
|
||||
"src/components/Roadtrip/vehicleRange.ts",
|
||||
"src/components/Settings/AboutTab.tsx",
|
||||
"src/components/Settings/AccountTab.test.tsx",
|
||||
"src/components/Settings/AccountTab.tsx",
|
||||
"src/components/Settings/AirTrailConnectionSection.test.tsx",
|
||||
@@ -991,9 +990,6 @@
|
||||
"src/components/Studio/useBookPresence.ts",
|
||||
"src/components/Studio/useBookStore.ts",
|
||||
"src/components/Studio/useSpreadInteraction.ts",
|
||||
"src/components/SystemNotices/ReleaseNoticeModal.test.tsx",
|
||||
"src/components/SystemNotices/ReleaseNoticeModal.tsx",
|
||||
"src/components/SystemNotices/ReleaseNoticeVisuals.tsx",
|
||||
"src/components/SystemNotices/SystemNoticeBanner.extra.test.tsx",
|
||||
"src/components/SystemNotices/SystemNoticeBanner.test.tsx",
|
||||
"src/components/SystemNotices/SystemNoticeBanner.tsx",
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2936,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
@@ -22,7 +21,7 @@
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
"src/pages/atlas/useAtlas.ts": 1211,
|
||||
"src/pages/atlas/useAtlas.ts": 1210,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 1918
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"components/Roadtrip/dayColors.ts": 8,
|
||||
"components/Roadtrip/nightBookend.ts": 1,
|
||||
"components/Roadtrip/stopKinds.ts": 1,
|
||||
"components/Settings/AboutTab.tsx": 3,
|
||||
"components/Settings/AboutTab.tsx": 5,
|
||||
"components/Settings/AccountTab.tsx": 2,
|
||||
"components/Settings/AppearanceSettingsTab.tsx": 1,
|
||||
"components/Settings/PasskeysSection.tsx": 1,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PlaceContribDecisionRequest, PlaceContribLogQuery } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** /api/admin/places-contribute: sharing place details with the TREK Places API. Admin only. */
|
||||
export const placeContribApi = {
|
||||
state: (signal?: AbortSignal): Promise<unknown> =>
|
||||
apiClient.get('/admin/places-contribute', { signal }).then((r) => r.data),
|
||||
decide: (body: PlaceContribDecisionRequest): Promise<unknown> =>
|
||||
apiClient.put('/admin/places-contribute/decision', body).then((r) => r.data),
|
||||
log: (query: Partial<PlaceContribLogQuery>, signal?: AbortSignal): Promise<unknown> =>
|
||||
apiClient.get('/admin/places-contribute/log', { params: query, signal }).then((r) => r.data),
|
||||
withdraw: (): Promise<unknown> =>
|
||||
apiClient.post('/admin/places-contribute/withdraw', { confirm: true }).then((r) => r.data),
|
||||
};
|
||||
@@ -22,6 +22,7 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||
tr: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.',
|
||||
ar: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||
az: 'Həddindən çox cəhd edildi. Bir az sonra yenidən cəhd edin.',
|
||||
he: 'יותר מדי ניסיונות. נסו שוב מאוחר יותר.',
|
||||
id: 'Terlalu banyak percobaan. Coba lagi nanti.',
|
||||
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
||||
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// FE-ADMIN-CONTRIB-LOG-001 to FE-ADMIN-CONTRIB-LOG-004
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { PlaceContribLogEntry } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import PlaceContribLog from './PlaceContribLog';
|
||||
|
||||
vi.mock('../../i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../i18n')>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, locale: 'en-US' }),
|
||||
}));
|
||||
|
||||
const ENTRY: PlaceContribLogEntry = {
|
||||
id: 1,
|
||||
day: '2026-10-29',
|
||||
key: 'osm:n123',
|
||||
placeName: 'Café Luitpold',
|
||||
field: 'phone',
|
||||
valueSummary: '+49 89 242 8750',
|
||||
result: 'accepted',
|
||||
reason: null,
|
||||
};
|
||||
|
||||
function serve(entries: PlaceContribLogEntry[], total = entries.length) {
|
||||
const queries: URLSearchParams[] = [];
|
||||
server.use(
|
||||
http.get('/api/admin/places-contribute/log', ({ request }) => {
|
||||
queries.push(new URL(request.url).searchParams);
|
||||
return HttpResponse.json({
|
||||
entries,
|
||||
total,
|
||||
page: Number(new URL(request.url).searchParams.get('page') ?? 1),
|
||||
perPage: 20,
|
||||
});
|
||||
})
|
||||
);
|
||||
return queries;
|
||||
}
|
||||
|
||||
describe('PlaceContribLog', () => {
|
||||
it('FE-ADMIN-CONTRIB-LOG-001: lists the place, the value, the field and the result', async () => {
|
||||
serve([ENTRY]);
|
||||
render(<PlaceContribLog />);
|
||||
expect(await screen.findByText('Café Luitpold')).toBeInTheDocument();
|
||||
expect(screen.getByText('+49 89 242 8750')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin.contribute.field.phone')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin.contribute.result.accepted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-LOG-002: an empty log says nothing was sent', async () => {
|
||||
serve([]);
|
||||
render(<PlaceContribLog />);
|
||||
expect(await screen.findByText('admin.contribute.log.empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-LOG-003: pages through a long log', async () => {
|
||||
const queries = serve([ENTRY], 45);
|
||||
render(<PlaceContribLog />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: /common\.showMore/ }));
|
||||
expect(await screen.findByText('1 / 3')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'admin.contribute.log.next' }));
|
||||
await waitFor(() => expect(queries[queries.length - 1]?.get('page')).toBe('2'));
|
||||
expect(queries[queries.length - 1]?.get('perPage')).toBe('20');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-LOG-004: shows the newest five until the list is opened', async () => {
|
||||
serve(Array.from({ length: 8 }, (_, i) => ({ ...ENTRY, id: i + 1, placeName: `Place ${i + 1}` })));
|
||||
render(<PlaceContribLog />);
|
||||
expect(await screen.findByText('Place 5')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Place 6')).not.toBeInTheDocument();
|
||||
const toggle = screen.getByRole('button', { name: 'common.showMore (3)' });
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
await userEvent.click(toggle);
|
||||
expect(screen.getByText('Place 8')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.showLess' }));
|
||||
expect(screen.queryByText('Place 8')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
PLACE_CONTRIB_FIELDS,
|
||||
type PlaceContribField,
|
||||
type PlaceContribLogResponse,
|
||||
type PlaceContribLogResult,
|
||||
} from '@trek/shared';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { placeContribRepo } from '../../repo/placeContribRepo';
|
||||
import { PillSelect } from '../shared/dialogParts';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { fieldLabel } from './placeContribModel';
|
||||
|
||||
const PER_PAGE = 20;
|
||||
/** Rows shown before the list is opened; a busy log should not push the page down. */
|
||||
const FOLDED = 5;
|
||||
const RESULTS: PlaceContribLogResult[] = ['accepted', 'duplicate', 'rejected', 'withheld', 'retracted'];
|
||||
const RESULT_LOOK: Record<PlaceContribLogResult, string> = {
|
||||
accepted: 'bg-success-soft text-success',
|
||||
duplicate: 'bg-surface-tertiary text-content-muted',
|
||||
rejected: 'bg-danger-soft text-danger',
|
||||
withheld: 'bg-surface-tertiary text-content-muted',
|
||||
retracted: 'bg-warning-soft text-warning',
|
||||
};
|
||||
|
||||
/**
|
||||
* What this installation sent, newest first: the place, the field, a short
|
||||
* form of the value and what the service made of it. Read only while the fold
|
||||
* is open, a page at a time, and only the newest FOLDED rows until opened.
|
||||
*/
|
||||
export default function PlaceContribLog() {
|
||||
const { t, locale } = useTranslation();
|
||||
const [page, setPage] = useState(1);
|
||||
const [result, setResult] = useState<PlaceContribLogResult | 'all'>('all');
|
||||
const [field, setField] = useState<PlaceContribField | 'all'>('all');
|
||||
const [data, setData] = useState<PlaceContribLogResponse | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
// The query that failed to load, so a new page or filter clears the message by itself.
|
||||
const query = `${page}|${result}|${field}`;
|
||||
const [failedQuery, setFailedQuery] = useState<string | null>(null);
|
||||
const failed = failedQuery === query;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
placeContribRepo
|
||||
.log(
|
||||
{
|
||||
page,
|
||||
perPage: PER_PAGE,
|
||||
...(result === 'all' ? {} : { result }),
|
||||
...(field === 'all' ? {} : { field }),
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
.then(setData, () => {
|
||||
if (!controller.signal.aborted) setFailedQuery(`${page}|${result}|${field}`);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [page, result, field]);
|
||||
|
||||
const pages = data ? Math.max(1, Math.ceil(data.total / PER_PAGE)) : 1;
|
||||
const foldable = !!data && (data.entries.length > FOLDED || pages > 1);
|
||||
const rows = data ? (open || !foldable ? data.entries : data.entries.slice(0, FOLDED)) : [];
|
||||
const day = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short' });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PillSelect<PlaceContribLogResult | 'all'>
|
||||
quiet
|
||||
label={t('admin.contribute.log.result')}
|
||||
value={result}
|
||||
onChange={(v) => {
|
||||
setResult(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'all', label: t('admin.contribute.log.allResults') },
|
||||
...RESULTS.map((r) => ({ value: r, label: t(`admin.contribute.result.${r}`) })),
|
||||
]}
|
||||
/>
|
||||
<PillSelect<PlaceContribField | 'all'>
|
||||
quiet
|
||||
label={t('admin.contribute.log.field')}
|
||||
value={field}
|
||||
onChange={(v) => {
|
||||
setField(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'all', label: t('admin.contribute.log.allFields') },
|
||||
...PLACE_CONTRIB_FIELDS.map((f) => ({ value: f, label: fieldLabel(t, f) })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{failed && (
|
||||
<p className="m-0 text-danger" style={fs(12, 'body')}>
|
||||
{t('admin.contribute.log.failed')}
|
||||
</p>
|
||||
)}
|
||||
{data && data.entries.length === 0 && (
|
||||
<p className="m-0 py-3 text-center text-content-faint" style={fs(12, 'body')}>
|
||||
{t('admin.contribute.log.empty')}
|
||||
</p>
|
||||
)}
|
||||
{data && data.entries.length > 0 && (
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card p-0">
|
||||
{rows.map((e) => (
|
||||
<li key={e.id} className="flex flex-wrap items-center gap-x-2.5 gap-y-1 px-3 py-2">
|
||||
<span className="w-12 flex-none tabular-nums text-content-faint" style={fs(11.5)}>
|
||||
{day.format(new Date(`${e.day}T12:00:00`))}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{e.placeName}
|
||||
</span>
|
||||
{e.valueSummary && (
|
||||
<span className="block truncate text-content-muted" style={fs(11.5)}>
|
||||
{e.valueSummary}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex flex-none items-center rounded-full bg-surface-secondary px-2 py-[2px] text-content-secondary"
|
||||
style={fs(11)}
|
||||
>
|
||||
{fieldLabel(t, e.field)}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex flex-none items-center rounded-full px-2 py-[2px] font-semibold ${RESULT_LOOK[e.result]}`}
|
||||
style={fs(11)}
|
||||
title={e.reason ?? undefined}
|
||||
>
|
||||
{t(`admin.contribute.result.${e.result}`)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{foldable && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex items-center gap-1 self-start font-medium text-content-muted hover:text-content"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={open ? 'rotate-180 transition-transform' : 'transition-transform'}
|
||||
/>
|
||||
{open ? t('common.showLess') : `${t('common.showMore')} (${(data?.total ?? 0) - FOLDED})`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{data && open && pages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2 text-content-muted" style={fs(12, 'body')}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
aria-label={t('admin.contribute.log.previous')}
|
||||
className="grid h-7 w-7 place-items-center rounded-lg border border-edge-faint bg-surface-card disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<span className="tabular-nums">
|
||||
{page} / {pages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
aria-label={t('admin.contribute.log.next')}
|
||||
className="grid h-7 w-7 place-items-center rounded-lg border border-edge-faint bg-surface-card disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-011
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { PlaceContribDecisionRequest, PlaceContribState } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import PlaceContribPanel from './PlaceContribPanel';
|
||||
|
||||
// The keys themselves, so a test fails on a missing or renamed key rather than on wording.
|
||||
vi.mock('../../i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../i18n')>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, locale: 'en-US' }),
|
||||
}));
|
||||
|
||||
const ON = { website: true, phone: true, email: true, opening_hours: true, description: true, image: true };
|
||||
|
||||
const UNDECIDED: PlaceContribState = {
|
||||
termsVersion: 1,
|
||||
ceiling: 'admin',
|
||||
eligible: true,
|
||||
ineligibleReason: null,
|
||||
consent: { enabled: false, fields: ON, termsVersion: null, decision: null, decidedBy: null, decidedAt: null },
|
||||
showPrompt: true,
|
||||
snoozedUntil: null,
|
||||
active: false,
|
||||
counts: { accepted: 0, duplicate: 0, rejected: 0, withheld: 0, retracted: 0 },
|
||||
lastSuccessDay: null,
|
||||
queue: { size: 0, cap: 5000, full: false },
|
||||
pauseReason: null,
|
||||
registrationOpen: false,
|
||||
};
|
||||
|
||||
const AGREED: PlaceContribState = {
|
||||
...UNDECIDED,
|
||||
showPrompt: false,
|
||||
active: true,
|
||||
consent: {
|
||||
enabled: true,
|
||||
fields: ON,
|
||||
termsVersion: 1,
|
||||
decision: 'agreed',
|
||||
decidedBy: 1,
|
||||
decidedAt: '2026-10-30T10:00:00Z',
|
||||
},
|
||||
counts: { accepted: 42, duplicate: 3, rejected: 1, withheld: 5, retracted: 0 },
|
||||
lastSuccessDay: '2026-10-29',
|
||||
queue: { size: 7, cap: 5000, full: false },
|
||||
};
|
||||
|
||||
/** Serves `state`, records every decision and answers it with `answer`. */
|
||||
function serve(
|
||||
state: PlaceContribState,
|
||||
answer: (body: PlaceContribDecisionRequest) => PlaceContribState = () => state
|
||||
) {
|
||||
const decisions: PlaceContribDecisionRequest[] = [];
|
||||
server.use(
|
||||
http.get('/api/admin/places-contribute', () => HttpResponse.json(state)),
|
||||
http.put('/api/admin/places-contribute/decision', async ({ request }) => {
|
||||
const body = (await request.json()) as PlaceContribDecisionRequest;
|
||||
decisions.push(body);
|
||||
return HttpResponse.json(answer(body));
|
||||
}),
|
||||
http.get('/api/admin/places-contribute/log', () =>
|
||||
HttpResponse.json({ entries: [], total: 0, page: 1, perPage: 20 })
|
||||
)
|
||||
);
|
||||
return decisions;
|
||||
}
|
||||
|
||||
describe('PlaceContribPanel', () => {
|
||||
it('FE-ADMIN-CONTRIB-001: an undecided admin gets the prompt with every field switched on', async () => {
|
||||
serve(UNDECIDED);
|
||||
render(<PlaceContribPanel />);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText('admin.contribute.prompt.title')).toBeInTheDocument();
|
||||
for (const field of Object.keys(ON)) {
|
||||
expect(within(dialog).getByRole('button', { name: `admin.contribute.field.${field}` })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
}
|
||||
expect(within(dialog).getByText('admin.contribute.never.notes')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('admin.contribute.never.providers')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-002: nothing is decided until a button is pressed, and Agree sends the switches it shows', async () => {
|
||||
const decisions = serve(UNDECIDED, () => AGREED);
|
||||
render(<PlaceContribPanel />);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(decisions).toHaveLength(0);
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'admin.contribute.field.image' }));
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'admin.contribute.prompt.agree' }));
|
||||
await waitFor(() => expect(decisions).toHaveLength(1));
|
||||
expect(decisions[0]).toEqual({ action: 'agree', termsVersion: 1, fields: { ...ON, image: false } });
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(screen.getByText('admin.contribute.status.active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-003: Agree is unavailable with every field off', async () => {
|
||||
serve(UNDECIDED);
|
||||
render(<PlaceContribPanel />);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
for (const field of Object.keys(ON)) {
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: `admin.contribute.field.${field}` }));
|
||||
}
|
||||
expect(within(dialog).getByRole('button', { name: 'admin.contribute.prompt.agree' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-004: "not now" snoozes and "no" declines for the current terms', async () => {
|
||||
const decisions = serve(UNDECIDED, () => ({ ...UNDECIDED, showPrompt: false }));
|
||||
const { unmount } = render(<PlaceContribPanel />);
|
||||
await userEvent.click(
|
||||
within(await screen.findByRole('dialog')).getByRole('button', { name: 'admin.contribute.prompt.snooze' })
|
||||
);
|
||||
await waitFor(() => expect(decisions).toEqual([{ action: 'snooze' }]));
|
||||
unmount();
|
||||
|
||||
render(<PlaceContribPanel />);
|
||||
await userEvent.click(
|
||||
within(await screen.findByRole('dialog')).getByRole('button', { name: 'admin.contribute.prompt.decline' })
|
||||
);
|
||||
await waitFor(() => expect(decisions[1]).toEqual({ action: 'decline', termsVersion: 1 }));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-005: closing the prompt decides nothing', async () => {
|
||||
const decisions = serve(UNDECIDED);
|
||||
render(<PlaceContribPanel />);
|
||||
await screen.findByRole('dialog');
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(decisions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-006: an agreement shows its figures and a switch per field', async () => {
|
||||
serve(AGREED);
|
||||
render(<PlaceContribPanel />);
|
||||
expect(await screen.findByText('admin.contribute.status.active')).toBeInTheDocument();
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
expect(screen.getByText('7')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'admin.contribute.field.phone' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-007: switching a field off sends the new switches', async () => {
|
||||
const decisions = serve(AGREED);
|
||||
render(<PlaceContribPanel />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'admin.contribute.field.description' }));
|
||||
await waitFor(() => expect(decisions).toEqual([{ action: 'fields', fields: { ...ON, description: false } }]));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-008: the master switch asks before it stops sharing', async () => {
|
||||
const decisions = serve(AGREED, () => ({
|
||||
...AGREED,
|
||||
active: false,
|
||||
consent: { ...AGREED.consent, enabled: false, decision: 'off' },
|
||||
}));
|
||||
render(<PlaceContribPanel />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'admin.contribute.title' }));
|
||||
expect(decisions).toHaveLength(0);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'admin.contribute.offConfirm' }));
|
||||
await waitFor(() => expect(decisions).toEqual([{ action: 'off' }]));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-009: an installation that cannot share says why and has no prompt', async () => {
|
||||
serve({ ...UNDECIDED, eligible: false, ineligibleReason: 'custom_places_url', showPrompt: false });
|
||||
render(<PlaceContribPanel />);
|
||||
expect(await screen.findByText('admin.contribute.ineligible.custom_places_url')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'admin.contribute.title' })).toBeDisabled();
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-010: open registration is named in the prompt', async () => {
|
||||
serve({ ...UNDECIDED, registrationOpen: true });
|
||||
render(<PlaceContribPanel />);
|
||||
expect(
|
||||
await within(await screen.findByRole('dialog')).findByText('admin.contribute.prompt.openRegistration')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
it('FE-ADMIN-CONTRIB-011: asks again while the key is still being fetched, and the prompt follows', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
let reads = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/places-contribute', () => {
|
||||
reads += 1;
|
||||
return HttpResponse.json(
|
||||
reads === 1 ? { ...UNDECIDED, eligible: false, ineligibleReason: 'no_key', showPrompt: false } : UNDECIDED
|
||||
);
|
||||
})
|
||||
);
|
||||
render(<PlaceContribPanel />);
|
||||
expect(await screen.findByText('admin.contribute.ineligible.no_key')).toBeInTheDocument();
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(reads).toBe(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { PlaceContribField, PlaceContribState } from '@trek/shared';
|
||||
import { ChevronRight, HeartHandshake, Undo2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { TranslationFn } from '../../types';
|
||||
import { getApiErrorMessage } from '../../utils/apiError';
|
||||
import { SettingsCard, StatusPill } from '../Settings/settingsKit';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { FigureBadge } from '../Tours/paddle/FigureBadge';
|
||||
import PlaceContribLog from './PlaceContribLog';
|
||||
import {
|
||||
contribNote,
|
||||
contribStatus,
|
||||
fieldEntries,
|
||||
fieldLabel,
|
||||
refusalReason,
|
||||
type ContribStatus,
|
||||
} from './placeContribModel';
|
||||
import PlaceContribPrompt from './PlaceContribPrompt';
|
||||
import { usePlaceContrib } from './usePlaceContrib';
|
||||
|
||||
const PILL_TONE: Record<ContribStatus, 'success' | 'warning' | 'neutral'> = {
|
||||
active: 'success',
|
||||
paused: 'warning',
|
||||
off: 'neutral',
|
||||
unavailable: 'neutral',
|
||||
};
|
||||
|
||||
function Figures({ state, t }: { state: PlaceContribState; t: TranslationFn }) {
|
||||
const { locale } = useTranslation();
|
||||
const number = new Intl.NumberFormat(locale);
|
||||
const last = state.lastSuccessDay
|
||||
? new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short' }).format(
|
||||
new Date(`${state.lastSuccessDay}T12:00:00`)
|
||||
)
|
||||
: t('admin.contribute.never');
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<FigureBadge
|
||||
label={t('admin.contribute.figure.accepted')}
|
||||
value={number.format(state.counts.accepted)}
|
||||
info={t('admin.contribute.figure.acceptedInfo')}
|
||||
/>
|
||||
<FigureBadge
|
||||
label={t('admin.contribute.figure.queue')}
|
||||
value={number.format(state.queue.size)}
|
||||
tone={state.queue.full ? 'warning' : 'neutral'}
|
||||
info={state.queue.full ? t('admin.contribute.figure.queueFull') : undefined}
|
||||
/>
|
||||
<FigureBadge label={t('admin.contribute.figure.last')} value={last} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sharing place details with the TREK Places API, inside the TREK API card:
|
||||
* one switch for the installation, one per field, the transmission log and the
|
||||
* way to take everything back. Turning it on for the first time opens the same
|
||||
* prompt an admin gets on the first visit, so an agreement always comes from it.
|
||||
*/
|
||||
export default function PlaceContribPanel() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const contrib = usePlaceContrib();
|
||||
const { state } = contrib;
|
||||
const [prompting, setPrompting] = useState<boolean | null>(null);
|
||||
const [confirm, setConfirm] = useState<'off' | 'withdraw' | null>(null);
|
||||
|
||||
if (!state) return null;
|
||||
const status = contribStatus(state);
|
||||
const agreed = state.consent.decision === 'agreed' && state.consent.termsVersion === state.termsVersion;
|
||||
// The prompt opens by itself once per visit while the server asks for it.
|
||||
const showPrompt = prompting ?? state.showPrompt;
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
const reason = refusalReason(err);
|
||||
toast.error(
|
||||
reason === 'terms_outdated' || reason === 'no_field'
|
||||
? t(`admin.contribute.refused.${reason}`)
|
||||
: getApiErrorMessage(err, t('common.error'))
|
||||
);
|
||||
};
|
||||
|
||||
const toggleField = (field: PlaceContribField) => {
|
||||
const fields = { ...state.consent.fields, [field]: !state.consent.fields[field] };
|
||||
if (!Object.values(fields).some(Boolean)) {
|
||||
setConfirm('off');
|
||||
return;
|
||||
}
|
||||
contrib.decide({ action: 'fields', fields }).catch(fail);
|
||||
};
|
||||
|
||||
const withdraw = () => {
|
||||
setConfirm(null);
|
||||
contrib.withdraw().then((r) => {
|
||||
const total = Object.values(r.withdrawn).reduce((a, b) => a + b, 0);
|
||||
toast.success(t('admin.contribute.withdrawn', { total }));
|
||||
}, fail);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
flush
|
||||
icon={HeartHandshake}
|
||||
title={t('admin.contribute.title')}
|
||||
badge={<StatusPill tone={PILL_TONE[status]}>{t(`admin.contribute.status.${status}`)}</StatusPill>}
|
||||
hint={contribNote(t, state)}
|
||||
action={
|
||||
<ToggleSwitch
|
||||
on={agreed && state.consent.enabled}
|
||||
label={t('admin.contribute.title')}
|
||||
disabled={!state.eligible || contrib.busy}
|
||||
onToggle={() => (agreed && state.consent.enabled ? setConfirm('off') : setPrompting(true))}
|
||||
/>
|
||||
}
|
||||
bodyClassName="flex flex-col gap-3 p-4"
|
||||
>
|
||||
{agreed && state.consent.enabled && (
|
||||
<>
|
||||
<Figures state={state} t={t} />
|
||||
<ul className="m-0 grid list-none grid-cols-2 gap-1.5 p-0 max-sm:grid-cols-1">
|
||||
{fieldEntries(state.consent.fields).map(([field, on]) => (
|
||||
<li
|
||||
key={field}
|
||||
className="flex items-center justify-between gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-1.5"
|
||||
>
|
||||
<span className={on ? 'text-content' : 'text-content-faint'} style={fs(12.5, 'body')}>
|
||||
{fieldLabel(t, field)}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
on={on}
|
||||
label={fieldLabel(t, field)}
|
||||
disabled={contrib.busy}
|
||||
onToggle={() => toggleField(field)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(agreed || state.consent.decision === 'off') && (
|
||||
<details className="group overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
{/* The admin page's fold head (pages/admin/FoldSummary), which a component cannot import. */}
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2.5 px-3.5 py-2.5 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--text-primary)] [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRight
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
className="flex-none text-content-faint transition-transform group-open:rotate-90"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{t('admin.contribute.log.title')}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="flex flex-col gap-3 border-t border-edge-faint p-3">
|
||||
<PlaceContribLog />
|
||||
<button
|
||||
type="button"
|
||||
disabled={contrib.busy}
|
||||
onClick={() => setConfirm('withdraw')}
|
||||
className="inline-flex items-center gap-1.5 self-start rounded-lg border border-edge-faint bg-surface-card px-3 py-1.5 font-medium text-danger hover:bg-danger-soft disabled:opacity-40"
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
{t('admin.contribute.withdraw')}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{showPrompt && state.eligible && (
|
||||
<PlaceContribPrompt state={state} contrib={contrib} onClose={() => setPrompting(false)} />
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={confirm === 'off'}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={() => {
|
||||
setConfirm(null);
|
||||
contrib.decide({ action: 'off' }).catch(fail);
|
||||
}}
|
||||
title={t('admin.contribute.offTitle')}
|
||||
message={t('admin.contribute.offMessage')}
|
||||
confirmLabel={t('admin.contribute.offConfirm')}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={confirm === 'withdraw'}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={withdraw}
|
||||
title={t('admin.contribute.withdrawTitle')}
|
||||
message={t('admin.contribute.withdrawMessage')}
|
||||
confirmLabel={t('admin.contribute.withdraw')}
|
||||
danger
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { PlaceContribFieldSwitches, PlaceContribState } from '@trek/shared';
|
||||
import { Check, HeartHandshake, ShieldCheck, TriangleAlert, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { getApiErrorMessage } from '../../utils/apiError';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../shared/DialogShell';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { ALL_FIELDS_ON, fieldEntries, fieldLabel, refusalReason } from './placeContribModel';
|
||||
import type { usePlaceContrib } from './usePlaceContrib';
|
||||
|
||||
const NEVER = ['notes', 'files', 'trips', 'people', 'providers'] as const;
|
||||
|
||||
/**
|
||||
* The question an admin gets once: share what this installation's users type
|
||||
* about places with every other TREK. Asked warmly and with every field already
|
||||
* on, because saying yes is the point of asking; but nothing leaves before the
|
||||
* admin presses Agree, and "not now" and "no" are as easy to reach.
|
||||
*
|
||||
* Closing the dialog decides nothing: it comes back on the next visit until one
|
||||
* of the three buttons is pressed.
|
||||
*/
|
||||
export default function PlaceContribPrompt({
|
||||
state,
|
||||
contrib,
|
||||
onClose,
|
||||
}: {
|
||||
state: PlaceContribState;
|
||||
contrib: ReturnType<typeof usePlaceContrib>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [fields, setFields] = useState<PlaceContribFieldSwitches>(() =>
|
||||
state.consent.decision === 'agreed' || state.consent.decision === 'off' ? state.consent.fields : ALL_FIELDS_ON
|
||||
);
|
||||
const none = !Object.values(fields).some(Boolean);
|
||||
|
||||
const run = async (body: Parameters<typeof contrib.decide>[0], done?: string) => {
|
||||
try {
|
||||
await contrib.decide(body);
|
||||
if (done) toast.success(done);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const reason = refusalReason(err);
|
||||
toast.error(
|
||||
reason === 'terms_outdated' || reason === 'no_field'
|
||||
? t(`admin.contribute.refused.${reason}`)
|
||||
: getApiErrorMessage(err, t('common.error'))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy="place-contrib-prompt-title"
|
||||
width="detail"
|
||||
blocked={contrib.busy}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<HeartHandshake size={22} className="text-content" aria-hidden="true" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId="place-contrib-prompt-title"
|
||||
eyebrow="TREK Places API"
|
||||
title={t('admin.contribute.prompt.title')}
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<DialogButton
|
||||
disabled={contrib.busy}
|
||||
onClick={() => void run({ action: 'decline', termsVersion: state.termsVersion })}
|
||||
>
|
||||
{t('admin.contribute.prompt.decline')}
|
||||
</DialogButton>
|
||||
<FooterSpacer />
|
||||
<DialogButton disabled={contrib.busy} onClick={() => void run({ action: 'snooze' })}>
|
||||
{t('admin.contribute.prompt.snooze')}
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
autoFocus
|
||||
disabled={contrib.busy || none}
|
||||
icon={<HeartHandshake size={15} aria-hidden="true" />}
|
||||
onClick={() =>
|
||||
void run(
|
||||
{ action: 'agree', termsVersion: state.termsVersion, fields },
|
||||
t('admin.contribute.prompt.thanks')
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('admin.contribute.prompt.agree')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="m-0 leading-relaxed text-content" style={fs(14, 'body')}>
|
||||
{t('admin.contribute.prompt.lead')}
|
||||
</p>
|
||||
<p className="m-0 leading-relaxed text-content-secondary" style={fs(13, 'body')}>
|
||||
{t('admin.contribute.prompt.why')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-2" aria-labelledby="place-contrib-prompt-fields">
|
||||
<h3 id="place-contrib-prompt-fields" className="m-0 font-semibold text-content" style={fs(13, 'body')}>
|
||||
{t('admin.contribute.prompt.whatLabel')}
|
||||
</h3>
|
||||
<ul className="m-0 grid list-none grid-cols-2 gap-1.5 p-0 max-sm:grid-cols-1">
|
||||
{fieldEntries(fields).map(([field, on]) => (
|
||||
<li
|
||||
key={field}
|
||||
className="flex items-center justify-between gap-3 rounded-[12px] border border-edge-faint bg-surface-secondary px-3 py-2"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 text-content" style={fs(13, 'body')}>
|
||||
<Check
|
||||
size={14}
|
||||
strokeWidth={2.4}
|
||||
className={on ? 'text-success' : 'text-content-faint opacity-40'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{fieldLabel(t, field)}</span>
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
on={on}
|
||||
label={fieldLabel(t, field)}
|
||||
onToggle={() => setFields((f) => ({ ...f, [field]: !f[field] }))}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="m-0 leading-normal text-content-faint" style={fs(11.5)}>
|
||||
{t('admin.contribute.prompt.imageNote')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="flex flex-col gap-2 rounded-[14px] border border-edge-faint bg-surface-secondary p-3"
|
||||
aria-labelledby="place-contrib-prompt-never"
|
||||
>
|
||||
<h3
|
||||
id="place-contrib-prompt-never"
|
||||
className="m-0 flex items-center gap-1.5 font-semibold text-content"
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<ShieldCheck size={15} className="text-success" aria-hidden="true" />
|
||||
{t('admin.contribute.prompt.neverLabel')}
|
||||
</h3>
|
||||
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
|
||||
{NEVER.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-edge-faint bg-surface-card px-2.5 py-[3px] font-medium text-content-secondary"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<X size={12} strokeWidth={2.4} className="text-content-faint" aria-hidden="true" />
|
||||
{t(`admin.contribute.never.${item}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="m-0 leading-normal text-content-muted" style={fs(12, 'body')}>
|
||||
{t('admin.contribute.prompt.how')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{state.registrationOpen && (
|
||||
<p
|
||||
className="m-0 flex items-start gap-2 rounded-[12px] bg-warning-soft px-3 py-2 leading-normal text-warning"
|
||||
style={fs(12, 'body')}
|
||||
role="note"
|
||||
>
|
||||
<TriangleAlert size={15} className="mt-[1px] flex-none" aria-hidden="true" />
|
||||
{t('admin.contribute.prompt.openRegistration')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="m-0 text-content-faint" style={fs(11.5)}>
|
||||
{t('admin.contribute.prompt.footnote')}
|
||||
</p>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ServiceAllowance, ServiceIdentityState, ServiceIdentityStatus, ServiceQuotaView } from '@trek/shared';
|
||||
import { MapPin, RefreshCw, Route, Search, type LucideIcon } from 'lucide-react';
|
||||
import { MapPin, RefreshCw, Route, Search, Signal, type LucideIcon } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { TranslationFn } from '../../types';
|
||||
import { getApiErrorMessage } from '../../utils/apiError';
|
||||
import { formatClockTime } from '../../utils/formatters';
|
||||
import { SettingsCard } from '../Settings/settingsKit';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { FigureBadge } from '../Tours/paddle/FigureBadge';
|
||||
@@ -121,7 +122,7 @@ function stateHint(t: TranslationFn, status: ServiceIdentityStatus | null, faile
|
||||
export default function ServiceIdentityPanel({ managed = false }: { managed?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const { status, failed, regenerating, regenerate } = useServiceIdentity(panel);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const resetAt = useResetTime(status);
|
||||
@@ -140,57 +141,66 @@ export default function ServiceIdentityPanel({ managed = false }: { managed?: bo
|
||||
const canRegenerate = !managed && status !== null && status.keySource !== 'env' && status.state !== 'off';
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={panel}
|
||||
aria-label={t('admin.trekApi.quota.title')}
|
||||
className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="m-0 inline-flex h-[26px] items-center gap-2 rounded-full bg-surface-card px-2.5 text-caption font-semibold text-content shadow-sm">
|
||||
<span aria-hidden="true" className={`h-2 w-2 flex-none rounded-full ${STATE_DOT[status?.state ?? 'off']}`} />
|
||||
{status ? t(`admin.trekApi.quota.state.${status.state}`) : t('admin.trekApi.quota.title')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{status?.tier && <FigureBadge value={t(`admin.trekApi.quota.tier.${status.tier}`)} />}
|
||||
{status?.keySource === 'env' && (
|
||||
<FigureBadge
|
||||
label={t('admin.trekApi.quota.key')}
|
||||
value="TREK_SERVICES_KEY"
|
||||
info={t('admin.trekApi.quota.envKey')}
|
||||
<div ref={panel}>
|
||||
<SettingsCard
|
||||
flush
|
||||
icon={Signal}
|
||||
title={t('admin.trekApi.quota.title')}
|
||||
badge={
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-surface-card px-2 py-[2px] text-caption font-semibold text-content shadow-sm">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-2 w-2 flex-none rounded-full ${STATE_DOT[status?.state ?? 'off']}`}
|
||||
/>
|
||||
)}
|
||||
{resetAt && <FigureBadge label={t('admin.trekApi.quota.resets')} value={resetAt} />}
|
||||
</div>
|
||||
</div>
|
||||
{status ? t(`admin.trekApi.quota.state.${status.state}`) : '…'}
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<>
|
||||
{status?.tier && <FigureBadge value={t(`admin.trekApi.quota.tier.${status.tier}`)} />}
|
||||
{status?.keySource === 'env' && (
|
||||
<FigureBadge
|
||||
label={t('admin.trekApi.quota.key')}
|
||||
value="TREK_SERVICES_KEY"
|
||||
info={t('admin.trekApi.quota.envKey')}
|
||||
/>
|
||||
)}
|
||||
{resetAt && <FigureBadge label={t('admin.trekApi.quota.resets')} value={resetAt} />}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{status && status.services.length > 0 && (
|
||||
<ul
|
||||
aria-label={t('admin.trekApi.quota.title')}
|
||||
className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-3"
|
||||
>
|
||||
{status.services.map((allowance) => (
|
||||
<ServiceTile key={allowance.service} allowance={allowance} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hint && <p className="m-0 text-caption leading-snug text-content-muted">{hint}</p>}
|
||||
|
||||
{status && status.services.length > 0 && (
|
||||
<ul
|
||||
aria-label={t('admin.trekApi.quota.title')}
|
||||
className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-3"
|
||||
>
|
||||
{status.services.map((allowance) => (
|
||||
<ServiceTile key={allowance.service} allowance={allowance} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hint && <p className="m-0 text-caption leading-snug text-content-muted">{hint}</p>}
|
||||
|
||||
{canRegenerate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
disabled={regenerating}
|
||||
className="inline-flex items-center gap-1.5 self-start rounded-lg text-caption font-medium text-content-secondary hover:text-content disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
size={13}
|
||||
strokeWidth={2}
|
||||
className={regenerating ? 'motion-safe:animate-spin' : ''}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('admin.trekApi.quota.regenerate')}
|
||||
</button>
|
||||
)}
|
||||
{/* The one action, on a rule of its own below the figures. */}
|
||||
{canRegenerate && (
|
||||
<div className="-mx-4 -mb-4 flex items-center justify-end border-t border-edge-faint px-4 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
disabled={regenerating}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-caption font-medium text-content-secondary shadow-sm hover:bg-surface-hover hover:text-content disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
size={13}
|
||||
strokeWidth={2}
|
||||
className={regenerating ? 'motion-safe:animate-spin' : ''}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('admin.trekApi.quota.regenerate')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirming}
|
||||
@@ -201,6 +211,6 @@ export default function ServiceIdentityPanel({ managed = false }: { managed?: bo
|
||||
confirmLabel={t('admin.trekApi.quota.regenerate')}
|
||||
danger={false}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
PLACE_CONTRIB_FIELDS,
|
||||
type PlaceContribField,
|
||||
type PlaceContribFieldSwitches,
|
||||
type PlaceContribState,
|
||||
} from '@trek/shared';
|
||||
import type { TranslationFn } from '../../types';
|
||||
|
||||
/** Every switch on: what the prompt proposes, and what an installation that never decided reads as. */
|
||||
export const ALL_FIELDS_ON: PlaceContribFieldSwitches = {
|
||||
website: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
opening_hours: true,
|
||||
description: true,
|
||||
image: true,
|
||||
};
|
||||
|
||||
export const fieldLabel = (t: TranslationFn, field: PlaceContribField): string => t(`admin.contribute.field.${field}`);
|
||||
|
||||
/** The switches as a list, in the order the contract names the fields. */
|
||||
export const fieldEntries = (switches: PlaceContribFieldSwitches): [PlaceContribField, boolean][] =>
|
||||
PLACE_CONTRIB_FIELDS.map((field) => [field, switches[field]]);
|
||||
|
||||
export type ContribStatus = 'active' | 'paused' | 'off' | 'unavailable';
|
||||
|
||||
/** Where sharing stands, as the pill beside the title says it. */
|
||||
export function contribStatus(state: PlaceContribState): ContribStatus {
|
||||
if (!state.eligible) return 'unavailable';
|
||||
if (!state.active) return 'off';
|
||||
return state.pauseReason ? 'paused' : 'active';
|
||||
}
|
||||
|
||||
/** The sentence under the title: why it cannot run, why it waits, or what it does. */
|
||||
export function contribNote(t: TranslationFn, state: PlaceContribState): string {
|
||||
if (!state.eligible && state.ineligibleReason) return t(`admin.contribute.ineligible.${state.ineligibleReason}`);
|
||||
if (state.active && state.pauseReason) return t(`admin.contribute.pause.${state.pauseReason}`);
|
||||
return t(state.active ? 'admin.contribute.noteActive' : 'admin.contribute.noteOff');
|
||||
}
|
||||
|
||||
/** The refusal a decision can meet, as the server names it in `reason`, or null for any other failure. */
|
||||
export function refusalReason(err: unknown): string | null {
|
||||
const reason = (err as { response?: { data?: { reason?: unknown } } } | null)?.response?.data?.reason;
|
||||
return typeof reason === 'string' && reason ? reason : null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// FE-ADMIN-CONTRIBHOOK-001
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { PlaceContribState } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { placeContribRepo } from '../../repo/placeContribRepo';
|
||||
import { usePlaceContrib } from './usePlaceContrib';
|
||||
|
||||
const STATE = { ineligibleReason: null, active: true } as unknown as PlaceContribState;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('usePlaceContrib', () => {
|
||||
it('FE-ADMIN-CONTRIBHOOK-001: a withdrawal that went through is answered even when the state cannot be read again', async () => {
|
||||
const state = vi.spyOn(placeContribRepo, 'state').mockResolvedValueOnce(STATE);
|
||||
vi.spyOn(placeContribRepo, 'withdraw').mockResolvedValue({ withdrawn: { website: 2 }, cleared: 1 });
|
||||
const { result } = renderHook(() => usePlaceContrib());
|
||||
await waitFor(() => expect(result.current.state).toBe(STATE));
|
||||
|
||||
state.mockRejectedValueOnce(new Error('offline'));
|
||||
let answer: unknown;
|
||||
await act(async () => {
|
||||
answer = await result.current.withdraw();
|
||||
});
|
||||
|
||||
expect(answer).toEqual({ withdrawn: { website: 2 }, cleared: 1 });
|
||||
expect(result.current.state).toBe(STATE);
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { PlaceContribDecisionRequest, PlaceContribState } from '@trek/shared';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { placeContribRepo } from '../../repo/placeContribRepo';
|
||||
|
||||
const KEY_RETRIES = 6;
|
||||
const KEY_RETRY_MS = 3_000;
|
||||
|
||||
/**
|
||||
* The admin's decision on sharing place details. Read once when the settings
|
||||
* tab opens, so the prompt can come up without the card being scrolled to;
|
||||
* every decision answers the new state, which replaces the old one.
|
||||
*/
|
||||
export function usePlaceContrib() {
|
||||
const [state, setState] = useState<PlaceContribState | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
// A fresh installation fetches its key on first use, so the first answer can
|
||||
// be no_key; asking again a few times lets the prompt appear once it landed.
|
||||
const read = (tries: number) => {
|
||||
placeContribRepo.state(controller.signal).then(
|
||||
(next) => {
|
||||
setState(next);
|
||||
if (next.ineligibleReason === 'no_key' && tries > 1) timer = setTimeout(() => read(tries - 1), KEY_RETRY_MS);
|
||||
},
|
||||
() => {
|
||||
if (!controller.signal.aborted) setFailed(true);
|
||||
}
|
||||
);
|
||||
};
|
||||
read(KEY_RETRIES);
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const decide = useCallback(async (body: PlaceContribDecisionRequest): Promise<PlaceContribState> => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const next = await placeContribRepo.decide(body);
|
||||
setState(next);
|
||||
return next;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const withdraw = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await placeContribRepo.withdraw();
|
||||
// The withdrawal went through; a failed re-read keeps the old state rather than reporting it failed.
|
||||
await placeContribRepo.state().then(setState, () => undefined);
|
||||
return result;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { state, failed, busy, decide, withdraw };
|
||||
}
|
||||
@@ -20,7 +20,10 @@ const trip = (id: number, start: string | null, end: string | null) => ({
|
||||
end_date: end,
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('rankPickerTrips', () => {
|
||||
it('FE-AI-PICK-001: the running trip leads, then the soonest to come, then the latest past one', () => {
|
||||
@@ -59,6 +62,9 @@ describe('rankPickerTrips', () => {
|
||||
|
||||
describe('useAssistantTripPicker', () => {
|
||||
it('FE-AI-PICK-004: loads the trips once and ranks them; a failed load offers none', async () => {
|
||||
// The hook ranks against the real clock; pin it so trip 7 keeps running.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(`${TODAY}T12:00:00`));
|
||||
const list = vi
|
||||
.spyOn(tripRepo, 'list')
|
||||
.mockResolvedValueOnce({ trips: [trip(7, '2026-10-01', '2026-10-30') as Trip], archivedTrips: [] });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-048, FE-COMP-JMAPGL-2453
|
||||
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-048, FE-COMP-JMAPGL-2453, FE-COMP-JMAPGL-2910
|
||||
|
||||
type Spy = ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -644,6 +644,15 @@ describe('JourneyMapGL', () => {
|
||||
expect(gl.map.on).toHaveBeenCalledWith('moveend', expect.any(Function))
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-2910: no marker element sets its own position, so the engine keeps it absolute', () => {
|
||||
withToken()
|
||||
const photos = [{ id: 'a', lat: 45, lng: 5, thumbUrl: '/api/photos/1/thumbnail' }]
|
||||
const marks = [{ kind: 'stay', key: 's1', lat: 46, lng: 6, color: '#14b8a6', stay: {} }]
|
||||
render(<JourneyMapGL checkins={[]} entries={entries} photos={photos} marks={marks} />)
|
||||
expect(gl.markers.some(m => m.element.innerHTML.includes('/api/photos/'))).toBe(true)
|
||||
for (const marker of gl.markers) expect(marker.element.style.position).toBe('')
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-2889: panning keeps the photo and mark groups, only a zoom regroups them', () => {
|
||||
withToken()
|
||||
const photos = [
|
||||
|
||||
@@ -557,7 +557,10 @@ function JourneyMapGL(
|
||||
if (!photos?.length) return
|
||||
for (const cluster of clusterPhotos(photos, glWorldPx(map))) {
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = 'position:relative;width:48px;height:48px;cursor:pointer'
|
||||
// No inline `position` (#2910): the engine's marker class positions the element
|
||||
// absolutely, and `position:relative` would drop it into the normal flow, where
|
||||
// every thumbnail sits below the ones drawn before it.
|
||||
el.style.cssText = 'width:48px;height:48px;cursor:pointer'
|
||||
el.innerHTML = photoMarkerHtml(cluster.members[0].thumbUrl, cluster.members.length)
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020
|
||||
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-022
|
||||
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
@@ -136,6 +136,18 @@ describe('MobileEntryView', () => {
|
||||
expect(container.querySelector('svg.lucide-camera')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYVIEW-021: the hero holds the proportions of the photo and shows its thumbnail before the original arrives (#2907)', () => {
|
||||
const { container } = renderView(buildEntry({ photos: [{ ...buildPhoto(4), width: 3000, height: 2000 }] }))
|
||||
const hero = container.querySelector('img') as HTMLImageElement
|
||||
expect(hero.style.aspectRatio).toBe('3000 / 2000')
|
||||
expect(hero.style.backgroundImage).toContain('/api/photos/4/thumbnail')
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYVIEW-022: a photo without recorded dimensions still reserves a 4:3 hero', () => {
|
||||
const { container } = renderView(buildEntry({ photos: [buildPhoto(4)] }))
|
||||
expect((container.querySelector('img') as HTMLImageElement).style.aspectRatio).toBe('4 / 3')
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYVIEW-008: several photos add a counter and a thumbnail strip', () => {
|
||||
const { container } = renderView(buildEntry({ photos: [buildPhoto(4), buildPhoto(5), buildPhoto(6)] }))
|
||||
expect(screen.getByText('3 photos')).toBeInTheDocument()
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||
ThumbsUp, ThumbsDown,
|
||||
} from 'lucide-react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import JournalBody from './JournalBody'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
@@ -39,6 +40,22 @@ function photoUrl(
|
||||
return `/api/photos/${p.photo_id}/${size}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero's box before its picture arrives (#2907): the photo's own proportions
|
||||
* when the upload recorded them, else 4:3, so the story below does not jump down
|
||||
* once the original has loaded. The thumbnail, usually cached by the strip or the
|
||||
* feed already, fills that box meanwhile.
|
||||
*/
|
||||
function heroPlaceholderStyle(photo: JourneyPhoto, thumbUrl: string): CSSProperties {
|
||||
const ratio = photo.width && photo.height ? `${photo.width} / ${photo.height}` : '4 / 3'
|
||||
return {
|
||||
aspectRatio: ratio,
|
||||
backgroundImage: `url("${encodeURI(thumbUrl)}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry
|
||||
readOnly?: boolean
|
||||
@@ -114,8 +131,10 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
<img
|
||||
src={photoUrl(photos[0], photos[0].media_type === 'video' ? 'thumbnail' : 'original', publicPhotoUrl)}
|
||||
alt=""
|
||||
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
decoding="async"
|
||||
className={`block w-full max-h-[50vh] bg-surface-secondary ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
}`}
|
||||
style={heroPlaceholderStyle(photos[0], photoUrl(photos[0], 'thumbnail', publicPhotoUrl))}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -143,7 +162,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
key={p.id || i}
|
||||
type="button"
|
||||
aria-label={p.caption || t('journey.photos')}
|
||||
className={`relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-0 p-0 hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all ${p.media_type === 'video' ? 'bg-black' : 'bg-transparent'
|
||||
className={`relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-0 p-0 hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all ${p.media_type === 'video' ? 'bg-black' : 'bg-surface-secondary'
|
||||
}`}
|
||||
onClick={() => onPhotoClick(photos, i)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-027
|
||||
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-029
|
||||
|
||||
// Plyr needs a real media pipeline, so the player is stubbed here.
|
||||
vi.mock('./VideoPlayer', () => ({
|
||||
@@ -326,4 +326,28 @@ describe('PhotoLightbox', () => {
|
||||
expect(screen.getByText('Google', { selector: 'p' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
it('FE-COMP-LIGHTBOX-028: the thumbnail and a spinner stand in until the original has loaded (#2907)', () => {
|
||||
const { container } = render(
|
||||
<PhotoLightbox photos={[{ id: 'a', src: '/a/original', thumbSrc: '/a/thumbnail' }]} onClose={vi.fn()} />,
|
||||
);
|
||||
const placeholder = screen.getByTestId('lightbox-loading');
|
||||
expect(placeholder.querySelector('img')).toHaveAttribute('src', '/a/thumbnail');
|
||||
expect(placeholder.querySelector('svg.animate-spin')).toBeInTheDocument();
|
||||
|
||||
fireEvent.load(container.ownerDocument.querySelector('img[src="/a/original"]') as HTMLImageElement);
|
||||
expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-029: the next photo waits for its own original, and a failed one drops the spinner too', () => {
|
||||
render(
|
||||
<PhotoLightbox photos={[{ id: 'a', src: '/a.jpg' }, { id: 'b', src: '/b.jpg' }]} onClose={vi.fn()} />,
|
||||
);
|
||||
fireEvent.load(document.querySelector('img[src="/a.jpg"]') as HTMLImageElement);
|
||||
expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' });
|
||||
expect(screen.getByTestId('lightbox-loading')).toBeInTheDocument();
|
||||
fireEvent.error(document.querySelector('img[src="/b.jpg"]') as HTMLImageElement);
|
||||
expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import VideoPlayer from './VideoPlayerLazy'
|
||||
import { usePhotoZoom } from './usePhotoZoom'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
@@ -8,6 +8,12 @@ import { isRtlLanguage, useTranslation } from '../../i18n'
|
||||
interface LightboxPhoto {
|
||||
id: string
|
||||
src: string
|
||||
/**
|
||||
* A small rendition of the same photo, usually cached already by the grid that
|
||||
* opened the lightbox. It stands in while the original loads, which on a slow
|
||||
* server or connection can take seconds (#2907).
|
||||
*/
|
||||
thumbSrc?: string
|
||||
caption?: string | null
|
||||
/** Where the caption's credit points to (a photo's source page), opened in a new tab. */
|
||||
captionHref?: string | null
|
||||
@@ -52,6 +58,9 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
|
||||
// Zoom starts over on every photo (#1484).
|
||||
const zoom = usePhotoZoom(photo?.id)
|
||||
const isImage = !!photo && photo.mediaType !== 'video'
|
||||
// The id whose original has finished (or failed) loading; any other photo is still on its way.
|
||||
const [loadedId, setLoadedId] = useState<string | null>(null)
|
||||
const loading = isImage && loadedId !== photo.id
|
||||
const hasPrev = idx > 0
|
||||
const hasNext = idx < photos.length - 1
|
||||
|
||||
@@ -187,6 +196,8 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
|
||||
src={photo.src}
|
||||
alt={photo.caption || ''}
|
||||
draggable={false}
|
||||
onLoad={() => setLoadedId(photo.id)}
|
||||
onError={() => setLoadedId(photo.id)}
|
||||
{...zoom.mouse}
|
||||
style={{
|
||||
maxWidth: '92vw', maxHeight: '92vh',
|
||||
@@ -198,6 +209,17 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div data-testid="lightbox-loading" aria-hidden="true" style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
|
||||
{photo.thumbSrc && (
|
||||
<img src={photo.thumbSrc} alt="" draggable={false} style={{ width: '92vw', height: '92vh', objectFit: 'contain', borderRadius: 4 }} />
|
||||
)}
|
||||
<span style={{ ...ROUND_BTN, position: 'absolute', cursor: 'default' }}>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next button */}
|
||||
{hasNext && (
|
||||
<button type="button" onClick={next} aria-label={t('journey.lightbox.next')} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 focus-visible:opacity-100 transition-opacity" style={{
|
||||
|
||||
@@ -97,6 +97,8 @@ describe('attachLocationMarker', () => {
|
||||
expect(root.children).toHaveLength(3)
|
||||
// The cone starts hidden — there is no heading until the first fix.
|
||||
expect((root.children[1] as HTMLElement).style.display).toBe('none')
|
||||
// The GL marker class owns `position`; an inline one would drop it into the flow.
|
||||
expect(root.style.position).toBe('')
|
||||
})
|
||||
|
||||
it('FE-COMP-LOCMARKERGL-003: adds the accuracy source and fill layer when the map is already loaded', () => {
|
||||
|
||||
@@ -13,7 +13,9 @@ type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string
|
||||
// and mapbox doesn't get confused about which element to position.
|
||||
function buildLocationEl(): { root: HTMLDivElement; cone: HTMLDivElement } {
|
||||
const root = document.createElement('div')
|
||||
root.style.cssText = 'width:28px;height:28px;position:relative;pointer-events:none;'
|
||||
// No inline `position`: the GL marker class keeps the root absolute, which the
|
||||
// children's insets resolve against (see the note on markers in MapViewGL).
|
||||
root.style.cssText = 'width:28px;height:28px;pointer-events:none;'
|
||||
// Accuracy pulse behind the dot
|
||||
const pulse = document.createElement('div')
|
||||
pulse.style.cssText = `
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { Copy, Mail, Phone, RotateCcw, X } from 'lucide-react'
|
||||
import type { PlaceOpeningHours } from '@trek/shared'
|
||||
import type { PlaceClientOrigin, PlaceOpeningHours } from '@trek/shared'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DialogSection, fs } from '../shared/DialogShell'
|
||||
@@ -16,7 +16,8 @@ interface Props {
|
||||
email: string
|
||||
/** The stored hours text (JSON), empty when the place has none of its own. */
|
||||
openingHours: string
|
||||
onChange: (field: 'phone' | 'email' | 'opening_hours', value: string) => void
|
||||
/** `origin` is set when the hours were taken over from the details lookup rather than typed. */
|
||||
onChange: (field: 'phone' | 'email' | 'opening_hours', value: string, origin?: PlaceClientOrigin) => void
|
||||
/**
|
||||
* The hours the place details column looked up, as a week. A place without
|
||||
* hours of its own takes them over on its own; one that has some gets a
|
||||
@@ -51,11 +52,11 @@ export function PlaceContactFields({ id: anchorId, phone, email, openingHours, o
|
||||
if (openingHours) setEditing(true)
|
||||
}, [openingHours])
|
||||
|
||||
const setWeek = (next: PlaceOpeningHours) => {
|
||||
const setWeek = (next: PlaceOpeningHours, origin?: PlaceClientOrigin) => {
|
||||
setDraft(next)
|
||||
const text = writeWeek(cleanWeek(next))
|
||||
written.current = text
|
||||
onChange('opening_hours', text)
|
||||
onChange('opening_hours', text, origin)
|
||||
}
|
||||
const setDay = (i: number, patch: Partial<PlaceOpeningHours[number]>) =>
|
||||
setWeek(week.map((day, d) => (d === i ? { ...day, ...patch } : day)))
|
||||
@@ -68,7 +69,7 @@ export function PlaceContactFields({ id: anchorId, phone, email, openingHours, o
|
||||
if (!suggestedHours || adopted.current === suggestedHours) return
|
||||
adopted.current = suggestedHours
|
||||
if (written.current) return
|
||||
setWeek(suggestedHours)
|
||||
setWeek(suggestedHours, 'enrichment')
|
||||
setEditing(true)
|
||||
// setWeek only reads refs and setters; the lookup is the trigger.
|
||||
}, [suggestedHours])
|
||||
@@ -99,7 +100,7 @@ export function PlaceContactFields({ id: anchorId, phone, email, openingHours, o
|
||||
<div className="flex items-center gap-1">
|
||||
{canTakeOver && (
|
||||
<Tooltip label={t('places.hoursFromDetails')}>
|
||||
<button type="button" onClick={() => setWeek(suggestedHours!)} aria-label={t('places.hoursFromDetails')}
|
||||
<button type="button" onClick={() => setWeek(suggestedHours!, 'enrichment')} aria-label={t('places.hoursFromDetails')}
|
||||
className="grid h-7 w-7 place-items-center rounded-full text-content-faint hover:bg-surface-hover hover:text-content">
|
||||
<RotateCcw size={13} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
@@ -308,49 +308,17 @@ export default function PlaceDetailsColumn({
|
||||
t={t}
|
||||
/>
|
||||
<FactList facts={data?.facts ?? []} dialog={dialog} t={t} />
|
||||
<DescriptionBlock description={data?.description ?? null} dialog={dialog} t={t} />
|
||||
<DescriptionBlock
|
||||
description={data?.description ?? null}
|
||||
dialog={dialog}
|
||||
t={t}
|
||||
onAdopt={() => data?.description && onAdoptDescription(data.description.text)}
|
||||
adoptBlocked={hasDescription}
|
||||
adoptBlockedId={adoptBlockedId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pinned, not scrolled. Adopting the text is the only thing this column
|
||||
asks the reader to do, and it used to sit below a description long
|
||||
enough to push it out of view — the button was there, just never
|
||||
where anyone looked. */}
|
||||
{selection && state === 'ready' && !data?.disabled && data?.description && (
|
||||
<div className={dialog ? 'shrink-0 border-t border-edge-faint p-2.5' : 'shrink-0 border-t border-edge p-2.5'}>
|
||||
{dialog ? (
|
||||
// White like the dialog's other secondary buttons. While it is
|
||||
// blocked the line under it says why, so it needs no tooltip, and
|
||||
// that line is its description for assistive tech.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAdoptDescription(data.description!.text)}
|
||||
disabled={hasDescription}
|
||||
aria-describedby={hasDescription ? adoptBlockedId : undefined}
|
||||
className={WHITE_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
{t('places.details.adopt')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAdoptDescription(data.description!.text)}
|
||||
disabled={hasDescription}
|
||||
title={hasDescription ? t('places.details.adoptBlocked') : undefined}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-body font-medium bg-accent-subtle text-accent-on hover:bg-accent hover:text-accent-text disabled:opacity-50 disabled:hover:bg-accent-subtle disabled:hover:text-accent-on transition-colors"
|
||||
>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
{t('places.details.adopt')}
|
||||
</button>
|
||||
)}
|
||||
{hasDescription && (
|
||||
<p id={dialog ? adoptBlockedId : undefined} className="mt-1 text-center text-caption text-content-faint">{t('places.details.adoptBlocked')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -402,6 +370,8 @@ function descriptionSourceLabel(
|
||||
return 'Wikivoyage'
|
||||
case 'wikipedia':
|
||||
return 'Wikipedia'
|
||||
case 'community':
|
||||
return 'TREK Community'
|
||||
case 'website':
|
||||
try {
|
||||
return new URL(sourceUrl || '').hostname.replace(/^www\./, '')
|
||||
@@ -649,10 +619,16 @@ function DescriptionBlock({
|
||||
description,
|
||||
dialog = false,
|
||||
t,
|
||||
onAdopt,
|
||||
adoptBlocked,
|
||||
adoptBlockedId,
|
||||
}: {
|
||||
description: MapsPlaceEnrichmentResult['description']
|
||||
dialog?: boolean
|
||||
t: TranslationFn
|
||||
onAdopt: () => void
|
||||
adoptBlocked: boolean
|
||||
adoptBlockedId: string
|
||||
}): React.ReactElement | null {
|
||||
if (!description) return null
|
||||
|
||||
@@ -690,6 +666,23 @@ function DescriptionBlock({
|
||||
)}
|
||||
{description.license && (dialog ? <span>{description.license}</span> : ` · ${description.license}`)}
|
||||
</p>
|
||||
{/* Right under the text it copies, so the reader finds it where they
|
||||
just read. While the form already has a description, the line under
|
||||
it says why it is off and doubles as its description for assistive tech. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdopt}
|
||||
disabled={adoptBlocked}
|
||||
aria-describedby={adoptBlocked ? adoptBlockedId : undefined}
|
||||
className={dialog ? WHITE_BUTTON : 'w-full inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-body font-medium bg-accent-subtle text-accent-on hover:bg-accent hover:text-accent-text disabled:opacity-50 disabled:hover:bg-accent-subtle disabled:hover:text-accent-on transition-colors'}
|
||||
style={dialog ? fs(13, 'body') : undefined}
|
||||
>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
{t('places.details.adopt')}
|
||||
</button>
|
||||
{adoptBlocked && (
|
||||
<p id={adoptBlockedId} className="text-center text-caption text-content-faint">{t('places.details.adoptBlocked')}</p>
|
||||
)}
|
||||
</div>
|
||||
</Block>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Loader2, MapPin, Navigation, Plus, Search } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import type { Category, Place } from '../../types';
|
||||
import { safeHexColor } from '../../utils/safeColor';
|
||||
import type { StopKind } from '../Roadtrip/stopKinds';
|
||||
import { getCategoryIcon } from '../shared/categoryIcons';
|
||||
import { PillSelect } from '../shared/dialogParts';
|
||||
import { PILL } from '../shared/DialogShell';
|
||||
import { NavigationMenu } from '../shared/NavigationMenu';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { getNavigationTargets, openNavigationTarget } from './placeNavigation';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/** The round pill of the head band: a + or a map pin, an icon and nothing else. */
|
||||
const ROUND_PILL_BUTTON =
|
||||
'grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content';
|
||||
|
||||
interface NewCategoryRow {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
name: string;
|
||||
setName: (name: string) => void;
|
||||
create: () => Promise<void>;
|
||||
inputId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The head band's pill row. The category to pick, with a round + beside it that
|
||||
* turns the two into a field for a new one; for a stop on a drive, its kind in
|
||||
* place of both. Then where the picked place came from, and a spinner while a
|
||||
* pick is still being looked up.
|
||||
*/
|
||||
export function HeaderPills({
|
||||
kind,
|
||||
categories,
|
||||
categoryId,
|
||||
onCategory,
|
||||
newCategory,
|
||||
pickedSource,
|
||||
busy,
|
||||
navPlace,
|
||||
refresh,
|
||||
t,
|
||||
}: {
|
||||
kind?: StopKind;
|
||||
categories: Category[] | null;
|
||||
categoryId: string;
|
||||
onCategory: (id: string) => void;
|
||||
newCategory: NewCategoryRow;
|
||||
pickedSource: string | null;
|
||||
busy: boolean;
|
||||
/** The place as the form holds it, once it has a position: opens it in a map app (#2178). */
|
||||
navPlace: NavigablePlace | null;
|
||||
/** Newer details for a saved place, when a fresh lookup has them (PlaceRefreshPill). */
|
||||
refresh?: ReactNode;
|
||||
t: Translate;
|
||||
}) {
|
||||
// When the new-category field closes, the field or the button that closed it
|
||||
// goes with it, and the focus would drop out of the dialog. It comes back to
|
||||
// the + that opened the field, as a picker's focus comes back to its pill.
|
||||
const plusRef = useRef<HTMLButtonElement | null>(null);
|
||||
const wasOpen = useRef(newCategory.open);
|
||||
useEffect(() => {
|
||||
const closed = wasOpen.current && !newCategory.open;
|
||||
wasOpen.current = newCategory.open;
|
||||
if (!closed) return;
|
||||
const active = document.activeElement;
|
||||
if (!active || active === document.body || !active.isConnected) plusRef.current?.focus();
|
||||
}, [newCategory.open]);
|
||||
|
||||
const noCategory = {
|
||||
label: t('places.noCategory'),
|
||||
icon: <MapPin size={13} strokeWidth={2.2} className="text-content-faint" />,
|
||||
};
|
||||
const options = [
|
||||
{ value: '', ...noCategory },
|
||||
...(categories || []).map((c) => {
|
||||
const Icon = getCategoryIcon(c.icon);
|
||||
// A string like form.category_id, so the picked option hands back the same
|
||||
// kind of value the form already keeps.
|
||||
return {
|
||||
value: String(c.id),
|
||||
label: c.name,
|
||||
icon: <Icon size={13} strokeWidth={2.2} style={{ color: safeHexColor(c.color, '') || 'var(--text-muted)' }} />,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
let lead: ReactNode;
|
||||
if (kind) {
|
||||
lead = (
|
||||
<span className={PILL}>
|
||||
<kind.Icon size={13} strokeWidth={2.2} style={{ color: kind.color }} />
|
||||
{t(kind.labelKey)}
|
||||
</span>
|
||||
);
|
||||
} else if (newCategory.open) {
|
||||
lead = <NewCategoryField {...newCategory} t={t} />;
|
||||
} else {
|
||||
lead = (
|
||||
<>
|
||||
<PillSelect
|
||||
label={t('places.formCategory')}
|
||||
value={categoryId}
|
||||
onChange={onCategory}
|
||||
options={options}
|
||||
fallback={noCategory}
|
||||
/>
|
||||
<Tooltip label={t('places.newCategory')}>
|
||||
<button
|
||||
ref={plusRef}
|
||||
type="button"
|
||||
onClick={() => newCategory.setOpen(true)}
|
||||
aria-label={t('places.newCategory')}
|
||||
className={ROUND_PILL_BUTTON}
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{lead}
|
||||
{pickedSource && (
|
||||
<span className={PILL}>
|
||||
<Search size={12} strokeWidth={2.2} className="text-content-faint" aria-hidden="true" />
|
||||
{pickedSource}
|
||||
</span>
|
||||
)}
|
||||
{navPlace && <OpenInMapsPill place={navPlace} t={t} />}
|
||||
{refresh}
|
||||
{busy && (
|
||||
<span className={PILL} role="status" aria-label={t('places.loadingDetails')}>
|
||||
<Loader2 size={13} className="animate-spin text-content-faint" aria-hidden="true" />
|
||||
<span className="font-medium text-content-muted">{t('places.loadingDetails')}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type NavigablePlace = Pick<Place, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>;
|
||||
|
||||
/**
|
||||
* A look at the picked place in a real map before it is saved (#2178): its reviews, its
|
||||
* photos, whether it is the right branch. The same apps the inspector offers, one tap
|
||||
* straight away when only one applies.
|
||||
*/
|
||||
function OpenInMapsPill({ place, t }: { place: NavigablePlace; t: Translate }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLSpanElement>(null);
|
||||
const targets = getNavigationTargets(place);
|
||||
if (targets.length === 0) return null;
|
||||
return (
|
||||
<span ref={anchorRef} className="inline-flex">
|
||||
<Tooltip label={t('places.openInMaps')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('places.openInMaps')}
|
||||
aria-haspopup={targets.length > 1 ? 'menu' : undefined}
|
||||
aria-expanded={targets.length > 1 ? open : undefined}
|
||||
onClick={() => {
|
||||
if (targets.length === 1) openNavigationTarget(targets[0]);
|
||||
else setOpen((o) => !o);
|
||||
}}
|
||||
className={ROUND_PILL_BUTTON}
|
||||
>
|
||||
<Navigation size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{open && <NavigationMenu targets={targets} anchor={anchorRef.current} onClose={() => setOpen(false)} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The new category's name, typed where the category pill was, with its OK and Cancel beside it. */
|
||||
function NewCategoryField({ inputId, name, setName, create, setOpen, t }: NewCategoryRow & { t: Translate }) {
|
||||
return (
|
||||
// One parent for the field and both of its buttons.
|
||||
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
id={inputId}
|
||||
type="text"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter confirms the name, like OK; it never reaches the place's own save.
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void create();
|
||||
}
|
||||
// Escape closes the field, like Cancel, and only the field: marked
|
||||
// handled, the dialog around it leaves it alone and keeps what is typed.
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
aria-label={t('places.newCategory')}
|
||||
placeholder={t('places.categoryNamePlaceholder')}
|
||||
className="w-48 min-w-0 rounded-full bg-surface-card px-3 py-1 text-content shadow-sm outline-none ring-1 ring-edge-faint placeholder:text-content-faint focus:ring-2 focus:ring-[color:var(--text-primary)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void create()}
|
||||
className="rounded-full bg-accent px-3 py-1 font-semibold text-accent-text hover:opacity-90"
|
||||
>
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)} className={`${PILL} hover:opacity-80`}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Assignment, Place, RoadtripStopType } from '@trek/shared'
|
||||
import type { Assignment, Place, PlaceFieldOrigins, RoadtripStopType } from '@trek/shared'
|
||||
import { originsAfterPick, resultOrigin } from './placeFieldOrigins'
|
||||
import { pendingImagePayload, type PendingImagePayload, type PendingPlaceImage } from './placeImageChoice'
|
||||
import type { PlaceDetailsSelection } from './PlaceDetailsColumn'
|
||||
|
||||
@@ -43,6 +44,9 @@ export interface PlaceFormData {
|
||||
// suggests how long that kind of pause usually takes, and the full form should not
|
||||
// throw the suggestion away on the way through. Editing a stay is the rail's job.
|
||||
duration_minutes?: number
|
||||
// Which shareable fields the user typed and which a pick or the details column filled
|
||||
// (placeFieldOrigins.ts). Rides along to the save as `fieldOrigins`; never shown.
|
||||
fieldOrigins?: PlaceFieldOrigins
|
||||
}
|
||||
|
||||
export function isGoogleMapsUrl(input: string): boolean {
|
||||
@@ -237,6 +241,8 @@ export function mergeResult(
|
||||
autoFilled: Set<ResultField>,
|
||||
): PlaceFormData {
|
||||
const next = { ...prev } as PlaceFormData & Record<string, string | undefined>
|
||||
const written: string[] = []
|
||||
const cleared: string[] = []
|
||||
|
||||
for (const field of RESULT_FIELDS) {
|
||||
const raw = result[field]
|
||||
@@ -245,15 +251,19 @@ export function mergeResult(
|
||||
if (value) {
|
||||
next[field] = value
|
||||
autoFilled.add(field)
|
||||
written.push(field)
|
||||
} else if (autoFilled.has(field)) {
|
||||
// Belonged to the place that is no longer selected.
|
||||
next[field] = ''
|
||||
autoFilled.delete(field)
|
||||
cleared.push(field)
|
||||
}
|
||||
// Otherwise the user put it there; leave it alone.
|
||||
}
|
||||
|
||||
return next
|
||||
// What the pick wrote is the provider's, not the user's, wherever it ends up.
|
||||
const merged: PlaceFormData = next
|
||||
return { ...merged, fieldOrigins: originsAfterPick(prev.fieldOrigins, written, cleared, resultOrigin(result)) }
|
||||
}
|
||||
|
||||
// #1152: a manually-added place is treated as a likely duplicate of an existing
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, useId, type ReactNode } from 'react'
|
||||
import type { RoadtripStopType, TourListItem } from '@trek/shared'
|
||||
import type { PlaceClientOrigin, RoadtripStopType, TourListItem } from '@trek/shared'
|
||||
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
@@ -12,7 +12,7 @@ import { PendingFiles, PlaceImageSection } from './PlaceFormMedia'
|
||||
import { usePlaceImageField } from './usePlaceImageField'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { Search, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react'
|
||||
import { Search, RotateCcw, LocateFixed, PencilLine } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { PlaceContactFields } from './PlaceContactFields'
|
||||
import { weekFromPeriods } from './placeHours'
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
import { useNewCategory, usePlaceForm } from './usePlaceForm'
|
||||
import { usePlaceSearch } from './usePlaceSearch'
|
||||
import { guessCategoryId } from '@trek/shared'
|
||||
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
|
||||
import { NavigationMenu } from '../shared/NavigationMenu'
|
||||
import { offersGoogleRetry } from '../../utils/placeSource'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
@@ -36,14 +34,17 @@ import { NumericInput } from '../shared/NumericInput'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import ServiceStopSection from '../Roadtrip/ServiceStopSection'
|
||||
import { DEFAULT_SERVICE_KIND, serviceStopChoice, type ServiceStopMode } from '../Roadtrip/manualStop'
|
||||
import { STOP_KIND_BY_KEY, type StopKind } from '../Roadtrip/stopKinds'
|
||||
import { STOP_KIND_BY_KEY } from '../Roadtrip/stopKinds'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import {
|
||||
DialogShell, DialogHeader, DialogTile, DialogSection, DialogFooter, FooterSpacer, DialogButton, NEUTRAL_TINT, PILL, fs,
|
||||
DialogShell, DialogHeader, DialogTile, DialogSection, DialogFooter, FooterSpacer, DialogButton, NEUTRAL_TINT, fs,
|
||||
} from '../shared/DialogShell'
|
||||
import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts'
|
||||
import { EditorField, GRID_2, INPUT, LABEL, PANEL, TEXTAREA } from '../shared/dialogParts'
|
||||
import { SoftPill, tintOf } from './planParts'
|
||||
import { TimeSection } from './PlaceFormTimeSection'
|
||||
import { HeaderPills } from './PlaceFormHeaderPills'
|
||||
import { PlaceRefreshPill } from './PlaceRefreshPill'
|
||||
import { usePlaceRefresh } from './usePlaceRefresh'
|
||||
|
||||
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
|
||||
// category_id is normalised, plus any files chosen before the place existed.
|
||||
@@ -278,6 +279,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setPickedSource(null)
|
||||
},
|
||||
})
|
||||
const refresh = usePlaceRefresh({ place, form, language: search.language })
|
||||
|
||||
/**
|
||||
* What a stop on a drive might be a second copy of, said while the form is being filled.
|
||||
@@ -292,9 +294,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
return findDuplicatePlace(form, places, { byName: false, byOsmId: true })?.name ?? null
|
||||
}, [serviceStop, place, form, places])
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
const handleChange = (field: string, value: string, origin?: PlaceClientOrigin) => {
|
||||
if (field === 'category_id') categoryGuessedRef.current = false
|
||||
changeField(field, value)
|
||||
changeField(field, value, origin)
|
||||
}
|
||||
|
||||
// What is around the pin the form holds (#976).
|
||||
@@ -425,6 +427,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
t,
|
||||
language: search.language,
|
||||
locale,
|
||||
refresh,
|
||||
timeFormat,
|
||||
googleAnswers: search.googleAnswers,
|
||||
placesEnrichEnabled,
|
||||
@@ -483,10 +486,6 @@ const SIDE_BUTTON = 'grid w-[38px] flex-none place-items-center rounded-[10px] t
|
||||
* for them; this gives them the eyebrow every other field here has.
|
||||
*/
|
||||
const SERVICE_STOP_LABELS = '[&_label]:mb-[5px] [&_label]:block [&_label]:font-geist [&_label]:text-[length:calc(9.5px*var(--fs-scale-caption,1))] [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-[.08em] [&_label]:text-content-faint'
|
||||
/** A round white button in the head band's pill row, as tall as the pills beside it. */
|
||||
const ROUND_PILL_BUTTON = 'grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content'
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
const S = usePlaceFormModal(props)
|
||||
@@ -570,6 +569,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
inputId: `${fieldId}-new-category`,
|
||||
}}
|
||||
pickedSource={S.pickedSource}
|
||||
refresh={<PlaceRefreshPill {...S.refresh} onApply={handleChange} locale={S.locale} t={t} />}
|
||||
busy={isSearchingMaps}
|
||||
navPlace={form.lat && form.lng ? {
|
||||
name: form.name, address: form.address, lat: Number(form.lat), lng: Number(form.lng),
|
||||
@@ -917,7 +917,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
email={form.email ?? ''}
|
||||
openingHours={form.opening_hours ?? ''}
|
||||
suggestedHours={S.detailsHours}
|
||||
onChange={(field, value) => handleChange(field, value)}
|
||||
onChange={(field, value, origin) => handleChange(field, value, origin)}
|
||||
/>
|
||||
|
||||
{/* Costs — create / view the expense linked to this place (#1298).
|
||||
@@ -947,154 +947,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface NewCategoryRow {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
name: string
|
||||
setName: (name: string) => void
|
||||
create: () => Promise<void>
|
||||
inputId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The head band's pill row. The category to pick, with a round + beside it that
|
||||
* turns the two into a field for a new one; for a stop on a drive, its kind in
|
||||
* place of both. Then where the picked place came from, and a spinner while a
|
||||
* pick is still being looked up.
|
||||
*/
|
||||
function HeaderPills({ kind, categories, categoryId, onCategory, newCategory, pickedSource, busy, navPlace, t }: {
|
||||
kind?: StopKind
|
||||
categories: Category[] | null
|
||||
categoryId: string
|
||||
onCategory: (id: string) => void
|
||||
newCategory: NewCategoryRow
|
||||
pickedSource: string | null
|
||||
busy: boolean
|
||||
/** The place as the form holds it, once it has a position: opens it in a map app (#2178). */
|
||||
navPlace: NavigablePlace | null
|
||||
t: Translate
|
||||
}) {
|
||||
// When the new-category field closes, the field or the button that closed it
|
||||
// goes with it, and the focus would drop out of the dialog. It comes back to
|
||||
// the + that opened the field, as a picker's focus comes back to its pill.
|
||||
const plusRef = useRef<HTMLButtonElement | null>(null)
|
||||
const wasOpen = useRef(newCategory.open)
|
||||
useEffect(() => {
|
||||
const closed = wasOpen.current && !newCategory.open
|
||||
wasOpen.current = newCategory.open
|
||||
if (!closed) return
|
||||
const active = document.activeElement
|
||||
if (!active || active === document.body || !active.isConnected) plusRef.current?.focus()
|
||||
}, [newCategory.open])
|
||||
|
||||
const noCategory = { label: t('places.noCategory'), icon: <MapPin size={13} strokeWidth={2.2} className="text-content-faint" /> }
|
||||
const options = [
|
||||
{ value: '', ...noCategory },
|
||||
...(categories || []).map(c => {
|
||||
const Icon = getCategoryIcon(c.icon)
|
||||
// A string like form.category_id, so the picked option hands back the same
|
||||
// kind of value the form already keeps.
|
||||
return { value: String(c.id), label: c.name, icon: <Icon size={13} strokeWidth={2.2} style={{ color: safeHexColor(c.color, '') || 'var(--text-muted)' }} /> }
|
||||
}),
|
||||
]
|
||||
|
||||
let lead: ReactNode
|
||||
if (kind) {
|
||||
lead = <span className={PILL}><kind.Icon size={13} strokeWidth={2.2} style={{ color: kind.color }} />{t(kind.labelKey)}</span>
|
||||
} else if (newCategory.open) {
|
||||
lead = <NewCategoryField {...newCategory} t={t} />
|
||||
} else {
|
||||
lead = (
|
||||
<>
|
||||
<PillSelect label={t('places.formCategory')} value={categoryId} onChange={onCategory} options={options} fallback={noCategory} />
|
||||
<Tooltip label={t('places.newCategory')}>
|
||||
<button ref={plusRef} type="button" onClick={() => newCategory.setOpen(true)} aria-label={t('places.newCategory')} className={ROUND_PILL_BUTTON}>
|
||||
<Plus size={13} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{lead}
|
||||
{pickedSource && (
|
||||
<span className={PILL}>
|
||||
<Search size={12} strokeWidth={2.2} className="text-content-faint" aria-hidden="true" />
|
||||
{pickedSource}
|
||||
</span>
|
||||
)}
|
||||
{navPlace && <OpenInMapsPill place={navPlace} t={t} />}
|
||||
{busy && (
|
||||
<span className={PILL} role="status" aria-label={t('places.loadingDetails')}>
|
||||
<Loader2 size={13} className="animate-spin text-content-faint" aria-hidden="true" />
|
||||
<span className="font-medium text-content-muted">{t('places.loadingDetails')}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type NavigablePlace = Pick<Place, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
|
||||
|
||||
/**
|
||||
* A look at the picked place in a real map before it is saved (#2178): its reviews, its
|
||||
* photos, whether it is the right branch. The same apps the inspector offers, one tap
|
||||
* straight away when only one applies.
|
||||
*/
|
||||
function OpenInMapsPill({ place, t }: { place: NavigablePlace; t: Translate }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const anchorRef = useRef<HTMLSpanElement>(null)
|
||||
const targets = getNavigationTargets(place)
|
||||
if (targets.length === 0) return null
|
||||
return (
|
||||
<span ref={anchorRef} className="inline-flex">
|
||||
<Tooltip label={t('places.openInMaps')}>
|
||||
<button type="button" aria-label={t('places.openInMaps')} aria-haspopup={targets.length > 1 ? 'menu' : undefined}
|
||||
aria-expanded={targets.length > 1 ? open : undefined}
|
||||
onClick={() => { if (targets.length === 1) openNavigationTarget(targets[0]); else setOpen(o => !o) }}
|
||||
className={ROUND_PILL_BUTTON}>
|
||||
<Navigation size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{open && <NavigationMenu targets={targets} anchor={anchorRef.current} onClose={() => setOpen(false)} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The new category's name, typed where the category pill was, with its OK and Cancel beside it. */
|
||||
function NewCategoryField({ inputId, name, setName, create, setOpen, t }: NewCategoryRow & { t: Translate }) {
|
||||
return (
|
||||
// One parent for the field and both of its buttons.
|
||||
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
id={inputId}
|
||||
type="text"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
// Enter confirms the name, like OK; it never reaches the place's own save.
|
||||
if (e.key === 'Enter') { e.preventDefault(); void create() }
|
||||
// Escape closes the field, like Cancel, and only the field: marked
|
||||
// handled, the dialog around it leaves it alone and keeps what is typed.
|
||||
if (e.key === 'Escape') { e.preventDefault(); setOpen(false) }
|
||||
}}
|
||||
aria-label={t('places.newCategory')}
|
||||
placeholder={t('places.categoryNamePlaceholder')}
|
||||
className="w-48 min-w-0 rounded-full bg-surface-card px-3 py-1 text-content shadow-sm outline-none ring-1 ring-edge-faint placeholder:text-content-faint focus:ring-2 focus:ring-[color:var(--text-primary)]"
|
||||
/>
|
||||
<button type="button" onClick={() => void create()} className="rounded-full bg-accent px-3 py-1 font-semibold text-accent-text hover:opacity-90">
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)} className={`${PILL} hover:opacity-80`}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** A Markdown field: its label, the formatting bar on the same line, the text under both. */
|
||||
function MarkdownField({ id, label, value, onChange, placeholder, maxLength }: {
|
||||
id: string
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// FE-PLANNER-REFRESHPILL-001 to FE-PLANNER-REFRESHPILL-003
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, within } from '../../../tests/helpers/render';
|
||||
import { PlaceRefreshPill } from './PlaceRefreshPill';
|
||||
import type { RefreshCandidate } from './placeRefresh';
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
const CANDIDATES: RefreshCandidate[] = [
|
||||
{ field: 'phone', current: '', next: '+49 381 87398063', preselected: true },
|
||||
{ field: 'website', current: 'https://old.example', next: 'https://www.thao-vinh.de/', preselected: false },
|
||||
];
|
||||
|
||||
describe('PlaceRefreshPill', () => {
|
||||
it('FE-PLANNER-REFRESHPILL-001: nothing shows while the details match the place', () => {
|
||||
const { container } = render(<PlaceRefreshPill candidates={[]} origin="osm" onApply={vi.fn()} t={t} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-REFRESHPILL-002: the pill counts the differences; empty fields are ticked, filled ones say they replace', async () => {
|
||||
render(<PlaceRefreshPill candidates={CANDIDATES} origin="osm" onApply={vi.fn()} t={t} />);
|
||||
const pill = screen.getByRole('button', { name: /places\.refresh\.pill/ });
|
||||
expect(within(pill).getByText('2')).toBeInTheDocument();
|
||||
await userEvent.click(pill);
|
||||
expect(screen.getByRole('button', { name: 'places.formPhone' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'places.formWebsite' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByText('places.refresh.replaces')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-REFRESHPILL-003: confirming takes over only the ticked fields, with the provider as origin', async () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PlaceRefreshPill candidates={CANDIDATES} origin="osm" onApply={onApply} t={t} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /places\.refresh\.pill/ }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'places.refresh.apply' }));
|
||||
expect(onApply).toHaveBeenCalledTimes(1);
|
||||
expect(onApply).toHaveBeenCalledWith('phone', '+49 381 87398063', 'osm');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { PlaceClientOrigin } from '@trek/shared';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import { fs, PILL } from '../shared/DialogShell';
|
||||
import { hoursLines } from './placeHours';
|
||||
import type { RefreshCandidate, RefreshField } from './placeRefresh';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
const LABEL: Record<RefreshField, string> = {
|
||||
description: 'places.formDescription',
|
||||
website: 'places.formWebsite',
|
||||
phone: 'places.formPhone',
|
||||
email: 'places.formEmail',
|
||||
opening_hours: 'inspector.openingHours',
|
||||
};
|
||||
|
||||
/** A value as the list shows it: hours as one line per day, the rest as stored. */
|
||||
function shown(field: RefreshField, value: string, locale: string | undefined, t: Translate): string[] {
|
||||
if (!value) return [t('places.refresh.empty')];
|
||||
if (field === 'opening_hours') return hoursLines(value, locale, t('places.hoursClosed')) ?? [value];
|
||||
return [value];
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved place's details that TREK knows better now: a pill in the head band
|
||||
* when a fresh lookup differs from what the place holds, and a list to pick
|
||||
* from. An empty field is ticked, a filled one is the person's to decide, and
|
||||
* nothing is taken over before they confirm. The form saves as usual after.
|
||||
*/
|
||||
export function PlaceRefreshPill({
|
||||
candidates,
|
||||
origin,
|
||||
onApply,
|
||||
locale,
|
||||
t,
|
||||
}: {
|
||||
candidates: RefreshCandidate[];
|
||||
origin: PlaceClientOrigin;
|
||||
/** Sets one field in the form, with the provider as its origin. */
|
||||
onApply: (field: RefreshField, value: string, origin: PlaceClientOrigin) => void;
|
||||
locale?: string;
|
||||
t: Translate;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [picked, setPicked] = useState<ReadonlySet<RefreshField>>(new Set());
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const show = () => {
|
||||
setPicked(new Set(candidates.filter((c) => c.preselected).map((c) => c.field)));
|
||||
setOpen(true);
|
||||
};
|
||||
const toggle = (field: RefreshField) =>
|
||||
setPicked((now) => {
|
||||
const next = new Set(now);
|
||||
if (next.has(field)) next.delete(field);
|
||||
else next.add(field);
|
||||
return next;
|
||||
});
|
||||
const apply = () => {
|
||||
for (const c of candidates) if (picked.has(c.field)) onApply(c.field, c.next, origin);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={show} className={`${PILL} hover:opacity-80`}>
|
||||
<RefreshCw size={12} strokeWidth={2.2} className="text-content-faint" aria-hidden="true" />
|
||||
{t('places.refresh.pill')}
|
||||
<span
|
||||
className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-accent px-1 font-semibold tabular-nums text-accent-text"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{candidates.length}
|
||||
</span>
|
||||
</button>
|
||||
<ConfirmDialog
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onConfirm={apply}
|
||||
title={t('places.refresh.title')}
|
||||
message={t('places.refresh.intro')}
|
||||
confirmLabel={t('places.refresh.apply')}
|
||||
danger={false}
|
||||
>
|
||||
<ul className="m-0 flex list-none flex-col gap-2 p-0">
|
||||
{candidates.map((c) => (
|
||||
<li
|
||||
key={c.field}
|
||||
className="flex items-start gap-3 rounded-[12px] border border-edge-faint bg-surface-secondary px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{t(LABEL[c.field])}
|
||||
</p>
|
||||
<div className="mt-1 grid grid-cols-2 gap-2" style={fs(11.5)}>
|
||||
<div className="min-w-0 text-content-faint">
|
||||
<p className="m-0 font-semibold uppercase tracking-[.06em]" style={fs(9.5)}>
|
||||
{t('places.refresh.now')}
|
||||
</p>
|
||||
{shown(c.field, c.current, locale, t).map((line) => (
|
||||
<p key={line} className={c.field === 'description' ? 'm-0 line-clamp-3' : 'm-0 truncate'}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0 text-content">
|
||||
<p className="m-0 font-semibold uppercase tracking-[.06em] text-content-faint" style={fs(9.5)}>
|
||||
{t('places.refresh.new')}
|
||||
</p>
|
||||
{shown(c.field, c.next, locale, t).map((line) => (
|
||||
<p key={line} className={c.field === 'description' ? 'm-0 line-clamp-3' : 'm-0 truncate'}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!c.preselected && (
|
||||
<p className="m-0 mt-1 text-warning" style={fs(11)}>
|
||||
{t('places.refresh.replaces')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ToggleSwitch on={picked.has(c.field)} label={t(LABEL[c.field])} onToggle={() => toggle(c.field)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -91,6 +91,27 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('TransitSearchPanel', () => {
|
||||
it.each(['google', 'transitous'] as const)('updates attribution when a later search returns %s fallback routes', async (provider) => {
|
||||
const user = userEvent.setup()
|
||||
const onAdd = vi.fn().mockResolvedValue({})
|
||||
const previous = provider === 'google' ? 'transitous' : 'google'
|
||||
transitApiMock.plan
|
||||
.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: previous })
|
||||
.mockResolvedValueOnce({ itineraries: [ITINERARY], provider })
|
||||
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
|
||||
await pickFromAndTo(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
await screen.findByText(previous === 'google' ? 'Google Maps' : 'Transitous')
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
expect(await screen.findByText(provider === 'google' ? 'Google Maps' : 'Transitous')).toBeInTheDocument()
|
||||
expect(screen.queryByText(previous === 'google' ? 'Google Maps' : 'Transitous')).not.toBeInTheDocument()
|
||||
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).toHaveBeenCalledWith(expect.objectContaining({
|
||||
metadata: expect.objectContaining({ transit: expect.objectContaining({ provider }) }),
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-001: renders from/to pickers, modes and preferences', () => {
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-ENRICHCACHE-001 to FE-PLANNER-ENRICHCACHE-005: the session cache the editor's
|
||||
// FE-PLANNER-ENRICHCACHE-001 to FE-PLANNER-ENRICHCACHE-007: the session cache the editor's
|
||||
// details column and the view's photo gallery share, so a place is asked about once.
|
||||
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -25,15 +25,22 @@ afterEach(() => {
|
||||
|
||||
describe('placeEnrichment', () => {
|
||||
it('FE-PLANNER-ENRICHCACHE-001: the key names the version, the place (or its coordinates) and the language', () => {
|
||||
expect(cacheKeyFor(SELECTION, 'de')).toBe('enrich_v4_way:1_de');
|
||||
expect(cacheKeyFor({ lat: 1.5, lng: 2, name: 'Pin' }, 'en')).toBe('enrich_v4_coords:1.5:2_en');
|
||||
expect(cacheKeyFor(SELECTION, 'de')).toBe('enrich_v5_way:1_de');
|
||||
expect(cacheKeyFor({ lat: 1.5, lng: 2, name: 'Pin' }, 'en')).toBe('enrich_v5_coords:1.5:2_en');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-ENRICHCACHE-006: a place of the index served with another community version is another answer', () => {
|
||||
const gers = { placeId: 'gers:abc', lat: 1, lng: 2, name: 'Cafe', details: { community_version: 3 } };
|
||||
expect(cacheKeyFor(gers, 'de')).toBe('enrich_v5_gers:abc_c3_de');
|
||||
expect(cacheKeyFor({ ...gers, details: { community_version: 4 } }, 'de')).not.toBe(cacheKeyFor(gers, 'de'));
|
||||
expect(cacheKeyFor({ ...gers, details: {} }, 'de')).toBe('enrich_v5_gers:abc_de');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-ENRICHCACHE-002: nothing is cached before it is stored, then both caches hold it', () => {
|
||||
expect(readCachedEnrichment(SELECTION, 'de')).toBeUndefined();
|
||||
storeEnrichment(SELECTION, 'de', RESULT);
|
||||
expect(readCachedEnrichment(SELECTION, 'de')).toBe(RESULT);
|
||||
expect(JSON.parse(sessionStorage.getItem('enrich_v4_way:1_de') ?? '')).toEqual(RESULT);
|
||||
expect(JSON.parse(sessionStorage.getItem('enrich_v5_way:1_de') ?? '').value).toEqual(RESULT);
|
||||
// Another language is another answer.
|
||||
expect(readCachedEnrichment(SELECTION, 'en')).toBeUndefined();
|
||||
});
|
||||
@@ -59,4 +66,16 @@ describe('placeEnrichment', () => {
|
||||
expect(() => storeEnrichment(SELECTION, 'de', RESULT)).not.toThrow();
|
||||
expect(readCachedEnrichment(SELECTION, 'de')).toBe(RESULT);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-ENRICHCACHE-007: an answer is reused for ten minutes, then asked again', () => {
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000);
|
||||
storeEnrichment(SELECTION, 'de', RESULT);
|
||||
now.mockReturnValue(1_000_000 + 9 * 60 * 1000);
|
||||
expect(readCachedEnrichment(SELECTION, 'de')).toBe(RESULT);
|
||||
now.mockReturnValue(1_000_000 + 10 * 60 * 1000);
|
||||
expect(readCachedEnrichment(SELECTION, 'de')).toBeUndefined();
|
||||
// Gone from the session too: a reload does not bring it back.
|
||||
__clearEnrichmentCacheForTests();
|
||||
expect(readCachedEnrichment(SELECTION, 'de')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,23 +19,37 @@ export interface PlaceDetailsSelection {
|
||||
* pay for the provider fan-out twice, and the gallery must reuse the pictures
|
||||
* the editor already fetched for the same place.
|
||||
*/
|
||||
const enrichmentCache = new Map<string, MapsPlaceEnrichmentResult>();
|
||||
const enrichmentCache = new Map<string, Stored>();
|
||||
|
||||
/** A kept answer and when it was fetched. */
|
||||
interface Stored {
|
||||
at: number;
|
||||
value: MapsPlaceEnrichmentResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a kept answer is reused. A saved place reopened for editing carries
|
||||
* no community version, so its key never changes: without an end, a picture
|
||||
* another installation contributed would stay out of this tab until it closed.
|
||||
* The server keeps an empty answer for the same ten minutes.
|
||||
*/
|
||||
const KEEP_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Test seam: the module-level cache otherwise leaks between cases. */
|
||||
export function __clearEnrichmentCacheForTests(): void {
|
||||
enrichmentCache.clear();
|
||||
}
|
||||
|
||||
function readSession(key: string): MapsPlaceEnrichmentResult | undefined {
|
||||
function readSession(key: string): Stored | undefined {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as MapsPlaceEnrichmentResult) : undefined;
|
||||
return raw ? (JSON.parse(raw) as Stored) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSession(key: string, value: MapsPlaceEnrichmentResult): void {
|
||||
function writeSession(key: string, value: Stored): void {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
@@ -48,11 +62,15 @@ function writeSession(key: string, value: MapsPlaceEnrichmentResult): void {
|
||||
* without it the tab that was open while the fix shipped keeps replaying the
|
||||
* answer the fix was about — and reports it as still broken.
|
||||
*/
|
||||
const ENRICH_CACHE_V = 4;
|
||||
const ENRICH_CACHE_V = 5;
|
||||
|
||||
export function cacheKeyFor(selection: PlaceDetailsSelection, language: string): string {
|
||||
const id = selection.placeId || `coords:${selection.lat}:${selection.lng}`;
|
||||
return `enrich_v${ENRICH_CACHE_V}_${id}_${language}`;
|
||||
// A place of the TREK index names the version of the community values it is
|
||||
// served with; a new version is a new answer, not the one this tab kept.
|
||||
const version = selection.details?.community_version;
|
||||
const community = typeof version === 'number' ? `_c${version}` : '';
|
||||
return `enrich_v${ENRICH_CACHE_V}_${id}${community}_${language}`;
|
||||
}
|
||||
|
||||
/** The answer if it was already fetched this session, else undefined. */
|
||||
@@ -62,8 +80,12 @@ export function readCachedEnrichment(
|
||||
): MapsPlaceEnrichmentResult | undefined {
|
||||
const key = cacheKeyFor(selection, language);
|
||||
const cached = enrichmentCache.get(key) ?? readSession(key);
|
||||
if (cached) enrichmentCache.set(key, cached);
|
||||
return cached;
|
||||
if (!cached || typeof cached.at !== 'number' || Date.now() - cached.at >= KEEP_MS) {
|
||||
enrichmentCache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
enrichmentCache.set(key, cached);
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
export function storeEnrichment(
|
||||
@@ -72,6 +94,7 @@ export function storeEnrichment(
|
||||
value: MapsPlaceEnrichmentResult
|
||||
): void {
|
||||
const key = cacheKeyFor(selection, language);
|
||||
enrichmentCache.set(key, value);
|
||||
writeSession(key, value);
|
||||
const stored = { at: Date.now(), value };
|
||||
enrichmentCache.set(key, stored);
|
||||
writeSession(key, stored);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// FE-PLANNER-ORIGINS-001 to FE-PLANNER-ORIGINS-005: where the place form's shareable fields
|
||||
// got their values, as the save reports it in fieldOrigins.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_FORM, mergeResult, type ResultField } from './PlaceFormModal.helpers';
|
||||
import { originsAfterPick, resultOrigin, withOrigin } from './placeFieldOrigins';
|
||||
|
||||
describe('placeFieldOrigins', () => {
|
||||
it('FE-PLANNER-ORIGINS-001: names the provider a result came from by the ids it carries', () => {
|
||||
expect(resultOrigin({ osm_id: 'gers:abc' })).toBe('trek');
|
||||
expect(resultOrigin({ source: 'trek-places' })).toBe('trek');
|
||||
expect(resultOrigin({ amap_poi_id: 'amap:B1' })).toBe('amap');
|
||||
expect(resultOrigin({ google_place_id: 'ChIJ1', osm_id: 'node:1' })).toBe('google');
|
||||
expect(resultOrigin({ osm_id: 'way:2' })).toBe('osm');
|
||||
expect(resultOrigin({ name: 'Pin' })).toBe('enrichment');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-ORIGINS-002: sets and forgets one shareable field, the picture under its own name', () => {
|
||||
expect(withOrigin(undefined, 'website', 'user')).toEqual({ website: 'user' });
|
||||
expect(withOrigin({ website: 'user' }, 'image_url', 'enrichment')).toEqual({
|
||||
website: 'user',
|
||||
image: 'enrichment',
|
||||
});
|
||||
expect(withOrigin({ website: 'user', image: 'enrichment' }, 'image_url', null)).toEqual({ website: 'user' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-ORIGINS-003: a field that is not shared leaves the origins as they were', () => {
|
||||
const origins = { phone: 'user' as const };
|
||||
expect(withOrigin(origins, 'name', 'user')).toBe(origins);
|
||||
expect(withOrigin(undefined, 'notes', 'user')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-ORIGINS-004: a pick owns what it wrote and drops what it cleared', () => {
|
||||
expect(originsAfterPick({ website: 'user', phone: 'google' }, ['name', 'website'], ['phone'], 'osm')).toEqual({
|
||||
website: 'osm',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-ORIGINS-005: mergeResult records the pick, and a typed field it leaves alone keeps its origin', () => {
|
||||
const autoFilled = new Set<ResultField>(['phone']);
|
||||
const prev = {
|
||||
...DEFAULT_FORM,
|
||||
phone: '+49 1',
|
||||
email: 'me@x.de',
|
||||
fieldOrigins: { phone: 'trek' as const, email: 'user' as const },
|
||||
};
|
||||
const next = mergeResult(prev, { name: 'Cafe', website: 'https://cafe.example', osm_id: 'node:4' }, autoFilled);
|
||||
expect(next.website).toBe('https://cafe.example');
|
||||
expect(next.phone).toBe('');
|
||||
expect(next.fieldOrigins).toEqual({ email: 'user', website: 'osm' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { PlaceClientOrigin, PlaceContribField, PlaceFieldOrigins } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Where the place form's shareable fields got their values, for the
|
||||
* `fieldOrigins` a create or update carries: `user` for what was typed, the
|
||||
* provider for what a search pick, a map POI or the details column filled in.
|
||||
* The server keeps the record of it (and decides for MCP, plugins and imports),
|
||||
* so this only has to say what the form saw; nothing here is shown.
|
||||
*/
|
||||
|
||||
const SHAREABLE: ReadonlySet<string> = new Set<PlaceContribField>([
|
||||
'website',
|
||||
'phone',
|
||||
'email',
|
||||
'opening_hours',
|
||||
'description',
|
||||
'image',
|
||||
]);
|
||||
|
||||
/** The form field a shareable detail lives in (`image_url` is the picture). */
|
||||
function contribFieldOf(formField: string): PlaceContribField | null {
|
||||
const field = formField === 'image_url' ? 'image' : formField;
|
||||
return SHAREABLE.has(field) ? (field as PlaceContribField) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which provider a picked search result or a prefilled POI came from, read off
|
||||
* the ids it carries: the TREK index, Amap, Google, OpenStreetMap.
|
||||
*/
|
||||
export function resultOrigin(result: Record<string, unknown>): PlaceClientOrigin {
|
||||
const osmId = typeof result.osm_id === 'string' ? result.osm_id : '';
|
||||
if (result.source === 'trek-places' || osmId.startsWith('gers:')) return 'trek';
|
||||
if (typeof result.amap_poi_id === 'string' && result.amap_poi_id) return 'amap';
|
||||
if (typeof result.google_place_id === 'string' && result.google_place_id) return 'google';
|
||||
if (osmId) return 'osm';
|
||||
return 'enrichment';
|
||||
}
|
||||
|
||||
/**
|
||||
* The origins with one form field set (or, for `null`, forgotten: a value that
|
||||
* belonged to the last pick and was cleared). A field that is not shareable
|
||||
* leaves them as they are.
|
||||
*/
|
||||
export function withOrigin(
|
||||
origins: PlaceFieldOrigins | undefined,
|
||||
formField: string,
|
||||
origin: PlaceClientOrigin | null
|
||||
): PlaceFieldOrigins | undefined {
|
||||
const field = contribFieldOf(formField);
|
||||
if (!field) return origins;
|
||||
const next: PlaceFieldOrigins = { ...origins };
|
||||
if (origin) next[field] = origin;
|
||||
else delete next[field];
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The origins after a search pick wrote or cleared these form fields. */
|
||||
export function originsAfterPick(
|
||||
origins: PlaceFieldOrigins | undefined,
|
||||
written: readonly string[],
|
||||
cleared: readonly string[],
|
||||
origin: PlaceClientOrigin
|
||||
): PlaceFieldOrigins | undefined {
|
||||
let next = origins;
|
||||
for (const field of written) next = withOrigin(next, field, origin);
|
||||
for (const field of cleared) next = withOrigin(next, field, null);
|
||||
return next;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ describe('placePhotoCredit', () => {
|
||||
expect(sourceLabelFor('google')).toBe('Google');
|
||||
expect(sourceLabelFor('wikipedia')).toBe('Wikipedia');
|
||||
expect(sourceLabelFor('wikimedia')).toBe('Wikimedia Commons');
|
||||
expect(sourceLabelFor('community')).toBe('TREK Community');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PHOTOCREDIT-002: the author is credited, and the provider when there is none', () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ export function creditOf(photo: PlacePhotoCandidate): string {
|
||||
export function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
|
||||
if (source === 'google') return 'Google';
|
||||
if (source === 'wikipedia') return 'Wikipedia';
|
||||
if (source === 'community') return 'TREK Community';
|
||||
return 'Wikimedia Commons';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// FE-PLANNER-REFRESH-001 to FE-PLANNER-REFRESH-007
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_FORM, type PlaceFormData } from './PlaceFormModal.helpers';
|
||||
import { refreshCandidates } from './placeRefresh';
|
||||
|
||||
const form = (over: Partial<PlaceFormData> = {}): PlaceFormData => ({ ...DEFAULT_FORM, ...over });
|
||||
|
||||
// Mo-Fr 10:30-20:00, as the details lookup hands periods out (day 0 is Sunday).
|
||||
const PERIODS = [1, 2, 3, 4, 5].map((day) => ({
|
||||
open: { day, hour: 10, minute: 30 },
|
||||
close: { day, hour: 20, minute: 0 },
|
||||
}));
|
||||
|
||||
describe('refreshCandidates', () => {
|
||||
it('FE-PLANNER-REFRESH-001: an empty field the lookup fills is offered and ticked', () => {
|
||||
const got = refreshCandidates(form(), { phone: '+49 381 87398063', website: 'www.thao-vinh.de' });
|
||||
expect(got).toEqual([
|
||||
expect.objectContaining({ field: 'website', current: '', preselected: true }),
|
||||
expect.objectContaining({ field: 'phone', current: '', next: '+49 381 87398063', preselected: true }),
|
||||
]);
|
||||
expect(got[0]?.next).toMatch(/^https:\/\/www\.thao-vinh\.de\/?$/);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-REFRESH-002: a value the person has is offered only when it differs, and not ticked', () => {
|
||||
const got = refreshCandidates(form({ phone: '0381 1111' }), { phone: '+49 381 2222' });
|
||||
expect(got).toEqual([{ field: 'phone', current: '0381 1111', next: '+49 381 2222', preselected: false }]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-REFRESH-003: the same value in another spelling is no difference', () => {
|
||||
expect(
|
||||
refreshCandidates(
|
||||
form({ phone: '+49 381 873 98 063', website: 'https://www.helgas-kitchen.de/', email: 'Info@Luna.de' }),
|
||||
{
|
||||
phone: '+4938187398063',
|
||||
website: 'https://www.helgas-kitchen.de',
|
||||
email: 'info@luna.de',
|
||||
}
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-REFRESH-004: hours come as the stored week, and the same week is no difference', () => {
|
||||
const [hours] = refreshCandidates(form(), { opening_periods: PERIODS });
|
||||
expect(hours?.field).toBe('opening_hours');
|
||||
expect(JSON.parse(hours?.next ?? '[]')).toHaveLength(7);
|
||||
expect(refreshCandidates(form({ opening_hours: hours?.next }), { opening_periods: PERIODS })).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-REFRESH-006: a description comes from the index object or the OSM summary', () => {
|
||||
const text = 'An Asian restaurant in Evershagen.';
|
||||
expect(refreshCandidates(form(), { description: { text, source: 'community' } })).toEqual([
|
||||
{ field: 'description', current: '', next: text, preselected: true },
|
||||
]);
|
||||
expect(refreshCandidates(form(), { summary: text, summary_source: 'community' })).toEqual([
|
||||
{ field: 'description', current: '', next: text, preselected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-REFRESH-007: the same description under the editor markup is no difference', () => {
|
||||
const text = 'An Asian restaurant in Evershagen.';
|
||||
expect(refreshCandidates(form({ description: `<p>${text}</p>` }), { summary: text })).toEqual([]);
|
||||
expect(refreshCandidates(form({ description: 'Our favourite.' }), { summary: text })).toEqual([
|
||||
{ field: 'description', current: 'Our favourite.', next: text, preselected: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-REFRESH-005: what the lookup does not know is never offered, so nothing is emptied', () => {
|
||||
expect(refreshCandidates(form({ phone: '0381 1111', website: 'https://a.example' }), {})).toEqual([]);
|
||||
expect(refreshCandidates(form(), null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { normalizePlaceWebsite, parsePlaceHours } from '@trek/shared';
|
||||
import type { PlaceFormData } from './PlaceFormModal.helpers';
|
||||
import { cleanWeek, weekFromPeriods, writeWeek } from './placeHours';
|
||||
|
||||
/** The details a saved place can take over from a fresh lookup. */
|
||||
const REFRESH_FIELDS = ['description', 'website', 'phone', 'email', 'opening_hours'] as const;
|
||||
export type RefreshField = (typeof REFRESH_FIELDS)[number];
|
||||
|
||||
/** One field where the lookup says something else than the place holds now. */
|
||||
export interface RefreshCandidate {
|
||||
field: RefreshField;
|
||||
/** What the form holds, as stored; empty when it holds nothing. */
|
||||
current: string;
|
||||
/** What the lookup has, in the form's own format (hours as the stored week). */
|
||||
next: string;
|
||||
/** An empty field takes the new value without asking; a filled one is the person's call. */
|
||||
preselected: boolean;
|
||||
}
|
||||
|
||||
type Details = Record<string, unknown>;
|
||||
|
||||
const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
/** A description as plain words: the editor stores markup, the lookup sends text. */
|
||||
const plain = (value: string): string =>
|
||||
value
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/[*_`~#>]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** The lookup's value of a field, written the way the form stores it. */
|
||||
function freshValue(field: RefreshField, details: Details): string {
|
||||
if (field === 'website') return normalizePlaceWebsite(text(details.website)) ?? '';
|
||||
// An index place carries its description as an object, an OSM place as the summary.
|
||||
if (field === 'description') {
|
||||
const own = details.description as { text?: unknown } | null | undefined;
|
||||
return text(own?.text) || text(details.summary);
|
||||
}
|
||||
if (field === 'opening_hours') {
|
||||
const week = weekFromPeriods(details.opening_periods as Parameters<typeof weekFromPeriods>[0]);
|
||||
return week ? writeWeek(cleanWeek(week)) : '';
|
||||
}
|
||||
return text(details[field]);
|
||||
}
|
||||
|
||||
/** Two values that say the same in a different spelling count as one. */
|
||||
function sameValue(field: RefreshField, a: string, b: string): boolean {
|
||||
if (field === 'phone') return a.replace(/[^\d+]/g, '') === b.replace(/[^\d+]/g, '');
|
||||
if (field === 'website')
|
||||
return (normalizePlaceWebsite(a) ?? a).replace(/\/$/, '').toLowerCase() === b.replace(/\/$/, '').toLowerCase();
|
||||
if (field === 'email') return a.toLowerCase() === b.toLowerCase();
|
||||
if (field === 'description') return plain(a) === plain(b);
|
||||
if (field === 'opening_hours') {
|
||||
const week = parsePlaceHours(a);
|
||||
return (week ? writeWeek(cleanWeek(week)) : a) === b;
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields where a fresh lookup of a saved place differs from what the form
|
||||
* holds. A field the lookup knows nothing about is never offered: refreshing
|
||||
* must not empty what the place already has.
|
||||
*/
|
||||
export function refreshCandidates(form: PlaceFormData, details: Details | null | undefined): RefreshCandidate[] {
|
||||
if (!details) return [];
|
||||
const out: RefreshCandidate[] = [];
|
||||
for (const field of REFRESH_FIELDS) {
|
||||
const next = freshValue(field, details);
|
||||
if (!next) continue;
|
||||
const current = (form[field] ?? '').trim();
|
||||
if (current && sameValue(field, current, next)) continue;
|
||||
out.push({ field, current, next, preselected: current === '' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -326,3 +326,38 @@ describe('useNewCategory', () => {
|
||||
expect(result.current.name).toBe('Bar');
|
||||
});
|
||||
});
|
||||
|
||||
// FE-PLANNER-PLFORM-030 to FE-PLANNER-PLFORM-033: which fields the user typed and which a
|
||||
// pick, a map POI or the details column filled, for the save's fieldOrigins.
|
||||
describe('usePlaceForm field origins', () => {
|
||||
it('FE-PLANNER-PLFORM-030: a typed shareable field is the user own; other fields report nothing', () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.handleChange('website', 'https://mine.example'));
|
||||
act(() => result.current.handleChange('name', 'Mine'));
|
||||
expect(result.current.form.fieldOrigins).toEqual({ website: 'user' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLFORM-031: a map POI owns what it prefilled, until the user types over it', () => {
|
||||
const { result } = setup();
|
||||
const prefill = { lat: 1, lng: 2, name: 'Cafe', website: 'https://cafe.example', phone: '+49 1', osm_id: 'node:9' };
|
||||
act(() => result.current.openForm(DEFAULT_FORM, null, prefill));
|
||||
expect(result.current.form.fieldOrigins).toEqual({ website: 'osm', phone: 'osm' });
|
||||
act(() => result.current.handleChange('phone', '+49 2'));
|
||||
expect(result.current.form.fieldOrigins).toEqual({ website: 'osm', phone: 'user' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLFORM-032: an adopted description and a picked photo are the details column own', () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.adoptDescription('A museum by the river.'));
|
||||
act(() => result.current.pickImage('/api/maps/place-photo/x/bytes'));
|
||||
expect(result.current.form.fieldOrigins).toEqual({ description: 'enrichment', image: 'enrichment' });
|
||||
act(() => result.current.removeImage());
|
||||
expect(result.current.form.fieldOrigins).toEqual({ description: 'enrichment' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLFORM-033: hours taken over from the lookup say so', () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.handleChange('opening_hours', '[]', 'enrichment'));
|
||||
expect(result.current.form.fieldOrigins).toEqual({ opening_hours: 'enrichment' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PlaceClientOrigin } from '@trek/shared';
|
||||
import type React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
@@ -15,9 +16,17 @@ import {
|
||||
type PlacePrefill,
|
||||
type ResultField,
|
||||
} from './PlaceFormModal.helpers';
|
||||
import { originsAfterPick, resultOrigin, withOrigin } from './placeFieldOrigins';
|
||||
import type { PendingPlaceImage } from './placeImageChoice';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
/** The form with another picture, and with it the origin of that picture; null where the save route decides it. */
|
||||
const withImage = (
|
||||
prev: PlaceFormData,
|
||||
image: string | null | undefined,
|
||||
origin: PlaceClientOrigin | null = null
|
||||
): PlaceFormData => ({ ...prev, image_url: image, fieldOrigins: withOrigin(prev.fieldOrigins, 'image_url', origin) });
|
||||
type Toast = ReturnType<typeof useToast>;
|
||||
|
||||
export interface PlaceFormOptions {
|
||||
@@ -70,18 +79,24 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
|
||||
place: Parameters<typeof openingDetailsSelection>[0],
|
||||
prefill: PlacePrefill | null | undefined
|
||||
) => {
|
||||
setForm(next);
|
||||
autoFilledRef.current = openingAutoFilled(place, prefill);
|
||||
const autoFilled = openingAutoFilled(place, prefill);
|
||||
// A map POI's website and phone are the provider's, like a search pick's.
|
||||
const origins = prefill
|
||||
? originsAfterPick(next.fieldOrigins, [...autoFilled], [], resultOrigin(prefill))
|
||||
: undefined;
|
||||
setForm(origins ? { ...next, fieldOrigins: origins } : next);
|
||||
autoFilledRef.current = autoFilled;
|
||||
setDetailsSelection(openingDetailsSelection(place, prefill));
|
||||
setPendingFiles([]);
|
||||
setPendingImage(null);
|
||||
setDuplicateWarning(null);
|
||||
};
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
/** A field set in the form: typed by hand unless the caller says it took a looked-up value over. */
|
||||
const handleChange = (field: string, value: string, origin: PlaceClientOrigin = 'user') => {
|
||||
// Typed by hand, so the next pick must not clear it.
|
||||
autoFilledRef.current.delete(field as ResultField);
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
setForm((prev) => ({ ...prev, [field]: value, fieldOrigins: withOrigin(prev.fieldOrigins, field, origin) }));
|
||||
};
|
||||
|
||||
const addFiles = (files: File[]) => setPendingFiles((prev) => [...prev, ...files]);
|
||||
@@ -110,29 +125,35 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
|
||||
/** A source's photo picked in the details column, or the place's own picture again. */
|
||||
const pickImage = (url: string | null) => {
|
||||
setPendingImage(null);
|
||||
setForm((prev) => ({ ...prev, image_url: url ?? undefined }));
|
||||
setForm((prev) => withImage(prev, url ?? undefined, url ? 'enrichment' : null));
|
||||
};
|
||||
|
||||
/** A picture from the device, shown at once and uploaded when the form is saved. */
|
||||
const stageUpload = (file: File) => {
|
||||
setPendingImage({ kind: 'upload', file, preview: URL.createObjectURL(file) });
|
||||
setForm((prev) => ({ ...prev, image_url: undefined }));
|
||||
// The upload route records the picture as the user's itself.
|
||||
setForm((prev) => withImage(prev, undefined));
|
||||
};
|
||||
|
||||
/** An image already attached to the place, made its picture when the form is saved. */
|
||||
const stageAttached = (file: TripFile) => {
|
||||
setPendingImage({ kind: 'attached', file });
|
||||
setForm((prev) => ({ ...prev, image_url: undefined }));
|
||||
setForm((prev) => withImage(prev, undefined));
|
||||
};
|
||||
|
||||
/** No picture at all: the save drops the place's own one. */
|
||||
const removeImage = () => {
|
||||
setPendingImage(null);
|
||||
setForm((prev) => ({ ...prev, image_url: null }));
|
||||
setForm((prev) => withImage(prev, null));
|
||||
};
|
||||
|
||||
/** The details column's description, taken over into the form. */
|
||||
const adoptDescription = (text: string) => setForm((prev) => ({ ...prev, description: text }));
|
||||
const adoptDescription = (text: string) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
description: text,
|
||||
fieldOrigins: withOrigin(prev.fieldOrigins, 'description', 'enrichment'),
|
||||
}));
|
||||
|
||||
/**
|
||||
* #1152: the first save of a new place warns when the trip seems to have it already;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// FE-PLANNER-REFRESHHOOK-001 to FE-PLANNER-REFRESHHOOK-002
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { placeDetailsRepo } from '../../repo/placeDetailsRepo';
|
||||
import type { Place } from '../../types';
|
||||
import { DEFAULT_FORM, type PlaceFormData } from './PlaceFormModal.helpers';
|
||||
import { usePlaceRefresh } from './usePlaceRefresh';
|
||||
|
||||
const SAVED = { id: 1, name: 'Café Luna', google_place_id: 'g-luna', osm_id: null } as unknown as Place;
|
||||
|
||||
const DETAILS: Record<string, Record<string, unknown>> = {
|
||||
'g-luna': { website: 'https://cafe-luna.de' },
|
||||
'g-sole': { website: 'https://sole.example', phone: '+39 06 1234' },
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('usePlaceRefresh', () => {
|
||||
it('FE-PLANNER-REFRESHHOOK-001: a saved place is compared with its own fresh details', async () => {
|
||||
const details = vi.spyOn(placeDetailsRepo, 'details').mockImplementation(async (id) => DETAILS[id] ?? null);
|
||||
const form: PlaceFormData = { ...DEFAULT_FORM };
|
||||
const { result } = renderHook(() => usePlaceRefresh({ place: SAVED, form, language: 'de' }));
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(1));
|
||||
expect(details).toHaveBeenCalledWith('g-luna', 'de');
|
||||
expect(result.current.candidates[0]).toMatchObject({ field: 'website', current: '' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-REFRESHHOOK-002: after another place is picked in the search box, its details are the ones compared', async () => {
|
||||
const details = vi.spyOn(placeDetailsRepo, 'details').mockImplementation(async (id) => DETAILS[id] ?? null);
|
||||
const form: PlaceFormData = { ...DEFAULT_FORM, google_place_id: 'g-sole', website: 'https://sole.example' };
|
||||
const { result } = renderHook(() => usePlaceRefresh({ place: SAVED, form, language: 'de' }));
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(1));
|
||||
expect(details).not.toHaveBeenCalledWith('g-luna', 'de');
|
||||
expect(result.current.candidates[0]).toMatchObject({ field: 'phone', next: '+39 06 1234' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { PlaceClientOrigin } from '@trek/shared';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { placeDetailsRepo } from '../../repo/placeDetailsRepo';
|
||||
import type { Place } from '../../types';
|
||||
import type { PlaceFormData } from './PlaceFormModal.helpers';
|
||||
import { resultOrigin } from './placeFieldOrigins';
|
||||
import { refreshCandidates, type RefreshCandidate } from './placeRefresh';
|
||||
|
||||
/**
|
||||
* The id the details lookup takes for a saved place: its Google id, else its
|
||||
* OSM or index id. A place picked anew in the search box brings its own ids
|
||||
* into the form, and those win, so the details compared are the picked place's.
|
||||
*/
|
||||
function detailIdOf(place: Place | null | undefined, form: PlaceFormData): string | null {
|
||||
if (!place) return null;
|
||||
return form.google_place_id || form.osm_id || place.google_place_id || place.osm_id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh details for a place being edited, and the fields where they differ
|
||||
* from the form. Asked once per opened place and language; a place without a
|
||||
* provider id, and a new place, have nothing to refresh from.
|
||||
*/
|
||||
export function usePlaceRefresh({
|
||||
place,
|
||||
form,
|
||||
language,
|
||||
}: {
|
||||
place: Place | null | undefined;
|
||||
form: PlaceFormData;
|
||||
language: string;
|
||||
}): { candidates: RefreshCandidate[]; origin: PlaceClientOrigin } {
|
||||
const detailId = detailIdOf(place, form);
|
||||
// Keyed by what was asked, so a stale answer for another place is never read.
|
||||
const [fresh, setFresh] = useState<{ key: string; details: Record<string, unknown> } | null>(null);
|
||||
const key = detailId ? `${detailId}|${language}` : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailId || !key) return;
|
||||
let live = true;
|
||||
placeDetailsRepo.details(detailId, language).then(
|
||||
(details) => {
|
||||
if (live && details) setFresh({ key, details });
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [detailId, key, language]);
|
||||
|
||||
const details = fresh && fresh.key === key ? fresh.details : null;
|
||||
const candidates = useMemo(() => refreshCandidates(form, details), [form, details]);
|
||||
return { candidates, origin: details ? resultOrigin(details) : 'enrichment' };
|
||||
}
|
||||
@@ -60,7 +60,8 @@ describe('AboutTab', () => {
|
||||
it('FE-COMP-ABOUT-009: all external links have rel="noopener noreferrer"', () => {
|
||||
render(<AboutTab appVersion="2.9.10" />);
|
||||
const links = document.querySelectorAll('a');
|
||||
expect(links).toHaveLength(6);
|
||||
// Six tiles, and the sponsor panel: its logo, its button and the name in its title and body.
|
||||
expect(links).toHaveLength(10);
|
||||
links.forEach((link) => {
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
@@ -130,4 +131,24 @@ describe('AboutTab', () => {
|
||||
expect(link.style.borderColor).toBe('');
|
||||
expect(link.style.boxShadow).toBe('');
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-018: the sponsor panel links dataforest cloud wherever it is named, with both logos bundled', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const links = document.querySelectorAll('a[href="https://cloud.dataforest.net/?mtm_campaign=trek"]');
|
||||
// The logo tile, the call to action, and the brand name in the title and in the body.
|
||||
expect(links).toHaveLength(4);
|
||||
for (const link of links) {
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
const logos = [...document.querySelectorAll('img')].map((img) => img.getAttribute('src'));
|
||||
expect(logos).toEqual(
|
||||
expect.arrayContaining(['/brands/dataforest-cloud.svg', '/brands/dataforest-cloud-dark.svg'])
|
||||
);
|
||||
// Served by TREK itself: opening Settings must not reach a third party.
|
||||
expect(logos.every((src) => src?.startsWith('/'))).toBe(true);
|
||||
expect(screen.getByText('Place search')).toBeInTheDocument();
|
||||
expect(screen.getByText('Address search')).toBeInTheDocument();
|
||||
expect(screen.getByText('Routing')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { BookOpen, Bug, Coffee, ExternalLink, Github, Heart, Info, Lightbulb } from 'lucide-react';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BookOpen,
|
||||
Bug,
|
||||
Coffee,
|
||||
ExternalLink,
|
||||
Github,
|
||||
Heart,
|
||||
Info,
|
||||
Lightbulb,
|
||||
MapPin,
|
||||
Route,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import React, { type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import Section from './Section';
|
||||
import { StatusPill } from './settingsKit';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
interface Props {
|
||||
appVersion: string;
|
||||
}
|
||||
|
||||
const DISCORD_PATH = 'M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z';
|
||||
const DISCORD_PATH =
|
||||
'M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z';
|
||||
|
||||
// The services' own colours: each tile takes its brand on hover and in its icon square.
|
||||
const BRAND = {
|
||||
@@ -27,7 +41,8 @@ const SIGNAL = {
|
||||
} as const;
|
||||
|
||||
/** Tile chrome that reads the tile's colour from `--tile`: the border and a faint ring on hover. */
|
||||
const TILE_HOVER = 'hover:border-[color:var(--tile)] hover:shadow-[0_0_0_1px_color-mix(in_srgb,var(--tile)_14%,transparent)]';
|
||||
const TILE_HOVER =
|
||||
'hover:border-[color:var(--tile)] hover:shadow-[0_0_0_1px_color-mix(in_srgb,var(--tile)_14%,transparent)]';
|
||||
/** The icon square in the tile's colour. */
|
||||
const TILE_ICON = 'bg-[color-mix(in_srgb,var(--tile)_12%,transparent)] text-[color:var(--tile)]';
|
||||
|
||||
@@ -36,7 +51,13 @@ const TILE_ICON = 'bg-[color-mix(in_srgb,var(--tile)_12%,transparent)] text-[col
|
||||
* external-link mark on the right. The border picks up the tile's colour while
|
||||
* the pointer is on it.
|
||||
*/
|
||||
function LinkTile({ href, color, icon, title, hint }: {
|
||||
function LinkTile({
|
||||
href,
|
||||
color,
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
}: {
|
||||
href: string;
|
||||
/** A brand colour or a theme token, handed to the classes as `--tile`. */
|
||||
color?: string;
|
||||
@@ -53,19 +74,133 @@ function LinkTile({ href, color, icon, title, hint }: {
|
||||
style={color ? ({ '--tile': color } as CSSProperties) : undefined}
|
||||
>
|
||||
{icon && (
|
||||
<span className={`grid h-10 w-10 flex-none place-items-center rounded-[12px] ${color ? TILE_ICON : 'bg-surface-tertiary text-content-secondary'}`}>
|
||||
<span
|
||||
className={`grid h-10 w-10 flex-none place-items-center rounded-[12px] ${color ? TILE_ICON : 'bg-surface-tertiary text-content-secondary'}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{title}</div>
|
||||
<div className="truncate text-content-faint" style={fs(11.5)}>{hint}</div>
|
||||
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="truncate text-content-faint" style={fs(11.5)}>
|
||||
{hint}
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="flex-none text-content-faint" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const DATAFOREST_HREF = 'https://cloud.dataforest.net/?mtm_campaign=trek';
|
||||
const DATAFOREST_NAME = 'dataforest cloud';
|
||||
const DATAFOREST = '#02C382'; // theme-lint-disable: brand colour (dataforest cloud)
|
||||
|
||||
/** The sponsor's name, linked wherever a line names it: it is a brand and reads the same in every language. */
|
||||
function withDataforestLink(text: string): ReactNode {
|
||||
const parts = text.split(DATAFOREST_NAME);
|
||||
if (parts.length === 1) return text;
|
||||
return parts.flatMap((part, i) =>
|
||||
i === 0
|
||||
? [part]
|
||||
: [
|
||||
<a
|
||||
key={i}
|
||||
href={DATAFOREST_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[color:var(--df)] no-underline hover:underline"
|
||||
>
|
||||
{DATAFOREST_NAME}
|
||||
</a>,
|
||||
part,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sponsor of TREK's own servers, across the whole section: what runs there
|
||||
* and a way to visit them. The logo ships with TREK, so opening Settings sends
|
||||
* nothing to a third party.
|
||||
*/
|
||||
function DataforestBanner() {
|
||||
const { t } = useTranslation();
|
||||
const services = [
|
||||
{ icon: <MapPin size={12} />, label: t('settings.about.sponsor.search') },
|
||||
{ icon: <Search size={12} />, label: t('settings.about.sponsor.address') },
|
||||
{ icon: <Route size={12} />, label: t('settings.about.sponsor.routing') },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-[18px] border border-[color:color-mix(in_srgb,var(--df)_28%,var(--border-primary))] bg-surface-card shadow-sm"
|
||||
style={{ '--df': DATAFOREST } as CSSProperties}
|
||||
>
|
||||
{/* The glow and the faint grid: a server room seen from far away, in the sponsor's green. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 bg-[radial-gradient(70%_120%_at_0%_0%,color-mix(in_srgb,var(--df)_16%,transparent)_0%,transparent_60%),radial-gradient(50%_90%_at_100%_100%,color-mix(in_srgb,var(--df)_10%,transparent)_0%,transparent_70%)]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.35] [background-image:radial-gradient(color-mix(in_srgb,var(--df)_35%,transparent)_1px,transparent_1px)] [background-size:16px_16px] [mask-image:linear-gradient(90deg,transparent_0%,#000_55%,#000_100%)]"
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-col gap-5 p-5 md:flex-row md:items-center">
|
||||
{/* The sponsor's own logo, one for each theme, served from TREK itself. */}
|
||||
<a
|
||||
href={DATAFOREST_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={DATAFOREST_NAME}
|
||||
className="flex h-[76px] w-full flex-none items-center justify-center rounded-[16px] bg-[color-mix(in_srgb,var(--df)_7%,var(--bg-card))] px-5 shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--df)_26%,transparent)] transition-shadow duration-200 hover:shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--df)_55%,transparent)] md:w-[220px]"
|
||||
>
|
||||
<img src="/brands/dataforest-cloud.svg" alt="" className="h-7 w-auto dark:hidden" />
|
||||
<img src="/brands/dataforest-cloud-dark.svg" alt="" className="hidden h-7 w-auto dark:block" />
|
||||
</a>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-bold uppercase tracking-[.12em] text-[color:var(--df)]" style={fs(10.5)}>
|
||||
{t('settings.about.sponsor.eyebrow')}
|
||||
</div>
|
||||
<div className="mt-1 font-semibold text-content" style={fs(16, 'subtitle')}>
|
||||
{withDataforestLink(t('settings.about.sponsor.title'))}
|
||||
</div>
|
||||
<p className="m-0 mt-1 leading-relaxed text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{withDataforestLink(t('settings.about.sponsor.body'))}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{services.map((s) => (
|
||||
<span
|
||||
key={s.label}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-edge bg-surface-secondary px-2.5 py-1 font-medium text-content-secondary"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<span className="text-[color:var(--df)]">{s.icon}</span>
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={DATAFOREST_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group inline-flex flex-none items-center gap-2 self-start rounded-full bg-[color:var(--df)] px-4 py-2.5 font-semibold text-[#04150e] no-underline shadow-[0_8px_20px_-10px_var(--df)] transition-[filter,transform] duration-200 hover:-translate-y-px hover:brightness-105 md:self-center" // theme-lint-disable: ink on the sponsor's green
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{t('settings.about.sponsor.cta')}
|
||||
<ArrowUpRight
|
||||
size={15}
|
||||
className="transition-transform duration-200 group-hover:-translate-y-px group-hover:translate-x-px"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const managed = useAuthStore((s) => s.managed);
|
||||
@@ -124,11 +259,11 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
<LinkTile
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
color={BRAND.discord}
|
||||
icon={(
|
||||
icon={
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={DISCORD_PATH} />
|
||||
</svg>
|
||||
)}
|
||||
}
|
||||
title="Discord"
|
||||
hint="Join the community"
|
||||
/>
|
||||
@@ -156,6 +291,10 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TREK's own servers, and who sponsors them. It asks nothing of the reader, so
|
||||
a hosted instance shows it too. */}
|
||||
<DataforestBanner />
|
||||
|
||||
{/* What replaces the grids above. AGPL §13 asks for the source to be
|
||||
offered prominently to whoever uses the software over a network, and a
|
||||
customer of a hosted instance is exactly that reader. The support and
|
||||
|
||||
@@ -38,7 +38,7 @@ export function SettingsHeader({ icon: Icon, title, subtitle, actions }: {
|
||||
* badge and an action on the right) and its body below. `tone` tints the band:
|
||||
* neutral for ordinary groups, danger for the destructive ones.
|
||||
*/
|
||||
export function SettingsCard({ icon: Icon, title, hint, badge, action, tone = 'neutral', children, id, bodyClassName }: {
|
||||
export function SettingsCard({ icon: Icon, title, hint, badge, action, tone = 'neutral', children, id, bodyClassName, flush = false }: {
|
||||
icon?: IconLike
|
||||
title: ReactNode
|
||||
/** One line under the title in the band. */
|
||||
@@ -50,10 +50,12 @@ export function SettingsCard({ icon: Icon, title, hint, badge, action, tone = 'n
|
||||
id?: string
|
||||
/** Replaces the body's padded column. */
|
||||
bodyClassName?: string
|
||||
/** Inside another card: no gap below, the parent spaces its children. */
|
||||
flush?: boolean
|
||||
}) {
|
||||
const tint = tone === 'danger' ? 'color-mix(in srgb, var(--danger) 9%, transparent)' : NEUTRAL_TINT
|
||||
return (
|
||||
<section id={id} className="mb-5 overflow-hidden rounded-2xl border border-edge-faint bg-surface-secondary">
|
||||
<section id={id} className={`${flush ? '' : 'mb-5 '}overflow-hidden rounded-2xl border border-edge-faint bg-surface-secondary`}>
|
||||
<div className="flex items-center gap-3 border-b border-edge-faint px-4 py-3" style={{ background: tint }}>
|
||||
{Icon && (
|
||||
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card shadow-sm ${tone === 'danger' ? 'text-danger' : 'text-content-secondary'}`}>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// FE-RN-001 to FE-RN-016
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ReleaseNoticeModal } from './ReleaseNoticeModal'
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore'
|
||||
// FE-RN-001 to FE-RN-020
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore';
|
||||
import { ReleaseNoticeModal } from './ReleaseNoticeModal';
|
||||
|
||||
/** A notice shaped like the release-notes registry entry, with the pieces a test needs to vary. */
|
||||
function releaseNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
|
||||
@@ -42,182 +42,249 @@ function releaseNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDT
|
||||
supportTextKey: 'rel.support',
|
||||
},
|
||||
...overrides,
|
||||
} as SystemNoticeDTO
|
||||
} as SystemNoticeDTO;
|
||||
}
|
||||
|
||||
function renderModal(notice = releaseNotice(), handlers: Partial<{
|
||||
onDismiss: () => void; onCTA: () => void; onSecondaryCTA: () => void
|
||||
}> = {}) {
|
||||
const onDismiss = handlers.onDismiss ?? vi.fn()
|
||||
const onCTA = handlers.onCTA ?? vi.fn()
|
||||
const onSecondaryCTA = handlers.onSecondaryCTA ?? vi.fn()
|
||||
type Release = NonNullable<SystemNoticeDTO['release']>;
|
||||
|
||||
/** The fixture with a few fields of its release block replaced. */
|
||||
function withRelease(patch: Partial<Release>): SystemNoticeDTO {
|
||||
const n = releaseNotice();
|
||||
return { ...n, release: { ...(n.release as Release), ...patch } };
|
||||
}
|
||||
|
||||
function renderModal(
|
||||
notice = releaseNotice(),
|
||||
handlers: Partial<{
|
||||
onDismiss: () => void;
|
||||
onCTA: () => void;
|
||||
onSecondaryCTA: () => void;
|
||||
}> = {}
|
||||
) {
|
||||
const onDismiss = handlers.onDismiss ?? vi.fn();
|
||||
const onCTA = handlers.onCTA ?? vi.fn();
|
||||
const onSecondaryCTA = handlers.onSecondaryCTA ?? vi.fn();
|
||||
const view = render(
|
||||
<ReleaseNoticeModal
|
||||
notice={notice}
|
||||
visible
|
||||
onDismiss={onDismiss}
|
||||
onCTA={onCTA}
|
||||
onSecondaryCTA={onSecondaryCTA}
|
||||
/>
|
||||
)
|
||||
return { ...view, onDismiss, onCTA, onSecondaryCTA }
|
||||
<ReleaseNoticeModal notice={notice} visible onDismiss={onDismiss} onCTA={onCTA} onSecondaryCTA={onSecondaryCTA} />
|
||||
);
|
||||
return { ...view, onDismiss, onCTA, onSecondaryCTA };
|
||||
}
|
||||
|
||||
describe('ReleaseNoticeModal', () => {
|
||||
it('FE-RN-001: renders nothing for a notice without a release block', () => {
|
||||
const { container } = renderModal(releaseNotice({ release: undefined }))
|
||||
expect(container.querySelector('.rn-overlay')).toBeNull()
|
||||
})
|
||||
const { container } = renderModal(releaseNotice({ release: undefined }));
|
||||
expect(container.querySelector('.rn-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-RN-002: shows the version as a plain figure, not a translation key', () => {
|
||||
renderModal()
|
||||
expect(screen.getByText('4.3.0')).toBeInTheDocument()
|
||||
})
|
||||
renderModal();
|
||||
expect(screen.getByText('4.3.0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-RN-003: renders one card per feature, each with its drawing', () => {
|
||||
renderModal()
|
||||
expect(screen.getByText('rel.f1.title')).toBeInTheDocument()
|
||||
expect(document.querySelectorAll('.rn-feature')).toHaveLength(3)
|
||||
expect(document.querySelector('.rn-vis-tours')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-studio')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-assist')).not.toBeNull()
|
||||
})
|
||||
renderModal();
|
||||
expect(screen.getByText('rel.f1.title')).toBeInTheDocument();
|
||||
expect(document.querySelectorAll('.rn-feature')).toHaveLength(3);
|
||||
expect(document.querySelector('.rn-vis-tours')).not.toBeNull();
|
||||
expect(document.querySelector('.rn-vis-studio')).not.toBeNull();
|
||||
expect(document.querySelector('.rn-vis-assist')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-RN-004: splits the note body on the blank line into separate paragraphs', () => {
|
||||
const n = releaseNotice()
|
||||
const n = releaseNotice();
|
||||
// The translation stub echoes the key, so drive the split through a real value.
|
||||
n.release!.note.bodyKey = 'first paragraph\n\nsecond paragraph'
|
||||
renderModal(n)
|
||||
expect(screen.getByText('first paragraph')).toBeInTheDocument()
|
||||
expect(screen.getByText('second paragraph')).toBeInTheDocument()
|
||||
})
|
||||
n.release!.note.bodyKey = 'first paragraph\n\nsecond paragraph';
|
||||
renderModal(n);
|
||||
expect(screen.getByText('first paragraph')).toBeInTheDocument();
|
||||
expect(screen.getByText('second paragraph')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-RN-005: omits the foot when the release has neither a footnote nor notes', () => {
|
||||
renderModal()
|
||||
expect(document.querySelector('.rn-release-foot')).toBeNull()
|
||||
})
|
||||
renderModal();
|
||||
expect(document.querySelector('.rn-release-foot')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-RN-006: shows the footnote and the notes link when both are present', () => {
|
||||
const n = releaseNotice()
|
||||
n.release!.footnoteKey = 'rel.footnote'
|
||||
n.release!.notes = { labelKey: 'rel.notes', href: 'https://example.test/notes' }
|
||||
renderModal(n)
|
||||
expect(screen.getByText('rel.footnote')).toBeInTheDocument()
|
||||
const link = screen.getByRole('link', { name: /rel\.notes/ })
|
||||
expect(link).toHaveAttribute('href', 'https://example.test/notes')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
const n = releaseNotice();
|
||||
n.release!.footnoteKey = 'rel.footnote';
|
||||
n.release!.notes = { labelKey: 'rel.notes', href: 'https://example.test/notes' };
|
||||
renderModal(n);
|
||||
expect(screen.getByText('rel.footnote')).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: /rel\.notes/ });
|
||||
expect(link).toHaveAttribute('href', 'https://example.test/notes');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('FE-RN-007: dismisses from the close button and from the backdrop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDismiss = vi.fn()
|
||||
renderModal(releaseNotice(), { onDismiss })
|
||||
const user = userEvent.setup();
|
||||
const onDismiss = vi.fn();
|
||||
renderModal(releaseNotice(), { onDismiss });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1)
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(document.querySelector('.rn-overlay')!)
|
||||
expect(onDismiss).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
await user.click(document.querySelector('.rn-overlay')!);
|
||||
expect(onDismiss).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-RN-008: a click inside the panel does not dismiss', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDismiss = vi.fn()
|
||||
renderModal(releaseNotice(), { onDismiss })
|
||||
await user.click(screen.getByText('rel.note.title'))
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
const user = userEvent.setup();
|
||||
const onDismiss = vi.fn();
|
||||
renderModal(releaseNotice(), { onDismiss });
|
||||
await user.click(screen.getByText('rel.note.title'));
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-RN-009: a non-dismissible notice has no close button and an inert backdrop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDismiss = vi.fn()
|
||||
renderModal(releaseNotice({ dismissible: false }), { onDismiss })
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
|
||||
await user.click(document.querySelector('.rn-overlay')!)
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
const user = userEvent.setup();
|
||||
const onDismiss = vi.fn();
|
||||
renderModal(releaseNotice({ dismissible: false }), { onDismiss });
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull();
|
||||
await user.click(document.querySelector('.rn-overlay')!);
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-RN-010: wires each support button to its own handler', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCTA = vi.fn()
|
||||
const onSecondaryCTA = vi.fn()
|
||||
renderModal(releaseNotice(), { onCTA, onSecondaryCTA })
|
||||
const user = userEvent.setup();
|
||||
const onCTA = vi.fn();
|
||||
const onSecondaryCTA = vi.fn();
|
||||
renderModal(releaseNotice(), { onCTA, onSecondaryCTA });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /rel\.bmc/ }))
|
||||
expect(onCTA).toHaveBeenCalledTimes(1)
|
||||
expect(onSecondaryCTA).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: /rel\.bmc/ }));
|
||||
expect(onCTA).toHaveBeenCalledTimes(1);
|
||||
expect(onSecondaryCTA).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /rel\.kofi/ }))
|
||||
expect(onSecondaryCTA).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /rel\.kofi/ }));
|
||||
expect(onSecondaryCTA).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-RN-011: labels the dialog with the headline and describes it with the intro', () => {
|
||||
renderModal()
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
expect(dialog).toHaveAttribute('aria-labelledby', 'notice-title-release-notes')
|
||||
expect(dialog).toHaveAttribute('aria-describedby', 'notice-body-release-notes')
|
||||
expect(document.getElementById('notice-title-release-notes')).toHaveTextContent('rel.headline')
|
||||
expect(document.getElementById('notice-body-release-notes')).toHaveTextContent('rel.intro')
|
||||
})
|
||||
renderModal();
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
expect(dialog).toHaveAttribute('aria-labelledby', 'notice-title-release-notes');
|
||||
expect(dialog).toHaveAttribute('aria-describedby', 'notice-body-release-notes');
|
||||
expect(document.getElementById('notice-title-release-notes')).toHaveTextContent('rel.headline');
|
||||
expect(document.getElementById('notice-body-release-notes')).toHaveTextContent('rel.intro');
|
||||
});
|
||||
|
||||
it('FE-RN-012: sets the promise and the support text with a bold lead', () => {
|
||||
renderModal()
|
||||
expect(screen.getByText('rel.promise.lead').tagName).toBe('B')
|
||||
expect(screen.getByText('rel.support.lead').tagName).toBe('B')
|
||||
expect(screen.getByText('rel.promise.text')).toBeInTheDocument()
|
||||
})
|
||||
renderModal();
|
||||
expect(screen.getByText('rel.promise.lead').tagName).toBe('B');
|
||||
expect(screen.getByText('rel.support.lead').tagName).toBe('B');
|
||||
expect(screen.getByText('rel.promise.text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-RN-013: the features aside is optional', () => {
|
||||
renderModal()
|
||||
expect(document.querySelector('.rn-features-aside')).toBeNull()
|
||||
renderModal();
|
||||
expect(document.querySelector('.rn-features-aside')).toBeNull();
|
||||
|
||||
const n = releaseNotice()
|
||||
n.release!.featuresAsideKey = 'rel.features.aside'
|
||||
renderModal(n)
|
||||
expect(screen.getByText('rel.features.aside')).toBeInTheDocument()
|
||||
})
|
||||
const n = releaseNotice();
|
||||
n.release!.featuresAsideKey = 'rel.features.aside';
|
||||
renderModal(n);
|
||||
expect(screen.getByText('rel.features.aside')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-RN-014: a card whose drawing this client does not know falls back to its icon', () => {
|
||||
const n = releaseNotice()
|
||||
const n = releaseNotice();
|
||||
n.release!.features = [
|
||||
{ iconName: 'Route', visual: 'from-a-newer-server', titleKey: 'rel.x.title', bodyKey: 'rel.x.body' },
|
||||
{ iconName: 'NoSuchIcon', titleKey: 'rel.y.title', bodyKey: 'rel.y.body' },
|
||||
]
|
||||
renderModal(n)
|
||||
expect(document.querySelectorAll('.rn-vis-icon')).toHaveLength(2)
|
||||
expect(document.querySelectorAll('.rn-vis-icon svg')).toHaveLength(2)
|
||||
})
|
||||
];
|
||||
renderModal(n);
|
||||
expect(document.querySelectorAll('.rn-vis-icon')).toHaveLength(2);
|
||||
expect(document.querySelectorAll('.rn-vis-icon svg')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-RN-015: a wide card spans the row with its drawing beside the text, the others stay upright', () => {
|
||||
const n = releaseNotice()
|
||||
const n = releaseNotice();
|
||||
n.release!.features = [
|
||||
...n.release!.features,
|
||||
{ iconName: 'LifeBuoy', visual: 'help', layout: 'wide', titleKey: 'rel.f4.title', bodyKey: 'rel.f4.body' },
|
||||
]
|
||||
renderModal(n)
|
||||
const cards = document.querySelectorAll('.rn-feature')
|
||||
expect(cards).toHaveLength(4)
|
||||
expect(cards[3].classList.contains('rn-feature-wide')).toBe(true)
|
||||
expect([...cards].slice(0, 3).some(c => c.classList.contains('rn-feature-wide'))).toBe(false)
|
||||
];
|
||||
renderModal(n);
|
||||
const cards = document.querySelectorAll('.rn-feature');
|
||||
expect(cards).toHaveLength(4);
|
||||
expect(cards[3].classList.contains('rn-feature-wide')).toBe(true);
|
||||
expect([...cards].slice(0, 3).some((c) => c.classList.contains('rn-feature-wide'))).toBe(false);
|
||||
// The help drawing: the help button and two rows of greetings, each written twice to loop.
|
||||
const wide = cards[3]
|
||||
expect(wide.querySelector('.rn-vis-help-q')).not.toBeNull()
|
||||
expect(wide.querySelectorAll('.rn-vis-hello-row')).toHaveLength(2)
|
||||
expect(wide.querySelectorAll('.rn-vis-hello').length % 2).toBe(0)
|
||||
const wide = cards[3];
|
||||
expect(wide.querySelector('.rn-vis-help-q')).not.toBeNull();
|
||||
expect(wide.querySelectorAll('.rn-vis-hello-row')).toHaveLength(2);
|
||||
expect(wide.querySelectorAll('.rn-vis-hello').length % 2).toBe(0);
|
||||
// The tours card lists the kinds of tour along its foot.
|
||||
expect(cards[0].querySelectorAll('.rn-vis-tours-kind')).toHaveLength(5)
|
||||
})
|
||||
expect(cards[0].querySelectorAll('.rn-vis-tours-kind')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('FE-RN-016: the close button belongs to the panel, not the note', () => {
|
||||
// Below 1080px the panel scrolls and the note is its second screen, so the
|
||||
// stylesheet sticks the button to the panel's own top edge. That only works
|
||||
// while it is a child of .rn-panel; put it back in the note and it scrolls a
|
||||
// whole release half out of reach with every CSS assertion still green.
|
||||
renderModal(releaseNotice())
|
||||
const close = screen.getByRole('button', { name: 'Close' })
|
||||
expect(close.parentElement?.classList.contains('rn-panel')).toBe(true)
|
||||
expect(close.closest('.rn-note')).toBeNull()
|
||||
})
|
||||
})
|
||||
renderModal(releaseNotice());
|
||||
const close = screen.getByRole('button', { name: 'Close' });
|
||||
expect(close.parentElement?.classList.contains('rn-panel')).toBe(true);
|
||||
expect(close.closest('.rn-note')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-RN-017: links the sponsor wherever a line names it, and leaves other lines plain', () => {
|
||||
// The translation stub echoes the key, so the lines carry the brand name as their value.
|
||||
renderModal(
|
||||
withRelease({
|
||||
introKey: 'Our servers, sponsored by dataforest cloud, and more.',
|
||||
supportTextKey: 'Thanks to dataforest cloud today.',
|
||||
})
|
||||
);
|
||||
const links = screen.getAllByRole('link', { name: 'dataforest cloud' });
|
||||
expect(links).toHaveLength(2);
|
||||
for (const link of links) {
|
||||
expect(link).toHaveAttribute('href', 'https://cloud.dataforest.net/?mtm_campaign=trek');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
expect(document.getElementById('notice-body-release-notes')).toHaveTextContent(
|
||||
'Our servers, sponsored by dataforest cloud, and more.'
|
||||
);
|
||||
expect(screen.getByText('rel.promise.text').querySelector('a')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-RN-018: with a support label the ask is set as a card like the promise', () => {
|
||||
renderModal(withRelease({ supportLabelKey: 'rel.support.label' }));
|
||||
const card = document.querySelector('.rn-support .rn-support-card');
|
||||
expect(card).not.toBeNull();
|
||||
expect(card?.classList.contains('rn-promise')).toBe(true);
|
||||
expect(card?.querySelector('.rn-promise-label')).toHaveTextContent('rel.support.label');
|
||||
expect(screen.getByText('rel.support.lead').tagName).toBe('B');
|
||||
expect(document.querySelector('.rn-support-text')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-RN-019: without a support label the ask stays a plain line', () => {
|
||||
renderModal();
|
||||
expect(document.querySelector('.rn-support-card')).toBeNull();
|
||||
expect(document.querySelector('.rn-support-text')).toHaveTextContent('rel.support.lead');
|
||||
});
|
||||
|
||||
it('FE-RN-020: draws the search, sharing and routing cards', () => {
|
||||
renderModal(
|
||||
withRelease({
|
||||
features: [
|
||||
{ iconName: 'Search', visual: 'search', titleKey: 'rel.s.title', bodyKey: 'rel.s.body' },
|
||||
{ iconName: 'Share2', visual: 'share', titleKey: 'rel.sh.title', bodyKey: 'rel.sh.body' },
|
||||
{ iconName: 'Route', visual: 'routing', titleKey: 'rel.r.title', bodyKey: 'rel.r.body' },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(document.querySelectorAll('.rn-vis-icon')).toHaveLength(0);
|
||||
// The right place on top: the first result is the highlighted one.
|
||||
const rows = document.querySelectorAll('.rn-vis-search-row');
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[0].classList.contains('rn-vis-search-row-top')).toBe(true);
|
||||
// One place's details going out to three other TREKs.
|
||||
expect(document.querySelectorAll('.rn-vis-share-field')).toHaveLength(4);
|
||||
expect(document.querySelectorAll('.rn-vis-share-node')).toHaveLength(3);
|
||||
// A day's five stops on one drawn route.
|
||||
expect(document.querySelectorAll('.rn-vis-route-stop')).toHaveLength(5);
|
||||
expect(document.querySelector('.rn-vis-route-line')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArrowRight, Heart, Infinity as InfinityIcon, ShieldCheck, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { ArrowRight, Heart, Infinity as InfinityIcon, X } from 'lucide-react';
|
||||
import { useTranslation } from '../../i18n/TranslationContext.js';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
|
||||
import { ReleaseFeatureVisual } from './ReleaseNoticeVisuals.js';
|
||||
@@ -16,8 +16,18 @@ interface Props {
|
||||
/** Lucide's coffee cup, drawn here so its three steam strokes can drift up in waves. */
|
||||
function SteamingCoffee() {
|
||||
return (
|
||||
<svg className="rn-coffee" width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth={2.1} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<svg
|
||||
className="rn-coffee"
|
||||
width={18}
|
||||
height={18}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.1}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17 8h1a4 4 0 1 1 0 8h-1" />
|
||||
<path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z" />
|
||||
<path className="rn-steam" d="M6 5.5c-1.2-.9 1.2-1.7 0-2.6s1.2-1.7 0-2.6 1.2-1.7 0-2.6" />
|
||||
@@ -27,9 +37,31 @@ function SteamingCoffee() {
|
||||
);
|
||||
}
|
||||
|
||||
const SPONSOR = 'dataforest cloud';
|
||||
const SPONSOR_HREF = 'https://cloud.dataforest.net/?mtm_campaign=trek';
|
||||
|
||||
/** The sponsor's name is a brand and reads the same in every language, so wherever a line names it, it links. */
|
||||
function withSponsorLink(text: string): React.ReactNode {
|
||||
const parts = text.split(SPONSOR);
|
||||
if (parts.length === 1) return text;
|
||||
return parts.flatMap((part, i) =>
|
||||
i === 0
|
||||
? [part]
|
||||
: [
|
||||
<a key={i} className="rn-sponsor" href={SPONSOR_HREF} target="_blank" rel="noopener noreferrer">
|
||||
{SPONSOR}
|
||||
</a>,
|
||||
part,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/** Splits a translated block into paragraphs the way the notice bodies are written. */
|
||||
function paragraphs(text: string): string[] {
|
||||
return text.split('\n\n').map(p => p.trim()).filter(Boolean);
|
||||
return text
|
||||
.split('\n\n')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,15 +87,15 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
|
||||
className="rn-overlay"
|
||||
role="presentation"
|
||||
style={{ opacity: visible ? 1 : 0, transition: 'opacity 260ms ease' }}
|
||||
onClick={notice.dismissible ? e => { if (e.target === e.currentTarget) onDismiss() } : undefined}
|
||||
onClick={
|
||||
notice.dismissible
|
||||
? (e) => {
|
||||
if (e.target === e.currentTarget) onDismiss();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={bodyId}
|
||||
className="rn-panel"
|
||||
>
|
||||
<div role="dialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={bodyId} className="rn-panel">
|
||||
{/* A child of the panel, not the note: stacked, the panel scrolls and the
|
||||
note is its second screen, so the button sticks to the panel's edge. */}
|
||||
{notice.dismissible && (
|
||||
@@ -86,19 +118,21 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
|
||||
|
||||
<div className="rn-version">{release.version}</div>
|
||||
|
||||
<h2 id={titleId} className="rn-headline">{t(release.headlineKey)}</h2>
|
||||
<p id={bodyId} className="rn-intro">{t(release.introKey)}</p>
|
||||
<h2 id={titleId} className="rn-headline">
|
||||
{t(release.headlineKey)}
|
||||
</h2>
|
||||
<p id={bodyId} className="rn-intro">
|
||||
{withSponsorLink(t(release.introKey))}
|
||||
</p>
|
||||
|
||||
<div className="rn-features-head">
|
||||
<span className="rn-features-label">{t(release.featuresLabelKey)}</span>
|
||||
<span className="rn-features-rule" aria-hidden="true" />
|
||||
{release.featuresAsideKey && (
|
||||
<span className="rn-features-aside">{t(release.featuresAsideKey)}</span>
|
||||
)}
|
||||
{release.featuresAsideKey && <span className="rn-features-aside">{t(release.featuresAsideKey)}</span>}
|
||||
</div>
|
||||
|
||||
<div className="rn-features">
|
||||
{release.features.map(f => (
|
||||
{release.features.map((f) => (
|
||||
<div key={f.titleKey} className={f.layout === 'wide' ? 'rn-feature rn-feature-wide' : 'rn-feature'}>
|
||||
<div className="rn-feature-shine" aria-hidden="true" />
|
||||
<div className="rn-feature-visual" aria-hidden="true">
|
||||
@@ -116,12 +150,7 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
|
||||
<div className="rn-release-foot">
|
||||
<div className="rn-footnote">{release.footnoteKey ? t(release.footnoteKey) : null}</div>
|
||||
{release.notes && (
|
||||
<a
|
||||
className="rn-notes"
|
||||
href={release.notes.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<a className="rn-notes" href={release.notes.href} target="_blank" rel="noopener noreferrer">
|
||||
{t(release.notes.labelKey)}
|
||||
<span className="rn-notes-arrow">
|
||||
<ArrowRight size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
@@ -139,7 +168,9 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
|
||||
<div className="rn-note-eyebrow">{t(release.note.eyebrowKey)}</div>
|
||||
<h3 className="rn-note-title">{t(release.note.titleKey)}</h3>
|
||||
|
||||
{paragraphs(t(release.note.bodyKey)).map((p, i) => <p key={i}>{p}</p>)}
|
||||
{paragraphs(t(release.note.bodyKey)).map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
|
||||
<div className="rn-promise">
|
||||
<div className="rn-promise-label">
|
||||
@@ -147,23 +178,39 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
|
||||
{t(release.note.promiseLabelKey)}
|
||||
</div>
|
||||
<div className="rn-promise-text">
|
||||
<b>{t(release.note.promiseLeadKey)}</b>{' '}
|
||||
<span>{t(release.note.promiseTextKey)}</span>
|
||||
<b>{t(release.note.promiseLeadKey)}</b> <span>{t(release.note.promiseTextKey)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{paragraphs(t(release.note.bodyAfterKey)).map((p, i) => <p key={i}>{p}</p>)}
|
||||
{paragraphs(t(release.note.bodyAfterKey)).map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
|
||||
<div className="rn-closing">{t(release.note.closingKey)}</div>
|
||||
</div>
|
||||
|
||||
<div className="rn-support">
|
||||
<div className="rn-support-text">
|
||||
<b>{t(release.supportLeadKey)}</b> {t(release.supportTextKey)}
|
||||
</div>
|
||||
{release.supportLabelKey ? (
|
||||
// Set like the promise above it, so the ask reads as the same kind of statement.
|
||||
<div className="rn-promise rn-support-card">
|
||||
<div className="rn-promise-label">
|
||||
<ShieldCheck size={14} strokeWidth={2.1} aria-hidden="true" />
|
||||
{t(release.supportLabelKey)}
|
||||
</div>
|
||||
<div className="rn-promise-text">
|
||||
<b>{withSponsorLink(t(release.supportLeadKey))}</b>{' '}
|
||||
<span>{withSponsorLink(t(release.supportTextKey))}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rn-support-text">
|
||||
<b>{withSponsorLink(t(release.supportLeadKey))}</b> {withSponsorLink(t(release.supportTextKey))}
|
||||
</div>
|
||||
)}
|
||||
<div className="rn-support-buttons">
|
||||
{notice.cta && (
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
id={`notice-cta-${notice.id}`}
|
||||
className="rn-support-btn rn-support-bmc"
|
||||
onClick={onCTA}
|
||||
@@ -173,7 +220,8 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
|
||||
</button>
|
||||
)}
|
||||
{notice.secondaryCta && (
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
id={`notice-cta2-${notice.id}`}
|
||||
className="rn-support-btn rn-support-kofi"
|
||||
onClick={onSecondaryCTA}
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import React from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Bike, Check, Footprints, Landmark, Plane, Sailboat, Snowflake, Sparkles, Star, TrendingUp } from 'lucide-react';
|
||||
import {
|
||||
Bike,
|
||||
Check,
|
||||
Clock,
|
||||
Coffee,
|
||||
Footprints,
|
||||
Globe,
|
||||
Image as ImageIcon,
|
||||
Landmark,
|
||||
MapPin,
|
||||
Phone,
|
||||
Plane,
|
||||
Sailboat,
|
||||
Search,
|
||||
Snowflake,
|
||||
Sparkles,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import './releaseNoticeVisuals.css';
|
||||
|
||||
/*
|
||||
@@ -127,8 +146,35 @@ function AssistantVisual() {
|
||||
|
||||
// ── Help on every screen, in 27 languages: hello in each of them, drifting by ──
|
||||
|
||||
const HELLO_A = ['Hallo', 'Bonjour', 'Hola', 'Ciao', 'Olá', 'Hej', 'Ahoj', 'Cześć', 'Szia', 'Tere', 'Salam', 'Bon dia', 'Halo'];
|
||||
const HELLO_B = ['Привіт', 'Привет', 'Γεια σου', 'مرحبا', 'こんにちは', '안녕하세요', '你好', '哈囉', 'สวัสดี', 'Xin chào', 'Merhaba', 'Dobrý deň'];
|
||||
const HELLO_A = [
|
||||
'Hallo',
|
||||
'Bonjour',
|
||||
'Hola',
|
||||
'Ciao',
|
||||
'Olá',
|
||||
'Hej',
|
||||
'Ahoj',
|
||||
'Cześć',
|
||||
'Szia',
|
||||
'Tere',
|
||||
'Salam',
|
||||
'Bon dia',
|
||||
'Halo',
|
||||
];
|
||||
const HELLO_B = [
|
||||
'Привіт',
|
||||
'Привет',
|
||||
'Γεια σου',
|
||||
'مرحبا',
|
||||
'こんにちは',
|
||||
'안녕하세요',
|
||||
'你好',
|
||||
'哈囉',
|
||||
'สวัสดี',
|
||||
'Xin chào',
|
||||
'Merhaba',
|
||||
'Dobrý deň',
|
||||
];
|
||||
|
||||
function HelloRow({ words, reverse }: { words: string[]; reverse?: boolean }) {
|
||||
// Written twice, so the row can slide by half its width and start over without a seam.
|
||||
@@ -156,19 +202,133 @@ function HelpVisual() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Place search: a query typed into the search field, the right place on top ──
|
||||
|
||||
const RESULTS: Array<{ name: string; Kind: React.ElementType; tone: string }> = [
|
||||
{ name: 'Kölner Dom', Kind: Landmark, tone: 'sight' },
|
||||
{ name: 'CGN', Kind: Plane, tone: 'air' },
|
||||
{ name: 'Café Reichard', Kind: Coffee, tone: 'food' },
|
||||
];
|
||||
|
||||
function SearchVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-search">
|
||||
<div className="rn-vis-search-field">
|
||||
<Search size={10} strokeWidth={2.6} />
|
||||
<span className="rn-vis-search-query">Kölner Dom</span>
|
||||
<span className="rn-vis-search-caret" />
|
||||
</div>
|
||||
<div className="rn-vis-search-list">
|
||||
{RESULTS.map(({ name, Kind, tone }, i) => (
|
||||
<div
|
||||
key={name}
|
||||
className={i === 0 ? 'rn-vis-search-row rn-vis-search-row-top' : 'rn-vis-search-row'}
|
||||
style={{ animationDelay: `${1.3 + i * 0.16}s` }}
|
||||
>
|
||||
<span className={`rn-vis-search-pin rn-vis-search-pin-${tone}`}>
|
||||
<Kind size={8} strokeWidth={2.6} />
|
||||
</span>
|
||||
<span className="rn-vis-search-name">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Place details, shared: one place's details travelling out to other TREKs ──
|
||||
|
||||
const DETAILS = [Globe, Clock, Phone, ImageIcon];
|
||||
|
||||
function ShareVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-share">
|
||||
<svg className="rn-vis-share-links" viewBox="0 0 200 112" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path d="M82 56 C 110 56, 120 24, 156 22" />
|
||||
<path d="M82 56 C 112 56, 126 56, 160 56" />
|
||||
<path d="M82 56 C 110 56, 120 88, 156 90" />
|
||||
</svg>
|
||||
<div className="rn-vis-share-card">
|
||||
<span className="rn-vis-share-pin">
|
||||
<MapPin size={11} strokeWidth={2.4} />
|
||||
</span>
|
||||
<div className="rn-vis-share-fields">
|
||||
{DETAILS.map((Detail, i) => (
|
||||
<span key={i} className="rn-vis-share-field" style={{ animationDelay: `${0.5 + i * 0.18}s` }}>
|
||||
<Detail size={8} strokeWidth={2.4} />
|
||||
<i />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{['top', 'mid', 'bottom'].map((at, i) => (
|
||||
<span
|
||||
key={at}
|
||||
className={`rn-vis-share-node rn-vis-share-node-${at}`}
|
||||
style={{ animationDelay: `${1.5 + i * 0.2}s` }}
|
||||
>
|
||||
<img src="/icons/icon-white.svg" alt="" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Routing: a day's stops joined in one go ──
|
||||
|
||||
const DAY_ROUTE = 'M16 92 C 34 78, 42 84, 56 70 S 76 40, 96 48 S 120 78, 138 62 S 160 26, 184 22';
|
||||
const DAY_STOPS: Array<[number, number]> = [
|
||||
[16, 92],
|
||||
[56, 70],
|
||||
[96, 48],
|
||||
[138, 62],
|
||||
[184, 22],
|
||||
];
|
||||
|
||||
function RoutingVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-route">
|
||||
<svg className="rn-vis-route-map" viewBox="0 0 200 112" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<path
|
||||
className="rn-vis-route-street"
|
||||
d="M0 30 L200 44 M0 76 L200 64 M40 0 L64 112 M118 0 L104 112 M168 0 L176 112"
|
||||
/>
|
||||
<path className="rn-vis-route-water" d="M0 104 C 50 96, 90 112, 140 102 S 190 98, 200 100 V112 H0 Z" />
|
||||
<path className="rn-vis-route-line" d={DAY_ROUTE} pathLength={1} />
|
||||
{DAY_STOPS.map(([cx, cy], i) => (
|
||||
<circle
|
||||
key={i}
|
||||
className="rn-vis-route-stop"
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r="4.2"
|
||||
style={{ animationDelay: `${0.3 + i * 0.22}s` }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<span className="rn-vis-route-badge">
|
||||
<Zap size={9} strokeWidth={2.6} />
|
||||
20
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const VISUALS: Record<string, () => React.ReactElement> = {
|
||||
tours: ToursVisual,
|
||||
studio: StudioVisual,
|
||||
assistant: AssistantVisual,
|
||||
help: HelpVisual,
|
||||
search: SearchVisual,
|
||||
share: ShareVisual,
|
||||
routing: RoutingVisual,
|
||||
};
|
||||
|
||||
/** The card's picture, or its icon when the release names no drawing this client knows. */
|
||||
export function ReleaseFeatureVisual({ visual, iconName }: { visual?: string; iconName: string }) {
|
||||
const Drawing = visual ? VISUALS[visual] : undefined;
|
||||
if (Drawing) return <Drawing />;
|
||||
const Icon: React.ElementType =
|
||||
((LucideIcons as Record<string, unknown>)[iconName] as React.ElementType) ?? Sparkles;
|
||||
const Icon: React.ElementType = ((LucideIcons as Record<string, unknown>)[iconName] as React.ElementType) ?? Sparkles;
|
||||
return (
|
||||
<div className="rn-vis rn-vis-icon">
|
||||
<Icon size={30} strokeWidth={1.6} aria-hidden="true" />
|
||||
|
||||
@@ -258,7 +258,7 @@
|
||||
/* Keeps the foot clear of the cards when the column has no height to spare. */
|
||||
margin-bottom: 18px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -528,6 +528,8 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.rn-support .rn-support-card { margin-top: 0; }
|
||||
|
||||
.rn-support-text {
|
||||
font-size: 15.5px;
|
||||
line-height: 1.62;
|
||||
@@ -555,13 +557,42 @@
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
box-shadow: 0 2px 6px oklch(0 0 0 / 0.1);
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
transition: filter 0.15s ease;
|
||||
/* Glass like the promise card, tinted with each platform's colour (set below). */
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(160deg,
|
||||
color-mix(in oklch, var(--rn-btn) 96%, white) 0%,
|
||||
color-mix(in oklch, var(--rn-btn) 82%, transparent) 100%);
|
||||
border: 1px solid color-mix(in oklch, var(--rn-btn) 70%, white);
|
||||
backdrop-filter: blur(16px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.3);
|
||||
box-shadow:
|
||||
inset 0 1px 0 oklch(1 0 0 / 0.45),
|
||||
0 12px 26px -14px color-mix(in oklch, var(--rn-btn) 80%, black);
|
||||
transition: filter 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.rn-support-btn:hover { filter: brightness(0.96); }
|
||||
/* The light along the top edge and the sheen in the corner, as on the promise card. */
|
||||
.rn-support-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.75), transparent);
|
||||
}
|
||||
.rn-support-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: radial-gradient(120% 140% at 0% 0%, oklch(1 0 0 / 0.32) 0%, transparent 55%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rn-support-btn:hover { filter: brightness(1.04) saturate(1.05); transform: translateY(-1px); }
|
||||
|
||||
/* The steam lifts off the cup in turn, and the heart beats. */
|
||||
.rn-coffee { overflow: visible; }
|
||||
@@ -593,11 +624,24 @@
|
||||
[data-reduce-motion='true'] .rn-heart { animation: none; }
|
||||
|
||||
/* Brand colours: Buy Me a Coffee and Ko-fi own these. */
|
||||
.rn-support-bmc { background: #ffdd00; color: #101013; } /* theme-lint-disable */
|
||||
.rn-support-kofi { background: #ff5e5b; color: #ffffff; } /* theme-lint-disable */
|
||||
.rn-support-bmc { --rn-btn: #ffdd00; color: #101013; } /* theme-lint-disable */
|
||||
.rn-support-kofi { --rn-btn: #ff5e5b; color: #ffffff; } /* theme-lint-disable */
|
||||
|
||||
/* Narrow viewports: keep the proportions, drop the outer padding. */
|
||||
@media (max-width: 900px) {
|
||||
.rn-overlay { padding: 0; }
|
||||
.rn-panel { border-radius: 0; border: 0; max-height: 100%; }
|
||||
}
|
||||
|
||||
/* The sponsor's name, linked wherever a line names it; it takes the colour of the line it sits in. */
|
||||
.rn-sponsor {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
||||
text-underline-offset: 3px;
|
||||
transition: text-decoration-color 160ms ease;
|
||||
}
|
||||
.rn-sponsor:hover {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
@@ -536,6 +536,264 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Place search: the query typed into the field, the right place on top. */
|
||||
.rn-vis-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 11px;
|
||||
background:
|
||||
radial-gradient(110% 80% at 100% 0%, oklch(0.62 0.15 200 / 0.32) 0%, transparent 62%),
|
||||
linear-gradient(165deg, #172025 0%, #11171b 100%);
|
||||
}
|
||||
|
||||
.rn-vis-search-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 99px;
|
||||
background: #f5f5f7;
|
||||
color: #101013;
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 12px oklch(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
.rn-vis-search-query {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
max-width: 0;
|
||||
animation: rn-type 0.9s steps(10, end) 0.3s both;
|
||||
}
|
||||
|
||||
.rn-vis-search-caret {
|
||||
width: 1.5px;
|
||||
height: 10px;
|
||||
background: #0ea5e9;
|
||||
animation: rn-caret 0.9s step-end infinite;
|
||||
}
|
||||
|
||||
.rn-vis-search-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.rn-vis-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 7px;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.3;
|
||||
animation: rn-rise 0.34s ease both;
|
||||
}
|
||||
|
||||
.rn-vis-search-row-top {
|
||||
background: oklch(1 0 0 / 0.1);
|
||||
border: 1px solid oklch(1 0 0 / 0.16);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rn-vis-search-pin {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
}
|
||||
.rn-vis-search-pin-sight {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.rn-vis-search-pin-air {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.rn-vis-search-pin-food {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.rn-vis-search-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Place details, shared: one place's card, its details travelling out to other TREKs. */
|
||||
.rn-vis-share {
|
||||
background:
|
||||
radial-gradient(90% 90% at 25% 50%, oklch(0.66 0.16 160 / 0.3) 0%, transparent 65%),
|
||||
linear-gradient(165deg, #16211c 0%, #101814 100%);
|
||||
}
|
||||
|
||||
.rn-vis-share-links {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rn-vis-share-links path {
|
||||
fill: none;
|
||||
stroke: oklch(0.8 0.14 160 / 0.6);
|
||||
stroke-width: 1.4;
|
||||
stroke-dasharray: 3 4;
|
||||
animation: rn-flow 1.4s linear infinite;
|
||||
}
|
||||
|
||||
.rn-vis-share-card {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-start: 12px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border-radius: 9px;
|
||||
background: #f5f5f7;
|
||||
color: #101013;
|
||||
transform: translateY(-50%);
|
||||
box-shadow: 0 6px 16px oklch(0 0 0 / 0.45);
|
||||
}
|
||||
|
||||
.rn-vis-share-pin {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
background: #10b981;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.rn-vis-share-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.rn-vis-share-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #059669;
|
||||
animation: rn-rise 0.3s ease both;
|
||||
}
|
||||
|
||||
.rn-vis-share-field i {
|
||||
width: 22px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: oklch(0.4 0.02 160 / 0.35);
|
||||
}
|
||||
|
||||
.rn-vis-share-node {
|
||||
position: absolute;
|
||||
inset-inline-end: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 7px;
|
||||
background: #101013;
|
||||
border: 1px solid oklch(1 0 0 / 0.22);
|
||||
box-shadow: 0 3px 10px oklch(0 0 0 / 0.45);
|
||||
animation: rn-pop 0.32s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
.rn-vis-share-node img {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.rn-vis-share-node-top {
|
||||
top: 10%;
|
||||
}
|
||||
.rn-vis-share-node-mid {
|
||||
top: 50%;
|
||||
margin-top: -11px;
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
.rn-vis-share-node-bottom {
|
||||
bottom: 10%;
|
||||
}
|
||||
|
||||
/* Routing: a day's stops joined in one go. */
|
||||
.rn-vis-route {
|
||||
background: linear-gradient(165deg, #1b1d22 0%, #131418 100%);
|
||||
}
|
||||
|
||||
.rn-vis-route-map {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rn-vis-route-street {
|
||||
fill: none;
|
||||
stroke: oklch(1 0 0 / 0.08);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.rn-vis-route-water {
|
||||
fill: oklch(0.55 0.1 240 / 0.28);
|
||||
}
|
||||
|
||||
.rn-vis-route-line {
|
||||
fill: none;
|
||||
stroke: #3b82f6;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
filter: drop-shadow(0 0 4px oklch(0.62 0.19 255 / 0.7));
|
||||
animation: rn-draw 1.3s cubic-bezier(0.6, 0, 0.3, 1) 0.3s forwards;
|
||||
}
|
||||
|
||||
.rn-vis-route-stop {
|
||||
fill: #f5f5f7;
|
||||
stroke: #131418;
|
||||
stroke-width: 2;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: rn-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
.rn-vis-route-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-start: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 99px;
|
||||
background: #facc15;
|
||||
color: #1a1400;
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
animation: rn-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) 1.5s both;
|
||||
}
|
||||
|
||||
@keyframes rn-type {
|
||||
to {
|
||||
max-width: 80px;
|
||||
}
|
||||
}
|
||||
@keyframes rn-caret {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes rn-flow {
|
||||
to {
|
||||
stroke-dashoffset: -14;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rn-vis-tours-trail,
|
||||
.rn-vis-tours-flag,
|
||||
@@ -544,9 +802,24 @@
|
||||
.rn-vis-assist-typing i,
|
||||
.rn-vis-assist-row,
|
||||
.rn-vis-assist-apply,
|
||||
.rn-vis-hello-row {
|
||||
.rn-vis-hello-row,
|
||||
.rn-vis-search-query,
|
||||
.rn-vis-search-caret,
|
||||
.rn-vis-search-row,
|
||||
.rn-vis-share-links path,
|
||||
.rn-vis-share-field,
|
||||
.rn-vis-share-node,
|
||||
.rn-vis-route-line,
|
||||
.rn-vis-route-stop,
|
||||
.rn-vis-route-badge {
|
||||
animation: none;
|
||||
}
|
||||
.rn-vis-search-query {
|
||||
max-width: 80px;
|
||||
}
|
||||
.rn-vis-route-line {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
.rn-vis-tours-trail {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
@@ -565,10 +838,27 @@
|
||||
[data-reduce-motion='true'] .rn-vis-assist-typing i,
|
||||
[data-reduce-motion='true'] .rn-vis-assist-row,
|
||||
[data-reduce-motion='true'] .rn-vis-assist-apply,
|
||||
[data-reduce-motion='true'] .rn-vis-hello-row {
|
||||
[data-reduce-motion='true'] .rn-vis-hello-row,
|
||||
[data-reduce-motion='true'] .rn-vis-search-query,
|
||||
[data-reduce-motion='true'] .rn-vis-search-caret,
|
||||
[data-reduce-motion='true'] .rn-vis-search-row,
|
||||
[data-reduce-motion='true'] .rn-vis-share-links path,
|
||||
[data-reduce-motion='true'] .rn-vis-share-field,
|
||||
[data-reduce-motion='true'] .rn-vis-share-node,
|
||||
[data-reduce-motion='true'] .rn-vis-route-line,
|
||||
[data-reduce-motion='true'] .rn-vis-route-stop,
|
||||
[data-reduce-motion='true'] .rn-vis-route-badge {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-search-query {
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-route-line {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-tours-trail {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function FigureBadge({
|
||||
style={fs(11)}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{value}</span>
|
||||
<span className="whitespace-nowrap">{value}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,7 @@ const localeLoaders: Record<SupportedLanguageCode, () => Promise<{ default: Tran
|
||||
id: () => import('@trek/shared/i18n/id'),
|
||||
ar: () => import('@trek/shared/i18n/ar'),
|
||||
az: () => import('@trek/shared/i18n/az'),
|
||||
he: () => import('@trek/shared/i18n/he'),
|
||||
br: () => import('@trek/shared/i18n/br'),
|
||||
cs: () => import('@trek/shared/i18n/cs'),
|
||||
sk: () => import('@trek/shared/i18n/sk'),
|
||||
|
||||
@@ -258,6 +258,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
photos: photos.map(p => ({
|
||||
id: p.id,
|
||||
src: photoUrl(p, 'original'),
|
||||
thumbSrc: photoUrl(p, 'thumbnail'),
|
||||
caption: p.caption ?? null,
|
||||
provider: p.provider,
|
||||
asset_id: p.asset_id,
|
||||
@@ -590,6 +591,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
showWeather={current.show_weather !== 0}
|
||||
userId={useAuthStore.getState().user?.id || 0}
|
||||
trips={current.trips}
|
||||
photoProviders={availableProviders}
|
||||
onMoveEarlier={
|
||||
canEditEntries && editingEntry.id !== 0 && editingEntry.type !== 'skeleton' && dayNeighbours(editingEntry).canMoveUp
|
||||
? () => { void moveWithinDay(editingEntry, -1) }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useRef, useState, type CSSProperties } from 'react'
|
||||
import { Briefcase, Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff, Play } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
@@ -9,13 +9,23 @@ import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicke
|
||||
import { isVideoFile } from '../../../utils/videoPoster'
|
||||
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
|
||||
import { useAddonStore } from '../../../store/addonStore'
|
||||
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import JournalBody from '../../../components/Journey/JournalBody'
|
||||
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
|
||||
import { MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
|
||||
import { useJourneyEntryForm, type PendingProviderGroup } from '../../../components/Journey/useJourneyEntryForm'
|
||||
|
||||
const NO_PROVIDERS: { id: string; name: string }[] = []
|
||||
|
||||
/**
|
||||
* A picked mood chip: its colour as the frame and the label, a light wash of it
|
||||
* behind. The frame is the full colour because a tint alone left the grey neutral
|
||||
* mood looking like the chips at rest on a dark sheet (#2905).
|
||||
*/
|
||||
function moodChipActiveStyle(color: string): CSSProperties {
|
||||
return { background: `${color}33`, color, borderColor: color }
|
||||
}
|
||||
|
||||
const PRO_COLOR = '#2FA37A'
|
||||
const CON_COLOR = '#D6273B'
|
||||
|
||||
@@ -38,6 +48,12 @@ interface MJourneyEntrySheetProps {
|
||||
readOnly?: boolean
|
||||
userId?: number
|
||||
trips?: JourneyTrip[]
|
||||
/**
|
||||
* The photo providers (Immich, Synology Photos) connected for this user. The
|
||||
* journey screen asks once when it opens, so the sheet has the answer the
|
||||
* moment it appears instead of probing again and growing a button later (#2908).
|
||||
*/
|
||||
photoProviders?: { id: string; name: string }[]
|
||||
/** The optional fields this journey still keeps (discussion #2299). Values are never cleared. */
|
||||
showVerdict?: boolean
|
||||
showMood?: boolean
|
||||
@@ -68,7 +84,7 @@ interface MJourneyEntrySheetProps {
|
||||
* search, mood (4), weather (6) and tags. Read-only for viewer contributors.
|
||||
*/
|
||||
export default function MJourneyEntrySheet({
|
||||
entry, galleryPhotos, quickCapture = false, readOnly = false, userId = 0, trips = [],
|
||||
entry, galleryPhotos, quickCapture = false, readOnly = false, userId = 0, trips = [], photoProviders: providers = NO_PROVIDERS,
|
||||
showVerdict = true, showMood = true, showWeather = true, onMoveEarlier, onMoveLater, onDismiss,
|
||||
onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDelete, onDone,
|
||||
}: MJourneyEntrySheetProps) {
|
||||
@@ -88,41 +104,10 @@ export default function MJourneyEntrySheet({
|
||||
})
|
||||
const [showExternal, setShowExternal] = useState(false)
|
||||
const [externalProvider, setExternalProvider] = useState<string | null>(null)
|
||||
const [providers, setProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const [captureOnly, setCaptureOnly] = useState(quickCapture)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const cameraRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// The addon list is already in the store — this only probes which of the
|
||||
// photo providers is actually connected for this user.
|
||||
const addons = useAddonStore(s => s.addons)
|
||||
const addonsLoaded = useAddonStore(s => s.loaded)
|
||||
const photoProviders = useMemo(
|
||||
() => addons.filter(a => a.type === 'photo_provider' && a.enabled).map(a => ({ id: a.id, name: a.name })),
|
||||
[addons],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly) return
|
||||
// App.tsx loads the addon list on boot, so this only reacts to it. Kicking off
|
||||
// a second load from here would race the other consumers, and loadAddons
|
||||
// overwrites the list unconditionally when it lands.
|
||||
if (!addonsLoaded) return
|
||||
if (photoProviders.length === 0) { setProviders([]); return }
|
||||
let active = true
|
||||
;void (async () => {
|
||||
const connected: { id: string; name: string }[] = []
|
||||
for (const provider of photoProviders) {
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider.id}/status`, { credentials: 'include' })
|
||||
if (res.ok && (await res.json()).connected) connected.push(provider)
|
||||
} catch { /* provider stays hidden */ }
|
||||
}
|
||||
if (active) setProviders(connected)
|
||||
})()
|
||||
return () => { active = false }
|
||||
}, [readOnly, addonsLoaded, photoProviders])
|
||||
|
||||
const activeProvider = externalProvider || providers[0]?.id || null
|
||||
const providerExistingAssetIds = providerAssetIds(activeProvider)
|
||||
|
||||
@@ -191,13 +176,13 @@ export default function MJourneyEntrySheet({
|
||||
>
|
||||
{uploadProgress ? (
|
||||
<>
|
||||
<span className="h-[14px] w-[14px] animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-muted" />
|
||||
{t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}
|
||||
<span className="h-[14px] w-[14px] shrink-0 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-muted" />
|
||||
<span className="truncate">{t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{captureOnly ? <Camera size={14} strokeWidth={2.2} /> : <Plus size={14} strokeWidth={2.2} />}
|
||||
{captureOnly ? t('journey.photo.add') : t('journey.editor.uploadPhotos')}
|
||||
{captureOnly ? <Camera size={14} strokeWidth={2.2} className="shrink-0" /> : <Plus size={14} strokeWidth={2.2} className="shrink-0" />}
|
||||
<span className="truncate">{captureOnly ? t('journey.photo.add') : t('journey.editor.uploadPhotos')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -215,12 +200,14 @@ export default function MJourneyEntrySheet({
|
||||
: 'border-dashed border-[color:var(--m-rowbr)] text-m-muted'
|
||||
}`}
|
||||
>
|
||||
<Image size={14} strokeWidth={2} />
|
||||
{captureOnly ? t('journey.share.gallery') : t('journey.editor.fromGallery')}
|
||||
<Image size={14} strokeWidth={2} className="shrink-0" />
|
||||
<span className="truncate">{captureOnly ? t('journey.share.gallery') : t('journey.editor.fromGallery')}</span>
|
||||
</button>
|
||||
{/* Immich/Synology, the same source the desktop editor offers (#1808).
|
||||
Only shown once a provider is actually connected — on a phone a
|
||||
button that can only say "nothing connected" is not worth its width. */}
|
||||
Only shown once a provider is actually connected: on a phone a
|
||||
button that can only say "nothing connected" is not worth its width.
|
||||
The icons keep their size and the labels truncate, so a third
|
||||
button never squeezes the other two (#2908). */}
|
||||
{!captureOnly && providers.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -232,8 +219,8 @@ export default function MJourneyEntrySheet({
|
||||
: 'border-dashed border-[color:var(--m-rowbr)] text-m-muted'
|
||||
}`}
|
||||
>
|
||||
<Images size={14} strokeWidth={2} />
|
||||
{t('journey.editor.externalPhotos')}
|
||||
<Images size={14} strokeWidth={2} className="shrink-0" />
|
||||
<span className="truncate">{t('journey.editor.externalPhotos')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -627,11 +614,12 @@ export default function MJourneyEntrySheet({
|
||||
key={m.id}
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
aria-pressed={active}
|
||||
onClick={() => setMood(active ? '' : m.id)}
|
||||
className={`flex items-center gap-[5px] rounded-full border px-3 py-[7px] text-[0.71875rem] font-semibold ${
|
||||
active ? '' : 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted'
|
||||
}`}
|
||||
style={active ? { background: `${m.color}24`, color: m.color, borderColor: `${m.color}4D` } : undefined}
|
||||
style={active ? moodChipActiveStyle(m.color) : undefined}
|
||||
>
|
||||
<m.icon size={13} strokeWidth={2.2} />
|
||||
{t(m.labelKey)}
|
||||
|
||||
@@ -215,7 +215,7 @@ function useDefaultAtlasHandlers() {
|
||||
// country features override this handler via server.use(...).
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json({ type: 'FeatureCollection', features: [] })),
|
||||
// Handler for region GeoJSON fetch (triggered by loadRegionsForViewport when intersects=true)
|
||||
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
|
||||
http.get('/api/addons/atlas/regions/geo/:code', () => HttpResponse.json({ features: [] })),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1589,7 +1589,7 @@ describe('AtlasPage', () => {
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
|
||||
http.get('/api/addons/atlas/regions/geo/:code', () => HttpResponse.json({ features: [] })),
|
||||
);
|
||||
|
||||
render(<AtlasPage />);
|
||||
@@ -1746,7 +1746,7 @@ describe('AtlasPage', () => {
|
||||
type: 'FeatureCollection',
|
||||
features: [{ type: 'Feature', properties: { ISO_A2: 'FR', ADM0_A3: 'FRA', ISO_A3: 'FRA', NAME: 'France', ADMIN: 'France' }, geometry: null }],
|
||||
})),
|
||||
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({
|
||||
http.get('/api/addons/atlas/regions/geo/:code', () => HttpResponse.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [{ type: 'Feature', properties: { iso_a2: 'FR', iso_3166_2: 'FR-IDF', name: 'Île-de-France', name_en: 'Île-de-France', admin: 'France' }, geometry: null }],
|
||||
})),
|
||||
|
||||
@@ -213,7 +213,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
|
||||
onClose={() => setViewingEntry(null)}
|
||||
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
|
||||
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -602,7 +602,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
|
||||
readOnly={!canEditEntries}
|
||||
onEdit={() => setEditingEntry(entry)}
|
||||
onDelete={() => setDeleteTarget(entry)}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -656,7 +656,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
|
||||
userId={useAuthStore.getState().user?.id || 0}
|
||||
trips={current.trips}
|
||||
canEdit={canEditEntries}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
|
||||
onRefresh={() => loadJourney(Number(id))}
|
||||
onAddProviderPhotos={addPickedProviderPhotos}
|
||||
/>
|
||||
@@ -735,7 +735,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
|
||||
const entry = current.entries.find(e => String(e.id) === entryId)
|
||||
if (!entry?.photos?.length) return
|
||||
setLightbox({
|
||||
photos: entry.photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })),
|
||||
photos: entry.photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })),
|
||||
index,
|
||||
})
|
||||
}}
|
||||
|
||||
@@ -72,6 +72,20 @@ function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail'
|
||||
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`;
|
||||
}
|
||||
|
||||
type SharedPhoto = { id: number | string; photo_id: number; caption?: string | null; media_type?: string | null };
|
||||
|
||||
// The lightbox opens on the original and shows the thumbnail the page already
|
||||
// loaded until it arrives, instead of a black screen (#2907).
|
||||
function lightboxPhoto(p: SharedPhoto, shareToken: string) {
|
||||
return {
|
||||
id: String(p.id),
|
||||
src: photoUrl(p, shareToken, 'original'),
|
||||
thumbSrc: photoUrl(p, shareToken, 'thumbnail'),
|
||||
caption: p.caption ?? null,
|
||||
mediaType: p.media_type,
|
||||
};
|
||||
}
|
||||
|
||||
// The share route answers 404 for the thumbnail of a clip that has no poster, and
|
||||
// an <img> pointed at it draws the broken-image glyph (#2341). The black ground
|
||||
// the layouts give a video stands in for the picture.
|
||||
@@ -172,12 +186,7 @@ export default function JourneyPublicPage() {
|
||||
const picked = allPhotos.filter((p) => wanted.has(String(p.id)));
|
||||
if (!token || picked.length === 0) return;
|
||||
setLightbox({
|
||||
photos: picked.map((p) => ({
|
||||
id: String(p.id),
|
||||
src: photoUrl(p, token, 'original'),
|
||||
caption: p.caption,
|
||||
mediaType: p.media_type,
|
||||
})),
|
||||
photos: picked.map((p) => lightboxPhoto(p, token)),
|
||||
index: 0,
|
||||
});
|
||||
};
|
||||
@@ -243,12 +252,7 @@ export default function JourneyPublicPage() {
|
||||
const prosArr = entry.pros_cons?.pros ?? [];
|
||||
const consArr = entry.pros_cons?.cons ?? [];
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0;
|
||||
const lightboxPhotos = photos.map((p) => ({
|
||||
id: String(p.id),
|
||||
src: photoUrl(p, token!, 'original'),
|
||||
caption: p.caption,
|
||||
mediaType: p.media_type,
|
||||
}));
|
||||
const lightboxPhotos = photos.map((p) => lightboxPhoto(p, token!));
|
||||
|
||||
const isActive = activeEntryId === String(entry.id);
|
||||
return (
|
||||
@@ -559,12 +563,7 @@ export default function JourneyPublicPage() {
|
||||
className="relative block aspect-square w-full cursor-pointer overflow-hidden rounded-lg"
|
||||
onClick={() =>
|
||||
setLightbox({
|
||||
photos: allPhotos.map((p) => ({
|
||||
id: String(p.id),
|
||||
src: photoUrl(p, token!, 'original'),
|
||||
caption: p.caption,
|
||||
mediaType: p.media_type,
|
||||
})),
|
||||
photos: allPhotos.map((p) => lightboxPhoto(p, token!)),
|
||||
index: idx,
|
||||
})
|
||||
}
|
||||
@@ -760,12 +759,7 @@ export default function JourneyPublicPage() {
|
||||
onDelete={() => { }}
|
||||
onPhotoClick={(photos, idx) =>
|
||||
setLightbox({
|
||||
photos: photos.map((p) => ({
|
||||
id: String(p.id),
|
||||
src: photoUrl(p as any, token!, 'original'),
|
||||
caption: (p as any).caption ?? null,
|
||||
mediaType: p.media_type,
|
||||
})),
|
||||
photos: photos.map((p) => lightboxPhoto(p, token!)),
|
||||
index: idx,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ function renderTab(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
/** The card (a settingsKit SettingsCard, a <section>) whose <h2> matches the given heading text. */
|
||||
/** The card with the TREK services, the providers and the search log, apart from the keys. */
|
||||
const SERVICES = 'Place search and routing';
|
||||
|
||||
function card(heading: string | RegExp): HTMLElement {
|
||||
return screen.getByRole('heading', { name: heading }).closest<HTMLElement>('section')!;
|
||||
}
|
||||
@@ -305,14 +308,14 @@ describe('AdminSettingsTab', () => {
|
||||
// failing, which is quiet enough to be mistaken for the provider working.
|
||||
renderTab({ placesProvider: 'amap', hasAmapKey: false });
|
||||
expect(
|
||||
within(card('API Keys')).getByText(/TREK index and OpenStreetMap alone/i),
|
||||
within(card(SERVICES)).getByText(/TREK index and OpenStreetMap alone/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMSET-022d: saving the provider goes through updateAppSettings, not the keys', () => {
|
||||
const admin = renderTab({ placesProvider: 'auto' });
|
||||
// A CustomSelect: the trigger shows the current choice, the menu is portaled.
|
||||
fireEvent.click(within(card('API Keys')).getByRole('button', { name: 'Automatic' }));
|
||||
fireEvent.click(within(card(SERVICES)).getByRole('button', { name: 'Automatic' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Amap (高德地图)' }));
|
||||
expect(admin.handleSavePlacesProvider).toHaveBeenCalledWith('amap');
|
||||
});
|
||||
@@ -322,10 +325,9 @@ describe('AdminSettingsTab', () => {
|
||||
// still the admin's call (server managed.ts lists places_provider as theirs).
|
||||
renderTab({ managed: true, placesProvider: 'auto' });
|
||||
|
||||
const keys = within(card('API Keys'));
|
||||
expect(keys.queryByLabelText('Google Maps API Key')).not.toBeInTheDocument();
|
||||
expect(keys.queryByLabelText(/Amap/)).not.toBeInTheDocument();
|
||||
expect(keys.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: 'API Keys' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Google Maps API Key')).not.toBeInTheDocument();
|
||||
expect(within(card(SERVICES)).getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMSET-022f: an operator key keeps the managed install quiet', () => {
|
||||
@@ -334,7 +336,7 @@ describe('AdminSettingsTab', () => {
|
||||
renderTab({ managed: true, placesProvider: 'google', mapsKey: '', hasMapsKey: true });
|
||||
|
||||
expect(
|
||||
within(card('API Keys')).queryByText(/falls back to OpenStreetMap/i),
|
||||
within(card(SERVICES)).queryByText(/falls back to OpenStreetMap/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -479,10 +481,10 @@ describe('AdminSettingsTab', () => {
|
||||
expect(hint.textContent).toMatch(/provider/i);
|
||||
});
|
||||
|
||||
it('FE-ADMSET-031: the API keys card leads with the TREK index and no weather panel', () => {
|
||||
it('FE-ADMSET-031: the place search card leads with the TREK index and no weather panel', () => {
|
||||
renderTab();
|
||||
|
||||
const keys = card('API Keys');
|
||||
const keys = card(SERVICES);
|
||||
expect(within(keys).getByRole('img', { name: 'TREK Places API' })).toBeInTheDocument();
|
||||
expect(within(keys).getByText('Recommended default')).toBeInTheDocument();
|
||||
// Weather needs no key, so it left a card about keys. The panel itself lives
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import {
|
||||
AlertTriangle, CheckCircle, Eye, EyeOff, FileType, Fingerprint, KeyRound, Loader2, LockKeyhole, LogIn,
|
||||
AlertTriangle, CheckCircle, Compass, Eye, EyeOff, FileType, Fingerprint, KeyRound, Loader2, LockKeyhole, LogIn,
|
||||
RefreshCw, Save, ShieldCheck, XCircle,
|
||||
} from 'lucide-react'
|
||||
import ToggleSwitch from '../../components/Settings/ToggleSwitch'
|
||||
@@ -350,9 +350,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
/>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
{/* Allowed File Types */}
|
||||
<SettingsCard icon={FileType} title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')}>
|
||||
<div>
|
||||
@@ -372,24 +370,134 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
onClick={saveFileTypes}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
|
||||
{/* API Keys.
|
||||
The card itself stays on a managed install: the TREK index needs no key,
|
||||
and which keyed provider answers place search is the admin's call there
|
||||
too (server managed.ts). What folds away is the operator's: the keys,
|
||||
what a lookup costs, and the per-place Google switches. Those switches
|
||||
sit under the keys rather than flat beside them because they are set
|
||||
once and then never touched, and side by side a rarely-used option
|
||||
looked as important as the key it depends on. Weather is a quiet row
|
||||
for the same reason in reverse: it had the loudest treatment on the
|
||||
card and is the one thing here with nothing to configure. */}
|
||||
<div className="min-w-0">
|
||||
|
||||
{/* Place search and routing: TREK's own services first, then which provider
|
||||
answers search and transit, and the search log. Apart from the keys, so a
|
||||
keyless install reads its whole setup in one card. Everything here saves
|
||||
as it is changed. */}
|
||||
<SettingsCard icon={Compass} title={t('admin.servicesCard.title')} hint={t('admin.servicesCard.hint')}>
|
||||
<TrekApiCard t={t} managed={managed} />
|
||||
{!managed && (<>
|
||||
{/* Transit Backend (#1699) — Transitous has no GTFS for much of Asia,
|
||||
so an install can point transit search at Google instead, on the
|
||||
same key. Falls back to Transitous when no key resolves. A block of
|
||||
its own rather than a row inside the Google one: picking Transitous
|
||||
is a decision about a provider that has nothing to do with a key. */}
|
||||
<ProviderBlock title={t('admin.transitProvider.title')}>
|
||||
<SettingsHint>{t('admin.transitProvider.subtitle')}</SettingsHint>
|
||||
{/* The app's own select rather than the browser's: a native option list
|
||||
is drawn by the OS, so it ignores the scheme, the radius and the
|
||||
text-size setting the rest of this card follows. The menu portals to
|
||||
the body, which is what lets it escape the card's overflow-hidden. */}
|
||||
<CustomSelect
|
||||
value={transitProvider}
|
||||
onChange={async value => {
|
||||
// A native select stayed silent when the option already selected was
|
||||
// picked again; this one reports every pick, and a no-op PUT is still
|
||||
// a write on an audited settings route.
|
||||
if (value === transitProvider) return
|
||||
const next = value === 'google' ? 'google' : 'transitous'
|
||||
const previous = transitProvider
|
||||
setTransitProviderState(next)
|
||||
try {
|
||||
const saved = await adminApi.updateTransitProvider(next)
|
||||
setTransitGoogleKeySource(saved.googleKeySource)
|
||||
} catch (err: unknown) {
|
||||
// The select springs back on its own; without a word that reads
|
||||
// as a broken control, not as a save that failed.
|
||||
setTransitProviderState(previous)
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: 'transitous', label: t('admin.transitProvider.transitous') },
|
||||
{ value: 'google', label: t('admin.transitProvider.google') },
|
||||
]}
|
||||
/>
|
||||
<SettingsHint className="-mt-1.5">
|
||||
{transitProvider === 'google' ? t('admin.transitProvider.googleHint') : t('admin.transitProvider.transitousHint')}
|
||||
</SettingsHint>
|
||||
|
||||
{/* Picking Google without a key that resolves changes nothing — the
|
||||
request-time fallback is silent, so this is the only place it can
|
||||
be said. 'user-row' is the subtler half: the resolver's last step
|
||||
is the caller's own row, so a personal key serves this admin and
|
||||
nobody else. */}
|
||||
{transitProvider === 'google' && transitGoogleKeySource === null && (
|
||||
<Callout>{t('admin.transitProvider.noKeyWarning')}</Callout>
|
||||
)}
|
||||
{transitProvider === 'google' && transitGoogleKeySource === 'user-row' && (
|
||||
<Callout>{t('admin.transitProvider.personalKeyWarning')}</Callout>
|
||||
)}
|
||||
</ProviderBlock>
|
||||
</>)}
|
||||
{/* Which keyed provider answers place search. Outside the managed guard
|
||||
on purpose: the operator owns the credentials, but what their users
|
||||
search against is still the admin's call (see server managed.ts). */}
|
||||
<ProviderBlock title={t('admin.placesProvider.title')}>
|
||||
<SettingsHint>{t('admin.placesProvider.subtitle')}</SettingsHint>
|
||||
<div className={savingPlacesProvider ? 'opacity-60' : undefined}>
|
||||
<CustomSelect
|
||||
value={placesProvider}
|
||||
disabled={savingPlacesProvider}
|
||||
onChange={value => {
|
||||
if (value === placesProvider) return
|
||||
void handleSavePlacesProvider(String(value))
|
||||
}}
|
||||
options={[
|
||||
{ value: 'auto', label: t('admin.placesProvider.auto') },
|
||||
{ value: 'google', label: t('admin.placesProvider.google') },
|
||||
{ value: 'amap', label: t('admin.placesProvider.amap') },
|
||||
{ value: 'openstreetmap', label: t('admin.placesProvider.openstreetmap') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{/* A provider chosen without a key behind it answers with
|
||||
OpenStreetMap rather than failing, which is quiet enough to be
|
||||
mistaken for the provider working. Say so. Asked of app-config
|
||||
and not of the fields above: those are empty on a managed
|
||||
install and on an operator key that came from the environment,
|
||||
and saying "falls back to OpenStreetMap" to an admin whose
|
||||
search works is worse than saying nothing. */}
|
||||
{((placesProvider === 'google' && !hasMapsKey) || (placesProvider === 'amap' && !hasAmapKey)) && (
|
||||
<Callout>{t('admin.placesProvider.missingKey')}</Callout>
|
||||
)}
|
||||
</ProviderBlock>
|
||||
|
||||
{/* The search log sits with the index rather than with a provider:
|
||||
it records which result somebody picked, so a candidate index can
|
||||
be judged against real searches later. Off unless switched on,
|
||||
and nothing it records leaves the instance. */}
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('admin.placeShadow.title')}
|
||||
hint={t('admin.placeShadow.subtitle')}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
on={placeShadowEnabled}
|
||||
label={t('admin.placeShadow.title')}
|
||||
onToggle={() => togglePlaceShadow(!placeShadowEnabled)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
|
||||
</SettingsCard>
|
||||
|
||||
{/* API Keys: only the keyed providers, Google, Unsplash and Amap. They are
|
||||
the operator's on a managed install, so the card is not shown there;
|
||||
which provider answers search stays the admin's call in the card above
|
||||
(server managed.ts). The per-place Google switches sit under the key
|
||||
because they are set once and then never touched. */}
|
||||
{!managed && (
|
||||
<SettingsCard icon={KeyRound} title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')}>
|
||||
{/* Three comparable blocks, in the order we would have people choose
|
||||
them. Weather moved out entirely: it needs no key and had nothing
|
||||
to configure, so it had no business in a card about keys. */}
|
||||
<TrekApiCard t={t} managed={managed} />
|
||||
|
||||
{!managed && (<>
|
||||
<ProviderBlock title={t('admin.mapsKey')}>
|
||||
{/* Said before the field, not after it. Someone about to paste a key
|
||||
should read this while deciding, not once they already have. */}
|
||||
@@ -530,113 +638,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
/>
|
||||
<SettingsHint className="-mt-1.5">{t('admin.amapKeyHint')}</SettingsHint>
|
||||
</ProviderBlock>
|
||||
{/* Transit Backend (#1699) — Transitous has no GTFS for much of Asia,
|
||||
so an install can point transit search at Google instead, on the
|
||||
same key. Falls back to Transitous when no key resolves. A block of
|
||||
its own rather than a row inside the Google one: picking Transitous
|
||||
is a decision about a provider that has nothing to do with a key. */}
|
||||
<ProviderBlock title={t('admin.transitProvider.title')}>
|
||||
<SettingsHint>{t('admin.transitProvider.subtitle')}</SettingsHint>
|
||||
{/* The app's own select rather than the browser's: a native option list
|
||||
is drawn by the OS, so it ignores the scheme, the radius and the
|
||||
text-size setting the rest of this card follows. The menu portals to
|
||||
the body, which is what lets it escape the card's overflow-hidden. */}
|
||||
<CustomSelect
|
||||
value={transitProvider}
|
||||
onChange={async value => {
|
||||
// A native select stayed silent when the option already selected was
|
||||
// picked again; this one reports every pick, and a no-op PUT is still
|
||||
// a write on an audited settings route.
|
||||
if (value === transitProvider) return
|
||||
const next = value === 'google' ? 'google' : 'transitous'
|
||||
const previous = transitProvider
|
||||
setTransitProviderState(next)
|
||||
try {
|
||||
const saved = await adminApi.updateTransitProvider(next)
|
||||
setTransitGoogleKeySource(saved.googleKeySource)
|
||||
} catch (err: unknown) {
|
||||
// The select springs back on its own; without a word that reads
|
||||
// as a broken control, not as a save that failed.
|
||||
setTransitProviderState(previous)
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: 'transitous', label: t('admin.transitProvider.transitous') },
|
||||
{ value: 'google', label: t('admin.transitProvider.google') },
|
||||
]}
|
||||
/>
|
||||
<SettingsHint className="-mt-1.5">
|
||||
{transitProvider === 'google' ? t('admin.transitProvider.googleHint') : t('admin.transitProvider.transitousHint')}
|
||||
</SettingsHint>
|
||||
|
||||
{/* Picking Google without a key that resolves changes nothing — the
|
||||
request-time fallback is silent, so this is the only place it can
|
||||
be said. 'user-row' is the subtler half: the resolver's last step
|
||||
is the caller's own row, so a personal key serves this admin and
|
||||
nobody else. */}
|
||||
{transitProvider === 'google' && transitGoogleKeySource === null && (
|
||||
<Callout>{t('admin.transitProvider.noKeyWarning')}</Callout>
|
||||
)}
|
||||
{transitProvider === 'google' && transitGoogleKeySource === 'user-row' && (
|
||||
<Callout>{t('admin.transitProvider.personalKeyWarning')}</Callout>
|
||||
)}
|
||||
</ProviderBlock>
|
||||
</>)}
|
||||
|
||||
{/* Which keyed provider answers place search. Outside the managed guard
|
||||
on purpose: the operator owns the credentials, but what their users
|
||||
search against is still the admin's call (see server managed.ts). */}
|
||||
<ProviderBlock title={t('admin.placesProvider.title')}>
|
||||
<SettingsHint>{t('admin.placesProvider.subtitle')}</SettingsHint>
|
||||
<div className={savingPlacesProvider ? 'opacity-60' : undefined}>
|
||||
<CustomSelect
|
||||
value={placesProvider}
|
||||
disabled={savingPlacesProvider}
|
||||
onChange={value => {
|
||||
if (value === placesProvider) return
|
||||
void handleSavePlacesProvider(String(value))
|
||||
}}
|
||||
options={[
|
||||
{ value: 'auto', label: t('admin.placesProvider.auto') },
|
||||
{ value: 'google', label: t('admin.placesProvider.google') },
|
||||
{ value: 'amap', label: t('admin.placesProvider.amap') },
|
||||
{ value: 'openstreetmap', label: t('admin.placesProvider.openstreetmap') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{/* A provider chosen without a key behind it answers with
|
||||
OpenStreetMap rather than failing, which is quiet enough to be
|
||||
mistaken for the provider working. Say so. Asked of app-config
|
||||
and not of the fields above: those are empty on a managed
|
||||
install and on an operator key that came from the environment,
|
||||
and saying "falls back to OpenStreetMap" to an admin whose
|
||||
search works is worse than saying nothing. */}
|
||||
{((placesProvider === 'google' && !hasMapsKey) || (placesProvider === 'amap' && !hasAmapKey)) && (
|
||||
<Callout>{t('admin.placesProvider.missingKey')}</Callout>
|
||||
)}
|
||||
</ProviderBlock>
|
||||
|
||||
{/* The search log sits with the index rather than with a provider:
|
||||
it records which result somebody picked, so a candidate index can
|
||||
be judged against real searches later. Off unless switched on,
|
||||
and nothing it records leaves the instance. */}
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('admin.placeShadow.title')}
|
||||
hint={t('admin.placeShadow.subtitle')}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
on={placeShadowEnabled}
|
||||
label={t('admin.placeShadow.title')}
|
||||
onToggle={() => togglePlaceShadow(!placeShadowEnabled)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
|
||||
{!managed && <SaveBar onClick={handleSaveApiKeys} saving={savingKeys} label={t('common.save')} />}
|
||||
<SaveBar onClick={handleSaveApiKeys} saving={savingKeys} label={t('common.save')} />
|
||||
</SettingsCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fs } from '../../components/shared/DialogShell'
|
||||
import { StatusPill } from '../../components/Settings/settingsKit'
|
||||
import FoldSummary from './FoldSummary'
|
||||
import ServiceIdentityPanel from '../../components/Admin/ServiceIdentityPanel'
|
||||
import PlaceContribPanel from '../../components/Admin/PlaceContribPanel'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import {
|
||||
TREK_API_SOURCES as SOURCES,
|
||||
@@ -83,6 +84,7 @@ export default function TrekApiCard({ t, managed = false }: TrekApiCardProps): R
|
||||
</ul>
|
||||
|
||||
<ServiceIdentityPanel managed={managed} />
|
||||
<PlaceContribPanel />
|
||||
</div>
|
||||
|
||||
<details className="group border-t border-edge-faint">
|
||||
|
||||
@@ -274,7 +274,7 @@ function installAtlasHandlers(over: Partial<Record<string, unknown>> = {}) {
|
||||
http.get('/api/addons/atlas/bucket-list', () => HttpResponse.json({ items: over.bucket ?? [] })),
|
||||
http.get('/api/addons/atlas/regions', () => HttpResponse.json({ regions: over.regions ?? {} })),
|
||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(over.geo ?? { type: 'FeatureCollection', features: [] })),
|
||||
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json(over.regionGeo ?? { features: [] })),
|
||||
http.get('/api/addons/atlas/regions/geo/:code', () => HttpResponse.json(over.regionGeo ?? { features: [] })),
|
||||
http.get('/api/atlas-layers', () => HttpResponse.json({ layers: over.layers ?? [] })),
|
||||
);
|
||||
}
|
||||
@@ -1053,8 +1053,8 @@ describe('useAtlas', () => {
|
||||
await waitFor(() => expect(atlas.atlas_country_options.length).toBe(seen.length));
|
||||
|
||||
const requested: string[] = [];
|
||||
server.use(http.get('/api/addons/atlas/regions/geo', ({ request }) => {
|
||||
requested.push(new URL(request.url).searchParams.get('countries') ?? '');
|
||||
server.use(http.get('/api/addons/atlas/regions/geo/:code', ({ params }) => {
|
||||
requested.push(String(params.code));
|
||||
return HttpResponse.json(regionGeo);
|
||||
}));
|
||||
|
||||
|
||||
@@ -317,18 +317,18 @@ export function useAtlas() {
|
||||
}
|
||||
if (!toLoad.length) return
|
||||
for (const code of toLoad) pendingRegionCodes.current.add(code)
|
||||
apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`)
|
||||
// One stable URL per country, so a country evicted by REGION_CACHE_MAX or seen on an earlier
|
||||
// visit comes back from the browser cache. Every request settles; the layer rebuilds once.
|
||||
void Promise.all(toLoad.map(c => apiClient.get(`/addons/atlas/regions/geo/${c}`)
|
||||
.then(geoRes => {
|
||||
const geo = geoRes.data
|
||||
if (!geo?.features) return
|
||||
let added = false
|
||||
for (const c of toLoad) {
|
||||
const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c)
|
||||
if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; touchRegionCode(c); added = true }
|
||||
}
|
||||
if (added) { evictRegionCache(); setRegionGeoLoaded(v => v + 1) }
|
||||
const features = (geoRes.data?.features || []).filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c)
|
||||
if (features.length === 0) return false
|
||||
regionGeoCache.current[c] = { type: 'FeatureCollection', features }
|
||||
touchRegionCode(c)
|
||||
return true
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => false)))
|
||||
.then(added => { if (added.some(Boolean)) { evictRegionCache(); setRegionGeoLoaded(v => v + 1) } })
|
||||
.finally(() => { for (const code of toLoad) pendingRegionCodes.current.delete(code) })
|
||||
}
|
||||
loadRegionsForViewportRef.current = loadRegionsForViewport
|
||||
|
||||
@@ -470,7 +470,7 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
|
||||
const photos = (current?.gallery ?? []).filter(p => wanted.has(String(p.id)))
|
||||
if (!photos.length) return
|
||||
setLightbox({
|
||||
photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })),
|
||||
photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })),
|
||||
index: 0,
|
||||
})
|
||||
}, [current?.gallery])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-DETAIL-RECORDINGS-001 to FE-JRN-DETAIL-RECORDINGS-003
|
||||
// FE-JRN-DETAIL-RECORDINGS-001 to FE-JRN-DETAIL-RECORDINGS-004
|
||||
//
|
||||
// JRN-01: the journey page's map lines, with the days the journey keeps beside
|
||||
// the trips' GPX tracks and the live Dawarich trail.
|
||||
@@ -81,4 +81,15 @@ describe('useJourneyDetailRecordings', () => {
|
||||
});
|
||||
expect(setup({ dawarichStatus: 'disconnected' }).result.current.canTakeOverRecording).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-JRN-DETAIL-RECORDINGS-004: the live trail of a day with entries wears the colour of that day, a day without keeps its own (#2906)', async () => {
|
||||
repo.list.mockResolvedValue([]);
|
||||
const live = [
|
||||
line({ place_id: -1, trip_id: 0, kind: 'dawarich_live', date: '2026-05-14', color: '#2563EB' }),
|
||||
line({ place_id: -2, trip_id: 0, kind: 'dawarich_live', date: '2026-05-15', color: '#7C3AED' }),
|
||||
];
|
||||
const { result } = setup({ liveTracks: live });
|
||||
await waitFor(() => expect(repo.list).toHaveBeenCalledWith(7));
|
||||
expect(result.current.mapTracks.map((track) => track.color)).toEqual(['#aa0000', '#ff0055', '#7C3AED']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { DawarichSuggestionsStatus } from '../../hooks/useDawarichSuggestio
|
||||
/**
|
||||
* The recorded days a journey keeps (JRN-01) as the journey page wires them:
|
||||
* their lines in the colour of the pins of their day, beside the trips' GPX
|
||||
* tracks and the live Dawarich trail. A kept day is the journey's own, so it
|
||||
* tracks and the live Dawarich trail, which takes those colours too. A kept day is the journey's own, so it
|
||||
* draws whatever the GPX switch says, and wins over the live trail of its date.
|
||||
*/
|
||||
export function useJourneyDetailRecordings({
|
||||
@@ -42,9 +42,16 @@ export function useJourneyDetailRecordings({
|
||||
|
||||
const mapTracks = useMemo(() => {
|
||||
if (recordings.tracks.length === 0 && liveTracks.length === 0) return tripTracks;
|
||||
const live = liveTracks.filter((track) => !track.date || !recordings.byDate.has(track.date));
|
||||
// The live trail comes in Dawarich's own day palette; on a journey a day wears
|
||||
// the colour of its entries, so the line of a day with entries takes theirs (#2906).
|
||||
const live = liveTracks
|
||||
.filter((track) => !track.date || !recordings.byDate.has(track.date))
|
||||
.map((track) => {
|
||||
const color = track.date ? colorOfDay(track.date) : null;
|
||||
return color && color !== track.color ? { ...track, color } : track;
|
||||
});
|
||||
return [...tripTracks, ...recordings.tracks, ...live];
|
||||
}, [tripTracks, recordings.tracks, recordings.byDate, liveTracks]);
|
||||
}, [tripTracks, recordings.tracks, recordings.byDate, liveTracks, colorOfDay]);
|
||||
|
||||
return {
|
||||
recordings,
|
||||
|
||||
@@ -26,7 +26,7 @@ export function useJourneyPublic() {
|
||||
const [error, setError] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
||||
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null; mediaType?: string | null }[]; index: number } | null>(null)
|
||||
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; thumbSrc?: string; caption?: string | null; mediaType?: string | null }[]; index: number } | null>(null)
|
||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||
const locale = useSettingsStore(s => s.settings.language) || 'en'
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
placeContribLogResponseSchema,
|
||||
placeContribStateSchema,
|
||||
placeContribWithdrawResponseSchema,
|
||||
type PlaceContribDecisionRequest,
|
||||
type PlaceContribLogQuery,
|
||||
type PlaceContribLogResponse,
|
||||
type PlaceContribState,
|
||||
type PlaceContribWithdrawResponse,
|
||||
} from '@trek/shared';
|
||||
import { placeContribApi } from '../api/placeContrib';
|
||||
import { requireOnline } from './onlineOnly';
|
||||
|
||||
/**
|
||||
* The installation's decision on sharing place details, and what it sent.
|
||||
* **Online-only, no Dexie table**, like the service identity beside it: a
|
||||
* consent read from a stale copy would be worse than no answer.
|
||||
*/
|
||||
export const placeContribRepo = {
|
||||
async state(signal?: AbortSignal): Promise<PlaceContribState> {
|
||||
requireOnline();
|
||||
return placeContribStateSchema.parse(await placeContribApi.state(signal));
|
||||
},
|
||||
|
||||
async decide(body: PlaceContribDecisionRequest): Promise<PlaceContribState> {
|
||||
requireOnline();
|
||||
return placeContribStateSchema.parse(await placeContribApi.decide(body));
|
||||
},
|
||||
|
||||
async log(query: Partial<PlaceContribLogQuery>, signal?: AbortSignal): Promise<PlaceContribLogResponse> {
|
||||
requireOnline();
|
||||
return placeContribLogResponseSchema.parse(await placeContribApi.log(query, signal));
|
||||
},
|
||||
|
||||
async withdraw(): Promise<PlaceContribWithdrawResponse> {
|
||||
requireOnline();
|
||||
return placeContribWithdrawResponseSchema.parse(await placeContribApi.withdraw());
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { mapsApi } from '../api/client';
|
||||
import { requireOnline } from './onlineOnly';
|
||||
|
||||
/**
|
||||
* A provider's details of a place, looked up fresh (Google, the TREK index or
|
||||
* OpenStreetMap, by the id the place carries). **Online-only, no Dexie table:**
|
||||
* the point of asking is the current answer, and a cached one would offer the
|
||||
* same values again as if they were new.
|
||||
*/
|
||||
export const placeDetailsRepo = {
|
||||
async details(placeId: string, lang: string): Promise<Record<string, unknown> | null> {
|
||||
requireOnline();
|
||||
const answer = (await mapsApi.details(placeId, lang)) as { place?: Record<string, unknown> | null } | null;
|
||||
return answer?.place ?? null;
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-REPO-PLACE-001 to FE-REPO-PLACE-013
|
||||
// FE-REPO-PLACE-001 to FE-REPO-PLACE-013, FE-REPO-PLACE-030
|
||||
// Offline write paths (mutation queue + optimistic Dexie rows) and the bulk
|
||||
// online paths, which tests/unit/repo/placeRepo.test.ts does not touch.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
@@ -51,6 +51,21 @@ describe('placeRepo.create — offline', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeRepo — field origins offline', () => {
|
||||
it('FE-REPO-PLACE-030: the queued body carries fieldOrigins for the replay, the optimistic row does not', async () => {
|
||||
const { place } = await placeRepo.create(3, { name: 'Cafe', website: 'https://cafe.example', fieldOrigins: { website: 'user' } })
|
||||
const cached = await offlineDb.places.get(place.id)
|
||||
expect(cached).not.toHaveProperty('fieldOrigins')
|
||||
expect(place).not.toHaveProperty('fieldOrigins')
|
||||
|
||||
await placeRepo.update(3, place.id, { phone: '+49 1', fieldOrigins: { phone: 'user' } })
|
||||
const mutations = await queue()
|
||||
expect(mutations.find(m => m.method === 'POST')?.body).toEqual({ name: 'Cafe', website: 'https://cafe.example', fieldOrigins: { website: 'user' } })
|
||||
expect(mutations.find(m => m.method === 'PUT')?.body).toEqual({ phone: '+49 1', fieldOrigins: { phone: 'user' } })
|
||||
expect(await offlineDb.places.get(place.id)).not.toHaveProperty('fieldOrigins')
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeRepo.update — offline', () => {
|
||||
it('FE-REPO-PLACE-003: merges into the cached row and sends the concurrency token', async () => {
|
||||
await offlineDb.places.put(buildPlace({ id: 90, trip_id: 3, name: 'Old', updated_at: '2026-01-01T00:00:00.000Z' }))
|
||||
|
||||
@@ -5,6 +5,16 @@ import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { Place } from '../types'
|
||||
|
||||
/**
|
||||
* What of a write the optimistic row keeps. `fieldOrigins` says which fields the
|
||||
* user typed; it travels in the queued body for the server's origin tracking and
|
||||
* is no column of the place.
|
||||
*/
|
||||
function placeRow(data: Record<string, unknown>): Record<string, unknown> {
|
||||
const { fieldOrigins: _origins, ...row } = data
|
||||
return row
|
||||
}
|
||||
|
||||
export const placeRepo = {
|
||||
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[] }> {
|
||||
return onlineThenCache(
|
||||
@@ -25,7 +35,7 @@ export const placeRepo = {
|
||||
if (isEffectivelyOffline()) {
|
||||
const tempId = nextTempId()
|
||||
const tempPlace: Place = {
|
||||
...(data as Partial<Place>),
|
||||
...(placeRow(data) as Partial<Place>),
|
||||
id: tempId,
|
||||
trip_id: Number(tripId),
|
||||
name: (data.name as string) ?? 'New place',
|
||||
@@ -56,7 +66,7 @@ export const placeRepo = {
|
||||
// a row without it is invisible and never cleaned up.
|
||||
const optimistic: Place = {
|
||||
...(existing ?? {} as Place),
|
||||
...(data as Partial<Place>),
|
||||
...(placeRow(data) as Partial<Place>),
|
||||
id: Number(id),
|
||||
trip_id: Number(tripId),
|
||||
}
|
||||
|
||||
@@ -166,4 +166,24 @@ export const adminHandlers = [
|
||||
http.get('/api/admin/service-identity', () => {
|
||||
return HttpResponse.json({ state: 'legacy', tier: 'legacy', keySource: null, services: [], lastSeenAt: null });
|
||||
}),
|
||||
|
||||
// Sharing place details, inside the same card: not eligible, so no prompt opens in other tests.
|
||||
http.get('/api/admin/places-contribute', () => {
|
||||
const fields = { website: true, phone: true, email: true, opening_hours: true, description: true, image: true };
|
||||
return HttpResponse.json({
|
||||
termsVersion: 1,
|
||||
ceiling: 'admin',
|
||||
eligible: false,
|
||||
ineligibleReason: 'not_production',
|
||||
consent: { enabled: false, fields, termsVersion: null, decision: null, decidedBy: null, decidedAt: null },
|
||||
showPrompt: false,
|
||||
snoozedUntil: null,
|
||||
active: false,
|
||||
counts: { accepted: 0, duplicate: 0, rejected: 0, withheld: 0, retracted: 0 },
|
||||
lastSuccessDay: null,
|
||||
queue: { size: 0, cap: 5000, full: false },
|
||||
pauseReason: null,
|
||||
registrationOpen: false,
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -75,8 +75,9 @@ describe('getIntlLanguage', () => {
|
||||
// ── FE-COMP-I18N-007/008: isRtlLanguage ──────────────────────────────────────
|
||||
|
||||
describe('isRtlLanguage', () => {
|
||||
it('FE-COMP-I18N-007: returns true only for Arabic', () => {
|
||||
it('FE-COMP-I18N-007: returns true for Arabic and Hebrew', () => {
|
||||
expect(isRtlLanguage('ar')).toBe(true)
|
||||
expect(isRtlLanguage('he')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-COMP-I18N-008: returns false for all other supported languages', () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { http, HttpResponse } from 'msw';
|
||||
import { localIsoDate } from '../../../../src/utils/localDate';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { mapsApi, weatherApi } from '../../../../src/api/client';
|
||||
import { useAddonStore } from '../../../../src/store/addonStore';
|
||||
import MJourneyEntrySheet from '../../../../src/mobile/screens/journey/MJourneyEntrySheet';
|
||||
import type { GalleryPhoto, JourneyEntry, JourneyPhoto } from '../../../../src/store/journeyStore';
|
||||
import type { ResilientResult, UploadProgress } from '../../../../src/utils/uploadQueue';
|
||||
@@ -252,6 +251,7 @@ interface MountOptions {
|
||||
withProviderHook?: boolean;
|
||||
upload?: UploadFn;
|
||||
strict?: boolean;
|
||||
photoProviders?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const defaultUpload: UploadFn = async (_entryId, files, cbs) => {
|
||||
@@ -274,6 +274,7 @@ function mountSheet(sheetEntry: JourneyEntry, opts: MountOptions = {}) {
|
||||
readOnly={opts.readOnly ?? false}
|
||||
userId={42}
|
||||
trips={[]}
|
||||
photoProviders={opts.photoProviders}
|
||||
onClose={onClose}
|
||||
onSave={onSave}
|
||||
onUploadPhotos={onUploadPhotos}
|
||||
@@ -745,6 +746,18 @@ describe('MJourneyEntrySheet full editor', () => {
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ mood: null, weather: null });
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-2905: the picked neutral mood is framed in its own colour and pressed, unlike the chips at rest', async () => {
|
||||
const user = userEvent.setup();
|
||||
mountSheet(buildEntry({ id: 5 }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Neutral' }));
|
||||
const neutral = screen.getByRole('button', { name: 'Neutral' });
|
||||
expect(neutral).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(neutral.className).not.toContain('text-m-muted');
|
||||
expect(neutral.style.borderColor).toBe('rgb(154, 154, 161)');
|
||||
expect(screen.getByRole('button', { name: 'Good' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-022: picks a searched location and stores its coordinates', async () => {
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({
|
||||
places: [
|
||||
@@ -1161,15 +1174,10 @@ describe('MJourneyEntrySheet quick capture edge cases', () => {
|
||||
// FE-MOB-JENTRY-041 to FE-MOB-JENTRY-047 — external photo providers in the phone
|
||||
// editor (#1808), mirroring the desktop EntryEditor mechanics.
|
||||
|
||||
const immichAddon = { id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'camera', enabled: true };
|
||||
|
||||
function seedAddons(addons: Array<Record<string, unknown>>) {
|
||||
useAddonStore.setState({ addons: addons as never, loaded: true });
|
||||
}
|
||||
const immich = [{ id: 'immich', name: 'Immich' }];
|
||||
|
||||
function useConnectedImmich() {
|
||||
server.use(
|
||||
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
|
||||
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({
|
||||
assets: [{ id: 'asset-1', takenAt: '2026-03-15T09:00:00.000Z', mediaType: 'image' }],
|
||||
hasMore: false,
|
||||
@@ -1185,27 +1193,32 @@ describe('MJourneyEntrySheet external photos', () => {
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
useAddonStore.setState({ addons: [], loaded: false });
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-041: hides the button while no provider is connected', async () => {
|
||||
let probes = 0;
|
||||
seedAddons([immichAddon]);
|
||||
server.use(http.get('/api/integrations/memories/immich/status', () => {
|
||||
probes += 1;
|
||||
return HttpResponse.json({ connected: false });
|
||||
}));
|
||||
it('FE-MOB-JENTRY-041: hides the button while no provider is connected', () => {
|
||||
mountSheet(buildEntry({ id: 5 }));
|
||||
|
||||
await waitFor(() => expect(probes).toBe(1));
|
||||
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-2908: a connected provider is there on the first render, without a probe of its own', () => {
|
||||
let probes = 0;
|
||||
server.use(http.get('/api/integrations/memories/immich/status', () => {
|
||||
probes += 1;
|
||||
return HttpResponse.json({ connected: true });
|
||||
}));
|
||||
mountSheet(buildEntry({ id: 5 }), { photoProviders: immich });
|
||||
|
||||
const external = screen.getByRole('button', { name: 'External photos' });
|
||||
// The icons keep their width when a third button shares the row.
|
||||
expect(external.querySelector('svg')).toHaveClass('shrink-0');
|
||||
expect(screen.getByRole('button', { name: 'From Gallery' }).querySelector('svg')).toHaveClass('shrink-0');
|
||||
expect(probes).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-042: opens the embedded picker for a connected provider', async () => {
|
||||
seedAddons([immichAddon, { id: 'budget', name: 'Budget', type: 'feature', icon: 'wallet', enabled: true }]);
|
||||
useConnectedImmich();
|
||||
const user = userEvent.setup();
|
||||
mountSheet(buildEntry({ id: 5, location_lat: 41.9, location_lng: 12.5, location_name: 'Rome' }));
|
||||
mountSheet(buildEntry({ id: 5, location_lat: 41.9, location_lng: 12.5, location_name: 'Rome' }), { photoProviders: immich });
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'External photos' }));
|
||||
|
||||
@@ -1216,20 +1229,18 @@ describe('MJourneyEntrySheet external photos', () => {
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-043: stays out of the quick capture sheet', async () => {
|
||||
seedAddons([immichAddon]);
|
||||
useConnectedImmich();
|
||||
Object.defineProperty(navigator, 'geolocation', { configurable: true, value: undefined });
|
||||
mountSheet(buildEntry(), { quickCapture: true });
|
||||
mountSheet(buildEntry(), { quickCapture: true, photoProviders: immich });
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Gallery' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-044: queues a picked photo and sends it after the save', async () => {
|
||||
seedAddons([immichAddon]);
|
||||
useConnectedImmich();
|
||||
const user = userEvent.setup();
|
||||
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry({ id: 5 }));
|
||||
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry({ id: 5 }), { photoProviders: immich });
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'External photos' }));
|
||||
await user.click(await screen.findByAltText(''));
|
||||
@@ -1249,10 +1260,9 @@ describe('MJourneyEntrySheet external photos', () => {
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-045: drops the queue again from the panel header', async () => {
|
||||
seedAddons([immichAddon]);
|
||||
useConnectedImmich();
|
||||
const user = userEvent.setup();
|
||||
const { onAddProviderPhotos } = mountSheet(buildEntry({ id: 5 }));
|
||||
const { onAddProviderPhotos } = mountSheet(buildEntry({ id: 5 }), { photoProviders: immich });
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'External photos' }));
|
||||
await user.click(await screen.findByAltText(''));
|
||||
@@ -1265,11 +1275,10 @@ describe('MJourneyEntrySheet external photos', () => {
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-046: a failed provider add keeps the queue and does not create a second entry', async () => {
|
||||
seedAddons([immichAddon]);
|
||||
useConnectedImmich();
|
||||
const user = userEvent.setup();
|
||||
// A brand new entry: the first save creates it, the provider write then fails.
|
||||
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry());
|
||||
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry(), { photoProviders: immich });
|
||||
onAddProviderPhotos.mockRejectedValueOnce(new Error('immich down'));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'External photos' }));
|
||||
@@ -1293,10 +1302,9 @@ describe('MJourneyEntrySheet external photos', () => {
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-047: a skeleton with only provider photos is promoted to an entry', async () => {
|
||||
seedAddons([immichAddon]);
|
||||
useConnectedImmich();
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton', title: 'Venice' }));
|
||||
const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton', title: 'Venice' }), { photoProviders: immich });
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'External photos' }));
|
||||
await user.click(await screen.findByAltText(''));
|
||||
|
||||
@@ -61,6 +61,7 @@ services:
|
||||
# - UNSPLASH_ACCESS_KEY= # Optional. Unsplash Access Key for trip-cover / place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from (#1449). Free key at unsplash.com/developers. Can also be set per-admin in Admin > Settings; the env var overrides that.
|
||||
# - TREK_PLACES_ENABLED=false # Optional. Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads, and the OpenStreetMap ways and horse routes Tours reads) asks the TREK Places API at https://places.liketrek.com by default, so the server makes outbound requests to that host. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing there. No switch in the admin panel on purpose: whether searches leave the instance is decided here. (default: true)
|
||||
# - TREK_PLACES_URL=https://places.liketrek.com # Optional. Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from configuration rather than typed by a user, so an address on your LAN or Docker network works without ALLOW_INTERNAL_NETWORK. (default: https://places.liketrek.com)
|
||||
# - TREK_PLACES_CONTRIBUTE=admin # Optional. Whether an admin may let this installation send the place details its users typed in (website, phone, e-mail, opening hours, description, picture) to the TREK Places API, so every installation gets better details. admin leaves the choice to the admin panel, where it stays off until an admin agrees; off takes it away and nothing is ever sent. (default: admin)
|
||||
# - TREK_SERVICES=off # Optional. Places search identifies this installation with a key it registers by itself on first use. off sends no key; searches then use the shared pool. (default: auto)
|
||||
# - TREK_SERVICES_KEY= # Optional. A key issued for this installation; it wins over the stored one and turns self-registration off.
|
||||
# - TREK_PHOTON_URL=https://photon.liketrek.com # Optional. The address search of the TREK services. With a key, search and reverse lookups ask it before Nominatim and fall back to Nominatim when it does not answer; never asked when NOMINATIM_URL is set. (default: https://photon.liketrek.com)
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"version": "5.0.1",
|
||||
"version": "5.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@trek/root",
|
||||
"version": "5.0.1",
|
||||
"version": "5.1.0",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@trek/client",
|
||||
"version": "5.0.1",
|
||||
"version": "5.1.0",
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@fontsource/abril-fatface": "^5.3.0",
|
||||
@@ -25005,7 +25005,7 @@
|
||||
},
|
||||
"server": {
|
||||
"name": "@trek/server",
|
||||
"version": "5.0.1",
|
||||
"version": "5.1.0",
|
||||
"dependencies": {
|
||||
"@aws-lite/client": "^0.23.7",
|
||||
"@aws-lite/s3": "^0.2.7",
|
||||
@@ -25384,7 +25384,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@trek/shared",
|
||||
"version": "5.0.1",
|
||||
"version": "5.1.0",
|
||||
"dependencies": {
|
||||
"isomorphic-dompurify": "^3.23.0",
|
||||
"zod": "^4.6.7"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "5.0.1",
|
||||
"version": "5.1.0",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -70,6 +70,7 @@ DEMO_MODE=false # Demo mode: resets data hourly
|
||||
|
||||
# TREK_PLACES_ENABLED=false # Whether place search asks the TREK Places API at https://places.liketrek.com: suggestions, the full search, the category buttons on the map, the Road trip search along the route, import geocoding, offline downloads, and the OpenStreetMap ways and horse routes Tours reads. It is on by default and has no switch in the admin panel, because whether searches leave the instance is a deployment decision. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing to that host. (default: true)
|
||||
# TREK_PLACES_URL=https://places.liketrek.com # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from configuration rather than typed by a user, so an address on your LAN or Docker network works without ALLOW_INTERNAL_NETWORK. (default: https://places.liketrek.com)
|
||||
# TREK_PLACES_CONTRIBUTE=admin # Whether an admin may let this installation send place details its users typed in (website, phone, e-mail, opening hours, description, picture) to the TREK Places API, so every installation gets better details. admin shows the choice in the admin panel, where it stays off until an admin agrees; off takes the choice away and nothing is ever sent. Nothing is sent outside NODE_ENV=production, in demo mode, with a custom TREK_PLACES_URL or with TREK_SERVICES=off. (default: admin)
|
||||
# TREK_SERVICES=auto # How this installation identifies itself to the TREK services. On first use it registers itself in the background and keeps a key in the database; nothing to click, no account. off sends no key, and searches use the shared pool. (default: auto)
|
||||
# TREK_SERVICES_KEY= # A key issued to you for this installation. Wins over the stored one and turns self-registration off; meant for hosted and pre-provisioned instances. (default: none)
|
||||
# TREK_SERVICES_HOSTS=places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com # Comma-separated hosts that receive the installation key over https. No other host ever sees it. (default: places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com)
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user