mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
Compare commits
67 Commits
v3.0.4
...
1cecaa1d30
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cecaa1d30 | |||
| c375e0d6f7 | |||
| 126f2df21b | |||
| 324d930ca3 | |||
| e050814c42 | |||
| c130ed41be | |||
| db5c403239 | |||
| bd29fcb0c0 | |||
| be71cae0d3 | |||
| ee2089e81d | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 | |||
| e27be5c965 | |||
| 86ee8044da | |||
| 75772445a7 | |||
| bfe6664ac4 | |||
| 117942f45e | |||
| e7211325df | |||
| 7e49f3467c | |||
| 93b51a0bf5 | |||
| 5b710a429a | |||
| da3cba2de3 | |||
| 7f87dc1ce1 | |||
| e7b419d397 | |||
| de3152ee57 | |||
| de6c0fb781 | |||
| 9f1d05e886 | |||
| 25f326a659 | |||
| 418f3e0bb2 | |||
| 640e5616e9 | |||
| 22f3bf4bfc | |||
| 256f38d8fa | |||
| 9592cc663f | |||
| dba4b28380 | |||
| 51b5bd6966 | |||
| 6072b969d6 | |||
| 4ae4e0c676 | |||
| 51ab30f436 | |||
| 8b53948231 | |||
| 78d6f2ba77 | |||
| bb89d70a94 | |||
| ad9f3887d8 | |||
| 7f1fb508db | |||
| 1f5deeba6c | |||
| ca832e8d88 | |||
| 12fc7f7b68 | |||
| 2770a189df | |||
| 2b162a8cc7 | |||
| 009d89fecf | |||
| 5c3b89578d | |||
| 303e7de433 | |||
| 08eb7f3733 | |||
| 90d86eda61 | |||
| 0eca6d54a1 | |||
| bc1fb71391 | |||
| cb425fb397 | |||
| 35ed712d46 | |||
| 4923973380 | |||
| 8342cf3010 | |||
| 2a37eeccb3 | |||
| ae0e59d9f1 | |||
| 50bb7573fd | |||
| b852317c84 | |||
| 4436b6f673 | |||
| 311647fd46 | |||
| 28dbd86d03 |
@@ -2,6 +2,7 @@ node_modules
|
|||||||
client/node_modules
|
client/node_modules
|
||||||
server/node_modules
|
server/node_modules
|
||||||
client/dist
|
client/dist
|
||||||
|
shared/dist
|
||||||
data
|
data
|
||||||
uploads
|
uploads
|
||||||
.git
|
.git
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ body:
|
|||||||
- Docker (standalone)
|
- Docker (standalone)
|
||||||
- Kubernetes / Helm
|
- Kubernetes / Helm
|
||||||
- Unraid template
|
- Unraid template
|
||||||
|
- Proxmox Community Script
|
||||||
- Sources
|
- Sources
|
||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
## Checklist
|
## Checklist
|
||||||
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
||||||
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||||
- [ ] This PR targets the `dev` branch, not `main`
|
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
|
||||||
- [ ] I have tested my changes locally
|
- [ ] I have tested my changes locally
|
||||||
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
||||||
- [ ] I have updated documentation if needed
|
- [ ] I have updated documentation if needed
|
||||||
|
|||||||
@@ -26,9 +26,36 @@ jobs:
|
|||||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
for (const pull of pulls) {
|
for (const pull of pulls) {
|
||||||
|
const hasBypass = pull.labels.some(l => l.name === 'bypass-branch-check');
|
||||||
|
if (hasBypass) continue;
|
||||||
|
|
||||||
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
||||||
if (!hasLabel) continue;
|
if (!hasLabel) continue;
|
||||||
|
|
||||||
|
// Wiki-only PRs are exempt — clear label and skip
|
||||||
|
const files = [];
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const { data } = await github.rest.pulls.listFiles({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: pull.number,
|
||||||
|
per_page: 100,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
files.push(...data);
|
||||||
|
if (data.length < 100) break;
|
||||||
|
}
|
||||||
|
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||||
|
if (allWiki) {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pull.number,
|
||||||
|
name: 'wrong-base-branch',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const createdAt = new Date(pull.created_at);
|
const createdAt = new Date(pull.created_at);
|
||||||
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ on:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- 'wiki/**'
|
- 'wiki/**'
|
||||||
- '.github/workflows/wiki.yml'
|
- '.github/workflows/**'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
bump:
|
bump:
|
||||||
@@ -99,16 +102,15 @@ jobs:
|
|||||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
||||||
|
|
||||||
# Update package.json files and Helm chart
|
# Update all workspace + root package.json files and the root lockfile in one shot
|
||||||
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
|
||||||
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
|
||||||
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||||
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
||||||
|
|
||||||
# Commit and tag
|
# Commit and tag
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
|
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
|
||||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||||
git tag "v$NEW_VERSION"
|
git tag "v$NEW_VERSION"
|
||||||
git push origin main --follow-tags
|
git push origin main --follow-tags
|
||||||
|
|||||||
@@ -21,6 +21,39 @@ jobs:
|
|||||||
const labels = context.payload.pull_request.labels.map(l => l.name);
|
const labels = context.payload.pull_request.labels.map(l => l.name);
|
||||||
const prNumber = context.payload.pull_request.number;
|
const prNumber = context.payload.pull_request.number;
|
||||||
|
|
||||||
|
// bypass-branch-check label skips all enforcement
|
||||||
|
if (labels.includes('bypass-branch-check')) {
|
||||||
|
console.log('bypass-branch-check label present, skipping enforcement.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wiki-only PRs are exempt from branch enforcement
|
||||||
|
const files = [];
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const { data } = await github.rest.pulls.listFiles({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: prNumber,
|
||||||
|
per_page: 100,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
files.push(...data);
|
||||||
|
if (data.length < 100) break;
|
||||||
|
}
|
||||||
|
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||||
|
if (allWiki) {
|
||||||
|
console.log('All changed files are under wiki/ — skipping enforcement.');
|
||||||
|
if (labels.includes('wrong-base-branch')) {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
name: 'wrong-base-branch',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If the base was fixed, remove the label and let it through
|
// If the base was fixed, remove the label and let it through
|
||||||
if (base !== 'main') {
|
if (base !== 'main') {
|
||||||
if (labels.includes('wrong-base-branch')) {
|
if (labels.includes('wrong-base-branch')) {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scout:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: trek:scan
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/scout-action@v1
|
||||||
|
with:
|
||||||
|
command: cves
|
||||||
|
image: trek:scan
|
||||||
|
only-severities: critical,high
|
||||||
|
exit-code: true
|
||||||
@@ -8,10 +8,33 @@ on:
|
|||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
paths:
|
paths:
|
||||||
- 'server/**'
|
- 'server/**'
|
||||||
- '.github/workflows/test.yml'
|
|
||||||
- 'client/**'
|
- 'client/**'
|
||||||
|
- 'shared/**'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
shared-contracts:
|
||||||
|
name: Shared Contracts (Zod)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspace shared
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: cd shared && npm run typecheck
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cd shared && npm test
|
||||||
|
|
||||||
server-tests:
|
server-tests:
|
||||||
name: Server Tests
|
name: Server Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -21,12 +44,24 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: server/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd server && npm ci
|
run: npm ci --workspace shared && npm ci --workspace server
|
||||||
|
|
||||||
|
- name: Build shared
|
||||||
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
|
- name: Build server (tsc -> dist)
|
||||||
|
run: cd server && npm run build
|
||||||
|
|
||||||
|
- name: Typecheck (informational)
|
||||||
|
# Pre-existing type errors in the NestJS rewrite; surfaces them without
|
||||||
|
# blocking CI. Ratchet to blocking once the legacy code is cleaned up.
|
||||||
|
continue-on-error: true
|
||||||
|
run: cd server && npm run typecheck
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cd server && npm run test:coverage
|
run: cd server && npm run test:coverage
|
||||||
@@ -48,12 +83,15 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: client/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd client && npm ci
|
run: npm ci --workspace shared && npm ci --workspace client
|
||||||
|
|
||||||
|
- name: Build shared
|
||||||
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cd client && npm run test:coverage
|
run: cd client && npm run test:coverage
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ node_modules/
|
|||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
client/dist/
|
client/dist/
|
||||||
|
server/dist/
|
||||||
|
shared/dist/
|
||||||
|
server/public/*
|
||||||
|
!server/public/.gitkeep
|
||||||
|
|
||||||
# Generated PWA icons (built from SVG via prebuild)
|
# Generated PWA icons (built from SVG via prebuild)
|
||||||
client/public/icons/*.png
|
client/public/icons/*.png
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
|||||||
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
||||||
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
|
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
|
||||||
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
||||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
|
||||||
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||||
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
||||||
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||||
|
|||||||
+52
-19
@@ -1,28 +1,60 @@
|
|||||||
# Stage 1: Build React client
|
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS client-builder
|
FROM node:24-alpine AS shared-builder
|
||||||
WORKDIR /app/client
|
WORKDIR /app
|
||||||
COPY client/package*.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
COPY shared/package.json ./shared/
|
||||||
COPY client/ ./
|
RUN npm ci --workspace=shared
|
||||||
RUN npm run build
|
COPY shared/ ./shared/
|
||||||
|
RUN npm run build --workspace=shared
|
||||||
|
|
||||||
# Stage 2: Production server
|
# ── Stage 2: client ──────────────────────────────────────────────────────────
|
||||||
FROM node:22-alpine
|
FROM node:24-alpine AS client-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY client/package.json ./client/
|
||||||
|
RUN npm ci --workspace=client
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY client/ ./client/
|
||||||
|
RUN npm run build --workspace=client
|
||||||
|
|
||||||
|
# ── Stage 3: server ──────────────────────────────────────────────────────────
|
||||||
|
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
|
||||||
|
FROM node:24-alpine AS server-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
RUN npm ci --workspace=server --ignore-scripts
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY server/ ./server/
|
||||||
|
RUN npm run build --workspace=server
|
||||||
|
|
||||||
|
# ── Stage 4: production runtime ──────────────────────────────────────────────
|
||||||
|
FROM node:24-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Timezone support + native deps (better-sqlite3 needs build tools)
|
# Workspace manifests only — source never enters this stage.
|
||||||
COPY server/package*.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
COPY shared/package.json ./shared/
|
||||||
npm ci --production && \
|
COPY server/package.json ./server/
|
||||||
apk del python3 make g++
|
|
||||||
|
|
||||||
COPY server/ ./
|
# better-sqlite3 native addon requires build tools; purged after install.
|
||||||
COPY --from=client-builder /app/client/dist ./public
|
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
npm ci --workspace=server --omit=dev && \
|
||||||
|
apk del python3 make g++ && \
|
||||||
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
|
||||||
|
COPY --from=server-builder /app/server/dist ./server/dist
|
||||||
|
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||||
|
COPY server/tsconfig.json ./server/
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY --from=client-builder /app/client/dist ./server/public
|
||||||
|
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||||
|
|
||||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
ln -s /app/uploads /app/server/uploads && \
|
||||||
|
ln -s /app/data /app/server/data && \
|
||||||
chown -R node:node /app
|
chown -R node:node /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -36,4 +68,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|||||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||||
|
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec su-exec node node --require tsconfig-paths/register dist/index.js"]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||||
|
|
||||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||||
|
|
||||||
@@ -400,6 +400,7 @@ Caddy handles TLS and WebSockets automatically.
|
|||||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||||
|
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
|
|||||||
If you discover a security vulnerability, please report it responsibly:
|
If you discover a security vulnerability, please report it responsibly:
|
||||||
|
|
||||||
1. **Do not** open a public issue
|
1. **Do not** open a public issue
|
||||||
2. Email: **mauriceboe@icloud.com**
|
2. Email: **report@liketrek.com**
|
||||||
3. Include a description of the vulnerability and steps to reproduce
|
3. Include a description of the vulnerability and steps to reproduce
|
||||||
|
|
||||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
# Trademark Policy
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
This is the TREK project's policy for the use of our trademarks. While TREK is
|
||||||
|
available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
|
||||||
|
license does not include a license to use our trademarks.
|
||||||
|
|
||||||
|
This policy describes how you may use our trademarks. Our goal is to strike a
|
||||||
|
balance between: 1) our need to ensure that our trademarks remain reliable
|
||||||
|
indicators of the software we release; and 2) our community members' desire to
|
||||||
|
be full participants in the TREK project.
|
||||||
|
|
||||||
|
## Our trademarks
|
||||||
|
|
||||||
|
This policy covers the name "TREK" as well as any associated logos, trade dress,
|
||||||
|
goodwill, or designs (our "Marks").
|
||||||
|
|
||||||
|
## In general
|
||||||
|
|
||||||
|
Whenever you use our Marks, you must always do so in a way that does not mislead
|
||||||
|
anyone about exactly who is the source of the software. For example, you cannot
|
||||||
|
say you are distributing TREK when you're distributing a modified version of it,
|
||||||
|
because people would think they would be getting the same software that they
|
||||||
|
can get directly from us when they aren't. You also cannot use our Marks on
|
||||||
|
your website in a way that suggests that your website is an official TREK
|
||||||
|
website or that we endorse your website. But, if true, you can say you like
|
||||||
|
TREK, that you participate in the TREK community, that you are providing an
|
||||||
|
unmodified version of TREK, or that you wrote a guide describing how to use
|
||||||
|
TREK.
|
||||||
|
|
||||||
|
This fundamental requirement, that it is always clear to people what they are
|
||||||
|
getting and from whom, is reflected throughout this policy. It should also
|
||||||
|
serve as your guide if you are not sure about how you are using the Marks.
|
||||||
|
|
||||||
|
In addition:
|
||||||
|
|
||||||
|
* You may not use or register, in whole or in part, the Marks as part of your
|
||||||
|
own trademark, service mark, domain name, company name, trade name, product
|
||||||
|
name or service name.
|
||||||
|
* Trademark law does not allow your use of names or trademarks that are too
|
||||||
|
similar to ours. You therefore may not use an obvious variation of any of our
|
||||||
|
Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
|
||||||
|
abbreviation for a similar or compatible product or service.
|
||||||
|
* You agree that you will not acquire any rights in the Marks and that any
|
||||||
|
goodwill generated by your use of the Marks and participation in our
|
||||||
|
community inures solely to our benefit.
|
||||||
|
|
||||||
|
## Distribution of unmodified source code or unmodified executable code we have compiled
|
||||||
|
|
||||||
|
When you redistribute an unmodified copy of TREK, you are not changing the
|
||||||
|
quality or nature of it. Therefore, you may retain the Marks we have placed on
|
||||||
|
the software to identify your redistribution. This kind of use only applies if
|
||||||
|
you are redistributing an official TREK distribution that has not been changed
|
||||||
|
in any way.
|
||||||
|
|
||||||
|
## Distribution of executable code that you have compiled, or modified code
|
||||||
|
|
||||||
|
You may use the word mark "TREK", but not any TREK logos, to truthfully
|
||||||
|
describe the origin of the software that you are providing, that is, that the
|
||||||
|
code you are distributing is a modification of TREK. You may say, for example,
|
||||||
|
that "this software is derived from the source code for TREK."
|
||||||
|
|
||||||
|
Of course, you can place your own trademarks or logos on versions of the
|
||||||
|
software to which you have made substantive modifications, because by modifying
|
||||||
|
the software, you have become the origin of that exact version. In that case,
|
||||||
|
you should not use our Marks.
|
||||||
|
|
||||||
|
However, you may use our Marks for the distribution of code (source or
|
||||||
|
executable) on the condition that any executable is built from an official TREK
|
||||||
|
source code release and that any modifications are limited to switching on or
|
||||||
|
off features already included in the software, translations into other
|
||||||
|
languages, and incorporating minor bug-fix patches. Use of our Marks on any
|
||||||
|
further modification is not permitted.
|
||||||
|
|
||||||
|
## Mobile wrappers, hosted instances, and forks
|
||||||
|
|
||||||
|
The following clarifications apply specifically to common ways TREK is
|
||||||
|
redistributed:
|
||||||
|
|
||||||
|
* **Self-hosted instances of unmodified TREK.** You may refer to your instance
|
||||||
|
as "a TREK instance" or "running TREK." You may not name the service itself
|
||||||
|
in a way that suggests it is the official TREK ("TREK Cloud," "TREK
|
||||||
|
Official," etc.).
|
||||||
|
* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
|
||||||
|
TREK.** You may describe your app as "a mobile client for TREK" or "for use
|
||||||
|
with TREK." You may not publish it on app stores under the name "TREK" or a
|
||||||
|
confusingly similar name, and you may not use the TREK logo as the app icon
|
||||||
|
unless your wrapper distributes only an unmodified, official TREK instance
|
||||||
|
and you have obtained permission.
|
||||||
|
* **Forks of the TREK source code.** Forks that diverge from upstream must use
|
||||||
|
a different name. You may state that your fork is "based on TREK" or "a fork
|
||||||
|
of TREK," but the project name itself must be your own.
|
||||||
|
|
||||||
|
## Statements about your software's relation to TREK
|
||||||
|
|
||||||
|
You may use the word mark, but not TREK logos, to truthfully describe the
|
||||||
|
relationship between your software and ours. The word mark "TREK" should be
|
||||||
|
used after a verb or preposition that describes the relationship between your
|
||||||
|
software and ours. So you may say, for example, "Bob's app for TREK" but may
|
||||||
|
not say "Bob's TREK app." Some other examples that may work for you are:
|
||||||
|
|
||||||
|
* [Your software] uses TREK
|
||||||
|
* [Your software] is powered by TREK
|
||||||
|
* [Your software] runs on TREK
|
||||||
|
* [Your software] for use with TREK
|
||||||
|
* [Your software] for TREK
|
||||||
|
|
||||||
|
## Questions and permission requests
|
||||||
|
|
||||||
|
If you are not sure whether your intended use of the Marks is permitted under
|
||||||
|
this policy, or if you would like to request explicit permission for a use that
|
||||||
|
is not covered, please open an issue on the TREK GitHub repository or contact
|
||||||
|
the maintainers directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
These guidelines are based on the
|
||||||
|
[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
|
||||||
|
under a
|
||||||
|
[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
CLIENT_DIR="$REPO_ROOT/client"
|
||||||
|
SERVER_DIR="$REPO_ROOT/server"
|
||||||
|
PUBLIC_DIR="$REPO_ROOT/server/public"
|
||||||
|
|
||||||
|
echo "==> Installing client dependencies"
|
||||||
|
cd "$CLIENT_DIR"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
echo "==> Building client"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "==> Installing server dependencies"
|
||||||
|
cd "$SERVER_DIR"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
echo "==> Populating server/public"
|
||||||
|
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
|
||||||
|
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
|
||||||
|
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
|
||||||
|
|
||||||
|
echo "==> Done — server/public is ready"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 3.0.4
|
version: 3.0.22
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "3.0.4"
|
appVersion: "3.0.22"
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ data:
|
|||||||
{{- if .Values.env.FORCE_HTTPS }}
|
{{- if .Values.env.FORCE_HTTPS }}
|
||||||
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
|
||||||
|
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
|
||||||
|
{{- end }}
|
||||||
{{- if .Values.env.COOKIE_SECURE }}
|
{{- if .Values.env.COOKIE_SECURE }}
|
||||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ env:
|
|||||||
# Also used as the base URL for links in email notifications and other external links.
|
# Also used as the base URL for links in email notifications and other external links.
|
||||||
# FORCE_HTTPS: "false"
|
# FORCE_HTTPS: "false"
|
||||||
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
||||||
|
# HSTS_INCLUDE_SUBDOMAINS: "false"
|
||||||
|
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
||||||
# COOKIE_SECURE: "true"
|
# COOKIE_SECURE: "true"
|
||||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||||
# TRUST_PROXY: "1"
|
# TRUST_PROXY: "1"
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-organize-imports",
|
||||||
|
"@trivago/prettier-plugin-sort-imports",
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
],
|
||||||
|
"importOrder": [
|
||||||
|
"^[a-zA-Z]",
|
||||||
|
"^@/.*"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderParserPlugins": [
|
||||||
|
"typescript",
|
||||||
|
"decorators-legacy"
|
||||||
|
]
|
||||||
|
}
|
||||||
+18
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "@trek/client",
|
||||||
"version": "3.0.4",
|
"version": "3.0.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,12 +12,17 @@
|
|||||||
"test:unit": "vitest run tests/unit",
|
"test:unit": "vitest run tests/unit",
|
||||||
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@trek/shared": "*",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
|
"heic-to": "^1.4.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"mapbox-gl": "^3.22.0",
|
"mapbox-gl": "^3.22.0",
|
||||||
@@ -34,6 +39,7 @@
|
|||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -56,6 +62,14 @@
|
|||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.1.4",
|
||||||
"vite-plugin-pwa": "^0.21.0",
|
"vite-plugin-pwa": "^0.21.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-config-flat-gitignore": "^2.3.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -58,7 +58,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
const redirectParam = encodeURIComponent(location.pathname + location.search)
|
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
||||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ export default function App() {
|
|||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||||
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
|
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
|
|||||||
+153
-86
@@ -1,30 +1,34 @@
|
|||||||
import axios, { AxiosInstance } from 'axios'
|
import axios, { AxiosInstance } from 'axios'
|
||||||
|
import type { WeatherResult } from '@trek/shared'
|
||||||
import { getSocketId } from './websocket'
|
import { getSocketId } from './websocket'
|
||||||
import en from '../i18n/translations/en'
|
import { isReachable, probeNow } from '../sync/connectivity'
|
||||||
import br from '../i18n/translations/br'
|
const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||||
import de from '../i18n/translations/de'
|
en: 'Too many attempts. Please try again later.',
|
||||||
import es from '../i18n/translations/es'
|
de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
|
||||||
import fr from '../i18n/translations/fr'
|
es: 'Demasiados intentos. Inténtelo de nuevo más tarde.',
|
||||||
import it from '../i18n/translations/it'
|
fr: 'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||||
import nl from '../i18n/translations/nl'
|
hu: 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
|
||||||
import pl from '../i18n/translations/pl'
|
nl: 'Te veel pogingen. Probeer het later opnieuw.',
|
||||||
import cs from '../i18n/translations/cs'
|
br: 'Muitas tentativas. Tente novamente mais tarde.',
|
||||||
import hu from '../i18n/translations/hu'
|
cs: 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
|
||||||
import ru from '../i18n/translations/ru'
|
pl: 'Zbyt wiele prób. Spróbuj ponownie później.',
|
||||||
import zh from '../i18n/translations/zh'
|
ru: 'Слишком много попыток. Попробуйте позже.',
|
||||||
import zhTw from '../i18n/translations/zhTw'
|
zh: '尝试次数过多,请稍后再试。',
|
||||||
import ar from '../i18n/translations/ar'
|
'zh-TW': '嘗試次數過多,請稍後再試。',
|
||||||
|
it: 'Troppi tentativi. Riprova più tardi.',
|
||||||
const rateLimitTranslations: Record<string, Record<string, string | unknown>> = {
|
tr: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.',
|
||||||
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar,
|
ar: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||||
|
id: 'Terlalu banyak percobaan. Coba lagi nanti.',
|
||||||
|
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
||||||
|
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||||
|
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
||||||
}
|
}
|
||||||
|
|
||||||
function translateRateLimit(): string {
|
function translateRateLimit(): string {
|
||||||
const fallback = 'Too many attempts. Please try again later.'
|
const fallback = RATE_LIMIT_MESSAGES['en']!
|
||||||
try {
|
try {
|
||||||
const lang = localStorage.getItem('app_language') || 'en'
|
const lang = localStorage.getItem('app_language') || 'en'
|
||||||
const table = rateLimitTranslations[lang] || rateLimitTranslations.en
|
return RATE_LIMIT_MESSAGES[lang] ?? fallback
|
||||||
return (table['common.tooManyAttempts'] as string) || (rateLimitTranslations.en['common.tooManyAttempts'] as string) || fallback
|
|
||||||
} catch {
|
} catch {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
@@ -33,6 +37,7 @@ function translateRateLimit(): string {
|
|||||||
export const apiClient: AxiosInstance = axios.create({
|
export const apiClient: AxiosInstance = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
timeout: 8000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -42,24 +47,24 @@ const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
|||||||
|
|
||||||
// Request interceptor - add socket ID + idempotency key for mutating requests
|
// Request interceptor - add socket ID + idempotency key for mutating requests
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const sid = getSocketId()
|
const sid = getSocketId()
|
||||||
if (sid) {
|
if (sid) {
|
||||||
config.headers['X-Socket-Id'] = sid
|
config.headers['X-Socket-Id'] = sid
|
||||||
}
|
}
|
||||||
// Attach a per-request idempotency key to all write operations so the
|
// Attach a per-request idempotency key to all write operations so the
|
||||||
// server can deduplicate retried requests (e.g. network blips).
|
// server can deduplicate retried requests (e.g. network blips).
|
||||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||||
const method = (config.method ?? '').toLowerCase()
|
const method = (config.method ?? '').toLowerCase()
|
||||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: Math.random().toString(36).slice(2)
|
: Math.random().toString(36).slice(2)
|
||||||
config.headers['X-Idempotency-Key'] = key
|
config.headers['X-Idempotency-Key'] = key
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error)
|
||||||
)
|
)
|
||||||
|
|
||||||
export function isAuthPublicPath(pathname: string): boolean {
|
export function isAuthPublicPath(pathname: string): boolean {
|
||||||
@@ -68,36 +73,84 @@ export function isAuthPublicPath(pathname: string): boolean {
|
|||||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
// Unregisters the SW before reloading so the navigation reaches the network.
|
||||||
|
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
|
||||||
|
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
|
||||||
|
async function unregisterSWAndReload(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker?.getRegistration()
|
||||||
|
if (reg) await reg.unregister()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => {
|
||||||
(error) => {
|
sessionStorage.removeItem('proxy_reauth_attempted')
|
||||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
return response
|
||||||
const { pathname } = window.location
|
},
|
||||||
if (!isAuthPublicPath(pathname)) {
|
async (error) => {
|
||||||
const currentPath = pathname + window.location.search
|
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
|
||||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
// as a CORS error with no response object. Probe the health endpoint to
|
||||||
|
// distinguish a proxy auth challenge from a genuine outage. If the server
|
||||||
|
// is reachable, a top-level reload lets the edge proxy run its auth flow.
|
||||||
|
if (!error.response && navigator.onLine) {
|
||||||
|
await probeNow()
|
||||||
|
// Both the original request and the health probe failed while the device
|
||||||
|
// has a network interface. This matches the proxy-auth-challenge pattern
|
||||||
|
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
|
||||||
|
// Guard with sessionStorage to prevent reload loops (server genuinely
|
||||||
|
// down would also land here, but only reloads once).
|
||||||
|
if (!isReachable()) {
|
||||||
|
const { pathname } = window.location
|
||||||
|
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||||
|
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||||
|
await unregisterSWAndReload()
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||||
if (
|
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||||
error.response?.status === 403 &&
|
// always application/json, so checking for text/html is unambiguous.
|
||||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
if (error.response?.status === 401) {
|
||||||
!window.location.pathname.startsWith('/settings')
|
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||||
) {
|
if (ct.includes('text/html')) {
|
||||||
window.location.href = '/settings?mfa=required'
|
const { pathname } = window.location
|
||||||
}
|
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||||
if (error.response?.status === 429) {
|
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||||
const translated = translateRateLimit()
|
await unregisterSWAndReload()
|
||||||
const data = error.response.data as { error?: string } | undefined
|
return Promise.reject(error)
|
||||||
if (data && typeof data === 'object') {
|
}
|
||||||
data.error = translated
|
}
|
||||||
} else {
|
|
||||||
error.response.data = { error: translated }
|
|
||||||
}
|
}
|
||||||
error.message = translated
|
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||||
|
const { pathname } = window.location
|
||||||
|
if (!isAuthPublicPath(pathname)) {
|
||||||
|
const currentPath = pathname + window.location.search + window.location.hash
|
||||||
|
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
error.response?.status === 403 &&
|
||||||
|
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||||
|
!window.location.pathname.startsWith('/settings')
|
||||||
|
) {
|
||||||
|
window.location.href = '/settings?mfa=required'
|
||||||
|
}
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
const translated = translateRateLimit()
|
||||||
|
const data = error.response.data as { error?: string } | undefined
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
data.error = translated
|
||||||
|
} else {
|
||||||
|
error.response.data = { error: translated }
|
||||||
|
}
|
||||||
|
error.message = translated
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
@@ -142,6 +195,7 @@ export const oauthApi = {
|
|||||||
state?: string
|
state?: string
|
||||||
code_challenge: string
|
code_challenge: string
|
||||||
code_challenge_method: string
|
code_challenge_method: string
|
||||||
|
resource?: string
|
||||||
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
||||||
|
|
||||||
/** Submit user consent (approve or deny) */
|
/** Submit user consent (approve or deny) */
|
||||||
@@ -153,12 +207,13 @@ export const oauthApi = {
|
|||||||
code_challenge: string
|
code_challenge: string
|
||||||
code_challenge_method: string
|
code_challenge_method: string
|
||||||
approved: boolean
|
approved: boolean
|
||||||
|
resource?: string
|
||||||
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
||||||
|
|
||||||
clients: {
|
clients: {
|
||||||
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||||
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
|
||||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||||
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
||||||
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
||||||
},
|
},
|
||||||
@@ -215,11 +270,11 @@ export const placesApi = {
|
|||||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||||
},
|
},
|
||||||
importGoogleList: (tripId: number | string, url: string) =>
|
importGoogleList: (tripId: number | string, url: string) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||||
importNaverList: (tripId: number | string, url: string) =>
|
importNaverList: (tripId: number | string, url: string) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assignmentsApi = {
|
export const assignmentsApi = {
|
||||||
@@ -313,7 +368,7 @@ export const adminApi = {
|
|||||||
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||||
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||||
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
||||||
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
||||||
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
||||||
@@ -322,7 +377,7 @@ export const adminApi = {
|
|||||||
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
||||||
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||||
sendTestNotification: (data: Record<string, unknown>) =>
|
sendTestNotification: (data: Record<string, unknown>) =>
|
||||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||||
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||||
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||||
@@ -355,8 +410,20 @@ export const journeyApi = {
|
|||||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
||||||
|
|
||||||
// Photos
|
// Photos
|
||||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
|
||||||
|
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||||
|
timeout: 0,
|
||||||
|
onUploadProgress: opts?.onUploadProgress,
|
||||||
|
signal: opts?.signal,
|
||||||
|
}).then(r => r.data),
|
||||||
|
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||||
|
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
|
||||||
|
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||||
|
timeout: 0,
|
||||||
|
onUploadProgress: opts?.onUploadProgress,
|
||||||
|
signal: opts?.signal,
|
||||||
|
}).then(r => r.data),
|
||||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
@@ -387,7 +454,7 @@ export const journeyApi = {
|
|||||||
export const mapsApi = {
|
export const mapsApi = {
|
||||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||||
@@ -437,13 +504,13 @@ export const reservationsApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const weatherApi = {
|
export const weatherApi = {
|
||||||
get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||||
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const configApi = {
|
export const configApi = {
|
||||||
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
||||||
apiClient.get('/config').then(r => r.data),
|
apiClient.get('/config').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
@@ -529,21 +596,21 @@ export const notificationsApi = {
|
|||||||
|
|
||||||
export const inAppNotificationsApi = {
|
export const inAppNotificationsApi = {
|
||||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
||||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||||
unreadCount: () =>
|
unreadCount: () =>
|
||||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||||
markRead: (id: number) =>
|
markRead: (id: number) =>
|
||||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||||
markUnread: (id: number) =>
|
markUnread: (id: number) =>
|
||||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||||
markAllRead: () =>
|
markAllRead: () =>
|
||||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||||
delete: (id: number) =>
|
delete: (id: number) =>
|
||||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||||
deleteAll: () =>
|
deleteAll: () =>
|
||||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||||
respond: (id: number, response: 'positive' | 'negative') =>
|
respond: (id: number, response: 'positive' | 'negative') =>
|
||||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export default apiClient
|
export default apiClient
|
||||||
@@ -20,7 +20,6 @@ type Defaults = {
|
|||||||
temperature_unit?: string
|
temperature_unit?: string
|
||||||
dark_mode?: string | boolean
|
dark_mode?: string | boolean
|
||||||
time_format?: string
|
time_format?: string
|
||||||
route_calculation?: boolean
|
|
||||||
blur_booking_codes?: boolean
|
blur_booking_codes?: boolean
|
||||||
map_tile_url?: string
|
map_tile_url?: string
|
||||||
}
|
}
|
||||||
@@ -208,22 +207,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</OptionRow>
|
</OptionRow>
|
||||||
|
|
||||||
{/* Route Calculation */}
|
|
||||||
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
|
|
||||||
{([
|
|
||||||
{ value: true, label: t('settings.on') || 'On' },
|
|
||||||
{ value: false, label: t('settings.off') || 'Off' },
|
|
||||||
] as const).map(opt => (
|
|
||||||
<OptionButton
|
|
||||||
key={String(opt.value)}
|
|
||||||
active={defaults.route_calculation === opt.value}
|
|
||||||
onClick={() => save({ route_calculation: opt.value })}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</OptionButton>
|
|
||||||
))}
|
|
||||||
</OptionRow>
|
|
||||||
|
|
||||||
{/* Blur Booking Codes */}
|
{/* Blur Booking Codes */}
|
||||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||||
{([
|
{([
|
||||||
|
|||||||
@@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
}
|
}
|
||||||
const handleRenameCategory = async (oldName, newName) => {
|
const handleRenameCategory = async (oldName, newName) => {
|
||||||
if (!newName.trim() || newName.trim() === oldName) return
|
if (!newName.trim() || newName.trim() === oldName) return
|
||||||
const items = grouped[oldName] || []
|
const items = grouped.get(oldName) || []
|
||||||
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||||
}
|
}
|
||||||
const handleAddCategory = () => {
|
const handleAddCategory = () => {
|
||||||
@@ -719,8 +719,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||||
{t('budget.title')}
|
{t('budget.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||||
<div style={{ width: 150 }}>
|
<div className="max-md:!w-full" style={{ width: 150 }}>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={currency}
|
value={currency}
|
||||||
onChange={setCurrency}
|
onChange={setCurrency}
|
||||||
@@ -730,7 +730,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div style={{ display: 'flex', gap: 6, width: 260 }}>
|
<div className="max-md:!w-full" style={{ display: 'flex', gap: 6, width: 260 }}>
|
||||||
<input
|
<input
|
||||||
value={newCategoryName}
|
value={newCategoryName}
|
||||||
onChange={e => setNewCategoryName(e.target.value)}
|
onChange={e => setNewCategoryName(e.target.value)}
|
||||||
@@ -763,7 +763,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
>
|
>
|
||||||
<Download size={14} strokeWidth={2.5} /> CSV
|
<Download size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">CSV</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -768,7 +768,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Composer */}
|
{/* Composer */}
|
||||||
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-[96px] md:pb-3">
|
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-3">
|
||||||
{/* Reply preview */}
|
{/* Reply preview */}
|
||||||
{replyTo && (
|
{replyTo && (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight, Plane, Train, Car, Ship } from 'lucide-react'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { filesApi } from '../../api/client'
|
import { filesApi } from '../../api/client'
|
||||||
@@ -236,6 +236,15 @@ function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise'])
|
||||||
|
|
||||||
|
function transportIcon(type: string) {
|
||||||
|
if (type === 'train') return Train
|
||||||
|
if (type === 'car') return Car
|
||||||
|
if (type === 'cruise') return Ship
|
||||||
|
return Plane
|
||||||
|
}
|
||||||
|
|
||||||
interface FileManagerProps {
|
interface FileManagerProps {
|
||||||
files?: TripFile[]
|
files?: TripFile[]
|
||||||
onUpload: (fd: FormData) => Promise<any>
|
onUpload: (fd: FormData) => Promise<any>
|
||||||
@@ -490,7 +499,9 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||||
))}
|
))}
|
||||||
{linkedReservations.map(r => (
|
{linkedReservations.map(r => (
|
||||||
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
TRANSPORT_TYPES.has(r.type)
|
||||||
|
? <SourceBadge key={r.id} icon={transportIcon(r.type)} label={`${t('files.sourceTransport')} · ${r.title || t('files.sourceTransport')}`} />
|
||||||
|
: <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||||
))}
|
))}
|
||||||
{file.note_id && (
|
{file.note_id && (
|
||||||
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||||
@@ -673,52 +684,68 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const bookingReservations = reservations.filter(r => !TRANSPORT_TYPES.has(r.type))
|
||||||
|
const transportReservations = reservations.filter(r => TRANSPORT_TYPES.has(r.type))
|
||||||
|
|
||||||
|
const reservationBtn = (r: Reservation) => {
|
||||||
|
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||||
|
const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket
|
||||||
|
return (
|
||||||
|
<button key={r.id} onClick={async () => {
|
||||||
|
if (isLinked) {
|
||||||
|
if (file.reservation_id === r.id) {
|
||||||
|
await handleAssign(file.id, { reservation_id: null })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||||
|
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
||||||
|
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!file.reservation_id) {
|
||||||
|
await handleAssign(file.id, { reservation_id: r.id })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} style={{
|
||||||
|
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||||
|
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||||
|
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||||
|
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||||
|
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const bookingsSection = reservations.length > 0 && (
|
const bookingsSection = reservations.length > 0 && (
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
{bookingReservations.length > 0 && (
|
||||||
{t('files.assignBooking')}
|
<>
|
||||||
</div>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
{reservations.map(r => {
|
{t('files.assignBooking')}
|
||||||
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
</div>
|
||||||
return (
|
{bookingReservations.map(reservationBtn)}
|
||||||
<button key={r.id} onClick={async () => {
|
</>
|
||||||
if (isLinked) {
|
)}
|
||||||
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
|
{transportReservations.length > 0 && (
|
||||||
if (file.reservation_id === r.id) {
|
<>
|
||||||
await handleAssign(file.id, { reservation_id: null })
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}>
|
||||||
} else {
|
{t('files.assignTransport')}
|
||||||
try {
|
</div>
|
||||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
{transportReservations.map(reservationBtn)}
|
||||||
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
</>
|
||||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
)}
|
||||||
refreshFiles()
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Link: if no primary, set it; otherwise use file_links
|
|
||||||
if (!file.reservation_id) {
|
|
||||||
await handleAssign(file.id, { reservation_id: r.id })
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
|
||||||
refreshFiles()
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}} style={{
|
|
||||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
|
||||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
|
||||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
|
||||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
|
||||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
|||||||
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
<div className="fixed inset-0 z-[9999] bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ vi.mock('react-router-dom', async () => {
|
|||||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { useAddonStore } from '../../store/addonStore';
|
||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildUser } from '../../../tests/helpers/factories';
|
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||||
import BottomNav from './BottomNav';
|
import BottomNav from './BottomNav';
|
||||||
|
|
||||||
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
||||||
@@ -39,7 +41,7 @@ describe('BottomNav', () => {
|
|||||||
|
|
||||||
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
|
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
|
||||||
render(<BottomNav />);
|
render(<BottomNav />);
|
||||||
expect(screen.getByText('Trips')).toBeInTheDocument();
|
expect(screen.getByText('My Trips')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
|
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
|
||||||
@@ -99,4 +101,39 @@ describe('BottomNav', () => {
|
|||||||
// Sheet should be closed — username no longer visible (only the nav Profile text remains)
|
// Sheet should be closed — username no longer visible (only the nav Profile text remains)
|
||||||
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
|
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', async () => {
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||||
|
render(<BottomNav />);
|
||||||
|
expect(await screen.findByText('Mes voyages')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', async () => {
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||||
|
render(<BottomNav />);
|
||||||
|
expect(await screen.findByText('Profil')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', async () => {
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||||
|
seedStore(useAddonStore, {
|
||||||
|
addons: [
|
||||||
|
{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true },
|
||||||
|
{ id: 'atlas', name: 'Atlas', type: 'global', icon: 'globe', enabled: true },
|
||||||
|
{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<BottomNav />);
|
||||||
|
expect(await screen.findByText('Vacances')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Atlas')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Journal de voyage')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => {
|
||||||
|
seedStore(useAddonStore, {
|
||||||
|
addons: [{ id: 'foo', name: 'Foo Addon', type: 'global', icon: 'star', enabled: true }],
|
||||||
|
});
|
||||||
|
render(<BottomNav />);
|
||||||
|
expect(screen.queryByText('Foo Addon')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,14 +7,10 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
|
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
|
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
|
||||||
{ to: '/trips', label: 'Trips', icon: Plane },
|
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
|
||||||
]
|
atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' },
|
||||||
|
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
|
||||||
const ADDON_NAV: Record<string, { to: string; label: string; icon: LucideIcon }> = {
|
|
||||||
vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays },
|
|
||||||
atlas: { to: '/atlas', label: 'Atlas', icon: Globe },
|
|
||||||
journey: { to: '/journey', label: 'Journey', icon: Compass },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BottomNav() {
|
export default function BottomNav() {
|
||||||
@@ -25,11 +21,13 @@ export default function BottomNav() {
|
|||||||
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
|
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
|
||||||
const [showProfile, setShowProfile] = useState(false)
|
const [showProfile, setShowProfile] = useState(false)
|
||||||
|
|
||||||
const items = [...BASE_ITEMS]
|
const items: { to: string; label: string; icon: LucideIcon }[] = [
|
||||||
for (const addon of globalAddons) {
|
{ to: '/trips', label: t('nav.myTrips'), icon: Plane },
|
||||||
const nav = ADDON_NAV[addon.id]
|
...globalAddons.flatMap(addon => {
|
||||||
if (nav) items.push(nav)
|
const nav = ADDON_NAV[addon.id]
|
||||||
}
|
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -266,17 +266,22 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', inset: 0, zIndex: 9999,
|
position: 'fixed', inset: 0, zIndex: 99999,
|
||||||
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
padding: 16, overflow: 'auto',
|
paddingTop: 'max(16px, env(safe-area-inset-top))',
|
||||||
|
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||||||
|
paddingLeft: 16, paddingRight: 16,
|
||||||
|
overflow: 'auto',
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
}} onClick={() => setDismissed(true)}>
|
}} onClick={() => setDismissed(true)}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
|
background: 'white', borderRadius: 20, padding: '28px 24px 0',
|
||||||
maxWidth: 480, width: '100%',
|
maxWidth: 480, width: '100%',
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
maxHeight: '90vh', overflow: 'auto',
|
maxHeight: 'min(90vh, calc(100dvh - 96px))',
|
||||||
|
overflow: 'auto',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -367,8 +372,10 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div style={{
|
<div style={{
|
||||||
paddingTop: 14, borderTop: '1px solid #e5e7eb',
|
padding: '14px 0 20px', borderTop: '1px solid #e5e7eb',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
position: 'sticky', bottom: 0, background: 'white',
|
||||||
|
marginTop: 'auto',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
||||||
<Github size={13} />
|
<Github size={13} />
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import { resetAllStores } from '../../../tests/helpers/store'
|
|||||||
import { buildPlace } from '../../../tests/helpers/factories'
|
import { buildPlace } from '../../../tests/helpers/factories'
|
||||||
import * as photoService from '../../services/photoService'
|
import * as photoService from '../../services/photoService'
|
||||||
|
|
||||||
|
const mapMock = vi.hoisted(() => ({
|
||||||
|
panTo: vi.fn(),
|
||||||
|
setView: vi.fn(),
|
||||||
|
fitBounds: vi.fn(),
|
||||||
|
getZoom: vi.fn().mockReturnValue(10),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
panBy: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('react-leaflet', () => ({
|
vi.mock('react-leaflet', () => ({
|
||||||
MapContainer: ({ children }: any) => <div data-testid="map-container">{children}</div>,
|
MapContainer: ({ children }: any) => <div data-testid="map-container">{children}</div>,
|
||||||
TileLayer: () => <div data-testid="tile-layer" />,
|
TileLayer: () => <div data-testid="tile-layer" />,
|
||||||
@@ -27,15 +37,7 @@ vi.mock('react-leaflet', () => ({
|
|||||||
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
|
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
|
||||||
CircleMarker: () => <div data-testid="circle-marker" />,
|
CircleMarker: () => <div data-testid="circle-marker" />,
|
||||||
Circle: () => <div data-testid="circle" />,
|
Circle: () => <div data-testid="circle" />,
|
||||||
useMap: () => ({
|
useMap: () => mapMock,
|
||||||
panTo: vi.fn(),
|
|
||||||
setView: vi.fn(),
|
|
||||||
fitBounds: vi.fn(),
|
|
||||||
getZoom: () => 10,
|
|
||||||
on: vi.fn(),
|
|
||||||
off: vi.fn(),
|
|
||||||
panBy: vi.fn(),
|
|
||||||
}),
|
|
||||||
useMapEvents: () => ({}),
|
useMapEvents: () => ({}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
resetAllStores()
|
resetAllStores()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -125,7 +128,8 @@ describe('MapView', () => {
|
|||||||
|
|
||||||
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
||||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
||||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
// Apple-Maps style draws a casing + a core line per segment.
|
||||||
|
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
||||||
@@ -152,16 +156,11 @@ describe('MapView', () => {
|
|||||||
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
|
it('FE-COMP-MAPVIEW-011: renders the route polyline; travel times are no longer drawn on the map', () => {
|
||||||
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][]
|
const route = [[[48.0, 2.0], [49.0, 3.0]]] as unknown as [number, number][][]
|
||||||
const routeSegments = [
|
render(<MapView route={route} />)
|
||||||
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
|
// The route is drawn; per-segment times now live in the day sidebar, not on the map.
|
||||||
]
|
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||||
render(<MapView route={route} routeSegments={routeSegments} />)
|
|
||||||
// Route polyline is rendered
|
|
||||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
|
||||||
// RouteLabel renders a Marker (mocked), but it returns null when zoom < 12
|
|
||||||
// so we just assert the polyline is there, exercising the routeSegments.map path
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
|
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
|
||||||
@@ -216,4 +215,33 @@ describe('MapView', () => {
|
|||||||
render(<MapView places={places} selectedPlaceId={5} />)
|
render(<MapView places={places} selectedPlaceId={5} />)
|
||||||
expect(screen.getByTestId('marker')).toBeTruthy()
|
expect(screen.getByTestId('marker')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-MAPVIEW-018: changing selectedPlaceId/hasInspector does not refit bounds (issue #921)', () => {
|
||||||
|
const places = [
|
||||||
|
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||||
|
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
|
||||||
|
]
|
||||||
|
const { rerender } = render(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
|
||||||
|
const initialCount = mapMock.fitBounds.mock.calls.length
|
||||||
|
|
||||||
|
// Toggle selectedPlaceId on — mimics opening place inspector (hasInspector flips,
|
||||||
|
// paddingOpts memo creates new object). fitBounds must NOT fire again.
|
||||||
|
rerender(<MapView places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />)
|
||||||
|
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
|
||||||
|
|
||||||
|
// Toggle selectedPlaceId off — mimics closing inspector via X button.
|
||||||
|
rerender(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
|
||||||
|
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-MAPVIEW-019: bumping fitKey triggers a new fitBounds call', () => {
|
||||||
|
const places = [
|
||||||
|
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||||
|
]
|
||||||
|
const { rerender } = render(<MapView places={places} fitKey={1} />)
|
||||||
|
const afterFirst = mapMock.fitBounds.mock.calls.length
|
||||||
|
|
||||||
|
rerender(<MapView places={places} fitKey={2} />)
|
||||||
|
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [fitKey, places, paddingOpts, map, hasDayDetail])
|
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -225,55 +225,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Route travel time label ──
|
// Travel times are shown in the day sidebar (per-segment connectors), not on the map.
|
||||||
interface RouteLabelProps {
|
|
||||||
midpoint: [number, number]
|
|
||||||
walkingText: string
|
|
||||||
drivingText: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
|
||||||
const map = useMap()
|
|
||||||
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return
|
|
||||||
const check = () => setVisible(map.getZoom() >= 12)
|
|
||||||
check()
|
|
||||||
map.on('zoomend', check)
|
|
||||||
return () => map.off('zoomend', check)
|
|
||||||
}, [map])
|
|
||||||
|
|
||||||
if (!visible || !midpoint) return null
|
|
||||||
|
|
||||||
const icon = L.divIcon({
|
|
||||||
className: 'route-info-pill',
|
|
||||||
html: `<div style="
|
|
||||||
display:flex;align-items:center;gap:5px;
|
|
||||||
background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);
|
|
||||||
color:#fff;border-radius:99px;padding:3px 9px;
|
|
||||||
font-size:9px;font-weight:600;white-space:nowrap;
|
|
||||||
font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
|
|
||||||
box-shadow:0 2px 12px rgba(0,0,0,0.3);
|
|
||||||
pointer-events:none;
|
|
||||||
position:relative;left:-50%;top:-50%;
|
|
||||||
">
|
|
||||||
<span style="display:flex;align-items:center;gap:2px">
|
|
||||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>
|
|
||||||
${walkingText}
|
|
||||||
</span>
|
|
||||||
<span style="opacity:0.3">|</span>
|
|
||||||
<span style="display:flex;align-items:center;gap:2px">
|
|
||||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>
|
|
||||||
${drivingText}
|
|
||||||
</span>
|
|
||||||
</div>`,
|
|
||||||
iconSize: [0, 0],
|
|
||||||
iconAnchor: [0, 0],
|
|
||||||
})
|
|
||||||
|
|
||||||
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module-level photo cache shared with PlaceAvatar
|
// Module-level photo cache shared with PlaceAvatar
|
||||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||||
@@ -597,23 +549,19 @@ export const MapView = memo(function MapView({
|
|||||||
{markers}
|
{markers}
|
||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
|
|
||||||
{route && route.length > 0 && (
|
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
|
||||||
<>
|
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
||||||
{route.map((seg, i) => seg.length > 1 && (
|
<Polyline
|
||||||
<Polyline
|
key={`${i}-casing`}
|
||||||
key={i}
|
positions={seg}
|
||||||
positions={seg}
|
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||||
color="#111827"
|
/>,
|
||||||
weight={3}
|
<Polyline
|
||||||
opacity={0.9}
|
key={`${i}-core`}
|
||||||
dashArray="6, 5"
|
positions={seg}
|
||||||
/>
|
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||||
))}
|
/>,
|
||||||
{routeSegments.map((seg, i) => (
|
] : [])}
|
||||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* GPX imported route geometries */}
|
{/* GPX imported route geometries */}
|
||||||
{gpxPolylines}
|
{gpxPolylines}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
||||||
|
import { render } from '../../../tests/helpers/render'
|
||||||
|
import { act } from '@testing-library/react'
|
||||||
|
import { resetAllStores } from '../../../tests/helpers/store'
|
||||||
|
import { buildPlace } from '../../../tests/helpers/factories'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
|
||||||
|
// Stable fake map so fitBounds call counts survive re-renders.
|
||||||
|
const glMap = vi.hoisted(() => ({
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
once: vi.fn(),
|
||||||
|
loaded: vi.fn().mockReturnValue(true),
|
||||||
|
fitBounds: vi.fn(),
|
||||||
|
flyTo: vi.fn(),
|
||||||
|
jumpTo: vi.fn(),
|
||||||
|
getZoom: vi.fn().mockReturnValue(10),
|
||||||
|
addControl: vi.fn(),
|
||||||
|
removeControl: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
addSource: vi.fn(),
|
||||||
|
getSource: vi.fn().mockReturnValue(null),
|
||||||
|
addLayer: vi.fn(),
|
||||||
|
setLayoutProperty: vi.fn(),
|
||||||
|
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
||||||
|
isStyleLoaded: vi.fn().mockReturnValue(true),
|
||||||
|
getCanvasContainer: vi.fn(() => document.createElement('div')),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('mapbox-gl', () => ({
|
||||||
|
default: {
|
||||||
|
accessToken: '',
|
||||||
|
Map: vi.fn(() => glMap),
|
||||||
|
Marker: vi.fn(() => ({
|
||||||
|
setLngLat: vi.fn().mockReturnThis(),
|
||||||
|
addTo: vi.fn().mockReturnThis(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
getElement: vi.fn(() => document.createElement('div')),
|
||||||
|
})),
|
||||||
|
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
|
||||||
|
NavigationControl: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||||
|
|
||||||
|
vi.mock('./mapboxSetup', () => ({
|
||||||
|
isStandardFamily: vi.fn(() => false),
|
||||||
|
supportsCustom3d: vi.fn(() => false),
|
||||||
|
wantsTerrain: vi.fn(() => false),
|
||||||
|
addCustom3dBuildings: vi.fn(),
|
||||||
|
addTerrainAndSky: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./locationMarkerMapbox', () => ({
|
||||||
|
attachLocationMarker: vi.fn(() => ({ update: vi.fn() })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./reservationsMapbox', () => ({
|
||||||
|
ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useGeolocation', () => ({
|
||||||
|
useGeolocation: vi.fn(() => ({
|
||||||
|
position: null,
|
||||||
|
mode: 'off',
|
||||||
|
error: null,
|
||||||
|
cycleMode: vi.fn(),
|
||||||
|
setMode: vi.fn(),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../services/photoService', () => ({
|
||||||
|
getCached: vi.fn(() => null),
|
||||||
|
isLoading: vi.fn(() => false),
|
||||||
|
fetchPhoto: vi.fn(),
|
||||||
|
onThumbReady: vi.fn(() => () => {}),
|
||||||
|
getAllThumbs: vi.fn(() => ({})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { MapViewGL } from './MapViewGL'
|
||||||
|
|
||||||
|
function buildMapPlace(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
...buildPlace(),
|
||||||
|
category_name: null,
|
||||||
|
category_color: null,
|
||||||
|
category_icon: null,
|
||||||
|
...overrides,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
settings: {
|
||||||
|
...useSettingsStore.getState().settings,
|
||||||
|
map_provider: 'mapbox-gl',
|
||||||
|
mapbox_access_token: 'pk.test_token',
|
||||||
|
mapbox_style: 'mapbox://styles/mapbox/streets-v12',
|
||||||
|
mapbox_3d_enabled: false,
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
resetAllStores()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MapViewGL', () => {
|
||||||
|
it('FE-COMP-MAPVIEWGL-001: opening place inspector does not refit bounds (issue #921)', async () => {
|
||||||
|
const places = [
|
||||||
|
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||||
|
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
|
||||||
|
)
|
||||||
|
await act(async () => {})
|
||||||
|
const after_initial = glMap.fitBounds.mock.calls.length
|
||||||
|
|
||||||
|
// Selecting a place flips hasInspector → paddingOpts memo changes.
|
||||||
|
// fitBounds must NOT fire again (this was the bug).
|
||||||
|
rerender(
|
||||||
|
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
|
||||||
|
)
|
||||||
|
await act(async () => {})
|
||||||
|
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-MAPVIEWGL-002: closing inspector does not refit bounds (issue #921)', async () => {
|
||||||
|
const places = [
|
||||||
|
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
|
||||||
|
)
|
||||||
|
await act(async () => {})
|
||||||
|
const after_initial = glMap.fitBounds.mock.calls.length
|
||||||
|
|
||||||
|
// Closing inspector (X button) clears selectedPlaceId → hasInspector=false → new paddingOpts.
|
||||||
|
rerender(
|
||||||
|
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
|
||||||
|
)
|
||||||
|
await act(async () => {})
|
||||||
|
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-MAPVIEWGL-003: bumping fitKey triggers a new fitBounds call', async () => {
|
||||||
|
const places = [
|
||||||
|
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const { rerender } = render(<MapViewGL places={places} fitKey={1} />)
|
||||||
|
await act(async () => {})
|
||||||
|
const after_first = glMap.fitBounds.mock.calls.length
|
||||||
|
|
||||||
|
rerender(<MapViewGL places={places} fitKey={2} />)
|
||||||
|
await act(async () => {})
|
||||||
|
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -132,6 +132,7 @@ export function MapViewGL({
|
|||||||
places = [],
|
places = [],
|
||||||
dayPlaces = [],
|
dayPlaces = [],
|
||||||
route = null,
|
route = null,
|
||||||
|
routeSegments = [],
|
||||||
selectedPlaceId = null,
|
selectedPlaceId = null,
|
||||||
onMarkerClick,
|
onMarkerClick,
|
||||||
onMapClick,
|
onMapClick,
|
||||||
@@ -216,16 +217,20 @@ export function MapViewGL({
|
|||||||
// initial route source — kept around so updates can setData() cheaply
|
// initial route source — kept around so updates can setData() cheaply
|
||||||
if (!map.getSource('trip-route')) {
|
if (!map.getSource('trip-route')) {
|
||||||
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||||
|
// Apple-Maps style: a darker-blue casing under a bright-blue core, both
|
||||||
|
// rounded. Casing is added first so it sits beneath the core line.
|
||||||
|
map.addLayer({
|
||||||
|
id: 'trip-route-casing',
|
||||||
|
type: 'line',
|
||||||
|
source: 'trip-route',
|
||||||
|
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
})
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'trip-route-line',
|
id: 'trip-route-line',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'trip-route',
|
source: 'trip-route',
|
||||||
paint: {
|
paint: { 'line-color': '#0a84ff', 'line-width': 5 },
|
||||||
'line-color': '#111827',
|
|
||||||
'line-width': 3,
|
|
||||||
'line-opacity': 0.9,
|
|
||||||
'line-dasharray': [2, 1.5],
|
|
||||||
},
|
|
||||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -442,6 +447,8 @@ export function MapViewGL({
|
|||||||
src.setData({ type: 'FeatureCollection', features })
|
src.setData({ type: 'FeatureCollection', features })
|
||||||
}, [route])
|
}, [route])
|
||||||
|
|
||||||
|
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
|
||||||
|
|
||||||
// Update GPX geometries
|
// Update GPX geometries
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
@@ -507,13 +514,10 @@ export function MapViewGL({
|
|||||||
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
|
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
|
||||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||||
|
|
||||||
// Also fit when the places collection changes so the initial render
|
const prevFitKey = useRef(-1)
|
||||||
// zooms to the trip instead of the default center.
|
|
||||||
const placeBoundsKey = useMemo(
|
|
||||||
() => places.filter(p => p.lat && p.lng).map(p => `${p.id}:${p.lat}:${p.lng}`).join('|'),
|
|
||||||
[places]
|
|
||||||
)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (fitKey === prevFitKey.current) return
|
||||||
|
prevFitKey.current = fitKey
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
if (!map) return
|
if (!map) return
|
||||||
const target = dayPlaces.length > 0 ? dayPlaces : places
|
const target = dayPlaces.length > 0 ? dayPlaces : places
|
||||||
@@ -533,7 +537,7 @@ export function MapViewGL({
|
|||||||
}
|
}
|
||||||
if (map.loaded()) run()
|
if (map.loaded()) run()
|
||||||
else map.once('load', run)
|
else map.once('load', run)
|
||||||
}, [fitKey, placeBoundsKey, paddingOpts, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// flyTo selected place
|
// flyTo selected place
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import type { RouteResult, RouteSegment, Waypoint } from '../../types'
|
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint } from '../../types'
|
||||||
|
|
||||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||||
|
|
||||||
|
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||||
|
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
|
||||||
|
// the matching profile so walking routes follow footpaths, not the road network.
|
||||||
|
const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||||
|
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
|
||||||
|
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
|
||||||
|
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
|
||||||
|
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
|
||||||
|
const routeCache = new Map<string, RouteWithLegs>()
|
||||||
|
const ROUTE_CACHE_MAX = 200
|
||||||
|
|
||||||
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
|
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
|
||||||
export async function calculateRoute(
|
export async function calculateRoute(
|
||||||
waypoints: Waypoint[],
|
waypoints: Waypoint[],
|
||||||
@@ -116,12 +130,72 @@ export async function calculateSegments(
|
|||||||
const walkingDuration = leg.distance / (5000 / 3600)
|
const walkingDuration = leg.distance / (5000 / 3600)
|
||||||
return {
|
return {
|
||||||
mid, from, to,
|
mid, from, to,
|
||||||
|
distance: leg.distance,
|
||||||
|
duration: leg.duration,
|
||||||
walkingText: formatDuration(walkingDuration),
|
walkingText: formatDuration(walkingDuration),
|
||||||
drivingText: formatDuration(leg.duration),
|
drivingText: formatDuration(leg.duration),
|
||||||
|
distanceText: formatDistance(leg.distance),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One OSRM call per waypoint-run that returns BOTH the real road geometry (for the
|
||||||
|
* map) and per-leg distance/duration (for the sidebar connectors). Results are cached
|
||||||
|
* by the exact waypoint list. Throws on OSRM failure so callers can fall back to a
|
||||||
|
* straight line.
|
||||||
|
*/
|
||||||
|
export async function calculateRouteWithLegs(
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
{ signal, profile = 'driving' }: { signal?: AbortSignal; profile?: 'driving' | 'walking' | 'cycling' } = {}
|
||||||
|
): Promise<RouteWithLegs> {
|
||||||
|
if (!waypoints || waypoints.length < 2) {
|
||||||
|
return { coordinates: [], distance: 0, duration: 0, legs: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||||
|
const cacheKey = `${profile}:${coords}`
|
||||||
|
const cached = routeCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const url = `${OSRM_PROFILE_BASE[profile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||||
|
const response = await fetch(url, { signal })
|
||||||
|
if (!response.ok) throw new Error('Route could not be calculated')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||||
|
|
||||||
|
const route = data.routes[0]
|
||||||
|
const coordinates: [number, number][] = route.geometry.coordinates.map(
|
||||||
|
([lng, lat]: [number, number]) => [lat, lng]
|
||||||
|
)
|
||||||
|
const legs: RouteSegment[] = (route.legs || []).map(
|
||||||
|
(leg: { distance: number; duration: number }, i: number): RouteSegment => {
|
||||||
|
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||||
|
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||||
|
const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
|
||||||
|
const walkingDuration = leg.distance / (5000 / 3600)
|
||||||
|
return {
|
||||||
|
mid, from, to,
|
||||||
|
distance: leg.distance,
|
||||||
|
duration: leg.duration,
|
||||||
|
walkingText: formatDuration(walkingDuration),
|
||||||
|
drivingText: formatDuration(leg.duration),
|
||||||
|
distanceText: formatDistance(leg.distance),
|
||||||
|
durationText: formatDuration(leg.duration),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
|
||||||
|
routeCache.set(cacheKey, result)
|
||||||
|
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||||
|
const oldest = routeCache.keys().next().value
|
||||||
|
if (oldest !== undefined) routeCache.delete(oldest)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
function formatDistance(meters: number): string {
|
function formatDistance(meters: number): string {
|
||||||
if (meters < 1000) {
|
if (meters < 1000) {
|
||||||
return `${Math.round(meters)} m`
|
return `${Math.round(meters)} m`
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ export function isStandardFamily(style: string): boolean {
|
|||||||
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terrain is only genuinely useful for the satellite imagery styles — on
|
// Terrain is only genuinely useful for styles that benefit from elevation
|
||||||
// clean flat styles like streets/light/dark it nudges route lines onto
|
// data. On flat vector styles (streets/light/dark) it nudges route lines
|
||||||
// the DEM while our HTML markers stay at Z=0, which causes the visible
|
// onto the DEM while HTML markers stay at Z=0, causing a visible drift
|
||||||
// offset when the map is pitched. Restrict terrain to satellite.
|
// when the map is pitched. Satellite and Outdoors are the intended styles
|
||||||
|
// for terrain; markers are re-pinned by syncMarkerAltitudes().
|
||||||
export function wantsTerrain(style: string): boolean {
|
export function wantsTerrain(style: string): boolean {
|
||||||
return style === 'mapbox://styles/mapbox/satellite-v9'
|
return style === 'mapbox://styles/mapbox/satellite-v9'
|
||||||
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
||||||
|
|| style === 'mapbox://styles/mapbox/outdoors-v12'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3D can be added to every style now — the standard family has it built-in
|
// 3D can be added to every style now — the standard family has it built-in
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const transportReservation = {
|
|||||||
id: 400,
|
id: 400,
|
||||||
title: 'Flight to Rome',
|
title: 'Flight to Rome',
|
||||||
type: 'flight',
|
type: 'flight',
|
||||||
|
day_id: 10,
|
||||||
reservation_time: '2025-06-01T14:30:00',
|
reservation_time: '2025-06-01T14:30:00',
|
||||||
confirmation_number: 'ABC123',
|
confirmation_number: 'ABC123',
|
||||||
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { getCategoryIcon } from '../shared/categoryIcons'
|
|||||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
|
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
|
||||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||||
|
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||||
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
|
||||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||||
if (!_renderToStaticMarkup) return ''
|
if (!_renderToStaticMarkup) return ''
|
||||||
@@ -140,23 +142,58 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const totalCost = Object.values(assignments || {})
|
const totalCost = Object.values(assignments || {})
|
||||||
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||||
|
|
||||||
|
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
|
||||||
|
const pdfGetDayOrder = (d: Day) => d.day_number
|
||||||
|
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||||
|
const startId = r.day_id
|
||||||
|
const endId = r.end_day_id ?? startId
|
||||||
|
if (!startId || startId === endId) return 'single'
|
||||||
|
if (dayId === startId) return 'start'
|
||||||
|
if (dayId === endId) return 'end'
|
||||||
|
return 'middle'
|
||||||
|
}
|
||||||
|
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
|
||||||
|
const phase = pdfGetSpanPhase(r, dayId)
|
||||||
|
if (phase === 'end') return r.reservation_end_time || null
|
||||||
|
if (phase === 'middle') return null
|
||||||
|
return r.reservation_time || null
|
||||||
|
}
|
||||||
|
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
|
||||||
|
if (phase === 'single') return null
|
||||||
|
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
|
||||||
|
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
|
||||||
|
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||||
|
}
|
||||||
|
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
|
||||||
|
if (r.type === 'hotel') return false
|
||||||
|
const startId = r.day_id
|
||||||
|
const endId = r.end_day_id ?? startId
|
||||||
|
if (startId == null) return false
|
||||||
|
if (endId !== startId) {
|
||||||
|
const startDay = sorted.find(d => d.id === startId)
|
||||||
|
const endDay = sorted.find(d => d.id === endId)
|
||||||
|
const thisDay = sorted.find(d => d.id === dayId)
|
||||||
|
if (!startDay || !endDay || !thisDay) return false
|
||||||
|
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
|
||||||
|
}
|
||||||
|
return startId === dayId
|
||||||
|
})
|
||||||
|
|
||||||
// Build day HTML
|
// Build day HTML
|
||||||
const daysHtml = sorted.map((day, di) => {
|
const daysHtml = sorted.map((day, di) => {
|
||||||
const assigned = assignments[String(day.id)] || []
|
const assigned = assignments[String(day.id)] || []
|
||||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||||
const cost = dayCost(assignments, day.id, loc)
|
const cost = dayCost(assignments, day.id, loc)
|
||||||
|
|
||||||
// Reservations for this day (hotel rendered via accommodations block)
|
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
|
||||||
const dayReservations = (reservations || []).filter(r => {
|
const dayReservations = pdfGetTransportForDay(day.id)
|
||||||
if (!r.reservation_time || r.type === 'hotel') return false
|
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
|
||||||
return day.date && r.reservation_time.split('T')[0] === day.date
|
|
||||||
})
|
|
||||||
|
|
||||||
const merged = []
|
const merged = []
|
||||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||||
dayReservations.forEach(r => {
|
dayReservations.forEach(r => {
|
||||||
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||||
merged.push({ type: 'reservation', k: pos, data: r })
|
merged.push({ type: 'reservation', k: pos, data: r })
|
||||||
})
|
})
|
||||||
merged.sort((a, b) => a.k - b.k)
|
merged.sort((a, b) => a.k - b.k)
|
||||||
@@ -177,13 +214,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||||
const locationLine = r.location || meta.location || ''
|
const locationLine = r.location || meta.location || ''
|
||||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
const phase = pdfGetSpanPhase(r, day.id)
|
||||||
|
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||||
|
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||||
|
const time = splitReservationDateTime(displayTime).time ?? ''
|
||||||
|
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
||||||
return `
|
return `
|
||||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||||
<div class="note-line" style="background: ${color};"></div>
|
<div class="note-line" style="background: ${color};"></div>
|
||||||
<span class="note-icon">${icon}</span>
|
<span class="note-icon">${icon}</span>
|
||||||
<div class="note-body">
|
<div class="note-body">
|
||||||
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||||
@@ -246,8 +287,12 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
}).join('')
|
}).join('')
|
||||||
|
|
||||||
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
|
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
|
||||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
).sort((a, b) => a.start_day_id - b.start_day_id)
|
).sort((a, b) => {
|
||||||
|
const startA = days.find(d => d.id === a.start_day_id)
|
||||||
|
const startB = days.find(d => d.id === b.start_day_id)
|
||||||
|
return (startA ? getDayOrder(startA, days) : 0) - (startB ? getDayOrder(startB, days) : 0)
|
||||||
|
})
|
||||||
|
|
||||||
const accommodationDetails = accommodationsForDay.map(item => {
|
const accommodationDetails = accommodationsForDay.map(item => {
|
||||||
const isCheckIn = day.id === item.start_day_id
|
const isCheckIn = day.id === item.start_day_id
|
||||||
|
|||||||
@@ -8,7 +8,21 @@ import { useAuthStore } from '../../store/authStore';
|
|||||||
import { useTripStore } from '../../store/tripStore';
|
import { useTripStore } from '../../store/tripStore';
|
||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
||||||
import PackingListPanel from './PackingListPanel';
|
import PackingListPanel, { itemWeight } from './PackingListPanel';
|
||||||
|
|
||||||
|
describe('itemWeight (bag total weight calc)', () => {
|
||||||
|
it('FE-COMP-PACKING-030: multiplies unit weight by quantity', () => {
|
||||||
|
expect(itemWeight({ weight_grams: 120, quantity: 3 })).toBe(360);
|
||||||
|
});
|
||||||
|
it('FE-COMP-PACKING-031: defaults quantity to 1 when missing', () => {
|
||||||
|
expect(itemWeight({ weight_grams: 250 })).toBe(250);
|
||||||
|
});
|
||||||
|
it('FE-COMP-PACKING-032: contributes 0 when weight is missing or zero', () => {
|
||||||
|
expect(itemWeight({ quantity: 5 })).toBe(0);
|
||||||
|
expect(itemWeight({ weight_grams: 0, quantity: 5 })).toBe(0);
|
||||||
|
expect(itemWeight({})).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ function katColor(kat, allCategories) {
|
|||||||
|
|
||||||
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
|
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
|
||||||
|
|
||||||
|
/** Weight an item contributes to a total: unit weight times quantity (defaults: 0 g, qty 1). */
|
||||||
|
export const itemWeight = (i: { weight_grams?: number | null; quantity?: number | null }): number =>
|
||||||
|
(i.weight_grams || 0) * (i.quantity || 1)
|
||||||
|
|
||||||
// ── Bag Card ──────────────────────────────────────────────────────────────
|
// ── Bag Card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface BagCardProps {
|
interface BagCardProps {
|
||||||
@@ -1311,8 +1315,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
|
|
||||||
{bags.map(bag => {
|
{bags.map(bag => {
|
||||||
const bagItems = items.filter(i => i.bag_id === bag.id)
|
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||||
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
|
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||||
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||||
return (
|
return (
|
||||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
|
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
|
||||||
@@ -1322,7 +1326,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
{/* Unassigned */}
|
{/* Unassigned */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const unassigned = items.filter(i => !i.bag_id)
|
const unassigned = items.filter(i => !i.bag_id)
|
||||||
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
|
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||||
if (unassigned.length === 0) return null
|
if (unassigned.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 14, opacity: 0.6 }}>
|
<div style={{ marginBottom: 14, opacity: 0.6 }}>
|
||||||
@@ -1342,7 +1346,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
|
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
<span>{t('packing.totalWeight')}</span>
|
<span>{t('packing.totalWeight')}</span>
|
||||||
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1380,8 +1384,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
|
|
||||||
{bags.map(bag => {
|
{bags.map(bag => {
|
||||||
const bagItems = items.filter(i => i.bag_id === bag.id)
|
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||||
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
|
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||||
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||||
return (
|
return (
|
||||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
|
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
|
||||||
@@ -1391,7 +1395,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
{/* Unassigned */}
|
{/* Unassigned */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const unassigned = items.filter(i => !i.bag_id)
|
const unassigned = items.filter(i => !i.bag_id)
|
||||||
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
|
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||||
if (unassigned.length === 0) return null
|
if (unassigned.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 16, opacity: 0.6 }}>
|
<div style={{ marginBottom: 16, opacity: 0.6 }}>
|
||||||
@@ -1411,7 +1415,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
|
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
<span>{t('packing.totalWeight')}</span>
|
<span>{t('packing.totalWeight')}</span>
|
||||||
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -892,6 +892,277 @@ describe('DayDetailPanel', () => {
|
|||||||
expect(screen.getByText(/June|15/i)).toBeInTheDocument();
|
expect(screen.getByText(/June|15/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Accommodation date-range picker — non-monotonic day IDs (issue #889) ─────
|
||||||
|
|
||||||
|
// Builds the reporter's exact ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
|
||||||
|
// This happens after repeated trip-length changes via generateDays (no import/migration needed).
|
||||||
|
function buildNonMonotonicDays() {
|
||||||
|
return [
|
||||||
|
buildDay({ id: 17, trip_id: 1, date: '2026-04-30' }),
|
||||||
|
buildDay({ id: 18, trip_id: 1, date: '2026-05-01' }),
|
||||||
|
buildDay({ id: 19, trip_id: 1, date: '2026-05-02' }),
|
||||||
|
buildDay({ id: 20, trip_id: 1, date: '2026-05-03' }),
|
||||||
|
buildDay({ id: 21, trip_id: 1, date: '2026-05-04' }),
|
||||||
|
buildDay({ id: 22, trip_id: 1, date: '2026-05-05' }),
|
||||||
|
buildDay({ id: 23, trip_id: 1, date: '2026-05-06' }),
|
||||||
|
buildDay({ id: 24, trip_id: 1, date: '2026-05-07' }),
|
||||||
|
buildDay({ id: 25, trip_id: 1, date: '2026-05-08' }),
|
||||||
|
buildDay({ id: 1, trip_id: 1, date: '2026-05-09' }),
|
||||||
|
buildDay({ id: 2, trip_id: 1, date: '2026-05-10' }),
|
||||||
|
buildDay({ id: 3, trip_id: 1, date: '2026-05-11' }),
|
||||||
|
buildDay({ id: 4, trip_id: 1, date: '2026-05-12' }),
|
||||||
|
buildDay({ id: 5, trip_id: 1, date: '2026-05-13' }),
|
||||||
|
buildDay({ id: 6, trip_id: 1, date: '2026-05-14' }),
|
||||||
|
buildDay({ id: 7, trip_id: 1, date: '2026-05-15' }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the two CustomSelect trigger buttons for start/end day pickers.
|
||||||
|
// When no dropdown is open, these are the only globally-visible buttons whose textContent
|
||||||
|
// matches /Day \d+/ (the main panel title is a div, not a button).
|
||||||
|
// [0] = start trigger, [1] = end trigger (DOM source order).
|
||||||
|
function getDayPickerTriggers() {
|
||||||
|
return screen.getAllByRole('button').filter(b => /Day \d+/.test(b.textContent ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-056: non-monotonic IDs — end picker does not clobber start-day', async () => {
|
||||||
|
const days = buildNonMonotonicDays();
|
||||||
|
const place = buildPlace({ id: 50, name: 'Range Hotel' });
|
||||||
|
let capturedBody: any;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||||
|
capturedBody = await request.json();
|
||||||
|
return HttpResponse.json({
|
||||||
|
accommodation: {
|
||||||
|
id: 99, place_id: 50, place_name: 'Range Hotel', place_address: null,
|
||||||
|
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||||
|
check_in: null, check_out: null, confirmation: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
|
||||||
|
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /Range Hotel/i }));
|
||||||
|
|
||||||
|
// Both triggers show "Day 1"; the second one is the end picker.
|
||||||
|
await userEvent.click(getDayPickerTriggers()[1]);
|
||||||
|
// Select "Day 16" (id=7) from the open dropdown — textContent starts with "Day 16".
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// start must remain id 17 (day 1) — old code would clobber it to id 7 via Math.min
|
||||||
|
expect(capturedBody?.start_day_id).toBe(17);
|
||||||
|
expect(capturedBody?.end_day_id).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-057: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
|
||||||
|
const days = buildNonMonotonicDays();
|
||||||
|
const place = buildPlace({ id: 51, name: 'Span Hotel' });
|
||||||
|
let capturedBody: any;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||||
|
capturedBody = await request.json();
|
||||||
|
return HttpResponse.json({
|
||||||
|
accommodation: {
|
||||||
|
id: 100, place_id: 51, place_name: 'Span Hotel', place_address: null,
|
||||||
|
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||||
|
check_in: null, check_out: null, confirmation: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
|
||||||
|
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /Span Hotel/i }));
|
||||||
|
|
||||||
|
// Set end to day 16 (id=7, low ID but last day by position).
|
||||||
|
await userEvent.click(getDayPickerTriggers()[1]);
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||||
|
|
||||||
|
// Set start to day 9 (id=25, high ID, but earlier by position than day 16).
|
||||||
|
// Old code: Math.max(25, 7) = 25 → end collapses to day 9.
|
||||||
|
// New code: position(id=25)=8 < position(id=7)=15 → end stays at 7 (day 16).
|
||||||
|
await userEvent.click(getDayPickerTriggers()[0]);
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(capturedBody?.start_day_id).toBe(25); // day 9
|
||||||
|
expect(capturedBody?.end_day_id).toBe(7); // day 16 — must NOT have collapsed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-058: non-monotonic IDs — All days button sets correct first/last IDs', async () => {
|
||||||
|
const days = buildNonMonotonicDays();
|
||||||
|
const place = buildPlace({ id: 52, name: 'Full Trip Hotel' });
|
||||||
|
let capturedBody: any;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||||
|
capturedBody = await request.json();
|
||||||
|
return HttpResponse.json({
|
||||||
|
accommodation: {
|
||||||
|
id: 101, place_id: 52, place_name: 'Full Trip Hotel', place_address: null,
|
||||||
|
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||||
|
check_in: null, check_out: null, confirmation: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
|
||||||
|
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /Full Trip Hotel/i }));
|
||||||
|
|
||||||
|
// "All" is the day.allDays translation (en: "All") — the Apply-to-entire-trip button.
|
||||||
|
// When categories=[] the category-filter "All" button is not rendered, so this is unique.
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^All$/i }));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// days[0].id=17 (first by position), days[15].id=7 (last by position)
|
||||||
|
expect(capturedBody?.start_day_id).toBe(17);
|
||||||
|
expect(capturedBody?.end_day_id).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-059: sequential IDs — end picker clamping still works (regression guard)', async () => {
|
||||||
|
const seqDays = [
|
||||||
|
buildDay({ id: 101, trip_id: 1, date: '2026-06-01' }),
|
||||||
|
buildDay({ id: 102, trip_id: 1, date: '2026-06-02' }),
|
||||||
|
buildDay({ id: 103, trip_id: 1, date: '2026-06-03' }),
|
||||||
|
];
|
||||||
|
const place = buildPlace({ id: 53, name: 'Seq Hotel' });
|
||||||
|
let capturedBody: any;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||||
|
capturedBody = await request.json();
|
||||||
|
return HttpResponse.json({
|
||||||
|
accommodation: {
|
||||||
|
id: 102, place_id: 53, place_name: 'Seq Hotel', place_address: null,
|
||||||
|
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||||
|
check_in: null, check_out: null, confirmation: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={seqDays[0]} days={seqDays} places={[place]} />);
|
||||||
|
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /Seq Hotel/i }));
|
||||||
|
|
||||||
|
// Pick end = day 3 (id=103, position 2 > position 0 of start id=101).
|
||||||
|
await userEvent.click(getDayPickerTriggers()[1]);
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 3'))!);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(capturedBody?.start_day_id).toBe(101);
|
||||||
|
expect(capturedBody?.end_day_id).toBe(103);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Post-save state filter — non-monotonic IDs (issue #889 follow-up) ────────
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-060: non-monotonic IDs — hotel stays visible after edit-save (issue #889 regression)', async () => {
|
||||||
|
const days = buildNonMonotonicDays();
|
||||||
|
let getCallCount = 0;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/accommodations', () => {
|
||||||
|
getCallCount++;
|
||||||
|
const acc = getCallCount === 1
|
||||||
|
// Initial load: single-day so old filter (17>=17 && 17<=17) passes — hotel visible, edit possible
|
||||||
|
? { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 17, check_in: null, check_out: null, confirmation: null }
|
||||||
|
// Post-save relist: full span — old filter (17>=17 && 17<=7) would drop it, new code keeps it
|
||||||
|
: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null };
|
||||||
|
return HttpResponse.json({ accommodations: [acc] });
|
||||||
|
}),
|
||||||
|
http.put('/api/trips/1/accommodations/1', async ({ request }) => {
|
||||||
|
const body = await request.json() as any;
|
||||||
|
return HttpResponse.json({
|
||||||
|
accommodation: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null,
|
||||||
|
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
|
||||||
|
check_in: null, check_out: null, confirmation: null },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
|
||||||
|
await screen.findByText('Span Hotel');
|
||||||
|
|
||||||
|
// Pencil = 3rd button (index 2): collapse, close, pencil, remove
|
||||||
|
const allButtons = screen.getAllByRole('button');
|
||||||
|
await userEvent.click(allButtons[2]);
|
||||||
|
|
||||||
|
// Extend end picker to Day 16 (id=7)
|
||||||
|
await userEvent.click(getDayPickerTriggers()[1]);
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||||
|
|
||||||
|
// Old code: 17>=17 && 17<=7 → false (hotel vanishes). New code: position 0 in [0,15] → visible.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Span Hotel')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-061: non-monotonic IDs — hotel appears after create-save on intermediate day', async () => {
|
||||||
|
const days = buildNonMonotonicDays();
|
||||||
|
const place = buildPlace({ id: 55, name: 'Created Hotel' });
|
||||||
|
// Current day: days[5] = id 22, position 5 (within any full-span range)
|
||||||
|
const currentDay = days[5];
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||||
|
const body = await request.json() as any;
|
||||||
|
return HttpResponse.json({
|
||||||
|
accommodation: { id: 200, place_id: 55, place_name: 'Created Hotel', place_address: null,
|
||||||
|
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
|
||||||
|
check_in: null, check_out: null, confirmation: null },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={currentDay} days={days} places={[place]} />);
|
||||||
|
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /Created Hotel/i }));
|
||||||
|
|
||||||
|
// Extend end to Day 16 (id=7) — start stays at current day id=22
|
||||||
|
await userEvent.click(getDayPickerTriggers()[1]);
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||||
|
|
||||||
|
// Old code: 22>=22 && 22<=7 → false (hotel vanishes). New code: position 5 in [5,15] → visible.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Created Hotel')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-062: non-monotonic IDs — hotel shown on initial load when it spans the full trip', async () => {
|
||||||
|
const days = buildNonMonotonicDays();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/accommodations', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
accommodations: [{ id: 1, place_id: 60, place_name: 'Full Trip Hotel', place_address: null,
|
||||||
|
start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null }],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Day 1 (id=17): old filter: 17>=17 && 17<=7 → false. New: position 0 in [0,15] → visible.
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
|
||||||
|
await screen.findByText('Full Trip Hotel');
|
||||||
|
|
||||||
|
// Intermediate day (id=1, position 9): old filter: 1>=17 → false. New: 9 in [0,15] → visible.
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={days[9]} days={days} />);
|
||||||
|
await screen.findByText('Full Trip Hotel');
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
|
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
|
||||||
seedStore(useSettingsStore, {
|
seedStore(useSettingsStore, {
|
||||||
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
|
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import CustomTimePicker from '../shared/CustomTimePicker'
|
|||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||||
|
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||||
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
|
||||||
const WEATHER_ICON_MAP = {
|
const WEATHER_ICON_MAP = {
|
||||||
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
||||||
@@ -56,9 +58,10 @@ interface DayDetailPanelProps {
|
|||||||
rightWidth?: number
|
rightWidth?: number
|
||||||
collapsed?: boolean
|
collapsed?: boolean
|
||||||
onToggleCollapse?: () => void
|
onToggleCollapse?: () => void
|
||||||
|
mobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse }: DayDetailPanelProps) {
|
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false }: DayDetailPanelProps) {
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const tripObj = useTripStore((s) => s.trip)
|
const tripObj = useTripStore((s) => s.trip)
|
||||||
@@ -66,7 +69,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||||
const fmtTime = (v) => formatTime12(v, is12h)
|
const fmtTime = (v) => {
|
||||||
|
if (!v) return v
|
||||||
|
if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||||
|
return formatTime12(v, is12h)
|
||||||
|
}
|
||||||
const unit = isFahrenheit ? '°F' : '°C'
|
const unit = isFahrenheit ? '°F' : '°C'
|
||||||
const collapsed = collapsedProp
|
const collapsed = collapsedProp
|
||||||
const toggleCollapse = () => onToggleCollapse?.()
|
const toggleCollapse = () => onToggleCollapse?.()
|
||||||
@@ -95,7 +102,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
setAccommodations(data.accommodations || [])
|
setAccommodations(data.accommodations || [])
|
||||||
const allForDay = (data.accommodations || []).filter(a =>
|
const allForDay = (data.accommodations || []).filter(a =>
|
||||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
)
|
)
|
||||||
setDayAccommodations(allForDay)
|
setDayAccommodations(allForDay)
|
||||||
setAccommodation(allForDay[0] || null)
|
setAccommodation(allForDay[0] || null)
|
||||||
@@ -126,7 +133,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
setAccommodations(updated)
|
setAccommodations(updated)
|
||||||
setAccommodation(newAcc)
|
setAccommodation(newAcc)
|
||||||
setDayAccommodations(updated.filter(a =>
|
setDayAccommodations(updated.filter(a =>
|
||||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
))
|
))
|
||||||
setShowHotelPicker(false)
|
setShowHotelPicker(false)
|
||||||
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
|
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
|
||||||
@@ -150,7 +157,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const updated = accommodations.filter(a => a.id !== accommodation.id)
|
const updated = accommodations.filter(a => a.id !== accommodation.id)
|
||||||
setAccommodations(updated)
|
setAccommodations(updated)
|
||||||
setDayAccommodations(updated.filter(a =>
|
setDayAccommodations(updated.filter(a =>
|
||||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
))
|
))
|
||||||
setAccommodation(null)
|
setAccommodation(null)
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
@@ -168,7 +175,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
|
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...(mobile ? { zIndex: 10000 } : null), ...font }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
backdropFilter: 'blur(40px) saturate(180%)',
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
@@ -283,7 +290,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
{/* ── Reservations for this day's assignments ── */}
|
{/* ── Reservations for this day's assignments ── */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const dayAssignments = assignments[String(day.id)] || []
|
const dayAssignments = assignments[String(day.id)] || []
|
||||||
const dayReservations = reservations.filter(r => dayAssignments.some(a => a.id === r.assignment_id))
|
const dayReservations = reservations.filter(r => {
|
||||||
|
if (r.type === 'hotel') return false
|
||||||
|
if (r.assignment_id && dayAssignments.some(a => a.id === r.assignment_id)) return true
|
||||||
|
return r.day_id === day.id
|
||||||
|
})
|
||||||
if (dayReservations.length === 0) return null
|
if (dayReservations.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 0 }}>
|
<div style={{ marginBottom: 0 }}>
|
||||||
@@ -300,12 +311,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
||||||
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
{r.reservation_time?.includes('T') && (
|
{(() => {
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
const { time: startTime } = splitReservationDateTime(r.reservation_time)
|
||||||
{new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
const { time: endTime } = splitReservationDateTime(r.reservation_end_time)
|
||||||
{r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
|
if (!startTime && !endTime) return null
|
||||||
</span>
|
return (
|
||||||
)}
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
{startTime ? formatTime12(startTime, is12h) : ''}
|
||||||
|
{endTime ? ` – ${formatTime12(endTime, is12h)}` : ''}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -459,7 +475,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={hotelDayRange.start}
|
value={hotelDayRange.start}
|
||||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
onChange={v => setHotelDayRange(prev => ({ start: v, end: days.findIndex(d => d.id === v) > days.findIndex(d => d.id === prev.end) ? v : prev.end }))}
|
||||||
options={days.map((d, i) => ({
|
options={days.map((d, i) => ({
|
||||||
value: d.id,
|
value: d.id,
|
||||||
label: d.title || t('planner.dayN', { n: i + 1 }),
|
label: d.title || t('planner.dayN', { n: i + 1 }),
|
||||||
@@ -474,7 +490,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={hotelDayRange.end}
|
value={hotelDayRange.end}
|
||||||
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
onChange={v => setHotelDayRange(prev => ({ start: days.findIndex(d => d.id === v) < days.findIndex(d => d.id === prev.start) ? v : prev.start, end: v }))}
|
||||||
options={days.map((d, i) => ({
|
options={days.map((d, i) => ({
|
||||||
value: d.id,
|
value: d.id,
|
||||||
label: d.title || t('planner.dayN', { n: i + 1 }),
|
label: d.title || t('planner.dayN', { n: i + 1 }),
|
||||||
@@ -594,9 +610,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const all = d.accommodations || []
|
const all = d.accommodations || []
|
||||||
setAccommodations(all)
|
setAccommodations(all)
|
||||||
setDayAccommodations(all.filter(a =>
|
setDayAccommodations(all.filter(a =>
|
||||||
days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
))
|
))
|
||||||
const acc = all.find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
const acc = all.find(a => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false)
|
||||||
setAccommodation(acc || null)
|
setAccommodation(acc || null)
|
||||||
})
|
})
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
|
|||||||
@@ -268,14 +268,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||||
// Find the pencil/edit button next to the title
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const editButtons = screen.getAllByRole('button')
|
|
||||||
const editBtn = editButtons.find(btn => btn.querySelector('svg') && btn.closest('[style]')?.textContent?.includes('Original Title'))
|
|
||||||
// Click the edit (pencil) button — it's the small one near the title
|
|
||||||
// The pencil button is inside the title area with opacity 0.35
|
|
||||||
const titleEl = screen.getByText('Original Title')
|
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
|
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -287,9 +280,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const onUpdateDayTitle = vi.fn()
|
const onUpdateDayTitle = vi.fn()
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||||
// Enter edit mode
|
// Enter edit mode
|
||||||
const titleEl = screen.getByText('Original Title')
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
const input = await screen.findByDisplayValue('Original Title')
|
const input = await screen.findByDisplayValue('Original Title')
|
||||||
await user.clear(input)
|
await user.clear(input)
|
||||||
await user.type(input, 'New Title')
|
await user.type(input, 'New Title')
|
||||||
@@ -301,9 +292,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||||
const titleEl = screen.getByText('Original Title')
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
const input = await screen.findByDisplayValue('Original Title')
|
const input = await screen.findByDisplayValue('Original Title')
|
||||||
await user.keyboard('{Escape}')
|
await user.keyboard('{Escape}')
|
||||||
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
|
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
|
||||||
@@ -625,9 +614,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const onUpdateDayTitle = vi.fn()
|
const onUpdateDayTitle = vi.fn()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||||
const titleEl = screen.getByText('Old Title')
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
const input = await screen.findByDisplayValue('Old Title')
|
const input = await screen.findByDisplayValue('Old Title')
|
||||||
await user.clear(input)
|
await user.clear(input)
|
||||||
await user.type(input, 'New Title')
|
await user.type(input, 'New Title')
|
||||||
|
|||||||
@@ -2,18 +2,19 @@
|
|||||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
|
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
|
||||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Footprints, Route as RouteIcon } from 'lucide-react'
|
||||||
|
|
||||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import remarkBreaks from 'remark-breaks'
|
||||||
import WeatherWidget from '../Weather/WeatherWidget'
|
import WeatherWidget from '../Weather/WeatherWidget'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
@@ -21,10 +22,16 @@ import { useTripStore } from '../../store/tripStore'
|
|||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||||
|
import {
|
||||||
|
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
||||||
|
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||||
|
type MergedItem,
|
||||||
|
} from '../../utils/dayMerge'
|
||||||
|
import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters'
|
||||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||||
import Tooltip from '../shared/Tooltip'
|
import Tooltip from '../shared/Tooltip'
|
||||||
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
|
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult, RouteSegment } from '../../types'
|
||||||
|
|
||||||
const NOTE_ICONS = [
|
const NOTE_ICONS = [
|
||||||
{ id: 'FileText', Icon: FileText },
|
{ id: 'FileText', Icon: FileText },
|
||||||
@@ -177,6 +184,10 @@ interface DayPlanSidebarProps {
|
|||||||
onExternalTransportDetailHandled?: () => void
|
onExternalTransportDetailHandled?: () => void
|
||||||
onAddReservation: () => void
|
onAddReservation: () => void
|
||||||
onNavigateToFiles?: () => void
|
onNavigateToFiles?: () => void
|
||||||
|
routeShown?: boolean
|
||||||
|
routeProfile?: 'driving' | 'walking'
|
||||||
|
onToggleRoute?: () => void
|
||||||
|
onSetRouteProfile?: (profile: 'driving' | 'walking') => void
|
||||||
onAddPlace?: () => void
|
onAddPlace?: () => void
|
||||||
onAddPlaceToDay?: (placeId: number, dayId: number) => void
|
onAddPlaceToDay?: (placeId: number, dayId: number) => void
|
||||||
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||||
@@ -189,6 +200,27 @@ interface DayPlanSidebarProps {
|
|||||||
onEditTransport?: (reservation: Reservation) => void
|
onEditTransport?: (reservation: Reservation) => void
|
||||||
onEditReservation?: (reservation: Reservation) => void
|
onEditReservation?: (reservation: Reservation) => void
|
||||||
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
||||||
|
initialScrollTop?: number
|
||||||
|
onScrollTopChange?: (top: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
||||||
|
function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: 'driving' | 'walking' }) {
|
||||||
|
const driving = profile === 'driving'
|
||||||
|
const Icon = driving ? Car : Footprints
|
||||||
|
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
||||||
|
<div style={line} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||||
|
<Icon size={11} strokeWidth={2} />
|
||||||
|
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||||
|
<span style={{ opacity: 0.4 }}>·</span>
|
||||||
|
<span>{seg.distanceText}</span>
|
||||||
|
</div>
|
||||||
|
<div style={line} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||||
@@ -207,6 +239,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
onAddPlace,
|
onAddPlace,
|
||||||
onAddPlaceToDay,
|
onAddPlaceToDay,
|
||||||
onNavigateToFiles,
|
onNavigateToFiles,
|
||||||
|
routeShown = false,
|
||||||
|
routeProfile = 'driving',
|
||||||
|
onToggleRoute,
|
||||||
|
onSetRouteProfile,
|
||||||
onExpandedDaysChange,
|
onExpandedDaysChange,
|
||||||
pushUndo,
|
pushUndo,
|
||||||
canUndo = false,
|
canUndo = false,
|
||||||
@@ -217,6 +253,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
onEditTransport,
|
onEditTransport,
|
||||||
onEditReservation,
|
onEditReservation,
|
||||||
onAddBookingToAssignment,
|
onAddBookingToAssignment,
|
||||||
|
initialScrollTop,
|
||||||
|
onScrollTopChange,
|
||||||
}: DayPlanSidebarProps) {
|
}: DayPlanSidebarProps) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
@@ -240,6 +278,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const [editTitle, setEditTitle] = useState('')
|
const [editTitle, setEditTitle] = useState('')
|
||||||
const [isCalculating, setIsCalculating] = useState(false)
|
const [isCalculating, setIsCalculating] = useState(false)
|
||||||
const [routeInfo, setRouteInfo] = useState(null)
|
const [routeInfo, setRouteInfo] = useState(null)
|
||||||
|
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
||||||
|
const legsAbortRef = useRef<AbortController | null>(null)
|
||||||
const [draggingId, setDraggingId] = useState(null)
|
const [draggingId, setDraggingId] = useState(null)
|
||||||
const [lockedIds, setLockedIds] = useState(new Set())
|
const [lockedIds, setLockedIds] = useState(new Set())
|
||||||
const [lockHoverId, setLockHoverId] = useState(null)
|
const [lockHoverId, setLockHoverId] = useState(null)
|
||||||
@@ -269,6 +309,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const dragDataRef = useRef(null)
|
const dragDataRef = useRef(null)
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (scrollContainerRef.current && initialScrollTop) {
|
||||||
|
scrollContainerRef.current.scrollTop = initialScrollTop
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
||||||
// Remember which assignment we last auto-scrolled into view so we don't
|
// Remember which assignment we last auto-scrolled into view so we don't
|
||||||
// keep yanking the user back whenever they scroll away while the same
|
// keep yanking the user back whenever they scroll away while the same
|
||||||
@@ -350,26 +396,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
|
||||||
|
|
||||||
// Get span phase: how a reservation relates to a specific day (by id)
|
|
||||||
const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
|
||||||
const startDayId = r.day_id
|
|
||||||
const endDayId = r.end_day_id ?? startDayId
|
|
||||||
if (!startDayId || startDayId === endDayId) return 'single'
|
|
||||||
if (dayId === startDayId) return 'start'
|
|
||||||
if (dayId === endDayId) return 'end'
|
|
||||||
return 'middle'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the appropriate display time for a reservation on a specific day
|
|
||||||
const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => {
|
|
||||||
const phase = getSpanPhase(r, dayId)
|
|
||||||
if (phase === 'end') return r.reservation_end_time || null
|
|
||||||
if (phase === 'middle') return null
|
|
||||||
return r.reservation_time || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get phase label for multi-day badge
|
// Get phase label for multi-day badge
|
||||||
const getSpanLabel = (r: Reservation, phase: string): string | null => {
|
const getSpanLabel = (r: Reservation, phase: string): string | null => {
|
||||||
if (phase === 'single') return null
|
if (phase === 'single') return null
|
||||||
@@ -394,27 +420,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
return { day_id: startId, end_day_id: targetDayId }
|
return { day_id: startId, end_day_id: targetDayId }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTransportForDay = (dayId: number) => {
|
const getTransportForDay = (dayId: number) =>
|
||||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
_getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days })
|
||||||
return reservations.filter(r => {
|
|
||||||
if (r.type === 'hotel') return false
|
|
||||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
|
||||||
|
|
||||||
const startDayId = r.day_id
|
|
||||||
const endDayId = r.end_day_id ?? startDayId
|
|
||||||
|
|
||||||
if (startDayId == null) return false
|
|
||||||
|
|
||||||
if (endDayId !== startDayId) {
|
|
||||||
const startDay = days.find(d => d.id === startDayId)
|
|
||||||
const endDay = days.find(d => d.id === endDayId)
|
|
||||||
const thisDay = days.find(d => d.id === dayId)
|
|
||||||
if (!startDay || !endDay || !thisDay) return false
|
|
||||||
return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay)
|
|
||||||
}
|
|
||||||
return startDayId === dayId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
||||||
const getActiveRentalsForDay = (dayId: number) => {
|
const getActiveRentalsForDay = (dayId: number) => {
|
||||||
@@ -434,20 +441,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const getDayAssignments = (dayId) =>
|
const getDayAssignments = (dayId) =>
|
||||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||||
|
|
||||||
// Helper: parse time string ("HH:MM" or ISO) to minutes since midnight, or null
|
|
||||||
const parseTimeToMinutes = (time?: string | null): number | null => {
|
|
||||||
if (!time) return null
|
|
||||||
// ISO-Format "2025-03-30T09:00:00"
|
|
||||||
if (time.includes('T')) {
|
|
||||||
const [h, m] = time.split('T')[1].split(':').map(Number)
|
|
||||||
return h * 60 + m
|
|
||||||
}
|
|
||||||
// Einfaches "HH:MM" Format
|
|
||||||
const parts = time.split(':').map(Number)
|
|
||||||
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute initial day_plan_position for a transport based on time
|
// Compute initial day_plan_position for a transport based on time
|
||||||
const computeTransportPosition = (r, da) => {
|
const computeTransportPosition = (r, da) => {
|
||||||
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
||||||
@@ -489,64 +482,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMergedItems = (dayId) => {
|
const getMergedItems = (dayId: number): MergedItem[] =>
|
||||||
const da = getDayAssignments(dayId)
|
_getMergedItems({
|
||||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
dayAssignments: getDayAssignments(dayId),
|
||||||
const transport = getTransportForDay(dayId)
|
dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order),
|
||||||
|
dayTransports: getTransportForDay(dayId),
|
||||||
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
|
dayId,
|
||||||
const baseItems = [
|
getDisplayTime: getDisplayTimeForDay,
|
||||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
})
|
||||||
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
|
||||||
].sort((a, b) => a.sortKey - b.sortKey)
|
|
||||||
|
|
||||||
// Transports are inserted among places based on time
|
|
||||||
const timedTransports = transport.map(r => ({
|
|
||||||
type: 'transport' as const,
|
|
||||||
data: r,
|
|
||||||
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0,
|
|
||||||
})).sort((a, b) => a.minutes - b.minutes)
|
|
||||||
|
|
||||||
if (timedTransports.length === 0) return baseItems
|
|
||||||
if (baseItems.length === 0) {
|
|
||||||
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert transports among places based on per-day position or time
|
|
||||||
const result = [...baseItems]
|
|
||||||
for (let ti = 0; ti < timedTransports.length; ti++) {
|
|
||||||
const timed = timedTransports[ti]
|
|
||||||
const minutes = timed.minutes
|
|
||||||
|
|
||||||
// Use per-day position if explicitly set by user reorder
|
|
||||||
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
|
||||||
if (perDayPos != null) {
|
|
||||||
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find insertion position: after the last place with time <= this transport's time
|
|
||||||
let insertAfterKey = -Infinity
|
|
||||||
for (const item of result) {
|
|
||||||
if (item.type === 'place') {
|
|
||||||
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
|
||||||
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
|
||||||
} else if (item.type === 'transport') {
|
|
||||||
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
|
||||||
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
|
||||||
const sortKey = insertAfterKey === -Infinity
|
|
||||||
? lastKey + 0.5 + ti * 0.01
|
|
||||||
: insertAfterKey + 0.01 + ti * 0.001
|
|
||||||
|
|
||||||
result.push({ type: timed.type, sortKey, data: timed.data })
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -558,6 +501,42 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [days, assignments, dayNotes, reservations, transportPosVersion])
|
}, [days, assignments, dayNotes, reservations, transportPosVersion])
|
||||||
|
|
||||||
|
// Per-segment driving times for the selected day's connectors. Groups located
|
||||||
|
// places into runs (split at transports), one cached OSRM call per run, keyed by
|
||||||
|
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
||||||
|
useEffect(() => {
|
||||||
|
if (legsAbortRef.current) legsAbortRef.current.abort()
|
||||||
|
if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
|
||||||
|
const merged = mergedItemsMap[selectedDayId] || []
|
||||||
|
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||||
|
let cur: { id: number; lat: number; lng: number }[] = []
|
||||||
|
for (const it of merged) {
|
||||||
|
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||||
|
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
||||||
|
} else if (it.type === 'transport') {
|
||||||
|
if (cur.length >= 2) runs.push(cur)
|
||||||
|
cur = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur.length >= 2) runs.push(cur)
|
||||||
|
if (runs.length === 0) { setRouteLegs({}); return }
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
legsAbortRef.current = controller
|
||||||
|
;(async () => {
|
||||||
|
const map: Record<number, RouteSegment> = {}
|
||||||
|
for (const run of runs) {
|
||||||
|
try {
|
||||||
|
const r = await calculateRouteWithLegs(run.map(p => ({ lat: p.lat, lng: p.lng })), { signal: controller.signal, profile: routeProfile })
|
||||||
|
r.legs.forEach((leg, i) => { map[run[i].id] = leg })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!controller.signal.aborted) setRouteLegs(map)
|
||||||
|
})()
|
||||||
|
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap])
|
||||||
|
|
||||||
const openAddNote = (dayId, e) => {
|
const openAddNote = (dayId, e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
_openAddNote(dayId, getMergedItems, (id) => {
|
_openAddNote(dayId, getMergedItems, (id) => {
|
||||||
@@ -878,13 +857,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGoogleMaps = () => {
|
|
||||||
if (!selectedDayId) return
|
|
||||||
const da = getDayAssignments(selectedDayId)
|
|
||||||
const url = generateGoogleMapsUrl(da.map(a => a.place).filter(p => p?.lat && p?.lng))
|
|
||||||
if (url) window.open(url, '_blank')
|
|
||||||
else toast.error(t('dayplan.toast.noGeoPlaces'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDropOnDay = (e, dayId) => {
|
const handleDropOnDay = (e, dayId) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -1116,7 +1088,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tagesliste */}
|
{/* Tagesliste */}
|
||||||
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
|
||||||
{days.map((day, index) => {
|
{days.map((day, index) => {
|
||||||
const isSelected = selectedDayId === day.id
|
const isSelected = selectedDayId === day.id
|
||||||
const isExpanded = expandedDays.has(day.id)
|
const isExpanded = expandedDays.has(day.id)
|
||||||
@@ -1133,6 +1105,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||||
<div
|
<div
|
||||||
|
className="dp-day-header"
|
||||||
|
data-selected={isSelected}
|
||||||
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
||||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
|
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
|
||||||
@@ -1152,16 +1126,34 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
||||||
>
|
>
|
||||||
{/* Tages-Badge */}
|
{/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */}
|
||||||
<div style={{
|
{(() => {
|
||||||
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
|
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
||||||
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
|
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
||||||
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
|
const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null)
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
return (
|
||||||
fontSize: 11, fontWeight: 700,
|
<div style={{
|
||||||
}}>
|
flexShrink: 0, alignSelf: 'flex-start',
|
||||||
{index + 1}
|
width: hasWeather ? 34 : 26,
|
||||||
</div>
|
borderRadius: hasWeather ? 11 : '50%',
|
||||||
|
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
|
||||||
|
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: '100%', height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700 }}>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
{hasWeather && (
|
||||||
|
<>
|
||||||
|
<div style={{ width: '64%', height: 1, background: 'currentColor', opacity: 0.25 }} />
|
||||||
|
<div style={{ padding: '3px 0 4px' }}>
|
||||||
|
<WeatherWidget lat={wLat} lng={wLng} date={day.date} stacked />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{editingDayId === day.id ? (
|
{editingDayId === day.id ? (
|
||||||
@@ -1179,42 +1171,29 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
borderBottom: '1.5px solid var(--text-primary)',
|
borderBottom: '1.5px solid var(--text-primary)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (<>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
|
||||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
||||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||||
</span>
|
</span>
|
||||||
{canEditDays && <button
|
{formattedDate && (
|
||||||
onClick={e => startEditTitle(day, e)}
|
<>
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
<span style={{ flexShrink: 0, width: 1, height: 11, background: 'var(--border-primary)' }} />
|
||||||
>
|
<span style={{ flexShrink: 0, fontSize: 11, fontWeight: 400, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>
|
||||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
{formattedDate}
|
||||||
</button>}
|
</span>
|
||||||
{canEditDays && onAddTransport && (
|
</>
|
||||||
<Tooltip label={t('transport.addTransport')} placement="top">
|
|
||||||
<button
|
|
||||||
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
|
|
||||||
aria-label={t('transport.addTransport')}
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
padding: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
opacity: 0.45,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = '1' }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '0.45' }}
|
|
||||||
>
|
|
||||||
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const hasAccs = accommodations.some(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||||
|
const hasRentals = getActiveRentalsForDay(day.id).length > 0
|
||||||
|
if (!hasAccs && !hasRentals) return null
|
||||||
|
return <div style={{ height: 1, background: 'var(--border-faint)', margin: '5px 0 5px' }} />
|
||||||
|
})()}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'nowrap', minWidth: 0 }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||||
// Sort: check-out first, then ongoing stays, then check-in last
|
// Sort: check-out first, then ongoing stays, then check-in last
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
|
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
|
||||||
@@ -1231,13 +1210,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
return dayAccs.map(acc => {
|
return dayAccs.map(acc => {
|
||||||
const isCheckIn = acc.start_day_id === day.id
|
const isCheckIn = acc.start_day_id === day.id
|
||||||
const isCheckOut = acc.end_day_id === day.id
|
const isCheckOut = acc.end_day_id === day.id
|
||||||
const bg = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.08)' : isCheckIn ? 'rgba(34,197,94,0.08)' : 'var(--bg-secondary)'
|
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-faint)'
|
||||||
const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)'
|
|
||||||
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)'
|
|
||||||
return (
|
return (
|
||||||
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: (acc as any).place_id ? 'pointer' : 'default' }}>
|
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: (acc as any).place_id ? 'pointer' : 'default', background: 'var(--bg-hover)', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
|
||||||
<Hotel size={8} style={{ color: iconColor, flexShrink: 0 }} />
|
<Hotel size={11} strokeWidth={1.8} style={{ color: iconColor, flexShrink: 0 }} />
|
||||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
|
<span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1247,41 +1224,50 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const activeRentals = getActiveRentalsForDay(day.id)
|
const activeRentals = getActiveRentalsForDay(day.id)
|
||||||
if (activeRentals.length === 0) return null
|
if (activeRentals.length === 0) return null
|
||||||
return activeRentals.map(r => (
|
return activeRentals.map(r => (
|
||||||
<span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.2)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
|
<span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: 'pointer', background: 'var(--bg-hover)', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
|
||||||
<Car size={8} style={{ color: '#3b82f6', flexShrink: 0 }} />
|
<Car size={11} strokeWidth={1.8} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
<span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{cost && (
|
||||||
|
<div style={{ marginTop: 2 }}>
|
||||||
|
<span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
|
|
||||||
{formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</span>}
|
|
||||||
{cost && <span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>}
|
|
||||||
{day.date && anyGeoPlace && <span style={{ width: 1, height: 10, background: 'var(--text-faint)', opacity: 0.3, flexShrink: 0 }} />}
|
|
||||||
{day.date && anyGeoPlace && (() => {
|
|
||||||
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
|
||||||
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
|
||||||
return <WeatherWidget lat={wLat} lng={wLng} date={day.date} compact />
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEditDays && <Tooltip label={t('dayplan.addNote')} placement="top"><button
|
{canEditDays ? (
|
||||||
onClick={e => openAddNote(day.id, e)}
|
(() => {
|
||||||
aria-label={t('dayplan.addNote')}
|
const cell = { padding: 7, cursor: 'pointer', display: 'grid', placeItems: 'center' } as const
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
const div = '1px solid var(--border-faint)'
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
return (
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
<div className="dp-day-actions" style={{ alignSelf: 'flex-start', flexShrink: 0, display: 'grid', gridTemplateColumns: '1fr 1fr', border: div, borderRadius: 9, overflow: 'hidden' }}>
|
||||||
>
|
<button onClick={e => startEditTitle(day, e)} aria-label={t('common.edit')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
|
||||||
<FileText size={16} strokeWidth={2} />
|
<Pencil size={14} strokeWidth={1.8} />
|
||||||
</button></Tooltip>}
|
</button>
|
||||||
<button
|
{onAddTransport ? (
|
||||||
onClick={e => toggleDay(day.id, e)}
|
<button onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
<Plus size={14} strokeWidth={1.8} />
|
||||||
>
|
</button>
|
||||||
{isExpanded ? <ChevronDown size={18} strokeWidth={2} /> : <ChevronRight size={18} strokeWidth={2} />}
|
) : <div style={{ borderBottom: div }} />}
|
||||||
</button>
|
<button onClick={e => openAddNote(day.id, e)} aria-label={t('dayplan.addNote')} style={{ ...cell, border: 'none', borderRight: div }}>
|
||||||
|
<FileText size={14} strokeWidth={1.8} />
|
||||||
|
</button>
|
||||||
|
<button onClick={e => toggleDay(day.id, e)} title={isExpanded ? t('common.collapse') : t('common.expand')} style={{ ...cell, border: 'none' }}>
|
||||||
|
{isExpanded ? <ChevronDown size={15} strokeWidth={1.8} /> : <ChevronRight size={15} strokeWidth={1.8} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<button onClick={e => toggleDay(day.id, e)} style={{ alignSelf: 'flex-start', flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
|
||||||
|
{isExpanded ? <ChevronDown size={16} strokeWidth={1.8} /> : <ChevronRight size={16} strokeWidth={1.8} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Aufgeklappte Orte + Notizen */}
|
{/* Aufgeklappte Orte + Notizen */}
|
||||||
@@ -1573,12 +1559,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
}}>
|
}}>
|
||||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||||
{res.reservation_time?.includes('T') && (
|
{(() => {
|
||||||
<span style={{ fontWeight: 400 }}>
|
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
if (!st && !et) return null
|
||||||
</span>
|
return (
|
||||||
)}
|
<span style={{ fontWeight: 400 }}>
|
||||||
|
{st ? formatTime(st, locale, timeFormat) : ''}
|
||||||
|
{et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
{(() => {
|
{(() => {
|
||||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||||
if (!meta) return null
|
if (!meta) return null
|
||||||
@@ -1688,6 +1679,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{routeLegs[assignment.id] && <RouteConnector seg={routeLegs[assignment.id]} profile={routeProfile} />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1722,7 +1714,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
return (
|
return (
|
||||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
||||||
<div
|
<div
|
||||||
onClick={() => canEditDays && onEditTransport?.(res)}
|
onClick={() => {
|
||||||
|
if (!canEditDays) return
|
||||||
|
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
|
||||||
|
else onEditReservation?.(res)
|
||||||
|
}}
|
||||||
onDragOver={e => {
|
onDragOver={e => {
|
||||||
e.preventDefault(); e.stopPropagation()
|
e.preventDefault(); e.stopPropagation()
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
@@ -1733,6 +1729,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
draggable={canEditDays && spanPhase !== 'middle'}
|
draggable={canEditDays && spanPhase !== 'middle'}
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
||||||
|
// setData is required for the drag to start reliably (Firefox) and
|
||||||
|
// matches how place/note items initiate their drag.
|
||||||
|
e.dataTransfer.setData('reservationId', String(res.id))
|
||||||
|
e.dataTransfer.setData('fromDayId', String(day.id))
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
|
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
|
||||||
setDraggingId(res.id)
|
setDraggingId(res.id)
|
||||||
@@ -1801,18 +1801,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{res.title}
|
{res.title}
|
||||||
</span>
|
</span>
|
||||||
{displayTime?.includes('T') && (
|
{(() => {
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
const { time: dispTime } = splitReservationDateTime(displayTime)
|
||||||
<Clock size={9} strokeWidth={2} />
|
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||||
{new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
if (!dispTime && !endTime) return null
|
||||||
{spanPhase === 'single' && res.reservation_end_time && (() => {
|
return (
|
||||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time)
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
||||||
return ` – ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`
|
<Clock size={9} strokeWidth={2} />
|
||||||
})()}
|
{dispTime ? formatTime(dispTime, locale, timeFormat) : ''}
|
||||||
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
|
{spanPhase === 'single' && endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||||
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
|
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
|
||||||
</span>
|
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
|
||||||
)}
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
@@ -1861,8 +1863,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault(); e.stopPropagation()
|
e.preventDefault(); e.stopPropagation()
|
||||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
const { placeId, noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||||
if (fromReservationId && fromDayId !== day.id) {
|
if (placeId) {
|
||||||
|
// New place dropped onto a note: insert it among the
|
||||||
|
// assignments at the note's position (after the places
|
||||||
|
// above it), so it lands right where the note sits.
|
||||||
|
const tm = getMergedItems(day.id)
|
||||||
|
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||||
|
const pos = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||||
|
onAssignToDay?.(parseInt(placeId), day.id, pos)
|
||||||
|
setDropTargetKey(null); window.__dragData = null
|
||||||
|
} else if (fromReservationId && fromDayId !== day.id) {
|
||||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
@@ -1959,7 +1970,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
|
||||||
}
|
}
|
||||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
if (!assignmentId && !noteId && !fromReservationId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||||
if (assignmentId && fromDayId !== day.id) {
|
if (assignmentId && fromDayId !== day.id) {
|
||||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||||
@@ -1975,6 +1986,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
|
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
|
||||||
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||||
|
else if (fromReservationId && String(lastItem?.data?.id) !== fromReservationId)
|
||||||
|
handleMergedDrop(day.id, 'transport', Number(fromReservationId), lastItem.type, lastItem.data.id, true)
|
||||||
|
setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{dropTargetKey === `end-${day.id}` && (
|
{dropTargetKey === `end-${day.id}` && (
|
||||||
@@ -1985,15 +1999,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
||||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
{routeInfo && (
|
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
<button
|
||||||
<span>{routeInfo.distance}</span>
|
onClick={() => onToggleRoute?.()}
|
||||||
<span style={{ color: 'var(--text-faint)' }}>·</span>
|
style={{
|
||||||
<span>{routeInfo.duration}</span>
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
</div>
|
padding: '6px 0', fontSize: 11, fontWeight: 600, borderRadius: 8,
|
||||||
)}
|
border: routeShown ? 'none' : '1px solid var(--border-faint)',
|
||||||
|
background: routeShown ? 'var(--accent)' : 'transparent',
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
color: routeShown ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RouteIcon size={12} strokeWidth={2} />
|
||||||
|
{t('dayplan.route')}
|
||||||
|
</button>
|
||||||
<button onClick={handleOptimize} style={{
|
<button onClick={handleOptimize} style={{
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||||
@@ -2002,14 +2022,35 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<RotateCcw size={12} strokeWidth={2} />
|
<RotateCcw size={12} strokeWidth={2} />
|
||||||
{t('dayplan.optimize')}
|
{t('dayplan.optimize')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleGoogleMaps} style={{
|
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
{(['driving', 'walking'] as const).map(p => {
|
||||||
padding: '6px 10px', fontSize: 11, fontWeight: 500, borderRadius: 8,
|
const ModeIcon = p === 'driving' ? Car : Footprints
|
||||||
border: '1px solid var(--border-faint)', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit',
|
const active = routeProfile === p
|
||||||
}}>
|
return (
|
||||||
<ExternalLink size={12} strokeWidth={2} />
|
<button
|
||||||
</button>
|
key={p}
|
||||||
|
onClick={() => onSetRouteProfile?.(p)}
|
||||||
|
aria-label={p === 'driving' ? 'Driving' : 'Walking'}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '6px 10px', border: 'none', cursor: 'pointer',
|
||||||
|
background: active ? 'var(--accent)' : 'transparent',
|
||||||
|
color: active ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModeIcon size={13} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{routeInfo && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
||||||
|
<span>{routeInfo.distance}</span>
|
||||||
|
<span style={{ color: 'var(--text-faint)' }}>·</span>
|
||||||
|
<span>{routeInfo.duration}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2173,13 +2214,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
|
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
|
||||||
{res.reservation_time?.includes('T')
|
{(() => {
|
||||||
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
const { date, time } = splitReservationDateTime(res.reservation_time)
|
||||||
: res.reservation_time
|
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||||
? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
const dateStr = date
|
||||||
|
? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
: ''
|
: ''
|
||||||
}
|
const timeStr = time ? formatTime(time, locale, timeFormat) : ''
|
||||||
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
const endStr = endTime ? formatTime(endTime, locale, timeFormat) : ''
|
||||||
|
const parts: string[] = []
|
||||||
|
if (dateStr) parts.push(dateStr)
|
||||||
|
if (timeStr) parts.push(timeStr + (endStr ? ` – ${endStr}` : ''))
|
||||||
|
return parts.join(', ')
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -2220,7 +2267,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
{res.notes && (
|
{res.notes && (
|
||||||
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||||
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>
|
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
|||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import remarkBreaks from 'remark-breaks'
|
||||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
|
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
@@ -9,6 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
|||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||||
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
|
||||||
const detailsCache = new Map()
|
const detailsCache = new Map()
|
||||||
|
|
||||||
@@ -168,7 +170,10 @@ export default function PlaceInspector({
|
|||||||
|
|
||||||
const category = categories?.find(c => c.id === place.category_id)
|
const category = categories?.find(c => c.id === place.category_id)
|
||||||
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
|
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
|
||||||
const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null
|
const assignmentInDay = selectedDayId
|
||||||
|
? ((selectedAssignmentId ? dayAssignments.find(a => a.id === selectedAssignmentId) : null)
|
||||||
|
?? dayAssignments.find(a => a.place?.id === place.id))
|
||||||
|
: null
|
||||||
|
|
||||||
const openingHours = googleDetails?.opening_hours || null
|
const openingHours = googleDetails?.opening_hours || null
|
||||||
const openNow = googleDetails?.open_now ?? null
|
const openNow = googleDetails?.open_now ?? null
|
||||||
@@ -343,14 +348,14 @@ export default function PlaceInspector({
|
|||||||
{/* Description / Summary */}
|
{/* Description / Summary */}
|
||||||
{(place.description || googleDetails?.summary) && (
|
{(place.description || googleDetails?.summary) && (
|
||||||
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{place.notes && (
|
{place.notes && (
|
||||||
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>{place.notes}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -377,21 +382,29 @@ export default function PlaceInspector({
|
|||||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
{res.reservation_time && (
|
{(() => {
|
||||||
<div>
|
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
|
||||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
return (
|
||||||
</div>
|
<>
|
||||||
)}
|
{date && (
|
||||||
{res.reservation_time?.includes('T') && (
|
<div>
|
||||||
<div>
|
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
</div>
|
||||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
)}
|
||||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
{(startTime || endTime) && (
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||||
)}
|
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||||
|
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
|
||||||
|
{endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
{res.confirmation_number && (
|
{res.confirmation_number && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
||||||
@@ -399,7 +412,7 @@ export default function PlaceInspector({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>}
|
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>}
|
||||||
{(() => {
|
{(() => {
|
||||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||||
if (!meta || Object.keys(meta).length === 0) return null
|
if (!meta || Object.keys(meta).length === 0) return null
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
|
import { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react'
|
||||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye, Route } from 'lucide-react'
|
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye, Route } from 'lucide-react'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
@@ -34,6 +34,8 @@ interface PlacesSidebarProps {
|
|||||||
onCategoryFilterChange?: (categoryIds: Set<string>) => void
|
onCategoryFilterChange?: (categoryIds: Set<string>) => void
|
||||||
onPlacesFilterChange?: (filter: string) => void
|
onPlacesFilterChange?: (filter: string) => void
|
||||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||||
|
initialScrollTop?: number
|
||||||
|
onScrollTopChange?: (top: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MemoPlaceRowProps {
|
interface MemoPlaceRowProps {
|
||||||
@@ -145,6 +147,7 @@ const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
|||||||
const PlacesSidebar = React.memo(function PlacesSidebar({
|
const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
|
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
|
||||||
|
initialScrollTop, onScrollTopChange,
|
||||||
}: PlacesSidebarProps) {
|
}: PlacesSidebarProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -159,6 +162,12 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
|
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
|
||||||
const [sidebarDragOver, setSidebarDragOver] = useState(false)
|
const [sidebarDragOver, setSidebarDragOver] = useState(false)
|
||||||
const sidebarDragCounter = useRef(0)
|
const sidebarDragCounter = useRef(0)
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (scrollContainerRef.current && initialScrollTop) {
|
||||||
|
scrollContainerRef.current.scrollTop = initialScrollTop
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSidebarDragEnter = (e: React.DragEvent) => {
|
const handleSidebarDragEnter = (e: React.DragEvent) => {
|
||||||
if (!canEditPlaces) return
|
if (!canEditPlaces) return
|
||||||
@@ -636,7 +645,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Liste */}
|
{/* Liste */}
|
||||||
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-035
|
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-052
|
||||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
@@ -723,4 +723,103 @@ describe('ReservationModal', () => {
|
|||||||
expect.objectContaining({ type: 'hotel' })
|
expect.objectContaining({ type: 'hotel' })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Hotel day-range picker — non-monotonic IDs (issue #929) ───────────────
|
||||||
|
// Mirrors DayDetailPanel-056/057 for the ReservationModal path.
|
||||||
|
// ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
|
||||||
|
|
||||||
|
function buildNonMonotonicDaysRM() {
|
||||||
|
return [
|
||||||
|
buildDay({ id: 17, trip_id: 1, date: '2026-04-30', day_number: 1 }),
|
||||||
|
buildDay({ id: 18, trip_id: 1, date: '2026-05-01', day_number: 2 }),
|
||||||
|
buildDay({ id: 19, trip_id: 1, date: '2026-05-02', day_number: 3 }),
|
||||||
|
buildDay({ id: 20, trip_id: 1, date: '2026-05-03', day_number: 4 }),
|
||||||
|
buildDay({ id: 21, trip_id: 1, date: '2026-05-04', day_number: 5 }),
|
||||||
|
buildDay({ id: 22, trip_id: 1, date: '2026-05-05', day_number: 6 }),
|
||||||
|
buildDay({ id: 23, trip_id: 1, date: '2026-05-06', day_number: 7 }),
|
||||||
|
buildDay({ id: 24, trip_id: 1, date: '2026-05-07', day_number: 8 }),
|
||||||
|
buildDay({ id: 25, trip_id: 1, date: '2026-05-08', day_number: 9 }),
|
||||||
|
buildDay({ id: 1, trip_id: 1, date: '2026-05-09', day_number: 10 }),
|
||||||
|
buildDay({ id: 2, trip_id: 1, date: '2026-05-10', day_number: 11 }),
|
||||||
|
buildDay({ id: 3, trip_id: 1, date: '2026-05-11', day_number: 12 }),
|
||||||
|
buildDay({ id: 4, trip_id: 1, date: '2026-05-12', day_number: 13 }),
|
||||||
|
buildDay({ id: 5, trip_id: 1, date: '2026-05-13', day_number: 14 }),
|
||||||
|
buildDay({ id: 6, trip_id: 1, date: '2026-05-14', day_number: 15 }),
|
||||||
|
buildDay({ id: 7, trip_id: 1, date: '2026-05-15', day_number: 16 }),
|
||||||
|
] as any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESMODAL-050: non-monotonic IDs — end picker with low ID does not clobber start', async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const days = buildNonMonotonicDaysRM();
|
||||||
|
|
||||||
|
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
|
||||||
|
|
||||||
|
// Switch to hotel type
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
|
||||||
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Overlap Hotel');
|
||||||
|
|
||||||
|
// Open start picker (first "Select day" trigger) and select Day 1 (id=17)
|
||||||
|
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || b.textContent?.startsWith('Day '))[0];
|
||||||
|
await userEvent.click(startTrigger());
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 1') && !b.textContent?.startsWith('Day 1 ') || b.textContent?.trim() === 'Day 1')!);
|
||||||
|
|
||||||
|
// Open end picker and select Day 16 (id=7, low ID but last positionally)
|
||||||
|
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
|
||||||
|
await userEvent.click(endTrigger());
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
|
const saved = onSave.mock.calls[0][0];
|
||||||
|
// start must stay id=17 (Day 1) — old Math.max would clobber it to id=7
|
||||||
|
expect(saved.create_accommodation?.start_day_id).toBe(17);
|
||||||
|
expect(saved.create_accommodation?.end_day_id).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESMODAL-051: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const days = buildNonMonotonicDaysRM();
|
||||||
|
|
||||||
|
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
|
||||||
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Span Hotel');
|
||||||
|
|
||||||
|
// Set end to Day 16 (id=7) first
|
||||||
|
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
|
||||||
|
await userEvent.click(endTrigger());
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||||
|
|
||||||
|
// Set start to Day 9 (id=25, high ID but earlier by position than Day 16)
|
||||||
|
// Old code: Math.max(25, 7) = 25 → end collapses to Day 9.
|
||||||
|
// New code: position(id=25)=8 < position(id=7)=15 → end stays id=7.
|
||||||
|
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[0];
|
||||||
|
await userEvent.click(startTrigger());
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
|
const saved = onSave.mock.calls[0][0];
|
||||||
|
expect(saved.create_accommodation?.start_day_id).toBe(25); // Day 9
|
||||||
|
expect(saved.create_accommodation?.end_day_id).toBe(7); // Day 16 — must NOT have collapsed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESMODAL-052: hotel with no accommodation_id sends assignment_id as null (issue #934)', async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
// Hotel reservation with assignment_id set but no accommodation
|
||||||
|
const res = buildReservation({
|
||||||
|
id: 10, title: 'Stale Hotel', type: 'hotel', status: 'confirmed',
|
||||||
|
accommodation_id: null, assignment_id: 99,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<ReservationModal {...defaultProps} onSave={onSave} reservation={res} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
|
expect(onSave.mock.calls[0][0].assignment_id).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
let combinedEndTime = form.reservation_end_time
|
let combinedEndTime = form.reservation_end_time
|
||||||
if (form.end_date) {
|
if (form.end_date) {
|
||||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
||||||
|
} else if (form.reservation_end_time && form.reservation_time) {
|
||||||
|
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
||||||
}
|
}
|
||||||
if (isBudgetEnabled) {
|
if (isBudgetEnabled) {
|
||||||
if (form.price) metadata.price = form.price
|
if (form.price) metadata.price = form.price
|
||||||
@@ -194,7 +196,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
|
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
|
||||||
location: form.location, confirmation_number: form.confirmation_number,
|
location: form.location, confirmation_number: form.confirmation_number,
|
||||||
notes: form.notes,
|
notes: form.notes,
|
||||||
assignment_id: form.assignment_id || null,
|
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null),
|
||||||
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
||||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||||
endpoints: [],
|
endpoints: [],
|
||||||
@@ -457,7 +459,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
<label style={labelStyle}>{t('reservations.meta.fromDay')}</label>
|
<label style={labelStyle}>{t('reservations.meta.fromDay')}</label>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={form.hotel_start_day}
|
value={form.hotel_start_day}
|
||||||
onChange={value => set('hotel_start_day', value)}
|
onChange={value => setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
hotel_start_day: value,
|
||||||
|
hotel_end_day: days.findIndex(d => d.id === value) > days.findIndex(d => d.id === prev.hotel_end_day)
|
||||||
|
? value : prev.hotel_end_day,
|
||||||
|
}))}
|
||||||
placeholder={t('reservations.meta.selectDay')}
|
placeholder={t('reservations.meta.selectDay')}
|
||||||
options={days.map(d => {
|
options={days.map(d => {
|
||||||
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
|
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
|
||||||
@@ -475,7 +482,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
<label style={labelStyle}>{t('reservations.meta.toDay')}</label>
|
<label style={labelStyle}>{t('reservations.meta.toDay')}</label>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={form.hotel_end_day}
|
value={form.hotel_end_day}
|
||||||
onChange={value => set('hotel_end_day', value)}
|
onChange={value => setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
hotel_start_day: days.findIndex(d => d.id === value) < days.findIndex(d => d.id === prev.hotel_start_day)
|
||||||
|
? value : prev.hotel_start_day,
|
||||||
|
hotel_end_day: value,
|
||||||
|
}))}
|
||||||
placeholder={t('reservations.meta.selectDay')}
|
placeholder={t('reservations.meta.selectDay')}
|
||||||
options={days.map(d => {
|
options={days.map(d => {
|
||||||
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
|
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ beforeEach(() => {
|
|||||||
resetAllStores();
|
resetAllStores();
|
||||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: false, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } });
|
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: false, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ReservationsPanel', () => {
|
describe('ReservationsPanel', () => {
|
||||||
@@ -211,7 +211,7 @@ describe('ReservationsPanel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-RESP-022: confirmation number is blurred when blur_booking_codes=true', () => {
|
it('FE-PLANNER-RESP-022: confirmation number is blurred when blur_booking_codes=true', () => {
|
||||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } });
|
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
|
||||||
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
|
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
|
||||||
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||||
const codeEl = screen.getByText('ABC123');
|
const codeEl = screen.getByText('ABC123');
|
||||||
@@ -220,7 +220,7 @@ describe('ReservationsPanel', () => {
|
|||||||
|
|
||||||
it('FE-PLANNER-RESP-023: confirmation code revealed on hover when blurred', async () => {
|
it('FE-PLANNER-RESP-023: confirmation code revealed on hover when blurred', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } });
|
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
|
||||||
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
|
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
|
||||||
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||||
const codeEl = screen.getByText('ABC123');
|
const codeEl = screen.getByText('ABC123');
|
||||||
@@ -389,4 +389,51 @@ describe('ReservationsPanel', () => {
|
|||||||
expect(screen.getByText('Pending 2')).toBeInTheDocument();
|
expect(screen.getByText('Pending 2')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Pending 3')).toBeInTheDocument();
|
expect(screen.getByText('Pending 3')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESP-041: dateless transport with legacy T-prefix shows time without "Invalid Date"', () => {
|
||||||
|
const day = buildDay({ date: null, day_number: 25 } as any);
|
||||||
|
const r = buildReservation({
|
||||||
|
title: 'Cruise test',
|
||||||
|
type: 'cruise',
|
||||||
|
status: 'pending',
|
||||||
|
reservation_time: 'T10:00',
|
||||||
|
reservation_end_time: 'T18:00',
|
||||||
|
day_id: day.id,
|
||||||
|
end_day_id: day.id,
|
||||||
|
} as any);
|
||||||
|
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||||
|
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/10:00/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESP-042: dateless transport with bare time format shows time without "Invalid Date"', () => {
|
||||||
|
const day = buildDay({ date: null, day_number: 3 } as any);
|
||||||
|
const r = buildReservation({
|
||||||
|
title: 'Car rental',
|
||||||
|
type: 'car',
|
||||||
|
status: 'pending',
|
||||||
|
reservation_time: '09:00',
|
||||||
|
reservation_end_time: '17:00',
|
||||||
|
day_id: day.id,
|
||||||
|
end_day_id: day.id,
|
||||||
|
} as any);
|
||||||
|
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||||
|
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/09:00/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESP-043: dated transport still shows date and time correctly', () => {
|
||||||
|
const day = buildDay({ date: '2026-07-15', day_number: 1 });
|
||||||
|
const r = buildReservation({
|
||||||
|
title: 'Flight out',
|
||||||
|
type: 'flight',
|
||||||
|
status: 'confirmed',
|
||||||
|
reservation_time: '2026-07-15T08:30',
|
||||||
|
reservation_end_time: '2026-07-15T10:45',
|
||||||
|
day_id: day.id,
|
||||||
|
} as any);
|
||||||
|
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||||
|
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/08:30/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle,
|
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
|
import Markdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import remarkBreaks from 'remark-breaks'
|
||||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||||
|
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||||
|
|
||||||
interface AssignmentLookupEntry {
|
interface AssignmentLookupEntry {
|
||||||
dayNumber: number
|
dayNumber: number
|
||||||
@@ -96,17 +100,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
const fmtDate = (str) => {
|
const startDt = splitReservationDateTime(r.reservation_time)
|
||||||
const dateOnly = str.includes('T') ? str.split('T')[0] : str
|
const endDt = splitReservationDateTime(r.reservation_end_time)
|
||||||
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
|
const fmtDate = (date: string) =>
|
||||||
}
|
new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
const fmtTime = (str) => {
|
|
||||||
const d = new Date(str)
|
|
||||||
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasDate = !!r.reservation_time
|
const hasDate = !!startDt.date
|
||||||
const hasTime = r.reservation_time?.includes('T')
|
const hasTime = !!(startDt.time || endDt.time)
|
||||||
const hasCode = !!r.confirmation_number
|
const hasCode = !!r.confirmation_number
|
||||||
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
||||||
|
|
||||||
@@ -230,22 +230,25 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Date / Time row */}
|
{/* Date / Time row */}
|
||||||
{hasDate && (
|
{(hasDate || hasTime) && (
|
||||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
|
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasDate && hasTime ? '1fr 1fr' : '1fr' }}>
|
||||||
<div>
|
{hasDate && (
|
||||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
<div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||||
{fmtDate(r.reservation_time)}
|
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||||
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
|
{fmtDate(startDt.date!)}
|
||||||
<> – {fmtDate(r.reservation_end_time)}</>
|
{endDt.date && endDt.date !== startDt.date && (
|
||||||
)}
|
<> – {fmtDate(endDt.date)}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{hasTime && (
|
{hasTime && (
|
||||||
<div>
|
<div>
|
||||||
<div style={fieldLabelStyle}>{t('reservations.time')}</div>
|
<div style={fieldLabelStyle}>{t('reservations.time')}</div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
|
{formatTime(startDt.time, locale, timeFormat)}
|
||||||
|
{endDt.time ? ` – ${formatTime(endDt.time, locale, timeFormat)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -304,8 +307,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` – ${fmtTime('2000-01-01T' + meta.check_in_end_time)}` : '') })
|
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
|
||||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
|
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||||
if (cells.length === 0) return null
|
if (cells.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
|
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
|
||||||
@@ -355,7 +358,9 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
{r.notes && (
|
{r.notes && (
|
||||||
<div>
|
<div>
|
||||||
<div style={fieldLabelStyle}>{t('reservations.notes')}</div>
|
<div style={fieldLabelStyle}>{t('reservations.notes')}</div>
|
||||||
<div style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5 }}>{r.notes}</div>
|
<div className="collab-note-md" style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5, wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||||
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{r.notes}</Markdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-021
|
||||||
|
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import { useAddonStore } from '../../store/addonStore';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import {
|
||||||
|
buildUser,
|
||||||
|
buildTrip,
|
||||||
|
buildDay,
|
||||||
|
buildReservation,
|
||||||
|
buildTripFile,
|
||||||
|
} from '../../../tests/helpers/factories';
|
||||||
|
import { TransportModal } from './TransportModal';
|
||||||
|
|
||||||
|
vi.mock('react-router-dom', async (importActual) => {
|
||||||
|
const actual = await importActual<typeof import('react-router-dom')>();
|
||||||
|
return { ...actual, useParams: () => ({ id: '1' }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../shared/CustomTimePicker', () => ({
|
||||||
|
default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
||||||
|
<input data-testid="time-picker" type="text" value={value} onChange={e => onChange(e.target.value)} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./AirportSelect', () => ({
|
||||||
|
default: ({ onChange }: { onChange: (a: any) => void }) => (
|
||||||
|
<input data-testid="airport-select" type="text" onChange={e => onChange({ iata: e.target.value, name: e.target.value, city: '', country: '', lat: 0, lng: 0, tz: 'UTC', icao: null })} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./LocationSelect', () => ({
|
||||||
|
default: ({ onChange }: { onChange: (l: any) => void }) => (
|
||||||
|
<input data-testid="location-select" type="text" onChange={e => onChange({ name: e.target.value, lat: 0, lng: 0, address: null })} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
isOpen: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onSave: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reservation: null,
|
||||||
|
days: [],
|
||||||
|
selectedDayId: null,
|
||||||
|
files: [],
|
||||||
|
onFileUpload: vi.fn().mockResolvedValue(undefined),
|
||||||
|
onFileDelete: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||||
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), budgetItems: [] });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TransportModal', () => {
|
||||||
|
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-001: renders without crashing', () => {
|
||||||
|
render(<TransportModal {...defaultProps} />);
|
||||||
|
expect(document.body).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-002: shows "Add transport" title for new transport', () => {
|
||||||
|
render(<TransportModal {...defaultProps} reservation={null} />);
|
||||||
|
expect(screen.getByText(/Add transport/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => {
|
||||||
|
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||||
|
expect(screen.getByText(/Edit transport/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-005: all 4 transport type buttons are visible', () => {
|
||||||
|
render(<TransportModal {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('button', { name: /^Flight$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^Train$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^Cruise$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-006: editing pre-fills title', () => {
|
||||||
|
const res = buildReservation({ title: 'LH123 Frankfurt', type: 'flight' });
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||||
|
expect(screen.getByDisplayValue('LH123 Frankfurt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-007: edit mode save button shows "Update"', () => {
|
||||||
|
const res = buildReservation({ title: 'My Train', type: 'train' });
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||||
|
expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-008: Cancel button calls onClose', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<TransportModal {...defaultProps} onClose={onClose} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-009: submitting valid flight calls onSave with correct type', async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||||
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH456');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'LH456', type: 'flight' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-010: switching to train type calls onSave with train type', async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||||
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'train' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
|
||||||
|
seedStore(useAddonStore, {
|
||||||
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
|
loaded: true,
|
||||||
|
});
|
||||||
|
render(<TransportModal {...defaultProps} />);
|
||||||
|
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
|
||||||
|
render(<TransportModal {...defaultProps} />);
|
||||||
|
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
|
||||||
|
seedStore(useAddonStore, {
|
||||||
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
|
loaded: true,
|
||||||
|
});
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||||
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
|
||||||
|
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── File attachment ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-014: attach file button rendered when onFileUpload provided', () => {
|
||||||
|
render(<TransportModal {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-015: attach file button absent when onFileUpload is undefined', () => {
|
||||||
|
render(<TransportModal {...defaultProps} onFileUpload={undefined} />);
|
||||||
|
expect(screen.queryByRole('button', { name: /Attach file/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-016: attached files shown for existing transport', () => {
|
||||||
|
const res = buildReservation({ id: 5, type: 'flight' });
|
||||||
|
const file = buildTripFile({ id: 1, trip_id: 1, original_name: 'boarding-pass.pdf' });
|
||||||
|
(file as any).reservation_id = 5;
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} files={[file]} />);
|
||||||
|
expect(screen.getByText('boarding-pass.pdf')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-017: pending file added for new transport on file input change', async () => {
|
||||||
|
render(<TransportModal {...defaultProps} reservation={null} />);
|
||||||
|
|
||||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const testFile = new File(['content'], 'itinerary.pdf', { type: 'application/pdf' });
|
||||||
|
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('itinerary.pdf')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-018: file upload to existing transport calls onFileUpload with correct FormData', async () => {
|
||||||
|
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const res = buildReservation({ id: 10, type: 'train', title: 'Eurostar' });
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
|
||||||
|
|
||||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const testFile = new File(['content'], 'ticket.pdf', { type: 'application/pdf' });
|
||||||
|
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
|
||||||
|
const [fd] = onFileUpload.mock.calls[0] as [FormData];
|
||||||
|
expect(fd.get('file')).toBeTruthy();
|
||||||
|
expect(fd.get('reservation_id')).toBe('10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-019: link existing file button appears when unattached files exist', () => {
|
||||||
|
const res = buildReservation({ id: 5, type: 'flight' });
|
||||||
|
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
||||||
|
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-020: clicking "link existing file" shows file picker dropdown', async () => {
|
||||||
|
const res = buildReservation({ id: 5, type: 'flight' });
|
||||||
|
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
|
||||||
|
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-021: clicking file in picker links it and closes picker', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/files/99/link', () => HttpResponse.json({ success: true })),
|
||||||
|
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = buildReservation({ id: 5, type: 'flight' });
|
||||||
|
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
|
||||||
|
await userEvent.click(screen.getByText('invoice.pdf'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-022: removing pending file removes it from list', async () => {
|
||||||
|
render(<TransportModal {...defaultProps} reservation={null} />);
|
||||||
|
|
||||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const testFile = new File(['content'], 'draft.pdf', { type: 'application/pdf' });
|
||||||
|
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('draft.pdf')).toBeInTheDocument());
|
||||||
|
|
||||||
|
const pendingFileRow = screen.getByText('draft.pdf').closest('div')!;
|
||||||
|
const removeBtn = pendingFileRow.querySelector('button')!;
|
||||||
|
await userEvent.click(removeBtn);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByText('draft.pdf')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-023: clicking attach file button triggers file input click', async () => {
|
||||||
|
render(<TransportModal {...defaultProps} />);
|
||||||
|
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
|
||||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
|
||||||
|
await userEvent.click(attachBtn);
|
||||||
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
|
clickSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-024: unlinking a linked file removes it from attached list', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/files/42/link', () => HttpResponse.json({ success: true })),
|
||||||
|
http.get('/api/trips/1/files/42/links', () => HttpResponse.json({ links: [{ id: 1, reservation_id: 7 }] })),
|
||||||
|
http.delete('/api/trips/1/files/42/link/1', () => HttpResponse.json({ success: true })),
|
||||||
|
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = buildReservation({ id: 7, type: 'car' });
|
||||||
|
const looseFile = buildTripFile({ id: 42, original_name: 'rental-agreement.pdf' });
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} files={[looseFile]} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
|
||||||
|
await waitFor(() => expect(screen.getByText('rental-agreement.pdf')).toBeInTheDocument());
|
||||||
|
await userEvent.click(screen.getByText('rental-agreement.pdf'));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileRow = screen.getByText('rental-agreement.pdf').closest('div')!;
|
||||||
|
const unlinkBtn = fileRow.querySelector('button[type="button"]')!;
|
||||||
|
await userEvent.click(unlinkBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-025: pending files flushed after saving new transport', async () => {
|
||||||
|
const savedReservation = buildReservation({ id: 99, type: 'flight' });
|
||||||
|
const onSave = vi.fn().mockResolvedValue(savedReservation);
|
||||||
|
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} onSave={onSave} onFileUpload={onFileUpload} reservation={null} />);
|
||||||
|
|
||||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const testFile = new File(['content'], 'boarding.pdf', { type: 'application/pdf' });
|
||||||
|
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||||
|
await waitFor(() => expect(screen.getByText('boarding.pdf')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH001');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
|
||||||
|
const [fd] = onFileUpload.mock.calls[0] as [FormData];
|
||||||
|
expect(fd.get('reservation_id')).toBe('99');
|
||||||
|
expect(fd.get('file')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { Plane, Train, Car, Ship } from 'lucide-react'
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { Plane, Train, Car, Ship, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
@@ -9,8 +10,10 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { formatDate } from '../../utils/formatters'
|
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||||
import type { Day, Reservation, ReservationEndpoint } from '../../types'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
|
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
|
||||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||||
@@ -89,26 +92,36 @@ const defaultForm = {
|
|||||||
interface TransportModalProps {
|
interface TransportModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (data: Record<string, any>) => Promise<void>
|
onSave: (data: Record<string, any>) => Promise<Reservation | undefined>
|
||||||
reservation: Reservation | null
|
reservation: Reservation | null
|
||||||
days: Day[]
|
days: Day[]
|
||||||
selectedDayId: number | null
|
selectedDayId: number | null
|
||||||
|
files?: TripFile[]
|
||||||
|
onFileUpload?: (fd: FormData) => Promise<void>
|
||||||
|
onFileDelete?: (fileId: number) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
|
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||||
const budgetItems = useTripStore(s => s.budgetItems)
|
const budgetItems = useTripStore(s => s.budgetItems)
|
||||||
|
const loadFiles = useTripStore(s => s.loadFiles)
|
||||||
const budgetCategories = useMemo(() => {
|
const budgetCategories = useMemo(() => {
|
||||||
const cats = new Set<string>()
|
const cats = new Set<string>()
|
||||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||||
return Array.from(cats).sort()
|
return Array.from(cats).sort()
|
||||||
}, [budgetItems])
|
}, [budgetItems])
|
||||||
|
const { id: tripId } = useParams<{ id: string }>()
|
||||||
const [form, setForm] = useState({ ...defaultForm })
|
const [form, setForm] = useState({ ...defaultForm })
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||||
|
const [uploadingFile, setUploadingFile] = useState(false)
|
||||||
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||||
|
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||||
|
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@@ -128,8 +141,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
status: reservation.status || 'pending',
|
status: reservation.status || 'pending',
|
||||||
start_day_id: reservation.day_id ?? '',
|
start_day_id: reservation.day_id ?? '',
|
||||||
end_day_id: reservation.end_day_id ?? '',
|
end_day_id: reservation.end_day_id ?? '',
|
||||||
departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
|
departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
|
||||||
arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
|
arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '',
|
||||||
confirmation_number: reservation.confirmation_number || '',
|
confirmation_number: reservation.confirmation_number || '',
|
||||||
notes: reservation.notes || '',
|
notes: reservation.notes || '',
|
||||||
meta_airline: meta.airline || '',
|
meta_airline: meta.airline || '',
|
||||||
@@ -166,7 +179,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
|
|
||||||
const buildTime = (day: Day | undefined, time: string): string | null => {
|
const buildTime = (day: Day | undefined, time: string): string | null => {
|
||||||
if (!time) return null
|
if (!time) return null
|
||||||
return day?.date ? `${day.date}T${time}` : `T${time}`
|
return day?.date ? `${day.date}T${time}` : time
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata: Record<string, string> = {}
|
const metadata: Record<string, string> = {}
|
||||||
@@ -222,7 +235,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||||
: { total_price: 0 }
|
: { total_price: 0 }
|
||||||
}
|
}
|
||||||
await onSave(payload)
|
const saved = await onSave(payload)
|
||||||
|
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
||||||
|
for (const file of pendingFiles) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('reservation_id', String(saved.id))
|
||||||
|
fd.append('description', form.title)
|
||||||
|
await onFileUpload(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -230,6 +252,38 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
if (reservation?.id) {
|
||||||
|
setUploadingFile(true)
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('reservation_id', String(reservation.id))
|
||||||
|
fd.append('description', reservation.title)
|
||||||
|
await onFileUpload!(fd)
|
||||||
|
toast.success(t('reservations.toast.fileUploaded'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('reservations.toast.uploadError'))
|
||||||
|
} finally {
|
||||||
|
setUploadingFile(false)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPendingFiles(prev => [...prev, file])
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachedFiles = reservation?.id
|
||||||
|
? files.filter(f =>
|
||||||
|
f.reservation_id === reservation.id ||
|
||||||
|
linkedFileIds.includes(f.id) ||
|
||||||
|
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
|
||||||
const inputStyle = {
|
const inputStyle = {
|
||||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
|
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
|
||||||
@@ -444,6 +498,94 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('files.title')}</label>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{attachedFiles.map(f => (
|
||||||
|
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||||
|
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
|
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
|
||||||
|
<button type="button" onClick={async () => {
|
||||||
|
if (f.reservation_id === reservation?.id) {
|
||||||
|
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||||
|
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||||
|
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
|
||||||
|
} catch {}
|
||||||
|
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
||||||
|
if (tripId) loadFiles(tripId)
|
||||||
|
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{pendingFiles.map((f, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||||
|
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||||
|
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||||
|
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||||
|
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Paperclip size={11} />
|
||||||
|
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||||
|
</button>}
|
||||||
|
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||||
|
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||||
|
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Link2 size={11} /> {t('reservations.linkExisting')}
|
||||||
|
</button>
|
||||||
|
{showFilePicker && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
|
||||||
|
<button key={f.id} type="button" onClick={async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
|
||||||
|
setLinkedFileIds(prev => [...prev, f.id])
|
||||||
|
setShowFilePicker(false)
|
||||||
|
if (tripId) loadFiles(tripId)
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
|
||||||
|
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||||
|
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Price + Budget Category */}
|
{/* Price + Budget Category */}
|
||||||
{isBudgetEnabled && (
|
{isBudgetEnabled && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -161,29 +161,6 @@ describe('DisplaySettingsTab', () => {
|
|||||||
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
|
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-DISPLAY-021: shows Route Calculation section', () => {
|
|
||||||
render(<DisplaySettingsTab />);
|
|
||||||
expect(screen.getByText(/route calculation/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-COMP-DISPLAY-022: route calculation On button is active when route_calculation is true', () => {
|
|
||||||
seedStore(useSettingsStore, { settings: buildSettings({ route_calculation: true }) });
|
|
||||||
render(<DisplaySettingsTab />);
|
|
||||||
const onButtons = screen.getAllByText(/^On$/i);
|
|
||||||
const routeCalcOnBtn = onButtons[0].closest('button')!;
|
|
||||||
expect(routeCalcOnBtn.style.border).toContain('var(--text-primary)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-COMP-DISPLAY-023: clicking route calculation Off calls updateSetting with false', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
|
||||||
seedStore(useSettingsStore, { settings: buildSettings({ route_calculation: true }), updateSetting });
|
|
||||||
render(<DisplaySettingsTab />);
|
|
||||||
const offButtons = screen.getAllByText(/^Off$/i);
|
|
||||||
await user.click(offButtons[0]);
|
|
||||||
expect(updateSetting).toHaveBeenCalledWith('route_calculation', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-COMP-DISPLAY-024: shows Blur Booking Codes section', () => {
|
it('FE-COMP-DISPLAY-024: shows Blur Booking Codes section', () => {
|
||||||
render(<DisplaySettingsTab />);
|
render(<DisplaySettingsTab />);
|
||||||
expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument();
|
expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -214,36 +214,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Route Calculation */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{[
|
|
||||||
{ value: true, label: t('settings.on') || 'On' },
|
|
||||||
{ value: false, label: t('settings.off') || 'Off' },
|
|
||||||
].map(opt => (
|
|
||||||
<button
|
|
||||||
key={String(opt.value)}
|
|
||||||
onClick={async () => {
|
|
||||||
try { await updateSetting('route_calculation', opt.value) }
|
|
||||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
|
||||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
|
||||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
|
||||||
border: (settings.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
|
||||||
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
transition: 'all 0.15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Booking route labels */}
|
{/* Booking route labels */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.bookingLabels')}</label>
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.bookingLabels')}</label>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface OAuthClient {
|
|||||||
client_id: string
|
client_id: string
|
||||||
redirect_uris: string[]
|
redirect_uris: string[]
|
||||||
allowed_scopes: string[]
|
allowed_scopes: string[]
|
||||||
|
allows_client_credentials: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
client_secret?: string // only present on create
|
client_secret?: string // only present on create
|
||||||
}
|
}
|
||||||
@@ -117,6 +118,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
const [oauthRotating, setOauthRotating] = useState(false)
|
const [oauthRotating, setOauthRotating] = useState(false)
|
||||||
// oauthScopesOpen is managed internally by ScopeGroupPicker
|
// oauthScopesOpen is managed internally by ScopeGroupPicker
|
||||||
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
|
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
|
||||||
|
const [oauthIsMachine, setOauthIsMachine] = useState(false)
|
||||||
|
|
||||||
// MCP sub-tab state
|
// MCP sub-tab state
|
||||||
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
|
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
|
||||||
@@ -214,16 +216,23 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
}, [mcpEnabled])
|
}, [mcpEnabled])
|
||||||
|
|
||||||
const handleCreateOAuthClient = async () => {
|
const handleCreateOAuthClient = async () => {
|
||||||
if (!oauthNewName.trim() || !oauthNewUris.trim()) return
|
if (!oauthNewName.trim()) return
|
||||||
|
if (!oauthIsMachine && !oauthNewUris.trim()) return
|
||||||
setOauthCreating(true)
|
setOauthCreating(true)
|
||||||
try {
|
try {
|
||||||
const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
|
const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
|
||||||
const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
|
const d = await oauthApi.clients.create({
|
||||||
|
name: oauthNewName.trim(),
|
||||||
|
redirect_uris: uris,
|
||||||
|
allowed_scopes: oauthNewScopes,
|
||||||
|
...(oauthIsMachine ? { allows_client_credentials: true } : {}),
|
||||||
|
})
|
||||||
setOauthCreatedClient(d.client)
|
setOauthCreatedClient(d.client)
|
||||||
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
|
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
|
||||||
setOauthNewName('')
|
setOauthNewName('')
|
||||||
setOauthNewUris('')
|
setOauthNewUris('')
|
||||||
setOauthNewScopes([])
|
setOauthNewScopes([])
|
||||||
|
setOauthIsMachine(false)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('settings.oauth.toast.createError'))
|
toast.error(t('settings.oauth.toast.createError'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -342,7 +351,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
|
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
|
||||||
|
|
||||||
<div className="flex justify-end mb-2">
|
<div className="flex justify-end mb-2">
|
||||||
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
|
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]); setOauthIsMachine(false) }}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
|
||||||
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
|
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
|
||||||
</button>
|
</button>
|
||||||
@@ -360,7 +369,15 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
|
||||||
|
{client.allows_client_credentials && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0"
|
||||||
|
style={{ background: 'rgba(99,102,241,0.12)', color: '#4f46e5', border: '1px solid rgba(99,102,241,0.3)' }}>
|
||||||
|
{t('settings.oauth.badge.machine')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{t('settings.oauth.clientId')}: {client.client_id}
|
{t('settings.oauth.clientId')}: {client.client_id}
|
||||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
|
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
|
||||||
@@ -616,15 +633,26 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
autoFocus />
|
autoFocus />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
|
<input type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)}
|
||||||
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
|
className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||||
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
|
<div>
|
||||||
rows={3}
|
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.machineClient')}</span>
|
||||||
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p>
|
||||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
|
</div>
|
||||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
|
</label>
|
||||||
</div>
|
|
||||||
|
{!oauthIsMachine && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
|
||||||
|
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
|
||||||
|
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
|
||||||
|
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
|
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
|
||||||
@@ -638,7 +666,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleCreateOAuthClient}
|
<button onClick={handleCreateOAuthClient}
|
||||||
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthCreating}
|
disabled={!oauthNewName.trim() || (!oauthIsMachine && !oauthNewUris.trim()) || oauthCreating}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
|
||||||
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
|
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
|
||||||
</button>
|
</button>
|
||||||
@@ -681,6 +709,12 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{oauthCreatedClient?.allows_client_credentials && (
|
||||||
|
<div className="p-3 rounded-lg border text-xs font-mono" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
|
||||||
|
{t('settings.oauth.modal.machineClientUsage')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
|
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ interface WeatherWidgetProps {
|
|||||||
lng: number | null
|
lng: number | null
|
||||||
date: string
|
date: string
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
|
/** Vertical icon-over-temp layout that inherits its color (for the day badge). */
|
||||||
|
stacked?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WeatherWidget({ lat, lng, date, compact = false }: WeatherWidgetProps) {
|
export default function WeatherWidget({ lat, lng, date, compact = false, stacked = false }: WeatherWidgetProps) {
|
||||||
const [weather, setWeather] = useState(null)
|
const [weather, setWeather] = useState(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
@@ -111,6 +113,15 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
|
|||||||
const unit = isFahrenheit ? '°F' : '°C'
|
const unit = isFahrenheit ? '°F' : '°C'
|
||||||
const isClimate = weather.type === 'climate'
|
const isClimate = weather.type === 'climate'
|
||||||
|
|
||||||
|
if (stacked) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, fontSize: 9.5, fontWeight: 600, lineHeight: 1, color: 'inherit', ...fontStyle }}>
|
||||||
|
<WeatherIcon main={weather.main} size={13} />
|
||||||
|
{temp !== null && <span>{isClimate ? 'Ø' : ''}{temp}°</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface PlaceAvatarProps {
|
|||||||
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
|
const imageUrlFailed = useRef(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||||
|
|
||||||
@@ -86,7 +87,18 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
|||||||
alt={place.name}
|
alt={place.name}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
onError={() => setPhotoSrc(null)}
|
onError={() => {
|
||||||
|
if (!imageUrlFailed.current && photoSrc === place.image_url && (place.google_place_id || place.osm_id)) {
|
||||||
|
imageUrlFailed.current = true
|
||||||
|
const photoId = place.google_place_id || place.osm_id!
|
||||||
|
const cacheKey = `refetch:${photoId}`
|
||||||
|
fetchPhoto(cacheKey, photoId, place.lat ?? undefined, place.lng ?? undefined, place.name,
|
||||||
|
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setPhotoSrc(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
|
||||||
import { useTripStore } from '../store/tripStore'
|
import { useTripStore } from '../store/tripStore'
|
||||||
import { calculateSegments } from '../components/Map/RouteCalculator'
|
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||||
import type { TripStoreState } from '../store/tripStore'
|
import type { TripStoreState } from '../store/tripStore'
|
||||||
import type { RouteSegment, RouteResult } from '../types'
|
import type { RouteSegment, RouteResult } from '../types'
|
||||||
|
|
||||||
@@ -9,20 +8,20 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise']
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
||||||
* day assignments, draws a straight-line route, and optionally fetches per-segment
|
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
|
||||||
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
|
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
|
||||||
*/
|
*/
|
||||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) {
|
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') {
|
||||||
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
||||||
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
||||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||||
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
|
|
||||||
const routeAbortRef = useRef<AbortController | null>(null)
|
const routeAbortRef = useRef<AbortController | null>(null)
|
||||||
const reservationsForSignature = useTripStore((s) => s.reservations)
|
const reservationsForSignature = useTripStore((s) => s.reservations)
|
||||||
|
|
||||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||||
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
// Route is manual: only compute when explicitly enabled (the "show route" toggle).
|
||||||
|
if (!dayId || !enabled) { setRoute(null); setRouteSegments([]); return }
|
||||||
// Read directly from store (not a render-phase ref) so callers after optimistic
|
// Read directly from store (not a render-phase ref) so callers after optimistic
|
||||||
// updates or non-optimistic deletes always see the latest assignments.
|
// updates or non-optimistic deletes always see the latest assignments.
|
||||||
const currentAssignments = useTripStore.getState().assignments || {}
|
const currentAssignments = useTripStore.getState().assignments || {}
|
||||||
@@ -67,35 +66,51 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
})),
|
})),
|
||||||
].sort((a, b) => a.pos - b.pos)
|
].sort((a, b) => a.pos - b.pos)
|
||||||
|
|
||||||
const segments: [number, number][][] = []
|
// Group consecutive located places into runs, resetting whenever a transport
|
||||||
let currentSeg: [number, number][] = []
|
// appears (you don't drive between a flight's endpoints) — mirrors getMergedItems order.
|
||||||
|
const runs: { lat: number; lng: number }[][] = []
|
||||||
|
let currentRun: { lat: number; lng: number }[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.kind === 'place') {
|
if (entry.kind === 'place') {
|
||||||
currentSeg.push([entry.lat, entry.lng])
|
currentRun.push({ lat: entry.lat, lng: entry.lng })
|
||||||
} else {
|
} else {
|
||||||
if (currentSeg.length >= 2) segments.push([...currentSeg])
|
if (currentRun.length >= 2) runs.push(currentRun)
|
||||||
currentSeg = []
|
currentRun = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentSeg.length >= 2) segments.push(currentSeg)
|
if (currentRun.length >= 2) runs.push(currentRun)
|
||||||
|
|
||||||
const geocodedWaypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng) as { lat: number; lng: number }[]
|
const straightLines = (): [number, number][][] =>
|
||||||
|
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
|
|
||||||
|
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
|
||||||
|
|
||||||
|
// Draw straight lines immediately for snappiness, then upgrade to the real
|
||||||
|
// OSRM road geometry.
|
||||||
|
setRoute(straightLines())
|
||||||
|
|
||||||
if (segments.length === 0 && geocodedWaypoints.length < 2) {
|
|
||||||
setRoute(null); setRouteSegments([]); return
|
|
||||||
}
|
|
||||||
setRoute(segments.length > 0 ? segments : null)
|
|
||||||
if (!routeCalcEnabled) { setRouteSegments([]); return }
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
routeAbortRef.current = controller
|
routeAbortRef.current = controller
|
||||||
try {
|
try {
|
||||||
const calcSegments = await calculateSegments(geocodedWaypoints, { signal: controller.signal })
|
const polylines: [number, number][][] = []
|
||||||
if (!controller.signal.aborted) setRouteSegments(calcSegments)
|
const allLegs: RouteSegment[] = []
|
||||||
|
for (const run of runs) {
|
||||||
|
try {
|
||||||
|
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
|
||||||
|
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
|
allLegs.push(...r.legs)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') throw err
|
||||||
|
// OSRM failed for this run — fall back to a straight line, no times.
|
||||||
|
polylines.push(run.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!controller.signal.aborted) { setRoute(polylines); setRouteSegments(allLegs) }
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
||||||
else if (!(err instanceof Error)) setRouteSegments([])
|
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
|
||||||
}
|
}
|
||||||
}, [routeCalcEnabled])
|
}, [enabled, profile])
|
||||||
|
|
||||||
// Stable signature for transport reservations on the selected day — changes when a transport
|
// Stable signature for transport reservations on the selected day — changes when a transport
|
||||||
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
||||||
@@ -117,7 +132,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||||
updateRouteForDay(selectedDayId)
|
updateRouteForDay(selectedDayId)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedDayId, selectedDayAssignments, transportSignature])
|
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
|
||||||
|
|
||||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,48 @@
|
|||||||
import React, { createContext, useContext, useEffect, useMemo, ReactNode } from 'react'
|
import React, { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import de from './translations/de'
|
import en from '@trek/shared/i18n/en'
|
||||||
import en from './translations/en'
|
import type { SupportedLanguageCode } from '@trek/shared'
|
||||||
import es from './translations/es'
|
import {
|
||||||
import fr from './translations/fr'
|
SUPPORTED_LANGUAGES,
|
||||||
import hu from './translations/hu'
|
getLocaleForLanguage,
|
||||||
import it from './translations/it'
|
getIntlLanguage,
|
||||||
import ru from './translations/ru'
|
isRtlLanguage,
|
||||||
import zh from './translations/zh'
|
} from '@trek/shared'
|
||||||
import zhTw from './translations/zhTw'
|
import type { TranslationStrings } from '@trek/shared/i18n'
|
||||||
import nl from './translations/nl'
|
|
||||||
import id from './translations/id'
|
|
||||||
import ar from './translations/ar'
|
|
||||||
import br from './translations/br'
|
|
||||||
import cs from './translations/cs'
|
|
||||||
import pl from './translations/pl'
|
|
||||||
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
|
|
||||||
|
|
||||||
export { SUPPORTED_LANGUAGES }
|
export { SUPPORTED_LANGUAGES }
|
||||||
|
|
||||||
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
|
// One explicit dynamic import per locale — Vite code-splits a separate chunk per locale.
|
||||||
|
// Only the active locale is fetched; en is always available synchronously as the fallback.
|
||||||
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
|
const localeLoaders: Record<SupportedLanguageCode, () => Promise<{ default: TranslationStrings }>> = {
|
||||||
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
|
en: () => Promise.resolve({ default: en }),
|
||||||
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl,
|
de: () => import('@trek/shared/i18n/de'),
|
||||||
|
es: () => import('@trek/shared/i18n/es'),
|
||||||
|
fr: () => import('@trek/shared/i18n/fr'),
|
||||||
|
hu: () => import('@trek/shared/i18n/hu'),
|
||||||
|
it: () => import('@trek/shared/i18n/it'),
|
||||||
|
tr: () => import('@trek/shared/i18n/tr'),
|
||||||
|
ru: () => import('@trek/shared/i18n/ru'),
|
||||||
|
zh: () => import('@trek/shared/i18n/zh'),
|
||||||
|
'zh-TW': () => import('@trek/shared/i18n/zh-TW'),
|
||||||
|
nl: () => import('@trek/shared/i18n/nl'),
|
||||||
|
id: () => import('@trek/shared/i18n/id'),
|
||||||
|
ar: () => import('@trek/shared/i18n/ar'),
|
||||||
|
br: () => import('@trek/shared/i18n/br'),
|
||||||
|
cs: () => import('@trek/shared/i18n/cs'),
|
||||||
|
pl: () => import('@trek/shared/i18n/pl'),
|
||||||
|
ja: () => import('@trek/shared/i18n/ja'),
|
||||||
|
ko: () => import('@trek/shared/i18n/ko'),
|
||||||
|
uk: () => import('@trek/shared/i18n/uk'),
|
||||||
|
gr: () => import('@trek/shared/i18n/gr'),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
|
// Re-export pure helpers that live in shared so downstream consumers can import them
|
||||||
const LOCALES: Record<string, string> = Object.fromEntries(
|
// through this module without changing their import path.
|
||||||
SUPPORTED_LANGUAGES.map(l => [l.value, l.locale])
|
export { getLocaleForLanguage, getIntlLanguage, isRtlLanguage }
|
||||||
)
|
|
||||||
const RTL_LANGUAGES = new Set(['ar'])
|
|
||||||
|
|
||||||
export function getLocaleForLanguage(language: string): string {
|
// Detects the user's preferred language from browser/OS settings.
|
||||||
return LOCALES[language] || LOCALES.en
|
// Returns null if no supported language matches.
|
||||||
}
|
|
||||||
|
|
||||||
export function getIntlLanguage(language: string): string {
|
|
||||||
if (language === 'br') return 'pt-BR'
|
|
||||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id'].includes(language) ? language : 'en'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRtlLanguage(language: string): boolean {
|
|
||||||
return RTL_LANGUAGES.has(language)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detects the user's preferred language from the browser/OS settings and maps
|
|
||||||
// it to one of the supported language codes. Returns null if no match is found.
|
|
||||||
export function detectBrowserLanguage(): string | null {
|
export function detectBrowserLanguage(): string | null {
|
||||||
if (typeof navigator === 'undefined') return null
|
if (typeof navigator === 'undefined') return null
|
||||||
const browserLangs = navigator.languages?.length
|
const browserLangs = navigator.languages?.length
|
||||||
@@ -55,17 +51,14 @@ export function detectBrowserLanguage(): string | null {
|
|||||||
const supported = SUPPORTED_LANGUAGES.map(l => l.value)
|
const supported = SUPPORTED_LANGUAGES.map(l => l.value)
|
||||||
|
|
||||||
for (const lang of browserLangs) {
|
for (const lang of browserLangs) {
|
||||||
// Exact match (e.g. 'de', 'zh-TW') — case-insensitive
|
|
||||||
const exactMatch = supported.find(s => s.toLowerCase() === lang.toLowerCase())
|
const exactMatch = supported.find(s => s.toLowerCase() === lang.toLowerCase())
|
||||||
if (exactMatch) return exactMatch
|
if (exactMatch) return exactMatch
|
||||||
|
|
||||||
// pt-BR has no exact match (our code is 'br', not 'pt-BR'), so map it explicitly.
|
// pt-BR has no exact match (our code is 'br'), so map it explicitly.
|
||||||
// pt-PT and bare 'pt' are NOT mapped — they fall through to null and let the
|
// pt-PT and bare 'pt' are NOT mapped — they fall through to null.
|
||||||
// server default or 'en' fallback apply instead.
|
|
||||||
if (lang.toLowerCase() === 'pt-br') return 'br'
|
if (lang.toLowerCase() === 'pt-br') return 'br'
|
||||||
|
|
||||||
// Prefix match (e.g. 'de-AT' → 'de', 'zh-CN' → 'zh') — case-insensitive
|
const prefix = lang.split('-')[0]?.toLowerCase()
|
||||||
const prefix = lang.split('-')[0].toLowerCase()
|
|
||||||
const prefixMatch = supported.find(s => s.toLowerCase() === prefix)
|
const prefixMatch = supported.find(s => s.toLowerCase() === prefix)
|
||||||
if (prefixMatch) return prefixMatch
|
if (prefixMatch) return prefixMatch
|
||||||
}
|
}
|
||||||
@@ -87,18 +80,27 @@ interface TranslationProviderProps {
|
|||||||
|
|
||||||
export function TranslationProvider({ children }: TranslationProviderProps) {
|
export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||||
const language = useSettingsStore((s) => s.settings.language) || 'en'
|
const language = useSettingsStore((s) => s.settings.language) || 'en'
|
||||||
|
const [strings, setStrings] = useState<TranslationStrings>(en)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.lang = language
|
document.documentElement.lang = language
|
||||||
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
|
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
|
||||||
}, [language])
|
}, [language])
|
||||||
|
|
||||||
const value = useMemo((): TranslationContextValue => {
|
useEffect(() => {
|
||||||
const strings = translations[language] || translations.en
|
const loader = localeLoaders[language as SupportedLanguageCode]
|
||||||
const fallback = translations.en
|
if (!loader) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
loader().then(mod => {
|
||||||
|
if (!cancelled) setStrings(mod.default)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [language])
|
||||||
|
|
||||||
|
const value = useMemo((): TranslationContextValue => {
|
||||||
function t(key: string, params?: Record<string, string | number>): string {
|
function t(key: string, params?: Record<string, string | number>): string {
|
||||||
let val: string = (strings[key] ?? fallback[key] ?? key) as string
|
let val: string = (strings[key] ?? en[key] ?? key) as string
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([k, v]) => {
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
||||||
@@ -108,7 +110,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { t, language, locale: getLocaleForLanguage(language) }
|
return { t, language, locale: getLocaleForLanguage(language) }
|
||||||
}, [language])
|
}, [strings, language])
|
||||||
|
|
||||||
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
|
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,4 @@
|
|||||||
export const SUPPORTED_LANGUAGES = [
|
// Canonical language registry now lives in @trek/shared. Re-exported here so
|
||||||
{ value: 'de', label: 'Deutsch', locale: 'de-DE' },
|
// existing imports of './supportedLanguages' continue to work unchanged.
|
||||||
{ value: 'en', label: 'English', locale: 'en-US' },
|
export { SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGE_CODES } from '@trek/shared'
|
||||||
{ value: 'es', label: 'Español', locale: 'es-ES' },
|
export type { SupportedLanguageCode } from '@trek/shared'
|
||||||
{ value: 'fr', label: 'Français', locale: 'fr-FR' },
|
|
||||||
{ value: 'hu', label: 'Magyar', locale: 'hu-HU' },
|
|
||||||
{ value: 'nl', label: 'Nederlands', locale: 'nl-NL' },
|
|
||||||
{ value: 'br', label: 'Português (Brasil)', locale: 'pt-BR' },
|
|
||||||
{ value: 'cs', label: 'Česky', locale: 'cs-CZ' },
|
|
||||||
{ value: 'pl', label: 'Polski', locale: 'pl-PL' },
|
|
||||||
{ value: 'ru', label: 'Русский', locale: 'ru-RU' },
|
|
||||||
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
|
|
||||||
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
|
|
||||||
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
|
|
||||||
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
|
|
||||||
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
|
|
||||||
|
|
||||||
export const SUPPORTED_LANGUAGE_CODES: string[] = SUPPORTED_LANGUAGES.map(l => l.value)
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+19
-1
@@ -807,8 +807,26 @@ img[alt="TREK"] {
|
|||||||
.collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); }
|
.collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); }
|
||||||
.collab-note-md-full pre { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); overflow-x: auto; margin: 0.5em 0; }
|
.collab-note-md-full pre { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); overflow-x: auto; margin: 0.5em 0; }
|
||||||
.collab-note-md-full pre code { padding: 0; background: none; }
|
.collab-note-md-full pre code { padding: 0; background: none; }
|
||||||
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; }
|
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; word-break: break-all; }
|
||||||
.collab-note-md blockquote, .collab-note-md-full blockquote { border-left: 3px solid var(--border-primary); padding-left: 12px; margin: 0.5em 0; color: var(--text-muted); }
|
.collab-note-md blockquote, .collab-note-md-full blockquote { border-left: 3px solid var(--border-primary); padding-left: 12px; margin: 0.5em 0; color: var(--text-muted); }
|
||||||
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
|
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
|
||||||
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
|
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
|
||||||
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
|
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
|
||||||
|
|
||||||
|
/* Day-plan header action grid (edit / +transport / note / collapse) */
|
||||||
|
.dp-day-actions button {
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: transparent;
|
||||||
|
transition: background-color 0.12s ease, color 0.12s ease;
|
||||||
|
}
|
||||||
|
.dp-day-actions button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
/* Reveal the action grid only when hovering the day row (pointer devices).
|
||||||
|
Touch devices (hover: none) keep it visible; the selected day stays visible too. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.dp-day-actions { opacity: 0; transition: opacity 0.12s ease; }
|
||||||
|
.dp-day-header:hover .dp-day-actions,
|
||||||
|
.dp-day-header[data-selected="true"] .dp-day-actions { opacity: 1; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import { startConnectivityProbe } from './sync/connectivity'
|
||||||
|
|
||||||
|
startConnectivityProbe()
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -857,7 +857,6 @@ describe('DashboardPage', () => {
|
|||||||
temperature_unit: 'fahrenheit',
|
temperature_unit: 'fahrenheit',
|
||||||
time_format: '12h',
|
time_format: '12h',
|
||||||
show_place_description: false,
|
show_place_description: false,
|
||||||
route_calculation: false,
|
|
||||||
blur_booking_codes: false,
|
blur_booking_codes: false,
|
||||||
dashboard_currency: 'on',
|
dashboard_currency: 'on',
|
||||||
dashboard_timezone: 'on',
|
dashboard_timezone: 'on',
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
import { formatLocationName } from '../utils/formatters'
|
import { formatLocationName } from '../utils/formatters'
|
||||||
|
import { normalizeImageFiles } from '../utils/convertHeic'
|
||||||
|
import { type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useJourneyStore } from '../store/journeyStore'
|
import { useJourneyStore } from '../store/journeyStore'
|
||||||
@@ -29,6 +31,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView'
|
|||||||
import { useIsMobile } from '../hooks/useIsMobile'
|
import { useIsMobile } from '../hooks/useIsMobile'
|
||||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
|
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
|
||||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||||
|
import { getApiErrorMessage } from '../types'
|
||||||
|
|
||||||
const GRADIENTS = [
|
const GRADIENTS = [
|
||||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||||
@@ -746,8 +749,8 @@ export default function JourneyDetailPage() {
|
|||||||
}
|
}
|
||||||
return entryId
|
return entryId
|
||||||
}}
|
}}
|
||||||
onUploadPhotos={async (entryId, formData) => {
|
onUploadPhotos={async (entryId, files, cbs) => {
|
||||||
return await uploadPhotos(entryId, formData)
|
return await uploadPhotos(entryId, files, cbs)
|
||||||
}}
|
}}
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
setEditingEntry(null)
|
setEditingEntry(null)
|
||||||
@@ -985,7 +988,8 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
|||||||
const [showPicker, setShowPicker] = useState(false)
|
const [showPicker, setShowPicker] = useState(false)
|
||||||
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
||||||
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
||||||
const [galleryUploading, setGalleryUploading] = useState(false)
|
const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null)
|
||||||
|
const galleryUploading = galleryProgress !== null
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
// check which providers are enabled AND connected for the current user
|
// check which providers are enabled AND connected for the current user
|
||||||
@@ -1025,17 +1029,22 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
|||||||
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files
|
const files = e.target.files
|
||||||
if (!files?.length) return
|
if (!files?.length) return
|
||||||
setGalleryUploading(true)
|
setGalleryProgress({ done: 0, total: files.length })
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const normalized = await normalizeImageFiles(files)
|
||||||
for (const f of files) formData.append('photos', f)
|
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
|
||||||
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
|
||||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
})
|
||||||
|
if (failed.length > 0) {
|
||||||
|
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
|
||||||
|
} else {
|
||||||
|
toast.success(t('journey.photosUploaded', { count: String(files.length) }))
|
||||||
|
}
|
||||||
onRefresh()
|
onRefresh()
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error(t('journey.settings.coverFailed'))
|
toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
|
||||||
} finally {
|
} finally {
|
||||||
setGalleryUploading(false)
|
setGalleryProgress(null)
|
||||||
}
|
}
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
@@ -1080,7 +1089,7 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
|||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{galleryUploading ? (
|
{galleryUploading ? (
|
||||||
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : t('journey.editor.uploading')}</>
|
||||||
) : (
|
) : (
|
||||||
<><Plus size={12} /> {t('common.upload')}</>
|
<><Plus size={12} /> {t('common.upload')}</>
|
||||||
)}
|
)}
|
||||||
@@ -1769,7 +1778,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
: t('journey.picker.newGallery')
|
: t('journey.picker.newGallery')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
<div className="fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -2169,10 +2178,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
galleryPhotos: GalleryPhoto[]
|
galleryPhotos: GalleryPhoto[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (data: Record<string, unknown>) => Promise<number>
|
onSave: (data: Record<string, unknown>) => Promise<number>
|
||||||
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const [title, setTitle] = useState(entry.title || '')
|
const [title, setTitle] = useState(entry.title || '')
|
||||||
const [story, setStory] = useState(entry.story || '')
|
const [story, setStory] = useState(entry.story || '')
|
||||||
@@ -2191,7 +2201,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
|
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
|
||||||
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
|
||||||
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||||
@@ -2244,9 +2254,21 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
})
|
})
|
||||||
// upload queued files after entry is created
|
// upload queued files after entry is created
|
||||||
if (pendingFiles.length > 0 && entryId) {
|
if (pendingFiles.length > 0 && entryId) {
|
||||||
const formData = new FormData()
|
const filesToUpload = pendingFiles
|
||||||
for (const f of pendingFiles) formData.append('photos', f)
|
setUploadProgress({ done: 0, total: filesToUpload.length })
|
||||||
await onUploadPhotos(entryId, formData)
|
try {
|
||||||
|
const { failed } = await onUploadPhotos(entryId, filesToUpload, {
|
||||||
|
onProgress: p => setUploadProgress({ done: p.done, total: p.total }),
|
||||||
|
})
|
||||||
|
setPendingFiles(failed)
|
||||||
|
if (failed.length > 0) {
|
||||||
|
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(filesToUpload.length) }))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')))
|
||||||
|
} finally {
|
||||||
|
setUploadProgress(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// link gallery photos that were picked before save
|
// link gallery photos that were picked before save
|
||||||
if (pendingLinkIds.length > 0 && entryId) {
|
if (pendingLinkIds.length > 0 && entryId) {
|
||||||
@@ -2265,7 +2287,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
if (!files?.length) return
|
if (!files?.length) return
|
||||||
// Queue files locally until Save so cancel/close actually discards. This
|
// Queue files locally until Save so cancel/close actually discards. This
|
||||||
// keeps photo behavior consistent with text fields — no silent persistence.
|
// keeps photo behavior consistent with text fields — no silent persistence.
|
||||||
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
const normalized = await normalizeImageFiles(files)
|
||||||
|
setPendingFiles(prev => [...prev, ...normalized])
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2300,11 +2323,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
disabled={uploading}
|
disabled={saving}
|
||||||
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
|
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{uploading ? (
|
{uploadProgress ? (
|
||||||
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</>
|
||||||
) : (
|
) : (
|
||||||
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
|
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '../../tests/helpers/render';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
|
import { resetAllStores } from '../../tests/helpers/store';
|
||||||
|
import LoginPage from './LoginPage';
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
return { ...actual, useNavigate: () => mockNavigate };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LoginPage — OIDC redirect preservation', () => {
|
||||||
|
let savedLocation: Location;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
mockNavigate.mockClear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
savedLocation = window.location;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: savedLocation,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setSearch(search: string) {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: { ...window.location, search },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
|
||||||
|
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
|
||||||
|
setSearch('?redirect=%2Foauth%2Fconsent%3Fclient_id%3Dfoo');
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/consent?client_id=foo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not write to sessionStorage when no redirect param is present', async () => {
|
||||||
|
render(<LoginPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByPlaceholderText('your@email.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auth/oidc/exchange', () =>
|
||||||
|
HttpResponse.json({ token: 'mock-oidc-token' })
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
|
||||||
|
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo&state=xyz');
|
||||||
|
setSearch('?oidc_code=testcode123');
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith(
|
||||||
|
'/oauth/consent?client_id=foo&state=xyz',
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to /dashboard when no sessionStorage redirect is set', async () => {
|
||||||
|
setSearch('?oidc_code=testcode123');
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
|
||||||
|
it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
|
||||||
|
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo');
|
||||||
|
setSearch('?oidc_error=token_failed');
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,12 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
return '/dashboard'
|
return '/dashboard'
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (redirectTarget !== '/dashboard') {
|
||||||
|
sessionStorage.setItem('oidc_redirect', redirectTarget)
|
||||||
|
}
|
||||||
|
}, [redirectTarget])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
@@ -83,7 +89,9 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
window.history.replaceState({}, '', '/login')
|
window.history.replaceState({}, '', '/login')
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
await loadUser()
|
await loadUser()
|
||||||
navigate('/dashboard', { replace: true })
|
const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard'
|
||||||
|
sessionStorage.removeItem('oidc_redirect')
|
||||||
|
navigate(savedRedirect, { replace: true })
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || t('login.oidcFailed'))
|
setError(data.error || t('login.oidcFailed'))
|
||||||
}
|
}
|
||||||
@@ -104,19 +112,34 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
invalid_state: t('login.oidc.invalidState'),
|
invalid_state: t('login.oidc.invalidState'),
|
||||||
}
|
}
|
||||||
setError(errorMessages[oidcError] || oidcError)
|
setError(errorMessages[oidcError] || oidcError)
|
||||||
|
sessionStorage.removeItem('oidc_redirect')
|
||||||
window.history.replaceState({}, '', '/login')
|
window.history.replaceState({}, '', '/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
|
const CONFIG_CACHE_KEY = 'trek_app_config_cache'
|
||||||
if (config) {
|
authApi.getAppConfig?.()
|
||||||
setAppConfig(config)
|
.then((config: AppConfig) => {
|
||||||
if (!config.has_users) setMode('register')
|
try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ }
|
||||||
if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
return { config, fromCache: false }
|
||||||
window.location.href = '/api/auth/oidc/login'
|
})
|
||||||
|
.catch(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CONFIG_CACHE_KEY)
|
||||||
|
return raw ? { config: JSON.parse(raw) as AppConfig, fromCache: true } : { config: null as AppConfig | null, fromCache: false }
|
||||||
|
} catch { return { config: null as AppConfig | null, fromCache: false } }
|
||||||
|
})
|
||||||
|
.then(({ config, fromCache }) => {
|
||||||
|
if (config) {
|
||||||
|
setAppConfig(config)
|
||||||
|
if (!config.has_users) setMode('register')
|
||||||
|
// Skip auto-redirect when config is from cache — network is unreliable
|
||||||
|
// and auto-redirecting to the IdP could loop if the proxy changed.
|
||||||
|
if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
||||||
|
window.location.href = '/api/auth/oidc/login'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
|
||||||
}, [navigate, t, noRedirect])
|
}, [navigate, t, noRedirect])
|
||||||
|
|
||||||
// Language detection chain (runs once on mount, only if user has no saved preference):
|
// Language detection chain (runs once on mount, only if user has no saved preference):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import OAuthAuthorizePage from './OAuthAuthorizePage';
|
|||||||
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
|
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
|
||||||
|
|
||||||
function setSearchParams(search: string) {
|
function setSearchParams(search: string) {
|
||||||
window.history.pushState({}, '', '/oauth/authorize' + search);
|
window.history.pushState({}, '', '/oauth/consent' + search);
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALIDATE_OK = {
|
const VALIDATE_OK = {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
const state = params.get('state') || ''
|
const state = params.get('state') || ''
|
||||||
const codeChallenge = params.get('code_challenge') || ''
|
const codeChallenge = params.get('code_challenge') || ''
|
||||||
const ccMethod = params.get('code_challenge_method') || ''
|
const ccMethod = params.get('code_challenge_method') || ''
|
||||||
|
const resource = params.get('resource') || undefined
|
||||||
|
|
||||||
// Load auth state once, then validate
|
// Load auth state once, then validate
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,7 +44,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return
|
if (authLoading) return
|
||||||
validateRequest()
|
validateRequest()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [authLoading, isAuthenticated])
|
}, [authLoading, isAuthenticated])
|
||||||
|
|
||||||
async function validateRequest() {
|
async function validateRequest() {
|
||||||
@@ -57,6 +58,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: ccMethod,
|
code_challenge_method: ccMethod,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
|
resource,
|
||||||
})
|
})
|
||||||
setValidation(result)
|
setValidation(result)
|
||||||
|
|
||||||
@@ -99,6 +101,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: ccMethod,
|
code_challenge_method: ccMethod,
|
||||||
approved,
|
approved,
|
||||||
|
resource,
|
||||||
})
|
})
|
||||||
setPageState('done')
|
setPageState('done')
|
||||||
window.location.href = result.redirect
|
window.location.href = result.redirect
|
||||||
@@ -111,20 +114,20 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
|
|
||||||
function toggleScope(s: string) {
|
function toggleScope(s: string) {
|
||||||
setSelectedScopes(prev =>
|
setSelectedScopes(prev =>
|
||||||
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleGroup(groupScopes: string[], allSelected: boolean) {
|
function toggleGroup(groupScopes: string[], allSelected: boolean) {
|
||||||
setSelectedScopes(prev =>
|
setSelectedScopes(prev =>
|
||||||
allSelected
|
allSelected
|
||||||
? prev.filter(s => !groupScopes.includes(s))
|
? prev.filter(s => !groupScopes.includes(s))
|
||||||
: [...new Set([...prev, ...groupScopes])]
|
: [...new Set([...prev, ...groupScopes])]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLoginRedirect() {
|
function handleLoginRedirect() {
|
||||||
const next = '/oauth/authorize?' + params.toString()
|
const next = '/oauth/consent?' + params.toString() + window.location.hash
|
||||||
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,212 +148,212 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
|
|
||||||
if (pageState === 'loading' || pageState === 'auto_approving') {
|
if (pageState === 'loading' || pageState === 'auto_approving') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageState === 'error') {
|
if (pageState === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
|
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
|
||||||
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
|
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
|
||||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
|
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageState === 'login_required') {
|
if (pageState === 'login_required') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
|
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
|
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
|
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLoginRedirect}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||||
|
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
Sign in to TREK
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleLoginRedirect}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
|
||||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
|
||||||
<LogIn className="w-4 h-4" />
|
|
||||||
Sign in to TREK
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageState === 'consent'
|
// pageState === 'consent'
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
|
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
|
||||||
|
|
||||||
{/* Left panel — app identity + actions */}
|
{/* Left panel — app identity + actions */}
|
||||||
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
|
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
|
||||||
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{validation?.client?.name || clientId}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
This application is requesting access to your TREK account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 space-y-2">
|
|
||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
Only grant access to applications you trust. Your data stays on your server.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => submitConsent(true)}
|
|
||||||
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
|
||||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
|
||||||
{submitting
|
|
||||||
? 'Authorizing…'
|
|
||||||
: validation?.scopeSelectable && selectedScopes.length === 0
|
|
||||||
? 'Select at least one scope'
|
|
||||||
: validation?.scopeSelectable
|
|
||||||
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
|
||||||
: 'Approve Access'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => submitConsent(false)}
|
|
||||||
disabled={submitting}
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
|
||||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
|
||||||
Deny
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right panel — selectable scopes */}
|
|
||||||
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{Object.keys(scopesByGroup).length > 0 && (
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
||||||
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{validation?.client?.name || clientId}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
This application is requesting access to your TREK account.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{validation?.scopeSelectable ? (
|
<div className="mt-8 space-y-2">
|
||||||
/* DCR client — user selects which scopes to grant */
|
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<div className="space-y-3">
|
Only grant access to applications you trust. Your data stays on your server.
|
||||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
</p>
|
||||||
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
|
<button
|
||||||
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
|
onClick={() => submitConsent(true)}
|
||||||
return (
|
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
|
||||||
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
||||||
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
|
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||||
<input
|
{submitting
|
||||||
type="checkbox"
|
? 'Authorizing…'
|
||||||
checked={allGroupSelected}
|
: validation?.scopeSelectable && selectedScopes.length === 0
|
||||||
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
? 'Select at least one scope'
|
||||||
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
|
: validation?.scopeSelectable
|
||||||
className="rounded flex-shrink-0"
|
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
||||||
/>
|
: 'Approve Access'}
|
||||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
|
</button>
|
||||||
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
<button
|
||||||
|
onClick={() => submitConsent(false)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel — selectable scopes */}
|
||||||
|
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.keys(scopesByGroup).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{validation?.scopeSelectable ? (
|
||||||
|
/* DCR client — user selects which scopes to grant */
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||||
|
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
|
||||||
|
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
|
||||||
|
return (
|
||||||
|
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
|
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allGroupSelected}
|
||||||
|
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
||||||
|
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
|
||||||
|
className="rounded flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
|
||||||
|
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length}
|
{groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
{groupScopes.map(s => {
|
{groupScopes.map(s => {
|
||||||
const keys = SCOPE_GROUPS[s]
|
const keys = SCOPE_GROUPS[s]
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={s}
|
key={s}
|
||||||
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedScopes.includes(s)}
|
checked={selectedScopes.includes(s)}
|
||||||
onChange={() => toggleScope(s)}
|
onChange={() => toggleScope(s)}
|
||||||
className="mt-0.5 rounded flex-shrink-0"
|
className="mt-0.5 rounded flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Settings-created client — scopes are fixed, show read-only */
|
|
||||||
<div className="space-y-5">
|
|
||||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
|
||||||
<div key={group}>
|
|
||||||
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{groupScopes.map(s => {
|
|
||||||
const keys = SCOPE_GROUPS[s]
|
|
||||||
return (
|
|
||||||
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
|
||||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
|
||||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
))}
|
/* Settings-created client — scopes are fixed, show read-only */
|
||||||
|
<div className="space-y-5">
|
||||||
|
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
||||||
|
<div key={group}>
|
||||||
|
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{groupScopes.map(s => {
|
||||||
|
const keys = SCOPE_GROUPS[s]
|
||||||
|
return (
|
||||||
|
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||||
|
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Always-available tools — granted regardless of scopes */}
|
{/* Always-available tools — granted regardless of scopes */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
Always included
|
Always included
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{[
|
{[
|
||||||
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
||||||
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
||||||
].map(({ name, desc }) => (
|
].map(({ name, desc }) => (
|
||||||
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
|
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -10,8 +10,10 @@ import { getCategoryIcon } from '../components/shared/categoryIcons'
|
|||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||||
|
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||||
|
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||||
|
import { splitReservationDateTime } from '../utils/formatters'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
|
||||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||||
|
|
||||||
function createMarkerIcon(place: any) {
|
function createMarkerIcon(place: any) {
|
||||||
@@ -183,14 +185,16 @@ export default function SharedTripPage() {
|
|||||||
{sortedDays.map((day: any, di: number) => {
|
{sortedDays.map((day: any, di: number) => {
|
||||||
const da = assignments[String(day.id)] || []
|
const da = assignments[String(day.id)] || []
|
||||||
const notes = (dayNotes[String(day.id)] || [])
|
const notes = (dayNotes[String(day.id)] || [])
|
||||||
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
|
const dayAssignmentIds: number[] = da.map((a: any) => a.id)
|
||||||
const dayAccs = (accommodations || []).filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
const dayTransport = getTransportForDay({ reservations: reservations || [], dayId: day.id, dayAssignmentIds, days: sortedDays })
|
||||||
|
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
|
||||||
|
|
||||||
const merged = [
|
const merged = getMergedItems({
|
||||||
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
|
dayAssignments: da,
|
||||||
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
|
dayNotes: notes,
|
||||||
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
|
dayTransports: dayTransport,
|
||||||
].sort((a, b) => a.k - b.k)
|
dayId: day.id,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
||||||
@@ -211,12 +215,12 @@ export default function SharedTripPage() {
|
|||||||
|
|
||||||
{selectedDay === day.id && merged.length > 0 && (
|
{selectedDay === day.id && merged.length > 0 && (
|
||||||
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{merged.map((item: any, idx: number) => {
|
{merged.map((item: any) => {
|
||||||
if (item.type === 'transport') {
|
if (item.type === 'transport') {
|
||||||
const r = item.data
|
const r = item.data
|
||||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
const time = splitReservationDateTime(r.reservation_time).time ?? ''
|
||||||
let sub = ''
|
let sub = ''
|
||||||
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||||
@@ -273,8 +277,9 @@ export default function SharedTripPage() {
|
|||||||
{(reservations || []).map((r: any) => {
|
{(reservations || []).map((r: any) => {
|
||||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
const { date: rDate, time: rTime } = splitReservationDateTime(r.reservation_time)
|
||||||
const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
const time = rTime ?? ''
|
||||||
|
const date = rDate ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
||||||
return (
|
return (
|
||||||
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
|||||||
@@ -1474,6 +1474,56 @@ describe('TripPlannerPage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FE-PAGE-PLANNER-051: Mobile Plan sidebar stays mounted after onPlaceClick (issue #932)', () => {
|
||||||
|
it('does not unmount the mobile Plan portal when a place is tapped, preserving scroll position', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
|
||||||
|
|
||||||
|
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
||||||
|
const assignment = buildAssignment({ id: 10, day_id: 99, place, order_index: 0 });
|
||||||
|
seedTripStore({ id: 42 });
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
places: [place],
|
||||||
|
assignments: { '99': [assignment] },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
renderPlannerPage(42);
|
||||||
|
act(() => { vi.runAllTimers(); });
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open the mobile Plan portal via the bottom-nav Plan button (selector mirrors FE-PAGE-PLANNER-049).
|
||||||
|
const mobilePlanBtn = Array.from(document.body.querySelectorAll('button')).find(
|
||||||
|
b => b.textContent === 'Plan' && !b.getAttribute('title'),
|
||||||
|
);
|
||||||
|
expect(mobilePlanBtn).toBeTruthy();
|
||||||
|
await act(async () => { fireEvent.click(mobilePlanBtn!); });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The mock factory overwrites capturedDayPlanSidebarProps on each mount,
|
||||||
|
// so current holds the mobile portal instance's props.
|
||||||
|
const mobileOnPlaceClick = capturedDayPlanSidebarProps.current.onPlaceClick;
|
||||||
|
expect(typeof mobileOnPlaceClick).toBe('function');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
mobileOnPlaceClick(place.id, assignment.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invariant: portal must NOT unmount — both instances persist.
|
||||||
|
// Pre-fix: collapses to 1 (setMobileSidebarOpen(null) destroyed scroll container).
|
||||||
|
// Post-fix: stays at 2, browser preserves scrollTop on the living DOM node.
|
||||||
|
expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('FE-PAGE-PLANNER-037: onExpandedDaysChange covers mapPlaces hidden logic', () => {
|
describe('FE-PAGE-PLANNER-037: onExpandedDaysChange covers mapPlaces hidden logic', () => {
|
||||||
it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => {
|
it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|||||||
@@ -269,9 +269,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||||
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
|
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
|
||||||
|
// Manual route planning: off by default, toggled from the day-plan footer. Mode
|
||||||
|
// (driving/walking) is per-session and selects which travel time the connectors show.
|
||||||
|
const [routeShown, setRouteShown] = useState(false)
|
||||||
|
const [routeProfile, setRouteProfile] = useState<'driving' | 'walking'>('driving')
|
||||||
const [fitKey, setFitKey] = useState<number>(0)
|
const [fitKey, setFitKey] = useState<number>(0)
|
||||||
const initialFitTripId = useRef<number | null>(null)
|
const initialFitTripId = useRef<number | null>(null)
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
||||||
|
const mobilePlanScrollTopRef = useRef<number>(0)
|
||||||
|
const mobilePlacesScrollTopRef = useRef<number>(0)
|
||||||
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
|
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
|
||||||
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
|
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
|
||||||
|
|
||||||
@@ -343,7 +349,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tripId) tripActions.loadReservations(tripId)
|
if (tripId) {
|
||||||
|
tripActions.loadReservations(tripId)
|
||||||
|
tripActions.loadBudgetItems?.(tripId)
|
||||||
|
}
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useTripWebSocket(tripId)
|
useTripWebSocket(tripId)
|
||||||
@@ -393,7 +402,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
})
|
})
|
||||||
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
|
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
|
||||||
|
|
||||||
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId)
|
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
|
||||||
|
|
||||||
const handleSelectDay = useCallback((dayId, skipFit) => {
|
const handleSelectDay = useCallback((dayId, skipFit) => {
|
||||||
const changed = dayId !== selectedDayId
|
const changed = dayId !== selectedDayId
|
||||||
@@ -663,15 +672,20 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const handleSaveTransport = async (data) => {
|
const handleSaveTransport = async (data) => {
|
||||||
try {
|
try {
|
||||||
if (editingTransport) {
|
if (editingTransport) {
|
||||||
await tripActions.updateReservation(tripId, editingTransport.id, data)
|
const r = await tripActions.updateReservation(tripId, editingTransport.id, data)
|
||||||
toast.success(t('trip.toast.reservationUpdated'))
|
toast.success(t('trip.toast.reservationUpdated'))
|
||||||
|
setShowTransportModal(false)
|
||||||
|
setEditingTransport(null)
|
||||||
|
setTransportModalDayId(null)
|
||||||
|
return r
|
||||||
} else {
|
} else {
|
||||||
await tripActions.addReservation(tripId, data)
|
const r = await tripActions.addReservation(tripId, data)
|
||||||
toast.success(t('trip.toast.reservationAdded'))
|
toast.success(t('trip.toast.reservationAdded'))
|
||||||
|
setShowTransportModal(false)
|
||||||
|
setEditingTransport(null)
|
||||||
|
setTransportModalDayId(null)
|
||||||
|
return r
|
||||||
}
|
}
|
||||||
setShowTransportModal(false)
|
|
||||||
setEditingTransport(null)
|
|
||||||
setTransportModalDayId(null)
|
|
||||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,7 +830,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
hasInspector={!!selectedPlace}
|
hasInspector={!!selectedPlace}
|
||||||
hasDayDetail={!!showDayDetail && !selectedPlace}
|
hasDayDetail={!!showDayDetail && !selectedPlace}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
showReservationStats={settings.route_calculation !== false}
|
showReservationStats={true}
|
||||||
visibleConnectionIds={visibleConnections}
|
visibleConnectionIds={visibleConnections}
|
||||||
onReservationClick={(rid) => {
|
onReservationClick={(rid) => {
|
||||||
const r = reservations.find(x => x.id === rid)
|
const r = reservations.find(x => x.id === rid)
|
||||||
@@ -881,6 +895,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
||||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||||
accommodations={tripAccommodations}
|
accommodations={tripAccommodations}
|
||||||
|
routeShown={routeShown}
|
||||||
|
routeProfile={routeProfile}
|
||||||
|
onToggleRoute={() => setRouteShown(v => !v)}
|
||||||
|
onSetRouteProfile={setRouteProfile}
|
||||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||||
onExpandedDaysChange={setExpandedDayIds}
|
onExpandedDaysChange={setExpandedDayIds}
|
||||||
pushUndo={pushUndo}
|
pushUndo={pushUndo}
|
||||||
@@ -993,6 +1011,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||||
collapsed={dayDetailCollapsed}
|
collapsed={dayDetailCollapsed}
|
||||||
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
|
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
|
||||||
|
mobile={isMobile}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
@@ -1106,8 +1125,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1164,7 +1183,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'dateien' && (
|
{activeTab === 'dateien' && (
|
||||||
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||||
<FileManager
|
<FileManager
|
||||||
files={files || []}
|
files={files || []}
|
||||||
onUpload={(fd) => tripActions.addFile(tripId, fd)}
|
onUpload={(fd) => tripActions.addFile(tripId, fd)}
|
||||||
@@ -1181,7 +1200,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'collab' && (
|
{activeTab === 'collab' && (
|
||||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
|
||||||
<CollabPanel tripId={tripId} tripMembers={tripMembers} collabFeatures={collabFeatures} />
|
<CollabPanel tripId={tripId} tripMembers={tripMembers} collabFeatures={collabFeatures} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1191,7 +1210,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
||||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} />}
|
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={!!deletePlaceId}
|
isOpen={!!deletePlaceId}
|
||||||
onClose={() => setDeletePlaceId(null)}
|
onClose={() => setDeletePlaceId(null)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
|
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '../../tests/helpers/msw/server';
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
|
import { journeyApi } from '../api/client';
|
||||||
import { useJourneyStore } from './journeyStore';
|
import { useJourneyStore } from './journeyStore';
|
||||||
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
|
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
|
||||||
|
|
||||||
@@ -282,16 +283,64 @@ describe('journeyStore', () => {
|
|||||||
useJourneyStore.setState({ current: detail });
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
|
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
|
||||||
server.use(
|
// MSW's XHR interceptor calls request.arrayBuffer() on FormData bodies to
|
||||||
http.post('/api/journeys/entries/100/photos', () =>
|
// emit upload progress events, which hangs in jsdom+Node. Spy on the API
|
||||||
HttpResponse.json({ photos: [newPhoto] })
|
// layer directly so this test exercises store state management only.
|
||||||
)
|
const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockResolvedValue({ photos: [newPhoto] } as any);
|
||||||
);
|
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
const result = await useJourneyStore.getState().uploadPhotos(100, new FormData());
|
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||||
expect(result).toHaveLength(1);
|
expect(result.succeeded).toHaveLength(1);
|
||||||
expect(result[0].id).toBe(91);
|
expect(result.succeeded[0].id).toBe(91);
|
||||||
|
expect(result.failed).toHaveLength(0);
|
||||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||||
expect(storedEntry?.photos).toHaveLength(2);
|
expect(storedEntry?.photos).toHaveLength(2);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-017: uploadPhotos returns failed files and merges only succeeded on network error', async () => {
|
||||||
|
const entry = buildEntry({ id: 100, photos: [] });
|
||||||
|
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post('/api/journeys/entries/100/photos', () =>
|
||||||
|
HttpResponse.error()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const file = new File(['x'], 'fail.jpg', { type: 'image/jpeg' });
|
||||||
|
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||||
|
expect(result.succeeded).toHaveLength(0);
|
||||||
|
expect(result.failed).toHaveLength(1);
|
||||||
|
expect(result.failed[0]).toBe(file);
|
||||||
|
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||||
|
expect(storedEntry?.photos).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-018: uploadPhotos merges each file result incrementally on partial success', async () => {
|
||||||
|
const entry = buildEntry({ id: 100, photos: [] });
|
||||||
|
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
const photo1 = buildPhoto({ id: 91, entry_id: 100 });
|
||||||
|
const photo2 = buildPhoto({ id: 92, entry_id: 100 });
|
||||||
|
let callCount = 0;
|
||||||
|
// Spy on the API layer to avoid MSW's FormData body hang (see FE-STORE-JOURNEY-013).
|
||||||
|
// Use a 4xx-shaped error for file2 so isRetryable returns false and the test runs instantly.
|
||||||
|
const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockImplementation(async () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) return { photos: [photo1] } as any;
|
||||||
|
throw Object.assign(new Error('Bad Request'), { response: { status: 400 } });
|
||||||
|
});
|
||||||
|
const file1 = new File(['a'], 'ok.jpg', { type: 'image/jpeg' });
|
||||||
|
const file2 = new File(['b'], 'fail.jpg', { type: 'image/jpeg' });
|
||||||
|
const result = await useJourneyStore.getState().uploadPhotos(100, [file1, file2], undefined);
|
||||||
|
expect(result.succeeded).toHaveLength(1);
|
||||||
|
expect(result.succeeded[0].id).toBe(photo1.id);
|
||||||
|
expect(result.failed).toHaveLength(1);
|
||||||
|
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||||
|
expect(storedEntry?.photos).toHaveLength(1);
|
||||||
|
void photo2; // referenced to avoid lint warning
|
||||||
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── deletePhoto ──────────────────────────────────────────────────────────
|
// ── deletePhoto ──────────────────────────────────────────────────────────
|
||||||
@@ -355,6 +404,37 @@ describe('journeyStore', () => {
|
|||||||
expect(useJourneyStore.getState().loading).toBe(false);
|
expect(useJourneyStore.getState().loading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── reorderEntries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-018: reorderEntries reorders by sort_order not entry_time', async () => {
|
||||||
|
const a = buildEntry({ id: 201, entry_date: '2026-04-01', entry_time: '09:00', sort_order: 0 });
|
||||||
|
const b = buildEntry({ id: 202, entry_date: '2026-04-01', entry_time: '11:00', sort_order: 1 });
|
||||||
|
const c = buildEntry({ id: 203, entry_date: '2026-04-01', entry_time: '14:00', sort_order: 2 });
|
||||||
|
const detail = buildJourneyDetail({ id: 55, entries: [a, b, c] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.put('/api/journeys/55/entries/reorder', () => HttpResponse.json({ success: true }))
|
||||||
|
);
|
||||||
|
await useJourneyStore.getState().reorderEntries(55, [202, 201, 203]);
|
||||||
|
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||||
|
expect(ids).toEqual([202, 201, 203]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-019: reorderEntries rolls back on API failure', async () => {
|
||||||
|
const a = buildEntry({ id: 211, entry_date: '2026-04-01', sort_order: 0 });
|
||||||
|
const b = buildEntry({ id: 212, entry_date: '2026-04-01', sort_order: 1 });
|
||||||
|
const detail = buildJourneyDetail({ id: 56, entries: [a, b] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.put('/api/journeys/56/entries/reorder', () => HttpResponse.json({}, { status: 403 }))
|
||||||
|
);
|
||||||
|
await expect(useJourneyStore.getState().reorderEntries(56, [212, 211])).rejects.toBeTruthy();
|
||||||
|
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||||
|
expect(ids).toEqual([211, 212]);
|
||||||
|
});
|
||||||
|
|
||||||
// ── clear ────────────────────────────────────────────────────────────────
|
// ── clear ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { journeyApi } from '../api/client'
|
import { journeyApi } from '../api/client'
|
||||||
|
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||||
|
|
||||||
export interface Journey {
|
export interface Journey {
|
||||||
id: number
|
id: number
|
||||||
@@ -121,8 +122,8 @@ interface JourneyState {
|
|||||||
deleteEntry: (entryId: number) => Promise<void>
|
deleteEntry: (entryId: number) => Promise<void>
|
||||||
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
||||||
|
|
||||||
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
uploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
|
uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<GalleryPhoto>>
|
||||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
||||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
||||||
deletePhoto: (photoId: number) => Promise<void>
|
deletePhoto: (photoId: number) => Promise<void>
|
||||||
@@ -223,10 +224,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
|||||||
)
|
)
|
||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
||||||
const atime = a.entry_time || ''
|
if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0)
|
||||||
const btime = b.entry_time || ''
|
return a.id - b.id
|
||||||
if (atime !== btime) return atime.localeCompare(btime)
|
|
||||||
return (a.sort_order || 0) - (b.sort_order || 0)
|
|
||||||
})
|
})
|
||||||
return { current: { ...s.current, entries } }
|
return { current: { ...s.current, entries } }
|
||||||
})
|
})
|
||||||
@@ -239,32 +238,49 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadPhotos: async (entryId, formData) => {
|
uploadPhotos: async (entryId, files, cbs) => {
|
||||||
const data = await journeyApi.uploadPhotos(entryId, formData)
|
return uploadFilesResilient<JourneyPhoto>(
|
||||||
const photos = data.photos || []
|
files,
|
||||||
set(s => {
|
async (file, opts) => {
|
||||||
if (!s.current) return s
|
const fd = new FormData()
|
||||||
return {
|
fd.append('photos', file)
|
||||||
current: {
|
const data = await journeyApi.uploadPhotos(entryId, fd, opts)
|
||||||
...s.current,
|
const photos: JourneyPhoto[] = data.photos || []
|
||||||
entries: s.current.entries.map(e =>
|
const gallery: GalleryPhoto[] = data.gallery || []
|
||||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
set(s => {
|
||||||
),
|
if (!s.current) return s
|
||||||
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
|
return {
|
||||||
},
|
current: {
|
||||||
}
|
...s.current,
|
||||||
})
|
entries: s.current.entries.map(e =>
|
||||||
return photos
|
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||||
|
),
|
||||||
|
gallery: [...(s.current.gallery || []), ...gallery],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return photos
|
||||||
|
},
|
||||||
|
{ onProgress: cbs?.onProgress },
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadGalleryPhotos: async (journeyId, formData) => {
|
uploadGalleryPhotos: async (journeyId, files, cbs) => {
|
||||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
return uploadFilesResilient<GalleryPhoto>(
|
||||||
const photos: GalleryPhoto[] = data.photos || []
|
files,
|
||||||
set(s => {
|
async (file, opts) => {
|
||||||
if (!s.current || s.current.id !== journeyId) return s
|
const fd = new FormData()
|
||||||
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
fd.append('photos', file)
|
||||||
})
|
const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
|
||||||
return photos
|
const photos: GalleryPhoto[] = data.photos || []
|
||||||
|
set(s => {
|
||||||
|
if (!s.current || s.current.id !== journeyId) return s
|
||||||
|
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
||||||
|
})
|
||||||
|
return photos
|
||||||
|
},
|
||||||
|
{ onProgress: cbs?.onProgress },
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const PROBE_INTERVAL_MS = 30_000
|
||||||
|
const PROBE_TIMEOUT_MS = 1_500
|
||||||
|
|
||||||
|
let reachable = true
|
||||||
|
const listeners = new Set<(v: boolean) => void>()
|
||||||
|
|
||||||
|
function setReachable(v: boolean): void {
|
||||||
|
if (reachable === v) return
|
||||||
|
reachable = v
|
||||||
|
listeners.forEach(fn => fn(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(): Promise<void> {
|
||||||
|
if (!navigator.onLine) { setReachable(false); return }
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS)
|
||||||
|
const res = await fetch('/api/health', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: ctrl.signal,
|
||||||
|
})
|
||||||
|
clearTimeout(t)
|
||||||
|
// /api/health returns JSON. CF Access / Pangolin will either return HTML
|
||||||
|
// (Pangolin 200 auth wall) or trigger a cross-origin redirect that throws
|
||||||
|
// below. Both proxy-auth scenarios resolve to reachable = false.
|
||||||
|
const ct = res.headers.get('content-type') || ''
|
||||||
|
setReachable(res.ok && ct.includes('application/json'))
|
||||||
|
} catch {
|
||||||
|
setReachable(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startConnectivityProbe(): void {
|
||||||
|
probe()
|
||||||
|
setInterval(probe, PROBE_INTERVAL_MS)
|
||||||
|
window.addEventListener('online', probe)
|
||||||
|
window.addEventListener('offline', () => setReachable(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReachable(): boolean { return reachable }
|
||||||
|
export function probeNow(): Promise<void> { return probe() }
|
||||||
|
export function onChange(fn: (v: boolean) => void): () => void {
|
||||||
|
listeners.add(fn)
|
||||||
|
return () => listeners.delete(fn)
|
||||||
|
}
|
||||||
+13
-1
@@ -31,6 +31,7 @@ export interface Trip {
|
|||||||
export interface Day {
|
export interface Day {
|
||||||
id: number
|
id: number
|
||||||
trip_id: number
|
trip_id: number
|
||||||
|
day_number?: number
|
||||||
date: string
|
date: string
|
||||||
title: string | null
|
title: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
@@ -174,6 +175,7 @@ export interface Reservation {
|
|||||||
accommodation_start_day_id?: number | null
|
accommodation_start_day_id?: number | null
|
||||||
accommodation_end_day_id?: number | null
|
accommodation_end_day_id?: number | null
|
||||||
day_plan_position?: number | null
|
day_plan_position?: number | null
|
||||||
|
day_positions?: Record<number, number> | null
|
||||||
metadata?: Record<string, string> | string | null
|
metadata?: Record<string, string> | string | null
|
||||||
needs_review?: number
|
needs_review?: number
|
||||||
endpoints?: ReservationEndpoint[]
|
endpoints?: ReservationEndpoint[]
|
||||||
@@ -213,7 +215,6 @@ export interface Settings {
|
|||||||
temperature_unit: string
|
temperature_unit: string
|
||||||
time_format: string
|
time_format: string
|
||||||
show_place_description: boolean
|
show_place_description: boolean
|
||||||
route_calculation?: boolean
|
|
||||||
blur_booking_codes?: boolean
|
blur_booking_codes?: boolean
|
||||||
map_booking_labels?: boolean
|
map_booking_labels?: boolean
|
||||||
map_provider?: 'leaflet' | 'mapbox-gl'
|
map_provider?: 'leaflet' | 'mapbox-gl'
|
||||||
@@ -235,8 +236,19 @@ export interface RouteSegment {
|
|||||||
mid: [number, number]
|
mid: [number, number]
|
||||||
from: [number, number]
|
from: [number, number]
|
||||||
to: [number, number]
|
to: [number, number]
|
||||||
|
distance: number
|
||||||
|
duration: number
|
||||||
walkingText: string
|
walkingText: string
|
||||||
drivingText: string
|
drivingText: string
|
||||||
|
distanceText: string
|
||||||
|
durationText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteWithLegs {
|
||||||
|
coordinates: [number, number][]
|
||||||
|
distance: number
|
||||||
|
duration: number
|
||||||
|
legs: RouteSegment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteResult {
|
export interface RouteResult {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
function looksLikeHeic(file: File): boolean {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||||||
|
return ext === 'heic' || ext === 'heif' || file.type === 'image/heic' || file.type === 'image/heif'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImageFile(file: File): Promise<File> {
|
||||||
|
if (!looksLikeHeic(file)) return file
|
||||||
|
const { isHeic, heicTo } = await import('heic-to')
|
||||||
|
if (!(await isHeic(file))) return file
|
||||||
|
const blob = await heicTo({ blob: file, type: 'image/jpeg', quality: 0.92 })
|
||||||
|
const jpegName = file.name.replace(/\.(heic|heif)$/i, '.jpg')
|
||||||
|
return new File([blob], jpegName, { type: 'image/jpeg' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImageFiles(files: FileList | File[]): Promise<File[]> {
|
||||||
|
return Promise.all(Array.from(files).map(normalizeImageFile))
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||||
|
|
||||||
|
describe('parseTimeToMinutes', () => {
|
||||||
|
it('parses HH:MM string', () => {
|
||||||
|
expect(parseTimeToMinutes('09:30')).toBe(570)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses ISO datetime string', () => {
|
||||||
|
expect(parseTimeToMinutes('2025-03-30T14:00:00')).toBe(840)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for null/empty', () => {
|
||||||
|
expect(parseTimeToMinutes(null)).toBeNull()
|
||||||
|
expect(parseTimeToMinutes(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSpanPhase', () => {
|
||||||
|
it('returns single when start === end', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 1 }, 1)).toBe('single')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns start for the departure day', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 1)).toBe('start')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns end for the arrival day', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 3)).toBe('end')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns middle for days in between', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 2)).toBe('middle')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDisplayTimeForDay', () => {
|
||||||
|
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
|
||||||
|
|
||||||
|
it('returns reservation_time on start day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 1)).toBe(r.reservation_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns reservation_end_time on end day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 3)).toBe(r.reservation_end_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for middle day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 2)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getTransportForDay', () => {
|
||||||
|
const days = [
|
||||||
|
{ id: 1, day_number: 1 },
|
||||||
|
{ id: 2, day_number: 2 },
|
||||||
|
{ id: 3, day_number: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('excludes hotel (rendered via accommodation path)', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes tour booking on the correct day', () => {
|
||||||
|
const reservations = [{ id: 20, type: 'tour', day_id: 1 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes restaurant, event, and other bookings by day_id', () => {
|
||||||
|
const reservations = [
|
||||||
|
{ id: 30, type: 'restaurant', day_id: 2 },
|
||||||
|
{ id: 31, type: 'event', day_id: 2 },
|
||||||
|
{ id: 32, type: 'other', day_id: 2 },
|
||||||
|
]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(3)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes single-day transport on the correct day', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes multi-day transport on all spanned days', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'train', day_id: 1, end_day_id: 3 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 3, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes transport linked to an assignment on that day', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'bus', day_id: 1, end_day_id: 1, assignment_id: 42 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getMergedItems', () => {
|
||||||
|
it('merges places and notes sorted by sortKey', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: null } },
|
||||||
|
{ id: 2, order_index: 2, place: { place_time: null } },
|
||||||
|
]
|
||||||
|
const dayNotes = [{ id: 10, sort_order: 1 }]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes, dayTransports: [], dayId: 5 })
|
||||||
|
expect(result.map(i => i.type)).toEqual(['place', 'note', 'place'])
|
||||||
|
expect(result[0].data.id).toBe(1)
|
||||||
|
expect(result[1].data.id).toBe(10)
|
||||||
|
expect(result[2].data.id).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts transport by time when no per-day position is set', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||||
|
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||||
|
]
|
||||||
|
const dayTransports = [
|
||||||
|
{ id: 20, type: 'flight', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: null },
|
||||||
|
]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||||
|
const types = result.map(i => i.type)
|
||||||
|
// transport (10:30) should be between place at 08:00 (idx 0) and place at 13:00 (idx 1)
|
||||||
|
expect(types).toEqual(['place', 'transport', 'place'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('per-day position overrides time-based insertion', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||||
|
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||||
|
]
|
||||||
|
// Transport at 10:30 would normally go between the two places
|
||||||
|
// but per-day position 1.5 puts it after the second place
|
||||||
|
const dayTransports = [
|
||||||
|
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
|
||||||
|
]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||||
|
const types = result.map(i => i.type)
|
||||||
|
expect(types).toEqual(['place', 'place', 'transport'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
|
|
||||||
|
export interface MergedItem {
|
||||||
|
type: 'place' | 'note' | 'transport'
|
||||||
|
sortKey: number
|
||||||
|
data: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTimeToMinutes(time?: string | null): number | null {
|
||||||
|
if (!time) return null
|
||||||
|
if (time.includes('T')) {
|
||||||
|
const [h, m] = time.split('T')[1].split(':').map(Number)
|
||||||
|
return h * 60 + m
|
||||||
|
}
|
||||||
|
const parts = time.split(':').map(Number)
|
||||||
|
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpanPhase(
|
||||||
|
r: { day_id?: number | null; end_day_id?: number | null },
|
||||||
|
dayId: number
|
||||||
|
): 'single' | 'start' | 'middle' | 'end' {
|
||||||
|
const startDayId = r.day_id
|
||||||
|
const endDayId = r.end_day_id ?? startDayId
|
||||||
|
if (!startDayId || startDayId === endDayId) return 'single'
|
||||||
|
if (dayId === startDayId) return 'start'
|
||||||
|
if (dayId === endDayId) return 'end'
|
||||||
|
return 'middle'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayTimeForDay(
|
||||||
|
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
|
||||||
|
dayId: number
|
||||||
|
): string | null {
|
||||||
|
const phase = getSpanPhase(r, dayId)
|
||||||
|
if (phase === 'end') return r.reservation_end_time || null
|
||||||
|
if (phase === 'middle') return null
|
||||||
|
return r.reservation_time || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
|
||||||
|
export function getTransportForDay(opts: {
|
||||||
|
reservations: any[]
|
||||||
|
dayId: number
|
||||||
|
dayAssignmentIds: number[]
|
||||||
|
days: Array<{ id: number; day_number?: number }>
|
||||||
|
}): any[] {
|
||||||
|
const { reservations, dayId, dayAssignmentIds, days } = opts
|
||||||
|
|
||||||
|
const getDayOrder = (id: number): number => {
|
||||||
|
const d = days.find(x => x.id === id)
|
||||||
|
return d ? ((d as any).day_number ?? days.indexOf(d)) : 0
|
||||||
|
}
|
||||||
|
const thisDayOrder = getDayOrder(dayId)
|
||||||
|
|
||||||
|
return reservations.filter(r => {
|
||||||
|
if (r.type === 'hotel') return false
|
||||||
|
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||||
|
|
||||||
|
const startDayId = r.day_id
|
||||||
|
const endDayId = r.end_day_id ?? startDayId
|
||||||
|
|
||||||
|
if (startDayId == null) return false
|
||||||
|
|
||||||
|
if (endDayId !== startDayId) {
|
||||||
|
const startOrder = getDayOrder(startDayId)
|
||||||
|
const endOrder = getDayOrder(endDayId)
|
||||||
|
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
|
||||||
|
}
|
||||||
|
return startDayId === dayId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge places, notes, and transports into a single ordered day timeline. */
|
||||||
|
export function getMergedItems(opts: {
|
||||||
|
dayAssignments: any[]
|
||||||
|
dayNotes: any[]
|
||||||
|
dayTransports: any[]
|
||||||
|
dayId: number
|
||||||
|
getDisplayTime?: (r: any, dayId: number) => string | null
|
||||||
|
}): MergedItem[] {
|
||||||
|
const { dayAssignments: da, dayNotes: dn, dayTransports: transport, dayId } = opts
|
||||||
|
const getDisplayTime = opts.getDisplayTime ?? getDisplayTimeForDay
|
||||||
|
|
||||||
|
const baseItems: MergedItem[] = [
|
||||||
|
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||||
|
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order ?? 0, data: n })),
|
||||||
|
].sort((a, b) => a.sortKey - b.sortKey)
|
||||||
|
|
||||||
|
const timedTransports = transport.map(r => ({
|
||||||
|
type: 'transport' as const,
|
||||||
|
data: r,
|
||||||
|
minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
|
||||||
|
})).sort((a, b) => a.minutes - b.minutes)
|
||||||
|
|
||||||
|
if (timedTransports.length === 0) return baseItems
|
||||||
|
if (baseItems.length === 0) {
|
||||||
|
return timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert transports among base items based on per-day position or time
|
||||||
|
const result = [...baseItems]
|
||||||
|
for (let ti = 0; ti < timedTransports.length; ti++) {
|
||||||
|
const timed = timedTransports[ti]
|
||||||
|
const minutes = timed.minutes
|
||||||
|
|
||||||
|
// Per-day position takes precedence (set by user reorder)
|
||||||
|
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
||||||
|
if (perDayPos != null) {
|
||||||
|
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-based fallback: insert after the last item whose time <= this transport's time
|
||||||
|
let insertAfterKey = -Infinity
|
||||||
|
for (const item of result) {
|
||||||
|
if (item.type === 'place') {
|
||||||
|
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
||||||
|
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
||||||
|
} else if (item.type === 'transport') {
|
||||||
|
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
||||||
|
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
||||||
|
const sortKey = insertAfterKey === -Infinity
|
||||||
|
? lastKey + 0.5 + ti * 0.01
|
||||||
|
: insertAfterKey + 0.01 + ti * 0.001
|
||||||
|
|
||||||
|
result.push({ type: timed.type, sortKey, data: timed.data })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Day } from '../types'
|
||||||
|
|
||||||
|
export const getDayOrder = (day: Day, days: Day[]): number =>
|
||||||
|
day.day_number ?? days.indexOf(day)
|
||||||
|
|
||||||
|
export const isDayInAccommodationRange = (
|
||||||
|
day: Day,
|
||||||
|
startDayId: number,
|
||||||
|
endDayId: number,
|
||||||
|
days: Day[],
|
||||||
|
): boolean => {
|
||||||
|
const startDay = days.find(d => d.id === startDayId)
|
||||||
|
const endDay = days.find(d => d.id === endDayId)
|
||||||
|
if (!startDay || !endDay) {
|
||||||
|
// Endpoint days not in the loaded array (e.g. sparse test data or partial load).
|
||||||
|
// Fall back to numeric ID range — acceptable since non-monotonic IDs only arise when
|
||||||
|
// both endpoints are present in a fully-loaded trip's days list.
|
||||||
|
return day.id >= Math.min(startDayId, endDayId) && day.id <= Math.max(startDayId, endDayId)
|
||||||
|
}
|
||||||
|
const lo = Math.min(getDayOrder(startDay, days), getDayOrder(endDay, days))
|
||||||
|
const hi = Math.max(getDayOrder(startDay, days), getDayOrder(endDay, days))
|
||||||
|
return getDayOrder(day, days) >= lo && getDayOrder(day, days) <= hi
|
||||||
|
}
|
||||||
@@ -32,6 +32,13 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void {
|
|||||||
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
|
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// navigator.standalone is true only on iOS when running as an
|
||||||
|
// add-to-home-screen PWA. In that context, target="_blank" hands off to
|
||||||
|
// Safari, which cannot access blob URLs sandboxed to the WebView.
|
||||||
|
function isIosStandalone(): boolean {
|
||||||
|
return (navigator as any).standalone === true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a protected file using cookie auth (credentials: include) and
|
* Fetches a protected file using cookie auth (credentials: include) and
|
||||||
* triggers a browser download. Works inside PWA standalone mode because the
|
* triggers a browser download. Works inside PWA standalone mode because the
|
||||||
@@ -56,7 +63,13 @@ export async function downloadFile(url: string, filename?: string): Promise<void
|
|||||||
* (including text/html and image/svg+xml which can execute script) are forced
|
* (including text/html and image/svg+xml which can execute script) are forced
|
||||||
* to download so that an uploaded file cannot run code in the TREK origin.
|
* to download so that an uploaded file cannot run code in the TREK origin.
|
||||||
*
|
*
|
||||||
* Falls back to a download trigger if the popup is blocked.
|
* Uses a synthetic <a target="_blank" rel="noopener noreferrer"> click rather
|
||||||
|
* than window.open(). window.open() called with the "noreferrer"/"noopener"
|
||||||
|
* window feature returns null per spec, which previously made the popup-block
|
||||||
|
* fallback trigger a download in the *current* tab on top of the new-tab open
|
||||||
|
* — i.e. the file opened twice. The anchor approach avoids that ambiguity:
|
||||||
|
* the new tab is opened by the browser's normal link-handling path, and no
|
||||||
|
* spurious in-page download is triggered.
|
||||||
*/
|
*/
|
||||||
export async function openFile(url: string, filename?: string): Promise<void> {
|
export async function openFile(url: string, filename?: string): Promise<void> {
|
||||||
assertRelativeUrl(url)
|
assertRelativeUrl(url)
|
||||||
@@ -71,11 +84,19 @@ export async function openFile(url: string, filename?: string): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const win = window.open(blobUrl, '_blank', 'noreferrer')
|
// iOS PWA: target="_blank" would open Safari, which can't access the blob
|
||||||
if (win) {
|
if (isIosStandalone()) {
|
||||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
|
||||||
} else {
|
|
||||||
// Popup blocked — fall back to download
|
|
||||||
triggerAnchorDownload(blobUrl, filename)
|
triggerAnchorDownload(blobUrl, filename)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = blobUrl
|
||||||
|
a.target = '_blank'
|
||||||
|
a.rel = 'noopener noreferrer'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
// Keep the blob URL alive long enough for the new tab to load it, then
|
||||||
|
// clean up the DOM node and revoke the URL.
|
||||||
|
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 30_000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { splitReservationDateTime } from './formatters'
|
||||||
|
|
||||||
|
describe('splitReservationDateTime', () => {
|
||||||
|
it('parses full ISO datetime', () => {
|
||||||
|
expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses full datetime with seconds', () => {
|
||||||
|
expect(splitReservationDateTime('2026-06-25T10:00:30')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses date-only string', () => {
|
||||||
|
expect(splitReservationDateTime('2026-06-25')).toEqual({ date: '2026-06-25', time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses bare HH:MM (new dateless format)', () => {
|
||||||
|
expect(splitReservationDateTime('10:00')).toEqual({ date: null, time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses bare single-digit hour time', () => {
|
||||||
|
expect(splitReservationDateTime('9:30')).toEqual({ date: null, time: '9:30' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles legacy malformed T-prefixed time ("T10:00")', () => {
|
||||||
|
expect(splitReservationDateTime('T10:00')).toEqual({ date: null, time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null date for T-prefixed without valid date', () => {
|
||||||
|
const result = splitReservationDateTime('T23:59')
|
||||||
|
expect(result.date).toBeNull()
|
||||||
|
expect(result.time).toBe('23:59')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for null input', () => {
|
||||||
|
expect(splitReservationDateTime(null)).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for undefined input', () => {
|
||||||
|
expect(splitReservationDateTime(undefined)).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for empty string', () => {
|
||||||
|
expect(splitReservationDateTime('')).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for unrecognized string', () => {
|
||||||
|
expect(splitReservationDateTime('garbage')).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -65,6 +65,18 @@ export function formatTime(timeStr: string | null | undefined, locale: string, t
|
|||||||
} catch { return timeStr }
|
} catch { return timeStr }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function splitReservationDateTime(value?: string | null): { date: string | null; time: string | null } {
|
||||||
|
if (!value) return { date: null, time: null }
|
||||||
|
const isoDate = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
if (value.includes('T')) {
|
||||||
|
const [d, t] = value.split('T')
|
||||||
|
return { date: isoDate.test(d) ? d : null, time: t ? t.slice(0, 5) : null }
|
||||||
|
}
|
||||||
|
if (isoDate.test(value)) return { date: value, time: null }
|
||||||
|
if (/^\d{1,2}:\d{2}/.test(value)) return { date: null, time: value.slice(0, 5) }
|
||||||
|
return { date: null, time: null }
|
||||||
|
}
|
||||||
|
|
||||||
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
|
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
|
||||||
const da = assignments[String(dayId)] || []
|
const da = assignments[String(dayId)] || []
|
||||||
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
|
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { AxiosProgressEvent } from 'axios'
|
||||||
|
|
||||||
|
export interface UploadProgress {
|
||||||
|
done: number
|
||||||
|
total: number
|
||||||
|
failed: number
|
||||||
|
percent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResilientResult<T> {
|
||||||
|
succeeded: T[]
|
||||||
|
failed: File[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadOpts {
|
||||||
|
onUploadProgress: (e: AxiosProgressEvent) => void
|
||||||
|
idempotencyKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
function isRetryable(err: unknown): boolean {
|
||||||
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
|
const status = (err as { response?: { status?: number } }).response?.status
|
||||||
|
if (status !== undefined && status >= 400 && status < 500) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFilesResilient<T>(
|
||||||
|
files: File[],
|
||||||
|
uploadOne: (file: File, opts: UploadOpts) => Promise<T[]>,
|
||||||
|
cbs?: {
|
||||||
|
concurrency?: number
|
||||||
|
retries?: number
|
||||||
|
onProgress?: (p: UploadProgress) => void
|
||||||
|
onUploaded?: (items: T[]) => void
|
||||||
|
},
|
||||||
|
): Promise<ResilientResult<T>> {
|
||||||
|
const concurrency = cbs?.concurrency ?? 3
|
||||||
|
const maxRetries = cbs?.retries ?? 2
|
||||||
|
|
||||||
|
const totalBytes = files.reduce((s, f) => s + f.size, 0)
|
||||||
|
const loadedMap = new Map<number, number>()
|
||||||
|
let doneCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
|
||||||
|
const emitProgress = () => {
|
||||||
|
if (!cbs?.onProgress) return
|
||||||
|
const sumLoaded = Array.from(loadedMap.values()).reduce((a, b) => a + b, 0)
|
||||||
|
const percent = totalBytes > 0 ? Math.round((sumLoaded / totalBytes) * 100) : 0
|
||||||
|
cbs.onProgress({ done: doneCount, total: files.length, failed: failedCount, percent })
|
||||||
|
}
|
||||||
|
|
||||||
|
const succeeded: T[] = []
|
||||||
|
const failedFiles: File[] = []
|
||||||
|
|
||||||
|
let idx = 0
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (true) {
|
||||||
|
const i = idx++
|
||||||
|
if (i >= files.length) break
|
||||||
|
const file = files[i]
|
||||||
|
const idempotencyKey = crypto.randomUUID()
|
||||||
|
loadedMap.set(i, 0)
|
||||||
|
|
||||||
|
let items: T[] | null = null
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
if (attempt > 0) await sleep(400 * attempt)
|
||||||
|
try {
|
||||||
|
items = await uploadOne(file, {
|
||||||
|
idempotencyKey,
|
||||||
|
onUploadProgress: (e) => {
|
||||||
|
loadedMap.set(i, e.loaded)
|
||||||
|
emitProgress()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
} catch (err) {
|
||||||
|
if (!isRetryable(err) || attempt === maxRetries) {
|
||||||
|
items = null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items !== null) {
|
||||||
|
succeeded.push(...items)
|
||||||
|
cbs?.onUploaded?.(items)
|
||||||
|
loadedMap.set(i, file.size)
|
||||||
|
doneCount++
|
||||||
|
} else {
|
||||||
|
failedFiles.push(file)
|
||||||
|
loadedMap.set(i, 0)
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
emitProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array.from({ length: Math.min(concurrency, files.length) }, () => worker())
|
||||||
|
await Promise.all(workers)
|
||||||
|
|
||||||
|
return { succeeded, failed: failedFiles }
|
||||||
|
}
|
||||||
@@ -258,7 +258,6 @@ export function buildSettings(overrides: Partial<Settings> = {}): Settings {
|
|||||||
temperature_unit: 'fahrenheit',
|
temperature_unit: 'fahrenheit',
|
||||||
time_format: '12h',
|
time_format: '12h',
|
||||||
show_place_description: false,
|
show_place_description: false,
|
||||||
route_calculation: false,
|
|
||||||
blur_booking_codes: false,
|
blur_booking_codes: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook, act } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation';
|
import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation';
|
||||||
import { useSettingsStore } from '../../../src/store/settingsStore';
|
|
||||||
import { useTripStore } from '../../../src/store/tripStore';
|
import { useTripStore } from '../../../src/store/tripStore';
|
||||||
import { buildAssignment, buildPlace } from '../../helpers/factories';
|
import { buildAssignment, buildPlace } from '../../helpers/factories';
|
||||||
import type { TripStoreState } from '../../../src/store/tripStore';
|
import type { TripStoreState } from '../../../src/store/tripStore';
|
||||||
@@ -9,13 +8,13 @@ import type { RouteSegment } from '../../../src/types';
|
|||||||
|
|
||||||
// Mock the RouteCalculator module to avoid real OSRM fetch calls
|
// Mock the RouteCalculator module to avoid real OSRM fetch calls
|
||||||
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
|
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
|
||||||
calculateSegments: vi.fn(),
|
calculateRouteWithLegs: vi.fn(),
|
||||||
calculateRoute: vi.fn(),
|
calculateRoute: vi.fn(),
|
||||||
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
|
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
|
||||||
generateGoogleMapsUrl: vi.fn(),
|
generateGoogleMapsUrl: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { calculateSegments } = await import('../../../src/components/Map/RouteCalculator');
|
const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator');
|
||||||
|
|
||||||
function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
|
function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
|
||||||
// Also populate the real Zustand store so updateRouteForDay (which reads from
|
// Also populate the real Zustand store so updateRouteForDay (which reads from
|
||||||
@@ -27,22 +26,29 @@ function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssig
|
|||||||
|
|
||||||
const MOCK_SEGMENTS: RouteSegment[] = [
|
const MOCK_SEGMENTS: RouteSegment[] = [
|
||||||
{
|
{
|
||||||
from: [48.8566, 2.3522],
|
distance: 343000,
|
||||||
to: [51.5074, -0.1278],
|
duration: 12600,
|
||||||
mid: [50.182, 1.1122],
|
distanceText: '343 km',
|
||||||
walkingText: '120 min',
|
durationText: '3 h 30 min',
|
||||||
drivingText: '90 min',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Empty coordinates make the hook fall back to the straight-line geometry,
|
||||||
|
// so the `route` assertions keep checking the raw waypoints while the legs
|
||||||
|
// still flow through to `routeSegments`.
|
||||||
|
const MOCK_ROUTE_WITH_LEGS = {
|
||||||
|
coordinates: [] as [number, number][],
|
||||||
|
distance: 343000,
|
||||||
|
duration: 12600,
|
||||||
|
legs: MOCK_SEGMENTS,
|
||||||
|
};
|
||||||
|
|
||||||
describe('useRouteCalculation', () => {
|
describe('useRouteCalculation', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Default: route_calculation disabled
|
|
||||||
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
|
||||||
// Reset trip store assignments so each test starts clean
|
// Reset trip store assignments so each test starts clean
|
||||||
useTripStore.setState({ assignments: {} } as any);
|
useTripStore.setState({ assignments: {} } as any);
|
||||||
(calculateSegments as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_SEGMENTS);
|
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_ROUTE_WITH_LEGS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-001: with no selectedDayId, route is null', () => {
|
it('FE-HOOK-ROUTE-001: with no selectedDayId, route is null', () => {
|
||||||
@@ -84,9 +90,7 @@ describe('useRouteCalculation', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-004: with route_calculation enabled, calls calculateSegments', async () => {
|
it('FE-HOOK-ROUTE-004: calls calculateRouteWithLegs and exposes the returned segments', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
||||||
const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
|
const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
|
||||||
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
|
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
|
||||||
@@ -99,32 +103,11 @@ describe('useRouteCalculation', () => {
|
|||||||
|
|
||||||
await act(async () => {});
|
await act(async () => {});
|
||||||
|
|
||||||
expect(calculateSegments).toHaveBeenCalled();
|
expect(calculateRouteWithLegs).toHaveBeenCalled();
|
||||||
expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS);
|
expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-005: with route_calculation disabled, does not call calculateSegments', async () => {
|
|
||||||
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
|
||||||
const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
|
|
||||||
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
|
|
||||||
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
|
|
||||||
const store = buildMockStore({ '5': [a1, a2] });
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useRouteCalculation(store as TripStoreState, 5)
|
|
||||||
);
|
|
||||||
|
|
||||||
await act(async () => {});
|
|
||||||
|
|
||||||
expect(calculateSegments).not.toHaveBeenCalled();
|
|
||||||
expect(result.current.routeSegments).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-006: assignments are sorted by order_index before extracting waypoints', async () => {
|
it('FE-HOOK-ROUTE-006: assignments are sorted by order_index before extracting waypoints', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||||
const p2 = buildPlace({ lat: 20, lng: 20 });
|
const p2 = buildPlace({ lat: 20, lng: 20 });
|
||||||
// order_index 1 comes before 0 in the array, but should be sorted
|
// order_index 1 comes before 0 in the array, but should be sorted
|
||||||
@@ -161,15 +144,14 @@ describe('useRouteCalculation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-008: AbortController.abort() is called when selectedDayId changes', async () => {
|
it('FE-HOOK-ROUTE-008: AbortController.abort() is called when selectedDayId changes', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
|
||||||
|
|
||||||
// Make calculateSegments resolve slowly
|
// Make calculateRouteWithLegs resolve slowly
|
||||||
let resolveSegments!: (val: RouteSegment[]) => void;
|
let resolveSegments!: (val: typeof MOCK_ROUTE_WITH_LEGS) => void;
|
||||||
(calculateSegments as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
||||||
(_waypoints: unknown[], options: { signal?: AbortSignal }) => {
|
(_waypoints: unknown[], options: { signal?: AbortSignal }) => {
|
||||||
return new Promise<RouteSegment[]>((resolve) => {
|
return new Promise<typeof MOCK_ROUTE_WITH_LEGS>((resolve) => {
|
||||||
resolveSegments = resolve;
|
resolveSegments = resolve;
|
||||||
options?.signal?.addEventListener('abort', () => resolve([]));
|
options?.signal?.addEventListener('abort', () => resolve(MOCK_ROUTE_WITH_LEGS));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -191,20 +173,19 @@ describe('useRouteCalculation', () => {
|
|||||||
rerender({ dayId: 6 });
|
rerender({ dayId: 6 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// calculateSegments should have been called at least once for day 5
|
// calculateRouteWithLegs should have been called at least once for day 5
|
||||||
// and once more for day 6
|
// and once more for day 6
|
||||||
expect((calculateSegments as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1);
|
expect((calculateRouteWithLegs as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
resolveSegments?.([]);
|
resolveSegments?.(MOCK_ROUTE_WITH_LEGS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-009: AbortError from calculateSegments does not set routeSegments to []', async () => {
|
it('FE-HOOK-ROUTE-009: AbortError from calculateSegments does not set routeSegments to []', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
|
||||||
|
|
||||||
const abortError = new Error('Aborted');
|
const abortError = new Error('Aborted');
|
||||||
abortError.name = 'AbortError';
|
abortError.name = 'AbortError';
|
||||||
(calculateSegments as ReturnType<typeof vi.fn>).mockRejectedValueOnce(abortError);
|
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(abortError);
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||||
const p2 = buildPlace({ lat: 20, lng: 20 });
|
const p2 = buildPlace({ lat: 20, lng: 20 });
|
||||||
@@ -222,9 +203,8 @@ describe('useRouteCalculation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-010: non-AbortError from calculateSegments sets routeSegments to []', async () => {
|
it('FE-HOOK-ROUTE-010: non-AbortError from calculateSegments sets routeSegments to []', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
|
||||||
|
|
||||||
(calculateSegments as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
|
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||||
const p2 = buildPlace({ lat: 20, lng: 20 });
|
const p2 = buildPlace({ lat: 20, lng: 20 });
|
||||||
@@ -273,7 +253,6 @@ describe('useRouteCalculation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-013: route recalculates when assignments change via store update', async () => {
|
it('FE-HOOK-ROUTE-013: route recalculates when assignments change via store update', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||||
const p2 = buildPlace({ lat: 20, lng: 20 });
|
const p2 = buildPlace({ lat: 20, lng: 20 });
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user