@mundogamernetwork/shared-ui 1.8.22 → 1.9.0

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.
@@ -36,6 +36,10 @@ const platforms = ref<{ id: number; name: string }[]>([])
36
36
  const loadingPools = ref(true)
37
37
  const loadingRequests = ref(true)
38
38
  const requesting = ref<number | null>(null)
39
+ // Per-pool refusal message. The catch below only ever handled 401, so every
40
+ // other refusal — quota reached, campaign closed, not eligible — left the
41
+ // button doing nothing at all with no explanation.
42
+ const requestErrors = ref<Record<number, string>>({})
39
43
  const activeTab = ref<'available' | 'mine'>('available')
40
44
  const isAuthenticated = ref(true) // assume true; set false on 401
41
45
 
@@ -114,6 +118,7 @@ function redirectToLogin() {
114
118
 
115
119
  async function doRequestKey(pool: KeyPool) {
116
120
  requesting.value = pool.id
121
+ delete requestErrors.value[pool.id]
117
122
  try {
118
123
  const form = requestForms.value[pool.id] ?? { platform_ids: [], description: '' }
119
124
  const body: Record<string, any> = {
@@ -129,8 +134,22 @@ async function doRequestKey(pool: KeyPool) {
129
134
  _checkNotifPrompt()
130
135
  }
131
136
  } catch (err: any) {
132
- if (err?.response?.status === 401) {
137
+ const status = err?.response?.status
138
+ const errorCode = err?.response?.data?.error_code
139
+
140
+ if (status === 401) {
133
141
  isAuthenticated.value = false
142
+ } else if (status === 409) {
143
+ requestErrors.value[pool.id] = t('keys.campaigns.error_duplicate')
144
+ } else if (status === 403 && errorCode === 'user_key_blocked') {
145
+ requestErrors.value[pool.id] = t('keys.campaigns.error_user_blocked')
146
+ } else if (status === 403) {
147
+ requestErrors.value[pool.id] = t('keys.campaigns.error_not_eligible')
148
+ } else if (status === 422 && errorCode === 'pending_limit_reached') {
149
+ requestErrors.value[pool.id] = t('keys.campaigns.error_pending_limit')
150
+ } else {
151
+ requestErrors.value[pool.id] =
152
+ err?.response?.data?.message || t('keys.campaigns.error_request')
134
153
  }
135
154
  } finally {
136
155
  requesting.value = null
@@ -325,6 +344,10 @@ function statusColor(slug: string): string {
325
344
  {{ requesting === pool.id ? $t('common.sending') : $t('keys.browser.request_btn') }}
326
345
  </button>
327
346
  </div>
347
+
348
+ <p v-if="requestErrors[pool.id]" class="kb__request-error" role="alert">
349
+ {{ requestErrors[pool.id] }}
350
+ </p>
328
351
  </div>
329
352
  </div>
330
353
  </div>
@@ -399,7 +422,7 @@ function statusColor(slug: string): string {
399
422
  &__auth-wall {
400
423
  padding: 40px;
401
424
  text-align: center;
402
- color: var(--text-secondary, #888);
425
+ color: var(--secondary-info-fg);
403
426
  display: flex;
404
427
  flex-direction: column;
405
428
  align-items: center;
@@ -430,7 +453,7 @@ function statusColor(slug: string): string {
430
453
  background: none;
431
454
  border: none;
432
455
  border-bottom: 2px solid transparent;
433
- color: var(--text-secondary, #888);
456
+ color: var(--secondary-info-fg);
434
457
  cursor: pointer;
435
458
  display: flex;
436
459
  align-items: center;
@@ -455,7 +478,7 @@ function statusColor(slug: string): string {
455
478
  &__empty {
456
479
  padding: 40px;
457
480
  text-align: center;
458
- color: var(--text-secondary, #888);
481
+ color: var(--secondary-info-fg);
459
482
  font-size: 0.9rem;
460
483
  }
461
484
 
@@ -484,13 +507,13 @@ function statusColor(slug: string): string {
484
507
 
485
508
  &__pool-availability { font-size: 0.82rem; }
486
509
  &__avail-ok { color: var(--success, #22c55e); }
487
- &__avail-empty { color: var(--text-secondary, #888); }
510
+ &__avail-empty { color: var(--secondary-info-fg); }
488
511
 
489
512
  &__platform-sel { display: flex; flex-direction: column; gap: 6px; }
490
513
 
491
514
  &__label {
492
515
  font-size: 0.75rem;
493
- color: var(--text-secondary, #888);
516
+ color: var(--secondary-info-fg);
494
517
  }
495
518
 
496
519
  &__platform-chips {
@@ -503,7 +526,7 @@ function statusColor(slug: string): string {
503
526
  padding: 4px 10px;
504
527
  font-size: 0.78rem;
505
528
  border: 1px solid var(--border-color, #2a2a2a);
506
- color: var(--text-secondary, #888);
529
+ color: var(--secondary-info-fg);
507
530
  cursor: pointer;
508
531
  transition: border-color 0.15s, color 0.15s;
509
532
 
@@ -551,6 +574,13 @@ function statusColor(slug: string): string {
551
574
  color: var(--success, #22c55e);
552
575
  }
553
576
 
577
+ &__request-error {
578
+ margin: 8px 0 0;
579
+ font-size: 0.8rem;
580
+ line-height: 1.45;
581
+ color: var(--danger, #ef4444);
582
+ }
583
+
554
584
  &__request-list {
555
585
  display: flex;
556
586
  flex-direction: column;
@@ -577,7 +607,7 @@ function statusColor(slug: string): string {
577
607
 
578
608
  &__request-platform {
579
609
  font-size: 0.78rem;
580
- color: var(--text-secondary, #888);
610
+ color: var(--secondary-info-fg);
581
611
  }
582
612
 
583
613
  &__status-chip {
@@ -613,7 +643,7 @@ function statusColor(slug: string): string {
613
643
 
614
644
  &__pending-label {
615
645
  font-size: 0.8rem;
616
- color: var(--text-secondary, #888);
646
+ color: var(--secondary-info-fg);
617
647
  font-style: italic;
618
648
  }
619
649
 
@@ -647,7 +677,7 @@ function statusColor(slug: string): string {
647
677
  p {
648
678
  margin: 0;
649
679
  font-size: 0.82rem;
650
- color: var(--text-secondary, #888);
680
+ color: var(--secondary-info-fg);
651
681
  line-height: 1.4;
652
682
  }
653
683
  }
@@ -664,7 +694,7 @@ function statusColor(slug: string): string {
664
694
  font-weight: 600;
665
695
  border: 1px solid var(--border-color, #2a2a2a);
666
696
  background: none;
667
- color: var(--text-secondary, #888);
697
+ color: var(--secondary-info-fg);
668
698
  cursor: pointer;
669
699
  transition: opacity 0.15s, border-color 0.15s, color 0.15s;
670
700
 
@@ -0,0 +1,303 @@
1
+ <template>
2
+ <article class="campaign-comment" :class="{ 'campaign-comment--hidden': comment.is_hidden }">
3
+ <div class="campaign-comment__head">
4
+ <img
5
+ v-if="comment.author?.avatar"
6
+ :src="comment.author.avatar"
7
+ :alt="comment.author?.name || ''"
8
+ class="campaign-comment__avatar"
9
+ loading="lazy"
10
+ >
11
+ <div class="campaign-comment__meta">
12
+ <strong class="campaign-comment__author">
13
+ {{ comment.author?.name || comment.author?.nickname || $t("keys.campaigns.comments.unknown_author") }}
14
+ </strong>
15
+ <span class="campaign-comment__date">
16
+ {{ formattedDate }}
17
+ <em v-if="comment.edited">· {{ $t("keys.campaigns.comments.edited") }}</em>
18
+ </span>
19
+ </div>
20
+
21
+ <!-- Only shown to studio/admin: the public never learns a comment
22
+ was hidden, which is the point of hiding rather than deleting. -->
23
+ <span v-if="comment.is_hidden" class="campaign-comment__hidden-badge">
24
+ {{ $t("keys.campaigns.comments.hidden_badge") }}
25
+ </span>
26
+ </div>
27
+
28
+ <p v-if="!editing" class="campaign-comment__body">{{ comment.body }}</p>
29
+
30
+ <form v-else class="campaign-comment__edit" @submit.prevent="saveEdit">
31
+ <textarea v-model="editDraft" class="campaign-comment__edit-input" rows="3" :maxlength="2200" />
32
+ <div class="campaign-comment__actions">
33
+ <button type="submit" class="campaign-comment__action">{{ $t("keys.campaigns.comments.save") }}</button>
34
+ <button type="button" class="campaign-comment__action" @click="cancelEdit">{{ $t("keys.campaigns.comments.cancel") }}</button>
35
+ </div>
36
+ </form>
37
+
38
+ <div class="campaign-comment__actions">
39
+ <button v-if="canReply" type="button" class="campaign-comment__action" @click="replying = !replying">
40
+ {{ $t("keys.campaigns.comments.reply") }}
41
+ </button>
42
+ <button v-if="comment.can_edit && !editing" type="button" class="campaign-comment__action" @click="startEdit">
43
+ {{ $t("keys.campaigns.comments.edit") }}
44
+ </button>
45
+ <button v-if="comment.can_delete" type="button" class="campaign-comment__action" @click="confirmDelete">
46
+ {{ $t("keys.campaigns.comments.delete") }}
47
+ </button>
48
+ <button
49
+ v-if="canModerate && !comment.is_hidden"
50
+ type="button"
51
+ class="campaign-comment__action"
52
+ @click="emit('hide', { uuid: comment.uuid })"
53
+ >
54
+ {{ $t("keys.campaigns.comments.hide") }}
55
+ </button>
56
+ <button
57
+ v-if="canModerate && comment.is_hidden"
58
+ type="button"
59
+ class="campaign-comment__action"
60
+ @click="emit('unhide', comment.uuid)"
61
+ >
62
+ {{ $t("keys.campaigns.comments.unhide") }}
63
+ </button>
64
+ </div>
65
+
66
+ <form v-if="replying" class="campaign-comment__reply" @submit.prevent="sendReply">
67
+ <textarea
68
+ v-model="replyDraft"
69
+ class="campaign-comment__edit-input"
70
+ rows="2"
71
+ :maxlength="2200"
72
+ :placeholder="$t('keys.campaigns.comments.reply_placeholder')"
73
+ />
74
+ <button type="submit" class="campaign-comment__action" :disabled="replyDraft.trim().length < 2">
75
+ {{ $t("keys.campaigns.comments.send") }}
76
+ </button>
77
+ </form>
78
+
79
+ <ul v-if="comment.replies?.length" class="campaign-comment__replies">
80
+ <li v-for="reply in comment.replies" :key="reply.uuid" class="campaign-comment__reply-item">
81
+ <div class="campaign-comment__head">
82
+ <img
83
+ v-if="reply.author?.avatar"
84
+ :src="reply.author.avatar"
85
+ :alt="reply.author?.name || ''"
86
+ class="campaign-comment__avatar campaign-comment__avatar--small"
87
+ loading="lazy"
88
+ >
89
+ <div class="campaign-comment__meta">
90
+ <strong class="campaign-comment__author">
91
+ {{ reply.author?.name || reply.author?.nickname }}
92
+ </strong>
93
+ </div>
94
+ </div>
95
+ <p class="campaign-comment__body">{{ reply.body }}</p>
96
+ </li>
97
+ </ul>
98
+ </article>
99
+ </template>
100
+
101
+ <script setup lang="ts">
102
+ import { ref, computed } from "vue"
103
+ import type { CampaignComment } from "../../services/campaignService"
104
+
105
+ const props = defineProps<{
106
+ comment: CampaignComment
107
+ canModerate?: boolean
108
+ canReply?: boolean
109
+ }>()
110
+
111
+ const emit = defineEmits<{
112
+ (e: "edit", payload: { uuid: string; body: string }): void
113
+ (e: "delete", uuid: string): void
114
+ (e: "reply", payload: { uuid: string; body: string }): void
115
+ (e: "hide", payload: { uuid: string; reason?: string }): void
116
+ (e: "unhide", uuid: string): void
117
+ }>()
118
+
119
+ const editing = ref(false)
120
+ const replying = ref(false)
121
+ const editDraft = ref("")
122
+ const replyDraft = ref("")
123
+
124
+ const formattedDate = computed(() => {
125
+ if (!props.comment.created_at) return ""
126
+ try {
127
+ return new Date(props.comment.created_at).toLocaleDateString(undefined, {
128
+ day: "2-digit",
129
+ month: "short",
130
+ year: "numeric",
131
+ })
132
+ } catch (_) {
133
+ return ""
134
+ }
135
+ })
136
+
137
+ function startEdit() {
138
+ editDraft.value = props.comment.body
139
+ editing.value = true
140
+ }
141
+
142
+ function cancelEdit() {
143
+ editing.value = false
144
+ editDraft.value = ""
145
+ }
146
+
147
+ function saveEdit() {
148
+ if (editDraft.value.trim().length < 2) return
149
+ emit("edit", { uuid: props.comment.uuid, body: editDraft.value.trim() })
150
+ editing.value = false
151
+ }
152
+
153
+ function confirmDelete() {
154
+ emit("delete", props.comment.uuid)
155
+ }
156
+
157
+ function sendReply() {
158
+ if (replyDraft.value.trim().length < 2) return
159
+ emit("reply", { uuid: props.comment.uuid, body: replyDraft.value.trim() })
160
+ replyDraft.value = ""
161
+ replying.value = false
162
+ }
163
+ </script>
164
+
165
+ <style lang="scss" scoped>
166
+ /* Theme tokens only — see KeyCampaignComments.vue for why no literal colours. */
167
+ .campaign-comment {
168
+ padding: 1rem;
169
+ background: var(--card-article-bg);
170
+ border: 1px solid var(--card-article-border);
171
+ }
172
+
173
+ .campaign-comment--hidden {
174
+ opacity: 0.55;
175
+ }
176
+
177
+ .campaign-comment__head {
178
+ display: flex;
179
+ align-items: center;
180
+ gap: 0.625rem;
181
+ margin-bottom: 0.5rem;
182
+ }
183
+
184
+ .campaign-comment__avatar {
185
+ width: 2.25rem;
186
+ height: 2.25rem;
187
+ flex-shrink: 0;
188
+ object-fit: cover;
189
+ }
190
+
191
+ .campaign-comment__avatar--small {
192
+ width: 1.75rem;
193
+ height: 1.75rem;
194
+ }
195
+
196
+ .campaign-comment__meta {
197
+ display: flex;
198
+ flex-direction: column;
199
+ min-width: 0;
200
+ line-height: 1.3;
201
+ }
202
+
203
+ .campaign-comment__author {
204
+ font-size: 0.9375rem;
205
+ color: var(--card-article-title);
206
+ }
207
+
208
+ .campaign-comment__date {
209
+ font-size: 0.75rem;
210
+ color: var(--secondary-info-fg);
211
+ }
212
+
213
+ .campaign-comment__hidden-badge {
214
+ margin-left: auto;
215
+ font-size: 0.6875rem;
216
+ text-transform: uppercase;
217
+ letter-spacing: 0.04em;
218
+ padding: 0.125rem 0.5rem;
219
+ font-weight: 600;
220
+ color: var(--title-fg);
221
+ border: 1px solid var(--warning);
222
+ }
223
+
224
+ .campaign-comment__body {
225
+ margin: 0;
226
+ white-space: pre-wrap;
227
+ overflow-wrap: anywhere;
228
+ font-size: 0.9375rem;
229
+ color: var(--card-article-title);
230
+ }
231
+
232
+ .campaign-comment__edit-input {
233
+ width: 100%;
234
+ padding: 0.5rem;
235
+ /* See KeyCampaignComments.vue: --input-bg/--input-fg is 1.61:1 in dark. */
236
+ background: var(--card-article-bg);
237
+ border: 1px solid var(--input-border-color);
238
+ color: var(--title-fg);
239
+ font: inherit;
240
+
241
+ &::placeholder {
242
+ color: var(--secondary-info-fg);
243
+ }
244
+
245
+ &:focus {
246
+ outline: none;
247
+ border-color: var(--key-accent, var(--primary));
248
+ }
249
+ }
250
+
251
+ .campaign-comment__actions {
252
+ display: flex;
253
+ flex-wrap: wrap;
254
+ gap: 0.75rem;
255
+ margin-top: 0.625rem;
256
+ }
257
+
258
+ .campaign-comment__action {
259
+ background: none;
260
+ border: 0;
261
+ padding: 0;
262
+ color: var(--secondary-info-fg);
263
+ font-size: 0.8125rem;
264
+ cursor: pointer;
265
+
266
+ &:hover {
267
+ color: var(--title-fg);
268
+ }
269
+ }
270
+
271
+ .campaign-comment__reply {
272
+ margin-top: 0.75rem;
273
+ }
274
+
275
+ .campaign-comment__replies {
276
+ list-style: none;
277
+ margin: 0.875rem 0 0;
278
+ padding: 0 0 0 1rem;
279
+ border-left: 2px solid var(--card-article-border);
280
+ display: grid;
281
+ gap: 0.75rem;
282
+ }
283
+
284
+ @media (max-width: 768px) {
285
+ .campaign-comment {
286
+ padding: 0.875rem;
287
+ }
288
+
289
+ /* Tap targets: the inline actions are too tight to hit reliably at 13px. */
290
+ .campaign-comment__actions {
291
+ gap: 1rem;
292
+ }
293
+
294
+ .campaign-comment__action {
295
+ padding: 0.25rem 0;
296
+ font-size: 0.875rem;
297
+ }
298
+
299
+ .campaign-comment__replies {
300
+ padding-left: 0.625rem;
301
+ }
302
+ }
303
+ </style>