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:
Maurice
2026-10-01 15:18:30 +02:00
parent 647bb33565
commit 39074f6d46
34 changed files with 217 additions and 29 deletions
@@ -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
}
+1
View File
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
'places.hoursCloses': 'يغلق',
'places.hoursCopyFirst': 'استخدم ساعات {day} لكل الأيام',
'places.hoursRemove': 'إزالة ساعات العمل',
'places.hoursFromDetails': 'الأخذ من تفاصيل المكان',
'places.searchNothing': 'لم يتم العثور على شيء لـ «{query}»',
'places.searchNothingHint': 'أضفه بنفسك: للعنوان والهاتف والبريد وساعات العمل حقول بالأسفل.',
'places.addByHand': 'أضف يدويًا',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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à',
+1
View File
@@ -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ě',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -103,6 +103,7 @@ const places: TranslationStrings = {
'places.hoursCloses': 'κλείνει',
'places.hoursCopyFirst': 'Χρήση του ωραρίου της ημέρας {day} για όλες τις ημέρες',
'places.hoursRemove': 'Αφαίρεση ωραρίου',
'places.hoursFromDetails': 'Λήψη από τις λεπτομέρειες τοποθεσίας',
'places.searchNothing': 'Δεν βρέθηκε τίποτα για «{query}»',
'places.searchNothingHint': 'Προσθέστε το μόνοι σας: διεύθυνση, τηλέφωνο, email και ωράριο έχουν πεδίο παρακάτω.',
'places.addByHand': 'Χειροκίνητη προσθήκη',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
'places.hoursCloses': '閉店',
'places.hoursCopyFirst': '{day}の時間を全曜日に適用',
'places.hoursRemove': '営業時間を削除',
'places.hoursFromDetails': '場所の詳細から取り込む',
'places.searchNothing': '「{query}」は見つかりませんでした',
'places.searchNothingHint': '自分で追加できます。住所・電話・メール・営業時間の欄が下にあります。',
'places.addByHand': '手動で追加',
+1
View File
@@ -101,6 +101,7 @@ const places: TranslationStrings = {
'places.hoursCloses': '마감',
'places.hoursCopyFirst': '{day} 시간을 모든 요일에 적용',
'places.hoursRemove': '영업시간 삭제',
'places.hoursFromDetails': '장소 정보에서 가져오기',
'places.searchNothing': '“{query}”에 대한 결과가 없습니다',
'places.searchNothingHint': '직접 추가하세요. 주소, 전화, 이메일, 영업시간 입력란이 아래에 있습니다.',
'places.addByHand': '직접 추가',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
'places.hoursCloses': 'закрывается',
'places.hoursCopyFirst': 'Применить часы дня «{day}» ко всем дням',
'places.hoursRemove': 'Удалить часы работы',
'places.hoursFromDetails': 'Взять из сведений о месте',
'places.searchNothing': 'По запросу «{query}» ничего не найдено',
'places.searchNothingHint': 'Добавьте сами: для адреса, телефона, почты и часов работы есть поля ниже.',
'places.addByHand': 'Добавить вручную',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -96,6 +96,7 @@ const places: TranslationStrings = {
'places.hoursCloses': 'ปิด',
'places.hoursCopyFirst': 'ใช้เวลาของวัน{day}กับทุกวัน',
'places.hoursRemove': 'ลบเวลาทำการ',
'places.hoursFromDetails': 'ดึงจากรายละเอียดสถานที่',
'places.searchNothing': 'ไม่พบผลลัพธ์สำหรับ “{query}”',
'places.searchNothingHint': 'เพิ่มเองได้: ที่อยู่ โทรศัพท์ อีเมล และเวลาทำการมีช่องให้กรอกด้านล่าง',
'places.addByHand': 'เพิ่มเอง',
+1
View File
@@ -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',
+1
View File
@@ -102,6 +102,7 @@ const places: TranslationStrings = {
'places.hoursCloses': 'зачиняється',
'places.hoursCopyFirst': 'Застосувати години дня «{day}» до всіх днів',
'places.hoursRemove': 'Видалити години роботи',
'places.hoursFromDetails': 'Взяти з даних про місце',
'places.searchNothing': 'За запитом «{query}» нічого не знайдено',
'places.searchNothingHint': 'Додайте самі: для адреси, телефону, пошти й годин роботи є поля нижче.',
'places.addByHand': 'Додати вручну',
+1
View File
@@ -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',
+1
View File
@@ -101,6 +101,7 @@ const places: TranslationStrings = {
'places.hoursCloses': '關門',
'places.hoursCopyFirst': '將{day}的時間套用到每一天',
'places.hoursRemove': '移除營業時間',
'places.hoursFromDetails': '從地點詳情帶入',
'places.searchNothing': '找不到「{query}」',
'places.searchNothingHint': '自行新增:下方有地址、電話、電子郵件和營業時間的欄位。',
'places.addByHand': '手動新增',
+1
View File
@@ -101,6 +101,7 @@ const places: TranslationStrings = {
'places.hoursCloses': '关门',
'places.hoursCopyFirst': '将{day}的时间用于每一天',
'places.hoursRemove': '移除营业时间',
'places.hoursFromDetails': '从地点详情获取',
'places.searchNothing': '未找到“{query}”',
'places.searchNothingHint': '自己添加:下方有地址、电话、电子邮件和营业时间的输入框。',
'places.addByHand': '手动添加',