@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.
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
- import { fetchPublicWall, fetchPublicWallGoals, getWallSupporters } from '../../../services/indieWallService'
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
- if (!authStore.signedIn) {
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
- const seoImage = computed(() => wall.value?.cover_image_url || wall.value?.logo_url || '')
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;
@@ -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
- reconnectAttempts++;
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
+ )