@mundogamernetwork/shared-ui 1.16.21 → 1.16.23
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 +1 -1
- package/components/playtest/build-access/BuildAccessPanel.vue +96 -0
- package/components/playtest/build-access/BuildDownload.vue +124 -0
- package/components/playtest/build-access/NdaSign.vue +221 -0
- package/components/playtest/concept-poll/FiveSecondTestVote.vue +1 -1
- package/components/playtest/lqa/FileIssueForm.vue +9 -1
- package/composables/usePlaytestBuildAccess.ts +219 -0
- package/locales/de.json +133 -0
- package/locales/en.json +133 -0
- package/locales/es.json +133 -0
- package/locales/pt-BR.json +133 -0
- package/locales/ro.json +133 -0
- package/package.json +1 -1
- package/pages/key-campaigns/redeem-key-approved.vue +51 -0
- package/pages/mural/[slug]/index.vue +148 -60
- package/plugins/echo.client.ts +49 -9
- package/services/esignatureService.ts +56 -0
- package/services/playtestTesterService.ts +53 -0
- package/pages/mural/[slug]/pixel/[pixelId].vue +0 -456
- package/pages/wall/[slug]/pixel/[pixelId].vue +0 -12
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
fetchPublicWall,
|
|
4
|
+
fetchPublicWallGoals,
|
|
5
|
+
getWallSupporters,
|
|
6
|
+
getWallPixel,
|
|
7
|
+
requestWallPixelShareRender,
|
|
8
|
+
pollWallPixelShareRender,
|
|
9
|
+
} from '../../../services/indieWallService'
|
|
3
10
|
import SupportStepper from '../../../components/indie-wall/SupportStepper.vue'
|
|
4
11
|
import MuralCanvas from '../../../components/indie-wall/MuralCanvas.vue'
|
|
5
12
|
import IndieWallLeaderboard from '../../../components/indie-wall/IndieWallLeaderboard.vue'
|
|
@@ -83,8 +90,9 @@ function finalizePending(pixelId: number | string) {
|
|
|
83
90
|
// Refresh supporters from API to get full pixel data (image_url, correct dimensions).
|
|
84
91
|
getWallSupporters(wallSlug, { per_page: 1000 }).then(res => {
|
|
85
92
|
supporters.value = (res.data?.data ?? []).filter((s: any) => s.status !== false)
|
|
93
|
+
const placed = supporters.value.find((s: any) => String(s.id) === String(pixelId))
|
|
94
|
+
if (placed) openPixelDetail(placed)
|
|
86
95
|
}).catch(() => { /* keep current state if refresh fails */ })
|
|
87
|
-
openSharePrompt(pixelId)
|
|
88
96
|
}
|
|
89
97
|
}
|
|
90
98
|
|
|
@@ -163,6 +171,20 @@ const stepperInitialSelection = ref<{ x: number; y: number; w: number; h: number
|
|
|
163
171
|
// ── Detail popup ───────────────────────────────────────────────────────────
|
|
164
172
|
const detailPixel = ref<any | null>(null)
|
|
165
173
|
|
|
174
|
+
// SSR-fetch the specific pixel for a ?pixel=<id> deep link (a shared pixel
|
|
175
|
+
// link), so a link-preview crawler gets that pixel's own og:title/
|
|
176
|
+
// description below instead of just the wall's — loadWall()'s own
|
|
177
|
+
// client-side route.query.pixel lookup (searching the fetched supporters
|
|
178
|
+
// list) still runs too, and is what keeps this in sync after any client-side
|
|
179
|
+
// navigation that doesn't reload the page.
|
|
180
|
+
const sharedPixelIdParam = route.query.pixel as string | undefined
|
|
181
|
+
const { data: ssrSharedPixel } = await useAsyncData(`mural-shared-pixel-${wallSlug}-${sharedPixelIdParam}`, () =>
|
|
182
|
+
sharedPixelIdParam && ssrWallData.value?.id
|
|
183
|
+
? getWallPixel(ssrWallData.value.id, sharedPixelIdParam).then(r => r.data?.data || r.data).catch(() => null)
|
|
184
|
+
: Promise.resolve(null),
|
|
185
|
+
)
|
|
186
|
+
if (ssrSharedPixel.value) detailPixel.value = ssrSharedPixel.value
|
|
187
|
+
|
|
166
188
|
// ── Computed ───────────────────────────────────────────────────────────────
|
|
167
189
|
const mgcBalance = computed(() => Number(authStore.user?.mgc_balance ?? 0))
|
|
168
190
|
|
|
@@ -246,10 +268,20 @@ function openPixelDetail(s: any) {
|
|
|
246
268
|
router.replace({ query: { ...route.query, pixel: s.id } })
|
|
247
269
|
}
|
|
248
270
|
|
|
271
|
+
// If a guest just purchased and we're about to show their pixel's detail
|
|
272
|
+
// overlay (which already has the full share panel — copy link, Twitter,
|
|
273
|
+
// WhatsApp, Facebook), the guest-account CTA is deferred until that overlay
|
|
274
|
+
// is dismissed, so the two overlays never stack.
|
|
275
|
+
const pendingGuestCtaAfterShare = ref(false)
|
|
276
|
+
|
|
249
277
|
function closePixelDetail() {
|
|
250
278
|
detailPixel.value = null
|
|
251
279
|
const { pixel: _omit, ...rest } = route.query
|
|
252
280
|
router.replace({ query: rest })
|
|
281
|
+
if (pendingGuestCtaAfterShare.value) {
|
|
282
|
+
pendingGuestCtaAfterShare.value = false
|
|
283
|
+
showGuestCta.value = true
|
|
284
|
+
}
|
|
253
285
|
}
|
|
254
286
|
|
|
255
287
|
function onCanvasTapSupporter(s: any) {
|
|
@@ -269,36 +301,26 @@ function onStepperClose() {
|
|
|
269
301
|
|
|
270
302
|
const showGuestCta = ref(false)
|
|
271
303
|
|
|
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
304
|
async function onStepperSuccess(payload: { pixelId?: number | string } = {}) {
|
|
291
305
|
showStepper.value = false
|
|
292
306
|
if (isPopup.value) {
|
|
293
307
|
notifyOpenerAndClose({ wallSlug })
|
|
294
308
|
return
|
|
295
309
|
}
|
|
296
|
-
|
|
310
|
+
const wasGuest = !authStore.signedIn
|
|
311
|
+
await loadWall()
|
|
312
|
+
const placed = payload?.pixelId
|
|
313
|
+
? supporters.value.find((s: any) => String(s.id) === String(payload.pixelId))
|
|
314
|
+
: null
|
|
315
|
+
if (placed) {
|
|
316
|
+
// Reopen the same detail overlay a manual canvas tap would — it
|
|
317
|
+
// already carries the full share panel, so this doubles as "share
|
|
318
|
+
// your new pixel" without a separate prompt/page.
|
|
319
|
+
pendingGuestCtaAfterShare.value = wasGuest
|
|
320
|
+
openPixelDetail(placed)
|
|
321
|
+
} else if (wasGuest) {
|
|
297
322
|
showGuestCta.value = true
|
|
298
|
-
} else {
|
|
299
|
-
openSharePrompt(payload?.pixelId)
|
|
300
323
|
}
|
|
301
|
-
await loadWall()
|
|
302
324
|
}
|
|
303
325
|
|
|
304
326
|
function supporterDisplayName(s: any): string {
|
|
@@ -341,7 +363,10 @@ const seoDescription = computed(() => {
|
|
|
341
363
|
}
|
|
342
364
|
return wall.value?.description || wall.value?.long_description?.replace(/\n+/g, ' ').slice(0, 160) || ''
|
|
343
365
|
})
|
|
344
|
-
|
|
366
|
+
// share_image_url is the backend's auto-generated branded reveal image for
|
|
367
|
+
// this pixel — a real hosted URL, safe for crawlers (unlike detailPixel's
|
|
368
|
+
// own image_url, which is inline base64 — see the comment above).
|
|
369
|
+
const seoImage = computed(() => detailPixel.value?.share_image_url || wall.value?.cover_image_url || wall.value?.logo_url || '')
|
|
345
370
|
|
|
346
371
|
// ── Per-supporter share link ────────────────────────────────────────────────
|
|
347
372
|
const sharePixelUrl = computed(() => {
|
|
@@ -349,12 +374,6 @@ const sharePixelUrl = computed(() => {
|
|
|
349
374
|
const base = `${requestUrl.origin}${route.path}`
|
|
350
375
|
return `${base}?pixel=${detailPixel.value.id}`
|
|
351
376
|
})
|
|
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
|
-
})
|
|
358
377
|
const shareText = computed(() => {
|
|
359
378
|
if (!detailPixel.value || !wall.value) return ''
|
|
360
379
|
return t('tv.dashboard.indie_wall.share_pixel_text', { name: supporterDisplayName(detailPixel.value), wall: wall.value.name })
|
|
@@ -372,6 +391,59 @@ async function copyShareLink() {
|
|
|
372
391
|
} catch { /* clipboard unavailable */ }
|
|
373
392
|
}
|
|
374
393
|
|
|
394
|
+
// ── Generate a branded reveal video/image on demand ─────────────────────────
|
|
395
|
+
type ShareRenderState = 'idle' | 'generating' | 'done' | 'failed'
|
|
396
|
+
const shareRenderState = ref<ShareRenderState>('idle')
|
|
397
|
+
const shareRenderAssetUrl = ref<string | null>(null)
|
|
398
|
+
let shareRenderPollTimer: ReturnType<typeof setInterval> | null = null
|
|
399
|
+
|
|
400
|
+
function stopShareRenderPolling() {
|
|
401
|
+
if (shareRenderPollTimer) {
|
|
402
|
+
clearInterval(shareRenderPollTimer)
|
|
403
|
+
shareRenderPollTimer = null
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function generateShareRevealVideo() {
|
|
408
|
+
if (!detailPixel.value || !wall.value || shareRenderState.value === 'generating') return
|
|
409
|
+
shareRenderState.value = 'generating'
|
|
410
|
+
shareRenderAssetUrl.value = null
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const startRes = await requestWallPixelShareRender(wall.value.id, detailPixel.value.id, 'video')
|
|
414
|
+
const jobId = startRes.data?.jobId
|
|
415
|
+
if (!jobId) throw new Error('no jobId')
|
|
416
|
+
|
|
417
|
+
shareRenderPollTimer = setInterval(async () => {
|
|
418
|
+
try {
|
|
419
|
+
const pollRes = await pollWallPixelShareRender(wall.value.id, detailPixel.value.id, jobId)
|
|
420
|
+
const status = pollRes.data?.status
|
|
421
|
+
if (status === 'done') {
|
|
422
|
+
stopShareRenderPolling()
|
|
423
|
+
shareRenderAssetUrl.value = pollRes.data?.assetUrl || null
|
|
424
|
+
shareRenderState.value = shareRenderAssetUrl.value ? 'done' : 'failed'
|
|
425
|
+
} else if (status === 'failed') {
|
|
426
|
+
stopShareRenderPolling()
|
|
427
|
+
shareRenderState.value = 'failed'
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
stopShareRenderPolling()
|
|
431
|
+
shareRenderState.value = 'failed'
|
|
432
|
+
}
|
|
433
|
+
}, 3000)
|
|
434
|
+
} catch {
|
|
435
|
+
shareRenderState.value = 'failed'
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
watch(detailPixel, (pixel) => {
|
|
440
|
+
if (!pixel) {
|
|
441
|
+
stopShareRenderPolling()
|
|
442
|
+
shareRenderState.value = 'idle'
|
|
443
|
+
shareRenderAssetUrl.value = null
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
|
|
375
447
|
// "Create your own wall" footer CTA — same host map as the backend's
|
|
376
448
|
// IndieWallController::resolverResponse, so it always points at whichever
|
|
377
449
|
// platform (TV/Agency) actually owns this wall rather than a hardcoded host.
|
|
@@ -415,6 +487,15 @@ useHead(() => ({
|
|
|
415
487
|
}))
|
|
416
488
|
|
|
417
489
|
onMounted(async () => {
|
|
490
|
+
if (ssrSharedPixel.value) {
|
|
491
|
+
// Landed here from a shared pixel link (?pixel=<id>) — the detail
|
|
492
|
+
// overlay is position:fixed (centered on the viewport), so it has to
|
|
493
|
+
// open only once the canvas is scrolled into view, or it looks like
|
|
494
|
+
// it opened over the header instead of over the mural. No animation:
|
|
495
|
+
// this is the very first paint the visitor sees, nothing to
|
|
496
|
+
// smooth-scroll from.
|
|
497
|
+
document.getElementById('mural-canvas-section')?.scrollIntoView({ behavior: 'instant', block: 'center' })
|
|
498
|
+
}
|
|
418
499
|
await loadWall()
|
|
419
500
|
if (wall.value?.id) subscribeWall(wall.value.id)
|
|
420
501
|
// If returning from the payment gateway, wait for the WS confirmation.
|
|
@@ -459,6 +540,7 @@ onUnmounted(() => {
|
|
|
459
540
|
if (wall.value?.id) unsubscribeWall(wall.value.id)
|
|
460
541
|
if (processingTimeoutId) clearTimeout(processingTimeoutId)
|
|
461
542
|
if (httpFallbackTimerId) clearInterval(httpFallbackTimerId)
|
|
543
|
+
stopShareRenderPolling()
|
|
462
544
|
})
|
|
463
545
|
</script>
|
|
464
546
|
|
|
@@ -610,7 +692,7 @@ onUnmounted(() => {
|
|
|
610
692
|
</section>
|
|
611
693
|
|
|
612
694
|
<!-- ── Canvas ─────────────────────────────────────────────────────── -->
|
|
613
|
-
<section class="section canvas-section">
|
|
695
|
+
<section id="mural-canvas-section" class="section canvas-section">
|
|
614
696
|
<div class="canvas-header">
|
|
615
697
|
<h2 class="section-title">{{ $t('tv.dashboard.indie_wall.mural_grid') }}</h2>
|
|
616
698
|
<span class="canvas-hint">{{ $t('tv.dashboard.indie_wall.mural_click_hint') }}</span>
|
|
@@ -710,21 +792,6 @@ onUnmounted(() => {
|
|
|
710
792
|
</div>
|
|
711
793
|
</Transition>
|
|
712
794
|
|
|
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
795
|
|
|
729
796
|
<Transition name="fade">
|
|
730
797
|
<SupportStepper
|
|
@@ -799,9 +866,6 @@ onUnmounted(() => {
|
|
|
799
866
|
<!-- ── Share this pixel ─────────────────────────────────────── -->
|
|
800
867
|
<div class="detail-share">
|
|
801
868
|
<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>
|
|
805
869
|
<div class="detail-share-row">
|
|
806
870
|
<a :href="shareTwitterUrl" target="_blank" rel="noopener" class="detail-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_twitter')">𝕏</a>
|
|
807
871
|
<a :href="shareWhatsappUrl" target="_blank" rel="noopener" class="detail-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_whatsapp')">
|
|
@@ -812,6 +876,23 @@ onUnmounted(() => {
|
|
|
812
876
|
{{ linkCopied ? '✓' : '🔗' }}
|
|
813
877
|
</button>
|
|
814
878
|
</div>
|
|
879
|
+
<button
|
|
880
|
+
v-if="shareRenderState !== 'done'"
|
|
881
|
+
type="button"
|
|
882
|
+
class="detail-share-video-btn"
|
|
883
|
+
:disabled="shareRenderState === 'generating'"
|
|
884
|
+
@click="generateShareRevealVideo"
|
|
885
|
+
>
|
|
886
|
+
{{ shareRenderState === 'generating' ? $t('tv.dashboard.indie_wall.share_generating') : $t('tv.dashboard.indie_wall.share_generate_video') }}
|
|
887
|
+
</button>
|
|
888
|
+
<p v-if="shareRenderState === 'failed'" class="detail-share-video-error">{{ $t('tv.dashboard.indie_wall.share_generate_failed') }}</p>
|
|
889
|
+
<video
|
|
890
|
+
v-if="shareRenderState === 'done' && shareRenderAssetUrl?.endsWith('.mp4')"
|
|
891
|
+
:src="shareRenderAssetUrl"
|
|
892
|
+
controls
|
|
893
|
+
class="detail-share-video-media"
|
|
894
|
+
/>
|
|
895
|
+
<img v-else-if="shareRenderState === 'done' && shareRenderAssetUrl" :src="shareRenderAssetUrl" class="detail-share-video-media" alt="" />
|
|
815
896
|
<button type="button" class="detail-share-cta" @click="closePixelDetail(); openStepper({ startStep: 1 })">
|
|
816
897
|
{{ $t('tv.dashboard.indie_wall.share_join_cta') }}
|
|
817
898
|
</button>
|
|
@@ -1158,16 +1239,6 @@ onUnmounted(() => {
|
|
|
1158
1239
|
.detail-share-title {
|
|
1159
1240
|
margin: 0 0 8px; font-size: 0.78rem; color: var(--secondary-info-fg, #aaa);
|
|
1160
1241
|
}
|
|
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
|
-
}
|
|
1171
1242
|
.detail-share-row {
|
|
1172
1243
|
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
|
|
1173
1244
|
}
|
|
@@ -1179,6 +1250,23 @@ onUnmounted(() => {
|
|
|
1179
1250
|
cursor: pointer; text-decoration: none; transition: border-color 0.15s;
|
|
1180
1251
|
&:hover { border-color: var(--chip-text, #D297FF); }
|
|
1181
1252
|
}
|
|
1253
|
+
.detail-share-video-btn {
|
|
1254
|
+
width: 100%;
|
|
1255
|
+
display: flex; align-items: center; justify-content: center;
|
|
1256
|
+
background: rgba(255,255,255,0.06); border: 1px solid #272930;
|
|
1257
|
+
color: var(--title-fg, #fff); font-size: 0.78rem; font-weight: 600;
|
|
1258
|
+
padding: 8px 10px; margin-bottom: 8px;
|
|
1259
|
+
cursor: pointer; transition: border-color 0.15s;
|
|
1260
|
+
&:hover:not(:disabled) { border-color: var(--chip-text, #D297FF); }
|
|
1261
|
+
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
1262
|
+
}
|
|
1263
|
+
.detail-share-video-error {
|
|
1264
|
+
color: #ee3831; font-size: 0.72rem; margin: 0 0 8px;
|
|
1265
|
+
}
|
|
1266
|
+
.detail-share-video-media {
|
|
1267
|
+
width: 100%; max-height: 220px; object-fit: contain;
|
|
1268
|
+
background: #000; margin-bottom: 10px;
|
|
1269
|
+
}
|
|
1182
1270
|
.detail-share-cta {
|
|
1183
1271
|
width: 100%;
|
|
1184
1272
|
background: var(--chip-text, #D297FF); color: #13161C;
|
package/plugins/echo.client.ts
CHANGED
|
@@ -160,6 +160,23 @@ export default defineNuxtPlugin({
|
|
|
160
160
|
let reconnectTimer: number | null = null;
|
|
161
161
|
let reconnectAttempts = 0;
|
|
162
162
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
163
|
+
// Once the backoff budget is exhausted we stop the timer-based retries, but the
|
|
164
|
+
// tab isn't necessarily hopeless forever — the failures so far might all be from
|
|
165
|
+
// a dead network or a backgrounded tab throttled by the browser. giveUp tracks
|
|
166
|
+
// that state so the visibility/online listeners below know to give the socket a
|
|
167
|
+
// fresh budget instead of calling connect() on top of an already-scheduled retry.
|
|
168
|
+
let giveUp = false;
|
|
169
|
+
|
|
170
|
+
const scheduleReconnect = () => {
|
|
171
|
+
reconnectAttempts++;
|
|
172
|
+
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 30000);
|
|
173
|
+
console.log(`[WebSocket] Reconnecting in ${delay / 1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
|
|
174
|
+
|
|
175
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
176
|
+
reconnectTimer = window.setTimeout(() => {
|
|
177
|
+
window.Echo.connector.pusher.connect();
|
|
178
|
+
}, delay);
|
|
179
|
+
};
|
|
163
180
|
|
|
164
181
|
window.Echo.connector.pusher.connection.bind("state_change", (states: any) => {
|
|
165
182
|
const { previous, current } = states;
|
|
@@ -169,7 +186,8 @@ export default defineNuxtPlugin({
|
|
|
169
186
|
// Stop reconnecting after max attempts to avoid infinite loop
|
|
170
187
|
// (e.g. when user is not authenticated)
|
|
171
188
|
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
172
|
-
console.log("[WebSocket] Max reconnect attempts reached, stopping.");
|
|
189
|
+
console.log("[WebSocket] Max reconnect attempts reached, stopping until the tab is visible/online again.");
|
|
190
|
+
giveUp = true;
|
|
173
191
|
if (reconnectTimer) {
|
|
174
192
|
clearTimeout(reconnectTimer);
|
|
175
193
|
reconnectTimer = null;
|
|
@@ -177,17 +195,11 @@ export default defineNuxtPlugin({
|
|
|
177
195
|
return;
|
|
178
196
|
}
|
|
179
197
|
|
|
180
|
-
|
|
181
|
-
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 30000);
|
|
182
|
-
console.log(`[WebSocket] Reconnecting in ${delay / 1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
|
|
183
|
-
|
|
184
|
-
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
185
|
-
reconnectTimer = window.setTimeout(() => {
|
|
186
|
-
window.Echo.connector.pusher.connect();
|
|
187
|
-
}, delay);
|
|
198
|
+
scheduleReconnect();
|
|
188
199
|
}
|
|
189
200
|
|
|
190
201
|
if (current === "connected") {
|
|
202
|
+
giveUp = false;
|
|
191
203
|
reconnectAttempts = 0;
|
|
192
204
|
if (reconnectTimer) {
|
|
193
205
|
clearTimeout(reconnectTimer);
|
|
@@ -196,6 +208,34 @@ export default defineNuxtPlugin({
|
|
|
196
208
|
}
|
|
197
209
|
});
|
|
198
210
|
|
|
211
|
+
// The backoff loop above only reacts to the socket's own state changes, so once
|
|
212
|
+
// it gives up after MAX_RECONNECT_ATTEMPTS nothing ever tries again — a tab left
|
|
213
|
+
// open across a Wi-Fi drop, a phone switching towers, or a background tab the
|
|
214
|
+
// browser froze mid-backoff stays stuck on "disconnected" for the rest of the
|
|
215
|
+
// session (falling back to HTTP polling wherever the app checks connection.state,
|
|
216
|
+
// e.g. community-frontend's chat store). Retry once when the tab regains focus or
|
|
217
|
+
// the browser reports the network is back, giving the connection a fresh budget
|
|
218
|
+
// instead of leaving it permanently given up on.
|
|
219
|
+
const retryIfStalled = () => {
|
|
220
|
+
const state = window.Echo?.connector?.pusher?.connection?.state;
|
|
221
|
+
if (!giveUp && state !== "disconnected" && state !== "failed" && state !== "unavailable") return;
|
|
222
|
+
if (state === "connected" || state === "connecting") return;
|
|
223
|
+
|
|
224
|
+
console.log("[WebSocket] Tab active/online again — retrying connection.");
|
|
225
|
+
giveUp = false;
|
|
226
|
+
reconnectAttempts = 0;
|
|
227
|
+
if (reconnectTimer) {
|
|
228
|
+
clearTimeout(reconnectTimer);
|
|
229
|
+
reconnectTimer = null;
|
|
230
|
+
}
|
|
231
|
+
window.Echo.connector.pusher.connect();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
document.addEventListener("visibilitychange", () => {
|
|
235
|
+
if (document.visibilityState === "visible") retryIfStalled();
|
|
236
|
+
});
|
|
237
|
+
window.addEventListener("online", retryIfStalled);
|
|
238
|
+
|
|
199
239
|
nuxtApp.provide("echo", window.Echo);
|
|
200
240
|
},
|
|
201
241
|
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import httpService from './httpService'
|
|
2
|
+
|
|
3
|
+
// Generic, consumer-agnostic e-signature service — the public, token-based
|
|
4
|
+
// signer flow (view -> otp -> consent -> sign / decline) under
|
|
5
|
+
// /esignature/*. Nothing here references Playtest or any other consumer;
|
|
6
|
+
// see playtestBuildService.ts for how a consumer (Playtest's build-access
|
|
7
|
+
// NDA) wires into this.
|
|
8
|
+
|
|
9
|
+
export interface EsignatureEnvelope {
|
|
10
|
+
id: number
|
|
11
|
+
title: string
|
|
12
|
+
status: string
|
|
13
|
+
content_type: 'html' | 'file'
|
|
14
|
+
document_html: string | null
|
|
15
|
+
/** Short-TTL presigned URL to preview the uploaded PDF, when content_type is 'file'. Re-fetch the session if it expires — never cache this across page loads. */
|
|
16
|
+
document_file_preview_url: string | null
|
|
17
|
+
expires_at: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EsignatureSignerState {
|
|
21
|
+
// `status` alone can't distinguish "OTP sent, not yet verified" from "OTP
|
|
22
|
+
// verified, consent not yet accepted" — both sit at 'otp_sent'. Check
|
|
23
|
+
// otp_verified for that distinction.
|
|
24
|
+
status: 'pending' | 'viewed' | 'otp_sent' | 'consented' | 'signed' | 'declined'
|
|
25
|
+
otp_verified: boolean
|
|
26
|
+
full_name: string
|
|
27
|
+
email: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EsignatureShowResponse {
|
|
31
|
+
data: {
|
|
32
|
+
envelope: EsignatureEnvelope
|
|
33
|
+
signer: EsignatureSignerState
|
|
34
|
+
consent_version: string
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Loads the envelope + signer state for a sign_token. Marks the signer as "viewed". */
|
|
39
|
+
export const fetchSigningSession = (token: string) =>
|
|
40
|
+
httpService.get<EsignatureShowResponse>(`/esignature/sign/${token}`)
|
|
41
|
+
|
|
42
|
+
/** Sends (or resends) the OTP code to the signer's email. */
|
|
43
|
+
export const requestSigningOtp = (token: string) =>
|
|
44
|
+
httpService.post<{ data: { status: string } }>(`/esignature/sign/${token}/otp`)
|
|
45
|
+
|
|
46
|
+
export const verifySigningOtp = (token: string, otpCode: string) =>
|
|
47
|
+
httpService.post<{ data: { status: string } }>(`/esignature/sign/${token}/verify`, { otp_code: otpCode })
|
|
48
|
+
|
|
49
|
+
export const acceptSigningConsent = (token: string) =>
|
|
50
|
+
httpService.post<{ data: { status: string } }>(`/esignature/sign/${token}/consent`)
|
|
51
|
+
|
|
52
|
+
export const submitSignature = (token: string) =>
|
|
53
|
+
httpService.post<{ data: { status: string } }>(`/esignature/sign/${token}/sign`)
|
|
54
|
+
|
|
55
|
+
export const declineSigning = (token: string, reason?: string) =>
|
|
56
|
+
httpService.post<{ data: { status: string } }>(`/esignature/sign/${token}/decline`, { reason })
|
|
@@ -503,3 +503,56 @@ export const storeRecordingMarker = (
|
|
|
503
503
|
responseId: number,
|
|
504
504
|
payload: { timestamp_seconds: number; comment: string; severity?: 'blocker' | 'major' | 'minor' | 'note' },
|
|
505
505
|
) => httpService.post<{ data: PlaytestRecordingMarker }>(`/playtest/responses/${responseId}/recording/markers`, payload)
|
|
506
|
+
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
// Build access — NDA-gated build download, requestable at ELIGIBILITY time
|
|
509
|
+
// (not gated behind response acceptance like PlaytestAccessGrant's key/link
|
|
510
|
+
// delivery — see PlaytestBuildGrant's doc comment in api-main). The actual
|
|
511
|
+
// NDA sign flow itself is generic (esignatureService.ts) — these calls only
|
|
512
|
+
// cover the Playtest-specific request/track/download steps around it.
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
export interface PlaytestBuildGrant {
|
|
516
|
+
id: number
|
|
517
|
+
playtest_campaign_id: number
|
|
518
|
+
user_id: number
|
|
519
|
+
status: 'pending_nda' | 'nda_signed' | 'revoked'
|
|
520
|
+
signature_envelope_id: number | null
|
|
521
|
+
granted_at: string | null
|
|
522
|
+
revoked_at: string | null
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export interface PlaytestBuildAsset {
|
|
526
|
+
id: number
|
|
527
|
+
playtest_campaign_id: number
|
|
528
|
+
platform: 'android' | 'ios' | 'windows' | 'mac' | 'web' | 'other'
|
|
529
|
+
version_label: string | null
|
|
530
|
+
original_filename: string
|
|
531
|
+
mime_type: string | null
|
|
532
|
+
size_bytes: number | null
|
|
533
|
+
status: 'pending' | 'ready' | 'failed'
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Lists the campaign's currently-ready build assets — no auth/ownership beyond being logged in, same visibility as the open browse list. */
|
|
537
|
+
export const fetchBuildAssets = (campaignId: number) =>
|
|
538
|
+
httpService.get<{ data: PlaytestBuildAsset[] }>(`/playtest/campaigns/${campaignId}/builds`)
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Idempotent — calling this again for a campaign/tester pair already granted
|
|
542
|
+
* just returns the existing grant (its current status), so it also doubles
|
|
543
|
+
* as "load my grant state for this campaign".
|
|
544
|
+
*/
|
|
545
|
+
export const requestBuildAccess = (campaignId: number) =>
|
|
546
|
+
httpService.post<{ data: PlaytestBuildGrant }>(`/playtest/campaigns/${campaignId}/build-access/request`)
|
|
547
|
+
|
|
548
|
+
/** Creates (or reuses) the signing envelope for this grant's NDA — no source_type/template input, the studio's own default template (or the MGN default) is resolved server-side. */
|
|
549
|
+
export const initiateBuildNda = (grantId: number) =>
|
|
550
|
+
httpService.post<{ data: { envelope_id: number; sign_token: string | null } }>(
|
|
551
|
+
`/playtest/build-access/${grantId}/nda/initiate`,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
/** Mints a fresh, short-TTL presigned URL — call this again for every download attempt, never cache the returned url. */
|
|
555
|
+
export const fetchBuildDownloadUrl = (grantId: number, assetId: number) =>
|
|
556
|
+
httpService.get<{ data: { url: string; expires_at: string } }>(
|
|
557
|
+
`/playtest/build-access/${grantId}/builds/${assetId}/download`,
|
|
558
|
+
)
|