@fy-/fws-vue-core 3.0.3 → 3.0.5

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.
Files changed (121) hide show
  1. package/package.json +6 -8
  2. package/src/components/fws/CmsArticleBoxed.vue +247 -0
  3. package/src/components/fws/CmsArticleSingle.vue +201 -0
  4. package/src/components/fws/DataTable.vue +659 -0
  5. package/src/components/fws/FilterData.vue +423 -0
  6. package/src/components/fws/UserData.vue +220 -0
  7. package/src/components/fws/UserFlow.vue +955 -0
  8. package/src/components/fws/UserOAuth2.vue +521 -0
  9. package/src/components/fws/UserProfile.vue +615 -0
  10. package/src/components/fws/UserProfileStrict.vue +233 -0
  11. package/src/components/ssr/ClientOnly.ts +10 -0
  12. package/src/components/ui/DefaultBreadcrumb.vue +99 -0
  13. package/src/components/ui/DefaultConfirm.vue +178 -0
  14. package/src/components/ui/DefaultConfirmWithInput.vue +217 -0
  15. package/src/components/ui/DefaultDropdown.vue +104 -0
  16. package/src/components/ui/DefaultDropdownLink.vue +94 -0
  17. package/src/components/ui/DefaultGallery.vue +1056 -0
  18. package/src/components/ui/DefaultInput.vue +768 -0
  19. package/src/components/ui/DefaultLoader.vue +125 -0
  20. package/src/components/ui/DefaultModal.vue +350 -0
  21. package/src/components/ui/DefaultNotif.vue +332 -0
  22. package/src/components/ui/DefaultPaging.vue +395 -0
  23. package/src/components/ui/DefaultSidebar.vue +267 -0
  24. package/src/components/ui/DefaultTagInput.vue +415 -0
  25. package/src/components/ui/transitions/CollapseTransition.vue +19 -0
  26. package/src/components/ui/transitions/ExpandTransition.vue +19 -0
  27. package/src/components/ui/transitions/FadeTransition.vue +17 -0
  28. package/src/components/ui/transitions/ScaleTransition.vue +21 -0
  29. package/src/components/ui/transitions/SlideTransition.vue +32 -0
  30. package/src/composables/event-bus.ts +15 -0
  31. package/src/composables/rest.ts +165 -0
  32. package/src/composables/seo.ts +142 -0
  33. package/src/composables/ssr.ts +103 -0
  34. package/src/composables/templating.ts +133 -0
  35. package/src/composables/translations.ts +45 -0
  36. package/src/env.d.ts +10 -0
  37. package/{dist/src/index.d.ts → src/index.ts} +71 -45
  38. package/src/plugin.ts +42 -0
  39. package/src/safelist.html +11 -0
  40. package/src/stores/serverRouter.ts +62 -0
  41. package/src/stores/user.ts +118 -0
  42. package/src/types.ts +58 -0
  43. package/dist/index.css +0 -2
  44. package/dist/index.js +0 -5767
  45. package/dist/src/components/fws/CmsArticleBoxed.vue.d.ts +0 -32
  46. package/dist/src/components/fws/CmsArticleBoxed.vue.d.ts.map +0 -1
  47. package/dist/src/components/fws/CmsArticleSingle.vue.d.ts +0 -29
  48. package/dist/src/components/fws/CmsArticleSingle.vue.d.ts.map +0 -1
  49. package/dist/src/components/fws/DataTable.vue.d.ts +0 -52
  50. package/dist/src/components/fws/DataTable.vue.d.ts.map +0 -1
  51. package/dist/src/components/fws/FilterData.vue.d.ts +0 -15
  52. package/dist/src/components/fws/FilterData.vue.d.ts.map +0 -1
  53. package/dist/src/components/fws/UserData.vue.d.ts +0 -8
  54. package/dist/src/components/fws/UserData.vue.d.ts.map +0 -1
  55. package/dist/src/components/fws/UserFlow.vue.d.ts +0 -116
  56. package/dist/src/components/fws/UserFlow.vue.d.ts.map +0 -1
  57. package/dist/src/components/fws/UserOAuth2.vue.d.ts +0 -17
  58. package/dist/src/components/fws/UserOAuth2.vue.d.ts.map +0 -1
  59. package/dist/src/components/fws/UserProfile.vue.d.ts +0 -40
  60. package/dist/src/components/fws/UserProfile.vue.d.ts.map +0 -1
  61. package/dist/src/components/fws/UserProfileStrict.vue.d.ts +0 -12
  62. package/dist/src/components/fws/UserProfileStrict.vue.d.ts.map +0 -1
  63. package/dist/src/components/ssr/ClientOnly.d.ts +0 -4
  64. package/dist/src/components/ssr/ClientOnly.d.ts.map +0 -1
  65. package/dist/src/components/ui/DefaultBreadcrumb.vue.d.ts +0 -11
  66. package/dist/src/components/ui/DefaultBreadcrumb.vue.d.ts.map +0 -1
  67. package/dist/src/components/ui/DefaultConfirm.vue.d.ts +0 -81
  68. package/dist/src/components/ui/DefaultConfirm.vue.d.ts.map +0 -1
  69. package/dist/src/components/ui/DefaultConfirmWithInput.vue.d.ts +0 -81
  70. package/dist/src/components/ui/DefaultConfirmWithInput.vue.d.ts.map +0 -1
  71. package/dist/src/components/ui/DefaultDropdown.vue.d.ts +0 -35
  72. package/dist/src/components/ui/DefaultDropdown.vue.d.ts.map +0 -1
  73. package/dist/src/components/ui/DefaultDropdownLink.vue.d.ts +0 -23
  74. package/dist/src/components/ui/DefaultDropdownLink.vue.d.ts.map +0 -1
  75. package/dist/src/components/ui/DefaultGallery.vue.d.ts +0 -114
  76. package/dist/src/components/ui/DefaultGallery.vue.d.ts.map +0 -1
  77. package/dist/src/components/ui/DefaultInput.vue.d.ts +0 -61
  78. package/dist/src/components/ui/DefaultInput.vue.d.ts.map +0 -1
  79. package/dist/src/components/ui/DefaultLoader.vue.d.ts +0 -12
  80. package/dist/src/components/ui/DefaultLoader.vue.d.ts.map +0 -1
  81. package/dist/src/components/ui/DefaultModal.vue.d.ts +0 -36
  82. package/dist/src/components/ui/DefaultModal.vue.d.ts.map +0 -1
  83. package/dist/src/components/ui/DefaultNotif.vue.d.ts +0 -3
  84. package/dist/src/components/ui/DefaultNotif.vue.d.ts.map +0 -1
  85. package/dist/src/components/ui/DefaultPaging.vue.d.ts +0 -13
  86. package/dist/src/components/ui/DefaultPaging.vue.d.ts.map +0 -1
  87. package/dist/src/components/ui/DefaultSidebar.vue.d.ts +0 -29
  88. package/dist/src/components/ui/DefaultSidebar.vue.d.ts.map +0 -1
  89. package/dist/src/components/ui/DefaultTagInput.vue.d.ts +0 -34
  90. package/dist/src/components/ui/DefaultTagInput.vue.d.ts.map +0 -1
  91. package/dist/src/components/ui/transitions/CollapseTransition.vue.d.ts +0 -18
  92. package/dist/src/components/ui/transitions/CollapseTransition.vue.d.ts.map +0 -1
  93. package/dist/src/components/ui/transitions/ExpandTransition.vue.d.ts +0 -18
  94. package/dist/src/components/ui/transitions/ExpandTransition.vue.d.ts.map +0 -1
  95. package/dist/src/components/ui/transitions/FadeTransition.vue.d.ts +0 -18
  96. package/dist/src/components/ui/transitions/FadeTransition.vue.d.ts.map +0 -1
  97. package/dist/src/components/ui/transitions/ScaleTransition.vue.d.ts +0 -18
  98. package/dist/src/components/ui/transitions/ScaleTransition.vue.d.ts.map +0 -1
  99. package/dist/src/components/ui/transitions/SlideTransition.vue.d.ts +0 -21
  100. package/dist/src/components/ui/transitions/SlideTransition.vue.d.ts.map +0 -1
  101. package/dist/src/composables/event-bus.d.ts +0 -8
  102. package/dist/src/composables/event-bus.d.ts.map +0 -1
  103. package/dist/src/composables/rest.d.ts +0 -24
  104. package/dist/src/composables/rest.d.ts.map +0 -1
  105. package/dist/src/composables/seo.d.ts +0 -26
  106. package/dist/src/composables/seo.d.ts.map +0 -1
  107. package/dist/src/composables/ssr.d.ts +0 -24
  108. package/dist/src/composables/ssr.d.ts.map +0 -1
  109. package/dist/src/composables/templating.d.ts +0 -7
  110. package/dist/src/composables/templating.d.ts.map +0 -1
  111. package/dist/src/composables/translations.d.ts +0 -8
  112. package/dist/src/composables/translations.d.ts.map +0 -1
  113. package/dist/src/index.d.ts.map +0 -1
  114. package/dist/src/plugin.d.ts +0 -3
  115. package/dist/src/plugin.d.ts.map +0 -1
  116. package/dist/src/stores/serverRouter.d.ts +0 -34
  117. package/dist/src/stores/serverRouter.d.ts.map +0 -1
  118. package/dist/src/stores/user.d.ts +0 -139
  119. package/dist/src/stores/user.d.ts.map +0 -1
  120. package/dist/src/types.d.ts +0 -48
  121. package/dist/src/types.d.ts.map +0 -1
