mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-09 15:05:59 +00:00
fix: resolve a batch of reported bugs (planner, budget, atlas, bookings, mobile)
- #1394 planner: two transports on one day no longer draw a phantom airport→airport road route between them (a run is only a drive when it holds a real place); mirrored in the map hook and the sidebar's leg list, with a regression test. - #1392 planner: the per-day Route button now points the selection at the tapped day before toggling, so on mobile it computes that day's route instead of the previously selected one, and only the selected day's button reads as active. - #1372 planner: the "open in Google Maps" route now includes the day's hotel bookends, matching the drawn map route. - #1375 planner: a multi-day accommodation no longer thrashes the plan scroll — the auto-scroll lock keys on the selection identity, not the per-day row. - #1377 planner: the reset-orientation compass is now shown on small screens too. - #1382 budget: settlement nets in the trip's canonical currency and converts to the display currency once, so balances no longer drift with live FX and no phantom third-party micro-flows appear (identity, hence unchanged, when they're the same). - #1366 atlas: countries reached only by a transport booking (no lodging/place) now count as visited, on both the dashboard and the Atlas page. - #1383 bookings: a hotel linked to an accommodation shows only its day-range, not a duplicate stamped date row, and the range stays correct after an edit. - #1353 bookings: any non-hotel reservation can now link an existing trip place/activity. - #1390 i18n: fix the Polish word for "buddies" (Towarzysze → Współpodróżnicy). - #1265 planner: drag-and-drop of places now works on touch devices via a polyfill.
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
"@trek/shared": "*",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"drag-drop-touch": "^1.3.1",
|
||||
"heic-to": "^1.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
|
||||
@@ -201,7 +201,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// Remember which assignment we last auto-scrolled into view so we don't
|
||||
// keep yanking the user back whenever they scroll away while the same
|
||||
// place stays selected.
|
||||
const lastAutoScrolledIdRef = useRef<number | null>(null)
|
||||
const lastAutoScrolledIdRef = useRef<string | number | null>(null)
|
||||
useEffect(() => {
|
||||
// Reset the scroll-lock whenever selection moves, so the next selected
|
||||
// row triggers a fresh scroll-into-view on its ref.
|
||||
@@ -392,9 +392,15 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const merged = mergedItemsMap[selectedDayId] || []
|
||||
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||
let cur: { id: number; lat: number; lng: number }[] = []
|
||||
// A run is only a real drive when it holds an actual place. Two back-to-back
|
||||
// transports (e.g. two flights on one day) would otherwise pair the first's
|
||||
// arrival with the second's departure into a phantom airport→airport leg — the
|
||||
// flight, not a drive — and surface it as a bogus connector distance (#1394).
|
||||
let curHasPlace = false
|
||||
for (const it of merged) {
|
||||
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
||||
curHasPlace = true
|
||||
} else if (it.type === 'transport') {
|
||||
const r = it.data
|
||||
const { from, to } = getTransportRouteEndpoints(r, selectedDayId)
|
||||
@@ -402,8 +408,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// Located transport: route to its departure point, break the run (the
|
||||
// flight/train itself isn't driven), and let its arrival start the next.
|
||||
if (from) cur.push({ id: r.id, lat: from.lat, lng: from.lng })
|
||||
if (cur.length >= 2) runs.push(cur)
|
||||
if (cur.length >= 2 && curHasPlace) runs.push(cur)
|
||||
cur = []
|
||||
curHasPlace = false
|
||||
if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng })
|
||||
} else if (cur.length > 0) {
|
||||
// No location: ignore for routing, but attribute the through-leg to the
|
||||
@@ -412,7 +419,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cur.length >= 2) runs.push(cur)
|
||||
if (cur.length >= 2 && curHasPlace) runs.push(cur)
|
||||
|
||||
// Hotel bookend legs: the drive from the day's accommodation to the first located
|
||||
// waypoint of the day (morning) and from the last one back to it (evening). Only when
|
||||
@@ -1058,6 +1065,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
|
||||
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const S = useDayPlanSidebar(props)
|
||||
// A stable key for the current selection. A multi-day place renders one row per
|
||||
// day (same place_id, different assignment ids); selecting it by place_id alone
|
||||
// (e.g. clicking an accommodation) marks every one of those rows selected, so a
|
||||
// per-assignment scroll-lock would let each day's row scroll the list in turn.
|
||||
// Keying the lock on the selection identity makes only the first row scroll (#1375).
|
||||
const selectionScrollKey = S.selectedAssignmentId != null ? `a${S.selectedAssignmentId}` : S.selectedPlaceId != null ? `p${S.selectedPlaceId}` : null
|
||||
// Needed by the route-tools visibility gate in the render below (#1330); the hook
|
||||
// keeps its own copy, so read it reactively here in the component scope too.
|
||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||
@@ -1609,14 +1622,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// the transition "just became selected". Once we've
|
||||
// scrolled for this assignment id, we won't scroll
|
||||
// again until selection actually moves somewhere else.
|
||||
if (el && isPlaceSelected && lastAutoScrolledIdRef.current !== assignment.id) {
|
||||
if (el && isPlaceSelected && selectionScrollKey != null && lastAutoScrolledIdRef.current !== selectionScrollKey) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const nearTop = rect.top < 80
|
||||
const nearBottom = rect.bottom > window.innerHeight - 80
|
||||
if (nearTop || nearBottom) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
lastAutoScrolledIdRef.current = assignment.id
|
||||
lastAutoScrolledIdRef.current = selectionScrollKey
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
@@ -2186,8 +2199,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||
<button
|
||||
onClick={() => onToggleRoute?.()}
|
||||
className={routeShown ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
|
||||
onClick={() => {
|
||||
// The route is computed for the globally selected day, so on
|
||||
// mobile (where an expanded non-selected day also shows this
|
||||
// footer) tapping Route must first point the selection at this
|
||||
// day — otherwise it toggles the previously selected day (#1392).
|
||||
if (isSelected) { onToggleRoute?.() }
|
||||
else { onSelectDay(day.id, true); if (!routeShown) onToggleRoute?.() }
|
||||
}}
|
||||
className={routeShown && isSelected ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
|
||||
style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, borderRadius: 8,
|
||||
@@ -2201,7 +2221,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = generateGoogleMapsUrl(getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[])
|
||||
// Bookend the Google Maps route with the day's accommodation the
|
||||
// same way the drawn map route does (routeBookends is null when
|
||||
// "optimize from accommodation" is off), so hotels aren't dropped
|
||||
// from the exported route (#1372).
|
||||
const stops = getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[]
|
||||
const morning = routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
|
||||
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng } : null
|
||||
const evening = routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
|
||||
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng } : null
|
||||
const url = generateGoogleMapsUrl([...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])])
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
aria-label={t('planner.openGoogleMaps')}
|
||||
|
||||
@@ -87,6 +87,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', url: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||
place_id: '' as string | number,
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
||||
hotel_address: '',
|
||||
@@ -139,6 +140,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
url: reservation.url || '',
|
||||
assignment_id: reservation.assignment_id || '',
|
||||
accommodation_id: reservation.accommodation_id || '',
|
||||
place_id: reservation.place_id || '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
meta_check_in_end_time: meta.check_in_end_time || '',
|
||||
meta_check_out_time: meta.check_out_time || '',
|
||||
@@ -168,6 +170,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
url: (prefill as { url?: string }).url || '',
|
||||
assignment_id: defaultAssignmentId ?? '',
|
||||
accommodation_id: '',
|
||||
place_id: '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
meta_check_in_end_time: meta.check_in_end_time || '',
|
||||
meta_check_out_time: meta.check_out_time || '',
|
||||
@@ -182,7 +185,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
setForm({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', url: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||
notes: '', url: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '', place_id: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', hotel_address: '',
|
||||
})
|
||||
@@ -242,6 +245,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
url: form.url,
|
||||
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null),
|
||||
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
||||
// Hotels link a place through the accommodation record; every other type links
|
||||
// the picked trip place/activity directly on the reservation (#1353).
|
||||
place_id: form.type === 'hotel' ? null : (form.place_id || null),
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
endpoints: [],
|
||||
needs_review: false,
|
||||
@@ -466,6 +472,35 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{/* Link an existing trip place/activity to any non-hotel booking (#1353). Hotels
|
||||
keep their own accommodation-based place picker below. */}
|
||||
{form.type !== 'hotel' && (
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.linkPlace')}</label>
|
||||
<CustomSelect
|
||||
value={form.place_id}
|
||||
onChange={value => {
|
||||
const p = places.find(pl => pl.id === value)
|
||||
setForm(prev => {
|
||||
const next = { ...prev, place_id: value }
|
||||
if (value && p) {
|
||||
if (!prev.title) next.title = p.name
|
||||
if (!prev.location && p.address) next.location = p.address
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
placeholder={t('reservations.meta.pickPlace')}
|
||||
options={[
|
||||
{ value: '', label: '—' },
|
||||
...places.map(p => ({ value: p.id, label: p.name })),
|
||||
]}
|
||||
searchable
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.type !== 'hotel' && (
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.locationAddress')}</label>
|
||||
|
||||
@@ -112,11 +112,14 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
||||
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
||||
const isHotel = r.type === 'hotel'
|
||||
const startDay = r.day_id ? days.find(d => d.id === r.day_id)
|
||||
: (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
|
||||
// For a hotel linked to an accommodation, the accommodation's own start/end days are
|
||||
// the source of truth for the stay range: a stale day_id left behind by a range edit
|
||||
// would otherwise mislabel the card, so prefer the accommodation ids here (#1383).
|
||||
const startDay = (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
|
||||
: r.day_id ? days.find(d => d.id === r.day_id)
|
||||
: undefined
|
||||
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id)
|
||||
: (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
|
||||
const endDay = (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
|
||||
: r.end_day_id ? days.find(d => d.id === r.end_day_id)
|
||||
: undefined
|
||||
const DayLabel = ({ day }: { day: typeof startDay }) => {
|
||||
if (!day) return null
|
||||
@@ -234,8 +237,10 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Date / Time row */}
|
||||
{(hasDate || hasTime) && (
|
||||
{/* Date / Time row — hidden for a hotel linked to an accommodation: its stay
|
||||
already shows as the day-range label above, and a reservation_time stamped
|
||||
on the auto-created reservation would otherwise duplicate it (#1383). */}
|
||||
{(hasDate || hasTime) && !(isHotel && (r.accommodation_start_day_id || r.accommodation_end_day_id)) && (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasDate && hasTime ? '1fr 1fr' : '1fr' }}>
|
||||
{hasDate && (
|
||||
<div>
|
||||
|
||||
@@ -89,19 +89,26 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
// arrival point starts the next run.
|
||||
// - A transport WITHOUT a location is ignored entirely — the places around it
|
||||
// connect directly, as if the booking weren't there.
|
||||
// A run is only a real drive when it contains at least one actual place. Two
|
||||
// back-to-back transports (e.g. two flights on one day) would otherwise pair the
|
||||
// first's arrival point with the second's departure point into a phantom
|
||||
// [airport → airport] road route — that is the flight itself, not a drive (#1394).
|
||||
const runs: { lat: number; lng: number }[][] = []
|
||||
let currentRun: { lat: number; lng: number }[] = []
|
||||
let runHasPlace = false
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'place') {
|
||||
currentRun.push({ lat: entry.lat, lng: entry.lng })
|
||||
runHasPlace = true
|
||||
} else if (entry.from || entry.to) {
|
||||
if (entry.from) currentRun.push(entry.from)
|
||||
if (currentRun.length >= 2) runs.push(currentRun)
|
||||
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
|
||||
currentRun = []
|
||||
runHasPlace = false
|
||||
if (entry.to) currentRun.push(entry.to)
|
||||
}
|
||||
}
|
||||
if (currentRun.length >= 2) runs.push(currentRun)
|
||||
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
|
||||
|
||||
// Bookend the route with the day's accommodation: a hotel → first-stop run and
|
||||
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
|
||||
|
||||
@@ -14,6 +14,11 @@ import '@fontsource/geist-sans/400.css'
|
||||
import '@fontsource/geist-sans/500.css'
|
||||
import '@fontsource/geist-sans/600.css'
|
||||
import './index.css'
|
||||
// Native HTML5 drag-and-drop never fires on touch input, so the planner's place /
|
||||
// day reordering was dead on Android and iOS. This polyfill synthesises the standard
|
||||
// drag events from touch gestures over draggable elements — it only hooks touch, so
|
||||
// desktop mouse dragging is untouched (#1265).
|
||||
import 'drag-drop-touch'
|
||||
import { startConnectivityProbe } from './sync/connectivity'
|
||||
import { requestPersistentStorage } from './sync/persistentStorage'
|
||||
|
||||
|
||||
@@ -338,6 +338,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: the compass/reset-orientation control lives centre-top on its own
|
||||
(the desktop cluster above is hidden below md), between the edge Plan/Places tabs. */}
|
||||
{glMap && (
|
||||
<div className="flex md:hidden" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
|
||||
<MapCompassPill map={glMap} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<button onClick={() => setLeftCollapsed(c => !c)}
|
||||
style={{
|
||||
|
||||
@@ -414,4 +414,30 @@ describe('useRouteCalculation', () => {
|
||||
[[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-018: two flights on one day are not road-routed airport→airport (#1394)', async () => {
|
||||
// Two single-day flights, no place between them. The arrival of the first and the
|
||||
// departure of the second must NOT be joined into a phantom driving run — that leg
|
||||
// is the flight itself, not a drive.
|
||||
const store = buildMockStore({ '5': [] });
|
||||
useTripStore.setState({
|
||||
reservations: [
|
||||
{ id: 1, type: 'flight', day_id: 5, end_day_id: 5, day_positions: { 5: 0 },
|
||||
endpoints: [{ role: 'from', lat: 52.5, lng: 13.4 }, { role: 'to', lat: 42.4, lng: 18.7 }] },
|
||||
{ id: 2, type: 'flight', day_id: 5, end_day_id: 5, day_positions: { 5: 1 },
|
||||
endpoints: [{ role: 'from', lat: 50.1, lng: 14.3 }, { role: 'to', lat: 42.4, lng: 18.9 }] },
|
||||
],
|
||||
days: [{ id: 5, day_number: 1 }],
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useRouteCalculation(store as TripStoreState, 5)
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
// No real place anywhere on the day → nothing is a drive → no route is drawn.
|
||||
// Before the fix this produced a bogus [flight1.arrival → flight2.departure] leg.
|
||||
expect(result.current.route).toBeNull();
|
||||
expect(result.current.routeSegments).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+7
@@ -34,6 +34,7 @@
|
||||
"@trek/shared": "*",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"drag-drop-touch": "^1.3.1",
|
||||
"heic-to": "^1.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
@@ -9909,6 +9910,12 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/drag-drop-touch": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/drag-drop-touch/-/drag-drop-touch-1.3.1.tgz",
|
||||
"integrity": "sha512-Q0/ZgsnW7VUjn+YqSnp1rvxjjPnZX5YLyVaw28einood+eTMcLzgOgHk8nyqIF9O18J68l+2htlEnbw5GsyTvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dts-resolver": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-3.0.0.tgz",
|
||||
|
||||
@@ -444,6 +444,22 @@ export async function getStats(userId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// Merge countries reached only by a transport booking. Those store geocoded from/to
|
||||
// coordinates in reservation_endpoints but create no place row, so they never show up
|
||||
// via resolvePlaceCountries above and would otherwise be missed (#1366).
|
||||
const endpoints = db.prepare(`
|
||||
SELECT DISTINCT e.lat, e.lng
|
||||
FROM reservation_endpoints e
|
||||
JOIN reservations r ON e.reservation_id = r.id
|
||||
WHERE r.trip_id IN (${tripIds.map(() => '?').join(',')})
|
||||
`).all(...tripIds) as { lat: number; lng: number }[];
|
||||
for (const e of endpoints) {
|
||||
const code = getCountryFromCoords(e.lat, e.lng);
|
||||
if (code && !countries.find(c => c.code === code)) {
|
||||
countries.push({ code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null });
|
||||
}
|
||||
}
|
||||
|
||||
const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null;
|
||||
|
||||
const continents: Record<string, number> = {};
|
||||
|
||||
@@ -17,6 +17,7 @@ import { revokeUserSessions } from '../mcp';
|
||||
import { startTripReminders } from '../scheduler';
|
||||
import { deleteUserCompletely } from './userCleanupService';
|
||||
import { getFlightDistanceKm } from './distanceService';
|
||||
import { getCountryFromCoords } from './atlasService';
|
||||
import { verifyJwtAndLoadUser } from '../middleware/auth';
|
||||
import { User } from '../types';
|
||||
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
||||
@@ -982,6 +983,23 @@ export function getTravelStats(userId: number) {
|
||||
`).all(userId, userId) as { country_code: string }[];
|
||||
placeRegionCodes.forEach(r => { if (r.country_code) countryCodes.add(r.country_code.toUpperCase()); });
|
||||
|
||||
// Transport bookings don't create a place row, so their geocoded endpoints never
|
||||
// reached place_regions — a country reached only by a flight/train (no lodging or
|
||||
// planned place there) was never counted as visited (#1366). Resolve each endpoint
|
||||
// coordinate to a country and fold it in too.
|
||||
const endpoints = db.prepare(`
|
||||
SELECT DISTINCT e.lat, e.lng
|
||||
FROM reservation_endpoints e
|
||||
JOIN reservations r ON e.reservation_id = r.id
|
||||
JOIN trips t ON r.trip_id = t.id
|
||||
LEFT JOIN trip_members tm ON t.id = tm.trip_id
|
||||
WHERE (t.user_id = ? OR tm.user_id = ?)
|
||||
`).all(userId, userId) as { lat: number; lng: number }[];
|
||||
for (const e of endpoints) {
|
||||
const code = getCountryFromCoords(e.lat, e.lng);
|
||||
if (code) countryCodes.add(code.toUpperCase());
|
||||
}
|
||||
|
||||
return {
|
||||
countries: [...countryCodes],
|
||||
cities: [...cities],
|
||||
|
||||
@@ -374,22 +374,34 @@ export function calculateSettlement(
|
||||
const base = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
|
||||
const tripCurrency = (opts.tripCurrency || base).toUpperCase();
|
||||
const rates = opts.rates ?? null;
|
||||
// Amount in some currency → base. Pre-rework rows store currency = NULL, which
|
||||
// means "the trip's own currency". rates[X] = units of X per 1 base.
|
||||
const toBase = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => {
|
||||
// Net the whole settlement in the trip's canonical currency and convert the final
|
||||
// totals to the display currency once, instead of netting in the (moving) display
|
||||
// currency. Otherwise per-expense rounding shifts as live FX drifts and the greedy
|
||||
// debt-simplifier reshuffles it into phantom third-party micro-flows (#1382). When
|
||||
// the display currency IS the trip currency (the common case) every conversion below
|
||||
// is the identity, so behaviour is unchanged.
|
||||
// rates[X] = units of X per 1 base; the frozen exchange_rate is units of item-currency
|
||||
// per 1 trip-currency. Pre-rework rows store currency = NULL = "the trip's own currency".
|
||||
const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => {
|
||||
const cur = (itemCurrency || tripCurrency).toUpperCase();
|
||||
if (cur === base) return amount;
|
||||
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate it
|
||||
// was booked at, so a later live-rate drift doesn't re-open it with a few-cent residual.
|
||||
// The stored rate is units of item-currency per 1 trip-currency, so it only applies when
|
||||
// converting to the trip's own currency; otherwise (and for legacy rows) use live rates.
|
||||
if (base === tripCurrency && itemRate != null && itemRate > 0 && itemRate !== 1) {
|
||||
return amount / itemRate;
|
||||
}
|
||||
if (cur === tripCurrency) return amount;
|
||||
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate
|
||||
// it was booked at, so a later live-rate drift doesn't re-open it with a residual.
|
||||
if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate;
|
||||
// Legacy rows without a frozen rate: convert via base with live rates.
|
||||
if (!rates) return amount;
|
||||
const r = rates[cur];
|
||||
return r && r > 0 ? amount / r : amount;
|
||||
const rCur = rates[cur];
|
||||
const rTrip = rates[tripCurrency];
|
||||
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
|
||||
return amount;
|
||||
};
|
||||
// trip-currency → display currency, applied once to the final netted totals.
|
||||
const toDisplay = (v: number): number =>
|
||||
base === tripCurrency ? v : (rates && rates[tripCurrency] > 0 ? v / rates[tripCurrency] : v);
|
||||
// A recorded settle-up amount is entered in whatever display currency the payer was
|
||||
// viewing (the table has no currency column), so bring it into trip currency to net.
|
||||
const settleToTrip = (v: number): number =>
|
||||
base === tripCurrency ? v : (rates && rates[tripCurrency] > 0 ? v * rates[tripCurrency] : v);
|
||||
|
||||
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
|
||||
const allMembers = db.prepare(`
|
||||
@@ -419,17 +431,17 @@ export function calculateSettlement(
|
||||
const payers = allPayers.filter(p => p.budget_item_id === item.id);
|
||||
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
|
||||
|
||||
// Payers are credited what they actually paid (converted to base with the
|
||||
// item's stored exchange rate)…
|
||||
for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate);
|
||||
// Payers are credited what they actually paid (converted to trip currency with
|
||||
// the item's stored exchange rate)…
|
||||
for (const p of payers) ensure(p.user_id, p).balance += toTrip(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate);
|
||||
// …and each split participant owes their share — a custom per-member amount
|
||||
// when one is set, otherwise an equal share of the expense total.
|
||||
const hasCustomSplit = members.some(m => m.amount !== null && m.amount !== undefined);
|
||||
const equalShares = !hasCustomSplit ? splitEqualShares(item.total_price, members, item.id) : {};
|
||||
for (const m of members) {
|
||||
const memberShare = hasCustomSplit && m.amount !== null && m.amount !== undefined
|
||||
? toBase(m.amount, item.currency, item.exchange_rate)
|
||||
: toBase(equalShares[m.user_id] || 0, item.currency, item.exchange_rate);
|
||||
? toTrip(m.amount, item.currency, item.exchange_rate)
|
||||
: toTrip(equalShares[m.user_id] || 0, item.currency, item.exchange_rate);
|
||||
ensure(m.user_id, m).balance -= memberShare;
|
||||
}
|
||||
}
|
||||
@@ -445,8 +457,8 @@ export function calculateSettlement(
|
||||
return balances[id];
|
||||
};
|
||||
for (const s of settlements) {
|
||||
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += s.amount;
|
||||
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= s.amount;
|
||||
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += settleToTrip(s.amount);
|
||||
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= settleToTrip(s.amount);
|
||||
}
|
||||
|
||||
// Calculate optimized payment flows (greedy algorithm)
|
||||
@@ -467,7 +479,7 @@ export function calculateSettlement(
|
||||
flows.push({
|
||||
from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
|
||||
to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
|
||||
amount: Math.round(transfer * 100) / 100,
|
||||
amount: Math.round(toDisplay(transfer) * 100) / 100,
|
||||
});
|
||||
}
|
||||
debtors[di].amount -= transfer;
|
||||
@@ -477,7 +489,7 @@ export function calculateSettlement(
|
||||
}
|
||||
|
||||
return {
|
||||
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
|
||||
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(toDisplay(b.balance) * 100) / 100 })),
|
||||
flows,
|
||||
settlements,
|
||||
};
|
||||
|
||||
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'ربط بالإقامة',
|
||||
'reservations.meta.noAccommodation': 'لا يوجد',
|
||||
'reservations.meta.hotelPlace': 'الإقامة',
|
||||
'reservations.meta.linkPlace': 'مكان / نشاط',
|
||||
'reservations.meta.pickPlace': 'اختر مكانًا / نشاطًا',
|
||||
'reservations.meta.pickHotel': 'اختر الإقامة',
|
||||
'reservations.meta.fromDay': 'من',
|
||||
'reservations.meta.toDay': 'إلى',
|
||||
|
||||
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Vincular à hospedagem',
|
||||
'reservations.meta.noAccommodation': 'Nenhuma',
|
||||
'reservations.meta.hotelPlace': 'Hospedagem',
|
||||
'reservations.meta.linkPlace': 'Local / Atividade',
|
||||
'reservations.meta.pickPlace': 'Selecionar local / atividade',
|
||||
'reservations.meta.pickHotel': 'Selecionar hospedagem',
|
||||
'reservations.meta.fromDay': 'De',
|
||||
'reservations.meta.toDay': 'Até',
|
||||
|
||||
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Propojit s ubytováním',
|
||||
'reservations.meta.noAccommodation': 'Nic',
|
||||
'reservations.meta.hotelPlace': 'Ubytování',
|
||||
'reservations.meta.linkPlace': 'Místo / Aktivita',
|
||||
'reservations.meta.pickPlace': 'Vybrat místo / aktivitu',
|
||||
'reservations.meta.pickHotel': 'Vybrat ubytování',
|
||||
'reservations.meta.fromDay': 'Od dne',
|
||||
'reservations.meta.toDay': 'Do dne',
|
||||
|
||||
@@ -47,6 +47,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Mit Unterkunft verknüpfen',
|
||||
'reservations.meta.noAccommodation': 'Keine',
|
||||
'reservations.meta.hotelPlace': 'Unterkunft',
|
||||
'reservations.meta.linkPlace': 'Ort / Aktivität',
|
||||
'reservations.meta.pickPlace': 'Ort / Aktivität wählen',
|
||||
'reservations.meta.pickHotel': 'Unterkunft auswählen',
|
||||
'reservations.meta.fromDay': 'Von',
|
||||
'reservations.meta.toDay': 'Bis',
|
||||
|
||||
@@ -46,6 +46,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Link to accommodation',
|
||||
'reservations.meta.noAccommodation': 'None',
|
||||
'reservations.meta.hotelPlace': 'Accommodation',
|
||||
'reservations.meta.linkPlace': 'Place / Activity',
|
||||
'reservations.meta.pickPlace': 'Select place / activity',
|
||||
'reservations.meta.pickHotel': 'Select accommodation',
|
||||
'reservations.meta.fromDay': 'From',
|
||||
'reservations.meta.toDay': 'To',
|
||||
|
||||
@@ -117,6 +117,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Vincular con alojamiento',
|
||||
'reservations.meta.noAccommodation': 'Ninguno',
|
||||
'reservations.meta.hotelPlace': 'Alojamiento',
|
||||
'reservations.meta.linkPlace': 'Lugar / Actividad',
|
||||
'reservations.meta.pickPlace': 'Seleccionar lugar / actividad',
|
||||
'reservations.meta.pickHotel': 'Seleccionar alojamiento',
|
||||
'reservations.meta.fromDay': 'Desde',
|
||||
'reservations.meta.toDay': 'Hasta',
|
||||
|
||||
@@ -46,6 +46,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Lier à un hébergement',
|
||||
'reservations.meta.noAccommodation': 'Aucun',
|
||||
'reservations.meta.hotelPlace': 'Hébergement',
|
||||
'reservations.meta.linkPlace': 'Lieu / Activité',
|
||||
'reservations.meta.pickPlace': 'Sélectionner un lieu / une activité',
|
||||
'reservations.meta.pickHotel': 'Sélectionner un hébergement',
|
||||
'reservations.meta.fromDay': 'Du',
|
||||
'reservations.meta.toDay': 'Au',
|
||||
|
||||
@@ -46,6 +46,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Σύνδεση με κατάλυμα',
|
||||
'reservations.meta.noAccommodation': 'Κανένα',
|
||||
'reservations.meta.hotelPlace': 'Κατάλυμα',
|
||||
'reservations.meta.linkPlace': 'Τοποθεσία / Δραστηριότητα',
|
||||
'reservations.meta.pickPlace': 'Επιλογή τοποθεσίας / δραστηριότητας',
|
||||
'reservations.meta.pickHotel': 'Επιλογή καταλύματος',
|
||||
'reservations.meta.fromDay': 'Από',
|
||||
'reservations.meta.toDay': 'Προς',
|
||||
|
||||
@@ -46,6 +46,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Szállás hozzárendelése',
|
||||
'reservations.meta.noAccommodation': 'Nincs',
|
||||
'reservations.meta.hotelPlace': 'Szálloda',
|
||||
'reservations.meta.linkPlace': 'Hely / Tevékenység',
|
||||
'reservations.meta.pickPlace': 'Hely / tevékenység kiválasztása',
|
||||
'reservations.meta.pickHotel': 'Szálloda kiválasztása',
|
||||
'reservations.meta.fromDay': 'Ettől',
|
||||
'reservations.meta.toDay': 'Eddig',
|
||||
|
||||
@@ -45,6 +45,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Hubungkan ke akomodasi',
|
||||
'reservations.meta.noAccommodation': 'Tidak ada',
|
||||
'reservations.meta.hotelPlace': 'Akomodasi',
|
||||
'reservations.meta.linkPlace': 'Tempat / Aktivitas',
|
||||
'reservations.meta.pickPlace': 'Pilih tempat / aktivitas',
|
||||
'reservations.meta.pickHotel': 'Pilih akomodasi',
|
||||
'reservations.meta.fromDay': 'Dari',
|
||||
'reservations.meta.toDay': 'Sampai',
|
||||
|
||||
@@ -45,6 +45,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Collega a un alloggio',
|
||||
'reservations.meta.noAccommodation': 'Nessuno',
|
||||
'reservations.meta.hotelPlace': 'Alloggio',
|
||||
'reservations.meta.linkPlace': 'Luogo / Attività',
|
||||
'reservations.meta.pickPlace': 'Seleziona luogo / attività',
|
||||
'reservations.meta.pickHotel': 'Seleziona alloggio',
|
||||
'reservations.meta.fromDay': 'Da',
|
||||
'reservations.meta.toDay': 'A',
|
||||
|
||||
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': '宿泊先にリンク',
|
||||
'reservations.meta.noAccommodation': 'なし',
|
||||
'reservations.meta.hotelPlace': '宿泊先',
|
||||
'reservations.meta.linkPlace': '場所 / アクティビティ',
|
||||
'reservations.meta.pickPlace': '場所 / アクティビティを選択',
|
||||
'reservations.meta.pickHotel': '宿泊先を選択',
|
||||
'reservations.meta.fromDay': '開始',
|
||||
'reservations.meta.toDay': '終了',
|
||||
|
||||
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': '숙박 연결',
|
||||
'reservations.meta.noAccommodation': '없음',
|
||||
'reservations.meta.hotelPlace': '숙박',
|
||||
'reservations.meta.linkPlace': '장소 / 활동',
|
||||
'reservations.meta.pickPlace': '장소 / 활동 선택',
|
||||
'reservations.meta.pickHotel': '숙박 선택',
|
||||
'reservations.meta.fromDay': '부터',
|
||||
'reservations.meta.toDay': '까지',
|
||||
|
||||
@@ -45,6 +45,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Koppel aan accommodatie',
|
||||
'reservations.meta.noAccommodation': 'Geen',
|
||||
'reservations.meta.hotelPlace': 'Accommodatie',
|
||||
'reservations.meta.linkPlace': 'Plaats / Activiteit',
|
||||
'reservations.meta.pickPlace': 'Kies plaats / activiteit',
|
||||
'reservations.meta.pickHotel': 'Selecteer accommodatie',
|
||||
'reservations.meta.fromDay': 'Van',
|
||||
'reservations.meta.toDay': 'Tot',
|
||||
|
||||
@@ -80,7 +80,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Użyj zdjęcia z Unsplash autorstwa {photographer}',
|
||||
'dashboard.titleRequired': 'Nazwa podróży jest wymagana',
|
||||
'dashboard.endDateError': 'Data zakończenia musi być po dacie rozpoczęcia',
|
||||
'dashboard.members': 'Towarzysze',
|
||||
'dashboard.members': 'Współpodróżnicy',
|
||||
'dashboard.copyTrip': 'Kopiuj',
|
||||
'dashboard.copySuffix': 'kopia',
|
||||
'dashboard.toast.copied': 'Podróż skopiowana!',
|
||||
@@ -92,7 +92,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.mobile.tripProgress': 'Postęp podróży',
|
||||
'dashboard.mobile.daysLeft': 'Pozostało {count} dni',
|
||||
'dashboard.mobile.places': 'Miejsca',
|
||||
'dashboard.mobile.buddies': 'Towarzysze',
|
||||
'dashboard.mobile.buddies': 'Współpodróżnicy',
|
||||
'dashboard.mobile.newTrip': 'Nowa podróż',
|
||||
'dashboard.mobile.currency': 'Waluta',
|
||||
'dashboard.mobile.timezone': 'Strefa czasowa',
|
||||
@@ -140,7 +140,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.atlas.kmUnit': 'km',
|
||||
'dashboard.atlas.aroundEquator': '≈ {count}× dookoła równika',
|
||||
'dashboard.card.idea': 'Pomysł',
|
||||
'dashboard.card.buddyOne': 'Towarzysz',
|
||||
'dashboard.card.buddyOne': 'Współpodróżnik',
|
||||
'dashboard.fx.from': 'Z',
|
||||
'dashboard.fx.to': 'Na',
|
||||
'dashboard.fx.unavailable': 'Kurs niedostępny',
|
||||
|
||||
@@ -42,6 +42,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Link do zakwaterowania',
|
||||
'reservations.meta.noAccommodation': 'Brak',
|
||||
'reservations.meta.hotelPlace': 'Zakwaterowanie',
|
||||
'reservations.meta.linkPlace': 'Miejsce / Aktywność',
|
||||
'reservations.meta.pickPlace': 'Wybierz miejsce / aktywność',
|
||||
'reservations.meta.pickHotel': 'Wybierz zakwaterowanie',
|
||||
'reservations.meta.fromDay': 'Od',
|
||||
'reservations.meta.toDay': 'Do',
|
||||
|
||||
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Привязать к жилью',
|
||||
'reservations.meta.noAccommodation': 'Нет',
|
||||
'reservations.meta.hotelPlace': 'Жильё',
|
||||
'reservations.meta.linkPlace': 'Место / Активность',
|
||||
'reservations.meta.pickPlace': 'Выбрать место / активность',
|
||||
'reservations.meta.pickHotel': 'Выбрать жильё',
|
||||
'reservations.meta.fromDay': 'С',
|
||||
'reservations.meta.toDay': 'По',
|
||||
|
||||
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Länk till boende',
|
||||
'reservations.meta.noAccommodation': 'Inget',
|
||||
'reservations.meta.hotelPlace': 'Boende',
|
||||
'reservations.meta.linkPlace': 'Plats / Aktivitet',
|
||||
'reservations.meta.pickPlace': 'Välj plats / aktivitet',
|
||||
'reservations.meta.pickHotel': 'Välj boende',
|
||||
'reservations.meta.fromDay': 'Från',
|
||||
'reservations.meta.toDay': 'Till',
|
||||
|
||||
@@ -45,6 +45,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Konaklama bağlantısı',
|
||||
'reservations.meta.noAccommodation': 'Hiçbiri',
|
||||
'reservations.meta.hotelPlace': 'Konaklama',
|
||||
'reservations.meta.linkPlace': 'Yer / Etkinlik',
|
||||
'reservations.meta.pickPlace': 'Yer / etkinlik seç',
|
||||
'reservations.meta.pickHotel': 'Konaklama seçin',
|
||||
'reservations.meta.fromDay': 'İtibaren',
|
||||
'reservations.meta.toDay': 'İle',
|
||||
|
||||
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Прив’язати до житла',
|
||||
'reservations.meta.noAccommodation': 'Ні',
|
||||
'reservations.meta.hotelPlace': 'Житло',
|
||||
'reservations.meta.linkPlace': 'Місце / Активність',
|
||||
'reservations.meta.pickPlace': 'Вибрати місце / активність',
|
||||
'reservations.meta.pickHotel': 'Оберіть житло',
|
||||
'reservations.meta.fromDay': 'З',
|
||||
'reservations.meta.toDay': 'По',
|
||||
|
||||
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': 'Liên kết chỗ ở',
|
||||
'reservations.meta.noAccommodation': 'Không có',
|
||||
'reservations.meta.hotelPlace': 'Chỗ ở',
|
||||
'reservations.meta.linkPlace': 'Địa điểm / Hoạt động',
|
||||
'reservations.meta.pickPlace': 'Chọn địa điểm / hoạt động',
|
||||
'reservations.meta.pickHotel': 'Chọn chỗ ở',
|
||||
'reservations.meta.fromDay': 'Từ',
|
||||
'reservations.meta.toDay': 'ĐẾN',
|
||||
|
||||
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': '關聯住宿',
|
||||
'reservations.meta.noAccommodation': '無',
|
||||
'reservations.meta.hotelPlace': '住宿',
|
||||
'reservations.meta.linkPlace': '地點 / 活動',
|
||||
'reservations.meta.pickPlace': '選擇地點 / 活動',
|
||||
'reservations.meta.pickHotel': '選擇住宿',
|
||||
'reservations.meta.fromDay': '從',
|
||||
'reservations.meta.toDay': '到',
|
||||
|
||||
@@ -44,6 +44,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.pickAccommodation': '关联住宿',
|
||||
'reservations.meta.noAccommodation': '无',
|
||||
'reservations.meta.hotelPlace': '住宿',
|
||||
'reservations.meta.linkPlace': '地点 / 活动',
|
||||
'reservations.meta.pickPlace': '选择地点 / 活动',
|
||||
'reservations.meta.pickHotel': '选择住宿',
|
||||
'reservations.meta.fromDay': '从',
|
||||
'reservations.meta.toDay': '到',
|
||||
|
||||
Reference in New Issue
Block a user