@mundogamernetwork/shared-ui 1.4.1 → 1.5.0
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 +88 -0
- package/components/checkout/MgPaymentMethodSelector.vue +3 -1
- package/components/keys/KeyBrowser.vue +2 -2
- package/components/playtest/PlaytestResponseFlow.vue +7 -1
- package/components/playtest/access-grant/AccessGrantPanel.vue +80 -0
- package/components/playtest/access-grant/AccessGrantReveal.vue +132 -0
- package/components/playtest/access-grant/AccessGrantWaiver.vue +170 -0
- package/components/playtest/video/RecordedSessionPlayer.vue +162 -0
- package/components/playtest/video/VideoPlaytestCapture.vue +195 -0
- package/components/ui/MgPaymentMethods.vue +20 -0
- package/composables/usePaymentMethods.ts +43 -12
- package/composables/usePlaytestAccessGrant.ts +88 -0
- package/composables/usePlaytestVideoRecording.ts +191 -0
- package/locales/de.json +1 -0
- package/locales/en.json +1 -0
- package/locales/pt-BR.json +1 -0
- package/locales/ro.json +1 -0
- package/package.json +1 -1
- package/pages/key-campaigns/redeem-key.vue +36 -0
- package/pages/presskit/game/[slug].vue +146 -0
- package/services/campaignService.ts +14 -0
- package/services/playtestTesterService.ts +63 -0
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
fetchKeyRegions,
|
|
9
9
|
fetchAvailableOptions,
|
|
10
10
|
requestKey,
|
|
11
|
+
enableKeyCampaignNotifications,
|
|
11
12
|
} from "../../services/campaignService"
|
|
12
13
|
|
|
13
14
|
const route = useRoute()
|
|
@@ -37,6 +38,7 @@ const selectedPlatformId = ref<number | null>(null)
|
|
|
37
38
|
const selectedRegionId = ref<number | null>(null)
|
|
38
39
|
const description = ref("")
|
|
39
40
|
const quantity = ref<number | null>(1)
|
|
41
|
+
const notifyKeyCampaigns = ref(true)
|
|
40
42
|
|
|
41
43
|
const maxKeysForUser = computed(() => campaign.value?.max_keys_for_user ?? 1)
|
|
42
44
|
|
|
@@ -137,6 +139,12 @@ async function submit() {
|
|
|
137
139
|
quantity_keys: Number(quantity.value) || 1,
|
|
138
140
|
})
|
|
139
141
|
const created = response?.data?.data ?? response?.data ?? {}
|
|
142
|
+
|
|
143
|
+
if (notifyKeyCampaigns.value) {
|
|
144
|
+
// Fire-and-forget — never block the request/reveal flow on this.
|
|
145
|
+
enableKeyCampaignNotifications().catch(() => {})
|
|
146
|
+
}
|
|
147
|
+
|
|
140
148
|
// requires_approval=false campaigns (or Pro/Business TV subscribers) get
|
|
141
149
|
// auto-approved by the backend observer synchronously — status 3 already
|
|
142
150
|
// reflects that by the time this response comes back, so skip the
|
|
@@ -281,6 +289,13 @@ function goBack() {
|
|
|
281
289
|
rows="3"
|
|
282
290
|
/>
|
|
283
291
|
</div>
|
|
292
|
+
|
|
293
|
+
<div class="form-row form-row--checkbox">
|
|
294
|
+
<label class="checkbox-label">
|
|
295
|
+
<input type="checkbox" v-model="notifyKeyCampaigns" />
|
|
296
|
+
{{ $t("keys.campaigns.notify_opt_in") }}
|
|
297
|
+
</label>
|
|
298
|
+
</div>
|
|
284
299
|
</div>
|
|
285
300
|
</div>
|
|
286
301
|
|
|
@@ -445,6 +460,27 @@ function goBack() {
|
|
|
445
460
|
color: var(--card-article-title);
|
|
446
461
|
}
|
|
447
462
|
|
|
463
|
+
.form-row--checkbox {
|
|
464
|
+
flex-direction: row;
|
|
465
|
+
align-items: center;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.checkbox-label {
|
|
469
|
+
display: flex;
|
|
470
|
+
align-items: center;
|
|
471
|
+
gap: 8px;
|
|
472
|
+
font-size: 13px;
|
|
473
|
+
color: var(--card-article-title);
|
|
474
|
+
cursor: pointer;
|
|
475
|
+
|
|
476
|
+
input[type="checkbox"] {
|
|
477
|
+
width: 16px;
|
|
478
|
+
height: 16px;
|
|
479
|
+
accent-color: var(--key-accent, var(--primary, #D297FF));
|
|
480
|
+
cursor: pointer;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
448
484
|
.select, .input, .textarea {
|
|
449
485
|
width: 100%;
|
|
450
486
|
background-color: transparent;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { trackPressKitEvent } from '../../../services/pressKitService';
|
|
3
|
+
import { mgApiUrl } from '../../../services/httpService';
|
|
3
4
|
|
|
4
5
|
definePageMeta({ layout: 'blank' });
|
|
5
6
|
|
|
6
7
|
const route = useRoute();
|
|
7
8
|
const slug = route.params.slug as string;
|
|
9
|
+
const { t } = useI18n();
|
|
8
10
|
|
|
9
11
|
const runtimeConfig = useRuntimeConfig();
|
|
10
12
|
const cfg = (runtimeConfig.public?.mgSharedUi || {}) as Record<string, any>;
|
|
@@ -93,6 +95,80 @@ function onPoolsReady(count: number) {
|
|
|
93
95
|
keyPoolsVisible.value = count > 0;
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
// ── Press Kit's own key request flow ────────────────────────────────────────
|
|
99
|
+
// Deliberately separate from the KeyBrowser widget above (that's the
|
|
100
|
+
// ecosystem-wide Key Campaigns product). This talks to the Press-Kit-specific
|
|
101
|
+
// pool/request endpoints and respects the studio's own `keys_public` setting
|
|
102
|
+
// (instant auto-delivery vs. manual review, configured in the dashboard).
|
|
103
|
+
const KEY_REQUEST_PLATFORMS = ['steam', 'epic', 'gog', 'xbox', 'psn', 'other'];
|
|
104
|
+
const KEY_REQUEST_REGIONS = ['worldwide', 'NA', 'EU', 'BR', 'LATAM', 'APAC', 'other'];
|
|
105
|
+
|
|
106
|
+
const requestForm = ref({ platform: 'steam', region: 'worldwide', objectives: '' });
|
|
107
|
+
const myKeyRequest = ref<any>(null);
|
|
108
|
+
const keyRequestLoaded = ref(false);
|
|
109
|
+
const keyRequestAuthed = ref(true);
|
|
110
|
+
const keyRequestSubmitting = ref(false);
|
|
111
|
+
const keyRequestError = ref('');
|
|
112
|
+
const keyCopied = ref(false);
|
|
113
|
+
|
|
114
|
+
const showOwnKeySection = computed(() => !!kit.value?.has_available_keys || !!myKeyRequest.value);
|
|
115
|
+
|
|
116
|
+
async function loadMyKeyRequest() {
|
|
117
|
+
try {
|
|
118
|
+
const res: any = await $fetch(`${apiBase}/public/press-kits/${slug}/my-request`, { credentials: 'include' });
|
|
119
|
+
myKeyRequest.value = res?.data || null;
|
|
120
|
+
} catch (err: any) {
|
|
121
|
+
if (err?.statusCode === 401 || err?.response?.status === 401) keyRequestAuthed.value = false;
|
|
122
|
+
} finally {
|
|
123
|
+
keyRequestLoaded.value = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function redirectToKeyLogin() {
|
|
128
|
+
window.location.href = mgApiUrl('login') + '?redirect_to=' + encodeURIComponent(window.location.href);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function submitOwnKeyRequest() {
|
|
132
|
+
if (!keyRequestAuthed.value) {
|
|
133
|
+
redirectToKeyLogin();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
keyRequestError.value = '';
|
|
137
|
+
keyRequestSubmitting.value = true;
|
|
138
|
+
try {
|
|
139
|
+
await $fetch(`${apiBase}/public/press-kits/${slug}/request-key`, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
credentials: 'include',
|
|
142
|
+
body: {
|
|
143
|
+
platform: requestForm.value.platform,
|
|
144
|
+
region: requestForm.value.region,
|
|
145
|
+
objectives: requestForm.value.objectives,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
await loadMyKeyRequest();
|
|
149
|
+
} catch (err: any) {
|
|
150
|
+
if (err?.statusCode === 401 || err?.response?.status === 401) {
|
|
151
|
+
keyRequestAuthed.value = false;
|
|
152
|
+
} else {
|
|
153
|
+
keyRequestError.value = err?.data?.message || t('kit.press.request_own_key_error');
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
keyRequestSubmitting.value = false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function copyKeyCode(code: string) {
|
|
161
|
+
try {
|
|
162
|
+
await navigator.clipboard.writeText(code);
|
|
163
|
+
keyCopied.value = true;
|
|
164
|
+
setTimeout(() => { keyCopied.value = false; }, 2000);
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
onMounted(() => {
|
|
169
|
+
if (kit.value?.id) loadMyKeyRequest();
|
|
170
|
+
});
|
|
171
|
+
|
|
96
172
|
// Scroll state for sticky CTA + back-to-top
|
|
97
173
|
const scrolled = ref(false);
|
|
98
174
|
let heroHeight = 0;
|
|
@@ -426,6 +502,76 @@ useHead(() => ({
|
|
|
426
502
|
/>
|
|
427
503
|
</section>
|
|
428
504
|
|
|
505
|
+
<!-- Press Kit's own key request flow — separate product from the
|
|
506
|
+
KeyBrowser ecosystem widget above. Respects the studio's own
|
|
507
|
+
keys_public setting (instant auto-delivery vs. manual review). -->
|
|
508
|
+
<section v-if="showOwnKeySection" id="request-key" class="kit-block kit-keys-section">
|
|
509
|
+
<h2 class="kit-keys-title">{{ $t('kit.press.request_own_key_title') }}</h2>
|
|
510
|
+
<p class="kit-keys-desc">{{ $t('kit.press.request_own_key_desc') }}</p>
|
|
511
|
+
<p class="kit-keys-mode-note">
|
|
512
|
+
{{ kit.keys_public ? $t('kit.press.request_own_key_public_note') : $t('kit.press.request_own_key_moderated_note') }}
|
|
513
|
+
</p>
|
|
514
|
+
|
|
515
|
+
<div v-if="!keyRequestLoaded" class="kit-keys-loading" />
|
|
516
|
+
|
|
517
|
+
<template v-else>
|
|
518
|
+
<div v-if="!keyRequestAuthed && !myKeyRequest" class="kit-keys-authwall">
|
|
519
|
+
<p>{{ $t('kit.press.request_own_key_login_required') }}</p>
|
|
520
|
+
<button type="button" class="kit-btn kit-btn-primary" @click="redirectToKeyLogin">
|
|
521
|
+
{{ $t('kit.press.request_own_key_login_btn') }}
|
|
522
|
+
</button>
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
<div v-else-if="myKeyRequest" class="kit-keys-status" :class="`status-${myKeyRequest.status}`">
|
|
526
|
+
<p v-if="myKeyRequest.status === 'pending'">{{ $t('kit.press.request_own_key_status_pending') }}</p>
|
|
527
|
+
<template v-else-if="myKeyRequest.status === 'approved'">
|
|
528
|
+
<p>{{ $t('kit.press.request_own_key_status_approved') }}</p>
|
|
529
|
+
<div class="kit-keys-code">
|
|
530
|
+
<code>{{ myKeyRequest.key }}</code>
|
|
531
|
+
<button type="button" class="kit-btn kit-btn-ghost kit-btn-sm" @click="copyKeyCode(myKeyRequest.key)">
|
|
532
|
+
{{ keyCopied ? $t('kit.press.request_own_key_copied') : $t('kit.press.request_own_key_copy') }}
|
|
533
|
+
</button>
|
|
534
|
+
</div>
|
|
535
|
+
</template>
|
|
536
|
+
<p v-else-if="myKeyRequest.status === 'rejected'">{{ $t('kit.press.request_own_key_status_rejected') }}</p>
|
|
537
|
+
</div>
|
|
538
|
+
|
|
539
|
+
<form v-else class="kit-keys-form" @submit.prevent="submitOwnKeyRequest">
|
|
540
|
+
<div class="kit-keys-row">
|
|
541
|
+
<div class="kit-keys-field">
|
|
542
|
+
<label>{{ $t('kit.press.request_own_key_platform') }}</label>
|
|
543
|
+
<select v-model="requestForm.platform">
|
|
544
|
+
<option v-for="p in KEY_REQUEST_PLATFORMS" :key="p" :value="p">{{ p }}</option>
|
|
545
|
+
</select>
|
|
546
|
+
</div>
|
|
547
|
+
<div class="kit-keys-field">
|
|
548
|
+
<label>{{ $t('kit.press.request_own_key_region') }}</label>
|
|
549
|
+
<select v-model="requestForm.region">
|
|
550
|
+
<option v-for="r in KEY_REQUEST_REGIONS" :key="r" :value="r">{{ r }}</option>
|
|
551
|
+
</select>
|
|
552
|
+
</div>
|
|
553
|
+
</div>
|
|
554
|
+
<div class="kit-keys-field">
|
|
555
|
+
<label>{{ $t('kit.press.request_own_key_objectives') }}</label>
|
|
556
|
+
<textarea
|
|
557
|
+
v-model="requestForm.objectives"
|
|
558
|
+
rows="3"
|
|
559
|
+
:placeholder="$t('kit.press.request_own_key_objectives_placeholder')"
|
|
560
|
+
/>
|
|
561
|
+
<span class="kit-keys-hint">{{ $t('kit.press.request_own_key_objectives_hint') }}</span>
|
|
562
|
+
</div>
|
|
563
|
+
<div v-if="keyRequestError" class="kit-keys-error">{{ keyRequestError }}</div>
|
|
564
|
+
<button
|
|
565
|
+
type="submit"
|
|
566
|
+
class="kit-btn kit-btn-primary"
|
|
567
|
+
:disabled="keyRequestSubmitting || requestForm.objectives.trim().length < 20"
|
|
568
|
+
>
|
|
569
|
+
{{ keyRequestSubmitting ? $t('kit.press.request_own_key_submitting') : $t('kit.press.request_own_key_submit') }}
|
|
570
|
+
</button>
|
|
571
|
+
</form>
|
|
572
|
+
</template>
|
|
573
|
+
</section>
|
|
574
|
+
|
|
429
575
|
<section v-if="kit.press_contact_email" id="contact" class="kit-block">
|
|
430
576
|
<div class="kit-cta">
|
|
431
577
|
<h2>{{ $t('kit.press.covering', { name: kit.name }) }}</h2>
|
|
@@ -421,6 +421,20 @@ export const revealKey = (requestId: number) => {
|
|
|
421
421
|
return authClient.get(`/public/key-requests/${requestId}/reveal`)
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Explicit opt-in to key campaign alerts — same endpoint used by
|
|
426
|
+
* KeyBrowser.vue's notification prompt (GET/PUT /public/user/notification-preferences).
|
|
427
|
+
*/
|
|
428
|
+
export const enableKeyCampaignNotifications = () => {
|
|
429
|
+
return authClient.put("/public/user/notification-preferences", {
|
|
430
|
+
category: "key_campaigns",
|
|
431
|
+
enabled: true,
|
|
432
|
+
channel_email: true,
|
|
433
|
+
channel_push: true,
|
|
434
|
+
channel_in_app: true,
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
|
|
424
438
|
/**
|
|
425
439
|
* Fetch required actions for a campaign.
|
|
426
440
|
*/
|
|
@@ -63,6 +63,8 @@ export interface SubmitBaselineResponsePayload {
|
|
|
63
63
|
time_on_task_seconds?: number
|
|
64
64
|
device?: string
|
|
65
65
|
os?: string
|
|
66
|
+
// Video Playtest only — id of a ready PlaytestRecording (see requestRecordingUploadUrl()).
|
|
67
|
+
recording_id?: number
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
export interface PlaytestResponse {
|
|
@@ -414,3 +416,64 @@ export const fetchMyPlaytestResponses = (status?: 'submitted' | 'accepted' | 're
|
|
|
414
416
|
httpService.get<PaginatedResponse<PlaytestResponse>>('/playtest/my-responses', {
|
|
415
417
|
params: { status, per_page: perPage, page },
|
|
416
418
|
})
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// Game access delivery + liability waiver (accepted responses only)
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
export interface PlaytestAccessGrant {
|
|
425
|
+
id: number
|
|
426
|
+
access_type: 'key' | 'link'
|
|
427
|
+
revealed: boolean
|
|
428
|
+
revealed_at: string | null
|
|
429
|
+
key_code?: string
|
|
430
|
+
external_link?: string
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Null when the response was accepted without a configured access grant. */
|
|
434
|
+
export const fetchAccessGrant = (responseId: number) =>
|
|
435
|
+
httpService.get<{ data: PlaytestAccessGrant | null }>(`/playtest/responses/${responseId}/access-grant`)
|
|
436
|
+
|
|
437
|
+
/** Sends (or resends) the liability-waiver OTP to the tester's account email. */
|
|
438
|
+
export const initiateAccessGrantWaiver = (responseId: number) =>
|
|
439
|
+
httpService.post<{ data: { document_id: number; status: string } }>(`/playtest/responses/${responseId}/waiver/initiate`)
|
|
440
|
+
|
|
441
|
+
/** Verifies the OTP code; on success the response includes the revealed key/link. */
|
|
442
|
+
export const verifyAccessGrantWaiver = (responseId: number, otpCode: string) =>
|
|
443
|
+
httpService.post<{ data: PlaytestAccessGrant }>(`/playtest/responses/${responseId}/waiver/verify`, { otp_code: otpCode })
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// Video Playtest — recording upload + timestamped markers ("minutagem")
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
export interface PlaytestRecording {
|
|
450
|
+
id: number
|
|
451
|
+
status: 'uploading' | 'processing' | 'ready' | 'failed'
|
|
452
|
+
duration_seconds: number | null
|
|
453
|
+
playback_url: string | null
|
|
454
|
+
external_id: string
|
|
455
|
+
markers?: PlaytestRecordingMarker[]
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export interface PlaytestRecordingMarker {
|
|
459
|
+
id: number
|
|
460
|
+
timestamp_seconds: number
|
|
461
|
+
comment: string
|
|
462
|
+
severity: 'blocker' | 'major' | 'minor' | 'note' | null
|
|
463
|
+
created_by_user_id: number
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Requests a direct-upload URL from the video provider — the browser uploads straight to it, never through this API. */
|
|
467
|
+
export const requestRecordingUploadUrl = (campaignId: number) =>
|
|
468
|
+
httpService.post<{ data: { recording_id: number; upload_url: string } }>(
|
|
469
|
+
`/playtest/campaigns/${campaignId}/recording/upload-url`,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
/** Poll this until status === 'ready' (set by the provider's webhook once processing finishes). */
|
|
473
|
+
export const fetchRecording = (responseId: number) =>
|
|
474
|
+
httpService.get<{ data: PlaytestRecording | null }>(`/playtest/responses/${responseId}/recording`)
|
|
475
|
+
|
|
476
|
+
export const storeRecordingMarker = (
|
|
477
|
+
responseId: number,
|
|
478
|
+
payload: { timestamp_seconds: number; comment: string; severity?: 'blocker' | 'major' | 'minor' | 'note' },
|
|
479
|
+
) => httpService.post<{ data: PlaytestRecordingMarker }>(`/playtest/responses/${responseId}/recording/markers`, payload)
|