@mundogamernetwork/shared-ui 1.8.14 → 1.8.16

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.
@@ -1,29 +1,30 @@
1
1
  <template>
2
2
  <div class="mk-images">
3
- <!-- Avatar -->
3
+ <!-- Avatar — square crop so it always displays consistently wherever
4
+ the media kit shows it, instead of uploading raw/uncropped. -->
4
5
  <div class="mk-field">
5
6
  <label class="mk-label">{{ t("media_kit.images.avatar") }}</label>
6
- <div class="mk-single">
7
- <img v-if="avatar" :src="avatar" class="mk-thumb" alt="avatar" />
8
- <label class="mk-upload" :class="{ 'is-busy': busy === 'avatar' }">
9
- <input type="file" accept="image/*" class="mk-file" :disabled="!!busy" @change="onSingle($event, 'avatar')" />
10
- {{ busy === 'avatar' ? t("media_kit.images.uploading") : t("media_kit.images.upload") }}
11
- </label>
12
- <button v-if="avatar" type="button" class="mk-remove" @click="clearSingle('avatar')">×</button>
13
- </div>
7
+ <div v-if="busy === 'avatar'" class="mk-crop-uploading">{{ t("media_kit.images.uploading") }}</div>
8
+ <MgAvatarUploadCrop
9
+ :initial-preview="avatar || ''"
10
+ :crop-width="400"
11
+ :crop-height="400"
12
+ @change="(payload) => onCropChange(payload, 'avatar')"
13
+ @error="(msg) => emit('error', msg)"
14
+ />
14
15
  </div>
15
16
 
16
- <!-- Cover -->
17
+ <!-- Cover — wide crop, same idea as avatar. -->
17
18
  <div class="mk-field">
18
19
  <label class="mk-label">{{ t("media_kit.images.cover") }}</label>
19
- <div class="mk-single">
20
- <img v-if="cover" :src="cover" class="mk-thumb mk-thumb--wide" alt="cover" />
21
- <label class="mk-upload" :class="{ 'is-busy': busy === 'cover' }">
22
- <input type="file" accept="image/*" class="mk-file" :disabled="!!busy" @change="onSingle($event, 'cover')" />
23
- {{ busy === 'cover' ? t("media_kit.images.uploading") : t("media_kit.images.upload") }}
24
- </label>
25
- <button v-if="cover" type="button" class="mk-remove" @click="clearSingle('cover')">×</button>
26
- </div>
20
+ <div v-if="busy === 'cover'" class="mk-crop-uploading">{{ t("media_kit.images.uploading") }}</div>
21
+ <MgAvatarUploadCrop
22
+ :initial-preview="cover || ''"
23
+ :crop-width="1200"
24
+ :crop-height="600"
25
+ @change="(payload) => onCropChange(payload, 'cover')"
26
+ @error="(msg) => emit('error', msg)"
27
+ />
27
28
  </div>
28
29
 
29
30
  <!-- Portfolio gallery -->
@@ -91,19 +92,17 @@ async function upload(file: File): Promise<string | null> {
91
92
  }
92
93
  }
93
94
 