@@ -0,0 +1,233 @@
1
+ <script setup lang="ts">
2
+ import { computed, reactive, ref, watchEffect } from 'vue'
3
+ import { useEventBus } from '../../composables/event-bus'
4
+ import { useRest } from '../../composables/rest'
5
+ import { useTranslation } from '../../composables/translations'
6
+ import { useUserStore } from '../../stores/user'
7
+ import DefaultInput from '../ui/DefaultInput.vue'
8
+
9
+ const rest = useRest()
10
+ const props = withDefaults(
11
+ defineProps<{
12
+ onCompleted?: (data: any) => void
13
+ termsText?: string
14
+ force18?: boolean
15
+ }>(),
16
+ {
17
+ onCompleted: () => {},
18
+ termsText: '',
19
+ force18: false,
20
+ },
21
+ )
22
+
23
+ const translate = useTranslation()
24
+ const userStore = useUserStore()
25
+ const userData = computed(() => userStore.user)
26
+ const eventBus = useEventBus()
27
+ const errors = ref<Record<string, string>>({})
28
+
29
+ const currentDate = new Date()
30
+ const defaultDate = new Date(
31
+ currentDate.setFullYear(currentDate.getFullYear() - 18),
32
+ ).toISOString().split('T')[0]
33
+
34
+ const state = reactive({
35
+ Username: '',
36
+ Gender: '',
37
+ Birthdate: defaultDate,
38
+ AcceptedTerms: true,
39
+ })
40
+
41
+ watchEffect(() => {
42
+ if (!userData.value) return
43
+ const profile = userData.value?.UserProfile
44
+ state.Username = profile?.Username || ''
45
+ state.Gender = profile?.Gender || ''
46
+ state.Birthdate = profile?.Birthdate
47
+ ? new Date(profile.Birthdate.unixms).toISOString().split('T')[0]
48
+ : defaultDate
49
+ state.AcceptedTerms = userData.value?.AcceptedTerms || true
50
+ })
51
+
52
+ function validateAge(value: string): boolean {
53
+ const today = new Date()
54
+ const birthDate = new Date(value)
55
+ let age = today.getFullYear() - birthDate.getFullYear()
56
+ const m = today.getMonth() - birthDate.getMonth()
57
+ if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) age--
58
+ return age >= 18 && age <= 200
59
+ }
60
+
61
+ function validate(): boolean {
62
+ errors.value = {}
63
+ const req = translate('vuelidate_validator_req') || 'Required'
64
+ if (!state.Username) errors.value.Username = req
65
+ if (!state.Gender) errors.value.Gender = req
66
+ if (!state.Birthdate) errors.value.Birthdate = req
67
+ if (props.force18 && state.Birthdate && !validateAge(state.Birthdate)) {
68
+ errors.value.Birthdate = translate('fws_under_18_error_message') || 'You must be at least 18 years old'
69
+ }
70
+ if (!state.AcceptedTerms) errors.value.AcceptedTerms = req
71
+ return Object.keys(errors.value).length === 0
72
+ }
73
+
74
+ async function patchUser() {
75
+ if (!validate()) return
76
+ eventBus.emit('main-loading', true)
77
+ const data = { ...state }
78
+ try {
79
+ const birthdate = new Date(`${data.Birthdate}T00:00:00Z`)
80
+ data.Birthdate = birthdate.toISOString().split('T')[0]
81
+ }
82
+ catch { /* keep original */ }
83
+
84
+ const response = await rest('User/_ForceProfile', 'PATCH', data)
85
+ if (response && response.result === 'success') {
86
+ if (props.onCompleted) props.onCompleted(response)
87
+ eventBus.emit('user:refresh', true)
88
+ }
89
+ eventBus.emit('main-loading', false)
90
+ }
91
+ </script>
92
+
93
+ <template>
94
+ <form class="fws-profile-strict" @submit.prevent="patchUser">
95
+ <div class="fws-profile-strict__container">
96
+ <h3 class="fws-profile-strict__heading">
97
+ {{ $t('fws_user_profile_title') || $t('fws_profile_settings') || 'Complete Your Profile' }}
98
+ </h3>
99
+
100
+ <div class="fws-profile-strict__fields">
101
+ <DefaultInput
102
+ id="usernameFWS"
103
+ v-model="state.Username"
104
+ type="text"
105
+ :label="$t('fws_username_label') || 'Username'"
106
+ :help="$t('fws_username_help') || 'Choose your display name'"
107
+ :error="errors.Username"
108
+ :disabled="!!(userData?.UserProfile?.HasUsernameAndSlug)"
109
+ />
110
+
111
+ <DefaultInput
112
+ id="birthdateFWS"
113
+ v-model="state.Birthdate"
114
+ type="date"
115
+ :label="$t('fws_birthdate_label') || 'Date of birth'"
116
+ :error="errors.Birthdate"
117
+ />
118
+
119
+ <DefaultInput
120
+ id="genderFWS"
121
+ v-model="state.Gender"
122
+ type="select"
123
+ :options="[
124
+ ['female', $t('fws_persona_phys_appearance_opt_female') || 'Female'],
125
+ ['male', $t('fws_persona_phys_appearance_opt_male') || 'Male'],
126
+ ['non-binary', $t('fws_persona_phys_appearance_opt_non_binary') || 'Non-binary'],
127
+ ]"
128
+ :label="$t('fws_gender_label') || 'Gender'"
129
+ :error="errors.Gender"
130
+ />
131
+
132
+ <!-- Terms -->
133
+ <div class="fws-profile-strict__terms">
134
+ <DefaultInput
135
+ id="acceptedTermsFWS"
136
+ v-model:checkbox-value="state.AcceptedTerms"
137
+ type="toggle"
138
+ :label="$t('fws_accepted_terms_label') || 'I accept the terms'"
139
+ :help="$t('fws_accepted_terms_help') || 'You must accept the terms to continue'"
140
+ :error="errors.AcceptedTerms"
141
+ />
142
+ <p v-if="termsText" class="fws-profile-strict__terms-text">
143
+ {{ termsText }}
144
+ </p>
145
+ </div>
146
+ </div>
147
+
148
+ <div class="fws-profile-strict__actions">
149
+ <button type="submit" class="btn primary">
150
+ {{ $t("fws_save_user_cta") || 'Continue' }}
151
+ </button>
152
+ </div>
153
+ </div>
154
+ </form>
155
+ </template>
156
+
157
+ <style scoped>
158
+ .fws-profile-strict__container {
159
+ display: flex;
160
+ flex-direction: column;
161
+ gap: 18px;
162
+ padding: 20px 24px;
163
+ border-radius: 10px;
164
+ background: #fff;
165
+ box-shadow:
166
+ 0 0 0 1px rgba(0, 0, 0, 0.06),
167
+ 0 2px 4px rgba(0, 0, 0, 0.04);
168
+ }
169
+
170
+ .dark .fws-profile-strict__container {
171
+ background: var(--fv-neutral-900, #0a0a0a);
172
+ box-shadow:
173
+ 0 0 0 1px rgba(255, 255, 255, 0.06),
174
+ 0 2px 4px rgba(0, 0, 0, 0.3);
175
+ }
176
+
177
+ .fws-profile-strict__heading {
178
+ font-size: 16px;
179
+ font-weight: 600;
180
+ color: var(--fv-neutral-900, #0a0a0a);
181
+ margin: 0;
182
+ padding-bottom: 12px;
183
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
184
+ }
185
+
186
+ .dark .fws-profile-strict__heading {
187
+ color: var(--fv-neutral-100, #f5f5f5);
188
+ border-bottom-color: rgba(255, 255, 255, 0.06);
189
+ }
190
+
191
+ .fws-profile-strict__fields {
192
+ display: flex;
193
+ flex-direction: column;
194
+ gap: 16px;
195
+ }
196
+
197
+ .fws-profile-strict__terms {
198
+ padding-top: 10px;
199
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
200
+ }
201
+
202
+ .dark .fws-profile-strict__terms {
203
+ border-top-color: rgba(255, 255, 255, 0.06);
204
+ }
205
+
206
+ .fws-profile-strict__terms-text {
207
+ margin: 8px 0 0;
208
+ padding: 10px 12px;
209
+ font-size: 13px;
210
+ line-height: 1.5;
211
+ border-radius: 6px;
212
+ color: var(--fv-neutral-700, #404040);
213
+ background: rgba(0, 0, 0, 0.02);
214
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04);
215
+ }
216
+
217
+ .dark .fws-profile-strict__terms-text {
218
+ color: var(--fv-neutral-300, #d4d4d4);
219
+ background: rgba(255, 255, 255, 0.02);
220
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
221
+ }
222
+
223
+ .fws-profile-strict__actions {
224
+ display: flex;
225
+ justify-content: flex-end;
226
+ }
227
+
228
+ @media (max-width: 640px) {
229
+ .fws-profile-strict__container {
230
+ padding: 16px;
231
+ }
232
+ }
233
+ </style>
@@ -0,0 +1,10 @@
1
+ import { defineComponent, onMounted, ref } from 'vue'
2
+
3
+ export const ClientOnly = defineComponent({
4
+ name: 'ClientOnly',
5
+ setup(_, { slots }) {
6
+ const show = ref(false)
7
+ onMounted(() => { show.value = true })
8
+ return () => show.value && slots.default ? slots.default() : null
9
+ },
10
+ })
@@ -0,0 +1,99 @@
1
+ <script lang="ts" setup>
2
+ import type { BreadcrumbLink } from '../../types'
3
+ import { getURL, stringHash } from '@fy-/fws-js'
4
+ import { ChevronRightIcon, HomeIcon } from '@heroicons/vue/24/solid'
5
+ import { defineBreadcrumb } from '@unhead/schema-org'
6
+ import { useSchemaOrg } from '@unhead/schema-org/vue'
7
+ import { computed } from 'vue'
8
+
9
+ const props = withDefaults(
10
+ defineProps<{
11
+ nav: BreadcrumbLink[]
12
+ showHome: boolean
13
+ }>(),
14
+ {
15
+ nav: () => [],
16
+ showHome: () => true,
17
+ },
18
+ )
19
+
20
+ const baseUrl = computed(() => {
21
+ const url = getURL()
22
+ return { host: url.Host, scheme: url.Scheme }
23
+ })
24
+
25
+ const breadcrumbsSchemaFormat = computed(() => {
26
+ const navLength = props.nav.length
27
+ return props.nav.map((item, index) => {
28
+ const isLastItem = index === navLength - 1
29
+ if (!item.to || isLastItem) {
30
+ return { '@type': 'ListItem', 'position': index + 1, 'name': item.name }
31
+ }
32
+ let itemUrl = item.to
33
+ if (itemUrl.startsWith('/')) {
34
+ itemUrl = `${baseUrl.value.scheme}://${baseUrl.value.host}${itemUrl}`
35
+ }
36
+ else if (!itemUrl.startsWith('http')) {
37
+ itemUrl = `${baseUrl.value.scheme}://${baseUrl.value.host}/${itemUrl}`
38
+ }
39
+ itemUrl = itemUrl.replace(/([^:]\/)\/+/g, '$1')
40
+ return { '@type': 'ListItem', 'position': index + 1, 'name': item.name, 'item': itemUrl }
41
+ })
42
+ })
43
+
44
+ const breadcrumbId = computed(() => {
45
+ if (!props.nav.length) return ''
46
+ return stringHash(props.nav.map(item => item.name).join(' > '))
47
+ })
48
+
49
+ const linkClasses = 'text-xs font-medium text-fv-neutral-700 hover:text-fv-neutral-900 dark:text-fv-neutral-200 dark:hover:text-white transition-colors duration-200'
50
+ const textClasses = 'text-xs font-medium text-fv-neutral-500 dark:text-fv-neutral-200'
51
+ const chevronClasses = 'w-4 h-4 md:w-5 md:h-5 text-fv-neutral-400 inline-block mx-0.5 md:mx-1.5'
52
+ const homeIconClasses = 'w-3.5 h-3.5 md:w-4 md:h-4 mr-1.5 md:mr-2 inline-block'
53
+
54
+ if (props.nav && props.nav.length > 0) {
55
+ useSchemaOrg([
56
+ defineBreadcrumb({
57
+ '@id': `#${breadcrumbId.value}`,
58
+ '@context': 'https://schema.org',
59
+ '@type': 'BreadcrumbList',
60
+ 'itemListElement': breadcrumbsSchemaFormat,
61
+ }),
62
+ ])
63
+ }
64
+ </script>
65
+
66
+ <template>
67
+ <nav aria-label="Breadcrumb">
68
+ <ol class="inline-flex items-center flex-wrap gap-y-1">
69
+ <template v-for="(item, index) in nav" :key="`bc_${index.toString()}`">
70
+ <li class="inline-flex items-center">
71
+ <ChevronRightIcon
72
+ v-if="index !== 0"
73
+ :class="chevronClasses"
74
+ aria-hidden="true"
75
+ />
76
+ <router-link
77
+ v-if="item.to && index !== nav.length - 1"
78
+ :to="item.to"
79
+ :class="linkClasses"
80
+ :aria-current="index === nav.length - 1 ? 'page' : undefined"
81
+ >
82
+ <HomeIcon
83
+ v-if="showHome && index === 0"
84
+ :class="homeIconClasses"
85
+ />
86
+ <span>{{ item.name }}</span>
87
+ </router-link>
88
+ <span
89
+ v-else
90
+ :class="textClasses"
91
+ :aria-current="index === nav.length - 1 ? 'page' : undefined"
92
+ >
93
+ {{ item.name }}
94
+ </span>
95
+ </li>
96
+ </template>
97
+ </ol>
98
+ </nav>
99
+ </template>
@@ -0,0 +1,178 @@
1
+ <script setup lang="ts">
2
+ import { useDebounceFn } from '@vueuse/core'
3
+ import { nextTick, onMounted, onUnmounted, ref } from 'vue'
4
+ import { useEventBus } from '../../composables/event-bus'
5
+ import DefaultModal from './DefaultModal.vue'
6
+
7
+ const eventBus = useEventBus()
8
+ const title = ref<string | null>(null)
9
+ const desc = ref<string | null>(null)
10
+ const onConfirm = ref<Function | null>(null)
11
+ const isOpen = ref(false)
12
+ const modalRef = ref<HTMLElement | null>(null)
13
+ let previouslyFocusedElement: HTMLElement | null = null
14
+
15
+ interface ConfirmModalData {
16
+ title: string
17
+ desc: string
18
+ onConfirm: Function
19
+ }
20
+
21
+ const _onConfirm = useDebounceFn(async () => {
22
+ const callback = onConfirm.value
23
+ resetConfirm()
24
+ if (callback) await callback()
25
+ }, 300)
26
+
27
+ function resetConfirm() {
28
+ title.value = null
29
+ desc.value = null
30
+ onConfirm.value = null
31
+ isOpen.value = false
32
+ eventBus.emit('confirmModal', false)
33
+ previouslyFocusedElement?.focus()
34
+ }
35
+
36
+ function showConfirm(data: ConfirmModalData) {
37
+ title.value = data.title
38
+ desc.value = data.desc
39
+ onConfirm.value = data.onConfirm
40
+ eventBus.emit('confirmModal', true)
41
+ requestAnimationFrame(() => {
42
+ isOpen.value = true
43
+ eventBus.emit('confirmModal', true)
44
+ nextTick(() => {
45
+ previouslyFocusedElement = document.activeElement as HTMLElement
46
+ modalRef.value?.focus()
47
+ })
48
+ })
49
+ }
50
+
51
+ onMounted(() => {
52
+ eventBus.on('resetConfirm', resetConfirm)
53
+ eventBus.on('showConfirm', showConfirm)
54
+ })
55
+ onUnmounted(() => {
56
+ eventBus.off('resetConfirm', resetConfirm)
57
+ eventBus.off('showConfirm', showConfirm)
58
+ })
59
+ </script>
60
+
61
+ <template>
62
+ <DefaultModal id="confirm" ref="modalRef" m-size="!max-w-lg w-full">
63
+ <div
64
+ class="fv-confirm"
65
+ :aria-labelledby="title ? 'confirm-modal-title' : undefined"
66
+ :aria-describedby="desc ? 'confirm-modal-desc' : undefined"
67
+ aria-modal="true"
68
+ role="dialog"
69
+ tabindex="-1"
70
+ >
71
+ <h3 v-if="title" id="confirm-modal-title" class="fv-confirm__title">
72
+ {{ title }}
73
+ </h3>
74
+
75
+ <div v-if="desc" class="fv-confirm__desc">
76
+ <p id="confirm-modal-desc" v-html="desc" />
77
+ </div>
78
+
79
+ <div class="fv-confirm__actions">
80
+ <button
81
+ class="fv-confirm__btn fv-confirm__btn--confirm"
82
+ @click="_onConfirm()"
83
+ >
84
+ {{ $t("confirm_modal_cta_confirm") || 'Confirm' }}
85
+ </button>
86
+ <button
87
+ class="fv-confirm__btn fv-confirm__btn--cancel"
88
+ @click="resetConfirm()"
89
+ >
90
+ {{ $t("confirm_modal_cta_cancel") || 'Cancel' }}
91
+ </button>
92
+ </div>
93
+ </div>
94
+ </DefaultModal>
95
+ </template>
96
+
97
+ <style scoped>
98
+ .fv-confirm {
99
+ padding: 0.25rem;
100
+ }
101
+
102
+ .fv-confirm__title {
103
+ font-size: 1.0625rem;
104
+ font-weight: 600;
105
+ color: #171717;
106
+ margin-bottom: 0.75rem;
107
+ }
108
+ :is(.dark) .fv-confirm__title { color: #f5f5f5; }
109
+
110
+ .fv-confirm__desc {
111
+ padding: 0.75rem;
112
+ border-radius: 0.5rem;
113
+ margin-bottom: 1.25rem;
114
+ font-size: 0.875rem;
115
+ line-height: 1.5;
116
+ color: #404040;
117
+
118
+ /* Subtle background panel */
119
+ background: #f5f5f5;
120
+ border: 1px solid rgba(0, 0, 0, 0.04);
121
+ }
122
+ :is(.dark) .fv-confirm__desc {
123
+ background: rgba(255, 255, 255, 0.04);
124
+ border-color: rgba(255, 255, 255, 0.06);
125
+ color: #d4d4d4;
126
+ }
127
+
128
+ .fv-confirm__actions {
129
+ display: flex;
130
+ justify-content: flex-end;
131
+ gap: 0.625rem;
132
+ }
133
+
134
+ .fv-confirm__btn {
135
+ padding: 0.5rem 1.25rem;
136
+ font-size: 0.8125rem;
137
+ font-weight: 600;
138
+ border-radius: 0.5rem;
139
+ border: none;
140
+ cursor: pointer;
141
+ transition: background-color 150ms, transform 100ms;
142
+ }
143
+ .fv-confirm__btn:active { transform: scale(0.97); }
144
+
145
+ /* Stripe/Superhuman: warm crimson for destructive confirms */
146
+ .fv-confirm__btn--confirm {
147
+ background: #cf2d56;
148
+ color: white;
149
+ }
150
+ .fv-confirm__btn--confirm:hover {
151
+ background: #b82750;
152
+ }
153
+ .fv-confirm__btn--confirm:focus-visible {
154
+ outline: 2px solid #cf2d56;
155
+ outline-offset: 2px;
156
+ box-shadow: 0 0 0 4px hsla(345, 64%, 49%, 0.15);
157
+ }
158
+
159
+ /* Ghost cancel — transparent with subtle border (Stripe pattern) */
160
+ .fv-confirm__btn--cancel {
161
+ background: transparent;
162
+ color: #525252;
163
+ box-shadow: rgba(0, 0, 0, 0.08) 0px 0px 0px 1px;
164
+ }
165
+ .fv-confirm__btn--cancel:hover {
166
+ background: rgba(0, 0, 0, 0.03);
167
+ color: #171717;
168
+ }
169
+ :is(.dark) .fv-confirm__btn--cancel {
170
+ background: transparent;
171
+ color: #d4d4d4;
172
+ box-shadow: rgba(255, 255, 255, 0.08) 0px 0px 0px 1px;
173
+ }
174
+ :is(.dark) .fv-confirm__btn--cancel:hover {
175
+ background: rgba(255, 255, 255, 0.06);
176
+ color: #f5f5f5;
177
+ }
178
+ </style>