@mundogamernetwork/shared-ui 1.8.14 → 1.8.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/MediaKit/MgMediaKitImages.vue +33 -29
- package/components/MediaKit/MgMediaKitLinks.vue +1 -1
- package/components/MediaKit/MgRateCardEditor.vue +12 -12
- package/components/ui/MgAvatarUploadCrop.vue +391 -0
- package/locales/de.json +13 -1
- package/locales/en.json +13 -1
- package/locales/pt-BR.json +13 -1
- package/locales/ro.json +13 -1
- package/package.json +1 -1
- package/pages/key-campaigns/[slug].vue +139 -15
- 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 +20 -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); }
|
|
@@ -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>
|
|
@@ -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,
|
|
@@ -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>
|
package/locales/de.json
CHANGED
|
@@ -59,6 +59,9 @@
|
|
|
59
59
|
"status_key": {
|
|
60
60
|
"see_key": "View key",
|
|
61
61
|
"of": "of",
|
|
62
|
+
"title_not_open": "This campaign",
|
|
63
|
+
"title_span_not_open": "hasn't opened yet",
|
|
64
|
+
"obs_not_open": "Requests aren't accepted until the campaign's start date. Come back then.",
|
|
62
65
|
"title_0": "Campaign",
|
|
63
66
|
"title_span_0": "ended",
|
|
64
67
|
"obs_0": "The redemption period for the key has expired.",
|
|
@@ -115,6 +118,10 @@
|
|
|
115
118
|
"launch": "Launch",
|
|
116
119
|
"status": "Status",
|
|
117
120
|
"status_active": "Active",
|
|
121
|
+
"status_upcoming": "Coming soon",
|
|
122
|
+
"opens_in": "Opens in",
|
|
123
|
+
"follow_campaign": "Benachrichtige mich bei Eröffnung",
|
|
124
|
+
"following": "Du wirst benachrichtigt",
|
|
118
125
|
"status_closed": "Closed",
|
|
119
126
|
"status_ending": "Ending soon",
|
|
120
127
|
"status_exhausted": "Keys exhausted",
|
|
@@ -132,7 +139,6 @@
|
|
|
132
139
|
"official": "Official streamers",
|
|
133
140
|
"affiliate": "Affiliate or above"
|
|
134
141
|
},
|
|
135
|
-
"rp_label": "Redeemed by",
|
|
136
142
|
"who_redeem": "Who redeemed",
|
|
137
143
|
"created_by": "Published by",
|
|
138
144
|
"no_data": "Diese Kampagne ist noch keinem Spiel im System zugeordnet",
|
|
@@ -209,6 +215,12 @@
|
|
|
209
215
|
"press_kit_link": "Presse-Kit",
|
|
210
216
|
"review_guideline": "Review-Richtlinie",
|
|
211
217
|
"embargo_date": "Sperrfrist",
|
|
218
|
+
"embargo_warning_title": "Dieser Key unterliegt einer Sperrfrist",
|
|
219
|
+
"embargo_warning_desc": "Du darfst vor dem {date} keine Inhalte über dieses Spiel veröffentlichen oder teilen.",
|
|
220
|
+
"embargo_ack_label": "Ich verstehe, dass dieser Key bis {date} gesperrt ist, und werde vorher nichts veröffentlichen.",
|
|
221
|
+
"embargo_ack_confirm": "Bestätigen & Key anzeigen",
|
|
222
|
+
"no_embargo_title": "Keine Sperrfrist",
|
|
223
|
+
"no_embargo_desc": "Du kannst sofort Inhalte über dieses Spiel veröffentlichen oder teilen.",
|
|
212
224
|
"pr_contact_label": "PR-Kontakt"
|
|
213
225
|
},
|
|
214
226
|
"materials": {
|
package/locales/en.json
CHANGED
|
@@ -59,6 +59,9 @@
|
|
|
59
59
|
"status_key": {
|
|
60
60
|
"see_key": "View key",
|
|
61
61
|
"of": "of",
|
|
62
|
+
"title_not_open": "This campaign",
|
|
63
|
+
"title_span_not_open": "hasn't opened yet",
|
|
64
|
+
"obs_not_open": "Requests aren't accepted until the campaign's start date. Come back then.",
|
|
62
65
|
"title_0": "Campaign",
|
|
63
66
|
"title_span_0": "ended",
|
|
64
67
|
"obs_0": "The redemption period for the key has expired.",
|
|
@@ -115,6 +118,10 @@
|
|
|
115
118
|
"launch": "Launch",
|
|
116
119
|
"status": "Status",
|
|
117
120
|
"status_active": "Active",
|
|
121
|
+
"status_upcoming": "Coming soon",
|
|
122
|
+
"opens_in": "Opens in",
|
|
123
|
+
"follow_campaign": "Notify me when it opens",
|
|
124
|
+
"following": "You'll be notified",
|
|
118
125
|
"status_closed": "Closed",
|
|
119
126
|
"status_ending": "Ending soon",
|
|
120
127
|
"status_exhausted": "Keys exhausted",
|
|
@@ -132,7 +139,6 @@
|
|
|
132
139
|
"official": "Official streamers",
|
|
133
140
|
"affiliate": "Affiliate or above"
|
|
134
141
|
},
|
|
135
|
-
"rp_label": "Redeemed by",
|
|
136
142
|
"who_redeem": "Who redeemed",
|
|
137
143
|
"created_by": "Published by",
|
|
138
144
|
"no_data": "This campaign is not associated with a game in our system yet",
|
|
@@ -209,6 +215,12 @@
|
|
|
209
215
|
"press_kit_link": "Press Kit",
|
|
210
216
|
"review_guideline": "Review guideline",
|
|
211
217
|
"embargo_date": "Embargo date",
|
|
218
|
+
"embargo_warning_title": "This key is under embargo",
|
|
219
|
+
"embargo_warning_desc": "You must not publish or share any content about this game before {date}.",
|
|
220
|
+
"embargo_ack_label": "I understand this key is embargoed until {date} and will not publish before then.",
|
|
221
|
+
"embargo_ack_confirm": "Confirm & reveal key",
|
|
222
|
+
"no_embargo_title": "No embargo",
|
|
223
|
+
"no_embargo_desc": "You're free to publish or share content about this game right away.",
|
|
212
224
|
"pr_contact_label": "PR contact"
|
|
213
225
|
},
|
|
214
226
|
"materials": {
|
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,10 @@
|
|
|
115
118
|
"launch": "Launch",
|
|
116
119
|
"status": "Status",
|
|
117
120
|
"status_active": "Active",
|
|
121
|
+
"status_upcoming": "Coming soon",
|
|
122
|
+
"opens_in": "Opens in",
|
|
123
|
+
"follow_campaign": "Me avise quando abrir",
|
|
124
|
+
"following": "Você será avisado",
|
|
118
125
|
"status_closed": "Closed",
|
|
119
126
|
"status_ending": "Ending soon",
|
|
120
127
|
"status_exhausted": "Keys exhausted",
|
|
@@ -132,7 +139,6 @@
|
|
|
132
139
|
"official": "Official streamers",
|
|
133
140
|
"affiliate": "Affiliate or above"
|
|
134
141
|
},
|
|
135
|
-
"rp_label": "Redeemed by",
|
|
136
142
|
"who_redeem": "Who redeemed",
|
|
137
143
|
"created_by": "Published by",
|
|
138
144
|
"no_data": "Esta campanha ainda não foi associada a um jogo no sistema",
|
|
@@ -209,6 +215,12 @@
|
|
|
209
215
|
"press_kit_link": "Press Kit",
|
|
210
216
|
"review_guideline": "Diretriz de review",
|
|
211
217
|
"embargo_date": "Data de embargo",
|
|
218
|
+
"embargo_warning_title": "Essa chave está sob embargo",
|
|
219
|
+
"embargo_warning_desc": "Você não pode publicar ou compartilhar nenhum conteúdo sobre esse jogo antes de {date}.",
|
|
220
|
+
"embargo_ack_label": "Entendo que essa chave está embargada até {date} e não vou publicar antes disso.",
|
|
221
|
+
"embargo_ack_confirm": "Confirmar e revelar chave",
|
|
222
|
+
"no_embargo_title": "Sem embargo",
|
|
223
|
+
"no_embargo_desc": "Você já pode publicar ou compartilhar conteúdo sobre esse jogo.",
|
|
212
224
|
"pr_contact_label": "Contato de imprensa"
|
|
213
225
|
},
|
|
214
226
|
"materials": {
|
package/locales/ro.json
CHANGED
|
@@ -59,6 +59,9 @@
|
|
|
59
59
|
"status_key": {
|
|
60
60
|
"see_key": "View key",
|
|
61
61
|
"of": "of",
|
|
62
|
+
"title_not_open": "This campaign",
|
|
63
|
+
"title_span_not_open": "hasn't opened yet",
|
|
64
|
+
"obs_not_open": "Requests aren't accepted until the campaign's start date. Come back then.",
|
|
62
65
|
"title_0": "Campaign",
|
|
63
66
|
"title_span_0": "ended",
|
|
64
67
|
"obs_0": "The redemption period for the key has expired.",
|
|
@@ -115,6 +118,10 @@
|
|
|
115
118
|
"launch": "Launch",
|
|
116
119
|
"status": "Status",
|
|
117
120
|
"status_active": "Active",
|
|
121
|
+
"status_upcoming": "Coming soon",
|
|
122
|
+
"opens_in": "Opens in",
|
|
123
|
+
"follow_campaign": "Anunță-mă când se deschide",
|
|
124
|
+
"following": "Vei fi anunțat",
|
|
118
125
|
"status_closed": "Closed",
|
|
119
126
|
"status_ending": "Ending soon",
|
|
120
127
|
"status_exhausted": "Keys exhausted",
|
|
@@ -132,7 +139,6 @@
|
|
|
132
139
|
"official": "Official streamers",
|
|
133
140
|
"affiliate": "Affiliate or above"
|
|
134
141
|
},
|
|
135
|
-
"rp_label": "Redeemed by",
|
|
136
142
|
"who_redeem": "Who redeemed",
|
|
137
143
|
"created_by": "Published by",
|
|
138
144
|
"no_data": "Această campanie nu este încă asociată cu un joc în sistem",
|
|
@@ -209,6 +215,12 @@
|
|
|
209
215
|
"press_kit_link": "Press Kit",
|
|
210
216
|
"review_guideline": "Ghid de recenzie",
|
|
211
217
|
"embargo_date": "Data embargo",
|
|
218
|
+
"embargo_warning_title": "Acest cod este sub embargo",
|
|
219
|
+
"embargo_warning_desc": "Nu ai voie să publici sau să distribui niciun conținut despre acest joc înainte de {date}.",
|
|
220
|
+
"embargo_ack_label": "Înțeleg că acest cod este sub embargo până la {date} și nu voi publica nimic înainte de asta.",
|
|
221
|
+
"embargo_ack_confirm": "Confirmă și dezvăluie codul",
|
|
222
|
+
"no_embargo_title": "Fără embargo",
|
|
223
|
+
"no_embargo_desc": "Poți publica sau distribui conținut despre acest joc chiar acum.",
|
|
212
224
|
"pr_contact_label": "Contact PR"
|
|
213
225
|
},
|
|
214
226
|
"materials": {
|
package/package.json
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
fetchKeyRequestById,
|
|
10
10
|
revealKey,
|
|
11
11
|
requestKey,
|
|
12
|
+
followCampaign,
|
|
13
|
+
unfollowCampaign,
|
|
12
14
|
} from "../../services/campaignService"
|
|
13
15
|
import { fetchDetailGame, fetchDetailGameImage } from "../../services/gamesService"
|
|
14
16
|
|
|
@@ -191,9 +193,67 @@ const isCampaignExpired = computed(() => {
|
|
|
191
193
|
return parseTimeToExpire(campaign.value?.time_to_expire) <= 0
|
|
192
194
|
})
|
|
193
195
|
|
|
196
|
+
// A campaign with a future start_date used to render as "Active" with a
|
|
197
|
+
// countdown that was actually counting down to end_date, not to its real
|
|
198
|
+
// opening date — nothing distinguished "scheduled, not open yet" from
|
|
199
|
+
// "already open". Backend now also rejects requests before start_date
|
|
200
|
+
// (KeyRequestController::store()); this is the matching frontend state.
|
|
201
|
+
const campaignNotYetOpen = computed(() => {
|
|
202
|
+
const start = campaign.value?.start
|
|
203
|
+
return !!start && new Date(start).getTime() > Date.now()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// Personal "notify me when this opens" toggle — distinct from the global,
|
|
207
|
+
// one-time key_campaigns alert opt-in (enableKeyCampaignNotifications()).
|
|
208
|
+
// Most useful while campaignNotYetOpen, since that's the only case where
|
|
209
|
+
// KeyCampaignOpenedNotification will ever actually fire for this campaign.
|
|
210
|
+
const following = ref(!!campaign.value?.is_following)
|
|
211
|
+
const followBusy = ref(false)
|
|
212
|
+
|
|
213
|
+
async function toggleFollow() {
|
|
214
|
+
if (!currentUser) {
|
|
215
|
+
return navigateTo(`${loginUrl}?redirect_to=${encodeURIComponent(route.fullPath)}`, { external: true })
|
|
216
|
+
}
|
|
217
|
+
if (!campaign.value?.id || followBusy.value) return
|
|
218
|
+
|
|
219
|
+
followBusy.value = true
|
|
220
|
+
const wasFollowing = following.value
|
|
221
|
+
following.value = !wasFollowing
|
|
222
|
+
try {
|
|
223
|
+
if (wasFollowing) {
|
|
224
|
+
await unfollowCampaign(campaign.value.id)
|
|
225
|
+
} else {
|
|
226
|
+
await followCampaign(campaign.value.id)
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
following.value = wasFollowing
|
|
230
|
+
} finally {
|
|
231
|
+
followBusy.value = false
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
194
235
|
let countdownInterval: ReturnType<typeof setInterval> | null = null
|
|
195
236
|
|
|
196
237
|
const updateCountdown = () => {
|
|
238
|
+
if (campaignNotYetOpen.value) {
|
|
239
|
+
// Count down to the real opening date instead of end_date — there is no
|
|
240
|
+
// server-computed "time to start" field, so this is derived client-side.
|
|
241
|
+
let totalSeconds = Math.max(0, Math.floor((new Date(campaign.value!.start!).getTime() - Date.now()) / 1000))
|
|
242
|
+
countdownInterval = setInterval(() => {
|
|
243
|
+
if (totalSeconds <= 0) {
|
|
244
|
+
if (countdownInterval) clearInterval(countdownInterval)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
totalSeconds--
|
|
248
|
+
timeValues.value = {
|
|
249
|
+
days: Math.floor(totalSeconds / 86400),
|
|
250
|
+
hours: Math.floor((totalSeconds % 86400) / 3600),
|
|
251
|
+
minutes: Math.floor((totalSeconds % 3600) / 60),
|
|
252
|
+
seconds: totalSeconds % 60,
|
|
253
|
+
}
|
|
254
|
+
}, 1000)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
197
257
|
if (hasNoDateLimit.value) {
|
|
198
258
|
timeValues.value = { days: 999, hours: 0, minutes: 0, seconds: 0 }
|
|
199
259
|
return
|
|
@@ -294,6 +354,13 @@ type StatusConfig = {
|
|
|
294
354
|
}
|
|
295
355
|
|
|
296
356
|
const statusConfigs: Record<string, StatusConfig> = {
|
|
357
|
+
not_yet_open: {
|
|
358
|
+
titleParts: [
|
|
359
|
+
{ text: "keys.campaigns.status_key.title_not_open" },
|
|
360
|
+
{ text: "keys.campaigns.status_key.title_span_not_open", color: "yellow" },
|
|
361
|
+
],
|
|
362
|
+
obs: "keys.campaigns.status_key.obs_not_open",
|
|
363
|
+
},
|
|
297
364
|
expired: {
|
|
298
365
|
titleParts: [
|
|
299
366
|
{ text: "keys.campaigns.status_key.title_0" },
|
|
@@ -442,6 +509,13 @@ const currentConfig = computed((): StatusConfig | null => {
|
|
|
442
509
|
const noRequestYet = status === null || status === undefined || status === 1
|
|
443
510
|
const userCanAccess = campaign.value?.user_can_access !== false
|
|
444
511
|
|
|
512
|
+
// Checked first: a not-yet-open campaign takes priority over every other
|
|
513
|
+
// state (tier lock, exclusive, expired, etc.) — none of those are
|
|
514
|
+
// meaningful yet if the campaign itself hasn't started.
|
|
515
|
+
if (campaignNotYetOpen.value && noRequestYet) {
|
|
516
|
+
return statusConfigs.not_yet_open
|
|
517
|
+
}
|
|
518
|
+
|
|
445
519
|
if (noRequestYet && !campaignExpired && !userCanAccess) {
|
|
446
520
|
if (campaign.value?.is_exclusive) return statusConfigs.locked_exclusive
|
|
447
521
|
if (campaign.value?.is_tier_locked) return statusConfigs.locked_tier
|
|
@@ -530,7 +604,7 @@ onMounted(async () => {
|
|
|
530
604
|
<div class="rescue-texts">
|
|
531
605
|
<div class="title">
|
|
532
606
|
<template v-for="(part, i) in currentConfig.titleParts" :key="i">
|
|
533
|
-
<template v-if="i > 0"
|
|
607
|
+
<template v-if="i > 0"> </template>
|
|
534
608
|
<span v-if="part.color" :class="part.color">{{ $t(part.text) }}</span>
|
|
535
609
|
<template v-if="!part.color">{{ $t(part.text) }}</template>
|
|
536
610
|
</template>
|
|
@@ -738,20 +812,23 @@ onMounted(async () => {
|
|
|
738
812
|
<div class="tags">
|
|
739
813
|
<div class="tag">
|
|
740
814
|
<span :class="{
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
815
|
+
upcoming: campaignNotYetOpen,
|
|
816
|
+
active: !campaignNotYetOpen && !isCampaignExpired && (campaign?.available_count ?? 0) > 0 && (hasNoDateLimit || timeValues.days > 7),
|
|
817
|
+
ending: !campaignNotYetOpen && !isCampaignExpired && (campaign?.available_count ?? 0) > 0 && !hasNoDateLimit && timeValues.days <= 7 && timeValues.days > 0,
|
|
818
|
+
inactive: !campaignNotYetOpen && (isCampaignExpired || (campaign?.available_count ?? 0) === 0),
|
|
744
819
|
}">●</span>
|
|
745
820
|
{{
|
|
746
|
-
|
|
747
|
-
? $t("keys.campaigns.campaign.
|
|
748
|
-
:
|
|
749
|
-
? $t("keys.campaigns.campaign.
|
|
750
|
-
:
|
|
751
|
-
? $t("keys.campaigns.campaign.
|
|
752
|
-
:
|
|
753
|
-
? $t("keys.campaigns.campaign.
|
|
754
|
-
:
|
|
821
|
+
campaignNotYetOpen
|
|
822
|
+
? $t("keys.campaigns.campaign.status_upcoming")
|
|
823
|
+
: isCampaignExpired
|
|
824
|
+
? $t("keys.campaigns.campaign.status_closed")
|
|
825
|
+
: (campaign?.available_count ?? 0) === 0
|
|
826
|
+
? $t("keys.campaigns.campaign.status_exhausted")
|
|
827
|
+
: hasNoDateLimit
|
|
828
|
+
? $t("keys.campaigns.campaign.status_active")
|
|
829
|
+
: timeValues.days <= 7
|
|
830
|
+
? $t("keys.campaigns.campaign.status_ending")
|
|
831
|
+
: $t("keys.campaigns.campaign.status_active")
|
|
755
832
|
}}
|
|
756
833
|
</div>
|
|
757
834
|
<div class="tag-quantity" v-if="!isCampaignExpired">
|
|
@@ -765,7 +842,8 @@ onMounted(async () => {
|
|
|
765
842
|
</div>
|
|
766
843
|
</div>
|
|
767
844
|
|
|
768
|
-
<div class="time" v-if="!hasNoDateLimit">
|
|
845
|
+
<div class="time" v-if="campaignNotYetOpen || !hasNoDateLimit">
|
|
846
|
+
<div class="time-label" v-if="campaignNotYetOpen">{{ $t("keys.campaigns.campaign.opens_in") }}</div>
|
|
769
847
|
<div class="card-time">
|
|
770
848
|
{{ timeValues.days }}<span>{{ $t("keys.campaigns.campaign.days") }}</span>
|
|
771
849
|
</div>
|
|
@@ -780,6 +858,18 @@ onMounted(async () => {
|
|
|
780
858
|
</div>
|
|
781
859
|
</div>
|
|
782
860
|
|
|
861
|
+
<button
|
|
862
|
+
v-if="campaignNotYetOpen"
|
|
863
|
+
type="button"
|
|
864
|
+
class="follow-btn"
|
|
865
|
+
:class="{ following }"
|
|
866
|
+
:disabled="followBusy"
|
|
867
|
+
@click="toggleFollow"
|
|
868
|
+
>
|
|
869
|
+
<MGIcon :icon="following ? 'check' : 'bell'" />
|
|
870
|
+
{{ following ? $t("keys.campaigns.campaign.following") : $t("keys.campaigns.campaign.follow_campaign") }}
|
|
871
|
+
</button>
|
|
872
|
+
|
|
783
873
|
<div class="requirements">
|
|
784
874
|
{{ $t("keys.campaigns.campaign.requirements") }}
|
|
785
875
|
<div class="tags">
|
|
@@ -891,7 +981,7 @@ onMounted(async () => {
|
|
|
891
981
|
</div>
|
|
892
982
|
<div class="texts">
|
|
893
983
|
{{ campaign?.company || "" }}
|
|
894
|
-
<div class="small">{{
|
|
984
|
+
<div v-if="campaign?.company_type" class="small">{{ campaign.company_type }}</div>
|
|
895
985
|
</div>
|
|
896
986
|
</div>
|
|
897
987
|
<div class="badge-tag" v-if="campaign?.email">
|
|
@@ -1279,15 +1369,24 @@ onMounted(async () => {
|
|
|
1279
1369
|
span.active { color: var(--success, #22c55e); }
|
|
1280
1370
|
span.inactive { color: var(--danger, #e53935); }
|
|
1281
1371
|
span.ending { color: var(--status-ending, #ffa600); }
|
|
1372
|
+
span.upcoming { color: var(--status-upcoming, #a78bfa); }
|
|
1282
1373
|
}
|
|
1283
1374
|
}
|
|
1284
1375
|
}
|
|
1285
1376
|
|
|
1286
1377
|
.time {
|
|
1287
1378
|
display: flex;
|
|
1379
|
+
flex-wrap: wrap;
|
|
1288
1380
|
gap: 12px;
|
|
1289
1381
|
width: 100%;
|
|
1290
1382
|
|
|
1383
|
+
.time-label {
|
|
1384
|
+
flex-basis: 100%;
|
|
1385
|
+
font-size: 12px;
|
|
1386
|
+
color: var(--status-upcoming, #a78bfa);
|
|
1387
|
+
font-weight: 600;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1291
1390
|
.card-time {
|
|
1292
1391
|
display: flex;
|
|
1293
1392
|
min-height: 80px;
|
|
@@ -1308,6 +1407,31 @@ onMounted(async () => {
|
|
|
1308
1407
|
}
|
|
1309
1408
|
}
|
|
1310
1409
|
|
|
1410
|
+
.follow-btn {
|
|
1411
|
+
display: flex;
|
|
1412
|
+
align-items: center;
|
|
1413
|
+
justify-content: center;
|
|
1414
|
+
gap: 8px;
|
|
1415
|
+
width: 100%;
|
|
1416
|
+
padding: 10px;
|
|
1417
|
+
border: 1px solid var(--key-accent, var(--primary, #D297FF));
|
|
1418
|
+
background: transparent;
|
|
1419
|
+
color: var(--key-accent, var(--primary, #D297FF));
|
|
1420
|
+
font-size: 13px;
|
|
1421
|
+
font-weight: 600;
|
|
1422
|
+
font-family: inherit;
|
|
1423
|
+
cursor: pointer;
|
|
1424
|
+
transition: background-color 0.15s, opacity 0.15s;
|
|
1425
|
+
|
|
1426
|
+
&:hover:not(:disabled) { background-color: rgba(210, 151, 255, 0.1); }
|
|
1427
|
+
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
1428
|
+
|
|
1429
|
+
&.following {
|
|
1430
|
+
background-color: var(--key-accent, var(--primary, #D297FF));
|
|
1431
|
+
color: var(--body-bg, #101012);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1311
1435
|
.requirements {
|
|
1312
1436
|
display: flex;
|
|
1313
1437
|
flex-direction: column;
|
|
@@ -22,6 +22,16 @@ const requestData = ref<any>(null)
|
|
|
22
22
|
const copied = ref(false)
|
|
23
23
|
const platform = ref("")
|
|
24
24
|
|
|
25
|
+
// Embargo handling: the creator gets the code right away specifically to
|
|
26
|
+
// prepare content ahead of the embargo lifting — embargo_date only restricts
|
|
27
|
+
// when they publish, not when they can have the key. The backend requires an
|
|
28
|
+
// explicit one-time acknowledgment before actually returning the code
|
|
29
|
+
// (needsEmbargoAck) — a real consent step, not just the neutral, unenforced
|
|
30
|
+
// date line this page used to show.
|
|
31
|
+
const needsEmbargoAck = ref(false)
|
|
32
|
+
const embargoAckDate = ref<string | null>(null)
|
|
33
|
+
const embargoAckChecked = ref(false)
|
|
34
|
+
|
|
25
35
|
// Maps a platform name to its official key-activation page.
|
|
26
36
|
const REDEEM_URLS: Record<string, string> = {
|
|
27
37
|
steam: "https://store.steampowered.com/account/registerkey",
|
|
@@ -69,22 +79,28 @@ onMounted(async () => {
|
|
|
69
79
|
}
|
|
70
80
|
})
|
|
71
81
|
|
|
72
|
-
async function reveal() {
|
|
82
|
+
async function reveal(acknowledgeEmbargo = false) {
|
|
73
83
|
revealing.value = true
|
|
74
84
|
error.value = ""
|
|
75
85
|
try {
|
|
76
|
-
const res = await revealKey(requestId)
|
|
86
|
+
const res = await revealKey(requestId, acknowledgeEmbargo)
|
|
77
87
|
const data = res?.data?.data || res?.data || null
|
|
78
88
|
keyCode.value = data?.code || (typeof data?.key === "string" ? data.key : null) || data?.value || null
|
|
79
89
|
// Keep the pre-reveal platform (chosen at request time) as fallback if the
|
|
80
90
|
// revealed item has no platform of its own.
|
|
81
91
|
platform.value = data?.platform || data?.platforms?.[0]?.name || platform.value
|
|
92
|
+
needsEmbargoAck.value = false
|
|
82
93
|
if (!keyCode.value) {
|
|
83
94
|
error.value = $i18n.t("keys.campaigns.error_reveal")
|
|
84
95
|
}
|
|
85
96
|
} catch (e: any) {
|
|
86
|
-
const
|
|
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>
|
|
@@ -306,7 +306,9 @@ export const fetchCampaignBySlug = async (slug: string) => {
|
|
|
306
306
|
request_status: k.request_user_status,
|
|
307
307
|
end: k.end_date,
|
|
308
308
|
start: k.start_date,
|
|
309
|
+
is_following: k.is_following ?? false,
|
|
309
310
|
company: k.company?.name,
|
|
311
|
+
company_type: k.company?.company_type ?? null,
|
|
310
312
|
email: k.company?.email,
|
|
311
313
|
twitter: k.company?.twitter,
|
|
312
314
|
logo: k.company?.thumbnail_url,
|
|
@@ -418,8 +420,10 @@ export const requestKey = (data: Record<string, any>) => {
|
|
|
418
420
|
/**
|
|
419
421
|
* Reveal a previously granted key.
|
|
420
422
|
*/
|
|
421
|
-
export const revealKey = (requestId: number) => {
|
|
422
|
-
return authClient.get(`/public/key-requests/${requestId}/reveal
|
|
423
|
+
export const revealKey = (requestId: number, acknowledgeEmbargo = false) => {
|
|
424
|
+
return authClient.get(`/public/key-requests/${requestId}/reveal`, {
|
|
425
|
+
params: acknowledgeEmbargo ? { acknowledge_embargo: 1 } : undefined,
|
|
426
|
+
})
|
|
423
427
|
}
|
|
424
428
|
|
|
425
429
|
/**
|
|
@@ -446,6 +450,20 @@ export const fetchKeyActions = (keyId: number) => {
|
|
|
446
450
|
return authClient.get(`/public/keys/${keyId}/actions`)
|
|
447
451
|
}
|
|
448
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Follow/unfollow a specific campaign — a personal reminder distinct from
|
|
455
|
+
* enableKeyCampaignNotifications() above (a one-time global opt-in to every
|
|
456
|
+
* eligible campaign). Most useful before a campaign with a future start
|
|
457
|
+
* date has opened: the user gets notified the moment it does.
|
|
458
|
+
*/
|
|
459
|
+
export const followCampaign = (keyId: number) => {
|
|
460
|
+
return authClient.post(`/public/keys/${keyId}/follow`)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export const unfollowCampaign = (keyId: number) => {
|
|
464
|
+
return authClient.delete(`/public/keys/${keyId}/follow`)
|
|
465
|
+
}
|
|
466
|
+
|
|
449
467
|
/**
|
|
450
468
|
* Submit a content material for a key request.
|
|
451
469
|
*/
|