@mundogamernetwork/shared-ui 1.8.14 → 1.8.15

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,8 @@
115
118
  "launch": "Launch",
116
119
  "status": "Status",
117
120
  "status_active": "Active",
121
+ "status_upcoming": "Coming soon",
122
+ "opens_in": "Opens in",
118
123
  "status_closed": "Closed",
119
124
  "status_ending": "Ending soon",
120
125
  "status_exhausted": "Keys exhausted",
@@ -209,6 +214,12 @@
209
214
  "press_kit_link": "Presse-Kit",
210
215
  "review_guideline": "Review-Richtlinie",
211
216
  "embargo_date": "Sperrfrist",
217
+ "embargo_warning_title": "Dieser Key unterliegt einer Sperrfrist",
218
+ "embargo_warning_desc": "Du darfst vor dem {date} keine Inhalte über dieses Spiel veröffentlichen oder teilen.",
219
+ "embargo_ack_label": "Ich verstehe, dass dieser Key bis {date} gesperrt ist, und werde vorher nichts veröffentlichen.",
220
+ "embargo_ack_confirm": "Bestätigen & Key anzeigen",
221
+ "no_embargo_title": "Keine Sperrfrist",
222
+ "no_embargo_desc": "Du kannst sofort Inhalte über dieses Spiel veröffentlichen oder teilen.",
212
223
  "pr_contact_label": "PR-Kontakt"
213
224
  },
214
225
  "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,8 @@
115
118
  "launch": "Launch",
116
119
  "status": "Status",
117
120
  "status_active": "Active",
121
+ "status_upcoming": "Coming soon",
122
+ "opens_in": "Opens in",
118
123
  "status_closed": "Closed",
119
124
  "status_ending": "Ending soon",
120
125
  "status_exhausted": "Keys exhausted",
@@ -209,6 +214,12 @@
209
214
  "press_kit_link": "Press Kit",
210
215
  "review_guideline": "Review guideline",
211
216
  "embargo_date": "Embargo date",
217
+ "embargo_warning_title": "This key is under embargo",
218
+ "embargo_warning_desc": "You must not publish or share any content about this game before {date}.",
219
+ "embargo_ack_label": "I understand this key is embargoed until {date} and will not publish before then.",
220
+ "embargo_ack_confirm": "Confirm & reveal key",
221
+ "no_embargo_title": "No embargo",
222
+ "no_embargo_desc": "You're free to publish or share content about this game right away.",
212
223
  "pr_contact_label": "PR contact"
213
224
  },
214
225
  "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,8 @@
115
118
  "launch": "Launch",
116
119
  "status": "Status",
117
120
  "status_active": "Active",
121
+ "status_upcoming": "Coming soon",
122
+ "opens_in": "Opens in",
118
123
  "status_closed": "Closed",
119
124
  "status_ending": "Ending soon",
120
125
  "status_exhausted": "Keys exhausted",
@@ -209,6 +214,12 @@
209
214
  "press_kit_link": "Press Kit",
210
215
  "review_guideline": "Diretriz de review",
211
216
  "embargo_date": "Data de embargo",
217
+ "embargo_warning_title": "Essa chave está sob embargo",
218
+ "embargo_warning_desc": "Você não pode publicar ou compartilhar nenhum conteúdo sobre esse jogo antes de {date}.",
219
+ "embargo_ack_label": "Entendo que essa chave está embargada até {date} e não vou publicar antes disso.",
220
+ "embargo_ack_confirm": "Confirmar e revelar chave",
221
+ "no_embargo_title": "Sem embargo",
222
+ "no_embargo_desc": "Você já pode publicar ou compartilhar conteúdo sobre esse jogo.",
212
223
  "pr_contact_label": "Contato de imprensa"
213
224
  },
214
225
  "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,8 @@
115
118
  "launch": "Launch",
116
119
  "status": "Status",
117
120
  "status_active": "Active",
121
+ "status_upcoming": "Coming soon",
122
+ "opens_in": "Opens in",
118
123
  "status_closed": "Closed",
119
124
  "status_ending": "Ending soon",
120
125
  "status_exhausted": "Keys exhausted",
@@ -209,6 +214,12 @@
209
214
  "press_kit_link": "Press Kit",
