@feedmepos/mf-order-setting 0.0.92 → 0.0.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/DisplaySystemView-Doh1JUoe.js +8891 -0
- package/dist/{KioskDevicesView-d2WLB0-w.js → KioskDevicesView-DvUq9eFa.js} +1 -1
- package/dist/{KioskDevicesView.vue_vue_type_script_setup_true_lang-rs-hNWUW.js → KioskDevicesView.vue_vue_type_script_setup_true_lang-Ce-CHsOK.js} +3 -3
- package/dist/{KioskSettingView-DFE3_u5c.js → KioskSettingView-Ba1sLolg.js} +3 -3
- package/dist/{KioskView-C3Pi3lWW.js → KioskView-CeKSWYbI.js} +5 -5
- package/dist/{OrderSettingsView-Dxg9RjSd.js → OrderSettingsView-CdxkPS_V.js} +22200 -22918
- package/dist/{app-BFlQtcsd.js → app-BQ0W0_dt.js} +2536 -1818
- package/dist/app.js +1 -1
- package/dist/featureFlag-C4gAe_F4.js +846 -0
- package/dist/frontend/mf-order/src/api/brandkit/index.d.ts +3 -0
- package/dist/frontend/mf-order/src/api/native-app/index.d.ts +2 -0
- package/dist/frontend/mf-order/src/app.d.ts +716 -0
- package/dist/frontend/mf-order/src/composables/featureFlag.d.ts +7 -0
- package/dist/frontend/mf-order/src/composables/useBusinessFeatureFlag.d.ts +4 -0
- package/dist/frontend/mf-order/src/composables/useLegacyOdsAccess.d.ts +4 -0
- package/dist/frontend/mf-order/src/composables/useMiniProgramPermission.d.ts +5 -0
- package/dist/frontend/mf-order/src/main.d.ts +716 -0
- package/dist/frontend/mf-order/src/modules/display-system/legacy-ods/interface.d.ts +80 -0
- package/dist/frontend/mf-order/src/views/display-system/legacy-ods/ColorOverrideDialog.vue.d.ts +26 -0
- package/dist/frontend/mf-order/src/views/display-system/legacy-ods/ColorSelection.vue.d.ts +15 -0
- package/dist/frontend/mf-order/src/views/display-system/legacy-ods/LegacyOdsEditorDialog.vue.d.ts +24 -0
- package/dist/frontend/mf-order/src/views/display-system/legacy-ods/LegacyOdsTemplateList.vue.d.ts +4 -0
- package/dist/frontend/mf-order/src/views/display-system/view-selection.d.ts +18 -0
- package/dist/frontend/mf-order/src/views/order-settings/components/push-availability.d.ts +13 -0
- package/dist/frontend/mf-order/tsconfig.app.tsbuildinfo +1 -1
- package/dist/{index-ByiTrmdq.js → index-BFJ4YEfL.js} +2 -2
- package/dist/{index-CaO7aaaT.js → index-CGiJXVU3.js} +1 -1
- package/dist/{index-s2kWGMtz.js → index-DSB009Br.js} +1 -1
- package/dist/{order.dto-C829lA69.js → order.dto-CUp8zhQC.js} +18 -18
- package/dist/package/entity/order-platform/tng/tng-menu.do.d.ts +74 -74
- package/dist/style.css +1 -1
- package/env.d.ts +2 -0
- package/package.json +2 -1
- package/src/api/brandkit/index.ts +24 -0
- package/src/api/native-app/index.ts +26 -0
- package/src/api/native-app/native-app.spec.ts +42 -0
- package/src/composables/featureFlag.ts +22 -0
- package/src/composables/useBusinessFeatureFlag.ts +44 -0
- package/src/composables/useLegacyOdsAccess.ts +35 -0
- package/src/composables/useMiniProgramPermission.ts +19 -0
- package/src/composables/useQrOrderingEnabled.ts +1 -21
- package/src/locales/en-US.json +179 -0
- package/src/locales/ja-JP.json +179 -0
- package/src/locales/th-TH.json +179 -0
- package/src/locales/zh-CN.json +179 -0
- package/src/modules/display-system/legacy-ods/interface.ts +108 -0
- package/src/router/routes.ts +1 -1
- package/src/views/display-system/DisplaySystemView.vue +82 -11
- package/src/views/display-system/legacy-ods/ColorOverrideDialog.vue +223 -0
- package/src/views/display-system/legacy-ods/ColorSelection.vue +67 -0
- package/src/views/display-system/legacy-ods/LegacyOdsEditorDialog.spec.ts +185 -0
- package/src/views/display-system/legacy-ods/LegacyOdsEditorDialog.vue +1677 -0
- package/src/views/display-system/legacy-ods/LegacyOdsTemplateList.spec.ts +124 -0
- package/src/views/display-system/legacy-ods/LegacyOdsTemplateList.vue +297 -0
- package/src/views/display-system/legacy-ods/brandkit.spec.ts +67 -0
- package/src/views/display-system/view-selection.spec.ts +93 -0
- package/src/views/display-system/view-selection.ts +54 -0
- package/src/views/order-settings/components/NotifyChannelEditor.spec.ts +184 -0
- package/src/views/order-settings/components/NotifyChannelEditor.vue +84 -5
- package/src/views/order-settings/components/push-availability.spec.ts +59 -0
- package/src/views/order-settings/components/push-availability.ts +78 -0
- package/src/views/order-settings/queue/QueueSetting.vue +7 -0
- package/src/views/order-settings/sms/SmsSetting.vue +11 -0
- package/dist/DisplaySystemView-DZS7EVfk.js +0 -6564
- package/dist/_plugin-vue_export-helper-CHgC5LLL.js +0 -9
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export type RecursivePartial<T> = {
|
|
2
|
+
[P in keyof T]?: T[P] extends (...args: any[]) => any
|
|
3
|
+
? T[P]
|
|
4
|
+
: T[P] extends readonly (infer U)[]
|
|
5
|
+
? ReadonlyArray<RecursivePartial<U>>
|
|
6
|
+
: T[P] extends (infer U)[]
|
|
7
|
+
? RecursivePartial<U>[]
|
|
8
|
+
: T[P] extends object
|
|
9
|
+
? RecursivePartial<T[P]>
|
|
10
|
+
: T[P]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type BaseBrandKit = {
|
|
14
|
+
colors: {
|
|
15
|
+
primary: string
|
|
16
|
+
secondary: string
|
|
17
|
+
accent: string
|
|
18
|
+
background: {
|
|
19
|
+
base: string
|
|
20
|
+
elevated: string
|
|
21
|
+
}
|
|
22
|
+
neutral: {
|
|
23
|
+
light: string
|
|
24
|
+
mid: string
|
|
25
|
+
dark: string
|
|
26
|
+
}
|
|
27
|
+
status: {
|
|
28
|
+
success: string
|
|
29
|
+
warning: string
|
|
30
|
+
error: string
|
|
31
|
+
info?: string
|
|
32
|
+
}
|
|
33
|
+
text: {
|
|
34
|
+
onPrimary: string
|
|
35
|
+
primary: string
|
|
36
|
+
secondary: string
|
|
37
|
+
tertiary: string
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type OdsConfigEntry = {
|
|
43
|
+
label?: string
|
|
44
|
+
isDefault: boolean
|
|
45
|
+
restaurantIds?: string[]
|
|
46
|
+
brandkitOverride?: RecursivePartial<BaseBrandKit>
|
|
47
|
+
layout?: OdsLayoutConfig
|
|
48
|
+
display?: OdsDisplayConfig
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type OdsFooterMode = 'feedme' | 'settingsIcon'
|
|
52
|
+
|
|
53
|
+
export type OrderGroupingMode = 'type' | 'status'
|
|
54
|
+
|
|
55
|
+
export type OrderStatusBoardLayout = 'horizontal' | 'vertical'
|
|
56
|
+
|
|
57
|
+
export type StatusSectionTemplateName =
|
|
58
|
+
'default' | 'bordered-card' | 'floating-tab' | 'centered-pill'
|
|
59
|
+
|
|
60
|
+
export type OdsLayoutConfig = {
|
|
61
|
+
background?: {
|
|
62
|
+
url: string
|
|
63
|
+
size?: string
|
|
64
|
+
position?: string
|
|
65
|
+
} | null
|
|
66
|
+
sidePanel?: {
|
|
67
|
+
url: string
|
|
68
|
+
position: 'left' | 'right'
|
|
69
|
+
widthPercent: number
|
|
70
|
+
objectFit?: string
|
|
71
|
+
} | null
|
|
72
|
+
boardWidthPercent?: number
|
|
73
|
+
boardAlign?: 'left' | 'right' | 'center'
|
|
74
|
+
footerMode?: OdsFooterMode
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type OdsDisplayConfig = {
|
|
78
|
+
groupBy: OrderGroupingMode
|
|
79
|
+
statusLayout: OrderStatusBoardLayout
|
|
80
|
+
statusSlidingPeriod: number
|
|
81
|
+
currentOrder?: {
|
|
82
|
+
enabled?: boolean
|
|
83
|
+
}
|
|
84
|
+
showProgressBar: boolean
|
|
85
|
+
statusColumnCount: number
|
|
86
|
+
showDeliveryIcons: boolean
|
|
87
|
+
showOrderTypeTabs: boolean
|
|
88
|
+
showOrderCount: boolean
|
|
89
|
+
showEstimatedTime: boolean
|
|
90
|
+
hideCompletedOrders?: boolean
|
|
91
|
+
statusTemplate?: StatusSectionTemplateName
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type BrandKit = Partial<BaseBrandKit> & {
|
|
95
|
+
ods?: OdsConfigEntry[]
|
|
96
|
+
[key: string]: unknown
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function migrateOdsToArray(ods: unknown, defaultLabel: string): OdsConfigEntry[] {
|
|
100
|
+
if (!ods) return []
|
|
101
|
+
if (Array.isArray(ods)) return ods
|
|
102
|
+
const legacy = ods as Partial<OdsConfigEntry>
|
|
103
|
+
const entry: OdsConfigEntry = { label: defaultLabel, isDefault: true }
|
|
104
|
+
if (legacy.brandkitOverride) entry.brandkitOverride = legacy.brandkitOverride
|
|
105
|
+
if (legacy.layout) entry.layout = legacy.layout
|
|
106
|
+
if (legacy.display) entry.display = legacy.display
|
|
107
|
+
return [entry]
|
|
108
|
+
}
|
package/src/router/routes.ts
CHANGED
|
@@ -57,7 +57,7 @@ const routes: RouteRecordRaw[] = [
|
|
|
57
57
|
component: withPermission(
|
|
58
58
|
defineAsyncComponent(() => import('@/views/display-system/DisplaySystemView.vue')),
|
|
59
59
|
),
|
|
60
|
-
meta: permissionMeta([manageOrderSetting]),
|
|
60
|
+
meta: permissionMeta([{ ...manageOrderSetting, coverSubject: Permission.Subject.Business.miniProgram_brandkit }]),
|
|
61
61
|
},
|
|
62
62
|
];
|
|
63
63
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
3
4
|
import { useCoreStore, useI18n } from '@feedmepos/mf-common'
|
|
4
5
|
import type { FmTabProps, ISelectItem } from '@feedmepos/ui-library'
|
|
5
6
|
|
|
@@ -10,21 +11,83 @@ import { useDisplaySystemStore } from '@/stores/display-system'
|
|
|
10
11
|
import DisplayScreenEditor from './DisplayScreenEditor.vue'
|
|
11
12
|
import DisplayTemplateEditor from './DisplayTemplateEditor.vue'
|
|
12
13
|
import DisplayLayoutMap from './DisplayLayoutMap.vue'
|
|
14
|
+
import { useOrderSettingPermission } from '@/composables/useOrderSettingPermission'
|
|
15
|
+
import { useMiniProgramPermission } from '@/composables/useMiniProgramPermission'
|
|
16
|
+
import { getBusinessBrandkit } from '@/api/brandkit'
|
|
17
|
+
import { availableDisplaySystemViews, defaultDisplaySystemView, isPosV8, requestedDisplaySystemView, type DisplaySystemView } from './view-selection'
|
|
18
|
+
import LegacyOdsTemplateList from './legacy-ods/LegacyOdsTemplateList.vue'
|
|
13
19
|
|
|
14
|
-
type Tab = 'screens' | 'templates'
|
|
15
20
|
const { t } = useI18n()
|
|
16
21
|
const { showError, showSuccess } = useSnackbarFunctions()
|
|
22
|
+
const route = useRoute()
|
|
23
|
+
const router = useRouter()
|
|
17
24
|
const core = useCoreStore()
|
|
18
25
|
const store = useDisplaySystemStore()
|
|
19
|
-
const
|
|
26
|
+
const { canManageOrderSetting } = useOrderSettingPermission()
|
|
27
|
+
const { canManageBrandkit } = useMiniProgramPermission()
|
|
28
|
+
const hasLegacyOdsConfigs = ref(false)
|
|
29
|
+
const availableViews = computed(() =>
|
|
30
|
+
availableDisplaySystemViews({
|
|
31
|
+
canManageOrderSetting: canManageOrderSetting.value,
|
|
32
|
+
canManageBrandkit: canManageBrandkit.value,
|
|
33
|
+
outlets: core.restaurants.value,
|
|
34
|
+
hasLegacyOdsConfigs: hasLegacyOdsConfigs.value
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
const selectedView = ref<DisplaySystemView>()
|
|
38
|
+
// The default is settled once the outlets and the current outlet have loaded;
|
|
39
|
+
// after that, picking another outlet on the Screens tab must not swap the view.
|
|
40
|
+
let viewSettled = false
|
|
41
|
+
watch(
|
|
42
|
+
[availableViews, () => core.restaurants.value.length, () => core.currentRestaurant.value?._id],
|
|
43
|
+
([available, outletCount, currentOutletId]) => {
|
|
44
|
+
const requested = requestedDisplaySystemView(route.query)
|
|
45
|
+
if (requested && available.includes(requested)) {
|
|
46
|
+
selectedView.value = requested
|
|
47
|
+
viewSettled = true
|
|
48
|
+
} else if (!viewSettled || !selectedView.value || !available.includes(selectedView.value)) {
|
|
49
|
+
selectedView.value = defaultDisplaySystemView(available, core.restaurants.value, currentOutletId)
|
|
50
|
+
viewSettled = Boolean(outletCount && currentOutletId)
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{ immediate: true }
|
|
54
|
+
)
|
|
55
|
+
const otherView = computed(() => availableViews.value.find((view) => view !== selectedView.value))
|
|
56
|
+
function switchView(view: DisplaySystemView) {
|
|
57
|
+
selectedView.value = view
|
|
58
|
+
viewSettled = true
|
|
59
|
+
const { tab: _tab, ...query } = route.query
|
|
60
|
+
router.replace({ query: { ...query, view } })
|
|
61
|
+
}
|
|
62
|
+
// Only an all-v8 business needs this: one with a v7 outlet already gets the v7 view.
|
|
63
|
+
watch(
|
|
64
|
+
[() => core.currentBusiness.value?._id, canManageBrandkit, () => core.restaurants.value.some((outlet) => outlet.posVersion && !isPosV8(outlet.posVersion))],
|
|
65
|
+
async ([businessId, canManage, hasV7Outlet]) => {
|
|
66
|
+
hasLegacyOdsConfigs.value = false
|
|
67
|
+
if (!businessId || !canManage || hasV7Outlet) return
|
|
68
|
+
try {
|
|
69
|
+
const brandkit = await getBusinessBrandkit(businessId, core.namespace.value)
|
|
70
|
+
hasLegacyOdsConfigs.value = Boolean(Array.isArray(brandkit.ods) ? brandkit.ods.length : brandkit.ods)
|
|
71
|
+
} catch {
|
|
72
|
+
hasLegacyOdsConfigs.value = false
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{ immediate: true }
|
|
76
|
+
)
|
|
77
|
+
const selectedTab = ref<'screens' | 'templates'>('screens')
|
|
20
78
|
const selectedRestaurantId = ref(core.currentRestaurant.value?._id ?? '')
|
|
21
79
|
const templateEditorOpen = ref(false)
|
|
22
80
|
const screenEditorOpen = ref(false)
|
|
23
81
|
const editingTemplate = ref<DisplayTemplate>()
|
|
24
82
|
const editingScreen = ref<DisplayScreen>()
|
|
25
83
|
const saving = ref(false)
|
|
84
|
+
const legacyOdsList = ref<InstanceType<typeof LegacyOdsTemplateList>>()
|
|
26
85
|
|
|
27
86
|
const tabs = computed<FmTabProps[]>(() => [{ label: t('order.displaySystem.screens'), value: 'screens' }, { label: t('order.displaySystem.templates'), value: 'templates' }])
|
|
87
|
+
const selectedRestaurant = computed(() =>
|
|
88
|
+
core.restaurants.value.find((item) => item._id === selectedRestaurantId.value)
|
|
89
|
+
)
|
|
90
|
+
const selectedRestaurantIsV8 = computed(() => isPosV8(selectedRestaurant.value?.posVersion))
|
|
28
91
|
const restaurantItems = computed<ISelectItem[]>(() => core.restaurants.value.map((restaurant) => ({ label: restaurant.profile.name, value: restaurant._id })))
|
|
29
92
|
const entitlement = computed(() => store.screenCapabilities)
|
|
30
93
|
// Oldest screens keep the licence, so the newest lose it first. Merchants can
|
|
@@ -33,7 +96,7 @@ const unlicensedScreens = computed(() =>
|
|
|
33
96
|
unlicensedScreenIds(store.screens, entitlement.value?.screenLimit ?? 0))
|
|
34
97
|
const remainingScreens = computed(() => Math.max(0, (entitlement.value?.screenLimit ?? 0) - (entitlement.value?.screenUsed ?? 0)))
|
|
35
98
|
const applicableTemplates = computed(() => store.templates.filter((template) => !template.restaurantIds?.length || template.restaurantIds.includes(selectedRestaurantId.value)))
|
|
36
|
-
const canAddScreen = computed(() => Boolean(selectedRestaurantId.value && entitlement.value?.posV8 && remainingScreens.value > 0 && applicableTemplates.value.length))
|
|
99
|
+
const canAddScreen = computed(() => Boolean(selectedRestaurantId.value && selectedRestaurantIsV8.value && entitlement.value?.posV8 && remainingScreens.value > 0 && applicableTemplates.value.length))
|
|
37
100
|
|
|
38
101
|
function templateName(id: string) { return store.templates.find((template) => template._id === id)?.label ?? 'Unknown template' }
|
|
39
102
|
function orientationMismatch(screen: DisplayScreen) {
|
|
@@ -49,7 +112,7 @@ function layoutSummary(template: DisplayTemplate) {
|
|
|
49
112
|
function isOnline(screen: DisplayScreen) { return Boolean(screen.lastSeenAt && Date.now() - new Date(screen.lastSeenAt).getTime() < 120_000) }
|
|
50
113
|
|
|
51
114
|
async function loadContext() {
|
|
52
|
-
if (!selectedRestaurantId.value) return
|
|
115
|
+
if (!canManageOrderSetting.value || !selectedRestaurantId.value) return
|
|
53
116
|
try { await Promise.all([store.loadScreens(selectedRestaurantId.value), store.loadScreenCapabilities(selectedRestaurantId.value)]) }
|
|
54
117
|
catch (error) { showError(error instanceof Error ? error.message : t('order.displaySystem.loadFailed')) }
|
|
55
118
|
}
|
|
@@ -109,6 +172,7 @@ async function toggleStatus(screen: DisplayScreen) {
|
|
|
109
172
|
|
|
110
173
|
onMounted(async () => {
|
|
111
174
|
if (!selectedRestaurantId.value && core.restaurants.value.length) selectedRestaurantId.value = core.restaurants.value[0]._id
|
|
175
|
+
if (!canManageOrderSetting.value) return
|
|
112
176
|
try { await store.loadTemplates() } catch (error) { showError(error instanceof Error ? error.message : t('order.displaySystem.loadFailed')) }
|
|
113
177
|
await loadContext()
|
|
114
178
|
})
|
|
@@ -116,14 +180,19 @@ watch(() => core.restaurants.value.length, async (length) => { if (!selectedRest
|
|
|
116
180
|
</script>
|
|
117
181
|
|
|
118
182
|
<template>
|
|
119
|
-
<div class="py-[1.5rem]"><FmPageHead :title="t('order.displaySystem.title')" />
|
|
183
|
+
<div class="py-[1.5rem]"><FmPageHead :title="selectedView === 'v7' ? t('order.displaySystem.legacyOds.title') : t('order.displaySystem.title')" />
|
|
120
184
|
<div class="px-[1.5rem]">
|
|
121
|
-
<div class="display-toolbar"><p class="text-sm text-gray-500">{{ t('order.displaySystem.description') }}</p><FmButton icon="add" size="md" :label="selectedTab === 'screens' ? t('order.displaySystem.addScreen') : t('order.displaySystem.addTemplateAction')" :disabled="selectedTab === 'screens' && !canAddScreen" @click="selectedTab === 'screens' ? addScreen() : addTemplate()" /></div>
|
|
185
|
+
<div class="display-toolbar"><p class="text-sm text-gray-500">{{ selectedView === 'v7' ? t('order.displaySystem.legacyOds.description') : t('order.displaySystem.description') }}</p><div class="display-actions"><FmButton v-if="otherView" variant="plain" size="md" :label="otherView === 'v7' ? t('order.displaySystem.legacyOds.switchToLegacy') : t('order.displaySystem.legacyOds.switchToDisplaySystem')" @click="switchView(otherView)" /><FmButton v-if="selectedView === 'v7'" icon="add" size="md" :label="t('order.displaySystem.legacyOds.addConfiguration')" @click="legacyOdsList?.addOdsEntry()" /><FmButton v-else-if="selectedView === 'v8'" icon="add" size="md" :label="selectedTab === 'screens' ? t('order.displaySystem.addScreen') : t('order.displaySystem.addTemplateAction')" :disabled="selectedTab === 'screens' && !canAddScreen" @click="selectedTab === 'screens' ? addScreen() : addTemplate()" /></div></div>
|
|
186
|
+
<template v-if="selectedView === 'v8'">
|
|
122
187
|
<!-- The outlet picker and its allowance belong to the Screens tab only:
|
|
123
188
|
screens are bought and paired per outlet, while templates are
|
|
124
189
|
business-wide and would read as outlet-scoped next to a picker. -->
|
|
125
190
|
<div class="display-controls"><FmTabs v-model:model-value="selectedTab" :items="tabs" /><div v-if="selectedTab === 'screens'" class="display-outlet"><p v-if="entitlement" class="display-entitlement">{{ t('order.displaySystem.screensUsed', { used: entitlement.screenUsed, limit: entitlement.screenLimit }) }} · {{ entitlement.capabilities.join(', ') || t('order.displaySystem.noModulesLicensed') }}</p><FmSelect class="display-restaurant-select" :model-value="selectedRestaurantId" :items="restaurantItems" :placeholder="t('order.displaySystem.selectRestaurant')" searchable @update:model-value="selectRestaurant" /></div></div>
|
|
126
|
-
<
|
|
191
|
+
<div v-if="selectedTab === 'screens' && selectedRestaurant && !selectedRestaurantIsV8" class="restriction-note">
|
|
192
|
+
<p>{{ selectedRestaurant.posVersion ? t('order.displaySystem.posV8RequiredVersion', { version: selectedRestaurant.posVersion }) : t('order.displaySystem.posV8Required') }}</p>
|
|
193
|
+
<FmButton v-if="availableViews.includes('v7')" variant="plain" :label="t('order.displaySystem.legacyOds.openLegacyTab')" @click="switchView('v7')" />
|
|
194
|
+
</div>
|
|
195
|
+
<p v-else-if="selectedTab === 'screens' && !canAddScreen && !store.loadingScreenCapabilities" class="restriction-note">{{ applicableTemplates.length ? t('order.displaySystem.screenLimitReached') : t('order.displaySystem.noTemplatesDescription') }}</p>
|
|
127
196
|
|
|
128
197
|
<section v-if="selectedTab === 'screens'" class="mt-12">
|
|
129
198
|
<div v-if="store.loadingScreens" class="flex justify-center py-40"><FmCircularProgress size="lg" /></div>
|
|
@@ -132,17 +201,19 @@ watch(() => core.restaurants.value.length, async (length) => { if (!selectedRest
|
|
|
132
201
|
<article v-for="screen in store.screens" :key="screen._id" class="resource-card"><div class="flex justify-between gap-12"><div class="min-w-0"><div class="flex flex-wrap items-center gap-8"><h3 class="truncate text-lg font-semibold">{{ screen.name }}</h3><span class="status-badge" :class="screen.status.toLowerCase()">{{ screen.status }}</span><span class="online-dot" :class="{ online: isOnline(screen) }">{{ isOnline(screen) ? t('order.displaySystem.online') : t('order.displaySystem.offline') }}</span><span v-if="unlicensedScreens.has(screen._id)" class="status-badge unlicensed">{{ t('order.displaySystem.unlicensed') }}</span></div><p class="mt-4 text-sm text-gray-500">{{ templateName(screen.templateId) }}</p><p v-if="orientationMismatch(screen)" class="mt-2 text-xs text-orange-700">Reported viewport orientation does not match the template.</p><p v-if="unlicensedScreens.has(screen._id)" class="mt-2 text-xs text-red-700">{{ t('order.displaySystem.unlicensedNote') }}</p></div><div class="flex gap-4"><FmButton :icon="screen.status === 'DISABLED' ? 'play_arrow' : 'pause'" variant="plain" @click="toggleStatus(screen)" /><FmButton icon="edit" variant="plain" @click="editScreen(screen)" /><FmButton icon="delete" variant="plain" @click="removeScreen(screen)" /></div></div><dl class="meta-grid"><div><dt>{{ t('order.displaySystem.deviceId') }}</dt><dd>{{ screen.deviceId || '—' }}</dd></div><div><dt>{{ t('order.displaySystem.lastSeen') }}</dt><dd>{{ screen.lastSeenAt ? new Date(screen.lastSeenAt).toLocaleString() : '—' }}</dd></div></dl></article>
|
|
133
202
|
</div>
|
|
134
203
|
</section>
|
|
135
|
-
<section v-else class="mt-12">
|
|
204
|
+
<section v-else-if="selectedTab === 'templates'" class="mt-12">
|
|
136
205
|
<div v-if="store.loadingTemplates" class="flex justify-center py-40"><FmCircularProgress size="lg" /></div>
|
|
137
206
|
<div v-else-if="!store.templates.length" class="empty-state"><strong>{{ t('order.displaySystem.noTemplates') }}</strong><span>{{ t('order.displaySystem.noTemplatesDescription') }}</span></div>
|
|
138
207
|
<div v-else class="grid grid-cols-1 gap-16 xl:grid-cols-2"><article v-for="template in store.templates" :key="template._id" class="resource-card"><div class="flex justify-between gap-12"><div class="min-w-0"><div class="flex items-center gap-8"><h3 class="truncate text-lg font-semibold">{{ template.label }}</h3><span class="mode-badge">{{ template.orientation }}</span></div><p class="mt-2 text-xs text-gray-500">{{ templateCapabilities(template).join(', ') || 'INFO' }}</p></div><div class="flex gap-4"><FmButton icon="edit" variant="plain" @click="editTemplate(template)" /><FmButton icon="content_copy" variant="plain" @click="duplicateTemplate(template)" /><FmButton icon="delete" variant="plain" @click="removeTemplate(template)" /></div></div><div class="layout-thumb"><DisplayLayoutMap :node="template.root as never" selected-zone-id="" :start-index="1" /></div><p class="thumb-caption">{{ t('order.displaySystem.areasCount', { count: layoutSummary(template).areas }) }} · {{ t('order.displaySystem.modulesCount', { count: layoutSummary(template).modules }) }}</p></article></div>
|
|
139
208
|
</section>
|
|
209
|
+
</template>
|
|
210
|
+
<LegacyOdsTemplateList v-else-if="selectedView === 'v7'" ref="legacyOdsList" />
|
|
140
211
|
</div>
|
|
141
212
|
</div>
|
|
142
|
-
<DisplayTemplateEditor v-model="templateEditorOpen" :template="editingTemplate" :preview-restaurant-id="selectedRestaurantId" :outlet-capabilities="entitlement?.capabilities ?? []" :saving="saving" @save="saveTemplate" />
|
|
143
|
-
<DisplayScreenEditor v-if="selectedRestaurantId" v-model="screenEditorOpen" :screen="editingScreen" :templates="applicableTemplates" :restaurant-id="selectedRestaurantId" :saving="saving" @save="saveScreen" />
|
|
213
|
+
<DisplayTemplateEditor v-if="canManageOrderSetting" v-model="templateEditorOpen" :template="editingTemplate" :preview-restaurant-id="selectedRestaurantId" :outlet-capabilities="entitlement?.capabilities ?? []" :saving="saving" @save="saveTemplate" />
|
|
214
|
+
<DisplayScreenEditor v-if="canManageOrderSetting && selectedRestaurantId" v-model="screenEditorOpen" :screen="editingScreen" :templates="applicableTemplates" :restaurant-id="selectedRestaurantId" :saving="saving" @save="saveScreen" />
|
|
144
215
|
</template>
|
|
145
216
|
|
|
146
217
|
<style scoped>
|
|
147
|
-
.display-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid #e5e7eb;padding-bottom:14px}.display-controls{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-top:12px}.display-restaurant-select{width:min(360px,100%)}.display-outlet{display:flex;align-items:center;gap:16px}.display-entitlement{color:#6b7280;font-size:12px;white-space:nowrap}.restriction-note{margin-top:12px;border:1px solid #fed7aa;border-radius:10px;padding:10px 12px;color:#9a3412;background:#fff7ed;font-size:13px}.empty-state{display:grid;justify-items:center;gap:6px;border:1px dashed #d1d5db;border-radius:12px;padding:56px 20px;color:#6b7280;text-align:center}.empty-state strong{color:#111827}.layout-thumb{display:flex;height:auto;min-height:96px;margin-top:16px;border-radius:10px;background:#f3f4f6;padding:5px;pointer-events:none}.thumb-caption{margin-top:8px;color:#6b7280;font-size:11px}.resource-card{border:1px solid #e5e7eb;border-radius:12px;padding:20px;background:white;box-shadow:0 2px 8px #0000000a}.mode-badge,.status-badge{border-radius:999px;padding:3px 8px;background:#f3f4f6;font-size:11px;font-weight:700}.status-badge.active{color:#166534;background:#dcfce7}.status-badge.disabled{color:#991b1b;background:#fee2e2}.status-badge.unlicensed{color:#b42318;background:#fef3f2;border:1px solid #fecdca}.online-dot{color:#6b7280;font-size:11px}.online-dot::before{display:inline-block;width:7px;height:7px;margin-right:5px;border-radius:50%;background:#9ca3af;content:''}.online-dot.online{color:#15803d}.online-dot.online::before{background:#22c55e}.meta-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:18px;border-top:1px solid #f0f0f0;padding-top:16px}.meta-grid dt{color:#6b7280;font-size:11px}.meta-grid dd{overflow:hidden;margin-top:2px;font-size:12px;text-overflow:ellipsis;white-space:nowrap}@media(max-width:640px){.display-toolbar{align-items:flex-start}.display-controls{align-items:stretch;flex-direction:column}.display-outlet{align-items:stretch;flex-direction:column;gap:8px}.display-restaurant-select{width:100%}}
|
|
218
|
+
.display-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid #e5e7eb;padding-bottom:14px}.display-actions{display:flex;flex-shrink:0;align-items:center;gap:8px}.display-controls{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-top:12px}.display-restaurant-select{width:min(360px,100%)}.display-outlet{display:flex;align-items:center;gap:16px}.display-entitlement{color:#6b7280;font-size:12px;white-space:nowrap}.restriction-note{margin-top:12px;border:1px solid #fed7aa;border-radius:10px;padding:10px 12px;color:#9a3412;background:#fff7ed;font-size:13px}.empty-state{display:grid;justify-items:center;gap:6px;border:1px dashed #d1d5db;border-radius:12px;padding:56px 20px;color:#6b7280;text-align:center}.empty-state strong{color:#111827}.layout-thumb{display:flex;height:auto;min-height:96px;margin-top:16px;border-radius:10px;background:#f3f4f6;padding:5px;pointer-events:none}.thumb-caption{margin-top:8px;color:#6b7280;font-size:11px}.resource-card{border:1px solid #e5e7eb;border-radius:12px;padding:20px;background:white;box-shadow:0 2px 8px #0000000a}.mode-badge,.status-badge{border-radius:999px;padding:3px 8px;background:#f3f4f6;font-size:11px;font-weight:700}.status-badge.active{color:#166534;background:#dcfce7}.status-badge.disabled{color:#991b1b;background:#fee2e2}.status-badge.unlicensed{color:#b42318;background:#fef3f2;border:1px solid #fecdca}.online-dot{color:#6b7280;font-size:11px}.online-dot::before{display:inline-block;width:7px;height:7px;margin-right:5px;border-radius:50%;background:#9ca3af;content:''}.online-dot.online{color:#15803d}.online-dot.online::before{background:#22c55e}.meta-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:18px;border-top:1px solid #f0f0f0;padding-top:16px}.meta-grid dt{color:#6b7280;font-size:11px}.meta-grid dd{overflow:hidden;margin-top:2px;font-size:12px;text-overflow:ellipsis;white-space:nowrap}@media(max-width:640px){.display-toolbar{align-items:flex-start}.display-controls{align-items:stretch;flex-direction:column}.display-outlet{align-items:stretch;flex-direction:column;gap:8px}.display-restaurant-select{width:100%}}
|
|
148
219
|
</style>
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import ColorSelection from './ColorSelection.vue'
|
|
4
|
+
import {
|
|
5
|
+
type BaseBrandKit,
|
|
6
|
+
type RecursivePartial
|
|
7
|
+
} from '@/modules/display-system/legacy-ods/interface'
|
|
8
|
+
import { useI18n } from '@feedmepos/mf-common'
|
|
9
|
+
|
|
10
|
+
type ColorField = { key: string; title: string; description: string }
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(
|
|
13
|
+
defineProps<{
|
|
14
|
+
modelValue: boolean
|
|
15
|
+
title: string
|
|
16
|
+
description?: string
|
|
17
|
+
baseBrandkit: BaseBrandKit
|
|
18
|
+
value?: RecursivePartial<BaseBrandKit>
|
|
19
|
+
colorFields: ColorField[]
|
|
20
|
+
}>(),
|
|
21
|
+
{
|
|
22
|
+
colorFields: () => []
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
(e: 'update:modelValue', v: boolean): void
|
|
28
|
+
(e: 'save', v: RecursivePartial<BaseBrandKit> | undefined): void
|
|
29
|
+
(e: 'cancel'): void
|
|
30
|
+
}>()
|
|
31
|
+
|
|
32
|
+
const { t } = useI18n()
|
|
33
|
+
|
|
34
|
+
const opened = computed({
|
|
35
|
+
get: () => props.modelValue,
|
|
36
|
+
set: (v) => emit('update:modelValue', v)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const draft = ref<RecursivePartial<BaseBrandKit>>({})
|
|
40
|
+
|
|
41
|
+
const cloneValue = () => JSON.parse(JSON.stringify(props.value ?? {}))
|
|
42
|
+
|
|
43
|
+
const syncDraft = () => {
|
|
44
|
+
draft.value = cloneValue()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
watch(
|
|
48
|
+
() => props.modelValue,
|
|
49
|
+
(v) => {
|
|
50
|
+
if (v) syncDraft()
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
watch(
|
|
55
|
+
() => props.value,
|
|
56
|
+
() => {
|
|
57
|
+
if (props.modelValue) syncDraft()
|
|
58
|
+
},
|
|
59
|
+
{ deep: true }
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const getFromPath = (obj: any, path: string) =>
|
|
63
|
+
path.split('.').reduce((acc, key) => (acc && typeof acc === 'object' ? acc[key] : undefined), obj)
|
|
64
|
+
|
|
65
|
+
const pruneEmpty = (value: any): any => {
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
const pruned = value.map(pruneEmpty).filter((v) => v !== undefined)
|
|
68
|
+
return pruned.length ? pruned : undefined
|
|
69
|
+
}
|
|
70
|
+
if (value && typeof value === 'object') {
|
|
71
|
+
const entries = Object.entries(value)
|
|
72
|
+
.map(([k, v]) => [k, pruneEmpty(v)] as const)
|
|
73
|
+
.filter(([, v]) => v !== undefined)
|
|
74
|
+
if (!entries.length) return undefined
|
|
75
|
+
return Object.fromEntries(entries)
|
|
76
|
+
}
|
|
77
|
+
return value
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const setAtPath = (obj: any, path: string, value: any) => {
|
|
81
|
+
const keys = path.split('.')
|
|
82
|
+
let current = obj
|
|
83
|
+
keys.forEach((key, idx) => {
|
|
84
|
+
if (idx === keys.length - 1) {
|
|
85
|
+
current[key] = value
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
current[key] = current[key] ?? {}
|
|
89
|
+
current = current[key]
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const deleteAtPath = (obj: any, path: string) => {
|
|
94
|
+
const keys = path.split('.')
|
|
95
|
+
const stack: { parent: any; key: string }[] = []
|
|
96
|
+
let current = obj
|
|
97
|
+
for (const key of keys) {
|
|
98
|
+
if (!current || typeof current !== 'object') return
|
|
99
|
+
stack.push({ parent: current, key })
|
|
100
|
+
current = current[key]
|
|
101
|
+
}
|
|
102
|
+
const last = stack.pop()
|
|
103
|
+
if (last) {
|
|
104
|
+
delete last.parent[last.key]
|
|
105
|
+
}
|
|
106
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
107
|
+
const { parent, key } = stack[i]
|
|
108
|
+
if (parent[key] && typeof parent[key] === 'object' && !Object.keys(parent[key]).length) {
|
|
109
|
+
delete parent[key]
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const baseColor = (path: string) => {
|
|
115
|
+
const value = getFromPath(props.baseBrandkit, path)
|
|
116
|
+
if (typeof value === 'string') return value
|
|
117
|
+
return '#000000'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const displayColor = (path: string) => {
|
|
121
|
+
const fromOverride = getFromPath(draft.value, path)
|
|
122
|
+
if (typeof fromOverride === 'string') return fromOverride
|
|
123
|
+
return baseColor(path)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const toggleOverride = (path: string, enabled: boolean) => {
|
|
127
|
+
if (!enabled) {
|
|
128
|
+
deleteAtPath(draft.value, path)
|
|
129
|
+
const pruned = pruneEmpty(draft.value)
|
|
130
|
+
draft.value = pruned ?? {}
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
setAtPath(draft.value, path, baseColor(path))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const onReset = (path: string) => {
|
|
137
|
+
setAtPath(draft.value, path, baseColor(path))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const onUpdateColor = (path: string, color: string) => {
|
|
141
|
+
setAtPath(draft.value, path, color)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const isOverrideEnabled = (path: string) => getFromPath(draft.value, path) !== undefined
|
|
145
|
+
|
|
146
|
+
const onClose = () => {
|
|
147
|
+
syncDraft()
|
|
148
|
+
emit('cancel')
|
|
149
|
+
opened.value = false
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const onSave = () => {
|
|
153
|
+
const allowedDraft: RecursivePartial<BaseBrandKit> = {}
|
|
154
|
+
for (const field of props.colorFields) {
|
|
155
|
+
const value = getFromPath(draft.value, field.key)
|
|
156
|
+
if (value !== undefined) {
|
|
157
|
+
setAtPath(allowedDraft, field.key, value)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const cleaned = pruneEmpty(allowedDraft)
|
|
161
|
+
emit('save', cleaned)
|
|
162
|
+
opened.value = false
|
|
163
|
+
}
|
|
164
|
+
</script>
|
|
165
|
+
|
|
166
|
+
<template>
|
|
167
|
+
<FmDialog v-model="opened" :max-width="720">
|
|
168
|
+
<template #dialog-header>
|
|
169
|
+
<div class="fm-typo-en-body-lg-600 text-fm-color-typo-primary">
|
|
170
|
+
{{ title }}
|
|
171
|
+
</div>
|
|
172
|
+
</template>
|
|
173
|
+
<div class="space-y-16 max-h-[70vh] overflow-y-auto">
|
|
174
|
+
<div
|
|
175
|
+
v-for="field in colorFields"
|
|
176
|
+
:key="field.key"
|
|
177
|
+
class="border border-fm-color-neutral-gray-100 rounded-lg overflow-hidden"
|
|
178
|
+
>
|
|
179
|
+
<div
|
|
180
|
+
class="flex items-center justify-between px-16 py-12 border-b border-fm-color-neutral-gray-100"
|
|
181
|
+
>
|
|
182
|
+
<div class="flex flex-col gap-4">
|
|
183
|
+
<div class="fm-typo-en-body-md-600 text-fm-color-typo-primary">
|
|
184
|
+
{{ field.title }}
|
|
185
|
+
</div>
|
|
186
|
+
<div class="fm-typo-en-body-sm-400 text-fm-color-typo-secondary">
|
|
187
|
+
{{ field.description }}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
<FmSwitch
|
|
191
|
+
:model-value="isOverrideEnabled(field.key)"
|
|
192
|
+
@update:model-value="(v: boolean) => toggleOverride(field.key, v)"
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
<div
|
|
196
|
+
class="px-16 py-12"
|
|
197
|
+
:class="{
|
|
198
|
+
'opacity-50 pointer-events-none': !isOverrideEnabled(field.key)
|
|
199
|
+
}"
|
|
200
|
+
>
|
|
201
|
+
<ColorSelection
|
|
202
|
+
:initial-color="baseColor(field.key)"
|
|
203
|
+
:color="displayColor(field.key)"
|
|
204
|
+
@update="(v) => onUpdateColor(field.key, v)"
|
|
205
|
+
@remove="onReset(field.key)"
|
|
206
|
+
:title="field.title"
|
|
207
|
+
:description="field.description"
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
<template #dialog-footer>
|
|
213
|
+
<div class="w-full flex justify-end gap-8">
|
|
214
|
+
<FmButton
|
|
215
|
+
variant="secondary"
|
|
216
|
+
:label="t('order.displaySystem.legacyOds.close')"
|
|
217
|
+
@click="onClose"
|
|
218
|
+
/>
|
|
219
|
+
<FmButton :label="t('order.displaySystem.legacyOds.save')" @click="onSave" />
|
|
220
|
+
</div>
|
|
221
|
+
</template>
|
|
222
|
+
</FmDialog>
|
|
223
|
+
</template>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ColorPicker } from 'vue-accessible-color-picker'
|
|
3
|
+
import 'vue-accessible-color-picker/styles'
|
|
4
|
+
|
|
5
|
+
defineProps<{
|
|
6
|
+
initialColor: string
|
|
7
|
+
color: string
|
|
8
|
+
title: string
|
|
9
|
+
description: string
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const handleCopy = (v: string) => {
|
|
13
|
+
void v
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const emits = defineEmits<{
|
|
17
|
+
(e: 'remove'): void
|
|
18
|
+
(e: 'update', value: string): void
|
|
19
|
+
}>()
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<div class="fm-typo-en-body-md-400">
|
|
24
|
+
<div class="flex gap-8 items-center">
|
|
25
|
+
<div class="p-4 relative">
|
|
26
|
+
<FmPopover>
|
|
27
|
+
<template #popover-button>
|
|
28
|
+
<div
|
|
29
|
+
class="aspect-square w-[60px] rounded-sm border border-fm-color-neutral-gray-200 cursor-pointer"
|
|
30
|
+
:style="{
|
|
31
|
+
backgroundColor: color
|
|
32
|
+
}"
|
|
33
|
+
></div>
|
|
34
|
+
</template>
|
|
35
|
+
<div class="shadow-lg rounded-lg p-8">
|
|
36
|
+
<ColorPicker
|
|
37
|
+
:copy="handleCopy"
|
|
38
|
+
@color-copy="(v) => handleCopy(v.cssColor)"
|
|
39
|
+
:default-format="'hex'"
|
|
40
|
+
:color="color"
|
|
41
|
+
@color-change="(v) => emits('update', v.cssColor)"
|
|
42
|
+
>
|
|
43
|
+
<template #copy-button>
|
|
44
|
+
<FmButton class="w-full h-32" variant="secondary" icon="content_copy" />
|
|
45
|
+
</template>
|
|
46
|
+
</ColorPicker>
|
|
47
|
+
</div>
|
|
48
|
+
</FmPopover>
|
|
49
|
+
<div class="absolute top-8 right-8" v-if="initialColor != color">
|
|
50
|
+
<div
|
|
51
|
+
@click="emits('remove')"
|
|
52
|
+
class="h-16 w-16 rounded-full bg-fm-color-neutral-white flex items-center justify-center cursor-pointer"
|
|
53
|
+
>
|
|
54
|
+
<FmIcon name="close_small" class="!text-[16px]" />
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="space-y-4">
|
|
59
|
+
<div>{{ title }}</div>
|
|
60
|
+
<div class="text-fm-color-typo-secondary">{{ description }}</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="text-fm-color-typo-secondary px-4">
|
|
64
|
+
{{ color }}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</template>
|