@mundogamernetwork/shared-ui 1.16.16 → 1.16.19

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.
@@ -45,6 +45,11 @@ const wallUrl = computed(() => {
45
45
  return localePath(`/${wall.value.url_prefix || 'mural'}/${wall.value.slug}`)
46
46
  })
47
47
 
48
+ const detailPixelPageUrl = computed(() => {
49
+ if (!wall.value || !detailPixel.value) return '#'
50
+ return localePath(`/${wall.value.url_prefix || 'mural'}/${wall.value.slug}/pixel/${detailPixel.value.id}`)
51
+ })
52
+
48
53
  function onTapEmpty(coord: { x: number; y: number }) {
49
54
  stepperInitialSelection.value = { x: coord.x, y: coord.y, w: 1, h: 1 }
50
55
  stepperStartStep.value = 1
@@ -279,6 +284,9 @@ onUnmounted(() => {
279
284
  <p v-if="detailPixel.link">
280
285
  <a :href="detailPixel.link" target="_blank" rel="noopener" class="mkw-detail-link">{{ detailPixel.link }}</a>
281
286
  </p>
287
+ <NuxtLink :to="detailPixelPageUrl" class="mkw-detail-share-link" @click="detailPixel = null">
288
+ {{ $t('tv.dashboard.indie_wall.share_view_page') }} →
289
+ </NuxtLink>
282
290
  </div>
283
291
  </div>
284
292
  </Transition>
@@ -387,6 +395,13 @@ onUnmounted(() => {
387
395
  color: var(--kit-accent, #D297FF); font-size: 0.75rem; word-break: break-all; text-decoration: none;
388
396
  &:hover { text-decoration: underline; }
389
397
  }
398
+ .mkw-detail-share-link {
399
+ display: block; margin-top: 10px; text-align: center;
400
+ border: 1px solid var(--kit-accent, #D297FF); color: var(--kit-accent, #D297FF);
401
+ font-size: 0.75rem; font-weight: 600; padding: 7px 10px; text-decoration: none;
402
+ transition: background 0.15s, color 0.15s;
403
+ &:hover { background: var(--kit-accent, #D297FF); color: var(--kit-accent-ink, #13161C); }
404
+ }
390
405
 
391
406
  .mkw-fade-enter-active, .mkw-fade-leave-active { transition: opacity 0.12s; }
392
407
  .mkw-fade-enter-from, .mkw-fade-leave-to { opacity: 0; }
@@ -21,7 +21,7 @@ const props = defineProps<{
21
21
 
22
22
  const emit = defineEmits<{
23
23
  (e: 'close'): void
24
- (e: 'support-success'): void
24
+ (e: 'support-success', payload: { pixelId?: number | string }): void
25
25
  }>()
26
26
 
27
27
  const { t } = useI18n()
@@ -182,7 +182,10 @@ async function confirm() {
182
182
  errorMsg.value = t('tv.dashboard.indie_wall.error_gateway')
183
183
  return
184
184
  }
185
- emit('support-success')
185
+ // `pixel_id` is the field name used on the payer_action/redirect path
186
+ // above; the immediate-success path (no gateway redirect needed) returns
187
+ // the full created pixel record instead, so fall back to its `id`.
188
+ emit('support-success', { pixelId: data?.pixel_id ?? data?.id })
186
189
  emit('close')
187
190
  } catch (err: any) {
188
191
  const apiError = err?.response?.data?.error
@@ -46,6 +46,7 @@ const initialized = computed(() => {
46
46
  :width="props.size"
47
47
  :height="props.size"
48
48
  class="mg-avatar-img"
49
+ alt=""
49
50
  />
50
51
  <template #fallback>
51
52
  <div
@@ -81,5 +82,32 @@ const initialized = computed(() => {
81
82
 
82
83
  .mg-avatar-img {
83
84
  display: block;
85
+ // Host apps wrap this in a fixed-size clickable link/button and expect
86
+ // the whole box to be clickable — the toggle handler lives on that
87
+ // wrapper, not here. Every host's own scoped `pointer-events: none` rule
88
+ // for this image (written against its OWN scope id) silently never
89
+ // matched: Vue's scoped-CSS attribute only reaches a child component's
90
+ // *root* node, and this <img> sits behind a v-if/v-else-if/v-else
91
+ // fragment here — one of several possible roots, not "the" root — so it
92
+ // never received the host's scope attribute. Click bubbling from the
93
+ // image up to the wrapper's handler still works regardless, but the
94
+ // image was still eating the browser's native hover/cursor/drag
95
+ // affordances (e.g. showing a "save image" cursor, or letting the
96
+ // browser start an image-drag mid-click) that a host expects its own
97
+ // wrapper to own. Setting it here — the actual owning component — means
98
+ // every host gets this for free instead of each one needing its own
99
+ // (broken) copy of the same rule.
100
+ pointer-events: none;
101
+ // `:width`/`:height` above render as HTML attributes, sized against the
102
+ // intended box — but a host's wrapper can end up 1-2px smaller once its
103
+ // own border/padding is applied (border-box sizing shrinks the content
104
+ // box the wrapper's `width: 100%` resolves against, while this image's
105
+ // pixel attributes don't know about that and keep its originally
106
+ // requested size), so the image quietly overflowed its own clickable
107
+ // wrapper on every side. Filling the wrapper via CSS instead keeps the
108
+ // image exactly inside whatever box the host actually gives it.
109
+ width: 100%;
110
+ height: 100%;
111
+ object-fit: cover;
84
112
  }
85
113
  </style>
@@ -529,6 +529,11 @@ const handleToggleOnline = () => {
529
529
  // Was a hardcoded blue on every host — see the .no-avatar-wrapper
530
530
  // comment above for why this fallback chain replaces it.
531
531
  border: 1px solid var(--highlight-color, var(--header-active-fg, var(--active)));
532
+ // MGN is angular by brand decision — explicit like .avatar-img/
533
+ // .no-avatar-wrapper above, since some hosts' own global stylesheet
534
+ // (e.g. a Bootstrap import) sets a default radius on <a>/<button>
535
+ // that this class would otherwise silently inherit.
536
+ border-radius: 0;
532
537
  font-weight: 400;
533
538
  font-size: 0.75rem;
534
539
  color: var(--active);
@@ -5,13 +5,23 @@
5
5
  <p class="sidebar-menu__intro">{{ $t('sidebar.platform_switcher_intro') }}</p>
6
6
  <ul class="menus">
7
7
  <li v-for="system in networkSystems" :key="system.id">
8
- <a class="sidebar__logo" :href="system.domain" target="_blank" rel="noopener noreferrer" :aria-label="ariaLabelFor(system)">
8
+ <component
9
+ :is="system.comingSoon ? 'span' : 'a'"
10
+ class="sidebar__logo"
11
+ :class="{ 'sidebar__logo--disabled': system.comingSoon }"
12
+ :href="system.comingSoon ? undefined : system.domain"
13
+ :target="system.comingSoon ? undefined : '_blank'"
14
+ :rel="system.comingSoon ? undefined : 'noopener noreferrer'"
15
+ :aria-disabled="system.comingSoon || undefined"
16
+ :aria-label="ariaLabelFor(system)"
17
+ >
9
18
  <img :src="colorMode.value === 'light' ? system.logo_light : system.logo_dark" :alt="system.name" />
10
19
  <span class="sidebar__tooltip" role="tooltip">
11
20
  <strong>{{ system.name }}</strong>
12
- <span v-if="descriptionFor(system.slug)">{{ descriptionFor(system.slug) }}</span>
21
+ <span v-if="system.comingSoon">{{ $t('sidebar.coming_soon') }}</span>
22
+ <span v-else-if="descriptionFor(system.slug)">{{ descriptionFor(system.slug) }}</span>
13
23
  </span>
14
- </a>
24
+ </component>
15
25
  </li>
16
26
  </ul>
17
27
  </div>
@@ -56,8 +66,15 @@ interface NetworkSystem {
56
66
  logo_light: string;
57
67
  status: number;
58
68
  slug: string;
69
+ comingSoon?: boolean;
59
70
  }
60
71
 
72
+ // mundogamer.shop was lost/hijacked (now parked/serving unrelated content)
73
+ // but mg_network_systems still carries it as an active prod_url — the API
74
+ // can't be trusted for this one entry until that's fixed at the source, so
75
+ // force it non-clickable here instead of linking out to it.
76
+ const HIJACKED_SLUGS = new Set(['shop']);
77
+
61
78
  // Fallback data - previous static logos
62
79
  const fallbackSystems: NetworkSystem[] = [
63
80
  { id: 1, name: 'Mundo Gamer Academy', domain: 'https://mundogamer.academy', logo_dark: '/imgs/dark/academy.svg', logo_light: '/imgs/light/academy.svg', status: 1, slug: 'academy' },
@@ -67,7 +84,7 @@ const fallbackSystems: NetworkSystem[] = [
67
84
  { id: 5, name: 'Mundo Gamer Community', domain: 'https://mundogamer.community', logo_dark: '/imgs/dark/community.svg', logo_light: '/imgs/light/community.svg', status: 1, slug: 'community' },
68
85
  { id: 6, name: 'Mundo Gamer Jobs', domain: 'https://mundogamerjobs.com', logo_dark: '/imgs/dark/jobs.svg', logo_light: '/imgs/light/jobs.svg', status: 1, slug: 'jobs' },
69
86
  { id: 7, name: 'Mundo Gamer Network', domain: 'https://mundogamer.network', logo_dark: '/imgs/dark/network.svg', logo_light: '/imgs/light/network.svg', status: 1, slug: 'network' },
70
- { id: 8, name: 'Mundo Gamer Shop', domain: 'https://mundogamer.shop', logo_dark: '/imgs/dark/shop.svg', logo_light: '/imgs/light/shop.svg', status: 1, slug: 'shop' },
87
+ { id: 8, name: 'Mundo Gamer Shop', domain: 'https://mundogamer.shop', logo_dark: '/imgs/dark/shop.svg', logo_light: '/imgs/light/shop.svg', status: 1, slug: 'shop', comingSoon: true },
71
88
  { id: 9, name: 'Mundo Gamer Token', domain: 'https://mundogamer.token', logo_dark: '/imgs/dark/token.svg', logo_light: '/imgs/light/token.svg', status: 1, slug: 'token' },
72
89
  { id: 10, name: 'Mundo Gamer TV', domain: 'https://mundogamer.tv', logo_dark: '/imgs/dark/tv.svg', logo_light: '/imgs/light/tv.svg', status: 1, slug: 'tv' },
73
90
  ];
@@ -104,7 +121,8 @@ async function fetchNetworkSystems() {
104
121
  logo_dark: slug ? `/imgs/dark/${slug}.svg` : (system.url_reduced_image_src || ''),
105
122
  logo_light: slug ? `/imgs/light/${slug}.svg` : (system.url_reduced_image_src || ''),
106
123
  status: system.status ? 1 : 0,
107
- slug
124
+ slug,
125
+ comingSoon: HIJACKED_SLUGS.has(slug)
108
126
  };
109
127
  });
110
128
  } catch (error) {
@@ -124,6 +142,7 @@ function descriptionFor(slug: string): string {
124
142
  }
125
143
 
126
144
  function ariaLabelFor(system: NetworkSystem): string {
145
+ if (system.comingSoon) return `${system.name} — ${t('sidebar.coming_soon')}`;
127
146
  const description = descriptionFor(system.slug);
128
147
  return description ? `${system.name} — ${description}` : system.name;
129
148
  }
@@ -218,6 +237,11 @@ onMounted(() => {
218
237
  &__logo {
219
238
  position: relative;
220
239
  display: inline-flex;
240
+
241
+ &--disabled {
242
+ cursor: default;
243
+ opacity: 0.4;
244
+ }
221
245
  }
222
246
 
223
247
  &__tooltip {
package/locales/de.json CHANGED
@@ -165,6 +165,7 @@
165
165
  "follow_campaign": "Benachrichtige mich bei Eröffnung",
166
166
  "following": "Du wirst benachrichtigt",
167
167
  "status_closed": "Closed",
168
+ "status_closed_action_pending": "Geschlossen — eine Aktion ist noch nötig",
168
169
  "status_ending": "Ending soon",
169
170
  "status_exhausted": "Keys exhausted",
170
171
  "days": "days",
@@ -310,6 +311,7 @@
310
311
  "indie_wall": {
311
312
  "about_section": "Über die Wall",
312
313
  "anonymous": "Anonym",
314
+ "back_to_wall": "Zurück zur Wall",
313
315
  "cancel": "Abbrechen",
314
316
  "confirm_support": "Unterstützung bestätigen",
315
317
  "cta_create_btn": "Create my account",
@@ -374,14 +376,25 @@
374
376
  "reached": "Erreicht",
375
377
  "recent_supporters": "Recent supporters",
376
378
  "share_copy_link": "Copy link",
379
+ "share_download_image": "Bild herunterladen",
380
+ "share_generate_failed": "Das Video konnte nicht erstellt werden. Bitte versuche es erneut.",
381
+ "share_generate_video": "Enthüllungsvideo erstellen",
382
+ "share_generating": "Dein Video wird erstellt… das kann einen Moment dauern.",
377
383
  "share_join_cta": "Diese Wall ebenfalls unterstützen",
384
+ "share_link_copied": "Link kopiert!",
378
385
  "share_pixel_cta": "Diese Unterstützung teilen",
379
386
  "share_pixel_default_desc": "See who is supporting the {wall} wall.",
380
387
  "share_pixel_text": "{name} supported the {wall} wall. Join in and support too.",
381
388
  "share_pixel_title": "{name} supported {wall}",
389
+ "share_prompt_cta": "Mein Pixel teilen",
390
+ "share_prompt_desc": "Teile dein Pixel mit deinen Freunden und Followern.",
391
+ "share_prompt_skip": "Vielleicht später",
392
+ "share_prompt_title": "Deine Unterstützung ist live!",
382
393
  "share_via_facebook": "Share on Facebook",
383
394
  "share_via_twitter": "Share on X",
384
395
  "share_via_whatsapp": "Share on WhatsApp",
396
+ "share_video_ready": "Dein Enthüllungsvideo ist fertig!",
397
+ "share_view_page": "Freigabeseite ansehen",
385
398
  "sold": "sold",
386
399
  "spots_unit": "Pixel",
387
400
  "step_back": "Back",
package/locales/en.json CHANGED
@@ -165,6 +165,7 @@
165
165
  "follow_campaign": "Notify me when it opens",
166
166
  "following": "You'll be notified",
167
167
  "status_closed": "Closed",
168
+ "status_closed_action_pending": "Closed — action needed",
168
169
  "status_ending": "Ending soon",
169
170
  "status_exhausted": "Keys exhausted",
170
171
  "days": "days",
@@ -310,6 +311,7 @@
310
311
  "indie_wall": {
311
312
  "about_section": "About the wall",
312
313
  "anonymous": "Anonymous",
314
+ "back_to_wall": "Back to the wall",
313
315
  "cancel": "Cancel",
314
316
  "confirm_support": "Confirm support",
315
317
  "cta_create_btn": "Create my account",
@@ -374,14 +376,25 @@
374
376
  "reached": "Reached",
375
377
  "recent_supporters": "Recent supporters",
376
378
  "share_copy_link": "Copy link",
379
+ "share_download_image": "Download image",
380
+ "share_generate_failed": "Could not generate the video. Please try again.",
381
+ "share_generate_video": "Generate reveal video",
382
+ "share_generating": "Generating your video… this can take a bit.",
377
383
  "share_join_cta": "Support this wall too",
384
+ "share_link_copied": "Link copied!",
378
385
  "share_pixel_cta": "Share this support",
379
386
  "share_pixel_default_desc": "See who is supporting the {wall} wall.",
380
387
  "share_pixel_text": "{name} supported the {wall} wall. Join in and support too.",
381
388
  "share_pixel_title": "{name} supported {wall}",
389
+ "share_prompt_cta": "Share your pixel",
390
+ "share_prompt_desc": "Share your pixel with your friends and followers.",
391
+ "share_prompt_skip": "Maybe later",
392
+ "share_prompt_title": "Your support is live!",
382
393
  "share_via_facebook": "Share on Facebook",
383
394
  "share_via_twitter": "Share on X",
384
395
  "share_via_whatsapp": "Share on WhatsApp",
396
+ "share_video_ready": "Your reveal video is ready!",
397
+ "share_view_page": "View share page",
385
398
  "sold": "sold",
386
399
  "spots_unit": "pixels",
387
400
  "step_back": "Back",
package/locales/es.json CHANGED
@@ -165,6 +165,7 @@
165
165
  "follow_campaign": "Avísame cuando abra",
166
166
  "following": "Te avisaremos",
167
167
  "status_closed": "Cerrada",
168
+ "status_closed_action_pending": "Cerrada — tienes una acción pendiente",
168
169
  "status_ending": "Terminando pronto",
169
170
  "status_exhausted": "Agotada",
170
171
  "days": "días",
@@ -310,6 +311,7 @@
310
311
  "indie_wall": {
311
312
  "about_section": "Sobre este mural",
312
313
  "anonymous": "Anónimo",
314
+ "back_to_wall": "Volver al mural",
313
315
  "cancel": "Cancelar",
314
316
  "confirm_support": "Confirmar y Pagar",
315
317
  "cta_create_btn": "Crear Cuenta",
@@ -374,14 +376,25 @@
374
376
  "reached": "Alcanzada",
375
377
  "recent_supporters": "Seguidores Recientes",
376
378
  "share_copy_link": "Copiar enlace",
379
+ "share_download_image": "Descargar imagen",
380
+ "share_generate_failed": "No se pudo generar el video. Inténtalo de nuevo.",
381
+ "share_generate_video": "Generar video de revelación",
382
+ "share_generating": "Generando tu video… esto puede tardar un momento.",
377
383
  "share_join_cta": "Apoya este mural también",
384
+ "share_link_copied": "¡Enlace copiado!",
378
385
  "share_pixel_cta": "Comparte tu apoyo",
379
386
  "share_pixel_default_desc": "¡Únete para apoyar {wall}!",
380
387
  "share_pixel_text": "¡{name} está apoyando {wall}! Échale un vistazo:",
381
388
  "share_pixel_title": "{name} apoya {wall} · Mundo Gamer Wall",
389
+ "share_prompt_cta": "Compartir mi píxel",
390
+ "share_prompt_desc": "Comparte tu píxel con tus amigos y seguidores.",
391
+ "share_prompt_skip": "Quizás más tarde",
392
+ "share_prompt_title": "¡Tu apoyo ya está publicado!",
382
393
  "share_via_facebook": "Compartir en Facebook",
383
394
  "share_via_twitter": "Compartir en X",
384
395
  "share_via_whatsapp": "Compartir en WhatsApp",
396
+ "share_video_ready": "¡Tu video de revelación está listo!",
397
+ "share_view_page": "Ver página para compartir",
385
398
  "sold": "vendidos",
386
399
  "spots_unit": "píxeles",
387
400
  "step_back": "Volver",
@@ -165,6 +165,7 @@
165
165
  "follow_campaign": "Me avise quando abrir",
166
166
  "following": "Você será avisado",
167
167
  "status_closed": "Closed",
168
+ "status_closed_action_pending": "Encerrada — falta uma ação sua",
168
169
  "status_ending": "Ending soon",
169
170
  "status_exhausted": "Keys exhausted",
170
171
  "days": "days",
@@ -310,6 +311,7 @@
310
311
  "indie_wall": {
311
312
  "about_section": "Sobre o mural",
312
313
  "anonymous": "Anônimo",
314
+ "back_to_wall": "Voltar ao mural",
313
315
  "cancel": "Cancelar",
314
316
  "confirm_support": "Confirmar apoio",
315
317
  "cta_create_btn": "Criar minha conta",
@@ -374,14 +376,25 @@
374
376
  "reached": "Concluída",
375
377
  "recent_supporters": "Apoiadores recentes",
376
378
  "share_copy_link": "Copiar link",
379
+ "share_download_image": "Baixar imagem",
380
+ "share_generate_failed": "Não foi possível gerar o vídeo. Tente novamente.",
381
+ "share_generate_video": "Gerar vídeo de revelação",
382
+ "share_generating": "Gerando seu vídeo… isso pode levar um instante.",
377
383
  "share_join_cta": "Apoiar este mural também",
384
+ "share_link_copied": "Link copiado!",
378
385
  "share_pixel_cta": "Compartilhe este apoio",
379
386
  "share_pixel_default_desc": "Veja quem está apoiando o mural {wall}.",
380
387
  "share_pixel_text": "{name} apoiou o mural {wall}. Venha apoiar também.",
381
388
  "share_pixel_title": "{name} apoiou {wall}",
389
+ "share_prompt_cta": "Compartilhar meu pixel",
390
+ "share_prompt_desc": "Compartilhe seu pixel com seus amigos e seguidores.",
391
+ "share_prompt_skip": "Talvez depois",
392
+ "share_prompt_title": "Seu apoio está no ar!",
382
393
  "share_via_facebook": "Compartilhar no Facebook",
383
394
  "share_via_twitter": "Compartilhar no X",
384
395
  "share_via_whatsapp": "Compartilhar no WhatsApp",
396
+ "share_video_ready": "Seu vídeo de revelação está pronto!",
397
+ "share_view_page": "Ver página de compartilhamento",
385
398
  "sold": "vendidos",
386
399
  "spots_unit": "pixels",
387
400
  "step_back": "Voltar",
package/locales/ro.json CHANGED
@@ -165,6 +165,7 @@
165
165
  "follow_campaign": "Anunță-mă când se deschide",
166
166
  "following": "Vei fi anunțat",
167
167
  "status_closed": "Closed",
168
+ "status_closed_action_pending": "Închisă — mai ai o acțiune de făcut",
168
169
  "status_ending": "Ending soon",
169
170
  "status_exhausted": "Keys exhausted",
170
171
  "days": "days",
@@ -310,6 +311,7 @@
310
311
  "indie_wall": {
311
312
  "about_section": "Despre mural",
312
313
  "anonymous": "Anonim",
314
+ "back_to_wall": "Înapoi la mural",
313
315
  "cancel": "Anulează",
314
316
  "confirm_support": "Confirmă susținerea",
315
317
  "cta_create_btn": "Create my account",
@@ -374,14 +376,25 @@
374
376
  "reached": "Atins",
375
377
  "recent_supporters": "Recent supporters",
376
378
  "share_copy_link": "Copy link",
379
+ "share_download_image": "Descarcă imaginea",
380
+ "share_generate_failed": "Videoclipul nu a putut fi generat. Încearcă din nou.",
381
+ "share_generate_video": "Generează videoclipul de dezvăluire",
382
+ "share_generating": "Se generează videoclipul tău… poate dura puțin.",
377
383
  "share_join_cta": "Susține și tu acest mural",
384
+ "share_link_copied": "Link copiat!",
378
385
  "share_pixel_cta": "Distribuie această susținere",
379
386
  "share_pixel_default_desc": "See who is supporting the {wall} wall.",
380
387
  "share_pixel_text": "{name} supported the {wall} wall. Join in and support too.",
381
388
  "share_pixel_title": "{name} supported {wall}",
389
+ "share_prompt_cta": "Distribuie pixelul meu",
390
+ "share_prompt_desc": "Distribuie pixelul tău prietenilor și urmăritorilor tăi.",
391
+ "share_prompt_skip": "Poate mai târziu",
392
+ "share_prompt_title": "Susținerea ta este live!",
382
393
  "share_via_facebook": "Share on Facebook",
383
394
  "share_via_twitter": "Share on X",
384
395
  "share_via_whatsapp": "Share on WhatsApp",
396
+ "share_video_ready": "Videoclipul tău de dezvăluire este gata!",
397
+ "share_view_page": "Vezi pagina de distribuire",
385
398
  "sold": "sold",
386
399
  "spots_unit": "pixeli",
387
400
  "step_back": "Back",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.16.16",
3
+ "version": "1.16.19",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -0,0 +1 @@
1
+ {"type":"edit","file":"/Users/guilhermecortez/Documents/GitHub/shared-ui/pages/key-campaigns/[slug].vue","timestamp":1788277610717,"sessionId":null}
@@ -356,6 +356,18 @@ const effectiveKeyStatus = computed(() => {
356
356
  const effectiveRequestId = computed(() => userRequestId.value ?? campaign.value?.request_id ?? null)
357
357
  const effectiveKeyItems = computed(() => userKeyItemId.value ?? campaign.value?.request_item_id ?? null)
358
358
 
359
+ // A creator who already holds an approved-not-revealed (3) or waiting-material
360
+ // (4) request still has something to do here even after the campaign itself
361
+ // closes to new requests — reveal/send-materials submission is never actually
362
+ // gated by the campaign's own end date (see key-materials.vue / backend). The
363
+ // top status badge below used to just say "Closed" regardless, which read as
364
+ // "too late" to a returning creator and buried the still-working action card
365
+ // underneath it.
366
+ const hasPendingObligation = computed(() => {
367
+ const status = effectiveKeyStatus.value
368
+ return status === 3 || status === 4
369
+ })
370
+
359
371
  // ------------------------------------------------------------------
360
372
  // Restriction badges
361
373
  // ------------------------------------------------------------------
@@ -866,7 +878,9 @@ onMounted(async () => {
866
878
  campaignNotYetOpen
867
879
  ? $t("keys.campaigns.campaign.status_upcoming")
868
880
  : isCampaignExpired
869
- ? $t("keys.campaigns.campaign.status_closed")
881
+ ? (hasPendingObligation
882
+ ? $t("keys.campaigns.campaign.status_closed_action_pending")
883
+ : $t("keys.campaigns.campaign.status_closed"))
870
884
  : (campaign?.available_count ?? 0) === 0
871
885
  ? $t("keys.campaigns.campaign.status_exhausted")
872
886
  : hasNoDateLimit
@@ -244,7 +244,7 @@ const seoDescription = computed(() => streamer.value?.bio || '');
244
244
  const seoImage = computed(() => streamer.value?.avatar || '');
245
245
 
246
246
  // Raw useHead meta-array form, not useSeoMeta — see the matching comment in
247
- // shared-ui/pages/mural/[slug].vue for why (useSeoMeta's og:*/twitter:*/
247
+ // shared-ui/pages/mural/[slug]/index.vue for why (useSeoMeta's og:*/twitter:*/
248
248
  // description overrides were silently dropped in this app).
249
249
  useHead(() => ({
250
250
  title: seoTitle.value,
@@ -1,14 +1,15 @@
1
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'
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
6
 
7
7
  const { t, locale } = useI18n()
8
8
  const route = useRoute()
9
9
  const router = useRouter()
10
10
  const wallSlug = route.params.slug as string
11
11
  const authStore = useAuthStore()
12
+ const localePath = useLocalePath()
12
13
 
13
14
  const preX = route.query.x !== undefined ? parseInt(route.query.x as string) : null
14
15
  const preY = route.query.y !== undefined ? parseInt(route.query.y as string) : null
@@ -83,6 +84,7 @@ function finalizePending(pixelId: number | string) {
83
84
  getWallSupporters(wallSlug, { per_page: 1000 }).then(res => {
84
85
  supporters.value = (res.data?.data ?? []).filter((s: any) => s.status !== false)
85
86
  }).catch(() => { /* keep current state if refresh fails */ })
87
+ openSharePrompt(pixelId)
86
88
  }
87
89
  }
88
90
 
@@ -267,7 +269,25 @@ function onStepperClose() {
267
269
 
268
270
  const showGuestCta = ref(false)
269
271
 
270
- async function onStepperSuccess() {
272
+ const showSharePrompt = ref(false)
273
+ const sharePromptPixelId = ref<number | string | null>(null)
274
+
275
+ function openSharePrompt(pixelId: number | string | null | undefined) {
276
+ if (!pixelId) return
277
+ sharePromptPixelId.value = pixelId
278
+ showSharePrompt.value = true
279
+ }
280
+
281
+ function closeSharePrompt() {
282
+ showSharePrompt.value = false
283
+ }
284
+
285
+ const sharePromptUrl = computed(() => {
286
+ if (!sharePromptPixelId.value) return ''
287
+ return localePath(`/mural/${wallSlug}/pixel/${sharePromptPixelId.value}`)
288
+ })
289
+
290
+ async function onStepperSuccess(payload: { pixelId?: number | string } = {}) {
271
291
  showStepper.value = false
272
292
  if (isPopup.value) {
273
293
  notifyOpenerAndClose({ wallSlug })
@@ -275,6 +295,8 @@ async function onStepperSuccess() {
275
295
  }
276
296
  if (!authStore.signedIn) {
277
297
  showGuestCta.value = true
298
+ } else {
299
+ openSharePrompt(payload?.pixelId)
278
300
  }
279
301
  await loadWall()
280
302
  }
@@ -327,6 +349,12 @@ const sharePixelUrl = computed(() => {
327
349
  const base = `${requestUrl.origin}${route.path}`
328
350
  return `${base}?pixel=${detailPixel.value.id}`
329
351
  })
352
+ // Link to the pixel's own dedicated, SEO-indexable share page (separate from
353
+ // sharePixelUrl above, which is the ?pixel= deep link into this same page).
354
+ const pixelPageUrl = computed(() => {
355
+ if (!detailPixel.value) return ''
356
+ return localePath(`/mural/${wallSlug}/pixel/${detailPixel.value.id}`)
357
+ })
330
358
  const shareText = computed(() => {
331
359
  if (!detailPixel.value || !wall.value) return ''
332
360
  return t('tv.dashboard.indie_wall.share_pixel_text', { name: supporterDisplayName(detailPixel.value), wall: wall.value.name })
@@ -682,6 +710,22 @@ onUnmounted(() => {
682
710
  </div>
683
711
  </Transition>
684
712
 
713
+ <Transition name="fade">
714
+ <div v-if="showSharePrompt" class="guest-cta-overlay" @click.self="closeSharePrompt">
715
+ <div class="guest-cta-box">
716
+ <button class="guest-cta-dismiss" type="button" @click="closeSharePrompt">×</button>
717
+ <h3 class="guest-cta-title">{{ $t('tv.dashboard.indie_wall.share_prompt_title') }}</h3>
718
+ <p class="guest-cta-desc">{{ $t('tv.dashboard.indie_wall.share_prompt_desc') }}</p>
719
+ <NuxtLink :to="sharePromptUrl" class="guest-cta-btn" @click="closeSharePrompt">
720
+ {{ $t('tv.dashboard.indie_wall.share_prompt_cta') }}
721
+ </NuxtLink>
722
+ <button class="guest-cta-skip" type="button" @click="closeSharePrompt">
723
+ {{ $t('tv.dashboard.indie_wall.share_prompt_skip') }}
724
+ </button>
725
+ </div>
726
+ </div>
727
+ </Transition>
728
+
685
729
  <Transition name="fade">
686
730
  <SupportStepper
687
731
  v-if="showStepper && wall"
@@ -755,6 +799,9 @@ onUnmounted(() => {
755
799
  <!-- ── Share this pixel ─────────────────────────────────────── -->
756
800
  <div class="detail-share">
757
801
  <p class="detail-share-title">{{ $t('tv.dashboard.indie_wall.share_pixel_cta') }}</p>
802
+ <NuxtLink :to="pixelPageUrl" class="detail-share-page-link" @click="closePixelDetail">
803
+ {{ $t('tv.dashboard.indie_wall.share_view_page') }} →
804
+ </NuxtLink>
758
805
  <div class="detail-share-row">
759
806
  <a :href="shareTwitterUrl" target="_blank" rel="noopener" class="detail-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_twitter')">𝕏</a>
760
807
  <a :href="shareWhatsappUrl" target="_blank" rel="noopener" class="detail-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_whatsapp')">
@@ -1111,6 +1158,16 @@ onUnmounted(() => {
1111
1158
  .detail-share-title {
1112
1159
  margin: 0 0 8px; font-size: 0.78rem; color: var(--secondary-info-fg, #aaa);
1113
1160
  }
1161
+ .detail-share-page-link {
1162
+ display: block;
1163
+ text-align: center;
1164
+ border: 1px solid var(--chip-text, #D297FF);
1165
+ color: var(--chip-text, #D297FF);
1166
+ font-size: 0.78rem; font-weight: 600;
1167
+ padding: 8px 10px; margin-bottom: 10px;
1168
+ text-decoration: none; transition: background 0.15s, color 0.15s;
1169
+ &:hover { background: var(--chip-text, #D297FF); color: #13161C; }
1170
+ }
1114
1171
  .detail-share-row {
1115
1172
  display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
1116
1173
  }
@@ -0,0 +1,456 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ fetchPublicWall,
4
+ getWallPixel,
5
+ requestWallPixelShareRender,
6
+ pollWallPixelShareRender,
7
+ } from '../../../../services/indieWallService'
8
+
9
+ const { t, locale } = useI18n()
10
+ const route = useRoute()
11
+ const localePath = useLocalePath()
12
+ const wallSlug = route.params.slug as string
13
+ const pixelId = route.params.pixelId as string
14
+
15
+ // SSR-safe fetch: the wall must be resolved first (its numeric id, not the
16
+ // slug, is what the pixel/share-render endpoints key off — see
17
+ // IndieWallController::showPixel / IndieWallPixelShareController, which
18
+ // compare indie_wall_id directly and do not resolve a slug like show()/
19
+ // supporters() do).
20
+ const { data, error } = await useAsyncData(`mural-pixel-${wallSlug}-${pixelId}`, async () => {
21
+ const wallRes = await fetchPublicWall(wallSlug)
22
+ const wall = wallRes.data?.data || wallRes.data
23
+ if (!wall?.id) return null
24
+ const pixelRes = await getWallPixel(wall.id, pixelId)
25
+ const pixel = pixelRes.data?.data || pixelRes.data
26
+ if (!pixel?.id) return null
27
+ return { wall, pixel }
28
+ })
29
+
30
+ if (error.value || !data.value?.wall || !data.value?.pixel) {
31
+ throw createError({ statusCode: 404, statusMessage: 'Pixel not found' })
32
+ }
33
+
34
+ const wall = ref<any>(data.value.wall)
35
+ const pixel = ref<any>(data.value.pixel)
36
+
37
+ // ── Display helpers (duplicated from pages/mural/[slug]/index.vue rather
38
+ // than shared, matching this repo's existing pattern in
39
+ // components/indie-wall/MediaKitWallBlock.vue, which duplicates the same
40
+ // small pure functions instead of extracting a composable). ─────────────────
41
+ function supporterDisplayName(s: any): string {
42
+ if (!s) return t('tv.dashboard.indie_wall.anonymous')
43
+ if (s.is_anonymous) return t('tv.dashboard.indie_wall.anonymous')
44
+ return s.user?.nickname
45
+ || s.user?.username
46
+ || s.user?.full_name
47
+ || s.user?.name
48
+ || s.guest_name
49
+ || t('tv.dashboard.indie_wall.anonymous')
50
+ }
51
+
52
+ function formatMoney(amount: number, currency?: string | null) {
53
+ try {
54
+ return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD' }).format(amount)
55
+ } catch {
56
+ return `${currency || ''} ${Number(amount).toFixed(2)}`.trim()
57
+ }
58
+ }
59
+
60
+ const displayName = computed(() => supporterDisplayName(pixel.value))
61
+
62
+ // ── SEO ───────────────────────────────────────────────────────────────────
63
+ const requestUrl = useRequestURL()
64
+
65
+ const seoTitle = computed(() => t('tv.dashboard.indie_wall.share_pixel_title', {
66
+ name: displayName.value,
67
+ wall: wall.value?.name,
68
+ }))
69
+ const seoDescription = computed(() =>
70
+ pixel.value?.supporter_message
71
+ || t('tv.dashboard.indie_wall.share_pixel_default_desc', { wall: wall.value?.name }),
72
+ )
73
+ // share_image_url (auto-generated branded reveal) takes priority, then the
74
+ // pixel's own uploaded image, then the wall's cover/logo as a last resort.
75
+ const seoImage = computed(() =>
76
+ pixel.value?.share_image_url
77
+ || pixel.value?.image_url
78
+ || wall.value?.cover_image_url
79
+ || wall.value?.logo_url
80
+ || '',
81
+ )
82
+
83
+ // Raw useHead meta-array form — same proven pattern as
84
+ // pages/mural/[slug]/index.vue (see the comment there: useSeoMeta silently
85
+ // dropped og:*/twitter:*/description overrides in this app).
86
+ useHead(() => ({
87
+ title: seoTitle.value,
88
+ link: [{ rel: 'canonical', href: requestUrl.href }],
89
+ meta: [
90
+ { name: 'description', content: seoDescription.value },
91
+ { property: 'og:title', content: seoTitle.value },
92
+ { property: 'og:description', content: seoDescription.value },
93
+ ...(seoImage.value ? [{ property: 'og:image', content: seoImage.value }] : []),
94
+ { property: 'og:type', content: 'website' },
95
+ { property: 'og:url', content: requestUrl.href },
96
+ { property: 'og:site_name', content: 'Mundo Gamer Wall' },
97
+ { name: 'twitter:card', content: 'summary_large_image' },
98
+ { name: 'twitter:title', content: seoTitle.value },
99
+ { name: 'twitter:description', content: seoDescription.value },
100
+ ...(seoImage.value ? [{ name: 'twitter:image', content: seoImage.value }] : []),
101
+ ],
102
+ }))
103
+
104
+ // ── Share: copy / native share the page link ────────────────────────────────
105
+ const shareText = computed(() => t('tv.dashboard.indie_wall.share_pixel_text', {
106
+ name: displayName.value,
107
+ wall: wall.value?.name,
108
+ }))
109
+ const shareTwitterUrl = computed(() => `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText.value)}&url=${encodeURIComponent(requestUrl.href)}`)
110
+ const shareWhatsappUrl = computed(() => `https://wa.me/?text=${encodeURIComponent(`${shareText.value} ${requestUrl.href}`)}`)
111
+ const shareFacebookUrl = computed(() => `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(requestUrl.href)}`)
112
+
113
+ const linkCopied = ref(false)
114
+ async function shareOrCopyLink() {
115
+ if (typeof navigator !== 'undefined' && (navigator as any).share) {
116
+ try {
117
+ await (navigator as any).share({ title: seoTitle.value, text: shareText.value, url: requestUrl.href })
118
+ return
119
+ } catch {
120
+ // user cancelled the native sheet, or it's unsupported for this
121
+ // context — fall back to clipboard copy below.
122
+ }
123
+ }
124
+ try {
125
+ await navigator.clipboard.writeText(requestUrl.href)
126
+ linkCopied.value = true
127
+ setTimeout(() => { linkCopied.value = false }, 2000)
128
+ } catch { /* clipboard unavailable */ }
129
+ }
130
+
131
+ // ── Download the pixel's own image ──────────────────────────────────────────
132
+ const pixelDownloadImage = computed(() => pixel.value?.share_image_url || pixel.value?.image_url || '')
133
+
134
+ // ── Generate-on-demand branded reveal video ─────────────────────────────────
135
+ const renderState = ref<'idle' | 'rendering' | 'done' | 'failed'>('idle')
136
+ const renderAssetUrl = ref<string | null>(null)
137
+ let renderPollTimer: ReturnType<typeof setInterval> | null = null
138
+
139
+ function stopRenderPolling() {
140
+ if (renderPollTimer) {
141
+ clearInterval(renderPollTimer)
142
+ renderPollTimer = null
143
+ }
144
+ }
145
+
146
+ function pollRenderJob(jobId: string) {
147
+ stopRenderPolling()
148
+ renderPollTimer = setInterval(async () => {
149
+ try {
150
+ const res = await pollWallPixelShareRender(wall.value.id, pixel.value.id, jobId)
151
+ const status = res.data?.status
152
+ if (status === 'done') {
153
+ renderAssetUrl.value = res.data?.assetUrl || null
154
+ renderState.value = renderAssetUrl.value ? 'done' : 'failed'
155
+ stopRenderPolling()
156
+ } else if (status === 'failed') {
157
+ renderState.value = 'failed'
158
+ stopRenderPolling()
159
+ }
160
+ // any other status (e.g. 'rendering') keeps the loop going
161
+ } catch {
162
+ // transient network hiccup on a single poll — keep trying
163
+ }
164
+ }, 3000)
165
+ }
166
+
167
+ async function generateReveal() {
168
+ if (!wall.value?.id || !pixel.value?.id || renderState.value === 'rendering') return
169
+ renderState.value = 'rendering'
170
+ renderAssetUrl.value = null
171
+ try {
172
+ const res = await requestWallPixelShareRender(wall.value.id, pixel.value.id, 'video')
173
+ const jobId = res.data?.jobId
174
+ if (!jobId) throw new Error('missing_job_id')
175
+ pollRenderJob(jobId)
176
+ } catch {
177
+ renderState.value = 'failed'
178
+ }
179
+ }
180
+
181
+ const renderAssetIsVideo = computed(() =>
182
+ !!renderAssetUrl.value && /\.(mp4|webm|mov)(\?|$)/i.test(renderAssetUrl.value),
183
+ )
184
+
185
+ onUnmounted(() => stopRenderPolling())
186
+
187
+ // ── Back to wall ─────────────────────────────────────────────────────────────
188
+ const backToWallUrl = computed(() => localePath(`/mural/${wallSlug}`))
189
+ </script>
190
+
191
+ <template>
192
+ <div class="pixel-page">
193
+ <NuxtLink :to="backToWallUrl" class="back-link">
194
+ ← {{ $t('tv.dashboard.indie_wall.back_to_wall') }}
195
+ </NuxtLink>
196
+
197
+ <div class="pixel-card">
198
+ <div class="pixel-hero" :style="{ background: pixel.background_color || '#272930' }">
199
+ <img v-if="pixel.share_image_url || pixel.image_url" :src="pixel.share_image_url || pixel.image_url" alt="" />
200
+ <span v-else class="pixel-initial">{{ (displayName || '?')[0].toUpperCase() }}</span>
201
+ </div>
202
+
203
+ <div class="pixel-body">
204
+ <p class="pixel-name">{{ displayName }}</p>
205
+ <p v-if="pixel.title && pixel.title !== displayName" class="pixel-title">{{ pixel.title }}</p>
206
+ <p v-if="pixel.supporter_message" class="pixel-msg">"{{ pixel.supporter_message }}"</p>
207
+
208
+ <div class="pixel-amount-row">
209
+ <span class="pixel-amount-label">{{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
210
+ <span class="pixel-amount-value">{{ formatMoney(Number(pixel.amount_paid), wall?.currency) }}</span>
211
+ </div>
212
+
213
+ <div class="pixel-chips">
214
+ <span class="pixel-chip">{{ pixel.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
215
+ <span class="pixel-chip">{{ $t('tv.dashboard.indie_wall.mural_position') }} ({{ pixel.x }}, {{ pixel.y }})</span>
216
+ </div>
217
+
218
+ <a v-if="pixel.link" :href="pixel.link" target="_blank" rel="noopener" class="pixel-link">
219
+ {{ pixel.link }}
220
+ </a>
221
+
222
+ <p class="pixel-wall-context">
223
+ {{ $t('tv.dashboard.indie_wall.mural_by', { name: wall.owner_nickname || wall.owner_name || wall.name }) }}
224
+ </p>
225
+
226
+ <!-- ── Share ─────────────────────────────────────────────────── -->
227
+ <div class="pixel-share">
228
+ <p class="pixel-share-title">{{ $t('tv.dashboard.indie_wall.share_pixel_cta') }}</p>
229
+
230
+ <div class="pixel-share-row">
231
+ <a :href="shareTwitterUrl" target="_blank" rel="noopener" class="pixel-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_twitter')">𝕏</a>
232
+ <a :href="shareWhatsappUrl" target="_blank" rel="noopener" class="pixel-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_whatsapp')">
233
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><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>
234
+ </a>
235
+ <a :href="shareFacebookUrl" target="_blank" rel="noopener" class="pixel-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_facebook')">f</a>
236
+ <button type="button" class="pixel-share-btn" @click="shareOrCopyLink" :aria-label="$t('tv.dashboard.indie_wall.share_copy_link')">
237
+ {{ linkCopied ? '✓' : '🔗' }}
238
+ </button>
239
+ </div>
240
+ <p v-if="linkCopied" class="pixel-share-copied">{{ $t('tv.dashboard.indie_wall.share_link_copied') }}</p>
241
+
242
+ <a
243
+ v-if="pixelDownloadImage"
244
+ :href="pixelDownloadImage"
245
+ download
246
+ target="_blank"
247
+ rel="noopener"
248
+ class="pixel-share-action"
249
+ >
250
+ {{ $t('tv.dashboard.indie_wall.share_download_image') }}
251
+ </a>
252
+
253
+ <button
254
+ type="button"
255
+ class="pixel-share-action"
256
+ :disabled="renderState === 'rendering'"
257
+ @click="generateReveal"
258
+ >
259
+ {{ renderState === 'rendering'
260
+ ? $t('tv.dashboard.indie_wall.share_generating')
261
+ : $t('tv.dashboard.indie_wall.share_generate_video') }}
262
+ </button>
263
+
264
+ <p v-if="renderState === 'failed'" class="pixel-share-error">
265
+ {{ $t('tv.dashboard.indie_wall.share_generate_failed') }}
266
+ </p>
267
+
268
+ <div v-if="renderState === 'done' && renderAssetUrl" class="pixel-reveal-result">
269
+ <p class="pixel-reveal-title">{{ $t('tv.dashboard.indie_wall.share_video_ready') }}</p>
270
+ <video v-if="renderAssetIsVideo" :src="renderAssetUrl" controls class="pixel-reveal-media" />
271
+ <img v-else :src="renderAssetUrl" alt="" class="pixel-reveal-media" />
272
+ <a :href="renderAssetUrl" download target="_blank" rel="noopener" class="pixel-share-action">
273
+ {{ $t('tv.dashboard.indie_wall.share_download_image') }}
274
+ </a>
275
+ </div>
276
+ </div>
277
+ </div>
278
+ </div>
279
+ </div>
280
+ </template>
281
+
282
+ <style lang="scss" scoped>
283
+ .pixel-page {
284
+ max-width: 480px;
285
+ margin: 0 auto;
286
+ padding: 32px 16px 100px;
287
+ }
288
+
289
+ .back-link {
290
+ display: inline-block;
291
+ color: var(--secondary-info-fg, #aaa);
292
+ font-size: 0.82rem;
293
+ text-decoration: none;
294
+ margin-bottom: 20px;
295
+ &:hover { color: var(--title-fg, #fff); }
296
+ }
297
+
298
+ .pixel-card {
299
+ background: #13141A;
300
+ border: 1px solid rgba(107, 21, 172, 0.35);
301
+ overflow: hidden;
302
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(107, 21, 172, 0.1);
303
+ }
304
+
305
+ .pixel-hero {
306
+ width: 100%;
307
+ height: 260px;
308
+ overflow: hidden;
309
+ display: flex;
310
+ align-items: center;
311
+ justify-content: center;
312
+ background: #0d0f13;
313
+ img { max-width: 100%; max-height: 100%; object-fit: contain; }
314
+ }
315
+ .pixel-initial {
316
+ font-size: 5rem;
317
+ font-weight: 700;
318
+ color: rgba(255, 255, 255, 0.5);
319
+ line-height: 1;
320
+ }
321
+
322
+ .pixel-body { padding: 24px; }
323
+ .pixel-name {
324
+ font-size: 1.3rem;
325
+ font-weight: 700;
326
+ color: var(--title-fg, #fff);
327
+ margin: 0 0 4px;
328
+ }
329
+ .pixel-title {
330
+ color: var(--secondary-info-fg, #aaa);
331
+ font-size: 0.85rem;
332
+ line-height: 1.35;
333
+ margin: 0 0 10px;
334
+ }
335
+ .pixel-msg {
336
+ color: var(--secondary-info-fg, #aaa);
337
+ font-size: 0.92rem;
338
+ font-style: italic;
339
+ line-height: 1.5;
340
+ margin: 0 0 16px;
341
+ }
342
+ .pixel-amount-row {
343
+ display: flex;
344
+ align-items: center;
345
+ justify-content: space-between;
346
+ background: rgba(107, 21, 172, 0.12);
347
+ border: 1px solid rgba(107, 21, 172, 0.25);
348
+ padding: 10px 14px;
349
+ margin-bottom: 12px;
350
+ }
351
+ .pixel-amount-label {
352
+ font-size: 12px;
353
+ color: var(--secondary-info-fg, #aaa);
354
+ text-transform: uppercase;
355
+ letter-spacing: 0.04em;
356
+ }
357
+ .pixel-amount-value {
358
+ font-weight: 700;
359
+ font-size: 1.15rem;
360
+ color: var(--chip-text, #D297FF);
361
+ }
362
+ .pixel-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
363
+ .pixel-chip {
364
+ font-size: 11px;
365
+ color: var(--secondary-info-fg, #aaa);
366
+ background: rgba(255, 255, 255, 0.05);
367
+ border: 1px solid rgba(255, 255, 255, 0.08);
368
+ padding: 3px 8px;
369
+ }
370
+ .pixel-link {
371
+ display: flex;
372
+ align-items: center;
373
+ gap: 6px;
374
+ color: var(--chip-text, #D297FF);
375
+ font-size: 0.8rem;
376
+ word-break: break-all;
377
+ text-decoration: none;
378
+ margin-bottom: 8px;
379
+ &:hover { text-decoration: underline; }
380
+ }
381
+ .pixel-wall-context {
382
+ color: var(--secondary-info-fg, #888);
383
+ font-size: 0.78rem;
384
+ margin: 0;
385
+ }
386
+
387
+ .pixel-share {
388
+ margin-top: 20px;
389
+ padding-top: 20px;
390
+ border-top: 1px solid #272930;
391
+ }
392
+ .pixel-share-title {
393
+ margin: 0 0 10px;
394
+ font-size: 0.85rem;
395
+ font-weight: 600;
396
+ color: var(--title-fg, #fff);
397
+ }
398
+ .pixel-share-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
399
+ .pixel-share-btn {
400
+ width: 36px;
401
+ height: 36px;
402
+ flex-shrink: 0;
403
+ display: flex;
404
+ align-items: center;
405
+ justify-content: center;
406
+ background: rgba(255, 255, 255, 0.06);
407
+ border: 1px solid #272930;
408
+ color: var(--title-fg, #fff);
409
+ font-size: 0.9rem;
410
+ font-weight: 700;
411
+ cursor: pointer;
412
+ text-decoration: none;
413
+ transition: border-color 0.15s;
414
+ &:hover { border-color: var(--chip-text, #D297FF); }
415
+ }
416
+ .pixel-share-copied {
417
+ color: var(--chip-text, #D297FF);
418
+ font-size: 0.78rem;
419
+ margin: 0 0 10px;
420
+ }
421
+ .pixel-share-action {
422
+ display: block;
423
+ width: 100%;
424
+ text-align: center;
425
+ background: none;
426
+ border: 1px solid var(--chip-text, #D297FF);
427
+ color: var(--chip-text, #D297FF);
428
+ font-weight: 600;
429
+ font-size: 0.85rem;
430
+ padding: 10px 14px;
431
+ margin-top: 8px;
432
+ cursor: pointer;
433
+ text-decoration: none;
434
+ transition: background 0.15s, color 0.15s, opacity 0.15s;
435
+ &:hover:not(:disabled) { background: var(--chip-text, #D297FF); color: #13161C; }
436
+ &:disabled { opacity: 0.5; cursor: not-allowed; }
437
+ }
438
+ .pixel-share-error {
439
+ color: #ff6b6b;
440
+ font-size: 0.78rem;
441
+ margin: 8px 0 0;
442
+ }
443
+ .pixel-reveal-result { margin-top: 16px; }
444
+ .pixel-reveal-title {
445
+ margin: 0 0 8px;
446
+ font-size: 0.82rem;
447
+ font-weight: 600;
448
+ color: var(--title-fg, #fff);
449
+ }
450
+ .pixel-reveal-media {
451
+ width: 100%;
452
+ display: block;
453
+ background: #0d0f13;
454
+ border: 1px solid #272930;
455
+ }
456
+ </style>
@@ -409,7 +409,7 @@ const seoDescription = computed(() => kit.value?.tagline || kit.value?.descripti
409
409
  const seoImage = computed(() => kit.value?.hero_image_url || kit.value?.cover_image_url || kit.value?.logo_url || '');
410
410
 
411
411
  // Raw useHead meta-array form, not useSeoMeta — see the matching comment in
412
- // shared-ui/pages/mural/[slug].vue for why (useSeoMeta's og:*/twitter:*/
412
+ // shared-ui/pages/mural/[slug]/index.vue for why (useSeoMeta's og:*/twitter:*/
413
413
  // description overrides get silently dropped in this app).
414
414
  useHead(() => ({
415
415
  title: seoTitle.value,
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ const localePath = useLocalePath()
4
+ const slug = route.params.slug as string
5
+ const pixelId = route.params.pixelId as string
6
+ const qs = Object.keys(route.query).length
7
+ ? '?' + new URLSearchParams(route.query as Record<string, string>).toString()
8
+ : ''
9
+ navigateTo(localePath(`/mural/${slug}/pixel/${pixelId}`) + qs, { replace: true })
10
+ </script>
11
+
12
+ <template><div /></template>
@@ -172,3 +172,23 @@ export const listPayouts = (
172
172
 
173
173
  export const requestPayout = (wallId: number | string) =>
174
174
  iw.post(`/api/v1/indie-walls/${wallId}/payouts`, {})
175
+
176
+ // ── Pixel detail / share ─────────────────────────────────────────────────────
177
+
178
+ export const getWallPixel = (wallId: number | string, pixelId: number | string) =>
179
+ iw.get(`/api/v1/indie-walls/${wallId}/pixels/${pixelId}`)
180
+
181
+ export const requestWallPixelShareRender = (
182
+ wallId: number | string,
183
+ pixelId: number | string,
184
+ format: 'video' | 'image' = 'video',
185
+ ) =>
186
+ iw.post(`/api/v1/indie-walls/${wallId}/pixels/${pixelId}/share-render`, {
187
+ format: format === 'video' ? 'mp4' : 'png',
188
+ })
189
+
190
+ export const pollWallPixelShareRender = (
191
+ wallId: number | string,
192
+ pixelId: number | string,
193
+ jobId: string,
194
+ ) => iw.get(`/api/v1/indie-walls/${wallId}/pixels/${pixelId}/share-render/${jobId}`)
File without changes