@mundogamernetwork/shared-ui 1.0.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.
Files changed (87) hide show
  1. package/README.md +283 -0
  2. package/components/PressKit/AssetGallery.vue +349 -0
  3. package/components/PressKit/Awards.vue +100 -0
  4. package/components/PressKit/Credits.vue +78 -0
  5. package/components/PressKit/FactSheet.vue +204 -0
  6. package/components/PressKit/Hero.vue +143 -0
  7. package/components/PressKit/Quotes.vue +80 -0
  8. package/components/PressKit/VideoPlayer.vue +134 -0
  9. package/components/checkout/MgCartItemList.vue +214 -0
  10. package/components/checkout/MgCartSummary.vue +204 -0
  11. package/components/checkout/MgCheckoutSidebar.vue +230 -0
  12. package/components/checkout/MgGuestEmailForm.vue +97 -0
  13. package/components/checkout/MgPaymentMethodSelector.vue +162 -0
  14. package/components/checkout/MgPixQRCode.vue +222 -0
  15. package/components/indie-wall/IndieWallLeaderboard.vue +208 -0
  16. package/components/indie-wall/MuralCanvas.vue +481 -0
  17. package/components/indie-wall/StepBlock.vue +314 -0
  18. package/components/indie-wall/StepCustomize.vue +530 -0
  19. package/components/indie-wall/StepGoal.vue +169 -0
  20. package/components/indie-wall/StepPackage.vue +145 -0
  21. package/components/indie-wall/StepPay.vue +209 -0
  22. package/components/indie-wall/SupportStepper.vue +372 -0
  23. package/components/invoices/MgInvoiceDownload.vue +50 -0
  24. package/components/pricing/MgBillingToggle.vue +74 -0
  25. package/components/pricing/MgPricingCard.vue +245 -0
  26. package/components/ui/Header/MgMessageCard.vue +147 -0
  27. package/components/ui/Header/MgMessageModal.vue +414 -0
  28. package/components/ui/Header/MgNotificationCard.vue +200 -0
  29. package/components/ui/Header/MgNotificationsModal.vue +125 -0
  30. package/components/ui/MgAnnouncementBanner.vue +147 -0
  31. package/components/ui/MgBanners.vue +23 -0
  32. package/components/ui/MgHeaderComponent.vue +283 -0
  33. package/components/ui/MgHeaderUIConfig.vue +225 -0
  34. package/components/ui/MgHeaderUIUser.vue +301 -0
  35. package/components/ui/MgLoginModal.vue +156 -0
  36. package/components/ui/MgPromotionBanner.vue +185 -0
  37. package/composables/useLogout.ts +42 -0
  38. package/composables/useMgCheckout.ts +287 -0
  39. package/composables/useMgUserNotifications.ts +122 -0
  40. package/composables/usePaymentMethods.ts +75 -0
  41. package/composables/useSubscription.ts +163 -0
  42. package/middleware/auth.global.ts +40 -0
  43. package/nuxt.config.ts +31 -0
  44. package/package.json +40 -0
  45. package/pages/[slug]/index.vue +112 -0
  46. package/pages/about.vue +133 -0
  47. package/pages/blog.vue +430 -0
  48. package/pages/careers.vue +329 -0
  49. package/pages/contact.vue +339 -0
  50. package/pages/faq.vue +317 -0
  51. package/pages/health-check.vue +20 -0
  52. package/pages/icons.vue +58 -0
  53. package/pages/magazine/[slug].vue +209 -0
  54. package/pages/magazine/index.vue +267 -0
  55. package/pages/media-kit/[slug].vue +625 -0
  56. package/pages/mural/[slug].vue +1058 -0
  57. package/pages/partners.vue +290 -0
  58. package/pages/press.vue +237 -0
  59. package/pages/presskit/[slug].vue +191 -0
  60. package/pages/roadmap.vue +355 -0
  61. package/pages/status.vue +199 -0
  62. package/pages/team.vue +266 -0
  63. package/pages/wall/[slug].vue +11 -0
  64. package/plugins/auth.client.ts +17 -0
  65. package/plugins/echo.client.ts +132 -0
  66. package/services/authService.ts +95 -0
  67. package/services/chatService.ts +53 -0
  68. package/services/contactService.ts +35 -0
  69. package/services/documentService.ts +16 -0
  70. package/services/httpService.ts +95 -0
  71. package/services/indieWallService.ts +174 -0
  72. package/services/institutionalService.ts +248 -0
  73. package/services/mediaKitService.ts +51 -0
  74. package/services/notificationsService.ts +20 -0
  75. package/services/pressKitService.ts +55 -0
  76. package/stores/announcement.ts +129 -0
  77. package/stores/auth.ts +86 -0
  78. package/stores/chat.ts +150 -0
  79. package/stores/contact.ts +28 -0
  80. package/stores/document.ts +27 -0
  81. package/stores/index.ts +34 -0
  82. package/stores/institutional.ts +231 -0
  83. package/stores/login.ts +27 -0
  84. package/stores/notifications.ts +133 -0
  85. package/stores/promotion.ts +154 -0
  86. package/types/index.ts +135 -0
  87. package/utils/serialize.ts +29 -0
