mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(places): hours can be typed, and an edited place takes over the looked-up hours; quota as a badge
This commit is contained in:
@@ -5,8 +5,8 @@ import { useGoogleQuota } from './useGoogleQuota'
|
||||
|
||||
/**
|
||||
* The daily ceiling on Google calls (#1582), as one row of the Google options:
|
||||
* the number on the right with today's count under it, a save button only while
|
||||
* there is something to save, and a warning once the day is used up.
|
||||
* today's count as a badge beside the name, the number on the right, a save
|
||||
* button only while there is something to save.
|
||||
*/
|
||||
export default function GoogleDailyLimitRow() {
|
||||
const { t } = useTranslation()
|
||||
@@ -15,10 +15,28 @@ export default function GoogleDailyLimitRow() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2 py-3">
|
||||
<div className="min-w-0 flex-1 basis-60">
|
||||
<label htmlFor="admin-google-daily-limit" className="text-sm font-medium text-content-secondary">{t('admin.googleQuota.title')}</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label htmlFor="admin-google-daily-limit" className="text-sm font-medium text-content-secondary">{t('admin.googleQuota.title')}</label>
|
||||
{/* Today's count beside the name it belongs to: green while there is room, amber once the day is used up. */}
|
||||
{status && (
|
||||
status.exhausted ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-warning-soft px-2 py-[2px] text-caption font-semibold tabular-nums text-warning">
|
||||
<AlertTriangle size={11} strokeWidth={2.4} />
|
||||
{t('admin.googleQuota.reached', { used: status.used_today })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[2px] text-caption font-semibold tabular-nums text-content-secondary">
|
||||
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
{status.daily_limit == null
|
||||
? t('admin.googleQuota.usedToday', { used: status.used_today })
|
||||
: t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-content-faint mt-0.5">{t('admin.googleQuota.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<NumericInput
|
||||
id="admin-google-daily-limit"
|
||||
@@ -38,20 +56,6 @@ export default function GoogleDailyLimitRow() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{status && (
|
||||
status.exhausted ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-0.5 text-xs font-medium text-warning">
|
||||
<AlertTriangle size={12} />
|
||||
{t('admin.googleQuota.reached', { used: status.used_today })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-full bg-surface-tertiary px-2.5 py-0.5 text-xs font-medium tabular-nums text-content-muted">
|
||||
{status.daily_limit == null
|
||||
? t('admin.googleQuota.usedToday', { used: status.used_today })
|
||||
: t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useState } from 'react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { PlaceContactFields } from './PlaceContactFields'
|
||||
import type { PlaceOpeningHours } from '@trek/shared'
|
||||
|
||||
function Harness({ onChange }: { onChange: (field: string, value: string) => void }) {
|
||||
const [state, setState] = useState({ phone: '', email: '', opening_hours: '' })
|
||||
function Harness({ onChange, start = '', suggested }: { onChange: (field: string, value: string) => void; start?: string; suggested?: PlaceOpeningHours | null }) {
|
||||
const [state, setState] = useState({ phone: '', email: '', opening_hours: start })
|
||||
return (
|
||||
<PlaceContactFields phone={state.phone} email={state.email} openingHours={state.opening_hours}
|
||||
<PlaceContactFields phone={state.phone} email={state.email} openingHours={state.opening_hours} suggestedHours={suggested}
|
||||
onChange={(field, value) => { onChange(field, value); setState(s => ({ ...s, [field]: value })) }} />
|
||||
)
|
||||
}
|
||||
@@ -44,4 +45,33 @@ describe('PlaceContactFields (#2472)', () => {
|
||||
expect(onChange).toHaveBeenLastCalledWith('opening_hours', '')
|
||||
expect(screen.getByRole('button', { name: 'Add opening hours' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-CONTACT-003: a time can be typed key by key without the field resetting', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<Harness onChange={onChange} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Add opening hours' }))
|
||||
const opens = screen.getAllByRole('textbox', { name: /Monday opens/ })
|
||||
await user.type(opens[0], '0930')
|
||||
expect(opens[0]).toHaveValue('09:30')
|
||||
expect(JSON.parse(onChange.mock.lastCall![1])[0]).toEqual({ closed: false, open: '09:30' })
|
||||
})
|
||||
|
||||
it('FE-PLANNER-CONTACT-004: looked-up hours fill an empty place, and an edited one offers to take them over', async () => {
|
||||
const user = userEvent.setup()
|
||||
const looked: PlaceOpeningHours = [{ closed: false, open: '10:00', close: '16:00' }, ...Array.from({ length: 6 }, () => ({ closed: true }))]
|
||||
const onChange = vi.fn()
|
||||
const { unmount } = render(<Harness onChange={onChange} suggested={looked} />)
|
||||
expect(JSON.parse(onChange.mock.lastCall![1])).toEqual(looked)
|
||||
expect(screen.queryByRole('button', { name: 'Take over from the place details' })).toBeNull()
|
||||
unmount()
|
||||
|
||||
onChange.mockClear()
|
||||
const own = JSON.stringify([{ closed: false, open: '08:00', close: '09:00' }, ...Array.from({ length: 6 }, () => ({ closed: false }))])
|
||||
render(<Harness onChange={onChange} start={own} suggested={looked} />)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Take over from the place details' }))
|
||||
expect(JSON.parse(onChange.mock.lastCall![1])).toEqual(looked)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { Copy, Mail, Phone, X } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { Copy, Mail, Phone, RotateCcw, X } from 'lucide-react'
|
||||
import type { PlaceOpeningHours } from '@trek/shared'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DialogSection, fs } from '../shared/DialogShell'
|
||||
import { AddRowButton, EditorField, GRID_2, INPUT } from '../shared/dialogParts'
|
||||
import { readWeek, weekdayNames, writeWeek } from './placeHours'
|
||||
import { cleanWeek, readWeek, weekdayNames, writeWeek } from './placeHours'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
@@ -16,6 +16,12 @@ interface Props {
|
||||
/** The stored hours text (JSON), empty when the place has none of its own. */
|
||||
openingHours: string
|
||||
onChange: (field: 'phone' | 'email' | 'opening_hours', value: string) => void
|
||||
/**
|
||||
* The hours the place details column looked up, as a week. A place without
|
||||
* hours of its own takes them over on its own; one that has some gets a
|
||||
* button to take them over instead.
|
||||
*/
|
||||
suggestedHours?: PlaceOpeningHours | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,19 +31,47 @@ interface Props {
|
||||
* behind one row until somebody wants them, so a place without any does not
|
||||
* carry a seven-row grid.
|
||||
*/
|
||||
export function PlaceContactFields({ id: anchorId, phone, email, openingHours, onChange }: Props) {
|
||||
export function PlaceContactFields({ id: anchorId, phone, email, openingHours, onChange, suggestedHours }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
const id = useId()
|
||||
const week = readWeek(openingHours)
|
||||
// The editor's own draft, so a half-typed time ("09:3") stays on screen while
|
||||
// only finished times reach the place.
|
||||
const [week, setDraft] = useState(() => readWeek(openingHours))
|
||||
const written = useRef(openingHours)
|
||||
const [editing, setEditing] = useState(() => openingHours !== '')
|
||||
const names = weekdayNames(locale, 'short')
|
||||
const longNames = weekdayNames(locale, 'long')
|
||||
|
||||
const setWeek = (next: PlaceOpeningHours) => onChange('opening_hours', writeWeek(next))
|
||||
// A value from outside (a picked search result, a reopened place) replaces the draft.
|
||||
useEffect(() => {
|
||||
if (openingHours === written.current) return
|
||||
written.current = openingHours
|
||||
setDraft(readWeek(openingHours))
|
||||
if (openingHours) setEditing(true)
|
||||
}, [openingHours])
|
||||
|
||||
const setWeek = (next: PlaceOpeningHours) => {
|
||||
setDraft(next)
|
||||
const text = writeWeek(cleanWeek(next))
|
||||
written.current = text
|
||||
onChange('opening_hours', text)
|
||||
}
|
||||
const setDay = (i: number, patch: Partial<PlaceOpeningHours[number]>) =>
|
||||
setWeek(week.map((day, d) => (d === i ? { ...day, ...patch } : day)))
|
||||
const copyFirstToAll = () => setWeek(week.map(() => ({ ...week[0] })))
|
||||
const clearHours = () => { onChange('opening_hours', ''); setEditing(false) }
|
||||
const clearHours = () => { setWeek(readWeek('')); setEditing(false) }
|
||||
|
||||
// Looked-up hours are taken over by a place that has none yet, once per lookup.
|
||||
const adopted = useRef<PlaceOpeningHours | null>(null)
|
||||
useEffect(() => {
|
||||
if (!suggestedHours || adopted.current === suggestedHours) return
|
||||
adopted.current = suggestedHours
|
||||
if (written.current) return
|
||||
setWeek(suggestedHours)
|
||||
setEditing(true)
|
||||
// setWeek only reads refs and setters; the lookup is the trigger.
|
||||
}, [suggestedHours])
|
||||
const canTakeOver = !!suggestedHours && writeWeek(suggestedHours) !== writeWeek(cleanWeek(week))
|
||||
|
||||
return (
|
||||
<div id={anchorId} className="flex flex-col gap-4">
|
||||
@@ -62,6 +96,14 @@ export function PlaceContactFields({ id: anchorId, phone, email, openingHours, o
|
||||
label={t('inspector.openingHours')}
|
||||
action={editing ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{canTakeOver && (
|
||||
<Tooltip label={t('places.hoursFromDetails')}>
|
||||
<button type="button" onClick={() => setWeek(suggestedHours!)} aria-label={t('places.hoursFromDetails')}
|
||||
className="grid h-7 w-7 place-items-center rounded-full text-content-faint hover:bg-surface-hover hover:text-content">
|
||||
<RotateCcw size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t('places.hoursCopyFirst', { day: longNames[0] })}>
|
||||
<button type="button" onClick={copyFirstToAll} aria-label={t('places.hoursCopyFirst', { day: longNames[0] })}
|
||||
className="grid h-7 w-7 place-items-center rounded-full text-content-faint hover:bg-surface-hover hover:text-content">
|
||||
|
||||
@@ -50,6 +50,8 @@ interface PlaceDetailsColumnProps {
|
||||
selectedImageUrl?: string
|
||||
onPickImage: (url: string | null) => void
|
||||
onAdoptDescription: (text: string) => void
|
||||
/** The looked-up opening hours, once they arrive, so the form can take them over (#2472). */
|
||||
onHours?: (hours: PlaceHours) => void
|
||||
/** True once the form's description field has something in it. */
|
||||
hasDescription: boolean
|
||||
language: string
|
||||
@@ -152,6 +154,7 @@ export default function PlaceDetailsColumn({
|
||||
selectedImageUrl,
|
||||
onPickImage,
|
||||
onAdoptDescription,
|
||||
onHours,
|
||||
hasDescription,
|
||||
language,
|
||||
timeFormat = '24h',
|
||||
@@ -244,6 +247,13 @@ export default function PlaceDetailsColumn({
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
// Handed to the form once per answer; a ref so a new callback identity does not re-send it.
|
||||
const onHoursRef = useRef(onHours)
|
||||
useEffect(() => { onHoursRef.current = onHours })
|
||||
useEffect(() => {
|
||||
if (state === 'ready' && data?.hours) onHoursRef.current?.(data.hours)
|
||||
}, [state, data])
|
||||
|
||||
const isEmpty =
|
||||
state === 'ready' &&
|
||||
!data?.disabled &&
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw, MapPin,
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { PlaceContactFields } from './PlaceContactFields'
|
||||
import { weekFromPeriods } from './placeHours'
|
||||
import type { PlaceOpeningHours } from '@trek/shared'
|
||||
import {
|
||||
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, isMapUrl, mergeResult, parseCoordinatePair, timeCollisions,
|
||||
type PlaceFormData, type ResultField,
|
||||
@@ -167,6 +169,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
// The query the last full search found nothing for (#2472): the cue to add the
|
||||
// place by hand instead of a silent empty list.
|
||||
const [emptySearch, setEmptySearch] = useState<string | null>(null)
|
||||
// The hours the details column looked up, as a week the contact block can take over (#2472).
|
||||
const [detailsHours, setDetailsHours] = useState<PlaceOpeningHours | null>(null)
|
||||
/**
|
||||
* The leg of the drive the traveller picked, or empty while the projection's own
|
||||
* answer stands. Empty rather than seeded, because there is nothing to project onto
|
||||
@@ -823,6 +827,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
isSaving,
|
||||
pendingFiles,
|
||||
emptySearch, setEmptySearch,
|
||||
detailsHours, setDetailsHours,
|
||||
fileRef,
|
||||
acSuggestions,
|
||||
setAcSuggestions,
|
||||
@@ -1018,6 +1023,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
selectedImageUrl={form.image_url}
|
||||
onPickImage={(url) => setForm(prev => ({ ...prev, image_url: url ?? undefined }))}
|
||||
onAdoptDescription={(text) => setForm(prev => ({ ...prev, description: text }))}
|
||||
onHours={(hours) => S.setDetailsHours(weekFromPeriods(hours.periods))}
|
||||
hasDescription={!!form.description.trim()}
|
||||
language={language}
|
||||
timeFormat={S.timeFormat}
|
||||
@@ -1318,6 +1324,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
phone={form.phone ?? ''}
|
||||
email={form.email ?? ''}
|
||||
openingHours={form.opening_hours ?? ''}
|
||||
suggestedHours={S.detailsHours}
|
||||
onChange={(field, value) => handleChange(field, value)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FE-PLANNER-HOURS-001 to FE-PLANNER-HOURS-003
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { emptyWeek, hoursLines, periodsFromWeek, readWeek, weekdayNames, writeWeek } from './placeHours'
|
||||
import { cleanWeek, emptyWeek, hoursLines, periodsFromWeek, readWeek, weekdayNames, weekFromPeriods, writeWeek } from './placeHours'
|
||||
|
||||
const week = JSON.stringify([
|
||||
{ closed: false, open: '09:00', close: '17:00' },
|
||||
@@ -37,3 +37,29 @@ describe('hand-kept opening hours (#2472)', () => {
|
||||
expect(periodsFromWeek(null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('typing and taking over (#2472)', () => {
|
||||
it('FE-PLANNER-HOURS-004: half-typed times stay out of what is stored', () => {
|
||||
const week = emptyWeek()
|
||||
week[0] = { closed: false, open: '09:3', close: '17:00' }
|
||||
expect(cleanWeek(week)[0]).toEqual({ closed: false, close: '17:00' })
|
||||
expect(readWeek(writeWeek(cleanWeek(week)))[0]).toEqual({ closed: false, close: '17:00' })
|
||||
})
|
||||
|
||||
it('FE-PLANNER-HOURS-005: looked-up periods become a week: one range a day, the rest closed, overnight and 24h handled', () => {
|
||||
const p = (day: number, h: number, m = 0) => ({ day, hour: h, minute: m })
|
||||
const week = weekFromPeriods([
|
||||
{ open: p(1, 9), close: p(1, 12) },
|
||||
{ open: p(1, 14), close: p(1, 18, 30) },
|
||||
{ open: p(5, 20), close: p(6, 2) },
|
||||
{ open: p(0, 0), close: null },
|
||||
])!
|
||||
expect(week[0]).toEqual({ closed: false, open: '09:00', close: '18:30' })
|
||||
expect(week[1]).toEqual({ closed: true })
|
||||
expect(week[4]).toEqual({ closed: false, open: '20:00', close: '02:00' })
|
||||
expect(week[6]).toEqual({ closed: false, open: '00:00', close: '23:59' })
|
||||
expect(weekFromPeriods(null)).toBeNull()
|
||||
expect(weekFromPeriods([])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -57,3 +57,45 @@ export function periodsFromWeek(raw: string | null | undefined): { open: { day:
|
||||
return [{ open: point(day.open, googleDay), close: point(day.close, overnight ? (googleDay + 1) % 7 : googleDay) }]
|
||||
})
|
||||
}
|
||||
|
||||
const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/
|
||||
|
||||
/**
|
||||
* The week with only finished times in it. The time field reports every key
|
||||
* press ("0", "09", "09:3"), and a half-typed time must neither be stored nor
|
||||
* make the stored week unreadable; it stays in the editor's own draft.
|
||||
*/
|
||||
export function cleanWeek(week: PlaceOpeningHours): PlaceOpeningHours {
|
||||
return week.map(day => ({
|
||||
closed: day.closed,
|
||||
...(day.open && HHMM.test(day.open) ? { open: day.open } : {}),
|
||||
...(day.close && HHMM.test(day.close) ? { close: day.close } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
interface Point { day: number; hour: number; minute: number }
|
||||
|
||||
/**
|
||||
* Looked-up hours as a week for the editor (#2472): what the place details
|
||||
* column shows, so a place edited later starts from them instead of from
|
||||
* nothing. One range per day, the earliest opening to the latest closing, which
|
||||
* is what the editor can hold; a day the provider names no period for is
|
||||
* closed. A period that never closes is open around the clock. Null when the
|
||||
* provider gave no periods at all.
|
||||
*/
|
||||
export function weekFromPeriods(periods: { open: Point; close?: Point | null }[] | null | undefined): PlaceOpeningHours | null {
|
||||
if (!periods?.length) return null
|
||||
// Minutes after the day's own midnight; a close past midnight counts on top of 24h.
|
||||
const span: ({ open: number; close: number } | null)[] = Array.from({ length: PLACE_HOURS_DAYS }, () => null)
|
||||
for (const period of periods) {
|
||||
const i = (period.open.day + 6) % 7
|
||||
const open = period.open.hour * 60 + period.open.minute
|
||||
let close = period.close ? period.close.hour * 60 + period.close.minute : 24 * 60 - 1
|
||||
if (period.close && close <= open) close += 24 * 60
|
||||
const cur = span[i]
|
||||
span[i] = cur ? { open: Math.min(cur.open, open), close: Math.max(cur.close, close) } : { open, close }
|
||||
}
|
||||
const clock = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
|
||||
const week: PlaceOpeningHours = span.map(s => (s ? { closed: false, open: clock(s.open), close: clock(s.close) } : { closed: true }))
|
||||
return week
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'يغلق',
|
||||
'places.hoursCopyFirst': 'استخدم ساعات {day} لكل الأيام',
|
||||
'places.hoursRemove': 'إزالة ساعات العمل',
|
||||
'places.hoursFromDetails': 'الأخذ من تفاصيل المكان',
|
||||
'places.searchNothing': 'لم يتم العثور على شيء لـ «{query}»',
|
||||
'places.searchNothingHint': 'أضفه بنفسك: للعنوان والهاتف والبريد وساعات العمل حقول بالأسفل.',
|
||||
'places.addByHand': 'أضف يدويًا',
|
||||
|
||||
@@ -112,6 +112,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'bağlanır',
|
||||
'places.hoursCopyFirst': '{day} saatlarını bütün günlərə tətbiq et',
|
||||
'places.hoursRemove': 'İş saatlarını sil',
|
||||
'places.hoursFromDetails': 'Yer təfərrüatlarından götür',
|
||||
'places.searchNothing': '“{query}” üçün heç nə tapılmadı',
|
||||
'places.searchNothingHint': 'Özünüz əlavə edin: ünvan, telefon, e-poçt və iş saatları üçün aşağıda sahə var.',
|
||||
'places.addByHand': 'Əllə əlavə et',
|
||||
|
||||
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'fecha',
|
||||
'places.hoursCopyFirst': 'Usar o horário de {day} em todos os dias',
|
||||
'places.hoursRemove': 'Remover horário de funcionamento',
|
||||
'places.hoursFromDetails': 'Usar dos detalhes do lugar',
|
||||
'places.searchNothing': 'Nada encontrado para “{query}”',
|
||||
'places.searchNothingHint': 'Adicione você mesmo: endereço, telefone, e-mail e horário têm campos abaixo.',
|
||||
'places.addByHand': 'Adicionar manualmente',
|
||||
|
||||
@@ -101,6 +101,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'tanca',
|
||||
'places.hoursCopyFirst': "Fes servir l'horari de {day} per a tots els dies",
|
||||
'places.hoursRemove': "Elimina l'horari",
|
||||
'places.hoursFromDetails': 'Agafa-ho dels detalls del lloc',
|
||||
'places.searchNothing': "No s'ha trobat res per a «{query}»",
|
||||
'places.searchNothingHint': 'Afegeix-lo tu: adreça, telèfon, correu i horari tenen un camp a sota.',
|
||||
'places.addByHand': 'Afegeix a mà',
|
||||
|
||||
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'zavírá',
|
||||
'places.hoursCopyFirst': 'Použít časy z {day} pro všechny dny',
|
||||
'places.hoursRemove': 'Odebrat otevírací dobu',
|
||||
'places.hoursFromDetails': 'Převzít z podrobností místa',
|
||||
'places.searchNothing': 'Pro „{query}“ nebylo nic nalezeno',
|
||||
'places.searchNothingHint': 'Přidejte ho sami: adresa, telefon, e-mail i otevírací doba mají pole níže.',
|
||||
'places.addByHand': 'Přidat ručně',
|
||||
|
||||
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'schließt',
|
||||
'places.hoursCopyFirst': 'Zeiten vom {day} für alle Tage übernehmen',
|
||||
'places.hoursRemove': 'Öffnungszeiten entfernen',
|
||||
'places.hoursFromDetails': 'Aus den Ortsdetails übernehmen',
|
||||
'places.searchNothing': 'Nichts gefunden für „{query}“',
|
||||
'places.searchNothingHint': 'Leg ihn selbst an: Adresse, Telefon, E-Mail und Öffnungszeiten haben unten ein Feld.',
|
||||
'places.addByHand': 'Selbst anlegen',
|
||||
|
||||
@@ -106,6 +106,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'closes',
|
||||
'places.hoursCopyFirst': "Use {day}'s hours for every day",
|
||||
'places.hoursRemove': 'Remove opening hours',
|
||||
'places.hoursFromDetails': 'Take over from the place details',
|
||||
'places.searchNothing': 'Nothing found for “{query}”',
|
||||
'places.searchNothingHint': 'Add it yourself: address, phone, e-mail and opening hours all have a field below.',
|
||||
'places.addByHand': 'Add by hand',
|
||||
|
||||
@@ -103,6 +103,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'cierra',
|
||||
'places.hoursCopyFirst': 'Usar el horario del {day} para todos los días',
|
||||
'places.hoursRemove': 'Quitar horario',
|
||||
'places.hoursFromDetails': 'Tomar de los detalles del lugar',
|
||||
'places.searchNothing': 'No se encontró nada para «{query}»',
|
||||
'places.searchNothingHint': 'Añádelo tú: dirección, teléfono, correo y horario tienen un campo abajo.',
|
||||
'places.addByHand': 'Añadir a mano',
|
||||
|
||||
@@ -103,6 +103,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'suletakse',
|
||||
'places.hoursCopyFirst': 'Kasuta päeva {day} aegu kõigil päevadel',
|
||||
'places.hoursRemove': 'Eemalda lahtiolekuajad',
|
||||
'places.hoursFromDetails': 'Võta üle koha andmetest',
|
||||
'places.searchNothing': 'Otsingule „{query}“ ei leitud midagi',
|
||||
'places.searchNothingHint': 'Lisa see ise: aadressil, telefonil, e-postil ja lahtiolekuaegadel on all väli.',
|
||||
'places.addByHand': 'Lisa käsitsi',
|
||||
|
||||
@@ -103,6 +103,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'ferme',
|
||||
'places.hoursCopyFirst': 'Appliquer les horaires du {day} à tous les jours',
|
||||
'places.hoursRemove': 'Supprimer les horaires',
|
||||
'places.hoursFromDetails': 'Reprendre depuis les détails du lieu',
|
||||
'places.searchNothing': 'Aucun résultat pour « {query} »',
|
||||
'places.searchNothingHint': 'Ajoutez-le vous-même : adresse, téléphone, e-mail et horaires ont un champ plus bas.',
|
||||
'places.addByHand': 'Ajouter à la main',
|
||||
|
||||
@@ -103,6 +103,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'κλείνει',
|
||||
'places.hoursCopyFirst': 'Χρήση του ωραρίου της ημέρας {day} για όλες τις ημέρες',
|
||||
'places.hoursRemove': 'Αφαίρεση ωραρίου',
|
||||
'places.hoursFromDetails': 'Λήψη από τις λεπτομέρειες τοποθεσίας',
|
||||
'places.searchNothing': 'Δεν βρέθηκε τίποτα για «{query}»',
|
||||
'places.searchNothingHint': 'Προσθέστε το μόνοι σας: διεύθυνση, τηλέφωνο, email και ωράριο έχουν πεδίο παρακάτω.',
|
||||
'places.addByHand': 'Χειροκίνητη προσθήκη',
|
||||
|
||||
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'zár',
|
||||
'places.hoursCopyFirst': 'A(z) {day} nyitvatartása minden napra',
|
||||
'places.hoursRemove': 'Nyitvatartás törlése',
|
||||
'places.hoursFromDetails': 'Átvétel a hely adataiból',
|
||||
'places.searchNothing': 'Nincs találat erre: „{query}”',
|
||||
'places.searchNothingHint': 'Add hozzá magad: a cím, telefon, e-mail és nyitvatartás lent kitölthető.',
|
||||
'places.addByHand': 'Kézi hozzáadás',
|
||||
|
||||
@@ -101,6 +101,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'tutup',
|
||||
'places.hoursCopyFirst': 'Pakai jam {day} untuk semua hari',
|
||||
'places.hoursRemove': 'Hapus jam buka',
|
||||
'places.hoursFromDetails': 'Ambil dari detail tempat',
|
||||
'places.searchNothing': 'Tidak ada hasil untuk “{query}”',
|
||||
'places.searchNothingHint': 'Tambahkan sendiri: alamat, telepon, email, dan jam buka ada di bawah.',
|
||||
'places.addByHand': 'Tambah manual',
|
||||
|
||||
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'chiude',
|
||||
'places.hoursCopyFirst': 'Usa gli orari di {day} per tutti i giorni',
|
||||
'places.hoursRemove': 'Rimuovi orari di apertura',
|
||||
'places.hoursFromDetails': 'Prendi dai dettagli del luogo',
|
||||
'places.searchNothing': 'Nessun risultato per «{query}»',
|
||||
'places.searchNothingHint': 'Aggiungilo tu: indirizzo, telefono, e-mail e orari hanno un campo qui sotto.',
|
||||
'places.addByHand': 'Aggiungi a mano',
|
||||
|
||||
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': '閉店',
|
||||
'places.hoursCopyFirst': '{day}の時間を全曜日に適用',
|
||||
'places.hoursRemove': '営業時間を削除',
|
||||
'places.hoursFromDetails': '場所の詳細から取り込む',
|
||||
'places.searchNothing': '「{query}」は見つかりませんでした',
|
||||
'places.searchNothingHint': '自分で追加できます。住所・電話・メール・営業時間の欄が下にあります。',
|
||||
'places.addByHand': '手動で追加',
|
||||
|
||||
@@ -101,6 +101,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': '마감',
|
||||
'places.hoursCopyFirst': '{day} 시간을 모든 요일에 적용',
|
||||
'places.hoursRemove': '영업시간 삭제',
|
||||
'places.hoursFromDetails': '장소 정보에서 가져오기',
|
||||
'places.searchNothing': '“{query}”에 대한 결과가 없습니다',
|
||||
'places.searchNothingHint': '직접 추가하세요. 주소, 전화, 이메일, 영업시간 입력란이 아래에 있습니다.',
|
||||
'places.addByHand': '직접 추가',
|
||||
|
||||
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'sluit',
|
||||
'places.hoursCopyFirst': 'Tijden van {day} voor alle dagen gebruiken',
|
||||
'places.hoursRemove': 'Openingstijden verwijderen',
|
||||
'places.hoursFromDetails': 'Overnemen uit de plekdetails',
|
||||
'places.searchNothing': 'Niets gevonden voor ‘{query}’',
|
||||
'places.searchNothingHint': 'Voeg het zelf toe: adres, telefoon, e-mail en openingstijden hebben hieronder een veld.',
|
||||
'places.addByHand': 'Zelf toevoegen',
|
||||
|
||||
@@ -92,6 +92,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'zamknięcie',
|
||||
'places.hoursCopyFirst': 'Użyj godzin z dnia {day} dla wszystkich dni',
|
||||
'places.hoursRemove': 'Usuń godziny otwarcia',
|
||||
'places.hoursFromDetails': 'Przejmij ze szczegółów miejsca',
|
||||
'places.searchNothing': 'Nic nie znaleziono dla „{query}”',
|
||||
'places.searchNothingHint': 'Dodaj samodzielnie: adres, telefon, e-mail i godziny otwarcia mają pola poniżej.',
|
||||
'places.addByHand': 'Dodaj ręcznie',
|
||||
|
||||
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'закрывается',
|
||||
'places.hoursCopyFirst': 'Применить часы дня «{day}» ко всем дням',
|
||||
'places.hoursRemove': 'Удалить часы работы',
|
||||
'places.hoursFromDetails': 'Взять из сведений о месте',
|
||||
'places.searchNothing': 'По запросу «{query}» ничего не найдено',
|
||||
'places.searchNothingHint': 'Добавьте сами: для адреса, телефона, почты и часов работы есть поля ниже.',
|
||||
'places.addByHand': 'Добавить вручную',
|
||||
|
||||
@@ -103,6 +103,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'zatvára',
|
||||
'places.hoursCopyFirst': 'Použiť časy z {day} pre všetky dni',
|
||||
'places.hoursRemove': 'Odstrániť otváracie hodiny',
|
||||
'places.hoursFromDetails': 'Prevziať z podrobností miesta',
|
||||
'places.searchNothing': 'Pre „{query}“ sa nič nenašlo',
|
||||
'places.searchNothingHint': 'Pridajte ho sami: adresa, telefón, e-mail aj otváracie hodiny majú pole nižšie.',
|
||||
'places.addByHand': 'Pridať ručne',
|
||||
|
||||
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'stänger',
|
||||
'places.hoursCopyFirst': 'Använd tiderna för {day} alla dagar',
|
||||
'places.hoursRemove': 'Ta bort öppettider',
|
||||
'places.hoursFromDetails': 'Hämta från platsdetaljerna',
|
||||
'places.searchNothing': 'Inget hittades för ”{query}”',
|
||||
'places.searchNothingHint': 'Lägg till den själv: adress, telefon, e-post och öppettider har fält nedanför.',
|
||||
'places.addByHand': 'Lägg till själv',
|
||||
|
||||
@@ -96,6 +96,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'ปิด',
|
||||
'places.hoursCopyFirst': 'ใช้เวลาของวัน{day}กับทุกวัน',
|
||||
'places.hoursRemove': 'ลบเวลาทำการ',
|
||||
'places.hoursFromDetails': 'ดึงจากรายละเอียดสถานที่',
|
||||
'places.searchNothing': 'ไม่พบผลลัพธ์สำหรับ “{query}”',
|
||||
'places.searchNothingHint': 'เพิ่มเองได้: ที่อยู่ โทรศัพท์ อีเมล และเวลาทำการมีช่องให้กรอกด้านล่าง',
|
||||
'places.addByHand': 'เพิ่มเอง',
|
||||
|
||||
@@ -104,6 +104,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'kapanış',
|
||||
'places.hoursCopyFirst': '{day} saatlerini tüm günlere uygula',
|
||||
'places.hoursRemove': 'Çalışma saatlerini kaldır',
|
||||
'places.hoursFromDetails': 'Yer ayrıntılarından al',
|
||||
'places.searchNothing': '“{query}” için sonuç bulunamadı',
|
||||
'places.searchNothingHint': 'Kendin ekle: adres, telefon, e-posta ve çalışma saatleri için aşağıda alan var.',
|
||||
'places.addByHand': 'Elle ekle',
|
||||
|
||||
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'зачиняється',
|
||||
'places.hoursCopyFirst': 'Застосувати години дня «{day}» до всіх днів',
|
||||
'places.hoursRemove': 'Видалити години роботи',
|
||||
'places.hoursFromDetails': 'Взяти з даних про місце',
|
||||
'places.searchNothing': 'За запитом «{query}» нічого не знайдено',
|
||||
'places.searchNothingHint': 'Додайте самі: для адреси, телефону, пошти й годин роботи є поля нижче.',
|
||||
'places.addByHand': 'Додати вручну',
|
||||
|
||||
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': 'đóng cửa',
|
||||
'places.hoursCopyFirst': 'Dùng giờ của {day} cho mọi ngày',
|
||||
'places.hoursRemove': 'Xóa giờ mở cửa',
|
||||
'places.hoursFromDetails': 'Lấy từ thông tin địa điểm',
|
||||
'places.searchNothing': 'Không tìm thấy “{query}”',
|
||||
'places.searchNothingHint': 'Tự thêm: địa chỉ, điện thoại, email và giờ mở cửa đều có ô bên dưới.',
|
||||
'places.addByHand': 'Thêm thủ công',
|
||||
|
||||
@@ -101,6 +101,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': '關門',
|
||||
'places.hoursCopyFirst': '將{day}的時間套用到每一天',
|
||||
'places.hoursRemove': '移除營業時間',
|
||||
'places.hoursFromDetails': '從地點詳情帶入',
|
||||
'places.searchNothing': '找不到「{query}」',
|
||||
'places.searchNothingHint': '自行新增:下方有地址、電話、電子郵件和營業時間的欄位。',
|
||||
'places.addByHand': '手動新增',
|
||||
|
||||
@@ -101,6 +101,7 @@ const places: TranslationStrings = {
|
||||
'places.hoursCloses': '关门',
|
||||
'places.hoursCopyFirst': '将{day}的时间用于每一天',
|
||||
'places.hoursRemove': '移除营业时间',
|
||||
'places.hoursFromDetails': '从地点详情获取',
|
||||
'places.searchNothing': '未找到“{query}”',
|
||||
'places.searchNothingHint': '自己添加:下方有地址、电话、电子邮件和营业时间的输入框。',
|
||||
'places.addByHand': '手动添加',
|
||||
|
||||
Reference in New Issue
Block a user