@mundogamernetwork/shared-ui 1.3.2 → 1.3.6

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.
@@ -34,6 +34,23 @@ body:has(.kit-press) {
34
34
  color: var(--kit-muted, #8b92a0);
35
35
  font-size: 1rem;
36
36
  }
37
+ .kit-state--not-found {
38
+ min-height: 100vh;
39
+ justify-content: flex-start;
40
+ padding-top: 15vh;
41
+ width: 100%;
42
+ }
43
+ .kit-state__desc {
44
+ max-width: 420px;
45
+ text-align: center;
46
+ margin: -8px 0 4px;
47
+ font-size: 0.9rem;
48
+ line-height: 1.5;
49
+ }
50
+ .kit-more--not-found {
51
+ width: 100%;
52
+ margin-top: 40px;
53
+ }
37
54
  .kit-state h2 {
38
55
  font-size: 1.5rem;
39
56
  color: var(--kit-text, #f4f6fa);
@@ -11,6 +11,10 @@
11
11
 
12
12
  <!-- Bundle Form -->
13
13
  <div v-if="showForm" class="bundle-form">
14
+ <div class="visibility-note">
15
+ <MGIcon icon="info" />
16
+ <span>{{ $t("media_kit.bundles.visibility_note") }}</span>
17
+ </div>
14
18
  <div class="form-group">
15
19
  <label>{{ $t("media_kit.bundles.bundle_name") }}</label>
16
20
  <input v-model="form.name" type="text" :placeholder="$t('media_kit.bundles.name_placeholder')" />
@@ -105,6 +109,20 @@
105
109
  </div>
106
110
  </div>
107
111
 
112
+ <label class="visibility-checkbox" :class="{ disabled: !isSubscriber }">
113
+ <input
114
+ type="checkbox"
115
+ :checked="showPublicly"
116
+ :disabled="!isSubscriber"
117
+ @change="onToggleVisibility(($event.target as HTMLInputElement).checked)"
118
+ />
119
+ <span>{{ $t("media_kit.bundles.show_publicly") }}</span>
120
+ <button v-if="!isSubscriber" type="button" class="upgrade-link" @click.prevent="emit('paywall', 'rates')">
121
+ <MGIcon icon="lock" />
122
+ {{ $t("media_kit.editor.upgrade_to_show") }}
123
+ </button>
124
+ </label>
125
+
108
126
  <div class="form-actions">
109
127
  <button class="btn-cancel" @click="cancelForm">{{ $t("media_kit.editor.cancel") }}</button>
110
128
  <button class="btn-save" @click="saveBundle" :disabled="saving || form.rate_card_ids.length < 2">
@@ -186,19 +204,32 @@ const props = withDefaults(
186
204
  availableRateCards: RateCard[];
187
205
  influencerProInfoId: number;
188
206
  isEnabled?: boolean;
207
+ /** Whether the account has an active paid subscription — gates public visibility only, not creation. */
208
+ isSubscriber?: boolean;
209
+ /** Current value of the account-wide "show bundles publicly" setting. */
210
+ showPublicly?: boolean;
189
211
  }>(),
190
212
  {
191
213
  isEnabled: true,
214
+ isSubscriber: false,
215
+ showPublicly: true,
192
216
  }
193
217
  );
194
218
 
195
219
  const emit = defineEmits<{
196
220
  (e: "refresh"): void;
197
221
  (e: "error", message: string): void;
198
- /** Bundles remain a premium feature host app decides how to react (e.g. show its own paywall modal). */
222
+ /** Fired when a non-subscriber clicks the upgrade CTA next to the disabled checkbox, or when isEnabled is false at save time. */
199
223
  (e: "paywall", feature: string): void;
224
+ /** Fired when the "show publicly" checkbox changes (only reachable while isSubscriber). */
225
+ (e: "toggle-visibility", value: boolean): void;
200
226
  }>();
201
227
 
228
+ function onToggleVisibility(value: boolean) {
229
+ if (!props.isSubscriber) return;
230
+ emit('toggle-visibility', value);
231
+ }
232
+
202
233
  const showForm = ref(false);
203
234
  const editing = ref<number | null>(null);
204
235
  const saving = ref(false);
@@ -216,6 +247,53 @@ const defaultForm = {
216
247
 
217
248
  const form = ref({ ...defaultForm, quantities: {} });
218
249
 
250
+ // --- Draft persistence (new-item only) --------------------------------------
251
+ // Guards against losing a half-filled form on accidental navigation/refresh.
252
+ // Scoped per profile so switching accounts in the same browser can't leak a
253
+ // draft between them. Only the "new item" flow is persisted — editing an
254
+ // existing bundle already has server data as the source of truth.
255
+ const DRAFT_KEY = computed(() => `mgn_bundle_draft_${props.influencerProInfoId}`);
256
+
257
+ function loadDraft() {
258
+ if (typeof window === 'undefined') return null;
259
+ try {
260
+ const raw = window.localStorage.getItem(DRAFT_KEY.value);
261
+ return raw ? JSON.parse(raw) : null;
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ function saveDraft() {
268
+ if (typeof window === 'undefined' || editing.value) return;
269
+ try {
270
+ window.localStorage.setItem(DRAFT_KEY.value, JSON.stringify(form.value));
271
+ } catch {
272
+ // storage unavailable (private browsing, quota) — draft just won't persist
273
+ }
274
+ }
275
+
276
+ function clearDraft() {
277
+ if (typeof window === 'undefined') return;
278
+ try {
279
+ window.localStorage.removeItem(DRAFT_KEY.value);
280
+ } catch {
281
+ // ignore
282
+ }
283
+ }
284
+
285
+ watch(form, () => {
286
+ if (!editing.value) saveDraft();
287
+ }, { deep: true });
288
+
289
+ onMounted(() => {
290
+ const draft = loadDraft();
291
+ if (draft) {
292
+ form.value = { ...defaultForm, quantities: {}, ...draft };
293
+ showForm.value = true;
294
+ }
295
+ });
296
+
219
297
  function qtyFor(id: number): number {
220
298
  return form.value.quantities[id] || 1;
221
299
  }
@@ -254,6 +332,7 @@ function cancelForm() {
254
332
  showForm.value = false;
255
333
  editing.value = null;
256
334
  form.value = { ...defaultForm, rate_card_ids: [], quantities: {} };
335
+ clearDraft();
257
336
  }
258
337
 
259
338
  function editBundle(bundle: Bundle) {
@@ -353,7 +432,58 @@ async function removeBundle(id: number) {
353
432
  margin: -8px 0 0;
354
433
  }
355
434
 
435
+ .visibility-checkbox {
436
+ display: flex;
437
+ align-items: center;
438
+ gap: 8px;
439
+ font-size: 13px;
440
+ color: #E4E6EB;
441
+ cursor: pointer;
442
+
443
+ input[type="checkbox"] {
444
+ width: 16px;
445
+ height: 16px;
446
+ cursor: pointer;
447
+ }
448
+
449
+ &.disabled {
450
+ cursor: not-allowed;
451
+ color: #7A7D89;
452
+
453
+ input[type="checkbox"] { cursor: not-allowed; }
454
+ }
455
+
456
+ .upgrade-link {
457
+ display: inline-flex;
458
+ align-items: center;
459
+ gap: 4px;
460
+ margin-left: 4px;
461
+ padding: 3px 8px;
462
+ background: none;
463
+ border: 1px solid #FDB215;
464
+ color: #FDB215;
465
+ font-size: 12px;
466
+ cursor: pointer;
467
+
468
+ &:hover { background: rgba(253, 178, 21, 0.1); }
469
+ }
470
+ }
471
+
356
472
  .bundle-form {
473
+ .visibility-note {
474
+ display: flex;
475
+ align-items: flex-start;
476
+ gap: 8px;
477
+ padding: 10px 12px;
478
+ background: rgba(255, 255, 255, 0.04);
479
+ border: 1px solid rgba(255, 255, 255, 0.08);
480
+ border-radius: 4px;
481
+ font-size: 13px;
482
+ color: #B7BAC4;
483
+
484
+ svg { flex-shrink: 0; margin-top: 2px; }
485
+ }
486
+
357
487
  background: #1E1E22;
358
488
  padding: 20px;
359
489
  display: flex;
@@ -8,6 +8,11 @@
8
8
  </div>
9
9
 
10
10
  <div v-if="showForm" class="rate-card-form">
11
+ <div class="visibility-note">
12
+ <MGIcon icon="info" />
13
+ <span>{{ $t("media_kit.editor.visibility_note") }}</span>
14
+ </div>
15
+
11
16
  <div class="form-row">
12
17
  <div class="form-group">
13
18
  <label>{{ $t("media_kit.editor.content_format") }}</label>
@@ -79,6 +84,20 @@
79
84
  <textarea v-model="form.description" rows="3"
80
85
  :placeholder="$t('media_kit.editor.description_placeholder')"></textarea>
81
86
  </div>
87
+ <label class="visibility-checkbox" :class="{ disabled: !isSubscriber }">
88
+ <input
89
+ type="checkbox"
90
+ :checked="showPublicly"
91
+ :disabled="!isSubscriber"
92
+ @change="onToggleVisibility(($event.target as HTMLInputElement).checked)"
93
+ />
94
+ <span>{{ $t("media_kit.editor.show_publicly") }}</span>
95
+ <button v-if="!isSubscriber" type="button" class="upgrade-link" @click.prevent="emit('paywall', 'rates')">
96
+ <MGIcon icon="lock" />
97
+ {{ $t("media_kit.editor.upgrade_to_show") }}
98
+ </button>
99
+ </label>
100
+
82
101
  <div class="form-actions">
83
102
  <button class="btn-cancel" @click="cancelForm">{{ $t("media_kit.editor.cancel") }}</button>
84
103
  <button class="btn-save" @click="saveRateCard" :disabled="saving">
@@ -143,19 +162,34 @@ const props = withDefaults(
143
162
  commissionPercent?: number;
144
163
  /** Current plan name, used in the fee label. */
145
164
  planName?: string;
165
+ /** Whether the account has an active paid subscription — gates public visibility only, not creation. */
166
+ isSubscriber?: boolean;
167
+ /** Current value of the account-wide "show rate cards publicly" setting. */
168
+ showPublicly?: boolean;
146
169
  }>(),
147
170
  {
148
171
  isEnabled: true,
149
172
  commissionPercent: 20,
150
173
  planName: "Free",
174
+ isSubscriber: false,
175
+ showPublicly: true,
151
176
  }
152
177
  );
153
178
 
154
179
  const emit = defineEmits<{
155
180
  (e: "refresh"): void;
156
181
  (e: "error", message: string): void;
182
+ /** Fired when the "show publicly" checkbox changes (only reachable while isSubscriber). */
183
+ (e: "toggle-visibility", value: boolean): void;
184
+ /** Fired when a non-subscriber clicks the upgrade CTA next to the disabled checkbox. */
185
+ (e: "paywall", feature: string): void;
157
186
  }>();
158
187
 
188
+ function onToggleVisibility(value: boolean) {
189
+ if (!props.isSubscriber) return;
190
+ emit('toggle-visibility', value);
191
+ }
192
+
159
193
  const showForm = ref(false);
160
194
  const editing = ref<number | null>(null);
161
195
  const saving = ref(false);
@@ -173,10 +207,58 @@ const defaultForm = {
173
207
 
174
208
  const form = ref({ ...defaultForm });
175
209
 
210
+ // --- Draft persistence (new-item only) --------------------------------------
211
+ // Guards against losing a half-filled form on accidental navigation/refresh.
212
+ // Scoped per profile so switching accounts in the same browser can't leak a
213
+ // draft between them. Only the "new item" flow is persisted — editing an
214
+ // existing card already has server data as the source of truth.
215
+ const DRAFT_KEY = computed(() => `mgn_rate_card_draft_${props.influencerProInfoId}`);
216
+
217
+ function loadDraft() {
218
+ if (typeof window === 'undefined') return null;
219
+ try {
220
+ const raw = window.localStorage.getItem(DRAFT_KEY.value);
221
+ return raw ? JSON.parse(raw) : null;
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+
227
+ function saveDraft() {
228
+ if (typeof window === 'undefined' || editing.value) return;
229
+ try {
230
+ window.localStorage.setItem(DRAFT_KEY.value, JSON.stringify(form.value));
231
+ } catch {
232
+ // storage unavailable (private browsing, quota) — draft just won't persist
233
+ }
234
+ }
235
+
236
+ function clearDraft() {
237
+ if (typeof window === 'undefined') return;
238
+ try {
239
+ window.localStorage.removeItem(DRAFT_KEY.value);
240
+ } catch {
241
+ // ignore
242
+ }
243
+ }
244
+
245
+ watch(form, () => {
246
+ if (!editing.value) saveDraft();
247
+ }, { deep: true });
248
+
249
+ onMounted(() => {
250
+ const draft = loadDraft();
251
+ if (draft) {
252
+ form.value = { ...defaultForm, ...draft };
253
+ showForm.value = true;
254
+ }
255
+ });
256
+
176
257
  const cancelForm = () => {
177
258
  showForm.value = false;
178
259
  editing.value = null;
179
260
  form.value = { ...defaultForm };
261
+ clearDraft();
180
262
  };
181
263
 
182
264
  const editCard = (card: RateCard) => {
@@ -284,6 +366,57 @@ const netDisplay = computed(() => {
284
366
  flex-direction: column;
285
367
  gap: 16px;
286
368
 
369
+ .visibility-note {
370
+ display: flex;
371
+ align-items: flex-start;
372
+ gap: 8px;
373
+ padding: 10px 12px;
374
+ background: rgba(255, 255, 255, 0.04);
375
+ border: 1px solid rgba(255, 255, 255, 0.08);
376
+ border-radius: 4px;
377
+ font-size: 13px;
378
+ color: #B7BAC4;
379
+
380
+ svg { flex-shrink: 0; margin-top: 2px; }
381
+ }
382
+
383
+ .visibility-checkbox {
384
+ display: flex;
385
+ align-items: center;
386
+ gap: 8px;
387
+ font-size: 13px;
388
+ color: #E4E6EB;
389
+ cursor: pointer;
390
+
391
+ input[type="checkbox"] {
392
+ width: 16px;
393
+ height: 16px;
394
+ cursor: pointer;
395
+ }
396
+
397
+ &.disabled {
398
+ cursor: not-allowed;
399
+ color: #7A7D89;
400
+
401
+ input[type="checkbox"] { cursor: not-allowed; }
402
+ }
403
+
404
+ .upgrade-link {
405
+ display: inline-flex;
406
+ align-items: center;
407
+ gap: 4px;
408
+ margin-left: 4px;
409
+ padding: 3px 8px;
410
+ background: none;
411
+ border: 1px solid #FDB215;
412
+ color: #FDB215;
413
+ font-size: 12px;
414
+ cursor: pointer;
415
+
416
+ &:hover { background: rgba(253, 178, 21, 0.1); }
417
+ }
418
+ }
419
+
287
420
  .mgn-fee-note {
288
421
  display: flex;
289
422
  flex-direction: column;
@@ -120,7 +120,7 @@ const detailRoute = computed(() =>
120
120
  </div>
121
121
 
122
122
  <!-- Tier-locked badge -->
123
- <div v-else-if="card.is_tier_locked && card.access_tier !== 'free'" class="kc-card__exclusive-badge">
123
+ <div v-else-if="card.is_tier_locked && card.access_tier !== 'free' && !userCanAccess" class="kc-card__exclusive-badge">
124
124
  <i class="fa-solid fa-crown" /> {{ cardTierLabel(card.access_tier) }}
125
125
  </div>
126
126
 
@@ -148,21 +148,21 @@ const detailRoute = computed(() =>
148
148
  <span class="lock-label">{{ $t("keys.card.exclusive_lock_title") }}</span>
149
149
  <span class="lock-cta">{{ $t("keys.card.exclusive_upgrade_cta") }}</span>
150
150
  </div>
151
- </div>
152
- </NuxtLink>
153
151
 
154
- <div class="kc-card__meta-bar">
155
- <div class="kc-card__days" v-if="card.early_access_hours && card.early_access_hours > 0 && userCanAccess">
156
- <span>{{ card.early_access_hours }}</span>h {{ $t("keys.card.days") }}
152
+ <div class="kc-card__meta-bar">
153
+ <div class="kc-card__days" v-if="card.early_access_hours && card.early_access_hours > 0 && userCanAccess">
154
+ <span>{{ card.early_access_hours }}</span>h {{ $t("keys.card.days") }}
155
+ </div>
156
+ <div class="kc-card__days" v-else>&nbsp;</div>
157
+ <span v-if="card.type" class="kc-card__type">
158
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="13" viewBox="0 0 16 17" fill="none">
159
+ <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="#D297FF"/>
160
+ </svg>
161
+ {{ card.type }}
162
+ </span>
163
+ </div>
157
164
  </div>
158
- <div class="kc-card__days" v-else>&nbsp;</div>
159
- <span v-if="card.type" class="kc-card__type">
160
- <svg xmlns="http://www.w3.org/2000/svg" width="12" height="13" viewBox="0 0 16 17" fill="none">
161
- <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="#D297FF"/>
162
- </svg>
163
- {{ card.type }}
164
- </span>
165
- </div>
165
+ </NuxtLink>
166
166
  </div>
167
167
 
168
168
  <div class="kc-card__body">
@@ -317,12 +317,18 @@ const detailRoute = computed(() =>
317
317
  }
318
318
 
319
319
  &__meta-bar {
320
+ position: absolute;
321
+ left: 0;
322
+ right: 0;
323
+ bottom: 0;
320
324
  display: flex;
321
325
  align-items: center;
322
326
  justify-content: space-between;
323
327
  padding: 6px 8px;
324
- background: var(--card-bg, #181818);
328
+ background: rgba(13, 13, 13, 0.82);
329
+ backdrop-filter: blur(2px);
325
330
  min-height: 30px;
331
+ z-index: 2;
326
332
  }
327
333
 
328
334
  &__days {
package/locales/de.json CHANGED
@@ -133,7 +133,7 @@
133
133
  "rp_label": "Redeemed by",
134
134
  "who_redeem": "Who redeemed",
135
135
  "created_by": "Published by",
136
- "no_data": "Campaign not found",
136
+ "no_data": "Diese Kampagne ist noch keinem Spiel im System zugeordnet",
137
137
  "title": "Kampagne",
138
138
  "about": "Über das Spiel",
139
139
  "screenshot_alt": "Spiel-Screenshot",
@@ -237,4 +237,4 @@
237
237
  "video_thumbnail": "Video-Vorschaubild",
238
238
  "expanded_image": "Vergrößertes Bild"
239
239
  }
240
- }
240
+ }
package/locales/en.json CHANGED
@@ -133,7 +133,7 @@
133
133
  "rp_label": "Redeemed by",
134
134
  "who_redeem": "Who redeemed",
135
135
  "created_by": "Published by",
136
- "no_data": "Campaign not found",
136
+ "no_data": "This campaign is not associated with a game in our system yet",
137
137
  "title": "Campaign",
138
138
  "about": "About the game",
139
139
  "screenshot_alt": "Game screenshot",
@@ -133,7 +133,7 @@
133
133
  "rp_label": "Redeemed by",
134
134
  "who_redeem": "Who redeemed",
135
135
  "created_by": "Published by",
136
- "no_data": "Campaign not found",
136
+ "no_data": "Esta campanha ainda não foi associada a um jogo no sistema",
137
137
  "title": "Campanha",
138
138
  "about": "Sobre o jogo",
139
139
  "screenshot_alt": "Captura de tela do jogo",
@@ -237,4 +237,4 @@
237
237
  "video_thumbnail": "Miniatura do vídeo",
238
238
  "expanded_image": "Imagem ampliada"
239
239
  }
240
- }
240
+ }
package/locales/ro.json CHANGED
@@ -133,7 +133,7 @@
133
133
  "rp_label": "Redeemed by",
134
134
  "who_redeem": "Who redeemed",
135
135
  "created_by": "Published by",
136
- "no_data": "Campaign not found",
136
+ "no_data": "Această campanie nu este încă asociată cu un joc în sistem",
137
137
  "title": "Campanie",
138
138
  "about": "Despre joc",
139
139
  "screenshot_alt": "Captură de ecran a jocului",
@@ -237,4 +237,4 @@
237
237
  "video_thumbnail": "Miniatură video",
238
238
  "expanded_image": "Imagine mărită"
239
239
  }
240
- }
240
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.3.2",
3
+ "version": "1.3.6",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -63,18 +63,18 @@ useSeoMeta({
63
63
  const restriction = computed(() => {
64
64
  const k = key.value;
65
65
  if (!k) return '';
66
- if (k.is_exclusive) return 'Exclusiva para streamers oficiais';
67
- if (k.is_tier_locked && k.access_tier && k.access_tier !== 'free') return `Exclusiva para o plano ${String(k.access_tier).toUpperCase()} ou superior`;
68
- if (k.streamer_type_id === 2) return 'Exclusiva para streamers oficiais';
69
- if (k.streamer_type_id === 3) return 'Exclusiva para afiliados';
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
70
  return '';
71
71
  });
72
72
  </script>
73
73
 
74
74
  <template>
75
75
  <div class="kc">
76
- <div v-if="loading" class="kc__state">Carregando…</div>
77
- <div v-else-if="error" class="kc__state">Campanha não encontrada.</div>
76
+ <div v-if="loading" class="kc__state">Loading…</div>
77
+ <div v-else-if="error" class="kc__state">Campaign not found.</div>
78
78
 
79
79
  <template v-else>
80
80
  <div class="kc__hero">
@@ -83,8 +83,8 @@ const restriction = computed(() => {
83
83
  <h1 class="kc__title">{{ key.name }}</h1>
84
84
  <div v-if="restriction" class="kc__badge">🔒 {{ restriction }}</div>
85
85
  <div class="kc__avail">
86
- <span v-if="key.available_count > 0">{{ key.available_count }} chave(s) pra resgatar</span>
87
- <span v-else>Chaves esgotadas</span>
86
+ <span v-if="key.available_count > 0">{{ key.available_count }} {{ key.available_count === 1 ? 'key' : 'keys' }} available to redeem</span>
87
+ <span v-else>Keys sold out</span>
88
88
  </div>
89
89
  </div>
90
90
  </div>
@@ -5,13 +5,13 @@
5
5
  <div class="stepper-header">
6
6
  <div
7
7
  :class="['stepper-step', { complete: currentStep > 1, active: currentStep === 1 }]"
8
- @click="currentStep = 1"
8
+ @click="setStep(1)"
9
9
  >
10
10
  {{ $t("keys.campaigns.title") }}
11
11
  </div>
12
12
  <div
13
13
  :class="['stepper-step', { complete: currentStep > 2, active: currentStep === 2 }]"
14
- @click="currentStep = 2"
14
+ @click="setStep(2)"
15
15
  >
16
16
  {{ $t("keys.campaigns.my_requests") }}
17
17
  </div>
@@ -134,7 +134,7 @@
134
134
  </div>
135
135
 
136
136
  <div
137
- v-else-if="card.is_tier_locked && card.access_tier !== 'free'"
137
+ v-else-if="card.is_tier_locked && card.access_tier !== 'free' && !card.user_can_access"
138
138
  class="exclusive-badge"
139
139
  >
140
140
  <i class="fa-solid fa-crown" /> {{ cardTierLabel(card.access_tier) }}
@@ -179,23 +179,23 @@
179
179
  <span class="lock-label">{{ $t("keys.campaigns.exclusive_lock_title") }}</span>
180
180
  <span class="lock-cta">{{ $t("keys.campaigns.exclusive_upgrade_cta") }}</span>
181
181
  </div>
182
+
183
+ <div class="title">
184
+ <div class="days" v-if="card.time > 0 && card.user_can_access">
185
+ <span>{{ card.time }}</span> {{ $t("keys.campaigns.days") }}
186
+ </div>
187
+ <div class="days" v-else></div>
188
+ <span>
189
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
190
+ <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)))" />
191
+ </svg>
192
+ {{ card.type }}
193
+ </span>
194
+ </div>
182
195
  </div>
183
196
  </nuxt-link>
184
197
  </div>
185
198
 
186
- <div class="title">
187
- <div class="days" v-if="card.time > 0 && card.user_can_access">
188
- <span>{{ card.time }}</span> {{ $t("keys.campaigns.days") }}
189
- </div>
190
- <div class="days" v-else></div>
191
- <span>
192
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
193
- <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)))" />
194
- </svg>
195
- {{ card.type }}
196
- </span>
197
- </div>
198
-
199
199
  <div class="text">
200
200
  <div v-if="card.platforms && card.platforms.length" class="card-platforms">
201
201
  <span v-for="p in card.platforms" :key="p" class="card-platform-tag">
@@ -344,13 +344,15 @@
344
344
  </template>
345
345
 
346
346
  <script setup lang="ts">
347
- import { ref, computed, onMounted, onUnmounted, watch, inject } from "vue"
347
+ import { ref, computed, onMounted, onUnmounted, watch, inject, unref } from "vue"
348
348
  import { useNuxtApp, navigateTo } from "#app"
349
349
  import { useCampaignsStore } from "../../stores/campaigns"
350
350
  import { fetchMyRequests, fetchRedeemedUsers } from "../../services/campaignService"
351
+ import { useLoginStore } from "../../stores/login"
351
352
 
352
353
  // Injected by each consuming front — provide these in app.vue / a plugin
353
354
  const mgPlatform = inject<string>("mgPlatform", "MGTV")
355
+ const currentUser = inject<any>("currentUser", null)
354
356
  const loginUrl = inject<string>("loginUrl", "/")
355
357
  const upgradeRoute = inject<string>("upgradeRoute", "/")
356
358
 
@@ -358,6 +360,7 @@ const { $i18n } = useNuxtApp()
358
360
  const locale = $i18n.locale
359
361
 
360
362
  const campaignsStore = useCampaignsStore()
363
+ const loginStore = useLoginStore()
361
364
 
362
365
  const currentStep = ref(1)
363
366
  const searchTerm = ref("")
@@ -374,6 +377,26 @@ const loadingFeatured = ref(true)
374
377
  const loadingRequests = ref(false)
375
378
  const loadError = ref(false)
376
379
 
380
+ function truthyFlag(value: unknown) {
381
+ return value === true || value === 1 || value === "1"
382
+ }
383
+
384
+ function hasCurrentUser() {
385
+ return !!unref(currentUser)
386
+ }
387
+
388
+ function requestLogin() {
389
+ loginStore.showLoginModalComponent = true
390
+ }
391
+
392
+ function setStep(step: number) {
393
+ if (step === 2 && !hasCurrentUser()) {
394
+ requestLogin()
395
+ return
396
+ }
397
+ currentStep.value = step
398
+ }
399
+
377
400
  // ------------------------------------------------------------------
378
401
  // Filters
379
402
  // ------------------------------------------------------------------
@@ -489,12 +512,17 @@ async function loadFeaturedCampaigns() {
489
512
  loadingFeatured.value = true
490
513
  try {
491
514
  const response = await campaignsStore.fetchFeaturedCampaigns(mgPlatform)
492
- sliderImages.value = (response.data || []).map((item: any) => ({
493
- id: item.id,
494
- cover: item.game?.url_banner_src || item.cover_src,
495
- name: item.name,
496
- slug: item.slug,
497
- }))
515
+ sliderImages.value = (response.data || [])
516
+ // Featured/highlight alone isn't enough — a campaign that's ended or
517
+ // sold out but still flagged featured must not show in the hero
518
+ // carousel. is_open already accounts for status/dates/available_count.
519
+ .filter((item: any) => (truthyFlag(item.featured) || truthyFlag(item.highlight)) && truthyFlag(item.is_open))
520
+ .map((item: any) => ({
521
+ id: item.id,
522
+ cover: item.game?.url_banner_src || item.cover_src,
523
+ name: item.name,
524
+ slug: item.slug,
525
+ }))
498
526
  } catch {
499
527
  // silent
500
528
  } finally {
@@ -600,6 +628,7 @@ function applyUserRequestStatuses() {
600
628
  }
601
629
 
602
630
  async function loadMyRequests() {
631
+ if (!hasCurrentUser()) return
603
632
  loadingRequests.value = true
604
633
  try {
605
634
  const response = await fetchMyRequests({ page: 1, per_page: 30, sort: "id", order: "asc" })
@@ -759,7 +788,7 @@ onMounted(async () => {
759
788
  await loadCampaigns(1)
760
789
  await loadCampaignTypes()
761
790
  await loadPlatforms()
762
- await loadMyRequests()
791
+ if (hasCurrentUser()) await loadMyRequests()
763
792
 
764
793
  updateSlider()
765
794
  startAutoplay()
@@ -811,7 +840,7 @@ onUnmounted(() => {
811
840
 
812
841
  .keys-badge {
813
842
  position: absolute;
814
- bottom: 8px;
843
+ top: 8px;
815
844
  right: 8px;
816
845
  background: var(--key-accent-dark, var(--key-accent-bg, rgba(107, 21, 172, 0.9)));
817
846
  color: #fff;
@@ -1141,12 +1170,18 @@ onUnmounted(() => {
1141
1170
  }
1142
1171
 
1143
1172
  .title {
1173
+ position: absolute;
1174
+ left: 0;
1175
+ right: 0;
1176
+ bottom: 0;
1144
1177
  display: flex;
1145
1178
  padding: 8px 16px;
1146
1179
  align-items: center;
1147
- background: rgba(13, 13, 13, 0.9);
1180
+ background: rgba(13, 13, 13, 0.82);
1181
+ backdrop-filter: blur(2px);
1148
1182
  width: 100%;
1149
1183
  justify-content: space-between;
1184
+ z-index: 2;
1150
1185
 
1151
1186
  .days {
1152
1187
  font-size: 12px;
@@ -147,9 +147,6 @@ async function submit() {
147
147
  return
148
148
  }
149
149
  success.value = true
150
- setTimeout(() => {
151
- navigateTo(`/${locale.value}/key-campaigns`)
152
- }, 2000)
153
150
  } catch (e: any) {
154
151
  const status = e?.response?.status
155
152
  const errorCode = e?.response?.data?.error_code
@@ -205,8 +205,33 @@ useSeoMeta({
205
205
  <template>
206
206
  <div class="kit-press" :data-tpl="tpl" :data-layout="layout">
207
207
  <div v-if="loading" class="kit-state">{{ $t('kit.loading') }}</div>
208
- <div v-else-if="error || !kit" class="kit-state">
208
+ <div v-else-if="error || !kit" class="kit-state kit-state--not-found">
209
209
  <h2>{{ $t('kit.press.not_found') }}</h2>
210
+ <p class="kit-state__desc">{{ $t('kit.press.not_found_desc') }}</p>
211
+ <a :href="`${agencyBase}/presskit`" target="_blank" rel="noopener" class="kit-footer__cta">
212
+ {{ $t('kit.press.create_yours_cta') }}
213
+ </a>
214
+
215
+ <section v-if="moreKits.length" class="kit-more kit-more--not-found">
216
+ <h2 class="kit-more__title">{{ $t('kit.press.more_games') }}</h2>
217
+ <div class="kit-more__track">
218
+ <a
219
+ v-for="k in moreKits"
220
+ :key="k.slug"
221
+ :href="`/presskit/game/${k.slug}`"
222
+ class="kit-more__card"
223
+ >
224
+ <div
225
+ class="kit-more__cover"
226
+ :style="k.cover_image_url ? { backgroundImage: `url(${k.cover_image_url})` } : {}"
227
+ >
228
+ <div v-if="!k.cover_image_url" class="kit-more__cover-fallback">{{ k.name?.charAt(0) }}</div>
229
+ </div>
230
+ <div class="kit-more__name">{{ k.name }}</div>
231
+ <div v-if="k.developer" class="kit-more__dev">{{ k.developer }}</div>
232
+ </a>
233
+ </div>
234
+ </section>
210
235
  </div>
211
236
 
212
237
  <template v-else>
@@ -22,10 +22,6 @@ function withApiV1(baseURL?: string): string {
22
22
  return /\/api\/v1$/.test(base) ? base : `${base}/api/v1`
23
23
  }
24
24
 
25
- function withoutApiV1(baseURL?: string): string {
26
- return (baseURL || "").replace(/\/api\/v1\/?$/, "").replace(/\/$/, "")
27
- }
28
-
29
25
  function getPublicApiBaseURL(): string {
30
26
  return withApiV1(
31
27
  import.meta.env.VITE_KEY_API_URL ||
@@ -47,6 +43,14 @@ function getAuthenticatedApiBaseURL(): string {
47
43
  )
48
44
  }
49
45
 
46
+ function setHeader(config: any, name: string, value: string) {
47
+ if (config.headers?.set) {
48
+ config.headers.set(name, value)
49
+ return
50
+ }
51
+ config.headers = { ...(config.headers || {}), [name]: value }
52
+ }
53
+
50
54
  function getMainClient(): AxiosInstance {
51
55
  if (_mainClient) return _mainClient
52
56
 
@@ -54,7 +58,9 @@ function getMainClient(): AxiosInstance {
54
58
 
55
59
  _mainClient.interceptors.request.use((config) => {
56
60
  const lang = resolveLang()
61
+ const systemId = import.meta.env.VITE_SYSTEM_ID
57
62
  config.params = { ...config.params, lang }
63
+ if (systemId) setHeader(config, "X-System-Id", String(systemId))
58
64
  // Only attach the shared debug token in true local dev — "!== production"
59
65
  // also matches "hlg" (staging), which has real logged-in users relying on
60
66
  // their own session cookie via withCredentials. Attaching this static
@@ -80,7 +86,9 @@ function getAuthClient(): AxiosInstance {
80
86
 
81
87
  _authClient.interceptors.request.use((config) => {
82
88
  const lang = resolveLang()
89
+ const systemId = import.meta.env.VITE_SYSTEM_ID
83
90
  config.params = { ...config.params, lang }
91
+ if (systemId) setHeader(config, "X-System-Id", String(systemId))
84
92
  // Only attach the shared debug token in true local dev — "!== production"
85
93
  // also matches "hlg" (staging), which has real logged-in users relying on
86
94
  // their own session cookie via withCredentials. Attaching this static
@@ -367,17 +375,7 @@ export const fetchCampaignTypes = (params: Record<string, any> = {}) => {
367
375
  * Fetch active platforms (for the platform filter select).
368
376
  */
369
377
  export const fetchCampaignPlatforms = () => {
370
- const communityBaseURL = withoutApiV1(
371
- import.meta.env.VITE_KEY_PLATFORMS_API_URL ||
372
- import.meta.env.VITE_BASE_URL_COMMUNITY ||
373
- (/community/i.test(import.meta.env.VITE_API_BASE_URL || "") ? import.meta.env.VITE_API_BASE_URL : ""),
374
- )
375
- if (communityBaseURL) {
376
- return axios.get(`${communityBaseURL}/${resolveLang()}/platforms/currently`, {
377
- withCredentials: true,
378
- })
379
- }
380
- return authClient.get("/platforms/currently")
378
+ return authClient.get("/public/key-platforms")
381
379
  }
382
380
 
383
381
  /**