On a failed fetch the store keeps the last real payload (stale > fake) and flags `disconnected` instead of falling back to bundled demo carts — an operator must never book a reject against a fabricated entry. Sample data and sample reason codes are gated behind import.meta.env.DEV, so `vite build` tree-shakes them out entirely (verified: no sample strings in dist). - cold start / no data: centered reconnect spinner that keeps retrying - data present + drop: stale carts stay, header shows amber "reconnecting" - reasons no longer seed sample codes in production - polling retries faster (5s) while disconnected, recovers automatically Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
170 lines
3.9 KiB
Vue
170 lines
3.9 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, onUnmounted, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import type { WindowCode } from '@/types/station'
|
|
|
|
defineProps<{ window: WindowCode; sample: boolean; disconnected?: boolean }>()
|
|
const emit = defineEmits<{ (e: 'update:window', code: WindowCode): void }>()
|
|
|
|
const { t } = useI18n()
|
|
const WINDOWS: WindowCode[] = ['12h', '1T', '7T', '14T', '30T']
|
|
|
|
const clock = ref('')
|
|
let timer: ReturnType<typeof setInterval> | null = null
|
|
function tick() {
|
|
clock.value = new Date().toLocaleTimeString('de-DE')
|
|
}
|
|
onMounted(() => {
|
|
tick()
|
|
timer = setInterval(tick, 1000)
|
|
})
|
|
onUnmounted(() => {
|
|
if (timer) clearInterval(timer)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="bar">
|
|
<span class="mark">{{ t('app.brand') }}</span>
|
|
<span class="titles">
|
|
<span class="title">{{ t('app.title') }}</span>
|
|
<span class="subtitle">{{ t('app.subtitle') }}</span>
|
|
</span>
|
|
<span v-if="sample" class="demo">{{ t('app.demo') }}</span>
|
|
<span class="spacer"></span>
|
|
<span v-if="disconnected" class="reconnect"><span class="spinner sm" aria-hidden="true"></span>{{ t('conn.reconnecting') }}</span>
|
|
<span v-else class="live"><span class="led"></span>{{ t('app.live') }}</span>
|
|
<span class="clock num">{{ clock }}</span>
|
|
<span class="rangep" role="group" :aria-label="t('window.label')">
|
|
<span class="rl">{{ t('window.label') }}</span>
|
|
<button
|
|
v-for="code in WINDOWS"
|
|
:key="code"
|
|
type="button"
|
|
:aria-pressed="code === window"
|
|
@click="emit('update:window', code)"
|
|
>
|
|
{{ code }}
|
|
</button>
|
|
</span>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 12px 16px;
|
|
border: 1px solid var(--line);
|
|
background: linear-gradient(180deg, var(--panel), color-mix(in srgb, var(--panel) 70%, transparent));
|
|
border-radius: var(--r);
|
|
flex-wrap: wrap;
|
|
}
|
|
.mark {
|
|
font-family: var(--mono);
|
|
font-weight: 700;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
font-size: 0.72rem;
|
|
color: #fff;
|
|
background: var(--ausschuss);
|
|
padding: 5px 9px;
|
|
border-radius: var(--r);
|
|
}
|
|
.titles {
|
|
display: flex;
|
|
flex-direction: column;
|
|
line-height: 1.15;
|
|
}
|
|
.title {
|
|
font-weight: 700;
|
|
font-size: 1rem;
|
|
}
|
|
.subtitle {
|
|
font-family: var(--mono);
|
|
font-size: 0.6rem;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
color: var(--ink-faint);
|
|
}
|
|
.demo {
|
|
font-family: var(--mono);
|
|
font-size: 0.62rem;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
color: var(--oven);
|
|
border: 1px solid color-mix(in srgb, var(--oven) 50%, transparent);
|
|
border-radius: var(--r);
|
|
padding: 2px 7px;
|
|
}
|
|
.spacer {
|
|
margin-left: auto;
|
|
}
|
|
.live {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
font-family: var(--mono);
|
|
font-size: 0.68rem;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
color: var(--ink-dim);
|
|
}
|
|
.led {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--done);
|
|
animation: blink 2.4s ease-in-out infinite;
|
|
}
|
|
.reconnect {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
font-family: var(--mono);
|
|
font-size: 0.68rem;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--transit);
|
|
}
|
|
.clock {
|
|
font-size: 0.95rem;
|
|
color: var(--ink-dim);
|
|
}
|
|
.rangep {
|
|
display: inline-flex;
|
|
border: 1px solid var(--line-2);
|
|
border-radius: var(--r);
|
|
overflow: hidden;
|
|
}
|
|
.rl {
|
|
align-self: center;
|
|
font-family: var(--mono);
|
|
font-size: 0.62rem;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--ink-faint);
|
|
padding: 0 11px;
|
|
}
|
|
.rangep button {
|
|
font-family: var(--mono);
|
|
background: transparent;
|
|
border: 0;
|
|
border-left: 1px solid var(--line);
|
|
color: var(--ink-dim);
|
|
padding: 8px 13px;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
.rangep button:hover {
|
|
background: var(--panel-3);
|
|
color: var(--ink);
|
|
}
|
|
.rangep button[aria-pressed='true'] {
|
|
background: var(--ausschuss);
|
|
color: #fff;
|
|
}
|
|
</style>
|