@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.
@@ -0,0 +1,355 @@
1
+ <template>
2
+ <section id="comments" class="campaign-comments">
3
+ <header class="campaign-comments__header">
4
+ <h3 class="campaign-comments__title">
5
+ {{ $t("keys.campaigns.comments.title") }}
6
+ <span v-if="total" class="campaign-comments__count">{{ total }}</span>
7
+ </h3>
8
+ <p class="campaign-comments__subtitle">
9
+ {{ $t("keys.campaigns.comments.subtitle") }}
10
+ </p>
11
+ </header>
12
+
13
+ <!-- Composer. Rendered only when the API says this viewer may write, so
14
+ the rules live in one place instead of being re-derived here. -->
15
+ <form v-if="canComment" class="campaign-comments__composer" @submit.prevent="submit">
16
+ <textarea
17
+ v-model="draft"
18
+ class="campaign-comments__input"
19
+ :placeholder="$t('keys.campaigns.comments.placeholder')"
20
+ :maxlength="2200"
21
+ rows="3"
22
+ :disabled="posting"
23
+ />
24
+ <div class="campaign-comments__composer-footer">
25
+ <span class="campaign-comments__counter">{{ draft.length }}/2200</span>
26
+ <button type="submit" class="campaign-comments__submit" :disabled="!canSubmit">
27
+ {{ posting ? $t("keys.campaigns.comments.sending") : $t("keys.campaigns.comments.send") }}
28
+ </button>
29
+ </div>
30
+ </form>
31
+
32
+ <p v-else-if="!signedIn" class="campaign-comments__notice">
33
+ {{ $t("keys.campaigns.comments.sign_in_notice") }}
34
+ </p>
35
+ <p v-else class="campaign-comments__notice">
36
+ {{ $t("keys.campaigns.comments.creators_only_notice") }}
37
+ </p>
38
+
39
+ <p v-if="error" class="campaign-comments__error">{{ error }}</p>
40
+
41
+ <div v-if="loading && !comments.length" class="campaign-comments__skeleton">
42
+ <div v-for="n in 3" :key="n" class="campaign-comments__skeleton-row" />
43
+ </div>
44
+
45
+ <!-- The empty state is a statement of fact ("nobody has posted yet"), so
46
+ it must not show when the list is empty merely because the request
47
+ failed — the error message above already says what happened. -->
48
+ <p v-else-if="!comments.length && !error" class="campaign-comments__empty">
49
+ {{ $t("keys.campaigns.comments.empty") }}
50
+ </p>
51
+
52
+ <ul v-else class="campaign-comments__list">
53
+ <li v-for="comment in comments" :key="comment.uuid" class="campaign-comments__item">
54
+ <KeyCampaignCommentItem
55
+ :comment="comment"
56
+ :can-moderate="canModerate"
57
+ :can-reply="canComment"
58
+ @edit="onEdit"
59
+ @delete="onDelete"
60
+ @reply="onReply"
61
+ @hide="onHide"
62
+ @unhide="onUnhide"
63
+ />
64
+ </li>
65
+ </ul>
66
+
67
+ <button
68
+ v-if="hasMore"
69
+ class="campaign-comments__more"
70
+ :disabled="loading"
71
+ @click="loadMore"
72
+ >
73
+ {{ $t("keys.campaigns.comments.load_more") }}
74
+ </button>
75
+ </section>
76
+ </template>
77
+
78
+ <script setup lang="ts">
79
+ import { ref, computed, onMounted } from "vue"
80
+ import { useNuxtApp } from "#app"
81
+ import {
82
+ fetchCampaignComments,
83
+ postCampaignComment,
84
+ replyCampaignComment,
85
+ updateCampaignComment,
86
+ deleteCampaignComment,
87
+ hideCampaignComment,
88
+ unhideCampaignComment,
89
+ type CampaignComment,
90
+ } from "../../services/campaignService"
91
+
92
+ const props = defineProps<{
93
+ keyId: number
94
+ /**
95
+ * Permission flags come from the campaign payload (KeyTransformer) so the
96
+ * composer's visibility matches what the API will actually accept. They are
97
+ * refreshed from the comments response, which computes them too.
98
+ */
99
+ canComment?: boolean
100
+ canModerate?: boolean
101
+ signedIn?: boolean
102
+ }>()
103
+
104
+ const { $i18n } = useNuxtApp()
105
+
106
+ const comments = ref<CampaignComment[]>([])
107
+ const page = ref(1)
108
+ const lastPage = ref(1)
109
+ const total = ref(0)
110
+ const loading = ref(false)
111
+ const posting = ref(false)
112
+ const error = ref("")
113
+ const draft = ref("")
114
+
115
+ const canComment = ref(props.canComment ?? false)
116
+ const canModerate = ref(props.canModerate ?? false)
117
+
118
+ const hasMore = computed(() => page.value < lastPage.value)
119
+ const canSubmit = computed(() => !posting.value && draft.value.trim().length >= 2)
120
+
121
+ async function load(targetPage = 1) {
122
+ loading.value = true
123
+ error.value = ""
124
+ try {
125
+ const { data } = await fetchCampaignComments(props.keyId, targetPage)
126
+ comments.value = targetPage === 1 ? data.data : [...comments.value, ...data.data]
127
+ page.value = data.meta.current_page
128
+ lastPage.value = data.meta.last_page
129
+ total.value = data.meta.total
130
+ canComment.value = data.can_comment
131
+ canModerate.value = data.can_moderate
132
+ } catch (_) {
133
+ // $i18n from useNuxtApp, not globalThis.$nuxt — the latter is undefined
134
+ // during SSR and on a fresh client load, which rendered the literal
135
+ // string "undefined" where the error message should be.
136
+ error.value = $i18n.t("keys.campaigns.comments.load_error")
137
+ } finally {
138
+ loading.value = false
139
+ }
140
+ }
141
+
142
+ const loadMore = () => load(page.value + 1)
143
+
144
+ async function submit() {
145
+ if (!canSubmit.value) return
146
+ posting.value = true
147
+ error.value = ""
148
+ try {
149
+ await postCampaignComment(props.keyId, draft.value.trim())
150
+ draft.value = ""
151
+ // Reload rather than unshifting locally: the server owns ordering and
152
+ // the permission flags, and a fresh page keeps them honest.
153
+ await load(1)
154
+ } catch (e: any) {
155
+ error.value = e?.response?.data?.message || e?.response?.data?.errors?.body?.[0] || ""
156
+ } finally {
157
+ posting.value = false
158
+ }
159
+ }
160
+
161
+ async function onReply({ uuid, body }: { uuid: string; body: string }) {
162
+ await replyCampaignComment(uuid, body)
163
+ await load(1)
164
+ }
165
+
166
+ async function onEdit({ uuid, body }: { uuid: string; body: string }) {
167
+ await updateCampaignComment(uuid, body)
168
+ await load(page.value)
169
+ }
170
+
171
+ async function onDelete(uuid: string) {
172
+ await deleteCampaignComment(uuid)
173
+ await load(1)
174
+ }
175
+
176
+ async function onHide({ uuid, reason }: { uuid: string; reason?: string }) {
177
+ await hideCampaignComment(uuid, reason)
178
+ await load(page.value)
179
+ }
180
+
181
+ async function onUnhide(uuid: string) {
182
+ await unhideCampaignComment(uuid)
183
+ await load(page.value)
184
+ }
185
+
186
+ onMounted(() => load(1))
187
+ </script>
188
+
189
+ <style lang="scss" scoped>
190
+ /* Theme tokens only — no literal colours. Every variable used here is defined
191
+ in both _light.scss and _dark.scss of agency-frontend, community-frontend and
192
+ tv-frontend, which are the fronts that render this page. Note the themes are
193
+ not "light = pale": agency's .light-mode sets --body-bg to #18181a, so a
194
+ hardcoded translucent-white surface would be invisible in one of them. */
195
+ .campaign-comments {
196
+ margin-top: 2.5rem;
197
+ }
198
+
199
+ .campaign-comments__header {
200
+ margin-bottom: 1.25rem;
201
+ }
202
+
203
+ .campaign-comments__title {
204
+ display: flex;
205
+ align-items: center;
206
+ gap: 0.5rem;
207
+ font-size: 1.25rem;
208
+ font-weight: 700;
209
+ margin: 0;
210
+ color: var(--title-fg);
211
+ }
212
+
213
+ .campaign-comments__count {
214
+ font-size: 0.875rem;
215
+ font-weight: 600;
216
+ padding: 0.125rem 0.5rem;
217
+ color: var(--title-fg);
218
+ background: var(--card-article-bg);
219
+ border: 1px solid var(--card-article-border);
220
+ }
221
+
222
+ .campaign-comments__subtitle {
223
+ margin: 0.25rem 0 0;
224
+ font-size: 0.875rem;
225
+ color: var(--secondary-info-fg);
226
+ }
227
+
228
+ .campaign-comments__composer {
229
+ margin-bottom: 1.5rem;
230
+ }
231
+
232
+ .campaign-comments__input {
233
+ width: 100%;
234
+ padding: 0.75rem;
235
+ /* Deliberately NOT --input-bg/--input-fg: --input-bg is #CCCCCC in both
236
+ themes while --input-fg is #FFFFFF in dark, giving 1.61:1 — unreadable.
237
+ These two invert with the theme and measure above 4.5:1 in both. */
238
+ background: var(--card-article-bg);
239
+ border: 1px solid var(--input-border-color);
240
+ color: var(--title-fg);
241
+ font: inherit;
242
+ resize: vertical;
243
+
244
+ &::placeholder {
245
+ color: var(--secondary-info-fg);
246
+ }
247
+
248
+ &:focus {
249
+ outline: none;
250
+ border-color: var(--key-accent, var(--primary));
251
+ }
252
+ }
253
+
254
+ .campaign-comments__composer-footer {
255
+ display: flex;
256
+ align-items: center;
257
+ justify-content: space-between;
258
+ gap: 0.75rem;
259
+ margin-top: 0.5rem;
260
+ }
261
+
262
+ .campaign-comments__counter {
263
+ font-size: 0.75rem;
264
+ color: var(--secondary-info-fg);
265
+ }
266
+
267
+ /* Matches the .btn convention of the campaign page itself. */
268
+ .campaign-comments__submit {
269
+ height: 44px;
270
+ padding: 0 1.25rem;
271
+ background: var(--key-accent, var(--primary));
272
+ color: #000;
273
+ border: 0;
274
+ font-weight: 600;
275
+ cursor: pointer;
276
+ }
277
+
278
+ .campaign-comments__submit:disabled {
279
+ opacity: 0.5;
280
+ cursor: not-allowed;
281
+ }
282
+
283
+ .campaign-comments__notice,
284
+ .campaign-comments__empty {
285
+ font-size: 0.875rem;
286
+ color: var(--secondary-info-fg);
287
+ margin: 0 0 1.25rem;
288
+ }
289
+
290
+ .campaign-comments__error {
291
+ font-size: 0.875rem;
292
+ color: var(--danger);
293
+ margin: 0 0 1rem;
294
+ }
295
+
296
+ .campaign-comments__list {
297
+ list-style: none;
298
+ margin: 0;
299
+ padding: 0;
300
+ display: grid;
301
+ gap: 1rem;
302
+ }
303
+
304
+ .campaign-comments__skeleton {
305
+ display: grid;
306
+ gap: 1rem;
307
+ }
308
+
309
+ /* Skeleton, not spinner — the list shape is known before the data arrives. */
310
+ .campaign-comments__skeleton-row {
311
+ height: 4.5rem;
312
+ background: var(--card-article-bg);
313
+ animation: campaign-comments-pulse 1.4s ease-in-out infinite;
314
+ }
315
+
316
+ @keyframes campaign-comments-pulse {
317
+ 0%, 100% { opacity: 1; }
318
+ 50% { opacity: 0.45; }
319
+ }
320
+
321
+ @media (prefers-reduced-motion: reduce) {
322
+ .campaign-comments__skeleton-row { animation: none; }
323
+ }
324
+
325
+ .campaign-comments__more {
326
+ margin-top: 1.25rem;
327
+ height: 44px;
328
+ padding: 0 1rem;
329
+ background: transparent;
330
+ border: 1px solid var(--card-article-border);
331
+ color: var(--title-fg);
332
+ cursor: pointer;
333
+ }
334
+
335
+ /* Same breakpoint the campaign page uses for its own stacking. */
336
+ @media (max-width: 768px) {
337
+ .campaign-comments {
338
+ margin-top: 1.75rem;
339
+ }
340
+
341
+ .campaign-comments__title {
342
+ font-size: 1.125rem;
343
+ }
344
+
345
+ .campaign-comments__composer-footer {
346
+ flex-direction: column;
347
+ align-items: stretch;
348
+ }
349
+
350
+ .campaign-comments__submit,
351
+ .campaign-comments__more {
352
+ width: 100%;
353
+ }
354
+ }
355
+ </style>
@@ -370,7 +370,7 @@ const detailRoute = computed(() =>
370
370
 
371
371
  &__days {
372
372
  font-size: 11px;
373
- color: var(--text-secondary, #aaa);
373
+ color: var(--secondary-info-fg);
374
374
  }
375
375
 
376
376
  &__type {
@@ -401,7 +401,7 @@ const detailRoute = computed(() =>
401
401
  font-weight: 600;
402
402
  padding: 2px 6px;
403
403
  border: 1px solid var(--border-color, rgba(255,255,255,0.2));
404
- color: var(--text-secondary, #aaa);
404
+ color: var(--secondary-info-fg);
405
405
  text-transform: uppercase;
406
406
  letter-spacing: 0.4px;
407
407
  }
@@ -0,0 +1,250 @@
1
+ <template>
2
+ <section v-if="canRate || submitted" class="campaign-feedback">
3
+ <header class="campaign-feedback__header">
4
+ <h3 class="campaign-feedback__title">{{ $t("keys.campaigns.feedback.title") }}</h3>
5
+ <p class="campaign-feedback__subtitle">
6
+ {{ $t("keys.campaigns.feedback.subtitle", { game: gameName }) }}
7
+ </p>
8
+ </header>
9
+
10
+ <p v-if="submitted" class="campaign-feedback__done">
11
+ {{ $t("keys.campaigns.feedback.submitted", { game: gameName }) }}
12
+ <a v-if="gameSlug" :href="gameUrl" class="campaign-feedback__link">
13
+ {{ $t("keys.campaigns.feedback.view_on_game_page") }}
14
+ </a>
15
+ </p>
16
+
17
+ <form v-else class="campaign-feedback__form" @submit.prevent="submit">
18
+ <div class="campaign-feedback__stars" role="radiogroup" :aria-label="$t('keys.campaigns.feedback.rating_label')">
19
+ <button
20
+ v-for="star in 5"
21
+ :key="star"
22
+ type="button"
23
+ class="campaign-feedback__star"
24
+ :class="{ 'campaign-feedback__star--on': star <= rating }"
25
+ role="radio"
26
+ :aria-checked="star === rating"
27
+ :aria-label="String(star)"
28
+ @click="rating = star"
29
+ >
30
+
31
+ </button>
32
+ </div>
33
+
34
+ <input
35
+ v-model="title"
36
+ type="text"
37
+ class="campaign-feedback__input"
38
+ :maxlength="191"
39
+ :placeholder="$t('keys.campaigns.feedback.title_placeholder')"
40
+ >
41
+
42
+ <textarea
43
+ v-model="body"
44
+ class="campaign-feedback__input campaign-feedback__textarea"
45
+ rows="4"
46
+ :maxlength="2200"
47
+ :placeholder="$t('keys.campaigns.feedback.body_placeholder')"
48
+ />
49
+
50
+ <!-- Not decoration: the API rejects the submission without it, because
51
+ this rating counts towards the game's public average. -->
52
+ <label class="campaign-feedback__disclosure">
53
+ <input v-model="disclosure" type="checkbox">
54
+ <span>{{ $t("keys.campaigns.feedback.disclosure") }}</span>
55
+ </label>
56
+
57
+ <p class="campaign-feedback__note">{{ $t("keys.campaigns.feedback.average_note") }}</p>
58
+
59
+ <p v-if="error" class="campaign-feedback__error">{{ error }}</p>
60
+
61
+ <button type="submit" class="campaign-feedback__submit" :disabled="!canSubmit">
62
+ {{ sending ? $t("keys.campaigns.feedback.sending") : $t("keys.campaigns.feedback.send") }}
63
+ </button>
64
+ </form>
65
+ </section>
66
+ </template>
67
+
68
+ <script setup lang="ts">
69
+ import { ref, computed } from "vue"
70
+ import { submitCampaignGameRating } from "../../services/campaignService"
71
+
72
+ const props = defineProps<{
73
+ keyId: number
74
+ /** From the campaign payload — true only for creators who revealed a key. */
75
+ canRate?: boolean
76
+ gameName?: string
77
+ gameSlug?: string
78
+ locale?: string
79
+ }>()
80
+
81
+ const rating = ref(0)
82
+ const title = ref("")
83
+ const body = ref("")
84
+ const disclosure = ref(false)
85
+ const sending = ref(false)
86
+ const submitted = ref(false)
87
+ const error = ref("")
88
+
89
+ const gameName = computed(() => props.gameName || "")
90
+ const gameUrl = computed(() => `/${props.locale || "en"}/game/${props.gameSlug}?tab=ratings`)
91
+
92
+ const canSubmit = computed(() => !sending.value && rating.value > 0 && disclosure.value)
93
+
94
+ async function submit() {
95
+ if (!canSubmit.value) return
96
+ sending.value = true
97
+ error.value = ""
98
+ try {
99
+ await submitCampaignGameRating(props.keyId, {
100
+ rating: rating.value,
101
+ comment_title: title.value || undefined,
102
+ user_comment: body.value || undefined,
103
+ disclosure_acknowledged: disclosure.value,
104
+ })
105
+ submitted.value = true
106
+ } catch (e: any) {
107
+ error.value =
108
+ e?.response?.data?.message ||
109
+ e?.response?.data?.errors?.disclosure_acknowledged?.[0] ||
110
+ e?.response?.data?.errors?.rating?.[0] ||
111
+ ""
112
+ } finally {
113
+ sending.value = false
114
+ }
115
+ }
116
+ </script>
117
+
118
+ <style lang="scss" scoped>
119
+ /* Theme tokens only — see KeyCampaignComments.vue for why no literal colours. */
120
+ .campaign-feedback {
121
+ margin-top: 2.5rem;
122
+ padding: 1.25rem;
123
+ background: var(--card-article-bg);
124
+ border: 1px solid var(--card-article-border);
125
+ }
126
+
127
+ .campaign-feedback__title {
128
+ margin: 0;
129
+ font-size: 1.125rem;
130
+ font-weight: 700;
131
+ color: var(--title-fg);
132
+ }
133
+
134
+ .campaign-feedback__subtitle {
135
+ margin: 0.25rem 0 1rem;
136
+ font-size: 0.875rem;
137
+ color: var(--secondary-info-fg);
138
+ }
139
+
140
+ .campaign-feedback__stars {
141
+ display: flex;
142
+ gap: 0.25rem;
143
+ margin-bottom: 0.875rem;
144
+ }
145
+
146
+ .campaign-feedback__star {
147
+ background: none;
148
+ border: 0;
149
+ padding: 0.125rem;
150
+ font-size: 1.75rem;
151
+ line-height: 1;
152
+ cursor: pointer;
153
+ color: var(--secondary-info-fg);
154
+ }
155
+
156
+ .campaign-feedback__star--on {
157
+ color: var(--warning);
158
+ }
159
+
160
+ .campaign-feedback__input {
161
+ display: block;
162
+ width: 100%;
163
+ margin-bottom: 0.75rem;
164
+ padding: 0.625rem;
165
+ /* See KeyCampaignComments.vue: --input-bg/--input-fg is 1.61:1 in dark. */
166
+ background: var(--card-article-bg);
167
+ border: 1px solid var(--input-border-color);
168
+ color: var(--title-fg);
169
+ font: inherit;
170
+
171
+ &::placeholder {
172
+ color: var(--secondary-info-fg);
173
+ }
174
+
175
+ &:focus {
176
+ outline: none;
177
+ border-color: var(--key-accent, var(--primary));
178
+ }
179
+ }
180
+
181
+ .campaign-feedback__textarea {
182
+ resize: vertical;
183
+ }
184
+
185
+ .campaign-feedback__disclosure {
186
+ display: flex;
187
+ align-items: flex-start;
188
+ gap: 0.5rem;
189
+ font-size: 0.8125rem;
190
+ margin-bottom: 0.5rem;
191
+ color: var(--card-article-title);
192
+ cursor: pointer;
193
+ }
194
+
195
+ .campaign-feedback__note {
196
+ margin: 0 0 0.875rem;
197
+ font-size: 0.75rem;
198
+ color: var(--secondary-info-fg);
199
+ }
200
+
201
+ .campaign-feedback__error {
202
+ margin: 0 0 0.75rem;
203
+ font-size: 0.8125rem;
204
+ color: var(--danger);
205
+ }
206
+
207
+ .campaign-feedback__submit {
208
+ height: 44px;
209
+ padding: 0 1.25rem;
210
+ background: var(--key-accent, var(--primary));
211
+ color: #000;
212
+ border: 0;
213
+ font-weight: 600;
214
+ cursor: pointer;
215
+ }
216
+
217
+ .campaign-feedback__submit:disabled {
218
+ opacity: 0.5;
219
+ cursor: not-allowed;
220
+ }
221
+
222
+ .campaign-feedback__done {
223
+ margin: 0;
224
+ font-size: 0.9375rem;
225
+ color: var(--card-article-title);
226
+ }
227
+
228
+ .campaign-feedback__link {
229
+ margin-left: 0.5rem;
230
+ color: var(--key-accent, var(--primary));
231
+ text-decoration: underline;
232
+ }
233
+
234
+ @media (max-width: 768px) {
235
+ .campaign-feedback {
236
+ margin-top: 1.75rem;
237
+ padding: 1rem;
238
+ }
239
+
240
+ /* Bigger hit area: 28px stars at 4px gaps miss constantly on touch. */
241
+ .campaign-feedback__star {
242
+ font-size: 2rem;
243
+ padding: 0.25rem;
244
+ }
245
+
246
+ .campaign-feedback__submit {
247
+ width: 100%;
248
+ }
249
+ }
250
+ </style>
@@ -77,7 +77,7 @@ function copyCode() {
77
77
 
78
78
  &__sub {
79
79
  font-size: 0.88rem;
80
- color: var(--text-secondary, #888);
80
+ color: var(--secondary-info-fg);
81
81
  margin: 0;
82
82
  }
83
83
 
@@ -112,7 +112,7 @@ function copyCode() {
112
112
  &__copy {
113
113
  background: none;
114
114
  border: none;
115
- color: var(--text-secondary, #888);
115
+ color: var(--secondary-info-fg);
116
116
  cursor: pointer;
117
117
  font-size: 1.1rem;
118
118
  padding: 4px;