@mundogamernetwork/shared-ui 1.8.13 → 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.
- package/components/MediaKit/MgMediaKitImages.vue +33 -29
- package/components/MediaKit/MgMediaKitLinks.vue +1 -1
- package/components/MediaKit/MgMoneyInput.vue +67 -0
- package/components/MediaKit/MgRateCardBundleEditor.vue +2 -4
- package/components/MediaKit/MgRateCardEditor.vue +19 -19
- package/components/ui/MgAvatarUploadCrop.vue +391 -0
- package/composables/useCurrencyFormat.ts +31 -0
- package/locales/de.json +11 -0
- package/locales/en.json +11 -0
- package/locales/pt-BR.json +11 -0
- package/locales/ro.json +11 -0
- package/package.json +1 -1
- package/pages/key-campaigns/[slug].vue +70 -14
- package/pages/key-campaigns/redeem-key-approved.vue +101 -12
- package/pages/press-kit/[slug].vue +13 -0
- package/pages/press-kit/game/[slug].vue +7 -0
- package/pages/press-kit/index.vue +5 -0
- package/services/campaignService.ts +4 -2
|
@@ -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-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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:
|
|
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); }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<input
|
|
3
|
+
:id="id"
|
|
4
|
+
type="text"
|
|
5
|
+
inputmode="numeric"
|
|
6
|
+
autocomplete="off"
|
|
7
|
+
:value="display"
|
|
8
|
+
:placeholder="placeholder"
|
|
9
|
+
:required="required"
|
|
10
|
+
class="mg-money-input"
|
|
11
|
+
@input="onInput"
|
|
12
|
+
@change="emit('change')"
|
|
13
|
+
/>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script setup lang="ts">
|
|
17
|
+
// Cents-based currency mask: every keystroke keeps only the digits typed so
|
|
18
|
+
// far and rebuilds the formatted string from the right (like a POS/bank
|
|
19
|
+
// amount field), so the caret always lands at the end. modelValue stays a
|
|
20
|
+
// plain decimal number (e.g. 19.99) or null for "unset" — matching what the
|
|
21
|
+
// rate-card/bundle forms already send to the API, so this is a drop-in
|
|
22
|
+
// replacement for a plain <input type="number"> without touching save logic.
|
|
23
|
+
const props = defineProps<{
|
|
24
|
+
modelValue: number | string | null;
|
|
25
|
+
/** ISO currency code (USD/BRL/EUR/...) — drives which locale's grouping/decimal marks are used. */
|
|
26
|
+
currency: string;
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
id?: string;
|
|
29
|
+
required?: boolean;
|
|
30
|
+
}>();
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
(e: 'update:modelValue', value: number | null): void;
|
|
34
|
+
(e: 'change'): void;
|
|
35
|
+
}>();
|
|
36
|
+
|
|
37
|
+
const { formatterFor } = useCurrencyFormat();
|
|
38
|
+
|
|
39
|
+
const numericValue = computed(() => {
|
|
40
|
+
const n = Number(props.modelValue);
|
|
41
|
+
return props.modelValue !== null && props.modelValue !== '' && Number.isFinite(n) ? n : null;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const display = computed(() => (numericValue.value == null ? '' : formatterFor(props.currency).format(numericValue.value)));
|
|
45
|
+
|
|
46
|
+
function onInput(event: Event) {
|
|
47
|
+
const digits = (event.target as HTMLInputElement).value.replace(/\D/g, '').replace(/^0+(?=\d)/, '');
|
|
48
|
+
emit('update:modelValue', digits === '' ? null : Number(digits) / 100);
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<style scoped>
|
|
53
|
+
.mg-money-input {
|
|
54
|
+
width: 100%;
|
|
55
|
+
background: #272930;
|
|
56
|
+
border: 1px solid #333;
|
|
57
|
+
color: #fff;
|
|
58
|
+
padding: 8px;
|
|
59
|
+
font-size: 13px;
|
|
60
|
+
outline: none;
|
|
61
|
+
font-variant-numeric: tabular-nums;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.mg-money-input:focus {
|
|
65
|
+
border-color: #fdb215;
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
</div>
|
|
78
78
|
<div class="form-group">
|
|
79
79
|
<label>{{ $t("media_kit.bundles.bundle_price") }}</label>
|
|
80
|
-
<
|
|
80
|
+
<MgMoneyInput v-model="form.bundle_price" :currency="form.currency" />
|
|
81
81
|
</div>
|
|
82
82
|
<div class="form-group">
|
|
83
83
|
<label>{{ $t("media_kit.bundles.currency") }}</label>
|
|
@@ -318,9 +318,7 @@ function getBundleDiscount(bundle: Bundle): number {
|
|
|
318
318
|
return Math.round(((bundle.original_price - bundle.bundle_price) / bundle.original_price) * 100);
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
-
const
|
|
322
|
-
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(Number(value));
|
|
323
|
-
};
|
|
321
|
+
const { formatMoney: formatPrice } = useCurrencyFormat();
|
|
324
322
|
|
|
325
323
|
function openNewForm() {
|
|
326
324
|
form.value = { ...defaultForm, rate_card_ids: [], quantities: {} };
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
</select>
|
|
25
25
|
</div>
|
|
26
26
|
<div class="form-group">
|
|
27
|
-
<label>{{ $t("media_kit.editor.
|
|
28
|
-
<select v-model="form.
|
|
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="
|
|
31
|
-
{{
|
|
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>
|
|
@@ -36,12 +36,12 @@
|
|
|
36
36
|
<div class="form-row">
|
|
37
37
|
<div class="form-group">
|
|
38
38
|
<label>{{ $t("media_kit.editor.price_min") }}</label>
|
|
39
|
-
<
|
|
39
|
+
<MgMoneyInput v-model="form.price_min" :currency="form.currency" :class="{ 'input-error': rateCardErrors.price_min }" required @change="delete rateCardErrors.price_min" />
|
|
40
40
|
<span v-if="rateCardErrors.price_min" class="field-error">{{ $t('media_kit.editor.error_required') }}</span>
|
|
41
41
|
</div>
|
|
42
42
|
<div class="form-group">
|
|
43
43
|
<label>{{ $t("media_kit.editor.price_max") }}</label>
|
|
44
|
-
<
|
|
44
|
+
<MgMoneyInput v-model="form.price_max" :currency="form.currency" />
|
|
45
45
|
</div>
|
|
46
46
|
<div class="form-group">
|
|
47
47
|
<label>{{ $t("media_kit.editor.currency") }}</label>
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
@@ -325,15 +325,15 @@ const removeCard = async (id: number) => {
|
|
|
325
325
|
}
|
|
326
326
|
};
|
|
327
327
|
|
|
328
|
+
const { formatMoney } = useCurrencyFormat();
|
|
329
|
+
|
|
328
330
|
const formatPrice = (min: string, max?: string, currency = 'USD') => {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
return fmt(min);
|
|
331
|
+
if (max && Number(max) > Number(min)) return `${formatMoney(min, currency)} - ${formatMoney(max, currency)}`;
|
|
332
|
+
return formatMoney(min, currency);
|
|
332
333
|
};
|
|
333
334
|
|
|
334
335
|
// --- MGN fee preview -------------------------------------------------------
|
|
335
|
-
const fmtMoney = (v: number) =>
|
|
336
|
-
new Intl.NumberFormat('en-US', { style: 'currency', currency: form.value.currency || 'USD' }).format(v);
|
|
336
|
+
const fmtMoney = (v: number) => formatMoney(v, form.value.currency || 'USD');
|
|
337
337
|
|
|
338
338
|
const netOf = (v: string): number | null => {
|
|
339
339
|
const n = Number(v);
|
|
@@ -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
|
+
>×</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>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Fixed locale per currency code (not the viewer's own UI locale) — a BRL/USD/EUR
|
|
2
|
+
// price should always format with that currency's own grouping/decimal
|
|
3
|
+
// conventions, regardless of whether the dashboard is being browsed in
|
|
4
|
+
// pt-BR/en/es/de/ro. Same convention already proven in ideia-bosta's MoneyInput.
|
|
5
|
+
const CURRENCY_LOCALE: Record<string, string> = {
|
|
6
|
+
EUR: 'de-DE',
|
|
7
|
+
USD: 'en-US',
|
|
8
|
+
BRL: 'pt-BR',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function useCurrencyFormat() {
|
|
12
|
+
function formatterFor(currency: string) {
|
|
13
|
+
return new Intl.NumberFormat(CURRENCY_LOCALE[currency] ?? 'en-US', {
|
|
14
|
+
style: 'currency',
|
|
15
|
+
currency: currency || 'USD',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatMoney(value: number | string | null | undefined, currency = 'USD'): string {
|
|
20
|
+
const n = Number(value);
|
|
21
|
+
if (value == null || value === '' || !Number.isFinite(n)) return '';
|
|
22
|
+
try {
|
|
23
|
+
return formatterFor(currency).format(n);
|
|
24
|
+
} catch {
|
|
25
|
+
// Unknown/invalid ISO currency code — fall back rather than throw.
|
|
26
|
+
return `${currency} ${n.toFixed(2)}`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { formatMoney, formatterFor, CURRENCY_LOCALE };
|
|
31
|
+
}
|
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": {
|
package/locales/pt-BR.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": "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
|
@@ -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"
|
|
576
|
+
<template v-if="i > 0"> </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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
747
|
-
? $t("keys.campaigns.campaign.
|
|
748
|
-
:
|
|
749
|
-
? $t("keys.campaigns.campaign.
|
|
750
|
-
:
|
|
751
|
-
? $t("keys.campaigns.campaign.
|
|
752
|
-
:
|
|
753
|
-
? $t("keys.campaigns.campaign.
|
|
754
|
-
:
|
|
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
|
|
87
|
-
|
|
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="
|
|
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
|
|
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>
|
|
@@ -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
|
/**
|