feat(10-03): open, edit and save a record with toast and 422 feedback

- The SPA loads the backend::lang bundle before /auth/me, sets the
  document language from meta.locale and renders plural messages with
  Intl.PluralRules; interpolate mirrors phrasebook for :name/:Name/:NAME
- Create and record routes; mapWinterUrl maps recordUrl and redirects
  onto the controller's list, create and record routes only
- List rows open their record; FormView loads the form schema and the
  record, shows context-allowed fields in the span grid, saves values
  keyed by field name and toasts the resolved saved message
- A 422 puts each message under its field (aria-invalid,
  aria-describedby), shows the plural banner, focuses the first invalid
  field in schema order and clears a field's error on change
- The D-05 registry maps text, textarea, number and dropdown; any other
  type renders the unsupported-field box with the type in DM Mono
- The admin OpenAPI document declares the write request bodies
  (AdminRecord, AdminIDsRequest) and the list filter query as a
  deepObject, so the typed client can send them
- New backend::lang form.load_failed key; boardwalk/dist rebuilt
This commit is contained in:
Jakub Zych
2026-09-27 16:43:47 +02:00
parent 453b8ae786
commit 126ca5b8ed
41 changed files with 1930 additions and 37 deletions

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed } from 'vue'
import { rendererFor, type FieldControlProps } from './registry'
// Picks the control for field.type from the registry (D-05) and forwards the
// shared control props and the value update.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
const control = computed(() => rendererFor(props.field.type))
</script>
<template>
<component
:is="control"
:field="field"
:model-value="modelValue"
:control-id="controlId"
:invalid="invalid"
:described-by="describedBy"
:labels="labels"
:source="source"
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
/>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CircleAlert } from '@lucide/vue'
import { t, tc } from '../../app/i18n'
// 422 banner (design screen 4): "Nie udało się zapisać. Popraw N pola…".
// Messages of keys that are not form fields are listed here, so no server
// message is lost.
const props = defineProps<{ errors: Record<string, string[]>; fieldNames: string[] }>()
const count = computed(() => Object.keys(props.errors).length)
const orphans = computed(() =>
Object.entries(props.errors)
.filter(([name]) => !props.fieldNames.includes(name))
.flatMap(([, messages]) => messages),
)
</script>
<template>
<div
v-if="count > 0"
role="alert"
data-error-banner
class="flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
>
<CircleAlert :size="20" class="mt-px shrink-0" aria-hidden="true" />
<div class="flex flex-col gap-1">
<p>
<strong>{{ t('backend::lang.form.error_title') }}</strong>
{{ tc('backend::lang.form.error_fields', count) }}
</p>
<p v-for="(text, index) in orphans" :key="index" class="text-[13px]">{{ text }}</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CircleAlert } from '@lucide/vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import FieldRenderer from './FieldRenderer.vue'
import { ownsLabel } from './registry'
// One form row: label (600, red aria-hidden asterisk when required), the
// control, the comment and the error line linked by aria-describedby.
const props = defineProps<{
field: FormField
modelValue: unknown
errors?: string[]
labels?: RelationOption[]
source?: ControllerParams | null
/** Prefix for element ids, so two forms on a page never collide. */
idPrefix?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
const base = computed(() => `${props.idPrefix ?? 'field'}-${props.field.name}`)
const controlId = computed(() => base.value)
const commentId = computed(() => `${base.value}-comment`)
const errorId = computed(() => `${base.value}-error`)
const invalid = computed(() => (props.errors?.length ?? 0) > 0)
const selfLabelled = computed(() => ownsLabel(props.field.type))
const showComment = computed(() => !selfLabelled.value && !!props.field.comment)
const describedBy = computed(() =>
[showComment.value || (selfLabelled.value && props.field.comment) ? commentId.value : '', invalid.value ? errorId.value : '']
.filter(Boolean)
.join(' '),
)
</script>
<template>
<div class="flex min-w-0 flex-col gap-1.5" :data-field="field.name">
<label v-if="!selfLabelled" :for="controlId" class="font-semibold">
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
</label>
<FieldRenderer
:field="field"
:model-value="modelValue"
:control-id="controlId"
:invalid="invalid"
:described-by="describedBy"
:labels="labels"
:source="source"
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
/>
<p v-if="showComment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>
<div v-if="invalid" :id="errorId" class="flex flex-col gap-0.5 text-[13px] text-danger">
<p v-for="(text, index) in errors" :key="index" class="flex items-center gap-1.5">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ text }}</span>
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { AdminRecord, ControllerParams, FormField as FormFieldSchema, RecordMeta } from '../../api/types'
import FormField from './FormField.vue'
// Two-column form grid (design screen 4): span left -> column 1, right ->
// column 2, full (Winter's default) -> the whole row, auto and row -> the next
// free slot. Below 600px everything is one column. Gaps are 22px x 24px.
defineProps<{
fields: FormFieldSchema[]
values: AdminRecord
errors: Record<string, string[]>
labels?: RecordMeta['labels']
source?: ControllerParams | null
idPrefix?: string
}>()
const emit = defineEmits<{ update: [name: string, value: unknown] }>()
function spanClass(span: string | undefined): string {
switch (span) {
case 'left':
return 'min-[600px]:col-start-1'
case 'right':
return 'min-[600px]:col-start-2'
case 'auto':
case 'row':
return ''
default:
return 'col-span-full'
}
}
</script>
<template>
<div class="grid grid-cols-1 gap-x-6 gap-y-[22px] min-[600px]:grid-cols-2">
<FormField
v-for="field in fields"
:key="field.name"
:class="spanClass(field.span)"
:field="field"
:model-value="values[field.name]"
:errors="errors[field.name]"
:labels="labels?.[field.name]"
:source="source"
:id-prefix="idPrefix"
@update:model-value="(value: unknown) => emit('update', field.name, value)"
/>
</div>
</template>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ChevronDown } from '@lucide/vue'
import type { FormOption } from '../../../api/types'
import { t } from '../../../app/i18n'
import { controlClass, type FieldControlProps } from '../registry'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value']] }>()
const EMPTY = 'empty'
const PLACEHOLDER = 'placeholder'
const options = computed(() => props.field.options ?? [])
function same(a: unknown, b: unknown): boolean {
if (a === null || a === undefined || b === null || b === undefined) {
return a === b || (a == null && b == null)
}
return String(a) === String(b)
}
// The <select> works on indexes so option values keep their JSON type
// (string, number or boolean) when they are sent back.
const selected = computed(() => {
const index = options.value.findIndex((option) => same(option.value, props.modelValue))
if (index >= 0) {
return String(index)
}
if (props.field.emptyOption !== undefined && (props.modelValue === null || props.modelValue === undefined || props.modelValue === '')) {
return EMPTY
}
return PLACEHOLDER
})
const muted = computed(() => selected.value === EMPTY || selected.value === PLACEHOLDER)
function onChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value
if (value === EMPTY) {
emit('update:modelValue', null)
return
}
const option = options.value[Number(value)]
if (option) {
emit('update:modelValue', option.value)
}
}
</script>
<template>
<div class="relative">
<select
:id="controlId"
:name="field.name"
:value="selected"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="[controlClass(invalid), muted ? 'text-muted' : '']"
class="h-input appearance-none pr-10"
@change="onChange"
>
<option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option>
<option v-else-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
{{ t('backend::lang.form.select_placeholder') }}
</option>
<option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text">
{{ option.label }}
</option>
</select>
<ChevronDown
:size="16"
class="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: number | null] }>()
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
const attrs = computed(() => controlAttributes(props.field))
// An empty input clears the value (null); anything else is sent as a number.
function onInput(event: Event): void {
const raw = (event.target as HTMLInputElement).value.trim()
if (raw === '') {
emit('update:modelValue', null)
return
}
const value = Number(raw)
emit('update:modelValue', Number.isFinite(value) ? value : null)
}
</script>
<template>
<input
:id="controlId"
v-bind="attrs"
:name="field.name"
type="number"
inputmode="decimal"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="h-input tabular-nums"
@input="onInput"
/>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
const attrs = computed(() => controlAttributes(props.field))
</script>
<template>
<input
:id="controlId"
v-bind="attrs"
:name="field.name"
type="text"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="h-input"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
// Winter textarea sizes mapped to rows; the design's Opis uses 4 (small).
const rowsBySize: Record<string, number> = { tiny: 2, small: 4, large: 8, huge: 12, giant: 18 }
const rows = computed(() => rowsBySize[props.field.size ?? ''] ?? 5)
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
const attrs = computed(() => controlAttributes(props.field))
</script>
<template>
<textarea
:id="controlId"
v-bind="attrs"
:name="field.name"
:rows="rows"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="min-h-input resize-y py-2.5"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Puzzle } from '@lucide/vue'
import { t } from '../../../app/i18n'
import type { FieldControlProps } from '../registry'
// D-05 fallback for any field type without a renderer. The translated
// sentence is split around the type so the type renders in DM Mono, as text.
const props = defineProps<FieldControlProps>()
const MARK = '\u0000'
const parts = computed(() => {
const [before = '', after = ''] = t('backend::lang.form.unsupported_field', { type: MARK }).split(MARK)
return { before, after }
})
const hasType = computed(() => t('backend::lang.form.unsupported_field', { type: MARK }).includes(MARK))
</script>
<template>
<div
:id="controlId"
data-unsupported-field
:aria-describedby="describedBy || undefined"
class="flex min-h-input items-center gap-2.5 rounded-control border-[1.5px] border-dashed border-border-strong bg-subtle px-3.5 text-[13px] text-muted"
>
<Puzzle :size="16" aria-hidden="true" />
<span v-if="hasType"
>{{ parts.before }}<code class="font-mono text-text">{{ props.field.type }}</code>{{ parts.after }}</span
>
<span v-else>{{ t('backend::lang.form.unsupported_field') }} <code class="font-mono text-text">{{ props.field.type }}</code></span>
</div>
</template>

