@mundogamernetwork/shared-ui 1.8.17 → 1.8.19

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.
@@ -12,7 +12,7 @@
12
12
  <!-- Bundle Form -->
13
13
  <div v-if="showForm" class="bundle-form">
14
14
  <div class="visibility-note">
15
- <MGIcon icon="info" />
15
+ <MGIcon icon="about" />
16
16
  <span>{{ $t("media_kit.bundles.visibility_note") }}</span>
17
17
  </div>
18
18
  <div class="form-group">
@@ -47,7 +47,7 @@
47
47
  <span class="card-price">{{ formatPrice(card.price_min, card.currency) }}</span>
48
48
  </div>
49
49
  <div class="check-indicator">
50
- <MGIcon icon="checkmark" v-if="form.rate_card_ids.includes(card.id)" />
50
+ <MGIcon icon="verified" v-if="form.rate_card_ids.includes(card.id)" />
51
51
  </div>
52
52
  <div v-if="form.rate_card_ids.includes(card.id)" class="card-qty" @click.stop.prevent>
53
53
  <span>×</span>
@@ -24,11 +24,11 @@
24
24
  </select>
25
25
  </div>
26
26
  <div class="form-group">
27
- <label>{{ $t("media_kit.editor.platform") }}</label>
28
- <select v-model="form.platform_id" :class="{ 'input-error': rateCardErrors.platform_id }" @change="delete rateCardErrors.platform_id" required>
27
+ <label>{{ $t("media_kit.editor.ad_channel") }}</label>
28
+ <select v-model="form.ad_channel_id" :class="{ 'input-error': rateCardErrors.ad_channel_id }" @change="delete rateCardErrors.ad_channel_id" required>
29
29
  <option value="">{{ $t("media_kit.editor.select") }}</option>
30
- <option v-for="platform in platforms" :key="platform.id" :value="platform.id">
31
- {{ platform.name }}
30
+ <option v-for="channel in adChannels" :key="channel.id" :value="channel.id">
31
+ {{ channel.name }}
32
32
  </option>
33
33
  </select>
34
34
  </div>
@@ -110,7 +110,7 @@
110
110
  <div v-for="card in rateCards" :key="card.id" class="rate-card-item">
111
111
  <div class="card-info">
112
112
  <span class="format">{{ card.content_format?.name || card.label }}</span>
113
- <span class="channel">{{ card.platform?.name }}</span>
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
  <div class="card-actions">
