@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.
@@ -29,254 +29,14 @@
29
29
  </q-card-section>
30
30
 
31
31
  <q-card-section class="pc-dlg__body">
32
- <!-- Section: Code & discount -->
33
- <div class="pc-section">
34
- <div class="pc-section__title">{{ msg.form.sectionCodeDiscount }}</div>
35
- <div class="pc-grid pc-grid--2">
36
- <div class="pc-field">
37
- <div class="pc-field__label">{{ msg.form.codeLabel }}</div>
38
- <input
39
- :value="row?.code ?? ''"
40
- class="pc-input pc-input--code"
41
- disabled
42
- />
43
- <div class="pc-field__hint">{{ msg.form.codeStableHint }}</div>
44
- </div>
45
-
46
- <div class="pc-field">
47
- <div class="pc-field__label">{{ msg.form.valueTypeLabel }}</div>
48
- <div class="pc-type-grid">
49
- <button
50
- v-for="o in typeOptions"
51
- :key="o.k"
52
- type="button"
53
- class="pc-type-opt"
54
- :class="{ 'pc-type-opt--active': form.valueType === o.k }"
55
- @click="form.valueType = o.k"
56
- >
57
- <div class="pc-type-opt__label">{{ o.label }}</div>
58
- <div class="pc-type-opt__sub">{{ o.sub }}</div>
59
- </button>
60
- </div>
61
- </div>
62
- </div>
63
-
64
- <div class="pc-field" style="max-width: 280px">
65
- <div class="pc-field__label">
66
- {{
67
- form.valueType === 'PERCENT'
68
- ? msg.form.valuePercentLabel
69
- : msg.form.valueAbsoluteLabel
70
- }}
71
- </div>
72
- <input
73
- v-model.number="form.value"
74
- class="pc-input"
75
- type="number"
76
- min="1"
77
- :max="form.valueType === 'PERCENT' ? 100 : undefined"
78
- />
79
- </div>
80
- </div>
81
-
82
- <!-- Section: Validity & duration -->
83
- <div class="pc-section">
84
- <div class="pc-section__title">{{ msg.form.sectionValidity }}</div>
85
-
86
- <div v-if="plans.length > 0" class="pc-field">
87
- <div class="pc-field__label">{{ msg.form.plansLabel }}</div>
88
- <div class="pc-plan-pick">
89
- <button
90
- v-for="p in plans"
91
- :key="p.key"
92
- type="button"
93
- class="pc-plan-opt"
94
- :class="{ 'pc-plan-opt--on': isPlanSelected(p.key) }"
95
- :style="planChipStyle(p)"
96
- @click="togglePlan(p.key)"
97
- >
98
- <span
99
- class="pc-plan-opt__mark"
100
- :style="{ background: p.color ?? '#64748b' }"
101
- />
102
- {{ p.label }}
103
- </button>
104
- </div>
105
- <div class="pc-field__hint">
106
- {{
107
- formatMessage(msg.form.plansHint, {
108
- count: form.appliesToPlans.length,
109
- })
110
- }}
111
- </div>
112
- </div>
113
-
114
- <div class="pc-grid pc-grid--2">
115
- <div class="pc-field">
116
- <div class="pc-field__label">{{ msg.form.durationLabel }}</div>
117
- <div class="pc-dur">
118
- <button
119
- v-for="o in durationOptions"
120
- :key="o.k"
121
- type="button"
122
- class="pc-dur-opt"
123
- :class="{ 'pc-dur-opt--active': form.durationType === o.k }"
124
- @click="form.durationType = o.k"
125
- >
126
- {{ o.label }}
127
- </button>
128
- </div>
129
- <input
130
- v-if="form.durationType !== 'ONCE'"
131
- v-model.number="form.durationValue"
132
- class="pc-input"
133
- type="number"
134
- min="1"
135
- style="margin-top: 8px; max-width: 120px"
136
- :placeholder="
137
- form.durationType === 'MONTHS'
138
- ? msg.form.durationMonthsPlaceholder
139
- : msg.form.durationCyclesPlaceholder
140
- "
141
- />
142
- </div>
143
-
144
- <div class="pc-field">
145
- <div class="pc-field__label">{{ msg.form.maxRedemptionsLabel }}</div>
146
- <input
147
- v-model.number="form.maxRedemptions"
148
- class="pc-input"
149
- type="number"
150
- min="1"
151
- :placeholder="msg.form.maxRedemptionsPlaceholder"
152
- />
153
- <div class="pc-field__hint">
154
- {{ msg.form.maxRedemptionsHintEdit }}
155
- </div>
156
- </div>
157
- </div>
158
-
159
- <div class="pc-grid pc-grid--2">
160
- <div class="pc-field">
161
- <div class="pc-field__label">{{ msg.form.validFromLabel }}</div>
162
- <input v-model="form.validFrom" class="pc-input" type="date" />
163
- </div>
164
- <div class="pc-field">
165
- <div class="pc-field__label">{{ msg.form.validUntilLabel }}</div>
166
- <input v-model="form.validUntil" class="pc-input" type="date" />
167
- </div>
168
- </div>
169
-
170
- <div class="pc-grid pc-grid--2">
171
- <div class="pc-field">
172
- <div class="pc-field__label">{{ common.status }}</div>
173
- <div class="pc-status">
174
- <button
175
- v-for="o in statusOptions"
176
- :key="o.k"
177
- type="button"
178
- class="pc-status-opt"
179
- :class="{
180
- 'pc-status-opt--active': form.status === o.k,
181
- }"
182
- @click="form.status = o.k"
183
- >
184
- <q-icon :name="o.icon" size="14px" />
185
- {{ o.label }}
186
- </button>
187
- </div>
188
- <div class="pc-field__hint">{{ msg.form.statusHint }}</div>
189
- </div>
190
- </div>
191
- </div>
192
-
193
- <!-- Section: Campaign & note -->
194
- <div class="pc-section">
195
- <div class="pc-section__title">{{ msg.form.sectionCampaign }}</div>
196
- <div v-if="showCampaignTag" class="pc-field">
197
- <div class="pc-field__label">{{ msg.form.campaignLabel }}</div>
198
- <input
199
- v-model="form.campaignTag"
200
- class="pc-input"
201
- :placeholder="msg.form.campaignPlaceholder"
202
- />
203
- <div class="pc-field__hint">{{ msg.form.campaignHint }}</div>
204
- </div>
205
- <div class="pc-field">
206
- <div class="pc-field__label">{{ msg.form.noteLabel }}</div>
207
- <textarea
208
- v-model="form.description"
209
- class="pc-input"
210
- rows="2"
211
- :placeholder="msg.form.notePlaceholder"
212
- />
213
- </div>
214
- </div>
215
-
216
- <!-- Section: Advanced -->
217
- <div class="pc-section">
218
- <button
219
- type="button"
220
- class="pc-section__toggle"
221
- @click="advancedOpen = !advancedOpen"
222
- >
223
- <q-icon
224
- :name="advancedOpen ? 'expand_more' : 'chevron_right'"
225
- size="16px"
226
- />
227
- {{ msg.form.advancedToggle }}
228
- </button>
229
- <div v-if="advancedOpen" class="pc-advanced">
230
- <div class="pc-grid pc-grid--2">
231
- <div class="pc-field">
232
- <div class="pc-field__label">{{ msg.form.billingCycleLabel }}</div>
233
- <select v-model="form.appliesToBilling" class="pc-input">
234
- <option :value="null">{{ common.both }}</option>
235
- <option value="MONTHLY">{{ common.monthly }}</option>
236
- <option value="YEARLY">{{ common.yearly }}</option>
237
- </select>
238
- </div>
239
- <div class="pc-field">
240
- <div class="pc-field__label">{{ msg.form.minAmountLabel }}</div>
241
- <input
242
- v-model.number="form.minimumPlanAmountGross"
243
- class="pc-input"
244
- type="number"
245
- min="0"
246
- :placeholder="msg.form.minAmountPlaceholder"
247
- />
248
- </div>
249
- </div>
250
- <div class="pc-grid pc-grid--2">
251
- <label class="pc-check">
252
- <input v-model="form.firstTimeCustomersOnly" type="checkbox" />
253
- <span>{{ msg.form.firstTimeOnly }}</span>
254
- </label>
255
- <label class="pc-check">
256
- <input v-model="form.allowZeroInvoice" type="checkbox" />
257
- <span>{{ msg.form.allowZeroInvoice }}</span>
258
- </label>
259
- </div>
260
- <div class="pc-field">
261
- <div class="pc-field__label">{{ msg.form.revenueAccountLabel }}</div>
262
- <input
263
- v-model="form.revenueDeductionAccount"
264
- class="pc-input"
265
- :placeholder="msg.form.revenueAccountPlaceholder"
266
- />
267
- </div>
268
- </div>
269
- </div>
270
-
271
- <!-- Live preview -->
272
- <div class="pc-preview">
273
- <div class="pc-preview__eyebrow">{{ msg.form.previewEyebrow }}</div>
274
- <div class="pc-preview__body">
275
- <code class="pc-preview__code">{{ row?.code || 'CODE' }}</code>
276
- <span class="pc-preview__disc">{{ previewValue }}</span>
277
- <span class="pc-preview__meta">{{ previewMeta }}</span>
278
- </div>
279
- </div>
32
+ <PromoCodeDialogFields
33
+ mode="edit"
34
+ :form="form"
35
+ :code="row?.code ?? ''"
36
+ v-model:advanced-open="advancedOpen"
37
+ :show-campaign-tag="showCampaignTag"
38
+ :plans="plans"
39
+ />
280
40
 
