feat(collections): a saved place keeps its price, website and phone, editable like in a trip

A saved place always had price, currency, website and phone columns. Saving
from a trip, the file import and the copy into a trip all carried them, but
neither editor showed them and the update contract did not know them, so the
validation pipe stripped them from a PATCH and the MCP tool refused them as
unknown keys (#2471).

- shared: collectionPlaceUpdateRequestSchema takes price (not negative,
  nullable), currency (three letters, trimmed and upper-cased), website
  (http or https, as on save) and phone (trimmed, capped at 60 like the file
  import). The save schema is unchanged.
- server: updatePlace writes the four fields inside the existing
  transaction. update_collection_place spreads the same shape, so REST and
  MCP take and refuse the same values; its description now names the price
  and asks for the currency with it.
- client: the desktop detail sheet and the phone sheet edit price with a
  currency picker, website and phone, and show them in read mode (price only
  above zero, phone as a call link, website as a link chip through
  safeHttpUrl). Both shells share one hook, useCollectionPlaceExtras, which
  sends only the fields the user changed and always pairs a price with its
  currency, since a list has no base currency. An amount that does not parse
  marks the field and holds the save.
- i18n: collections.price, collections.priceHint and collections.phone in
  all 23 locales; the website label reuses places.formWebsite.
- wiki: Collections and MCP-Addon-Tools name the new fields.

Tests: schema spec, COLLECTIONS-SVC-128 to 131, controller pipe parity,
update_collection_place with the new fields, COLLECTIONS-E2E-085 to 087,
FE-COMP-COLEXTRAS-001 to 012, FE-COMP-COLDETAIL-049 to 054 and
FE-MOB-CPLSH-042 to 046.
This commit is contained in:
Maurice
2026-09-23 17:02:43 +02:00
parent e8ec3427e5
commit 0df82185bd
40 changed files with 840 additions and 24 deletions
@@ -1,4 +1,4 @@
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-048
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-054
import React from 'react';
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -688,3 +688,80 @@ 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();
});
});
@@ -3,11 +3,12 @@ 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 } from 'lucide-react'
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2, Navigation, Banknote, Globe, Phone } 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'
@@ -19,6 +20,7 @@ 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 }
@@ -35,7 +37,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 }) => Promise<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<void>
/** Upload a custom cover image (#1136); enables the cover change/remove controls. */
onUploadImage?: (file: File) => Promise<void>
onCopyToTrip: () => void
@@ -65,8 +67,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 / markdown description / links, saving via updatePlace. Status
* is an always-live segmented control (auto-saves).
* name / category / price / website / phone / 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,
@@ -86,6 +88,7 @@ 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.
@@ -156,6 +159,7 @@ 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)
@@ -163,7 +167,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 })
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() })
setEditing(false)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
@@ -228,9 +232,11 @@ export default function CollectionPlaceDetail({
<div className="col-detail-body">
{/* Meta (view only) */}
{!editing && place.address && (
{!editing && (place.address || extras.priceLabel || extras.phone) && (
<div className="col-detail-meta">
{place.address && <span className="col-detail-addr"><MapPin size={12} /> {place.address}</span>}
{extras.priceLabel && <span className="col-detail-price"><Banknote size={12} /> {extras.priceLabel}</span>}
{extras.phone && extras.phoneHref && <a href={extras.phoneHref} className="col-detail-addr col-detail-tel"><Phone size={12} /> {extras.phone}</a>}
{/* 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. */}
@@ -291,6 +297,25 @@ export default function CollectionPlaceDetail({
<NumericInput mode="signed" value={lng} onValueChange={setLng} placeholder={t('places.formLng')} className="col-detail-input flex-1" />
</div>
</div>
{/* Price (#2471): a rough cost such as an entry fee, always in its own currency */}
<div className="col-detail-field">
<div className="col-detail-label"><Banknote size={12} /> {t('collections.price')}</div>
<div className="col-detail-link-row">
<NumericInput {...extras.priceInput} placeholder="0" aria-label={t('collections.price')} className="col-detail-input flex-1" />
<div className="w-36 flex-none">
<CustomSelect {...extras.currencySelect} searchable size="sm" style={{ width: '100%' }} />
</div>
</div>
<div className="col-detail-hint">{t('collections.priceHint')}</div>
</div>
<div className="col-detail-field">
<div className="col-detail-label"><Globe size={12} /> {t('places.formWebsite')}</div>
<input type="url" value={extras.draft.website} onChange={e => extras.set('website', e.target.value)} aria-label={t('places.formWebsite')} className="col-detail-input" />
</div>
<div className="col-detail-field">
<div className="col-detail-label"><Phone size={12} /> {t('collections.phone')}</div>
<input type="tel" value={extras.draft.phone} onChange={e => extras.set('phone', e.target.value)} maxLength={60} aria-label={t('collections.phone')} className="col-detail-input" />
</div>
{/* Labels */}
{labels.length > 0 && (
<div className="col-detail-field">
@@ -335,9 +360,14 @@ export default function CollectionPlaceDetail({
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description}</Markdown>
</div>
)}
{place.links && place.links.length > 0 && (
{(extras.websiteHref || (place.links && place.links.length > 0)) && (
<div className="col-detail-links">
{place.links.map((l, i) => (
{extras.websiteHref && (
<a href={extras.websiteHref} target="_blank" rel="noopener noreferrer" className="col-detail-link">
<Globe size={13} /> {linkHost(extras.websiteHref)}
</a>
)}
{(place.links ?? []).map((l, i) => (
<a key={i} href={l.url} target="_blank" rel="noopener noreferrer" className="col-detail-link">
<ExternalLink size={13} /> {l.label || linkHost(l.url)}
</a>
@@ -352,11 +382,11 @@ export default function CollectionPlaceDetail({
{editing ? (
<>
<button type="button" onClick={resetForm} className="col-detail-btn">{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={saving} className="col-detail-btn primary"><Check size={14} /> {t('common.save')}</button>
<button type="button" onClick={save} disabled={saving || !extras.priceValid} className="col-detail-btn primary"><Check size={14} /> {t('common.save')}</button>
</>
) : (
<>
{canEdit && <button type="button" onClick={() => setEditing(true)} className="col-detail-btn"><Pencil size={14} /> {t('common.edit')}</button>}
{canEdit && <button type="button" onClick={startEdit} className="col-detail-btn"><Pencil size={14} /> {t('common.edit')}</button>}
<button type="button" onClick={onCopyToTrip} className="col-detail-btn"><Copy size={14} /> {t('collections.copyToTrip')}</button>
{/* 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
@@ -0,0 +1,134 @@
// FE-COMP-COLEXTRAS-001 to FE-COMP-COLEXTRAS-012: 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,
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('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: the user's own default names it.
const bare = renderHook(() => useCollectionPlaceExtras({ price: 8, currency: null }))
expect(bare.result.current.priceLabel).toBe(formatMoney(8, 'CHF', 'en-US'))
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')
})
})
@@ -0,0 +1,123 @@
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<CollectionPlace, 'price' | 'currency' | 'website' | 'phone'>
export type CollectionPlaceExtrasPatch = Pick<CollectionPlaceUpdateRequest, 'price' | 'currency' | 'website' | 'phone'>
/** 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
}
/** 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(<K extends keyof CollectionPlaceExtrasDraft>(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 ? formatMoney(price, place?.currency || defaultCurrency || 'EUR', locale) : null,
websiteHref: safeHttpUrl(place?.website),
phone,
phoneHref: phone ? phoneHref(phone) : null,
}
}
@@ -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 { Camera, Check, Copy, ExternalLink, Loader2, MapPin, Pencil, Trash2, X } from 'lucide-react'
import { Banknote, Camera, Check, Copy, ExternalLink, Globe, Loader2, MapPin, Pencil, Phone, 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,6 +12,9 @@ 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'
@@ -27,6 +30,9 @@ 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
@@ -36,7 +42,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 }) => Promise<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<void>
onUploadImage?: (file: File) => Promise<void>
onCopyToTrip: () => void
onRemove: () => void
@@ -69,6 +75,7 @@ export default function MCollPlaceSheet({
const [links, setLinks] = useState<CollectionLink[]>([])
const [labelIds, setLabelIds] = useState<number[]>([])
const [saving, setSaving] = useState(false)
const extras = useCollectionPlaceExtras(held)
const [fetchedPhoto, setFetchedPhoto] = useState<string | null>(null)
const heldId = held?.id
@@ -100,7 +107,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 })
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() })
setEditing(false)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
@@ -109,6 +116,11 @@ export default function MCollPlaceSheet({
}
}
const startEdit = () => {
extras.reset(held)
setEditing(true)
}
const cancelEdit = () => {
if (!held) return
setEditing(false)
@@ -212,6 +224,20 @@ export default function MCollPlaceSheet({
<MapPin size={13} strokeWidth={2} className="mt-[1px] flex-none" /> {held.address}
</div>
)}
{!editing && (extras.priceLabel || extras.phone) && (
<div className="mt-[6px] flex flex-wrap items-center gap-x-3 gap-y-1 font-geist text-[0.75rem] leading-[1.5] text-m-muted">
{extras.priceLabel && (
<span className="inline-flex items-center gap-[5px] font-semibold text-m-ink [font-variant-numeric:tabular-nums]">
<Banknote size={13} strokeWidth={2} className="flex-none" /> {extras.priceLabel}
</span>
)}
{extras.phone && extras.phoneHref && (
<a href={extras.phoneHref} className="inline-flex items-center gap-[5px]">
<Phone size={13} strokeWidth={2} className="flex-none" /> {extras.phone}
</a>
)}
</div>
)}
{/* Status cycle */}
<div className="mt-3 flex gap-[6px]">
@@ -251,6 +277,26 @@ export default function MCollPlaceSheet({
{/* Address (#1870): free text, same as the add sheet offers */}
<Eyebrow className="mb-[6px] mt-[14px]">{t('places.formAddress').toUpperCase()}</Eyebrow>
<input value={address} onChange={e => 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 */}
<Eyebrow className="mb-[6px] mt-[14px]">{t('collections.price').toUpperCase()}</Eyebrow>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<NumericInput
{...extras.priceInput}
placeholder="0"
aria-label={t('collections.price')}
className={`${INPUT_CLS} aria-[invalid=true]:border-[color:var(--m-st-danger)]`}
/>
</div>
<div className="w-32 flex-none">
<CustomSelect {...extras.currencySelect} searchable size="sm" style={{ width: '100%' }} />
</div>
</div>
<div className="mt-[6px] font-geist text-[0.6875rem] leading-[1.45] text-m-muted">{t('collections.priceHint')}</div>
<Eyebrow className="mb-[6px] mt-[14px]">{t('places.formWebsite').toUpperCase()}</Eyebrow>
<input type="url" value={extras.draft.website} onChange={e => extras.set('website', e.target.value)} aria-label={t('places.formWebsite')} className={INPUT_CLS} />
<Eyebrow className="mb-[6px] mt-[14px]">{t('collections.phone').toUpperCase()}</Eyebrow>
<input type="tel" value={extras.draft.phone} onChange={e => extras.set('phone', e.target.value)} maxLength={60} aria-label={t('collections.phone')} className={INPUT_CLS} />
<Eyebrow className="mb-[6px] mt-[14px]">{t('collections.category').toUpperCase()}</Eyebrow>
<MCollCategoryPicker categories={categories} value={categoryId} onChange={setCategoryId} t={t} />
{labels.length > 0 && (
@@ -285,7 +331,7 @@ export default function MCollPlaceSheet({
<MCollLinksEditor links={links} onChange={setLinks} t={t} />
<div className="mt-4 flex items-center gap-2">
<CancelPill className="ml-auto" onClick={cancelEdit}>{t('common.cancel')}</CancelPill>
<PrimaryPill onClick={save} disabled={saving}>
<PrimaryPill onClick={save} disabled={saving || !extras.priceValid}>
<Check size={14} strokeWidth={2.4} /> {t('common.save')}
</PrimaryPill>
</div>
@@ -314,15 +360,20 @@ export default function MCollPlaceSheet({
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{held.description}</Markdown>
</div>
)}
{held.links && held.links.length > 0 && (
{(extras.websiteHref || (held.links && held.links.length > 0)) && (
<div className="mt-3 flex flex-wrap gap-[6px]">
{held.links.map((l, i) => (
{extras.websiteHref && (
<a href={extras.websiteHref} target="_blank" rel="noopener noreferrer" className={LINK_CHIP}>
<Globe size={13} strokeWidth={2} className="text-m-muted" /> {linkHost(extras.websiteHref)}
</a>
)}
{(held.links ?? []).map((l, i) => (
<a
key={i}
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="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"
className={LINK_CHIP}
>
<ExternalLink size={13} strokeWidth={2} className="text-m-muted" /> {l.label || linkHost(l.url)}
</a>
@@ -331,7 +382,7 @@ export default function MCollPlaceSheet({
)}
<div className="mt-[14px] flex gap-2">
{canEdit && (
<button type="button" onClick={() => setEditing(true)} className={actionBtn}>
<button type="button" onClick={startEdit} className={actionBtn}>
<Pencil size={13} strokeWidth={2.2} /> {t('common.edit')}
</button>
)}
+6
View File
@@ -449,6 +449,10 @@
.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. */
@@ -483,6 +487,8 @@
.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; }
@@ -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-041
// FE-MOB-CPLSH-001 to FE-MOB-CPLSH-046
// react-markdown ships ESM-only chunks jsdom chokes on; the sheet only needs
// the raw description text to reach the renderer.
@@ -478,4 +478,69 @@ 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()
})
})
@@ -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, 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, 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).',
inputSchema: { placeId: z.number().int().positive(), ...collectionPlaceUpdateRequestSchema.shape, links: linksInput },
annotations: TOOL_ANNOTATIONS_WRITE,
when: collectionsAddonOn,
@@ -1010,6 +1010,10 @@ 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); }
+44
View File
@@ -183,6 +183,50 @@ 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-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;
@@ -380,6 +380,45 @@ 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' });
});
});
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<string, unknown> }).properties)).toEqual(
expect.arrayContaining(['price', 'currency', 'website', 'phone']),
);
} finally {
await h.cleanup();
}
});
});
// ---------------------------------------------------------------------------
@@ -6,7 +6,9 @@ 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 type { CollectionGpxProblem } from '@trek/shared';
import { collectionPlaceUpdateRequestSchema, type CollectionGpxProblem } from '@trek/shared';
import { CollectionPlaceUpdateDto } from '../../../src/nest/collections/collections.dto';
import { ZodValidationPipe } from '../../../src/nest/common/zod-validation.pipe';
const storageStub = { put: vi.fn().mockResolvedValue(undefined) } as unknown as StorageService;
@@ -110,6 +112,27 @@ 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.
@@ -337,6 +337,61 @@ 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' });
});
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 ─────────────────────────────────────────────────────────────
@@ -1,5 +1,6 @@
import {
collectionDeleteManyRequestSchema,
collectionPlaceCurrencySchema,
collectionPlaceUpdateRequestSchema,
collectionReorderRequestSchema,
collectionSavePlaceRequestSchema,
@@ -47,6 +48,65 @@ 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', () => {
@@ -184,10 +184,31 @@ export const collectionSaveFromTripManyRequestSchema = z.object({
});
export type CollectionSaveFromTripManyRequest = z.infer<typeof collectionSaveFromTripManyRequestSchema>;
/**
* 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(),
+3
View File
@@ -59,6 +59,9 @@ const collection: TranslationStrings = {
'collections.sort.default': 'افتراضي',
'collections.sort.nameAsc': 'الاسم (أ–ي)',
'collections.coordinates': 'الإحداثيات',
'collections.price': 'السعر',
'collections.priceHint': 'تكلفة تقريبية، مثل رسوم الدخول. تنتقل مع المكان عند نسخه إلى رحلة.',
'collections.phone': 'الهاتف',
'collections.showList': 'إظهار القائمة',
'collections.expandMap': 'توسيع الخريطة',
'collections.hero.mine': 'مجموعتي',
+4
View File
@@ -59,6 +59,10 @@ 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',
+3
View File
@@ -59,6 +59,9 @@ 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ó',
+3
View File
@@ -59,6 +59,9 @@ 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',
+4
View File
@@ -59,6 +59,10 @@ 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',
+3
View File
@@ -59,6 +59,9 @@ 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',
+4
View File
@@ -59,6 +59,10 @@ 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',
+4
View File
@@ -59,6 +59,10 @@ 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',
+4
View File
@@ -59,6 +59,10 @@ const collection: TranslationStrings = {
'collections.sort.default': 'Προεπιλογή',
'collections.sort.nameAsc': 'Όνομα (Α–Ω)',
'collections.coordinates': 'Συντεταγμένες',
'collections.price': 'Τιμή',
'collections.priceHint':
'Ένα κατά προσέγγιση κόστος, όπως ένα εισιτήριο εισόδου. Μεταφέρεται μαζί με το μέρος κατά την αντιγραφή σε ταξίδι.',
'collections.phone': 'Τηλέφωνο',
'collections.showList': 'Εμφάνιση λίστας',
'collections.expandMap': 'Μεγέθυνση χάρτη',
'collections.hero.mine': 'Η συλλογή μου',
+4
View File
@@ -59,6 +59,10 @@ 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ásolja.',
'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',
+4
View File
@@ -59,6 +59,10 @@ 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',
+4
View File
@@ -59,6 +59,10 @@ 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',
+3
View File
@@ -59,6 +59,9 @@ 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': 'マイコレクション',
+3
View File
@@ -59,6 +59,9 @@ 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': '내 컬렉션',
+4
View File
@@ -59,6 +59,10 @@ 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',
+4
View File
@@ -59,6 +59,10 @@ 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',
+4
View File
@@ -59,6 +59,10 @@ const collection: TranslationStrings = {
'collections.sort.default': 'По умолчанию',
'collections.sort.nameAsc': 'Название (А–Я)',
'collections.coordinates': 'Координаты',
'collections.price': 'Цена',
'collections.priceHint':
'Примерная стоимость, например входной билет. Переносится вместе с местом при копировании в поездку.',
'collections.phone': 'Телефон',
'collections.showList': 'Показать список',
'collections.expandMap': 'Развернуть карту',
'collections.hero.mine': 'Моя коллекция',
+4
View File
@@ -59,6 +59,10 @@ 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',
+4
View File
@@ -59,6 +59,10 @@ 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',
+4
View File
@@ -59,6 +59,10 @@ const collection: TranslationStrings = {
'collections.sort.default': 'За замовчуванням',
'collections.sort.nameAsc': 'Назва (А–Я)',
'collections.coordinates': 'Координати',
'collections.price': 'Ціна',
'collections.priceHint':
'Приблизна вартість, наприклад вхідний квиток. Переноситься разом із місцем під час копіювання в подорож.',
'collections.phone': 'Телефон',
'collections.showList': 'Показати список',
'collections.expandMap': 'Розгорнути карту',
'collections.hero.mine': 'Моя колекція',
+4
View File
@@ -59,6 +59,10 @@ 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',
+3
View File
@@ -59,6 +59,9 @@ 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': '我的收藏',
+3
View File
@@ -59,6 +59,9 @@ 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': '我的收藏',
+1 -1
View File
@@ -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 [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.
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.
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.
+1 -1
View File
@@ -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, 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, price with its currency, website, phone, 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. |