From bd6cd55a13662c3d2da15779eb9fe0a332c0f421 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 21:36:19 +0200 Subject: [PATCH] =?UTF-8?q?fix(journey):=20resolve=20issues=20#789-801=20?= =?UTF-8?q?=E2=80=94=20mobile=20layout,=20day=20colors,=20location=20forma?= =?UTF-8?q?tting,=20date=20picker,=20public=20share=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/Journey/JourneyMap.tsx | 39 +++++-------- .../src/components/Journey/JourneyMapAuto.tsx | 2 + .../src/components/Journey/JourneyMapGL.tsx | 33 +++++------ .../components/Journey/MobileEntryCard.tsx | 12 ++-- .../components/Journey/MobileEntryView.tsx | 3 +- .../components/Journey/MobileMapTimeline.tsx | 31 ++++++++-- client/src/components/Journey/dayColors.ts | 12 ++++ .../shared/CustomDateTimePicker.tsx | 7 ++- client/src/pages/JourneyDetailPage.tsx | 58 ++++++++++++------- client/src/pages/JourneyPublicPage.tsx | 23 ++++++++ client/src/utils/formatters.ts | 33 +++++++++++ 11 files changed, 178 insertions(+), 75 deletions(-) create mode 100644 client/src/components/Journey/dayColors.ts diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 2dd1c711..f11dd8ea 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -9,6 +9,8 @@ export interface MapMarkerItem { label: string mood?: string | null time: string + dayColor: string + dayLabel: number } export interface JourneyMapHandle { @@ -24,6 +26,8 @@ interface MapEntry { title?: string | null mood?: string | null entry_date: string + dayColor?: string + dayLabel?: number } interface Props { @@ -49,6 +53,8 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] { label: e.title || 'Entry', mood: e.mood, time: e.entry_date, + dayColor: e.dayColor || '#52525B', + dayLabel: e.dayLabel ?? 1, }) } } @@ -59,30 +65,19 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] { const MARKER_W = 28 const MARKER_H = 36 -function markerSvg(index: number, highlighted: boolean, dark: boolean): string { - // Highlighted: inverted colors for contrast (black on light, white on dark) - const fill = dark - ? (highlighted ? '#FAFAFA' : '#A1A1AA') - : (highlighted ? '#18181B' : '#52525B') - const textColor = dark - ? (highlighted ? '#18181B' : '#18181B') - : (highlighted ? '#fff' : '#fff') - const stroke = highlighted - ? (dark ? '#fff' : '#18181B') - : (dark ? '#3F3F46' : '#fff') +function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string { + const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)' const shadow = highlighted - ? (dark - ? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' - : 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))') + ? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' : 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))' - const label = String(index + 1) + const label = String(dayLabel) const scale = highlighted ? 1.2 : 1 return `
- - - ${label} + + + ${label}
` } @@ -115,12 +110,11 @@ const JourneyMap = forwardRef(function JourneyMap( const marker = markersRef.current.get(prev) const item = itemsRef.current.find(i => i.id === prev) if (marker && item) { - const idx = itemsRef.current.indexOf(item) marker.setIcon(L.divIcon({ className: '', iconSize: [MARKER_W, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H], - html: markerSvg(idx, false, isDark), + html: markerSvg(item.dayColor, item.dayLabel, false), })) marker.setZIndexOffset(0) } @@ -130,12 +124,11 @@ const JourneyMap = forwardRef(function JourneyMap( const marker = markersRef.current.get(id) const item = itemsRef.current.find(i => i.id === id) if (marker && item) { - const idx = itemsRef.current.indexOf(item) marker.setIcon(L.divIcon({ className: '', iconSize: [MARKER_W, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H], - html: markerSvg(idx, true, isDark), + html: markerSvg(item.dayColor, item.dayLabel, true), })) marker.setZIndexOffset(1000) } @@ -226,7 +219,7 @@ const JourneyMap = forwardRef(function JourneyMap( className: '', iconSize: [MARKER_W, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H], - html: markerSvg(i, false, !!dark), + html: markerSvg(item.dayColor, item.dayLabel, false), }) const marker = L.marker(pos, { icon }).addTo(map) diff --git a/client/src/components/Journey/JourneyMapAuto.tsx b/client/src/components/Journey/JourneyMapAuto.tsx index 9b126535..478f5d6f 100644 --- a/client/src/components/Journey/JourneyMapAuto.tsx +++ b/client/src/components/Journey/JourneyMapAuto.tsx @@ -14,6 +14,8 @@ interface MapEntry { location_name?: string | null mood?: string | null entry_date: string + dayColor?: string + dayLabel?: number } interface Props { diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index 60cef2c5..7f2dd6a9 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -18,6 +18,8 @@ interface MapEntry { location_name?: string | null mood?: string | null entry_date: string + dayColor?: string + dayLabel?: number } interface Props { @@ -39,6 +41,8 @@ interface Item { label: string locationName: string time: string + dayColor: string + dayLabel: number } const MARKER_W = 28 @@ -55,6 +59,8 @@ function buildItems(entries: MapEntry[]): Item[] { label: e.title || '', locationName: e.location_name || '', time: e.entry_date, + dayColor: e.dayColor || '#52525B', + dayLabel: e.dayLabel ?? 1, }) } } @@ -157,21 +163,15 @@ function ensureJourneyPopupStyle() { document.head.appendChild(s) } -function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement { - const fill = dark - ? (highlighted ? '#FAFAFA' : '#A1A1AA') - : (highlighted ? '#18181B' : '#52525B') - const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff' - const stroke = highlighted - ? (dark ? '#fff' : '#18181B') - : (dark ? '#3F3F46' : '#fff') +function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement { + const fill = dayColor + const textColor = '#fff' + const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)' const shadow = highlighted - ? (dark - ? 'drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' - : 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))') + ? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))' const scale = highlighted ? 1.2 : 1 - const label = String(index + 1) + const label = String(dayLabel) // Outer wrap holds the element mapbox positions via `transform: translate(...)`. // Anything animated (scale, filter) has to live on an inner child — otherwise @@ -183,7 +183,7 @@ function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDiv inner.className = 'trek-journey-marker-inner' inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};` inner.innerHTML = ` - + ${label} ` @@ -273,13 +273,12 @@ const JourneyMapGL = forwardRef(function JourneyMapGL const item = itemsRef.current.find(i => i.id === id) const marker = markersRef.current.get(id) if (!item || !marker) return - const idx = itemsRef.current.indexOf(item) const el = marker.getElement() const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null if (!currentInner) return // Only swap the inner element's styles/HTML. Touching `el.style.cssText` // would wipe mapbox's positional transform and make the marker flicker. - const next = markerHtml(idx, highlighted, !!darkRef.current) + const next = markerHtml(item.dayColor, item.dayLabel, highlighted) const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement currentInner.style.cssText = nextInner.style.cssText currentInner.innerHTML = nextInner.innerHTML @@ -382,8 +381,8 @@ const JourneyMapGL = forwardRef(function JourneyMapGL } // markers - items.forEach((item, i) => { - const el = markerHtml(i, false, !!darkRef.current) + items.forEach((item) => { + const el = markerHtml(item.dayColor, item.dayLabel, false) const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' }) .setLngLat([item.lng, item.lat]) .addTo(map) diff --git a/client/src/components/Journey/MobileEntryCard.tsx b/client/src/components/Journey/MobileEntryCard.tsx index 0f29f87e..9cc1a549 100644 --- a/client/src/components/Journey/MobileEntryCard.tsx +++ b/client/src/components/Journey/MobileEntryCard.tsx @@ -1,4 +1,5 @@ import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react' +import { formatLocationName } from '../../utils/formatters' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' const MOOD_ICONS: Record = { @@ -37,13 +38,14 @@ function stripMarkdown(text: string): string { interface Props { entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null } - index: number + dayLabel: number + dayColor: string isActive: boolean onClick: () => void publicPhotoUrl?: (photoId: number) => string } -export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) { +export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) { const hasLocation = !!(entry.location_lat && entry.location_lng) const hasPhotos = entry.photos && entry.photos.length > 0 const firstPhoto = hasPhotos ? entry.photos![0] : null @@ -98,8 +100,8 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
{/* Day number + date + mood/weather */}
- - {index + 1} + + {dayLabel} {dateStr} {entry.entry_time && ( @@ -141,7 +143,7 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi {hasLocation ? ( - {entry.location_name || 'On the map'} + {formatLocationName(entry.location_name) || 'On the map'} ) : ( No location diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx index f7a76943..50be5edf 100644 --- a/client/src/components/Journey/MobileEntryView.tsx +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -6,6 +6,7 @@ import { ThumbsUp, ThumbsDown, ChevronDown, } from 'lucide-react' import JournalBody from './JournalBody' +import { formatLocationName } from '../../utils/formatters' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' const MOOD_CONFIG: Record = { @@ -130,7 +131,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
- {entry.location_name} + {formatLocationName(entry.location_name)}
)} diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx index 33c88e99..cd543a91 100644 --- a/client/src/components/Journey/MobileMapTimeline.tsx +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -1,9 +1,10 @@ -import { useRef, useState, useEffect, useCallback } from 'react' +import { useRef, useState, useEffect, useCallback, useMemo } from 'react' import { Plus } from 'lucide-react' import JourneyMap from './JourneyMap' import MobileEntryCard from './MobileEntryCard' import type { JourneyMapHandle } from './JourneyMap' import type { JourneyEntry } from '../../store/journeyStore' +import { DAY_COLORS } from './dayColors' interface MapEntry { id: string @@ -23,6 +24,7 @@ interface Props { onEntryClick: (entry: any) => void onAddEntry?: () => void publicPhotoUrl?: (photoId: number) => string + carouselBottom?: string } export default function MobileMapTimeline({ @@ -34,10 +36,22 @@ export default function MobileMapTimeline({ onEntryClick, onAddEntry, publicPhotoUrl, + carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)', }: Props) { const mapRef = useRef(null) const carouselRef = useRef(null) const [activeIndex, setActiveIndex] = useState(0) + + const entryDayMeta = useMemo(() => { + const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())] + const counters = new Map() + return entries.map((e: any) => { + const dayIdx = uniqueDates.indexOf(e.entry_date) + const dayLabel = (counters.get(e.entry_date) ?? 0) + 1 + counters.set(e.entry_date, dayLabel) + return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] } + }) + }, [entries]) const cardRefs = useRef>(new Map()) const activeIndexRef = useRef(activeIndex) useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex]) @@ -142,7 +156,10 @@ export default function MobileMapTimeline({ if (entries.length === 0) { return ( -
+
+
{/* Full-screen map */}
handleCardTap(entry, i)} publicPhotoUrl={publicPhotoUrl} diff --git a/client/src/components/Journey/dayColors.ts b/client/src/components/Journey/dayColors.ts new file mode 100644 index 00000000..8f009ace --- /dev/null +++ b/client/src/components/Journey/dayColors.ts @@ -0,0 +1,12 @@ +export const DAY_COLORS = [ + '#6366f1', + '#f97316', + '#14b8a6', + '#ec4899', + '#22c55e', + '#3b82f6', + '#a855f7', + '#ef4444', + '#f59e0b', + '#06b6d4', +] diff --git a/client/src/components/shared/CustomDateTimePicker.tsx b/client/src/components/shared/CustomDateTimePicker.tsx index 1b685a90..67085f93 100644 --- a/client/src/components/shared/CustomDateTimePicker.tsx +++ b/client/src/components/shared/CustomDateTimePicker.tsx @@ -119,13 +119,14 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com ...(() => { const r = ref.current?.getBoundingClientRect() if (!r) return { top: 0, left: 0 } - const w = 268, pad = 8 + const w = 268, pad = 8, h = 360 const vw = window.innerWidth - const vh = window.innerHeight + const vh = window.visualViewport?.height ?? window.innerHeight let left = r.left let top = r.bottom + 4 if (left + w > vw - pad) left = Math.max(pad, vw - w - pad) - if (top + 320 > vh) top = Math.max(pad, r.top - 320) + if (top + h > vh - pad) top = r.top - h - 4 + top = Math.max(pad, Math.min(top, vh - h - pad)) if (vw < 360) left = Math.max(pad, (vw - w) / 2) return { top, left } })(), diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index f5287a24..41a6da26 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react' +import { formatLocationName } from '../utils/formatters' import { createPortal } from 'react-dom' import { useParams, useNavigate } from 'react-router-dom' import { useJourneyStore } from '../store/journeyStore' @@ -8,6 +9,7 @@ import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client' import { addListener, removeListener } from '../api/websocket' import Navbar from '../components/Layout/Navbar' import JourneyMap from '../components/Journey/JourneyMapAuto' +import { DAY_COLORS } from '../components/Journey/dayColors' import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto' import JournalBody from '../components/Journey/JournalBody' import MarkdownToolbar from '../components/Journey/MarkdownToolbar' @@ -279,16 +281,28 @@ export default function JourneyDetailPage() { [current?.entries] ) - const sidebarMapItems = useMemo(() => mapEntries.map(e => ({ - id: String(e.id), - lat: e.location_lat!, - lng: e.location_lng!, - title: e.title || '', - location_name: e.location_name || '', - mood: e.mood, - created_at: e.entry_date, - entry_date: e.entry_date, - })), [mapEntries]) + const sidebarMapItems = useMemo(() => { + const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date)) + const uniqueDates = [...new Set(sorted.map(e => e.entry_date))] + const dayCounters = new Map() + return sorted.map(e => { + const dayIdx = uniqueDates.indexOf(e.entry_date) + const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1 + dayCounters.set(e.entry_date, dayLabel) + return { + id: String(e.id), + lat: e.location_lat!, + lng: e.location_lng!, + title: e.title || '', + location_name: e.location_name || '', + mood: e.mood, + created_at: e.entry_date, + entry_date: e.entry_date, + dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length], + dayLabel, + } + }) + }, [mapEntries]) const tripDates = useMemo(() => { const dates = new Set() @@ -424,7 +438,7 @@ export default function JourneyDetailPage() { ? 'max-w-[1440px] mx-auto px-0 pt-0' : 'flex w-full overflow-hidden' } - style={!isMobile ? { height: 'calc(100vh - var(--nav-h, 56px))' } : undefined} + style={!isMobile ? { height: 'calc(100dvh - var(--nav-h, 56px))' } : undefined} > {/* LEFT column (full width on mobile, scrollable feed on desktop) */}
{hideSkeletons ? : } - + {hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
@@ -584,7 +598,7 @@ export default function JourneyDetailPage() {
-
+
{dayIdx + 1}
@@ -613,7 +627,7 @@ export default function JourneyDetailPage() { .catch(() => toast.error(t('common.errorOccurred'))) } return ( -
+
{ setActiveEntryId(String(entry.id)); mapRef.current?.highlightMarker(String(entry.id)) }} style={String(entry.id) === activeEntryId ? { outline: `2px solid ${DAY_COLORS[dayIdx % DAY_COLORS.length]}`, outlineOffset: '3px', borderRadius: '12px' } : undefined}> {canReorder && (
- {e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''} + {formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''}
@@ -1360,7 +1375,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: { {entry.location_name && ( - {entry.location_name} + {formatLocationName(entry.location_name)} )} {entry.entry_time && ( @@ -1403,7 +1418,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
{entry.location_name && ( - {entry.location_name} + {formatLocationName(entry.location_name)} )} {entry.entry_time && ( @@ -1482,7 +1497,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => {entry.title || t('journey.detail.newEntry')}
- {entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''} + {formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
@@ -2962,11 +2977,12 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) { ) } -function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: { +function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: { journey: JourneyDetail onClose: () => void onSaved: () => void onOpenInvite: () => void + onRefresh: () => void }) { const { t } = useTranslation() const [title, setTitle] = useState(journey.title) @@ -3133,7 +3149,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: { try { await journeyApi.removeContributor(journey.id, c.user_id) toast.success(t('journey.contributors.removed')) - onSaved() + onRefresh() } catch { toast.error(t('journey.contributors.removeFailed')) } diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index aa10be43..816ac6ec 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -216,6 +216,28 @@ export default function JourneyPublicPage() {
)} + {/* Floating view toggle — visible above the fullscreen map on mobile */} + {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && ( +
+
+ {availableViews.map(v => ( + + ))} +
+
+ )} + {/* Mobile combined map+timeline (public, read-only) */} {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && ( {}} publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} + carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)" /> )} diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index 980f85ac..7a6c01a2 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -1,5 +1,38 @@ import type { AssignmentsMap } from '../types' +// Collapses verbose Nominatim display_name strings (e.g. "Place, 1, Road, Neighbourhood, +// City, County, State, Country, Postcode, Country") into "Place, Postcode, Country". +// Clean short names (≤3 parts) pass through untouched. +export function formatLocationName(raw: string | null | undefined): string { + if (!raw) return '' + const parts = raw.split(',').map(p => p.trim()).filter(Boolean) + if (parts.length <= 3) return raw.trim() + + // Dedup preserving insertion order + const seen = new Set() + const unique: string[] = [] + for (const p of parts) { + if (!seen.has(p.toLowerCase())) { seen.add(p.toLowerCase()); unique.push(p) } + } + if (unique.length <= 3) return unique.join(', ') + + const name = unique[0] + const last = unique[unique.length - 1] + const secondLast = unique.length >= 2 ? unique[unique.length - 2] : null + + // Detect postcode at tail: short alphanumeric with at least one digit, ≤10 chars + const postalRe = /^[A-Z0-9][A-Z0-9\s\-]{1,8}$/i + const isLastPostal = postalRe.test(last) && /\d/.test(last) && last.length <= 10 + const postcode = isLastPostal ? last : null + const country = isLastPostal ? secondLast : last + + const result: string[] = [name] + if (postcode && postcode !== name) result.push(postcode) + if (country && country !== name && country !== postcode) result.push(country) + + return result.join(', ') +} + const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF']) export function currencyDecimals(currency: string): number {