@mundogamernetwork/shared-ui 1.6.0 → 1.8.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.
@@ -93,9 +93,9 @@ const save = () => {
93
93
  </template>
94
94
 
95
95
  <style scoped lang="scss">
96
- .mk-links-editor { border: 1px solid rgba(140, 140, 160, .3); padding: 20px; background: rgba(255, 255, 255, .02); }
96
+ .mk-links-editor { border: 1px solid rgba(140, 140, 160, .3); padding: 20px; background: rgba(255, 255, 255, .02); color: #f2f3f5; }
97
97
  .mk-links-head { display: flex; justify-content: space-between; gap: 20px; align-items: start; }
98
- .mk-links-head h3 { margin: 0 0 5px; font-size: 17px; }
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
100
  .mk-link-add, .mk-link-save { border: 1px solid #ffb000; background: #ffb000; color: #090909; min-height: 38px; padding: 0 16px; font-weight: 750; cursor: pointer; white-space: nowrap; }
101
101
  button:disabled { opacity: .42; cursor: not-allowed; }
@@ -103,7 +103,8 @@ button:disabled { opacity: .42; cursor: not-allowed; }
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); }
104
104
  .mk-link-number { align-self: center; color: #ffb000; font-weight: 800; text-align: center; }
105
105
  .mk-link-row label { display: grid; gap: 6px; color: #9292a4; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
106
- .mk-link-row input { min-width: 0; height: 40px; border: 1px solid rgba(140, 140, 160, .35); background: rgba(0, 0, 0, .2); color: inherit; padding: 0 11px; outline: none; }
106
+ .mk-link-row input { min-width: 0; height: 40px; border: 1px solid rgba(140, 140, 160, .35); background: rgba(0, 0, 0, .2); color: #f2f3f5; padding: 0 11px; outline: none; }
107
+ .mk-link-row input::placeholder { color: #6f7480; }
107
108
  .mk-link-row input:focus { border-color: #ffb000; }
108
109
  .mk-link-actions { display: flex; gap: 5px; }
109
110
  .mk-link-actions button { width: 36px; height: 40px; border: 1px solid rgba(140, 140, 160, .35); background: transparent; color: inherit; cursor: pointer; }
@@ -26,9 +26,7 @@ const ranked = computed<RankEntry[]>(() => {
26
26
  const key = userId ? `u:${userId}` : email ? `e:${email}` : null
27
27
  if (!key) continue
28
28
 
29
- const name = s.is_anonymous
30
- ? t('tv.dashboard.indie_wall.anonymous')
31
- : (s.title || s.user?.name || s.guest_name || 'Guest')
29
+ const name = supporterDisplayName(s)
32
30
 
33
31
  if (map.has(key)) {
34
32
  const entry = map.get(key)!
@@ -38,7 +36,7 @@ const ranked = computed<RankEntry[]>(() => {
38
36
  map.set(key, {
39
37
  key,
40
38
  name,
41
- avatarUrl: s.user?.avatar_url || null,
39
+ avatarUrl: supporterAvatarUrl(s),
42
40
  totalPixels: s.pixel_count || 1,
43
41
  purchases: 1,
44
42
  position: 0,
@@ -57,6 +55,21 @@ const visible = computed(() => showAll.value ? ranked.value : ranked.value.slice
57
55
  function initials(name: string): string {
58
56
  return name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
59
57
  }
58
+
59
+ function supporterDisplayName(s: any): string {
60
+ if (s.is_anonymous) return t('tv.dashboard.indie_wall.anonymous')
61
+ return s.user?.nickname
62
+ || s.user?.username
63
+ || s.user?.full_name
64
+ || s.user?.name
65
+ || s.guest_name
66
+ || 'Guest'
67
+ }
68
+
69
+ function supporterAvatarUrl(s: any): string | null {
70
+ if (s.is_anonymous) return null
71
+ return s.user?.avatar_url || s.user?.profile_image || s.user?.avatar || s.user?.image || null
72
+ }
60
73
  </script>
61
74
 
62
75
  <template>
@@ -1,9 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  // Playback + clickable marker timeline for a Video Playtest recording.
3
- // Native <video> — plays HLS natively in Safari; other browsers need an
4
- // hls.js integration this first version doesn't include (documented
5
- // limitation, not silently broken: shows a note when playback fails).
6
- import { ref } from "vue";
3
+ // Safari plays HLS natively; every other browser needs hls.js (MediaSource
4
+ // Extensions) falls back to a direct-link note only if neither path works
5
+ // (very old browsers without MSE support).
6
+ import { ref, onMounted, onBeforeUnmount, watch } from "vue";
7
+ import Hls from "hls.js";
7
8
 
8
9
  const props = defineProps<{
9
10
  playbackUrl: string | null;
@@ -14,6 +15,41 @@ const props = defineProps<{
14
15
  const videoEl = ref<HTMLVideoElement | null>(null);
15
16
  const playbackError = ref(false);
16
17
 
18
+ let hls: Hls | null = null;
19
+
20
+ function destroyPlayer() {
21
+ if (hls) {
22
+ hls.destroy();
23
+ hls = null;
24
+ }
25
+ }
26
+
27
+ function setupPlayback() {
28
+ destroyPlayer();
29
+ playbackError.value = false;
30
+
31
+ const video = videoEl.value;
32
+ if (!video || !props.playbackUrl) return;
33
+
34
+ if (Hls.isSupported()) {
35
+ hls = new Hls();
36
+ hls.on(Hls.Events.ERROR, (_event, data) => {
37
+ if (data.fatal) playbackError.value = true;
38
+ });
39
+ hls.loadSource(props.playbackUrl);
40
+ hls.attachMedia(video);
41
+ } else if (video.canPlayType("application/vnd.apple.mpegurl")) {
42
+ // Safari and other browsers with native HLS support.
43
+ video.src = props.playbackUrl;
44
+ } else {
45
+ playbackError.value = true;
46
+ }
47
+ }
48
+
49
+ onMounted(setupPlayback);
50
+ watch(() => props.playbackUrl, setupPlayback);
51
+ onBeforeUnmount(destroyPlayer);
52
+
17
53
  function seekTo(seconds: number) {
18
54
  if (videoEl.value) {
19
55
  videoEl.value.currentTime = seconds;
@@ -46,7 +82,6 @@ function severityClass(severity?: string | null): string {
46
82
  <template v-else>
47
83
  <video
48
84
  ref="videoEl"
49
- :src="playbackUrl"
50
85
  controls
51
86
  class="recorded-session-player__video"
52
87
  @error="playbackError = true"
@@ -16,6 +16,8 @@ const emit = defineEmits<{
16
16
  const rec = usePlaytestVideoRecording(props.campaignId);
17
17
 
18
18
  const markerDraft = ref("");
19
+ const submitted = ref(false);
20
+ const retryingMarkers = ref(false);
19
21
  const step = computed<"record" | "review" | "upload" | "done">(() => {
20
22
  if (rec.recordedBlob.value && !rec.recordingId.value) return "review";
21
23
  if (rec.recordingId.value) return "upload";
@@ -30,7 +32,23 @@ function addMarker() {
30
32
 
31
33
  async function submit() {
32
34
  const result = await rec.submitVideoResponse();
33
- if (result) emit("submitted");
35
+ if (!result) return;
36
+ submitted.value = true;
37
+ if (rec.markerSubmitErrors.value === 0) emit("submitted");
38
+ }
39
+
40
+ async function retryMarkers() {
41
+ retryingMarkers.value = true;
42
+ try {
43
+ await rec.retryPendingMarkers();
44
+ if (rec.markerSubmitErrors.value === 0) emit("submitted");
45
+ } finally {
46
+ retryingMarkers.value = false;
47
+ }
48
+ }
49
+
50
+ function continueAfterSubmit() {
51
+ emit("submitted");
34
52
  }
35
53
 
36
54
  function formatSeconds(s: number): string {
@@ -97,7 +115,7 @@ function formatSeconds(s: number): string {
97
115
  </template>
98
116
 
99
117
  <!-- Upload done, waiting on provider processing — submit -->
100
- <template v-else-if="step === 'upload'">
118
+ <template v-else-if="step === 'upload' && !submitted">
101
119
  <p class="video-capture__hint">
102
120
  {{ $t("playtest.video_capture.processing_hint", "Your recording is uploaded and processing. Submit when you're ready — we'll finish confirming it's ready in the background.") }}
103
121
  </p>
@@ -107,6 +125,23 @@ function formatSeconds(s: number): string {
107
125
  : $t("playtest.video_capture.submit", "Submit response") }}
108
126
  </button>
109
127
  </template>
128
+
129
+ <!-- Response submitted, but one or more markers failed to save — offer a retry instead of silently dropping them -->
130
+ <template v-else-if="submitted && rec.markerSubmitErrors.value > 0">
131
+ <p class="video-capture__hint video-capture__error">
132
+ {{ $t("playtest.video_capture.marker_save_failed", "Response submitted, but {v} note(s) failed to save.", { v: rec.markerSubmitErrors.value }) }}
133
+ </p>
134
+ <div class="video-capture__actions">
135
+ <button type="button" class="btn primary" :disabled="retryingMarkers" @click="retryMarkers">
136
+ {{ retryingMarkers
137
+ ? $t("playtest.video_capture.retrying", "Retrying…")
138
+ : $t("playtest.video_capture.retry_markers", "Retry saving notes") }}
139
+ </button>
140
+ <button type="button" class="btn" @click="continueAfterSubmit">
141
+ {{ $t("playtest.video_capture.continue_anyway", "Continue anyway") }}
142
+ </button>
143
+ </div>
144
+ </template>
110
145
  </div>
111
146
  </template>
112
147
 
@@ -130,6 +165,7 @@ function formatSeconds(s: number): string {
130
165
 
131
166
  &__actions {
132
167
  display: flex;
168
+ gap: 8px;
133
169
  }
134
170
 
135
171
  &__recording {
@@ -18,6 +18,8 @@ import {
18
18
  * audio only — a real limitation, not an oversight), duration capped
19
19
  * server-side (config playtest.video.max_duration_seconds).
20
20
  */
21
+ type PendingMarker = { timestamp_seconds: number; comment: string; severity?: string };
22
+
21
23
  export function usePlaytestVideoRecording(campaignId: number) {
22
24
  const capturing = ref(false);
23
25
  const uploading = ref(false);
@@ -29,12 +31,45 @@ export function usePlaytestVideoRecording(campaignId: number) {
29
31
  const recording = ref<PlaytestRecording | null>(null);
30
32
  const recordedBlob = ref<Blob | null>(null);
31
33
  const recordedDurationSeconds = ref(0);
32
- const markers = ref<{ timestamp_seconds: number; comment: string; severity?: string }[]>([]);
34
+ const markers = ref<PendingMarker[]>([]);
35
+ // Markers a submitted response still needs posted — set when the post-submit
36
+ // loop hits an error partway through, so the UI can offer a retry instead of
37
+ // silently dropping the remainder (see storeRecordingMarker loop below).
38
+ const markerSubmitErrors = ref(0);
33
39
 
34
40
  let mediaRecorder: MediaRecorder | null = null;
35
41
  let chunks: BlobPart[] = [];
36
42
  let captureStartedAt = 0;
37
43
 
44
+ // Markers only ever live in this Vue ref + localStorage until they're
45
+ // posted — a crash between adding a marker and a successful submit loses
46
+ // nothing beyond that point (the recording Blob itself is still
47
+ // memory-only and can't survive a reload; that's a separate, harder
48
+ // problem this doesn't attempt to solve).
49
+ const storageKey = `mgn_playtest_pending_markers_${campaignId}`;
50
+
51
+ function persistMarkers() {
52
+ try {
53
+ if (markers.value.length) {
54
+ localStorage.setItem(storageKey, JSON.stringify(markers.value));
55
+ } else {
56
+ localStorage.removeItem(storageKey);
57
+ }
58
+ } catch {
59
+ // localStorage unavailable (private mode / quota) — best-effort only.
60
+ }
61
+ }
62
+
63
+ /** Restores markers left over from a previous capture on this campaign that never made it to a submit. */
64
+ function restorePendingMarkers(): PendingMarker[] {
65
+ try {
66
+ const raw = localStorage.getItem(storageKey);
67
+ return raw ? (JSON.parse(raw) as PendingMarker[]) : [];
68
+ } catch {
69
+ return [];
70
+ }
71
+ }
72
+
38
73
  function resetError() {
39
74
  genericError.value = null;
40
75
  }
@@ -50,6 +85,12 @@ export function usePlaytestVideoRecording(campaignId: number) {
50
85
 
51
86
  async function startCapture() {
52
87
  resetError();
88
+ // A previous capture on this campaign may have left markers behind
89
+ // (crash/reload before a successful submit) — recover them rather than
90
+ // silently starting from an empty list.
91
+ const leftover = restorePendingMarkers();
92
+ if (leftover.length) markers.value = leftover;
93
+
53
94
  try {
54
95
  const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
55
96
  chunks = [];
@@ -80,6 +121,7 @@ export function usePlaytestVideoRecording(campaignId: number) {
80
121
  /** Adds a marker anchored to the current elapsed recording time. */
81
122
  function markNow(comment: string, severity?: string) {
82
123
  markers.value.push({ timestamp_seconds: elapsedSeconds(), comment, severity });
124
+ persistMarkers();
83
125
  }
84
126
 
85
127
  /** Requests the upload slot and uploads the recorded blob directly to the video provider. */
@@ -118,6 +160,60 @@ export function usePlaytestVideoRecording(campaignId: number) {
118
160
  }
119
161
  }
120
162
 
163
+ let lastSubmittedResponseId: number | null = null;
164
+
165
+ /**
166
+ * Marker timestamps are wall-clock elapsed time from when capture started,
167
+ * not the real position in the final encoded file — any delay before
168
+ * MediaRecorder actually starts encoding, or a hiccup mid-recording, drifts
169
+ * them apart. Once the provider confirms the real duration (post-submit,
170
+ * since submit only succeeds once the recording is 'ready'), scale every
171
+ * marker proportionally against the wall-clock duration we measured — a
172
+ * practical correction, not frame-perfect, but far closer than raw elapsed
173
+ * seconds once the two durations diverge.
174
+ */
175
+ function correctForDrift(realDurationSeconds: number | null | undefined) {
176
+ if (!realDurationSeconds || !recordedDurationSeconds.value) return;
177
+ const scale = realDurationSeconds / recordedDurationSeconds.value;
178
+ if (!isFinite(scale) || scale <= 0 || scale === 1) return;
179
+ markers.value = markers.value.map((m) => ({
180
+ ...m,
181
+ timestamp_seconds: Math.min(realDurationSeconds, Math.max(0, Math.round(m.timestamp_seconds * scale))),
182
+ }));
183
+ persistMarkers();
184
+ }
185
+
186
+ /**
187
+ * Posts whatever's left in markers.value for responseId, one at a time.
188
+ * A failure on one marker does NOT abort the rest — each is independent
189
+ * feedback, so losing one shouldn't cost the others. Successfully-posted
190
+ * markers are removed from the pending list (and localStorage) as they
191
+ * succeed; anything left after this call can be retried via
192
+ * retryPendingMarkers() instead of being silently dropped.
193
+ */
194
+ async function postPendingMarkers(responseId: number) {
195
+ lastSubmittedResponseId = responseId;
196
+ const remaining: PendingMarker[] = [];
197
+ let failures = 0;
198
+ for (const m of markers.value) {
199
+ try {
200
+ await storeRecordingMarker(responseId, m as any);
201
+ } catch {
202
+ remaining.push(m);
203
+ failures++;
204
+ }
205
+ }
206
+ markers.value = remaining;
207
+ markerSubmitErrors.value = failures;
208
+ persistMarkers();
209
+ }
210
+
211
+ /** Retries any markers that failed to post during submitVideoResponse(). */
212
+ async function retryPendingMarkers() {
213
+ if (!lastSubmittedResponseId || !markers.value.length) return;
214
+ await postPendingMarkers(lastSubmittedResponseId);
215
+ }
216
+
121
217
  /**
122
218
  * Submits the response once the backend confirms the recording is ready.
123
219
  * Processing is async (the provider's webhook updates our DB after
@@ -135,6 +231,7 @@ export function usePlaytestVideoRecording(campaignId: number) {
135
231
  return null;
136
232
  }
137
233
  resetError();
234
+ markerSubmitErrors.value = 0;
138
235
  submitting.value = true;
139
236
  try {
140
237
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -144,9 +241,13 @@ export function usePlaytestVideoRecording(campaignId: number) {
144
241
  recording_id: recordingId.value,
145
242
  ...meta,
146
243
  });
147
- for (const m of markers.value) {
148
- await storeRecordingMarker(data.id, m as any);
244
+ try {
245
+ const fetched = await fetchRecording(data.id);
246
+ correctForDrift(fetched.data.data?.duration_seconds ?? null);
247
+ } catch {
248
+ // Non-fatal — fall back to uncorrected (wall-clock) timestamps.
149
249
  }
250
+ await postPendingMarkers(data.id);
150
251
  return data;
151
252
  } catch (e: any) {
152
253
  if (attempt === maxAttempts) throw e;
@@ -179,11 +280,13 @@ export function usePlaytestVideoRecording(campaignId: number) {
179
280
  recordedBlob,
180
281
  recordedDurationSeconds,
181
282
  markers,
283
+ markerSubmitErrors,
182
284
  startCapture,
183
285
  stopCapture,
184
286
  markNow,
185
287
  uploadRecording,
186
288
  submitVideoResponse,
289
+ retryPendingMarkers,
187
290
  loadRecording,
188
291
  };
189
292
  }
package/error.vue CHANGED
@@ -51,13 +51,13 @@ onUnmounted(() => {
51
51
 
52
52
  <template>
53
53
  <NuxtLayout>
54
- <errors401 v-if="statusCodeError === HttpStatusCode.Unauthorized" />
55
- <errors403 v-else-if="statusCodeError === HttpStatusCode.Forbidden" />
56
- <errors404 v-else-if="statusCodeError === HttpStatusCode.NotFound" />
57
- <errors500 v-else-if="statusCodeError === HttpStatusCode.InternalServerError" />
58
- <errors503 v-else-if="statusCodeError === HttpStatusCode.ServiceUnavailable" />
59
- <errors504 v-else-if="statusCodeError === HttpStatusCode.GatewayTimeout" />
60
- <errors500 v-else />
54
+ <Error401 v-if="statusCodeError === HttpStatusCode.Unauthorized" />
55
+ <Error403 v-else-if="statusCodeError === HttpStatusCode.Forbidden" />
56
+ <Error404 v-else-if="statusCodeError === HttpStatusCode.NotFound" />
57
+ <Error500 v-else-if="statusCodeError === HttpStatusCode.InternalServerError" />
58
+ <Error503 v-else-if="statusCodeError === HttpStatusCode.ServiceUnavailable" />
59
+ <Error504 v-else-if="statusCodeError === HttpStatusCode.GatewayTimeout" />
60
+ <Error500 v-else />
61
61
 
62
62
  <p v-if="isRecoverableError" class="recovery-line">
63
63
  {{ $t("errors.recovery_hint") }}
package/locales/de.json CHANGED
@@ -240,5 +240,120 @@
240
240
  "common": {
241
241
  "video_thumbnail": "Video-Vorschaubild",
242
242
  "expanded_image": "Vergrößertes Bild"
243
+ },
244
+ "tv": {
245
+ "dashboard": {
246
+ "indie_wall": {
247
+ "about_section": "Über die Wall",
248
+ "anonymous": "Anonym",
249
+ "cancel": "Abbrechen",
250
+ "confirm_support": "Unterstützung bestätigen",
251
+ "cta_create_btn": "Create my account",
252
+ "cta_create_desc": "Create an account to track your support, receive updates, and join other walls.",
253
+ "cta_create_skip": "Continue without an account",
254
+ "cta_create_title": "Want to save your support?",
255
+ "drag_to_crop": "Drag to crop",
256
+ "error_area_occupied": "This area is already taken. Choose another space on the wall.",
257
+ "error_gateway": "Could not start payment. Try again.",
258
+ "error_save": "Could not save your support. Try again.",
259
+ "filled": "Gefüllt",
260
+ "footer_create_cta": "Create your own wall",
261
+ "footer_made_with": "Made with",
262
+ "goal_card_cta": "Dieses Ziel unterstützen",
263
+ "goal_card_goal": "Goal",
264
+ "goal_card_missing": "es fehlen {count} Pixel bis zur nächsten Lieferung",
265
+ "goal_card_missing_amount": "es fehlen {amount} bis zur nächsten Lieferung",
266
+ "goal_card_result": "Result",
267
+ "goal_card_select": "Select goal",
268
+ "goal_card_you_help": "You help",
269
+ "goals": "Ziele",
270
+ "guest_email_label": "Your email",
271
+ "guest_email_placeholder": "you@email.com",
272
+ "guest_intro": "You can support without creating an account. Use a name and email to receive confirmation.",
273
+ "guest_name_label": "Public name",
274
+ "guest_name_placeholder": "How do you want to appear on the wall?",
275
+ "just_supported": "Support received",
276
+ "leaderboard_pixels": "pixels",
277
+ "leaderboard_purchases": "support",
278
+ "leaderboard_purchases_plural": "supports",
279
+ "leaderboard_show_less": "Show less",
280
+ "leaderboard_show_more": "Show more",
281
+ "leaderboard_title": "Supporter",
282
+ "leaderboard_title_span": "leaderboard",
283
+ "message": "Message",
284
+ "message_placeholder": "Leave a message for the creator",
285
+ "mural_by": "by {name}",
286
+ "mural_click_hint": "Click a pixel to see who supported",
287
+ "mural_grid": "Wall",
288
+ "mural_legend_free": "Free",
289
+ "mural_legend_taken": "Taken",
290
+ "mural_position": "Position",
291
+ "mural_support_now": "Support now",
292
+ "mural_title_field": "Support title",
293
+ "mural_title_placeholder": "Ex: First pixels of the project",
294
+ "not_found": "Wall not found.",
295
+ "pay_agree": "I agree to the support and billing terms.",
296
+ "pay_agree_rights": "I understand the image and message may appear publicly on the wall.",
297
+ "pay_paypal": "PayPal",
298
+ "pay_paypal_meta": "Secure payment through PayPal",
299
+ "pay_pix": "Pix",
300
+ "pay_pix_soon": "Coming soon",
301
+ "pay_stripe": "Card",
302
+ "pay_stripe_meta": "Secure payment through Stripe",
303
+ "pixels_available": "Available pixels",
304
+ "processing_close": "Close",
305
+ "processing_desc": "We are confirming your support. This may take a few seconds.",
306
+ "processing_timeout_desc": "Confirmation is taking longer than usual. You can close this and check again later.",
307
+ "processing_timeout_title": "Still processing",
308
+ "processing_title": "Processing support",
309
+ "raised": "Gesammelt",
310
+ "reached": "Erreicht",
311
+ "recent_supporters": "Recent supporters",
312
+ "share_copy_link": "Copy link",
313
+ "share_join_cta": "Diese Wall ebenfalls unterstützen",
314
+ "share_pixel_cta": "Diese Unterstützung teilen",
315
+ "share_pixel_default_desc": "See who is supporting the {wall} wall.",
316
+ "share_pixel_text": "{name} supported the {wall} wall. Join in and support too.",
317
+ "share_pixel_title": "{name} supported {wall}",
318
+ "share_via_facebook": "Share on Facebook",
319
+ "share_via_twitter": "Share on X",
320
+ "share_via_whatsapp": "Share on WhatsApp",
321
+ "sold": "sold",
322
+ "spots_unit": "Pixel",
323
+ "step_back": "Back",
324
+ "step_block_auto": "Choose automatically",
325
+ "step_block_confirm": "Confirm position",
326
+ "step_block_help": "Choose where your {count} pixels will appear on the wall.",
327
+ "step_block_manual": "Choose manually",
328
+ "step_block_quantity": "Quantity",
329
+ "step_block_shape": "Shape",
330
+ "step_block_title": "Choose your space",
331
+ "step_block_total": "{total} pixels",
332
+ "step_customize_continue": "Continue",
333
+ "step_customize_help": "Upload your image, title, and message to appear on the wall.",
334
+ "step_customize_image": "Support image",
335
+ "step_customize_image_hint": "Use a square image or crop it before continuing.",
336
+ "step_customize_link": "Optional link",
337
+ "step_customize_link_error": "Enter a valid link.",
338
+ "step_customize_remove_image": "Remove image",
339
+ "step_customize_replace_image": "Replace image",
340
+ "step_customize_title": "Customize your support",
341
+ "step_customize_upload_image": "Upload image",
342
+ "step_goal_help": "Choose a goal to direct your support.",
343
+ "step_goal_skip_desc": "Your support goes to the general wall without linking to a specific goal.",
344
+ "step_goal_skip_title": "Support without a goal",
345
+ "step_goal_title": "Choose a goal",
346
+ "step_package_custom_cta": "Set quantity",
347
+ "step_package_custom_desc": "Choose how many pixels you want to buy.",
348
+ "step_package_custom_title": "Custom",
349
+ "step_package_help": "Choose a pixel package for the wall.",
350
+ "step_package_title": "Choose your package",
351
+ "step_pay_method": "Payment method",
352
+ "step_pay_title": "Payment",
353
+ "step_pay_total": "Total",
354
+ "supporters": "Unterstützer",
355
+ "tiers": "Packages"
356
+ }
357
+ }
243
358
  }
244
359
  }
package/locales/en.json CHANGED
@@ -240,5 +240,120 @@
240
240
  "common": {
241
241
  "video_thumbnail": "Video thumbnail",
242
242
  "expanded_image": "Expanded image"
243
+ },
244
+ "tv": {
245
+ "dashboard": {
246
+ "indie_wall": {
247
+ "about_section": "About the wall",
248
+ "anonymous": "Anonymous",
249
+ "cancel": "Cancel",
250
+ "confirm_support": "Confirm support",
251
+ "cta_create_btn": "Create my account",
252
+ "cta_create_desc": "Create an account to track your support, receive updates, and join other walls.",
253
+ "cta_create_skip": "Continue without an account",
254
+ "cta_create_title": "Want to save your support?",
255
+ "drag_to_crop": "Drag to crop",
256
+ "error_area_occupied": "This area is already taken. Choose another space on the wall.",
257
+ "error_gateway": "Could not start payment. Try again.",
258
+ "error_save": "Could not save your support. Try again.",
259
+ "filled": "Filled",
260
+ "footer_create_cta": "Create your own wall",
261
+ "footer_made_with": "Made with",
262
+ "goal_card_cta": "Support this goal",
263
+ "goal_card_goal": "Goal",
264
+ "goal_card_missing": "missing {count} pixels for the next delivery",
265
+ "goal_card_missing_amount": "missing {amount} for the next delivery",
266
+ "goal_card_result": "Result",
267
+ "goal_card_select": "Select goal",
268
+ "goal_card_you_help": "You help",
269
+ "goals": "Goals",
270
+ "guest_email_label": "Your email",
271
+ "guest_email_placeholder": "you@email.com",
272
+ "guest_intro": "You can support without creating an account. Use a name and email to receive confirmation.",
273
+ "guest_name_label": "Public name",
274
+ "guest_name_placeholder": "How do you want to appear on the wall?",
275
+ "just_supported": "Support received",
276
+ "leaderboard_pixels": "pixels",
277
+ "leaderboard_purchases": "support",
278
+ "leaderboard_purchases_plural": "supports",
279
+ "leaderboard_show_less": "Show less",
280
+ "leaderboard_show_more": "Show more",
281
+ "leaderboard_title": "Supporter",
282
+ "leaderboard_title_span": "leaderboard",
283
+ "message": "Message",
284
+ "message_placeholder": "Leave a message for the creator",
285
+ "mural_by": "by {name}",
286
+ "mural_click_hint": "Click a pixel to see who supported",
287
+ "mural_grid": "Wall",
288
+ "mural_legend_free": "Free",
289
+ "mural_legend_taken": "Taken",
290
+ "mural_position": "Position",
291
+ "mural_support_now": "Support now",
292
+ "mural_title_field": "Support title",
293
+ "mural_title_placeholder": "Ex: First pixels of the project",
294
+ "not_found": "Wall not found.",
295
+ "pay_agree": "I agree to the support and billing terms.",
296
+ "pay_agree_rights": "I understand the image and message may appear publicly on the wall.",
297
+ "pay_paypal": "PayPal",
298
+ "pay_paypal_meta": "Secure payment through PayPal",
299
+ "pay_pix": "Pix",
300
+ "pay_pix_soon": "Coming soon",
301
+ "pay_stripe": "Card",
302
+ "pay_stripe_meta": "Secure payment through Stripe",
303
+ "pixels_available": "Available pixels",
304
+ "processing_close": "Close",
305
+ "processing_desc": "We are confirming your support. This may take a few seconds.",
306
+ "processing_timeout_desc": "Confirmation is taking longer than usual. You can close this and check again later.",
307
+ "processing_timeout_title": "Still processing",
308
+ "processing_title": "Processing support",
309
+ "raised": "Raised",
310
+ "reached": "Reached",
311
+ "recent_supporters": "Recent supporters",
312
+ "share_copy_link": "Copy link",
313
+ "share_join_cta": "Support this wall too",
314
+ "share_pixel_cta": "Share this support",
315
+ "share_pixel_default_desc": "See who is supporting the {wall} wall.",
316
+ "share_pixel_text": "{name} supported the {wall} wall. Join in and support too.",
317
+ "share_pixel_title": "{name} supported {wall}",
318
+ "share_via_facebook": "Share on Facebook",
319
+ "share_via_twitter": "Share on X",
320
+ "share_via_whatsapp": "Share on WhatsApp",
321
+ "sold": "sold",
322
+ "spots_unit": "pixels",
323
+ "step_back": "Back",
324
+ "step_block_auto": "Choose automatically",
325
+ "step_block_confirm": "Confirm position",
326
+ "step_block_help": "Choose where your {count} pixels will appear on the wall.",
327
+ "step_block_manual": "Choose manually",
328
+ "step_block_quantity": "Quantity",
329
+ "step_block_shape": "Shape",
330
+ "step_block_title": "Choose your space",
331
+ "step_block_total": "{total} pixels",
332
+ "step_customize_continue": "Continue",
333
+ "step_customize_help": "Upload your image, title, and message to appear on the wall.",
334
+ "step_customize_image": "Support image",
335
+ "step_customize_image_hint": "Use a square image or crop it before continuing.",
336
+ "step_customize_link": "Optional link",
337
+ "step_customize_link_error": "Enter a valid link.",
338
+ "step_customize_remove_image": "Remove image",
339
+ "step_customize_replace_image": "Replace image",
340
+ "step_customize_title": "Customize your support",
341
+ "step_customize_upload_image": "Upload image",
342
+ "step_goal_help": "Choose a goal to direct your support.",
343
+ "step_goal_skip_desc": "Your support goes to the general wall without linking to a specific goal.",
344
+ "step_goal_skip_title": "Support without a goal",
345
+ "step_goal_title": "Choose a goal",
346
+ "step_package_custom_cta": "Set quantity",
347
+ "step_package_custom_desc": "Choose how many pixels you want to buy.",
348
+ "step_package_custom_title": "Custom",
349
+ "step_package_help": "Choose a pixel package for the wall.",
350
+ "step_package_title": "Choose your package",
351
+ "step_pay_method": "Payment method",
352
+ "step_pay_title": "Payment",
353
+ "step_pay_total": "Total",
354
+ "supporters": "Supporters",
355
+ "tiers": "Packages"
356
+ }
357
+ }
243
358
  }
244
- }
359
+ }