@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.
@@ -4,7 +4,7 @@ import SupportStepper from '../../components/indie-wall/SupportStepper.vue'
4
4
  import MuralCanvas from '../../components/indie-wall/MuralCanvas.vue'
5
5
  import IndieWallLeaderboard from '../../components/indie-wall/IndieWallLeaderboard.vue'
6
6
 
7
- const { t } = useI18n()
7
+ const { t, locale } = useI18n()
8
8
  const route = useRoute()
9
9
  const router = useRouter()
10
10
  const wallSlug = route.params.slug as string
@@ -142,6 +142,15 @@ const goals = ref<any[]>([])
142
142
  const supporters = ref<any[]>([])
143
143
  const loading = ref(true)
144
144
 
145
+ // SSR-safe fetch so bots/link-preview scrapers (which don't run JS) get the
146
+ // real wall data for the SEO tags below, without waiting for the client-side
147
+ // onMounted → loadWall() refresh (which still runs for goals/supporters/WS).
148
+ const { data: ssrWallData } = await useAsyncData(
149
+ `mural-wall-${wallSlug}`,
150
+ () => fetchPublicWall(wallSlug).then(r => r.data?.data || r.data).catch(() => null),
151
+ )
152
+ if (ssrWallData.value) wall.value = ssrWallData.value
153
+
145
154
  // ── Stepper state ──────────────────────────────────────────────────────────
146
155
  const showStepper = ref(false)
147
156
  const stepperStartStep = ref<1 | 2 | 3 | 4 | 5>(1)
@@ -163,6 +172,15 @@ const progressPercent = computed(() => {
163
172
  return total > 0 ? Math.min(100, ((wall.value.used_pixels || 0) / total) * 100) : 0
164
173
  })
165
174
 
175
+ // Locale-aware currency symbol + consistent decimals (was raw "BRL 500" string concat).
176
+ function formatMoney(amount: number, currency?: string | null) {
177
+ try {
178
+ return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD' }).format(amount)
179
+ } catch {
180
+ return `${currency || ''} ${Number(amount).toFixed(2)}`.trim()
181
+ }
182
+ }
183
+
166
184
  // ── Load ────────────────────────────────────────────────────────────────────
167
185
  async function loadWall() {
168
186
  loading.value = true
@@ -191,6 +209,13 @@ async function loadWall() {
191
209
  stepperStartStep.value = 1
192
210
  showStepper.value = true
193
211
  }
212
+
213
+ // Deep-link from a supporter's own share link (?pixel=123): open their
214
+ // pixel detail automatically so the shared link lands on their spot.
215
+ if (route.query.pixel) {
216
+ const shared = supporters.value.find((s: any) => String(s.id) === String(route.query.pixel))
217
+ if (shared) detailPixel.value = shared
218
+ }
194
219
  } catch (err) {
195
220
  console.error(err)
196
221
  } finally {
@@ -214,8 +239,19 @@ function onCanvasTapEmpty(coord: { x: number; y: number }) {
214
239
  })
215
240
  }
216
241
 