View File

@@ -0,0 +1,83 @@
// Helpers shared by the record form and the settings form: context
// filtering, the save payload, 422 detail mapping and focus.
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
import { isRegistered } from './registry'
export type FormMode = 'create' | 'update'
/** Winter `context:` semantics: no context shows the field everywhere. */
export function contextAllows(field: FormField, mode: FormMode): boolean {
const context = field.context
if (context === undefined || context === null) {
return true
}
const values = Array.isArray(context) ? context : [context]
return values.length === 0 || values.includes(mode)
}
/**
* The save body: values keyed by field name for every editable field that
* has a value. Read-only fields and types without a renderer are never sent.
*/
export function editablePayload(fields: FormField[], values: AdminRecord): AdminRecord {
const out: AdminRecord = {}
for (const field of fields) {
if (field.readOnly || !isRegistered(field.type)) {
continue
}
const value = values[field.name]
if (value !== undefined) {
out[field.name] = value
}
}
return out
}
/** Initial values of a new record: schema defaults, toggles off, no ids. */
export function initialValues(fields: FormField[]): AdminRecord {
const out: AdminRecord = {}
for (const field of fields) {
if (field.default !== undefined) {
out[field.name] = field.default
} else if (field.type === 'switch' || field.type === 'checkbox') {
out[field.name] = false
} else if (field.type === 'relation' && field.multiple) {
out[field.name] = []
}
}
return out
}
/** D-10 422 details (field -> messages) as a string-list map. */
export function fieldErrors(details: ErrorBody['details'] | undefined): Record<string, string[]> {
const out: Record<string, string[]> = {}
for (const [name, value] of Object.entries(details ?? {})) {
const messages = Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string' && item !== '')
: typeof value === 'string' && value !== ''
? [value]
: []
if (messages.length > 0) {
out[name] = messages
}
}
return out
}
/** Stable serialization for dirty checks. */
export function snapshot(record: AdminRecord): string {
return JSON.stringify(Object.keys(record).sort().map((key) => [key, record[key]]))
}
/** Focuses a field's control (FormField ids are `${prefix}-${name}`). */
export function focusField(prefix: string, name: string): boolean {
const element = document.getElementById(`${prefix}-${name}`)
if (!element) {
return false
}
if (!element.matches('input, select, textarea, button, [tabindex]')) {
element.setAttribute('tabindex', '-1')
}
element.focus()
return true
}

