@mundogamernetwork/shared-ui 1.11.3 → 1.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/kit/kit-base.css +60 -0
- package/components/PressKit/VideoPlayer.vue +92 -3
- package/components/keys/KeyBrowser.vue +4 -4
- package/locales/de.json +4 -4
- package/locales/pt-BR.json +5 -5
- package/locales/ro.json +5 -5
- package/package.json +1 -1
- package/pages/presskit/game/[slug].vue +67 -3
- package/plugins/echo.client.ts +46 -0
- package/services/keyService.ts +15 -6
package/assets/kit/kit-base.css
CHANGED
|
@@ -440,6 +440,66 @@ body:has(.kit-press) {
|
|
|
440
440
|
}
|
|
441
441
|
.kit-logo-tile .ic { color: var(--kit-accent, #FDB215); }
|
|
442
442
|
|
|
443
|
+
/* Downloads: press releases, fact sheets, asset bundles. Its own section
|
|
444
|
+
because these used to sit inside "Logos & icons", where a journalist
|
|
445
|
+
hunting for the press release would never look. One row per file so the
|
|
446
|
+
same release in several languages reads as a list of choices. */
|
|
447
|
+
.kit-docs {
|
|
448
|
+
display: flex;
|
|
449
|
+
flex-direction: column;
|
|
450
|
+
gap: 8px;
|
|
451
|
+
}
|
|
452
|
+
.kit-doc {
|
|
453
|
+
display: flex;
|
|
454
|
+
align-items: center;
|
|
455
|
+
gap: 12px;
|
|
456
|
+
padding: 12px 16px;
|
|
457
|
+
background: var(--kit-surface, #14171d);
|
|
458
|
+
border: 1px solid var(--kit-line, #252a34);
|
|
459
|
+
color: var(--kit-text, #f4f6fa);
|
|
460
|
+
text-decoration: none;
|
|
461
|
+
transition: border-color 0.15s;
|
|
462
|
+
}
|
|
463
|
+
.kit-doc:hover { border-color: var(--kit-accent, #FDB215); }
|
|
464
|
+
.kit-doc__ic {
|
|
465
|
+
color: var(--kit-accent, #FDB215);
|
|
466
|
+
font-size: 0.85rem;
|
|
467
|
+
flex-shrink: 0;
|
|
468
|
+
}
|
|
469
|
+
.kit-doc__body {
|
|
470
|
+
display: flex;
|
|
471
|
+
flex-direction: column;
|
|
472
|
+
gap: 2px;
|
|
473
|
+
min-width: 0;
|
|
474
|
+
flex: 1;
|
|
475
|
+
}
|
|
476
|
+
.kit-doc__title {
|
|
477
|
+
font-size: 0.9rem;
|
|
478
|
+
overflow: hidden;
|
|
479
|
+
text-overflow: ellipsis;
|
|
480
|
+
white-space: nowrap;
|
|
481
|
+
}
|
|
482
|
+
.kit-doc__meta {
|
|
483
|
+
font-size: 0.72rem;
|
|
484
|
+
color: var(--kit-muted, #8b92a0);
|
|
485
|
+
}
|
|
486
|
+
.kit-doc__lang {
|
|
487
|
+
display: flex;
|
|
488
|
+
align-items: center;
|
|
489
|
+
gap: 6px;
|
|
490
|
+
flex-shrink: 0;
|
|
491
|
+
padding: 3px 9px;
|
|
492
|
+
border: 1px solid var(--kit-line, #252a34);
|
|
493
|
+
color: var(--kit-muted, #8b92a0);
|
|
494
|
+
font-size: 0.72rem;
|
|
495
|
+
}
|
|
496
|
+
.kit-doc__lang img {
|
|
497
|
+
width: 16px;
|
|
498
|
+
height: 12px;
|
|
499
|
+
object-fit: cover;
|
|
500
|
+
display: block;
|
|
501
|
+
}
|
|
502
|
+
|
|
443
503
|
/* Articles */
|
|
444
504
|
.kit-articles {
|
|
445
505
|
display: grid;
|
|
@@ -7,6 +7,8 @@ interface Video {
|
|
|
7
7
|
thumbnail_url?: string;
|
|
8
8
|
is_primary?: boolean;
|
|
9
9
|
sort_order?: number;
|
|
10
|
+
/** Spoken/subtitled language, for studios shipping a dubbed cut per market. */
|
|
11
|
+
language?: { name?: string; native_name?: string; name_abrev?: string; flag_url?: string } | null;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
interface Props {
|
|
@@ -23,6 +25,12 @@ const selectedVideoId = ref<number | null>(
|
|
|
23
25
|
const activeVideo = computed(() =>
|
|
24
26
|
props.videos.find(v => v.id === selectedVideoId.value) || props.videos[0] || null,
|
|
25
27
|
);
|
|
28
|
+
const playing = ref(false);
|
|
29
|
+
|
|
30
|
+
function selectVideo(id: number) {
|
|
31
|
+
selectedVideoId.value = id;
|
|
32
|
+
playing.value = false;
|
|
33
|
+
}
|
|
26
34
|
|
|
27
35
|
function getEmbedUrl(url: string) {
|
|
28
36
|
const ytMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/);
|
|
@@ -48,6 +56,16 @@ function formatType(type?: string) {
|
|
|
48
56
|
|
|
49
57
|
<div v-if="activeVideo" class="video-embed">
|
|
50
58
|
<video v-if="isDirectVideo(activeVideo.url)" :src="activeVideo.url" :poster="activeVideo.thumbnail_url || undefined" controls preload="metadata" />
|
|
59
|
+
<button
|
|
60
|
+
v-else-if="activeVideo.thumbnail_url && !playing"
|
|
61
|
+
type="button"
|
|
62
|
+
class="video-poster"
|
|
63
|
+
:aria-label="activeVideo.title"
|
|
64
|
+
@click="playing = true"
|
|
65
|
+
>
|
|
66
|
+
<img :src="activeVideo.thumbnail_url" :alt="activeVideo.title" />
|
|
67
|
+
<span class="video-poster__play">▶</span>
|
|
68
|
+
</button>
|
|
51
69
|
<iframe
|
|
52
70
|
v-else
|
|
53
71
|
:src="getEmbedUrl(activeVideo.url)"
|
|
@@ -62,12 +80,19 @@ function formatType(type?: string) {
|
|
|
62
80
|
v-for="video in videos"
|
|
63
81
|
:key="video.id"
|
|
64
82
|
:class="['video-item', { active: activeVideo?.id === video.id }]"
|
|
65
|
-
@click="
|
|
83
|
+
@click="selectVideo(video.id)"
|
|
66
84
|
>
|
|
67
|
-
<
|
|
85
|
+
<img v-if="video.thumbnail_url" :src="video.thumbnail_url" :alt="video.title" class="video-item__thumb" />
|
|
86
|
+
<MGIcon v-else icon="videos" />
|
|
68
87
|
<div class="video-meta">
|
|
69
88
|
<span class="video-title">{{ video.title }}</span>
|
|
70
|
-
<span
|
|
89
|
+
<span class="video-sub">
|
|
90
|
+
<span v-if="video.type" class="video-type">{{ formatType(video.type) }}</span>
|
|
91
|
+
<span v-if="video.language" class="video-lang">
|
|
92
|
+
<img v-if="video.language.flag_url" :src="video.language.flag_url" :alt="video.language.name || ''" loading="lazy" />
|
|
93
|
+
{{ video.language.native_name || video.language.name }}
|
|
94
|
+
</span>
|
|
95
|
+
</span>
|
|
71
96
|
</div>
|
|
72
97
|
</div>
|
|
73
98
|
</div>
|
|
@@ -101,6 +126,40 @@ function formatType(type?: string) {
|
|
|
101
126
|
height: 100%;
|
|
102
127
|
object-fit: contain;
|
|
103
128
|
}
|
|
129
|
+
|
|
130
|
+
.video-poster {
|
|
131
|
+
position: absolute;
|
|
132
|
+
inset: 0;
|
|
133
|
+
width: 100%;
|
|
134
|
+
height: 100%;
|
|
135
|
+
padding: 0;
|
|
136
|
+
border: 0;
|
|
137
|
+
background: #000;
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
|
|
140
|
+
img {
|
|
141
|
+
width: 100%;
|
|
142
|
+
height: 100%;
|
|
143
|
+
object-fit: cover;
|
|
144
|
+
display: block;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.video-poster__play {
|
|
149
|
+
position: absolute;
|
|
150
|
+
left: 50%;
|
|
151
|
+
top: 50%;
|
|
152
|
+
transform: translate(-50%, -50%);
|
|
153
|
+
width: 64px;
|
|
154
|
+
height: 64px;
|
|
155
|
+
display: grid;
|
|
156
|
+
place-items: center;
|
|
157
|
+
padding-left: 4px;
|
|
158
|
+
background: rgba(253, 178, 21, 0.95);
|
|
159
|
+
color: #101217;
|
|
160
|
+
font-size: 24px;
|
|
161
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.55);
|
|
162
|
+
}
|
|
104
163
|
}
|
|
105
164
|
|
|
106
165
|
.video-list {
|
|
@@ -136,6 +195,14 @@ function formatType(type?: string) {
|
|
|
136
195
|
flex-direction: column;
|
|
137
196
|
}
|
|
138
197
|
|
|
198
|
+
.video-item__thumb {
|
|
199
|
+
width: 72px;
|
|
200
|
+
height: 42px;
|
|
201
|
+
object-fit: cover;
|
|
202
|
+
flex-shrink: 0;
|
|
203
|
+
border: 1px solid #343842;
|
|
204
|
+
}
|
|
205
|
+
|
|
139
206
|
.video-title {
|
|
140
207
|
font-size: 0.9rem;
|
|
141
208
|
font-weight: 500;
|
|
@@ -145,5 +212,27 @@ function formatType(type?: string) {
|
|
|
145
212
|
font-size: 0.75rem;
|
|
146
213
|
color: #888;
|
|
147
214
|
}
|
|
215
|
+
|
|
216
|
+
.video-sub {
|
|
217
|
+
display: flex;
|
|
218
|
+
align-items: center;
|
|
219
|
+
gap: 8px;
|
|
220
|
+
flex-wrap: wrap;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.video-lang {
|
|
224
|
+
display: inline-flex;
|
|
225
|
+
align-items: center;
|
|
226
|
+
gap: 5px;
|
|
227
|
+
font-size: 0.75rem;
|
|
228
|
+
color: #888;
|
|
229
|
+
|
|
230
|
+
img {
|
|
231
|
+
width: 15px;
|
|
232
|
+
height: 11px;
|
|
233
|
+
object-fit: cover;
|
|
234
|
+
display: block;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
148
237
|
}
|
|
149
238
|
</style>
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
fetchPlatforms,
|
|
6
6
|
requestKey as apiRequestKey,
|
|
7
7
|
revealKey as apiRevealKey,
|
|
8
|
-
type
|
|
8
|
+
type KeyCampaign,
|
|
9
9
|
type KeyRequest,
|
|
10
10
|
type RevealResult,
|
|
11
11
|
} from '../../services/keyService'
|
|
@@ -30,7 +30,7 @@ const emit = defineEmits<{
|
|
|
30
30
|
|
|
31
31
|
// ─── State ────────────────────────────────────────────────────────────────────
|
|
32
32
|
|
|
33
|
-
const pools = ref<
|
|
33
|
+
const pools = ref<KeyCampaign[]>([])
|
|
34
34
|
const myRequests = ref<KeyRequest[]>([])
|
|
35
35
|
const platforms = ref<{ id: number; name: string }[]>([])
|
|
36
36
|
const loadingPools = ref(true)
|
|
@@ -116,7 +116,7 @@ function redirectToLogin() {
|
|
|
116
116
|
|
|
117
117
|
// ─── Request ──────────────────────────────────────────────────────────────────
|
|
118
118
|
|
|
119
|
-
async function doRequestKey(pool:
|
|
119
|
+
async function doRequestKey(pool: KeyCampaign) {
|
|
120
120
|
requesting.value = pool.id
|
|
121
121
|
delete requestErrors.value[pool.id]
|
|
122
122
|
try {
|
|
@@ -156,7 +156,7 @@ async function doRequestKey(pool: KeyPool) {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
function handleRequestKey(pool:
|
|
159
|
+
function handleRequestKey(pool: KeyCampaign) {
|
|
160
160
|
if (!isAuthenticated.value) {
|
|
161
161
|
redirectToLogin()
|
|
162
162
|
return
|
package/locales/de.json
CHANGED
|
@@ -58,16 +58,16 @@
|
|
|
58
58
|
"edit": "Bearbeiten",
|
|
59
59
|
"delete": "Löschen"
|
|
60
60
|
},
|
|
61
|
-
"title": "Key
|
|
62
|
-
"my_requests": "
|
|
61
|
+
"title": "Key-Kampagnen",
|
|
62
|
+
"my_requests": "Meine Anfragen",
|
|
63
63
|
"calendar_export_label": "Diese Fristen in deinen Kalender übernehmen:",
|
|
64
64
|
"calendar_export_ics": "Kalender (.ics)",
|
|
65
65
|
"calendar_export_csv": "Tabelle (.csv)",
|
|
66
66
|
"calendar_export_busy": "Wird vorbereitet...",
|
|
67
67
|
"calendar_export_error": "Die Datei konnte nicht erstellt werden. Bitte versuche es erneut.",
|
|
68
|
-
"all": "
|
|
68
|
+
"all": "Alle",
|
|
69
69
|
"details": "Details",
|
|
70
|
-
"see_campaign": "
|
|
70
|
+
"see_campaign": "Kampagne ansehen",
|
|
71
71
|
"see_content": "See content",
|
|
72
72
|
"redeem": "Request",
|
|
73
73
|
"load_more": "Load more",
|
package/locales/pt-BR.json
CHANGED
|
@@ -58,16 +58,16 @@
|
|
|
58
58
|
"edit": "Editar",
|
|
59
59
|
"delete": "Excluir"
|
|
60
60
|
},
|
|
61
|
-
"title": "
|
|
62
|
-
"my_requests": "
|
|
61
|
+
"title": "Campanhas de Chaves",
|
|
62
|
+
"my_requests": "Minhas Solicitações",
|
|
63
63
|
"calendar_export_label": "Leve estes prazos para o seu calendário:",
|
|
64
64
|
"calendar_export_ics": "Calendário (.ics)",
|
|
65
65
|
"calendar_export_csv": "Planilha (.csv)",
|
|
66
66
|
"calendar_export_busy": "Preparando...",
|
|
67
67
|
"calendar_export_error": "Não foi possível gerar o arquivo. Tente novamente.",
|
|
68
|
-
"all": "
|
|
69
|
-
"details": "
|
|
70
|
-
"see_campaign": "
|
|
68
|
+
"all": "Todas",
|
|
69
|
+
"details": "Detalhes",
|
|
70
|
+
"see_campaign": "Ver Campanha",
|
|
71
71
|
"see_content": "See content",
|
|
72
72
|
"redeem": "Request",
|
|
73
73
|
"load_more": "Load more",
|
package/locales/ro.json
CHANGED
|
@@ -58,16 +58,16 @@
|
|
|
58
58
|
"edit": "Editează",
|
|
59
59
|
"delete": "Șterge"
|
|
60
60
|
},
|
|
61
|
-
"title": "
|
|
62
|
-
"my_requests": "
|
|
61
|
+
"title": "Campanii de Chei",
|
|
62
|
+
"my_requests": "Solicitările Mele",
|
|
63
63
|
"calendar_export_label": "Adaugă aceste termene în calendarul tău:",
|
|
64
64
|
"calendar_export_ics": "Calendar (.ics)",
|
|
65
65
|
"calendar_export_csv": "Foaie de calcul (.csv)",
|
|
66
66
|
"calendar_export_busy": "Se pregătește...",
|
|
67
67
|
"calendar_export_error": "Fișierul nu a putut fi generat. Încearcă din nou.",
|
|
68
|
-
"all": "
|
|
69
|
-
"details": "
|
|
70
|
-
"see_campaign": "
|
|
68
|
+
"all": "Toate",
|
|
69
|
+
"details": "Detalii",
|
|
70
|
+
"see_campaign": "Vezi Campania",
|
|
71
71
|
"see_content": "See content",
|
|
72
72
|
"redeem": "Request",
|
|
73
73
|
"load_more": "Load more",
|
package/package.json
CHANGED
|
@@ -104,7 +104,26 @@ const showcases = computed(() => list(kit.value?.showcases));
|
|
|
104
104
|
const hasEcosystem = computed(() => showcases.value.length || kit.value?.ventures_url || kit.value?.magazine_feature || kit.value?.community_url);
|
|
105
105
|
|
|
106
106
|
const screenshots = computed(() => assets.value.filter((a: any) => ['screenshot', 'artwork', 'gif'].includes(a.type)));
|
|
107
|
-
|
|
107
|
+
// Documents used to be lumped in with logos under "Logos & icons", which is
|
|
108
|
+
// where a journalist looking for the press release would never think to look.
|
|
109
|
+
const brandAssets = computed(() => assets.value.filter((a: any) => a.type === 'logo'));
|
|
110
|
+
// Grouped by language so the same release in several languages reads as one
|
|
111
|
+
// row of choices rather than a pile of near-identical filenames.
|
|
112
|
+
const documents = computed(() => [...assets.value.filter((a: any) => a.type === 'document')]
|
|
113
|
+
.sort((a: any, b: any) => (a.language?.name_abrev || '').localeCompare(b.language?.name_abrev || '')));
|
|
114
|
+
|
|
115
|
+
const fileExtension = (a: any) => {
|
|
116
|
+
const match = String(a?.url || '').split('?')[0].match(/\.([a-z0-9]{1,5})$/i);
|
|
117
|
+
return match ? match[1].toUpperCase() : '';
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const fileSize = (bytes?: number) => {
|
|
121
|
+
if (!bytes) return '';
|
|
122
|
+
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
123
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const documentMeta = (a: any) => [fileExtension(a), fileSize(a.file_size)].filter(Boolean).join(' · ');
|
|
108
127
|
|
|
109
128
|
// Animated assets get the full gallery row at their natural height — cropping a
|
|
110
129
|
// gameplay loop into the 16/9 thumbnail grid makes it unreadable. The type is
|
|
@@ -135,12 +154,23 @@ const allowDownload = computed(() => kit.value?.allow_asset_download !== false);
|
|
|
135
154
|
const hasDownloadableAssets = computed(() => assets.value.length > 0);
|
|
136
155
|
const canDownload = computed(() => allowDownload.value && hasDownloadableAssets.value);
|
|
137
156
|
const zipDownloadUrl = computed(() => `${apiBase}/public/press-kits/${slug}/download${previewToken ? `?preview_token=${previewToken}` : ''}`);
|
|
138
|
-
|
|
157
|
+
/**
|
|
158
|
+
* The interview button used to be a hardcoded mailto: that showed up whenever
|
|
159
|
+
* the kit had a press e-mail — studios could neither hide it nor point it at
|
|
160
|
+
* the scheduler they already run. `interview_mode` is now the source of truth;
|
|
161
|
+
* kits that predate the field default to 'email' server-side, so nothing about
|
|
162
|
+
* an existing published kit changes.
|
|
163
|
+
*/
|
|
164
|
+
const interviewMode = computed(() => kit.value?.interview_mode || 'email');
|
|
165
|
+
const interviewHref = computed(() => {
|
|
166
|
+
if (interviewMode.value === 'off') return '';
|
|
167
|
+
if (interviewMode.value === 'external') return kit.value?.interview_url || '';
|
|
139
168
|
const email = kit.value?.press_contact_email;
|
|
140
169
|
if (!email) return '';
|
|
141
170
|
const subject = `${t('kit.press.request_interview')} — ${kit.value?.name || ''}`;
|
|
142
171
|
return `mailto:${email}?subject=${encodeURIComponent(subject)}`;
|
|
143
172
|
});
|
|
173
|
+
const interviewIsExternal = computed(() => interviewMode.value === 'external' && !!interviewHref.value);
|
|
144
174
|
|
|
145
175
|
const keyPoolsVisible = ref(false);
|
|
146
176
|
function onPoolsReady(count: number) {
|
|
@@ -261,6 +291,7 @@ const navSections = computed(() => {
|
|
|
261
291
|
s.push({ id: 'factsheet', label: 'kit.press.fact_sheet' });
|
|
262
292
|
if (steamAppId.value) s.push({ id: 'steam', label: 'kit.press.steam_widget' });
|
|
263
293
|
if (screenshots.value.length || brandAssets.value.length) s.push({ id: 'media', label: 'kit.press.screenshots' });
|
|
294
|
+
if (documents.value.length) s.push({ id: 'downloads', label: 'kit.press.downloads' });
|
|
264
295
|
if (videos.value.length) s.push({ id: 'trailers', label: 'kit.press.videos' });
|
|
265
296
|
if (articles.value.length) s.push({ id: 'articles', label: 'kit.press.articles' });
|
|
266
297
|
if (coverage.value.length) s.push({ id: 'creators', label: 'kit.press.coverage' });
|
|
@@ -403,7 +434,14 @@ useHead(() => ({
|
|
|
403
434
|
offered when the kit is actually linked to a game with open pools, same
|
|
404
435
|
gate as the section itself, so this never links to an empty/broken state. -->
|
|
405
436
|
<a v-if="kit.game_id" class="kit-btn kit-btn-ghost" href="#review-key" @click.prevent="scrollToSection('review-key')">{{ $t('kit.press.request_review_key') }}</a>
|
|
406
|
-
<a
|
|
437
|
+
<a
|
|
438
|
+
v-if="interviewHref"
|
|
439
|
+
class="kit-btn kit-btn-ghost"
|
|
440
|
+
:href="interviewHref"
|
|
441
|
+
:target="interviewIsExternal ? '_blank' : undefined"
|
|
442
|
+
:rel="interviewIsExternal ? 'noopener' : undefined"
|
|
443
|
+
@click="track('contact')"
|
|
444
|
+
>{{ $t('kit.press.request_interview') }}</a>
|
|
407
445
|
</div>
|
|
408
446
|
</div>
|
|
409
447
|
</div>
|
|
@@ -515,6 +553,32 @@ useHead(() => ({
|
|
|
515
553
|
</div>
|
|
516
554
|
</section>
|
|
517
555
|
|
|
556
|
+
<!-- Documentos: press release, fact sheet, bundles -->
|
|
557
|
+
<section v-if="documents.length" id="downloads" class="kit-block">
|
|
558
|
+
<div class="kit-shead"><h2>{{ $t('kit.press.downloads') }}</h2></div>
|
|
559
|
+
<div class="kit-docs">
|
|
560
|
+
<a
|
|
561
|
+
v-for="(d, i) in documents"
|
|
562
|
+
:key="i"
|
|
563
|
+
class="kit-doc"
|
|
564
|
+
:href="d.url"
|
|
565
|
+
target="_blank"
|
|
566
|
+
rel="noopener"
|
|
567
|
+
@click="track('download')"
|
|
568
|
+
>
|
|
569
|
+
<span class="kit-doc__ic">▼</span>
|
|
570
|
+
<span class="kit-doc__body">
|
|
571
|
+
<span class="kit-doc__title">{{ d.title || fileExtension(d) }}</span>
|
|
572
|
+
<span v-if="documentMeta(d)" class="kit-doc__meta">{{ documentMeta(d) }}</span>
|
|
573
|
+
</span>
|
|
574
|
+
<span v-if="d.language" class="kit-doc__lang">
|
|
575
|
+
<img v-if="d.language.flag_url" :src="d.language.flag_url" :alt="d.language.name || ''" loading="lazy" />
|
|
576
|
+
{{ d.language.native_name || d.language.name }}
|
|
577
|
+
</span>
|
|
578
|
+
</a>
|
|
579
|
+
</div>
|
|
580
|
+
</section>
|
|
581
|
+
|
|
518
582
|
<!-- Conteúdos & Artigos -->
|
|
519
583
|
<section v-if="articles.length" id="articles" class="kit-block">
|
|
520
584
|
<div class="kit-shead"><h2>{{ $t('kit.press.articles') }}</h2></div>
|
package/plugins/echo.client.ts
CHANGED
|
@@ -36,6 +36,11 @@ export default defineNuxtPlugin({
|
|
|
36
36
|
headers.Authorization = `Bearer ${import.meta.env.VITE_BEARER_TOKEN}`;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Per-channel count of consecutive unauthorized auth attempts, so a channel
|
|
40
|
+
// whose session is gone stops being re-authorized on every reconnect.
|
|
41
|
+
const authFailures = new Map<string, number>();
|
|
42
|
+
const MAX_AUTH_FAILURES = 3;
|
|
43
|
+
|
|
39
44
|
window.Pusher = Pusher;
|
|
40
45
|
window.Echo = new Echo({
|
|
41
46
|
broadcaster: "pusher",
|
|
@@ -66,10 +71,51 @@ export default defineNuxtPlugin({
|
|
|
66
71
|
},
|
|
67
72
|
)
|
|
68
73
|
.then((response) => {
|
|
74
|
+
authFailures.delete(channel.name);
|
|
69
75
|
callback(false, response.data);
|
|
70
76
|
})
|
|
71
77
|
.catch((error) => {
|
|
78
|
+
const status = error?.response?.status;
|
|
72
79
|
callback(true, error);
|
|
80
|
+
|
|
81
|
+
// 401/403 means the session is gone, not that the
|
|
82
|
+
// request glitched. pusher-js keeps a failed channel
|
|
83
|
+
// in its subscription list and re-subscribes every
|
|
84
|
+
// channel on each reconnect, so the channel asks
|
|
85
|
+
// broadcasting/auth again on every cycle — forever,
|
|
86
|
+
// from a tab nobody is even looking at.
|
|
87
|
+
//
|
|
88
|
+
// Not dropped on the first failure: a token being
|
|
89
|
+
// refreshed can produce one transient 401, and giving
|
|
90
|
+
// up there would silently kill a channel that was
|
|
91
|
+
// about to work. After MAX_AUTH_FAILURES the session
|
|
92
|
+
// is genuinely gone, so the channel is released and
|
|
93
|
+
// the reconnect loop has nothing left to retry.
|
|
94
|
+
if (status !== 401 && status !== 403) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const failures = (authFailures.get(channel.name) ?? 0) + 1;
|
|
99
|
+
authFailures.set(channel.name, failures);
|
|
100
|
+
|
|
101
|
+
if (failures < MAX_AUTH_FAILURES) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
authFailures.delete(channel.name);
|
|
106
|
+
console.warn(
|
|
107
|
+
`[WebSocket] Dropping ${channel.name} after ${failures} unauthorized auth attempts.`,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Deferred: unsubscribing re-enters pusher-js, and we
|
|
111
|
+
// are still inside its authorize() callback here.
|
|
112
|
+
setTimeout(() => {
|
|
113
|
+
try {
|
|
114
|
+
window.Echo.leaveChannel(channel.name);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
console.error("[WebSocket] Failed to leave channel:", e);
|
|
117
|
+
}
|
|
118
|
+
}, 0);
|
|
73
119
|
});
|
|
74
120
|
},
|
|
75
121
|
};
|
package/services/keyService.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import httpService from './httpService'
|
|
2
2
|
|
|
3
|
-
export type
|
|
3
|
+
export type KeyCampaign = {
|
|
4
4
|
id: number
|
|
5
5
|
name: string
|
|
6
6
|
slug: string
|
|
@@ -29,17 +29,26 @@ export type RevealResult = {
|
|
|
29
29
|
revealed_at: string
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Every route below is registered by agency-api inside its `public` group
|
|
33
|
+
// (routes/api.php: `['prefix' => 'public']`), so the real paths are
|
|
34
|
+
// /api/v1/public/... — httpService's baseURL only supplies /api/v1. These
|
|
35
|
+
// calls omitted the `public` segment and had been 404ing: KeyBrowser rendered
|
|
36
|
+
// an empty "no campaigns available" state instead of the real list, on the
|
|
37
|
+
// press room, the media-partner dashboard and the public press kit.
|
|
38
|
+
// Confirmed against `php artisan route:list`; agency-frontend is the only
|
|
39
|
+
// consumer of this service, and it points at agency-api.
|
|
40
|
+
|
|
32
41
|
export const fetchAvailablePools = (params: Record<string, any> = {}) =>
|
|
33
|
-
httpService.get<{ data:
|
|
42
|
+
httpService.get<{ data: KeyCampaign[] }>('/public/key-campaigns/available', { params })
|
|
34
43
|
|
|
35
44
|
export const fetchMyRequests = () =>
|
|
36
|
-
httpService.get<{ data: KeyRequest[] }>('/key-requests/mine')
|
|
45
|
+
httpService.get<{ data: KeyRequest[] }>('/public/key-requests/mine')
|
|
37
46
|
|
|
38
47
|
export const requestKey = (poolId: number, body: Record<string, any>) =>
|
|
39
|
-
httpService.post(`/key-
|
|
48
|
+
httpService.post(`/public/key-campaigns/${poolId}/request`, body)
|
|
40
49
|
|
|
41
50
|
export const revealKey = (requestId: number, body: Record<string, any> = {}) =>
|
|
42
|
-
httpService.post<{ data: RevealResult }>(`/key-requests/${requestId}/reveal`, body)
|
|
51
|
+
httpService.post<{ data: RevealResult }>(`/public/key-requests/${requestId}/reveal`, body)
|
|
43
52
|
|
|
44
53
|
export const fetchPlatforms = () =>
|
|
45
|
-
httpService.get<{ data: { id: number; name: string }[] }>('/showcase-studio-dashboard/platforms')
|
|
54
|
+
httpService.get<{ data: { id: number; name: string }[] }>('/public/showcase-studio-dashboard/platforms')
|