import type { VNode } from 'vue' import { h } from 'vue' import { toast } from 'vue-sonner' import { i18n } from '~/modules/i18n' interface DeploymentRegion { code: string label: string } interface DeploymentToastContext { eventLabel: string route?: string | null actionLabel?: string onAction?: () => void } type Timer = ReturnType type Timeout = ReturnType const DEPLOYMENT_REGIONS: DeploymentRegion[] = [ { code: 'aws:eu-central-1', label: 'EU (Frankfurt)' }, { code: 'gcp:me-central1', label: 'Middle East (Riyadh)' }, { code: 'gcp:asia-east2', label: 'Hong Kong' }, { code: 'aws:ap-northeast-1', label: 'Japan (Tokyo)' }, { code: 'aws:ap-south-1', label: 'India (Mumbai)' }, { code: 'aws:us-east-1', label: 'North America (Virginia)' }, { code: 'gcp:africa-south1', label: 'Africa (South Africa)' }, { code: 'aws:ap-southeast-2', label: 'Oceania (Sydney)' }, { code: 'aws:sa-east-1', label: 'South America (São Paulo)' }, ] // Update distribution got faster: the walkthrough over all regions takes 30s. const TOTAL_REPLICATION_MS = 30_000 const UPDATE_INTERVAL_MS = 500 const REPLICATION_TOAST_CLASSES = { content: 'w-full min-w-0', toast: 'replication-toast', } const REPLICATION_DONE_TOAST_STYLE = { '--initial-height': 'auto', } function getCurrentTimeZone(): string | null { if (typeof Intl === 'undefined') return null const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone return timeZone || null } function getClosestRegionFromTimeZone(): string | null { const tz = getCurrentTimeZone() if (!tz) return null if (tz.startsWith('America/')) return 'aws:us-east-1' if (tz.startsWith('Europe/') || tz.startsWith('Atlantic/')) return 'aws:eu-central-1' if (tz.startsWith('Africa/')) return 'gcp:africa-south1' if (tz.startsWith('Asia/')) { const region = (tz.split('/')[1] ?? '').toLowerCase() if (['tokyo', 'osaka', 'seoul', 'pyongyang'].includes(region)) return 'aws:ap-northeast-1' if (['kolkata', 'mumbai', 'calcutta', 'chennai', 'bangalore', 'hyderabad', 'delhi', 'dhaka', 'colombo', 'karachi'].includes(region)) return 'aws:ap-south-1' if (['hong_kong', 'singapore', 'manila', 'jakarta', 'kuala_lumpur', 'beijing', 'shanghai', 'hongkong', 'taipei'].includes(region)) return 'gcp:asia-east2' if (['sydney', 'melbourne', 'canberra', 'perth', 'brisbane', 'darwin', 'adelaide'].includes(region)) return 'aws:ap-southeast-2' return 'gcp:asia-east2' } if (tz.startsWith('Pacific/') || tz.startsWith('Australia/')) return 'aws:ap-southeast-2' if (typeof navigator !== 'undefined') { const language = navigator.language?.toLowerCase() || '' if (language.endsWith('-in')) return 'aws:ap-south-1' if (language.endsWith('-br') || language.endsWith('-ar')) return 'aws:sa-east-1' if (language.endsWith('-jp')) return 'aws:ap-northeast-1' if (language.endsWith('-za')) return 'gcp:africa-south1' } return null } function getOrderedRegions(): DeploymentRegion[] { const sorted = [...DEPLOYMENT_REGIONS] const closestCode = getClosestRegionFromTimeZone() if (!closestCode) return sorted const idx = sorted.findIndex(region => region.code === closestCode) if (idx < 0) return sorted const [closest] = sorted.splice(idx, 1) sorted.push(closest) return sorted } function formatDuration(seconds: number) { return i18n.global.t('replication-toast-time-left', { seconds }) } function buildActionButton(actionLabel?: string, onAction?: () => void): VNode | null { if (!actionLabel || !onAction) return null return h('div', { class: 'mt-2 flex justify-center' }, [ h('button', { class: [ 'inline-flex h-9 w-fit items-center justify-center rounded-md px-4', 'bg-slate-900 text-sm font-medium text-white', 'border border-transparent hover:bg-slate-800', 'cursor-pointer', 'focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0', 'transition-none', ].join(' '), type: 'button', onClick: onAction, }, actionLabel), ]) } // One segment per region: done, in progress (pulsing), pending. function buildStepBar(total: number, completed: number): VNode { return h('div', { 'class': 'flex w-full gap-1', 'aria-hidden': 'true' }, Array.from({ length: total }, (_, index) => { const state = index < completed ? 'bg-emerald-500' : index === completed ? 'bg-amber-400 animate-pulse' : 'bg-slate-200' return h('span', { class: `h-1.5 flex-1 rounded-full ${state}` }) })) } function getToastTitle() { return i18n.global.t('replication-toast-title') } // Shows only the region being replicated now, so the toast stays compact on phones. function buildDescription( regions: DeploymentRegion[], completed: number, remainingMs: number, actionLabel?: string, onAction?: () => void, ): VNode { const total = regions.length const safeCompleted = Math.min(completed, total) const current = regions[Math.min(safeCompleted, total - 1)] const actionButton = buildActionButton(actionLabel, onAction) return h('div', { class: 'w-full flex flex-col gap-2' }, [ h('div', { class: 'flex items-center gap-2 min-w-0' }, [ h('span', { class: 'inline-block h-2 w-2 shrink-0 rounded-full bg-amber-400 animate-pulse' }), h('span', { class: 'min-w-0 flex-1 truncate text-sm font-medium text-slate-800' }, current?.label ?? ''), h('span', { class: 'shrink-0 text-xs tabular-nums text-slate-500' }, `${Math.min(safeCompleted + 1, total)}/${total}`), ]), buildStepBar(total, safeCompleted), h('div', { class: 'flex justify-between gap-2 text-xs text-slate-500' }, [ h('span', i18n.global.t('replication-toast-regions-replicated', { completed: safeCompleted, total })), h('span', { class: 'tabular-nums' }, formatDuration(Math.max(0, Math.ceil(remainingMs / 1000)))), ]), ...(actionButton ? [actionButton] : []), ]) } function buildDoneDescription(regions: DeploymentRegion[], actionLabel?: string, onAction?: () => void): VNode { const actionButton = buildActionButton(actionLabel, onAction) return h('div', { class: 'w-full flex flex-col gap-2' }, [ buildStepBar(regions.length, regions.length), h('div', { class: 'text-xs text-slate-600' }, i18n.global.t('replication-toast-complete')), ...(actionButton ? [actionButton] : []), ]) } function getCompletedRegionCount(elapsedMs: number, regionCount: number): number { if (regionCount <= 0) return 0 const clampedElapsed = Math.min(Math.max(elapsedMs, 0), TOTAL_REPLICATION_MS) return Math.floor((clampedElapsed / TOTAL_REPLICATION_MS) * regionCount) } export function isUploadReplicationEvent(eventLabel: string): boolean { const event = eventLabel.toLowerCase() return event.includes('upload') && (event.includes('bundle') || event.includes('update') || event.includes('version')) } export function showUploadReplicationToast({ eventLabel: _eventLabel, route, actionLabel, onAction, }: DeploymentToastContext): void { const regions = getOrderedRegions() if (!regions.length) return const hasAction = route && onAction && actionLabel const regionCount = regions.length const startedAt = Date.now() let finished = false let intervalId: Timer | null = null let timeoutId: Timeout | null = null let toastId: string | number const update = () => { if (finished) return const elapsed = Date.now() - startedAt const remaining = Math.max(0, TOTAL_REPLICATION_MS - elapsed) const completed = getCompletedRegionCount(elapsed, regionCount) toast(getToastTitle(), { id: toastId, descriptionClass: 'w-full', classes: REPLICATION_TOAST_CLASSES, description: buildDescription( regions, completed, remaining, hasAction ? actionLabel : undefined, hasAction ? onAction : undefined, ), duration: remaining + 5000, }) } const cleanup = () => { if (finished) return finished = true if (intervalId) clearInterval(intervalId) if (timeoutId) clearTimeout(timeoutId) } const finalize = () => { if (finished) return cleanup() toast(i18n.global.t('replication-toast-globally-available'), { id: toastId, descriptionClass: 'w-full', classes: REPLICATION_TOAST_CLASSES, style: REPLICATION_DONE_TOAST_STYLE, description: buildDoneDescription( regions, hasAction ? actionLabel : undefined, hasAction ? onAction : undefined, ), duration: 6000, }) } const toastTitle = getToastTitle() toastId = toast(toastTitle, { descriptionClass: 'w-full', classes: REPLICATION_TOAST_CLASSES, description: buildDescription( regions, 0, TOTAL_REPLICATION_MS, hasAction ? actionLabel : undefined, hasAction ? onAction : undefined, ), duration: TOTAL_REPLICATION_MS + 5000, onDismiss: cleanup, }) intervalId = setInterval(update, UPDATE_INTERVAL_MS) timeoutId = setTimeout(() => finalize(), TOTAL_REPLICATION_MS) }