210
215
  "review_guideline": "Ghid de recenzie",
211
216
  "embargo_date": "Data embargo",
217
+ "embargo_warning_title": "Acest cod este sub embargo",
218
+ "embargo_warning_desc": "Nu ai voie să publici sau să distribui niciun conținut despre acest joc înainte de {date}.",
219
+ "embargo_ack_label": "Înțeleg că acest cod este sub embargo până la {date} și nu voi publica nimic înainte de asta.",
220
+ "embargo_ack_confirm": "Confirmă și dezvăluie codul",
221
+ "no_embargo_title": "Fără embargo",
222
+ "no_embargo_desc": "Poți publica sau distribui conținut despre acest joc chiar acum.",
212
223
  "pr_contact_label": "Contact PR"
213
224
  },
214
225
  "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.15",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -191,9 +191,38 @@ const isCampaignExpired = computed(() => {
191
191
  return parseTimeToExpire(campaign.value?.time_to_expire) <= 0
192
192
  })
193
193
 
194
+ // A campaign with a future start_date used to render as "Active" with a
195
+ // countdown that was actually counting down to end_date, not to its real
196
+ // opening date — nothing distinguished "scheduled, not open yet" from
197
+ // "already open". Backend now also rejects requests before start_date
198
+ // (KeyRequestController::store()); this is the matching frontend state.
199
+ const campaignNotYetOpen = computed(() => {
200
+ const start = campaign.value?.start
201
+ return !!start && new Date(start).getTime() > Date.now()
202
+ })
203
+
194
204
  let countdownInterval: ReturnType<typeof setInterval> | null = null
195
205
 
