@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.
- package/components/indie-wall/MediaKitWallBlock.vue +15 -0
- package/components/indie-wall/SupportStepper.vue +5 -2
- package/components/ui/MgHeaderAvatarToggle.vue +28 -0
- package/components/ui/MgHeaderUIUser.vue +5 -0
- package/components/ui/MgPlatformSwitcherSidebar.vue +29 -5
- package/locales/de.json +13 -0
- package/locales/en.json +13 -0
- package/locales/es.json +13 -0
- package/locales/pt-BR.json +13 -0
- package/locales/ro.json +13 -0
- package/package.json +1 -1
- package/pages/key-campaigns/.claude-flow/data/pending-insights.jsonl +1 -0
- package/pages/key-campaigns/[slug].vue +15 -1
- package/pages/media-kit/[slug].vue +1 -1
- package/pages/mural/{[slug].vue → [slug]/index.vue} +62 -5
- package/pages/mural/[slug]/pixel/[pixelId].vue +456 -0
- package/pages/presskit/game/[slug].vue +1 -1
- package/pages/wall/[slug]/pixel/[pixelId].vue +12 -0
- package/services/indieWallService.ts +20 -0
- /package/pages/wall/{[slug].vue → [slug]/index.vue} +0 -0
|
@@ -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
|
-
|
|
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
|
-
<
|
|
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="
|
|
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
|
-
</
|
|
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",
|
package/locales/pt-BR.json
CHANGED
|
@@ -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
|
@@ -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
|
-
?
|
|
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 '
|
|
3
|
-
import SupportStepper from '
|
|
4
|
-
import MuralCanvas from '
|
|
5
|
-
import IndieWallLeaderboard from '
|
|
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
|
-
|
|
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
|