281
41
  <p v-if="error" class="pc-error">{{ error }}</p>
282
42
  </q-card-section>
@@ -298,6 +58,7 @@
298
58
 
299
59
  <script setup lang="ts">
300
60
  import { computed, reactive, ref, watch } from 'vue';
61
+ import PromoCodeDialogFields from './PromoCodeDialogFields.vue';
301
62
  import { formatMessage } from '../../client/i18n/format.js';
302
63
  import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
303
64
  import type {
@@ -358,34 +119,6 @@ const emit = defineEmits<{
358
119
  const msg = useSaMessages('promos');
359
120
  const common = useSaMessages('common');
360
121
 
361
- const statusOptions = computed<
362
- ReadonlyArray<{ k: 'ACTIVE' | 'PAUSED'; label: string; icon: string }>
363
- >(() => [
364
- { k: 'ACTIVE', label: common.value.active, icon: 'play_arrow' },
365
- { k: 'PAUSED', label: msg.value.form.statusPaused, icon: 'pause' },
366
- ]);
367
-
368
- const typeOptions = computed<ReadonlyArray<{ k: PromoCodeValueType; label: string; sub: string }>>(
369
- () => [
370
- {
371
- k: 'PERCENT',
372
- label: msg.value.form.valueTypePercent,
373
- sub: msg.value.form.valueTypePercentSub,
374
- },
375
- {
376
- k: 'ABSOLUTE',
377
- label: msg.value.form.valueTypeAbsolute,
378
- sub: msg.value.form.valueTypeAbsoluteSub,
379
- },
380
- ],
381
- );
382
-
383
- const durationOptions = computed<ReadonlyArray<{ k: PromoCodeDurationType; label: string }>>(() => [
384
- { k: 'ONCE', label: msg.value.form.durationOnce },
385
- { k: 'MONTHS', label: msg.value.form.durationMonths },
386
- { k: 'BILLING_CYCLES', label: msg.value.form.durationBillingCycles },
387
- ]);
388
-
389
122
  interface EditForm {
390
123
  status: 'ACTIVE' | 'PAUSED';
391
124
  valueType: PromoCodeValueType;
@@ -453,49 +186,6 @@ const loading = ref(false);
453
186
  const error = ref('');
454
187
  const advancedOpen = ref(false);
455
188
 
456
- function isPlanSelected(key: string): boolean {
457
- return form.appliesToPlans.includes(key);
458
- }
459
- function togglePlan(key: string): void {
460
- if (form.appliesToPlans.includes(key)) {
461
- form.appliesToPlans = form.appliesToPlans.filter((k) => k !== key);
462
- } else {
463
- form.appliesToPlans = [...form.appliesToPlans, key];
464
- }
465
- }
466
- function planChipStyle(p: PromoCodePlanOption): Record<string, string> {
467
- if (!isPlanSelected(p.key) || !p.color) return {};
468
- return {
469
- borderColor: p.color,
470
- background: `${p.color}12`,
471
- color: p.color,
472
- };
473
- }
474
-
475
- const previewValue = computed(() => {
476
- if (form.valueType === 'PERCENT') return `−${form.value || 0}%`;
477
- return `−${form.value || 0} €`;
478
- });
479
-
480
- const previewMeta = computed(() => {
481
- const parts: string[] = [];
482
- const count = form.durationValue || 0;
483
- if (form.durationType === 'ONCE') parts.push(msg.value.form.previewOnce);
484
- else if (form.durationType === 'MONTHS')
485
- parts.push(formatMessage(msg.value.form.previewMonths, { count }));
486
- else parts.push(formatMessage(msg.value.form.previewCycles, { count }));
487
- parts.push(
488
- form.appliesToPlans.length > 0
489
- ? form.appliesToPlans.join(', ')
490
- : props.plans.length > 0
491
- ? msg.value.form.previewAllPlans
492
- : msg.value.form.previewNoPlanFilter,
493
- );
494
- if (form.maxRedemptions)
495
- parts.push(formatMessage(msg.value.form.previewMax, { count: form.maxRedemptions }));
496
- return parts.join(' · ');
497
- });
498
-
499
189
  const isValid = computed(() => {
500
190
  if (!form.value || form.value <= 0) return false;
501
191
  if (form.valueType === 'PERCENT' && form.value > 100) return false;
@@ -598,287 +288,42 @@ async function onSubmit() {
598
288
  min-width: 720px;
599
289
  max-width: 96vw;
600
290
  }
291
+
601
292
  .pc-dlg__head {
602
293
  display: flex;
603
294
  align-items: flex-start;
604
295
  gap: 16px;
605
296
  padding-bottom: 8px;
606
297
  }
298
+
607
299
  .pc-dlg__close {
608
300
  margin-left: auto;
609
301
  }
302
+
610
303
  .pc-dlg__title {
611
304
  font-family: var(--sa-font-head, system-ui, sans-serif);
612
305
  font-weight: 700;
613
306
  font-size: 18px;
614
307
  color: var(--sa-heading, #0f172a);
615
308
  }
309
+
616
310
  .pc-dlg__sub {
617
311
  font-size: 12.5px;
618
312
  color: var(--sa-muted, #64748b);
619
313
  margin-top: 2px;
620
314
  }
315
+
621
316
  .pc-dlg__body {
622
317
  display: flex;
623
318
  flex-direction: column;
624
319
  gap: 16px;
625
320
  padding-top: 4px;
626
321
  }
322
+
627
323
  .pc-dlg__foot {
628
324
  border-top: 1px solid var(--sa-border, #e2e8f0);
629
325
  }
630
326
 
631
- .pc-section {
632
- border: 1px solid var(--sa-border, #e2e8f0);
633
- border-radius: 10px;
634
- padding: 14px 16px;
635
- background: #fafbfc;
636
- }
637
- .pc-section__title {
638
- font-family: var(--sa-font-head, system-ui, sans-serif);
639
- font-weight: 700;
640
- font-size: 13px;
641
- color: var(--sa-heading, #0f172a);
642
- margin-bottom: 12px;
643
- letter-spacing: -0.005em;
644
- }
645
- .pc-section__toggle {
646
- border: 0;
647
- background: transparent;
648
- cursor: pointer;
649
- font: 600 12.5px var(--sa-font-body, system-ui, sans-serif);
650
- color: var(--sa-muted-dark, #475569);
651
- display: inline-flex;
652
- align-items: center;
653
- gap: 4px;
654
- padding: 0;
655
- }
656
- .pc-advanced {
657
- display: flex;
658
- flex-direction: column;
659
- gap: 12px;
660
- margin-top: 12px;
661
- }
662
-
663
- .pc-grid {
664
- display: grid;
665
- gap: 12px;
666
- margin-bottom: 12px;
667
- }
668
- .pc-grid--2 {
669
- grid-template-columns: 1fr 1fr;
670
- }
671
- @media (max-width: 600px) {
672
- .pc-grid--2 {
673
- grid-template-columns: 1fr;
674
- }
675
- }
676
- .pc-grid:last-child {
677
- margin-bottom: 0;
678
- }
679
-
680
- .pc-field {
681
- display: flex;
682
- flex-direction: column;
683
- gap: 4px;
684
- }
685
- .pc-field__label {
686
- font-size: 11.5px;
687
- font-weight: 600;
688
- color: var(--sa-muted, #64748b);
689
- text-transform: uppercase;
690
- letter-spacing: 0.04em;
691
- }
692
- .pc-field__hint {
693
- font-size: 11.5px;
694
- color: #94a3b8;
695
- }
696
-
697
- .pc-input {
698
- width: 100%;
699
- border: 1px solid var(--sa-border, #e2e8f0);
700
- background: #fff;
701
- border-radius: 7px;
702
- padding: 8px 10px;
703
- font: 13.5px var(--sa-font-body, system-ui, sans-serif);
704
- color: var(--sa-body, #1e293b);
705
- outline: 0;
706
- }
707
- .pc-input:disabled {
708
- background: #f1f5f9;
709
- color: var(--sa-muted, #64748b);
710
- cursor: not-allowed;
711
- }
712
- .pc-input:focus {
713
- border-color: var(--sa-primary, #3f6bff);
714
- box-shadow: 0 0 0 3px rgba(63, 107, 255, 0.12);
715
- }
716
- .pc-input--code {
717
- font: 600 14px var(--sa-font-mono, ui-monospace, monospace);
718
- letter-spacing: 0.04em;
719
- text-transform: uppercase;
720
- }
721
- textarea.pc-input {
722
- font: 13.5px var(--sa-font-body, system-ui, sans-serif);
723
- resize: vertical;
724
- }
725
-
726
- .pc-status {
727
- display: flex;
728
- gap: 6px;
729
- }
730
- .pc-status-opt {
731
- display: inline-flex;
732
- align-items: center;
733
- gap: 6px;
734
- border: 1px solid var(--sa-border, #e2e8f0);
735
- background: #fff;
736
- border-radius: 7px;
737
- padding: 6px 12px;
738
- font: 500 12.5px var(--sa-font-body, system-ui, sans-serif);
739
- color: var(--sa-muted-dark, #475569);
740
- cursor: pointer;
741
- }
742
- .pc-status-opt--active {
743
- border-color: var(--sa-primary, #3f6bff);
744
- background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
745
- color: var(--sa-primary, #3f6bff);
746
- }
747
-
748
- .pc-type-grid {
749
- display: grid;
750
- grid-template-columns: 1fr 1fr;
751
- gap: 6px;
752
- }
753
- .pc-type-opt {
754
- border: 1px solid var(--sa-border, #e2e8f0);
755
- background: #fff;
756
- border-radius: 8px;
757
- padding: 8px 10px;
758
- text-align: left;
759
- cursor: pointer;
760
- transition:
761
- border-color 0.1s,
762
- background 0.1s;
763
- }
764
- .pc-type-opt:hover {
765
- border-color: #cbd5e1;
766
- }
767
- .pc-type-opt--active {
768
- border-color: var(--sa-primary, #3f6bff);
769
- background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
770
- }
771
- .pc-type-opt__label {
772
- font: 600 12.5px var(--sa-font-body, system-ui, sans-serif);
773
- color: var(--sa-heading, #0f172a);
774
- }
775
- .pc-type-opt__sub {
776
- font: 11.5px var(--sa-font-mono, ui-monospace, monospace);
777
- color: var(--sa-muted, #64748b);
778
- margin-top: 1px;
779
- }
780
-
781
- .pc-plan-pick {
782
- display: flex;
783
- flex-wrap: wrap;
784
- gap: 6px;
785
- }
786
- .pc-plan-opt {
787
- display: inline-flex;
788
- align-items: center;
789
- gap: 8px;
790
- background: #fff;
791
- border: 1px solid var(--sa-border, #e2e8f0);
792
- border-radius: 999px;
793
- padding: 5px 12px 5px 8px;
794
- font: 600 12px var(--sa-font-body, system-ui, sans-serif);
795
- cursor: pointer;
796
- color: var(--sa-muted-dark, #475569);
797
- transition:
798
- border-color 0.1s,
799
- background 0.1s;
800
- }
801
- .pc-plan-opt:hover {
802
- border-color: #cbd5e1;
803
- }
804
- .pc-plan-opt--on {
805
- border-color: var(--sa-primary, #3f6bff);
806
- background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
807
- color: var(--sa-primary, #3f6bff);
808
- }
809
- .pc-plan-opt__mark {
810
- width: 8px;
811
- height: 8px;
812
- border-radius: 50%;
813
- }
814
-
815
- .pc-dur {
816
- display: flex;
817
- gap: 4px;
818
- }
819
- .pc-dur-opt {
820
- flex: 1;
821
- border: 1px solid var(--sa-border, #e2e8f0);
822
- background: #fff;
823
- border-radius: 7px;
824
- padding: 6px 10px;
825
- font: 500 12px var(--sa-font-body, system-ui, sans-serif);
826
- cursor: pointer;
827
- color: var(--sa-muted-dark, #475569);
828
- }
829
- .pc-dur-opt--active {
830
- border-color: var(--sa-primary, #3f6bff);
831
- background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
832
- color: var(--sa-primary, #3f6bff);
833
- }
834
-
835
- .pc-check {
836
- display: inline-flex;
837
- align-items: center;
838
- gap: 8px;
839
- font: 13px var(--sa-font-body, system-ui, sans-serif);
840
- color: var(--sa-body, #1e293b);
841
- cursor: pointer;
842
- }
843
-
844
- .pc-preview {
845
- background: var(--sa-primary-soft, rgba(63, 107, 255, 0.06));
846
- border: 1px solid var(--sa-primary-border, rgba(63, 107, 255, 0.18));
847
- border-radius: 10px;
848
- padding: 12px 14px;
849
- }
850
- .pc-preview__eyebrow {
851
- font-size: 11px;
852
- font-weight: 700;
853
- color: var(--sa-primary, #3f6bff);
854
- text-transform: uppercase;
855
- letter-spacing: 0.06em;
856
- margin-bottom: 6px;
857
- }
858
- .pc-preview__body {
859
- display: flex;
860
- align-items: center;
861
- gap: 10px;
862
- flex-wrap: wrap;
863
- }
864
- .pc-preview__code {
865
- background: #fff;
866
- border: 1px solid var(--sa-border, #e2e8f0);
867
- border-radius: 6px;
868
- padding: 3px 8px;
869
- font: 600 13px var(--sa-font-mono, ui-monospace, monospace);
870
- letter-spacing: 0.04em;
871
- color: var(--sa-heading, #0f172a);
872
- }
873
- .pc-preview__disc {
874
- font: 700 13px var(--sa-font-body, system-ui, sans-serif);
875
- color: var(--sa-positive, #047857);
876
- }
877
- .pc-preview__meta {
878
- font-size: 12px;
879
- color: var(--sa-muted, #64748b);
880
- }
881
-
882
327
  .pc-error {
883
328
  background: #fef2f2;
884
329
  border: 1px solid #fecaca;
@@ -888,4 +333,11 @@ textarea.pc-input {
888
333
  padding: 8px 12px;
889
334
  border-radius: 8px;
890
335
  }
336
+
337
+ /* The code cannot be changed after creation. */
338
+ .pc-input:disabled {
339
+ background: #f8fafc;
340
+ color: #64748b;
341
+ cursor: not-allowed;
342
+ }
891
343
  </style>