@mundogamernetwork/shared-ui 1.16.27 → 1.17.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,174 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, onMounted } from "vue";
3
+ import type { AxiosInstance } from "axios";
4
+ import { useMgBillingProfile } from "../../composables/useMgBillingProfile";
5
+
6
+ /**
7
+ * "I need an invoice with company details" — the fiscal block every checkout
8
+ * needs.
9
+ *
10
+ * The data is stored once on the customer's profile and snapshotted onto every
11
+ * document issued afterwards, so asking here covers the purchase being made,
12
+ * every renewal of it, and every later one-off purchase too.
13
+ */
14
+ const props = defineProps<{
15
+ httpService: AxiosInstance;
16
+ /** Start expanded regardless of whether a profile already exists. */
17
+ defaultOpen?: boolean;
18
+ }>();
19
+
20
+ const emit = defineEmits<{ (e: "change", wanted: boolean): void }>();
21
+
22
+ const billing = useMgBillingProfile(props.httpService);
23
+ const wanted = ref(props.defaultOpen ?? false);
24
+
25
+ // A customer who already gave us their details should see them, ticked — not
26
+ // an empty box that reads as the platform having forgotten.
27
+ watch(
28
+ () => billing.hasData.value,
29
+ (hasData) => {
30
+ if (hasData) wanted.value = true;
31
+ }
32
+ );
33
+
34
+ watch(wanted, (value) => emit("change", value));
35
+
36
+ onMounted(billing.load);
37
+
38
+ /**
39
+ * Persist the details, if the customer asked for them.
40
+ *
41
+ * @returns false only when a requested save actually failed, so a caller can
42
+ * hold back the gateway redirect instead of sending the customer off
43
+ * with details that were never stored.
44
+ */
45
+ async function save(): Promise<boolean> {
46
+ if (!wanted.value) return true;
47
+ return billing.save();
48
+ }
49
+
50
+ defineExpose({ save, wanted, profile: billing.profile, hasData: billing.hasData });
51
+ </script>
52
+
53
+ <template>
54
+ <div class="mg-billing-profile">
55
+ <label class="mg-billing-profile__toggle">
56
+ <input v-model="wanted" type="checkbox" />
57
+ <span>{{ $t?.("billing_profile.toggle") ?? "I need an invoice with company details" }}</span>
58
+ </label>
59
+
60
+ <div v-show="wanted" class="mg-billing-profile__body">
61
+ <p class="mg-billing-profile__hint">{{ $t?.("billing_profile.hint") ?? "Saved to your account and used on every invoice from now on, including each renewal." }}</p>
62
+
63
+ <div class="mg-billing-profile__grid">
64
+ <label class="mg-billing-profile__field mg-billing-profile__field--wide">
65
+ <span>{{ $t?.("billing_profile.company_name") ?? "Company name" }}</span>
66
+ <input v-model="billing.profile.value.company_name" type="text" />
67
+ </label>
68
+ <label class="mg-billing-profile__field mg-billing-profile__field--wide">
69
+ <span>{{ $t?.("billing_profile.legal_name") ?? "Legal name" }}</span>
70
+ <input v-model="billing.profile.value.legal_name" type="text" />
71
+ </label>
72
+ <label class="mg-billing-profile__field">
73
+ <span>{{ $t?.("billing_profile.vat_number") ?? "VAT number" }}</span>
74
+ <input v-model="billing.profile.value.vat_number" type="text" />
75
+ </label>
76
+ <label class="mg-billing-profile__field">
77
+ <span>{{ $t?.("billing_profile.tax_id") ?? "Tax ID" }}</span>
78
+ <input v-model="billing.profile.value.tax_id" type="text" />
79
+ </label>
80
+ <label class="mg-billing-profile__field">
81
+ <span>{{ $t?.("billing_profile.registration_number") ?? "Registration number" }}</span>
82
+ <input v-model="billing.profile.value.registration_number" type="text" />
83
+ </label>
84
+ <label class="mg-billing-profile__field">
85
+ <span>{{ $t?.("billing_profile.represented_by") ?? "Represented by (director)" }}</span>
86
+ <input v-model="billing.profile.value.represented_by" type="text" />
87
+ </label>
88
+ <label class="mg-billing-profile__field mg-billing-profile__field--wide">
89
+ <span>{{ $t?.("billing_profile.address") ?? "Address" }}</span>
90
+ <input v-model="billing.profile.value.address_line_1" type="text" />
91
+ </label>
92
+ <label class="mg-billing-profile__field">
93
+ <span>{{ $t?.("billing_profile.city") ?? "City" }}</span>
94
+ <input v-model="billing.profile.value.city" type="text" />
95
+ </label>
96
+ <label class="mg-billing-profile__field">
97
+ <span>{{ $t?.("billing_profile.postal_code") ?? "Postal code" }}</span>
98
+ <input v-model="billing.profile.value.postal_code" type="text" />
99
+ </label>
100
+ <label class="mg-billing-profile__field">
101
+ <span>{{ $t?.("billing_profile.country") ?? "Country" }}</span>
102
+ <input v-model="billing.profile.value.country" type="text" />
103
+ </label>
104
+ </div>
105
+
106
+ <p v-if="billing.error.value" class="mg-billing-profile__error">
107
+ {{ billing.error.value || $t?.("billing_profile.save_error") ?? "Could not save your billing details. Please try again." }}
108
+ </p>
109
+ </div>
110
+ </div>
111
+ </template>
112
+
113
+ <style lang="scss" scoped>
114
+ .mg-billing-profile {
115
+ margin-top: 16px;
116
+ border-top: 1px solid var(--inactive, #ececec);
117
+ padding-top: 16px;
118
+ }
119
+
120
+ .mg-billing-profile__toggle {
121
+ display: flex;
122
+ align-items: center;
123
+ gap: 8px;
124
+ font-size: 14px;
125
+ cursor: pointer;
126
+ }
127
+
128
+ .mg-billing-profile__body {
129
+ margin-top: 14px;
130
+ }
131
+
132
+ .mg-billing-profile__hint {
133
+ font-size: 13px;
134
+ color: var(--inactive-text, #666);
135
+ margin: 0 0 12px;
136
+ }
137
+
138
+ .mg-billing-profile__grid {
139
+ display: grid;
140
+ grid-template-columns: repeat(2, minmax(0, 1fr));
141
+ gap: 12px;
142
+ }
143
+
144
+ .mg-billing-profile__field {
145
+ display: flex;
146
+ flex-direction: column;
147
+ gap: 4px;
148
+ font-size: 12px;
149
+ }
150
+
151
+ .mg-billing-profile__field--wide {
152
+ grid-column: 1 / -1;
153
+ }
154
+
155
+ .mg-billing-profile__field input {
156
+ border: 1px solid var(--inactive, #d5d5d5);
157
+ padding: 8px 10px;
158
+ font-size: 14px;
159
+ background: var(--bg, #fff);
160
+ color: var(--active, #111);
161
+ }
162
+
163
+ .mg-billing-profile__error {
164
+ margin: 10px 0 0;
165
+ color: var(--error, #ee3831);
166
+ font-size: 13px;
167
+ }
168
+
169
+ @media (max-width: 620px) {
170
+ .mg-billing-profile__grid {
171
+ grid-template-columns: 1fr;
172
+ }
173
+ }
174
+ </style>
@@ -69,7 +69,7 @@ async function loadPools() {
69
69
  loadingPools.value = true
70
70
  try {
71
71
  const res = await fetchAvailablePools(queryParams())
72
- pools.value = (res as any)?.data ?? []
72
+ pools.value = (res as any)?.data?.data ?? []
73
73
  pools.value.forEach(p => {
74
74
  if (!requestForms.value[p.id]) {
75
75
  requestForms.value[p.id] = { platform_ids: [], description: '' }
@@ -88,7 +88,7 @@ async function loadMyRequests() {
88
88
  loadingRequests.value = true
89
89
  try {
90
90
  const res = await fetchMyRequests()
91
- myRequests.value = (res as any)?.data ?? []
91
+ myRequests.value = (res as any)?.data?.data ?? []
92
92
  } catch (err: any) {
93
93
  if (err?.response?.status === 401) isAuthenticated.value = false
94
94
  } finally {
@@ -99,7 +99,7 @@ async function loadMyRequests() {
99
99
  async function loadPlatformsData() {
100
100
  try {
101
101
  const res = await fetchPlatforms()
102
- platforms.value = (res as any)?.data ?? []
102
+ platforms.value = (res as any)?.data?.data ?? []
103
103
  } catch {}
104
104
  }
105
105
 
@@ -218,7 +218,7 @@ async function revealCode(reqId: number) {
218
218
  const body: Record<string, any> = {}
219
219
  if (props.accessToken) body.access_token = props.accessToken
220
220
  const res = await apiRevealKey(reqId, body)
221
- revealResult.value = (res as any)?.data ?? null
221
+ revealResult.value = (res as any)?.data?.data ?? null
222
222
  } catch (err: any) {
223
223
  if (err?.response?.status === 401) isAuthenticated.value = false
224
224
  } finally {
@@ -0,0 +1,99 @@
1
+ import { ref, computed } from "vue";
2
+ import type { AxiosInstance } from "axios";
3
+
4
+ /**
5
+ * The customer's fiscal identity — company name, tax ids, address, the person
6
+ * who signs for the company.
7
+ *
8
+ * It is stored once per customer and snapshotted onto every document the
9
+ * platform issues them afterwards (api-main does that on Invoice and on
10
+ * PlanSubscriptionPayment creation). Collecting it at checkout is what stops a
11
+ * subscription from producing an unusable receipt every single billing cycle:
12
+ * the payment record is created on the way back from the gateway, so anything
13
+ * asked for afterwards misses at least the first document.
14
+ */
15
+ const BILLING_PROFILE_URL = "api/v1/public/user/billing-profile";
16
+
17
+ export interface MgBillingProfile {
18
+ name: string;
19
+ company_name: string;
20
+ legal_name: string;
21
+ vat_number: string;
22
+ tax_id: string;
23
+ registration_number: string;
24
+ represented_by: string;
25
+ address_line_1: string;
26
+ address_line_2: string;
27
+ city: string;
28
+ state: string;
29
+ postal_code: string;
30
+ country: string;
31
+ }
32
+
33
+ export function emptyMgBillingProfile(): MgBillingProfile {
34
+ return {
35
+ name: "",
36
+ company_name: "",
37
+ legal_name: "",
38
+ vat_number: "",
39
+ tax_id: "",
40
+ registration_number: "",
41
+ represented_by: "",
42
+ address_line_1: "",
43
+ address_line_2: "",
44
+ city: "",
45
+ state: "",
46
+ postal_code: "",
47
+ country: "",
48
+ };
49
+ }
50
+
51
+ export function useMgBillingProfile(httpService: AxiosInstance) {
52
+ const profile = ref<MgBillingProfile>(emptyMgBillingProfile());
53
+ const loading = ref(false);
54
+ const saving = ref(false);
55
+ const error = ref<string | null>(null);
56
+
57
+ /** Whether the customer has given us anything worth printing on a document. */
58
+ const hasData = computed(() =>
59
+ Object.values(profile.value).some((value) => String(value ?? "").trim() !== "")
60
+ );
61
+
62
+ async function load() {
63
+ loading.value = true;
64
+ try {
65
+ const res = await httpService.get(BILLING_PROFILE_URL);
66
+ const data = res?.data?.data ?? {};
67
+ (Object.keys(profile.value) as Array<keyof MgBillingProfile>).forEach((key) => {
68
+ profile.value[key] = data[key] ?? "";
69
+ });
70
+ } catch {
71
+ // A profile that cannot be read just starts empty — never a reason to
72
+ // block a checkout the customer is in the middle of.
73
+ } finally {
74
+ loading.value = false;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * @returns whether the save succeeded, so a caller can hold back a gateway
80
+ * redirect rather than sending the customer off with details that
81
+ * were never stored.
82
+ */
83
+ async function save(): Promise<boolean> {
84
+ if (saving.value) return false;
85
+ saving.value = true;
86
+ error.value = null;
87
+ try {
88
+ await httpService.put(BILLING_PROFILE_URL, profile.value);
89
+ return true;
90
+ } catch (err: any) {
91
+ error.value = err?.response?.data?.message ?? null;
92
+ return false;
93
+ } finally {
94
+ saving.value = false;
95
+ }
96
+ }
97
+
98
+ return { profile, loading, saving, error, hasData, load, save };
99
+ }
@@ -307,12 +307,37 @@ export function useMgCheckout(httpService: AxiosInstance, options?: {
307
307
 
308
308
  // ── startValidation: the actual checkout submission ────────────────────────
309
309
 
310
+ // ── Fiscal data ────────────────────────────────────────────────────────────
311
+ // A checkout page registers its MgBillingProfileFields here; the details are
312
+ // then saved before the gateway redirect. That ordering is the whole point:
313
+ // the payment record is created on the way back and snapshots whatever the
314
+ // profile holds at that moment, so anything collected afterwards misses the
315
+ // document the customer is buying right now — and on a subscription it
316
+ // misses every cycle until somebody notices.
317
+ const billingProfileFields = ref<{ save: () => Promise<boolean> } | null>(null);
318
+
319
+ function registerBillingProfileFields(instance: { save: () => Promise<boolean> } | null) {
320
+ billingProfileFields.value = instance;
321
+ }
322
+
310
323
  async function startValidation() {
311
324
  if (loading.value) return;
312
325
  try {
313
326
  loading.value = true;
314
327
  if (buyerPageName) localStorage.setItem("buyerPage", buyerPageName);
315
328
 
329
+ if (billingProfileFields.value) {
330
+ const saved = await billingProfileFields.value.save();
331
+ if (!saved) {
332
+ checkoutError.value = _t(
333
+ "billing_profile.save_error",
334
+ "Could not save your billing details. Please try again."
335
+ );
336
+ loading.value = false;
337
+ return;
338
+ }
339
+ }
340
+
316
341
  const gatewayStr = selectedPaymentGateway.value || "stripe";
317
342
  const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
318
343
  const localeStr = locale.value || "en";
@@ -523,6 +548,10 @@ export function useMgCheckout(httpService: AxiosInstance, options?: {
523
548
  emailError,
524
549
  isInvalid,
525
550
 
551
+ // Fiscal data
552
+ billingProfileFields,
553
+ registerBillingProfileFields,
554
+
526
555
  // Actions
527
556
  handlePurchase,
528
557
  startValidation,
package/locales/de.json CHANGED
@@ -449,6 +449,7 @@
449
449
  "required_fields": "Fill in all required fields",
450
450
  "url_error": "Invalid URL",
451
451
  "submit_error": "Error submitting. Try again.",
452
+ "duplicate_url_error": "Du hast diesen Link für diese Anfrage bereits gesendet.",
452
453
  "sending": "Sending...",
453
454
  "finish": "Submit",
454
455
  "submitted_title": "Material submitted!",
@@ -586,5 +587,20 @@
586
587
  "tiers": "Packages"
587
588
  }
588
589
  }
590
+ },
591
+ "billing_profile": {
592
+ "toggle": "Ich benötige eine Rechnung mit Firmendaten",
593
+ "hint": "Wird in Ihrem Konto gespeichert und ab sofort auf jeder Rechnung verwendet, auch bei jeder Verlängerung.",
594
+ "company_name": "Firmenname",
595
+ "legal_name": "Rechtlicher Name",
596
+ "vat_number": "USt-IdNr.",
597
+ "tax_id": "Steuernummer",
598
+ "registration_number": "Registernummer",
599
+ "represented_by": "Vertreten durch (Geschäftsführer)",
600
+ "address": "Anschrift",
601
+ "city": "Stadt",
602
+ "postal_code": "Postleitzahl",
603
+ "country": "Land",
604
+ "save_error": "Ihre Rechnungsdaten konnten nicht gespeichert werden. Bitte versuchen Sie es erneut."
589
605
  }
590
606
  }
package/locales/en.json CHANGED
@@ -449,6 +449,7 @@
449
449
  "required_fields": "Fill in all required fields",
450
450
  "url_error": "Invalid URL",
451
451
  "submit_error": "Error submitting. Try again.",
452
+ "duplicate_url_error": "You already sent this link for this request.",
452
453
  "sending": "Sending...",
453
454
  "finish": "Submit",
454
455
  "submitted_title": "Material submitted!",
@@ -586,5 +587,20 @@
586
587
  "tiers": "Packages"
587
588
  }
588
589
  }
590
+ },
591
+ "billing_profile": {
592
+ "toggle": "I need an invoice with company details",
593
+ "hint": "Saved to your account and used on every invoice from now on, including each renewal.",
594
+ "company_name": "Company name",
595
+ "legal_name": "Legal name",
596
+ "vat_number": "VAT number",
597
+ "tax_id": "Tax ID",
598
+ "registration_number": "Registration number",
599
+ "represented_by": "Represented by (director)",
600
+ "address": "Address",
601
+ "city": "City",
602
+ "postal_code": "Postal code",
603
+ "country": "Country",
604
+ "save_error": "Could not save your billing details. Please try again."
589
605
  }
590
606
  }
package/locales/es.json CHANGED
@@ -449,6 +449,7 @@
449
449
  "required_fields": "¡Por favor, completa todos los campos obligatorios!",
450
450
  "url_error": "Introduce URLs válidas (deben comenzar con http:// o https://)",
451
451
  "submit_error": "Hubo un error al procesar tu material.",
452
+ "duplicate_url_error": "Ya enviaste este enlace para esta solicitud.",
452
453
  "sending": "Enviando...",
453
454
  "finish": "Finalizar",
454
455
  "submitted_title": "Material Enviado",
@@ -586,5 +587,20 @@
586
587
  "tiers": "Paquetes de Apoyo"
587
588
  }
588
589
  }
590
+ },
591
+ "billing_profile": {
592
+ "toggle": "Necesito factura con datos de empresa",
593
+ "hint": "Se guarda en tu cuenta y aparece en todas las facturas a partir de ahora, incluidas las renovaciones.",
594
+ "company_name": "Razón social",
595
+ "legal_name": "Nombre legal",
596
+ "vat_number": "Número de VAT",
597
+ "tax_id": "Identificación fiscal",
598
+ "registration_number": "Número de registro",
599
+ "represented_by": "Representada por (director)",
600
+ "address": "Dirección",
601
+ "city": "Ciudad",
602
+ "postal_code": "Código postal",
603
+ "country": "País",
604
+ "save_error": "No se pudieron guardar tus datos de facturación. Inténtalo de nuevo."
589
605
  }
590
606
  }
@@ -449,6 +449,7 @@
449
449
  "required_fields": "Fill in all required fields",
450
450
  "url_error": "Invalid URL",
451
451
  "submit_error": "Error submitting. Try again.",
452
+ "duplicate_url_error": "Você já enviou esse link para este pedido.",
452
453
  "sending": "Sending...",
453
454
  "finish": "Submit",
454
455
  "submitted_title": "Material submitted!",
@@ -586,5 +587,20 @@
586
587
  "tiers": "Pacotes"
587
588
  }
588
589
  }
590
+ },
591
+ "billing_profile": {
592
+ "toggle": "Preciso de nota com dados da empresa",
593
+ "hint": "Fica salvo na sua conta e sai em todas as próximas faturas, inclusive nas renovações.",
594
+ "company_name": "Razão social",
595
+ "legal_name": "Nome legal",
596
+ "vat_number": "CNPJ / VAT",
597
+ "tax_id": "Inscrição fiscal",
598
+ "registration_number": "Número de registro",
599
+ "represented_by": "Representada por (diretor)",
600
+ "address": "Endereço",
601
+ "city": "Cidade",
602
+ "postal_code": "CEP",
603
+ "country": "País",
604
+ "save_error": "Não foi possível salvar seus dados de faturamento. Tente novamente."
589
605
  }
590
606
  }
package/locales/ro.json CHANGED
@@ -449,6 +449,7 @@
449
449
  "required_fields": "Fill in all required fields",
450
450
  "url_error": "Invalid URL",
451
451
  "submit_error": "Error submitting. Try again.",
452
+ "duplicate_url_error": "Ai trimis deja acest link pentru această solicitare.",
452
453
  "sending": "Sending...",
453
454
  "finish": "Submit",
454
455
  "submitted_title": "Material submitted!",
@@ -586,5 +587,20 @@
586
587
  "tiers": "Packages"
587
588
  }
588
589
  }
590
+ },
591
+ "billing_profile": {
592
+ "toggle": "Am nevoie de factură cu datele firmei",
593
+ "hint": "Se salvează în contul tău și apare pe toate facturile de acum înainte, inclusiv la reînnoiri.",
594
+ "company_name": "Denumirea firmei",
595
+ "legal_name": "Denumire legală",
596
+ "vat_number": "Cod TVA",
597
+ "tax_id": "Identificator fiscal",
598
+ "registration_number": "Număr de înregistrare",
599
+ "represented_by": "Reprezentată de (director)",
600
+ "address": "Adresă",
601
+ "city": "Oraș",
602
+ "postal_code": "Cod poștal",
603
+ "country": "Țară",
604
+ "save_error": "Nu am putut salva datele tale de facturare. Încearcă din nou."
589
605
  }
590
606
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.16.27",
3
+ "version": "1.17.0",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -352,19 +352,33 @@ const submitForm = async () => {
352
352
  }
353
353
 
354
354
  for (const form of forms.value) {
355
- await submitMaterial({
356
- key_request_id: requestId.value,
357
- user_id: unref(currentUserId),
358
- // Slugs, not guessed ids. article/video/stream/web/print already
359
- // match content_types.slug (Spatie\Sluggable on the model's own
360
- // `name`) the backend resolves them, which is what makes this
361
- // work regardless of what numeric ids that table happens to hold
362
- // in a given environment.
363
- content_types: form.content_types,
364
- material_url: form.material_url,
365
- description: form.description,
366
- submitted_at: new Date(form.submitted_at).toISOString(),
367
- })
355
+ try {
356
+ await submitMaterial({
357
+ key_request_id: requestId.value,
358
+ user_id: unref(currentUserId),
359
+ // Slugs, not guessed ids. article/video/stream/web/print already
360
+ // match content_types.slug (Spatie\Sluggable on the model's own
361
+ // `name`) the backend resolves them, which is what makes this
362
+ // work regardless of what numeric ids that table happens to hold
363
+ // in a given environment.
364
+ content_types: form.content_types,
365
+ material_url: form.material_url,
366
+ description: form.description,
367
+ submitted_at: new Date(form.submitted_at).toISOString(),
368
+ })
369
+ } catch (submitError: any) {
370
+ // Returning to this page later (or double-submitting) used to
371
+ // silently create a second identical row — the backend now
372
+ // rejects the exact link a second time per key request, but
373
+ // without catching this specific code here it fell through to
374
+ // the generic "something went wrong" below, leaving the person
375
+ // no wiser that it's the SAME link they already sent.
376
+ if (submitError?.response?.data?.error === "DUPLICATE_MATERIAL_URL") {
377
+ formError.value = t("keys.materials.duplicate_url_error")
378
+ return
379
+ }
380
+ throw submitError
381
+ }
368
382
  }
369
383
 
370
384
  // Reload submitted list