@mundogamernetwork/shared-ui 1.0.0

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 (87) hide show
  1. package/README.md +283 -0
  2. package/components/PressKit/AssetGallery.vue +349 -0
  3. package/components/PressKit/Awards.vue +100 -0
  4. package/components/PressKit/Credits.vue +78 -0
  5. package/components/PressKit/FactSheet.vue +204 -0
  6. package/components/PressKit/Hero.vue +143 -0
  7. package/components/PressKit/Quotes.vue +80 -0
  8. package/components/PressKit/VideoPlayer.vue +134 -0
  9. package/components/checkout/MgCartItemList.vue +214 -0
  10. package/components/checkout/MgCartSummary.vue +204 -0
  11. package/components/checkout/MgCheckoutSidebar.vue +230 -0
  12. package/components/checkout/MgGuestEmailForm.vue +97 -0
  13. package/components/checkout/MgPaymentMethodSelector.vue +162 -0
  14. package/components/checkout/MgPixQRCode.vue +222 -0
  15. package/components/indie-wall/IndieWallLeaderboard.vue +208 -0
  16. package/components/indie-wall/MuralCanvas.vue +481 -0
  17. package/components/indie-wall/StepBlock.vue +314 -0
  18. package/components/indie-wall/StepCustomize.vue +530 -0
  19. package/components/indie-wall/StepGoal.vue +169 -0
  20. package/components/indie-wall/StepPackage.vue +145 -0
  21. package/components/indie-wall/StepPay.vue +209 -0
  22. package/components/indie-wall/SupportStepper.vue +372 -0
  23. package/components/invoices/MgInvoiceDownload.vue +50 -0
  24. package/components/pricing/MgBillingToggle.vue +74 -0
  25. package/components/pricing/MgPricingCard.vue +245 -0
  26. package/components/ui/Header/MgMessageCard.vue +147 -0
  27. package/components/ui/Header/MgMessageModal.vue +414 -0
  28. package/components/ui/Header/MgNotificationCard.vue +200 -0
  29. package/components/ui/Header/MgNotificationsModal.vue +125 -0
  30. package/components/ui/MgAnnouncementBanner.vue +147 -0
  31. package/components/ui/MgBanners.vue +23 -0
  32. package/components/ui/MgHeaderComponent.vue +283 -0
  33. package/components/ui/MgHeaderUIConfig.vue +225 -0
  34. package/components/ui/MgHeaderUIUser.vue +301 -0
  35. package/components/ui/MgLoginModal.vue +156 -0
  36. package/components/ui/MgPromotionBanner.vue +185 -0
  37. package/composables/useLogout.ts +42 -0
  38. package/composables/useMgCheckout.ts +287 -0
  39. package/composables/useMgUserNotifications.ts +122 -0
  40. package/composables/usePaymentMethods.ts +75 -0
  41. package/composables/useSubscription.ts +163 -0
  42. package/middleware/auth.global.ts +40 -0
  43. package/nuxt.config.ts +31 -0
  44. package/package.json +40 -0
  45. package/pages/[slug]/index.vue +112 -0
  46. package/pages/about.vue +133 -0
  47. package/pages/blog.vue +430 -0
  48. package/pages/careers.vue +329 -0
  49. package/pages/contact.vue +339 -0
  50. package/pages/faq.vue +317 -0
  51. package/pages/health-check.vue +20 -0
  52. package/pages/icons.vue +58 -0
  53. package/pages/magazine/[slug].vue +209 -0
  54. package/pages/magazine/index.vue +267 -0
  55. package/pages/media-kit/[slug].vue +625 -0
  56. package/pages/mural/[slug].vue +1058 -0
  57. package/pages/partners.vue +290 -0
  58. package/pages/press.vue +237 -0
  59. package/pages/presskit/[slug].vue +191 -0
  60. package/pages/roadmap.vue +355 -0
  61. package/pages/status.vue +199 -0
  62. package/pages/team.vue +266 -0
  63. package/pages/wall/[slug].vue +11 -0
  64. package/plugins/auth.client.ts +17 -0
  65. package/plugins/echo.client.ts +132 -0
  66. package/services/authService.ts +95 -0
  67. package/services/chatService.ts +53 -0
  68. package/services/contactService.ts +35 -0
  69. package/services/documentService.ts +16 -0
  70. package/services/httpService.ts +95 -0
  71. package/services/indieWallService.ts +174 -0
  72. package/services/institutionalService.ts +248 -0
  73. package/services/mediaKitService.ts +51 -0
  74. package/services/notificationsService.ts +20 -0
  75. package/services/pressKitService.ts +55 -0
  76. package/stores/announcement.ts +129 -0
  77. package/stores/auth.ts +86 -0
  78. package/stores/chat.ts +150 -0
  79. package/stores/contact.ts +28 -0
  80. package/stores/document.ts +27 -0
  81. package/stores/index.ts +34 -0
  82. package/stores/institutional.ts +231 -0
  83. package/stores/login.ts +27 -0
  84. package/stores/notifications.ts +133 -0
  85. package/stores/promotion.ts +154 -0
  86. package/types/index.ts +135 -0
  87. package/utils/serialize.ts +29 -0
