From a1d6348c5f094cd8a71022fee9ffbc0e35a0cc6d Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 23 Sep 2026 20:32:41 +0200 Subject: [PATCH] revert(collections): a saved place carries no price, website or phone again Takes back the editable price, currency, website and phone on collection places from 0df82185b and its review follow-up in a910c2a83. Costs are kept inside a trip only; a collection does not carry them, and #2471 is a feature request rather than a bug. The collection files are back to how they were before, including the dimmed save button from f16698bdb, which only mattered for the price field. --- .../CollectionPlaceDetail.test.tsx | 79 +--------- .../Collections/CollectionPlaceDetail.tsx | 50 ++---- .../useCollectionPlaceExtras.test.ts | 144 ------------------ .../Collections/useCollectionPlaceExtras.ts | 134 ---------------- .../screens/collections/MCollPlaceSheet.tsx | 67 +------- client/src/styles/collections.css | 7 - .../collections/MCollPlaceSheet.test.tsx | 67 +------- .../src/nest/collections/collections.mcp.ts | 2 +- .../nest/collections/collections.service.ts | 22 --- server/tests/e2e/collections.e2e.test.ts | 55 ------- .../tests/unit/mcp/tools-collections.test.ts | 58 ------- .../unit/nest/collections.controller.test.ts | 25 +-- .../unit/nest/collections.service.test.ts | 80 ---------- .../src/collection/collection.schema.spec.ts | 60 -------- shared/src/collection/collection.schema.ts | 21 --- shared/src/i18n/ar/collection.ts | 3 - shared/src/i18n/br/collection.ts | 4 - shared/src/i18n/ca/collection.ts | 3 - shared/src/i18n/cs/collection.ts | 3 - shared/src/i18n/de/collection.ts | 4 - shared/src/i18n/en/collection.ts | 3 - shared/src/i18n/es/collection.ts | 4 - shared/src/i18n/fr/collection.ts | 4 - shared/src/i18n/gr/collection.ts | 4 - shared/src/i18n/hu/collection.ts | 7 +- shared/src/i18n/id/collection.ts | 4 - shared/src/i18n/it/collection.ts | 4 - shared/src/i18n/ja/collection.ts | 3 - shared/src/i18n/ko/collection.ts | 3 - shared/src/i18n/nl/collection.ts | 4 - shared/src/i18n/pl/collection.ts | 4 - shared/src/i18n/ru/collection.ts | 4 - shared/src/i18n/sv/collection.ts | 4 - shared/src/i18n/tr/collection.ts | 4 - shared/src/i18n/uk/collection.ts | 4 - shared/src/i18n/vi/collection.ts | 4 - shared/src/i18n/zh-TW/collection.ts | 3 - shared/src/i18n/zh/collection.ts | 3 - wiki/Collections.md | 2 +- wiki/MCP-Addon-Tools.md | 2 +- 40 files changed, 25 insertions(+), 937 deletions(-) delete mode 100644 client/src/components/Collections/useCollectionPlaceExtras.test.ts delete mode 100644 client/src/components/Collections/useCollectionPlaceExtras.ts diff --git a/client/src/components/Collections/CollectionPlaceDetail.test.tsx b/client/src/components/Collections/CollectionPlaceDetail.test.tsx index 84dab4511..7b65ecf81 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.test.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-054 +// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-048 import React from 'react'; import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; @@ -688,80 +688,3 @@ describe('CollectionPlaceDetail: cover controls', () => { expect(controls).toHaveClass('ms-auto'); }); }); - -// #2471: price, website and phone travelled with a saved place but no editor -// showed them, so a cost could be neither seen nor changed in a list. -describe('CollectionPlaceDetail: price, website and phone', () => { - it('FE-COMP-COLDETAIL-049: edit mode offers price with its currency, website and phone, seeded from the place', async () => { - const user = userEvent.setup(); - renderDetail({ place: { ...place, price: 4.9, currency: 'EUR', website: 'https://cafe.example', phone: '+49 30 1234' } }); - await user.click(screen.getByRole('button', { name: 'Edit' })); - - // EUR renders with its own decimal comma, the way the costs form does. - expect(screen.getByLabelText('Price')).toHaveValue('4,90'); - expect(screen.getByText(/^EUR/)).toBeInTheDocument(); - expect(screen.getByText('A rough cost, such as an entry fee. It goes along when you copy the place into a trip.')).toBeInTheDocument(); - expect(screen.getByLabelText('Website')).toHaveValue('https://cafe.example'); - expect(screen.getByLabelText('Phone')).toHaveValue('+49 30 1234'); - }); - - it('FE-COMP-COLDETAIL-050: saving sends the typed price with the picked currency, the website and the phone', async () => { - const user = userEvent.setup(); - const props = renderDetail({ place: { ...place, price: null, currency: null } }); - await user.click(screen.getByRole('button', { name: 'Edit' })); - await user.type(screen.getByLabelText('Price'), '12,5'); - await user.click(screen.getByText(/^EUR/)); - await user.click(await screen.findByText(/^CHF/)); - await user.type(screen.getByLabelText('Website'), 'kunsthaus.example'); - await user.type(screen.getByLabelText('Phone'), '+41 44 253 84 84'); - await user.click(screen.getByRole('button', { name: /Save/ })); - - await waitFor(() => expect(props.onSave).toHaveBeenCalled()); - expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({ - price: 12.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44 253 84 84', - })); - }); - - it('FE-COMP-COLDETAIL-051: an emptied price is sent as null, and untouched fields are not sent at all', async () => { - const user = userEvent.setup(); - const props = renderDetail({ place: { ...place, price: 12, currency: 'EUR', website: 'https://cafe.example', phone: '+49 30' } }); - await user.click(screen.getByRole('button', { name: 'Edit' })); - await user.clear(screen.getByLabelText('Price')); - await user.click(screen.getByRole('button', { name: /Save/ })); - - await waitFor(() => expect(props.onSave).toHaveBeenCalled()); - const patch = vi.mocked(props.onSave).mock.calls[0][0]; - expect(patch.price).toBeNull(); - expect('currency' in patch).toBe(false); - expect('website' in patch).toBe(false); - expect('phone' in patch).toBe(false); - }); - - it('FE-COMP-COLDETAIL-052: read mode shows the price only above zero, the phone as a call link and the website as a chip', async () => { - renderDetail({ place: { ...place, price: 24.5, currency: 'CHF', website: 'https://www.kunsthaus.example/visit', phone: '+41 44 253' } }); - const chip = await screen.findByText((_, el) => el?.classList.contains('col-detail-price') ?? false); - expect(chip.textContent).toMatch(/24\.50/); - expect(chip.textContent).toMatch(/CHF/); - expect(screen.getByRole('link', { name: /\+41 44 253/ })).toHaveAttribute('href', 'tel:+4144253'); - expect(screen.getByRole('link', { name: /kunsthaus\.example/ })).toHaveAttribute('href', 'https://www.kunsthaus.example/visit'); - }); - - it('FE-COMP-COLDETAIL-053: a free place shows no price chip, and a script link never becomes a website chip', async () => { - renderDetail({ place: { ...place, price: 0, currency: 'EUR', website: 'javascript:alert(1)', phone: null } }); - await screen.findByRole('button', { name: 'Edit' }); - expect(document.querySelector('.col-detail-price')).toBeNull(); - expect(screen.queryByRole('link', { name: /alert/ })).not.toBeInTheDocument(); - expect(document.querySelector('a[href^="tel:"]')).toBeNull(); - }); - - it('FE-COMP-COLDETAIL-054: a price that is not one amount marks the field and holds the save', async () => { - const user = userEvent.setup(); - const props = renderDetail(); - await user.click(screen.getByRole('button', { name: 'Edit' })); - await user.type(screen.getByLabelText('Price'), '1.2.3'); - - expect(screen.getByLabelText('Price')).toHaveAttribute('aria-invalid', 'true'); - expect(screen.getByRole('button', { name: /Save/ })).toBeDisabled(); - expect(props.onSave).not.toHaveBeenCalled(); - }); -}); diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx index d512f396e..7574c95ef 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -3,12 +3,11 @@ import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { markdownLinkComponents } from '../shared/markdownLink' -import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2, Navigation, Banknote, Globe, Phone } from 'lucide-react' +import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2, Navigation } from 'lucide-react' import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared' import type { Category, TranslationFn } from '../../types' import MarkdownToolbar from '../Journey/MarkdownToolbar' import { NumericInput } from '../shared/NumericInput' -import CustomSelect from '../shared/CustomSelect' import { mapsApi } from '../../api/client' import { entityGradient } from '../../utils/gradients' import { getCategoryIcon } from '../shared/categoryIcons' @@ -20,7 +19,6 @@ import { Tooltip } from '../shared/Tooltip' import PlaceRating from '../shared/StarRating' import { normalizeImageFile } from '../../utils/convertHeic' import { getApiErrorMessage } from '../../utils/apiError' -import { useCollectionPlaceExtras, type CollectionPlaceExtrasPatch } from './useCollectionPlaceExtras' function linkHost(url: string): string { try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url } @@ -37,7 +35,7 @@ interface CollectionPlaceDetailProps { anchorRect?: { left: number; width: number } | null onClose: () => void onSetStatus: (status: CollectionStatus) => void - onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; lat?: number | null; lng?: number | null; address?: string | null } & CollectionPlaceExtrasPatch) => Promise + onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; lat?: number | null; lng?: number | null; address?: string | null }) => Promise /** Upload a custom cover image (#1136); enables the cover change/remove controls. */ onUploadImage?: (file: File) => Promise onCopyToTrip: () => void @@ -67,8 +65,8 @@ function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet: * Bottom detail sheet for a saved place — an opaque, clearly-sectioned card * (cover → meta → status → description → links) docked over the list column. * Read mode renders the description as markdown + link chips; edit mode swaps in - * name / category / price / website / phone / markdown description / links, - * saving via updatePlace. Status is an always-live segmented control (auto-saves). + * name / category / markdown description / links, saving via updatePlace. Status + * is an always-live segmented control (auto-saves). */ export default function CollectionPlaceDetail({ place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, onRate, t, @@ -88,7 +86,6 @@ export default function CollectionPlaceDetail({ const [address, setAddress] = useState(place.address ?? '') const [lat, setLat] = useState(place.lat != null ? String(place.lat) : '') const [lng, setLng] = useState(place.lng != null ? String(place.lng) : '') - const extras = useCollectionPlaceExtras(place) const [saving, setSaving] = useState(false) // A higher-res photo pulled from the maps provider when the place has none of // its own — the list avatar's little thumbnail is too low-res for the cover. @@ -159,7 +156,6 @@ export default function CollectionPlaceDetail({ if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) } } const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id)) - const startEdit = () => { extras.reset(place); setEditing(true) } const save = async () => { const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url) @@ -167,7 +163,7 @@ export default function CollectionPlaceDetail({ const lngNum = lng.trim() ? Number(lng) : Number.NaN setSaving(true) try { - await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, address: address.trim() || null, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null, ...extras.patch() }) + await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, address: address.trim() || null, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null }) setEditing(false) } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) @@ -232,11 +228,9 @@ export default function CollectionPlaceDetail({
{/* Meta (view only) */} - {!editing && (place.address || extras.priceLabel || extras.phone) && ( + {!editing && place.address && (
{place.address && {place.address}} - {extras.priceLabel && {extras.priceLabel}} - {extras.phone && extras.phoneHref && {extras.phone}} {/* Same picker as inside a trip (#1455). A saved place is somewhere you intend to go, and until now the only way to get directions was to add it to a trip first. */} @@ -297,25 +291,6 @@ export default function CollectionPlaceDetail({
- {/* Price (#2471): a rough cost such as an entry fee, always in its own currency */} -
-
{t('collections.price')}
-
- -
- -
-
-
{t('collections.priceHint')}
-
-
-
{t('places.formWebsite')}
- extras.set('website', e.target.value)} aria-label={t('places.formWebsite')} className="col-detail-input" /> -
-
-
{t('collections.phone')}
- extras.set('phone', e.target.value)} maxLength={60} aria-label={t('collections.phone')} className="col-detail-input" /> -
{/* Labels */} {labels.length > 0 && (
@@ -360,14 +335,9 @@ export default function CollectionPlaceDetail({ {place.description}
)} - {(extras.websiteHref || (place.links && place.links.length > 0)) && ( + {place.links && place.links.length > 0 && (
- {extras.websiteHref && ( - - {linkHost(extras.websiteHref)} - - )} - {(place.links ?? []).map((l, i) => ( + {place.links.map((l, i) => ( {l.label || linkHost(l.url)} @@ -382,11 +352,11 @@ export default function CollectionPlaceDetail({ {editing ? ( <> - + ) : ( <> - {canEdit && } + {canEdit && } {/* Beside the other two rather than up by the address: getting directions is something you DO with the place, like editing it or copying it into a diff --git a/client/src/components/Collections/useCollectionPlaceExtras.test.ts b/client/src/components/Collections/useCollectionPlaceExtras.test.ts deleted file mode 100644 index ca7a5a32d..000000000 --- a/client/src/components/Collections/useCollectionPlaceExtras.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -// FE-COMP-COLEXTRAS-001 to FE-COMP-COLEXTRAS-013: price, website and phone of a saved place (#2471). -import { act, renderHook } from '@testing-library/react' -import { useSettingsStore } from '../../store/settingsStore' -import { resetAllStores } from '../../../tests/helpers/store' -import { formatMoney } from '../../utils/formatters' -import { - extrasDraftFrom, - extrasPatch, - parsePriceInput, - phoneHref, - priceLabel, - useCollectionPlaceExtras, - type CollectionPlaceExtrasDraft, -} from './useCollectionPlaceExtras' - -vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k, locale: 'en-US' }) })) - -const blank: CollectionPlaceExtrasDraft = { price: '', currency: 'EUR', website: '', phone: '' } - -function withDefaultCurrency(code: string) { - useSettingsStore.setState(s => ({ settings: { ...s.settings, default_currency: code } })) -} - -beforeEach(() => { - resetAllStores() -}) - -describe('extrasDraftFrom', () => { - it('FE-COMP-COLEXTRAS-001: seeds the price padded to its currency, and keeps website and phone', () => { - expect(extrasDraftFrom({ price: 4.9, currency: 'eur', website: 'https://a.example', phone: '+49 30' }, 'USD')).toEqual({ - price: '4.90', currency: 'EUR', website: 'https://a.example', phone: '+49 30', - }) - expect(extrasDraftFrom({ price: 1500, currency: 'JPY' }, 'EUR').price).toBe('1500') - }) - - it('FE-COMP-COLEXTRAS-002: without a price or a currency it falls back to the user default, then EUR', () => { - expect(extrasDraftFrom({ price: null, currency: null }, 'chf')).toEqual({ price: '', currency: 'CHF', website: '', phone: '' }) - expect(extrasDraftFrom(null, '')).toEqual(blank) - }) -}) - -describe('parsePriceInput', () => { - it('FE-COMP-COLEXTRAS-003: reads an empty field as no price and a comma as the decimal separator', () => { - expect(parsePriceInput(' ')).toBeNull() - expect(parsePriceInput('12,5')).toBe(12.5) - expect(parsePriceInput('0')).toBe(0) - expect(parsePriceInput('.5')).toBe(0.5) - }) - - it('FE-COMP-COLEXTRAS-004: anything that is not one plain amount is NaN', () => { - for (const raw of ['1.2.3', '1,000.50', '-3', 'abc']) expect(parsePriceInput(raw)).toBeNaN() - }) -}) - -describe('extrasPatch', () => { - it('FE-COMP-COLEXTRAS-005: an untouched form sends nothing, whatever the stored values look like', () => { - const initial = extrasDraftFrom({ price: 10, currency: null, website: 'www.legacy.example', phone: '1'.repeat(80) }, 'EUR') - expect(extrasPatch(initial, { ...initial })).toEqual({}) - }) - - it('FE-COMP-COLEXTRAS-006: a new price goes out with its currency', () => { - expect(extrasPatch(blank, { ...blank, price: '12.5', currency: 'CHF' })).toEqual({ price: 12.5, currency: 'CHF' }) - }) - - it('FE-COMP-COLEXTRAS-007: changing only the currency of a price resends both', () => { - const initial = { ...blank, price: '12.00' } - expect(extrasPatch(initial, { ...initial, currency: 'USD' })).toEqual({ price: 12, currency: 'USD' }) - }) - - it('FE-COMP-COLEXTRAS-008: clearing the price sends null and leaves the currency alone; a currency without a price sends nothing', () => { - const initial = { ...blank, price: '12.00' } - expect(extrasPatch(initial, { ...initial, price: '' })).toEqual({ price: null }) - expect(extrasPatch(blank, { ...blank, currency: 'USD' })).toEqual({}) - }) - - it('FE-COMP-COLEXTRAS-009: website gets https:// like a link does, phone is trimmed, and emptied fields clear', () => { - expect(extrasPatch(blank, { ...blank, website: ' museum.example ', phone: ' +41 44 ' })).toEqual({ - website: 'https://museum.example', phone: '+41 44', - }) - const initial = { ...blank, website: 'https://museum.example', phone: '+41 44' } - expect(extrasPatch(initial, { ...initial, website: ' ', phone: '' })).toEqual({ website: null, phone: null }) - }) -}) - -describe('phoneHref', () => { - it('FE-COMP-COLEXTRAS-010: keeps the digits and a leading plus', () => { - expect(phoneHref('+41 (44) 253-84 84')).toBe('tel:+41442538484') - }) -}) - -describe('priceLabel', () => { - it('FE-COMP-COLEXTRAS-013: names the stored currency, and without one shows the bare amount whatever the viewer uses', () => { - expect(priceLabel(2000, 'JPY', 'en-US')).toBe(formatMoney(2000, 'JPY', 'en-US')) - expect(priceLabel(2000, null, 'en-US')).toBe('2,000') - expect(priceLabel(12.5, '', 'de-DE')).toBe('12,5') - expect(priceLabel(12.5, undefined, 'de-DE')).not.toMatch(/€|\$|EUR|USD/) - }) -}) - -describe('useCollectionPlaceExtras', () => { - it('FE-COMP-COLEXTRAS-011: formats the stored price for read mode, only above zero, and guards the website', () => { - withDefaultCurrency('CHF') - const paid = renderHook(() => useCollectionPlaceExtras({ price: 24.5, currency: 'EUR', website: 'https://kunsthaus.example', phone: ' +41 44 ' })) - expect(paid.result.current.priceLabel).toBe(formatMoney(24.5, 'EUR', 'en-US')) - expect(paid.result.current.websiteHref).toBe('https://kunsthaus.example') - expect(paid.result.current.phone).toBe('+41 44') - expect(paid.result.current.phoneHref).toBe('tel:+4144') - - // No currency stored: a bare amount, not the viewer's own default currency. - const bare = renderHook(() => useCollectionPlaceExtras({ price: 8, currency: null })) - expect(bare.result.current.priceLabel).toBe('8') - - const free = renderHook(() => useCollectionPlaceExtras({ price: 0, currency: 'EUR', website: 'javascript:alert(1)', phone: ' ' })) - expect(free.result.current.priceLabel).toBeNull() - expect(free.result.current.websiteHref).toBeNull() - expect(free.result.current.phone).toBeNull() - expect(free.result.current.phoneHref).toBeNull() - }) - - it('FE-COMP-COLEXTRAS-012: reset seeds from the place, set edits the draft, and an unparseable price blocks the patch', () => { - withDefaultCurrency('USD') - const { result } = renderHook(() => useCollectionPlaceExtras(null)) - act(() => result.current.reset({ price: 12, currency: 'EUR', website: null, phone: null })) - expect(result.current.draft).toEqual({ price: '12.00', currency: 'EUR', website: '', phone: '' }) - expect(result.current.patch()).toEqual({}) - expect(result.current.currencySelect.options).toContainEqual({ value: 'EUR', label: 'EUR €' }) - // EUR shows its decimal comma in the field; the draft stays dot-normalized. - expect(result.current.priceInput.value).toBe('12,00') - - act(() => result.current.priceInput.onValueChange('1.2.3')) - expect(result.current.priceValid).toBe(false) - expect(result.current.priceInput['aria-invalid']).toBe(true) - - act(() => result.current.priceInput.onValueChange('15,5')) - act(() => result.current.currencySelect.onChange('GBP')) - expect(result.current.draft.price).toBe('15.5') - expect(result.current.priceValid).toBe(true) - expect(result.current.patch()).toEqual({ price: 15.5, currency: 'GBP' }) - - // A place without a currency seeds the user's default into the picker. - act(() => result.current.reset({ price: null, currency: null })) - expect(result.current.draft.currency).toBe('USD') - }) -}) diff --git a/client/src/components/Collections/useCollectionPlaceExtras.ts b/client/src/components/Collections/useCollectionPlaceExtras.ts deleted file mode 100644 index 140fb69b4..000000000 --- a/client/src/components/Collections/useCollectionPlaceExtras.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { useCallback, useState } from 'react' -import type { CollectionPlace, CollectionPlaceUpdateRequest } from '@trek/shared' -import { useTranslation } from '../../i18n' -import { useSettingsStore } from '../../store/settingsStore' -import { amountToInputString, formatMoney, localizeAmountInput } from '../../utils/formatters' -import { safeHttpUrl } from '../../utils/safeUrl' -import { normalizeLinkUrl } from '../../pages/collections/collectionsModel' -import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants' - -/** - * Price, website and phone of a saved place (#2471). - * - * The columns always existed and travelled along on save and on copy into a - * trip, but neither editor showed them. Desktop (CollectionPlaceDetail) and - * phone (MCollPlaceSheet) both edit them through this one hook, so the two - * shells only differ in markup. - */ - -type ExtrasSource = Pick - -export type CollectionPlaceExtrasPatch = Pick - -/** Form state: the price is a dot-normalized input string, like every amount field. */ -export interface CollectionPlaceExtrasDraft { - price: string - currency: string - website: string - phone: string -} - -export function extrasDraftFrom(place: ExtrasSource | null | undefined, fallbackCurrency: string): CollectionPlaceExtrasDraft { - const currency = (place?.currency || fallbackCurrency || 'EUR').toUpperCase() - return { - price: place?.price != null ? amountToInputString(place.price, currency) : '', - currency, - website: place?.website ?? '', - phone: place?.phone ?? '', - } -} - -/** The typed price: null for an empty field, NaN for one that is not a plain amount. */ -export function parsePriceInput(raw: string): number | null { - const s = raw.trim().replace(',', '.') - if (!s) return null - const n = Number(s) - return Number.isFinite(n) && n >= 0 ? n : Number.NaN -} - -/** - * Only what the user changed. The update contract holds these fields to a - * stricter standard than older rows were written with, so resending an - * untouched value could refuse an unrelated edit, and a stored price would get - * a currency pinned on it that nobody picked. - */ -export function extrasPatch(initial: CollectionPlaceExtrasDraft, draft: CollectionPlaceExtrasDraft): CollectionPlaceExtrasPatch { - const patch: CollectionPlaceExtrasPatch = {} - const price = parsePriceInput(draft.price) - const priceChanged = draft.price.trim() !== initial.price.trim() - if (priceChanged || (price != null && draft.currency !== initial.currency)) { - patch.price = price - // A list has no base currency to read a bare number in, so a price always - // goes out with the currency it was typed in. - if (price != null) patch.currency = draft.currency - } - const website = draft.website.trim() - if (website !== initial.website.trim()) patch.website = website ? normalizeLinkUrl(website) : null - const phone = draft.phone.trim() - if (phone !== initial.phone.trim()) patch.phone = phone || null - return patch -} - -/** - * The read-mode price. An older row can hold an amount without a currency (a - * trip place saved before it had one); it shows as a bare number rather than - * in the viewer's own default currency, which would name a different currency - * for every member of a shared list. - */ -export function priceLabel(price: number, currency: string | null | undefined, locale: string): string { - if (currency) return formatMoney(price, currency, locale) - return new Intl.NumberFormat(locale || undefined, { maximumFractionDigits: 2 }).format(price) -} - -/** A tel: link keeps the digits and a leading plus, nothing a dialer would choke on. */ -export function phoneHref(phone: string): string { - return `tel:${phone.replace(/[^\d+]/g, '')}` -} - -export function useCollectionPlaceExtras(place: ExtrasSource | null | undefined) { - const { locale } = useTranslation() - const defaultCurrency = useSettingsStore(s => s.settings.default_currency) - const [initial, setInitial] = useState(() => extrasDraftFrom(place, defaultCurrency)) - const [draft, setDraft] = useState(initial) - - /** Seed the form from the place as it is now; call it when editing starts. */ - const reset = useCallback((from: ExtrasSource | null | undefined) => { - const seeded = extrasDraftFrom(from, defaultCurrency) - setInitial(seeded) - setDraft(seeded) - }, [defaultCurrency]) - - const set = useCallback((key: K, value: CollectionPlaceExtrasDraft[K]) => { - setDraft(d => ({ ...d, [key]: value })) - }, []) - - const price = place?.price ?? 0 - const phone = place?.phone?.trim() || null - const priceValid = !Number.isNaN(parsePriceInput(draft.price)) - return { - draft, - set, - reset, - priceValid, - patch: () => extrasPatch(initial, draft), - // Spread onto the shells' NumericInput and CustomSelect. The amount shows with - // the currency's own decimal separator and is kept dot-normalized, the way the - // costs form does it. - priceInput: { - mode: 'decimal' as const, - value: localizeAmountInput(draft.price, draft.currency), - onValueChange: (v: string) => set('price', v.replace(',', '.')), - 'aria-invalid': !priceValid, - }, - currencySelect: { - value: draft.currency, - onChange: (v: string | number) => set('currency', String(v)), - options: currenciesWith(draft.currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c })), - }, - // Read mode: a free place shows no price, the same rule the trip inspector follows. - priceLabel: price > 0 ? priceLabel(price, place?.currency, locale) : null, - websiteHref: safeHttpUrl(place?.website), - phone, - phoneHref: phone ? phoneHref(phone) : null, - } -} diff --git a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx index 467d1c1c9..92faf9be3 100644 --- a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx +++ b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' -import { Banknote, Camera, Check, Copy, ExternalLink, Globe, Loader2, MapPin, Pencil, Phone, Trash2, X } from 'lucide-react' +import { Camera, Check, Copy, ExternalLink, Loader2, MapPin, Pencil, Trash2, X } from 'lucide-react' import type { CollectionLabel, CollectionLink, CollectionPlace, CollectionStatus } from '@trek/shared' import type { Category, TranslationFn } from '../../../types' import { mapsApi } from '../../../api/client' @@ -12,9 +12,6 @@ import { getApiErrorMessage } from '../../../utils/apiError' import { normalizeLinkUrl, STATUS_ORDER } from '../../../pages/collections/collectionsModel' import MSheet from '../../components/MSheet' import PlaceRating from '../../../components/shared/StarRating' -import CustomSelect from '../../../components/shared/CustomSelect' -import { NumericInput } from '../../../components/shared/NumericInput' -import { useCollectionPlaceExtras, type CollectionPlaceExtrasPatch } from '../../../components/Collections/useCollectionPlaceExtras' import MCollCategoryPicker from './MCollCategoryPicker' import MCollLinksEditor from './MCollLinksEditor' import { STATUS_SPEC } from './collectionsMobileModel' @@ -30,9 +27,6 @@ const HERO_CAT_CHIP = // Close and the cover controls beside it. const HERO_BTN = 'flex h-8 w-8 items-center justify-center rounded-full bg-[rgba(0,0,0,.28)] text-white' // theme-lint-disable -// A saved link, and the website beside it. -const LINK_CHIP = - 'inline-flex items-center gap-[5px] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[7px] text-[0.71875rem] font-semibold text-m-ink' interface MCollPlaceSheetProps { place: CollectionPlace | null @@ -42,7 +36,7 @@ interface MCollPlaceSheetProps { labels: CollectionLabel[] onClose: () => void onSetStatus: (status: CollectionStatus) => void - onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; address?: string | null } & CollectionPlaceExtrasPatch) => Promise + onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; address?: string | null }) => Promise onUploadImage?: (file: File) => Promise onCopyToTrip: () => void onRemove: () => void @@ -75,7 +69,6 @@ export default function MCollPlaceSheet({ const [links, setLinks] = useState([]) const [labelIds, setLabelIds] = useState([]) const [saving, setSaving] = useState(false) - const extras = useCollectionPlaceExtras(held) const [fetchedPhoto, setFetchedPhoto] = useState(null) const heldId = held?.id @@ -107,7 +100,7 @@ export default function MCollPlaceSheet({ const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url) setSaving(true) try { - await onSave({ name: name.trim() || held.name, address: address.trim() || null, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, ...extras.patch() }) + await onSave({ name: name.trim() || held.name, address: address.trim() || null, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds }) setEditing(false) } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) @@ -116,11 +109,6 @@ export default function MCollPlaceSheet({ } } - const startEdit = () => { - extras.reset(held) - setEditing(true) - } - const cancelEdit = () => { if (!held) return setEditing(false) @@ -224,20 +212,6 @@ export default function MCollPlaceSheet({ {held.address}
)} - {!editing && (extras.priceLabel || extras.phone) && ( -
- {extras.priceLabel && ( - - {extras.priceLabel} - - )} - {extras.phone && extras.phoneHref && ( - - {extras.phone} - - )} -
- )} {/* Status cycle */}
@@ -277,26 +251,6 @@ export default function MCollPlaceSheet({ {/* Address (#1870): free text, same as the add sheet offers */} {t('places.formAddress').toUpperCase()} setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={INPUT_CLS} /> - {/* Price (#2471): a rough cost such as an entry fee, always in its own currency */} - {t('collections.price').toUpperCase()} -
-
- -
-
- -
-
-
{t('collections.priceHint')}
- {t('places.formWebsite').toUpperCase()} - extras.set('website', e.target.value)} aria-label={t('places.formWebsite')} className={INPUT_CLS} /> - {t('collections.phone').toUpperCase()} - extras.set('phone', e.target.value)} maxLength={60} aria-label={t('collections.phone')} className={INPUT_CLS} /> {t('collections.category').toUpperCase()} {labels.length > 0 && ( @@ -331,7 +285,7 @@ export default function MCollPlaceSheet({
{t('common.cancel')} - + {t('common.save')}
@@ -360,20 +314,15 @@ export default function MCollPlaceSheet({ {held.description}
)} - {(extras.websiteHref || (held.links && held.links.length > 0)) && ( + {held.links && held.links.length > 0 && (
- {extras.websiteHref && ( - - {linkHost(extras.websiteHref)} - - )} - {(held.links ?? []).map((l, i) => ( + {held.links.map((l, i) => ( {l.label || linkHost(l.url)} @@ -382,7 +331,7 @@ export default function MCollPlaceSheet({ )}
{canEdit && ( - )} diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css index d56ee751d..841a8b13d 100644 --- a/client/src/styles/collections.css +++ b/client/src/styles/collections.css @@ -449,10 +449,6 @@ .trek-dash .col-detail-catchip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px 4px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; color: var(--cat, var(--accent)); background: color-mix(in oklch, var(--cat, var(--accent)) 15%, transparent); } .trek-dash .col-detail-addr { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--ink-3); min-width: 0; } .trek-dash .col-detail-addr svg { flex-shrink: 0; } -/* Price (#2471): a quiet pill beside the address, the figure the place costs to get in. */ -.trek-dash .col-detail-price { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; color: var(--ink-2); background: var(--surface-2); font-variant-numeric: tabular-nums; } -.trek-dash .col-detail-price svg { flex-shrink: 0; } -.trek-dash .col-detail-tel:hover { color: var(--ink); } /* Directions (#1455): sits on the meta line beside the address, quieter than the link chips below — it is an action on the place, not a piece of it. */ @@ -487,8 +483,6 @@ .trek-dash .col-detail-link-row { display: flex; align-items: center; gap: 8px; } .trek-dash .col-detail-input { padding: 7px 10px; border-radius: 9px; border: 1px solid var(--line-2); background: var(--surface-2); color: var(--ink); font-size: 12.5px; outline: none; min-width: 0; } .trek-dash .col-detail-input:focus { border-color: var(--accent); background: var(--surface); } -.trek-dash .col-detail-input[aria-invalid="true"] { border-color: var(--danger); } -.trek-dash .col-detail-hint { font-size: 11.5px; line-height: 1.45; color: var(--ink-3); } .trek-dash .col-detail-icon-btn { padding: 7px; border-radius: 8px; color: var(--ink-3); } .trek-dash .col-detail-icon-btn:hover { color: oklch(0.6 0.2 25); background: var(--surface-2); } .trek-dash .col-detail-add-link { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 11px; border-radius: 9px; border: 1px dashed var(--line-2); color: var(--ink-2); font-size: 12.5px; font-weight: 500; } @@ -503,7 +497,6 @@ .trek-dash .col-detail-btn:hover { background: var(--surface); color: var(--ink); } .trek-dash .col-detail-btn svg { flex-shrink: 0; } .trek-dash .col-detail-btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } -.trek-dash .col-detail-btn:disabled { opacity: .5; cursor: not-allowed; } .trek-dash .col-detail-btn.danger { color: oklch(0.6 0.2 25); border-color: transparent; background: transparent; } .trek-dash .col-detail-btn.danger:hover { background: oklch(0.6 0.2 25 / .12); } diff --git a/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx b/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx index 2c86d255c..16c847c47 100644 --- a/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx +++ b/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx @@ -8,7 +8,7 @@ import type { Category } from '../../../../src/types' import { resetAllStores } from '../../../helpers/store' import { useTranslation } from '../../../../src/i18n' -// FE-MOB-CPLSH-001 to FE-MOB-CPLSH-046 +// FE-MOB-CPLSH-001 to FE-MOB-CPLSH-041 // react-markdown ships ESM-only chunks jsdom chokes on; the sheet only needs // the raw description text to reach the renderer. @@ -478,69 +478,4 @@ describe('MCollPlaceSheet', () => { expect(chip).toHaveClass('min-w-0') expect(text).toHaveClass('truncate') }) - - // #2471: price, website and phone travelled with a saved place but the sheet - // never showed them, so a cost could be neither seen nor changed on a phone. - it('FE-MOB-CPLSH-042: edit mode offers price with its currency, website and phone, seeded from the place', () => { - setup({ place: place({ price: 4.9, currency: 'EUR', website: 'https://narisawa.example', phone: '+81 3 5785' }) }) - fireEvent.click(screen.getByRole('button', { name: 'Edit' })) - - expect(screen.getByLabelText('Price')).toHaveValue('4,90') - expect(screen.getByText(/^EUR/)).toBeInTheDocument() - expect(screen.getByText('A rough cost, such as an entry fee. It goes along when you copy the place into a trip.')).toBeInTheDocument() - expect(screen.getByLabelText('Website')).toHaveValue('https://narisawa.example') - expect(screen.getByLabelText('Phone')).toHaveValue('+81 3 5785') - }) - - it('FE-MOB-CPLSH-043: saving sends the typed price with the picked currency, the website and the phone', async () => { - const { onSave } = setup({ place: place({ price: null, currency: null }) }) - fireEvent.click(screen.getByRole('button', { name: 'Edit' })) - fireEvent.change(screen.getByLabelText('Price'), { target: { value: '15000' } }) - fireEvent.click(screen.getByText(/^EUR/)) - fireEvent.click(await screen.findByText(/^JPY/)) - fireEvent.change(screen.getByLabelText('Website'), { target: { value: 'narisawa.example' } }) - fireEvent.change(screen.getByLabelText('Phone'), { target: { value: ' +81 3 5785 0799 ' } }) - fireEvent.click(screen.getByRole('button', { name: 'Save' })) - - await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - price: 15000, currency: 'JPY', website: 'https://narisawa.example', phone: '+81 3 5785 0799', - }))) - }) - - it('FE-MOB-CPLSH-044: an emptied price is sent as null and untouched fields stay out of the patch', async () => { - const { onSave } = setup({ place: place({ price: 12, currency: 'EUR', website: 'https://narisawa.example', phone: '+81' }) }) - fireEvent.click(screen.getByRole('button', { name: 'Edit' })) - fireEvent.change(screen.getByLabelText('Price'), { target: { value: '' } }) - fireEvent.click(screen.getByRole('button', { name: 'Save' })) - - await waitFor(() => expect(onSave).toHaveBeenCalled()) - const patch = onSave.mock.calls[0][0] - expect(patch.price).toBeNull() - expect('currency' in patch).toBe(false) - expect('website' in patch).toBe(false) - expect('phone' in patch).toBe(false) - }) - - it('FE-MOB-CPLSH-045: read mode shows the price only above zero, the phone as a call link and the website as a chip', () => { - const { unmount } = setup({ place: place({ price: 24.5, currency: 'CHF', website: 'https://www.narisawa.example/menu', phone: '+81 3 5785' }) }) - expect(screen.getByText(/24\.50/)).toBeInTheDocument() - expect(screen.getByRole('link', { name: /\+81 3 5785/ })).toHaveAttribute('href', 'tel:+8135785') - expect(screen.getByRole('link', { name: /^narisawa\.example$/ })).toHaveAttribute('href', 'https://www.narisawa.example/menu') - unmount() - - setup({ place: place({ price: 0, currency: 'CHF', website: 'javascript:alert(1)', phone: null }) }) - expect(screen.queryByText(/0\.00/)).not.toBeInTheDocument() - expect(screen.queryByRole('link', { name: /alert/ })).not.toBeInTheDocument() - expect(document.querySelector('a[href^="tel:"]')).toBeNull() - }) - - it('FE-MOB-CPLSH-046: a price that is not one amount marks the field and holds the save', () => { - const { onSave } = setup() - fireEvent.click(screen.getByRole('button', { name: 'Edit' })) - fireEvent.change(screen.getByLabelText('Price'), { target: { value: '1.2.3' } }) - - expect(screen.getByLabelText('Price')).toHaveAttribute('aria-invalid', 'true') - expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() - expect(onSave).not.toHaveBeenCalled() - }) }) diff --git a/server/src/nest/collections/collections.mcp.ts b/server/src/nest/collections/collections.mcp.ts index 5a0a04b04..7761c9ffc 100644 --- a/server/src/nest/collections/collections.mcp.ts +++ b/server/src/nest/collections/collections.mcp.ts @@ -229,7 +229,7 @@ export class CollectionsMcp { @Tool({ name: 'update_collection_place', - description: 'Update a saved place\'s name, address, coordinates (lat/lng), description, notes, price with currency (a rough cost such as an entry fee; always send the three-letter currency with a price, since a collection has no base currency, and it travels into a trip when the place is copied there), website, phone, status, category, links, tags, labels, image, or move it to another collection (set collection_id).', + description: 'Update a saved place\'s name, address, coordinates (lat/lng), description, notes, status, category, links, tags, labels, image, or move it to another collection (set collection_id).', inputSchema: { placeId: z.number().int().positive(), ...collectionPlaceUpdateRequestSchema.shape, links: linksInput }, annotations: TOOL_ANNOTATIONS_WRITE, when: collectionsAddonOn, diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index cf76e9d3c..8006c03e4 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -995,7 +995,6 @@ export class CollectionsService { async updatePlace(userId: number, placeId: number, body: CollectionPlaceUpdateRequest, socketId?: string): Promise { const currentCollection = this.collectionIdOfPlace(placeId); this.assertCanEdit(userId, currentCollection); - if (body.price !== undefined || body.currency !== undefined) this.assertPriceHasCurrency(placeId, body); // Capture the previous thumbnail so a replaced/cleared custom upload (#1136) // can be reclaimed once nothing references it any more. @@ -1011,10 +1010,6 @@ export class CollectionsService { if (body.lat !== undefined) { updates.push('lat = ?'); params.push(body.lat ?? null); } if (body.lng !== undefined) { updates.push('lng = ?'); params.push(body.lng ?? null); } if (body.address !== undefined) { updates.push('address = ?'); params.push(body.address ?? null); } - if (body.price !== undefined) { updates.push('price = ?'); params.push(body.price ?? null); } - if (body.currency !== undefined) { updates.push('currency = ?'); params.push(body.currency ?? null); } - if (body.website !== undefined) { updates.push('website = ?'); params.push(body.website ?? null); } - if (body.phone !== undefined) { updates.push('phone = ?'); params.push(body.phone ?? null); } if (body.status !== undefined) { updates.push('status = ?'); params.push(body.status); } if (body.category_id !== undefined) { updates.push('category_id = ?'); params.push(body.category_id ?? null); } if (body.image_url !== undefined) { updates.push('image_url = ?'); params.push(body.image_url ?? null); } @@ -1057,23 +1052,6 @@ export class CollectionsService { return this.getPlaceById(placeId); } - /** - * A list has no base currency, so a price that names none would be read in - * each viewer's own default currency and carried into a trip as the trip's - * (#2471). Checked here rather than in the contract so REST and the MCP tool, - * which spreads the contract's shape, refuse it alike. Only the row as it - * would look after this update counts, so a zero price or clearing both - * fields still goes through. - */ - private assertPriceHasCurrency(placeId: number, body: Pick): void { - const stored = this.db.get<{ price: number | null; currency: string | null }>( - 'SELECT price, currency FROM collection_places WHERE id = ?', placeId, - ); - const price = body.price === undefined ? stored?.price ?? null : body.price; - const currency = body.currency === undefined ? stored?.currency ?? null : body.currency; - if (price != null && price > 0 && !currency) httpError(400, 'A price needs a currency: send its three-letter code along'); - } - setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string): CollectionPlace { const collectionId = this.collectionIdOfPlace(placeId); this.assertCanEdit(userId, collectionId); diff --git a/server/tests/e2e/collections.e2e.test.ts b/server/tests/e2e/collections.e2e.test.ts index 5c21c47c4..623018455 100644 --- a/server/tests/e2e/collections.e2e.test.ts +++ b/server/tests/e2e/collections.e2e.test.ts @@ -183,61 +183,6 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => expect(db.prepare('SELECT address FROM collection_places WHERE id = ?').get(placeId)).toEqual({ address: null }); }); - // #2471: price, currency, website and phone were stripped by the pipe the same way. - it('COLLECTIONS-E2E-085: PATCH price, currency, website and phone is stored and survives a rename', async () => { - const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Prices' })).body; - const placeId = (await request(server).post('/api/addons/collections/places') - .set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Kunsthaus' })).body.place.id; - - const patched = await request(server).patch(`/api/addons/collections/places/${placeId}`) - .set('Cookie', sessionCookie(ownerId)).send({ price: 12.5, currency: 'chf', website: 'https://kunsthaus.example', phone: '+41 44 253 84 84' }); - expect(patched.status).toBe(200); - expect(patched.body).toMatchObject({ price: 12.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44 253 84 84' }); - - const renamed = await request(server).patch(`/api/addons/collections/places/${placeId}`) - .set('Cookie', sessionCookie(ownerId)).send({ name: 'Kunsthaus Zürich' }); - expect(renamed.status).toBe(200); - expect(db.prepare('SELECT price, currency, website, phone FROM collection_places WHERE id = ?').get(placeId)).toEqual({ - price: 12.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44 253 84 84', - }); - }); - - it('COLLECTIONS-E2E-086: a negative price, a made-up currency or a script link is a 400 and changes nothing', async () => { - const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Bad prices' })).body; - const placeId = (await request(server).post('/api/addons/collections/places') - .set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Kunsthaus', price: 10, currency: 'EUR' })).body.place.id; - - for (const body of [{ price: -1 }, { currency: 'EURO' }, { website: 'javascript:alert(1)' }]) { - const res = await request(server).patch(`/api/addons/collections/places/${placeId}`).set('Cookie', sessionCookie(ownerId)).send(body); - expect(res.status).toBe(400); - } - expect(db.prepare('SELECT price, currency, website FROM collection_places WHERE id = ?').get(placeId)).toEqual({ price: 10, currency: 'EUR', website: null }); - }); - - it('COLLECTIONS-E2E-088: a price without a currency on a place that has none is a 400', async () => { - const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Bare prices' })).body; - const placeId = (await request(server).post('/api/addons/collections/places') - .set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Kunsthaus' })).body.place.id; - - const res = await request(server).patch(`/api/addons/collections/places/${placeId}`).set('Cookie', sessionCookie(ownerId)).send({ price: 14 }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/currency/); - expect(db.prepare('SELECT price, currency FROM collection_places WHERE id = ?').get(placeId)).toEqual({ price: null, currency: null }); - }); - - it('COLLECTIONS-E2E-087: a member who may only read cannot set a price (403)', async () => { - const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Read only prices' })).body; - await request(server).post('/api/addons/collections/invite').set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, user_id: otherId, role: 'viewer' }); - await request(server).post('/api/addons/collections/invite/accept').set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id }); - const placeId = (await request(server).post('/api/addons/collections/places') - .set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Kunsthaus' })).body.place.id; - - const res = await request(server).patch(`/api/addons/collections/places/${placeId}`) - .set('Cookie', sessionCookie(otherId)).send({ price: 12, currency: 'CHF' }); - expect(res.status).toBe(403); - expect(db.prepare('SELECT price, currency FROM collection_places WHERE id = ?').get(placeId)).toEqual({ price: null, currency: null }); - }); - // ── Cross-user isolation ───────────────────────────────────────────────── it('COLLECTIONS-E2E-020: a stranger gets 404 on someone else’s collection', async () => { const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Private' })).body; diff --git a/server/tests/unit/mcp/tools-collections.test.ts b/server/tests/unit/mcp/tools-collections.test.ts index 09b61b444..da3efb6ba 100644 --- a/server/tests/unit/mcp/tools-collections.test.ts +++ b/server/tests/unit/mcp/tools-collections.test.ts @@ -380,64 +380,6 @@ describe('Tool: update_collection_place / set_collection_place_status / rate_col expect(cleared.place.rating_count).toBe(0); }); }); - - // #2471: the tool spreads the REST update contract, so the four fields the - // contract gained arrive here too; the strict shape still refuses the rest. - it('sets price, currency, website and phone, refuses a bad price and still refuses an unknown key', async () => { - const { user } = createUser(testDb); - const col = seedCollection(user.id, 'P'); - const pid = seedPlace(col, user.id, 'Pergamon'); - await withHarness(user.id, async (h) => { - const upd = parseToolResult(await h.client.callTool({ - name: 'update_collection_place', - arguments: { placeId: pid, price: 14, currency: 'eur', website: 'https://pergamon.example', phone: '+49 30 266 42 42 42' }, - })) as { place: { price: number; currency: string; website: string; phone: string } }; - expect(upd.place).toMatchObject({ price: 14, currency: 'EUR', website: 'https://pergamon.example', phone: '+49 30 266 42 42 42' }); - - const negative = await h.client.callTool({ name: 'update_collection_place', arguments: { placeId: pid, price: -3 } }); - expect(negative.isError).toBe(true); - - const unknown = await h.client.callTool({ name: 'update_collection_place', arguments: { placeId: pid, entry_fee: 14 } }); - expect(unknown.isError).toBe(true); - expect(errorText(unknown)).toMatch(/Unrecognized key.*entry_fee/); - - const stored = testDb.prepare('SELECT price, currency FROM collection_places WHERE id = ?').get(pid); - expect(stored).toEqual({ price: 14, currency: 'EUR' }); - }); - }); - - // The contract takes price and currency as separate fields; the service holds - // them together, so an agent cannot leave a bare amount on a place without one. - it('refuses a price without a currency on a place that has none', async () => { - const { user } = createUser(testDb); - const col = seedCollection(user.id, 'P'); - const pid = seedPlace(col, user.id, 'Pergamon'); - await withHarness(user.id, async (h) => { - const bare = await h.client.callTool({ name: 'update_collection_place', arguments: { placeId: pid, price: 14 } }); - expect(bare.isError).toBe(true); - expect(errorText(bare)).toMatch(/currency/); - expect(testDb.prepare('SELECT price, currency FROM collection_places WHERE id = ?').get(pid)).toEqual({ price: null, currency: null }); - - const paired = parseToolResult(await h.client.callTool({ - name: 'update_collection_place', arguments: { placeId: pid, price: 14, currency: 'EUR' }, - })) as { place: { price: number; currency: string } }; - expect(paired.place).toMatchObject({ price: 14, currency: 'EUR' }); - }); - }); - - it('describes the price as a rough cost that needs its currency', async () => { - const { user } = createUser(testDb); - const h = await createMcpHarness({ userId: user.id, withResources: false }); - try { - const tool = (await h.client.listTools()).tools.find((t) => t.name === 'update_collection_place'); - expect(tool?.description).toMatch(/price with currency/); - expect(Object.keys((tool?.inputSchema as { properties: Record }).properties)).toEqual( - expect.arrayContaining(['price', 'currency', 'website', 'phone']), - ); - } finally { - await h.cleanup(); - } - }); }); // --------------------------------------------------------------------------- diff --git a/server/tests/unit/nest/collections.controller.test.ts b/server/tests/unit/nest/collections.controller.test.ts index 61dd3673e..7d3b28d8e 100644 --- a/server/tests/unit/nest/collections.controller.test.ts +++ b/server/tests/unit/nest/collections.controller.test.ts @@ -6,9 +6,7 @@ import type { CollectionsService } from '../../../src/nest/collections/collectio import type { StorageService } from '../../../src/nest/storage/storage.service'; import { CollectionGpxError } from '../../../src/nest/collections/collection-gpx.helpers'; import type { User } from '../../../src/types'; -import { collectionPlaceUpdateRequestSchema, type CollectionGpxProblem } from '@trek/shared'; -import { CollectionPlaceUpdateDto } from '../../../src/nest/collections/collections.dto'; -import { ZodValidationPipe } from '../../../src/nest/common/zod-validation.pipe'; +import type { CollectionGpxProblem } from '@trek/shared'; const storageStub = { put: vi.fn().mockResolvedValue(undefined) } as unknown as StorageService; @@ -112,27 +110,6 @@ describe('CollectionsController', () => { expect(c.copyToTrip(user, { trip_id: 5, place_ids: [9] } as never)).toEqual({ copied: 1, skipped: [] }); }); - // #2471: the PATCH body stripped price, currency, website and phone before the - // handler ever saw them. The DTO is the same shape update_collection_place - // spreads, so REST and MCP take the same four fields and refuse the same values. - it('updatePlace keeps price, currency, website and phone through the body pipe and forwards them', () => { - const pipe = new ZodValidationPipe(); - const meta = { type: 'body' as const, metatype: CollectionPlaceUpdateDto }; - const body = pipe.transform({ price: 12.5, currency: ' chf', website: 'https://kunsthaus.example', phone: '+41 44' }, meta); - expect(body).toEqual({ price: 12.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44' }); - - const svc = makeService(); - new CollectionsController(svc, new RuntimeEnvService(), storageStub).updatePlace(user, '9', body as never, 'sid'); - expect(svc.updatePlace).toHaveBeenCalledWith(1, 9, { price: 12.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44' }, 'sid'); - - expect(thrown(() => pipe.transform({ price: -1 }, meta)).status).toBe(400); - expect(thrown(() => pipe.transform({ currency: 'EURO' }, meta)).status).toBe(400); - expect(Object.keys(collectionPlaceUpdateRequestSchema.shape)).toEqual( - expect.arrayContaining(['price', 'currency', 'website', 'phone']), - ); - expect(CollectionPlaceUpdateDto.schema).toBe(collectionPlaceUpdateRequestSchema); - }); - // The legacy 'ids must be an array of numbers' 400 is gone: // collectionDeleteManyRequestSchema types the array, so the pipe rejects a // bad payload before the handler runs. diff --git a/server/tests/unit/nest/collections.service.test.ts b/server/tests/unit/nest/collections.service.test.ts index c2ee793ed..48fd050bf 100644 --- a/server/tests/unit/nest/collections.service.test.ts +++ b/server/tests/unit/nest/collections.service.test.ts @@ -337,86 +337,6 @@ describe('status + updatePlace move', () => { expect((await svc.updatePlace(u.id, p.id, { name: 'Trattoria da Enzo' })).address).toBe('Via Vechia 1'); }); - - // #2471: price, currency, website and phone were stored on save and carried into - // a trip on copy, but the UPDATE set never named them, so none could be edited. - it('COLLECTIONS-SVC-128: updatePlace writes price, currency, website and phone', async () => { - const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Zurich' }); - const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Kunsthaus' }).place!; - - const updated = await svc.updatePlace(u.id, p.id, { - price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44 253 84 84', - }); - - expect(updated).toMatchObject({ price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44 253 84 84' }); - expect(testDb.prepare('SELECT price, currency, website, phone FROM collection_places WHERE id = ?').get(p.id)).toEqual({ - price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44 253 84 84', - }); - }); - - it('COLLECTIONS-SVC-129: null clears each of the four', async () => { - const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Zurich' }); - const p = svc.savePlace(u.id, { - collection_id: col.id, name: 'Kunsthaus', price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44', - }).place!; - - const cleared = await svc.updatePlace(u.id, p.id, { price: null, currency: null, website: null, phone: null }); - - expect(cleared).toMatchObject({ price: null, currency: null, website: null, phone: null }); - }); - - it('COLLECTIONS-SVC-130: an update that leaves them out keeps the stored values', async () => { - const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Zurich' }); - const p = svc.savePlace(u.id, { - collection_id: col.id, name: 'Kunsthaus', price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44', - }).place!; - - const renamed = await svc.updatePlace(u.id, p.id, { name: 'Kunsthaus Zürich' }); - - expect(renamed).toMatchObject({ name: 'Kunsthaus Zürich', price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44' }); - }); - - // A list has no base currency, so a bare amount would be read in every - // viewer's own default currency. The service refuses it for REST and MCP alike. - it('COLLECTIONS-SVC-132: a price without a currency on a place that has none is a 400 and changes nothing', async () => { - const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Zurich' }); - const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Kunsthaus' }).place!; - - await expect(svc.updatePlace(u.id, p.id, { price: 14 })).rejects.toMatchObject({ status: 400, message: expect.stringMatching(/currency/) }); - await expect(svc.updatePlace(u.id, p.id, { price: 14, currency: null })).rejects.toMatchObject({ status: 400 }); - - expect(testDb.prepare('SELECT price, currency FROM collection_places WHERE id = ?').get(p.id)).toEqual({ price: null, currency: null }); - }); - - it('COLLECTIONS-SVC-133: a price alone keeps the stored currency, a zero price needs none, and a priced place cannot lose its currency', async () => { - const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Zurich' }); - const priced = svc.savePlace(u.id, { collection_id: col.id, name: 'Kunsthaus', price: 10, currency: 'CHF' }).place!; - const free = svc.savePlace(u.id, { collection_id: col.id, name: 'Lake shore' }).place!; - - expect(await svc.updatePlace(u.id, priced.id, { price: 24.5 })).toMatchObject({ price: 24.5, currency: 'CHF' }); - expect(await svc.updatePlace(u.id, free.id, { price: 0 })).toMatchObject({ price: 0, currency: null }); - await expect(svc.updatePlace(u.id, priced.id, { currency: null })).rejects.toMatchObject({ status: 400 }); - expect(await svc.updatePlace(u.id, priced.id, { price: null, currency: null })).toMatchObject({ price: null, currency: null }); - }); - - it('COLLECTIONS-SVC-131: copyToTrip carries the edited price, currency, website and phone into the trip place', async () => { - const u = createUser(testDb).user; - const trip = createTrip(testDb, u.id); - const col = svc.createCollection(u.id, { name: 'Zurich' }); - const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Kunsthaus', price: 10, currency: 'EUR' }).place!; - await svc.updatePlace(u.id, p.id, { price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44' }); - - expect(svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p.id] }).copied).toBe(1); - - expect(testDb.prepare("SELECT price, currency, website, phone FROM places WHERE trip_id = ? AND name = 'Kunsthaus'").get(trip.id)).toEqual({ - price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44', - }); - }); }); // ── copy to trip ───────────────────────────────────────────────────────────── diff --git a/shared/src/collection/collection.schema.spec.ts b/shared/src/collection/collection.schema.spec.ts index 951684854..b247f8673 100644 --- a/shared/src/collection/collection.schema.spec.ts +++ b/shared/src/collection/collection.schema.spec.ts @@ -1,6 +1,5 @@ import { collectionDeleteManyRequestSchema, - collectionPlaceCurrencySchema, collectionPlaceUpdateRequestSchema, collectionReorderRequestSchema, collectionSavePlaceRequestSchema, @@ -48,65 +47,6 @@ describe('collectionPlaceUpdateRequestSchema', () => { }); }); -// #2471: price, currency, website and phone were columns the update contract did -// not know, so the validation pipe stripped them and a saved place's cost could -// never be edited. -describe('collectionPlaceUpdateRequestSchema price, currency, website and phone', () => { - it('passes all four through', () => { - const parsed = collectionPlaceUpdateRequestSchema.parse({ - price: 12.5, - currency: 'CHF', - website: 'https://museum.example', - phone: '+41 44 123 45 67', - }); - expect(parsed).toEqual({ - price: 12.5, - currency: 'CHF', - website: 'https://museum.example', - phone: '+41 44 123 45 67', - }); - }); - - it('accepts null on each to clear it, and zero as a price', () => { - const none = { price: null, currency: null, website: null, phone: null }; - expect(collectionPlaceUpdateRequestSchema.parse(none)).toEqual(none); - expect(collectionPlaceUpdateRequestSchema.parse({ price: 0 }).price).toBe(0); - }); - - it('leaves all four absent when an unrelated field changes', () => { - const parsed = collectionPlaceUpdateRequestSchema.parse({ name: 'Kunsthaus' }); - for (const key of ['price', 'currency', 'website', 'phone']) expect(key in parsed).toBe(false); - }); - - it('refuses a negative or non-finite price', () => { - for (const price of [-1, -0.01, Number.NaN, Number.POSITIVE_INFINITY]) { - expect(collectionPlaceUpdateRequestSchema.safeParse({ price }).success).toBe(false); - } - }); - - it('normalises the currency to a trimmed upper-case code', () => { - expect(collectionPlaceUpdateRequestSchema.parse({ currency: ' usd ' }).currency).toBe('USD'); - expect(collectionPlaceCurrencySchema.parse('jpy')).toBe('JPY'); - }); - - it('refuses a currency that is not three letters', () => { - for (const currency of ['EU', 'EURO', '€', '12A', '']) { - expect(collectionPlaceUpdateRequestSchema.safeParse({ currency }).success).toBe(false); - } - }); - - it('holds the website to http or https, like the save side', () => { - expect(collectionPlaceUpdateRequestSchema.safeParse({ website: 'javascript:alert(1)' }).success).toBe(false); - expect(collectionPlaceUpdateRequestSchema.safeParse({ website: 'http://museum.example' }).success).toBe(true); - }); - - it('trims the phone and caps it at 60 characters', () => { - expect(collectionPlaceUpdateRequestSchema.parse({ phone: ' +49 30 1234 ' }).phone).toBe('+49 30 1234'); - expect(collectionPlaceUpdateRequestSchema.safeParse({ phone: '1'.repeat(60) }).success).toBe(true); - expect(collectionPlaceUpdateRequestSchema.safeParse({ phone: '1'.repeat(61) }).success).toBe(false); - }); -}); - // The two ratchet schemas replace hand-rolled "array of numbers" checks — they // must stay exactly that permissive (empty arrays included, no int/positive). describe('collectionReorderRequestSchema / collectionDeleteManyRequestSchema', () => { diff --git a/shared/src/collection/collection.schema.ts b/shared/src/collection/collection.schema.ts index c243acc10..d5379d5fe 100644 --- a/shared/src/collection/collection.schema.ts +++ b/shared/src/collection/collection.schema.ts @@ -184,31 +184,10 @@ export const collectionSaveFromTripManyRequestSchema = z.object({ }); export type CollectionSaveFromTripManyRequest = z.infer; -/** - * A saved place's currency. A collection has no base currency to fall back on, - * so a price always names its own: three letters, trimmed and upper-cased so - * 'eur ' lands as 'EUR'. - */ -export const collectionPlaceCurrencySchema = z - .string() - .trim() - .regex(/^[A-Za-z]{3}$/, { message: 'must be a three-letter ISO 4217 code' }) - .toUpperCase(); - export const collectionPlaceUpdateRequestSchema = z.object({ name: z.string().min(1).optional(), description: z.string().nullable().optional(), notes: z.string().nullable().optional(), - // A rough cost such as an entry fee (#2471). The columns were always there and - // travelled along on save and on copy into a trip, but nothing could edit them. - // Stricter than the save side on purpose (no negative amount, a real currency - // code); the edit forms only resend a field the user changed, so an older row - // never blocks an unrelated edit. - price: z.number().nonnegative().nullable().optional(), - currency: collectionPlaceCurrencySchema.nullable().optional(), - website: placeWebsiteSchema.nullable().optional(), - // Capped like the file import's phone, which is where most of them come from. - phone: z.string().trim().max(60).nullable().optional(), // Editable coordinates so a place added by GPS can be corrected later (#1435). lat: z.number().nullable().optional(), lng: z.number().nullable().optional(), diff --git a/shared/src/i18n/ar/collection.ts b/shared/src/i18n/ar/collection.ts index 1fbc70670..4c21f5936 100644 --- a/shared/src/i18n/ar/collection.ts +++ b/shared/src/i18n/ar/collection.ts @@ -59,9 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'افتراضي', 'collections.sort.nameAsc': 'الاسم (أ–ي)', 'collections.coordinates': 'الإحداثيات', - 'collections.price': 'السعر', - 'collections.priceHint': 'تكلفة تقريبية، مثل رسوم الدخول. تنتقل مع المكان عند نسخه إلى رحلة.', - 'collections.phone': 'الهاتف', 'collections.showList': 'إظهار القائمة', 'collections.expandMap': 'توسيع الخريطة', 'collections.hero.mine': 'مجموعتي', diff --git a/shared/src/i18n/br/collection.ts b/shared/src/i18n/br/collection.ts index d0b440a61..813b8210b 100644 --- a/shared/src/i18n/br/collection.ts +++ b/shared/src/i18n/br/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Padrão', 'collections.sort.nameAsc': 'Nome (A–Z)', 'collections.coordinates': 'Coordenadas', - 'collections.price': 'Preço', - 'collections.priceHint': - 'Um custo aproximado, como uma entrada. Ele vai junto quando você copia o lugar para uma viagem.', - 'collections.phone': 'Telefone', 'collections.showList': 'Mostrar lista', 'collections.expandMap': 'Ampliar mapa', 'collections.hero.mine': 'Minha coleção', diff --git a/shared/src/i18n/ca/collection.ts b/shared/src/i18n/ca/collection.ts index 0140b2f52..66920ca11 100644 --- a/shared/src/i18n/ca/collection.ts +++ b/shared/src/i18n/ca/collection.ts @@ -59,9 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Predeterminat', 'collections.sort.nameAsc': 'Nom (A–Z)', 'collections.coordinates': 'Coordenades', - 'collections.price': 'Preu', - 'collections.priceHint': 'Un cost aproximat, com ara una entrada. Acompanya el lloc quan el copies a un viatge.', - 'collections.phone': 'Telèfon', 'collections.showList': 'Mostra la llista', 'collections.expandMap': 'Ampliar el mapa', 'collections.hero.mine': 'La meva col·lecció', diff --git a/shared/src/i18n/cs/collection.ts b/shared/src/i18n/cs/collection.ts index 041e80610..fe7dc06a6 100644 --- a/shared/src/i18n/cs/collection.ts +++ b/shared/src/i18n/cs/collection.ts @@ -59,9 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Výchozí', 'collections.sort.nameAsc': 'Název (A–Z)', 'collections.coordinates': 'Souřadnice', - 'collections.price': 'Cena', - 'collections.priceHint': 'Přibližná cena, například vstupné. Přenese se s místem při kopírování do výletu.', - 'collections.phone': 'Telefon', 'collections.showList': 'Zobrazit seznam', 'collections.expandMap': 'Zvětšit mapu', 'collections.hero.mine': 'Moje sbírka', diff --git a/shared/src/i18n/de/collection.ts b/shared/src/i18n/de/collection.ts index 8f0686b67..d8f796947 100644 --- a/shared/src/i18n/de/collection.ts +++ b/shared/src/i18n/de/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Standard', 'collections.sort.nameAsc': 'Name (A–Z)', 'collections.coordinates': 'Koordinaten', - 'collections.price': 'Preis', - 'collections.priceHint': - 'Ungefähre Kosten, etwa ein Eintritt. Sie kommen mit, wenn du den Ort in eine Reise kopierst.', - 'collections.phone': 'Telefon', 'collections.showList': 'Liste zeigen', 'collections.expandMap': 'Karte vergrößern', 'collections.hero.mine': 'Meine Sammlung', diff --git a/shared/src/i18n/en/collection.ts b/shared/src/i18n/en/collection.ts index ad0cfb826..04c7f5d70 100644 --- a/shared/src/i18n/en/collection.ts +++ b/shared/src/i18n/en/collection.ts @@ -59,9 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Default', 'collections.sort.nameAsc': 'Name (A–Z)', 'collections.coordinates': 'Coordinates', - 'collections.price': 'Price', - 'collections.priceHint': 'A rough cost, such as an entry fee. It goes along when you copy the place into a trip.', - 'collections.phone': 'Phone', 'collections.showList': 'Show list', 'collections.expandMap': 'Expand map', 'collections.hero.mine': 'My collection', diff --git a/shared/src/i18n/es/collection.ts b/shared/src/i18n/es/collection.ts index 9722ec54c..a52d4347b 100644 --- a/shared/src/i18n/es/collection.ts +++ b/shared/src/i18n/es/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Predeterminado', 'collections.sort.nameAsc': 'Nombre (A–Z)', 'collections.coordinates': 'Coordenadas', - 'collections.price': 'Precio', - 'collections.priceHint': - 'Un precio orientativo, como el de una entrada. Se incluye cuando copias el lugar a un viaje.', - 'collections.phone': 'Teléfono', 'collections.showList': 'Mostrar lista', 'collections.expandMap': 'Ampliar mapa', 'collections.hero.mine': 'Mi colección', diff --git a/shared/src/i18n/fr/collection.ts b/shared/src/i18n/fr/collection.ts index 95e48e578..7517b358f 100644 --- a/shared/src/i18n/fr/collection.ts +++ b/shared/src/i18n/fr/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Par défaut', 'collections.sort.nameAsc': 'Nom (A–Z)', 'collections.coordinates': 'Coordonnées', - 'collections.price': 'Prix', - 'collections.priceHint': - 'Un coût approximatif, comme un droit d’entrée. Il suit le lieu quand vous le copiez vers un voyage.', - 'collections.phone': 'Téléphone', 'collections.showList': 'Afficher la liste', 'collections.expandMap': 'Agrandir la carte', 'collections.hero.mine': 'Ma collection', diff --git a/shared/src/i18n/gr/collection.ts b/shared/src/i18n/gr/collection.ts index 52432a8f4..329cd9a90 100644 --- a/shared/src/i18n/gr/collection.ts +++ b/shared/src/i18n/gr/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Προεπιλογή', 'collections.sort.nameAsc': 'Όνομα (Α–Ω)', 'collections.coordinates': 'Συντεταγμένες', - 'collections.price': 'Τιμή', - 'collections.priceHint': - 'Ένα κατά προσέγγιση κόστος, όπως ένα εισιτήριο εισόδου. Μεταφέρεται μαζί με το μέρος κατά την αντιγραφή σε ταξίδι.', - 'collections.phone': 'Τηλέφωνο', 'collections.showList': 'Εμφάνιση λίστας', 'collections.expandMap': 'Μεγέθυνση χάρτη', 'collections.hero.mine': 'Η συλλογή μου', diff --git a/shared/src/i18n/hu/collection.ts b/shared/src/i18n/hu/collection.ts index 4b26f501e..0ce34a6cc 100644 --- a/shared/src/i18n/hu/collection.ts +++ b/shared/src/i18n/hu/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Alapértelmezett', 'collections.sort.nameAsc': 'Név (A–Z)', 'collections.coordinates': 'Koordináták', - 'collections.price': 'Ár', - 'collections.priceHint': - 'Hozzávetőleges költség, például belépődíj. A hellyel együtt átkerül, amikor utazásba másolod.', - 'collections.phone': 'Telefon', 'collections.showList': 'Lista megjelenítése', 'collections.expandMap': 'Térkép nagyítása', 'collections.hero.mine': 'Saját gyűjtemény', @@ -161,8 +157,7 @@ const collection: TranslationStrings = { 'collections.file.targetExisting': 'Hozzáadás listához', 'collections.file.targetExistingHint': 'Egy meglévő listához', 'collections.file.searchLists': 'Listák keresése', - 'collections.file.intoHint': - 'A listában már meglévő helyek változatlanok maradnak, ahogy a neve és a színe is. A fájl címkéi bekerülnek.', + 'collections.file.intoHint': 'A listában már meglévő helyek változatlanok maradnak, ahogy a neve és a színe is. A fájl címkéi bekerülnek.', 'collections.file.confirmInto': 'Hozzáadás a listához', 'collections.file.doneInto': '{count} hely hozzáadva ehhez: {name}', 'collections.file.doneIntoDuplicates': '{count} hozzáadva ehhez: {name}, {duplicates} már benne volt', diff --git a/shared/src/i18n/id/collection.ts b/shared/src/i18n/id/collection.ts index 9e6b32ae9..7015612c3 100644 --- a/shared/src/i18n/id/collection.ts +++ b/shared/src/i18n/id/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Bawaan', 'collections.sort.nameAsc': 'Nama (A–Z)', 'collections.coordinates': 'Koordinat', - 'collections.price': 'Harga', - 'collections.priceHint': - 'Perkiraan biaya, misalnya tiket masuk. Ikut tersalin saat tempat ini disalin ke perjalanan.', - 'collections.phone': 'Telepon', 'collections.showList': 'Tampilkan daftar', 'collections.expandMap': 'Perbesar peta', 'collections.hero.mine': 'Koleksi saya', diff --git a/shared/src/i18n/it/collection.ts b/shared/src/i18n/it/collection.ts index a6b126146..857297d2e 100644 --- a/shared/src/i18n/it/collection.ts +++ b/shared/src/i18n/it/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Predefinito', 'collections.sort.nameAsc': 'Nome (A–Z)', 'collections.coordinates': 'Coordinate', - 'collections.price': 'Prezzo', - 'collections.priceHint': - 'Un costo indicativo, come un biglietto d’ingresso. Viene copiato insieme al luogo quando lo copi in un viaggio.', - 'collections.phone': 'Telefono', 'collections.showList': 'Mostra elenco', 'collections.expandMap': 'Espandi mappa', 'collections.hero.mine': 'La mia raccolta', diff --git a/shared/src/i18n/ja/collection.ts b/shared/src/i18n/ja/collection.ts index 1a628b8bb..6811382b4 100644 --- a/shared/src/i18n/ja/collection.ts +++ b/shared/src/i18n/ja/collection.ts @@ -59,9 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'デフォルト', 'collections.sort.nameAsc': '名前順(A–Z)', 'collections.coordinates': '座標', - 'collections.price': '価格', - 'collections.priceHint': '入場料などのおおよその費用です。場所を旅行にコピーすると一緒にコピーされます。', - 'collections.phone': '電話', 'collections.showList': 'リストを表示', 'collections.expandMap': '地図を拡大', 'collections.hero.mine': 'マイコレクション', diff --git a/shared/src/i18n/ko/collection.ts b/shared/src/i18n/ko/collection.ts index 31da8c4f0..1db3109ae 100644 --- a/shared/src/i18n/ko/collection.ts +++ b/shared/src/i18n/ko/collection.ts @@ -59,9 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': '기본', 'collections.sort.nameAsc': '이름순 (A–Z)', 'collections.coordinates': '좌표', - 'collections.price': '가격', - 'collections.priceHint': '입장료 같은 대략적인 비용입니다. 장소를 여행에 복사하면 함께 복사됩니다.', - 'collections.phone': '전화', 'collections.showList': '목록 표시', 'collections.expandMap': '지도 확대', 'collections.hero.mine': '내 컬렉션', diff --git a/shared/src/i18n/nl/collection.ts b/shared/src/i18n/nl/collection.ts index ff4313fb4..55164bfa4 100644 --- a/shared/src/i18n/nl/collection.ts +++ b/shared/src/i18n/nl/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Standaard', 'collections.sort.nameAsc': 'Naam (A–Z)', 'collections.coordinates': 'Coördinaten', - 'collections.price': 'Prijs', - 'collections.priceHint': - 'Een globale prijs, zoals een entreeprijs. Gaat mee wanneer je de plek naar een reis kopieert.', - 'collections.phone': 'Telefoon', 'collections.showList': 'Lijst tonen', 'collections.expandMap': 'Kaart vergroten', 'collections.hero.mine': 'Mijn collectie', diff --git a/shared/src/i18n/pl/collection.ts b/shared/src/i18n/pl/collection.ts index 9ad7db27d..3d55b0e1e 100644 --- a/shared/src/i18n/pl/collection.ts +++ b/shared/src/i18n/pl/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Domyślnie', 'collections.sort.nameAsc': 'Nazwa (A–Z)', 'collections.coordinates': 'Współrzędne', - 'collections.price': 'Cena', - 'collections.priceHint': - 'Przybliżony koszt, na przykład bilet wstępu. Przechodzi razem z miejscem, gdy kopiujesz je do podróży.', - 'collections.phone': 'Telefon', 'collections.showList': 'Pokaż listę', 'collections.expandMap': 'Powiększ mapę', 'collections.hero.mine': 'Moja kolekcja', diff --git a/shared/src/i18n/ru/collection.ts b/shared/src/i18n/ru/collection.ts index 979c1eca2..a121b99a2 100644 --- a/shared/src/i18n/ru/collection.ts +++ b/shared/src/i18n/ru/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'По умолчанию', 'collections.sort.nameAsc': 'Название (А–Я)', 'collections.coordinates': 'Координаты', - 'collections.price': 'Цена', - 'collections.priceHint': - 'Примерная стоимость, например входной билет. Переносится вместе с местом при копировании в поездку.', - 'collections.phone': 'Телефон', 'collections.showList': 'Показать список', 'collections.expandMap': 'Развернуть карту', 'collections.hero.mine': 'Моя коллекция', diff --git a/shared/src/i18n/sv/collection.ts b/shared/src/i18n/sv/collection.ts index 3de29223f..6e2043a9d 100644 --- a/shared/src/i18n/sv/collection.ts +++ b/shared/src/i18n/sv/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Standard', 'collections.sort.nameAsc': 'Namn (A–Ö)', 'collections.coordinates': 'Koordinater', - 'collections.price': 'Pris', - 'collections.priceHint': - 'En ungefärlig kostnad, till exempel ett inträde. Den följer med när du kopierar platsen till en resa.', - 'collections.phone': 'Telefon', 'collections.showList': 'Visa lista', 'collections.expandMap': 'Förstora karta', 'collections.hero.mine': 'Min samling', diff --git a/shared/src/i18n/tr/collection.ts b/shared/src/i18n/tr/collection.ts index c86581a7f..3a2195e6f 100644 --- a/shared/src/i18n/tr/collection.ts +++ b/shared/src/i18n/tr/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Varsayılan', 'collections.sort.nameAsc': 'Ad (A–Z)', 'collections.coordinates': 'Koordinatlar', - 'collections.price': 'Fiyat', - 'collections.priceHint': - 'Giriş ücreti gibi yaklaşık bir maliyet. Yeri bir geziye kopyaladığınızda onunla birlikte taşınır.', - 'collections.phone': 'Telefon', 'collections.showList': 'Listeyi göster', 'collections.expandMap': 'Haritayı genişlet', 'collections.hero.mine': 'Koleksiyonum', diff --git a/shared/src/i18n/uk/collection.ts b/shared/src/i18n/uk/collection.ts index ac7d67862..4c2bf12cd 100644 --- a/shared/src/i18n/uk/collection.ts +++ b/shared/src/i18n/uk/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'За замовчуванням', 'collections.sort.nameAsc': 'Назва (А–Я)', 'collections.coordinates': 'Координати', - 'collections.price': 'Ціна', - 'collections.priceHint': - 'Приблизна вартість, наприклад вхідний квиток. Переноситься разом із місцем під час копіювання в подорож.', - 'collections.phone': 'Телефон', 'collections.showList': 'Показати список', 'collections.expandMap': 'Розгорнути карту', 'collections.hero.mine': 'Моя колекція', diff --git a/shared/src/i18n/vi/collection.ts b/shared/src/i18n/vi/collection.ts index 1e0c9be4f..f325d62ce 100644 --- a/shared/src/i18n/vi/collection.ts +++ b/shared/src/i18n/vi/collection.ts @@ -59,10 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': 'Mặc định', 'collections.sort.nameAsc': 'Tên (A–Z)', 'collections.coordinates': 'Tọa độ', - 'collections.price': 'Giá', - 'collections.priceHint': - 'Chi phí ước tính, chẳng hạn vé vào cửa. Được sao chép cùng địa điểm khi bạn sao chép nó vào chuyến đi.', - 'collections.phone': 'Điện thoại', 'collections.showList': 'Hiện danh sách', 'collections.expandMap': 'Mở rộng bản đồ', 'collections.hero.mine': 'Bộ sưu tập của tôi', diff --git a/shared/src/i18n/zh-TW/collection.ts b/shared/src/i18n/zh-TW/collection.ts index fd4ccf465..1701268f1 100644 --- a/shared/src/i18n/zh-TW/collection.ts +++ b/shared/src/i18n/zh-TW/collection.ts @@ -59,9 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': '預設', 'collections.sort.nameAsc': '名稱 (A–Z)', 'collections.coordinates': '座標', - 'collections.price': '價格', - 'collections.priceHint': '大致費用,例如門票。將地點複製到行程時會一併帶上。', - 'collections.phone': '電話', 'collections.showList': '顯示清單', 'collections.expandMap': '展開地圖', 'collections.hero.mine': '我的收藏', diff --git a/shared/src/i18n/zh/collection.ts b/shared/src/i18n/zh/collection.ts index aa518eb74..979d588a2 100644 --- a/shared/src/i18n/zh/collection.ts +++ b/shared/src/i18n/zh/collection.ts @@ -59,9 +59,6 @@ const collection: TranslationStrings = { 'collections.sort.default': '默认', 'collections.sort.nameAsc': '名称 (A–Z)', 'collections.coordinates': '坐标', - 'collections.price': '价格', - 'collections.priceHint': '大致费用,例如门票。将地点复制到行程时会一并带上。', - 'collections.phone': '电话', 'collections.showList': '显示列表', 'collections.expandMap': '展开地图', 'collections.hero.mine': '我的收藏', diff --git a/wiki/Collections.md b/wiki/Collections.md index c0354c6b8..7150f9f9c 100644 --- a/wiki/Collections.md +++ b/wiki/Collections.md @@ -56,7 +56,7 @@ Places can be assigned a **category** from the same admin-defined set used acros ## Place detail -Clicking a saved place opens a detail sheet showing a cover photo (fetched automatically when the place has none of its own), its category, its price, phone and website when it has them, its [labels](#custom-labels), a live status control, a markdown description, and links. Editing the place also lets you assign its labels and set a price in its own currency (a rough cost such as an entry fee), a website and a phone number. From there you can edit the place, **copy it into a trip**, or remove it from the list. +Clicking a saved place opens a detail sheet showing a cover photo (fetched automatically when the place has none of its own), its category, its [labels](#custom-labels), a live status control, a markdown description, and links. Editing the place also lets you assign its labels. From there you can edit the place, **copy it into a trip**, or remove it from the list. To set your own cover instead of the auto-fetched one, use the camera button on the detail sheet's cover to upload an image (JPG, PNG, GIF or WebP, up to 20 MB); the remove button next to it clears the custom image and restores the automatic default. diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index 2a9dbcc5b..42388be82 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -107,7 +107,7 @@ Requires `collections:read` or `collections:write` scope. | `reorder_collections` | Reorder the user's collections — pass every collection id in the desired order. | | `save_place_to_collection` | Save a place into a collection from a raw payload. Returns a duplicate marker instead of saving when a similar place already exists, unless `force` is true. | | `save_trip_places_to_collection` | Copy one or more existing trip places into a collection. Duplicates are skipped unless `force` is true. | -| `update_collection_place` | Update a saved place's name, address, coordinates, description, notes, price with its currency, website, phone, status, category, links, tags, labels, image, or move it to another collection. | +| `update_collection_place` | Update a saved place's name, address, coordinates, description, notes, status, category, links, tags, labels, image, or move it to another collection. | | `set_collection_place_status` | Set a saved place's status: `idea`, `want`, or `visited`. | | `set_collection_place_status_from_trip` | Set a status on every saved copy of the given trip places, in every list they are on, for marking places visited after a day out. Ids are trip place ids. Lists you may only read are skipped. Returns how many saved places changed and how many of the trip places were found in at least one list. | | `rate_collection_place` | Set or clear the current user's 1-5 star rating on a saved place. Every member rates independently; pass `null` to remove the vote. |