217
- function onCanvasTapSupporter(s: any) {
242
+ function openPixelDetail(s: any) {
218
243
  detailPixel.value = s
244
+ router.replace({ query: { ...route.query, pixel: s.id } })
245
+ }
246
+
247
+ function closePixelDetail() {
248
+ detailPixel.value = null
249
+ const { pixel: _omit, ...rest } = route.query
250
+ router.replace({ query: rest })
251
+ }
252
+
253
+ function onCanvasTapSupporter(s: any) {
254
+ openPixelDetail(s)
219
255
  }
220
256
 
221
257
  const hoverTip = ref<{ supporter: any; x: number; y: number } | null>(null)
@@ -248,10 +284,93 @@ function supporterDisplayName(s: any): string {
248
284
  return s.title || s.user?.name || t('tv.dashboard.indie_wall.anonymous')
249
285
  }
250
286
 
251
- useHead({
252
- title: computed(() => wall.value?.name ? `Mural · ${wall.value.name}` : 'Mural'),
287
+ const requestUrl = useRequestURL()
288
+
289
+ // When a supporter's own pixel is open (either they just clicked it, or a
290
+ // visitor followed that supporter's share link), the page's own title/
291
+ // description swap to their name+message so *their* share card, not just
292
+ // the wall's, reads correctly. Image stays the wall's own cover — pixel
293
+ // images are inline base64, not real URLs, so crawlers can't fetch them.
294
+ const seoTitle = computed(() => {
295
+ if (detailPixel.value && wall.value) {
296
+ return t('tv.dashboard.indie_wall.share_pixel_title', { name: supporterDisplayName(detailPixel.value), wall: wall.value.name })
297
+ }
298
+ return wall.value?.name ? `${wall.value.name} · Mundo Gamer Wall` : 'Mundo Gamer Wall'
299
+ })
300
+ const seoDescription = computed(() => {
301
+ if (detailPixel.value) {
302
+ return detailPixel.value.supporter_message
303
+ || (wall.value?.name ? t('tv.dashboard.indie_wall.share_pixel_default_desc', { wall: wall.value.name }) : '')
304
+ }
305
+ return wall.value?.description || wall.value?.long_description?.replace(/\n+/g, ' ').slice(0, 160) || ''
306
+ })
307
+ const seoImage = computed(() => wall.value?.cover_image_url || wall.value?.logo_url || '')
308
+
309
+ // ── Per-supporter share link ────────────────────────────────────────────────
310
+ const sharePixelUrl = computed(() => {
311
+ if (!detailPixel.value) return requestUrl.href
312
+ const base = `${requestUrl.origin}${route.path}`
313
+ return `${base}?pixel=${detailPixel.value.id}`
314
+ })
315
+ const shareText = computed(() => {
316
+ if (!detailPixel.value || !wall.value) return ''
317
+ return t('tv.dashboard.indie_wall.share_pixel_text', { name: supporterDisplayName(detailPixel.value), wall: wall.value.name })
318
+ })
319
+ const shareTwitterUrl = computed(() => `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText.value)}&url=${encodeURIComponent(sharePixelUrl.value)}`)
320
+ const shareWhatsappUrl = computed(() => `https://wa.me/?text=${encodeURIComponent(`${shareText.value} ${sharePixelUrl.value}`)}`)
321
+ const shareFacebookUrl = computed(() => `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(sharePixelUrl.value)}`)
322
+
323
+ const linkCopied = ref(false)
324
+ async function copyShareLink() {
325
+ try {
326
+ await navigator.clipboard.writeText(sharePixelUrl.value)
327
+ linkCopied.value = true
328
+ setTimeout(() => { linkCopied.value = false }, 2000)
329
+ } catch { /* clipboard unavailable */ }
330
+ }
331
+
332
+ // "Create your own wall" footer CTA — same host map as the backend's
333
+ // IndieWallController::resolverResponse, so it always points at whichever
334
+ // platform (TV/Agency) actually owns this wall rather than a hardcoded host.
335
+ const CREATE_WALL_HOST_MAP: Record<string, string> = {
336
+ MGTV: 'https://mundogamer.tv',
337
+ MGAG: 'https://mundogamer.agency',
338
+ }
339
+ const createWallUrl = computed(() => {
340
+ const shortName = wall.value?.origin_system?.short_name
341
+ const host = (shortName && CREATE_WALL_HOST_MAP[shortName]) || CREATE_WALL_HOST_MAP.MGTV
342
+ return `${host}/${locale.value}/dashboard/wall`
253
343
  })
254
344
 
345
+ // Raw useHead meta-array form — matches the exact mechanism the site-wide
346
+ // defaults in nuxt.config.ts use, and is proven to reliably override by
347
+ // name/property key in this app. useSeoMeta was tried first but its og:*/
348
+ // twitter:*/description overrides were silently dropped here (stray
349
+ // duplicate `unhead`/`@vueuse/head` deps in community-frontend/package.json
350
+ // likely cause a resolution conflict) — only `title` and `ogUrl` came
351
+ // through. Do not switch this back to useSeoMeta without first removing
352
+ // those stray deps and re-verifying against raw SSR HTML (curl), not just
353
+ // the browser, since hydration can mask a server-side-only failure.
354
+ useHead(() => ({
355
+ title: seoTitle.value,
356
+ link: [{ rel: 'canonical', href: requestUrl.href }],
357
+ meta: [
358
+ { name: 'description', content: seoDescription.value },
359
+ { property: 'og:title', content: seoTitle.value },
360
+ { property: 'og:description', content: seoDescription.value },
361
+ // Only override the image when the wall actually has one — an empty
362
+ // string would replace (not fall back to) the site default image.
363
+ ...(seoImage.value ? [{ property: 'og:image', content: seoImage.value }] : []),
364
+ { property: 'og:type', content: 'website' },
365
+ { property: 'og:url', content: requestUrl.href },
366
+ { property: 'og:site_name', content: 'Mundo Gamer Wall' },
367
+ { name: 'twitter:card', content: 'summary_large_image' },
368
+ { name: 'twitter:title', content: seoTitle.value },
369
+ { name: 'twitter:description', content: seoDescription.value },
370
+ ...(seoImage.value ? [{ name: 'twitter:image', content: seoImage.value }] : []),
371
+ ],
372
+ }))
373
+
255
374
  onMounted(async () => {
256
375
  await loadWall()
257
376
  if (wall.value?.id) subscribeWall(wall.value.id)
@@ -318,11 +437,15 @@ onUnmounted(() => {
318
437
  <div class="hero-overlay">
319
438
  <img v-if="wall.logo_url" :src="wall.logo_url" class="hero-logo" alt="" />
320
439
  <h1 class="hero-title">{{ wall.name }}</h1>
440
+ <div v-if="wall.owner_nickname || wall.owner_name" class="hero-owner">
441
+ <img v-if="wall.owner_avatar_url" :src="wall.owner_avatar_url" class="hero-owner-avatar" alt="" />
442
+ <span class="hero-owner-name">{{ $t('tv.dashboard.indie_wall.mural_by', { name: wall.owner_nickname || wall.owner_name }) }}</span>
443
+ </div>
321
444
  <p v-if="wall.description" class="hero-desc">{{ wall.description }}</p>
322
445
 
323
446
  <div class="hero-stats">
324
447
  <div class="hero-stat">
325
- <span class="hero-stat-value">{{ wall.currency }} {{ Number(wall.raised_amount || 0).toFixed(2) }}</span>
448
+ <span class="hero-stat-value">{{ formatMoney(Number(wall.raised_amount || 0), wall.currency) }}</span>
326
449
  <span class="hero-stat-label">{{ $t('tv.dashboard.indie_wall.raised') }}</span>
327
450
  </div>
328
451
  <div class="hero-stat-divider" />
@@ -372,7 +495,7 @@ onUnmounted(() => {
372
495
  {{ $t('tv.dashboard.indie_wall.reached') }}
373
496
  </span>
374
497
  <span v-else-if="goal.target_amount > 0" class="goal-amount">
375
- {{ wall.currency }} {{ Number(goal.target_amount).toFixed(0) }}
498
+ {{ formatMoney(Number(goal.target_amount), wall.currency) }}
376
499
  </span>
377
500
  <span v-else class="goal-amount">
378
501
  {{ goal.target_pixels }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}
@@ -396,7 +519,7 @@ onUnmounted(() => {
396
519
  <div class="goal-progress-fill" :style="{ width: (goal.progress_percent || 0) + '%' }" />
397
520
  <span class="goal-progress-text">
398
521
  <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) }) }}
522
+ {{ $t('tv.dashboard.indie_wall.goal_card_missing_amount', { amount: formatMoney(Math.max(0, goal.target_amount - (goal.current_amount || 0)), wall.currency) }) }}
400
523
  </template>
401
524
  <template v-else>
402
525
  {{ $t('tv.dashboard.indie_wall.goal_card_missing', { count: Math.max(0, (goal.target_pixels || 0) - (goal.current_pixels || 0)) }) }}
@@ -433,7 +556,7 @@ onUnmounted(() => {
433
556
  >
434
557
  <img v-if="tier.image_url" :src="tier.image_url" class="tier-img" alt="" />
435
558
  <h3 class="tier-name">{{ tier.name }}</h3>
436
- <div class="tier-price">{{ tier.currency || wall.currency }} {{ Number(tier.price).toFixed(2) }}</div>
559
+ <div class="tier-price">{{ formatMoney(Number(tier.price), tier.currency || wall.currency) }}</div>
437
560
  <p class="tier-pixels">{{ tier.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</p>
438
561
  <p v-if="tier.description" class="tier-desc">{{ tier.description }}</p>
439
562
  <div v-if="tier.max_quantity" class="tier-stock">
@@ -471,7 +594,7 @@ onUnmounted(() => {
471
594
  <section v-if="supporters.length" class="section">
472
595
  <h2 class="section-title">{{ $t('tv.dashboard.indie_wall.recent_supporters') }}</h2>
473
596
  <div class="supporters-grid">
474
- <div v-for="s in supporters.slice(0, 24)" :key="s.id" class="supp-card" @click="detailPixel = s">
597
+ <div v-for="s in supporters.slice(0, 24)" :key="s.id" class="supp-card" @click="openPixelDetail(s)">
475
598
  <div class="supp-pixel">
476
599
  <img v-if="s.image_url" :src="s.image_url" class="supp-pixel-img" alt="" />
477
600
  <div v-else class="supp-pixel-swatch" :style="{ background: s.background_color || '#272930' }" />
@@ -484,7 +607,7 @@ onUnmounted(() => {
484
607
  <span class="supp-name">{{ supporterDisplayName(s) }}</span>
485
608
  <span v-if="!s.is_anonymous && !s.user && s.guest_email" class="supp-email">{{ s.guest_email }}</span>
486
609
  </div>
487
- <span class="supp-amount">{{ wall.currency }} {{ Number(s.amount_paid || 0).toFixed(2) }}</span>
610
+ <span class="supp-amount">{{ formatMoney(Number(s.amount_paid || 0), wall.currency) }}</span>
488
611
  </div>
489
612
  <p v-if="s.message || s.supporter_message" class="supp-msg">"{{ s.message || s.supporter_message }}"</p>
490
613
  </div>
@@ -497,6 +620,16 @@ onUnmounted(() => {
497
620
  <IndieWallLeaderboard :supporters="supporters" />
498
621
  </section>
499
622
 
623
+ <!-- ── Footer credit ─────────────────────────────────────────────────── -->
624
+ <footer class="mural-footer">
625
+ <p class="mural-footer-text">
626
+ {{ $t('tv.dashboard.indie_wall.footer_made_with') }} <strong>Mundo Gamer Wall</strong>
627
+ </p>
628
+ <a :href="createWallUrl" target="_blank" rel="noopener" class="mural-footer-cta">
629
+ {{ $t('tv.dashboard.indie_wall.footer_create_cta') }}
630
+ </a>
631
+ </footer>
632
+
500
633
  </div>
501
634
 
502
635
  <!-- ── Overlays ────────────────────────────────────────────────────────── -->
@@ -568,7 +701,7 @@ onUnmounted(() => {
568
701
  <p class="hover-tip-name">{{ supporterDisplayName(hoverTip.supporter) }}</p>
569
702
  <p v-if="hoverTip.supporter.supporter_message" class="hover-tip-msg">"{{ hoverTip.supporter.supporter_message }}"</p>
570
703
  <div class="hover-tip-badges">
571
- <span class="hover-tip-amount">{{ wall.currency }} {{ Number(hoverTip.supporter.amount_paid || 0).toFixed(2) }}</span>
704
+ <span class="hover-tip-amount">{{ formatMoney(Number(hoverTip.supporter.amount_paid || 0), wall.currency) }}</span>
572
705
  <span class="hover-tip-pixels">{{ hoverTip.supporter.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
573
706
  </div>
574
707
  </div>
@@ -576,9 +709,9 @@ onUnmounted(() => {
576
709
  </Transition>
577
710
 
578
711
  <Transition name="fade">
579
- <div v-if="detailPixel" class="detail-overlay" @click.self="detailPixel = null">
712
+ <div v-if="detailPixel" class="detail-overlay" @click.self="closePixelDetail">
580
713
  <div class="detail-box">
581
- <button class="overlay-close" type="button" @click="detailPixel = null">×</button>
714
+ <button class="overlay-close" type="button" @click="closePixelDetail">×</button>
582
715
  <div
583
716
  class="detail-hero"
584
717
  :style="{ background: detailPixel.background_color || '#272930' }"
@@ -592,7 +725,7 @@ onUnmounted(() => {
592
725
  <div class="detail-amount-row">
593
726
  <span class="detail-amount-label">{{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
594
727
  <span class="detail-amount-value">
595
- <span v-if="wall">{{ wall.currency }}</span> {{ Number(detailPixel.amount_paid).toFixed(2) }}
728
+ {{ formatMoney(Number(detailPixel.amount_paid), wall?.currency) }}
596
729
  </span>
597
730
  </div>
598
731
  <div class="detail-chips">
@@ -602,6 +735,24 @@ onUnmounted(() => {
602
735
  <a v-if="detailPixel.link" :href="detailPixel.link" target="_blank" rel="noopener" class="detail-link">
603
736
  {{ detailPixel.link }}
604
737
  </a>
738
+
739
+ <!-- ── Share this pixel ─────────────────────────────────────── -->
740
+ <div class="detail-share">
741
+ <p class="detail-share-title">{{ $t('tv.dashboard.indie_wall.share_pixel_cta') }}</p>
742
+ <div class="detail-share-row">
743
+ <a :href="shareTwitterUrl" target="_blank" rel="noopener" class="detail-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_twitter')">𝕏</a>
744
+ <a :href="shareWhatsappUrl" target="_blank" rel="noopener" class="detail-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_whatsapp')">
745
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12.04 2C6.58 2 2.13 6.45 2.13 11.91c0 1.75.46 3.45 1.32 4.95L2.05 22l5.25-1.38a9.9 9.9 0 0 0 4.74 1.2h.01c5.46 0 9.9-4.45 9.9-9.91 0-2.65-1.03-5.14-2.9-7.01A9.82 9.82 0 0 0 12.04 2Z" opacity="0"/><path d="M9.2 6.8c-.2-.45-.42-.46-.62-.47h-.53c-.18 0-.48.07-.73.34-.25.27-.96.93-.96 2.28s.98 2.65 1.12 2.83c.14.18 1.9 3.03 4.7 4.14 2.32.92 2.8.74 3.3.7.5-.05 1.62-.66 1.85-1.3.23-.63.23-1.17.16-1.29-.07-.11-.25-.18-.53-.32-.27-.14-1.62-.8-1.87-.89-.25-.09-.43-.14-.62.14-.18.27-.7.89-.86 1.07-.16.18-.32.2-.59.07-.27-.14-1.15-.42-2.19-1.35-.81-.72-1.36-1.62-1.52-1.89-.16-.27-.02-.42.12-.55.13-.13.27-.33.4-.5.13-.16.18-.27.27-.45.09-.18.05-.34-.02-.48-.07-.14-.6-1.5-.85-2.05Z"/></svg>
746
+ </a>
747
+ <a :href="shareFacebookUrl" target="_blank" rel="noopener" class="detail-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_facebook')">f</a>
748
+ <button type="button" class="detail-share-btn" @click="copyShareLink" :aria-label="$t('tv.dashboard.indie_wall.share_copy_link')">
749
+ {{ linkCopied ? '✓' : '🔗' }}
750
+ </button>
751
+ </div>
752
+ <button type="button" class="detail-share-cta" @click="closePixelDetail(); openStepper({ startStep: 1 })">
753
+ {{ $t('tv.dashboard.indie_wall.share_join_cta') }}
754
+ </button>
755
+ </div>
605
756
  </div>
606
757
  </div>
607
758
  </div>
@@ -647,6 +798,9 @@ onUnmounted(() => {
647
798
  margin-bottom: 16px; border: 2px solid rgba(255,255,255,0.1);
648
799
  }
649
800
  .hero-title { font-size: 2rem; font-weight: 800; color: var(--title-fg, #fff); margin: 0 0 12px; letter-spacing: -0.5px; }
801
+ .hero-owner { display: flex; align-items: center; gap: 8px; margin: -6px 0 12px; }
802
+ .hero-owner-avatar { width: 24px; height: 24px; border-radius: 50%; object-fit: cover; }
803
+ .hero-owner-name { color: rgba(255,255,255,0.75); font-size: 0.9rem; font-weight: 500; }
650
804
  .hero-desc { color: rgba(255,255,255,0.65); font-size: 1rem; max-width: 600px; line-height: 1.6; margin: 0 0 24px; }
651
805
  .hero-stats { display: flex; align-items: center; gap: 24px; margin-bottom: 16px; }
652
806
  .hero-stat { display: flex; flex-direction: column; align-items: center; gap: 2px; }
@@ -930,6 +1084,32 @@ onUnmounted(() => {
930
1084
  &:hover { text-decoration: underline; }
931
1085
  }
932
1086
 
1087
+ .detail-share {
1088
+ margin-top: 16px; padding-top: 16px;
1089
+ border-top: 1px solid #272930;
1090
+ }
1091
+ .detail-share-title {
1092
+ margin: 0 0 8px; font-size: 0.78rem; color: var(--secondary-info-fg, #aaa);
1093
+ }
1094
+ .detail-share-row {
1095
+ display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
1096
+ }
1097
+ .detail-share-btn {
1098
+ width: 34px; height: 34px; flex-shrink: 0;
1099
+ display: flex; align-items: center; justify-content: center;
1100
+ background: rgba(255,255,255,0.06); border: 1px solid #272930;
1101
+ color: var(--title-fg, #fff); font-size: 0.9rem; font-weight: 700;
1102
+ cursor: pointer; text-decoration: none; transition: border-color 0.15s;
1103
+ &:hover { border-color: var(--chip-text, #D297FF); }
1104
+ }
1105
+ .detail-share-cta {
1106
+ width: 100%;
1107
+ background: var(--chip-text, #D297FF); color: #13161C;
1108
+ border: none; padding: 10px 14px; font-weight: 700; font-size: 0.85rem;
1109
+ cursor: pointer; transition: opacity 0.15s;
1110
+ &:hover { opacity: 0.88; }
1111
+ }
1112
+
933
1113
  .fade-enter-active, .fade-leave-active { transition: opacity 0.18s; }
934
1114
  .fade-enter-from, .fade-leave-to { opacity: 0; }
935
1115
 
@@ -1047,6 +1227,28 @@ onUnmounted(() => {
1047
1227
  background: rgba(255,255,255,0.06); padding: 2px 6px;
1048
1228
  }
1049
1229
 
1230
+ // ── Footer credit ─────────────────────────────────────────────────────────
1231
+ .mural-footer {
1232
+ display: flex; flex-direction: column; align-items: center; gap: 12px;
1233
+ padding: 48px 16px; margin-top: 8px;
1234
+ border-top: 1px solid #1e2028;
1235
+ text-align: center;
1236
+ }
1237
+ .mural-footer-text {
1238
+ font-size: 0.8rem; color: var(--secondary-info-fg, #888); margin: 0;
1239
+ strong { color: var(--title-fg, #fff); }
1240
+ }
1241
+ .mural-footer-cta {
1242
+ display: inline-block;
1243
+ padding: 8px 20px;
1244
+ border: 1px solid var(--chip-text, #D297FF);
1245
+ color: var(--chip-text, #D297FF);
1246
+ font-size: 0.8rem; font-weight: 600;
1247
+ text-decoration: none;
1248
+ transition: background 0.15s, color 0.15s;
1249
+ &:hover { background: var(--chip-text, #D297FF); color: #13161C; }
1250
+ }
1251
+
1050
1252
  @media (max-width: 640px) {
1051
1253
  .hero-title { font-size: 1.5rem; }
1052
1254
  .hero-stats { gap: 12px; }
@@ -86,6 +86,7 @@ const layout = computed(() => kit.value?.layout || 'classic');
86
86
  const allowDownload = computed(() => kit.value?.allow_asset_download !== false);
87
87
  const hasDownloadableAssets = computed(() => assets.value.length > 0);
88
88
  const canDownload = computed(() => allowDownload.value && hasDownloadableAssets.value);
89
+ const zipDownloadUrl = computed(() => `${apiBase}/public/press-kits/${slug}/download${previewToken ? `?preview_token=${previewToken}` : ''}`);
89
90
 
90
91
  const keyPoolsVisible = ref(false);
91
92
  function onPoolsReady(count: number) {
@@ -179,27 +180,37 @@ function track(type: 'click' | 'download' | 'contact') {
179
180
 
180
181
  const requestUrl = useRequestURL();
181
182
 
182
- const seoTitle = computed(() => kit.value ? `${kit.value.name} — Press Kit` : 'Press Kit');
183
+ const seoTitle = computed(() => kit.value ? `${kit.value.name} — Mundo Gamer Press Kit` : 'Mundo Gamer Press Kit');
183
184
  const seoDescription = computed(() => kit.value?.tagline || kit.value?.description?.replace(/<[^>]*>/g, '').slice(0, 160) || '');
184
185
  const seoImage = computed(() => kit.value?.hero_image_url || kit.value?.cover_image_url || kit.value?.logo_url || '');
185
186
 
187
+ // Raw useHead meta-array form, not useSeoMeta — see the matching comment in
188
+ // shared-ui/pages/mural/[slug].vue for why (useSeoMeta's og:*/twitter:*/
189
+ // description overrides get silently dropped in this app).
186
190
  useHead(() => ({
187
191
  title: seoTitle.value,
192
+ link: [{ rel: 'canonical', href: requestUrl.href }],
193
+ // Studio's own GA4 tag, opt-in per kit — free feature, no plan gate.
194
+ script: kit.value?.analytics_ga_id ? [
195
+ { src: `https://www.googletagmanager.com/gtag/js?id=${kit.value.analytics_ga_id}`, async: true },
196
+ { innerHTML: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${kit.value.analytics_ga_id}');` },
197
+ ] : [],
198
+ meta: [
199
+ { name: 'description', content: seoDescription.value },
200
+ { property: 'og:title', content: seoTitle.value },
201
+ { property: 'og:description', content: seoDescription.value },
202
+ // Only override the image when the kit actually has one — an empty
203
+ // string would replace (not fall back to) the site default image.
204
+ ...(seoImage.value ? [{ property: 'og:image', content: seoImage.value }] : []),
205
+ { property: 'og:type', content: 'article' },
206
+ { property: 'og:url', content: requestUrl.href },
207
+ { property: 'og:site_name', content: 'Mundo Gamer Press Kit' },
208
+ { name: 'twitter:card', content: 'summary_large_image' },
209
+ { name: 'twitter:title', content: seoTitle.value },
210
+ { name: 'twitter:description', content: seoDescription.value },
211
+ ...(seoImage.value ? [{ name: 'twitter:image', content: seoImage.value }] : []),
212
+ ],
188
213
  }));
189
-
190
- useSeoMeta({
191
- title: () => seoTitle.value,
192
- description: () => seoDescription.value,
193
- ogTitle: () => seoTitle.value,
194
- ogDescription: () => seoDescription.value,
195
- ogImage: () => seoImage.value,
196
- ogType: 'article',
197
- ogUrl: () => requestUrl.href,
198
- twitterCard: 'summary_large_image',
199
- twitterTitle: () => seoTitle.value,
200
- twitterDescription: () => seoDescription.value,
201
- twitterImage: () => seoImage.value,
202
- });
203
214
  </script>
204
215
 
205
216
  <template>
@@ -247,7 +258,7 @@ useSeoMeta({
247
258
  <p v-if="kit.developer" class="kit-kicker">{{ kit.developer }}</p>
248
259
  <div class="kit-actions">
249
260
  <template v-if="allowDownload">
250
- <a v-if="canDownload" class="kit-btn kit-btn-primary" @click="track('download')">{{ $t('kit.press.download_assets') }}</a>
261
+ <a v-if="canDownload" class="kit-btn kit-btn-primary" :href="zipDownloadUrl" @click="track('download')">{{ $t('kit.press.download_assets') }}</a>
251
262
  <span v-else class="kit-btn kit-btn-primary kit-btn-disabled" :title="$t('kit.press.no_assets')">{{ $t('kit.press.no_assets') }}</span>
252
263
  </template>
253
264
  <a v-if="kit.press_contact_email" class="kit-btn kit-btn-ghost" :href="`mailto:${kit.press_contact_email}`" @click="track('contact')">{{ $t('kit.press.request_key') }}</a>
@@ -274,7 +285,7 @@ useSeoMeta({
274
285
  <span class="kit-stickybar-name">{{ kit.name }}</span>
275
286
  <div class="kit-stickybar-actions">
276
287
  <template v-if="allowDownload">
277
- <a v-if="canDownload" class="kit-btn kit-btn-primary kit-btn-sm" @click="track('download')">{{ $t('kit.press.download_assets') }}</a>
288
+ <a v-if="canDownload" class="kit-btn kit-btn-primary kit-btn-sm" :href="zipDownloadUrl" @click="track('download')">{{ $t('kit.press.download_assets') }}</a>
278
289
  <span v-else class="kit-btn kit-btn-primary kit-btn-sm kit-btn-disabled" :title="$t('kit.press.no_assets')">{{ $t('kit.press.no_assets') }}</span>
279
290
  </template>
280
291
  <a v-if="kit.press_contact_email" class="kit-btn kit-btn-ghost kit-btn-sm" :href="`mailto:${kit.press_contact_email}`" @click="track('contact')">{{ $t('kit.press.request_key') }}</a>
@@ -450,7 +461,7 @@ useSeoMeta({
450
461
  </section>
451
462
 
452
463
  <footer class="kit-footer">
453
- <p class="kit-footer__text">{{ $t('kit.press.made_with') }} <strong>Mundo Gamer Agency</strong></p>
464
+ <p v-if="!kit.is_premium" class="kit-footer__text">{{ $t('kit.press.made_with') }} <strong>Mundo Gamer Agency</strong></p>
454
465
  <a :href="kit.create_kit_url || `${agencyBase}/presskit`" target="_blank" rel="noopener" class="kit-footer__cta">{{ $t('kit.press.create_yours') }}</a>
455
466
  </footer>
456
467
  </template>
Binary file
@@ -297,6 +297,8 @@ export const fetchCampaignBySlug = async (slug: string) => {
297
297
  name: k.name,
298
298
  slug: k.slug,
299
299
  cover: k.cover_src,
300
+ cover_focal_x: k.cover_focal_x ?? 0.5,
301
+ cover_focal_y: k.cover_focal_y ?? 0.5,
300
302
  game_cover: k.game?.url_cover_src,
301
303
  game_slug: k.game?.slug,
302
304
  time_to_expire: k.time_to_expire,
@@ -307,6 +309,8 @@ export const fetchCampaignBySlug = async (slug: string) => {
307
309
  email: k.company?.email,
308
310
  twitter: k.company?.twitter,
309
311
  logo: k.company?.thumbnail_url,
312
+ pr_contact_name: k.supplier_pr?.name ?? null,
313
+ pr_contact_email: k.supplier_pr?.email ?? null,
310
314
  status: k.status,
311
315
  quantity: k.quantity,
312
316
  available_count: k.available_count ?? 0,