@saasicat/ui-vue 0.18.0 → 0.19.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.
@@ -22,244 +22,14 @@
22
22
  </q-card-section>
23
23
 
24
24
  <q-card-section class="pc-dlg__body">
25
- <!-- Section: Code & Discount -->
26
- <div class="pc-section">
27
- <div class="pc-section__title">{{ msg.form.sectionCodeDiscount }}</div>
28
- <div class="pc-grid pc-grid--2">
29
- <div class="pc-field">
30
- <div class="pc-field__label">{{ msg.form.codeLabel }}</div>
31
- <div class="pc-code-input">
32
- <input
33
- v-model="form.code"
34
- class="pc-input pc-input--code"
35
- :placeholder="msg.form.codePlaceholder"
36
- @input="onCodeInput"
37
- />
38
- <button
39
- type="button"
40
- class="pc-btn-mini"
41
- @click="form.code = randomCode()"
42
- >
43
- {{ msg.form.codeRandom }}
44
- </button>
45
- </div>
46
- <div class="pc-field__hint">{{ msg.form.codeHint }}</div>
47
- </div>
48
-
49
- <div class="pc-field">
50
- <div class="pc-field__label">{{ msg.form.valueTypeLabel }}</div>
51
- <div class="pc-type-grid">
52
- <button
53
- v-for="o in typeOptions"
54
- :key="o.k"
55
- type="button"
56
- class="pc-type-opt"
57
- :class="{ 'pc-type-opt--active': form.valueType === o.k }"
58
- @click="form.valueType = o.k"
59
- >
60
- <div class="pc-type-opt__label">{{ o.label }}</div>
61
- <div class="pc-type-opt__sub">{{ o.sub }}</div>
62
- </button>
63
- </div>
64
- </div>
65
- </div>
66
-
67
- <div class="pc-field" style="max-width: 280px">
68
- <div class="pc-field__label">
69
- {{
70
- form.valueType === 'PERCENT'
71
- ? msg.form.valuePercentLabel
72
- : msg.form.valueAbsoluteLabel
73
- }}
74
- </div>
75
- <input
76
- v-model.number="form.value"
77
- class="pc-input"
78
- type="number"
79
- min="1"
80
- :max="form.valueType === 'PERCENT' ? 100 : undefined"
81
- />
82
- </div>
83
- </div>
84
-
85
- <!-- Section: Validity & Duration -->
86
- <div class="pc-section">
87
- <div class="pc-section__title">{{ msg.form.sectionValidity }}</div>
88
-
89
- <div v-if="plans.length > 0" class="pc-field">
90
- <div class="pc-field__label">{{ msg.form.plansLabel }}</div>
91
- <div class="pc-plan-pick">
92
- <button
93
- v-for="p in plans"
94
- :key="p.key"
95
- type="button"
96
- class="pc-plan-opt"
97
- :class="{ 'pc-plan-opt--on': isPlanSelected(p.key) }"
98
- :style="planChipStyle(p)"
99
- @click="togglePlan(p.key)"
100
- >
101
- <span
102
- class="pc-plan-opt__mark"
103
- :style="{ background: p.color ?? '#64748b' }"
104
- />
105
- {{ p.label }}
106
- </button>
107
- </div>
108
- <div class="pc-field__hint">
109
- {{
110
- formatMessage(msg.form.plansHint, {
111
- count: form.appliesToPlans.length,
112
- })
113
- }}
114
- </div>
115
- </div>
116
-
117
- <div class="pc-grid pc-grid--2">
118
- <div class="pc-field">
119
- <div class="pc-field__label">{{ msg.form.durationLabel }}</div>
120
- <div class="pc-dur">
121
- <button
122
- v-for="o in durationOptions"
123
- :key="o.k"
124
- type="button"
125
- class="pc-dur-opt"
126
- :class="{ 'pc-dur-opt--active': form.durationType === o.k }"
127
- @click="form.durationType = o.k"
128
- >
129
- {{ o.label }}
130
- </button>
131
- </div>
132
- <input
133
- v-if="form.durationType !== 'ONCE'"
134
- v-model.number="form.durationValue"
135
- class="pc-input"
136
- type="number"
137
- min="1"
138
- style="margin-top: 8px; max-width: 120px"
139
- :placeholder="
140
- form.durationType === 'MONTHS'
141
- ? msg.form.durationMonthsPlaceholder
142
- : msg.form.durationCyclesPlaceholder
143
- "
144
- />
145
- </div>
146
-
147
- <div class="pc-field">
148
- <div class="pc-field__label">{{ msg.form.maxRedemptionsLabel }}</div>
149
- <input
150
- v-model.number="form.maxRedemptions"
151
- class="pc-input"
152
- type="number"
153
- min="1"
154
- :placeholder="msg.form.maxRedemptionsPlaceholder"
155
- />
156
- <div class="pc-field__hint">
157
- {{ msg.form.maxRedemptionsHintCreate }}
158
- </div>
159
- </div>
160
- </div>
161
-
162
- <div class="pc-grid pc-grid--2">
163
- <div class="pc-field">
164
- <div class="pc-field__label">{{ msg.form.validFromLabel }}</div>
165
- <input v-model="form.validFrom" class="pc-input" type="date" />
166
- </div>
167
- <div class="pc-field">
168
- <div class="pc-field__label">{{ msg.form.validUntilLabel }}</div>
169
- <input v-model="form.validUntil" class="pc-input" type="date" />
170
- </div>
171
- </div>
172
- </div>
173
-
174
- <!-- Section: Campaign & Note -->
175
- <div class="pc-section">
176
- <div class="pc-section__title">{{ msg.form.sectionCampaign }}</div>
177
- <div v-if="showCampaignTag" class="pc-field">
178
- <div class="pc-field__label">{{ msg.form.campaignLabel }}</div>
179
- <input
180
- v-model="form.campaignTag"
181
- class="pc-input"
182
- :placeholder="msg.form.campaignPlaceholder"
183
- />
184
- <div class="pc-field__hint">{{ msg.form.campaignHint }}</div>
185
- </div>
186
- <div class="pc-field">
187
- <div class="pc-field__label">{{ msg.form.noteLabel }}</div>
188
- <textarea
189
- v-model="form.description"
190
- class="pc-input"
191
- rows="2"
192
- :placeholder="msg.form.notePlaceholder"
193
- />
194
- </div>
195
- </div>
196
-
197
- <!-- Section: Advanced (backend-only fields, collapsed) -->
198
- <div class="pc-section">
199
- <button
200
- type="button"
201
- class="pc-section__toggle"
202
- @click="advancedOpen = !advancedOpen"
203
- >
204
- <q-icon
205
- :name="advancedOpen ? 'expand_more' : 'chevron_right'"
206
- size="16px"
207
- />
208
- {{ msg.form.advancedToggle }}
209
- </button>
210
- <div v-if="advancedOpen" class="pc-advanced">
211
- <div class="pc-grid pc-grid--2">
212
- <div class="pc-field">
213
- <div class="pc-field__label">{{ msg.form.billingCycleLabel }}</div>
214
- <select v-model="form.appliesToBilling" class="pc-input">
215
- <option :value="undefined">
216
- {{ common.both }}
217
- </option>
218
- <option value="MONTHLY">{{ common.monthly }}</option>
219
- <option value="YEARLY">{{ common.yearly }}</option>
220
- </select>
221
- </div>
222
- <div class="pc-field">
223
- <div class="pc-field__label">{{ msg.form.minAmountLabel }}</div>
224
- <input
225
- v-model.number="form.minimumPlanAmountGross"
226
- class="pc-input"
227
- type="number"
228
- min="0"
229
- :placeholder="msg.form.minAmountPlaceholder"
230
- />
231
- </div>
232
- </div>
233
- <div class="pc-grid pc-grid--2">
234
- <label class="pc-check">
235
- <input v-model="form.firstTimeCustomersOnly" type="checkbox" />
236
- <span>{{ msg.form.firstTimeOnly }}</span>
237
- </label>
238
- <label class="pc-check">
239
- <input v-model="form.allowZeroInvoice" type="checkbox" />
240
- <span>{{ msg.form.allowZeroInvoice }}</span>
241
- </label>
242
- </div>
243
- <div class="pc-field">
244
- <div class="pc-field__label">{{ msg.form.revenueAccountLabel }}</div>
245
- <input
246
- v-model="form.revenueDeductionAccount"
247
- class="pc-input"
248
- :placeholder="msg.form.revenueAccountPlaceholder"
249
- />
250
- </div>
251
- </div>
252
- </div>
253
-
254
- <!-- Live preview -->
255
- <div class="pc-preview">
256
- <div class="pc-preview__eyebrow">{{ msg.form.previewEyebrow }}</div>
257
- <div class="pc-preview__body">
258
- <code class="pc-preview__code">{{ form.code || 'CODE' }}</code>
259
- <span class="pc-preview__disc">{{ previewValue }}</span>
260
- <span class="pc-preview__meta">{{ previewMeta }}</span>
261
- </div>
262
- </div>
25
+ <PromoCodeDialogFields
26
+ mode="create"
27
+ :form="form"
28
+ v-model:code="form.code"
29
+ v-model:advanced-open="advancedOpen"
30
+ :show-campaign-tag="showCampaignTag"
31
+ :plans="plans"
32
+ />
263
33
 