@@ -0,0 +1,1058 @@
1
+ <script setup lang="ts">
2
+ import { fetchPublicWall, fetchPublicWallGoals, getWallSupporters } from '../../services/indieWallService'
3
+ import SupportStepper from '../../components/indie-wall/SupportStepper.vue'
4
+ import MuralCanvas from '../../components/indie-wall/MuralCanvas.vue'
5
+ import IndieWallLeaderboard from '../../components/indie-wall/IndieWallLeaderboard.vue'
6
+
7
+ const { t } = useI18n()
8
+ const route = useRoute()
9
+ const router = useRouter()
10
+ const wallSlug = route.params.slug as string
11
+ const authStore = useAuthStore()
12
+
13
+ const preX = route.query.x !== undefined ? parseInt(route.query.x as string) : null
14
+ const preY = route.query.y !== undefined ? parseInt(route.query.y as string) : null
15
+ const isPopup = computed(() => route.query.popup === '1')
16
+
17
+ function notifyOpenerAndClose(payload: Record<string, any> = {}) {
18
+ if (!isPopup.value || typeof window === 'undefined') return
19
+ try {
20
+ if (window.opener && !window.opener.closed) {
21
+ window.opener.postMessage({ type: 'mgn-mural-support-success', ...payload }, '*')
22
+ }
23
+ } catch {
24
+ // ignore cross-origin issues
25
+ }
26
+ setTimeout(() => window.close(), 200)
27
+ }
28
+
29
+ // ── Realtime subscription (Laravel Echo / Pusher) ─────────────────────────
30
+ const showProcessing = ref(false)
31
+ const processingTimedOut = ref(false)
32
+ const pendingPixelId = ref<number | null>(null)
33
+ let processingTimeoutId: ReturnType<typeof setTimeout> | null = null
34
+ let httpFallbackTimerId: ReturnType<typeof setInterval> | null = null
35
+ const WS_TIMEOUT_MS = 30000 // 30s fallback window
36
+ const HTTP_FALLBACK_MS = 5000 // when WS isn't connected, retry HTTP every 5s
37
+
38
+ function appendOrReplaceSupporter(pixel: any) {
39
+ if (!pixel) return
40
+ const idx = supporters.value.findIndex((s: any) => Number(s.id) === Number(pixel.id))
41
+ if (idx === -1) {
42
+ supporters.value = [pixel, ...supporters.value]
43
+ } else {
44
+ supporters.value.splice(idx, 1, pixel)
45
+ }
46
+ }
47
+
48
+ function onPixelConfirmed(payload: any) {
49
+ const pixel = payload?.pixel
50
+ if (!pixel || Number(pixel.indie_wall_id) !== Number(wall.value?.id)) return
51
+ appendOrReplaceSupporter(pixel)
52
+
53
+ // If we were waiting for our own pixel, dismiss the overlay.
54
+ if (pendingPixelId.value && Number(pixel.id) === Number(pendingPixelId.value)) {
55
+ finalizePending(pixel.id)
56
+ }
57
+
58
+ // Update wall stats from the broadcast payload (optimistic).
59
+ if (payload?.wall && wall.value) {
60
+ wall.value.raised_amount = payload.wall.raised_amount ?? wall.value.raised_amount
61
+ wall.value.used_pixels = payload.wall.used_pixels ?? wall.value.used_pixels
62
+ wall.value.progress_percent = payload.wall.progress_percent ?? wall.value.progress_percent
63
+ }
64
+ if (payload?.goal) {
65
+ const g = goals.value.find((g: any) => Number(g.id) === Number(payload.goal.id))
66
+ if (g) Object.assign(g, payload.goal)
67
+ }
68
+ }
69
+
70
+ function finalizePending(pixelId: number | string) {
71
+ pendingPixelId.value = null
72
+ showProcessing.value = false
73
+ if (processingTimeoutId) { clearTimeout(processingTimeoutId); processingTimeoutId = null }
74
+ if (httpFallbackTimerId) { clearInterval(httpFallbackTimerId); httpFallbackTimerId = null }
75
+ try {
76
+ sessionStorage.removeItem('mgn-mural-pending-pixel')
77
+ sessionStorage.removeItem('mgn-mural-pending-wall')
78
+ } catch { /* ignore */ }
79
+ if (isPopup.value) {
80
+ notifyOpenerAndClose({ wallSlug, pixelId, fromGateway: true })
81
+ } else {
82
+ // Refresh supporters from API to get full pixel data (image_url, correct dimensions).
83
+ getWallSupporters(wallSlug, { per_page: 1000 }).then(res => {
84
+ supporters.value = (res.data?.data ?? []).filter((s: any) => s.status !== false)
85
+ }).catch(() => { /* keep current state if refresh fails */ })
86
+ }
87
+ }
88
+
89
+ function startWaitingForPixel(pixelId: number) {
90
+ pendingPixelId.value = pixelId
91
+ showProcessing.value = true
92
+ processingTimedOut.value = false
93
+
94
+ // Fallback: if WS doesn't deliver in WS_TIMEOUT_MS, mark as timed out.
95
+ processingTimeoutId = setTimeout(() => {
96
+ processingTimedOut.value = true
97
+ try {
98
+ sessionStorage.removeItem('mgn-mural-pending-pixel')
99
+ sessionStorage.removeItem('mgn-mural-pending-wall')
100
+ } catch { /* ignore */ }
101
+ if (isPopup.value) {
102
+ notifyOpenerAndClose({ wallSlug, fromGateway: true, pending: true })
103
+ }
104
+ }, WS_TIMEOUT_MS)
105
+
106
+ // Secondary fallback: poll HTTP regardless of WS state in case the broadcast is missed.
107
+ httpFallbackTimerId = setInterval(async () => {
108
+ try {
109
+ const res = await getWallSupporters(wallSlug, { per_page: 1000 })
110
+ const list = (res.data?.data ?? []).filter((s: any) => s.status !== false)
111
+ const found = list.find((s: any) => Number(s.id) === Number(pixelId))
112
+ if (found) {
113
+ supporters.value = list
114
+ finalizePending(pixelId)
115
+ }
116
+ } catch { /* keep trying */ }
117
+ }, HTTP_FALLBACK_MS)
118
+ }
119
+
120
+ let echoChannel: any = null
121
+ function subscribeWall(wallId: number | string) {
122
+ const echo = (window as any).Echo
123
+ if (!echo) return
124
+ try {
125
+ echoChannel = echo.channel(`indie-wall.${wallId}`)
126
+ echoChannel.listen('.pixel.confirmed', onPixelConfirmed)
127
+ } catch (err) {
128
+ console.warn('[mural] Echo subscribe failed', err)
129
+ }
130
+ }
131
+
132
+ function unsubscribeWall(wallId: number | string) {
133
+ try {
134
+ (window as any).Echo?.leave?.(`indie-wall.${wallId}`)
135
+ } catch { /* ignore */ }
136
+ echoChannel = null
137
+ }
138
+
139
+ // ── Data ───────────────────────────────────────────────────────────────────
140
+ const wall = ref<any>(null)
141
+ const goals = ref<any[]>([])
142
+ const supporters = ref<any[]>([])
143
+ const loading = ref(true)
144
+
145
+ // ── Stepper state ──────────────────────────────────────────────────────────
146
+ const showStepper = ref(false)
147
+ const stepperStartStep = ref<1 | 2 | 3 | 4 | 5>(1)
148
+ const stepperInitialGoalId = ref<number | null>(null)
149
+ const stepperInitialTierId = ref<number | null>(null)
150
+ const stepperInitialSelection = ref<{ x: number; y: number; w: number; h: number } | null>(null)
151
+
152
+ // ── Detail popup ───────────────────────────────────────────────────────────
153
+ const detailPixel = ref<any | null>(null)
154
+
155
+ // ── Computed ───────────────────────────────────────────────────────────────
156
+ const mgcBalance = computed(() => Number(authStore.user?.mgc_balance ?? 0))
157
+
158
+ const tiersData = computed<any[]>(() => wall.value?.tiers?.data ?? wall.value?.tiers ?? [])
159
+
160
+ const progressPercent = computed(() => {
161
+ if (!wall.value) return 0
162
+ const total = wall.value.width * wall.value.height
163
+ return total > 0 ? Math.min(100, ((wall.value.used_pixels || 0) / total) * 100) : 0
164
+ })
165
+
166
+ // ── Load ────────────────────────────────────────────────────────────────────
167
+ async function loadWall() {
168
+ loading.value = true
169
+ try {
170
+ const [wallRes, goalsRes, supportersRes] = await Promise.all([
171
+ fetchPublicWall(wallSlug),
172
+ fetchPublicWallGoals(wallSlug),
173
+ getWallSupporters(wallSlug, { per_page: 1000 }),
174
+ ])
175
+ wall.value = wallRes.data?.data || wallRes.data
176
+ goals.value = goalsRes.data?.data || []
177
+ supporters.value = (supportersRes.data?.data || []).filter((s: any) => s.status !== false)
178
+
179
+ // Pre-open stepper at step 1 if arriving with x/y query (e.g. from embed)
180
+ if (preX !== null && preY !== null && wall.value) {
181
+ const w = wall.value
182
+ if (preX >= 0 && preY >= 0 && preX < w.width && preY < w.height) {
183
+ stepperInitialSelection.value = { x: preX, y: preY, w: 1, h: 1 }
184
+ stepperStartStep.value = 1
185
+ showStepper.value = true
186
+ }
187
+ }
188
+
189
+ // In popup mode (opened from embed), open the stepper immediately
190
+ if (isPopup.value && wall.value) {
191
+ stepperStartStep.value = 1
192
+ showStepper.value = true
193
+ }
194
+ } catch (err) {
195
+ console.error(err)
196
+ } finally {
197
+ loading.value = false
198
+ }
199
+ }
200
+
201
+ // ── Stepper triggers ───────────────────────────────────────────────────────
202
+ function openStepper(opts: { goalId?: number | null; tierId?: number | null; selection?: { x: number; y: number; w: number; h: number } | null; startStep?: 1 | 2 | 3 | 4 | 5 } = {}) {
203
+ stepperInitialGoalId.value = opts.goalId ?? null
204
+ stepperInitialTierId.value = opts.tierId ?? null
205
+ stepperInitialSelection.value = opts.selection ?? null
206
+ stepperStartStep.value = opts.startStep ?? 1
207
+ showStepper.value = true
208
+ }
209
+
210
+ function onCanvasTapEmpty(coord: { x: number; y: number }) {
211
+ openStepper({
212
+ selection: { x: coord.x, y: coord.y, w: 1, h: 1 },
213
+ startStep: 1,
214
+ })
215
+ }
216
+
217
+ function onCanvasTapSupporter(s: any) {
218
+ detailPixel.value = s
219
+ }
220
+
221
+ const hoverTip = ref<{ supporter: any; x: number; y: number } | null>(null)
222
+ function onHoverSupporter(payload: { supporter: any | null; clientX: number; clientY: number }) {
223
+ hoverTip.value = payload.supporter
224
+ ? { supporter: payload.supporter, x: payload.clientX, y: payload.clientY }
225
+ : null
226
+ }
227
+
228
+ function onStepperClose() {
229
+ showStepper.value = false
230
+ }
231
+
232
+ const showGuestCta = ref(false)
233
+
234
+ async function onStepperSuccess() {
235
+ showStepper.value = false
236
+ if (isPopup.value) {
237
+ notifyOpenerAndClose({ wallSlug })
238
+ return
239
+ }
240
+ if (!authStore.signedIn) {
241
+ showGuestCta.value = true
242
+ }
243
+ await loadWall()
244
+ }
245
+
246
+ function supporterDisplayName(s: any): string {
247
+ if (s.is_anonymous) return t('tv.dashboard.indie_wall.anonymous')
248
+ return s.title || s.user?.name || t('tv.dashboard.indie_wall.anonymous')
249
+ }
250
+
251
+ useHead({
252
+ title: computed(() => wall.value?.name ? `Mural · ${wall.value.name}` : 'Mural'),
253
+ })
254
+
255
+ onMounted(async () => {
256
+ await loadWall()
257
+ if (wall.value?.id) subscribeWall(wall.value.id)
258
+ // If returning from the payment gateway, wait for the WS confirmation.
259
+ if (route.query.payment === 'success') {
260
+ // Strip ?payment=success from the URL immediately so F5 does not restart the wait.
261
+ router.replace({ query: { ...route.query, payment: undefined } })
262
+ let pendingId: number | null = null
263
+ try {
264
+ const raw = sessionStorage.getItem('mgn-mural-pending-pixel')
265
+ const rawSlug = sessionStorage.getItem('mgn-mural-pending-wall')
266
+ if (raw && rawSlug === wallSlug) pendingId = parseInt(raw)
267
+ } catch { /* ignore */ }
268
+ if (pendingId) {
269
+ // If pixel is already in the loaded supporters list, it was confirmed — just clear state
270
+ const alreadyConfirmed = supporters.value.find((s: any) => Number(s.id) === Number(pendingId))
271
+ if (alreadyConfirmed) {
272
+ try {
273
+ sessionStorage.removeItem('mgn-mural-pending-pixel')
274
+ sessionStorage.removeItem('mgn-mural-pending-wall')
275
+ } catch { }
276
+ if (isPopup.value) notifyOpenerAndClose({ wallSlug, pixelId: pendingId, fromGateway: true })
277
+ } else {
278
+ startWaitingForPixel(pendingId)
279
+ // Also check immediately in case WS event fired before page loaded
280
+ try {
281
+ const res = await getWallSupporters(wallSlug, { per_page: 1000 })
282
+ const list = (res.data?.data ?? []).filter((s: any) => s.status !== false)
283
+ const found = list.find((s: any) => Number(s.id) === Number(pendingId))
284
+ if (found) {
285
+ supporters.value = list
286
+ finalizePending(pendingId)
287
+ }
288
+ } catch { /* WS will handle it */ }
289
+ }
290
+ } else if (isPopup.value) {
291
+ notifyOpenerAndClose({ wallSlug, fromGateway: true })
292
+ }
293
+ }
294
+ })
295
+
296
+ onUnmounted(() => {
297
+ if (wall.value?.id) unsubscribeWall(wall.value.id)
298
+ if (processingTimeoutId) clearTimeout(processingTimeoutId)
299
+ if (httpFallbackTimerId) clearInterval(httpFallbackTimerId)
300
+ })
301
+ </script>
302
+
303
+ <template>
304
+ <div v-if="loading" class="mural-loading">
305
+ <div class="mural-spinner" />
306
+ </div>
307
+
308
+ <div v-else-if="!wall" class="mural-not-found">
309
+ <p>{{ $t('tv.dashboard.indie_wall.not_found') }}</p>
310
+ </div>
311
+
312
+ <div v-else class="mural-page">
313
+ <!-- ── Hero ─────────────────────────────────────────────────────── -->
314
+ <section
315
+ class="hero"
316
+ :style="wall.cover_image_url ? `background-image:url(${wall.cover_image_url})` : ''"
317
+ >
318
+ <div class="hero-overlay">
319
+ <img v-if="wall.logo_url" :src="wall.logo_url" class="hero-logo" alt="" />
320
+ <h1 class="hero-title">{{ wall.name }}</h1>
321
+ <p v-if="wall.description" class="hero-desc">{{ wall.description }}</p>
322
+
323
+ <div class="hero-stats">
324
+ <div class="hero-stat">
325
+ <span class="hero-stat-value">{{ wall.currency }} {{ Number(wall.raised_amount || 0).toFixed(2) }}</span>
326
+ <span class="hero-stat-label">{{ $t('tv.dashboard.indie_wall.raised') }}</span>
327
+ </div>
328
+ <div class="hero-stat-divider" />
329
+ <div class="hero-stat">
330
+ <span class="hero-stat-value">{{ wall.supporters_count || supporters.length }}</span>
331
+ <span class="hero-stat-label">{{ $t('tv.dashboard.indie_wall.supporters') }}</span>
332
+ </div>
333
+ <div class="hero-stat-divider" />
334
+ <div class="hero-stat">
335
+ <span class="hero-stat-value">{{ progressPercent.toFixed(0) }}%</span>
336
+ <span class="hero-stat-label">{{ $t('tv.dashboard.indie_wall.filled') }}</span>
337
+ </div>
338
+ </div>
339
+
340
+ <div class="hero-progress-wrap">
341
+ <div class="hero-progress-bar" :style="{ width: progressPercent + '%' }" />
342
+ </div>
343
+
344
+ <button class="hero-cta" type="button" @click="openStepper({ startStep: 1 })">
345
+ {{ $t('tv.dashboard.indie_wall.mural_support_now') }}
346
+ </button>
347
+ </div>
348
+ </section>
349
+
350
+ <!-- ── About ─────────────────────────────────────────────────────── -->
351
+ <section v-if="wall.long_description" class="section about-section">
352
+ <h2 class="section-title">{{ $t('tv.dashboard.indie_wall.about_section') }}</h2>
353
+ <div class="about-body">
354
+ <p v-for="(para, i) in wall.long_description.split(/\n\n+/)" :key="i">{{ para }}</p>
355
+ </div>
356
+ </section>
357
+
358
+ <!-- ── Goals ─────────────────────────────────────────────────────── -->
359
+ <section v-if="goals.length" class="section">
360
+ <h2 class="section-title">{{ $t('tv.dashboard.indie_wall.goals') }}</h2>
361
+ <div class="goals-grid">
362
+ <article
363
+ v-for="goal in goals"
364
+ :key="goal.id"
365
+ class="goal-card"
366
+ :class="{ reached: goal.is_reached }"
367
+ >
368
+ <header class="goal-card-head">
369
+ <h3 class="goal-title">{{ goal.title }}</h3>
370
+ <div class="goal-head-right">
371
+ <span v-if="goal.is_reached" class="badge-reached">
372
+ {{ $t('tv.dashboard.indie_wall.reached') }}
373
+ </span>
374
+ <span v-else-if="goal.target_amount > 0" class="goal-amount">
375
+ {{ wall.currency }} {{ Number(goal.target_amount).toFixed(0) }}
376
+ </span>
377
+ <span v-else class="goal-amount">
378
+ {{ goal.target_pixels }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}
379
+ </span>
380
+ </div>
381
+ </header>
382
+
383
+ <ul class="goal-attrs">
384
+ <li v-if="goal.description">
385
+ <span class="goal-attr-label">{{ $t('tv.dashboard.indie_wall.goal_card_you_help') }}:</span>
386
+ <span class="goal-attr-val">{{ goal.description }}</span>
387
+ </li>
388
+ <li v-if="goal.reward_description">
389
+ <span class="goal-attr-label">{{ $t('tv.dashboard.indie_wall.goal_card_result') }}:</span>
390
+ <span class="goal-attr-val">{{ goal.reward_description }}</span>
391
+ </li>
392
+ </ul>
393
+
394
+ <div class="goal-progress-wrap">
395
+ <div class="goal-progress-bar">
396
+ <div class="goal-progress-fill" :style="{ width: (goal.progress_percent || 0) + '%' }" />
397
+ <span class="goal-progress-text">
398
+ <template v-if="goal.target_amount > 0">
399
+ {{ $t('tv.dashboard.indie_wall.goal_card_missing_amount', { currency: wall.currency, amount: Math.max(0, goal.target_amount - (goal.current_amount || 0)).toFixed(0) }) }}
400
+ </template>
401
+ <template v-else>
402
+ {{ $t('tv.dashboard.indie_wall.goal_card_missing', { count: Math.max(0, (goal.target_pixels || 0) - (goal.current_pixels || 0)) }) }}
403
+ </template>
404
+ </span>
405
+ </div>
406
+ </div>
407
+
408
+ <button
409
+ class="goal-cta"
410
+ type="button"
411
+ :disabled="goal.is_reached"
412
+ @click="!goal.is_reached && openStepper({ goalId: goal.id, startStep: 1 })"
413
+ >
414
+ {{ goal.is_reached
415
+ ? $t('tv.dashboard.indie_wall.reached')
416
+ : $t('tv.dashboard.indie_wall.goal_card_cta') }}
417
+ </button>
418
+ </article>
419
+ </div>
420
+ </section>
421
+
422
+ <!-- ── Packages / tiers ──────────────────────────────────────────── -->
423
+ <section v-if="tiersData.length" class="section">
424
+ <h2 class="section-title">{{ $t('tv.dashboard.indie_wall.tiers') }}</h2>
425
+ <div class="cards-row">
426
+ <button
427
+ v-for="tier in tiersData"
428
+ :key="tier.id"
429
+ class="tier-card"
430
+ type="button"
431
+ :disabled="tier.max_quantity && tier.sold_count >= tier.max_quantity"
432
+ @click="openStepper({ tierId: tier.id, startStep: 2 })"
433
+ >
434
+ <img v-if="tier.image_url" :src="tier.image_url" class="tier-img" alt="" />
435
+ <h3 class="tier-name">{{ tier.name }}</h3>
436
+ <div class="tier-price">{{ tier.currency || wall.currency }} {{ Number(tier.price).toFixed(2) }}</div>
437
+ <p class="tier-pixels">{{ tier.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</p>
438
+ <p v-if="tier.description" class="tier-desc">{{ tier.description }}</p>
439
+ <div v-if="tier.max_quantity" class="tier-stock">
440
+ {{ tier.sold_count }}/{{ tier.max_quantity }} {{ $t('tv.dashboard.indie_wall.sold') }}
441
+ </div>
442
+ </button>
443
+ </div>
444
+ </section>
445
+
446
+ <!-- ── Canvas ─────────────────────────────────────────────────────── -->
447
+ <section class="section canvas-section">
448
+ <div class="canvas-header">
449
+ <h2 class="section-title">{{ $t('tv.dashboard.indie_wall.mural_grid') }}</h2>
450
+ <span class="canvas-hint">{{ $t('tv.dashboard.indie_wall.mural_click_hint') }}</span>
451
+ </div>
452
+
453
+ <MuralCanvas
454
+ :wall="wall"
455
+ :supporters="supporters"
456
+ :selection="null"
457
+ :interactive="true"
458
+ :height="560"
459
+ @tap-empty="onCanvasTapEmpty"
460
+ @tap-supporter="onCanvasTapSupporter"
461
+ @hover-supporter="onHoverSupporter"
462
+ />
463
+
464
+ <div class="canvas-legend">
465
+ <span class="legend-dot occupied" /> {{ $t('tv.dashboard.indie_wall.mural_legend_taken') }}
466
+ <span class="legend-dot empty" /> {{ $t('tv.dashboard.indie_wall.mural_legend_free') }}
467
+ </div>
468
+ </section>
469
+
470
+ <!-- ── Supporters ─────────────────────────────────────────────────── -->
471
+ <section v-if="supporters.length" class="section">
472
+ <h2 class="section-title">{{ $t('tv.dashboard.indie_wall.recent_supporters') }}</h2>
473
+ <div class="supporters-grid">
474
+ <div v-for="s in supporters.slice(0, 24)" :key="s.id" class="supp-card" @click="detailPixel = s">
475
+ <div class="supp-pixel">
476
+ <img v-if="s.image_url" :src="s.image_url" class="supp-pixel-img" alt="" />
477
+ <div v-else class="supp-pixel-swatch" :style="{ background: s.background_color || '#272930' }" />
478
+ </div>
479
+ <div class="supp-body">
480
+ <div class="supp-identity">
481
+ <img v-if="s.user?.avatar_url || s.user?.profile_image" :src="s.user.avatar_url || s.user.profile_image" class="supp-avi" alt="" />
482
+ <div v-else class="supp-avi-init">{{ (supporterDisplayName(s)[0] || '?').toUpperCase() }}</div>
483
+ <div class="supp-user-info">
484
+ <span class="supp-name">{{ supporterDisplayName(s) }}</span>
485
+ <span v-if="!s.is_anonymous && !s.user && s.guest_email" class="supp-email">{{ s.guest_email }}</span>
486
+ </div>
487
+ <span class="supp-amount">{{ wall.currency }} {{ Number(s.amount_paid || 0).toFixed(2) }}</span>
488
+ </div>
489
+ <p v-if="s.message || s.supporter_message" class="supp-msg">"{{ s.message || s.supporter_message }}"</p>
490
+ </div>
491
+ </div>
492
+ </div>
493
+ </section>
494
+
495
+ <!-- ── Leaderboard ────────────────────────────────────────────────────── -->
496
+ <section v-if="supporters.length" class="section">
497
+ <IndieWallLeaderboard :supporters="supporters" />
498
+ </section>
499
+
500
+ </div>
501
+
502
+ <!-- ── Overlays ────────────────────────────────────────────────────────── -->
503
+ <Teleport to="body">
504
+ <Transition name="fade">
505
+ <div v-if="showProcessing" class="processing-overlay">
506
+ <div class="processing-box">
507
+ <div class="processing-spinner" />
508
+ <h3>{{ processingTimedOut
509
+ ? $t('tv.dashboard.indie_wall.processing_timeout_title')
510
+ : $t('tv.dashboard.indie_wall.processing_title') }}</h3>
511
+ <p>{{ processingTimedOut
512
+ ? $t('tv.dashboard.indie_wall.processing_timeout_desc')
513
+ : $t('tv.dashboard.indie_wall.processing_desc') }}</p>
514
+ <button v-if="processingTimedOut" type="button" class="processing-close" @click="showProcessing = false">
515
+ {{ $t('tv.dashboard.indie_wall.processing_close') }}
516
+ </button>
517
+ </div>
518
+ </div>
519
+ </Transition>
520
+
521
+ <Transition name="fade">
522
+ <div v-if="showGuestCta" class="guest-cta-overlay" @click.self="showGuestCta = false">
523
+ <div class="guest-cta-box">
524
+ <button class="guest-cta-dismiss" type="button" @click="showGuestCta = false">×</button>
525
+ <h3 class="guest-cta-title">{{ $t('tv.dashboard.indie_wall.cta_create_title') }}</h3>
526
+ <p class="guest-cta-desc">{{ $t('tv.dashboard.indie_wall.cta_create_desc') }}</p>
527
+ <a :href="`/${$i18n.locale}/register`" class="guest-cta-btn">
528
+ {{ $t('tv.dashboard.indie_wall.cta_create_btn') }}
529
+ </a>
530
+ <button class="guest-cta-skip" type="button" @click="showGuestCta = false">
531
+ {{ $t('tv.dashboard.indie_wall.cta_create_skip') }}
532
+ </button>
533
+ </div>
534
+ </div>
535
+ </Transition>
536
+
537
+ <Transition name="fade">
538
+ <SupportStepper
539
+ v-if="showStepper && wall"
540
+ :wall="wall"
541
+ :goals="goals"
542
+ :tiers="tiersData"
543
+ :supporters="supporters"
544
+ :mgc-balance="mgcBalance"
545
+ :start-step="stepperStartStep"
546
+ :initial-goal-id="stepperInitialGoalId"
547
+ :initial-tier-id="stepperInitialTierId"
548
+ :initial-selection="stepperInitialSelection"
549
+ @close="onStepperClose"
550
+ @support-success="onStepperSuccess"
551
+ />
552
+ </Transition>
553
+
554
+ <Transition name="fade">
555
+ <div
556
+ v-if="hoverTip"
557
+ class="hover-tip"
558
+ :style="{ left: (hoverTip.x + 16) + 'px', top: (hoverTip.y + 16) + 'px' }"
559
+ >
560
+ <div
561
+ class="hover-tip-image"
562
+ :style="{ background: hoverTip.supporter.background_color || '#272930' }"
563
+ >
564
+ <img v-if="hoverTip.supporter.image_url" :src="hoverTip.supporter.image_url" alt="" />
565
+ <span v-else class="hover-tip-initial">{{ (supporterDisplayName(hoverTip.supporter) || '?')[0].toUpperCase() }}</span>
566
+ </div>
567
+ <div class="hover-tip-body">
568
+ <p class="hover-tip-name">{{ supporterDisplayName(hoverTip.supporter) }}</p>
569
+ <p v-if="hoverTip.supporter.supporter_message" class="hover-tip-msg">"{{ hoverTip.supporter.supporter_message }}"</p>
570
+ <div class="hover-tip-badges">
571
+ <span class="hover-tip-amount">{{ wall.currency }} {{ Number(hoverTip.supporter.amount_paid || 0).toFixed(2) }}</span>
572
+ <span class="hover-tip-pixels">{{ hoverTip.supporter.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
573
+ </div>
574
+ </div>
575
+ </div>
576
+ </Transition>
577
+
578
+ <Transition name="fade">
579
+ <div v-if="detailPixel" class="detail-overlay" @click.self="detailPixel = null">
580
+ <div class="detail-box">
581
+ <button class="overlay-close" type="button" @click="detailPixel = null">×</button>
582
+ <div
583
+ class="detail-hero"
584
+ :style="{ background: detailPixel.background_color || '#272930' }"
585
+ >
586
+ <img v-if="detailPixel.image_url" :src="detailPixel.image_url" alt="" />
587
+ <span v-else class="detail-initial">{{ (supporterDisplayName(detailPixel) || '?')[0].toUpperCase() }}</span>
588
+ </div>
589
+ <div class="detail-body">
590
+ <p class="detail-name">{{ supporterDisplayName(detailPixel) }}</p>
591
+ <p v-if="detailPixel.supporter_message" class="detail-msg">"{{ detailPixel.supporter_message }}"</p>
592
+ <div class="detail-amount-row">
593
+ <span class="detail-amount-label">{{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
594
+ <span class="detail-amount-value">
595
+ <span v-if="wall">{{ wall.currency }}</span> {{ Number(detailPixel.amount_paid).toFixed(2) }}
596
+ </span>
597
+ </div>
598
+ <div class="detail-chips">
599
+ <span class="detail-chip">{{ detailPixel.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
600
+ <span class="detail-chip">{{ $t('tv.dashboard.indie_wall.mural_position') }} ({{ detailPixel.x }}, {{ detailPixel.y }})</span>
601
+ </div>
602
+ <a v-if="detailPixel.link" :href="detailPixel.link" target="_blank" rel="noopener" class="detail-link">
603
+ {{ detailPixel.link }}
604
+ </a>
605
+ </div>
606
+ </div>
607
+ </div>
608
+ </Transition>
609
+ </Teleport>
610
+ </template>
611
+
612
+ <style lang="scss" scoped>
613
+ .mural-loading {
614
+ display: flex; align-items: center; justify-content: center; min-height: 60vh;
615
+ }
616
+ .mural-spinner {
617
+ width: 32px; height: 32px;
618
+ border: 2px solid rgba(255,255,255,0.1);
619
+ border-top-color: var(--chip-text, #D297FF);
620
+ animation: spin 0.8s linear infinite;
621
+ }
622
+ @keyframes spin { to { transform: rotate(360deg); } }
623
+
624
+ .mural-not-found { text-align: center; padding: 80px 20px; color: var(--secondary-info-fg, #888); }
625
+
626
+ .mural-page {
627
+ max-width: 1200px;
628
+ margin: 0 auto;
629
+ padding-bottom: 100px;
630
+ }
631
+
632
+ // ── Hero ──────────────────────────────────────────────────────────────────
633
+ .hero {
634
+ position: relative; min-height: 360px;
635
+ background: #13161C; background-size: cover; background-position: center;
636
+ margin-bottom: 40px;
637
+ }
638
+ .hero-overlay {
639
+ background: linear-gradient(to top, rgba(13,15,19,0.97) 60%, rgba(13,15,19,0.55));
640
+ padding: 48px 32px 40px;
641
+ min-height: 360px;
642
+ display: flex; flex-direction: column; align-items: center; text-align: center;
643
+ justify-content: flex-end;
644
+ }
645
+ .hero-logo {
646
+ width: 80px; height: 80px; object-fit: cover;
647
+ margin-bottom: 16px; border: 2px solid rgba(255,255,255,0.1);
648
+ }
649
+ .hero-title { font-size: 2rem; font-weight: 800; color: var(--title-fg, #fff); margin: 0 0 12px; letter-spacing: -0.5px; }
650
+ .hero-desc { color: rgba(255,255,255,0.65); font-size: 1rem; max-width: 600px; line-height: 1.6; margin: 0 0 24px; }
651
+ .hero-stats { display: flex; align-items: center; gap: 24px; margin-bottom: 16px; }
652
+ .hero-stat { display: flex; flex-direction: column; align-items: center; gap: 2px; }
653
+ .hero-stat-value { font-size: 1.4rem; font-weight: 700; color: var(--chip-text, #D297FF); }
654
+ .hero-stat-label { font-size: 0.7rem; color: rgba(255,255,255,0.45); text-transform: uppercase; letter-spacing: 0.5px; }
655
+ .hero-stat-divider { width: 1px; height: 32px; background: rgba(255,255,255,0.1); }
656
+ .hero-progress-wrap { width: 100%; max-width: 480px; height: 4px; background: rgba(255,255,255,0.08); margin-bottom: 24px; overflow: hidden; }
657
+ .hero-progress-bar { height: 100%; background: var(--chip-text, #D297FF); transition: width 0.6s ease; min-width: 3px; }
658
+ .hero-cta {
659
+ background: var(--chip-text, #D297FF); color: #13161C;
660
+ border: none; padding: 14px 36px; font-size: 0.95rem;
661
+ font-weight: 700; cursor: pointer; letter-spacing: 0.3px;
662
+ transition: opacity 0.2s;
663
+ &:hover { opacity: 0.88; }
664
+ }
665
+
666
+ // ── Sections ──────────────────────────────────────────────────────────────
667
+ .section { padding: 0 16px; margin-bottom: 48px; }
668
+ .section-title { font-size: 1.1rem; font-weight: 700; color: var(--title-fg, #fff); margin: 0 0 16px; }
669
+
670
+ .about-section .about-body p {
671
+ color: var(--secondary-info-fg, #aaa);
672
+ font-size: 0.92rem; line-height: 1.6;
673
+ margin: 0 0 12px;
674
+ }
675
+
676
+ .cards-row {
677
+ display: flex; gap: 10px; overflow-x: auto; padding-bottom: 6px;
678
+ scroll-snap-type: x mandatory;
679
+ &::-webkit-scrollbar { height: 6px; }
680
+ &::-webkit-scrollbar-thumb { background: #272930; }
681
+ }
682
+
683
+ // ── Goal cards ────────────────────────────────────────────────────────────
684
+ .goals-grid {
685
+ display: grid;
686
+ grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
687
+ gap: 16px;
688
+ @media (max-width: 720px) { grid-template-columns: 1fr; }
689
+ }
690
+
691
+ .goal-card {
692
+ background: #191B20;
693
+ border: 1px solid #1e2028;
694
+ padding: 24px;
695
+ display: flex; flex-direction: column;
696
+ gap: 16px;
697
+
698
+ &.reached { border-color: rgba(76,175,80,0.25); }
699
+ }
700
+ .goal-card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
701
+ .goal-title {
702
+ margin: 0;
703
+ font-size: 1.05rem; font-weight: 700;
704
+ color: var(--title-fg, #fff); line-height: 1.3;
705
+ flex: 1; min-width: 0;
706
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
707
+ overflow: hidden; word-break: break-word;
708
+ }
709
+ .goal-head-right { flex-shrink: 0; display: flex; align-items: center; gap: 8px; }
710
+ .goal-amount { font-size: 0.9rem; font-weight: 700; color: var(--chip-text, #D297FF); white-space: nowrap; }
711
+ .badge-reached { background: rgba(76,175,80,0.12); color: #4caf50; font-size: 0.72rem; font-weight: 600; padding: 3px 8px; }
712
+
713
+ .goal-attrs {
714
+ list-style: none; padding: 0; margin: 0;
715
+ display: flex; flex-direction: column; gap: 8px;
716
+
717
+ li {
718
+ position: relative; padding-left: 14px;
719
+ font-size: 0.88rem; color: var(--title-fg, #fff); line-height: 1.45;
720
+
721
+ &::before {
722
+ content: '';
723
+ position: absolute;
724
+ top: 9px; left: 0;
725
+ width: 6px; height: 6px;
726
+ background: var(--chip-text, #D297FF);
727
+ }
728
+ }
729
+ }
730
+ .goal-attr-label { font-weight: 700; color: var(--title-fg, #fff); margin-right: 4px; }
731
+ .goal-attr-val { color: var(--secondary-info-fg, #aaa); }
732
+
733
+ .goal-progress-wrap { margin-top: 4px; }
734
+ .goal-progress-bar {
735
+ position: relative;
736
+ height: 30px;
737
+ background: #1e2028;
738
+ border: 1px solid #272930;
739
+ overflow: hidden;
740
+ }
741
+ .goal-progress-fill {
742
+ height: 100%;
743
+ background: var(--chip-text, #D297FF);
744
+ opacity: 0.5;
745
+ transition: width 0.4s ease;
746
+ min-width: 2px;
747
+ }
748
+ .goal-progress-text {
749
+ position: absolute; inset: 0;
750
+ display: flex; align-items: center; justify-content: center;
751
+ font-size: 0.78rem; color: var(--title-fg, #fff);
752
+ text-align: center; padding: 0 8px;
753
+ }
754
+
755
+ .goal-cta {
756
+ background: var(--chip-text, #D297FF);
757
+ color: #13161C;
758
+ border: none; padding: 12px 16px;
759
+ font-size: 0.88rem; font-weight: 600;
760
+ cursor: pointer; transition: opacity 0.15s;
761
+ width: 100%;
762
+
763
+ &:hover:not(:disabled) { opacity: 0.88; }
764
+ &:disabled { opacity: 0.5; cursor: not-allowed; background: #4caf50; color: #fff; }
765
+ }
766
+
767
+ // ── Tier cards ────────────────────────────────────────────────────────────
768
+ .tier-card {
769
+ flex: 0 0 220px; scroll-snap-align: start;
770
+ background: #191B20; border: 1px solid #1e2028;
771
+ padding: 20px; text-align: center;
772
+ cursor: pointer; color: inherit; font-family: inherit;
773
+ display: flex; flex-direction: column; gap: 6px;
774
+ transition: border-color 0.15s, background 0.15s, transform 0.15s;
775
+ position: relative;
776
+
777
+ &::after {
778
+ content: '→';
779
+ position: absolute;
780
+ right: 12px; bottom: 10px;
781
+ color: var(--chip-text, #D297FF);
782
+ opacity: 0; transition: opacity 0.15s, transform 0.15s;
783
+ font-size: 1.1rem;
784
+ }
785
+
786
+ &:hover:not(:disabled) {
787
+ border-color: var(--chip-text, #D297FF); background: #1e2028;
788
+ transform: translateY(-2px);
789
+ &::after { opacity: 1; transform: translateX(2px); }
790
+ }
791
+ &:disabled { opacity: 0.45; cursor: not-allowed; }
792
+ }
793
+ .tier-img { width: 100%; height: 100px; object-fit: cover; }
794
+ .tier-name {
795
+ font-size: 0.92rem; font-weight: 600; color: var(--title-fg, #fff); margin: 0;
796
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
797
+ overflow: hidden; word-break: break-word;
798
+ }
799
+ .tier-price { font-size: 1.15rem; font-weight: 700; color: var(--chip-text, #D297FF); }
800
+ .tier-pixels { color: var(--secondary-info-fg, #888); font-size: 0.75rem; margin: 0; }
801
+ .tier-desc { color: var(--secondary-info-fg, #888); font-size: 0.8rem; margin: 0; }
802
+ .tier-stock { color: var(--secondary-info-fg, #888); font-size: 0.7rem; }
803
+
804
+ // ── Canvas section ────────────────────────────────────────────────────────
805
+ .canvas-section { }
806
+ .canvas-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
807
+ .canvas-hint { color: var(--secondary-info-fg, #555); font-size: 0.72rem; }
808
+ .canvas-legend {
809
+ display: flex; align-items: center; gap: 16px; margin-top: 6px;
810
+ font-size: 0.72rem; color: var(--secondary-info-fg, #555);
811
+ }
812
+ .legend-dot {
813
+ display: inline-block; width: 10px; height: 10px; margin-right: 4px; flex-shrink: 0;
814
+ &.occupied { background: #6b15ac; }
815
+ &.empty { background: #1e2028; border: 1px solid #333; }
816
+ }
817
+
818
+ // ── Supporters ────────────────────────────────────────────────────────────
819
+ .supporters-grid {
820
+ display: grid;
821
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
822
+ gap: 8px;
823
+ }
824
+ .supp-card {
825
+ background: #191B20; border: 1px solid #1e2028;
826
+ overflow: hidden; cursor: pointer;
827
+ display: flex; flex-direction: column;
828
+ transition: border-color 0.15s;
829
+ &:hover { border-color: var(--chip-text, #D297FF); }
830
+ }
831
+ .supp-pixel {
832
+ width: 100%; aspect-ratio: 1; overflow: hidden; background: #0d0f13; flex-shrink: 0;
833
+ }
834
+ .supp-pixel-img {
835
+ width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated;
836
+ }
837
+ .supp-pixel-swatch { width: 100%; height: 100%; }
838
+ .supp-body { padding: 8px 10px; display: flex; flex-direction: column; gap: 4px; }
839
+ .supp-identity { display: flex; align-items: center; gap: 6px; }
840
+ .supp-avi {
841
+ width: 22px; height: 22px; flex-shrink: 0;
842
+ object-fit: cover; border: 1px solid #272930;
843
+ }
844
+ .supp-avi-init {
845
+ width: 22px; height: 22px; flex-shrink: 0;
846
+ background: #272930; color: var(--secondary-info-fg, #aaa);
847
+ font-size: 0.6rem; font-weight: 700;
848
+ display: flex; align-items: center; justify-content: center;
849
+ }
850
+ .supp-user-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
851
+ .supp-name {
852
+ color: var(--title-fg, #fff); font-size: 0.78rem; font-weight: 500;
853
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
854
+ }
855
+ .supp-email {
856
+ color: var(--secondary-info-fg, #666); font-size: 0.65rem;
857
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
858
+ }
859
+ .supp-amount {
860
+ color: var(--chip-text, #D297FF); font-size: 0.75rem; font-weight: 700;
861
+ white-space: nowrap; flex-shrink: 0; margin-left: auto;
862
+ }
863
+ .supp-msg {
864
+ color: var(--secondary-info-fg, #666); font-size: 0.68rem; font-style: italic;
865
+ line-height: 1.35; margin: 0;
866
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
867
+ overflow: hidden;
868
+ }
869
+
870
+ // ── Detail overlay ────────────────────────────────────────────────────────
871
+ .detail-overlay {
872
+ position: fixed; inset: 0; background: rgba(0,0,0,0.8);
873
+ display: flex; align-items: center; justify-content: center;
874
+ z-index: 300; padding: 16px;
875
+ }
876
+ .detail-box {
877
+ background: #13141A; border: 1px solid rgba(107,21,172,0.35);
878
+ width: 100%; max-width: 360px; position: relative; overflow: hidden;
879
+ box-shadow: 0 16px 48px rgba(0,0,0,0.8), 0 0 0 1px rgba(107,21,172,0.1);
880
+ }
881
+ .overlay-close {
882
+ position: absolute; top: 10px; right: 10px; z-index: 2;
883
+ background: rgba(0,0,0,0.5); border: none; color: #fff;
884
+ width: 28px; height: 28px;
885
+ display: flex; align-items: center; justify-content: center;
886
+ cursor: pointer; font-size: 1.1rem; line-height: 1; backdrop-filter: blur(4px);
887
+ &:hover { background: rgba(0,0,0,0.75); }
888
+ }
889
+ .detail-hero {
890
+ width: 100%; height: 180px; overflow: hidden;
891
+ display: flex; align-items: center; justify-content: center;
892
+ background: #0d0f13;
893
+ img { max-width: 100%; max-height: 100%; object-fit: contain; }
894
+ }
895
+ .detail-initial {
896
+ font-size: 4rem; font-weight: 700; color: rgba(255,255,255,0.5); line-height: 1;
897
+ }
898
+ .detail-body { padding: 20px; }
899
+ .detail-name {
900
+ font-size: 1.1rem; font-weight: 700; color: var(--title-fg, #fff); margin: 0 0 8px;
901
+ }
902
+ .detail-msg {
903
+ color: var(--secondary-info-fg, #aaa); font-size: 0.875rem; font-style: italic;
904
+ line-height: 1.5; margin: 0 0 16px;
905
+ }
906
+ .detail-amount-row {
907
+ display: flex; align-items: center; justify-content: space-between;
908
+ background: rgba(107,21,172,0.12); border: 1px solid rgba(107,21,172,0.25);
909
+ padding: 10px 14px; margin-bottom: 12px;
910
+ }
911
+ .detail-amount-label {
912
+ font-size: 12px; color: var(--secondary-info-fg, #aaa);
913
+ text-transform: uppercase; letter-spacing: 0.04em;
914
+ }
915
+ .detail-amount-value {
916
+ font-weight: 700; font-size: 1.1rem; color: var(--chip-text, #D297FF);
917
+ }
918
+ .detail-chips {
919
+ display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px;
920
+ }
921
+ .detail-chip {
922
+ font-size: 11px; color: var(--secondary-info-fg, #aaa);
923
+ background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
924
+ padding: 3px 8px;
925
+ }
926
+ .detail-link {
927
+ display: flex; align-items: center; gap: 6px;
928
+ color: var(--chip-text, #D297FF); font-size: 0.75rem; word-break: break-all;
929
+ text-decoration: none; margin-top: 4px;
930
+ &:hover { text-decoration: underline; }
931
+ }
932
+
933
+ .fade-enter-active, .fade-leave-active { transition: opacity 0.18s; }
934
+ .fade-enter-from, .fade-leave-to { opacity: 0; }
935
+
936
+ .processing-overlay {
937
+ position: fixed; inset: 0;
938
+ background: rgba(0,0,0,0.78);
939
+ display: flex; align-items: center; justify-content: center;
940
+ z-index: 350; padding: 16px;
941
+ }
942
+ .processing-box {
943
+ background: #191B20; border: 1px solid #272930;
944
+ padding: 32px 28px; max-width: 380px; width: 100%;
945
+ text-align: center;
946
+ display: flex; flex-direction: column; align-items: center; gap: 12px;
947
+ h3 { color: var(--title-fg, #fff); font-size: 1.05rem; margin: 0; font-weight: 700; }
948
+ p { color: var(--secondary-info-fg, #aaa); font-size: 0.85rem; margin: 0; line-height: 1.5; }
949
+ }
950
+ .processing-spinner {
951
+ width: 36px; height: 36px;
952
+ border: 3px solid rgba(255,255,255,0.1);
953
+ border-top-color: var(--chip-text, #D297FF);
954
+ animation: spin 0.8s linear infinite;
955
+ }
956
+ .processing-close {
957
+ margin-top: 6px;
958
+ background: var(--chip-text, #D297FF); color: #13161C;
959
+ border: none; padding: 8px 18px; font-weight: 600; font-size: 0.85rem;
960
+ cursor: pointer;
961
+ &:hover { opacity: 0.88; }
962
+ }
963
+
964
+ .guest-cta-overlay {
965
+ position: fixed; inset: 0;
966
+ background: rgba(0,0,0,0.72);
967
+ display: flex; align-items: center; justify-content: center;
968
+ z-index: 400; padding: 16px;
969
+ }
970
+ .guest-cta-box {
971
+ background: #191B20; border: 1px solid #272930;
972
+ padding: 32px 28px; width: 100%; max-width: 420px;
973
+ display: flex; flex-direction: column; align-items: center;
974
+ gap: 14px; text-align: center; position: relative;
975
+ }
976
+ .guest-cta-dismiss {
977
+ position: absolute; top: 10px; right: 10px;
978
+ background: #272930; border: none; color: var(--title-fg, #fff);
979
+ width: 28px; height: 28px; display: flex; align-items: center;
980
+ justify-content: center; cursor: pointer; font-size: 1.1rem;
981
+ }
982
+ .guest-cta-title {
983
+ margin: 0; font-size: 1.15rem; font-weight: 700;
984
+ color: var(--title-fg, #fff);
985
+ }
986
+ .guest-cta-desc {
987
+ margin: 0; font-size: 0.85rem;
988
+ color: var(--secondary-info-fg, #aaa); line-height: 1.5;
989
+ }
990
+ .guest-cta-btn {
991
+ display: inline-block;
992
+ background: var(--chip-text, #D297FF); color: #13161C;
993
+ font-weight: 700; font-size: 0.9rem; padding: 12px 24px;
994
+ text-decoration: none;
995
+ &:hover { opacity: 0.88; }
996
+ }
997
+ .guest-cta-skip {
998
+ background: none; border: none;
999
+ color: var(--secondary-info-fg, #888); font-size: 0.78rem;
1000
+ cursor: pointer; padding: 0;
1001
+ &:hover { color: var(--title-fg, #fff); }
1002
+ }
1003
+
1004
+ .hover-tip {
1005
+ position: fixed;
1006
+ z-index: 9999;
1007
+ pointer-events: none;
1008
+ background: #13141A;
1009
+ border: 1px solid rgba(107,21,172,0.35);
1010
+ width: 220px;
1011
+ overflow: hidden;
1012
+ box-shadow: 0 8px 28px rgba(0,0,0,0.7), 0 0 0 1px rgba(107,21,172,0.1);
1013
+ }
1014
+ .hover-tip-image {
1015
+ width: 100%; height: 110px; overflow: hidden;
1016
+ display: flex; align-items: center; justify-content: center;
1017
+ background: #0d0f13;
1018
+ img { max-width: 100%; max-height: 100%; object-fit: contain; }
1019
+ }
1020
+ .hover-tip-initial {
1021
+ font-size: 2.5rem; font-weight: 700; color: rgba(255,255,255,0.6); line-height: 1;
1022
+ }
1023
+ .hover-tip-body {
1024
+ padding: 10px 12px 12px;
1025
+ display: flex; flex-direction: column; gap: 4px;
1026
+ }
1027
+ .hover-tip-name {
1028
+ font-size: 13px; font-weight: 700;
1029
+ color: var(--title-fg, #fff); margin: 0;
1030
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1031
+ }
1032
+ .hover-tip-msg {
1033
+ font-size: 11px;
1034
+ color: var(--secondary-info-fg, #aaa); font-style: italic; margin: 0;
1035
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
1036
+ }
1037
+ .hover-tip-badges {
1038
+ display: flex; align-items: center; justify-content: space-between;
1039
+ margin-top: 6px; gap: 6px;
1040
+ }
1041
+ .hover-tip-amount {
1042
+ font-size: 13px; font-weight: 700; color: var(--chip-text, #D297FF);
1043
+ }
1044
+ .hover-tip-pixels {
1045
+ font-size: 11px;
1046
+ color: var(--secondary-info-fg, #aaa);
1047
+ background: rgba(255,255,255,0.06); padding: 2px 6px;
1048
+ }
1049
+
1050
+ @media (max-width: 640px) {
1051
+ .hero-title { font-size: 1.5rem; }
1052
+ .hero-stats { gap: 12px; }
1053
+ .hero-stat-value { font-size: 1.1rem; }
1054
+ .hero-stat-divider { display: none; }
1055
+ .canvas-hint { display: none; }
1056
+ .supporters-grid { grid-template-columns: repeat(2, 1fr); }
1057
+ }
1058
+ </style>