196
206
  const updateCountdown = () => {
207
+ if (campaignNotYetOpen.value) {
208
+ // Count down to the real opening date instead of end_date — there is no
209
+ // server-computed "time to start" field, so this is derived client-side.
210
+ let totalSeconds = Math.max(0, Math.floor((new Date(campaign.value!.start!).getTime() - Date.now()) / 1000))
211
+ countdownInterval = setInterval(() => {
212
+ if (totalSeconds <= 0) {
213
+ if (countdownInterval) clearInterval(countdownInterval)
214
+ return
215
+ }
216
+ totalSeconds--
217
+ timeValues.value = {
218
+ days: Math.floor(totalSeconds / 86400),
219
+ hours: Math.floor((totalSeconds % 86400) / 3600),
220
+ minutes: Math.floor((totalSeconds % 3600) / 60),
221
+ seconds: totalSeconds % 60,
222
+ }
223
+ }, 1000)
224
+ return
225
+ }
197
226
  if (hasNoDateLimit.value) {
198
227
  timeValues.value = { days: 999, hours: 0, minutes: 0, seconds: 0 }
199
228
  return
@@ -294,6 +323,13 @@ type StatusConfig = {
294
323
  }
295
324
 
296
325
  const statusConfigs: Record<string, StatusConfig> = {
326
+ not_yet_open: {
327
+ titleParts: [
328
+ { text: "keys.campaigns.status_key.title_not_open" },
329
+ { text: "keys.campaigns.status_key.title_span_not_open", color: "yellow" },
330
+ ],
331
+ obs: "keys.campaigns.status_key.obs_not_open",
332
+ },
297
333
  expired: {
298
334
  titleParts: [
299
335
  { text: "keys.campaigns.status_key.title_0" },
@@ -442,6 +478,13 @@ const currentConfig = computed((): StatusConfig | null => {
442
478
  const noRequestYet = status === null || status === undefined || status === 1
443
479
  const userCanAccess = campaign.value?.user_can_access !== false
444
480
 
481
+ // Checked first: a not-yet-open campaign takes priority over every other
482
+ // state (tier lock, exclusive, expired, etc.) — none of those are
483
+ // meaningful yet if the campaign itself hasn't started.
484
+ if (campaignNotYetOpen.value && noRequestYet) {
485
+ return statusConfigs.not_yet_open
486
+ }
487
+
445
488
  if (noRequestYet && !campaignExpired && !userCanAccess) {
446
489
  if (campaign.value?.is_exclusive) return statusConfigs.locked_exclusive
447
490
  if (campaign.value?.is_tier_locked) return statusConfigs.locked_tier
@@ -530,7 +573,7 @@ onMounted(async () => {
530
573
  <div class="rescue-texts">
531
574
  <div class="title">
532
575
  <template v-for="(part, i) in currentConfig.titleParts" :key="i">
533
- <template v-if="i > 0"> </template>
576
+ <template v-if="i > 0">&nbsp;</template>
534
577
  <span v-if="part.color" :class="part.color">{{ $t(part.text) }}</span>
535
578
  <template v-if="!part.color">{{ $t(part.text) }}</template>
536
579
  </template>
@@ -738,20 +781,23 @@ onMounted(async () => {
738
781
  <div class="tags">
739
782
  <div class="tag">
740
783
  <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,
784
+ upcoming: campaignNotYetOpen,
785
+ active: !campaignNotYetOpen && !isCampaignExpired && (campaign?.available_count ?? 0) > 0 && (hasNoDateLimit || timeValues.days > 7),
786
+ ending: !campaignNotYetOpen && !isCampaignExpired && (campaign?.available_count ?? 0) > 0 && !hasNoDateLimit && timeValues.days <= 7 && timeValues.days > 0,
787
+ inactive: !campaignNotYetOpen && (isCampaignExpired || (campaign?.available_count ?? 0) === 0),
744
788
  }">●</span>
745
789
  {{
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")
790
+ campaignNotYetOpen
791
+ ? $t("keys.campaigns.campaign.status_upcoming")
792
+ : isCampaignExpired
793
+ ? $t("keys.campaigns.campaign.status_closed")
794
+ : (campaign?.available_count ?? 0) === 0
795
+ ? $t("keys.campaigns.campaign.status_exhausted")
796
+ : hasNoDateLimit
797
+ ? $t("keys.campaigns.campaign.status_active")
798
+ : timeValues.days <= 7
799
+ ? $t("keys.campaigns.campaign.status_ending")
800
+ : $t("keys.campaigns.campaign.status_active")
755
801
  }}
756
802
  </div>
757
803
  <div class="tag-quantity" v-if="!isCampaignExpired">
@@ -765,7 +811,8 @@ onMounted(async () => {
765
811
  </div>
766
812
  </div>
767
813
 
768
- <div class="time" v-if="!hasNoDateLimit">
814
+ <div class="time" v-if="campaignNotYetOpen || !hasNoDateLimit">
815
+ <div class="time-label" v-if="campaignNotYetOpen">{{ $t("keys.campaigns.campaign.opens_in") }}</div>
769
816
  <div class="card-time">
770
817
  {{ timeValues.days }}<span>{{ $t("keys.campaigns.campaign.days") }}</span>
771
818
  </div>
@@ -1279,15 +1326,24 @@ onMounted(async () => {
1279
1326
  span.active { color: var(--success, #22c55e); }
1280
1327
  span.inactive { color: var(--danger, #e53935); }
1281
1328
  span.ending { color: var(--status-ending, #ffa600); }
1329
+ span.upcoming { color: var(--status-upcoming, #a78bfa); }
1282
1330
  }
1283
1331
  }
1284
1332
  }
1285
1333
 
1286
1334
  .time {
1287
1335
  display: flex;
1336
+ flex-wrap: wrap;
1288
1337
  gap: 12px;
1289
1338
  width: 100%;
1290
1339
 
1340
+ .time-label {
1341
+ flex-basis: 100%;
1342
+ font-size: 12px;
1343
+ color: var(--status-upcoming, #a78bfa);
1344
+ font-weight: 600;
1345
+ }
1346
+
1291
1347
  .card-time {
1292
1348
  display: flex;
1293
1349
  min-height: 80px;
@@ -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>
@@ -418,8 +418,10 @@ export const requestKey = (data: Record<string, any>) => {
418
418
  /**
419
419
  * Reveal a previously granted key.
420
420
  */
421
- export const revealKey = (requestId: number) => {
422
- return authClient.get(`/public/key-requests/${requestId}/reveal`)
421
+ export const revealKey = (requestId: number, acknowledgeEmbargo = false) => {
422
+ return authClient.get(`/public/key-requests/${requestId}/reveal`, {
423
+ params: acknowledgeEmbargo ? { acknowledge_embargo: 1 } : undefined,
424
+ })
423
425
  }
424
426
 
425
427
  /**