mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-27 17:13:01 +00:00
fix(bookings): show where an imported stay lands on the map before it is saved
An imported hotel was looked up once, by its name and its address together. A booking site's romanized address (Agoda for a stay in Japan) often matches nothing and sank the hotel's name with it, so the place was created without coordinates and silently never showed on the map. The review now finds the stay right away (name and address, then the name, then the address) and says under the address whether it is on the map and where, or that it is not yet, with a way to search again. Picking an address suggestion sets the spot by hand. The save uses that spot, falls back the same way when there is none, and a stay saved without a spot says so in a toast. Desktop dialog and phone sheet share the lookup through useReservationForm.
This commit is contained in:
@@ -150,7 +150,6 @@
|
||||
"src/pages/resetPassword/useResetPassword.ts",
|
||||
"src/pages/settings/useSettings.ts",
|
||||
"src/pages/sharedTrip/useSharedTrip.ts",
|
||||
"src/pages/tripPlanner/useBookingEdits.ts",
|
||||
"src/pages/tripPlanner/usePlaceFormOpeners.ts",
|
||||
"src/pages/tripPlanner/usePlannerDialogs.ts",
|
||||
"src/pages/tripPlanner/useTripPlanner.ts",
|
||||
|
||||
@@ -5,19 +5,22 @@ import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
|
||||
import { useSuggestionDropdown } from './useSuggestionDropdown'
|
||||
import { PlaceSuggestion, SuggestionList, SuggestionRow } from './SuggestionDropdown'
|
||||
import type { MapMatch } from '../../repo/mapMatchRepo'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
/** A picked suggestion's spot on the map, for a form that saves where the address is (an imported stay). */
|
||||
onPick?: (match: MapMatch) => void
|
||||
}
|
||||
|
||||
// Free-text address input with location autocomplete, backed by the same maps
|
||||
// search as LocationSelect. Unlike LocationSelect the typed text is
|
||||
// authoritative: every keystroke reaches the parent so a hand-written address
|
||||
// is never lost, and picking a suggestion just replaces the text (#1496).
|
||||
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
|
||||
export default function AddressInput({ value, onChange, placeholder, className, onPick }: Props) {
|
||||
const { locale } = useTranslation()
|
||||
const placeLang = usePlaceLanguage()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
@@ -60,6 +63,9 @@ export default function AddressInput({ value, onChange, placeholder, className }
|
||||
// A response still on its way must not repopulate the list behind the pick.
|
||||
reqIdRef.current++
|
||||
onChange(r.address || r.name || '')
|
||||
if (onPick && Number.isFinite(r.lat) && Number.isFinite(r.lng)) {
|
||||
onPick({ lat: r.lat, lng: r.lng, label: [r.name, r.address].filter(Boolean).join(', ') })
|
||||
}
|
||||
setOpen(false)
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useReservationForm } from './useReservationForm'
|
||||
import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, filesNeedConnection, uploadBookingFiles } from './bookingFormModel'
|
||||
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
|
||||
import { useBookingFileAttach } from './useBookingFileAttach'
|
||||
import StayPinStatus from './StayPinStatus'
|
||||
|
||||
const EMPTY_FORM = {
|
||||
title: '', type: 'other', status: 'pending',
|
||||
@@ -81,7 +82,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const {
|
||||
form, setForm, set, isSaving, setIsSaving, pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler,
|
||||
isEndBeforeStart, endTimeBeforeStartTime, dateBounds: tripDateRange, startDate, startTime, setStartDate, setStartTime, takeStopDay,
|
||||
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, saveData: buildSaveData,
|
||||
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, stayPin, saveData: buildSaveData,
|
||||
} = useReservationForm(EMPTY_FORM, days, places)
|
||||
const attach = useBookingFileAttach({ reservation, files, onFileUpload, setPendingFiles, toast, t })
|
||||
|
||||
@@ -102,17 +103,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
}
|
||||
|
||||
setTravelerIds(travelerIdsOf(reservation))
|
||||
stayPin.reset()
|
||||
if (reservation) {
|
||||
setForm({ ...reservationFieldsFrom(reservation, accommodations, places, true), assignment_id: reservation.assignment_id || '' })
|
||||
} else if (prefill) {
|
||||
// Review-before-save: populate from a parsed import item, stay in create mode.
|
||||
const hotelPlaceId = matchPlaceId(prefill._venue?.name || prefill.title)
|
||||
setForm({
|
||||
...reservationFieldsFromPrefill(prefill, days, true),
|
||||
assignment_id: defaultAssignmentId ?? '',
|
||||
accommodation_id: '',
|
||||
place_id: '',
|
||||
hotel_place_id: matchPlaceId(prefill._venue?.name || prefill.title),
|
||||
hotel_place_id: hotelPlaceId,
|
||||
})
|
||||
// A stay the trip does not hold yet: find it on the map now, so the review
|
||||
// says whether it will show there before it is saved.
|
||||
if (prefill.type === 'hotel' && !hotelPlaceId) stayPin.lookUp(prefill._venue?.name || prefill.title, prefill._venue?.address)
|
||||
// Seed the booking's Files with the document this item was parsed from.
|
||||
setPendingFiles(prefill._sourceFiles ?? [])
|
||||
} else {
|
||||
@@ -362,8 +368,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
<EditorField label={t('reservations.locationAddress')}>
|
||||
<AddressInput value={form.hotel_address} onChange={v => set('hotel_address', v)}
|
||||
onPick={!form.hotel_place_id ? stayPin.pick : undefined}
|
||||
placeholder={t('reservations.locationPlaceholder')} className={INPUT} />
|
||||
</EditorField>
|
||||
{!form.hotel_place_id && (
|
||||
<StayPinStatus pin={stayPin.pin} onRetry={() => stayPin.lookUp(form.title, form.hotel_address)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createElement } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||
import StayPinStatus from './StayPinStatus';
|
||||
import type { StayPin } from './useStayPin';
|
||||
|
||||
function show(pin: StayPin, onRetry = vi.fn()) {
|
||||
render(createElement(TranslationProvider, null, createElement(StayPinStatus, { pin, onRetry })));
|
||||
return onRetry;
|
||||
}
|
||||
|
||||
describe('StayPinStatus', () => {
|
||||
it('FE-STAY-PIN-STATUS-001: shows nothing before a lookup', () => {
|
||||
const { container } = render(
|
||||
createElement(
|
||||
TranslationProvider,
|
||||
null,
|
||||
createElement(StayPinStatus, { pin: { status: 'idle' }, onRetry: vi.fn() })
|
||||
)
|
||||
);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-STATUS-002: says it is looking, then where it found the stay', () => {
|
||||
show({ status: 'searching' });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Looking for it on the map');
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-STATUS-003: a found stay names the match, so a wrong one is plain', () => {
|
||||
show({ status: 'found', match: { lat: 1, lng: 2, label: 'Hotel Gracery Shinjuku, Tokyo' } });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('On the map');
|
||||
expect(screen.getByText('Hotel Gracery Shinjuku, Tokyo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-STATUS-004: a missing stay says so and offers to search again', () => {
|
||||
const onRetry = show({ status: 'missing' });
|
||||
expect(screen.getByText('Not on the map yet')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /search again/i }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { MapPin, MapPinOff, RotateCw } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { StayPin } from './useStayPin';
|
||||
|
||||
interface StayPinStatusProps {
|
||||
pin: StayPin;
|
||||
/** Asks the map again with what the form now holds. */
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an imported stay will show on the map, under its address in the
|
||||
* review: being looked up, found (and where, so a wrong match is plain), or not
|
||||
* found, with a way to search again before it is saved without a pin. Shared by
|
||||
* the desktop booking dialog and the phone's booking sheet.
|
||||
*/
|
||||
export default function StayPinStatus({ pin, onRetry }: StayPinStatusProps) {
|
||||
const { t } = useTranslation();
|
||||
if (pin.status === 'idle') return null;
|
||||
|
||||
if (pin.status === 'searching') {
|
||||
return (
|
||||
<div role="status" className="flex items-center gap-2 px-1 text-caption text-content-muted">
|
||||
<span className="h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-edge border-t-content-muted" />
|
||||
{t('reservations.import.pinSearching')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pin.status === 'found') {
|
||||
return (
|
||||
<div role="status" className="flex min-w-0 items-center gap-2 rounded-lg bg-success-soft px-3 py-2 text-caption">
|
||||
<MapPin size={14} strokeWidth={2.2} className="flex-none text-success" />
|
||||
<span className="flex-none font-semibold text-success">{t('reservations.import.pinFound')}</span>
|
||||
<span className="min-w-0 truncate text-content-secondary" title={pin.match.label}>
|
||||
{pin.match.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="status" className="flex items-start gap-3 rounded-xl bg-warning-soft px-3.5 py-3">
|
||||
<MapPinOff size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-warning" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 text-body font-semibold text-content">{t('reservations.import.pinMissingTitle')}</p>
|
||||
<p className="m-0 mt-0.5 text-caption text-content-muted">{t('reservations.import.pinMissingBody')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-edge bg-surface-card px-2.5 py-1 text-caption font-medium text-content hover:bg-surface-hover"
|
||||
>
|
||||
<RotateCw size={12} strokeWidth={2.2} />
|
||||
{t('reservations.import.pinRetry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -287,6 +287,8 @@ export interface ReservationSaveOptions {
|
||||
isEdit: boolean;
|
||||
/** The desktop also saves the end of the check-in window. */
|
||||
withCheckInEnd: boolean;
|
||||
/** Where the review found an imported stay on the map, so the save creates its place right there. */
|
||||
venuePin?: { lat: number; lng: number } | null;
|
||||
}
|
||||
|
||||
export type ReservationSaveData = Record<string, unknown> & { title: string };
|
||||
@@ -344,7 +346,11 @@ export function reservationSaveData(
|
||||
// import): the save handler geocodes it and creates the place.
|
||||
venue:
|
||||
!form.hotel_place_id && (form.hotel_address || form.title)
|
||||
? { name: form.title, address: form.hotel_address || null }
|
||||
? {
|
||||
name: form.title,
|
||||
address: form.hotel_address || null,
|
||||
...(opts.venuePin ? { lat: opts.venuePin.lat, lng: opts.venuePin.lng } : {}),
|
||||
}
|
||||
: null,
|
||||
// The typed address, so the save handler can write it through to a linked
|
||||
// place; an edited address used to be silently dropped (#1496).
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
withStartDate,
|
||||
withStartTime,
|
||||
} from './reservationFormModel';
|
||||
import { useStayPin } from './useStayPin';
|
||||
|
||||
/**
|
||||
* The booking form behind the desktop ReservationModal and the phone's booking
|
||||
@@ -39,6 +40,8 @@ export function useReservationForm<F extends ReservationFields & CheckInEndField
|
||||
|
||||
const set = (field: keyof F, value: string | number) => setForm((prev) => ({ ...prev, [field]: value }));
|
||||
const toggleTraveler = (id: number) => setTravelerIds((prev) => toggledTraveler(prev, id));
|
||||
// Where an imported stay will land on the map, looked up while it is reviewed.
|
||||
const stayPin = useStayPin();
|
||||
|
||||
// Restrict non-hotel booking dates to the trip's span (#1662). Hotels already
|
||||
// constrain to trip days via their day dropdowns.
|
||||
@@ -71,6 +74,7 @@ export function useReservationForm<F extends ReservationFields & CheckInEndField
|
||||
pickHotelPlace: (value: string | number) => setForm((prev) => withHotelPlace(prev, value, places)),
|
||||
pickHotelStart: (value: string | number) => setForm((prev) => withHotelStart(prev, value, days)),
|
||||
pickHotelEnd: (value: string | number) => setForm((prev) => withHotelEnd(prev, value, days)),
|
||||
saveData: (opts: ReservationSaveOptions) => reservationSaveData(form, opts),
|
||||
stayPin,
|
||||
saveData: (opts: ReservationSaveOptions) => reservationSaveData(form, { ...opts, venuePin: stayPin.match }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { findOnMap } from '../../repo/mapMatchRepo';
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode';
|
||||
import { useStayPin } from './useStayPin';
|
||||
|
||||
vi.mock('../../hooks/usePlaceLanguage', () => ({ usePlaceLanguage: () => 'en' }));
|
||||
vi.mock('../../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
|
||||
vi.mock('../../repo/mapMatchRepo', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../repo/mapMatchRepo')>()),
|
||||
findOnMap: vi.fn(),
|
||||
}));
|
||||
|
||||
const SPOT = { lat: 35.69, lng: 139.7, label: 'Hotel Gracery Shinjuku' };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe('useStayPin', () => {
|
||||
it('FE-STAY-PIN-001: looks the stay up by name and address and says where it found it', async () => {
|
||||
vi.mocked(findOnMap).mockResolvedValueOnce(SPOT);
|
||||
const { result } = renderHook(() => useStayPin());
|
||||
expect(result.current.pin).toEqual({ status: 'idle' });
|
||||
|
||||
act(() => result.current.lookUp('Hotel Gracery', '1-19-1 Kabukicho'));
|
||||
expect(result.current.pin).toEqual({ status: 'searching' });
|
||||
await waitFor(() => expect(result.current.pin).toEqual({ status: 'found', match: SPOT }));
|
||||
expect(result.current.match).toEqual(SPOT);
|
||||
expect(findOnMap).toHaveBeenCalledWith(
|
||||
['Hotel Gracery 1-19-1 Kabukicho', 'Hotel Gracery', '1-19-1 Kabukicho'],
|
||||
'en'
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-002: nothing found, a failed search and an empty name all read as not on the map', async () => {
|
||||
const { result } = renderHook(() => useStayPin());
|
||||
vi.mocked(findOnMap).mockResolvedValueOnce(null);
|
||||
act(() => result.current.lookUp('Ryokan', null));
|
||||
await waitFor(() => expect(result.current.pin).toEqual({ status: 'missing' }));
|
||||
expect(result.current.match).toBeNull();
|
||||
|
||||
vi.mocked(findOnMap).mockRejectedValueOnce(new Error('down'));
|
||||
act(() => result.current.lookUp('Ryokan', null));
|
||||
await waitFor(() => expect(result.current.pin).toEqual({ status: 'missing' }));
|
||||
|
||||
act(() => result.current.lookUp('', ''));
|
||||
expect(result.current.pin).toEqual({ status: 'missing' });
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-003: a later lookup or a hand-picked spot wins over an answer still on its way', async () => {
|
||||
let answer: (m: typeof SPOT | null) => void = () => undefined;
|
||||
vi.mocked(findOnMap).mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
|
||||
const { result } = renderHook(() => useStayPin());
|
||||
act(() => result.current.lookUp('Hotel', 'Somewhere'));
|
||||
const picked = { lat: 1, lng: 2, label: 'Picked' };
|
||||
act(() => result.current.pick(picked));
|
||||
await act(async () => answer(SPOT));
|
||||
expect(result.current.pin).toEqual({ status: 'found', match: picked });
|
||||
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.pin).toEqual({ status: 'idle' });
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-004: offline there is nothing to ask, so the review shows nothing', () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
|
||||
const { result } = renderHook(() => useStayPin());
|
||||
act(() => result.current.lookUp('Hotel', 'Somewhere'));
|
||||
expect(result.current.pin).toEqual({ status: 'idle' });
|
||||
expect(findOnMap).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage';
|
||||
import { findOnMap, type MapMatch, stayQueries } from '../../repo/mapMatchRepo';
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode';
|
||||
|
||||
/**
|
||||
* Where an imported stay will sit on the map, as the review shows it: not asked
|
||||
* yet (or offline, where there is nothing to ask), being looked up, found, or
|
||||
* not found at all.
|
||||
*/
|
||||
export type StayPin =
|
||||
{ status: 'idle' } | { status: 'searching' } | { status: 'found'; match: MapMatch } | { status: 'missing' };
|
||||
|
||||
/**
|
||||
* The map spot of a stay reviewed from an import, looked up before it is saved
|
||||
* so the review can say whether it will show on the map. A stay the search did
|
||||
* not find used to be saved without coordinates and simply never appeared.
|
||||
* A later lookup always wins over an earlier one still on its way.
|
||||
*/
|
||||
export function useStayPin() {
|
||||
const lang = usePlaceLanguage();
|
||||
const [pin, setPin] = useState<StayPin>({ status: 'idle' });
|
||||
const ticket = useRef(0);
|
||||
useEffect(
|
||||
() => () => {
|
||||
ticket.current++;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const lookUp = (name: string, address: string | null | undefined) => {
|
||||
const mine = ++ticket.current;
|
||||
const queries = stayQueries(name, address);
|
||||
if (isEffectivelyOffline()) {
|
||||
setPin({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
if (queries.length === 0) {
|
||||
setPin({ status: 'missing' });
|
||||
return;
|
||||
}
|
||||
setPin({ status: 'searching' });
|
||||
findOnMap(queries, lang).then(
|
||||
(match) => {
|
||||
if (mine === ticket.current) setPin(match ? { status: 'found', match } : { status: 'missing' });
|
||||
},
|
||||
() => {
|
||||
if (mine === ticket.current) setPin({ status: 'missing' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
pin,
|
||||
/** The spot to save the stay at, once one is known. */
|
||||
match: pin.status === 'found' ? pin.match : null,
|
||||
lookUp,
|
||||
/** A spot the reader picked by hand, which beats any lookup. */
|
||||
pick: (match: MapMatch) => {
|
||||
ticket.current++;
|
||||
setPin({ status: 'found', match });
|
||||
},
|
||||
reset: () => {
|
||||
ticket.current++;
|
||||
setPin({ status: 'idle' });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill, selectedDayDate,
|
||||
} from '../../../../components/Planner/reservationFormModel'
|
||||
import { useReservationForm } from '../../../../components/Planner/useReservationForm'
|
||||
import StayPinStatus from '../../../../components/Planner/StayPinStatus'
|
||||
import { buildAssignmentOptions } from '../../../../components/Planner/assignmentOptions'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
|
||||
@@ -60,7 +61,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
const {
|
||||
form, setForm, set, isSaving, setIsSaving, pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler,
|
||||
isEndBeforeStart, endTimeBeforeStartTime, dateBounds, startDate, startTime, setStartDate, setStartTime, takeStopDay,
|
||||
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, saveData: buildSaveData,
|
||||
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, stayPin, saveData: buildSaveData,
|
||||
} = useReservationForm(EMPTY, days, places)
|
||||
const assignmentOptions = useMemo(
|
||||
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||
@@ -85,6 +86,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
expense.reset()
|
||||
setPendingFiles([])
|
||||
setTravelerIds(travelerIdsOf(editingReservation))
|
||||
stayPin.reset()
|
||||
|
||||
const res = editingReservation
|
||||
if (res) {
|
||||
@@ -93,6 +95,8 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
const pf = reservationPrefill
|
||||
setForm({ ...EMPTY, ...reservationFieldsFromPrefill(pf, days, false) })
|
||||
setPendingFiles(pf._sourceFiles ?? [])
|
||||
// Find an imported stay on the map now, so the review says whether it will show there.
|
||||
if (pf.type === 'hotel') stayPin.lookUp(pf._venue?.name || pf.title, pf._venue?.address)
|
||||
} else {
|
||||
// Opened from a day's toolbar: start on that day rather than on a blank
|
||||
// date the user has to look up again (#1998). A hotel spans to the next
|
||||
@@ -335,6 +339,11 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
placeholder={t('reservations.locationPlaceholder')}
|
||||
className={FIELD_CLS}
|
||||
/>
|
||||
{!form.hotel_place_id && (
|
||||
<div className="mt-2">
|
||||
<StayPinStatus pin={stayPin.pin} onRetry={() => stayPin.lookUp(form.title, form.hotel_address)} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { Translate } from './plannerTypes'
|
||||
import { useBookingEdits } from './useBookingEdits'
|
||||
|
||||
type Options = Parameters<typeof useBookingEdits>[0]
|
||||
type ImportedStay = { place_id?: number; venue?: { name?: string; address?: string | null }; address?: string }
|
||||
type ImportedStay = { place_id?: number; venue?: { name?: string; address?: string | null; lat?: number; lng?: number }; address?: string }
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
|
||||
const t = ((key: string) => key) as Translate
|
||||
@@ -231,6 +231,32 @@ describe('useBookingEdits', () => {
|
||||
expect(actions.addReservation).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('FE-TP-BOOKING-007b: a stay lands where the review found it, falls back to its name, and says when it has no spot', async () => {
|
||||
const { result } = renderEdits()
|
||||
|
||||
// The review already found it: no second search, the place sits right there.
|
||||
vi.mocked(mapsApi.search).mockClear()
|
||||
await saveHotel(result, { venue: { name: 'Gracery', address: '1-19-1 Kabukicho', lat: 35.69, lng: 139.7 } })
|
||||
expect(mapsApi.search).not.toHaveBeenCalled()
|
||||
expect(actions.addPlace).toHaveBeenLastCalledWith(42, { name: 'Gracery', lat: 35.69, lng: 139.7, address: '1-19-1 Kabukicho' })
|
||||
expect(toast.warning).not.toHaveBeenCalled()
|
||||
|
||||
// A romanized address that matches nothing no longer sinks the hotel's name.
|
||||
vi.mocked(mapsApi.search).mockImplementation(async (query: string) =>
|
||||
(query === 'Gracery' ? { places: [{ lat: 35.69, lng: 139.7 }] } : { places: [] }) as never)
|
||||
await saveHotel(result, { venue: { name: 'Gracery', address: '1-19-1 Kabukicho' } })
|
||||
expect(vi.mocked(mapsApi.search).mock.calls.map(([q]) => q)).toEqual(['Gracery 1-19-1 Kabukicho', 'Gracery'])
|
||||
expect(actions.addPlace).toHaveBeenLastCalledWith(42, { name: 'Gracery', lat: 35.69, lng: 139.7, address: '1-19-1 Kabukicho' })
|
||||
expect(toast.warning).not.toHaveBeenCalled()
|
||||
|
||||
// Found nowhere: the booking is still saved, and the reader is told it has no pin.
|
||||
vi.mocked(mapsApi.search).mockResolvedValue({ places: [] } as never)
|
||||
await saveHotel(result, { venue: { name: 'Nowhere Inn', address: 'Nowhere 1' } })
|
||||
expect(actions.addPlace).toHaveBeenLastCalledWith(42, { name: 'Nowhere Inn', lat: null, lng: null, address: 'Nowhere 1' })
|
||||
expect(toast.warning).toHaveBeenCalledWith('reservations.import.savedWithoutPin')
|
||||
expect(actions.addReservation).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('FE-TP-BOOKING-008: a booking that cannot be saved says so, whatever was thrown', async () => {
|
||||
const { result } = renderEdits()
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { BookingImportPreviewItem } from '@trek/shared'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { receiptToPrefill } from '../../components/Budget/CostsPanel.helpers'
|
||||
import type { ExpensePrefill } from '../../components/Budget/CostsPanel'
|
||||
import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
|
||||
import { getImportFiles, deleteImportFiles } from '../../db/offlineDb'
|
||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||
import { findOnMap, stayQueries } from '../../repo/mapMatchRepo'
|
||||
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
|
||||
import type { TripStoreState } from '../../store/tripStore'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
@@ -63,7 +63,10 @@ export function useBookingEdits(options: BookingEditsOptions) {
|
||||
// an existing place by name, else geocode the address and create one, then link it.
|
||||
const acc = (data as Record<string, any>).create_accommodation
|
||||
if (data.type === 'hotel' && acc && acc.venue && !acc.place_id) {
|
||||
acc.place_id = (await resolveImportedPlace(acc.venue)) ?? undefined
|
||||
const resolved = await resolveImportedPlace(acc.venue)
|
||||
acc.place_id = resolved?.id ?? undefined
|
||||
// Saved, but nowhere on the map: say so rather than leave the pin to go missing.
|
||||
if (resolved && !resolved.placed) toast.warning(t('reservations.import.savedWithoutPin'))
|
||||
delete acc.venue
|
||||
}
|
||||
// A hotel's address lives on the linked place. Write an edited address
|
||||
@@ -141,9 +144,13 @@ export function useBookingEdits(options: BookingEditsOptions) {
|
||||
}
|
||||
|
||||
// ── Review-before-save booking import ───────────────────────────────────────
|
||||
// Match an existing trip place by name, else geocode the reviewed address and
|
||||
// create one. Returns the place id (or null if even creation failed).
|
||||
const resolveImportedPlace = async (venue: { name?: string; address?: string | null }): Promise<number | null> => {
|
||||
// Match an existing trip place by name, else create one at the spot the review
|
||||
// found, or the first spot the map search knows for its name and address.
|
||||
// Returns the place and whether it has a spot on the map (null if even creation
|
||||
// failed).
|
||||
const resolveImportedPlace = async (
|
||||
venue: { name?: string; address?: string | null; lat?: number; lng?: number },
|
||||
): Promise<{ id: number; placed: boolean } | null> => {
|
||||
const name = (venue.name || '').trim()
|
||||
const n = name.toLowerCase()
|
||||
if (n) {
|
||||
@@ -153,23 +160,23 @@ export function useBookingEdits(options: BookingEditsOptions) {
|
||||
// the booking waits in the queue behind that place and is sent with its real
|
||||
// id. Online the booking goes out at once, and day_accommodations.place_id
|
||||
// carries a foreign key, so a temp id there is a 500 instead of a saved booking.
|
||||
if (existing && (existing.id > 0 || isEffectivelyOffline())) return existing.id
|
||||
if (existing && (existing.id > 0 || isEffectivelyOffline())) return { id: existing.id, placed: existing.lat != null && existing.lng != null }
|
||||
}
|
||||
let lat: number | null = null
|
||||
let lng: number | null = null
|
||||
let lat: number | null = venue.lat ?? null
|
||||
let lng: number | null = venue.lng ?? null
|
||||
let address: string | null = venue.address ?? null
|
||||
try {
|
||||
const query = venue.address ? `${name} ${venue.address}`.trim() : name
|
||||
// Offline there is no geocoder to ask; the place is created without coords.
|
||||
if (query && !isEffectivelyOffline()) {
|
||||
const res = await mapsApi.search(query)
|
||||
const hit = res?.places?.[0] as { lat?: number; lng?: number; address?: string } | undefined
|
||||
if (hit && hit.lat != null && hit.lng != null) {
|
||||
if (lat == null || lng == null) {
|
||||
try {
|
||||
// Name and address, then each alone: a booking site's romanized address
|
||||
// often matches nothing and sinks the hotel's name with it. Offline
|
||||
// findOnMap answers null and the place is created without coords.
|
||||
const hit = await findOnMap(stayQueries(name, venue.address))
|
||||
if (hit) {
|
||||
lat = hit.lat; lng = hit.lng
|
||||
if (!address && hit.address) address = hit.address
|
||||
}
|
||||
}
|
||||
} catch { /* geocode failure is non-fatal: create the place without coords */ }
|
||||
} catch { /* geocode failure is non-fatal: create the place without coords, and say so */ }
|
||||
}
|
||||
try {
|
||||
// Through the store, not placesApi directly: the API answers { place },
|
||||
// and reading .id off that wrapper linked nothing. Every save of the
|
||||
@@ -178,7 +185,7 @@ export function useBookingEdits(options: BookingEditsOptions) {
|
||||
// find it. addPlace unwraps the response and puts the place into
|
||||
// `places`, so the next save reuses it.
|
||||
const place = await tripActions.addPlace(tripId, { name: name || address || 'Accommodation', lat, lng, address })
|
||||
return place && (place.id > 0 || isEffectivelyOffline()) ? place.id : null
|
||||
return place && (place.id > 0 || isEffectivelyOffline()) ? { id: place.id, placed: lat != null && lng != null } : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { mapsApi } from '../api/client';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
import { findOnMap, stayQueries } from './mapMatchRepo';
|
||||
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
|
||||
vi.mock('../api/client', () => ({ mapsApi: { search: vi.fn() } }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
|
||||
vi.mocked(mapsApi.search).mockResolvedValue({ places: [] } as never);
|
||||
});
|
||||
|
||||
describe('finding a stay on the map', () => {
|
||||
it('FE-MAP-MATCH-001: asks name and address together, then the name, then the address, each once', () => {
|
||||
expect(stayQueries('Hotel Gracery', '1-19-1 Kabukicho, Shinjuku')).toEqual([
|
||||
'Hotel Gracery 1-19-1 Kabukicho, Shinjuku',
|
||||
'Hotel Gracery',
|
||||
'1-19-1 Kabukicho, Shinjuku',
|
||||
]);
|
||||
expect(stayQueries(' Hut ', null)).toEqual(['Hut']);
|
||||
expect(stayQueries('', 'Main 1')).toEqual(['Main 1']);
|
||||
expect(stayQueries('Inn', 'Inn')).toEqual(['Inn Inn', 'Inn']);
|
||||
expect(stayQueries('', ' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MAP-MATCH-002: a romanized address that finds nothing falls back to the hotel name', async () => {
|
||||
vi.mocked(mapsApi.search).mockImplementation(async (query: string) =>
|
||||
query === 'Hotel Gracery'
|
||||
? ({
|
||||
places: [{ name: 'Hotel Gracery Shinjuku', address: 'Kabukicho, Tokyo', lat: 35.69, lng: 139.7 }],
|
||||
} as never)
|
||||
: ({ places: [] } as never)
|
||||
);
|
||||
await expect(findOnMap(stayQueries('Hotel Gracery', '1-19-1 Kabukicho'), 'en')).resolves.toEqual({
|
||||
lat: 35.69,
|
||||
lng: 139.7,
|
||||
label: 'Hotel Gracery Shinjuku, Kabukicho, Tokyo',
|
||||
address: 'Kabukicho, Tokyo',
|
||||
});
|
||||
expect(vi.mocked(mapsApi.search).mock.calls.map(([q]) => q)).toEqual([
|
||||
'Hotel Gracery 1-19-1 Kabukicho',
|
||||
'Hotel Gracery',
|
||||
]);
|
||||
expect(mapsApi.search).toHaveBeenLastCalledWith('Hotel Gracery', 'en');
|
||||
});
|
||||
|
||||
it('FE-MAP-MATCH-003: a row without coordinates is no match, and without a language the search is asked as before', async () => {
|
||||
vi.mocked(mapsApi.search).mockResolvedValueOnce({
|
||||
places: [
|
||||
{ name: 'Nowhere', lat: null, lng: 2 },
|
||||
{ name: 'Inn', lat: 1, lng: 2 },
|
||||
],
|
||||
} as never);
|
||||
await expect(findOnMap(['Inn'])).resolves.toEqual({ lat: 1, lng: 2, label: 'Inn' });
|
||||
expect(mapsApi.search).toHaveBeenCalledWith('Inn');
|
||||
|
||||
vi.mocked(mapsApi.search).mockResolvedValueOnce(undefined as never);
|
||||
await expect(findOnMap(['Gone'])).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MAP-MATCH-004: offline there is nothing to ask', async () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
|
||||
await expect(findOnMap(['Hotel'])).resolves.toBeNull();
|
||||
expect(mapsApi.search).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mapsApi } from '../api/client';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
|
||||
/** A spot the map search found for a place that so far is only a name and an address. */
|
||||
export interface MapMatch {
|
||||
lat: number;
|
||||
lng: number;
|
||||
/** What the search found, as the reader sees it: its name, and its address when it has one. */
|
||||
label: string;
|
||||
/** The found place's own address, for a stay whose booking named none. */
|
||||
address?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways to ask for a stay, best first: name and address together, then the
|
||||
* name alone, then the address alone. A booking site's romanized address (an
|
||||
* Agoda stay in Japan, say) often matches nothing, and asked together with the
|
||||
* hotel's name it sinks the name too; the name alone usually finds the hotel.
|
||||
*/
|
||||
export function stayQueries(name: string, address: string | null | undefined): string[] {
|
||||
const n = name.trim();
|
||||
const a = (address ?? '').trim();
|
||||
const queries = [n && a ? `${n} ${a}` : '', n, a].filter((q) => q.length >= 3);
|
||||
return [...new Set(queries)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The first spot the map search knows for one of `queries`, asked in order.
|
||||
* Online only, on purpose: offline there is no search to ask, and the answer
|
||||
* is null rather than a guess.
|
||||
*/
|
||||
export async function findOnMap(queries: string[], lang?: string): Promise<MapMatch | null> {
|
||||
if (isEffectivelyOffline()) return null;
|
||||
for (const query of queries) {
|
||||
const res = lang ? await mapsApi.search(query, lang) : await mapsApi.search(query);
|
||||
for (const row of res?.places ?? []) {
|
||||
const place = row as { lat?: unknown; lng?: unknown; name?: unknown; address?: unknown };
|
||||
const lat = typeof place.lat === 'number' ? place.lat : NaN;
|
||||
const lng = typeof place.lng === 'number' ? place.lng : NaN;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
||||
const parts = [place.name, place.address].filter((v): v is string => typeof v === 'string' && v.trim() !== '');
|
||||
const address = typeof place.address === 'string' && place.address.trim() ? place.address : undefined;
|
||||
return { lat, lng, label: parts.join(', ') || query, ...(address ? { address } : {}) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -139,6 +139,13 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'استيراد من ملف',
|
||||
'reservations.import.dropHere': 'أسقط ملفات تأكيد الحجز هنا أو انقر للتحديد',
|
||||
'reservations.import.dropActive': 'أسقط الملفات للاستيراد',
|
||||
'reservations.import.pinSearching': 'جارٍ البحث على الخريطة…',
|
||||
'reservations.import.pinFound': 'على الخريطة',
|
||||
'reservations.import.pinMissingTitle': 'ليس على الخريطة بعد',
|
||||
'reservations.import.pinMissingBody':
|
||||
'لم يعثر TREK على مكان الإقامة هذا على الخريطة. تحقّق من الاسم والعنوان أعلاه وابحث مجددًا، أو احفظه دون دبوس.',
|
||||
'reservations.import.pinRetry': 'ابحث مجددًا',
|
||||
'reservations.import.savedWithoutPin': 'حُفظ دون دبوس على الخريطة. عدّل المكان وحدّد موقعه لتراه على الخريطة.',
|
||||
'reservations.import.acceptedFormats':
|
||||
'المقبول: EML، PDF، PKPass، HTML، TXT (بحد أقصى 10 ميغابايت لكل ملف، حتى 5 ملفات)',
|
||||
'reservations.import.acceptedPhotos': 'يقرأ نموذج الذكاء الاصطناعي الصور (JPG وPNG وWEBP).',
|
||||
|
||||
@@ -170,6 +170,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.dropHere':
|
||||
'Rezervasiya təsdiqi fayllarını buraya sürükləyib buraxın və ya seçmək üçün klikləyin',
|
||||
'reservations.import.dropActive': 'İdxal etmək üçün faylları buraya buraxın',
|
||||
'reservations.import.pinSearching': 'Xəritədə axtarılır…',
|
||||
'reservations.import.pinFound': 'Xəritədə',
|
||||
'reservations.import.pinMissingTitle': 'Hələ xəritədə deyil',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK bu yaşayış yerini xəritədə tapa bilmədi. Yuxarıdakı adı və ünvanı yoxlayın və yenidən axtarın, ya da onu sancaqsız yadda saxlayın.',
|
||||
'reservations.import.pinRetry': 'Yenidən axtar',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Xəritə sancağı olmadan yadda saxlanıldı. Xəritədə görmək üçün yeri redaktə edin və mövqeyini təyin edin.',
|
||||
'reservations.import.acceptedFormats':
|
||||
'Qəbul edilən formatlar: EML, PDF, PKPass, HTML, TXT (hər biri maksimum 10 MB, 5 fayladək)',
|
||||
'reservations.import.acceptedPhotos': 'Fotoları (JPG, PNG, WEBP) süni intellekt modeli oxuyur.',
|
||||
|
||||
@@ -139,6 +139,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Importar de arquivo',
|
||||
'reservations.import.dropHere': 'Solte os arquivos de confirmação de reserva aqui ou clique para selecionar',
|
||||
'reservations.import.dropActive': 'Solte os arquivos para importar',
|
||||
'reservations.import.pinSearching': 'Procurando no mapa…',
|
||||
'reservations.import.pinFound': 'No mapa',
|
||||
'reservations.import.pinMissingTitle': 'Ainda não está no mapa',
|
||||
'reservations.import.pinMissingBody':
|
||||
'O TREK não encontrou esta hospedagem no mapa. Confira o nome e o endereço acima e pesquise de novo, ou salve sem alfinete.',
|
||||
'reservations.import.pinRetry': 'Pesquisar de novo',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Salvo sem alfinete no mapa. Edite o lugar e defina a localização para vê-lo no mapa.',
|
||||
'reservations.import.acceptedFormats': 'Aceitos: EML, PDF, PKPass, HTML, TXT (máx. 10 MB cada, até 5 arquivos)',
|
||||
'reservations.import.acceptedPhotos': 'As fotos (JPG, PNG, WEBP) são lidas pelo modelo de IA.',
|
||||
'reservations.import.parsing': 'Analisando arquivos…',
|
||||
|
||||
@@ -139,6 +139,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': "Importa des d'un fitxer",
|
||||
'reservations.import.dropHere': 'Deixa anar els fitxers de confirmació de reserva aquí o fes clic per seleccionar',
|
||||
'reservations.import.dropActive': 'Deixa anar els fitxers per importar',
|
||||
'reservations.import.pinSearching': "S'està cercant al mapa…",
|
||||
'reservations.import.pinFound': 'Al mapa',
|
||||
'reservations.import.pinMissingTitle': 'Encara no és al mapa',
|
||||
'reservations.import.pinMissingBody':
|
||||
"TREK no ha trobat aquest allotjament al mapa. Revisa el nom i l'adreça de dalt i torna a cercar, o desa'l sense xinxeta.",
|
||||
'reservations.import.pinRetry': 'Torna a cercar',
|
||||
'reservations.import.savedWithoutPin':
|
||||
"Desat sense xinxeta al mapa. Edita el lloc i fixa'n la ubicació per veure'l al mapa.",
|
||||
'reservations.import.acceptedFormats':
|
||||
'Acceptats: EML, PDF, PKPass, HTML, TXT (màx. 10 MB per fitxer, fins a 5 fitxers)',
|
||||
'reservations.import.acceptedPhotos': "El model d'IA llegeix les fotos (JPG, PNG, WEBP).",
|
||||
|
||||
@@ -139,6 +139,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Importovat ze souboru',
|
||||
'reservations.import.dropHere': 'Přetáhněte soubory s potvrzením rezervace sem nebo klikněte pro výběr',
|
||||
'reservations.import.dropActive': 'Pusťte soubory pro import',
|
||||
'reservations.import.pinSearching': 'Hledání na mapě…',
|
||||
'reservations.import.pinFound': 'Na mapě',
|
||||
'reservations.import.pinMissingTitle': 'Zatím není na mapě',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK toto ubytování na mapě nenašel. Zkontrolujte název a adresu výše a vyhledejte znovu, nebo ho uložte bez špendlíku.',
|
||||
'reservations.import.pinRetry': 'Hledat znovu',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Uloženo bez špendlíku na mapě. Upravte místo a nastavte jeho polohu, aby se zobrazilo na mapě.',
|
||||
'reservations.import.acceptedFormats':
|
||||
'Přijímané formáty: EML, PDF, PKPass, HTML, TXT (max. 10 MB každý, až 5 souborů)',
|
||||
'reservations.import.acceptedPhotos': 'Fotografie (JPG, PNG, WEBP) čte model AI.',
|
||||
|
||||
@@ -143,6 +143,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Aus Datei importieren',
|
||||
'reservations.import.dropHere': 'Buchungsbestätigungsdateien hier ablegen oder klicken zum Auswählen',
|
||||
'reservations.import.dropActive': 'Dateien zum Importieren ablegen',
|
||||
'reservations.import.pinSearching': 'Wird auf der Karte gesucht …',
|
||||
'reservations.import.pinFound': 'Auf der Karte',
|
||||
'reservations.import.pinMissingTitle': 'Noch nicht auf der Karte',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK hat diese Unterkunft nicht auf der Karte gefunden. Prüf Name und Adresse oben und such erneut, oder speichere sie ohne Kartenpunkt.',
|
||||
'reservations.import.pinRetry': 'Erneut suchen',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Ohne Kartenpunkt gespeichert. Bearbeite den Ort und setze seine Position, damit er auf der Karte erscheint.',
|
||||
'reservations.import.acceptedFormats':
|
||||
'Akzeptiert: EML, PDF, PKPass, HTML, TXT (max. 10 MB pro Datei, bis zu 5 Dateien)',
|
||||
'reservations.import.acceptedPhotos': 'Fotos (JPG, PNG, WEBP) liest das KI-Modell.',
|
||||
|
||||
@@ -144,6 +144,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Import from file',
|
||||
'reservations.import.dropHere': 'Drop booking confirmation files here, or click to select',
|
||||
'reservations.import.dropActive': 'Drop files to import',
|
||||
'reservations.import.pinSearching': 'Looking for it on the map…',
|
||||
'reservations.import.pinFound': 'On the map',
|
||||
'reservations.import.pinMissingTitle': 'Not on the map yet',
|
||||
'reservations.import.pinMissingBody':
|
||||
"TREK couldn't find this stay on the map. Check the name and address above and search again, or save it without a pin.",
|
||||
'reservations.import.pinRetry': 'Search again',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Saved without a map pin. Edit the place and set its location to see it on the map.',
|
||||
'reservations.import.acceptedFormats': 'Accepted: EML, PDF, PKPass, HTML, TXT (max 10 MB each, up to 5 files)',
|
||||
'reservations.import.acceptedPhotos': 'Photos (JPG, PNG, WEBP) are read by the AI model.',
|
||||
'reservations.import.parsing': 'Parsing files…',
|
||||
|
||||
@@ -141,6 +141,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Importar desde archivo',
|
||||
'reservations.import.dropHere': 'Suelta los archivos de confirmación de reserva aquí o haz clic para seleccionar',
|
||||
'reservations.import.dropActive': 'Suelta los archivos para importar',
|
||||
'reservations.import.pinSearching': 'Buscando en el mapa…',
|
||||
'reservations.import.pinFound': 'En el mapa',
|
||||
'reservations.import.pinMissingTitle': 'Todavía no está en el mapa',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK no ha encontrado este alojamiento en el mapa. Revisa el nombre y la dirección de arriba y vuelve a buscar, o guárdalo sin chincheta.',
|
||||
'reservations.import.pinRetry': 'Buscar de nuevo',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Guardado sin chincheta en el mapa. Edita el lugar y fija su ubicación para verlo en el mapa.',
|
||||
'reservations.import.acceptedFormats':
|
||||
'Aceptados: EML, PDF, PKPass, HTML, TXT (máx. 10 MB por archivo, hasta 5 archivos)',
|
||||
'reservations.import.acceptedPhotos': 'El modelo de IA lee las fotos (JPG, PNG, WEBP).',
|
||||
|
||||
@@ -144,6 +144,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Impordi failist',
|
||||
'reservations.import.dropHere': 'Lohista broneeringukinnituse failid siia või klõpsa valimiseks',
|
||||
'reservations.import.dropActive': 'Impordiks lohista failid siia',
|
||||
'reservations.import.pinSearching': 'Kaardilt otsimine…',
|
||||
'reservations.import.pinFound': 'Kaardil',
|
||||
'reservations.import.pinMissingTitle': 'Veel pole kaardil',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK ei leidnud seda majutust kaardilt. Kontrolli ülal nime ja aadressi ning otsi uuesti või salvesta see ilma nõelata.',
|
||||
'reservations.import.pinRetry': 'Otsi uuesti',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Salvestatud ilma kaardinõelata. Muuda kohta ja määra selle asukoht, et seda kaardil näha.',
|
||||
'reservations.import.acceptedFormats': 'Lubatud: EML, PDF, PKPass, HTML, TXT (kuni 10 MB faili kohta, kuni 5 faili)',
|
||||
'reservations.import.acceptedPhotos': 'Fotosid (JPG, PNG, WEBP) loeb tehisintellekti mudel.',
|
||||
'reservations.import.parsing': 'Failide töötlemine…',
|
||||
|
||||
@@ -142,6 +142,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.dropHere':
|
||||
'Déposez les fichiers de confirmation de réservation ici ou cliquez pour sélectionner',
|
||||
'reservations.import.dropActive': 'Déposez les fichiers pour importer',
|
||||
'reservations.import.pinSearching': 'Recherche sur la carte…',
|
||||
'reservations.import.pinFound': 'Sur la carte',
|
||||
'reservations.import.pinMissingTitle': 'Pas encore sur la carte',
|
||||
'reservations.import.pinMissingBody':
|
||||
"TREK n'a pas trouvé cet hébergement sur la carte. Vérifiez le nom et l'adresse ci-dessus et relancez la recherche, ou enregistrez-le sans repère.",
|
||||
'reservations.import.pinRetry': 'Rechercher à nouveau',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Enregistré sans repère sur la carte. Modifiez le lieu et définissez sa position pour le voir sur la carte.',
|
||||
'reservations.import.acceptedFormats':
|
||||
"Acceptés : EML, PDF, PKPass, HTML, TXT (max. 10 Mo chacun, jusqu'à 5 fichiers)",
|
||||
'reservations.import.acceptedPhotos': "Les photos (JPG, PNG, WEBP) sont lues par le modèle d'IA.",
|
||||
|
||||
@@ -142,6 +142,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Εισαγωγή από αρχείο',
|
||||
'reservations.import.dropHere': 'Αποθέστε αρχεία επιβεβαίωσης κράτησης εδώ ή κάντε κλικ για επιλογή',
|
||||
'reservations.import.dropActive': 'Αποθέστε αρχεία για εισαγωγή',
|
||||
'reservations.import.pinSearching': 'Αναζήτηση στον χάρτη…',
|
||||
'reservations.import.pinFound': 'Στον χάρτη',
|
||||
'reservations.import.pinMissingTitle': 'Δεν είναι ακόμη στον χάρτη',
|
||||
'reservations.import.pinMissingBody':
|
||||
'Το TREK δεν βρήκε αυτό το κατάλυμα στον χάρτη. Ελέγξτε το όνομα και τη διεύθυνση παραπάνω και αναζητήστε ξανά ή αποθηκεύστε το χωρίς καρφίτσα.',
|
||||
'reservations.import.pinRetry': 'Νέα αναζήτηση',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Αποθηκεύτηκε χωρίς καρφίτσα στον χάρτη. Επεξεργαστείτε το μέρος και ορίστε τη θέση του για να εμφανιστεί στον χάρτη.',
|
||||
'reservations.import.acceptedFormats':
|
||||
'Αποδεκτά: EML, PDF, PKPass, HTML, TXT (μέγιστο 10 MB το καθένα, έως 5 αρχεία)',
|
||||
'reservations.import.acceptedPhotos': 'Τις φωτογραφίες (JPG, PNG, WEBP) τις διαβάζει το μοντέλο ΤΝ.',
|
||||
|
||||
@@ -140,6 +140,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Importálás fájlból',
|
||||
'reservations.import.dropHere': 'Dobja ide a foglalási visszaigazolás fájlokat, vagy kattintson a kiválasztáshoz',
|
||||
'reservations.import.dropActive': 'Dobja ide a fájlokat az importáláshoz',
|
||||
'reservations.import.pinSearching': 'Keresés a térképen…',
|
||||
'reservations.import.pinFound': 'A térképen',
|
||||
'reservations.import.pinMissingTitle': 'Még nincs a térképen',
|
||||
'reservations.import.pinMissingBody':
|
||||
'A TREK nem találta meg ezt a szállást a térképen. Ellenőrizd a fenti nevet és címet, és keress újra, vagy mentsd el tű nélkül.',
|
||||
'reservations.import.pinRetry': 'Keresés újra',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Térképtű nélkül mentve. Szerkeszd a helyet, és állítsd be a pozícióját, hogy megjelenjen a térképen.',
|
||||
'reservations.import.acceptedFormats':
|
||||
'Elfogadott: EML, PDF, PKPass, HTML, TXT (max. 10 MB darabonként, legfeljebb 5 fájl)',
|
||||
'reservations.import.acceptedPhotos': 'A fotókat (JPG, PNG, WEBP) az MI-modell olvassa be.',
|
||||
|
||||
@@ -140,6 +140,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Impor dari file',
|
||||
'reservations.import.dropHere': 'Seret file konfirmasi pemesanan ke sini atau klik untuk memilih',
|
||||
'reservations.import.dropActive': 'Lepaskan file untuk mengimpor',
|
||||
'reservations.import.pinSearching': 'Mencari di peta…',
|
||||
'reservations.import.pinFound': 'Di peta',
|
||||
'reservations.import.pinMissingTitle': 'Belum ada di peta',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK tidak menemukan penginapan ini di peta. Periksa nama dan alamat di atas lalu cari lagi, atau simpan tanpa pin.',
|
||||
'reservations.import.pinRetry': 'Cari lagi',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Disimpan tanpa pin peta. Edit tempatnya dan atur lokasinya agar muncul di peta.',
|
||||
'reservations.import.acceptedFormats': 'Diterima: EML, PDF, PKPass, HTML, TXT (maks. 10 MB per file, hingga 5 file)',
|
||||
'reservations.import.acceptedPhotos': 'Foto (JPG, PNG, WEBP) dibaca oleh model AI.',
|
||||
'reservations.import.parsing': 'Memproses file…',
|
||||
|
||||
@@ -140,6 +140,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Importa da file',
|
||||
'reservations.import.dropHere': 'Trascina i file di conferma prenotazione qui o clicca per selezionare',
|
||||
'reservations.import.dropActive': 'Rilascia i file per importare',
|
||||
'reservations.import.pinSearching': 'Ricerca sulla mappa…',
|
||||
'reservations.import.pinFound': 'Sulla mappa',
|
||||
'reservations.import.pinMissingTitle': 'Non ancora sulla mappa',
|
||||
'reservations.import.pinMissingBody':
|
||||
"TREK non ha trovato questo alloggio sulla mappa. Controlla il nome e l'indirizzo qui sopra e cerca di nuovo, oppure salvalo senza segnaposto.",
|
||||
'reservations.import.pinRetry': 'Cerca di nuovo',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Salvato senza segnaposto sulla mappa. Modifica il luogo e imposta la sua posizione per vederlo sulla mappa.',
|
||||
'reservations.import.acceptedFormats': 'Accettati: EML, PDF, PKPass, HTML, TXT (max 10 MB ciascuno, fino a 5 file)',
|
||||
'reservations.import.acceptedPhotos': 'Le foto (JPG, PNG, WEBP) vengono lette dal modello di IA.',
|
||||
'reservations.import.parsing': 'Analisi dei file in corso…',
|
||||
|
||||
@@ -139,6 +139,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'ファイルからインポート',
|
||||
'reservations.import.dropHere': '予約確認ファイルをここにドロップするか、クリックして選択',
|
||||
'reservations.import.dropActive': 'ファイルをドロップしてインポート',
|
||||
'reservations.import.pinSearching': '地図で検索しています…',
|
||||
'reservations.import.pinFound': '地図上の場所',
|
||||
'reservations.import.pinMissingTitle': 'まだ地図にありません',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK はこの宿泊先を地図上で見つけられませんでした。上の名前と住所を確認して再検索するか、ピンなしで保存してください。',
|
||||
'reservations.import.pinRetry': '再検索',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'地図のピンなしで保存しました。地図に表示するには、場所を編集して位置を設定してください。',
|
||||
'reservations.import.acceptedFormats': '対応形式:EML、PDF、PKPass、HTML、TXT(各最大 10 MB、最大 5 ファイル)',
|
||||
'reservations.import.acceptedPhotos': '写真(JPG、PNG、WEBP)は AI モデルが読み取ります。',
|
||||
'reservations.import.parsing': 'ファイルを解析中…',
|
||||
|
||||
@@ -139,6 +139,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': '파일에서 가져오기',
|
||||
'reservations.import.dropHere': '예약 확인 파일을 여기에 끌어다 놓거나 클릭하여 선택',
|
||||
'reservations.import.dropActive': '가져올 파일을 여기에 놓으세요',
|
||||
'reservations.import.pinSearching': '지도에서 찾는 중…',
|
||||
'reservations.import.pinFound': '지도에 있음',
|
||||
'reservations.import.pinMissingTitle': '아직 지도에 없음',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK이 지도에서 이 숙소를 찾지 못했습니다. 위의 이름과 주소를 확인하고 다시 검색하거나, 핀 없이 저장하세요.',
|
||||
'reservations.import.pinRetry': '다시 검색',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'지도 핀 없이 저장했습니다. 지도에 표시하려면 장소를 편집해 위치를 설정하세요.',
|
||||
'reservations.import.acceptedFormats': '허용 형식: EML, PDF, PKPass, HTML, TXT (파일당 최대 10 MB, 최대 5개)',
|
||||
'reservations.import.acceptedPhotos': '사진(JPG, PNG, WEBP)은 AI 모델이 읽습니다.',
|
||||
'reservations.import.parsing': '파일 분석 중…',
|
||||
|
||||
@@ -140,6 +140,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Importeren vanuit bestand',
|
||||
'reservations.import.dropHere': 'Zet hier bevestigingsbestanden neer of klik om te selecteren',
|
||||
'reservations.import.dropActive': 'Laat bestanden los om te importeren',
|
||||
'reservations.import.pinSearching': 'Zoeken op de kaart…',
|
||||
'reservations.import.pinFound': 'Op de kaart',
|
||||
'reservations.import.pinMissingTitle': 'Nog niet op de kaart',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK kon dit verblijf niet op de kaart vinden. Controleer de naam en het adres hierboven en zoek opnieuw, of sla het op zonder speld.',
|
||||
'reservations.import.pinRetry': 'Opnieuw zoeken',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Opgeslagen zonder speld op de kaart. Bewerk de plek en stel de locatie in om hem op de kaart te zien.',
|
||||
'reservations.import.acceptedFormats':
|
||||
'Geaccepteerd: EML, PDF, PKPass, HTML, TXT (max. 10 MB per stuk, tot 5 bestanden)',
|
||||
'reservations.import.acceptedPhotos': "Foto's (JPG, PNG, WEBP) worden door het AI-model gelezen.",
|
||||
|
||||
@@ -141,6 +141,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Importuj z pliku',
|
||||
'reservations.import.dropHere': 'Upuść pliki potwierdzeń rezerwacji tutaj lub kliknij, aby wybrać',
|
||||
'reservations.import.dropActive': 'Upuść pliki, aby zaimportować',
|
||||
'reservations.import.pinSearching': 'Szukanie na mapie…',
|
||||
'reservations.import.pinFound': 'Na mapie',
|
||||
'reservations.import.pinMissingTitle': 'Jeszcze nie na mapie',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK nie znalazł tego noclegu na mapie. Sprawdź nazwę i adres powyżej i wyszukaj ponownie albo zapisz go bez pinezki.',
|
||||
'reservations.import.pinRetry': 'Szukaj ponownie',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Zapisano bez pinezki na mapie. Edytuj miejsce i ustaw jego położenie, aby zobaczyć je na mapie.',
|
||||
'reservations.import.acceptedFormats': 'Akceptowane: EML, PDF, PKPass, HTML, TXT (maks. 10 MB każdy, do 5 plików)',
|
||||
'reservations.import.acceptedPhotos': 'Zdjęcia (JPG, PNG, WEBP) odczytuje model AI.',
|
||||
'reservations.import.parsing': 'Przetwarzanie plików…',
|
||||
|
||||
@@ -139,6 +139,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Импортировать из файла',
|
||||
'reservations.import.dropHere': 'Перетащите файлы подтверждений бронирования сюда или нажмите для выбора',
|
||||
'reservations.import.dropActive': 'Отпустите файлы для импорта',
|
||||
'reservations.import.pinSearching': 'Поиск на карте…',
|
||||
'reservations.import.pinFound': 'На карте',
|
||||
'reservations.import.pinMissingTitle': 'Пока нет на карте',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK не нашёл это жильё на карте. Проверьте название и адрес выше и повторите поиск или сохраните без метки.',
|
||||
'reservations.import.pinRetry': 'Искать снова',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Сохранено без метки на карте. Измените место и укажите его расположение, чтобы увидеть его на карте.',
|
||||
'reservations.import.acceptedFormats': 'Принимаются: EML, PDF, PKPass, HTML, TXT (макс. 10 МБ каждый, до 5 файлов)',
|
||||
'reservations.import.acceptedPhotos': 'Фотографии (JPG, PNG, WEBP) читает модель ИИ.',
|
||||
'reservations.import.parsing': 'Обработка файлов…',
|
||||
|
||||
@@ -145,6 +145,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Importovať zo súboru',
|
||||
'reservations.import.dropHere': 'Presuňte sem súbory s potvrdením rezervácie alebo kliknite pre výber',
|
||||
'reservations.import.dropActive': 'Pustite súbory pre import',
|
||||
'reservations.import.pinSearching': 'Hľadanie na mape…',
|
||||
'reservations.import.pinFound': 'Na mape',
|
||||
'reservations.import.pinMissingTitle': 'Zatiaľ nie je na mape',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK toto ubytovanie na mape nenašiel. Skontrolujte názov a adresu vyššie a vyhľadajte znova, alebo ho uložte bez špendlíka.',
|
||||
'reservations.import.pinRetry': 'Hľadať znova',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Uložené bez špendlíka na mape. Upravte miesto a nastavte jeho polohu, aby sa zobrazilo na mape.',
|
||||
'reservations.import.acceptedFormats': 'Prijímané: EML, PDF, PKPass, HTML, TXT (max. 10 MB každý, až 5 súborov)',
|
||||
'reservations.import.acceptedPhotos': 'Fotografie (JPG, PNG, WEBP) číta model AI.',
|
||||
'reservations.import.parsing': 'Spracúvajú sa súbory…',
|
||||
|
||||
@@ -140,6 +140,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Importera från fil',
|
||||
'reservations.import.dropHere': 'Dra och släpp bokningsbekräftelser här, eller klicka för att välja',
|
||||
'reservations.import.dropActive': 'Dra och släpp filerna som ska importeras',
|
||||
'reservations.import.pinSearching': 'Söker på kartan…',
|
||||
'reservations.import.pinFound': 'På kartan',
|
||||
'reservations.import.pinMissingTitle': 'Inte på kartan än',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK hittade inte boendet på kartan. Kontrollera namnet och adressen ovan och sök igen, eller spara det utan nål.',
|
||||
'reservations.import.pinRetry': 'Sök igen',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Sparat utan nål på kartan. Redigera platsen och ange dess position för att se den på kartan.',
|
||||
'reservations.import.acceptedFormats':
|
||||
'Godkända filformat: EML, PDF, PKPass, HTML, TXT (högst 10 MB per fil, upp till 5 filer)',
|
||||
'reservations.import.acceptedPhotos': 'Foton (JPG, PNG, WEBP) läses av AI-modellen.',
|
||||
|
||||
@@ -144,6 +144,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'นำเข้าจากไฟล์',
|
||||
'reservations.import.dropHere': 'วางไฟล์ยืนยันการจองที่นี่ หรือคลิกเพื่อเลือก',
|
||||
'reservations.import.dropActive': 'วางไฟล์ที่จะนำเข้า',
|
||||
'reservations.import.pinSearching': 'กำลังค้นหาบนแผนที่…',
|
||||
'reservations.import.pinFound': 'อยู่บนแผนที่',
|
||||
'reservations.import.pinMissingTitle': 'ยังไม่อยู่บนแผนที่',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK ไม่พบที่พักนี้บนแผนที่ ตรวจสอบชื่อและที่อยู่ด้านบนแล้วค้นหาอีกครั้ง หรือบันทึกโดยไม่มีหมุด',
|
||||
'reservations.import.pinRetry': 'ค้นหาอีกครั้ง',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'บันทึกแล้วโดยไม่มีหมุดบนแผนที่ แก้ไขสถานที่และกำหนดตำแหน่งเพื่อให้แสดงบนแผนที่',
|
||||
'reservations.import.acceptedFormats': 'ยอมรับ: EML, PDF, PKPass, HTML, TXT (สูงสุด 10 MB ต่อไฟล์ สูงสุด 5 ไฟล์)',
|
||||
'reservations.import.acceptedPhotos': 'โมเดล AI จะอ่านรูปถ่าย (JPG, PNG, WEBP)',
|
||||
'reservations.import.parsing': 'กำลังแยกวิเคราะห์ไฟล์...',
|
||||
|
||||
@@ -140,6 +140,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Dosyadan içe aktar',
|
||||
'reservations.import.dropHere': 'Rezervasyon onay dosyalarını buraya sürükleyin veya seçmek için tıklayın',
|
||||
'reservations.import.dropActive': 'İçe aktarmak için dosyaları bırakın',
|
||||
'reservations.import.pinSearching': 'Haritada aranıyor…',
|
||||
'reservations.import.pinFound': 'Haritada',
|
||||
'reservations.import.pinMissingTitle': 'Henüz haritada değil',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK bu konaklamayı haritada bulamadı. Yukarıdaki adı ve adresi kontrol edip yeniden arayın ya da raptiye olmadan kaydedin.',
|
||||
'reservations.import.pinRetry': 'Yeniden ara',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Harita raptiyesi olmadan kaydedildi. Haritada görmek için yeri düzenleyip konumunu ayarlayın.',
|
||||
'reservations.import.acceptedFormats':
|
||||
'Kabul edilenler: EML, PDF, PKPass, HTML, TXT (her biri maks. 10 MB, en fazla 5 dosya)',
|
||||
'reservations.import.acceptedPhotos': 'Fotoğrafları (JPG, PNG, WEBP) yapay zekâ modeli okur.',
|
||||
|
||||
@@ -139,6 +139,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Імпортувати з файлу',
|
||||
'reservations.import.dropHere': 'Перетягніть файли підтверджень бронювання сюди або натисніть для вибору',
|
||||
'reservations.import.dropActive': 'Відпустіть файли для імпорту',
|
||||
'reservations.import.pinSearching': 'Пошук на мапі…',
|
||||
'reservations.import.pinFound': 'На мапі',
|
||||
'reservations.import.pinMissingTitle': 'Поки немає на мапі',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK не знайшов це житло на мапі. Перевірте назву й адресу вище та повторіть пошук або збережіть без позначки.',
|
||||
'reservations.import.pinRetry': 'Шукати знову',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Збережено без позначки на мапі. Відредагуйте місце й укажіть його розташування, щоб побачити його на мапі.',
|
||||
'reservations.import.acceptedFormats': 'Підтримуються: EML, PDF, PKPass, HTML, TXT (макс. 10 МБ кожен, до 5 файлів)',
|
||||
'reservations.import.acceptedPhotos': 'Фотографії (JPG, PNG, WEBP) читає модель ШІ.',
|
||||
'reservations.import.parsing': 'Обробка файлів…',
|
||||
|
||||
@@ -140,6 +140,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': 'Nhập từ tập tin',
|
||||
'reservations.import.dropHere': 'Thả file xác nhận đặt phòng tại đây, hoặc bấm để chọn',
|
||||
'reservations.import.dropActive': 'Thả tập tin để nhập',
|
||||
'reservations.import.pinSearching': 'Đang tìm trên bản đồ…',
|
||||
'reservations.import.pinFound': 'Trên bản đồ',
|
||||
'reservations.import.pinMissingTitle': 'Chưa có trên bản đồ',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK không tìm thấy chỗ ở này trên bản đồ. Kiểm tra tên và địa chỉ ở trên rồi tìm lại, hoặc lưu mà không có ghim.',
|
||||
'reservations.import.pinRetry': 'Tìm lại',
|
||||
'reservations.import.savedWithoutPin':
|
||||
'Đã lưu mà không có ghim trên bản đồ. Hãy sửa địa điểm và đặt vị trí để thấy nó trên bản đồ.',
|
||||
'reservations.import.acceptedFormats':
|
||||
'Đã chấp nhận: EML, PDF, PKPass, HTML, TXT (tối đa 10 MB mỗi cái, tối đa 5 tệp)',
|
||||
'reservations.import.acceptedPhotos': 'Ảnh (JPG, PNG, WEBP) được mô hình AI đọc.',
|
||||
|
||||
@@ -138,6 +138,13 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': '從檔案匯入',
|
||||
'reservations.import.dropHere': '將訂位確認檔案拖放到此處,或點擊選擇',
|
||||
'reservations.import.dropActive': '放開檔案以匯入',
|
||||
'reservations.import.pinSearching': '正在地圖上尋找…',
|
||||
'reservations.import.pinFound': '已在地圖上',
|
||||
'reservations.import.pinMissingTitle': '尚未在地圖上',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK 無法在地圖上找到這個住宿。請檢查上方的名稱和地址後重新搜尋,或不帶圖釘儲存。',
|
||||
'reservations.import.pinRetry': '重新搜尋',
|
||||
'reservations.import.savedWithoutPin': '已儲存,但沒有地圖圖釘。編輯該地點並設定位置後,即可在地圖上看到。',
|
||||
'reservations.import.acceptedFormats': '支援格式:EML、PDF、PKPass、HTML、TXT(每個最大 10 MB,最多 5 個檔案)',
|
||||
'reservations.import.acceptedPhotos': '照片(JPG、PNG、WEBP)由 AI 模型讀取。',
|
||||
'reservations.import.parsing': '正在解析檔案…',
|
||||
|
||||
@@ -138,6 +138,13 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.cta': '从文件导入',
|
||||
'reservations.import.dropHere': '将预订确认文件拖放到此处,或点击选择',
|
||||
'reservations.import.dropActive': '松开文件以导入',
|
||||
'reservations.import.pinSearching': '正在地图上查找…',
|
||||
'reservations.import.pinFound': '已在地图上',
|
||||
'reservations.import.pinMissingTitle': '尚未在地图上',
|
||||
'reservations.import.pinMissingBody':
|
||||
'TREK 未能在地图上找到此住宿。请检查上方的名称和地址后重新搜索,或不带图钉保存。',
|
||||
'reservations.import.pinRetry': '重新搜索',
|
||||
'reservations.import.savedWithoutPin': '已保存,但没有地图图钉。编辑该地点并设置位置后即可在地图上看到。',
|
||||
'reservations.import.acceptedFormats': '支持格式:EML、PDF、PKPass、HTML、TXT(每个最大 10 MB,最多 5 个文件)',
|
||||
'reservations.import.acceptedPhotos': '照片(JPG、PNG、WEBP)由 AI 模型读取。',
|
||||
'reservations.import.parsing': '正在解析文件…',
|
||||
|
||||
Reference in New Issue
Block a user