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()
})
})