264
34
  <p v-if="error" class="pc-error">{{ error }}</p>
265
35
  </q-card-section>
@@ -281,8 +51,8 @@
281
51
 
282
52
  <script setup lang="ts">
283
53
  import { computed, reactive, ref, watch } from 'vue';
284
- import { formatMessage } from '../../client/i18n/format.js';
285
54
  import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
55
+ import PromoCodeDialogFields from './PromoCodeDialogFields.vue';
286
56
  import type {
287
57
  PromoCodeCreatePayload,
288
58
  PromoCodeDurationType,
@@ -290,10 +60,10 @@ import type {
290
60
  PromoCodeValueType,
291
61
  } from './types.js';
292
62
 
293
- // Platform promo-code create dialog (sim layout: sections, type grid, plan
294
- // picker, live preview). The app supplies the submit handler and optionally
295
- // the plan list for the plan picker (e.g. from the manifest snapshot). If no
296
- // plans are passed through, the plan selection section is hidden.
63
+ // Platform promo-code create dialog. The form body — sections, type grid, plan
64
+ // picker, live preview — is shared with the edit dialog through
65
+ // PromoCodeDialogFields; what stays here is the create-specific state and the
66
+ // payload the app's submit handler receives.
297
67
 
298
68
  const props = withDefaults(
299
69
  defineProps<{
@@ -317,27 +87,6 @@ const emit = defineEmits<{
317
87
  const msg = useSaMessages('promos');
318
88
  const common = useSaMessages('common');
319
89
 
320
- const typeOptions = computed<ReadonlyArray<{ k: PromoCodeValueType; label: string; sub: string }>>(
321
- () => [
322
- {
323
- k: 'PERCENT',
324
- label: msg.value.form.valueTypePercent,
325
- sub: msg.value.form.valueTypePercentSub,
326
- },
327
- {
328
- k: 'ABSOLUTE',
329
- label: msg.value.form.valueTypeAbsolute,
330
- sub: msg.value.form.valueTypeAbsoluteSub,
331
- },
332
- ],
333
- );
334
-
335
- const durationOptions = computed<ReadonlyArray<{ k: PromoCodeDurationType; label: string }>>(() => [
336
- { k: 'ONCE', label: msg.value.form.durationOnce },
337
- { k: 'MONTHS', label: msg.value.form.durationMonths },
338
- { k: 'BILLING_CYCLES', label: msg.value.form.durationBillingCycles },
339
- ]);
340
-
341
90
  function emptyForm() {
342
91
  return {
343
92
  code: '',
@@ -364,60 +113,6 @@ const loading = ref(false);
364
113
  const error = ref('');
365
114
  const advancedOpen = ref(false);
366
115
 
367
- function onCodeInput(): void {
368
- form.code = form.code.toUpperCase().replace(/[^A-Z0-9_-]/g, '');
369
- }
370
-
371
- function randomCode(): string {
372
- const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
373
- let s = '';
374
- for (let i = 0; i < 8; i++) s += chars[Math.floor(Math.random() * chars.length)];
375
- return s;
376
- }
377
-
378
- function isPlanSelected(key: string): boolean {
379
- return form.appliesToPlans.includes(key);
380
- }
381
- function togglePlan(key: string): void {
382
- if (form.appliesToPlans.includes(key)) {
383
- form.appliesToPlans = form.appliesToPlans.filter((k) => k !== key);
384
- } else {
385
- form.appliesToPlans = [...form.appliesToPlans, key];
386
- }
387
- }
388
- function planChipStyle(p: PromoCodePlanOption): Record<string, string> {
389
- if (!isPlanSelected(p.key) || !p.color) return {};
390
- return {
391
- borderColor: p.color,
392
- background: `${p.color}12`,
393
- color: p.color,
394
- };
395
- }
396
-
397
- const previewValue = computed(() => {
398
- if (form.valueType === 'PERCENT') return `−${form.value || 0}%`;
399
- return `−${form.value || 0} €`;
400
- });
401
-
402
- const previewMeta = computed(() => {
403
- const parts: string[] = [];
404
- const count = form.durationValue || 0;
405
- if (form.durationType === 'ONCE') parts.push(msg.value.form.previewOnce);
406
- else if (form.durationType === 'MONTHS')
407
- parts.push(formatMessage(msg.value.form.previewMonths, { count }));
408
- else parts.push(formatMessage(msg.value.form.previewCycles, { count }));
409
- parts.push(
410
- form.appliesToPlans.length > 0
411
- ? form.appliesToPlans.join(', ')
412
- : props.plans.length > 0
413
- ? msg.value.form.previewAllPlans
414
- : msg.value.form.previewNoPlanFilter,
415
- );
416
- if (form.maxRedemptions)
417
- parts.push(formatMessage(msg.value.form.previewMax, { count: form.maxRedemptions }));
418
- return parts.join(' · ');
419
- });
420
-
421
116
  const isValid = computed(() => {
422
117
  if (!/^[A-Z0-9_-]{3,32}$/.test(form.code)) return false;
423
118
  if (!form.value || form.value <= 0) return false;
@@ -479,278 +174,42 @@ async function onSubmit() {
479
174
  min-width: 720px;
480
175
  max-width: 96vw;
481
176
  }
177
+
482
178
  .pc-dlg__head {
483
179
  display: flex;
484
180
  align-items: flex-start;
485
181
  gap: 16px;
486
182
  padding-bottom: 8px;
487
183
  }
184
+
488
185
  .pc-dlg__close {
489
186
  margin-left: auto;
490
187
  }
188
+
491
189
  .pc-dlg__title {
492
190
  font-family: var(--sa-font-head, system-ui, sans-serif);
493
191
  font-weight: 700;
494
192
  font-size: 18px;
495
193
  color: var(--sa-heading, #0f172a);
496
194
  }
195
+
497
196
  .pc-dlg__sub {
498
197
  font-size: 12.5px;
499
198
  color: var(--sa-muted, #64748b);
500
199
  margin-top: 2px;
501
200
  }
201
+
502
202
  .pc-dlg__body {
503
203
  display: flex;
504
204
  flex-direction: column;
505
205
  gap: 16px;
506
206
  padding-top: 4px;
507
207
  }
208
+
508
209
  .pc-dlg__foot {
509
210
  border-top: 1px solid var(--sa-border, #e2e8f0);
510
211
  }
511
212
 
512
- .pc-section {
513
- border: 1px solid var(--sa-border, #e2e8f0);
514
- border-radius: 10px;
515
- padding: 14px 16px;
516
- background: #fafbfc;
517
- }
518
- .pc-section__title {
519
- font-family: var(--sa-font-head, system-ui, sans-serif);
520
- font-weight: 700;
521
- font-size: 13px;
522
- color: var(--sa-heading, #0f172a);
523
- margin-bottom: 12px;
524
- letter-spacing: -0.005em;
525
- }
526
- .pc-section__toggle {
527
- border: 0;
528
- background: transparent;
529
- cursor: pointer;
530
- font: 600 12.5px var(--sa-font-body, system-ui, sans-serif);
531
- color: var(--sa-muted-dark, #475569);
532
- display: inline-flex;
533
- align-items: center;
534
- gap: 4px;
535
- padding: 0;
536
- }
537
- .pc-advanced {
538
- display: flex;
539
- flex-direction: column;
540
- gap: 12px;
541
- margin-top: 12px;
542
- }
543
-
544
- .pc-grid {
545
- display: grid;
546
- gap: 12px;
547
- margin-bottom: 12px;
548
- }
549
- .pc-grid--2 {
550
- grid-template-columns: 1fr 1fr;
551
- }
552
- @media (max-width: 600px) {
553
- .pc-grid--2 {
554
- grid-template-columns: 1fr;
555
- }
556
- }
557
- .pc-grid:last-child {
558
- margin-bottom: 0;
559
- }
560
-
561
- .pc-field {
562
- display: flex;
563
- flex-direction: column;
564
- gap: 4px;
565
- }
566
- .pc-field__label {
567
- font-size: 11.5px;
568
- font-weight: 600;
569
- color: var(--sa-muted, #64748b);
570
- text-transform: uppercase;
571
- letter-spacing: 0.04em;
572
- }
573
- .pc-field__hint {
574
- font-size: 11.5px;
575
- color: #94a3b8;
576
- }
577
-
578
- .pc-input {
579
- width: 100%;
580
- border: 1px solid var(--sa-border, #e2e8f0);
581
- background: #fff;
582
- border-radius: 7px;
583
- padding: 8px 10px;
584
- font: 13.5px var(--sa-font-body, system-ui, sans-serif);
585
- color: var(--sa-body, #1e293b);
586
- outline: 0;
587
- }
588
- .pc-input:focus {
589
- border-color: var(--sa-primary, #3f6bff);
590
- box-shadow: 0 0 0 3px rgba(63, 107, 255, 0.12);
591
- }
592
- .pc-input--code {
593
- font: 600 14px var(--sa-font-mono, ui-monospace, monospace);
594
- letter-spacing: 0.04em;
595
- text-transform: uppercase;
596
- }
597
- textarea.pc-input {
598
- font: 13.5px var(--sa-font-body, system-ui, sans-serif);
599
- resize: vertical;
600
- }
601
-
602
- .pc-code-input {
603
- display: flex;
604
- gap: 8px;
605
- align-items: center;
606
- }
607
- .pc-btn-mini {
608
- border: 1px solid var(--sa-border, #e2e8f0);
609
- background: #fff;
610
- border-radius: 7px;
611
- padding: 6px 10px;
612
- font: 500 12px var(--sa-font-body, system-ui, sans-serif);
613
- cursor: pointer;
614
- color: var(--sa-muted-dark, #475569);
615
- }
616
- .pc-btn-mini:hover {
617
- background: #f1f5f9;
618
- }
619
-
620
- .pc-type-grid {
621
- display: grid;
622
- grid-template-columns: 1fr 1fr;
623
- gap: 6px;
624
- }
625
- .pc-type-opt {
626
- border: 1px solid var(--sa-border, #e2e8f0);
627
- background: #fff;
628
- border-radius: 8px;
629
- padding: 8px 10px;
630
- text-align: left;
631
- cursor: pointer;
632
- transition:
633
- border-color 0.1s,
634
- background 0.1s;
635
- }
636
- .pc-type-opt:hover {
637
- border-color: #cbd5e1;
638
- }
639
- .pc-type-opt--active {
640
- border-color: var(--sa-primary, #3f6bff);
641
- background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
642
- }
643
- .pc-type-opt__label {
644
- font: 600 12.5px var(--sa-font-body, system-ui, sans-serif);
645
- color: var(--sa-heading, #0f172a);
646
- }
647
- .pc-type-opt__sub {
648
- font: 11.5px var(--sa-font-mono, ui-monospace, monospace);
649
- color: var(--sa-muted, #64748b);
650
- margin-top: 1px;
651
- }
652
-
653
- .pc-plan-pick {
654
- display: flex;
655
- flex-wrap: wrap;
656
- gap: 6px;
657
- }
658
- .pc-plan-opt {
659
- display: inline-flex;
660
- align-items: center;
661
- gap: 8px;
662
- background: #fff;
663
- border: 1px solid var(--sa-border, #e2e8f0);
664
- border-radius: 999px;
665
- padding: 5px 12px 5px 8px;
666
- font: 600 12px var(--sa-font-body, system-ui, sans-serif);
667
- cursor: pointer;
668
- color: var(--sa-muted-dark, #475569);
669
- transition:
670
- border-color 0.1s,
671
- background 0.1s;
672
- }
673
- .pc-plan-opt:hover {
674
- border-color: #cbd5e1;
675
- }
676
- .pc-plan-opt--on {
677
- border-color: var(--sa-primary, #3f6bff);
678
- background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
679
- color: var(--sa-primary, #3f6bff);
680
- }
681
- .pc-plan-opt__mark {
682
- width: 8px;
683
- height: 8px;
684
- border-radius: 50%;
685
- }
686
-
687
- .pc-dur {
688
- display: flex;
689
- gap: 4px;
690
- }
691
- .pc-dur-opt {
692
- flex: 1;
693
- border: 1px solid var(--sa-border, #e2e8f0);
694
- background: #fff;
695
- border-radius: 7px;
696
- padding: 6px 10px;
697
- font: 500 12px var(--sa-font-body, system-ui, sans-serif);
698
- cursor: pointer;
699
- color: var(--sa-muted-dark, #475569);
700
- }
701
- .pc-dur-opt--active {
702
- border-color: var(--sa-primary, #3f6bff);
703
- background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
704
- color: var(--sa-primary, #3f6bff);
705
- }
706
-
707
- .pc-check {
708
- display: inline-flex;
709
- align-items: center;
710
- gap: 8px;
711
- font: 13px var(--sa-font-body, system-ui, sans-serif);
712
- color: var(--sa-body, #1e293b);
713
- cursor: pointer;
714
- }
715
-
716
- .pc-preview {
717
- background: var(--sa-primary-soft, rgba(63, 107, 255, 0.06));
718
- border: 1px solid var(--sa-primary-border, rgba(63, 107, 255, 0.18));
719
- border-radius: 10px;
720
- padding: 12px 14px;
721
- }
722
- .pc-preview__eyebrow {
723
- font-size: 11px;
724
- font-weight: 700;
725
- color: var(--sa-primary, #3f6bff);
726
- text-transform: uppercase;
727
- letter-spacing: 0.06em;
728
- margin-bottom: 6px;
729
- }
730
- .pc-preview__body {
731
- display: flex;
732
- align-items: center;
733
- gap: 10px;
734
- flex-wrap: wrap;
735
- }
736
- .pc-preview__code {
737
- background: #fff;
738
- border: 1px solid var(--sa-border, #e2e8f0);
739
- border-radius: 6px;
740
- padding: 3px 8px;
741
- font: 600 13px var(--sa-font-mono, ui-monospace, monospace);
742
- letter-spacing: 0.04em;
743
- color: var(--sa-heading, #0f172a);
744
- }
745
- .pc-preview__disc {
746
- font: 700 13px var(--sa-font-body, system-ui, sans-serif);
747
- color: var(--sa-positive, #047857);
748
- }
749
- .pc-preview__meta {
750
- font-size: 12px;
751
- color: var(--sa-muted, #64748b);
752
- }
753
-
754
213
  .pc-error {
755
214
  background: #fef2f2;
756
215
  border: 1px solid #fecaca;