@mundogamernetwork/shared-ui 1.4.2 → 1.6.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/components/MediaKit/MgMediaKitLinks.vue +121 -0
- package/components/MediaKit/MgMediaKitStore.vue +107 -0
- package/components/checkout/MgPaymentMethodSelector.vue +3 -1
- package/components/form/SortInput.vue +9 -2
- 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/nuxt.config.ts +10 -3
- package/package.json +1 -1
- package/pages/key-campaigns/index.vue +7 -6
- package/pages/key-campaigns/redeem-key.vue +36 -0
- package/pages/media-kit/[slug].vue +180 -39
- package/services/campaignService.ts +14 -0
- package/services/playtestTesterService.ts +63 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
type MediaKitLink = { label: string; url: string };
|
|
3
|
+
|
|
4
|
+
const props = withDefaults(defineProps<{ links?: MediaKitLink[] }>(), {
|
|
5
|
+
links: () => [],
|
|
6
|
+
});
|
|
7
|
+
const emit = defineEmits<{ update: [key: 'custom_links', value: MediaKitLink[]] }>();
|
|
8
|
+
|
|
9
|
+
const draft = ref<MediaKitLink[]>([]);
|
|
10
|
+
const saved = ref(false);
|
|
11
|
+
|
|
12
|
+
watch(
|
|
13
|
+
() => props.links,
|
|
14
|
+
(value) => {
|
|
15
|
+
draft.value = (value || []).map(link => ({ label: link.label || '', url: link.url || '' }));
|
|
16
|
+
},
|
|
17
|
+
{ immediate: true, deep: true },
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const addLink = () => {
|
|
21
|
+
if (draft.value.length < 12) draft.value.push({ label: '', url: '' });
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const removeLink = (index: number) => draft.value.splice(index, 1);
|
|
25
|
+
|
|
26
|
+
const move = (index: number, direction: -1 | 1) => {
|
|
27
|
+
const target = index + direction;
|
|
28
|
+
if (target < 0 || target >= draft.value.length) return;
|
|
29
|
+
const [link] = draft.value.splice(index, 1);
|
|
30
|
+
draft.value.splice(target, 0, link);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const canSave = computed(() => draft.value.every(link => {
|
|
34
|
+
if (!link.label.trim() || !link.url.trim()) return false;
|
|
35
|
+
try {
|
|
36
|
+
const parsed = new URL(link.url);
|
|
37
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
const save = () => {
|
|
44
|
+
if (!canSave.value) return;
|
|
45
|
+
emit('update', 'custom_links', draft.value.map(link => ({
|
|
46
|
+
label: link.label.trim(),
|
|
47
|
+
url: link.url.trim(),
|
|
48
|
+
})));
|
|
49
|
+
saved.value = true;
|
|
50
|
+
setTimeout(() => { saved.value = false; }, 1800);
|
|
51
|
+
};
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<div class="mk-links-editor">
|
|
56
|
+
<div class="mk-links-head">
|
|
57
|
+
<div>
|
|
58
|
+
<h3>{{ $t('media_kit.links.title') }}</h3>
|
|
59
|
+
<p>{{ $t('media_kit.links.description') }}</p>
|
|
60
|
+
</div>
|
|
61
|
+
<button type="button" class="mk-link-add" :disabled="draft.length >= 12" @click="addLink">
|
|
62
|
+
+ {{ $t('media_kit.links.add') }}
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div v-if="draft.length" class="mk-link-list">
|
|
67
|
+
<div v-for="(link, index) in draft" :key="index" class="mk-link-row">
|
|
68
|
+
<span class="mk-link-number">{{ index + 1 }}</span>
|
|
69
|
+
<label>
|
|
70
|
+
<span>{{ $t('media_kit.links.label') }}</span>
|
|
71
|
+
<input v-model="link.label" type="text" maxlength="80" :placeholder="$t('media_kit.links.label_placeholder')" />
|
|
72
|
+
</label>
|
|
73
|
+
<label class="mk-link-url">
|
|
74
|
+
<span>{{ $t('media_kit.links.url') }}</span>
|
|
75
|
+
<input v-model="link.url" type="url" maxlength="2048" placeholder="https://" />
|
|
76
|
+
</label>
|
|
77
|
+
<div class="mk-link-actions">
|
|
78
|
+
<button type="button" :disabled="index === 0" :aria-label="$t('media_kit.links.move_up')" @click="move(index, -1)">↑</button>
|
|
79
|
+
<button type="button" :disabled="index === draft.length - 1" :aria-label="$t('media_kit.links.move_down')" @click="move(index, 1)">↓</button>
|
|
80
|
+
<button type="button" class="danger" :aria-label="$t('media_kit.links.remove')" @click="removeLink(index)">×</button>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
<p v-else class="mk-links-empty">{{ $t('media_kit.links.empty') }}</p>
|
|
85
|
+
|
|
86
|
+
<div class="mk-links-footer">
|
|
87
|
+
<span v-if="draft.length && !canSave" class="mk-links-error">{{ $t('media_kit.links.invalid') }}</span>
|
|
88
|
+
<button type="button" class="mk-link-save" :disabled="!canSave" @click="save">
|
|
89
|
+
{{ saved ? $t('media_kit.links.saved') : $t('media_kit.links.save') }}
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</template>
|
|
94
|
+
|
|
95
|
+
<style scoped lang="scss">
|
|
96
|
+
.mk-links-editor { border: 1px solid rgba(140, 140, 160, .3); padding: 20px; background: rgba(255, 255, 255, .02); }
|
|
97
|
+
.mk-links-head { display: flex; justify-content: space-between; gap: 20px; align-items: start; }
|
|
98
|
+
.mk-links-head h3 { margin: 0 0 5px; font-size: 17px; }
|
|
99
|
+
.mk-links-head p, .mk-links-empty { margin: 0; color: #9292a4; font-size: 13px; line-height: 1.5; }
|
|
100
|
+
.mk-link-add, .mk-link-save { border: 1px solid #ffb000; background: #ffb000; color: #090909; min-height: 38px; padding: 0 16px; font-weight: 750; cursor: pointer; white-space: nowrap; }
|
|
101
|
+
button:disabled { opacity: .42; cursor: not-allowed; }
|
|
102
|
+
.mk-link-list { display: grid; gap: 10px; margin-top: 18px; }
|
|
103
|
+
.mk-link-row { display: grid; grid-template-columns: 30px minmax(150px, .75fr) minmax(220px, 1.4fr) auto; gap: 10px; align-items: end; padding: 12px; border: 1px solid rgba(140, 140, 160, .25); }
|
|
104
|
+
.mk-link-number { align-self: center; color: #ffb000; font-weight: 800; text-align: center; }
|
|
105
|
+
.mk-link-row label { display: grid; gap: 6px; color: #9292a4; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
|
106
|
+
.mk-link-row input { min-width: 0; height: 40px; border: 1px solid rgba(140, 140, 160, .35); background: rgba(0, 0, 0, .2); color: inherit; padding: 0 11px; outline: none; }
|
|
107
|
+
.mk-link-row input:focus { border-color: #ffb000; }
|
|
108
|
+
.mk-link-actions { display: flex; gap: 5px; }
|
|
109
|
+
.mk-link-actions button { width: 36px; height: 40px; border: 1px solid rgba(140, 140, 160, .35); background: transparent; color: inherit; cursor: pointer; }
|
|
110
|
+
.mk-link-actions .danger { color: #ff7b7b; }
|
|
111
|
+
.mk-links-empty { padding: 22px 0 6px; }
|
|
112
|
+
.mk-links-footer { display: flex; align-items: center; justify-content: flex-end; gap: 14px; margin-top: 16px; }
|
|
113
|
+
.mk-links-error { color: #ff9898; font-size: 12px; }
|
|
114
|
+
|
|
115
|
+
@media (max-width: 760px) {
|
|
116
|
+
.mk-links-head { flex-direction: column; }
|
|
117
|
+
.mk-link-row { grid-template-columns: 24px 1fr; }
|
|
118
|
+
.mk-link-row label, .mk-link-actions { grid-column: 2; }
|
|
119
|
+
.mk-link-actions { justify-content: flex-end; }
|
|
120
|
+
}
|
|
121
|
+
</style>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps<{
|
|
3
|
+
store: Record<string, any>;
|
|
4
|
+
contactEmail?: string | null;
|
|
5
|
+
}>();
|
|
6
|
+
|
|
7
|
+
const emit = defineEmits<{
|
|
8
|
+
click: [type: string];
|
|
9
|
+
}>();
|
|
10
|
+
|
|
11
|
+
const products = computed(() => props.store?.products ?? []);
|
|
12
|
+
|
|
13
|
+
function formatPrice(product: Record<string, any>) {
|
|
14
|
+
try {
|
|
15
|
+
return new Intl.NumberFormat(undefined, {
|
|
16
|
+
style: 'currency',
|
|
17
|
+
currency: product.currency || props.store.currency || 'BRL',
|
|
18
|
+
}).format(Number(product.price || 0));
|
|
19
|
+
} catch {
|
|
20
|
+
return `${product.currency || props.store.currency || 'BRL'} ${Number(product.price || 0).toFixed(2)}`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function interestUrl(product: Record<string, any>) {
|
|
25
|
+
if (!props.contactEmail) return null;
|
|
26
|
+
const subject = encodeURIComponent(`${props.store.name} — ${product.name}`);
|
|
27
|
+
return `mailto:${props.contactEmail}?subject=${subject}`;
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<div class="mks">
|
|
33
|
+
<div class="mks__head">
|
|
34
|
+
<div class="mks__identity">
|
|
35
|
+
<img v-if="store.logo_url" :src="store.logo_url" :alt="store.name" />
|
|
36
|
+
<div>
|
|
37
|
+
<p class="mks__eyebrow">{{ $t('kit.media.store_eyebrow') }}</p>
|
|
38
|
+
<h2>{{ store.name }}</h2>
|
|
39
|
+
<p v-if="store.description">{{ store.description }}</p>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
<span class="mks__badge">Mundo Gamer</span>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div v-if="products.length" class="mks__grid">
|
|
46
|
+
<article v-for="product in products" :key="product.id" class="mks__product">
|
|
47
|
+
<div class="mks__image">
|
|
48
|
+
<img v-if="product.image" :src="product.image" :alt="product.name" />
|
|
49
|
+
<span v-else>{{ product.name?.charAt(0) }}</span>
|
|
50
|
+
<em v-if="product.is_featured">{{ $t('kit.media.store_featured') }}</em>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="mks__body">
|
|
53
|
+
<div>
|
|
54
|
+
<h3>{{ product.name }}</h3>
|
|
55
|
+
<p v-if="product.description">{{ product.description }}</p>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="mks__footer">
|
|
58
|
+
<strong>{{ formatPrice(product) }}</strong>
|
|
59
|
+
<a
|
|
60
|
+
v-if="interestUrl(product)"
|
|
61
|
+
:href="interestUrl(product) || undefined"
|
|
62
|
+
@click="emit('click', 'store_product')"
|
|
63
|
+
>{{ $t('kit.media.store_interest') }}</a>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</article>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<p v-else class="mks__empty">{{ $t('kit.media.store_empty') }}</p>
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
72
|
+
|
|
73
|
+
<style scoped lang="scss">
|
|
74
|
+
.mks { display: grid; gap: 22px; }
|
|
75
|
+
.mks__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
|
76
|
+
.mks__identity { display: flex; align-items: center; gap: 16px; min-width: 0; }
|
|
77
|
+
.mks__identity img { width: 64px; height: 64px; object-fit: cover; border: 1px solid var(--kit-line); }
|
|
78
|
+
.mks__identity h2 { margin: 2px 0 4px; font-size: 24px; color: var(--kit-text); }
|
|
79
|
+
.mks__identity p { margin: 0; color: var(--kit-muted); line-height: 1.5; }
|
|
80
|
+
.mks__eyebrow { color: var(--kit-accent) !important; text-transform: uppercase; letter-spacing: .12em; font-size: 10px; font-weight: 800; }
|
|
81
|
+
.mks__badge { flex: none; padding: 6px 9px; border: 1px solid var(--kit-line); color: var(--kit-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
|
|
82
|
+
.mks__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; }
|
|
83
|
+
.mks__product { display: flex; flex-direction: column; min-height: 290px; overflow: hidden; border: 1px solid var(--kit-line); background: var(--kit-surface-2); }
|
|
84
|
+
.mks__image { position: relative; display: grid; place-items: center; aspect-ratio: 4 / 3; overflow: hidden; background: color-mix(in srgb, var(--kit-accent) 10%, var(--kit-bg)); color: var(--kit-accent); font-size: 38px; font-weight: 800; }
|
|
85
|
+
.mks__image img { width: 100%; height: 100%; object-fit: cover; transition: transform .25s ease; }
|
|
86
|
+
.mks__product:hover .mks__image img { transform: scale(1.035); }
|
|
87
|
+
.mks__image em { position: absolute; top: 10px; left: 10px; padding: 5px 7px; background: var(--kit-accent); color: #0c0e12; font-size: 9px; font-style: normal; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
|
|
88
|
+
.mks__body { display: flex; flex: 1; flex-direction: column; justify-content: space-between; gap: 18px; padding: 14px; }
|
|
89
|
+
.mks__body h3 { margin: 0 0 6px; color: var(--kit-text); font-size: 15px; }
|
|
90
|
+
.mks__body p { display: -webkit-box; margin: 0; overflow: hidden; color: var(--kit-muted); font-size: 12px; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
|
91
|
+
.mks__footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
|
92
|
+
.mks__footer strong { color: var(--kit-text); font-size: 15px; }
|
|
93
|
+
.mks__footer a { padding: 7px 9px; border: 1px solid var(--kit-accent); color: var(--kit-accent); font-size: 11px; font-weight: 700; text-decoration: none; }
|
|
94
|
+
.mks__empty { margin: 0; color: var(--kit-muted); }
|
|
95
|
+
|
|
96
|
+
@media (max-width: 560px) {
|
|
97
|
+
.mks__head { flex-direction: column; }
|
|
98
|
+
.mks__badge { display: none; }
|
|
99
|
+
.mks__grid { grid-template-columns: 1fr 1fr; }
|
|
100
|
+
.mks__product { min-height: 250px; }
|
|
101
|
+
.mks__footer { align-items: flex-start; flex-direction: column; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@media (max-width: 390px) {
|
|
105
|
+
.mks__grid { grid-template-columns: 1fr; }
|
|
106
|
+
}
|
|
107
|
+
</style>
|
|
@@ -48,6 +48,7 @@ function gatewayKey(m: { id: number; name: string }): string {
|
|
|
48
48
|
const n = (m.name ?? "").trim().toLowerCase();
|
|
49
49
|
if (n === "paypal" || m.id === 2) return "paypal";
|
|
50
50
|
if (n === "stripe" || m.id === 3) return "stripe";
|
|
51
|
+
if (n === "pix" || m.id === 4) return "pix";
|
|
51
52
|
return n;
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -60,11 +61,12 @@ function getMethodId(m: { id: number; name: string }): number {
|
|
|
60
61
|
const key = gatewayKey(m);
|
|
61
62
|
if (key === "paypal") return 2;
|
|
62
63
|
if (key === "stripe") return 3;
|
|
64
|
+
if (key === "pix") return 4;
|
|
63
65
|
return m.id;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
function getLabel(m: { id: number; name: string }): string {
|
|
67
|
-
const labels: Record<string, string> = { paypal: "PayPal", stripe: "Stripe" };
|
|
69
|
+
const labels: Record<string, string> = { paypal: "PayPal", stripe: "Stripe", pix: "Pix" };
|
|
68
70
|
return labels[gatewayKey(m)] ?? m.name;
|
|
69
71
|
}
|
|
70
72
|
</script>
|
|
@@ -13,8 +13,15 @@ const emit = defineEmits(["update:model"]);
|
|
|
13
13
|
const props = defineProps<SortInputProps>();
|
|
14
14
|
const sortModel = ref(props.model);
|
|
15
15
|
|
|
16
|
-
watch(
|
|
17
|
-
|
|
16
|
+
watch(
|
|
17
|
+
() => props.model,
|
|
18
|
+
(value) => {
|
|
19
|
+
sortModel.value = value;
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
watch(sortModel, (value) => {
|
|
24
|
+
emit("update:model", value);
|
|
18
25
|
});
|
|
19
26
|
|
|
20
27
|
const toggleSort = () => {
|
|
@@ -149,7 +149,7 @@ function handleRequestKey(pool: KeyPool) {
|
|
|
149
149
|
|
|
150
150
|
async function _checkNotifPrompt() {
|
|
151
151
|
try {
|
|
152
|
-
const res = await httpService.get('/user/notification-preferences', {
|
|
152
|
+
const res = await httpService.get('/public/user/notification-preferences', {
|
|
153
153
|
params: { category: 'key_campaigns' },
|
|
154
154
|
})
|
|
155
155
|
if (res.data?.data === null) {
|
|
@@ -163,7 +163,7 @@ async function _checkNotifPrompt() {
|
|
|
163
163
|
async function enableNotifAlerts() {
|
|
164
164
|
notifSaving.value = true
|
|
165
165
|
try {
|
|
166
|
-
await httpService.put('/user/notification-preferences', {
|
|
166
|
+
await httpService.put('/public/user/notification-preferences', {
|
|
167
167
|
category: 'key_campaigns',
|
|
168
168
|
enabled: true,
|
|
169
169
|
channel_email: true,
|
|
@@ -39,8 +39,9 @@ import CriterionRow from "./accessibility/CriterionRow.vue";
|
|
|
39
39
|
import ChecklistItemRow from "./compliance/ChecklistItemRow.vue";
|
|
40
40
|
import SessionCard from "./moderated/SessionCard.vue";
|
|
41
41
|
import JoinCallPanel from "./moderated/JoinCallPanel.vue";
|
|
42
|
+
import VideoPlaytestCapture from "./video/VideoPlaytestCapture.vue";
|
|
42
43
|
|
|
43
|
-
type PlaytestCategory = "baseline" | "concept_poll" | "localization_qa" | "accessibility_audit" | "compliance" | "moderated";
|
|
44
|
+
type PlaytestCategory = "baseline" | "concept_poll" | "localization_qa" | "accessibility_audit" | "compliance" | "moderated" | "video_playtest";
|
|
44
45
|
|
|
45
46
|
const props = defineProps<{
|
|
46
47
|
campaignId: number;
|
|
@@ -306,6 +307,11 @@ async function cancelSessionFlow() {
|
|
|
306
307
|
</button>
|
|
307
308
|
</div>
|
|
308
309
|
|
|
310
|
+
<!-- ── Video Playtest ── -->
|
|
311
|
+
<div v-else-if="category === 'video_playtest'">
|
|
312
|
+
<VideoPlaytestCapture :campaign-id="campaignId" @submitted="handleDone" />
|
|
313
|
+
</div>
|
|
314
|
+
|
|
309
315
|
<!-- ── Moderated ── -->
|
|
310
316
|
<div v-else-if="category === 'moderated'">
|
|
311
317
|
<JoinCallPanel
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Single orchestrator for an accepted response's game-access grant — the
|
|
3
|
+
// component both agency-frontend (mirrored /testing section) and
|
|
4
|
+
// community-frontend (My Tests) actually mount, one per accepted response.
|
|
5
|
+
// Mirrors PlaytestResponseFlow.vue's role for the submission side: owns the
|
|
6
|
+
// composable instance, dispatches to AccessGrantWaiver (not yet signed) or
|
|
7
|
+
// AccessGrantReveal (signed), and renders nothing if the response has no
|
|
8
|
+
// configured access grant.
|
|
9
|
+
import { onMounted } from "vue";
|
|
10
|
+
import { usePlaytestAccessGrant } from "../../../composables/usePlaytestAccessGrant";
|
|
11
|
+
import AccessGrantWaiver from "./AccessGrantWaiver.vue";
|
|
12
|
+
import AccessGrantReveal from "./AccessGrantReveal.vue";
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
responseId: number;
|
|
16
|
+
}>();
|
|
17
|
+
|
|
18
|
+
const grantState = usePlaytestAccessGrant(props.responseId);
|
|
19
|
+
|
|
20
|
+
onMounted(() => {
|
|
21
|
+
grantState.loadAccessGrant();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function sendCode() {
|
|
25
|
+
grantState.initiateWaiver();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function verifyCode(otpCode: string) {
|
|
29
|
+
grantState.verifyWaiver(otpCode);
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div v-if="grantState.loading.value" class="access-grant-panel access-grant-panel--loading">
|
|
35
|
+
<div class="access-grant-panel__skeleton" />
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div v-else-if="grantState.grant.value" class="access-grant-panel">
|
|
39
|
+
<AccessGrantWaiver
|
|
40
|
+
v-if="!grantState.grant.value.revealed"
|
|
41
|
+
:submitting="grantState.submitting.value"
|
|
42
|
+
:waiver-initiated="grantState.waiverInitiated.value"
|
|
43
|
+
:error="grantState.genericError.value"
|
|
44
|
+
@send-code="sendCode"
|
|
45
|
+
@verify="verifyCode"
|
|
46
|
+
/>
|
|
47
|
+
<AccessGrantReveal
|
|
48
|
+
v-else
|
|
49
|
+
:access-type="grantState.grant.value.access_type"
|
|
50
|
+
:key-code="grantState.grant.value.key_code"
|
|
51
|
+
:external-link="grantState.grant.value.external_link"
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<style scoped lang="scss">
|
|
57
|
+
.access-grant-panel {
|
|
58
|
+
padding: 16px;
|
|
59
|
+
background: var(--bg-app-badge);
|
|
60
|
+
border: 1px solid var(--button-secondary-default-bg);
|
|
61
|
+
|
|
62
|
+
&--loading {
|
|
63
|
+
padding: 0;
|
|
64
|
+
border: none;
|
|
65
|
+
background: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
&__skeleton {
|
|
69
|
+
width: 100%;
|
|
70
|
+
height: 64px;
|
|
71
|
+
background: var(--bg-app-badge);
|
|
72
|
+
animation: access-grant-panel-pulse 1.4s ease-in-out infinite;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@keyframes access-grant-panel-pulse {
|
|
77
|
+
0%, 100% { opacity: 1; }
|
|
78
|
+
50% { opacity: 0.4; }
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Masked-reveal-then-copy for a signed access grant. Unlike the key-campaigns
|
|
3
|
+
// redeem flow, the real value already arrived in the API response the moment
|
|
4
|
+
// the waiver was verified — "reveal" here is a deliberate client-side
|
|
5
|
+
// unmask, not a second network round-trip. Mirrors
|
|
6
|
+
// pages/key-campaigns/redeem-key-approved.vue's code-box UI.
|
|
7
|
+
import { ref } from "vue";
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
accessType: "key" | "link";
|
|
11
|
+
keyCode?: string | null;
|
|
12
|
+
externalLink?: string | null;
|
|
13
|
+
}>();
|
|
14
|
+
|
|
15
|
+
const unmasked = ref(false);
|
|
16
|
+
const copied = ref(false);
|
|
17
|
+
|
|
18
|
+
const value = props.accessType === "key" ? (props.keyCode ?? "") : (props.externalLink ?? "");
|
|
19
|
+
|
|
20
|
+
async function copyValue() {
|
|
21
|
+
if (!value) return;
|
|
22
|
+
try {
|
|
23
|
+
await navigator.clipboard.writeText(value);
|
|
24
|
+
copied.value = true;
|
|
25
|
+
setTimeout(() => { copied.value = false; }, 2000);
|
|
26
|
+
} catch {
|
|
27
|
+
// clipboard API unavailable — the value is still selectable in the input
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<div class="access-grant-reveal">
|
|
34
|
+
<h3 class="access-grant-reveal__title">
|
|
35
|
+
{{ accessType === "key"
|
|
36
|
+
? $t("playtest.access_grant.your_key", "Your game key")
|
|
37
|
+
: $t("playtest.access_grant.your_link", "Your download link") }}
|
|
38
|
+
</h3>
|
|
39
|
+
|
|
40
|
+
<div class="access-grant-reveal__box">
|
|
41
|
+
<input
|
|
42
|
+
v-if="!unmasked"
|
|
43
|
+
type="text"
|
|
44
|
+
value="••••••••••••••••"
|
|
45
|
+
readonly
|
|
46
|
+
class="access-grant-reveal__input access-grant-reveal__input--masked"
|
|
47
|
+
/>
|
|
48
|
+
<input
|
|
49
|
+
v-else
|
|
50
|
+
type="text"
|
|
51
|
+
:value="value"
|
|
52
|
+
readonly
|
|
53
|
+
class="access-grant-reveal__input"
|
|
54
|
+
/>
|
|
55
|
+
|
|
56
|
+
<button
|
|
57
|
+
v-if="!unmasked"
|
|
58
|
+
type="button"
|
|
59
|
+
class="btn primary"
|
|
60
|
+
@click="unmasked = true"
|
|
61
|
+
>
|
|
62
|
+
{{ $t("playtest.access_grant.reveal", "Reveal") }}
|
|
63
|
+
</button>
|
|
64
|
+
<button
|
|
65
|
+
v-else
|
|
66
|
+
type="button"
|
|
67
|
+
class="btn primary"
|
|
68
|
+
@click="copyValue"
|
|
69
|
+
>
|
|
70
|
+
<MGIcon :icon="copied ? 'check' : 'copy'" size="1x" />
|
|
71
|
+
{{ copied ? $t("playtest.access_grant.copied", "Copied") : $t("playtest.access_grant.copy", "Copy") }}
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<a
|
|
76
|
+
v-if="unmasked && accessType === 'link'"
|
|
77
|
+
:href="value"
|
|
78
|
+
target="_blank"
|
|
79
|
+
rel="noopener"
|
|
80
|
+
class="access-grant-reveal__open-link"
|
|
81
|
+
>
|
|
82
|
+
{{ $t("playtest.access_grant.open_link", "Open link") }}
|
|
83
|
+
</a>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
86
|
+
|
|
87
|
+
<style scoped lang="scss">
|
|
88
|
+
.access-grant-reveal {
|
|
89
|
+
display: flex;
|
|
90
|
+
flex-direction: column;
|
|
91
|
+
gap: 10px;
|
|
92
|
+
|
|
93
|
+
&__title {
|
|
94
|
+
font-size: 15px;
|
|
95
|
+
font-weight: 700;
|
|
96
|
+
color: var(--card-article-title, #fff);
|
|
97
|
+
margin: 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
&__box {
|
|
101
|
+
display: flex;
|
|
102
|
+
align-items: center;
|
|
103
|
+
gap: 6px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
&__input {
|
|
107
|
+
display: flex;
|
|
108
|
+
padding: 12px;
|
|
109
|
+
background-color: transparent;
|
|
110
|
+
height: 44px;
|
|
111
|
+
border: 1px solid var(--bg-app-badge);
|
|
112
|
+
width: 100%;
|
|
113
|
+
font-size: 14px;
|
|
114
|
+
color: var(--card-article-title, #fff);
|
|
115
|
+
|
|
116
|
+
&--masked {
|
|
117
|
+
letter-spacing: 3px;
|
|
118
|
+
color: rgba(128, 128, 128, 0.5);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
&__open-link {
|
|
123
|
+
align-self: flex-start;
|
|
124
|
+
font-size: 12px;
|
|
125
|
+
color: var(--primary, #d297ff);
|
|
126
|
+
|
|
127
|
+
&:hover {
|
|
128
|
+
text-decoration: underline;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
</style>
|