View File

@@ -0,0 +1,84 @@
// Field renderer registry (D-05). A form field's `type` selects the control
// component; any type without a renderer gets UnsupportedField, the design's
// dashed box, so an unknown type never breaks the form. Phase 10.1 turns this
// seam into the plugin extension point.
import type { Component } from 'vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import DropdownField from './fields/DropdownField.vue'
import NumberField from './fields/NumberField.vue'
import TextField from './fields/TextField.vue'
import TextareaField from './fields/TextareaField.vue'
import UnsupportedField from './fields/UnsupportedField.vue'
/** Props every field control receives from FieldRenderer. */
export interface FieldControlProps {
field: FormField
modelValue: unknown
/** id of the focusable control; the label points at it. */
controlId: string
invalid?: boolean
/** Space-separated ids of the comment and error lines. */
describedBy?: string
/** Display labels of the field's current relation values (meta.labels). */
labels?: RelationOption[]
/** Controller whose option endpoints serve this form; null for settings. */
source?: ControllerParams | null
}
const renderers = new Map<string, Component>([
['text', TextField],
['textarea', TextareaField],
['number', NumberField],
['dropdown', DropdownField],
])
/** Types whose control shows the label itself (toggle cards). */
const selfLabelled = new Set<string>()
export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField
}
/** Whether the SPA can edit values of this field type. */
export function isRegistered(type: string): boolean {
return renderers.has(type)
}
export function ownsLabel(type: string): boolean {
return selfLabelled.has(type)
}
// Winter `attributes:` are HTML attributes on the control. Only these are
// passed through; anything else (event handlers included) is dropped.
const allowedAttributes = new Set([
'readonly',
'placeholder',
'maxlength',
'minlength',
'min',
'max',
'step',
'autocomplete',
'spellcheck',
'pattern',
])
export function controlAttributes(field: FormField): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {}
for (const [name, value] of Object.entries(field.attributes ?? {})) {
const key = name.toLowerCase()
if (!allowedAttributes.has(key) || value === null || value === false) {
continue
}
out[key] = value
}
return out
}
/** Shared control styling: 44px, radius 10, danger border when invalid. */
export function controlClass(invalid: boolean | undefined): string {
return [
'w-full rounded-control border bg-surface px-3.5 text-text transition-colors duration-150 ease-out',
invalid ? 'border-danger' : 'border-border-strong',
].join(' ')
}