94
- async function onSingle(e: Event, key: "avatar" | "cover") {
95
- const input = e.target as HTMLInputElement;
96
- const file = input.files?.[0];
97
- if (!file) return;
95
+ async function onCropChange(payload: { file: File | null; base64: string }, key: "avatar" | "cover") {
96
+ const field = key === "avatar" ? "avatar_url" : "cover_url";
97
+ if (!payload.file) {
98
+ // User hit the crop component's own remove ("✕") button.
99
+ emit("update", field, null);
100
+ return;
101
+ }
98
102
  busy.value = key;
99
- const url = await upload(file);
103
+ const url = await upload(payload.file);
100
104
  busy.value = null;
101
- input.value = "";
102
- if (url) emit("update", key === "avatar" ? "avatar_url" : "cover_url", url);
103
- }
104
-
105
- function clearSingle(key: "avatar" | "cover") {
106
- emit("update", key === "avatar" ? "avatar_url" : "cover_url", null);
105
+ if (url) emit("update", field, url);
107
106
  }
108
107
 
109
108
  async function onMulti(e: Event, key: "portfolio" | "brands") {
@@ -140,6 +139,11 @@ function removeFromList(key: "portfolio" | "brands", idx: number) {
140
139
  gap: 0.5rem;
141
140
  }
142
141
 
142
+ .mk-crop-uploading {
143
+ font-size: 0.8rem;
144
+ color: #aaa;
145
+ }
146
+
143
147
  .mk-label {
144
148
  color: #c0c4d0;
145
149
  font-size: 0.8rem;
@@ -97,7 +97,7 @@ const save = () => {
97
97
  .mk-links-head { display: flex; justify-content: space-between; gap: 20px; align-items: start; }
98
98
  .mk-links-head h3 { margin: 0 0 5px; color: #f2f3f5; font-size: 17px; }
99
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; }
100
+ .mk-link-add, .mk-link-save { border: 1px solid #ffb000; background: #ffb000; color: #090909; min-height: 38px; padding: 0 16px; font-weight: 500; cursor: pointer; white-space: nowrap; }
101
101
  button:disabled { opacity: .42; cursor: not-allowed; }
102
102
  .mk-link-list { display: grid; gap: 10px; margin-top: 18px; }
103
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); }
@@ -24,11 +24,11 @@
24
24
  </select>
25
25
  </div>
26
26
  <div class="form-group">
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>
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>
29
29
  <option value="">{{ $t("media_kit.editor.select") }}</option>
30
- <option v-for="channel in adChannels" :key="channel.id" :value="channel.id">
31
- {{ channel.name }}
30
+ <option v-for="platform in platforms" :key="platform.id" :value="platform.id">
31
+ {{ platform.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.ad_channel?.name }}</span>
113
+ <span class="channel">{{ card.platform?.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
- ad_channel_id: number;
137
+ platform_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
- ad_channel?: { id: number; name: string };
146
+ platform?: { 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
- adChannels: LookupItem[];
159
+ platforms: 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
- ad_channel_id: '',
199
+ platform_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
- ad_channel_id: String(card.ad_channel_id),
268
+ platform_id: String(card.platform_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.ad_channel_id) rateCardErrors.ad_channel_id = 'required';
286
+ if (!form.value.platform_id) rateCardErrors.platform_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
- ad_channel_id: Number(form.value.ad_channel_id),
296
+ platform_id: Number(form.value.platform_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,
@@ -0,0 +1,391 @@
1
+ <script setup lang="ts">
2
+ const props = withDefaults(defineProps<{
3
+ initialPreview?: string;
4
+ cropWidth?: number;
5
+ cropHeight?: number;
6
+ }>(), {
7
+ cropWidth: 300,
8
+ cropHeight: 300,
9
+ });
10
+
11
+ const emit = defineEmits<{
12
+ (e: 'change', payload: { file: File | null; base64: string }): void;
13
+ (e: 'error', message: string): void;
14
+ }>();
15
+
16
+ const { t } = useI18n();
17
+
18
+ const isMounted = ref(false);
19
+ onMounted(() => { isMounted.value = true; });
20
+
21
+ const inputRef = ref<HTMLInputElement | null>(null);
22
+ const canvasRef = ref<HTMLCanvasElement | null>(null);
23
+ const croppedPreview = ref<string>(props.initialPreview ?? '');
24
+ const showModal = ref(false);
25
+
26
+ // Image state
27
+ const img = ref<HTMLImageElement | null>(null);
28
+ const scale = ref(1);
29
+ const minimumScale = ref(0.1);
30
+ const offsetX = ref(0);
31
+ const offsetY = ref(0);
32
+ const dragging = ref(false);
33
+ const dragStart = ref({ clientX: 0, clientY: 0, offsetX: 0, offsetY: 0 });
34
+
35
+ const CANVAS_W = computed(() => props.cropWidth);
36
+ const CANVAS_H = computed(() => props.cropHeight);
37
+ const maximumScale = computed(() => minimumScale.value * 4);
38
+
39
+ // Display size — fit within 680×480 (modal viewport) while preserving aspect ratio
40
+ const displaySize = computed(() => {
41
+ const maxW = 680;
42
+ const maxH = 480; // cap height so portrait crops don't overflow the screen
43
+ const ratio = CANVAS_H.value / CANVAS_W.value;
44
+ let w = Math.min(CANVAS_W.value, maxW);
45
+ let h = Math.round(w * ratio);
46
+ if (h > maxH) {
47
+ h = maxH;
48
+ w = Math.round(h / ratio);
49
+ }
50
+ return { w, h };
51
+ });
52
+
53
+ const openPicker = () => {
54
+ inputRef.value?.click();
55
+ };
56
+
57
+ const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
58
+ const MAX_SOURCE_SIZE_BYTES = 20 * 1024 * 1024; // generous — the crop step re-encodes to a small JPEG anyway
59
+
60
+ const onFileSelected = (event: Event) => {
61
+ const input = event.target as HTMLInputElement;
62
+ const file = input.files?.[0];
63
+ if (!file) return;
64
+ input.value = '';
65
+
66
+ if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
67
+ emit('error', t('components.avatar_crop.error_file_type'));
68
+ return;
69
+ }
70
+ if (file.size > MAX_SOURCE_SIZE_BYTES) {
71
+ emit('error', t('components.avatar_crop.error_file_too_large', { size: '20MB' }));
72
+ return;
73
+ }
74
+
75
+ const reader = new FileReader();
76
+ reader.onerror = () => emit('error', t('components.avatar_crop.error_read_failed'));
77
+ reader.onload = (e) => {
78
+ const image = new Image();
79
+ image.onerror = () => emit('error', t('components.avatar_crop.error_read_failed'));
80
+ image.onload = () => {
81
+ img.value = image;
82
+ // Scale to cover the crop area fully (no empty borders by default)
83
+ const scaleX = CANVAS_W.value / image.width;
84
+ const scaleY = CANVAS_H.value / image.height;
85
+ minimumScale.value = Math.max(scaleX, scaleY);
86
+ scale.value = minimumScale.value;
87
+ offsetX.value = (CANVAS_W.value - image.width * scale.value) / 2;
88
+ offsetY.value = (CANVAS_H.value - image.height * scale.value) / 2;
89
+ showModal.value = true;
90
+ nextTick(() => drawCanvas());
91
+ };
92
+ image.src = e.target?.result as string;
93
+ };
94
+ reader.readAsDataURL(file);
95
+ };
96
+
97
+ const drawCanvas = () => {
98
+ const canvas = canvasRef.value;
99
+ const image = img.value;
100
+ if (!canvas || !image) return;
101
+
102
+ const ctx = canvas.getContext('2d');
103
+ if (!ctx) return;
104
+
105
+ canvas.width = CANVAS_W.value;
106
+ canvas.height = CANVAS_H.value;
107
+
108
+ ctx.fillStyle = '#0e0e0e';
109
+ ctx.fillRect(0, 0, CANVAS_W.value, CANVAS_H.value);
110
+ ctx.drawImage(image, offsetX.value, offsetY.value, image.width * scale.value, image.height * scale.value);
111
+ };
112
+
113
+ const constrainPosition = () => {
114
+ const image = img.value;
115
+ if (!image) return;
116
+
117
+ const renderedWidth = image.width * scale.value;
118
+ const renderedHeight = image.height * scale.value;
119
+ // Keep the crop completely covered. Without these bounds it was possible
120
+ // to upload a hero with black strips after dragging or zooming out.
121
+ offsetX.value = renderedWidth <= CANVAS_W.value
122
+ ? (CANVAS_W.value - renderedWidth) / 2
123
+ : Math.min(0, Math.max(CANVAS_W.value - renderedWidth, offsetX.value));
124
+ offsetY.value = renderedHeight <= CANVAS_H.value
125
+ ? (CANVAS_H.value - renderedHeight) / 2
126
+ : Math.min(0, Math.max(CANVAS_H.value - renderedHeight, offsetY.value));
127
+ };
128
+
129
+ const setScale = (newScale: number) => {
130
+ const boundedScale = Math.max(minimumScale.value, Math.min(maximumScale.value, newScale));
131
+ const ratio = boundedScale / scale.value;
132
+ const cx = CANVAS_W.value / 2;
133
+ const cy = CANVAS_H.value / 2;
134
+ offsetX.value = cx - (cx - offsetX.value) * ratio;
135
+ offsetY.value = cy - (cy - offsetY.value) * ratio;
136
+ scale.value = boundedScale;
137
+ constrainPosition();
138
+ drawCanvas();
139
+ };
140
+
141
+ const onWheel = (e: WheelEvent) => {
142
+ e.preventDefault();
143
+ const step = minimumScale.value * 0.08;
144
+ setScale(scale.value + (e.deltaY > 0 ? -step : step));
145
+ };
146
+
147
+ const onZoomSlider = (e: Event) => {
148
+ setScale(parseFloat((e.target as HTMLInputElement).value));
149
+ };
150
+
151
+ const clientToCanvasRatio = () => {
152
+ const rect = canvasRef.value?.getBoundingClientRect();
153
+ return {
154
+ x: CANVAS_W.value / (rect?.width || displaySize.value.w),
155
+ y: CANVAS_H.value / (rect?.height || displaySize.value.h),
156
+ };
157
+ };
158
+
159
+ const onMouseDown = (e: MouseEvent) => {
160
+ dragging.value = true;
161
+ dragStart.value = {
162
+ clientX: e.clientX,
163
+ clientY: e.clientY,
164
+ offsetX: offsetX.value,
165
+ offsetY: offsetY.value,
166
+ };
167
+ };
168
+
169
+ const onMouseMove = (e: MouseEvent) => {
170
+ if (!dragging.value) return;
171
+ // Client coordinates refer to the reduced on-screen canvas. Convert the
172
+ // delta back to export-canvas coordinates so the image tracks the cursor.
173
+ const ratio = clientToCanvasRatio();
174
+ offsetX.value = dragStart.value.offsetX + (e.clientX - dragStart.value.clientX) * ratio.x;
175
+ offsetY.value = dragStart.value.offsetY + (e.clientY - dragStart.value.clientY) * ratio.y;
176
+ constrainPosition();
177
+ drawCanvas();
178
+ };
179
+
180
+ const onMouseUp = () => { dragging.value = false; };
181
+
182
+ const onTouchStart = (e: TouchEvent) => {
183
+ if (e.touches.length !== 1) return;
184
+ dragging.value = true;
185
+ const touch = e.touches[0];
186
+ dragStart.value = {
187
+ clientX: touch.clientX,
188
+ clientY: touch.clientY,
189
+ offsetX: offsetX.value,
190
+ offsetY: offsetY.value,
191
+ };
192
+ };
193
+
194
+ const onTouchMove = (e: TouchEvent) => {
195
+ if (!dragging.value || e.touches.length !== 1) return;
196
+ e.preventDefault();
197
+ const touch = e.touches[0];
198
+ const ratio = clientToCanvasRatio();
199
+ offsetX.value = dragStart.value.offsetX + (touch.clientX - dragStart.value.clientX) * ratio.x;
200
+ offsetY.value = dragStart.value.offsetY + (touch.clientY - dragStart.value.clientY) * ratio.y;
201
+ constrainPosition();
202
+ drawCanvas();
203
+ };
204
+
205
+ const onTouchEnd = () => { dragging.value = false; };
206
+
207
+ const confirmCrop = () => {
208
+ const canvas = canvasRef.value;
209
+ if (!canvas) return;
210
+
211
+ const base64 = canvas.toDataURL('image/jpeg', 0.92);
212
+ canvas.toBlob((blob: Blob | null) => {
213
+ if (!blob) return;
214
+ croppedPreview.value = base64;
215
+ const file = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
216
+ emit('change', { file, base64 });
217
+ showModal.value = false;
218
+ }, 'image/jpeg', 0.92);
219
+ };
220
+
221
+ const cancelCrop = () => { showModal.value = false; };
222
+
223
+ const removeImage = () => {
224
+ croppedPreview.value = '';
225
+ emit('change', { file: null, base64: '' });
226
+ };
227
+
228
+ watch(() => props.initialPreview, (value) => { croppedPreview.value = value ?? ''; });
229
+ </script>
230
+
231
+ <template>
232
+ <div style="position: relative; width: 100%;"
233
+ >
234
+ <input
235
+ ref="inputRef"
236
+ type="file"
237
+ accept="image/png,image/jpeg,image/jpg,image/webp,image/gif"
238
+ style="position: absolute; width: 0; height: 0; opacity: 0; pointer-events: none;"
239
+ @change="onFileSelected"
240
+ />
241
+
242
+ <!-- Upload button -->
243
+ <div
244
+ v-if="!croppedPreview"
245
+ style="display: flex; flex-direction: column; justify-content: center; align-items: center; width: 100%; min-height: 120px; border: 2px dashed #666; background: #1e2028; color: #ccc; font-size: 13px; gap: 10px; cursor: pointer; padding: 16px;"
246
+ @click="openPicker"
247
+ @mouseenter="($event.currentTarget as HTMLElement).style.borderColor = '#fdb215'"
248
+ @mouseleave="($event.currentTarget as HTMLElement).style.borderColor = '#666'"
249
+ >
250
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none">
251
+ <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="#fdb215" />
252
+ </svg>
253
+ <span style="color: #fdb215; font-weight: 500;">{{ t('components.avatar_crop.select_image') }}</span>
254
+ <span style="color: #888; font-size: 11px;">{{ t('components.avatar_crop.format_hint', { w: CANVAS_W, h: CANVAS_H }) }}</span>
255
+ </div>
256
+
257
+ <!-- Preview — proportional thumbnail capped at 160px wide -->
258
+ <div
259
+ v-else
260
+ :style="{
261
+ position: 'relative',
262
+ width: '160px',
263
+ height: Math.round(160 * (CANVAS_H / CANVAS_W)) + 'px',
264
+ overflow: 'hidden',
265
+ border: '2px solid #555',
266
+ cursor: 'pointer',
267
+ }"
268
+ @click="openPicker"
269
+ >
270
+ <img :src="croppedPreview" style="width: 100%; height: 100%; object-fit: cover;" alt="avatar" />
271
+ <div
272
+ style="position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; background: rgba(0,0,0,0.6); opacity: 0; transition: opacity 0.2s;"
273
+ @mouseenter="($event.currentTarget as HTMLElement).style.opacity = '1'"
274
+ @mouseleave="($event.currentTarget as HTMLElement).style.opacity = '0'"
275
+ >
276
+ <button
277
+ type="button"
278
+ style="width: 32px; height: 32px; border-radius: 0; background: rgba(0,0,0,0.8); border: none; color: #fff; cursor: pointer; font-size: 14px;"
279
+ @click.stop="openPicker"
280
+ >✎</button>
281
+ <button
282
+ type="button"
283
+ style="width: 32px; height: 32px; border-radius: 0; background: #e05555; border: none; color: #fff; cursor: pointer; font-size: 14px;"
284
+ @click.stop="removeImage"
285
+ >✕</button>
286
+ </div>
287
+ </div>
288
+
289
+ <!-- Crop Modal -->
290
+ <Teleport v-if="isMounted" to="body">
291
+ <div
292
+ v-if="showModal"
293
+ style="position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 99999; display: flex; align-items: center; justify-content: center;"
294
+ @click.self="cancelCrop"
295
+ >
296
+ <div
297
+ :style="{
298
+ background: '#191b20',
299
+ border: '1px solid #333',
300
+ width: '90vw',
301
+ maxWidth: (displaySize.w + 48) + 'px',
302
+ display: 'flex',
303
+ flexDirection: 'column',
304
+ overflow: 'hidden',
305
+ }"
306
+ >
307
+ <!-- Header -->
308
+ <div style="display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #272930; color: #fff; font-size: 16px; font-weight: 600;">
309
+ <div style="display: flex; align-items: baseline; gap: 10px;">
310
+ <span>{{ t('components.avatar_crop.modal_title') }}</span>
311
+ <span style="font-size: 12px; font-weight: 400; color: #fdb215; background: rgba(253,178,21,0.12); padding: 2px 8px; letter-spacing: 0.03em;">
312
+ {{ CANVAS_W }} × {{ CANVAS_H }} px
313
+ </span>
314
+ </div>
315
+ <button
316
+ type="button"
317
+ style="background: none; border: none; color: #aaa; cursor: pointer; font-size: 24px; line-height: 1; padding: 0;"
318
+ @click="cancelCrop"
319
+ >&times;</button>
320
+ </div>
321
+
322
+ <!-- Canvas area — display at most 680px wide, preserve aspect ratio -->
323
+ <div style="padding: 20px; display: flex; flex-direction: column; align-items: center; gap: 16px;">
324
+ <div
325
+ :style="{
326
+ position: 'relative',
327
+ width: displaySize.w + 'px',
328
+ height: displaySize.h + 'px',
329
+ overflow: 'hidden',
330
+ cursor: 'grab',
331
+ border: '2px solid rgba(253,178,21,0.4)',
332
+ maxWidth: '100%',
333
+ }"
334
+ >
335
+ <canvas
336
+ ref="canvasRef"
337
+ :width="CANVAS_W"
338
+ :height="CANVAS_H"
339
+ :style="{
340
+ display: 'block',
341
+ width: displaySize.w + 'px',
342
+ height: displaySize.h + 'px',
343
+ maxWidth: '100%',
344
+ }"
345
+ @mousedown="onMouseDown"
346
+ @mousemove="onMouseMove"
347
+ @mouseup="onMouseUp"
348
+ @mouseleave="onMouseUp"
349
+ @wheel.prevent="onWheel"
350
+ @touchstart="onTouchStart"
351
+ @touchmove="onTouchMove"
352
+ @touchend="onTouchEnd"
353
+ />
354
+ </div>
355
+
356
+ <!-- Zoom slider -->
357
+ <div style="display: flex; align-items: center; gap: 12px; width: 100%; max-width: 280px;">
358
+ <span style="color: #aaa; font-size: 18px; font-weight: 600; user-select: none;">−</span>
359
+ <input
360
+ type="range"
361
+ :min="minimumScale"
362
+ :max="maximumScale"
363
+ :step="minimumScale / 100"
364
+ :value="scale"
365
+ style="flex: 1; accent-color: #fdb215;"
366
+ @input="onZoomSlider"
367
+ />
368
+ <span style="color: #aaa; font-size: 18px; font-weight: 600; user-select: none;">+</span>
369
+ </div>
370
+
371
+ <span style="font-size: 11px; color: #888;">{{ t('components.avatar_crop.drag_hint') }} · {{ CANVAS_W }}×{{ CANVAS_H }}px</span>
372
+ </div>
373
+
374
+ <!-- Footer -->
375
+ <div style="display: flex; justify-content: flex-end; gap: 12px; padding: 16px 20px; border-top: 1px solid #272930;">
376
+ <button
377
+ type="button"
378
+ style="height: 40px; padding: 0 20px; background: transparent; border: 1px solid #555; border-radius: 0; color: #fff; font-size: 14px; cursor: pointer;"
379
+ @click="cancelCrop"
380
+ >{{ t('modal.cancel') }}</button>
381
+ <button
382
+ type="button"
383
+ style="height: 40px; padding: 0 24px; background: #fdb215; border: none; border-radius: 0; color: #0e0e0e; font-size: 14px; font-weight: 600; cursor: pointer;"
384
+ @click="confirmCrop"
385
+ >{{ t('modal.confirm') }}</button>
386
+ </div>
387
+ </div>
388
+ </div>
389
+ </Teleport>
390
+ </div>
391
+ </template>
package/locales/de.json CHANGED
@@ -59,6 +59,9 @@
59
59
  "status_key": {
60
60
  "see_key": "View key",
61
61
  "of": "of",
62
+ "title_not_open": "This campaign",
63
+ "title_span_not_open": "hasn't opened yet",
64
+ "obs_not_open": "Requests aren't accepted until the campaign's start date. Come back then.",
62
65
  "title_0": "Campaign",
63
66
  "title_span_0": "ended",
64
67
  "obs_0": "The redemption period for the key has expired.",
@@ -115,6 +118,10 @@
115
118
  "launch": "Launch",
116
119
  "status": "Status",
117
120
  "status_active": "Active",
121
+ "status_upcoming": "Coming soon",
122
+ "opens_in": "Opens in",
123
+ "follow_campaign": "Benachrichtige mich bei Eröffnung",
124
+ "following": "Du wirst benachrichtigt",
118
125
  "status_closed": "Closed",
119
126
  "status_ending": "Ending soon",
120
127
  "status_exhausted": "Keys exhausted",
@@ -132,7 +139,6 @@
132
139
  "official": "Official streamers",
133
140
  "affiliate": "Affiliate or above"
134
141
  },
135
- "rp_label": "Redeemed by",
136
142
  "who_redeem": "Who redeemed",
137
143
  "created_by": "Published by",
138
144
  "no_data": "Diese Kampagne ist noch keinem Spiel im System zugeordnet",
@@ -209,6 +215,12 @@
209
215
  "press_kit_link": "Presse-Kit",
210
216
  "review_guideline": "Review-Richtlinie",
211
217
  "embargo_date": "Sperrfrist",
218
+ "embargo_warning_title": "Dieser Key unterliegt einer Sperrfrist",
219
+ "embargo_warning_desc": "Du darfst vor dem {date} keine Inhalte über dieses Spiel veröffentlichen oder teilen.",
220
+ "embargo_ack_label": "Ich verstehe, dass dieser Key bis {date} gesperrt ist, und werde vorher nichts veröffentlichen.",
221
+ "embargo_ack_confirm": "Bestätigen & Key anzeigen",
222
+ "no_embargo_title": "Keine Sperrfrist",
223
+ "no_embargo_desc": "Du kannst sofort Inhalte über dieses Spiel veröffentlichen oder teilen.",
212
224
  "pr_contact_label": "PR-Kontakt"
213
225
  },
214
226
  "materials": {
package/locales/en.json CHANGED
@@ -59,6 +59,9 @@
59
59
  "status_key": {
60
60
  "see_key": "View key",
61
61
  "of": "of",
62
+ "title_not_open": "This campaign",
63
+ "title_span_not_open": "hasn't opened yet",
64
+ "obs_not_open": "Requests aren't accepted until the campaign's start date. Come back then.",
62
65
  "title_0": "Campaign",
63
66
  "title_span_0": "ended",
64
67
  "obs_0": "The redemption period for the key has expired.",
@@ -115,6 +118,10 @@
115
118
  "launch": "Launch",
116
119
  "status": "Status",
117
120
  "status_active": "Active",
121
+ "status_upcoming": "Coming soon",
122
+ "opens_in": "Opens in",
123
+ "follow_campaign": "Notify me when it opens",
124
+ "following": "You'll be notified",
118
125
  "status_closed": "Closed",
119
126
  "status_ending": "Ending soon",
120
127
  "status_exhausted": "Keys exhausted",
@@ -132,7 +139,6 @@
132
139
  "official": "Official streamers",
133
140
  "affiliate": "Affiliate or above"
134
141
  },
135
- "rp_label": "Redeemed by",
136
142
  "who_redeem": "Who redeemed",
137
143
  "created_by": "Published by",
138
144
  "no_data": "This campaign is not associated with a game in our system yet",
@@ -209,6 +215,12 @@
209
215
  "press_kit_link": "Press Kit",
210
216
  "review_guideline": "Review guideline",
211
217
  "embargo_date": "Embargo date",
218
+ "embargo_warning_title": "This key is under embargo",
219
+ "embargo_warning_desc": "You must not publish or share any content about this game before {date}.",
220
+ "embargo_ack_label": "I understand this key is embargoed until {date} and will not publish before then.",
221
+ "embargo_ack_confirm": "Confirm & reveal key",
222
+ "no_embargo_title": "No embargo",
223
+ "no_embargo_desc": "You're free to publish or share content about this game right away.",
212
224
  "pr_contact_label": "PR contact"
213
225
  },
214
226
  "materials": {
@@ -59,6 +59,9 @@
59
59
  "status_key": {
60
60
  "see_key": "View key",
61
61
  "of": "of",
62
+ "title_not_open": "This campaign",
63
+ "title_span_not_open": "hasn't opened yet",
64
+ "obs_not_open": "Requests aren't accepted until the campaign's start date. Come back then.",
62
65
  "title_0": "Campaign",
63
66
  "title_span_0": "ended",
64
67
  "obs_0": "The redemption period for the key has expired.",
@@ -115,6 +118,10 @@
115
118
  "launch": "Launch",
116
119
  "status": "Status",
117
120
  "status_active": "Active",
121
+ "status_upcoming": "Coming soon",
122
+ "opens_in": "Opens in",
123
+ "follow_campaign": "Me avise quando abrir",
124
+ "following": "Você será avisado",
118
125
  "status_closed": "Closed",
119
126
  "status_ending": "Ending soon",
120
127
  "status_exhausted": "Keys exhausted",
@@ -132,7 +139,6 @@
132
139
  "official": "Official streamers",
133
140
  "affiliate": "Affiliate or above"
134
141
  },
135
- "rp_label": "Redeemed by",
136
142
  "who_redeem": "Who redeemed",
137
143
  "created_by": "Published by",
138
144
  "no_data": "Esta campanha ainda não foi associada a um jogo no sistema",
@@ -209,6 +215,12 @@
209
215
  "press_kit_link": "Press Kit",
210
216
  "review_guideline": "Diretriz de review",
211
217
  "embargo_date": "Data de embargo",
218
+ "embargo_warning_title": "Essa chave está sob embargo",
219
+ "embargo_warning_desc": "Você não pode publicar ou compartilhar nenhum conteúdo sobre esse jogo antes de {date}.",
220
+ "embargo_ack_label": "Entendo que essa chave está embargada até {date} e não vou publicar antes disso.",
221
+ "embargo_ack_confirm": "Confirmar e revelar chave",
222
+ "no_embargo_title": "Sem embargo",
223
+ "no_embargo_desc": "Você já pode publicar ou compartilhar conteúdo sobre esse jogo.",
212
224
  "pr_contact_label": "Contato de imprensa"
213
225
  },
214
226
  "materials": {
package/locales/ro.json CHANGED
@@ -59,6 +59,9 @@
59
59
  "status_key": {
60
60
  "see_key": "View key",
61
61
  "of": "of",
62
+ "title_not_open": "This campaign",
63
+ "title_span_not_open": "hasn't opened yet",
64
+ "obs_not_open": "Requests aren't accepted until the campaign's start date. Come back then.",
62
65
  "title_0": "Campaign",
63
66
  "title_span_0": "ended",
64
67
  "obs_0": "The redemption period for the key has expired.",
@@ -115,6 +118,10 @@
115
118
  "launch": "Launch",
116
119
  "status": "Status",
117
120
  "status_active": "Active",
121
+ "status_upcoming": "Coming soon",
122
+ "opens_in": "Opens in",
123
+ "follow_campaign": "Anunță-mă când se deschide",
124
+ "following": "Vei fi anunțat",
118
125
  "status_closed": "Closed",
119
126
  "status_ending": "Ending soon",
120
127
  "status_exhausted": "Keys exhausted",
@@ -132,7 +139,6 @@
132
139
  "official": "Official streamers",
133
140
  "affiliate": "Affiliate or above"
134
141
  },
135
- "rp_label": "Redeemed by",
136
142
  "who_redeem": "Who redeemed",
137
143
  "created_by": "Published by",
138
144
  "no_data": "Această campanie nu este încă asociată cu un joc în sistem",
@@ -209,6 +215,12 @@
209
215
  "press_kit_link": "Press Kit",
210
216
  "review_guideline": "Ghid de recenzie",
211
217
  "embargo_date": "Data embargo",
218
+ "embargo_warning_title": "Acest cod este sub embargo",
219
+ "embargo_warning_desc": "Nu ai voie să publici sau să distribui niciun conținut despre acest joc înainte de {date}.",
220
+ "embargo_ack_label": "Înțeleg că acest cod este sub embargo până la {date} și nu voi publica nimic înainte de asta.",
221
+ "embargo_ack_confirm": "Confirmă și dezvăluie codul",
222
+ "no_embargo_title": "Fără embargo",
223
+ "no_embargo_desc": "Poți publica sau distribui conținut despre acest joc chiar acum.",
212
224
  "pr_contact_label": "Contact PR"
213
225
  },
214
226
  "materials": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.8.14",
3
+ "version": "1.8.16",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -9,6 +9,8 @@ import {
9
9
  fetchKeyRequestById,
10
10
  revealKey,
11
11
  requestKey,
12
+ followCampaign,
13
+ unfollowCampaign,
12
14
  } from "../../services/campaignService"
13
15
  import { fetchDetailGame, fetchDetailGameImage } from "../../services/gamesService"
14
16
 
@@ -191,9 +193,67 @@ const isCampaignExpired = computed(() => {
191
193
  return parseTimeToExpire(campaign.value?.time_to_expire) <= 0
192
194
  })
193
195
 
196
+ // A campaign with a future start_date used to render as "Active" with a
197
+ // countdown that was actually counting down to end_date, not to its real
198
+ // opening date — nothing distinguished "scheduled, not open yet" from
199
+ // "already open". Backend now also rejects requests before start_date
200
+ // (KeyRequestController::store()); this is the matching frontend state.
201
+ const campaignNotYetOpen = computed(() => {
202
+ const start = campaign.value?.start
203
+ return !!start && new Date(start).getTime() > Date.now()
204
+ })
205
+
206
+ // Personal "notify me when this opens" toggle — distinct from the global,
207
+ // one-time key_campaigns alert opt-in (enableKeyCampaignNotifications()).
208
+ // Most useful while campaignNotYetOpen, since that's the only case where
209
+ // KeyCampaignOpenedNotification will ever actually fire for this campaign.
210
+ const following = ref(!!campaign.value?.is_following)
211
+ const followBusy = ref(false)
212
+
213
+ async function toggleFollow() {
214
+ if (!currentUser) {
215
+ return navigateTo(`${loginUrl}?redirect_to=${encodeURIComponent(route.fullPath)}`, { external: true })
216
+ }
217
+ if (!campaign.value?.id || followBusy.value) return
218
+
219
+ followBusy.value = true
220
+ const wasFollowing = following.value
221
+ following.value = !wasFollowing
222
+ try {
223
+ if (wasFollowing) {
224
+ await unfollowCampaign(campaign.value.id)
225
+ } else {
226
+ await followCampaign(campaign.value.id)
227
+ }
228
+ } catch {
229
+ following.value = wasFollowing
230
+ } finally {
231
+ followBusy.value = false
232
+ }
233
+ }
234
+
194
235
  let countdownInterval: ReturnType<typeof setInterval> | null = null
195
236
 
196
237
  const updateCountdown = () => {
238
+ if (campaignNotYetOpen.value) {
239
+ // Count down to the real opening date instead of end_date — there is no
240
+ // server-computed "time to start" field, so this is derived client-side.
241
+ let totalSeconds = Math.max(0, Math.floor((new Date(campaign.value!.start!).getTime() - Date.now()) / 1000))
242
+ countdownInterval = setInterval(() => {
243
+ if (totalSeconds <= 0) {
244
+ if (countdownInterval) clearInterval(countdownInterval)
245
+ return
246
+ }
247
+ totalSeconds--
248
+ timeValues.value = {
249
+ days: Math.floor(totalSeconds / 86400),
250
+ hours: Math.floor((totalSeconds % 86400) / 3600),
251
+ minutes: Math.floor((totalSeconds % 3600) / 60),
252
+ seconds: totalSeconds % 60,
253
+ }
254
+ }, 1000)
255
+ return
256
+ }
197
257
  if (hasNoDateLimit.value) {
198
258
  timeValues.value = { days: 999, hours: 0, minutes: 0, seconds: 0 }
199
259
  return
@@ -294,6 +354,13 @@ type StatusConfig = {
294
354
  }
295
355
 
296
356
  const statusConfigs: Record<string, StatusConfig> = {
357
+ not_yet_open: {
358
+ titleParts: [
359
+ { text: "keys.campaigns.status_key.title_not_open" },
360
+ { text: "keys.campaigns.status_key.title_span_not_open", color: "yellow" },
361
+ ],
362
+ obs: "keys.campaigns.status_key.obs_not_open",
363
+ },
297
364
  expired: {
298
365
  titleParts: [
299
366
  { text: "keys.campaigns.status_key.title_0" },
@@ -442,6 +509,13 @@ const currentConfig = computed((): StatusConfig | null => {
442
509
  const noRequestYet = status === null || status === undefined || status === 1
443
510
  const userCanAccess = campaign.value?.user_can_access !== false
444
511
 
512
+ // Checked first: a not-yet-open campaign takes priority over every other
513
+ // state (tier lock, exclusive, expired, etc.) — none of those are
514
+ // meaningful yet if the campaign itself hasn't started.
515
+ if (campaignNotYetOpen.value && noRequestYet) {
516
+ return statusConfigs.not_yet_open
517
+ }
518
+
445
519
  if (noRequestYet && !campaignExpired && !userCanAccess) {
446
520
  if (campaign.value?.is_exclusive) return statusConfigs.locked_exclusive
447
521
  if (campaign.value?.is_tier_locked) return statusConfigs.locked_tier
@@ -530,7 +604,7 @@ onMounted(async () => {
530
604
  <div class="rescue-texts">
531
605
  <div class="title">
532
606
  <template v-for="(part, i) in currentConfig.titleParts" :key="i">
533
- <template v-if="i > 0"> </template>
607
+ <template v-if="i > 0">&nbsp;</template>
534
608
  <span v-if="part.color" :class="part.color">{{ $t(part.text) }}</span>
535
609
  <template v-if="!part.color">{{ $t(part.text) }}</template>
536
610
  </template>
@@ -738,20 +812,23 @@ onMounted(async () => {
738
812
  <div class="tags">
739
813
  <div class="tag">
740
814
  <span :class="{
741
- active: !isCampaignExpired && (campaign?.available_count ?? 0) > 0 && (hasNoDateLimit || timeValues.days > 7),
742
- ending: !isCampaignExpired && (campaign?.available_count ?? 0) > 0 && !hasNoDateLimit && timeValues.days <= 7 && timeValues.days > 0,
743
- inactive: isCampaignExpired || (campaign?.available_count ?? 0) === 0,
815
+ upcoming: campaignNotYetOpen,
816
+ active: !campaignNotYetOpen && !isCampaignExpired && (campaign?.available_count ?? 0) > 0 && (hasNoDateLimit || timeValues.days > 7),
817
+ ending: !campaignNotYetOpen && !isCampaignExpired && (campaign?.available_count ?? 0) > 0 && !hasNoDateLimit && timeValues.days <= 7 && timeValues.days > 0,
818
+ inactive: !campaignNotYetOpen && (isCampaignExpired || (campaign?.available_count ?? 0) === 0),
744
819
  }">●</span>
745
820
  {{
746
- isCampaignExpired
747
- ? $t("keys.campaigns.campaign.status_closed")
748
- : (campaign?.available_count ?? 0) === 0
749
- ? $t("keys.campaigns.campaign.status_exhausted")
750
- : hasNoDateLimit
751
- ? $t("keys.campaigns.campaign.status_active")
752
- : timeValues.days <= 7
753
- ? $t("keys.campaigns.campaign.status_ending")
754
- : $t("keys.campaigns.campaign.status_active")
821
+ campaignNotYetOpen
822
+ ? $t("keys.campaigns.campaign.status_upcoming")
823
+ : isCampaignExpired
824
+ ? $t("keys.campaigns.campaign.status_closed")
825
+ : (campaign?.available_count ?? 0) === 0
826
+ ? $t("keys.campaigns.campaign.status_exhausted")
827
+ : hasNoDateLimit
828
+ ? $t("keys.campaigns.campaign.status_active")
829
+ : timeValues.days <= 7
830
+ ? $t("keys.campaigns.campaign.status_ending")
831
+ : $t("keys.campaigns.campaign.status_active")
755
832
  }}
756
833
  </div>
757
834
  <div class="tag-quantity" v-if="!isCampaignExpired">
@@ -765,7 +842,8 @@ onMounted(async () => {
765
842
  </div>
766
843
  </div>
767
844
 
768
- <div class="time" v-if="!hasNoDateLimit">
845
+ <div class="time" v-if="campaignNotYetOpen || !hasNoDateLimit">
846
+ <div class="time-label" v-if="campaignNotYetOpen">{{ $t("keys.campaigns.campaign.opens_in") }}</div>
769
847
  <div class="card-time">
770
848
  {{ timeValues.days }}<span>{{ $t("keys.campaigns.campaign.days") }}</span>
771
849
  </div>
@@ -780,6 +858,18 @@ onMounted(async () => {
780
858
  </div>
781
859
  </div>
782
860
 
861
+ <button
862
+ v-if="campaignNotYetOpen"
863
+ type="button"
864
+ class="follow-btn"
865
+ :class="{ following }"
866
+ :disabled="followBusy"
867
+ @click="toggleFollow"
868
+ >
869
+ <MGIcon :icon="following ? 'check' : 'bell'" />
870
+ {{ following ? $t("keys.campaigns.campaign.following") : $t("keys.campaigns.campaign.follow_campaign") }}
871
+ </button>
872
+
783
873
  <div class="requirements">
784
874
  {{ $t("keys.campaigns.campaign.requirements") }}
785
875
  <div class="tags">
@@ -891,7 +981,7 @@ onMounted(async () => {
891
981
  </div>
892
982
  <div class="texts">
893
983
  {{ campaign?.company || "" }}
894
- <div class="small">{{ $t("keys.campaigns.campaign.rp_label") }}</div>
984
+ <div v-if="campaign?.company_type" class="small">{{ campaign.company_type }}</div>
895
985
  </div>
896
986
  </div>
897
987
  <div class="badge-tag" v-if="campaign?.email">
@@ -1279,15 +1369,24 @@ onMounted(async () => {
1279
1369
  span.active { color: var(--success, #22c55e); }
1280
1370
  span.inactive { color: var(--danger, #e53935); }
1281
1371
  span.ending { color: var(--status-ending, #ffa600); }
1372
+ span.upcoming { color: var(--status-upcoming, #a78bfa); }
1282
1373
  }
1283
1374
  }
1284
1375
  }
1285
1376
 
1286
1377
  .time {
1287
1378
  display: flex;
1379
+ flex-wrap: wrap;
1288
1380
  gap: 12px;
1289
1381
  width: 100%;
1290
1382
 
1383
+ .time-label {
1384
+ flex-basis: 100%;
1385
+ font-size: 12px;
1386
+ color: var(--status-upcoming, #a78bfa);
1387
+ font-weight: 600;
1388
+ }
1389
+
1291
1390
  .card-time {
1292
1391
  display: flex;
1293
1392
  min-height: 80px;
@@ -1308,6 +1407,31 @@ onMounted(async () => {
1308
1407
  }
1309
1408
  }
1310
1409
 
1410
+ .follow-btn {
1411
+ display: flex;
1412
+ align-items: center;
1413
+ justify-content: center;
1414
+ gap: 8px;
1415
+ width: 100%;
1416
+ padding: 10px;
1417
+ border: 1px solid var(--key-accent, var(--primary, #D297FF));
1418
+ background: transparent;
1419
+ color: var(--key-accent, var(--primary, #D297FF));
1420
+ font-size: 13px;
1421
+ font-weight: 600;
1422
+ font-family: inherit;
1423
+ cursor: pointer;
1424
+ transition: background-color 0.15s, opacity 0.15s;
1425
+
1426
+ &:hover:not(:disabled) { background-color: rgba(210, 151, 255, 0.1); }
1427
+ &:disabled { opacity: 0.6; cursor: not-allowed; }
1428
+
1429
+ &.following {
1430
+ background-color: var(--key-accent, var(--primary, #D297FF));
1431
+ color: var(--body-bg, #101012);
1432
+ }
1433
+ }
1434
+
1311
1435
  .requirements {
1312
1436
  display: flex;
1313
1437
  flex-direction: column;
@@ -22,6 +22,16 @@ const requestData = ref<any>(null)
22
22
  const copied = ref(false)
23
23
  const platform = ref("")
24
24
 
25
+ // Embargo handling: the creator gets the code right away specifically to
26
+ // prepare content ahead of the embargo lifting — embargo_date only restricts
27
+ // when they publish, not when they can have the key. The backend requires an
28
+ // explicit one-time acknowledgment before actually returning the code
29
+ // (needsEmbargoAck) — a real consent step, not just the neutral, unenforced
30
+ // date line this page used to show.
31
+ const needsEmbargoAck = ref(false)
32
+ const embargoAckDate = ref<string | null>(null)
33
+ const embargoAckChecked = ref(false)
34
+
25
35
  // Maps a platform name to its official key-activation page.
26
36
  const REDEEM_URLS: Record<string, string> = {
27
37
  steam: "https://store.steampowered.com/account/registerkey",
@@ -69,22 +79,28 @@ onMounted(async () => {
69
79
  }
70
80
  })
71
81
 
72
- async function reveal() {
82
+ async function reveal(acknowledgeEmbargo = false) {
73
83
  revealing.value = true
74
84
  error.value = ""
75
85
  try {
76
- const res = await revealKey(requestId)
86
+ const res = await revealKey(requestId, acknowledgeEmbargo)
77
87
  const data = res?.data?.data || res?.data || null
78
88
  keyCode.value = data?.code || (typeof data?.key === "string" ? data.key : null) || data?.value || null
79
89
  // Keep the pre-reveal platform (chosen at request time) as fallback if the
80
90
  // revealed item has no platform of its own.
81
91
  platform.value = data?.platform || data?.platforms?.[0]?.name || platform.value
92
+ needsEmbargoAck.value = false
82
93
  if (!keyCode.value) {
83
94
  error.value = $i18n.t("keys.campaigns.error_reveal")
84
95
  }
85
96
  } catch (e: any) {
86
- const code = e?.response?.data?.message || ""
87
- if (code === "NO_KEYS_AVAILABLE" || code === "KEY_ITEM_NOT_FOUND") {
97
+ const body = e?.response?.data || {}
98
+ const code = body.message || ""
99
+ const errorCode = body.error_code || ""
100
+ if (errorCode === "embargo_acknowledgment_required") {
101
+ needsEmbargoAck.value = true
102
+ embargoAckDate.value = body.embargo_date || null
103
+ } else if (code === "NO_KEYS_AVAILABLE" || code === "KEY_ITEM_NOT_FOUND") {
88
104
  error.value = $i18n.t("keys.campaigns.error_no_keys")
89
105
  } else if (code === "KEY_NOT_APPROVED") {
90
106
  error.value = $i18n.t("keys.campaigns.error_not_approved")
@@ -96,6 +112,11 @@ async function reveal() {
96
112
  }
97
113
  }
98
114
 
115
+ function confirmEmbargoAndReveal() {
116
+ if (!embargoAckChecked.value) return
117
+ reveal(true)
118
+ }
119
+
99
120
  async function copyCode() {
100
121
  if (!keyCode.value) return
101
122
  try {
@@ -151,15 +172,44 @@ function goBack() {
151
172
  <p v-if="platform" class="platform-name">{{ $t("keys.campaigns.platform") }}: {{ platform }}</p>
152
173
  </div>
153
174
 
175
+ <div v-if="requestData && !keyCode" class="embargo-warning" :class="{ 'embargo-warning--none': !requestData?.key?.embargo_date }">
176
+ <MGIcon size="1x" :icon="requestData?.key?.embargo_date ? 'lock' : 'unlock'" />
177
+ <div class="embargo-warning-text">
178
+ <template v-if="requestData?.key?.embargo_date">
179
+ <strong>{{ $t("keys.campaigns.embargo_warning_title") }}</strong>
180
+ <p>{{ $t("keys.campaigns.embargo_warning_desc", { date: formatDate(requestData.key.embargo_date) }) }}</p>
181
+ </template>
182
+ <template v-else>
183
+ <strong>{{ $t("keys.campaigns.no_embargo_title") }}</strong>
184
+ <p>{{ $t("keys.campaigns.no_embargo_desc") }}</p>
185
+ </template>
186
+ </div>
187
+ </div>
188
+
154
189
  <div class="cards">
155
190
  <div class="side">
156
191
  <div class="code-list">
157
192
  <div class="code">
158
193
  <div class="number">1</div>
159
194
  <div class="texts">
160
- <div class="code-box" v-if="!keyCode">
195
+ <div class="embargo-ack" v-if="!keyCode && needsEmbargoAck">
196
+ <label class="embargo-ack-label">
197
+ <input type="checkbox" v-model="embargoAckChecked" />
198
+ {{ $t("keys.campaigns.embargo_ack_label", { date: formatDate(embargoAckDate) }) }}
199
+ </label>
200
+ <button
201
+ class="btn"
202
+ :class="{ disabled: !embargoAckChecked || revealing }"
203
+ :disabled="!embargoAckChecked || revealing"
204
+ @click="confirmEmbargoAndReveal"
205
+ >
206
+ {{ revealing ? $t("keys.campaigns.revealing") : $t("keys.campaigns.embargo_ack_confirm") }}
207
+ </button>
208
+ </div>
209
+
210
+ <div class="code-box" v-else-if="!keyCode">
161
211
  <input type="text" value="••••••••••••••••" readonly class="code-masked" />
162
- <button class="btn" :class="{ disabled: revealing }" :disabled="revealing" @click="reveal">
212
+ <button class="btn" :class="{ disabled: revealing }" :disabled="revealing" @click="() => reveal()">
163
213
  {{ revealing ? $t("keys.campaigns.revealing") : $t("keys.campaigns.status_key.reveal_key") }}
164
214
  </button>
165
215
  </div>
@@ -209,7 +259,7 @@ function goBack() {
209
259
  </div>
210
260
  </div>
211
261
 
212
- <div class="resources" v-if="requestData?.key?.press_kit_link || requestData?.key?.review_guideline || requestData?.key?.embargo_date">
262
+ <div class="resources" v-if="requestData?.key?.press_kit_link || requestData?.key?.review_guideline">
213
263
  <div class="resources-title">{{ $t("keys.campaigns.resources_title") }}</div>
214
264
 
215
265
  <a
@@ -227,11 +277,6 @@ function goBack() {
227
277
  <div class="resource-label">{{ $t("keys.campaigns.review_guideline") }}</div>
228
278
  <div class="resource-text">{{ requestData.key.review_guideline }}</div>
229
279
  </div>
230
-
231
- <div v-if="requestData?.key?.embargo_date" class="resource-item">
232
- <div class="resource-label">{{ $t("keys.campaigns.embargo_date") }}</div>
233
- <div class="resource-text">{{ formatDate(requestData.key.embargo_date) }}</div>
234
- </div>
235
280
  </div>
236
281
  </div>
237
282
  </div>
@@ -405,6 +450,50 @@ function goBack() {
405
450
 
406
451
  .req-error { font-size: 12px; color: var(--danger, #e53935); margin-top: 4px; }
407
452
 
453
+ .embargo-warning {
454
+ display: flex;
455
+ gap: 12px;
456
+ align-items: flex-start;
457
+ background: rgba(229, 57, 53, 0.1);
458
+ border: 1px solid var(--danger, #e53935);
459
+ padding: 14px 16px;
460
+ margin-bottom: 20px;
461
+
462
+ svg { color: var(--danger, #e53935); flex-shrink: 0; margin-top: 2px; }
463
+
464
+ .embargo-warning-text {
465
+ strong { display: block; color: var(--danger, #e53935); font-size: 14px; margin-bottom: 4px; }
466
+ p { margin: 0; font-size: 13px; color: var(--text-secondary, #ccc); }
467
+ }
468
+
469
+ &--none {
470
+ background: rgba(34, 197, 94, 0.08);
471
+ border-color: var(--success, #22c55e);
472
+
473
+ svg { color: var(--success, #22c55e); }
474
+ .embargo-warning-text strong { color: var(--success, #22c55e); }
475
+ }
476
+ }
477
+
478
+ .embargo-ack {
479
+ display: flex;
480
+ flex-direction: column;
481
+ gap: 10px;
482
+ padding: 14px;
483
+ background: rgba(229, 57, 53, 0.06);
484
+ border: 1px solid var(--danger, #e53935);
485
+
486
+ .embargo-ack-label {
487
+ display: flex;
488
+ align-items: flex-start;
489
+ gap: 8px;
490
+ font-size: 13px;
491
+ cursor: pointer;
492
+
493
+ input { margin-top: 2px; }
494
+ }
495
+ }
496
+
408
497
  .resources {
409
498
  display: flex;
410
499
  flex-direction: column;
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ // /presskit (no hyphen) is the canonical public press-kit route — every
3
+ // share link/redirect/marketing surface in the app already generates that
4
+ // form. "/press-kit" (hyphenated) never had a route at all, so anyone
5
+ // who typed or linked the more conventional-looking hyphenated form (it's
6
+ // the same spelling the dashboard editor route /dashboard/press-kit uses)
7
+ // got a 404. Redirect it to the canonical slug route instead of trying to
8
+ // make both forms equally "real" — same technique as presskit/[slug].vue's
9
+ // own redirect to the canonical /presskit/game/[slug] page.
10
+ definePageMeta({ layout: 'blank' })
11
+ const route = useRoute()
12
+ navigateTo(`/presskit/game/${route.params.slug}`, { redirectCode: 301 })
13
+ </script>
@@ -0,0 +1,7 @@
1
+ <script setup lang="ts">
2
+ // Same compatibility redirect as ../[slug].vue, for the /press-kit/game/...
3
+ // form specifically (in case that's what got linked/typed instead).
4
+ definePageMeta({ layout: 'blank' })
5
+ const route = useRoute()
6
+ navigateTo(`/presskit/game/${route.params.slug}`, { redirectCode: 301 })
7
+ </script>
@@ -0,0 +1,5 @@
1
+ <script setup lang="ts">
2
+ // Same compatibility redirect as [slug].vue, for the bare /press-kit form.
3
+ definePageMeta({ layout: 'blank' })
4
+ navigateTo('/presskit', { redirectCode: 301 })
5
+ </script>
@@ -306,7 +306,9 @@ export const fetchCampaignBySlug = async (slug: string) => {
306
306
  request_status: k.request_user_status,
307
307
  end: k.end_date,
308
308
  start: k.start_date,
309
+ is_following: k.is_following ?? false,
309
310
  company: k.company?.name,
311
+ company_type: k.company?.company_type ?? null,
310
312
  email: k.company?.email,
311
313
  twitter: k.company?.twitter,
312
314
  logo: k.company?.thumbnail_url,
@@ -418,8 +420,10 @@ export const requestKey = (data: Record<string, any>) => {
418
420
  /**
419
421
  * Reveal a previously granted key.
420
422
  */
421
- export const revealKey = (requestId: number) => {
422
- return authClient.get(`/public/key-requests/${requestId}/reveal`)
423
+ export const revealKey = (requestId: number, acknowledgeEmbargo = false) => {
424
+ return authClient.get(`/public/key-requests/${requestId}/reveal`, {
425
+ params: acknowledgeEmbargo ? { acknowledge_embargo: 1 } : undefined,
426
+ })
423
427
  }
424
428
 
425
429
  /**
@@ -446,6 +450,20 @@ export const fetchKeyActions = (keyId: number) => {
446
450
  return authClient.get(`/public/keys/${keyId}/actions`)
447
451
  }
448
452
 
453
+ /**
454
+ * Follow/unfollow a specific campaign — a personal reminder distinct from
455
+ * enableKeyCampaignNotifications() above (a one-time global opt-in to every
456
+ * eligible campaign). Most useful before a campaign with a future start
457
+ * date has opened: the user gets notified the moment it does.
458
+ */
459
+ export const followCampaign = (keyId: number) => {
460
+ return authClient.post(`/public/keys/${keyId}/follow`)
461
+ }
462
+
463
+ export const unfollowCampaign = (keyId: number) => {
464
+ return authClient.delete(`/public/keys/${keyId}/follow`)
465
+ }
466
+
449
467
  /**
450
468
  * Submit a content material for a key request.
451
469
  */