@mundogamernetwork/shared-ui 1.3.7 → 1.4.1

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.
@@ -0,0 +1,111 @@
1
+ <script lang="ts" setup>
2
+ import { defineEmits, defineProps, ref, watch } from "vue";
3
+
4
+ interface SelectProps {
5
+ id: string;
6
+ label: string;
7
+ options: Array<any[] | { value: any; label: string }>;
8
+ disabled?: boolean;
9
+ model: any;
10
+ }
11
+
12
+ const emit = defineEmits(["update:model"]);
13
+ const props = defineProps<SelectProps>();
14
+ const selectModel = ref(props.model);
15
+
16
+ watch(selectModel, () => {
17
+ emit("update:model", selectModel.value);
18
+ });
19
+ </script>
20
+
21
+ <template>
22
+ <div
23
+ :class="{
24
+ 'select-wrapper': true,
25
+ disabled: typeof props?.disabled !== 'undefined' ? props.disabled : false,
26
+ }"
27
+ >
28
+ <span class="before"></span>
29
+ <select
30
+ :id="props.id"
31
+ v-model="selectModel"
32
+ :name="props.id"
33
+ :class="{ filled: props.model !== '' && props.model !== null }"
34
+ :aria-label="$t(props.label)"
35
+ :placeholder="$t(props.label)"
36
+ :disabled="
37
+ typeof props?.disabled !== 'undefined' ? props.disabled : false
38
+ "
39
+ >
40
+ <option value="" disabled>
41
+ {{ $t(props.label) }}
42
+ </option>
43
+
44
+ <option
45
+ v-for="(opt, key) in props.options"
46
+ :key="`select-${id}-option-${key}`"
47
+ :value="Array.isArray(opt) ? opt[0] : opt.value"
48
+ >
49
+ {{ Array.isArray(opt) ? opt[1] : opt.label }}
50
+ </option>
51
+ </select>
52
+ </div>
53
+ </template>
54
+
55
+ <style lang="scss" scoped>
56
+ .select-wrapper {
57
+ position: relative;
58
+ border-bottom: 1px solid var(--input-border-color);
59
+ cursor: pointer;
60
+
61
+ &.disabled {
62
+ cursor: default;
63
+ filter: contrast(0.3) brightness(0.7);
64
+ }
65
+
66
+ &:not(.disabled) .before {
67
+ position: absolute;
68
+ bottom: -1px;
69
+ left: 0;
70
+ display: inline-block;
71
+ width: 0;
72
+ height: 2px;
73
+ background-color: var(--input-active-border-color);
74
+ z-index: 2;
75
+ transition: 0.2s ease-in-out;
76
+ }
77
+
78
+ &:has(select:focus):not(.disabled) .before,
79
+ &:has(select:focus-visible):not(.disabled) .before,
80
+ &:has(select:active):not(.disabled) .before {
81
+ width: 100%;
82
+ }
83
+
84
+ select {
85
+ height: 32px;
86
+ padding: 0.25rem 0.75rem;
87
+ padding-left: 0;
88
+ padding-bottom: 0;
89
+ border: 0;
90
+ background: var(--input-bg);
91
+ font-size: 0.75rem;
92
+ font-weight: 400;
93
+ color: var(--input-fg);
94
+
95
+ &:not(.filled) {
96
+ color: var(--input-placeholder-fg);
97
+ }
98
+
99
+ &:focus,
100
+ &:focus-visible,
101
+ &:active {
102
+ outline: 0;
103
+ box-shadow: none;
104
+ }
105
+
106
+ option{
107
+ color: #000;
108
+ }
109
+ }
110
+ }
111
+ </style>
@@ -0,0 +1,98 @@
1
+ <script lang="ts" setup>
2
+ import { defineEmits, defineProps, ref, watch } from "vue";
3
+
4
+ interface SortInputProps {
5
+ id: string;
6
+ ascLabel: string;
7
+ descLabel: string;
8
+ model: "asc" | "desc";
9
+ disabled?: boolean;
10
+ }
11
+
12
+ const emit = defineEmits(["update:model"]);
13
+ const props = defineProps<SortInputProps>();
14
+ const sortModel = ref(props.model);
15
+
16
+ watch(sortModel, () => {
17
+ emit("update:model", sortModel);
18
+ });
19
+
20
+ const toggleSort = () => {
21
+ if (!props?.disabled) {
22
+ sortModel.value = sortModel.value === "desc" ? "asc" : "desc";
23
+ }
24
+ };
25
+ </script>
26
+
27
+ <template>
28
+ <span
29
+ :id="props.id"
30
+ :class="{
31
+ 'sort-input': true,
32
+ disabled: typeof props?.disabled !== 'undefined' ? props.disabled : false,
33
+ }"
34
+ @click="toggleSort"
35
+ >
36
+ <span class="before" />
37
+
38
+ {{ $t(sortModel === "asc" ? props.ascLabel : props.descLabel) }}
39
+ <img
40
+ src="/imgs/sort-active.png"
41
+ width="20"
42
+ height="20"
43
+ aria-hidden="true"
44
+ :class="{ 'ms-auto': true, reversed: sortModel === 'asc' }"
45
+ />
46
+ </span>
47
+ </template>
48
+
49
+ <style lang="scss" scoped>
50
+ .sort-input {
51
+ position: relative;
52
+ display: flex;
53
+ justify-content: flex-start;
54
+ align-items: center;
55
+ height: 32px;
56
+ min-width: 116px;
57
+ padding-top: 0.25rem;
58
+
59
+ border-bottom: 1px solid var(--input-border-color);
60
+ color: var(--input-placeholder-fg);
61
+ font-size: 0.75rem;
62
+ font-weight: 400;
63
+ cursor: pointer;
64
+
65
+ &.disabled {
66
+ cursor: default;
67
+ filter: contrast(0.3) brightness(0.7);
68
+ pointer-events: none;
69
+ user-select: none;
70
+ }
71
+
72
+ &:not(.disabled) .before {
73
+ position: absolute;
74
+ bottom: -1px;
75
+ left: 0;
76
+ display: inline-block;
77
+ width: 0;
78
+ height: 2px;
79
+ background-color: var(--input-active-border-color);
80
+ z-index: 2;
81
+ transition: 0.2s ease-in-out;
82
+ }
83
+
84
+ &:active:not(.disabled) .before,
85
+ &:focus:not(.disabled) .before,
86
+ &:focus-visible:not(.disabled) .before {
87
+ width: 100%;
88
+ }
89
+
90
+ img {
91
+ transition: 0.15s ease-in-out;
92
+
93
+ &.reversed {
94
+ transform: rotate(180deg);
95
+ }
96
+ }
97
+ }
98
+ </style>
@@ -0,0 +1,408 @@
1
+ <script setup lang="ts">
2
+ import { fetchStreamerActiveWalls, fetchPublicWallGoals, getWallSupporters } from '../../services/indieWallService'
3
+ import MuralCanvas from './MuralCanvas.vue'
4
+ import SupportStepper from './SupportStepper.vue'
5
+
6
+ const props = defineProps<{ userId: number | string }>()
7
+ const localePath = useLocalePath()
8
+ const authStore = useAuthStore()
9
+ const { locale } = useI18n()
10
+
11
+ function formatMoney(amount: number, currency?: string | null) {
12
+ try {
13
+ return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD' }).format(amount)
14
+ } catch {
15
+ return `${currency || ''} ${Number(amount).toFixed(2)}`.trim()
16
+ }
17
+ }
18
+
19
+ const wall = ref<any>(null)
20
+ const goals = ref<any[]>([])
21
+ const supporters = ref<any[]>([])
22
+ const loading = ref(true)
23
+
24
+ const showStepper = ref(false)
25
+ const stepperStartStep = ref<1 | 2 | 3 | 4 | 5>(1)
26
+ const stepperInitialSelection = ref<{ x: number; y: number; w: number; h: number } | null>(null)
27
+
28
+ const detailPixel = ref<any | null>(null)
29
+
30
+ const totalPotential = computed(() =>
31
+ (wall.value?.width ?? 0) * (wall.value?.height ?? 0) * (Number(wall.value?.pixel_price) || 0)
32
+ )
33
+
34
+ const raisedPercent = computed(() => {
35
+ if (!totalPotential.value) return 0
36
+ return Math.min(100, (Number(wall.value?.raised_amount || 0) / totalPotential.value) * 100)
37
+ })
38
+
39
+ const tiersData = computed<any[]>(() => wall.value?.tiers?.data ?? wall.value?.tiers ?? [])
40
+ const mgcBalance = computed(() => Number(authStore.user?.mgc_balance ?? 0))
41
+
42
+ const wallUrl = computed(() => {
43
+ if (!wall.value) return '#'
44
+ return localePath(`/${wall.value.url_prefix || 'mural'}/${wall.value.slug}`)
45
+ })
46
+
47
+ function onTapEmpty(coord: { x: number; y: number }) {
48
+ stepperInitialSelection.value = { x: coord.x, y: coord.y, w: 1, h: 1 }
49
+ stepperStartStep.value = 1
50
+ showStepper.value = true
51
+ }
52
+
53
+ function onTapSupporter(s: any) {
54
+ detailPixel.value = s
55
+ }
56
+
57
+ function openStepper() {
58
+ stepperInitialSelection.value = null
59
+ stepperStartStep.value = 1
60
+ showStepper.value = true
61
+ }
62
+
63
+ async function onStepperSuccess() {
64
+ showStepper.value = false
65
+ await reloadSupporters()
66
+ }
67
+
68
+ async function reloadSupporters() {
69
+ if (!wall.value) return
70
+ const supRes = await getWallSupporters(wall.value.slug || wall.value.id, { per_page: 500 })
71
+ supporters.value = (supRes.data?.data ?? []).filter((p: any) => p.status)
72
+ }
73
+
74
+ const hoverTip = ref<{ supporter: any; x: number; y: number } | null>(null)
75
+
76
+ const supporterToast = ref<{ name: string; amount: number; currency: string } | null>(null)
77
+ let toastDismissTimer: ReturnType<typeof setTimeout> | null = null
78
+
79
+ function showSupporterToast(pixel: any) {
80
+ if (toastDismissTimer) clearTimeout(toastDismissTimer)
81
+ const name = pixel.is_anonymous ? 'Anonymous' : (pixel.title || pixel.user?.name || 'Anonymous')
82
+ const currency = wall.value?.currency ?? 'USD'
83
+ const amount = Number(pixel.amount_paid || 0)
84
+ supporterToast.value = { name, amount, currency }
85
+ toastDismissTimer = setTimeout(() => { supporterToast.value = null }, 6000)
86
+ }
87
+
88
+ function onHoverSupporter(payload: { supporter: any | null; clientX: number; clientY: number }) {
89
+ hoverTip.value = payload.supporter
90
+ ? { supporter: payload.supporter, x: payload.clientX, y: payload.clientY }
91
+ : null
92
+ }
93
+
94
+ function supporterDisplayName(s: any): string {
95
+ if (s.is_anonymous) return 'Anonymous'
96
+ return s.title || s.user?.name || 'Anonymous'
97
+ }
98
+
99
+ async function load() {
100
+ loading.value = true
101
+ try {
102
+ const res = await fetchStreamerActiveWalls(props.userId)
103
+ const list = res.data?.data ?? res.data ?? []
104
+ const first = Array.isArray(list) ? list.find((w: any) => w.show_on_media_kit) ?? list[0] : null
105
+ if (!first) return
106
+ wall.value = first
107
+ const [goalsRes, supRes] = await Promise.all([
108
+ fetchPublicWallGoals(first.slug || first.id).catch(() => ({ data: { data: [] } })),
109
+ getWallSupporters(first.slug || first.id, { per_page: 500 }),
110
+ ])
111
+ goals.value = goalsRes.data?.data || []
112
+ supporters.value = (supRes.data?.data ?? []).filter((p: any) => p.status)
113
+ } catch {
114
+ // no wall — renders nothing
115
+ } finally {
116
+ loading.value = false
117
+ }
118
+ }
119
+
120
+ function onPixelConfirmed(payload: any) {
121
+ const pixel = payload?.pixel
122
+ if (!pixel || !wall.value || Number(pixel.indie_wall_id) !== Number(wall.value.id)) return
123
+
124
+ if (payload?.wall) {
125
+ wall.value.raised_amount = payload.wall.raised_amount ?? wall.value.raised_amount
126
+ wall.value.used_pixels = payload.wall.used_pixels ?? wall.value.used_pixels
127
+ }
128
+
129
+ showSupporterToast(pixel)
130
+
131
+ // Re-fetch full supporter list to get image_url — the WS payload omits it
132
+ // intentionally to stay under Pusher's 10 KB message limit.
133
+ reloadSupporters()
134
+ }
135
+ let echoChannel: any = null
136
+ function subscribeWall(wallId: number | string) {
137
+ const echo = (window as any).Echo
138
+ if (!echo) return
139
+ try {
140
+ echoChannel = echo.channel(`indie-wall.${wallId}`)
141
+ echoChannel.listen('.pixel.confirmed', onPixelConfirmed)
142
+ } catch { /* ignore */ }
143
+ }
144
+ function unsubscribeWall(wallId: number | string) {
145
+ try { (window as any).Echo?.leave?.(`indie-wall.${wallId}`) } catch { /* ignore */ }
146
+ echoChannel = null
147
+ }
148
+
149
+ watch(wall, (newWall, oldWall) => {
150
+ if (oldWall?.id && oldWall.id !== newWall?.id) unsubscribeWall(oldWall.id)
151
+ if (newWall?.id) subscribeWall(newWall.id)
152
+ })
153
+
154
+ onMounted(load)
155
+ onUnmounted(() => {
156
+ if (wall.value?.id) unsubscribeWall(wall.value.id)
157
+ if (toastDismissTimer) clearTimeout(toastDismissTimer)
158
+ })
159
+ </script>
160
+
161
+ <template>
162
+ <div v-if="!loading && wall" class="mkw-block">
163
+ <h2 class="mkw-section-title">{{ $t('kit.media.support') }}</h2>
164
+
165
+ <div class="mkw-card">
166
+ <div class="mkw-meta">
167
+ <div class="mkw-info">
168
+ <span class="mkw-name">{{ wall.name }}</span>
169
+ <p v-if="wall.description" class="mkw-desc">{{ wall.description }}</p>
170
+ </div>
171
+ <div class="mkw-meta-actions">
172
+ <button type="button" class="mkw-cta" @click="openStepper">
173
+ {{ $t('kit.media.support') }}
174
+ </button>
175
+ <NuxtLink :to="wallUrl" class="mkw-link">
176
+ {{ $t('kit.media.view_wall') }}
177
+ </NuxtLink>
178
+ </div>
179
+ </div>
180
+
181
+ <div class="mkw-progress-row">
182
+ <span class="mkw-raised">
183
+ {{ formatMoney(Number(wall.raised_amount || 0), wall.currency) }} {{ $t('kit.media.raised') }}
184
+ </span>
185
+ <span v-if="totalPotential > 0" class="mkw-total">
186
+ / {{ formatMoney(totalPotential, wall.currency) }}
187
+ </span>
188
+ <span class="mkw-pct">{{ raisedPercent.toFixed(0) }}%</span>
189
+ </div>
190
+ <div class="mkw-bar">
191
+ <div class="mkw-bar-fill" :style="{ width: raisedPercent + '%' }" />
192
+ </div>
193
+
194
+ <div class="mkw-canvas-area">
195
+ <div class="mkw-hint-row">
196
+ <span class="mkw-hint">{{ $t('tv.dashboard.indie_wall.mural_click_hint') }}</span>
197
+ <span class="mkw-availability">
198
+ {{ Math.max(0, (wall.total_pixels || 0) - (wall.used_pixels || 0)) }}
199
+ / {{ wall.total_pixels || 0 }}
200
+ {{ $t('tv.dashboard.indie_wall.pixels_available') }}
201
+ </span>
202
+ </div>
203
+ <MuralCanvas
204
+ :wall="wall"
205
+ :supporters="supporters"
206
+ :selection="null"
207
+ :interactive="true"
208
+ :height="300"
209
+ @tap-empty="onTapEmpty"
210
+ @tap-supporter="onTapSupporter"
211
+ @hover-supporter="onHoverSupporter"
212
+ />
213
+ <Transition name="mkw-toast">
214
+ <div v-if="supporterToast" class="mkw-supporter-toast">
215
+ <span class="mkw-toast-icon">★</span>
216
+ <div class="mkw-toast-text">
217
+ <strong>{{ supporterToast.name }}</strong>
218
+ <span>{{ $t('tv.dashboard.indie_wall.just_supported') }} · {{ formatMoney(supporterToast.amount, supporterToast.currency) }}</span>
219
+ </div>
220
+ <button class="mkw-toast-close" type="button" @click="supporterToast = null">×</button>
221
+ </div>
222
+ </Transition>
223
+ </div>
224
+ </div>
225
+
226
+ <Teleport to="body">
227
+ <Transition name="mkw-fade">
228
+ <div
229
+ v-if="hoverTip"
230
+ class="mkw-hover-tip"
231
+ :style="{ left: (hoverTip.x + 14) + 'px', top: (hoverTip.y + 14) + 'px' }"
232
+ >
233
+ <div class="mkw-tip-swatch" :style="{ background: hoverTip.supporter.background_color || '#D297FF' }">
234
+ <img v-if="hoverTip.supporter.image_url" :src="hoverTip.supporter.image_url" alt="" />
235
+ </div>
236
+ <div class="mkw-tip-info">
237
+ <strong>{{ supporterDisplayName(hoverTip.supporter) }}</strong>
238
+ <span v-if="hoverTip.supporter.supporter_message">"{{ hoverTip.supporter.supporter_message }}"</span>
239
+ <small>{{ formatMoney(Number(hoverTip.supporter.amount_paid || 0), wall.currency) }} · {{ hoverTip.supporter.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</small>
240
+ </div>
241
+ </div>
242
+ </Transition>
243
+
244
+ <Transition name="mkw-fade">
245
+ <SupportStepper
246
+ v-if="showStepper && wall"
247
+ :wall="wall"
248
+ :goals="goals"
249
+ :tiers="tiersData"
250
+ :supporters="supporters"
251
+ :mgc-balance="mgcBalance"
252
+ :start-step="stepperStartStep"
253
+ :initial-selection="stepperInitialSelection"
254
+ @close="showStepper = false"
255
+ @support-success="onStepperSuccess"
256
+ />
257
+ </Transition>
258
+
259
+ <Transition name="mkw-fade">
260
+ <div v-if="detailPixel" class="mkw-detail-overlay" @click.self="detailPixel = null">
261
+ <div class="mkw-detail-box">
262
+ <button class="mkw-detail-close" type="button" @click="detailPixel = null">×</button>
263
+ <div class="mkw-detail-swatch" :style="{ background: detailPixel.background_color || '#D297FF' }">
264
+ <img v-if="detailPixel.image_url" :src="detailPixel.image_url" alt="" />
265
+ </div>
266
+ <p class="mkw-detail-name">{{ supporterDisplayName(detailPixel) }}</p>
267
+ <p v-if="detailPixel.supporter_message" class="mkw-detail-msg">"{{ detailPixel.supporter_message }}"</p>
268
+ <div class="mkw-detail-meta">
269
+ <span>{{ $t('tv.dashboard.indie_wall.mural_position') }}: ({{ detailPixel.x }}, {{ detailPixel.y }})</span>
270
+ <span>{{ detailPixel.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
271
+ <span>{{ formatMoney(Number(detailPixel.amount_paid), wall.currency) }}</span>
272
+ </div>
273
+ <p v-if="detailPixel.link">
274
+ <a :href="detailPixel.link" target="_blank" rel="noopener" class="mkw-detail-link">{{ detailPixel.link }}</a>
275
+ </p>
276
+ </div>
277
+ </div>
278
+ </Transition>
279
+ </Teleport>
280
+ </div>
281
+ </template>
282
+
283
+ <style scoped lang="scss">
284
+ .mkw-block { margin-bottom: 16px; }
285
+
286
+ .mkw-section-title {
287
+ font-size: 13px; letter-spacing: 3px; text-transform: uppercase;
288
+ color: var(--kit-muted); font-weight: 700; margin-bottom: 16px;
289
+ display: flex; align-items: center; gap: 12px;
290
+ &::after { content: ""; flex: 1; height: 1px; background: var(--kit-line); }
291
+ }
292
+
293
+ .mkw-card {
294
+ background: var(--kit-surface, #191B20);
295
+ border: 1px solid var(--kit-line, rgba(128, 128, 128, 0.15));
296
+ padding: 16px;
297
+ }
298
+
299
+ .mkw-meta {
300
+ display: flex; align-items: flex-start; justify-content: space-between;
301
+ gap: 12px; margin-bottom: 12px;
302
+ @media (max-width: 576px) { flex-direction: column; }
303
+ }
304
+
305
+ .mkw-info { flex: 1; min-width: 0; }
306
+ .mkw-name {
307
+ font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 19px;
308
+ color: var(--kit-text, #fff); display: block;
309
+ }
310
+ .mkw-desc {
311
+ font-size: 13px; color: var(--kit-muted, #888); margin: 4px 0 0;
312
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
313
+ }
314
+
315
+ .mkw-meta-actions { display: flex; flex-direction: column; gap: 6px; flex-shrink: 0; align-items: stretch; }
316
+
317
+ .mkw-cta {
318
+ display: inline-flex; align-items: center; justify-content: center;
319
+ background: var(--kit-accent, #D297FF); color: var(--kit-accent-ink, #13161C);
320
+ font-weight: 700; font-size: 13px; padding: 8px 14px; border: none;
321
+ white-space: nowrap; cursor: pointer; text-transform: uppercase;
322
+ &:hover { filter: brightness(1.1); }
323
+ }
324
+
325
+ .mkw-link {
326
+ font-size: 11px; color: var(--kit-accent, #D297FF);
327
+ text-decoration: none; text-align: center;
328
+ &:hover { text-decoration: underline; }
329
+ }
330
+
331
+ .mkw-progress-row {
332
+ display: flex; align-items: baseline; gap: 6px; font-size: 12px; margin-bottom: 5px;
333
+ }
334
+ .mkw-raised { color: var(--kit-accent, #D297FF); font-weight: 700; }
335
+ .mkw-total { color: var(--kit-muted, #888); font-size: 11px; }
336
+ .mkw-pct { color: var(--kit-muted, #888); font-size: 11px; margin-left: auto; }
337
+
338
+ .mkw-bar { height: 3px; background: var(--kit-surface-2, rgba(128, 128, 128, 0.15)); margin-bottom: 14px; overflow: hidden; }
339
+ .mkw-bar-fill { height: 100%; background: var(--kit-accent, #D297FF); transition: width 0.5s ease; min-width: 2px; }
340
+
341
+ .mkw-canvas-area { display: flex; flex-direction: column; gap: 6px; }
342
+ .mkw-hint-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
343
+ .mkw-hint { font-size: 11px; color: var(--kit-muted, #888); }
344
+ .mkw-availability { font-size: 11px; color: var(--kit-accent, #D297FF); font-weight: 600; }
345
+
346
+ .mkw-hover-tip {
347
+ position: fixed; z-index: 9999; pointer-events: none;
348
+ background: #191B20; border: 1px solid #272930; padding: 8px 10px;
349
+ display: flex; gap: 8px; max-width: 260px; box-shadow: 0 4px 14px rgba(0,0,0,0.5);
350
+ }
351
+ .mkw-tip-swatch {
352
+ width: 26px; height: 26px; flex-shrink: 0; overflow: hidden;
353
+ img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; }
354
+ }
355
+ .mkw-tip-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
356
+ .mkw-tip-info strong { color: #fff; font-size: 12px; }
357
+ .mkw-tip-info span {
358
+ color: #888; font-size: 11px; font-style: italic;
359
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
360
+ }
361
+ .mkw-tip-info small { color: var(--kit-accent, #D297FF); font-size: 11px; margin-top: 2px; }
362
+
363
+ .mkw-detail-overlay {
364
+ position: fixed; inset: 0; background: rgba(0,0,0,0.72);
365
+ display: flex; align-items: center; justify-content: center; z-index: 9998; padding: 16px;
366
+ }
367
+ .mkw-detail-box { background: #191B20; border: 1px solid #272930; padding: 24px; width: 100%; max-width: 320px; position: relative; }
368
+ .mkw-detail-close {
369
+ position: absolute; top: 10px; right: 10px; background: #272930; border: none; color: #fff;
370
+ width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
371
+ cursor: pointer; font-size: 1rem; line-height: 1;
372
+ }
373
+ .mkw-detail-swatch {
374
+ width: 44px; height: 44px; margin-bottom: 12px; overflow: hidden;
375
+ img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; }
376
+ }
377
+ .mkw-detail-name { font-size: 1rem; font-weight: 600; color: #fff; margin-bottom: 6px; }
378
+ .mkw-detail-msg { color: #aaa; font-size: 0.85rem; font-style: italic; margin-bottom: 12px; line-height: 1.4; }
379
+ .mkw-detail-meta { display: flex; flex-direction: column; gap: 3px; color: #666; font-size: 0.75rem; margin-bottom: 8px; }
380
+ .mkw-detail-link {
381
+ color: var(--kit-accent, #D297FF); font-size: 0.75rem; word-break: break-all; text-decoration: none;
382
+ &:hover { text-decoration: underline; }
383
+ }
384
+
385
+ .mkw-fade-enter-active, .mkw-fade-leave-active { transition: opacity 0.12s; }
386
+ .mkw-fade-enter-from, .mkw-fade-leave-to { opacity: 0; }
387
+
388
+ .mkw-supporter-toast {
389
+ display: flex; align-items: center; gap: 10px;
390
+ background: #1a1200; border: 1px solid var(--kit-accent, #D297FF); padding: 10px 12px;
391
+ margin-top: 10px; position: relative;
392
+ }
393
+ .mkw-toast-icon { color: var(--kit-accent, #D297FF); font-size: 1rem; flex-shrink: 0; line-height: 1; }
394
+ .mkw-toast-text {
395
+ display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0;
396
+ strong { color: #fff; font-size: 0.82rem; font-weight: 700; }
397
+ span { color: var(--kit-accent, #D297FF); font-size: 0.75rem; }
398
+ }
399
+ .mkw-toast-close {
400
+ background: none; border: none; color: #888; cursor: pointer; font-size: 1rem;
401
+ line-height: 1; padding: 0; flex-shrink: 0;
402
+ &:hover { color: #fff; }
403
+ }
404
+ .mkw-toast-enter-active { transition: opacity 0.2s, transform 0.2s; }
405
+ .mkw-toast-leave-active { transition: opacity 0.3s; }
406
+ .mkw-toast-enter-from { opacity: 0; transform: translateY(-6px); }
407
+ .mkw-toast-leave-to { opacity: 0; }
408
+ </style>
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ const { t } = useI18n()
3
+
2
4
  const props = withDefaults(defineProps<{
3
5
  form: {
4
6
  title: string
@@ -36,6 +38,12 @@ const messageError = ref('')
36
38
  const imageMissingError = ref('')
37
39
  const emailError = ref('')
38
40
 
41
+ // Errors are (re)computed on submit in next(); clear them as soon as the user
42
+ // edits the field so a corrected value doesn't keep showing a stale error.
43
+ watch(() => local.guest_email, () => { emailError.value = '' })
44
+ watch(() => local.title, () => { titleError.value = '' })
45
+ watch(() => local.message, () => { messageError.value = '' })
46
+
39
47
  const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
40
48
  const URL_REGEX = /^https?:\/\/.+\..+/
41
49
 
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ const { locale } = useI18n()
3
+
2
4
  defineProps<{
3
5
  goals: any[]
4
6
  selectedGoalId: number | null
@@ -14,6 +16,14 @@ function pick(goalId: number | null) {
14
16
  emit('select', goalId)
15
17
  emit('next')
16
18
  }
19
+
20
+ function formatMoney(amount: number, currency?: string | null) {
21
+ try {
22
+ return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD', maximumFractionDigits: 0 }).format(amount)
23
+ } catch {
24
+ return `${currency || ''} ${Number(amount).toFixed(0)}`.trim()
25
+ }
26
+ }
17
27
  </script>
18
28
 
19
29
  <template>
@@ -34,7 +44,7 @@ function pick(goalId: number | null) {
34
44
  {{ $t('tv.dashboard.indie_wall.reached') }}
35
45
  </span>
36
46
  <span v-else class="sw-goal-amount">
37
- {{ currency }} {{ Number(goal.target_amount || 0).toFixed(0) }}
47
+ {{ formatMoney(Number(goal.target_amount || 0), currency) }}
38
48
  </span>
39
49
  </header>
40
50