mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitCents, payerSum, payersBalanced, rebalancePayers } from './CostsPanel.helpers'
|
||||
import { hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, writeTicketItems } from './CostsPanel.helpers'
|
||||
|
||||
describe('splitCents', () => {
|
||||
it('splits evenly when it divides cleanly', () => {
|
||||
@@ -76,3 +76,59 @@ describe('rebalancePayers', () => {
|
||||
expect(payersBalanced(next, new Set([1, 2, 3]), 100)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Notes vs. itemized receipts (#1658) ──────────────────────────────────────
|
||||
|
||||
describe('readTicketItems', () => {
|
||||
const receipt = '{"items":[{"name":"Bread","price":"3.50","parts":[1,2]},{"name":"Wine","price":"12","parts":[2]}]}'
|
||||
|
||||
it('reads the receipt out of its own column', () => {
|
||||
const items = readTicketItems({ ticket_json: receipt })
|
||||
expect(items.map(i => i.name)).toEqual(['Bread', 'Wine'])
|
||||
expect(items[0].participants).toEqual(new Set([1, 2]))
|
||||
expect(items[1].price).toBe('12')
|
||||
})
|
||||
|
||||
it('still reads a response cached before the column existed', () => {
|
||||
const items = readTicketItems({ note: 'TICKETJSON:' + receipt })
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[1].participants).toEqual(new Set([2]))
|
||||
})
|
||||
|
||||
it('prefers the column over the legacy note when both are present', () => {
|
||||
const items = readTicketItems({ ticket_json: '{"items":[{"name":"Only","price":"1","parts":[]}]}', note: 'TICKETJSON:' + receipt })
|
||||
expect(items.map(i => i.name)).toEqual(['Only'])
|
||||
})
|
||||
|
||||
it('is empty for an expense with neither, and for malformed JSON', () => {
|
||||
expect(readTicketItems({ note: 'just a note' })).toEqual([])
|
||||
expect(readTicketItems(null)).toEqual([])
|
||||
expect(readTicketItems({ ticket_json: '{oops' })).toEqual([])
|
||||
})
|
||||
|
||||
it('round-trips through writeTicketItems', () => {
|
||||
const items = readTicketItems({ ticket_json: receipt })
|
||||
expect(readTicketItems({ ticket_json: writeTicketItems(items) })).toEqual(items)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasTicketSplit', () => {
|
||||
it('recognises both storage shapes and nothing else', () => {
|
||||
expect(hasTicketSplit({ ticket_json: '{"items":[]}' })).toBe(true)
|
||||
expect(hasTicketSplit({ note: 'TICKETJSON:{"items":[]}' })).toBe(true)
|
||||
expect(hasTicketSplit({ note: 'dinner with Ben' })).toBe(false)
|
||||
expect(hasTicketSplit(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readUserNote', () => {
|
||||
it('returns what the user typed', () => {
|
||||
expect(readUserNote({ note: 'Lisa pays half back' })).toBe('Lisa pays half back')
|
||||
})
|
||||
|
||||
it('never returns a receipt blob as if it were a note', () => {
|
||||
expect(readUserNote({ note: 'TICKETJSON:{"items":[]}' })).toBe('')
|
||||
expect(readUserNote({ note: null })).toBe('')
|
||||
expect(readUserNote(null)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,6 +88,53 @@ export interface TicketItem {
|
||||
participants: Set<number>
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the itemized receipt off an expense (#1658).
|
||||
*
|
||||
* It lives in `ticket_json` since migration 186. Before that it was smuggled
|
||||
* through `note` behind a `TICKETJSON:` prefix, which is why an expense split by
|
||||
* receipt could never carry a written note. The old shape is still read here so
|
||||
* a response cached before the migration keeps rendering its split.
|
||||
*/
|
||||
export function readTicketItems(item: { ticket_json?: string | null; note?: string | null } | null | undefined): TicketItem[] {
|
||||
const raw = item?.ticket_json ?? (item?.note?.startsWith('TICKETJSON:') ? item.note.slice(11) : null)
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return (parsed.items || []).map((line: { name?: string; price?: unknown; parts?: number[] }, i: number) => ({
|
||||
id: `${i}`,
|
||||
name: String(line.name ?? ''),
|
||||
price: String(line.price ?? ''),
|
||||
participants: new Set(line.parts || []),
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** True when this expense is split line by line rather than equally or by amount. */
|
||||
export function hasTicketSplit(item: { ticket_json?: string | null; note?: string | null } | null | undefined): boolean {
|
||||
return Boolean(item?.ticket_json || item?.note?.startsWith('TICKETJSON:'))
|
||||
}
|
||||
|
||||
/** Serialize the receipt lines for storage in `ticket_json`. */
|
||||
export function writeTicketItems(items: TicketItem[]): string {
|
||||
return JSON.stringify({ items: items.map(i => ({ name: i.name, price: i.price, parts: [...i.participants] })) })
|
||||
}
|
||||
|
||||
/** Long enough for a receipt discrepancy or a reimbursement reminder, short
|
||||
* enough that the row stays a row. */
|
||||
export const NOTE_MAX = 500
|
||||
|
||||
/**
|
||||
* The note a user actually typed, which is never the receipt blob. Only matters
|
||||
* for data written before migration 186 moved the receipt out of the field.
|
||||
*/
|
||||
export function readUserNote(item: { note?: string | null } | null | undefined): string {
|
||||
const note = item?.note
|
||||
return note && !note.startsWith('TICKETJSON:') ? note : ''
|
||||
}
|
||||
|
||||
/** Per-person totals for a receipt split line by line, plus the receipt total. */
|
||||
export function calculateTicketShares(items: TicketItem[]): { shares: Record<number, number>; total: number } {
|
||||
const shares: Record<number, number> = {}
|
||||
|
||||
@@ -171,6 +171,79 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Notes on an expense (#1658) ────────────────────────────────────────────
|
||||
|
||||
it('shows a note on the row and expands it on click', async () => {
|
||||
const long = 'Bought the whole week of breakfasts here, Ben pays back his half once the card statement lands'
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Supermarket' }),
|
||||
total_price: 60,
|
||||
note: long,
|
||||
payers: [{ user_id: 1, amount: 60, username: 'alice' }],
|
||||
}
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
// Re-queried after each click: the row is re-created on every render of the
|
||||
// panel, so a handle taken before the click points at a detached node.
|
||||
const noteToggle = () => screen.getByRole('button', { name: new RegExp(long.slice(0, 20)) })
|
||||
await screen.findByText('Supermarket')
|
||||
expect(noteToggle()).toHaveAttribute('aria-expanded', 'false')
|
||||
await user.click(noteToggle())
|
||||
expect(noteToggle()).toHaveAttribute('aria-expanded', 'true')
|
||||
await user.click(noteToggle())
|
||||
expect(noteToggle()).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('never renders a receipt blob as a note', async () => {
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Market' }),
|
||||
total_price: 20,
|
||||
// How the receipt was stored before it got its own column.
|
||||
note: 'TICKETJSON:{"items":[{"name":"Cheese","price":"20","parts":[1]}]}',
|
||||
payers: [{ user_id: 1, amount: 20, username: 'alice' }],
|
||||
}
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Market')
|
||||
expect(screen.queryByText(/TICKETJSON/)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an existing note when the expense is saved again', async () => {
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Lunch' }),
|
||||
total_price: 30,
|
||||
note: 'split with the neighbours',
|
||||
payers: [{ user_id: 1, amount: 30, username: 'alice' }],
|
||||
}
|
||||
let patched: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.put('/api/trips/1/budget/:id', async ({ request }) => {
|
||||
patched = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...item, ...patched } })
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Lunch')
|
||||
await user.click(screen.getAllByTitle('Edit')[0])
|
||||
await user.click(await screen.findByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(patched).toBeTruthy())
|
||||
expect(patched!.note).toBe('split with the neighbours')
|
||||
})
|
||||
|
||||
it('shows the net hint on a settled expense row and hides it while unfinished', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
@@ -534,7 +607,9 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect.objectContaining({ user_id: 1, amount: 75 }),
|
||||
expect.objectContaining({ user_id: 2, amount: 25 }),
|
||||
]))
|
||||
expect(posted!.note).toContain('TICKETJSON:')
|
||||
// The receipt has its own column since #1658; `note` is the user's text.
|
||||
expect(JSON.parse(posted!.ticket_json as string).items).toHaveLength(3)
|
||||
expect(posted!.note).toBeNull()
|
||||
})
|
||||
|
||||
// ── Display currency ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -16,7 +16,7 @@ import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { calculateTicketShares, payersBalanced, rebalancePayers, splitEqualShares, type TicketItem } from './CostsPanel.helpers'
|
||||
import { calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
@@ -80,6 +80,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||
// One note open at a time: two expanded rows next to each other read as a mess,
|
||||
// and the point of the collapse is that the list stays scannable.
|
||||
const [expandedNoteId, setExpandedNoteId] = useState<number | null>(null)
|
||||
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
|
||||
const [addingPayment, setAddingPayment] = useState(false)
|
||||
|
||||
@@ -246,8 +249,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
|
||||
for (const e of items) {
|
||||
const cur = curOf(e)
|
||||
// Ticket notes carry the itemized-receipt JSON, not a human note.
|
||||
const note = e.note && !e.note.startsWith('TICKETJSON:') ? e.note : ''
|
||||
const note = readUserNote(e)
|
||||
rows.push([
|
||||
esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)),
|
||||
(e.total_price || 0).toFixed(currencyDecimals(cur)), cur,
|
||||
@@ -502,6 +504,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
.costs-root .text-content-muted { color: var(--c-ink2) !important; }
|
||||
.costs-root .text-content-faint { color: var(--c-ink3) !important; }
|
||||
.costs-root .exp-actions { opacity: 1; }
|
||||
.costs-root .exp-actions button { background: none; }
|
||||
.costs-root .exp-actions button:hover { background: var(--bg-secondary); color: var(--c-ink); }
|
||||
/* Destructive actions keep their own tint: the neutral one would read as
|
||||
"safe" on the button that deletes the expense. */
|
||||
.costs-root .exp-actions .exp-action-danger:hover { background: rgba(220,38,38,0.14); color: #dc2626; }
|
||||
@media (max-width: 1100px) {
|
||||
.costs-root .costs-summary { grid-template-columns: 1fr 1fr !important; }
|
||||
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
|
||||
@@ -653,6 +660,28 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}
|
||||
|
||||
// ── inline subcomponents (close over helpers) ────────────────────────────
|
||||
/**
|
||||
* Edit and delete, in a capsule beside the row rather than inside it — the
|
||||
* mobile card has always done it this way, and out here the buttons stop
|
||||
* competing with the amount for the right edge of the card.
|
||||
*/
|
||||
function RowActions({ onEdit, onDelete, deleteLabel }: { onEdit: () => void; onDelete: () => void; deleteLabel: string }) {
|
||||
// No `background` here on purpose: an inline value would beat the :hover
|
||||
// rule in the stylesheet block below, which is how the first attempt at
|
||||
// this silently did nothing.
|
||||
const btn: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 999, border: 0,
|
||||
cursor: 'pointer', padding: 0,
|
||||
}
|
||||
return (
|
||||
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
|
||||
<button title={t('common.edit')} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
|
||||
<button title={deleteLabel} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExpenseRow({ e }: { e: BudgetItem }) {
|
||||
const c = catMeta(e.category)
|
||||
const Icon = c.Icon
|
||||
@@ -660,17 +689,38 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||
const unfinished = isUnfinished(e)
|
||||
const note = readUserNote(e)
|
||||
const open = expandedNoteId === e.id
|
||||
|
||||
// Fixed columns, identical on every row, so the eye can run down the list
|
||||
// instead of re-finding each field on each line. A row without a note leaves
|
||||
// its column empty rather than letting everything after it drift left, which
|
||||
// is what made the list read as unstructured.
|
||||
const cols = isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto'
|
||||
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ position: 'relative', flex: 1, minWidth: 0, display: 'grid', gridTemplateColumns: cols, gap: 18, alignItems: 'center', borderRadius: 18, padding: isMobile ? '16px 20px' : '20px 20px 16px' }}>
|
||||
{/* The category rides the card edge as a tab, the way the mobile card
|
||||
does, so it stops taking up a slot inside the row itself. */}
|
||||
{!isMobile && (
|
||||
<span aria-hidden="true" style={{ position: 'absolute', left: -1, top: -1, display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 11px 4px 10px', borderRadius: '17px 0 12px 0', background: c.color, color: '#fff', fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', fontWeight: 800, letterSpacing: '0.05em', textTransform: 'uppercase', lineHeight: 1.2 }}>
|
||||
<Icon size={10} strokeWidth={2.4} />
|
||||
{t(c.labelKey)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
|
||||
<Icon size={21} />
|
||||
{isMobile && unfinished && (
|
||||
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* What it was. */}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{e.name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.name}</span>
|
||||
{unfinished && !isMobile && (
|
||||
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 800 }}>!</span>
|
||||
@@ -678,8 +728,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cur !== base && (
|
||||
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{fmt(e.total_price, cur)} {'→'} {fmt(baseTotal(e))}
|
||||
</div>
|
||||
)}
|
||||
{/* Under the name, because a chip reading "Y 122,00" says nothing on
|
||||
its own — the name above it is what gives it a meaning. */}
|
||||
{payers.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginTop: 6 }}>
|
||||
{payers.map(p => (
|
||||
<span key={p.user_id} className="bg-surface-secondary border border-edge" title={personName(p.user_id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>
|
||||
<Avatar id={p.user_id} size={18} />
|
||||
@@ -688,29 +745,38 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)} → ${fmt(baseTotal(e))}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The note, marked by a rule in the category colour instead of a box, so
|
||||
it reads as part of the row rather than something dropped onto it. */}
|
||||
{!isMobile && (
|
||||
note ? (
|
||||
<button type="button" onClick={() => setExpandedNoteId(open ? null : e.id)}
|
||||
aria-expanded={open} title={open ? t('costs.hideNote') : t('costs.showNote')}
|
||||
className="bg-surface-secondary border border-edge text-content-muted hover:text-content hover:border-content-faint transition-colors exp-note-btn"
|
||||
style={{ display: 'flex', alignItems: open ? 'flex-start' : 'center', gap: 9, minWidth: 0, width: '100%', padding: open ? '10px 14px 11px 13px' : '7px 12px 7px 11px', borderRadius: open ? 14 : 999, borderLeft: '3px solid ' + c.color, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', lineHeight: 1.5, textAlign: 'left' }}>
|
||||
<span style={open
|
||||
? { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', minWidth: 0, flex: 1 }
|
||||
: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0, flex: 1 }}>{note}</span>
|
||||
<ChevronDown size={13} style={{ flexShrink: 0, marginTop: open ? 3 : 0, transition: 'transform .18s ease', transform: open ? 'rotate(180deg)' : 'none' }} />
|
||||
</button>
|
||||
) : <span />
|
||||
)}
|
||||
|
||||
{/* The money, and what to do with it. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4, whiteSpace: 'nowrap' }}>
|
||||
<span className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', padding: isMobile ? '0' : '6px 13px', borderRadius: 999, border: isMobile ? 0 : undefined, background: isMobile ? 'none' : undefined, fontSize: isMobile ? 'calc(18px * var(--fs-scale-subtitle, 1))' : 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>{fmt(baseTotal(e))}</span>
|
||||
{!unfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', padding: isMobile ? 0 : '2px 9px', borderRadius: 999, background: isMobile ? 'none' : (net > 0 ? 'rgba(22,163,74,0.13)' : 'rgba(220,38,38,0.13)'), fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
||||
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
||||
<button title={t('common.edit')} onClick={() => { setEditing(e); setModalOpen(true) }} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
<button title={t('common.delete')} onClick={() => handleDelete(e.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && <RowActions onEdit={() => { setEditing(e); setModalOpen(true) }} onDelete={() => handleDelete(e.id)} deleteLabel={t('common.delete')} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -720,7 +786,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// Legacy transfers carry no currency and were entered in the display base.
|
||||
const cur = (s.currency || base).toUpperCase()
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ flex: 1, minWidth: 0, display: 'grid', gridTemplateColumns: isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto', gap: isMobile ? 16 : 18, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>
|
||||
@@ -732,16 +799,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} → {personName(s.to_user_id)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* A payment carries no note, but it keeps the column so its amount lands
|
||||
on the same axis as every expense above it. */}
|
||||
{!isMobile && <span />}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(convert(s.amount, cur))}</div>
|
||||
{canEdit && (
|
||||
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
||||
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><RotateCcw size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
<span className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', padding: '6px 13px', borderRadius: 999, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{fmt(convert(s.amount, cur))}</span>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
|
||||
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="text-content-muted transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0 }}><Pencil size={13} /></button>
|
||||
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="exp-action-danger transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0, color: '#dc2626' }}><RotateCcw size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -936,6 +1007,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const { addBudgetItem, updateBudgetItem } = useTripStore()
|
||||
const { convert } = useExchangeRates(base)
|
||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||
@@ -944,6 +1016,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||
const [note, setNote] = useState(() => readUserNote(editing))
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? String(editing.total_price) : ''
|
||||
if (prefill?.amount != null) return String(prefill.amount)
|
||||
@@ -977,7 +1050,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const [pinnedPayers, setPinnedPayers] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
|
||||
|
||||
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
|
||||
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
|
||||
if (hasTicketSplit(editing)) {
|
||||
return 'ticket'
|
||||
}
|
||||
if (editing && editing.members && editing.members.length > 0) {
|
||||
@@ -987,22 +1060,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
return 'equally'
|
||||
})
|
||||
|
||||
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => {
|
||||
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
|
||||
try {
|
||||
const parsed = JSON.parse(editing.note.slice(11))
|
||||
return (parsed.items || []).map((item: any) => ({
|
||||
id: String(Math.random()),
|
||||
name: item.name,
|
||||
price: String(item.price),
|
||||
participants: new Set(item.parts || [])
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => readTicketItems(editing))
|
||||
|
||||
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
@@ -1191,13 +1249,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
member_ids: [...participants],
|
||||
expense_date: day || null,
|
||||
total_price: totalNum,
|
||||
note: splitMode === 'ticket' ? 'TICKETJSON:' + JSON.stringify({
|
||||
items: ticketItems.map(item => ({
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
parts: [...item.participants]
|
||||
}))
|
||||
}) : null,
|
||||
note: note.trim() || null,
|
||||
ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null,
|
||||
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||
}
|
||||
try {
|
||||
@@ -1212,17 +1265,25 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
}
|
||||
|
||||
const inputCls = 'w-full bg-surface-input border border-edge text-content'
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
const labelCls = 'block text-caption font-semibold uppercase tracking-[0.14em] text-content-faint mb-2'
|
||||
// Borrowed from the trip dialog (TripFormModal): related fields sit together in
|
||||
// a panel instead of running as one undifferentiated column of inputs.
|
||||
const panelCls = 'rounded-2xl border border-edge bg-surface-secondary p-4'
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size="2xl"
|
||||
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size={isMobile ? '2xl' : '5xl'}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
|
||||
</div>
|
||||
}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{/* Two columns on desktop: what the expense was on the left, how it is
|
||||
shared on the right. The split follows the two questions the dialog
|
||||
actually asks, so neither side is a leftovers pile. */}
|
||||
<div style={{ display: isMobile ? 'flex' : 'grid', flexDirection: 'column', gridTemplateColumns: '1fr 1fr', alignItems: 'start', gap: isMobile ? 14 : 28 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}>
|
||||
<div className={panelCls} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.whatFor')}</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', outline: 'none' }} />
|
||||
@@ -1260,7 +1321,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
</div>
|
||||
|
||||
<div className={panelCls}>
|
||||
<label className={labelCls}>{t('costs.category')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
||||
{COST_CATEGORY_LIST.map(c => {
|
||||
@@ -1277,8 +1340,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}>
|
||||
<div className={panelCls}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.whoPaid')}</label>
|
||||
<button type="button" onClick={() => (multiPayer ? disableMultiPayer() : enableMultiPayer())}
|
||||
className="text-content-muted"
|
||||
@@ -1340,23 +1406,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={panelCls}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<label className={labelCls}>{t('costs.split') || 'Split'}</label>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2 }}>
|
||||
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.split') || 'Split'}</label>
|
||||
<div className="bg-surface-card border border-edge" style={{ display: 'flex', borderRadius: 999, padding: 2 }}>
|
||||
<button type="button" onClick={() => setSplitMode('equally')}
|
||||
className={splitMode === 'equally' ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
className={splitMode === 'equally' ? 'bg-surface-secondary text-content' : 'text-content-muted'}
|
||||
style={{ padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', borderRadius: 999, fontWeight: 600, border: 0, background: splitMode === 'equally' ? undefined : 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('costs.splitEqually') || 'Equally'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setSplitMode('custom')}
|
||||
className={splitMode === 'custom' ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
className={splitMode === 'custom' ? 'bg-surface-secondary text-content' : 'text-content-muted'}
|
||||
style={{ padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', borderRadius: 999, fontWeight: 600, border: 0, background: splitMode === 'custom' ? undefined : 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('costs.splitCustom') || 'Custom'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setSplitMode('ticket')}
|
||||
className={splitMode === 'ticket' ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
className={splitMode === 'ticket' ? 'bg-surface-secondary text-content' : 'text-content-muted'}
|
||||
style={{ padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', borderRadius: 999, fontWeight: 600, border: 0, background: splitMode === 'ticket' ? undefined : 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('costs.splitTicket') || 'Ticket'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1491,6 +1557,14 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={panelCls}>
|
||||
<label className={labelCls} htmlFor="expense-note">{t('costs.note')}</label>
|
||||
<textarea id="expense-note" value={note} onChange={e => setNote(e.target.value)} rows={2}
|
||||
placeholder={t('costs.notePlaceholder')} maxLength={NOTE_MAX}
|
||||
className={inputCls} style={{ borderRadius: 10, padding: '10px 13px', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', outline: 'none', resize: 'vertical', minHeight: 68, width: '100%', boxSizing: 'border-box', fontFamily: 'inherit', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -131,4 +131,16 @@ describe('CollectionFilterBar', () => {
|
||||
render(<Harness {...makeProps({ showSelect: false })} />);
|
||||
expect(screen.queryByRole('button', { name: 'Select' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-009: the active Select button marks itself with `on`, not `open`', () => {
|
||||
// `.open` belongs to the dropdowns, where it means the panel is down and the
|
||||
// label stays dark. Sharing that class made the active button paint a dark
|
||||
// icon on its dark accent fill, because the dropdown rule sits later in the
|
||||
// stylesheet and both have the same specificity.
|
||||
render(<Harness {...makeProps({ selectMode: true })} />);
|
||||
const selectBtn = screen.getByRole('button', { name: 'Select' });
|
||||
expect(selectBtn).toHaveClass('on');
|
||||
expect(selectBtn).not.toHaveClass('open');
|
||||
expect(selectBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react'
|
||||
import { ChevronDown, Check, Layers, Tag, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react'
|
||||
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CategoryOption, LabelOption } from '../../pages/collections/collectionsModel'
|
||||
import CollectionLabelFilter from './CollectionLabelFilter'
|
||||
|
||||
interface Opt {
|
||||
key: string | number
|
||||
@@ -148,36 +149,26 @@ export default function CollectionFilterBar({
|
||||
<Dropdown current={ratingFilter} options={ratingOpts} onSelect={k => onRatingFilter(k as number | 'all')} lead={<Star size={13} />} />
|
||||
<Dropdown current={sortMode} options={sortOpts} onSelect={k => onSortMode(k as CollectionSortMode)} lead={<ArrowDownUp size={13} />} />
|
||||
{showSelect && (
|
||||
<button type="button" onClick={onToggleSelect} className={`col-filter-btn col-filter-select${selectMode ? ' open' : ''}`} aria-pressed={selectMode}>
|
||||
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSelect}
|
||||
className={`col-filter-btn col-filter-icon col-filter-select${selectMode ? ' on' : ''}`}
|
||||
aria-pressed={selectMode}
|
||||
aria-label={t('collections.select')}
|
||||
title={t('collections.select')}
|
||||
>
|
||||
<CheckSquare size={15} />
|
||||
</button>
|
||||
)}
|
||||
{showLabels && (labelOptions.length > 0 || canManageLabels) && (
|
||||
<div className="col-labelfilter">
|
||||
{labelOptions.map(l => {
|
||||
const on = labelFilter.includes(l.id)
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
className={`col-labelchip${on ? ' on' : ''}`}
|
||||
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
|
||||
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
|
||||
aria-pressed={on}
|
||||
>
|
||||
<span className="col-labelchip-dot" />
|
||||
<span className="col-filter-lbl">{l.name}</span>
|
||||
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{canManageLabels && (
|
||||
<button type="button" className="col-filter-btn col-filter-addlabel" onClick={onManageLabels} title={t('collections.labels.manage')}>
|
||||
<Tags size={13} />
|
||||
<span className="col-filter-lbl">{labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<CollectionLabelFilter
|
||||
labelOptions={labelOptions}
|
||||
labelFilter={labelFilter}
|
||||
onLabelFilter={onLabelFilter}
|
||||
canManageLabels={canManageLabels}
|
||||
onManageLabels={onManageLabels}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import { Plus, Settings2, Tags } from 'lucide-react'
|
||||
import type { LabelOption } from '../../pages/collections/collectionsModel'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
export type { LabelOption }
|
||||
|
||||
interface CollectionLabelFilterProps {
|
||||
labelOptions: LabelOption[]
|
||||
labelFilter: number[]
|
||||
onLabelFilter: (ids: number[]) => void
|
||||
canManageLabels?: boolean
|
||||
onManageLabels?: () => void
|
||||
/**
|
||||
* 'bar' is the filter row above the list, 'map' the strip floating over the
|
||||
* map, which needs the same glass treatment as the buttons beside it.
|
||||
*/
|
||||
variant?: 'bar' | 'map'
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* The label filter, in one place because it has two homes.
|
||||
*
|
||||
* Labels are an axis of their own next to status, category and rating, so they
|
||||
* are grouped into a tray instead of joining the row as more loose buttons. On
|
||||
* a desktop it rides in the map's top bar, where there is room to spare; the
|
||||
* filter row keeps it whenever no map is on screen, so it never becomes
|
||||
* unreachable.
|
||||
*/
|
||||
export default function CollectionLabelFilter({
|
||||
labelOptions, labelFilter, onLabelFilter, canManageLabels = false, onManageLabels,
|
||||
variant = 'bar', t,
|
||||
}: CollectionLabelFilterProps): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`col-labelfilter${variant === 'map' ? ' on-map' : ''}`}
|
||||
role="group"
|
||||
aria-label={t('collections.labels.manage')}
|
||||
>
|
||||
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
|
||||
{labelOptions.map(l => {
|
||||
const on = labelFilter.includes(l.id)
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
className={`col-labelchip${on ? ' on' : ''}`}
|
||||
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
|
||||
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
|
||||
aria-pressed={on}
|
||||
>
|
||||
<span className="col-labelchip-dot" />
|
||||
<span className="col-filter-lbl">{l.name}</span>
|
||||
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{canManageLabels && onManageLabels && (
|
||||
<button
|
||||
type="button"
|
||||
className="col-filter-addlabel"
|
||||
onClick={onManageLabels}
|
||||
title={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
|
||||
aria-label={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
|
||||
>
|
||||
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { PanelLeftClose, PanelLeftOpen, Search } from 'lucide-react'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import CollectionMap from './CollectionMap'
|
||||
import CollectionLabelFilter, { type LabelOption } from './CollectionLabelFilter'
|
||||
|
||||
interface CollectionMapPanelProps {
|
||||
places: CollectionPlace[]
|
||||
@@ -17,6 +18,16 @@ interface CollectionMapPanelProps {
|
||||
onToggleView: () => void
|
||||
search: string
|
||||
onSearch: (v: string) => void
|
||||
/**
|
||||
* The label filter rides along in the map's top bar, where there is room for
|
||||
* it. The filter row keeps it whenever no map is on screen, so it can never
|
||||
* become unreachable.
|
||||
*/
|
||||
labelOptions?: LabelOption[]
|
||||
labelFilter?: number[]
|
||||
onLabelFilter?: (ids: number[]) => void
|
||||
canManageLabels?: boolean
|
||||
onManageLabels?: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
@@ -27,8 +38,10 @@ interface CollectionMapPanelProps {
|
||||
*/
|
||||
export default function CollectionMapPanel({
|
||||
places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView,
|
||||
search, onSearch, t,
|
||||
search, onSearch, labelOptions = [], labelFilter = [], onLabelFilter, canManageLabels = false,
|
||||
onManageLabels, t,
|
||||
}: CollectionMapPanelProps): React.ReactElement {
|
||||
const showLabels = onLabelFilter != null && (labelOptions.length > 0 || canManageLabels)
|
||||
return (
|
||||
<div className="col-map-shell">
|
||||
<CollectionMap
|
||||
@@ -50,6 +63,17 @@ export default function CollectionMapPanel({
|
||||
>
|
||||
{view === 'map' ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
|
||||
</button>
|
||||
{showLabels && (
|
||||
<CollectionLabelFilter
|
||||
variant="map"
|
||||
labelOptions={labelOptions}
|
||||
labelFilter={labelFilter}
|
||||
onLabelFilter={onLabelFilter!}
|
||||
canManageLabels={canManageLabels}
|
||||
onManageLabels={onManageLabels}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-map-group right">
|
||||
<div className="col-map-search">
|
||||
|
||||
@@ -342,7 +342,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 }}>
|
||||
{dayDetailPlugins.map((p) => (
|
||||
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
|
||||
<PluginFrame pluginId={p.id} tripId={String(tripId)} dayId={String(day.id)} title={p.name} />
|
||||
<PluginFrame pluginId={p.id} tripId={String(tripId)} dayId={String(day.id)} title={p.name} surface="detail-slot" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDet
|
||||
import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay'
|
||||
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
|
||||
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
|
||||
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
|
||||
|
||||
interface DayPlanSidebarProps {
|
||||
tripId: number
|
||||
@@ -1701,12 +1701,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||
onContextMenu={e => {
|
||||
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
|
||||
// Flat entries, one per app: the menu has room to
|
||||
// grow and a submenu would need a component that
|
||||
// does not exist here.
|
||||
const navTargets = getNavigationTargets(place)
|
||||
ctxMenu.open(e, [
|
||||
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
googleMapsUrl && { label: t('inspector.google'), icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
|
||||
...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })),
|
||||
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
|
||||
{ divider: true },
|
||||
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor, fireEvent, act } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor, fireEvent, act, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { buildUser, buildTrip, buildPlace, buildCategory, buildReservation } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
@@ -632,13 +632,18 @@ describe('PlaceInspector', () => {
|
||||
|
||||
// ── Google Maps URL action ─────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-043: Google Maps lat/lng button visible when no google_maps_url', () => {
|
||||
it('FE-PLANNER-INSPECTOR-043: the navigation button offers every app the place can be opened in', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlaceInspector {...defaultProps} />);
|
||||
// place has lat/lng so Google Maps button should appear with Navigation icon
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
// Find button containing "Google Maps" text
|
||||
const mapsBtn = allButtons.find(btn => btn.textContent?.includes('Google Maps'));
|
||||
expect(mapsBtn).toBeTruthy();
|
||||
// A place with coordinates reaches Google Maps and Waze (Apple Maps only on
|
||||
// Apple platforms), so the button collects them behind one entry.
|
||||
const navBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Navigation'))!;
|
||||
expect(navBtn).toBeTruthy();
|
||||
|
||||
await user.click(navBtn);
|
||||
const menu = await screen.findByRole('menu');
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument();
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Waze' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-043b: Google Maps action uses google_ftid over coordinates', async () => {
|
||||
@@ -651,9 +656,10 @@ describe('PlaceInspector', () => {
|
||||
lng: -80.5542617,
|
||||
google_ftid: '0x882bf179e806d471:0x8591dde29c821a93',
|
||||
})} />);
|
||||
const mapsBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Google Maps'))!;
|
||||
await user.click(mapsBtn);
|
||||
expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank');
|
||||
const navBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Navigation'))!;
|
||||
await user.click(navBtn);
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Google Maps' }));
|
||||
expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank', 'noopener,noreferrer');
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -934,12 +940,17 @@ describe('PlaceInspector', () => {
|
||||
|
||||
// ── Footer actions ───────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-069: the OpenStreetMap and website buttons open their targets in a new tab', () => {
|
||||
it('FE-PLANNER-INSPECTOR-069: OpenStreetMap sits in the navigation menu, the website keeps its own button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const open = vi.fn();
|
||||
vi.stubGlobal('open', open);
|
||||
const p = buildPlace({ id: 705, name: 'Linked Place', osm_id: 'node/12345', website: 'https://example.org' });
|
||||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
fireEvent.click(screen.getByText('OpenStreetMap').closest('button')!);
|
||||
|
||||
// OSM moved in with the other map apps rather than standing beside them.
|
||||
await user.click(screen.getAllByRole('button').find(b => b.textContent?.includes('Navigation'))!);
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'OpenStreetMap' }));
|
||||
|
||||
fireEvent.click(screen.getByText('Open Website').closest('button')!);
|
||||
expect(open).toHaveBeenCalledTimes(2);
|
||||
expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank']);
|
||||
|
||||
@@ -27,8 +27,8 @@ import type { CollectionStatus } from '@trek/shared'
|
||||
import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { formatDistance, formatElevation } from '../../utils/units'
|
||||
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
|
||||
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
|
||||
import { NavigationMenu } from '../shared/NavigationMenu'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
import { convertHoursLine } from './placeHoursFormat'
|
||||
|
||||
@@ -178,6 +178,8 @@ export default function PlaceInspector({
|
||||
// Extra native rows contributed by placeDetailProvider plugins (#1429). Fail-safe:
|
||||
// any provider error/timeout is dropped server-side, so this only ever adds rows.
|
||||
const [providerDetails, setProviderDetails] = useState<Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }>>([])
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
const navBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const placeIdForDetails = mode === 'trip' ? place?.id : undefined
|
||||
useEffect(() => {
|
||||
if (placeIdForDetails == null) { setProviderDetails([]); return }
|
||||
@@ -316,11 +318,10 @@ export default function PlaceInspector({
|
||||
googleDetails?.open_now,
|
||||
)
|
||||
// Prefer the place's stored ftid; if it has none yet, use the one just fetched from Google.
|
||||
const googleMapsUrl = getGoogleMapsUrlForPlace(
|
||||
const navigationTargets = getNavigationTargets(
|
||||
place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null,
|
||||
googleDetails?.google_maps_url,
|
||||
)
|
||||
const openStreetMapUrl = getOpenStreetMapUrlForPlace(place)
|
||||
const selectedDay = days?.find(d => d.id === selectedDayId)
|
||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date, placeTimeZone)
|
||||
|
||||
@@ -450,7 +451,7 @@ export default function PlaceInspector({
|
||||
const tid = (place as { trip_id?: number | string }).trip_id
|
||||
return (
|
||||
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
|
||||
<PluginFrame pluginId={p.id} tripId={tid != null ? String(tid) : null} placeId={String(place.id)} title={p.name} />
|
||||
<PluginFrame pluginId={p.id} tripId={tid != null ? String(tid) : null} placeId={String(place.id)} title={p.name} surface="detail-slot" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -484,13 +485,32 @@ export default function PlaceInspector({
|
||||
icon={savedInCollection ? <BookmarkCheck size={13} /> : <Bookmark size={13} />}
|
||||
label={<span className="hidden sm:inline">{savedInCollection ? t('inspector.savedToCollection') : t('inspector.saveToCollection')}</span>} />
|
||||
)}
|
||||
{googleMapsUrl && (
|
||||
<ActionButton onClick={() => window.open(googleMapsUrl, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
||||
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
|
||||
)}
|
||||
{openStreetMapUrl && (
|
||||
<ActionButton onClick={() => window.open(openStreetMapUrl, '_blank')} variant="ghost" icon={<MapIcon size={13} />}
|
||||
label={<span className="hidden sm:inline">{t('inspector.openStreetMap')}</span>} />
|
||||
{navigationTargets.length > 0 && (
|
||||
<>
|
||||
{/* One target left (a place without coordinates) opens straight
|
||||
away, exactly as this button always did. */}
|
||||
<ActionButton
|
||||
ref={navBtnRef}
|
||||
onClick={() => {
|
||||
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
|
||||
else setNavOpen(o => !o)
|
||||
}}
|
||||
variant="ghost"
|
||||
icon={<Navigation size={13} />}
|
||||
label={
|
||||
<span className="hidden sm:inline">
|
||||
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{navOpen && (
|
||||
<NavigationMenu
|
||||
targets={navigationTargets}
|
||||
anchor={navBtnRef.current}
|
||||
onClose={() => setNavOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(place.website || googleDetails?.website) && (
|
||||
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
|
||||
@@ -535,9 +555,11 @@ interface ActionButtonProps {
|
||||
variant: 'primary' | 'ghost' | 'danger'
|
||||
icon: React.ReactNode
|
||||
label: React.ReactNode
|
||||
/** For callers that anchor a popup to the button. React 19 passes it through. */
|
||||
ref?: React.Ref<HTMLButtonElement>
|
||||
}
|
||||
|
||||
export function ActionButton({ onClick, variant, icon, label }: ActionButtonProps) {
|
||||
export function ActionButton({ onClick, variant, icon, label, ref }: ActionButtonProps) {
|
||||
const base = {
|
||||
primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' },
|
||||
ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' },
|
||||
@@ -546,6 +568,7 @@ export function ActionButton({ onClick, variant, icon, label }: ActionButtonProp
|
||||
const s = base[variant] || base.ghost
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
|
||||
@@ -672,6 +672,28 @@ describe('track colour legend (#776)', () => {
|
||||
const strokes = Array.from(row.querySelectorAll('span')).filter(el => (el as HTMLElement).style.borderRadius === '999px');
|
||||
expect(strokes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-051: the star button filters the list down to a minimum rating', async () => {
|
||||
// Replaces the old sort toggle: sorting put the best first but still left
|
||||
// every other place on the list, which is no help when the point is to see
|
||||
// only what the group actually wants to do.
|
||||
const user = userEvent.setup();
|
||||
const places = [
|
||||
buildPlace({ id: 20, name: 'Loved Place', rating_avg: 4.6 }),
|
||||
buildPlace({ id: 21, name: 'Meh Place', rating_avg: 2.1 }),
|
||||
buildPlace({ id: 22, name: 'Unrated Place' }),
|
||||
];
|
||||
render(<PlacesSidebar {...defaultProps} places={places} />);
|
||||
expect(screen.getByText('Meh Place')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByLabelText('Filter by rating'));
|
||||
await user.click(screen.getByText('4+'));
|
||||
|
||||
expect(screen.getByText('Loved Place')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Meh Place')).not.toBeInTheDocument();
|
||||
// An unrated place has no average to clear the floor with.
|
||||
expect(screen.queryByText('Unrated Place')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// #1616 — the other half of the reporter's gesture: the pickup. A tablet cannot
|
||||
|
||||
@@ -25,7 +25,8 @@ export function PlacesHeader(S: SidebarState) {
|
||||
places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks,
|
||||
filter, setFilter, setSelectedIds, selectMode, setSelectMode,
|
||||
catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFilters,
|
||||
ratingSort, setRatingSort,
|
||||
ratingFilter, setRatingFilter,
|
||||
starDropOpen, setStarDropOpen,
|
||||
} = S
|
||||
return (
|
||||
<div className="border-b border-edge-faint" style={{ padding: '14px 16px 10px', flexShrink: 0 }}>
|
||||
@@ -150,8 +151,10 @@ export function PlacesHeader(S: SidebarState) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category multi-select dropdown */}
|
||||
{categories.length > 0 && (() => {
|
||||
{/* Category multi-select plus the star filter. Rendered even without any
|
||||
category, because the rating filter does not depend on one and a fresh
|
||||
trip is exactly where someone collects places to rate. */}
|
||||
{(() => {
|
||||
const label = categoryFilters.size === 0
|
||||
? t('places.allCategories')
|
||||
: categoryFilters.size === 1
|
||||
@@ -159,7 +162,7 @@ export function PlacesHeader(S: SidebarState) {
|
||||
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||
return (
|
||||
<div style={{ marginTop: 6, position: 'relative', display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||
<button onClick={() => setCatDropOpen(v => !v)} className="bg-surface-card text-content" style={{
|
||||
{categories.length > 0 && <button onClick={() => setCatDropOpen(v => !v)} className="bg-surface-card text-content" style={{
|
||||
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
@@ -167,26 +170,64 @@ export function PlacesHeader(S: SidebarState) {
|
||||
}}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<ChevronDown size={12} className="text-content-faint" style={{ flexShrink: 0, transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{/* Star sort (#1435): order the list by average rating, best first. */}
|
||||
<Tooltip label={t('places.sortByRating')} placement="bottom">
|
||||
</button>}
|
||||
{/* Minimum-stars filter (#1435), same floors as the collections bar. */}
|
||||
<Tooltip label={t('places.filterByRating')} placement="bottom">
|
||||
<button
|
||||
onClick={() => setRatingSort(v => !v)}
|
||||
aria-label={t('places.sortByRating')}
|
||||
aria-pressed={ratingSort}
|
||||
className={ratingSort ? 'text-accent' : 'text-content-faint'}
|
||||
onClick={() => { setStarDropOpen(v => !v); setCatDropOpen(false) }}
|
||||
aria-label={t('places.filterByRating')}
|
||||
aria-expanded={starDropOpen}
|
||||
className={ratingFilter !== 'all' ? 'text-accent' : 'text-content-faint'}
|
||||
style={{
|
||||
width: 30, flexShrink: 0, borderRadius: 8,
|
||||
border: `1px solid ${ratingSort ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
background: ratingSort ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||
height: 30, flexShrink: 0, borderRadius: 8, gap: 3,
|
||||
padding: ratingFilter === 'all' ? 0 : '0 7px',
|
||||
width: ratingFilter === 'all' ? 30 : undefined,
|
||||
border: `1px solid ${ratingFilter !== 'all' ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
background: ratingFilter !== 'all' ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background 0.18s, color 0.18s, border-color 0.18s',
|
||||
}}
|
||||
>
|
||||
<Star size={13} strokeWidth={2.2} fill={ratingSort ? 'currentColor' : 'none'} />
|
||||
<Star size={13} strokeWidth={2.2} fill={ratingFilter !== 'all' ? 'currentColor' : 'none'} />
|
||||
{ratingFilter !== 'all' && <span>{ratingFilter}+</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{starDropOpen && (
|
||||
<div className="bg-surface-card" style={{
|
||||
position: 'absolute', top: '100%', right: 0, zIndex: 50, marginTop: 4, minWidth: 116,
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4,
|
||||
}}>
|
||||
{(['all', 5, 4, 3, 2, 1] as const).map(opt => {
|
||||
const active = ratingFilter === opt
|
||||
return (
|
||||
<button
|
||||
key={String(opt)}
|
||||
onClick={() => { setRatingFilter(opt as number | 'all'); setStarDropOpen(false) }}
|
||||
className={`text-content ${active ? 'bg-surface-hover' : 'bg-transparent'}`}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7, width: '100%',
|
||||
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{opt === 'all'
|
||||
? <span style={{ flex: 1 }}>{t('common.all')}</span>
|
||||
: (
|
||||
<>
|
||||
<Star size={12} strokeWidth={2.2} color="#facc15" fill="#facc15" />
|
||||
<span style={{ flex: 1 }}>{opt}+</span>
|
||||
</>
|
||||
)}
|
||||
{active && <Check size={11} strokeWidth={3} className="text-content-faint" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{canEditPlaces && (
|
||||
<Tooltip label={t('common.select')} placement="bottom">
|
||||
<button
|
||||
@@ -222,7 +263,7 @@ export function PlacesHeader(S: SidebarState) {
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{catDropOpen && (
|
||||
{catDropOpen && categories.length > 0 && (
|
||||
<div className="bg-surface-card" style={{
|
||||
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
|
||||
@@ -443,7 +443,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '0 14px 14px' }}>
|
||||
{detailPlugins.map(p => (
|
||||
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
|
||||
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} />
|
||||
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} surface="detail-slot" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -623,7 +623,7 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
|
||||
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{detailPlugins.map(p => (
|
||||
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
|
||||
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} />
|
||||
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} surface="detail-slot" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,16 @@ describe('getGoogleMapsUrlForPlace', () => {
|
||||
expect(url).toBe('https://maps.google.com/?cid=123')
|
||||
})
|
||||
|
||||
it('FE-PLACE-GMAPS-005: falls back to coordinates as a last resort', () => {
|
||||
it('FE-PLACE-GMAPS-005: searches by name and address when Google knows the place by neither id', () => {
|
||||
// #1278: coordinates alone open a nameless pin rather than the place, and
|
||||
// Google documents query=PLACE_NAME,ADDRESS for precisely this case.
|
||||
const url = getGoogleMapsUrlForPlace({ ...base, address: 'Champ de Mars, 75007 Paris' })
|
||||
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower%2C%20Champ%20de%20Mars%2C%2075007%20Paris')
|
||||
})
|
||||
|
||||
it('FE-PLACE-GMAPS-005b: a name with no address still falls back to coordinates', () => {
|
||||
// "Central Station" on its own would happily match a different city; the
|
||||
// position is the safer answer even though the pin carries no label.
|
||||
const url = getGoogleMapsUrlForPlace(base)
|
||||
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=48.8584,2.2945')
|
||||
})
|
||||
@@ -33,4 +42,9 @@ describe('getGoogleMapsUrlForPlace', () => {
|
||||
expect(getGoogleMapsUrlForPlace(null)).toBeNull()
|
||||
expect(getGoogleMapsUrlForPlace({ ...base, lat: null, lng: null })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLACE-GMAPS-007: an address alone carries the link even without coordinates', () => {
|
||||
const url = getGoogleMapsUrlForPlace({ ...base, lat: null, lng: null, address: 'Champ de Mars, 75007 Paris' })
|
||||
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower%2C%20Champ%20de%20Mars%2C%2075007%20Paris')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AssignmentPlace, Place } from '../../types'
|
||||
|
||||
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
|
||||
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
|
||||
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i
|
||||
|
||||
export function getGoogleMapsUrlForPlace(place: PlaceLike | null | undefined, detailsUrl?: string | null): string | null {
|
||||
@@ -14,6 +14,19 @@ export function getGoogleMapsUrlForPlace(place: PlaceLike | null | undefined, de
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(place.name)}&query_place_id=${encodeURIComponent(placeId)}`
|
||||
}
|
||||
if (detailsUrl) return detailsUrl
|
||||
|
||||
// No Google identifier of any kind. Coordinates alone drop a pin on a spot
|
||||
// with no name attached, which is what #1278 reported: the map opens, but not
|
||||
// the place. Google documents `query=PLACE_NAME,ADDRESS` for exactly this
|
||||
// case, and an address is specific enough that the name cannot match the
|
||||
// wrong "Central Station" three cities over.
|
||||
const name = place.name?.trim()
|
||||
const address = place.address?.trim()
|
||||
if (name && address) {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${name}, ${address}`)}`
|
||||
}
|
||||
if (place.lat == null || place.lng == null) return null
|
||||
// A name without an address is not safe to search on its own, so the position
|
||||
// wins: a pin in the right spot beats a confident link to the wrong place.
|
||||
return `https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getNavigationTargets, showsAppleMaps } from './placeNavigation'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
// FE-PLANNER-NAV-001 to FE-PLANNER-NAV-008
|
||||
|
||||
function place(overrides: Partial<Place> = {}): Place {
|
||||
return {
|
||||
name: 'Stephansdom',
|
||||
lat: 48.2038,
|
||||
lng: 16.3616,
|
||||
google_place_id: null,
|
||||
google_ftid: null,
|
||||
...overrides,
|
||||
} as unknown as Place
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom reports its own agent, so Apple has to be simulated. userAgent lives on
|
||||
* the prototype as a getter: shadowing it on the instance and deleting that
|
||||
* again afterwards restores the real one without having to reconstruct it.
|
||||
*/
|
||||
function withUserAgent(ua: string) {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true })
|
||||
return () => { delete (navigator as unknown as Record<string, unknown>).userAgent }
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('getNavigationTargets', () => {
|
||||
it('FE-PLANNER-NAV-001: offers every app that can resolve a place with coordinates', () => {
|
||||
const targets = getNavigationTargets(place())
|
||||
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
|
||||
expect(targets[0].label).toBe('Google Maps')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-002: Waze gets the name as well as the position, and navigates', () => {
|
||||
// Coordinates alone would show a driver a number instead of a destination;
|
||||
// the name alone would be a guess. Waze documents taking both.
|
||||
const waze = getNavigationTargets(place()).find(t => t.id === 'waze')!
|
||||
expect(waze.url).toBe('https://waze.com/ul?q=Stephansdom&ll=48.2038,16.3616&navigate=yes')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-003: Google keeps its precise link when the place has an ftid', () => {
|
||||
const targets = getNavigationTargets(place({ google_ftid: '0x882b:0x8591' }))
|
||||
// The whole point of the ftid: it lands on the entry, not on the roof.
|
||||
expect(targets[0].url).toContain('ftid=0x882b:0x8591')
|
||||
expect(targets[0].url).not.toContain('query=48.2038')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-004: a Windows desktop gets Apple Maps too, through its web version', () => {
|
||||
const restore = withUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
|
||||
try {
|
||||
expect(getNavigationTargets(place()).map(t => t.id)).toContain('apple')
|
||||
} finally { restore() }
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-004b: an Android phone does not, because nobody there wants it', () => {
|
||||
const restore = withUserAgent('Mozilla/5.0 (Linux; Android 15; Pixel 9)')
|
||||
try {
|
||||
expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm'])
|
||||
} finally { restore() }
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-005: an iPhone gets Apple Maps with a navigation target', () => {
|
||||
const restore = withUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')
|
||||
try {
|
||||
const targets = getNavigationTargets(place())
|
||||
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
|
||||
// q next to ll labels the pin rather than searching blindly.
|
||||
expect(targets[2].url).toBe('https://maps.apple.com/?q=Stephansdom&ll=48.2038,16.3616')
|
||||
} finally { restore() }
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-006: iPadOS reports itself as a Mac, and both deserve the entry', () => {
|
||||
const restore = withUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
|
||||
try {
|
||||
expect(showsAppleMaps()).toBe(true)
|
||||
} finally { restore() }
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-007: a place without coordinates keeps only what Google can resolve', () => {
|
||||
// Waze and Apple Maps take coordinates and nothing else, so they cannot
|
||||
// offer anything here — Google still can, through the place id.
|
||||
// OpenStreetMap still manages a name search, the two driving apps do not.
|
||||
const targets = getNavigationTargets(place({ lat: null, lng: null, google_place_id: 'ChIJabc' }))
|
||||
expect(targets.map(t => t.id)).toEqual(['google', 'osm'])
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-008: no place at all yields nothing', () => {
|
||||
expect(getNavigationTargets(null)).toEqual([])
|
||||
expect(getNavigationTargets(place({ lat: null, lng: null, name: '' }))).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-009: a nameless place still reaches every app, just without a label', () => {
|
||||
const targets = getNavigationTargets(place({ name: '' }))
|
||||
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
|
||||
expect(targets.find(t => t.id === 'waze')!.url).toBe('https://waze.com/ul?ll=48.2038,16.3616&navigate=yes')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { AssignmentPlace, Place } from '../../types'
|
||||
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
|
||||
|
||||
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
|
||||
|
||||
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm'
|
||||
|
||||
export interface NavigationTarget {
|
||||
id: NavigationAppId
|
||||
/** Product name. Not translated in any language, so it carries no i18n key. */
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Apple Maps is worth offering.
|
||||
*
|
||||
* Apple platforms open the installed app. Everywhere else the link still works,
|
||||
* because Apple Maps has had a web version since 2024, so a Windows or Linux
|
||||
* desktop gets a perfectly usable map rather than a dead end.
|
||||
*
|
||||
* Android is the one place it stays hidden: the web version works there too,
|
||||
* but nobody navigating from an Android phone reaches for Apple Maps, and the
|
||||
* row of choices is short for a reason.
|
||||
*
|
||||
* iPadOS 13 and later report themselves as "Macintosh", which is why the Mac
|
||||
* branch is not narrowed by touch: a real Mac has the app, an iPad has the app,
|
||||
* so both sides of that ambiguity are correct.
|
||||
*/
|
||||
export function showsAppleMaps(): boolean {
|
||||
if (typeof navigator === 'undefined') return false
|
||||
const ua = navigator.userAgent
|
||||
if (/iPhone|iPad|iPod|Macintosh/.test(ua)) return true
|
||||
return !/Android/i.test(ua)
|
||||
}
|
||||
|
||||
/**
|
||||
* The map apps a place can be opened in, in the order they are offered.
|
||||
*
|
||||
* None of them gets bare coordinates. Waze and Apple Maps both take a query
|
||||
* alongside the position (`q` in each case, documented by both), and the pair
|
||||
* is what makes the destination legible: the coordinates anchor which place is
|
||||
* meant, the name is what the driver sees on the screen instead of a number.
|
||||
* Without the position a name alone would be a gamble — there are a lot of
|
||||
* places called "Bahnhof" — so a place TREK has no coordinates for reaches
|
||||
* neither app.
|
||||
*
|
||||
* Google is the exception and keeps the link it always had, because it can do
|
||||
* better than a name: `getGoogleMapsUrlForPlace` walks ftid, then place id,
|
||||
* then the details URL, which lands on the right entry inside a mall rather
|
||||
* than on the roof.
|
||||
*
|
||||
* Waze arms navigation, since driving is the only thing it does. The other
|
||||
* three open the place, which is what Google has always done here, and starting
|
||||
* navigation from there is one tap.
|
||||
*/
|
||||
export function getNavigationTargets(
|
||||
place: PlaceLike | null | undefined,
|
||||
detailsUrl?: string | null,
|
||||
): NavigationTarget[] {
|
||||
if (!place) return []
|
||||
const targets: NavigationTarget[] = []
|
||||
const name = place.name?.trim()
|
||||
|
||||
const googleUrl = getGoogleMapsUrlForPlace(place, detailsUrl)
|
||||
if (googleUrl) targets.push({ id: 'google', label: 'Google Maps', url: googleUrl })
|
||||
|
||||
if (place.lat != null && place.lng != null) {
|
||||
const ll = `${place.lat},${place.lng}`
|
||||
const q = name ? `q=${encodeURIComponent(name)}&` : ''
|
||||
targets.push({
|
||||
id: 'waze',
|
||||
label: 'Waze',
|
||||
url: `https://waze.com/ul?${q}ll=${ll}&navigate=yes`,
|
||||
})
|
||||
if (showsAppleMaps()) {
|
||||
targets.push({
|
||||
id: 'apple',
|
||||
label: 'Apple Maps',
|
||||
url: `https://maps.apple.com/?${q}ll=${ll}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const osmUrl = getOpenStreetMapUrlForPlace(place)
|
||||
if (osmUrl) targets.push({ id: 'osm', label: 'OpenStreetMap', url: osmUrl })
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
/** Opens a target the way every external link in the planner is opened. */
|
||||
export function openNavigationTarget(target: NavigationTarget): void {
|
||||
window.open(target.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
@@ -187,25 +187,29 @@ describe('usePlacesSidebar filtering', () => {
|
||||
expect([...useTripStore.getState().placesCategoryFilter]).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PSHOOK-012: the star sort puts the best rating first and unrated places last', () => {
|
||||
it('FE-PLANNER-PSHOOK-012: the star filter keeps everything at or above the floor', () => {
|
||||
const places = [
|
||||
buildPlace({ name: 'Unrated' }),
|
||||
buildPlace({ name: 'Good', rating_avg: 4.2 }),
|
||||
buildPlace({ name: 'Best', rating_avg: 4.9 }),
|
||||
buildPlace({ name: 'Meh', rating_avg: 2.5 }),
|
||||
];
|
||||
render(<Host {...makeProps({ places })} />);
|
||||
act(() => { S.setRatingSort(true); });
|
||||
expect(names()).toEqual(['Best', 'Good', 'Unrated']);
|
||||
act(() => { S.setRatingFilter(4); });
|
||||
// The list keeps its own order; the filter only decides who is on it.
|
||||
expect(names()).toEqual(['Good', 'Best']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PSHOOK-013: equal ratings keep the incoming order', () => {
|
||||
it('FE-PLANNER-PSHOOK-013: an unrated place drops out as soon as a floor is set, and comes back with "all"', () => {
|
||||
const places = [
|
||||
buildPlace({ name: 'First', rating_avg: 4 }),
|
||||
buildPlace({ name: 'Second', rating_avg: 4 }),
|
||||
buildPlace({ name: 'Unrated' }),
|
||||
buildPlace({ name: 'Rated', rating_avg: 1 }),
|
||||
];
|
||||
render(<Host {...makeProps({ places })} />);
|
||||
act(() => { S.setRatingSort(true); });
|
||||
expect(names()).toEqual(['First', 'Second']);
|
||||
act(() => { S.setRatingFilter(1); });
|
||||
expect(names()).toEqual(['Rated']);
|
||||
act(() => { S.setRatingFilter('all'); });
|
||||
expect(names()).toEqual(['Unrated', 'Rated']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PSHOOK-014: search and category filter combine', () => {
|
||||
|
||||
@@ -147,7 +147,11 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const setCategoryFilters = useTripStore((s) => s.setPlacesCategoryFilter)
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
// Star sort (#1435): list-only toggle, so it stays local (the map keeps its order).
|
||||
const [ratingSort, setRatingSort] = useState(false)
|
||||
// Minimum average stars, matching the collections filter (#1435): 'all', or a
|
||||
// floor of 1..5 that unrated places fall through. It replaced a sort toggle,
|
||||
// which put the best first but still left everything else on the list — no
|
||||
// help at all when the point is to see only what the group actually rated.
|
||||
const [ratingFilter, setRatingFilter] = useState<number | 'all'>('all')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
|
||||
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
|
||||
@@ -179,6 +183,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
}
|
||||
const [dayPickerPlace, setDayPickerPlace] = useState<Place | null>(null)
|
||||
const [catDropOpen, setCatDropOpen] = useState(false)
|
||||
const [starDropOpen, setStarDropOpen] = useState(false)
|
||||
const [mobileShowDays, setMobileShowDays] = useState(false)
|
||||
|
||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||
@@ -201,15 +206,11 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
}
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
|
||||
return true
|
||||
})
|
||||
// Star sort (#1435): highest average first, unrated places at the end;
|
||||
// ties keep the list's original order (stable sort).
|
||||
if (ratingSort) {
|
||||
return [...list].sort((a, b) => (b.rating_avg ?? -1) - (a.rating_avg ?? -1))
|
||||
}
|
||||
return list
|
||||
}, [places, filter, categoryFilters, search, plannedIds, ratingSort])
|
||||
}, [places, filter, categoryFilters, search, plannedIds, ratingFilter])
|
||||
|
||||
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
@@ -269,7 +270,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
listImportEnrich, setListImportEnrich, canEnrichImport,
|
||||
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
|
||||
search, setSearch, filter, setFilter, categoryFilters, setCategoryFilters,
|
||||
ratingSort, setRatingSort,
|
||||
ratingFilter, setRatingFilter,
|
||||
starDropOpen, setStarDropOpen,
|
||||
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
||||
categoryPickerOpen, setCategoryPickerOpen,
|
||||
saveToListOpen, setSaveToListOpen, collectionsEnabled, tripId,
|
||||
|
||||
@@ -115,7 +115,7 @@ export function PluginActions({ items, tripId, className }: { items: ViewContrib
|
||||
<button onClick={() => setFrame(null)} className="text-content-muted hover:text-content"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<PluginFrame pluginId={frame.pluginId} tripId={tripId != null ? String(tripId) : null} title={frame.label} fill />
|
||||
<PluginFrame pluginId={frame.pluginId} tripId={tripId != null ? String(tripId) : null} title={frame.label} fill surface="action-frame" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,6 +109,58 @@ describe('PluginFrame', () => {
|
||||
await waitFor(() => expect(posted.some((m) => (m as { type?: string }).type === 'trek:response')).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-005a: the context says which surface the frame sits in and what shape it takes', () => {
|
||||
// Without this a plugin cannot tell a full-height tab from a widget that
|
||||
// reports its own height — and on a filling surface the height report is
|
||||
// dropped, so it never finds out by trying.
|
||||
const { container } = render(<PluginFrame pluginId="demo" surface="trip-tab" fill />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
const posted: Array<Record<string, unknown>> = [];
|
||||
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
|
||||
|
||||
fromFrame(iframe, { type: 'trek:context:request' });
|
||||
|
||||
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
|
||||
expect(ctx!.viewport).toMatchObject({ surface: 'trip-tab', fill: true, formFactor: 'desktop' });
|
||||
expect((ctx!.viewport as { insets: unknown }).insets).toEqual({ top: 0, bottom: 0 });
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-005b: a widget reports its own height, and says so', () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" surface="dashboard-widget" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
const posted: Array<Record<string, unknown>> = [];
|
||||
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
|
||||
|
||||
fromFrame(iframe, { type: 'trek:context:request' });
|
||||
|
||||
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
|
||||
expect(ctx!.viewport).toMatchObject({ surface: 'dashboard-widget', fill: false });
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-005c: the mobile palette is read off the mobile shell, not the document element', () => {
|
||||
// The --m-* family is scoped to .m-root. Reading it at documentElement — where
|
||||
// every other token lives — yields nothing, which is why it never reached a
|
||||
// plugin before.
|
||||
const shell = document.createElement('div');
|
||||
shell.className = 'm-root';
|
||||
shell.style.setProperty('--m-card', 'rgba(255,255,255,.55)');
|
||||
shell.style.setProperty('--m-ink', '#101013');
|
||||
document.body.appendChild(shell);
|
||||
try {
|
||||
const { container } = render(<PluginFrame pluginId="demo" surface="trip-tab" fill />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
const posted: Array<Record<string, unknown>> = [];
|
||||
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
|
||||
|
||||
fromFrame(iframe, { type: 'trek:context:request' });
|
||||
|
||||
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
|
||||
expect(ctx!.tokens).toMatchObject({ '--m-card': 'rgba(255,255,255,.55)', '--m-ink': '#101013' });
|
||||
} finally {
|
||||
shell.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-005: context carries theme tokens, formats and non-secret display identity', () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useToast } from '../shared/Toast'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
|
||||
// The design-token contract handed to plugins (#4 richer context): non-secret CSS
|
||||
// values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and
|
||||
@@ -21,6 +22,31 @@ import { addListener, removeListener } from '../../api/websocket'
|
||||
// (--glass-*/--r-*/--sh-*) is intentionally NOT read here: it is scoped to the
|
||||
// dashboard subtree, so it resolves EMPTY at documentElement — the SDK design kit
|
||||
// bakes those values instead (they don't vary with the accent, only light/dark).
|
||||
/**
|
||||
* The mobile palette (client/src/mobile/mobile.css) is a SECOND, unrelated token
|
||||
* family, and it is scoped to `.m-root` rather than to the document element. That
|
||||
* scope is the reason it cannot simply join the list below: `readThemeTokens`
|
||||
* measures at documentElement, where every one of these resolves empty — the same
|
||||
* trap the comment above describes for `--glass-*`. They are read off the mobile
|
||||
* shell instead, when one is mounted.
|
||||
*
|
||||
* A plugin sitting inside the mobile shell needs these to match its surroundings;
|
||||
* the global palette above describes the desktop chrome and looks foreign there.
|
||||
*/
|
||||
const MOBILE_TOKEN_VARS = [
|
||||
// ink + ground
|
||||
'--m-ink', '--m-muted', '--m-faint', '--m-bg', '--m-scr',
|
||||
// glass surfaces the mobile design is built from
|
||||
'--m-glass', '--m-gbr', '--m-card', '--m-cbr', '--m-inner', '--m-inbr',
|
||||
'--m-sheet', '--m-sheetop', '--m-shbr',
|
||||
// action + accents
|
||||
'--m-act', '--m-actfg', '--m-dim', '--m-ic', '--m-rowbr', '--m-avbr', '--m-knob',
|
||||
// status canon (theme independent, but a plugin should not restate it)
|
||||
'--m-st-confirmed', '--m-st-pending', '--m-st-info', '--m-st-danger', '--m-st-neutral',
|
||||
// chrome metrics
|
||||
'--m-safe-top',
|
||||
]
|
||||
|
||||
const TOKEN_VARS = [
|
||||
// surfaces
|
||||
'--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-elevated',
|
||||
@@ -39,6 +65,9 @@ const TOKEN_VARS = [
|
||||
// radii, type, misc
|
||||
'--radius-sm', '--radius-md', '--radius-lg', '--radius-xl',
|
||||
'--font-system', '--font-subtext', '--overlay', '--ease-out-quint',
|
||||
// How much a host surface keeps clear at the bottom. Global, unlike the rest of
|
||||
// the mobile metrics, and useful on both form factors.
|
||||
'--bottom-nav-h',
|
||||
]
|
||||
function readThemeTokens(): Record<string, string> {
|
||||
const cs = getComputedStyle(document.documentElement)
|
||||
@@ -47,9 +76,54 @@ function readThemeTokens(): Record<string, string> {
|
||||
const val = cs.getPropertyValue(v).trim()
|
||||
if (val) out[v] = val
|
||||
}
|
||||
// Measured on the mobile shell, because that is where the mobile palette is
|
||||
// declared. Absent on desktop, and then these keys are simply absent too — a
|
||||
// plugin checks for them rather than assuming both families are present.
|
||||
const mobileRoot = document.querySelector('.m-root')
|
||||
if (mobileRoot) {
|
||||
const mcs = getComputedStyle(mobileRoot)
|
||||
for (const v of MOBILE_TOKEN_VARS) {
|
||||
const val = mcs.getPropertyValue(v).trim()
|
||||
if (val) out[v] = val
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Where a frame is mounted. Part of the plugin contract, so the names are stable. */
|
||||
export type PluginSurface =
|
||||
| 'trip-tab' // a plugin's own tab inside a trip (fills, scrolls itself)
|
||||
| 'plugin-page' // /plugins/:id (fills, scrolls itself)
|
||||
| 'dashboard-widget'// a card on the dashboard (reports its height)
|
||||
| 'user-settings' // the plugin's settings.html (reports its height)
|
||||
| 'detail-slot' // inside a place/day/reservation panel (reports its height)
|
||||
| 'action-frame' // the modal a table action opens (fills)
|
||||
|
||||
/**
|
||||
* What the host already keeps clear around this frame, in CSS pixels.
|
||||
*
|
||||
* This describes space the plugin does NOT have to account for; it is not a
|
||||
* request to add padding. The mobile trip tab is the only surface with floating
|
||||
* chrome above and below it, and the numbers come from the same variables the
|
||||
* native tabs use, so the two can never drift apart.
|
||||
*/
|
||||
function readInsets(surface: PluginSurface | undefined, phone: boolean): { top: number; bottom: number } {
|
||||
if (!phone || surface !== 'trip-tab') return { top: 0, bottom: 0 }
|
||||
const mobileRoot = document.querySelector('.m-root')
|
||||
if (!mobileRoot) return { top: 0, bottom: 0 }
|
||||
const cs = getComputedStyle(mobileRoot)
|
||||
const px = (value: string, fallback: number) => {
|
||||
const n = Number.parseFloat(value)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
// Mirrors TabScroller: the controls end 58px below the safe-top anchor, and the
|
||||
// dock plus its breathing room is what --bottom-nav-h already expresses.
|
||||
return {
|
||||
top: px(cs.getPropertyValue('--m-safe-top'), 12) + 58,
|
||||
bottom: px(cs.getPropertyValue('--bottom-nav-h'), 84) + 22,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's current appearance state, mirrored from the attributes applyAppearance
|
||||
* writes on <html>, so a plugin can honour the same accessibility/appearance choices
|
||||
@@ -97,6 +171,13 @@ interface PluginFrameProps {
|
||||
* below — right for dashboard widgets, wrong for a page.
|
||||
*/
|
||||
fill?: boolean
|
||||
/**
|
||||
* Which host surface this frame is mounted in. Handed to the plugin verbatim,
|
||||
* because the contract differs per surface and a plugin cannot see where it
|
||||
* sits: a page fills and scrolls itself, a widget reports its height and gets
|
||||
* it honoured, a detail slot is a narrow column inside someone else's panel.
|
||||
*/
|
||||
surface?: PluginSurface
|
||||
className?: string
|
||||
title?: string
|
||||
/**
|
||||
@@ -208,7 +289,8 @@ function createPluginSessionStorageKeys(
|
||||
}
|
||||
}
|
||||
|
||||
export default function PluginFrame({ pluginId, tripId = null, placeId = null, dayId = null, reservationId = null, fill = false, className, title, path = 'index.html' }: PluginFrameProps) {
|
||||
export default function PluginFrame({ pluginId, tripId = null, placeId = null, dayId = null, reservationId = null, fill = false, surface, className, title, path = 'index.html' }: PluginFrameProps) {
|
||||
const isPhone = useIsPhone()
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null)
|
||||
// A sandboxed frame may navigate ITSELF (connect-src can't stop that), and its
|
||||
// window identity keeps matching our iframe afterwards. Track loads and refuse
|
||||
@@ -282,6 +364,15 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
// mirror the same look and accessibility choices as the host.
|
||||
user: userName != null ? { name: userName, avatar: userAvatar, isAdmin } : null,
|
||||
appearance: readAppearance(),
|
||||
// Where this frame sits and what shape it is expected to take. Without it a
|
||||
// plugin cannot tell a full-height tab from a widget that reports its own
|
||||
// height, and a height report is silently dropped on a filling surface.
|
||||
viewport: {
|
||||
surface: surface ?? null,
|
||||
formFactor: isPhone ? 'phone' : 'desktop',
|
||||
fill,
|
||||
insets: readInsets(surface, isPhone),
|
||||
},
|
||||
formats: {
|
||||
locale,
|
||||
currency: displayCurrency,
|
||||
@@ -292,7 +383,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
blurBookingCodes: Boolean(settings.blur_booking_codes),
|
||||
},
|
||||
tokens: readThemeTokens(),
|
||||
}), [tripId, placeId, dayId, reservationId, userId, locale, userName, userAvatar, isAdmin, settings])
|
||||
}), [tripId, placeId, dayId, reservationId, userId, locale, userName, userAvatar, isAdmin, settings, surface, fill, isPhone])
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current
|
||||
|
||||
@@ -18,13 +18,19 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act
|
||||
{plugins.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
// The glass vocabulary (--glass-*, --r-xl, --ink-3) is scoped to
|
||||
// .trek-dash. The phone dashboard is a different route and mounts these
|
||||
// widgets outside that subtree, so every one of those resolved empty and
|
||||
// the card lost its background, border, shadow and blur — a bare title
|
||||
// over the gradient. Each now falls back to a mobile token, which is
|
||||
// what the surrounding cards on that screen are made of.
|
||||
style={{
|
||||
background: 'var(--glass-bg)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
background: 'var(--glass-bg, var(--m-card))',
|
||||
border: '1px solid var(--glass-border, var(--m-cbr))',
|
||||
borderRadius: 'var(--r-xl, 20px)',
|
||||
boxShadow: 'var(--glass-shadow), var(--glass-highlight)',
|
||||
backdropFilter: 'var(--glass-blur)',
|
||||
WebkitBackdropFilter: 'var(--glass-blur)',
|
||||
boxShadow: 'var(--glass-shadow, 0 16px 44px -20px rgba(0,0,0,.28)), var(--glass-highlight, 0 0 0 0 transparent)',
|
||||
backdropFilter: 'var(--glass-blur, blur(30px) saturate(1.8))',
|
||||
WebkitBackdropFilter: 'var(--glass-blur, blur(30px) saturate(1.8))',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
@@ -32,7 +38,7 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '16px 20px 8px',
|
||||
fontSize: 13, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.14em',
|
||||
color: 'var(--ink-3)',
|
||||
color: 'var(--ink-3, var(--m-faint))',
|
||||
}}
|
||||
>
|
||||
<PluginIcon name={p.icon} size={14} style={{ flexShrink: 0 }} />
|
||||
@@ -40,7 +46,7 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act
|
||||
</div>
|
||||
{/* min-height is just a pre-resize floor; trek:resize drives the real height. */}
|
||||
<div style={{ minHeight: 60 }}>
|
||||
<PluginFrame pluginId={p.id} tripId={tripId} title={p.name} />
|
||||
<PluginFrame pluginId={p.id} tripId={tripId} title={p.name} surface="dashboard-widget" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -242,7 +242,7 @@ function PluginSettingsUiCard({ id, name, icon }: { id: string; name: string; ic
|
||||
white canvas behind the transparent frame (same trap .hero-overlay-frame
|
||||
guards against), which glares in dark mode. */}
|
||||
<div className="min-h-[120px]">
|
||||
<PluginFrame pluginId={id} path="settings.html" title={name} className="[color-scheme:light]" />
|
||||
<PluginFrame pluginId={id} path="settings.html" title={name} surface="user-settings" className="[color-scheme:light]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { NavigationMenu } from './NavigationMenu'
|
||||
import type { NavigationTarget } from '../Planner/placeNavigation'
|
||||
|
||||
// FE-COMP-NAVMENU-001 to FE-COMP-NAVMENU-007
|
||||
|
||||
const TARGETS: NavigationTarget[] = [
|
||||
{ id: 'google', label: 'Google Maps', url: 'https://maps.example/g' },
|
||||
{ id: 'waze', label: 'Waze', url: 'https://waze.example/w' },
|
||||
]
|
||||
|
||||
/** A trigger at a chosen viewport position, so the flip can be provoked. */
|
||||
function anchorAt(top: number, height = 30) {
|
||||
const el = document.createElement('button')
|
||||
document.body.appendChild(el)
|
||||
el.getBoundingClientRect = () => ({
|
||||
top, bottom: top + height, left: 40, right: 160, width: 120, height,
|
||||
x: 40, y: top, toJSON: () => ({}),
|
||||
}) as DOMRect
|
||||
return el
|
||||
}
|
||||
|
||||
/** jsdom measures everything as 0, so the menu needs a size to be placed. */
|
||||
function withMenuHeight(height: number) {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
top: 0, bottom: height, left: 0, right: 200, width: 200, height,
|
||||
x: 0, y: 0, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
window.innerHeight = 800
|
||||
window.innerWidth = 1200
|
||||
})
|
||||
|
||||
describe('NavigationMenu', () => {
|
||||
it('FE-COMP-NAVMENU-001: lists one entry per target', () => {
|
||||
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(100)} onClose={vi.fn()} />)
|
||||
expect(screen.getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Waze' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-NAVMENU-002: opening a target closes the menu', () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const onClose = vi.fn()
|
||||
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(100)} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Waze' }))
|
||||
|
||||
expect(open).toHaveBeenCalledWith('https://waze.example/w', '_blank', 'noopener,noreferrer')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-NAVMENU-003: sits below the trigger when there is room', () => {
|
||||
withMenuHeight(120)
|
||||
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(100)} onClose={vi.fn()} />)
|
||||
// Trigger ends at 130, so the menu starts just under it.
|
||||
expect(screen.getByRole('menu')).toHaveStyle({ top: '136px' })
|
||||
})
|
||||
|
||||
it('FE-COMP-NAVMENU-004: flips above the trigger when the bottom edge is close', () => {
|
||||
// Trigger ends at 780 with a viewport of 800: a 120px menu would be cut off.
|
||||
withMenuHeight(120)
|
||||
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(750)} onClose={vi.fn()} />)
|
||||
expect(screen.getByRole('menu')).toHaveStyle({ top: '624px' })
|
||||
})
|
||||
|
||||
it('FE-COMP-NAVMENU-005: never leaves the viewport, even when neither side fits', () => {
|
||||
// Taller than the window: clamped to the top margin rather than negative.
|
||||
withMenuHeight(900)
|
||||
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(700)} onClose={vi.fn()} />)
|
||||
expect(screen.getByRole('menu')).toHaveStyle({ top: '8px' })
|
||||
})
|
||||
|
||||
it('FE-COMP-NAVMENU-006: Escape closes it', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<NavigationMenu targets={TARGETS} anchor={anchorAt(100)} onClose={onClose} />)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-NAVMENU-007: renders nothing without targets', () => {
|
||||
render(<NavigationMenu targets={[]} anchor={anchorAt(100)} onClose={vi.fn()} />)
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Navigation } from 'lucide-react'
|
||||
import type { NavigationTarget } from '../Planner/placeNavigation'
|
||||
import { openNavigationTarget } from '../Planner/placeNavigation'
|
||||
|
||||
interface NavigationMenuProps {
|
||||
targets: NavigationTarget[]
|
||||
/** The element the menu is anchored to; it is measured, never moved. */
|
||||
anchor: HTMLElement | null
|
||||
onClose: () => void
|
||||
/** Heading above the list. Omitted on desktop, where the trigger says it. */
|
||||
title?: string
|
||||
}
|
||||
|
||||
const GAP = 6
|
||||
const EDGE = 8
|
||||
|
||||
/**
|
||||
* The map-app picker.
|
||||
*
|
||||
* Portalled rather than nested, because both call sites sit inside panels that
|
||||
* clip their overflow — a menu rendered in place would be cut off at the panel
|
||||
* edge. Being in the body means the position has to be computed, and computed
|
||||
* after the menu has a size: opening near the bottom of the window flips it
|
||||
* above the trigger instead of letting it run off the screen, and a menu wider
|
||||
* than the space to its right is pulled back inside.
|
||||
*/
|
||||
export function NavigationMenu({ targets, anchor, onClose, title }: NavigationMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
// Layout effect, not effect: the menu is placed before the browser paints, so
|
||||
// it never appears at the wrong spot for a frame and jumps.
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || !anchor) return
|
||||
const place = () => {
|
||||
const a = anchor.getBoundingClientRect()
|
||||
const m = el.getBoundingClientRect()
|
||||
const below = window.innerHeight - a.bottom
|
||||
const flip = below < m.height + GAP + EDGE && a.top > below
|
||||
const top = flip ? Math.max(EDGE, a.top - m.height - GAP) : a.bottom + GAP
|
||||
const left = Math.min(Math.max(EDGE, a.left), window.innerWidth - m.width - EDGE)
|
||||
setPos({ top, left })
|
||||
}
|
||||
place()
|
||||
window.addEventListener('resize', place)
|
||||
window.addEventListener('scroll', place, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', place)
|
||||
window.removeEventListener('scroll', place, true)
|
||||
}
|
||||
}, [anchor, targets.length])
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node) && !anchor?.contains(e.target as Node)) onClose()
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [anchor, onClose])
|
||||
|
||||
if (targets.length === 0) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: pos?.top ?? -9999,
|
||||
left: pos?.left ?? -9999,
|
||||
// Hidden until measured, so the first paint is never in the wrong place.
|
||||
visibility: pos ? 'visible' : 'hidden',
|
||||
zIndex: 99999,
|
||||
minWidth: 186,
|
||||
maxHeight: `calc(100vh - ${EDGE * 2}px)`,
|
||||
overflowY: 'auto',
|
||||
padding: 5,
|
||||
background: 'var(--bg-card)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 10px 34px rgba(0,0,0,0.16)',
|
||||
animation: 'trek-menu-enter 180ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<div style={{
|
||||
padding: '5px 10px 7px',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-faint)',
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{targets.map(target => (
|
||||
<button
|
||||
key={target.id}
|
||||
role="menuitem"
|
||||
onClick={() => { openNavigationTarget(target); onClose() }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
|
||||
padding: '8px 10px', borderRadius: 8, border: 'none',
|
||||
background: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
textAlign: 'left', color: 'var(--text-primary)',
|
||||
transition: 'background 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<Navigation size={13} style={{ flexShrink: 0, color: 'var(--text-faint)' }} />
|
||||
<span>{target.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -288,7 +288,7 @@ function PluginSettingsUiCard({ id, name, icon }: { id: string; name: string; ic
|
||||
white canvas behind the transparent frame (same trap .hero-overlay-frame
|
||||
guards against), which glares in dark mode. */}
|
||||
<div className="min-h-[120px]">
|
||||
<PluginFrame pluginId={id} path="settings.html" title={name} className="[color-scheme:light]" />
|
||||
<PluginFrame pluginId={id} path="settings.html" title={name} surface="user-settings" className="[color-scheme:light]" />
|
||||
</div>
|
||||
</MSetCard>
|
||||
)
|
||||
|
||||
@@ -458,6 +458,24 @@ export default function MTripShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A plugin tab is the only one that used to arrive without a name: it is
|
||||
not in the dock, so nothing was lit up there either, and the screen
|
||||
gave no clue which plugin was open. Same treatment as the others, from
|
||||
the tab entry the planner already builds (id, label, icon). */}
|
||||
{trTab.startsWith('plugin:') && (() => {
|
||||
const tab = planner.TRIP_TABS.find(x => x.id === trTab)
|
||||
if (!tab) return null
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<div className="pointer-events-none absolute left-[52px] right-[52px] top-1/2 flex -translate-y-1/2 items-center justify-center gap-[7px]">
|
||||
<div className="flex min-w-0 items-center gap-[7px] rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-[13px] py-[7px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
|
||||
{Icon && <Icon size={14} strokeWidth={2} className="flex-none text-m-muted" />}
|
||||
<span className="truncate text-[0.8125rem] font-semibold text-m-ink">{tab.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{trTab === 'plan' ? (
|
||||
<MIconBtn
|
||||
ariaLabel={view === 'plan' ? t('mobileTrip.mapView') : t('mobileTrip.listView')}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Plus, Trash2, Wallet } from 'lucide-react'
|
||||
import { Check, ChevronDown, Plus, Trash2, Wallet } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import CustomSelect from '../../../../components/shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
|
||||
import { NumericInput } from '../../../../components/shared/NumericInput'
|
||||
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useToast } from '../../../../components/shared/Toast'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
@@ -12,7 +12,7 @@ import { useExchangeRates } from '../../../../hooks/useExchangeRates'
|
||||
import { formatMoney, localizeAmountInput } from '../../../../utils/formatters'
|
||||
import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
|
||||
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
|
||||
import { calculateTicketShares, splitEqualShares, type TicketItem } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { calculateTicketShares, hasTicketSplit, NOTE_MAX, readTicketItems, readUserNote, splitEqualShares, writeTicketItems, type TicketItem } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel'
|
||||
import { payersBalanced, rebalancePayers } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import GuestBadge from '../../../../components/shared/GuestBadge'
|
||||
@@ -69,6 +69,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
|
||||
const [name, setName] = useState(editing?.name || prefill?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [catOpen, setCatOpen] = useState(false)
|
||||
const [note, setNote] = useState(() => readUserNote(editing))
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
@@ -96,7 +98,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
const [pinnedPayers, setPinnedPayers] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
|
||||
|
||||
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
|
||||
if (editing?.note && editing.note.startsWith('TICKETJSON:')) return 'ticket'
|
||||
if (hasTicketSplit(editing)) return 'ticket'
|
||||
if (editing && editing.members && editing.members.length > 0) {
|
||||
const hasCustom = editing.members.some(m => m.amount !== null && m.amount !== undefined)
|
||||
return hasCustom ? 'custom' : 'equally'
|
||||
@@ -104,22 +106,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
return 'equally'
|
||||
})
|
||||
|
||||
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => {
|
||||
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
|
||||
try {
|
||||
const parsed = JSON.parse(editing.note.slice(11))
|
||||
return (parsed.items || []).map((item: { name: string; price: number | string; parts?: number[] }) => ({
|
||||
id: String(Math.random()),
|
||||
name: item.name,
|
||||
price: String(item.price),
|
||||
participants: new Set<number>(item.parts || []),
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => readTicketItems(editing))
|
||||
|
||||
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
@@ -268,9 +255,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
member_ids: [...participants],
|
||||
expense_date: day || null,
|
||||
total_price: totalNum,
|
||||
note: splitMode === 'ticket'
|
||||
? 'TICKETJSON:' + JSON.stringify({ items: ticketItems.map(item => ({ name: item.name, price: item.price, parts: [...item.participants] })) })
|
||||
: null,
|
||||
note: note.trim() || null,
|
||||
ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null,
|
||||
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||
}
|
||||
try {
|
||||
@@ -316,11 +302,6 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
</span>
|
||||
)
|
||||
|
||||
const pillCls = (active: boolean) =>
|
||||
`flex items-center gap-[5px] rounded-full px-[11px] py-[6px] text-[0.71875rem] font-semibold ${
|
||||
active ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted'
|
||||
}`
|
||||
|
||||
const submitLabel = saving ? t('common.saving') : editing ? t('common.save') : t('common.add')
|
||||
|
||||
return (
|
||||
@@ -391,19 +372,45 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CATEGORY */}
|
||||
{/* CATEGORY — a dropdown rather than eleven pills, which took a third of
|
||||
the sheet and pushed the split below the fold. Same shape as the
|
||||
category filter on the tab behind it. */}
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('costs.category')}</Eyebrow>
|
||||
<div className="flex flex-wrap gap-[6px]">
|
||||
{COST_CATEGORY_LIST.map(c => {
|
||||
const Icon = c.Icon
|
||||
return (
|
||||
<button key={c.key} type="button" onClick={() => setCat(c.key)} aria-pressed={cat === c.key} className={pillCls(cat === c.key)}>
|
||||
<Icon size={12} strokeWidth={2.2} style={{ color: c.color }} />
|
||||
{t(c.labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={catOpen}
|
||||
onClick={() => setCatOpen(v => !v)}
|
||||
className="flex w-full items-center gap-[10px] overflow-hidden rounded-xl border border-[color:var(--m-rowbr)] bg-m-card px-[13px] py-[11px] text-left"
|
||||
>
|
||||
{(() => {
|
||||
const meta = catMeta(cat)
|
||||
const Icon = meta.Icon
|
||||
return <Icon size={15} strokeWidth={2} style={{ color: meta.color }} className="flex-none" />
|
||||
})()}
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{t(catMeta(cat).labelKey)}</span>
|
||||
<ChevronDown size={14} strokeWidth={2} className={`flex-none text-m-faint transition-transform duration-200 ${catOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{catOpen && (
|
||||
<div className="mt-[6px] max-h-[240px] overflow-y-auto overscroll-contain rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)]">
|
||||
{COST_CATEGORY_LIST.map(c => {
|
||||
const Icon = c.Icon
|
||||
const on = cat === c.key
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => { setCat(c.key); setCatOpen(false) }}
|
||||
className="flex w-full items-center gap-[10px] border-b border-[color:var(--m-rowbr)] px-[13px] py-[11px] text-left last:border-b-0"
|
||||
>
|
||||
<Icon size={14} strokeWidth={2} style={{ color: c.color }} className="flex-none" />
|
||||
<span className={`flex-1 text-[0.78125rem] ${on ? 'font-bold text-m-ink' : 'font-medium text-m-muted'}`}>{t(c.labelKey)}</span>
|
||||
{on && <Check size={14} strokeWidth={2.4} className="flex-none text-m-ink" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* WHO PAID */}
|
||||
<div className="mb-[6px] mt-3 flex items-center justify-between">
|
||||
@@ -609,6 +616,18 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* NOTE — last, because it is the one field that is never required. The
|
||||
room for it came from folding the category pills into a dropdown. */}
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('costs.note')}</Eyebrow>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={NOTE_MAX}
|
||||
placeholder={t('costs.notePlaceholder')}
|
||||
className={FIELD_AREA_CLS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormSheetFooter
|
||||
|
||||
@@ -18,7 +18,8 @@ import TrackColorPicker from '../../../../components/shared/TrackColorPicker'
|
||||
import { resolveTrackColor, inheritedTrackColor } from '../../../../components/Map/trackColors'
|
||||
import { avatarSrc } from '../../../../utils/avatarSrc'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import { getGoogleMapsUrlForPlace } from '../../../../components/Planner/placeGoogleMaps'
|
||||
import { getNavigationTargets, openNavigationTarget } from '../../../../components/Planner/placeNavigation'
|
||||
import { NavigationMenu } from '../../../../components/shared/NavigationMenu'
|
||||
import type { Assignment, Day, TripMember } from '../../../../types'
|
||||
import { ActionCircle, Eyebrow, INNER_CLS } from './MTripSheetUi'
|
||||
|
||||
@@ -43,6 +44,8 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const [dayPickerOpen, setDayPickerOpen] = useState(false)
|
||||
const [participantPickerOpen, setParticipantPickerOpen] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
const navBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const [imgBusy, setImgBusy] = useState(false)
|
||||
const [colorPickerOpen, setColorPickerOpen] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
@@ -199,9 +202,13 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
planner.setFitKey(k => k + 1)
|
||||
}
|
||||
|
||||
// One app installed-and-offered means no picker: the tap opens it, which is
|
||||
// what this button has always done. The sheet only appears when there is an
|
||||
// actual choice to make.
|
||||
const navTargets = getNavigationTargets(place)
|
||||
const openDirections = () => {
|
||||
const url = getGoogleMapsUrlForPlace(place)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
if (navTargets.length === 1) openNavigationTarget(navTargets[0])
|
||||
else if (navTargets.length > 1) setNavOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -496,10 +503,24 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
<Bookmark size={15} strokeWidth={2} />
|
||||
</ActionCircle>
|
||||
)}
|
||||
{place.lat != null && place.lng != null && (
|
||||
<ActionCircle onClick={openDirections} label={t('inspector.google')}>
|
||||
<Navigation size={15} strokeWidth={2} />
|
||||
</ActionCircle>
|
||||
{navTargets.length > 0 && (
|
||||
<>
|
||||
<ActionCircle
|
||||
ref={navBtnRef}
|
||||
onClick={openDirections}
|
||||
label={navTargets.length === 1 ? navTargets[0].label : t('inspector.navigation')}
|
||||
>
|
||||
<Navigation size={15} strokeWidth={2} />
|
||||
</ActionCircle>
|
||||
{navOpen && (
|
||||
<NavigationMenu
|
||||
targets={navTargets}
|
||||
anchor={navBtnRef.current}
|
||||
onClose={() => setNavOpen(false)}
|
||||
title={t('inspector.openWith')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{place.lat != null && place.lng != null && (
|
||||
<ActionCircle onClick={showOnMap} label={t('mobileTrip.showOnMap')}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { ReactNode, Ref } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import MIconBtn from '../../../components/MIconBtn'
|
||||
import { formatTime } from '../../../../utils/formatters'
|
||||
@@ -88,12 +88,15 @@ interface ActionCircleProps {
|
||||
danger?: boolean
|
||||
className?: string
|
||||
children: ReactNode
|
||||
/** For callers that anchor a popup to the button. React 19 passes it through. */
|
||||
ref?: Ref<HTMLButtonElement>
|
||||
}
|
||||
|
||||
/** 38px round action button of the inspector footer rows. */
|
||||
export function ActionCircle({ onClick, label, primary = false, danger = false, className = '', children }: ActionCircleProps) {
|
||||
export function ActionCircle({ onClick, label, primary = false, danger = false, className = '', children, ref }: ActionCircleProps) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import { ConfirmationCode, CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
|
||||
import { BOOKING_TYPE_COLOR } from './bookingsModel'
|
||||
@@ -41,7 +41,14 @@ export default function MBookingsTab({ planner, shell }: MTabScreenProps) {
|
||||
return (
|
||||
<TabScroller>
|
||||
{showTravelerFilter && (
|
||||
<TravelerFilterRow members={planner.tripMembers} active={travelerFilter} onToggle={toggleTravelerFilter} label={t('reservations.travelers.label')} />
|
||||
<TravelerFilterRow
|
||||
members={planner.tripMembers}
|
||||
active={travelerFilter}
|
||||
onToggle={toggleTravelerFilter}
|
||||
onClear={() => setTravelerFilter(new Set())}
|
||||
label={t('reservations.travelers.label')}
|
||||
allLabel={t('common.all')}
|
||||
/>
|
||||
)}
|
||||
{sections.length === 0 ? (
|
||||
<div className="flex min-h-full flex-1 flex-col items-center justify-center px-8 py-10 text-center">
|
||||
@@ -52,7 +59,7 @@ export default function MBookingsTab({ planner, shell }: MTabScreenProps) {
|
||||
<div key={section.id}>
|
||||
<SectionHeader
|
||||
label={section.label}
|
||||
count={<CountPill>{section.rows.length}</CountPill>}
|
||||
count={section.rows.length}
|
||||
open={!collapsed[section.id]}
|
||||
onToggle={() => toggle(section.id)}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ import MSheet from '../../../components/MSheet'
|
||||
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import { CountPill, SectionHeader, TabScroller } from './tabChrome'
|
||||
import { SectionHeader, TabScroller } from './tabChrome'
|
||||
import {
|
||||
formatPollCountdown,
|
||||
hasUserVoted,
|
||||
@@ -191,7 +191,7 @@ export default function MCollabPolls({ planner }: MCollabPollsProps) {
|
||||
{active.length > 0 && (
|
||||
<SectionHeader
|
||||
label={t('collab.polls.closedSection')}
|
||||
count={<CountPill>{closed.length}</CountPill>}
|
||||
count={closed.length}
|
||||
open={!collapsedClosed}
|
||||
onToggle={() => setCollapsedClosed(v => !v)}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
AlertCircle, ArrowDown, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp,
|
||||
Layers, Pencil, Plus, Trash2,
|
||||
Layers, Pencil, Plus, StickyNote, Trash2,
|
||||
} from 'lucide-react'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
@@ -12,6 +12,7 @@ import { formatMoney } from '../../../../utils/formatters'
|
||||
import { downloadBlob } from '../../../../utils/fileDownload'
|
||||
import { budgetApi } from '../../../../api/client'
|
||||
import MCostSheet from '../sheets/MCostSheet'
|
||||
import { readUserNote } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
@@ -536,6 +537,10 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
const unfinished = isUnfinished(item, ctx)
|
||||
const borderColor = tint(meta.color, 0.55)
|
||||
const members = item.members || []
|
||||
const note = readUserNote(item)
|
||||
// A phone row has no width to spare for a note, so it stays folded away and
|
||||
// the card itself is the handle — no extra control, no extra height.
|
||||
const [noteOpen, setNoteOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-[6px]">
|
||||
@@ -590,6 +595,25 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
{formatMoney(total, base, locale)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{note && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={noteOpen}
|
||||
onClick={() => setNoteOpen(v => !v)}
|
||||
className="mt-[7px] flex w-full items-center gap-[7px] rounded-xl bg-[color:var(--m-ic)] px-[9px] py-[6px] text-left"
|
||||
>
|
||||
<StickyNote size={11} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
<span className={`min-w-0 flex-1 text-[0.6875rem] leading-[1.5] text-m-muted ${noteOpen ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'truncate'}`}>
|
||||
{note}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
strokeWidth={2}
|
||||
className={`flex-none text-m-faint transition-transform duration-200 ${noteOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import { ConfirmationCode, CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import {
|
||||
TRANSPORT_TYPE_COLOR,
|
||||
@@ -46,7 +46,14 @@ export default function MTransportsTab({ planner, shell }: MTabScreenProps) {
|
||||
return (
|
||||
<TabScroller>
|
||||
{showTravelerFilter && (
|
||||
<TravelerFilterRow members={planner.tripMembers} active={travelerFilter} onToggle={toggleTravelerFilter} label={t('reservations.travelers.label')} />
|
||||
<TravelerFilterRow
|
||||
members={planner.tripMembers}
|
||||
active={travelerFilter}
|
||||
onToggle={toggleTravelerFilter}
|
||||
onClear={() => setTravelerFilter(new Set())}
|
||||
label={t('reservations.travelers.label')}
|
||||
allLabel={t('common.all')}
|
||||
/>
|
||||
)}
|
||||
{sections.length === 0 ? (
|
||||
<div className="flex min-h-full flex-col items-center justify-center px-8 py-10 text-center">
|
||||
@@ -57,7 +64,7 @@ export default function MTransportsTab({ planner, shell }: MTabScreenProps) {
|
||||
<div key={section.id}>
|
||||
<SectionHeader
|
||||
label={section.label}
|
||||
count={<CountPill>{section.rows.length}</CountPill>}
|
||||
count={section.rows.length}
|
||||
open={!collapsed[section.id]}
|
||||
onToggle={() => toggle(section.id)}
|
||||
/>
|
||||
|
||||
@@ -18,12 +18,27 @@ import PluginFrame from '../../../../components/Plugins/PluginFrame'
|
||||
*/
|
||||
export default function MTripTabPanel({ planner, shell, tab }: MTripTabPanelProps) {
|
||||
// Trip-page plugin tab — the same sandboxed frame the desktop planner mounts,
|
||||
// filling the panel between the top chrome and the dock. The bottom padding
|
||||
// mirrors TabScroller's safe-area clearance so the frame never hides under it.
|
||||
// between the floating top controls and the dock. Both clearances come from the
|
||||
// variables TabScroller uses, so the frame cannot drift away from the native
|
||||
// tabs: the top one kept the plugin's first row under the z-42 back button, and
|
||||
// the bottom one was a copy of 84px that ignored the safe area on the devices
|
||||
// that have one. The frame also states its surface, so the plugin knows it is
|
||||
// expected to fill and scroll itself rather than report a height.
|
||||
//
|
||||
// color-scheme is pinned for the same reason both settings mounts pin it:
|
||||
// Chromium paints a white canvas behind a transparent frame otherwise.
|
||||
if (tab.startsWith('plugin:')) {
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 84px)' }}>
|
||||
<PluginFrame pluginId={tab.slice('plugin:'.length)} tripId={planner.tripId != null ? String(planner.tripId) : null} fill className="h-full w-full" />
|
||||
<div
|
||||
className="absolute inset-0 pt-[calc(var(--m-safe-top,12px)+58px)] pb-[calc(var(--bottom-nav-h,84px)+22px)]"
|
||||
>
|
||||
<PluginFrame
|
||||
pluginId={tab.slice('plugin:'.length)}
|
||||
tripId={planner.tripId != null ? String(planner.tripId) : null}
|
||||
fill
|
||||
surface="trip-tab"
|
||||
className="h-full w-full [color-scheme:light]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CostCategory } from '@trek/shared'
|
||||
import { splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { readUserNote, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories'
|
||||
import { currencyDecimals } from '../../../../utils/formatters'
|
||||
import type { BudgetItem } from '../../../../types'
|
||||
@@ -233,8 +233,7 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
|
||||
const sorted = items.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
|
||||
for (const e of sorted) {
|
||||
const cur = currencyOf(e, opts.ctx)
|
||||
// Ticket notes carry the itemized-receipt JSON, not a human note.
|
||||
const note = e.note && !e.note.startsWith('TICKETJSON:') ? e.note : ''
|
||||
const note = readUserNote(e)
|
||||
rows.push(
|
||||
[
|
||||
esc(fmtDate(e.expense_date || '')),
|
||||
|
||||
@@ -158,31 +158,65 @@ export function TravelerAvatars({ travelers, label }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** Avatar toggle row to filter the list by assigned traveler (#1517/#1557). */
|
||||
export function TravelerFilterRow({ members, active, onToggle, label }: {
|
||||
/**
|
||||
* Avatar toggle row to filter the list by assigned traveler (#1517/#1557).
|
||||
*
|
||||
* The avatars used to sit on their own between the toolbar and the first
|
||||
* section, which read as decoration rather than as a control: nothing said what
|
||||
* they were, and with a filter on there was no way back other than tapping the
|
||||
* same avatar again. It is a labelled bar now, closed off by the same divider
|
||||
* the rows below use, with an explicit way to clear the filter.
|
||||
*
|
||||
* Its horizontal padding follows SectionHeader rather than the sheet's usual 18,
|
||||
* so the label starts on the same line as the section titles right below it.
|
||||
*/
|
||||
export function TravelerFilterRow({ members, active, onToggle, onClear, label, allLabel }: {
|
||||
members: { id: number; username: string; avatar_url?: string | null }[]
|
||||
active: Set<number>
|
||||
onToggle: (id: number) => void
|
||||
onClear?: () => void
|
||||
label: string
|
||||
/** Resets the filter. Only rendered while one is active. */
|
||||
allLabel?: string
|
||||
}) {
|
||||
const filtering = active.size > 0
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-[18px] pb-2 pt-1" aria-label={label}>
|
||||
{members.map(m => {
|
||||
const on = active.has(m.id)
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onToggle(m.id)}
|
||||
title={m.username}
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 bg-[color:var(--m-ic)] text-[0.625rem] font-bold text-m-muted transition-opacity ${on ? 'border-[color:var(--m-act)]' : 'border-[color:var(--m-rowbr)]'} ${on || active.size === 0 ? 'opacity-100' : 'opacity-40'}`}
|
||||
>
|
||||
{m.avatar_url
|
||||
? <img src={m.avatar_url} className="h-full w-full object-cover" alt="" />
|
||||
: m.username?.[0]?.toUpperCase()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div
|
||||
className="mb-[2px] flex items-center gap-[10px] border-b border-[color:var(--m-rowbr)] px-[2px] pb-[9px] pt-[2px]"
|
||||
role="group"
|
||||
aria-label={label}
|
||||
>
|
||||
<span className="flex-none font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
{members.map(m => {
|
||||
const on = active.has(m.id)
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onToggle(m.id)}
|
||||
title={m.username}
|
||||
aria-pressed={on}
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 bg-[color:var(--m-ic)] text-[0.625rem] font-bold text-m-muted transition-opacity ${on ? 'border-[color:var(--m-act)]' : 'border-[color:var(--m-rowbr)]'} ${on || !filtering ? 'opacity-100' : 'opacity-40'}`}
|
||||
>
|
||||
{m.avatar_url
|
||||
? <img src={m.avatar_url} className="h-full w-full object-cover" alt="" />
|
||||
: m.username?.[0]?.toUpperCase()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{filtering && allLabel && onClear && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="flex-none rounded-full border border-[color:var(--m-rowbr)] px-[9px] py-[3px] font-geist text-[0.625rem] font-bold uppercase tracking-[.06em] text-m-muted"
|
||||
>
|
||||
{allLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ function makeHook(overrides: Record<string, unknown> = {}) {
|
||||
places: [{ id: 10 }],
|
||||
visiblePlaces: [{ id: 10 }],
|
||||
mappable: [{ id: 10 }],
|
||||
hasMappable: true,
|
||||
members: [],
|
||||
incomingInvites: [],
|
||||
counts: { all: 1, idea: 0, want: 1, visited: 0 },
|
||||
@@ -124,8 +125,25 @@ describe('CollectionsPage — Add place button reachability (#1485)', () => {
|
||||
})
|
||||
|
||||
it('shows the Add button on an empty collection', () => {
|
||||
mockUseCollections.mockReturnValue(makeHook({ places: [], visiblePlaces: [], mappable: [] }))
|
||||
mockUseCollections.mockReturnValue(makeHook({ places: [], visiblePlaces: [], mappable: [], hasMappable: false }))
|
||||
render(<CollectionsPage />)
|
||||
expect(screen.getAllByRole('button', { name: 'collections.addPlace' })).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CollectionsPage — the map survives a filter that matches nothing', () => {
|
||||
it('keeps the map when a label filter empties the list', () => {
|
||||
// Clicking a label with no places behind it left visiblePlaces empty, and
|
||||
// the layout hung off exactly that: the map was torn out and the page
|
||||
// reflowed to full width. A filter should empty the map, not remove it.
|
||||
mockUseCollections.mockReturnValue(makeHook({ visiblePlaces: [], mappable: [], hasMappable: true }))
|
||||
render(<CollectionsPage />)
|
||||
expect(screen.getByTestId('map')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still hides the map for a list with nothing mappable in it at all', () => {
|
||||
mockUseCollections.mockReturnValue(makeHook({ mappable: [], hasMappable: false }))
|
||||
render(<CollectionsPage />)
|
||||
expect(screen.queryByTestId('map')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,8 +59,8 @@ function CollectionsPageDesktop(): React.ReactElement {
|
||||
// + detail come into view alongside the map.
|
||||
const onMapSelect = (id: number) => { openPlace(id); if (c.view === 'map') c.setView('list') }
|
||||
|
||||
const desktopSplit = c.isWide && mappable.length > 0
|
||||
const mapShown = mappable.length > 0 && (c.view === 'map' || c.isWide)
|
||||
const desktopSplit = c.isWide && c.hasMappable
|
||||
const mapShown = c.hasMappable && (c.view === 'map' || c.isWide)
|
||||
const mapOverlay = c.isWide && mapShown // the map carries the toggle + search
|
||||
const canAddPlace = typeof c.activeId === 'number' && c.canEdit // a real list you can edit
|
||||
|
||||
@@ -79,6 +79,11 @@ function CollectionsPageDesktop(): React.ReactElement {
|
||||
)
|
||||
const mapPanel = (overlay: boolean) => (
|
||||
<CollectionMapPanel
|
||||
labelOptions={isRealList ? c.labelOptions : []}
|
||||
labelFilter={c.labelFilter}
|
||||
onLabelFilter={isRealList ? c.setLabelFilter : undefined}
|
||||
canManageLabels={canManageLabels}
|
||||
onManageLabels={() => c.setShowLabelManager(true)}
|
||||
places={mappable}
|
||||
selectedPlaceId={c.selectedPlaceId}
|
||||
onSelect={onMapSelect}
|
||||
@@ -109,7 +114,7 @@ function CollectionsPageDesktop(): React.ReactElement {
|
||||
onSortMode={c.setSortMode}
|
||||
canAddPlace={canAddPlace}
|
||||
onAddPlace={() => c.setShowAddPlace(true)}
|
||||
showLabels={isRealList}
|
||||
showLabels={isRealList && !mapShown}
|
||||
labelOptions={c.labelOptions}
|
||||
labelFilter={c.labelFilter}
|
||||
onLabelFilter={c.setLabelFilter}
|
||||
@@ -151,7 +156,7 @@ function CollectionsPageDesktop(): React.ReactElement {
|
||||
<div className="col-split-map">{mapPanel(true)}</div>
|
||||
</div>
|
||||
)
|
||||
} else if (c.view === 'map' && mappable.length > 0) {
|
||||
} else if (c.view === 'map' && c.hasMappable) {
|
||||
body = <div className="col-mapwrap">{mapPanel(false)}</div>
|
||||
} else {
|
||||
body = listColumn
|
||||
@@ -212,7 +217,7 @@ function CollectionsPageDesktop(): React.ReactElement {
|
||||
<button type="button" className="col-rail-toggle" onClick={() => c.setMobileRailOpen(true)}>
|
||||
<Bookmark size={15} /> {t('collections.title')}
|
||||
</button>
|
||||
{!c.isWide && mappable.length > 0 && (
|
||||
{!c.isWide && c.hasMappable && (
|
||||
<div className="col-viewseg" role="group" aria-label={t('collections.title')}>
|
||||
<button type="button" aria-pressed={c.view === 'list'} onClick={() => c.setView('list')} aria-label={t('collections.view.list')} title={t('collections.view.list')} className={c.view === 'list' ? 'on' : ''}>
|
||||
<ListIcon size={16} />
|
||||
|
||||
@@ -59,9 +59,11 @@ vi.mock('../components/Collections/CollectionFilterBar', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../components/Collections/CollectionMapPanel', () => ({
|
||||
default: (p: { overlay: boolean; places: { id: number }[]; onSelect: (id: number) => void; onDeselect: () => void; onToggleView: () => void; onSearch: (v: string) => void }) => (
|
||||
default: (p: { overlay: boolean; places: { id: number }[]; onSelect: (id: number) => void; onDeselect: () => void; onToggleView: () => void; onSearch: (v: string) => void; onLabelFilter?: (ids: number[]) => void; onManageLabels?: () => void }) => (
|
||||
<div data-testid={p.overlay ? 'map-overlay' : 'map-plain'}>
|
||||
<span data-testid="map-count">{p.places.length}</span>
|
||||
<span data-testid="map-has-labels">{String(p.onLabelFilter != null)}</span>
|
||||
{p.onManageLabels && <button type="button" onClick={p.onManageLabels}>map-labels</button>}
|
||||
<button type="button" onClick={() => p.onSelect(10)}>map-select</button>
|
||||
<button type="button" onClick={p.onDeselect}>map-deselect</button>
|
||||
<button type="button" onClick={p.onToggleView}>map-toggle</button>
|
||||
@@ -206,6 +208,7 @@ function makeHook(overrides: Hook = {}): Hook {
|
||||
places: [{ id: 10 }],
|
||||
visiblePlaces: [{ id: 10 }],
|
||||
mappable: [{ id: 10 }],
|
||||
hasMappable: true,
|
||||
members: [],
|
||||
incomingInvites: [],
|
||||
counts: { all: 1, idea: 0, want: 1, visited: 0 },
|
||||
@@ -340,7 +343,7 @@ describe('CollectionsPage — shell', () => {
|
||||
})
|
||||
|
||||
it('FE-PAGE-COLLPAGE-007: shows a spinner while the first places load', () => {
|
||||
renderPage({ places: [], visiblePlaces: [], mappable: [], placesLoading: true })
|
||||
renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false, placesLoading: true })
|
||||
expect(document.querySelector('.col-spinner')).not.toBeNull()
|
||||
expect(screen.queryByTestId('list')).toBeNull()
|
||||
})
|
||||
@@ -348,7 +351,7 @@ describe('CollectionsPage — shell', () => {
|
||||
|
||||
describe('CollectionsPage — body branches', () => {
|
||||
it('FE-PAGE-COLLPAGE-008: an empty list shows the empty state plus the add CTA', () => {
|
||||
const hook = renderPage({ places: [], visiblePlaces: [], mappable: [] })
|
||||
const hook = renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false })
|
||||
expect(screen.getByTestId('empty-collections')).toHaveTextContent('collections.empty.title')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.addPlace/ }))
|
||||
@@ -356,7 +359,7 @@ describe('CollectionsPage — body branches', () => {
|
||||
})
|
||||
|
||||
it('FE-PAGE-COLLPAGE-009: a viewer on an empty list gets no add CTA', () => {
|
||||
renderPage({ places: [], visiblePlaces: [], mappable: [], canEdit: false })
|
||||
renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false, canEdit: false })
|
||||
expect(screen.getByTestId('empty-collections')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /collections.addPlace/ })).toBeNull()
|
||||
})
|
||||
@@ -384,13 +387,13 @@ describe('CollectionsPage — body branches', () => {
|
||||
})
|
||||
|
||||
it('FE-PAGE-COLLPAGE-013: places without coordinates keep the list column even on a wide layout', () => {
|
||||
renderPage({ mappable: [] })
|
||||
renderPage({ mappable: [], hasMappable: false })
|
||||
expect(screen.getByTestId('list')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('map-overlay')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PAGE-COLLPAGE-014: a filter that matches nothing keeps the filter bar and shows the no-match state', () => {
|
||||
renderPage({ visiblePlaces: [], mappable: [] })
|
||||
renderPage({ visiblePlaces: [], mappable: [], hasMappable: false })
|
||||
expect(screen.getByTestId('filterbar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('empty-search')).toHaveTextContent('collections.empty.noMatchTitle')
|
||||
expect(screen.queryByTestId('list')).toBeNull()
|
||||
@@ -555,20 +558,32 @@ describe('CollectionsPage — child wiring', () => {
|
||||
expect(hook.setSelectedPlaceId).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PAGE-COLLPAGE-030: the filter bar drives add-place, label management and select mode', () => {
|
||||
it('FE-PAGE-COLLPAGE-030: the filter bar drives add-place and select mode, the map takes the labels', () => {
|
||||
const hook = renderPage()
|
||||
expect(screen.getByTestId('filter-flags')).toHaveTextContent('true|true|true')
|
||||
// Labels ride in the map's top bar while a map is on screen, so the filter
|
||||
// row is told not to draw them a second time.
|
||||
expect(screen.getByTestId('filter-flags')).toHaveTextContent('false|true|true')
|
||||
expect(screen.getByTestId('map-has-labels')).toHaveTextContent('true')
|
||||
|
||||
fireEvent.click(screen.getByText('filter-add'))
|
||||
expect(hook.setShowAddPlace).toHaveBeenCalledWith(true)
|
||||
|
||||
fireEvent.click(screen.getByText('filter-labels'))
|
||||
fireEvent.click(screen.getByText('map-labels'))
|
||||
expect(hook.setShowLabelManager).toHaveBeenCalledWith(true)
|
||||
|
||||
fireEvent.click(screen.getByText('filter-select'))
|
||||
expect(hook.setSelectMode).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-PAGE-COLLPAGE-030b: without a map the filter row keeps the labels, so they stay reachable', () => {
|
||||
const hook = renderPage({ mappable: [], hasMappable: false })
|
||||
expect(screen.getByTestId('filter-flags')).toHaveTextContent('true|true|true')
|
||||
expect(screen.queryByTestId('map-overlay')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByText('filter-labels'))
|
||||
expect(hook.setShowLabelManager).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-PAGE-COLLPAGE-031: the All-saved union has no labels and no add button in the filter row', () => {
|
||||
renderPage({ isAllSaved: true, activeId: 'all', activeCollection: null })
|
||||
expect(screen.getByTestId('filter-flags')).toHaveTextContent('false|false|true')
|
||||
|
||||
@@ -419,7 +419,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
|
||||
{heroPlugins.length > 0 && (
|
||||
<div className="hero-pass-overlay" aria-hidden="true">
|
||||
{heroPlugins.map(p => (
|
||||
<PluginFrame key={p.id} pluginId={p.id} tripId={String(trip.id)} title={p.name} className="hero-overlay-frame" />
|
||||
<PluginFrame key={p.id} pluginId={p.id} tripId={String(trip.id)} title={p.name} surface="dashboard-widget" className="hero-overlay-frame" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function PluginPage(): React.ReactElement {
|
||||
return (
|
||||
<PageShell background="var(--bg-secondary)" navOffset="var(--nav-h, 56px)">
|
||||
<div style={{ height: 'calc(100vh - var(--nav-h, 56px))' }}>
|
||||
<PluginFrame pluginId={pluginId} title={plugin?.name} fill className="w-full h-full" />
|
||||
<PluginFrame pluginId={pluginId} title={plugin?.name} fill surface="plugin-page" className="w-full h-full" />
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
|
||||
@@ -784,7 +784,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
|
||||
{activeTab.startsWith('plugin:') && (
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
|
||||
<PluginFrame pluginId={activeTab.slice('plugin:'.length)} tripId={String(tripId)} fill className="w-full h-full" />
|
||||
<PluginFrame pluginId={activeTab.slice('plugin:'.length)} tripId={String(tripId)} fill surface="trip-tab" className="w-full h-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -177,6 +177,11 @@ export function useCollections() {
|
||||
// Stable reference so the map doesn't tear down + rebuild every marker on each
|
||||
// unrelated re-render (which would swallow marker clicks mid-rebuild).
|
||||
const mappable = useMemo(() => mappablePlaces(visiblePlaces), [visiblePlaces])
|
||||
// Whether this list has anything mappable AT ALL, filters aside. The layout
|
||||
// hangs off this rather than off `mappable`: a label with no places left the
|
||||
// filtered set empty, which tore the map out and reflowed the page to full
|
||||
// width. The filter should empty the map, not remove it.
|
||||
const hasMappable = useMemo(() => mappablePlaces(places).length > 0, [places])
|
||||
const counts = useMemo(() => statusCounts(places), [places])
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────
|
||||
@@ -385,7 +390,7 @@ export function useCollections() {
|
||||
collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner,
|
||||
myRole, canEdit, canDelete,
|
||||
canShare, shareMemberCount,
|
||||
activeId, places, visiblePlaces, mappable, members, incomingInvites, counts,
|
||||
activeId, places, visiblePlaces, mappable, hasMappable, members, incomingInvites, counts,
|
||||
view, statusFilter, categoryFilter, categoryOptions, ratingFilter, sortMode, search, selectedPlaceId, selectMode, selectedIds,
|
||||
labels, labelFilter, labelOptions,
|
||||
loading, placesLoading,
|
||||
|
||||
@@ -274,9 +274,31 @@
|
||||
.trek-dash .col-lrow-label { display: inline-flex; align-items: center; gap: 5px; max-width: 120px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); }
|
||||
.trek-dash .col-lrow-label.more { color: var(--ink-3); background: var(--surface-2); }
|
||||
.trek-dash .col-labelchip-dot { width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0; background: var(--label, var(--accent)); }
|
||||
.trek-dash .col-labelfilter { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
|
||||
.trek-dash .col-labelchip:hover { background: var(--surface-2); color: var(--ink); }
|
||||
/* Labels are their own axis of filtering, so they read as one tray rather than
|
||||
as more loose buttons: a recessed strip, led by a tag glyph, holding chips
|
||||
that share the filter buttons' radius and type. The chips carry no border of
|
||||
their own — a frame inside a frame is what made this look like a pile. */
|
||||
.trek-dash .col-labelfilter {
|
||||
display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px;
|
||||
min-height: var(--col-ctl-h); padding: 3px 6px 3px 9px; border-radius: 11px;
|
||||
background: color-mix(in oklch, var(--surface-2) 70%, transparent);
|
||||
border: 1px solid var(--line-2);
|
||||
}
|
||||
.trek-dash .col-labelfilter-lead { color: var(--ink-3); flex-shrink: 0; margin-right: 2px; }
|
||||
/* On the map the tray joins the floating controls, so it takes their glass,
|
||||
their radius and their shadow instead of the row's flat surface. */
|
||||
.trek-dash .col-labelfilter.on-map {
|
||||
pointer-events: auto; max-width: min(52ch, 100%); flex-wrap: nowrap; overflow-x: auto;
|
||||
border-radius: 11px; border-color: var(--glass-border); box-shadow: var(--sh-md);
|
||||
background: color-mix(in oklch, var(--surface) 92%, transparent);
|
||||
backdrop-filter: blur(8px) saturate(1.2); -webkit-backdrop-filter: blur(8px) saturate(1.2);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.trek-dash .col-labelfilter.on-map::-webkit-scrollbar { display: none; }
|
||||
.trek-dash .col-labelfilter.on-map .col-labelchip { flex-shrink: 0; }
|
||||
.trek-dash .col-labelfilter.on-map .col-labelchip:hover { background: var(--surface-2); }
|
||||
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 5px 9px; border-radius: 8px; font-size: 12.5px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: transparent; border: 1px solid transparent; transition: background .12s, border-color .12s, color .12s; }
|
||||
.trek-dash .col-labelchip:hover { background: var(--surface); color: var(--ink); }
|
||||
.trek-dash .col-labelchip .col-filter-lbl { overflow: hidden; text-overflow: ellipsis; }
|
||||
.trek-dash .col-labelchip.on { color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--label, var(--accent)) 42%, transparent); }
|
||||
.trek-dash .col-labelchip.on .col-labelchip-dot { background: var(--label, var(--accent)); }
|
||||
@@ -284,14 +306,35 @@
|
||||
.trek-dash .col-detail-labels { display: flex; flex-wrap: wrap; gap: 7px; margin: 2px 0 10px; }
|
||||
|
||||
/* ----------------- filter bar (status + category dropdowns) ----------------- */
|
||||
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||
/* One control height for the whole page: the filter row and the buttons floating
|
||||
over the map are read as a single band when the page is scrolled to the top,
|
||||
so they share a height and a top offset instead of each having their own. */
|
||||
.trek-dash { --col-ctl-h: 38px; }
|
||||
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
/* Beside the map, the row starts where the map's own top bar does. */
|
||||
.trek-dash .col-split-list .col-filterbar { margin-top: 12px; }
|
||||
.trek-dash .col-filter { position: relative; }
|
||||
/* Icon-only add-place button: square it off so it matches the dropdowns' height. */
|
||||
.trek-dash .col-filter-add { padding: 7px; }
|
||||
/* Square icon-only buttons: as wide as they are tall, so they sit in the row
|
||||
without being the one element that is a few pixels off. */
|
||||
.trek-dash .col-filter-add,
|
||||
.trek-dash .col-filter-icon { width: var(--col-ctl-h); padding: 0; justify-content: center; }
|
||||
/* Manage/add labels sits inside the label tray, so it stays quiet until hovered. */
|
||||
.trek-dash .col-filter-addlabel {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 5px; border-radius: 8px; color: var(--ink-3);
|
||||
background: transparent; border: 1px solid transparent;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.trek-dash .col-filter-addlabel:hover { background: var(--surface); color: var(--ink); }
|
||||
.trek-dash .col-filter-select { margin-left: auto; }
|
||||
.trek-dash .col-filter-select.open { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
.trek-dash .col-filter-select.open:hover { background: var(--accent); color: var(--accent-text); }
|
||||
.trek-dash .col-filter-btn { display: inline-flex; align-items: center; gap: 7px; padding: 7px 11px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
|
||||
/* `.on`, not `.open`: on a dropdown "open" means the panel is down and the text
|
||||
stays dark, and that rule sits later in this file, so sharing the name left
|
||||
the active select button drawing a dark icon on its dark accent fill. The
|
||||
other toggles in this page (col-map-btn, col-iconbtn) already use `.on`. */
|
||||
.trek-dash .col-filter-select.on { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
.trek-dash .col-filter-select.on:hover { background: var(--accent); color: var(--accent-text); }
|
||||
.trek-dash .col-filter-btn { display: inline-flex; align-items: center; gap: 7px; height: var(--col-ctl-h); padding: 0 11px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
|
||||
.trek-dash .col-filter-btn:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.trek-dash .col-filter-btn.open { border-color: var(--accent); color: var(--ink); }
|
||||
.trek-dash .col-filter-btn .col-filter-chev { color: var(--ink-3); transition: transform .15s; }
|
||||
@@ -461,9 +504,10 @@
|
||||
.trek-dash .col-search { flex: 1; }
|
||||
.trek-dash .col-mapwrap { height: calc(100dvh - 260px); min-height: 320px; }
|
||||
|
||||
/* bigger touch targets on phones (visuals unchanged on desktop) */
|
||||
/* bigger touch targets on phones (visuals unchanged on desktop). Raising the
|
||||
shared height keeps the square icon buttons square. */
|
||||
.trek-dash { --col-ctl-h: 40px; }
|
||||
.trek-dash .col-viewseg button { width: 44px; height: 38px; }
|
||||
.trek-dash .col-filter-btn { min-height: 40px; }
|
||||
.trek-dash .col-filter-opt { min-height: 40px; }
|
||||
.trek-dash .col-selbar-btn { min-height: 40px; }
|
||||
.trek-dash .col-detail-close { width: 40px; height: 40px; }
|
||||
|
||||
@@ -64,6 +64,8 @@ function fillBasics(name: string, total: string) {
|
||||
fireEvent.change(totalField(), { target: { value: total } })
|
||||
}
|
||||
|
||||
const CAT_LABELS = ['Accommodation', 'Food & drink', 'Groceries', 'Transport', 'Flights', 'Activities', 'Sightseeing', 'Shopping', 'Fees & tickets', 'Health', 'Tips', 'Other']
|
||||
|
||||
describe('MCostSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
@@ -84,11 +86,19 @@ describe('MCostSheet', () => {
|
||||
delete (window as unknown as { __addToast?: unknown }).__addToast
|
||||
})
|
||||
|
||||
// The category moved from a row of eleven pills to a dropdown: the trigger
|
||||
// shows the current one, and the options only exist while it is open.
|
||||
const catTrigger = () => screen.getByRole('button', { name: new RegExp(`^(${CAT_LABELS.join('|')})$`) })
|
||||
const chooseCategory = (label: string) => {
|
||||
fireEvent.click(catTrigger())
|
||||
fireEvent.click(screen.getByRole('button', { name: label, pressed: false }))
|
||||
}
|
||||
|
||||
it('FE-MOB-COSTSH-001: opens in add mode with everyone in an equal split and a blocked submit', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('dialog', { name: 'Add expense' })).toBeInTheDocument()
|
||||
expect(nameField()).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: 'Food & drink' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(catTrigger()).toHaveTextContent('Food & drink')
|
||||
expect(screen.getByRole('button', { name: 'Y You' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'B bob' })).toBeInTheDocument()
|
||||
expect(submit()).toBeDisabled()
|
||||
@@ -115,15 +125,48 @@ describe('MCostSheet', () => {
|
||||
expense_date: TODAY,
|
||||
total_price: 85.5,
|
||||
note: null,
|
||||
ticket_json: null,
|
||||
})
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-020: the category dropdown opens, marks the current pick and closes on choose (#1658)', () => {
|
||||
renderSheet()
|
||||
expect(screen.queryByRole('button', { name: 'Groceries' })).toBeNull()
|
||||
|
||||
fireEvent.click(catTrigger())
|
||||
expect(screen.getByRole('button', { name: 'Food & drink', pressed: true })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Groceries', pressed: false }))
|
||||
expect(catTrigger()).toHaveTextContent('Groceries')
|
||||
expect(screen.queryByRole('button', { name: 'Sightseeing' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-021: a note typed on the phone is saved with the expense (#1658)', async () => {
|
||||
renderSheet()
|
||||
fillBasics('Pharmacy', '18')
|
||||
fireEvent.change(screen.getByPlaceholderText(/what it covered/i), { target: { value: 'Ben was ill, he pays this back' } })
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
||||
note: 'Ben was ill, he pays this back',
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-022: an existing note is loaded for editing, and a receipt blob never is (#1658)', () => {
|
||||
const { unmount } = renderSheet({ editing: buildBudgetItem({ id: 5, name: 'Chemist', note: 'reimburse from the kitty' }) })
|
||||
expect(screen.getByPlaceholderText(/what it covered/i)).toHaveValue('reimburse from the kitty')
|
||||
unmount()
|
||||
|
||||
renderSheet({ editing: buildBudgetItem({ id: 6, name: 'Market', note: 'TICKETJSON:{"items":[]}' }) })
|
||||
expect(screen.getByPlaceholderText(/what it covered/i)).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-003: adopts the prefill and carries the reservation link into the payload', async () => {
|
||||
renderSheet({ prefill: { name: 'Ryokan', category: 'accommodation', amount: 240, reservationId: 77 } })
|
||||
expect(nameField()).toHaveValue('Ryokan')
|
||||
expect(totalField()).toHaveValue('240')
|
||||
expect(screen.getByRole('button', { name: 'Accommodation' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(catTrigger()).toHaveTextContent('Accommodation')
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
||||
@@ -167,9 +210,8 @@ describe('MCostSheet', () => {
|
||||
it('FE-MOB-COSTSH-006: switching the category updates the pressed pill and the payload', async () => {
|
||||
renderSheet()
|
||||
fillBasics('Metro pass', '20')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Transport' }))
|
||||
expect(screen.getByRole('button', { name: 'Transport' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Food & drink' })).toHaveAttribute('aria-pressed', 'false')
|
||||
chooseCategory('Transport')
|
||||
expect(catTrigger()).toHaveTextContent('Transport')
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ category: 'transport' })))
|
||||
@@ -323,7 +365,7 @@ describe('MCostSheet', () => {
|
||||
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
||||
total_price: 20,
|
||||
members: [{ user_id: 1, amount: 10 }, { user_id: 2, amount: 10 }],
|
||||
note: 'TICKETJSON:{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
|
||||
ticket_json: '{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -369,7 +411,7 @@ describe('MCostSheet', () => {
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Edit expense' })).toBeInTheDocument()
|
||||
expect(nameField()).toHaveValue('Flight to Osaka')
|
||||
expect(screen.getByRole('button', { name: 'Flights' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(catTrigger()).toHaveTextContent('Flights')
|
||||
expect(screen.getByRole('button', { name: 'USD $' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Custom' })).toHaveClass('bg-m-act')
|
||||
|
||||
@@ -368,9 +368,14 @@ describe('MPlaceSheet', () => {
|
||||
const shell = makeShell({ trTab: 'dateien', view: 'plan' })
|
||||
const { planner } = renderSheet(makePlanner(), shell)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Google Maps' }))
|
||||
// A place with coordinates reaches more than one map app, so the circle
|
||||
// opens the picker instead of guessing which one was meant.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Navigation' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Google Maps' }))
|
||||
// Name and address rather than bare coordinates (#1278): the place has both,
|
||||
// so Google opens the entry instead of an unlabelled pin.
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://www.google.com/maps/search/?api=1&query=48.2038,16.3616', '_blank', 'noopener,noreferrer',
|
||||
'https://www.google.com/maps/search/?api=1&query=Kunsthistorisches%20Museum%2C%20Maria-Theresien-Platz', '_blank', 'noopener,noreferrer',
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Website' }))
|
||||
@@ -405,7 +410,7 @@ describe('MPlaceSheet', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Save to Collection' })).not.toBeInTheDocument()
|
||||
// Read-only members still get the navigation actions.
|
||||
expect(screen.getByRole('button', { name: 'Google Maps' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-026: collapses the track colour picker again when the sheet closes', () => {
|
||||
|
||||
@@ -20,8 +20,8 @@ vi.mock('../../../../src/mobile/screens/trip/tabs/MFilesTab', () => ({ default:
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabTab', () => ({ default: stub('collab') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MListsTab', () => ({ default: stub('lists') }))
|
||||
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
|
||||
default: ({ pluginId, tripId, fill }: { pluginId: string; tripId: string | null; fill?: boolean }) => (
|
||||
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-fill={String(!!fill)} />
|
||||
default: ({ pluginId, tripId, fill, surface }: { pluginId: string; tripId: string | null; fill?: boolean; surface?: string }) => (
|
||||
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-fill={String(!!fill)} data-surface={surface} />
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -71,8 +71,20 @@ describe('MTripTabPanel', () => {
|
||||
expect(frame).toHaveAttribute('data-plugin', 'trip-todos')
|
||||
expect(frame).toHaveAttribute('data-trip', '1')
|
||||
expect(frame).toHaveAttribute('data-fill', 'true')
|
||||
// The frame keeps the dock clearance the scroll body uses.
|
||||
expect((frame.parentElement as HTMLElement).style.paddingBottom).toContain('safe-area-inset-bottom')
|
||||
// Both clearances come from the variables TabScroller uses, so the frame
|
||||
// cannot drift away from the native tabs. The top one was missing entirely,
|
||||
// which put the plugin's first row under the floating back button; the bottom
|
||||
// one was a hard-coded 84 that ignored the safe area.
|
||||
const wrapper = frame.parentElement as HTMLElement
|
||||
expect(wrapper.className).toContain('--m-safe-top')
|
||||
expect(wrapper.className).toContain('--bottom-nav-h')
|
||||
})
|
||||
|
||||
it('FE-MOB-TABPANEL-007b: tells the plugin which surface it is mounted in', () => {
|
||||
// A trip tab fills and scrolls itself; a widget reports its height. The plugin
|
||||
// cannot see the difference from inside the frame.
|
||||
renderTab('plugin:trip-todos')
|
||||
expect(screen.getByTestId('plugin-frame')).toHaveAttribute('data-surface', 'trip-tab')
|
||||
})
|
||||
|
||||
it('FE-MOB-TABPANEL-008: passes a null tripId to the plugin frame before the trip is known', () => {
|
||||
|
||||
@@ -148,4 +148,54 @@ describe('tabChrome', () => {
|
||||
expect(screen.getByTitle('Ada').className).toContain('opacity-100')
|
||||
expect(screen.getByTitle('Bob').className).toContain('opacity-100')
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-015: TravelerFilterRow names itself, so the avatars are not left unexplained', () => {
|
||||
render(
|
||||
<TravelerFilterRow
|
||||
members={[{ id: 1, username: 'Ada' }]}
|
||||
active={new Set()}
|
||||
onToggle={vi.fn()}
|
||||
label="Travelers"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('group', { name: 'Travelers' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Travelers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-016: the way out of a filter only appears once one is set', () => {
|
||||
const onClear = vi.fn()
|
||||
const { rerender } = render(
|
||||
<TravelerFilterRow
|
||||
members={[{ id: 1, username: 'Ada' }]}
|
||||
active={new Set()}
|
||||
onToggle={vi.fn()}
|
||||
onClear={onClear}
|
||||
label="Travelers"
|
||||
allLabel="All"
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<TravelerFilterRow
|
||||
members={[{ id: 1, username: 'Ada' }]}
|
||||
active={new Set([1])}
|
||||
onToggle={vi.fn()}
|
||||
onClear={onClear}
|
||||
label="Travelers"
|
||||
allLabel="All"
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'All' }))
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-017: SectionHeader wraps its count exactly once', () => {
|
||||
// The callers used to pass a <CountPill> into a prop that wraps one itself,
|
||||
// which put a pill inside a pill and showed two stacked backgrounds.
|
||||
render(<SectionHeader label="Pending" count={2} open onToggle={vi.fn()} />)
|
||||
const pill = screen.getByText('2')
|
||||
expect(pill.querySelector('span')).toBeNull()
|
||||
expect(pill.parentElement?.tagName).toBe('BUTTON')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -279,6 +279,125 @@ body.trek-ui {
|
||||
@media (max-width: 639px) {
|
||||
.trek-modal-enter { animation: trek-drawer-enter 320ms cubic-bezier(0.32, 0.72, 0, 1); }
|
||||
}
|
||||
|
||||
/* Mobile layer ------------------------------------------------------------- *
|
||||
* TREK 4 gives the phone its own design: a translucent, border-led surface over
|
||||
* a gradient, no drop shadows, and touch feedback rather than hover. The host
|
||||
* hands the mobile palette (--m-*) down with the rest of the tokens whenever a
|
||||
* frame is mounted inside the mobile shell, so these rules only restate the
|
||||
* SHAPE — the colours come from the same variables the native screens use.
|
||||
*
|
||||
* Everything here is keyed on [data-form-factor="phone"], which the host sets
|
||||
* from the viewport it reports. On desktop not a single rule below applies. */
|
||||
|
||||
[data-form-factor="phone"] .trek-ui,
|
||||
[data-form-factor="phone"] body.trek-ui { font-size: 15px; }
|
||||
|
||||
/* The card is the workhorse. On the phone it is a translucent tile with a hairline
|
||||
border and no shadow — a drop shadow over the gradient reads as a sticker. */
|
||||
[data-form-factor="phone"] .trek-card {
|
||||
background: var(--m-card, var(--bg-card));
|
||||
border: 1px solid var(--m-cbr, var(--border-primary));
|
||||
border-radius: 16px;
|
||||
box-shadow: none;
|
||||
padding: 14px;
|
||||
-webkit-backdrop-filter: blur(30px) saturate(1.8);
|
||||
backdrop-filter: blur(30px) saturate(1.8);
|
||||
}
|
||||
[data-form-factor="phone"] .trek-glass {
|
||||
background: var(--m-glass, var(--glass-bg));
|
||||
border-color: var(--m-gbr, var(--glass-border));
|
||||
border-radius: 20px;
|
||||
box-shadow: none;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Touch has no hover. The lift on hover never fires on a phone and the pressed
|
||||
state never existed, so a tap gave no feedback at all. */
|
||||
[data-form-factor="phone"] .trek-card.trek-interactive:hover,
|
||||
[data-form-factor="phone"] .trek-glass.trek-interactive:hover { transform: none; box-shadow: none; }
|
||||
[data-form-factor="phone"] .trek-interactive:active,
|
||||
[data-form-factor="phone"] .trek-btn:active,
|
||||
[data-form-factor="phone"] .trek-row:active { transform: scale(.985); opacity: .9; }
|
||||
[data-form-factor="phone"] .trek-btn:hover { filter: none; }
|
||||
|
||||
/* 44px is the smallest target that reliably hits on a phone. */
|
||||
[data-form-factor="phone"] .trek-btn { min-height: 44px; border-radius: 12px; }
|
||||
[data-form-factor="phone"] .trek-row { min-height: 48px; }
|
||||
|
||||
/* An input below 16px makes iOS Safari zoom the page on focus, and the zoom does
|
||||
not come back. This is the single most common mobile bug in embedded UI. */
|
||||
[data-form-factor="phone"] .trek-input,
|
||||
[data-form-factor="phone"] .trek-select,
|
||||
[data-form-factor="phone"] .trek-textarea { font-size: 16px; min-height: 44px; border-radius: 12px; }
|
||||
|
||||
/* Ink follows the mobile palette where it exists. */
|
||||
[data-form-factor="phone"] .trek-title { color: var(--m-ink, var(--text-primary)); }
|
||||
[data-form-factor="phone"] .trek-sub,
|
||||
[data-form-factor="phone"] .trek-muted { color: var(--m-muted, var(--text-muted)); }
|
||||
|
||||
/* A filling surface owns its own scrolling: the host will not scroll for it, and
|
||||
a height report is discarded. Anchor the body and scroll the content. */
|
||||
[data-fill] .trek-ui, [data-fill] body.trek-ui { height: 100%; overflow: hidden; }
|
||||
[data-fill] .trek-scroll {
|
||||
height: 100%; overflow-y: auto; -webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Building blocks the native mobile screens are made of, so a plugin does not
|
||||
have to reverse-engineer them from screenshots. */
|
||||
.trek-eyebrow {
|
||||
font-size: 10px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase;
|
||||
color: var(--m-faint, var(--text-faint));
|
||||
}
|
||||
.trek-countpill {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px;
|
||||
background: var(--m-ic, var(--bg-tertiary)); color: var(--m-muted, var(--text-muted));
|
||||
font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.trek-sectionhead {
|
||||
display: flex; align-items: center; gap: 7px; width: 100%;
|
||||
margin: 15px 0 2px; padding: 0 2px; background: none; border: 0;
|
||||
font: inherit; text-align: left; cursor: pointer;
|
||||
}
|
||||
.trek-statusdot { width: 8px; height: 8px; border-radius: 999px; flex: none; background: var(--m-st-neutral, var(--text-faint)); }
|
||||
.trek-statusdot[data-status="confirmed"] { background: var(--m-st-confirmed, var(--success)); }
|
||||
.trek-statusdot[data-status="pending"] { background: var(--m-st-pending, var(--warning)); }
|
||||
.trek-statusdot[data-status="info"] { background: var(--m-st-info, var(--info)); }
|
||||
.trek-statusdot[data-status="danger"] { background: var(--m-st-danger, var(--danger)); }
|
||||
.trek-iconbtn {
|
||||
display: inline-grid; place-items: center; flex: none;
|
||||
width: 38px; height: 38px; border-radius: 999px; cursor: pointer;
|
||||
background: var(--m-ic, var(--bg-tertiary));
|
||||
border: 1px solid var(--m-gbr, var(--border-primary));
|
||||
color: var(--m-ink, var(--text-primary));
|
||||
}
|
||||
.trek-iconbtn:active { transform: scale(.94); }
|
||||
.trek-field { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.trek-field > .trek-field-label {
|
||||
font-size: 9px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase;
|
||||
color: var(--m-faint, var(--text-faint));
|
||||
}
|
||||
.trek-field > .trek-field-value {
|
||||
font-size: 13px; font-weight: 600; color: var(--m-ink, var(--text-primary));
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.trek-segmented {
|
||||
display: inline-flex; align-items: center; gap: 2px; padding: 3px; border-radius: 999px;
|
||||
background: var(--m-glass, var(--bg-tertiary));
|
||||
border: 1px solid var(--m-gbr, var(--border-primary));
|
||||
}
|
||||
.trek-segmented > button {
|
||||
border: 0; background: none; cursor: pointer; font: inherit;
|
||||
padding: 8px 16px; border-radius: 999px; white-space: nowrap;
|
||||
font-size: 13px; font-weight: 500; color: var(--m-ink, var(--text-primary));
|
||||
}
|
||||
.trek-segmented > button[aria-pressed="true"],
|
||||
.trek-segmented > button.on {
|
||||
background: var(--m-act, var(--accent)); color: var(--m-actfg, var(--accent-text)); font-weight: 600;
|
||||
}
|
||||
|
||||
.trek-backdrop-enter { animation: trek-backdrop-enter 180ms var(--trek-ease-quint); }
|
||||
.trek-toast-enter { animation: trek-toast-enter 260ms var(--trek-ease-quint); will-change: transform, opacity; }
|
||||
.trek-pie-reveal {
|
||||
@@ -371,6 +490,18 @@ export const TREK_THEME_JS = `(function () {
|
||||
if (m.theme) { docEl.setAttribute('data-theme', m.theme); }
|
||||
if (m.locale) { docEl.setAttribute('lang', m.locale); }
|
||||
docEl.setAttribute('dir', m.dir === 'rtl' ? 'rtl' : 'ltr');
|
||||
// Where this frame sits, and in what shape. data-form-factor drives the
|
||||
// mobile layer of this stylesheet; data-surface lets a plugin tell a
|
||||
// full-height tab from a widget that reports its height. The insets are the
|
||||
// space the HOST already keeps clear — exposed so a plugin can align its own
|
||||
// sticky chrome with the host's, not so it adds padding on top.
|
||||
var vp = m.viewport || {};
|
||||
if (vp.formFactor) { docEl.setAttribute('data-form-factor', vp.formFactor); }
|
||||
if (vp.surface) { docEl.setAttribute('data-surface', vp.surface); }
|
||||
setFlag('data-fill', vp.fill);
|
||||
var ins = vp.insets || {};
|
||||
docEl.style.setProperty('--trek-inset-top', (ins.top || 0) + 'px');
|
||||
docEl.style.setProperty('--trek-inset-bottom', (ins.bottom || 0) + 'px');
|
||||
var t = m.tokens || {};
|
||||
for (var k in t) {
|
||||
if (Object.prototype.hasOwnProperty.call(t, k) && t[k]) { docEl.style.setProperty(k, t[k]); }
|
||||
|
||||
@@ -3841,6 +3841,21 @@ function runMigrations(db: Database.Database): void {
|
||||
insert.run(key);
|
||||
}
|
||||
},
|
||||
|
||||
// Free the `note` column on budget items for its actual purpose (#1658).
|
||||
// The costs UI stores an itemized receipt in it as `TICKETJSON:{...}`, which
|
||||
// means an expense split by receipt can never carry a written note, and
|
||||
// saving an expense any other way wipes whatever was typed in the budget
|
||||
// table. The receipt moves to its own column and note becomes text again.
|
||||
() => {
|
||||
const hasTicket = db.prepare("SELECT 1 FROM pragma_table_info('budget_items') WHERE name = 'ticket_json'").get();
|
||||
if (!hasTicket) db.exec('ALTER TABLE budget_items ADD COLUMN ticket_json TEXT');
|
||||
db.exec(`
|
||||
UPDATE budget_items
|
||||
SET ticket_json = substr(note, 12), note = NULL
|
||||
WHERE note LIKE 'TICKETJSON:%'
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -17,6 +17,29 @@ type SettlementRow = {
|
||||
to_username: string; to_avatar: string | null;
|
||||
};
|
||||
|
||||
/** How the costs UI used to smuggle an itemized receipt through the note field. */
|
||||
const LEGACY_TICKET_PREFIX = 'TICKETJSON:';
|
||||
|
||||
/**
|
||||
* Keep a written note and an itemized receipt out of each other's way (#1658).
|
||||
*
|
||||
* The receipt has its own column since migration 186, but a client that predates
|
||||
* it still sends the blob as `note`. Such a payload says nothing about the note
|
||||
* the user typed, so the note is reported as untouched (`undefined`) rather than
|
||||
* overwritten — an old tab left open must not erase a note written elsewhere.
|
||||
*
|
||||
* `undefined` means "caller did not speak about this field"; `null` means "clear it".
|
||||
*/
|
||||
export function splitLegacyTicketNote(
|
||||
note: string | null | undefined,
|
||||
ticket: string | null | undefined,
|
||||
): { note: string | null | undefined; ticket: string | null | undefined } {
|
||||
if (typeof note === 'string' && note.startsWith(LEGACY_TICKET_PREFIX)) {
|
||||
return { note: undefined, ticket: note.slice(LEGACY_TICKET_PREFIX.length) };
|
||||
}
|
||||
return { note, ticket };
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget domain service — owns the budget SQL (moved from the legacy
|
||||
* services/budgetService.ts: identical statements, the `||` falsy-coercion
|
||||
@@ -276,6 +299,7 @@ export class BudgetService {
|
||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||
members?: { user_id: number; amount?: number | null }[];
|
||||
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
|
||||
ticket_json?: string | null;
|
||||
reservation_id?: number | null;
|
||||
},
|
||||
) {
|
||||
@@ -303,8 +327,10 @@ export class BudgetService {
|
||||
const knownIds = data.member_ids ? this.knownUserIds(data.member_ids) : null;
|
||||
const memberIds = data.member_ids && knownIds ? data.member_ids.filter(uid => knownIds.has(uid)) : undefined;
|
||||
|
||||
const { note, ticket } = splitLegacyTicketNote(data.note, data.ticket_json);
|
||||
|
||||
const result = this.db.run(
|
||||
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, ticket_json, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
tripId,
|
||||
cat,
|
||||
data.name,
|
||||
@@ -313,7 +339,8 @@ export class BudgetService {
|
||||
data.exchange_rate != null ? data.exchange_rate : 1,
|
||||
memberIds ? memberIds.length : (data.persons != null ? data.persons : null),
|
||||
data.days !== undefined && data.days !== null ? data.days : null,
|
||||
data.note || null,
|
||||
note || null,
|
||||
ticket || null,
|
||||
sortOrder,
|
||||
data.expense_date || null,
|
||||
data.reservation_id != null ? data.reservation_id : null,
|
||||
@@ -364,12 +391,19 @@ export class BudgetService {
|
||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||
members?: { user_id: number; amount?: number | null }[];
|
||||
persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null;
|
||||
ticket_json?: string | null;
|
||||
},
|
||||
) {
|
||||
return this.db.transaction(() => {
|
||||
const item = this.db.get('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
if (!item) return null;
|
||||
|
||||
// An old client sending a receipt in `note` still lands in ticket_json, and
|
||||
// its note is left untouched rather than clobbered with the receipt blob.
|
||||
const { note, ticket } = splitLegacyTicketNote(data.note, data.ticket_json);
|
||||
const noteTouched = data.note !== undefined && note !== undefined;
|
||||
const ticketTouched = data.ticket_json !== undefined || ticket !== undefined;
|
||||
|
||||
this.db.run(`
|
||||
UPDATE budget_items SET
|
||||
category = COALESCE(?, category),
|
||||
@@ -380,6 +414,7 @@ export class BudgetService {
|
||||
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
|
||||
days = CASE WHEN ? THEN ? ELSE days END,
|
||||
note = CASE WHEN ? THEN ? ELSE note END,
|
||||
ticket_json = CASE WHEN ? THEN ? ELSE ticket_json END,
|
||||
sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END,
|
||||
expense_date = CASE WHEN ? THEN ? ELSE expense_date END
|
||||
WHERE id = ?
|
||||
@@ -391,7 +426,8 @@ export class BudgetService {
|
||||
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
|
||||
data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
|
||||
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
|
||||
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
|
||||
noteTouched ? 1 : 0, noteTouched ? note : null,
|
||||
ticketTouched ? 1 : 0, ticketTouched ? ticket : null,
|
||||
data.sort_order !== undefined ? 1 : null, data.sort_order !== undefined ? data.sort_order : 0,
|
||||
data.expense_date !== undefined ? 1 : 0, data.expense_date !== undefined ? (data.expense_date || null) : null,
|
||||
id,
|
||||
|
||||
@@ -260,6 +260,8 @@ export interface CommonsCandidate {
|
||||
interface GooglePlaceResult {
|
||||
id: string;
|
||||
displayName?: { text: string };
|
||||
/** OPERATIONAL | CLOSED_TEMPORARILY | CLOSED_PERMANENTLY. Absent on non-business results. */
|
||||
businessStatus?: string;
|
||||
formattedAddress?: string;
|
||||
location?: { latitude: number; longitude: number };
|
||||
rating?: number;
|
||||
@@ -806,6 +808,13 @@ export class MapsService {
|
||||
const tags = el.tags || {};
|
||||
const name = tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || null;
|
||||
if (!name) continue; // unnamed POIs aren't useful to add to a plan
|
||||
// A shut-down place is not somewhere to plan a visit (#1341). OSM usually
|
||||
// re-tags one with a `disused:`/`abandoned:` prefix, and those never match
|
||||
// the selectors above — but plenty keep their original tag and gain a marker
|
||||
// instead, and those do come back. `opening_hours=closed`/`off` is the same
|
||||
// statement in the hours field.
|
||||
if (tags.disused === 'yes' || tags.abandoned === 'yes') continue;
|
||||
if (tags.opening_hours === 'closed' || tags.opening_hours === 'off') continue;
|
||||
const lat = el.lat ?? el.center?.lat;
|
||||
const lng = el.lon ?? el.center?.lon;
|
||||
if (lat == null || lng == null) continue;
|
||||
@@ -1404,7 +1413,7 @@ export class MapsService {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask':
|
||||
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri',
|
||||
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri,places.businessStatus',
|
||||
},
|
||||
body: JSON.stringify(searchBody),
|
||||
});
|
||||
@@ -1417,7 +1426,13 @@ export class MapsService {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const places = (data.places || []).map((p: GooglePlaceResult) => ({
|
||||
// A place that has shut down for good is never the answer to "where should we
|
||||
// go" (#1341). Temporarily closed stays: a restaurant on holiday next month is
|
||||
// still worth planning around. Anything without the field is a non-business
|
||||
// result (a park, a viewpoint) and is kept.
|
||||
const places = (data.places || [])
|
||||
.filter((p: GooglePlaceResult) => p.businessStatus !== 'CLOSED_PERMANENTLY')
|
||||
.map((p: GooglePlaceResult) => ({
|
||||
google_place_id: p.id,
|
||||
google_ftid: googleFtidFromMapsUrl(p.googleMapsUri),
|
||||
name: p.displayName?.text || '',
|
||||
|
||||
@@ -135,6 +135,8 @@ export interface BudgetItem {
|
||||
persons?: number | null;
|
||||
days?: number | null;
|
||||
note?: string | null;
|
||||
/** Itemized receipt for a per-item split, as JSON. Its own column since #1658. */
|
||||
ticket_json?: string | null;
|
||||
reservation_id?: number | null;
|
||||
paid_by_user_id?: number | null;
|
||||
expense_date?: string | null;
|
||||
|
||||
@@ -468,6 +468,84 @@ describe('post-fold quirk fixes', () => {
|
||||
expect(budget.listSettlements(trip.id)[0].from_username).toBe('Alice Displayed');
|
||||
});
|
||||
|
||||
// ── Notes vs. itemized receipts (#1658) ────────────────────────────────────
|
||||
|
||||
it('BUDGET-SVC-DB-021: a note and a receipt are stored in their own columns', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const item = budget.createBudgetItem(trip.id, {
|
||||
name: 'Groceries',
|
||||
total_price: 40,
|
||||
note: 'Lisa pays half of this back',
|
||||
ticket_json: '{"items":[{"name":"Bread","price":"3","parts":[1]}]}',
|
||||
});
|
||||
|
||||
const row = testDb.prepare('SELECT note, ticket_json FROM budget_items WHERE id = ?').get(item!.id) as {
|
||||
note: string | null; ticket_json: string | null;
|
||||
};
|
||||
expect(row.note).toBe('Lisa pays half of this back');
|
||||
expect(JSON.parse(row.ticket_json!).items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-022: an update that omits note leaves the stored one alone', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 20, note: 'split with Ben' });
|
||||
|
||||
// What the mobile sheet sends: notes are written on desktop, so it never
|
||||
// speaks about the field.
|
||||
budget.updateBudgetItem(item!.id, trip.id, { total_price: 25 });
|
||||
|
||||
const row = testDb.prepare('SELECT note FROM budget_items WHERE id = ?').get(item!.id) as { note: string | null };
|
||||
expect(row.note).toBe('split with Ben');
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-023: an explicit null clears the note', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 12, note: 'airport run' });
|
||||
|
||||
budget.updateBudgetItem(item!.id, trip.id, { note: null });
|
||||
|
||||
const row = testDb.prepare('SELECT note FROM budget_items WHERE id = ?').get(item!.id) as { note: string | null };
|
||||
expect(row.note).toBeNull();
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-024: a pre-#1658 client sending the receipt as a note cannot erase the note', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Supermarket', total_price: 30, note: 'reimburse from the kitty' });
|
||||
|
||||
// An old tab, still encoding the receipt into `note`.
|
||||
budget.updateBudgetItem(item!.id, trip.id, {
|
||||
note: 'TICKETJSON:{"items":[{"name":"Milk","price":"2","parts":[1]}]}',
|
||||
});
|
||||
|
||||
const row = testDb.prepare('SELECT note, ticket_json FROM budget_items WHERE id = ?').get(item!.id) as {
|
||||
note: string | null; ticket_json: string | null;
|
||||
};
|
||||
expect(row.note).toBe('reimburse from the kitty');
|
||||
expect(JSON.parse(row.ticket_json!).items[0].name).toBe('Milk');
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-025: the same legacy payload on create lands in the receipt column', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const item = budget.createBudgetItem(trip.id, {
|
||||
name: 'Market',
|
||||
total_price: 9,
|
||||
note: 'TICKETJSON:{"items":[{"name":"Cheese","price":"9","parts":[1]}]}',
|
||||
});
|
||||
|
||||
const row = testDb.prepare('SELECT note, ticket_json FROM budget_items WHERE id = ?').get(item!.id) as {
|
||||
note: string | null; ticket_json: string | null;
|
||||
};
|
||||
expect(row.note).toBeNull();
|
||||
expect(JSON.parse(row.ticket_json!).items[0].name).toBe('Cheese');
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-020: a failing item insert rolls back the category-order side write (quirk fix)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -804,6 +804,25 @@ describe('searchOverpassPois localized names (#1655)', () => {
|
||||
expect(pois[0].name).toBe('Elephant Obelisk');
|
||||
});
|
||||
|
||||
it('skips POIs tagged as closed for good (#1341)', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
elements: [
|
||||
{ type: 'node', id: 1, lat: 41.9, lon: 12.48, tags: { name: 'Gone Forever', tourism: 'attraction', disused: 'yes' } },
|
||||
{ type: 'node', id: 2, lat: 41.9, lon: 12.49, tags: { name: 'Ruin', tourism: 'attraction', abandoned: 'yes' } },
|
||||
{ type: 'node', id: 3, lat: 41.9, lon: 12.5, tags: { name: 'Shut', tourism: 'attraction', opening_hours: 'closed' } },
|
||||
{ type: 'node', id: 4, lat: 41.9, lon: 12.51, tags: { name: 'Open For Business', tourism: 'attraction' } },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const { pois } = await svc.searchOverpassPois('sights', bbox(5), 'en-US');
|
||||
expect(pois.map((p: any) => p.name)).toEqual(['Open For Business']);
|
||||
});
|
||||
|
||||
it('falls back to the native name when no localized tag exists', async () => {
|
||||
stubOverpass({ name: tags.name, tourism: 'attraction' });
|
||||
const { pois } = await svc.searchOverpassPois('sights', bbox(4), 'fr-FR');
|
||||
@@ -1162,6 +1181,27 @@ describe('searchPlaces (fetch stubbed)', () => {
|
||||
expect(place.phone).toBeNull();
|
||||
});
|
||||
|
||||
it('MAPS-183: drops permanently closed Google results and keeps the rest (#1341)', async () => {
|
||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
places: [
|
||||
{ id: 'gone', displayName: { text: 'Shuttered Bistro' }, businessStatus: 'CLOSED_PERMANENTLY' },
|
||||
{ id: 'holiday', displayName: { text: 'Winter Break Cafe' }, businessStatus: 'CLOSED_TEMPORARILY' },
|
||||
{ id: 'open', displayName: { text: 'Still Trading' }, businessStatus: 'OPERATIONAL' },
|
||||
{ id: 'park', displayName: { text: 'City Park' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const result = await svc.searchPlaces(1, 'bistro');
|
||||
expect(result.places.map((p: any) => p.google_place_id)).toEqual(['holiday', 'open', 'park']);
|
||||
// The field has to be requested or it never arrives to filter on.
|
||||
const mask = (fetchMock.mock.calls[0][1].headers as Record<string, string>)['X-Goog-FieldMask'];
|
||||
expect(mask).toContain('places.businessStatus');
|
||||
});
|
||||
|
||||
it('MAPS-107: keeps 0 coordinates (equator / prime meridian) instead of nulling them', async () => {
|
||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -113,6 +113,8 @@ export const budgetItemSchema = z.object({
|
||||
persons: z.number().nullable().optional(),
|
||||
days: z.number().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
/** Itemized receipt behind a per-item split, as JSON. Its own column since #1658. */
|
||||
ticket_json: z.string().nullable().optional(),
|
||||
reservation_id: z.number().nullable().optional(),
|
||||
paid_by_user_id: z.number().nullable().optional(),
|
||||
expense_date: z.string().nullable().optional(),
|
||||
@@ -148,6 +150,7 @@ export const budgetCreateItemRequestSchema = z.object({
|
||||
persons: z.number().nullable().optional(),
|
||||
days: z.number().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
ticket_json: z.string().nullable().optional(),
|
||||
expense_date: z.string().nullable().optional(),
|
||||
// Link this expense to a reservation (e.g. created from a booking's
|
||||
// "add expense" flow). The server stores it on budget_items.reservation_id.
|
||||
@@ -168,6 +171,7 @@ export const budgetUpdateItemRequestSchema = z.object({
|
||||
persons: z.number().nullable().optional(),
|
||||
days: z.number().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
ticket_json: z.string().nullable().optional(),
|
||||
expense_date: z.string().nullable().optional(),
|
||||
});
|
||||
export type BudgetUpdateItemRequest = z.infer<typeof budgetUpdateItemRequestSchema>;
|
||||
|
||||
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': 'تراجع',
|
||||
'costs.whatFor': 'لأجل ماذا كان؟',
|
||||
'costs.namePlaceholder': 'مثل: عشاء، هدايا تذكارية، وقود…',
|
||||
'costs.note': 'ملاحظة',
|
||||
'costs.notePlaceholder': 'ما الذي شملته، ومن يدين بماذا…',
|
||||
'costs.addNote': 'إضافة ملاحظة',
|
||||
'costs.showNote': 'عرض الملاحظة',
|
||||
'costs.hideNote': 'إخفاء الملاحظة',
|
||||
'costs.totalAmount': 'المبلغ الإجمالي',
|
||||
'costs.currency': 'العملة',
|
||||
'costs.day': 'اليوم',
|
||||
|
||||
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
|
||||
'inspector.confirmedRes': 'حجز مؤكد',
|
||||
'inspector.pendingRes': 'حجز قيد الانتظار',
|
||||
'inspector.google': 'Google Maps',
|
||||
'inspector.navigation': 'التنقل',
|
||||
'inspector.openWith': 'الفتح باستخدام',
|
||||
'inspector.openStreetMap': 'OpenStreetMap',
|
||||
'inspector.website': 'فتح الموقع الإلكتروني',
|
||||
'inspector.saveToCollection': 'حفظ في مجموعة',
|
||||
|
||||
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
|
||||
'places.unplanned': 'غير مخطط',
|
||||
'places.planned': 'مخطط لها',
|
||||
'places.filterTracks': 'المسارات',
|
||||
'places.sortByRating': 'الترتيب حسب التقييم',
|
||||
'places.filterByRating': 'تصفية حسب التقييم',
|
||||
'places.yourRating': 'تقييمك',
|
||||
'places.notRated': 'لم يُقيَّم بعد',
|
||||
'places.search': 'ابحث عن أماكن...',
|
||||
|
||||
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': 'Desfazer',
|
||||
'costs.whatFor': 'Para que foi?',
|
||||
'costs.namePlaceholder': 'ex.: jantar, lembranças, gasolina…',
|
||||
'costs.note': 'Nota',
|
||||
'costs.notePlaceholder': 'O que incluía, quem deve o quê…',
|
||||
'costs.addNote': 'Adicionar nota',
|
||||
'costs.showNote': 'Mostrar nota',
|
||||
'costs.hideNote': 'Ocultar nota',
|
||||
'costs.totalAmount': 'Valor total',
|
||||
'costs.currency': 'Moeda',
|
||||
'costs.day': 'Dia',
|
||||
|
||||
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
|
||||
'inspector.confirmedRes': 'Reserva confirmada',
|
||||
'inspector.pendingRes': 'Reserva pendente',
|
||||
'inspector.google': 'Google Maps',
|
||||
'inspector.navigation': 'Navegação',
|
||||
'inspector.openWith': 'Abrir com',
|
||||
'inspector.openStreetMap': 'OpenStreetMap',
|
||||
'inspector.saveToCollection': 'Salvar na Coleção',
|
||||
'inspector.savedToCollection': 'Salvo',
|
||||
|
||||
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
|
||||
'places.unplanned': 'Não planejados',
|
||||
'places.planned': 'Planejados',
|
||||
'places.filterTracks': 'Trilhas',
|
||||
'places.sortByRating': 'Ordenar por avaliação',
|
||||
'places.filterByRating': 'Filtrar por avaliação',
|
||||
'places.yourRating': 'Sua avaliação',
|
||||
'places.notRated': 'Ainda sem avaliações',
|
||||
'places.search': 'Buscar lugares...',
|
||||
|
||||
@@ -87,6 +87,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': 'Desfer',
|
||||
'costs.whatFor': 'Per a què ha estat?',
|
||||
'costs.namePlaceholder': 'p. ex. Sopar, records, benzina…',
|
||||
'costs.note': 'Nota',
|
||||
'costs.notePlaceholder': 'Què incloïa, qui deu què…',
|
||||
'costs.addNote': 'Afegeix una nota',
|
||||
'costs.showNote': 'Mostra la nota',
|
||||
'costs.hideNote': 'Amaga la nota',
|
||||
'costs.totalAmount': 'Import total',
|
||||
'costs.currency': 'Divisa',
|
||||
'costs.day': 'Dia',
|
||||
|
||||
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
|
||||
'inspector.confirmedRes': 'Reserva confirmada',
|
||||
'inspector.pendingRes': 'Reserva pendent',
|
||||
'inspector.google': 'Obre a Google Maps',
|
||||
'inspector.navigation': 'Navegació',
|
||||
'inspector.openWith': 'Obre amb',
|
||||
'inspector.website': 'Obre el lloc web',
|
||||
'inspector.addRes': 'Reserva',
|
||||
'inspector.editRes': 'Edita la reserva',
|
||||
|
||||
@@ -43,7 +43,7 @@ const places: TranslationStrings = {
|
||||
'places.unplanned': 'Sense planificar',
|
||||
'places.planned': 'Planificats',
|
||||
'places.filterTracks': 'Rutes',
|
||||
'places.sortByRating': 'Ordena per valoració',
|
||||
'places.filterByRating': 'Filtra per valoració',
|
||||
'places.yourRating': 'La teva valoració',
|
||||
'places.notRated': 'Encara sense valoració',
|
||||
'places.search': 'Cerca llocs...',
|
||||
|
||||
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': 'Vrátit zpět',
|
||||
'costs.whatFor': 'Za co to bylo?',
|
||||
'costs.namePlaceholder': 'např. večeře, suvenýry, benzín…',
|
||||
'costs.note': 'Poznámka',
|
||||
'costs.notePlaceholder': 'Co zahrnovalo, kdo co dluží…',
|
||||
'costs.addNote': 'Přidat poznámku',
|
||||
'costs.showNote': 'Zobrazit poznámku',
|
||||
'costs.hideNote': 'Skrýt poznámku',
|
||||
'costs.totalAmount': 'Celková částka',
|
||||
'costs.currency': 'Měna',
|
||||
'costs.day': 'Den',
|
||||
|
||||
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
|
||||
'inspector.confirmedRes': 'Potvrzená rezervace',
|
||||
'inspector.pendingRes': 'Čekající rezervace',
|
||||
'inspector.google': 'Google Maps',
|
||||
'inspector.navigation': 'Navigace',
|
||||
'inspector.openWith': 'Otevřít v',
|
||||
'inspector.openStreetMap': 'OpenStreetMap',
|
||||
'inspector.website': 'Otevřít webové stránky',
|
||||
'inspector.saveToCollection': 'Uložit do sbírky',
|
||||
|
||||
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
|
||||
'places.unplanned': 'Nezařazené',
|
||||
'places.planned': 'Naplánované',
|
||||
'places.filterTracks': 'Trasy',
|
||||
'places.sortByRating': 'Seřadit podle hodnocení',
|
||||
'places.filterByRating': 'Filtrovat podle hodnocení',
|
||||
'places.yourRating': 'Tvé hodnocení',
|
||||
'places.notRated': 'Zatím bez hodnocení',
|
||||
'places.search': 'Hledat místa...',
|
||||
|
||||
@@ -92,6 +92,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': 'Rückgängig',
|
||||
'costs.whatFor': 'Wofür war es?',
|
||||
'costs.namePlaceholder': 'z.B. Abendessen, Souvenirs, Benzin…',
|
||||
'costs.note': 'Notiz',
|
||||
'costs.notePlaceholder': 'Was darin enthalten war, wer was zurückzahlt…',
|
||||
'costs.addNote': 'Notiz hinzufügen',
|
||||
'costs.showNote': 'Notiz anzeigen',
|
||||
'costs.hideNote': 'Notiz ausblenden',
|
||||
'costs.totalAmount': 'Gesamtbetrag',
|
||||
'costs.currency': 'Währung',
|
||||
'costs.day': 'Tag',
|
||||
|
||||
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
|
||||
'inspector.confirmedRes': 'Bestätigte Reservierung',
|
||||
'inspector.pendingRes': 'Ausstehende Reservierung',
|
||||
'inspector.google': 'Google Maps',
|
||||
'inspector.navigation': 'Navigation',
|
||||
'inspector.openWith': 'Öffnen mit',
|
||||
'inspector.openStreetMap': 'OpenStreetMap',
|
||||
'inspector.website': 'Webseite öffnen',
|
||||
'inspector.saveToCollection': 'In Sammlung speichern',
|
||||
|
||||
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
|
||||
'places.unplanned': 'Ungeplant',
|
||||
'places.planned': 'Geplant',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.sortByRating': 'Nach Bewertung sortieren',
|
||||
'places.filterByRating': 'Nach Bewertung filtern',
|
||||
'places.yourRating': 'Deine Bewertung',
|
||||
'places.notRated': 'Noch nicht bewertet',
|
||||
'places.search': 'Orte suchen...',
|
||||
|
||||
@@ -92,6 +92,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': 'Undo',
|
||||
'costs.whatFor': 'What was it for?',
|
||||
'costs.namePlaceholder': 'e.g. Dinner, souvenirs, gas…',
|
||||
'costs.note': 'Note',
|
||||
'costs.notePlaceholder': 'What it covered, who owes what back…',
|
||||
'costs.addNote': 'Add note',
|
||||
'costs.showNote': 'Show note',
|
||||
'costs.hideNote': 'Hide note',
|
||||
'costs.totalAmount': 'Total amount',
|
||||
'costs.currency': 'Currency',
|
||||
'costs.day': 'Day',
|
||||
|
||||
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
|
||||
'inspector.confirmedRes': 'Confirmed Reservation',
|
||||
'inspector.pendingRes': 'Pending Reservation',
|
||||
'inspector.google': 'Google Maps',
|
||||
'inspector.navigation': 'Navigation',
|
||||
'inspector.openWith': 'Open with',
|
||||
'inspector.openStreetMap': 'OpenStreetMap',
|
||||
'inspector.website': 'Open Website',
|
||||
'inspector.saveToCollection': 'Save to Collection',
|
||||
|
||||
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
|
||||
'places.unplanned': 'Unplanned',
|
||||
'places.planned': 'Planned',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.sortByRating': 'Sort by rating',
|
||||
'places.filterByRating': 'Filter by rating',
|
||||
'places.yourRating': 'Your rating',
|
||||
'places.notRated': 'Not rated yet',
|
||||
'places.search': 'Search places...',
|
||||
|
||||
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': 'Deshacer',
|
||||
'costs.whatFor': '¿Para qué fue?',
|
||||
'costs.namePlaceholder': 'p. ej. Cena, souvenirs, gasolina…',
|
||||
'costs.note': 'Nota',
|
||||
'costs.notePlaceholder': 'Qué incluía, quién debe qué…',
|
||||
'costs.addNote': 'Añadir nota',
|
||||
'costs.showNote': 'Mostrar nota',
|
||||
'costs.hideNote': 'Ocultar nota',
|
||||
'costs.totalAmount': 'Importe total',
|
||||
'costs.currency': 'Moneda',
|
||||
'costs.day': 'Día',
|
||||
|
||||
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
|
||||
'inspector.confirmedRes': 'Reserva confirmada',
|
||||
'inspector.pendingRes': 'Reserva pendiente',
|
||||
'inspector.google': 'Google Maps',
|
||||
'inspector.navigation': 'Navegación',
|
||||
'inspector.openWith': 'Abrir con',
|
||||
'inspector.openStreetMap': 'OpenStreetMap',
|
||||
'inspector.website': 'Abrir la web',
|
||||
'inspector.saveToCollection': 'Guardar en colección',
|
||||
|
||||
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
|
||||
'places.unplanned': 'Sin planificar',
|
||||
'places.planned': 'Planificados',
|
||||
'places.filterTracks': 'Rutas',
|
||||
'places.sortByRating': 'Ordenar por valoración',
|
||||
'places.filterByRating': 'Filtrar por valoración',
|
||||
'places.yourRating': 'Tu valoración',
|
||||
'places.notRated': 'Sin valorar todavía',
|
||||
'places.search': 'Buscar lugares...',
|
||||
|
||||
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': 'Annuler',
|
||||
'costs.whatFor': "C'était pour quoi ?",
|
||||
'costs.namePlaceholder': 'ex. dîner, souvenirs, essence…',
|
||||
'costs.note': 'Note',
|
||||
'costs.notePlaceholder': 'Ce que ça couvrait, qui doit quoi…',
|
||||
'costs.addNote': 'Ajouter une note',
|
||||
'costs.showNote': 'Afficher la note',
|
||||
'costs.hideNote': 'Masquer la note',
|
||||
'costs.totalAmount': 'Montant total',
|
||||
'costs.currency': 'Devise',
|
||||
'costs.day': 'Jour',
|
||||
|
||||
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
|
||||
'inspector.confirmedRes': 'Réservation confirmée',
|
||||
'inspector.pendingRes': 'Réservation en attente',
|
||||
'inspector.google': 'Google Maps',
|
||||
'inspector.navigation': 'Navigation',
|
||||
'inspector.openWith': 'Ouvrir avec',
|
||||
'inspector.openStreetMap': 'OpenStreetMap',
|
||||
'inspector.website': 'Ouvrir le site web',
|
||||
'inspector.saveToCollection': 'Enregistrer dans une collection',
|
||||
|
||||
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
|
||||
'places.unplanned': 'Non planifiés',
|
||||
'places.planned': 'Planifiés',
|
||||
'places.filterTracks': 'Traces',
|
||||
'places.sortByRating': 'Trier par note',
|
||||
'places.filterByRating': 'Filtrer par note',
|
||||
'places.yourRating': 'Ta note',
|
||||
'places.notRated': 'Pas encore noté',
|
||||
'places.search': 'Rechercher des lieux…',
|
||||
|
||||
@@ -90,6 +90,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': 'Αναίρεση',
|
||||
'costs.whatFor': 'Για τι ήταν;',
|
||||
'costs.namePlaceholder': 'π.χ. Δείπνο, σουβενίρ, βενζίνη…',
|
||||
'costs.note': 'Σημείωση',
|
||||
'costs.notePlaceholder': 'Τι κάλυπτε, ποιος χρωστάει τι…',
|
||||
'costs.addNote': 'Προσθήκη σημείωσης',
|
||||
'costs.showNote': 'Εμφάνιση σημείωσης',
|
||||
'costs.hideNote': 'Απόκρυψη σημείωσης',
|
||||
'costs.totalAmount': 'Συνολικό ποσό',
|
||||
'costs.currency': 'Νόμισμα',
|
||||
'costs.day': 'Ημέρα',
|
||||
|
||||
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
|
||||
'inspector.confirmedRes': 'Επιβεβαιωμένη Κράτηση',
|
||||
'inspector.pendingRes': 'Εκκρεμής Κράτηση',
|
||||
'inspector.google': 'Google Maps',
|
||||
'inspector.navigation': 'Πλοήγηση',
|
||||
'inspector.openWith': 'Άνοιγμα με',
|
||||
'inspector.openStreetMap': 'OpenStreetMap',
|
||||
'inspector.saveToCollection': 'Αποθήκευση στη Συλλογή',
|
||||
'inspector.savedToCollection': 'Αποθηκεύτηκε',
|
||||
|
||||
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
|
||||
'places.unplanned': 'Μη προγραμματισμένα',
|
||||
'places.planned': 'Προγραμματισμένα',
|
||||
'places.filterTracks': 'Ίχνη',
|
||||
'places.sortByRating': 'Ταξινόμηση κατά βαθμολογία',
|
||||
'places.filterByRating': 'Φιλτράρισμα κατά βαθμολογία',
|
||||
'places.yourRating': 'Η βαθμολογία σου',
|
||||
'places.notRated': 'Χωρίς βαθμολογία ακόμη',
|
||||
'places.search': 'Αναζήτηση μερών...',
|
||||
|
||||
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': 'Visszavonás',
|
||||
'costs.whatFor': 'Mire volt?',
|
||||
'costs.namePlaceholder': 'pl. vacsora, ajándékok, benzin…',
|
||||
'costs.note': 'Megjegyzés',
|
||||
'costs.notePlaceholder': 'Mit fedezett, ki tartozik mivel…',
|
||||
'costs.addNote': 'Megjegyzés hozzáadása',
|
||||
'costs.showNote': 'Megjegyzés megjelenítése',
|
||||
'costs.hideNote': 'Megjegyzés elrejtése',
|
||||
'costs.totalAmount': 'Teljes összeg',
|
||||
'costs.currency': 'Pénznem',
|
||||
'costs.day': 'Nap',
|
||||
|
||||
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
|
||||
'inspector.confirmedRes': 'Megerősített foglalás',
|
||||
'inspector.pendingRes': 'Függőben lévő foglalás',
|
||||
'inspector.google': 'Google Maps',
|
||||
'inspector.navigation': 'Navigáció',
|
||||
'inspector.openWith': 'Megnyitás ezzel',
|
||||
'inspector.openStreetMap': 'OpenStreetMap',
|
||||
'inspector.website': 'Weboldal megnyitása',
|
||||
'inspector.saveToCollection': 'Mentés gyűjteménybe',
|
||||
|
||||
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
|
||||
'places.unplanned': 'Nem tervezett',
|
||||
'places.planned': 'Tervezett',
|
||||
'places.filterTracks': 'Nyomvonalak',
|
||||
'places.sortByRating': 'Rendezés értékelés szerint',
|
||||
'places.filterByRating': 'Szűrés értékelés szerint',
|
||||
'places.yourRating': 'Az értékelésed',
|
||||
'places.notRated': 'Még nincs értékelve',
|
||||
'places.search': 'Helyek keresése...',
|
||||
|
||||
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': 'Urungkan',
|
||||
'costs.whatFor': 'Untuk apa?',
|
||||
'costs.namePlaceholder': 'mis. Makan malam, oleh-oleh, bensin…',
|
||||
'costs.note': 'Catatan',
|
||||
'costs.notePlaceholder': 'Apa saja yang termasuk, siapa berutang apa…',
|
||||
'costs.addNote': 'Tambah catatan',
|
||||
'costs.showNote': 'Tampilkan catatan',
|
||||
'costs.hideNote': 'Sembunyikan catatan',
|
||||
'costs.totalAmount': 'Jumlah total',
|
||||
'costs.currency': 'Mata uang',
|
||||
'costs.day': 'Hari',
|
||||
|
||||
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
|
||||
'inspector.confirmedRes': 'Reservasi Dikonfirmasi',
|
||||
'inspector.pendingRes': 'Reservasi Menunggu',
|
||||
'inspector.google': 'Google Maps',
|
||||
'inspector.navigation': 'Navigasi',
|
||||
'inspector.openWith': 'Buka dengan',
|
||||
'inspector.openStreetMap': 'OpenStreetMap',
|
||||
'inspector.saveToCollection': 'Simpan ke Koleksi',
|
||||
'inspector.savedToCollection': 'Tersimpan',
|
||||
|
||||
@@ -44,7 +44,7 @@ const places: TranslationStrings = {
|
||||
'places.unplanned': 'Belum direncanakan',
|
||||
'places.planned': 'Direncanakan',
|
||||
'places.filterTracks': 'Trek',
|
||||
'places.sortByRating': 'Urutkan berdasarkan rating',
|
||||
'places.filterByRating': 'Saring menurut penilaian',
|
||||
'places.yourRating': 'Rating kamu',
|
||||
'places.notRated': 'Belum dinilai',
|
||||
'places.search': 'Cari tempat...',
|
||||
|
||||
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': 'Annulla',
|
||||
'costs.whatFor': 'Per cosa era?',
|
||||
'costs.namePlaceholder': 'es. Cena, souvenir, benzina…',
|
||||
'costs.note': 'Nota',
|
||||
'costs.notePlaceholder': 'Cosa comprendeva, chi deve cosa…',
|
||||
'costs.addNote': 'Aggiungi nota',
|
||||
'costs.showNote': 'Mostra nota',
|
||||
'costs.hideNote': 'Nascondi nota',
|
||||
'costs.totalAmount': 'Importo totale',
|
||||
'costs.currency': 'Valuta',
|
||||
'costs.day': 'Giorno',
|
||||
|
||||
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
|
||||
'inspector.confirmedRes': 'Prenotazione confermata',
|
||||
'inspector.pendingRes': 'Prenotazione in attesa',
|
||||
'inspector.google': 'Google Maps',
|
||||
'inspector.navigation': 'Navigazione',
|
||||
'inspector.openWith': 'Apri con',
|
||||
'inspector.openStreetMap': 'OpenStreetMap',
|
||||
'inspector.website': 'Apri sito web',
|
||||
'inspector.saveToCollection': 'Salva nella raccolta',
|
||||
|
||||
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
|
||||
'places.unplanned': 'Non pianificati',
|
||||
'places.planned': 'Pianificati',
|
||||
'places.filterTracks': 'Tracce',
|
||||
'places.sortByRating': 'Ordina per valutazione',
|
||||
'places.filterByRating': 'Filtra per valutazione',
|
||||
'places.yourRating': 'La tua valutazione',
|
||||
'places.notRated': 'Non ancora valutato',
|
||||
'places.search': 'Cerca luoghi...',
|
||||
|
||||
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': '元に戻す',
|
||||
'costs.whatFor': '何の支出ですか?',
|
||||
'costs.namePlaceholder': '例:夕食、お土産、ガソリン…',
|
||||
'costs.note': 'メモ',
|
||||
'costs.notePlaceholder': '内訳や立て替えのメモなど…',
|
||||
'costs.addNote': 'メモを追加',
|
||||
'costs.showNote': 'メモを表示',
|
||||
'costs.hideNote': 'メモを非表示',
|
||||
'costs.totalAmount': '合計金額',
|
||||
'costs.currency': '通貨',
|
||||
'costs.day': '日',
|
||||
|
||||
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
|
||||
'inspector.confirmedRes': '確定済み予約',
|
||||
'inspector.pendingRes': '保留中の予約',
|
||||
'inspector.google': 'Google Maps',
|
||||
'inspector.navigation': 'ナビゲーション',
|
||||
'inspector.openWith': 'アプリで開く',
|
||||
'inspector.openStreetMap': 'OpenStreetMap',
|
||||
'inspector.website': 'Webサイトを開く',
|
||||
'inspector.saveToCollection': 'コレクションに保存',
|
||||
|
||||
@@ -45,7 +45,7 @@ const places: TranslationStrings = {
|
||||
'places.unplanned': '未計画',
|
||||
'places.planned': '計画済み',
|
||||
'places.filterTracks': 'トラック',
|
||||
'places.sortByRating': '評価順に並べ替え',
|
||||
'places.filterByRating': '評価で絞り込む',
|
||||
'places.yourRating': 'あなたの評価',
|
||||
'places.notRated': '未評価',
|
||||
'places.search': '場所を検索…',
|
||||
|
||||
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
|
||||
'costs.undo': '실행 취소',
|
||||
'costs.whatFor': '무엇을 위한 것인가요?',
|
||||
'costs.namePlaceholder': '예: 저녁 식사, 기념품, 주유…',
|
||||
'costs.note': '메모',
|
||||
'costs.notePlaceholder': '무엇을 샀는지, 누가 갚아야 하는지…',
|
||||
'costs.addNote': '메모 추가',
|
||||
'costs.showNote': '메모 보기',
|
||||
'costs.hideNote': '메모 숨기기',
|
||||
'costs.totalAmount': '총 금액',
|
||||
'costs.currency': '통화',
|
||||
'costs.day': '날짜',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user