@@ -134,7 +134,7 @@ const { t } = useI18n();
134
134
  interface RateCard {
135
135
  id: number;
136
136
  content_format_id: number;
137
- platform_id: number;
137
+ ad_channel_id: number;
138
138
  label?: string;
139
139
  price_min: string;
140
140
  price_max?: string;
@@ -143,7 +143,7 @@ interface RateCard {
143
143
  description?: string;
144
144
  is_active: boolean;
145
145
  content_format?: { id: number; name: string };
146
- platform?: { id: number; name: string };
146
+ ad_channel?: { id: number; name: string };
147
147
  }
148
148
 
149
149
  interface LookupItem {
@@ -156,7 +156,7 @@ const props = withDefaults(
156
156
  rateCards: RateCard[];
157
157
  influencerProInfoId: number;
158
158
  contentFormats: LookupItem[];
159
- platforms: LookupItem[];
159
+ adChannels: LookupItem[];
160
160
  isEnabled?: boolean;
161
161
  /** MGN commission % for the current plan (host app resolves this, e.g. from feature_limits). */
162
162
  commissionPercent?: number;
@@ -196,7 +196,7 @@ const saving = ref(false);
196
196
 
197
197
  const defaultForm = {
198
198
  content_format_id: '',
199
- platform_id: '',
199
+ ad_channel_id: '',
200
200
  price_min: '',
201
201
  price_max: '',
202
202
  currency: 'USD',
@@ -265,7 +265,7 @@ const editCard = (card: RateCard) => {
265
265
  editing.value = card.id;
266
266
  form.value = {
267
267
  content_format_id: String(card.content_format_id),
268
- platform_id: String(card.platform_id),
268
+ ad_channel_id: String(card.ad_channel_id),
269
269
  price_min: card.price_min,
270
270
  price_max: card.price_max || '',
271
271
  currency: card.currency,
@@ -283,7 +283,7 @@ const saveRateCard = async () => {
283
283
  // Client-side required field check (HTML required bypassed by @click)
284
284
  Object.keys(rateCardErrors).forEach(k => delete rateCardErrors[k]);
285
285
  if (!form.value.content_format_id) rateCardErrors.content_format_id = 'required';
286
- if (!form.value.platform_id) rateCardErrors.platform_id = 'required';
286
+ if (!form.value.ad_channel_id) rateCardErrors.ad_channel_id = 'required';
287
287
  if (!form.value.price_min && form.value.price_min !== 0) rateCardErrors.price_min = 'required';
288
288
  if (Object.keys(rateCardErrors).length) return;
289
289
 
@@ -293,7 +293,7 @@ const saveRateCard = async () => {
293
293
  ...form.value,
294
294
  influencer_professional_info_id: props.influencerProInfoId,
295
295
  content_format_id: Number(form.value.content_format_id),
296
- platform_id: Number(form.value.platform_id),
296
+ ad_channel_id: Number(form.value.ad_channel_id),
297
297
  price_min: Number(form.value.price_min),
298
298
  price_max: form.value.price_max ? Number(form.value.price_max) : null,
299
299
  delivery_time_days: form.value.delivery_time_days ? Number(form.value.delivery_time_days) : null,
@@ -80,7 +80,7 @@ function downloadAll() {
80
80
  <div class="gallery-header">
81
81
  <h3 class="section-title">{{ $t('press_kit.assets') }}</h3>
82
82
  <button v-if="allowDownload && assets.length" class="btn-download-all" @click="downloadAll">
83
- <MGIcon icon="download" /> {{ $t('press_kit.download_all') }}
83
+ <MGIcon icon="save" /> {{ $t('press_kit.download_all') }}
84
84
  </button>
85
85
  </div>
86
86
 
@@ -134,7 +134,7 @@ function downloadAll() {
134
134
  <div class="lb-info">
135
135
  <span>{{ imageAssets[lightboxIndex]?.title || imageAssets[lightboxIndex]?.type }}</span>
136
136
  <button v-if="allowDownload" class="lb-download" @click="downloadAsset(imageAssets[lightboxIndex])">
137
- <MGIcon icon="download" /> {{ $t('press_kit.download') }}
137
+ <MGIcon icon="save" /> {{ $t('press_kit.download') }}
138
138
  </button>
139
139
  </div>
140
140
  </div>
@@ -27,7 +27,7 @@ defineProps<Props>();
27
27
  rel="noopener"
28
28
  :class="['award-card', { 'no-link': !award.url }]"
29
29
  >
30
- <MGIcon icon="trophy" class="award-icon" />
30
+ <MGIcon icon="achievements" class="award-icon" />
31
31
  <div class="award-info">
32
32
  <span class="award-title">{{ award.title }}</span>
33
33
  <span v-if="award.event || award.year" class="award-event">
@@ -27,7 +27,7 @@ const youtubeEmbedUrl = computed(() => {
27
27
  <h1 v-else class="hero-title">{{ name }}</h1>
28
28
  <p v-if="tagline" class="hero-tagline">{{ tagline }}</p>
29
29
  <button v-if="trailerUrl" class="btn-trailer" @click="showTrailer = true">
30
- <MGIcon icon="play" /> {{ $t('press_kit.watch_trailer') }}
30
+ <MGIcon icon="videos" /> {{ $t('press_kit.watch_trailer') }}
31
31
  </button>
32
32
  </div>
33
33
  </div>
@@ -55,7 +55,7 @@ function formatType(type?: string) {
55
55
  :class="['video-item', { active: activeVideo?.id === video.id }]"
56
56
  @click="activeVideo = video"
57
57
  >
58
- <MGIcon icon="play-circle" />
58
+ <MGIcon icon="videos" />
59
59
  <div class="video-meta">
60
60
  <span class="video-title">{{ video.title }}</span>
61
61
  <span v-if="video.type" class="video-type">{{ formatType(video.type) }}</span>
@@ -89,10 +89,16 @@ function formatCountdown(availableAt: string | null | undefined): string {
89
89
  return h > 0 ? `${h}h ${m}m` : `${m}m`;
90
90
  }
91
91
 
92
+ // key-campaigns (plural) is the canonical route on every front. The singular
93
+ // used to be a second, standalone copy of this page in shared-ui — worse than
94
+ // the real one (hardcoded English, no embargo handling) and the reason a shared
95
+ // link previewed differently depending on which app served it. It is now a
96
+ // server-level 301, so defaulting here to the singular would only send every
97
+ // card click through a needless redirect.
92
98
  const detailRoute = computed(() =>
93
99
  props.publicMode
94
- ? `/${locale.value}/${props.baseRoute ?? 'key-campaign'}/${props.card.slug}`
95
- : `/${locale.value}/dashboard/${props.baseRoute ?? 'key-campaign'}/${props.card.slug}`
100
+ ? `/${locale.value}/${props.baseRoute ?? 'key-campaigns'}/${props.card.slug}`
101
+ : `/${locale.value}/dashboard/${props.baseRoute ?? 'key-campaigns'}/${props.card.slug}`
96
102
  );
97
103
  </script>
98
104
 
@@ -130,38 +136,38 @@ const detailRoute = computed(() =>
130
136
 
131
137
  <!-- Exclusive badge -->
132
138
  <div v-if="card.is_exclusive && !userCanAccess" class="kc-card__exclusive-badge kc-card__exclusive-lock">
133
- <i class="fa-solid fa-crown" /> {{ $t("keys.card.exclusive_upgrade") }}
139
+ <MGIcon size="1x" icon="reward" /> {{ $t("keys.card.exclusive_upgrade") }}
134
140
  </div>
135
141
  <div v-else-if="card.is_exclusive" class="kc-card__exclusive-badge">
136
- <i class="fa-solid fa-crown" /> {{ $t("keys.card.exclusive") }}
142
+ <MGIcon size="1x" icon="reward" /> {{ $t("keys.card.exclusive") }}
137
143
  </div>
138
144
 
139
145
  <!-- Tier-locked badge -->
140
146
  <div v-else-if="card.is_tier_locked && card.access_tier !== 'free' && !userCanAccess" class="kc-card__exclusive-badge">
141
- <i class="fa-solid fa-crown" /> {{ cardTierLabel(card.access_tier) }}
147
+ <MGIcon size="1x" icon="reward" /> {{ cardTierLabel(card.access_tier) }}
142
148
  </div>
143
149
 
144
150
  <!-- Early access badge -->
145
151
  <div v-else-if="(card.early_access_hours ?? 0) > 0 && userCanAccess" class="kc-card__early-access-badge">
146
- <i class="fa-solid fa-bolt" /> {{ $t("keys.card.early_access") }}
152
+ <MGIcon size="1x" icon="clock" /> {{ $t("keys.card.early_access") }}
147
153
  </div>
148
154
 
149
155
  <!-- Streamer level badge -->
150
156
  <div v-if="card.streamer_type === 2 || card.streamer_type === 3" class="kc-card__exclusive-badge kc-card__level-badge">
151
- <i class="fa-solid fa-user-check" />
157
+ <MGIcon size="1x" icon="verified" />
152
158
  {{ card.streamer_type === 2 ? $t("keys.card.level_official") : $t("keys.card.level_affiliate") }}
153
159
  </div>
154
160
 
155
161
  <!-- Early-access lock overlay -->
156
162
  <div v-if="!userCanAccess && card.available_at_for_user" class="kc-card__lock-overlay">
157
- <i class="fa-solid fa-lock" />
163
+ <MGIcon size="1x" icon="lock" />
158
164
  <span class="lock-label">{{ $t("keys.card.early_access_lock_title") }}</span>
159
165
  <span class="lock-countdown">{{ $t("keys.card.early_access_opens_in") }} {{ formatCountdown(card.available_at_for_user) }}</span>
160
166
  </div>
161
167
 
162
168
  <!-- Exclusive lock overlay -->
163
169
  <div v-else-if="!userCanAccess && card.is_exclusive" class="kc-card__lock-overlay kc-card__lock-overlay--exclusive">
164
- <i class="fa-solid fa-crown" />
170
+ <MGIcon size="1x" icon="reward" />
165
171
  <span class="lock-label">{{ $t("keys.card.exclusive_lock_title") }}</span>
166
172
  <span class="lock-cta">{{ $t("keys.card.exclusive_upgrade_cta") }}</span>
167
173
  </div>
@@ -198,7 +204,7 @@ const detailRoute = computed(() =>
198
204
  </NuxtLink>
199
205
  <NuxtLink v-if="!publicMode" :to="`/${locale}/dashboard/pricing`">
200
206
  <button class="btn secondary upgrade-btn">
201
- <i class="fa-solid fa-arrow-up" /> {{ $t("keys.card.upgrade_plan") }}
207
+ <MGIcon size="1x" icon="chevron-up" /> {{ $t("keys.card.upgrade_plan") }}
202
208
  </button>
203
209
  </NuxtLink>
204
210
  </template>
@@ -54,7 +54,7 @@ const categoryLabel = computed(() => {
54
54
  </div>
55
55
 
56
56
  <div v-if="campaign.eligible === false" class="pt-brief__ineligible">
57
- <i class="fa-solid fa-lock" />
57
+ <MGIcon size="1x" icon="lock" />
58
58
  <span>{{ campaign.ineligibility_reason || $t("playtest.brief.ineligible_generic", "You don't currently qualify for this playtest.") }}</span>
59
59
  </div>
60
60
 
@@ -23,8 +23,8 @@ const emit = defineEmits<{
23
23
  <template>
24
24
  <div class="pt-pending" :class="{ 'pt-pending--ineligible': ineligible }">
25
25
  <div class="pt-pending__icon">
26
- <i v-if="ineligible" class="fa-solid fa-circle-exclamation" />
27
- <i v-else class="fa-solid fa-circle-check" />
26
+ <MGIcon v-if="ineligible" size="1x" icon="exclamation" />
27
+ <MGIcon v-else size="1x" icon="verified-filled" />
28
28
  </div>
29
29
 
30
30
  <h3 class="pt-pending__title">
@@ -65,7 +65,7 @@ function submit() {
65
65
  :aria-label="$t('playtest.concept_poll.move_up', 'Move up')"
66
66
  @click="moveUp(index)"
67
67
  >
68
- <i class="fa-solid fa-arrow-up" />
68
+ <MGIcon size="1x" icon="chevron-up" />
69
69
  </button>
70
70
  <button
71
71
  type="button"
@@ -74,7 +74,7 @@ function submit() {
74
74
  :aria-label="$t('playtest.concept_poll.move_down', 'Move down')"
75
75
  @click="moveDown(index)"
76
76
  >
77
- <i class="fa-solid fa-arrow-down" />
77
+ <MGIcon size="1x" icon="chevron-down" />
78
78
  </button>
79
79
  </span>
80
80
  </li>
@@ -35,7 +35,7 @@ const scheduledAt = computed(() => (props.session?.scheduled_at ? new Date(props
35
35
  <p class="join-call-panel__time">{{ scheduledAt?.toLocaleString() }}</p>
36
36
 
37
37
  <div v-if="session.tester_confirmed_at" class="join-call-panel__confirmed-badge">
38
- <i class="fa-solid fa-circle-check" />
38
+ <MGIcon size="1x" icon="verified-filled" />
39
39
  {{ $t("playtest.moderated.you_confirmed", "You've confirmed attendance") }}
40
40
  </div>
41
41
 
@@ -106,7 +106,7 @@ const handleLogout = async () => {
106
106
 
107
107
  <div v-if="features.wallet && user?.coins !== null" class="row info-row">
108
108
  <span class="coins">
109
- <MGIcon icon="coin" />
109
+ <MGIcon icon="coin-v2" />
110
110
  {{ Math.floor(user?.coins ?? 0).toLocaleString() }}
111
111
  </span>
112
112
  </div>
@@ -43,7 +43,7 @@ const isSubscriber = computed(() => !!(user.value as any)?.subscription?.is_subs
43
43
  <template>
44
44
  <div class="mg-wallet-plan">
45
45
  <span v-if="showWallet" class="mg-wallet-plan__coins">
46
- <MGIcon icon="coin" />
46
+ <MGIcon icon="coin-v2" />
47
47
  {{ Math.floor(walletBalance).toLocaleString() }}
48
48
  <span class="mg-wallet-plan__currency">MGC</span>
49
49
  </span>
package/locales/de.json CHANGED
@@ -195,6 +195,8 @@
195
195
  "required_data": "Erforderliche Angaben",
196
196
  "already_requested": "Sie haben bereits eine Anfrage für diese Kampagne gestellt.",
197
197
  "sold_out": "Alle Keys für diese Kampagne wurden bereits eingelöst.",
198
+ "keys_left": "{n} Key übrig | {n} Keys übrig",
199
+ "keys_low_stock": "Letzter Key | Letzte {n}",
198
200
  "back_to_campaign": "Zur Kampagne zurückkehren",
199
201
  "status_pending": "Anfrage ausstehend — bitte warten Sie auf die Genehmigung",
200
202
  "status_approved": "Anfrage genehmigt — Sie können jetzt Ihren Schlüssel abrufen",
package/locales/en.json CHANGED
@@ -195,6 +195,8 @@
195
195
  "required_data": "Required data",
196
196
  "already_requested": "You have already submitted a request for this campaign.",
197
197
  "sold_out": "All keys for this campaign have already been claimed.",
198
+ "keys_left": "{n} key left | {n} keys left",
199
+ "keys_low_stock": "Last key | Last {n}",
198
200
  "back_to_campaign": "Back to campaign",
199
201
  "status_pending": "Request pending — waiting for approval",
200
202
  "status_approved": "Request approved — you can now retrieve your key",
package/locales/es.json CHANGED
@@ -195,6 +195,8 @@
195
195
  "required_data": "Datos requeridos",
196
196
  "already_requested": "Ya has enviado una solicitud para esta campaña.",
197
197
  "sold_out": "Todas las claves de esta campaña ya fueron reclamadas.",
198
+ "keys_left": "{n} clave restante | {n} claves restantes",
199
+ "keys_low_stock": "Última clave | Últimas {n}",
198
200
  "back_to_campaign": "Volver a la campaña",
199
201
  "status_pending": "Solicitud pendiente — esperando aprobación",
200
202
  "status_approved": "Solicitud aprobada — ya puedes obtener tu clave",
@@ -195,6 +195,8 @@
195
195
  "required_data": "Dados obrigatórios",
196
196
  "already_requested": "Você já enviou uma solicitação para esta campanha.",
197
197
  "sold_out": "Todas as chaves desta campanha já foram resgatadas.",
198
+ "keys_left": "{n} chave restante | {n} chaves restantes",
199
+ "keys_low_stock": "Última chave | Últimas {n}",
198
200
  "back_to_campaign": "Voltar para a campanha",
199
201
  "status_pending": "Solicitação pendente — aguardando aprovação",
200
202
  "status_approved": "Solicitação aprovada — você pode resgatar sua chave",
package/locales/ro.json CHANGED
@@ -195,6 +195,8 @@
195
195
  "required_data": "Date obligatorii",
196
196
  "already_requested": "Ați trimis deja o cerere pentru această campanie.",
197
197
  "sold_out": "Toate cheile pentru această campanie au fost deja revendicate.",
198
+ "keys_left": "{n} cheie rămasă | {n} chei rămase",
199
+ "keys_low_stock": "Ultima cheie | Ultimele {n}",
198
200
  "back_to_campaign": "Înapoi la campanie",
199
201
  "status_pending": "Cerere în așteptare — așteptați aprobarea",
200
202
  "status_approved": "Cerere aprobată — puteți prelua cheia",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.8.17",
3
+ "version": "1.8.19",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
package/pages/icons.vue CHANGED
@@ -1,13 +1,48 @@
1
1
  <script setup lang="ts">
2
2
  definePageMeta({ layout: "default" });
3
3
 
4
+ // The real contents of the custom-icons font (assets/scss/_icons.scss in
5
+ // each consuming app — identical across tv/community/agency).
6
+ //
7
+ // This list used to be a plausible-sounding invention: "bell", "user",
8
+ // "star", "check", "info", "download"... none of which exist in the font.
9
+ // A catalogue that lists icons you cannot use is worse than no catalogue —
10
+ // it is where `icon="check"` and friends came from, rendering an empty
11
+ // element on real screens.
4
12
  const icons = [
5
- "home", "search", "message", "bell", "gear", "user", "chevron-left",
6
- "chevron-right", "chevron-down", "chevron-up", "close", "menu",
7
- "link", "star", "heart", "share", "edit", "trash", "plus", "minus",
8
- "check", "info", "warning", "error", "calendar", "clock", "download",
9
- "upload", "filter", "sort", "grid", "list", "eye", "eye-off",
10
- "lock", "unlock", "mail", "phone", "map", "globe", "flag",
13
+ "about", "achievements", "add-community", "add-friend", "add-team", "added", "administration",
14
+ "ai", "ai-vr", "analytics-research", "animation", "arcade", "arts", "arts-crafts", "audio-sound",
15
+ "battle-royale", "beta-feature", "bug", "business-operations", "camera", "camera-solid",
16
+ "cancel-message", "cancel-notification", "card-games", "cart", "cel-v1", "cel-v2", "chevron-down",
17
+ "chevron-left", "chevron-right", "chevron-up", "childcare-voucher", "clear", "clock",
18
+ "clock-filled", "close", "coin-v1", "coin-v2", "community", "community-filled",
19
+ "community-management", "community-plus", "contact-v2", "coop", "creatormode",
20
+ "customer-services", "cycle", "dental-healthcare", "discord", "discounts", "discover",
21
+ "display-blocks", "display-list", "double-", "double-chevron-left", "e-sports", "edit",
22
+ "editorial-journalism", "education", "educational", "engineering", "event-notification",
23
+ "exclamation", "exclusive-themes", "external-link", "eye", "eye-slash", "facebook", "facebook-v2",
24
+ "faq", "feed", "fight", "filter", "flexible-work", "follow", "free-food", "gambling", "game-jam",
25
+ "game-release-notification", "game-room", "games", "games-filled", "gear", "gear-dev", "giveaway",
26
+ "gym", "hack-slash", "handshake-filled", "healthcheck", "home", "horror", "impressum",
27
+ "infinity-community", "infinity-team", "information-technology", "instagran", "interaction",
28
+ "jack-of-all-trades", "job-apply", "jolife-insurance", "language", "language-class", "legal",
29
+ "like", "like-filled", "link", "linkedin", "linkedin-v2", "list", "localisation", "location",
30
+ "location-v1", "location-v2", "lock", "log-status", "mail-v1", "mail-v2", "manage-team",
31
+ "membership", "menu", "message", "message-active", "money", "moon", "more",
32
+ "narrative-design-writting", "new-friend", "news", "no-ads", "no-data", "no-dresscode",
33
+ "not-interested", "notification", "notification-active", "office-library", "official-streamer",
34
+ "official-streamer-filled", "official-writer", "operations", "others", "paid-overtime", "partner",
35
+ "partner-filled", "phone", "picture", "plan", "plan-filled", "platformer", "politics",
36
+ "private-medic", "procurement-supply-chain", "production", "profile", "profile-filled",
37
+ "programmmig-development", "public-relations", "public-transport", "puzzlers",
38
+ "quality-assurance", "quality-control", "question", "racing", "rate", "recognition",
39
+ "recruitment", "region", "report", "retirement", "retro", "reward", "roadmap", "rpg",
40
+ "sales-representative", "save", "save-filled", "search", "senior-management", "share",
41
+ "share-filled", "shooter", "simulation", "simulator", "smile", "social-medias", "sort", "sports",
42
+ "stipend", "stocks", "strategy", "strategy-1", "stream", "suppot", "survival", "team",
43
+ "team-event", "telegran", "terms", "text-size", "theme", "tiktok", "training", "trash", "twitter",
44
+ "twitter-v2", "twtch", "ux-design", "verified", "verified-filled", "version", "videos", "vr",
45
+ "wallet", "web-development", "webpage", "whatsapp", "wiki", "youtube",
11
46
  ];
12
47
  </script>
13
48
 
@@ -127,44 +127,49 @@
127
127
  @error="(e) => { (e.target as HTMLImageElement).src = '/imgs/no-cover-img.jpg' }"
128
128
  />
129
129
 
130
+ <!-- Scarcity only. The plain count now lives in the card's
131
+ info row, where card metadata is actually read — a bare
132
+ number floating on the artwork said nothing, and the icon
133
+ that was supposed to explain it never rendered. Up here it
134
+ earns its place only when stock is running out. -->
130
135
  <div
131
136
  class="keys-badge"
132
- v-if="card.available_count > 0 && card.user_can_access && !card.isExpired && !card.isUpcoming"
137
+ v-if="showsLowStock(card)"
133
138
  :title="$t('keys.campaigns.keys_left', { n: card.available_count })"
134
139
  >
135
- <i class="fa-solid fa-key" />
136
- {{ card.available_count }}
140
+ <MGIcon size="1x" icon="giveaway" />
141
+ {{ $t("keys.campaigns.keys_low_stock", { n: card.available_count }) }}
137
142
  </div>
138
143
 
139
144
  <div
140
145
  v-if="card.is_exclusive && !card.user_can_access"
141
146
  class="exclusive-badge exclusive-lock"
142
147
  >
143
- <i class="fa-solid fa-crown" /> {{ $t("keys.campaigns.exclusive_upgrade") }}
148
+ <MGIcon size="1x" icon="reward" /> {{ $t("keys.campaigns.exclusive_upgrade") }}
144
149
  </div>
145
150
  <div v-else-if="card.is_exclusive" class="exclusive-badge">
146
- <i class="fa-solid fa-crown" /> {{ $t("keys.campaigns.exclusive") }}
151
+ <MGIcon size="1x" icon="reward" /> {{ $t("keys.campaigns.exclusive") }}
147
152
  </div>
148
153
 
149
154
  <div
150
155
  v-else-if="card.is_tier_locked && card.access_tier !== 'free' && !card.user_can_access"
151
156
  class="exclusive-badge"
152
157
  >
153
- <i class="fa-solid fa-crown" /> {{ cardTierLabel(card.access_tier) }}
158
+ <MGIcon size="1x" icon="reward" /> {{ cardTierLabel(card.access_tier) }}
154
159
  </div>
155
160
 
156
161
  <div
157
162
  v-else-if="card.early_access_hours > 0 && card.user_can_access"
158
163
  class="early-access-badge"
159
164
  >
160
- <i class="fa-solid fa-bolt" /> {{ $t("keys.campaigns.early_access") }}
165
+ <MGIcon size="1x" icon="clock" /> {{ $t("keys.campaigns.early_access") }}
161
166
  </div>
162
167
 
163
168
  <div
164
169
  v-if="card.streamer_type === 2 || card.streamer_type === 3"
165
170
  class="exclusive-badge level-badge"
166
171
  >
167
- <i class="fa-solid fa-user-check" />
172
+ <MGIcon size="1x" icon="verified" />
168
173
  {{
169
174
  card.streamer_type === 2
170
175
  ? $t("keys.campaigns.restriction.level_official")
@@ -176,7 +181,7 @@
176
181
  v-if="!card.user_can_access && card.available_at_for_user"
177
182
  class="early-access-lock-overlay"
178
183
  >
179
- <i class="fa-solid fa-lock" />
184
+ <MGIcon size="1x" icon="lock" />
180
185
  <span class="lock-label">{{ $t("keys.campaigns.early_access_lock_title") }}</span>
181
186
  <span class="lock-countdown">
182
187
  {{ $t("keys.campaigns.early_access_opens_in") }}
@@ -188,7 +193,7 @@
188
193
  v-else-if="!card.user_can_access && card.is_exclusive"
189
194
  class="exclusive-lock-overlay"
190
195
  >
191
- <i class="fa-solid fa-crown" />
196
+ <MGIcon size="1x" icon="reward" />
192
197
  <span class="lock-label">{{ $t("keys.campaigns.exclusive_lock_title") }}</span>
193
198
  <span class="lock-cta">{{ $t("keys.campaigns.exclusive_upgrade_cta") }}</span>
194
199
  </div>
@@ -208,6 +213,15 @@
208
213
  <div class="days" v-if="card.time > 0 && card.user_can_access">
209
214
  <span>{{ card.time }}</span> {{ $t("keys.campaigns.days") }}
210
215
  </div>
216
+ <!-- Labelled, next to the deadline: the two numbers a creator
217
+ actually decides on are "how long left" and "how many left". -->
218
+ <span
219
+ v-if="card.available_count > 0 && card.user_can_access && !card.isExpired && !card.isUpcoming"
220
+ class="card-keys-left"
221
+ >
222
+ <MGIcon size="1x" icon="giveaway" />
223
+ {{ $t("keys.campaigns.keys_left", { n: card.available_count }) }}
224
+ </span>
211
225
  <span>
212
226
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
213
227
  <path d="M13.3333 4.1112L12.8666 3.64453L11.9333 4.57786L10.9333 3.64453L8.33329 4.1112L2.66663 9.77786L7.19996 14.3112L12.8666 8.64453L13.3333 6.04453L12.4 5.1112L13.3333 4.1112ZM12.6 6.24453L12.2666 8.3112L7.19996 13.3779L3.59996 9.77786L8.66663 4.7112L10.7333 4.3112L11.4666 5.04453L11 5.5112L10.5333 5.04453L9.99996 5.57786L11.4 6.97786L11.8666 6.5112L11.4 6.04453L11.8666 5.57786L12.6 6.24453Z" fill="var(--key-accent, var(--key-accent, var(--primary, #D297FF)))" />
@@ -231,7 +245,7 @@
231
245
  </nuxt-link>
232
246
  <nuxt-link :to="upgradeRoute">
233
247
  <button class="btn secondary upgrade-btn">
234
- <i class="fa-solid fa-arrow-up" />
248
+ <MGIcon size="1x" icon="chevron-up" />
235
249
  {{ $t("keys.campaigns.upgrade_plan") }}
236
250
  </button>
237
251
  </nuxt-link>
@@ -473,6 +487,20 @@ function platformAbbr(name: string): string {
473
487
  return PLATFORM_ABBR[name.toLowerCase()] ?? name
474
488
  }
475
489
 
490
+ /**
491
+ * At or below this many keys left, the cover badge appears as a scarcity
492
+ * signal. Above it, the count is just a fact and lives in the info row.
493
+ */
494
+ const LOW_STOCK_THRESHOLD = 5
495
+
496
+ function showsLowStock(card: any): boolean {
497
+ return card.available_count > 0
498
+ && card.available_count <= LOW_STOCK_THRESHOLD
499
+ && card.user_can_access
500
+ && !card.isExpired
501
+ && !card.isUpcoming
502
+ }
503
+
476
504
  function cardTierLabel(tier: string): string {
477
505
  const labels: Record<string, string> = { free: "Free", pro: "PRO", business: "Business", official: "Official" }
478
506
  return $i18n.t("keys.campaigns.restriction.tier_locked", { tier: labels[tier] || tier })
@@ -874,6 +902,15 @@ onUnmounted(() => {
874
902
  opacity: 0.5;
875
903
  }
876
904
 
905
+ .card-keys-left {
906
+ display: inline-flex;
907
+ align-items: center;
908
+ gap: 4px;
909
+ white-space: nowrap;
910
+
911
+ i.icon { color: var(--key-accent, var(--primary, #D297FF)); }
912
+ }
913
+
877
914
  .keys-badge {
878
915
  position: absolute;
879
916
  top: 8px;
@@ -150,7 +150,7 @@
150
150
  <!-- Success modal -->
151
151
  <div v-if="showSuccessModal" class="success-modal-overlay" @click.self="handleSuccessClose">
152
152
  <div class="success-modal">
153
- <MGIcon size="2x" icon="check" style="color: #42FF00" />
153
+ <MGIcon size="2x" icon="verified" style="color: #42FF00" />
154
154
  <p>{{ $t("keys.materials.success_message") }}</p>
155
155
  <button class="btn button-submit" @click="handleSuccessClose">
156
156
  {{ $t("keys.materials.success_close") }}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts" setup>
2
- import { ref, computed, inject, onMounted, unref, watch } from "vue"
2
+ import { ref, computed, inject, onMounted, onUnmounted, unref, watch } from "vue"
3
3
  import { useRoute, navigateTo } from "#app"
4
4
  import { useNuxtApp } from "#app"
5
5
  import {
@@ -51,6 +51,13 @@ const embargoAckChecked = ref(false)
51
51
 
52
52
  const maxKeysForUser = computed(() => campaign.value?.max_keys_for_user ?? 1)
53
53
 
54
+ // Where the success screen sends the user back to. Falls back to the id the
55
+ // page was opened with, which fetchCampaignBySlug also accepts.
56
+ const campaignPath = computed(
57
+ () => `/${locale.value}/key-campaigns/${campaign.value?.slug || keyId}`,
58
+ )
59
+ let redirectTimer: ReturnType<typeof setTimeout> | null = null
60
+
54
61
  const embargoDateTime = computed(() =>
55
62
  formatEmbargoDateTime(campaign.value?.embargo_date, campaign.value?.embargo_timezone, locale.value),
56
63
  )
@@ -196,6 +203,13 @@ async function submit() {
196
203
  return
197
204
  }
198
205
  success.value = true
206
+ // The success screen says "Redirecting…", so actually redirect: it used
207
+ // to sit there forever, leaving the user staring at a promise that never
208
+ // resolved with only a manual back button to escape.
209
+ redirectTimer = setTimeout(() => {
210
+ redirectTimer = null
211
+ navigateTo(campaignPath.value)
212
+ }, 2000)
199
213
  } catch (e: any) {
200
214
  const status = e?.response?.status
201
215
  const errorCode = e?.response?.data?.error_code
@@ -223,12 +237,25 @@ async function submit() {
223
237
  }
224
238
 
225
239
  function goBack() {
240
+ // Tapping the button beats the pending auto-redirect to the punch.
241
+ if (redirectTimer) {
242
+ clearTimeout(redirectTimer)
243
+ redirectTimer = null
244
+ }
245
+ if (success.value) {
246
+ navigateTo(campaignPath.value)
247
+ return
248
+ }
226
249
  if (typeof window !== "undefined" && window.history.length > 1) {
227
250
  window.history.back()
228
251
  } else {
229
252
  navigateTo(`/${locale.value}/key-campaigns`)
230
253
  }
231
254
  }
255
+
256
+ onUnmounted(() => {
257
+ if (redirectTimer) clearTimeout(redirectTimer)
258
+ })
232
259
  </script>
233
260
 
234
261
  <template>
@@ -248,7 +275,7 @@ function goBack() {
248
275
  <div v-else-if="error" class="state error">{{ error }}</div>
249
276
 
250
277
  <div v-else-if="success" class="state success">
251
- <MGIcon icon="check-circle" size="3x" />
278
+ <MGIcon icon="verified-filled" size="3x" />
252
279
  <p>{{ $t("keys.campaigns.request_sent") }}</p>
253
280
  <button class="btn" @click="goBack">
254
281
  {{ $t("keys.campaigns.back_to_campaign") }}
@@ -292,7 +319,7 @@ function goBack() {
292
319
  <div class="required-data">
293
320
  <div class="required-data-header" @click="toggleBox">
294
321
  <div class="required-data-header-left">
295
- <MGIcon size="1x" icon="check" :style="{ color: isBoxOpen ? 'var(--key-accent, var(--primary, #D297FF))' : 'var(--inactive)' }" />
322
+ <MGIcon size="1x" icon="verified" :style="{ color: isBoxOpen ? 'var(--key-accent, var(--primary, #D297FF))' : 'var(--inactive)' }" />
296
323
  <span>{{ $t("keys.campaigns.required_data") }}</span>
297
324
  </div>
298
325
  <button type="button" class="box-toggler" :aria-expanded="isBoxOpen">
@@ -1,164 +0,0 @@
1
- <script setup lang="ts">
2
- // Public, shareable standalone key-campaign page. Reached from broadcast links
3
- // (Discord/Telegram/WhatsApp) and from anywhere — no need to enter a dashboard.
4
- // Anyone can view; requesting a key uses KeyBrowser's hybrid auth (it redirects
5
- // to login on 401 and returns).
6
- definePageMeta({ layout: 'blank' });
7
-
8
- const route = useRoute();
9
- const slug = route.params.slug as string;
10
-
11
- const SUPPORTED_LOCALES = ['en', 'pt-BR', 'es', 'de', 'ro', 'ar-AE'];
12
- const routePath = route.path.split('/');
13
- const lang = SUPPORTED_LOCALES.find(l => routePath.includes(l)) || 'en';
14
-
15
- const config = useRuntimeConfig();
16
- // keyApiUrl overrides apiBaseURL — lets each front point to the API that
17
- // registered the shared key routes.
18
- // Follow the same override pattern as presskit/game/[slug].vue.
19
- const cfg = (config.public as any)?.mgSharedUi || config.public;
20
- const withApiV1 = (base?: string) => {
21
- if (!base) return '';
22
- const clean = base.replace(/\/$/, '');
23
- return /\/api\/v1$/.test(clean) ? clean : `${clean}/api/v1`;
24
- };
25
- const apiBase: string = withApiV1(
26
- (config.public as any).keyApiUrl ||
27
- cfg.keyApiUrl ||
28
- (config.public.apiBaseURL as string) ||
29
- '',
30
- );
31
-
32
- const { data: keyData, error: fetchError, pending, refresh } = await useAsyncData(
33
- `public-key-${slug}`,
34
- () => $fetch<any>(`${apiBase}/public/keys/${slug}`, { params: { lang } }),
35
- { lazy: false, server: !!apiBase },
36
- );
37
-
38
- // If SSR produced no data (e.g. apiBase was empty during SSR), run a client-side
39
- // fetch once mounted so the request appears in DevTools and the page can recover.
40
- onMounted(async () => {
41
- if (!keyData.value && !pending.value) {
42
- await refresh();
43
- }
44
- });
45
-
46
- const key = computed(() => keyData.value?.data || null);
47
- const loading = pending;
48
- const error = computed(() => !!fetchError.value || (!pending.value && !key.value));
49
-
50
- const { $i18n } = useNuxtApp()
51
- const t = $i18n.t.bind($i18n)
52
-
53
- useSeoMeta({
54
- title: () => key.value?.name ? t('keys.campaigns.seo.title', { game: key.value.name }) : t('keys.campaigns.seo.title_generic'),
55
- description: () => key.value?.name ? t('keys.campaigns.seo.description', { game: key.value.name }) : t('keys.campaigns.seo.title_generic'),
56
- ogTitle: () => key.value?.name ? t('keys.campaigns.seo.title', { game: key.value.name }) : t('keys.campaigns.seo.title_generic'),
57
- ogDescription: () => key.value?.name ? t('keys.campaigns.seo.description', { game: key.value.name }) : t('keys.campaigns.seo.title_generic'),
58
- ogImage: () => key.value?.cover_src || key.value?.cover || undefined,
59
- ogType: 'website',
60
- twitterCard: 'summary_large_image',
61
- })
62
-
63
- const restriction = computed(() => {
64
- const k = key.value;
65
- if (!k) return '';
66
- if (k.is_exclusive) return 'Exclusive to official streamers';
67
- if (k.is_tier_locked && k.access_tier && k.access_tier !== 'free') return `Exclusive to ${String(k.access_tier).toUpperCase()} plan or higher`;
68
- if (k.streamer_type_id === 2) return 'Exclusive to official streamers';
69
- if (k.streamer_type_id === 3) return 'Exclusive to affiliates';
70
- return '';
71
- });
72
- </script>
73
-
74
- <template>
75
- <div class="kc">
76
- <div v-if="loading" class="kc__state">Loading…</div>
77
- <div v-else-if="error" class="kc__state">Campaign not found.</div>
78
-
79
- <template v-else>
80
- <div class="kc__hero">
81
- <img
82
- v-if="key.cover_src"
83
- :src="key.cover_src"
84
- :alt="key.name"
85
- class="kc__cover"
86
- :style="{ objectPosition: `${(key.cover_focal_x ?? 0.5) * 100}% ${(key.cover_focal_y ?? 0.5) * 100}%` }"
87
- >
88
- <div class="kc__hero-body">
89
- <h1 class="kc__title">{{ key.name }}</h1>
90
- <div v-if="restriction" class="kc__badge">🔒 {{ restriction }}</div>
91
- <div class="kc__avail">
92
- <span v-if="key.available_count > 0">{{ key.available_count }} {{ key.available_count === 1 ? 'key' : 'keys' }} available to redeem</span>
93
- <span v-else>Keys sold out</span>
94
- </div>
95
- </div>
96
- </div>
97
-
98
- <div class="kc__browser">
99
- <KeyBrowser
100
- requester-context="user"
101
- :game-id="key.game?.id || key.game_id"
102
- />
103
- </div>
104
-
105
- <KeyCampaignsCarousel :exclude-id="key.id ?? null" :public-mode="true" base-route="key-campaign" />
106
- </template>
107
- </div>
108
- </template>
109
-
110
- <style scoped lang="scss">
111
- .kc {
112
- max-width: 960px;
113
- margin: 0 auto;
114
- padding: 24px 16px 64px;
115
-
116
- &__state {
117
- padding: 80px 0;
118
- text-align: center;
119
- color: var(--text-secondary, #888);
120
- }
121
-
122
- &__hero {
123
- display: flex;
124
- gap: 20px;
125
- align-items: flex-start;
126
- margin-bottom: 28px;
127
- flex-wrap: wrap;
128
- }
129
-
130
- &__cover {
131
- width: 260px;
132
- max-width: 100%;
133
- height: auto;
134
- object-fit: cover;
135
- border: 1px solid var(--border-color, #2a2a2a);
136
- }
137
-
138
- &__hero-body { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 10px; }
139
-
140
- &__title {
141
- font-size: 1.6rem;
142
- font-weight: 700;
143
- color: var(--text-primary, #fff);
144
- margin: 0;
145
- }
146
-
147
- &__badge {
148
- display: inline-flex;
149
- align-items: center;
150
- gap: 6px;
151
- width: fit-content;
152
- padding: 4px 10px;
153
- font-size: 0.8rem;
154
- font-weight: 600;
155
- color: var(--key-accent, var(--primary, #D297FF));
156
- border: 1px solid rgba(210, 151, 255, 0.35);
157
- background: rgba(210, 151, 255, 0.12);
158
- }
159
-
160
- &__avail { font-size: 0.9rem; color: var(--text-secondary, #aaa); }
161
-
162
- &__browser { margin-top: 8px; }
163
- }
164
- </style>