@mundogamernetwork/shared-ui 1.9.0 → 1.11.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.
|
@@ -113,11 +113,26 @@
|
|
|
113
113
|
<span class="channel">{{ card.ad_channel?.name }}</span>
|
|
114
114
|
<span class="price">{{ formatPrice(card.price_min, card.price_max, card.currency) }}</span>
|
|
115
115
|
</div>
|
|
116
|
-
|
|
116
|
+
<!-- Two-step delete rather than a modal: this component ships to
|
|
117
|
+
several host apps and cannot assume any particular confirm
|
|
118
|
+
dialog exists in the host. The trash icon used to fire the
|
|
119
|
+
DELETE on the first click, with no way back. -->
|
|
120
|
+
<div v-if="pendingDeleteId === card.id" class="card-actions confirm">
|
|
121
|
+
<span class="confirm-label">{{ $t("media_kit.editor.confirm_delete") }}</span>
|
|
122
|
+
<button @click="removeCard(card.id)" class="btn-confirm-delete">
|
|
123
|
+
{{ $t("media_kit.editor.confirm_delete_yes") }}
|
|
124
|
+
</button>
|
|
125
|
+
<!-- Reuses the editor's existing cancel key, which every host
|
|
126
|
+
app already ships, so only two keys are new. -->
|
|
127
|
+
<button @click="pendingDeleteId = null" class="btn-cancel-delete">
|
|
128
|
+
{{ $t("media_kit.editor.cancel") }}
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
<div v-else class="card-actions">
|
|
117
132
|
<button @click="editCard(card)" class="btn-edit">
|
|
118
133
|
<MGIcon icon="edit" />
|
|
119
134
|
</button>
|
|
120
|
-
<button @click="
|
|
135
|
+
<button @click="pendingDeleteId = card.id" class="btn-delete">
|
|
121
136
|
<MGIcon icon="trash" />
|
|
122
137
|
</button>
|
|
123
138
|
</div>
|
|
@@ -315,9 +330,13 @@ const saveRateCard = async () => {
|
|
|
315
330
|
}
|
|
316
331
|
};
|
|
317
332
|
|
|
333
|
+
/** Card awaiting a second click to confirm its deletion. */
|
|
334
|
+
const pendingDeleteId = ref<number | null>(null);
|
|
335
|
+
|
|
318
336
|
const removeCard = async (id: number) => {
|
|
319
337
|
try {
|
|
320
338
|
await deleteRateCard(id);
|
|
339
|
+
pendingDeleteId.value = null;
|
|
321
340
|
emit('refresh');
|
|
322
341
|
} catch (error) {
|
|
323
342
|
emit('error', t('media_kit.editor.error_generic'));
|
|
@@ -605,6 +624,36 @@ const netDisplay = computed(() => {
|
|
|
605
624
|
.btn-delete:hover i {
|
|
606
625
|
color: #ff4d4f;
|
|
607
626
|
}
|
|
627
|
+
|
|
628
|
+
&.confirm {
|
|
629
|
+
align-items: center;
|
|
630
|
+
gap: 12px;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
.confirm-label {
|
|
634
|
+
color: #AAA;
|
|
635
|
+
font-size: 13px;
|
|
636
|
+
white-space: nowrap;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
.btn-confirm-delete,
|
|
640
|
+
.btn-cancel-delete {
|
|
641
|
+
font-size: 13px;
|
|
642
|
+
padding: 4px 10px;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.btn-confirm-delete {
|
|
646
|
+
color: #ff4d4f;
|
|
647
|
+
font-weight: 600;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
.btn-cancel-delete {
|
|
651
|
+
color: #AAA;
|
|
652
|
+
|
|
653
|
+
&:hover {
|
|
654
|
+
color: #FFF;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
608
657
|
}
|
|
609
658
|
}
|
|
610
659
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Public landing for a studio's playtest invitation link.
|
|
3
|
+
//
|
|
4
|
+
// Shared rather than owned by one app for the same reason PlaytestResponseFlow
|
|
5
|
+
// is: the tester experience already mounts in both community-frontend and
|
|
6
|
+
// agency-frontend, and an invitee can legitimately arrive at either. The page
|
|
7
|
+
// files in each app are thin wrappers around this.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately readable with no account. Playtest participation genuinely
|
|
10
|
+
// requires one — playtest_responses.user_id is NOT NULL and rewards are paid
|
|
11
|
+
// in MGC, which needs a wallet — but a login wall in front of a stranger being
|
|
12
|
+
// asked for a favour is the highest-friction possible version of that request.
|
|
13
|
+
// So the game, the reward and the deadline come first, and the account is only
|
|
14
|
+
// asked for at the point of accepting.
|
|
15
|
+
import { computed, onMounted, ref } from "vue";
|
|
16
|
+
import {
|
|
17
|
+
fetchPlaytestInvitation,
|
|
18
|
+
type PlaytestInvitationLanding,
|
|
19
|
+
} from "../../services/playtestTesterService";
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
token: string;
|
|
23
|
+
/**
|
|
24
|
+
* Where accepting sends someone who has no account yet. Supplied by the
|
|
25
|
+
* consuming app because the registration host differs per product, and
|
|
26
|
+
* carrying the invitation token through means the invite is not lost in
|
|
27
|
+
* the signup.
|
|
28
|
+
*/
|
|
29
|
+
registerUrl: string;
|
|
30
|
+
/** Route to the campaign for a visitor who is already signed in. */
|
|
31
|
+
campaignPath: string;
|
|
32
|
+
signedIn?: boolean;
|
|
33
|
+
}>();
|
|
34
|
+
|
|
35
|
+
const { t } = useI18n();
|
|
36
|
+
|
|
37
|
+
const invitation = ref<PlaytestInvitationLanding | null>(null);
|
|
38
|
+
const loading = ref(true);
|
|
39
|
+
/** 'not_found' (bad link) vs 'gone' (spent or expired) — different messages. */
|
|
40
|
+
const failure = ref<"not_found" | "gone" | "error" | null>(null);
|
|
41
|
+
|
|
42
|
+
const campaign = computed(() => invitation.value?.campaign ?? null);
|
|
43
|
+
|
|
44
|
+
const rewardLabel = computed(() => {
|
|
45
|
+
const c = campaign.value;
|
|
46
|
+
if (!c || c.reward_amount === null || c.reward_amount === undefined) return "";
|
|
47
|
+
|
|
48
|
+
return c.reward_type === "cash"
|
|
49
|
+
? `${c.reward_amount} ${c.reward_currency ?? "USD"}`
|
|
50
|
+
: `${c.reward_amount} MGC`;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const expiresLabel = computed(() => {
|
|
54
|
+
const raw = invitation.value?.expires_at;
|
|
55
|
+
if (!raw) return "";
|
|
56
|
+
|
|
57
|
+
const date = new Date(raw);
|
|
58
|
+
|
|
59
|
+
return Number.isNaN(date.getTime()) ? "" : date.toLocaleDateString();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
onMounted(async () => {
|
|
63
|
+
try {
|
|
64
|
+
const { data } = await fetchPlaytestInvitation(props.token);
|
|
65
|
+
invitation.value = ((data as any)?.data ?? data) as PlaytestInvitationLanding;
|
|
66
|
+
} catch (e: any) {
|
|
67
|
+
// The endpoint separates these on purpose: 404 means the link is wrong,
|
|
68
|
+
// 410 means it was real and is no longer usable. Telling someone their
|
|
69
|
+
// link is invalid when it simply expired sends them looking for a
|
|
70
|
+
// typo that is not there.
|
|
71
|
+
const status = e?.response?.status;
|
|
72
|
+
failure.value = status === 404 ? "not_found" : status === 410 ? "gone" : "error";
|
|
73
|
+
} finally {
|
|
74
|
+
loading.value = false;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<div class="pil">
|
|
81
|
+
<div v-if="loading" class="pil__loading">
|
|
82
|
+
<div class="pil__skeleton pil__skeleton--title" />
|
|
83
|
+
<div class="pil__skeleton" />
|
|
84
|
+
<div class="pil__skeleton" />
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div v-else-if="failure" class="pil__failure">
|
|
88
|
+
<h1>{{ t(`playtest_invite.failure_${failure}_title`) }}</h1>
|
|
89
|
+
<p>{{ t(`playtest_invite.failure_${failure}_body`) }}</p>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<template v-else>
|
|
93
|
+
<p class="pil__eyebrow">{{ t('playtest_invite.eyebrow') }}</p>
|
|
94
|
+
<h1 class="pil__title">{{ campaign?.title }}</h1>
|
|
95
|
+
<p class="pil__lead">{{ t('playtest_invite.lead') }}</p>
|
|
96
|
+
|
|
97
|
+
<div class="pil__facts">
|
|
98
|
+
<div v-if="rewardLabel" class="pil__fact">
|
|
99
|
+
<strong>{{ rewardLabel }}</strong>
|
|
100
|
+
<span>{{ t('playtest_invite.fact_reward') }}</span>
|
|
101
|
+
</div>
|
|
102
|
+
<div v-if="expiresLabel" class="pil__fact">
|
|
103
|
+
<strong>{{ expiresLabel }}</strong>
|
|
104
|
+
<span>{{ t('playtest_invite.fact_expires') }}</span>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<!-- Someone already signed in has no reason to see a signup CTA. -->
|
|
109
|
+
<NuxtLink v-if="signedIn" :to="campaignPath" class="pil__cta">
|
|
110
|
+
{{ t('playtest_invite.cta_open') }}
|
|
111
|
+
</NuxtLink>
|
|
112
|
+
<a v-else :href="registerUrl" class="pil__cta">
|
|
113
|
+
{{ t('playtest_invite.cta_join') }}
|
|
114
|
+
</a>
|
|
115
|
+
|
|
116
|
+
<!-- Stated plainly rather than discovered at the end of a signup:
|
|
117
|
+
taking part needs an account because the reward is paid into a
|
|
118
|
+
wallet. Surprising someone with that after they commit is worse
|
|
119
|
+
than telling them now. -->
|
|
120
|
+
<p class="pil__note">{{ t('playtest_invite.account_note') }}</p>
|
|
121
|
+
|
|
122
|
+
<div class="pil__perks">
|
|
123
|
+
<h2>{{ t('playtest_invite.perks_title') }}</h2>
|
|
124
|
+
<ul>
|
|
125
|
+
<li>{{ t('playtest_invite.perk_playtests') }}</li>
|
|
126
|
+
<li>{{ t('playtest_invite.perk_keys') }}</li>
|
|
127
|
+
<li>{{ t('playtest_invite.perk_rewards') }}</li>
|
|
128
|
+
</ul>
|
|
129
|
+
</div>
|
|
130
|
+
</template>
|
|
131
|
+
</div>
|
|
132
|
+
</template>
|
|
133
|
+
|
|
134
|
+
<style scoped lang="scss">
|
|
135
|
+
.pil {
|
|
136
|
+
margin: 0 auto;
|
|
137
|
+
max-width: 640px;
|
|
138
|
+
padding: 40px 20px 64px;
|
|
139
|
+
|
|
140
|
+
&__eyebrow {
|
|
141
|
+
color: #fdb215;
|
|
142
|
+
font-size: 0.8rem;
|
|
143
|
+
font-weight: 700;
|
|
144
|
+
letter-spacing: 0.08em;
|
|
145
|
+
margin: 0 0 8px;
|
|
146
|
+
text-transform: uppercase;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
&__title {
|
|
150
|
+
font-size: 1.9rem;
|
|
151
|
+
line-height: 1.2;
|
|
152
|
+
margin: 0 0 12px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
&__lead {
|
|
156
|
+
font-size: 1rem;
|
|
157
|
+
line-height: 1.6;
|
|
158
|
+
margin: 0 0 24px;
|
|
159
|
+
opacity: 0.85;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
&__facts {
|
|
163
|
+
display: flex;
|
|
164
|
+
flex-wrap: wrap;
|
|
165
|
+
gap: 12px;
|
|
166
|
+
margin-bottom: 24px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
&__fact {
|
|
170
|
+
border: 1px solid rgba(128, 128, 128, 0.3);
|
|
171
|
+
display: flex;
|
|
172
|
+
flex-direction: column;
|
|
173
|
+
gap: 2px;
|
|
174
|
+
min-width: 140px;
|
|
175
|
+
padding: 12px 16px;
|
|
176
|
+
|
|
177
|
+
strong { font-size: 1.15rem; }
|
|
178
|
+
span { font-size: 0.78rem; opacity: 0.7; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
&__cta {
|
|
182
|
+
background: #fdb215;
|
|
183
|
+
color: #13161c;
|
|
184
|
+
display: inline-block;
|
|
185
|
+
font-size: 1rem;
|
|
186
|
+
font-weight: 700;
|
|
187
|
+
padding: 14px 32px;
|
|
188
|
+
text-decoration: none;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
&__note {
|
|
192
|
+
font-size: 0.85rem;
|
|
193
|
+
margin: 14px 0 0;
|
|
194
|
+
opacity: 0.7;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
&__perks {
|
|
198
|
+
border-top: 1px solid rgba(128, 128, 128, 0.25);
|
|
199
|
+
margin-top: 36px;
|
|
200
|
+
padding-top: 24px;
|
|
201
|
+
|
|
202
|
+
h2 { font-size: 1rem; margin: 0 0 12px; }
|
|
203
|
+
|
|
204
|
+
ul { margin: 0; padding-left: 20px; }
|
|
205
|
+
li { line-height: 1.7; opacity: 0.85; }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
&__failure {
|
|
209
|
+
padding: 40px 0;
|
|
210
|
+
text-align: center;
|
|
211
|
+
|
|
212
|
+
h1 { font-size: 1.4rem; margin: 0 0 10px; }
|
|
213
|
+
p { margin: 0; opacity: 0.75; }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
&__skeleton {
|
|
217
|
+
background: rgba(128, 128, 128, 0.18);
|
|
218
|
+
height: 18px;
|
|
219
|
+
margin-bottom: 12px;
|
|
220
|
+
|
|
221
|
+
&--title { height: 34px; width: 70%; }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
</style>
|
package/package.json
CHANGED
|
@@ -293,6 +293,32 @@ export const fetchPlaytestCatalog = () =>
|
|
|
293
293
|
export const fetchAvailablePlaytestCampaigns = () =>
|
|
294
294
|
httpService.get<PlaytestCampaign[]>('/playtest/available')
|
|
295
295
|
|
|
296
|
+
export interface PlaytestInvitationLanding {
|
|
297
|
+
email: string
|
|
298
|
+
name: string | null
|
|
299
|
+
expires_at: string | null
|
|
300
|
+
campaign: {
|
|
301
|
+
id: number
|
|
302
|
+
title: string | null
|
|
303
|
+
category: string | null
|
|
304
|
+
reward_type: string | null
|
|
305
|
+
reward_amount: number | string | null
|
|
306
|
+
reward_currency: string | null
|
|
307
|
+
closes_at: string | null
|
|
308
|
+
} | null
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Public: what a studio's invitation link resolves to.
|
|
313
|
+
*
|
|
314
|
+
* Unauthenticated by design — the invitee may have no account yet, and the
|
|
315
|
+
* point is to show them what they were invited to before asking for one.
|
|
316
|
+
* Answers 404 for an unknown token and 410 once the invitation is spent or
|
|
317
|
+
* expired, so the caller can tell "wrong link" from "too late".
|
|
318
|
+
*/
|
|
319
|
+
export const fetchPlaytestInvitation = (token: string) =>
|
|
320
|
+
httpService.get<{ data: PlaytestInvitationLanding }>(`/playtest/invitations/${token}`)
|
|
321
|
+
|
|
296
322
|
// ---------------------------------------------------------------------------
|
|
297
323
|
// Baseline (quick/deep, category=null)
|
|
298
324
|
// ---------------------------------------------------------------------------
|