View File

@@ -1,17 +1,36 @@
<script setup lang="ts">
import { RouterLink, useRouter } from 'vue-router'
import type { AdminRecord, ListColumn } from '../../api/types'
import { t } from '../../app/i18n'
// Read-only, schema-driven table: columns come from the list schema and each
// record is read through its column keys (D-16). Selection, sorting and
// row links arrive with the list screens.
defineProps<{
// Schema-driven table: columns come from the list schema and each record is
// read through its column keys (D-16). With rowLink a row opens its record
// route; the first cell carries the link for keyboard users.
const props = defineProps<{
columns: ListColumn[]
rows: AdminRecord[]
loading?: boolean
emptyText?: string
rowLink?: (row: AdminRecord) => string | null
}>()
const router = useRouter()
function linkOf(row: AdminRecord): string | null {
return props.rowLink ? props.rowLink(row) : null
}
function openRow(event: MouseEvent, row: AdminRecord): void {
const target = event.target as HTMLElement | null
if (target?.closest('a, button, input, label')) {
return
}
const to = linkOf(row)
if (to) {
void router.push(to)
}
}
function cellText(row: AdminRecord, column: ListColumn): string {
return formatValue(row[column.key])
}
@@ -68,14 +87,23 @@ function rowKey(row: AdminRecord, index: number): string {
</td>
</tr>
<template v-else>
<tr v-for="(row, index) in rows" :key="rowKey(row, index)" class="h-row border-b border-border">
<tr
v-for="(row, index) in rows"
:key="rowKey(row, index)"
:class="linkOf(row) ? 'cursor-pointer hover:bg-hover' : ''"
class="h-row border-b border-border"
@click="openRow($event, row)"
>
<td
v-for="(column, columnIndex) in columns"
:key="column.key"
:class="columnIndex === 0 ? 'font-semibold' : 'text-muted'"
class="px-3.5 first:pl-5 last:pr-5"
>
{{ cellText(row, column) }}
<RouterLink v-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
{{ cellText(row, column) }}
</RouterLink>
<template v-else>{{ cellText(row, column) }}</template>
</td>
</tr>
</template>

View File

@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { ChevronRight } from '@lucide/vue'
import PluginRail from './PluginRail.vue'
import SectionPanel from './SectionPanel.vue'
import Toast from '../ui/Toast.vue'
import { t } from '../../app/i18n'
import { controllerIdFromPath } from '../../app/controllerRoutes'
import { currentUser } from '../../state/useAuth'
@@ -68,5 +69,6 @@ const initials = computed(() =>
<slot />
</main>
</div>
<Toast />
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed, type Component } from 'vue'
import { RouterLink, type RouteLocationRaw } from 'vue-router'
// One button for the whole admin (design tokens): primary, outline, ghost and
// danger (outline). With `to` it renders a router link styled the same way.
const props = withDefaults(
defineProps<{
variant?: 'primary' | 'outline' | 'ghost' | 'danger'
size?: 'md' | 'sm'
type?: 'button' | 'submit'
disabled?: boolean
icon?: Component
to?: RouteLocationRaw
}>(),
{ variant: 'outline', size: 'md', type: 'button', disabled: false, icon: undefined, to: undefined },
)
const variants: Record<NonNullable<typeof props.variant>, string> = {
primary: 'bg-primary text-on-primary hover:opacity-90',
outline: 'border border-border-strong bg-surface text-text hover:bg-hover',
ghost: 'text-muted hover:bg-hover hover:text-text',
danger: 'border border-danger bg-surface text-danger hover:bg-danger-soft',
}
const classes = computed(() => [
'inline-flex shrink-0 items-center justify-center gap-2 rounded-control px-4 font-semibold whitespace-nowrap no-underline transition-colors duration-150 ease-out disabled:cursor-not-allowed disabled:opacity-60',
props.size === 'sm' ? 'h-[38px]' : 'h-button',
variants[props.variant],
])
</script>
<template>
<RouterLink v-if="to !== undefined" :to="to" :class="classes">
<component :is="icon" v-if="icon" :size="16" aria-hidden="true" />
<slot />
</RouterLink>
<button v-else :type="type" :disabled="disabled" :class="classes">
<component :is="icon" v-if="icon" :size="16" aria-hidden="true" />
<slot />
</button>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { Check, CircleAlert, X } from '@lucide/vue'
import { t } from '../../app/i18n'
import { dismissToast, toasts } from '../../state/useToasts'
</script>
<template>
<!-- Bottom centre, 88px up so it clears the sticky form footer. -->
<div class="pointer-events-none fixed inset-x-0 bottom-[88px] z-50 flex flex-col items-center gap-2 px-4">
<div
v-for="toast in toasts"
:key="toast.id"
:role="toast.tone === 'danger' ? 'alert' : 'status'"
:data-tone="toast.tone"
class="pointer-events-auto flex max-w-[560px] items-center gap-3 rounded-[14px] bg-[#1d2740] py-3 pr-2.5 pl-3.5 text-white shadow-toast"
>
<span
:class="toast.tone === 'danger' ? 'bg-[#f58a8a] text-[#1d2740]' : 'bg-accent text-on-accent'"
class="flex size-[26px] shrink-0 items-center justify-center rounded-full"
aria-hidden="true"
>
<CircleAlert v-if="toast.tone === 'danger'" :size="16" />
<Check v-else :size="16" />
</span>
<span class="font-medium">{{ toast.text }}</span>
<button
type="button"
:aria-label="t('backend::lang.form.close')"
class="flex size-8 items-center justify-center rounded-pager text-[#c3cbda] hover:bg-side-hover hover:text-white"
@click="dismissToast(toast.id)"
>
<X :size="16" aria-hidden="true" />
</button>
</div>
</div>
</template>