@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.
@@ -0,0 +1,666 @@
1
+ <template>
2
+ <!-- Section: Code & Discount -->
3
+ <div class="pc-section">
4
+ <div class="pc-section__title">{{ msg.form.sectionCodeDiscount }}</div>
5
+ <div class="pc-grid pc-grid--2">
6
+ <div class="pc-field">
7
+ <div class="pc-field__label">{{ msg.form.codeLabel }}</div>
8
+ <div v-if="mode === 'create'" class="pc-code-input">
9
+ <input
10
+ :value="code"
11
+ class="pc-input pc-input--code"
12
+ :placeholder="msg.form.codePlaceholder"
13
+ @input="onCodeInput"
14
+ />
15
+ <button type="button" class="pc-btn-mini" @click="onRandomCode">
16
+ {{ msg.form.codeRandom }}
17
+ </button>
18
+ </div>
19
+ <input v-else :value="code" class="pc-input pc-input--code" disabled />
20
+ <div class="pc-field__hint">
21
+ {{ mode === 'create' ? msg.form.codeHint : msg.form.codeStableHint }}
22
+ </div>
23
+ </div>
24
+
25
+ <div class="pc-field">
26
+ <div class="pc-field__label">{{ msg.form.valueTypeLabel }}</div>
27
+ <div class="pc-type-grid">
28
+ <button
29
+ v-for="o in typeOptions"
30
+ :key="o.k"
31
+ type="button"
32
+ class="pc-type-opt"
33
+ :class="{ 'pc-type-opt--active': form.valueType === o.k }"
34
+ @click="form.valueType = o.k"
35
+ >
36
+ <div class="pc-type-opt__label">{{ o.label }}</div>
37
+ <div class="pc-type-opt__sub">{{ o.sub }}</div>
38
+ </button>
39
+ </div>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="pc-field" style="max-width: 280px">
44
+ <div class="pc-field__label">
45
+ {{
46
+ form.valueType === 'PERCENT'
47
+ ? msg.form.valuePercentLabel
48
+ : msg.form.valueAbsoluteLabel
49
+ }}
50
+ </div>
51
+ <input
52
+ v-model.number="form.value"
53
+ class="pc-input"
54
+ type="number"
55
+ min="1"
56
+ :max="form.valueType === 'PERCENT' ? 100 : undefined"
57
+ />
58
+ </div>
59
+ </div>
60
+
61
+ <!-- Section: Validity & Duration -->
62
+ <div class="pc-section">
63
+ <div class="pc-section__title">{{ msg.form.sectionValidity }}</div>
64
+
65
+ <div v-if="plans.length > 0" class="pc-field">
66
+ <div class="pc-field__label">{{ msg.form.plansLabel }}</div>
67
+ <div class="pc-plan-pick">
68
+ <button
69
+ v-for="p in plans"
70
+ :key="p.key"
71
+ type="button"
72
+ class="pc-plan-opt"
73
+ :class="{ 'pc-plan-opt--on': isPlanSelected(p.key) }"
74
+ :style="planChipStyle(p)"
75
+ @click="togglePlan(p.key)"
76
+ >
77
+ <span class="pc-plan-opt__mark" :style="{ background: p.color ?? '#64748b' }" />
78
+ {{ p.label }}
79
+ </button>
80
+ </div>
81
+ <div class="pc-field__hint">
82
+ {{
83
+ formatMessage(msg.form.plansHint, {
84
+ count: form.appliesToPlans.length,
85
+ })
86
+ }}
87
+ </div>
88
+ </div>
89
+
90
+ <div class="pc-grid pc-grid--2">
91
+ <div class="pc-field">
92
+ <div class="pc-field__label">{{ msg.form.durationLabel }}</div>
93
+ <div class="pc-dur">
94
+ <button
95
+ v-for="o in durationOptions"
96
+ :key="o.k"
97
+ type="button"
98
+ class="pc-dur-opt"
99
+ :class="{ 'pc-dur-opt--active': form.durationType === o.k }"
100
+ @click="form.durationType = o.k"
101
+ >
102
+ {{ o.label }}
103
+ </button>
104
+ </div>
105
+ <input
106
+ v-if="form.durationType !== 'ONCE'"
107
+ v-model.number="form.durationValue"
108
+ class="pc-input"
109
+ type="number"
110
+ min="1"
111
+ style="margin-top: 8px; max-width: 120px"
112
+ :placeholder="
113
+ form.durationType === 'MONTHS'
114
+ ? msg.form.durationMonthsPlaceholder
115
+ : msg.form.durationCyclesPlaceholder
116
+ "
117
+ />
118
+ </div>
119
+
120
+ <div class="pc-field">
121
+ <div class="pc-field__label">{{ msg.form.maxRedemptionsLabel }}</div>
122
+ <input
123
+ v-model.number="form.maxRedemptions"
124
+ class="pc-input"
125
+ type="number"
126
+ min="1"
127
+ :placeholder="msg.form.maxRedemptionsPlaceholder"
128
+ />
129
+ <div class="pc-field__hint">
130
+ {{
131
+ mode === 'create'
132
+ ? msg.form.maxRedemptionsHintCreate
133
+ : msg.form.maxRedemptionsHintEdit
134
+ }}
135
+ </div>
136
+ </div>
137
+ </div>
138
+
139
+ <div class="pc-grid pc-grid--2">
140
+ <div class="pc-field">
141
+ <div class="pc-field__label">{{ msg.form.validFromLabel }}</div>
142
+ <input v-model="form.validFrom" class="pc-input" type="date" />
143
+ </div>
144
+ <div class="pc-field">
145
+ <div class="pc-field__label">{{ msg.form.validUntilLabel }}</div>
146
+ <input v-model="form.validUntil" class="pc-input" type="date" />
147
+ </div>
148
+ </div>
149
+
150
+ <div v-if="mode === 'edit'" class="pc-grid pc-grid--2">
151
+ <div class="pc-field">
152
+ <div class="pc-field__label">{{ common.status }}</div>
153
+ <div class="pc-status">
154
+ <button
155
+ v-for="o in statusOptions"
156
+ :key="o.k"
157
+ type="button"
158
+ class="pc-status-opt"
159
+ :class="{ 'pc-status-opt--active': form.status === o.k }"
160
+ @click="form.status = o.k"
161
+ >
162
+ <q-icon :name="o.icon" size="14px" />
163
+ {{ o.label }}
164
+ </button>
165
+ </div>
166
+ <div class="pc-field__hint">{{ msg.form.statusHint }}</div>
167
+ </div>
168
+ </div>
169
+ </div>
170
+
171
+ <!-- Section: Campaign & Note -->
172
+ <div class="pc-section">
173
+ <div class="pc-section__title">{{ msg.form.sectionCampaign }}</div>
174
+ <div v-if="showCampaignTag" class="pc-field">
175
+ <div class="pc-field__label">{{ msg.form.campaignLabel }}</div>
176
+ <input
177
+ v-model="form.campaignTag"
178
+ class="pc-input"
179
+ :placeholder="msg.form.campaignPlaceholder"
180
+ />
181
+ <div class="pc-field__hint">{{ msg.form.campaignHint }}</div>
182
+ </div>
183
+ <div class="pc-field">
184
+ <div class="pc-field__label">{{ msg.form.noteLabel }}</div>
185
+ <textarea
186
+ v-model="form.description"
187
+ class="pc-input"
188
+ rows="2"
189
+ :placeholder="msg.form.notePlaceholder"
190
+ />
191
+ </div>
192
+ </div>
193
+
194
+ <!-- Section: Advanced (backend-only fields, collapsed) -->
195
+ <div class="pc-section">
196
+ <button type="button" class="pc-section__toggle" @click="advancedOpen = !advancedOpen">
197
+ <q-icon :name="advancedOpen ? 'expand_more' : 'chevron_right'" size="16px" />
198
+ {{ msg.form.advancedToggle }}
199
+ </button>
200
+ <div v-if="advancedOpen" class="pc-advanced">
201
+ <div class="pc-grid pc-grid--2">
202
+ <div class="pc-field">
203
+ <div class="pc-field__label">{{ msg.form.billingCycleLabel }}</div>
204
+ <select v-model="form.appliesToBilling" class="pc-input">
205
+ <option :value="mode === 'create' ? undefined : null">
206
+ {{ common.both }}
207
+ </option>
208
+ <option value="MONTHLY">{{ common.monthly }}</option>
209
+ <option value="YEARLY">{{ common.yearly }}</option>
210
+ </select>
211
+ </div>
212
+ <div class="pc-field">
213
+ <div class="pc-field__label">{{ msg.form.minAmountLabel }}</div>
214
+ <input
215
+ v-model.number="form.minimumPlanAmountGross"
216
+ class="pc-input"
217
+ type="number"
218
+ min="0"
219
+ :placeholder="msg.form.minAmountPlaceholder"
220
+ />
221
+ </div>
222
+ </div>
223
+ <div class="pc-grid pc-grid--2">
224
+ <label class="pc-check">
225
+ <input v-model="form.firstTimeCustomersOnly" type="checkbox" />
226
+ <span>{{ msg.form.firstTimeOnly }}</span>
227
+ </label>
228
+ <label class="pc-check">
229
+ <input v-model="form.allowZeroInvoice" type="checkbox" />
230
+ <span>{{ msg.form.allowZeroInvoice }}</span>
231
+ </label>
232
+ </div>
233
+ <div class="pc-field">
234
+ <div class="pc-field__label">{{ msg.form.revenueAccountLabel }}</div>
235
+ <input
236
+ v-model="form.revenueDeductionAccount"
237
+ class="pc-input"
238
+ :placeholder="msg.form.revenueAccountPlaceholder"
239
+ />
240
+ </div>
241
+ </div>
242
+ </div>
243
+
244
+ <!-- Live preview -->
245
+ <div class="pc-preview">
246
+ <div class="pc-preview__eyebrow">{{ msg.form.previewEyebrow }}</div>
247
+ <div class="pc-preview__body">
248
+ <code class="pc-preview__code">{{ code || 'CODE' }}</code>
249
+ <span class="pc-preview__disc">{{ previewValue }}</span>
250
+ <span class="pc-preview__meta">{{ previewMeta }}</span>
251
+ </div>
252
+ </div>
253
+ </template>
254
+
255
+ <script setup lang="ts">
256
+ import { computed } from 'vue';
257
+ import { formatMessage } from '../../client/i18n/format.js';
258
+ import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
259
+ import type { PromoCodeDurationType, PromoCodePlanOption, PromoCodeValueType } from './types.js';
260
+
261
+ // The form body shared by both promo-code dialogs.
262
+ //
263
+ // Create and edit show the same sections and differ in a handful of
264
+ // enumerable points: the code is only editable on create, the status only on
265
+ // edit, two hint texts swap, and an empty billing cycle is `undefined` on
266
+ // create (omit the field) but `null` on edit (clear the field). Hence a `mode`
267
+ // rather than slots: slot content carries the parent's scope, so the shared
268
+ // `.pc-*` rules would never reach it.
269
+ //
270
+ // This component is internal to the two dialogs and not part of the public
271
+ // surface.
272
+
273
+ /** The fields both dialogs share. `status` exists on edit only. */
274
+ export interface PromoCodeSharedForm {
275
+ valueType: PromoCodeValueType;
276
+ value: number;
277
+ durationType: PromoCodeDurationType;
278
+ durationValue: number | null;
279
+ maxRedemptions: number | null;
280
+ validFrom: string;
281
+ validUntil: string;
282
+ appliesToPlans: string[];
283
+ appliesToBilling?: 'MONTHLY' | 'YEARLY' | null;
284
+ firstTimeCustomersOnly: boolean;
285
+ minimumPlanAmountGross: number | null;
286
+ allowZeroInvoice: boolean;
287
+ campaignTag: string;
288
+ revenueDeductionAccount: string;
289
+ description: string;
290
+ status?: 'ACTIVE' | 'PAUSED';
291
+ }
292
+
293
+ const props = withDefaults(
294
+ defineProps<{
295
+ mode: 'create' | 'edit';
296
+ form: PromoCodeSharedForm;
297
+ /** Editable on create, display-only on edit. */
298
+ code: string;
299
+ showCampaignTag?: boolean;
300
+ plans?: readonly PromoCodePlanOption[];
301
+ }>(),
302
+ { showCampaignTag: true, plans: () => [] },
303
+ );
304
+
305
+ const emit = defineEmits<{ (e: 'update:code', v: string): void }>();
306
+
307
+ const msg = useSaMessages('promos');
308
+ const common = useSaMessages('common');
309
+
310
+ const typeOptions = computed<ReadonlyArray<{ k: PromoCodeValueType; label: string; sub: string }>>(
311
+ () => [
312
+ {
313
+ k: 'PERCENT',
314
+ label: msg.value.form.valueTypePercent,
315
+ sub: msg.value.form.valueTypePercentSub,
316
+ },
317
+ {
318
+ k: 'ABSOLUTE',
319
+ label: msg.value.form.valueTypeAbsolute,
320
+ sub: msg.value.form.valueTypeAbsoluteSub,
321
+ },
322
+ ],
323
+ );
324
+
325
+ const durationOptions = computed<ReadonlyArray<{ k: PromoCodeDurationType; label: string }>>(() => [
326
+ { k: 'ONCE', label: msg.value.form.durationOnce },
327
+ { k: 'MONTHS', label: msg.value.form.durationMonths },
328
+ { k: 'BILLING_CYCLES', label: msg.value.form.durationBillingCycles },
329
+ ]);
330
+
331
+ const statusOptions = computed<
332
+ ReadonlyArray<{ k: 'ACTIVE' | 'PAUSED'; label: string; icon: string }>
333
+ >(() => [
334
+ { k: 'ACTIVE', label: common.value.active, icon: 'play_arrow' },
335
+ { k: 'PAUSED', label: msg.value.form.statusPaused, icon: 'pause' },
336
+ ]);
337
+
338
+ /** The code round-trips through the caller so its form stays the source of truth. */
339
+ function onCodeInput(event: Event): void {
340
+ const raw = (event.target as HTMLInputElement).value;
341
+ emit('update:code', raw.toUpperCase().replace(/[^A-Z0-9_-]/g, ''));
342
+ }
343
+
344
+ function onRandomCode(): void {
345
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
346
+ let s = '';
347
+ for (let i = 0; i < 8; i++) s += chars[Math.floor(Math.random() * chars.length)];
348
+ emit('update:code', s);
349
+ }
350
+
351
+ function isPlanSelected(key: string): boolean {
352
+ return props.form.appliesToPlans.includes(key);
353
+ }
354
+
355
+ function togglePlan(key: string): void {
356
+ if (props.form.appliesToPlans.includes(key)) {
357
+ props.form.appliesToPlans = props.form.appliesToPlans.filter((k) => k !== key);
358
+ } else {
359
+ props.form.appliesToPlans = [...props.form.appliesToPlans, key];
360
+ }
361
+ }
362
+
363
+ function planChipStyle(p: PromoCodePlanOption): Record<string, string> {
364
+ if (!isPlanSelected(p.key) || !p.color) return {};
365
+ return { borderColor: p.color, background: `${p.color}12`, color: p.color };
366
+ }
367
+
368
+ const previewValue = computed(() => {
369
+ if (props.form.valueType === 'PERCENT') return `\u2212${props.form.value || 0}%`;
370
+ return `\u2212${props.form.value || 0} \u20ac`;
371
+ });
372
+
373
+ const previewMeta = computed(() => {
374
+ const parts: string[] = [];
375
+ const count = props.form.durationValue || 0;
376
+ if (props.form.durationType === 'ONCE') parts.push(msg.value.form.previewOnce);
377
+ else if (props.form.durationType === 'MONTHS')
378
+ parts.push(formatMessage(msg.value.form.previewMonths, { count }));
379
+ else parts.push(formatMessage(msg.value.form.previewCycles, { count }));
380
+ parts.push(
381
+ props.form.appliesToPlans.length > 0
382
+ ? props.form.appliesToPlans.join(', ')
383
+ : props.plans.length > 0
384
+ ? msg.value.form.previewAllPlans
385
+ : msg.value.form.previewNoPlanFilter,
386
+ );
387
+ if (props.form.maxRedemptions)
388
+ parts.push(formatMessage(msg.value.form.previewMax, { count: props.form.maxRedemptions }));
389
+ return parts.join(' \u00b7 ');
390
+ });
391
+
392
+ const advancedOpen = defineModel<boolean>('advancedOpen', { default: false });
393
+ </script>
394
+
395
+ <style scoped>
396
+ .pc-section {
397
+ border: 1px solid var(--sa-border, #e2e8f0);
398
+ border-radius: 10px;
399
+ padding: 14px 16px;
400
+ background: #fafbfc;
401
+ }
402
+
403
+ .pc-section__title {
404
+ font-family: var(--sa-font-head, system-ui, sans-serif);
405
+ font-weight: 700;
406
+ font-size: 13px;
407
+ color: var(--sa-heading, #0f172a);
408
+ margin-bottom: 12px;
409
+ letter-spacing: -0.005em;
410
+ }
411
+
412
+ .pc-section__toggle {
413
+ border: 0;
414
+ background: transparent;
415
+ cursor: pointer;
416
+ font: 600 12.5px var(--sa-font-body, system-ui, sans-serif);
417
+ color: var(--sa-muted-dark, #475569);
418
+ display: inline-flex;
419
+ align-items: center;
420
+ gap: 4px;
421
+ padding: 0;
422
+ }
423
+
424
+ .pc-advanced {
425
+ display: flex;
426
+ flex-direction: column;
427
+ gap: 12px;
428
+ margin-top: 12px;
429
+ }
430
+
431
+ .pc-grid {
432
+ display: grid;
433
+ gap: 12px;
434
+ margin-bottom: 12px;
435
+ }
436
+
437
+ .pc-grid--2 {
438
+ grid-template-columns: 1fr 1fr;
439
+ }
440
+
441
+ @media (max-width: 600px) {
442
+ .pc-grid--2 {
443
+ grid-template-columns: 1fr;
444
+ }
445
+ }
446
+
447
+ .pc-grid:last-child {
448
+ margin-bottom: 0;
449
+ }
450
+
451
+ .pc-field {
452
+ display: flex;
453
+ flex-direction: column;
454
+ gap: 4px;
455
+ }
456
+
457
+ .pc-field__label {
458
+ font-size: 11.5px;
459
+ font-weight: 600;
460
+ color: var(--sa-muted, #64748b);
461
+ text-transform: uppercase;
462
+ letter-spacing: 0.04em;
463
+ }
464
+
465
+ .pc-field__hint {
466
+ font-size: 11.5px;
467
+ color: #94a3b8;
468
+ }
469
+
470
+ .pc-input {
471
+ width: 100%;
472
+ border: 1px solid var(--sa-border, #e2e8f0);
473
+ background: #fff;
474
+ border-radius: 7px;
475
+ padding: 8px 10px;
476
+ font: 13.5px var(--sa-font-body, system-ui, sans-serif);
477
+ color: var(--sa-body, #1e293b);
478
+ outline: 0;
479
+ }
480
+
481
+ .pc-input:focus {
482
+ border-color: var(--sa-primary, #3f6bff);
483
+ box-shadow: 0 0 0 3px rgba(63, 107, 255, 0.12);
484
+ }
485
+
486
+ .pc-input--code {
487
+ font: 600 14px var(--sa-font-mono, ui-monospace, monospace);
488
+ letter-spacing: 0.04em;
489
+ text-transform: uppercase;
490
+ }
491
+
492
+ textarea.pc-input {
493
+ font: 13.5px var(--sa-font-body, system-ui, sans-serif);
494
+ resize: vertical;
495
+ }
496
+
497
+ .pc-code-input {
498
+ display: flex;
499
+ gap: 8px;
500
+ align-items: center;
501
+ }
502
+
503
+ .pc-btn-mini {
504
+ border: 1px solid var(--sa-border, #e2e8f0);
505
+ background: #fff;
506
+ border-radius: 7px;
507
+ padding: 6px 10px;
508
+ font: 500 12px var(--sa-font-body, system-ui, sans-serif);
509
+ cursor: pointer;
510
+ color: var(--sa-muted-dark, #475569);
511
+ }
512
+
513
+ .pc-btn-mini:hover {
514
+ background: #f1f5f9;
515
+ }
516
+
517
+ .pc-type-grid {
518
+ display: grid;
519
+ grid-template-columns: 1fr 1fr;
520
+ gap: 6px;
521
+ }
522
+
523
+ .pc-type-opt {
524
+ border: 1px solid var(--sa-border, #e2e8f0);
525
+ background: #fff;
526
+ border-radius: 8px;
527
+ padding: 8px 10px;
528
+ text-align: left;
529
+ cursor: pointer;
530
+ transition:
531
+ border-color 0.1s,
532
+ background 0.1s;
533
+ }
534
+
535
+ .pc-type-opt:hover {
536
+ border-color: #cbd5e1;
537
+ }
538
+
539
+ .pc-type-opt--active {
540
+ border-color: var(--sa-primary, #3f6bff);
541
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
542
+ }
543
+
544
+ .pc-type-opt__label {
545
+ font: 600 12.5px var(--sa-font-body, system-ui, sans-serif);
546
+ color: var(--sa-heading, #0f172a);
547
+ }
548
+
549
+ .pc-type-opt__sub {
550
+ font: 11.5px var(--sa-font-mono, ui-monospace, monospace);
551
+ color: var(--sa-muted, #64748b);
552
+ margin-top: 1px;
553
+ }
554
+
555
+ .pc-plan-pick {
556
+ display: flex;
557
+ flex-wrap: wrap;
558
+ gap: 6px;
559
+ }
560
+
561
+ .pc-plan-opt {
562
+ display: inline-flex;
563
+ align-items: center;
564
+ gap: 8px;
565
+ background: #fff;
566
+ border: 1px solid var(--sa-border, #e2e8f0);
567
+ border-radius: 999px;
568
+ padding: 5px 12px 5px 8px;
569
+ font: 600 12px var(--sa-font-body, system-ui, sans-serif);
570
+ cursor: pointer;
571
+ color: var(--sa-muted-dark, #475569);
572
+ transition:
573
+ border-color 0.1s,
574
+ background 0.1s;
575
+ }
576
+
577
+ .pc-plan-opt:hover {
578
+ border-color: #cbd5e1;
579
+ }
580
+
581
+ .pc-plan-opt--on {
582
+ border-color: var(--sa-primary, #3f6bff);
583
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
584
+ color: var(--sa-primary, #3f6bff);
585
+ }
586
+
587
+ .pc-plan-opt__mark {
588
+ width: 8px;
589
+ height: 8px;
590
+ border-radius: 50%;
591
+ }
592
+
593
+ .pc-dur {
594
+ display: flex;
595
+ gap: 4px;
596
+ }
597
+
598
+ .pc-dur-opt {
599
+ flex: 1;
600
+ border: 1px solid var(--sa-border, #e2e8f0);
601
+ background: #fff;
602
+ border-radius: 7px;
603
+ padding: 6px 10px;
604
+ font: 500 12px var(--sa-font-body, system-ui, sans-serif);
605
+ cursor: pointer;
606
+ color: var(--sa-muted-dark, #475569);
607
+ }
608
+
609
+ .pc-dur-opt--active {
610
+ border-color: var(--sa-primary, #3f6bff);
611
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
612
+ color: var(--sa-primary, #3f6bff);
613
+ }
614
+
615
+ .pc-check {
616
+ display: inline-flex;
617
+ align-items: center;
618
+ gap: 8px;
619
+ font: 13px var(--sa-font-body, system-ui, sans-serif);
620
+ color: var(--sa-body, #1e293b);
621
+ cursor: pointer;
622
+ }
623
+
624
+ .pc-preview {
625
+ background: var(--sa-primary-soft, rgba(63, 107, 255, 0.06));
626
+ border: 1px solid var(--sa-primary-border, rgba(63, 107, 255, 0.18));
627
+ border-radius: 10px;
628
+ padding: 12px 14px;
629
+ }
630
+
631
+ .pc-preview__eyebrow {
632
+ font-size: 11px;
633
+ font-weight: 700;
634
+ color: var(--sa-primary, #3f6bff);
635
+ text-transform: uppercase;
636
+ letter-spacing: 0.06em;
637
+ margin-bottom: 6px;
638
+ }
639
+
640
+ .pc-preview__body {
641
+ display: flex;
642
+ align-items: center;
643
+ gap: 10px;
644
+ flex-wrap: wrap;
645
+ }
646
+
647
+ .pc-preview__code {
648
+ background: #fff;
649
+ border: 1px solid var(--sa-border, #e2e8f0);
650
+ border-radius: 6px;
651
+ padding: 3px 8px;
652
+ font: 600 13px var(--sa-font-mono, ui-monospace, monospace);
653
+ letter-spacing: 0.04em;
654
+ color: var(--sa-heading, #0f172a);
655
+ }
656
+
657
+ .pc-preview__disc {
658
+ font: 700 13px var(--sa-font-body, system-ui, sans-serif);
659
+ color: var(--sa-positive, #047857);
660
+ }
661
+
662
+ .pc-preview__meta {
663
+ font-size: 12px;
664
+ color: var(--sa-muted, #64748b);
665
+ }
666
+ </style>