@@ -0,0 +1,530 @@
1
+ <script setup lang="ts">
2
+ const props = withDefaults(defineProps<{
3
+ form: {
4
+ title: string
5
+ message: string
6
+ background_color: string
7
+ image_url: string
8
+ link: string
9
+ is_anonymous: boolean
10
+ guest_email: string
11
+ guest_name: string
12
+ }
13
+ isGuest: boolean
14
+ pixelW?: number
15
+ pixelH?: number
16
+ }>(), {
17
+ pixelW: 1,
18
+ pixelH: 1,
19
+ })
20
+
21
+ const emit = defineEmits<{
22
+ (e: 'update', form: typeof props.form): void
23
+ (e: 'next'): void
24
+ }>()
25
+
26
+ const local = reactive({ ...props.form })
27
+
28
+ watch(local, (v) => emit('update', { ...v }), { deep: true })
29
+
30
+ const fileInput = ref<HTMLInputElement | null>(null)
31
+ const imageError = ref('')
32
+ const uploading = ref(false)
33
+
34
+ const titleError = ref('')
35
+ const messageError = ref('')
36
+ const imageMissingError = ref('')
37
+ const emailError = ref('')
38
+
39
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
40
+ const URL_REGEX = /^https?:\/\/.+\..+/
41
+
42
+ const linkError = ref('')
43
+
44
+ function validateLink() {
45
+ const val = local.link.trim()
46
+ if (!val) { linkError.value = ''; return }
47
+ // Auto-prepend https:// if user typed a bare domain (no protocol, no spaces)
48
+ if (!/^https?:\/\//i.test(val) && !val.includes(' ')) {
49
+ local.link = 'https://' + val
50
+ }
51
+ if (!URL_REGEX.test(local.link.trim())) {
52
+ linkError.value = t('tv.dashboard.indie_wall.step_customize_link_error')
53
+ } else {
54
+ linkError.value = ''
55
+ }
56
+ }
57
+
58
+ const MAX_TITLE = 60
59
+ const MAX_MESSAGE = 200
60
+ const MAX_IMAGE_BYTES = 256 * 1024
61
+
62
+ // Original image kept in memory for crop re-rendering; separate from the exported base64.
63
+ const originalImageUrl = ref(props.form.image_url || '')
64
+ const cropOffsetX = ref(50)
65
+ const cropOffsetY = ref(50)
66
+ const isDragging = ref(false)
67
+ let dragStartX = 0
68
+ let dragStartY = 0
69
+ let dragStartOX = 50
70
+ let dragStartOY = 50
71
+ const previewRef = ref<HTMLElement | null>(null)
72
+
73
+ // Preview dimensions respect the actual block aspect ratio (pixelW × pixelH).
74
+ const previewSize = computed(() => {
75
+ const maxLong = 160
76
+ const minShort = 24
77
+ const w = props.pixelW ?? 1
78
+ const h = props.pixelH ?? 1
79
+ if (w >= h) {
80
+ return { width: maxLong, height: Math.max(minShort, Math.round(maxLong * h / w)) }
81
+ } else {
82
+ return { height: maxLong, width: Math.max(minShort, Math.round(maxLong * w / h)) }
83
+ }
84
+ })
85
+
86
+ function readFileAsDataUrl(file: File): Promise<string> {
87
+ return new Promise((resolve, reject) => {
88
+ const reader = new FileReader()
89
+ reader.onload = () => resolve(reader.result as string)
90
+ reader.onerror = reject
91
+ reader.readAsDataURL(file)
92
+ })
93
+ }
94
+
95
+ async function onPickFile(e: Event) {
96
+ const target = e.target as HTMLInputElement
97
+ const file = target.files?.[0]
98
+ if (!file) return
99
+ imageError.value = ''
100
+ imageMissingError.value = ''
101
+ if (!file.type.startsWith('image/')) {
102
+ imageError.value = 'Please choose an image file.'
103
+ target.value = ''
104
+ return
105
+ }
106
+ uploading.value = true
107
+ try {
108
+ originalImageUrl.value = await readFileAsDataUrl(file)
109
+ cropOffsetX.value = 50
110
+ cropOffsetY.value = 50
111
+ // Clear any previously exported image so validation passes only after export on next()
112
+ local.image_url = originalImageUrl.value
113
+ } catch {
114
+ imageError.value = 'Could not load this image.'
115
+ } finally {
116
+ uploading.value = false
117
+ target.value = ''
118
+ }
119
+ }
120
+
121
+ function clearImage() {
122
+ originalImageUrl.value = ''
123
+ local.image_url = ''
124
+ imageError.value = ''
125
+ cropOffsetX.value = 50
126
+ cropOffsetY.value = 50
127
+ }
128
+
129
+ // Bake current crop + aspect ratio into a fixed-size base64 for the API.
130
+ function exportCrop(): Promise<string> {
131
+ return new Promise((resolve, reject) => {
132
+ const img = new Image()
133
+ img.onload = () => {
134
+ const pw = props.pixelW ?? 1
135
+ const ph = props.pixelH ?? 1
136
+ const MAX = 256
137
+ let targetW: number, targetH: number
138
+ if (pw >= ph) {
139
+ targetW = MAX
140
+ targetH = Math.max(1, Math.round(MAX * ph / pw))
141
+ } else {
142
+ targetH = MAX
143
+ targetW = Math.max(1, Math.round(MAX * pw / ph))
144
+ }
145
+ const canvas = document.createElement('canvas')
146
+ canvas.width = targetW
147
+ canvas.height = targetH
148
+ const ctx = canvas.getContext('2d')
149
+ if (!ctx) return reject(new Error('No canvas context'))
150
+ ctx.imageSmoothingEnabled = true
151
+ // object-fit: cover with user-chosen crop offset
152
+ const scale = Math.max(targetW / img.width, targetH / img.height)
153
+ const scaledW = img.width * scale
154
+ const scaledH = img.height * scale
155
+ const drawX = -(scaledW - targetW) * (cropOffsetX.value / 100)
156
+ const drawY = -(scaledH - targetH) * (cropOffsetY.value / 100)
157
+ ctx.drawImage(img, drawX, drawY, scaledW, scaledH)
158
+ resolve(canvas.toDataURL('image/jpeg', 0.82))
159
+ }
160
+ img.onerror = reject
161
+ img.src = originalImageUrl.value
162
+ })
163
+ }
164
+
165
+ // Drag-to-crop on the preview.
166
+ function startCropDrag(e: PointerEvent) {
167
+ if (!originalImageUrl.value) { fileInput.value?.click(); return }
168
+ isDragging.value = true
169
+ dragStartX = e.clientX
170
+ dragStartY = e.clientY
171
+ dragStartOX = cropOffsetX.value
172
+ dragStartOY = cropOffsetY.value
173
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
174
+ }
175
+
176
+ function onCropMove(e: PointerEvent) {
177
+ if (!isDragging.value) return
178
+ const el = previewRef.value
179
+ if (!el) return
180
+ // Sensitivity: move 1px on screen ≈ how many % of offset change.
181
+ // We invert direction: dragging right moves the "view" right (crop shifts left).
182
+ const sensitivity = 100 / Math.max(el.offsetWidth, el.offsetHeight, 1)
183
+ cropOffsetX.value = Math.max(0, Math.min(100, dragStartOX - (e.clientX - dragStartX) * sensitivity))
184
+ cropOffsetY.value = Math.max(0, Math.min(100, dragStartOY - (e.clientY - dragStartY) * sensitivity))
185
+ }
186
+
187
+ function endCropDrag() {
188
+ isDragging.value = false
189
+ }
190
+
191
+ async function next() {
192
+ titleError.value = ''
193
+ messageError.value = ''
194
+ imageMissingError.value = ''
195
+ emailError.value = ''
196
+ validateLink()
197
+ if (props.isGuest) {
198
+ if (!local.guest_email.trim()) {
199
+ emailError.value = 'Email is required.'
200
+ } else if (!EMAIL_REGEX.test(local.guest_email.trim())) {
201
+ emailError.value = 'Please enter a valid email.'
202
+ }
203
+ }
204
+ if (!local.is_anonymous && !local.title.trim()) {
205
+ titleError.value = 'Display name is required.'
206
+ }
207
+ if (!local.message.trim()) {
208
+ messageError.value = 'A message is required.'
209
+ }
210
+ if (!originalImageUrl.value) {
211
+ imageMissingError.value = 'Please upload an image for your pixel.'
212
+ }
213
+ if (emailError.value || titleError.value || messageError.value || imageMissingError.value || linkError.value) return
214
+
215
+ // Bake crop into final base64 before advancing.
216
+ uploading.value = true
217
+ try {
218
+ const cropped = await exportCrop()
219
+ if (cropped.length > MAX_IMAGE_BYTES * 1.4) {
220
+ imageError.value = 'Image too large after resize. Pick a smaller image.'
221
+ return
222
+ }
223
+ local.image_url = cropped
224
+ } catch {
225
+ imageError.value = 'Could not process image.'
226
+ return
227
+ } finally {
228
+ uploading.value = false
229
+ }
230
+
231
+ emit('next')
232
+ }
233
+ </script>
234
+
235
+ <template>
236
+ <div class="sw-step">
237
+ <h3 class="sw-step-title">{{ $t('tv.dashboard.indie_wall.step_customize_title') }}</h3>
238
+ <p class="sw-step-help">{{ $t('tv.dashboard.indie_wall.step_customize_help') }}</p>
239
+
240
+ <div v-if="isGuest" class="sw-guest-block">
241
+ <p class="sw-guest-intro">{{ $t('tv.dashboard.indie_wall.guest_intro') }}</p>
242
+ <div class="sw-field">
243
+ <label>{{ $t('tv.dashboard.indie_wall.guest_name_label') }}</label>
244
+ <input
245
+ v-model="local.guest_name"
246
+ type="text"
247
+ autocomplete="name"
248
+ :placeholder="$t('tv.dashboard.indie_wall.guest_name_placeholder')"
249
+ />
250
+ </div>
251
+ <div class="sw-field">
252
+ <label>{{ $t('tv.dashboard.indie_wall.guest_email_label') }} *</label>
253
+ <input
254
+ v-model="local.guest_email"
255
+ type="email"
256
+ autocomplete="email"
257
+ :placeholder="$t('tv.dashboard.indie_wall.guest_email_placeholder')"
258
+ :class="{ 'sw-input-error': emailError }"
259
+ />
260
+ <p v-if="emailError" class="sw-error-inline">{{ emailError }}</p>
261
+ </div>
262
+ </div>
263
+
264
+ <div class="sw-field">
265
+ <label>{{ $t('tv.dashboard.indie_wall.step_customize_image') }} *</label>
266
+
267
+ <!-- Live pixel preview at correct block aspect ratio -->
268
+ <div class="sw-preview-wrapper">
269
+ <div
270
+ ref="previewRef"
271
+ class="sw-pixel-preview"
272
+ :class="{
273
+ 'sw-pixel-preview--dragging': isDragging,
274
+ 'sw-image-error-border': imageMissingError,
275
+ }"
276
+ :style="{
277
+ width: previewSize.width + 'px',
278
+ height: previewSize.height + 'px',
279
+ background: local.background_color || '#13161C',
280
+ cursor: originalImageUrl ? (isDragging ? 'grabbing' : 'grab') : 'pointer',
281
+ }"
282
+ @pointerdown="startCropDrag"
283
+ @pointermove="onCropMove"
284
+ @pointerup="endCropDrag"
285
+ @pointercancel="endCropDrag"
286
+ >
287
+ <img
288
+ v-if="originalImageUrl"
289
+ :src="originalImageUrl"
290
+ class="sw-preview-img"
291
+ :style="{ objectPosition: `${cropOffsetX}% ${cropOffsetY}%` }"
292
+ draggable="false"
293
+ />
294
+ <span v-else class="sw-image-empty">+</span>
295
+ <span
296
+ v-if="!local.is_anonymous && local.title"
297
+ class="sw-preview-title-overlay"
298
+ >{{ local.title }}</span>
299
+ <span v-if="originalImageUrl && !isDragging" class="sw-crop-hint">
300
+ {{ $t('tv.dashboard.indie_wall.drag_to_crop') }}
301
+ </span>
302
+ </div>
303
+ <p class="sw-preview-dims">{{ pixelW }} × {{ pixelH }}</p>
304
+ </div>
305
+
306
+ <div class="sw-image-actions">
307
+ <button type="button" class="sw-image-btn" :disabled="uploading" @click="fileInput?.click()">
308
+ {{ uploading ? '…' : (originalImageUrl
309
+ ? $t('tv.dashboard.indie_wall.step_customize_replace_image')
310
+ : $t('tv.dashboard.indie_wall.step_customize_upload_image'))
311
+ }}
312
+ </button>
313
+ <button v-if="originalImageUrl" type="button" class="sw-image-btn sw-image-remove" @click="clearImage">
314
+ {{ $t('tv.dashboard.indie_wall.step_customize_remove_image') }}
315
+ </button>
316
+ <input
317
+ ref="fileInput"
318
+ type="file"
319
+ accept="image/*"
320
+ style="display: none"
321
+ @change="onPickFile"
322
+ />
323
+ <p class="sw-image-hint">
324
+ {{ $t('tv.dashboard.indie_wall.step_customize_image_hint') }}
325
+ </p>
326
+ </div>
327
+ <p v-if="imageError" class="sw-error-inline">{{ imageError }}</p>
328
+ <p v-if="imageMissingError" class="sw-error-inline">{{ imageMissingError }}</p>
329
+ </div>
330
+
331
+ <div class="sw-field">
332
+ <label>{{ $t('tv.dashboard.indie_wall.mural_title_field') }} *</label>
333
+ <input
334
+ v-model="local.title"
335
+ type="text"
336
+ :maxlength="MAX_TITLE"
337
+ :placeholder="$t('tv.dashboard.indie_wall.mural_title_placeholder')"
338
+ :class="{ 'sw-input-error': titleError }"
339
+ :disabled="local.is_anonymous"
340
+ />
341
+ <p v-if="titleError" class="sw-error-inline">{{ titleError }}</p>
342
+ </div>
343
+
344
+ <div class="sw-field">
345
+ <label>{{ $t('tv.dashboard.indie_wall.message') }} *</label>
346
+ <textarea
347
+ v-model="local.message"
348
+ rows="2"
349
+ :maxlength="MAX_MESSAGE"
350
+ :placeholder="$t('tv.dashboard.indie_wall.message_placeholder')"
351
+ :class="{ 'sw-input-error': messageError }"
352
+ />
353
+ <p v-if="messageError" class="sw-error-inline">{{ messageError }}</p>
354
+ </div>
355
+
356
+ <div class="sw-field">
357
+ <label>{{ $t('tv.dashboard.indie_wall.step_customize_link') }}</label>
358
+ <input
359
+ v-model="local.link"
360
+ type="text"
361
+ placeholder="https://"
362
+ :class="{ 'sw-input-error': linkError }"
363
+ @blur="validateLink"
364
+ />
365
+ <p v-if="linkError" class="sw-error-inline">{{ linkError }}</p>
366
+ </div>
367
+
368
+ <label class="sw-checkbox">
369
+ <input v-model="local.is_anonymous" type="checkbox" />
370
+ {{ $t('tv.dashboard.indie_wall.anonymous') }}
371
+ </label>
372
+
373
+ <div class="sw-actions">
374
+ <button type="button" class="sw-confirm" :disabled="uploading" @click="next">
375
+ {{ uploading ? '…' : $t('tv.dashboard.indie_wall.step_customize_continue') }}
376
+ </button>
377
+ </div>
378
+ </div>
379
+ </template>
380
+
381
+ <style scoped lang="scss">
382
+ .sw-step { display: flex; flex-direction: column; gap: 10px; }
383
+ .sw-step-title { font-size: 1.05rem; font-weight: 600; color: var(--title-fg, #fff); margin: 0; }
384
+ .sw-step-help { font-size: 0.82rem; color: var(--secondary-info-fg, #aaa); margin: 0 0 4px; }
385
+
386
+ .sw-field {
387
+ display: flex; flex-direction: column; gap: 4px;
388
+ label { font-size: 0.75rem; color: var(--secondary-info-fg, #777); }
389
+ input, textarea {
390
+ background: #13161C; border: 1px solid #1e2028;
391
+ padding: 8px 10px; color: var(--title-fg, #fff);
392
+ font-size: 0.85rem; font-family: inherit; width: 100%;
393
+ &:focus { border-color: var(--chip-text, #D297FF); outline: none; }
394
+ &.sw-input-error { border-color: #ff6b6b; }
395
+ &:disabled { opacity: 0.5; cursor: not-allowed; }
396
+ }
397
+ textarea { resize: vertical; }
398
+ }
399
+
400
+ .sw-error-inline {
401
+ margin: 0;
402
+ color: #ff6b6b;
403
+ font-size: 0.75rem;
404
+ }
405
+
406
+ .sw-guest-block {
407
+ display: flex; flex-direction: column; gap: 8px;
408
+ background: #13161C; border: 1px solid #1e2028;
409
+ padding: 12px;
410
+ }
411
+ .sw-guest-intro {
412
+ margin: 0 0 4px;
413
+ font-size: 0.78rem;
414
+ color: var(--secondary-info-fg, #aaa);
415
+ line-height: 1.4;
416
+ }
417
+
418
+ // Preview wrapper: centres the block and shows dimensions below.
419
+ .sw-preview-wrapper {
420
+ display: flex;
421
+ flex-direction: column;
422
+ align-items: center;
423
+ gap: 4px;
424
+ margin-bottom: 8px;
425
+ }
426
+
427
+ .sw-preview-dims {
428
+ margin: 0;
429
+ font-size: 0.68rem;
430
+ color: var(--secondary-info-fg, #666);
431
+ letter-spacing: 0.03em;
432
+ }
433
+
434
+ // The live pixel preview — shape matches pixelW × pixelH.
435
+ .sw-pixel-preview {
436
+ position: relative;
437
+ overflow: hidden;
438
+ border: 1px solid #1e2028;
439
+ flex-shrink: 0;
440
+ touch-action: none;
441
+ user-select: none;
442
+ transition: border-color 0.15s;
443
+
444
+ &:hover { border-color: var(--chip-text, #D297FF); }
445
+ &--dragging { border-color: var(--chip-text, #D297FF); }
446
+ &.sw-image-error-border { border-color: #ff6b6b; }
447
+ }
448
+
449
+ .sw-preview-img {
450
+ display: block;
451
+ width: 100%;
452
+ height: 100%;
453
+ object-fit: cover;
454
+ image-rendering: pixelated;
455
+ pointer-events: none;
456
+ }
457
+
458
+ .sw-image-empty {
459
+ position: absolute;
460
+ inset: 0;
461
+ display: flex;
462
+ align-items: center;
463
+ justify-content: center;
464
+ font-size: 2rem;
465
+ color: var(--secondary-info-fg, #555);
466
+ font-weight: 200;
467
+ }
468
+
469
+ // Title overlay shown at the bottom of the preview (matches mural render).
470
+ .sw-preview-title-overlay {
471
+ position: absolute;
472
+ bottom: 0;
473
+ left: 0;
474
+ right: 0;
475
+ padding: 2px 4px;
476
+ background: rgba(0, 0, 0, 0.55);
477
+ color: #fff;
478
+ font-size: 0.6rem;
479
+ font-weight: 600;
480
+ text-align: center;
481
+ overflow: hidden;
482
+ text-overflow: ellipsis;
483
+ white-space: nowrap;
484
+ pointer-events: none;
485
+ }
486
+
487
+ // "Drag to adjust" hint — fades in on hover.
488
+ .sw-crop-hint {
489
+ position: absolute;
490
+ top: 0;
491
+ left: 0;
492
+ right: 0;
493
+ padding: 3px;
494
+ background: rgba(0, 0, 0, 0.6);
495
+ color: rgba(255, 255, 255, 0.75);
496
+ font-size: 0.6rem;
497
+ text-align: center;
498
+ opacity: 0;
499
+ transition: opacity 0.15s;
500
+ pointer-events: none;
501
+ }
502
+ .sw-pixel-preview:hover .sw-crop-hint { opacity: 1; }
503
+
504
+ .sw-image-actions { display: flex; flex-direction: column; gap: 6px; }
505
+ .sw-image-btn {
506
+ background: #1e2028; color: var(--title-fg, #fff);
507
+ border: 1px solid #272930; padding: 8px 12px;
508
+ font-size: 0.8rem; cursor: pointer; text-align: left;
509
+ &:hover { border-color: var(--chip-text, #D297FF); }
510
+ &:disabled { opacity: 0.5; cursor: not-allowed; }
511
+ }
512
+ .sw-image-remove { color: #ff6b6b; }
513
+ .sw-image-hint { margin: 4px 0 0; font-size: 0.7rem; color: var(--secondary-info-fg, #888); line-height: 1.4; }
514
+
515
+ .sw-checkbox {
516
+ display: flex; align-items: center; gap: 6px;
517
+ color: var(--secondary-info-fg, #888); font-size: 0.82rem;
518
+ cursor: pointer; user-select: none;
519
+ margin-top: 4px;
520
+ }
521
+
522
+ .sw-actions { display: flex; gap: 8px; justify-content: flex-end; }
523
+ .sw-confirm {
524
+ background: var(--chip-text, #D297FF);
525
+ color: #13161C; border: none; padding: 10px 18px;
526
+ font-weight: 700; font-size: 0.85rem; cursor: pointer;
527
+ &:hover:not(:disabled) { opacity: 0.88; }
528
+ &:disabled { opacity: 0.5; cursor: not-allowed; }
529
+ }
530
+ </style>
@@ -0,0 +1,169 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ goals: any[]
4
+ selectedGoalId: number | null
5
+ currency: string
6
+ }>()
7
+
8
+ const emit = defineEmits<{
9
+ (e: 'select', goalId: number | null): void
10
+ (e: 'next'): void
11
+ }>()
12
+
13
+ function pick(goalId: number | null) {
14
+ emit('select', goalId)
15
+ emit('next')
16
+ }
17
+ </script>
18
+
19
+ <template>
20
+ <div class="sw-step">
21
+ <h3 class="sw-step-title">{{ $t('tv.dashboard.indie_wall.step_goal_title') }}</h3>
22
+ <p class="sw-step-help">{{ $t('tv.dashboard.indie_wall.step_goal_help') }}</p>
23
+
24
+ <div class="sw-goals-grid">
25
+ <article
26
+ v-for="goal in goals"
27
+ :key="goal.id"
28
+ class="sw-goal"
29
+ :class="{ active: selectedGoalId === goal.id, reached: goal.is_reached }"
30
+ >
31
+ <header class="sw-goal-head">
32
+ <h4 class="sw-goal-title">{{ goal.title }}</h4>
33
+ <span v-if="goal.is_reached" class="sw-goal-badge">
34
+ {{ $t('tv.dashboard.indie_wall.reached') }}
35
+ </span>
36
+ <span v-else class="sw-goal-amount">
37
+ {{ currency }} {{ Number(goal.target_amount || 0).toFixed(0) }}
38
+ </span>
39
+ </header>
40
+
41
+ <ul class="sw-goal-attrs">
42
+ <li v-if="goal.description">
43
+ <span class="sw-attr-label">{{ $t('tv.dashboard.indie_wall.goal_card_you_help') }}:</span>
44
+ <span class="sw-attr-val">{{ goal.description }}</span>
45
+ </li>
46
+ <li>
47
+ <span class="sw-attr-label">{{ $t('tv.dashboard.indie_wall.goal_card_goal') }}:</span>
48
+ <span class="sw-attr-val">{{ goal.target_pixels }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
49
+ </li>
50
+ <li v-if="goal.reward_description">
51
+ <span class="sw-attr-label">{{ $t('tv.dashboard.indie_wall.goal_card_result') }}:</span>
52
+ <span class="sw-attr-val">{{ goal.reward_description }}</span>
53
+ </li>
54
+ </ul>
55
+
56
+ <div class="sw-goal-progress">
57
+ <div class="sw-goal-progress-fill" :style="{ width: (goal.progress_percent || 0) + '%' }" />
58
+ <span class="sw-goal-progress-text">
59
+ {{ $t('tv.dashboard.indie_wall.goal_card_missing', { count: Math.max(0, (goal.target_pixels || 0) - (goal.current_pixels || 0)) }) }}
60
+ </span>
61
+ </div>
62
+
63
+ <button
64
+ type="button"
65
+ class="sw-goal-cta"
66
+ :disabled="goal.is_reached"
67
+ @click="!goal.is_reached && pick(goal.id)"
68
+ >
69
+ {{ goal.is_reached
70
+ ? $t('tv.dashboard.indie_wall.reached')
71
+ : $t('tv.dashboard.indie_wall.goal_card_select') }}
72
+ </button>
73
+ </article>
74
+
75
+ <button class="sw-skip" :class="{ active: selectedGoalId === null }" type="button" @click="pick(null)">
76
+ <span class="sw-skip-title">{{ $t('tv.dashboard.indie_wall.step_goal_skip_title') }}</span>
77
+ <span class="sw-skip-desc">{{ $t('tv.dashboard.indie_wall.step_goal_skip_desc') }}</span>
78
+ </button>
79
+ </div>
80
+ </div>
81
+ </template>
82
+
83
+ <style scoped lang="scss">
84
+ .sw-step { display: flex; flex-direction: column; gap: 12px; }
85
+ .sw-step-title { font-size: 1.05rem; font-weight: 600; color: var(--title-fg, #fff); margin: 0; }
86
+ .sw-step-help { font-size: 0.82rem; color: var(--secondary-info-fg, #aaa); margin: 0 0 4px; }
87
+
88
+ .sw-goals-grid {
89
+ display: grid;
90
+ grid-template-columns: 1fr;
91
+ gap: 10px;
92
+ }
93
+
94
+ .sw-goal {
95
+ background: #191B20;
96
+ border: 1px solid #1e2028;
97
+ padding: 16px;
98
+ display: flex; flex-direction: column;
99
+ gap: 12px;
100
+
101
+ &.active { border-color: var(--chip-text, #D297FF); background: #1e2028; }
102
+ &.reached { border-color: rgba(76,175,80,0.25); }
103
+ }
104
+ .sw-goal-head {
105
+ display: flex; justify-content: space-between; align-items: flex-start; gap: 8px;
106
+ }
107
+ .sw-goal-title {
108
+ margin: 0; font-size: 0.96rem; font-weight: 700; color: var(--title-fg, #fff);
109
+ line-height: 1.3; flex: 1; min-width: 0;
110
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
111
+ overflow: hidden; word-break: break-word;
112
+ }
113
+ .sw-goal-amount { font-size: 0.85rem; font-weight: 700; color: var(--chip-text, #D297FF); white-space: nowrap; flex-shrink: 0; }
114
+ .sw-goal-badge { background: rgba(76,175,80,0.12); color: #4caf50; font-size: 0.7rem; font-weight: 600; padding: 2px 6px; flex-shrink: 0; }
115
+
116
+ .sw-goal-attrs {
117
+ list-style: none; padding: 0; margin: 0;
118
+ display: flex; flex-direction: column; gap: 6px;
119
+
120
+ li {
121
+ position: relative; padding-left: 12px;
122
+ font-size: 0.82rem; color: var(--title-fg, #fff); line-height: 1.4;
123
+ &::before {
124
+ content: ''; position: absolute; top: 7px; left: 0;
125
+ width: 5px; height: 5px;
126
+ background: var(--chip-text, #D297FF);
127
+ }
128
+ }
129
+ }
130
+ .sw-attr-label { font-weight: 700; color: var(--title-fg, #fff); margin-right: 4px; }
131
+ .sw-attr-val { color: var(--secondary-info-fg, #aaa); }
132
+
133
+ .sw-goal-progress {
134
+ position: relative; height: 24px;
135
+ background: #13161C; border: 1px solid #272930; overflow: hidden;
136
+ }
137
+ .sw-goal-progress-fill {
138
+ height: 100%; background: var(--chip-text, #D297FF);
139
+ opacity: 0.4; transition: width 0.4s ease; min-width: 2px;
140
+ }
141
+ .sw-goal-progress-text {
142
+ position: absolute; inset: 0;
143
+ display: flex; align-items: center; justify-content: center;
144
+ font-size: 0.72rem; color: var(--title-fg, #fff); padding: 0 8px;
145
+ }
146
+
147
+ .sw-goal-cta {
148
+ background: var(--chip-text, #D297FF); color: #13161C;
149
+ border: none; padding: 10px 14px;
150
+ font-size: 0.85rem; font-weight: 600;
151
+ cursor: pointer; transition: opacity 0.15s;
152
+ &:hover:not(:disabled) { opacity: 0.88; }
153
+ &:disabled { opacity: 0.5; cursor: not-allowed; background: #4caf50; color: #fff; }
154
+ }
155
+
156
+ .sw-skip {
157
+ background: transparent;
158
+ border: 1px dashed #272930;
159
+ padding: 14px 16px; cursor: pointer;
160
+ display: flex; flex-direction: column; gap: 4px; text-align: left;
161
+ color: inherit; font-family: inherit;
162
+ transition: border-color 0.15s, background 0.15s;
163
+
164
+ &:hover { border-color: var(--chip-text, #D297FF); }
165
+ &.active { border-color: var(--chip-text, #D297FF); background: #1e2028; }
166
+ }
167
+ .sw-skip-title { font-size: 0.9rem; font-weight: 600; color: var(--title-fg, #fff); }
168
+ .sw-skip-desc { font-size: 0.78rem; color: var(--secondary-info-fg, #888); }
169
+ </style>