includio-cms 0.34.1 → 0.35.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.
- package/API.md +4 -2
- package/CHANGELOG.md +19 -0
- package/DOCS.md +1 -1
- package/dist/admin/client/shop/coupon-edit-page.svelte +1 -0
- package/dist/admin/client/shop/coupon-form.svelte +62 -2
- package/dist/admin/client/shop/coupon-schema.d.ts +5 -0
- package/dist/admin/client/shop/coupon-schema.js +2 -0
- package/dist/admin/client/shop/shop-order-detail-page.svelte +72 -2
- package/dist/admin/components/fields/date-field.svelte +81 -27
- package/dist/admin/components/fields/date-field.svelte.d.ts +3 -0
- package/dist/admin/components/fields/datetime-field.svelte +142 -29
- package/dist/admin/components/fields/datetime-field.svelte.d.ts +3 -0
- package/dist/admin/remote/shop.remote.d.ts +6 -0
- package/dist/admin/remote/shop.remote.js +4 -0
- package/dist/core/server/generator/generator.js +3 -2
- package/dist/db-postgres/schema/shop/coupons.d.ts +20 -0
- package/dist/db-postgres/schema/shop/coupons.js +3 -0
- package/dist/paraglide/messages/_index.d.ts +36 -3
- package/dist/paraglide/messages/_index.js +71 -3
- package/dist/paraglide/messages/en.d.ts +5 -0
- package/dist/paraglide/messages/en.js +14 -0
- package/dist/paraglide/messages/pl.d.ts +5 -0
- package/dist/paraglide/messages/pl.js +14 -0
- package/dist/shop/client/index.d.ts +1 -0
- package/dist/shop/http/order-handler.js +2 -1
- package/dist/shop/pricing.d.ts +18 -6
- package/dist/shop/pricing.js +33 -8
- package/dist/shop/server/coupons.js +3 -2
- package/dist/shop/server/email.js +30 -0
- package/dist/shop/templates/_partials/items.en.html +10 -0
- package/dist/shop/templates/_partials/items.pl.html +10 -0
- package/dist/updates/0.35.0/index.d.ts +2 -0
- package/dist/updates/0.35.0/index.js +16 -0
- package/dist/updates/index.js +3 -1
- package/package.json +1 -1
- package/dist/paraglide/messages/hello_world.d.ts +0 -5
- package/dist/paraglide/messages/hello_world.js +0 -33
- package/dist/paraglide/messages/login_hello.d.ts +0 -16
- package/dist/paraglide/messages/login_hello.js +0 -34
- package/dist/paraglide/messages/login_please_login.d.ts +0 -16
- package/dist/paraglide/messages/login_please_login.js +0 -34
package/API.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# includio-cms — Public API v0.
|
|
1
|
+
# includio-cms — Public API v0.35.0
|
|
2
2
|
|
|
3
3
|
> Auto-generated by `scripts/generate-api-md.ts`. Do not edit by hand.
|
|
4
4
|
|
|
5
|
-
Entry points: **19** · Stable: **
|
|
5
|
+
Entry points: **19** · Stable: **490** · Experimental: **4**
|
|
6
6
|
|
|
7
7
|
Tags:
|
|
8
8
|
- `@public` — frozen for v1.0; semver-protected.
|
|
@@ -362,6 +362,7 @@ Tags:
|
|
|
362
362
|
- `const sessionRelations: <inferred>`
|
|
363
363
|
- `interface ShippingCarrierConfig`
|
|
364
364
|
- `type ShopCarrierType = 'none' | 'inpost' | string`
|
|
365
|
+
- `type ShopCouponAppliesTo = 'net' | 'gross'`
|
|
365
366
|
- `const shopCouponRedemptionsTable: <inferred>`
|
|
366
367
|
- `const shopCouponsTable: <inferred>`
|
|
367
368
|
- `type ShopCouponType = 'percent' | 'fixed'`
|
|
@@ -404,6 +405,7 @@ Tags:
|
|
|
404
405
|
- `type InpostServiceType = | 'inpost_locker_standard' | 'inpost_locker_express' | 'inpost_courier_standard' | 'inpost_courie...`
|
|
405
406
|
- `interface ShippingCarrierConfig`
|
|
406
407
|
- `type ShopCarrierType = 'none' | 'inpost' | string`
|
|
408
|
+
- `type ShopCouponAppliesTo = 'net' | 'gross'`
|
|
407
409
|
- `const shopCouponRedemptionsTable: <inferred>`
|
|
408
410
|
- `const shopCouponsTable: <inferred>`
|
|
409
411
|
- `type ShopCouponType = 'percent' | 'fixed'`
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
All notable changes to includio-cms are documented here.
|
|
4
4
|
Generated from `src/lib/updates/` — do not edit manually.
|
|
5
5
|
|
|
6
|
+
## 0.35.0 — 2026-06-05
|
|
7
|
+
|
|
8
|
+
Kupon `appliesTo` (netto/brutto), uczestnicy w `notes` (admin + email), Calendar+Popover w polach `date`/`datetime` (admin), `api.ts` bez `FormEntryMap` gdy projekt nie ma form. Additive only — domyślne zachowania bez zmian.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `shop_coupons` dostaje kolumnę `applies_to` (enum `net`/`gross`, default `net`). `calculateCouponDiscountNet()` (`$lib/shop/pricing.ts`) liczy zniżkę od brutto (konwersja do netto przez średnią ważoną VAT) gdy `appliesTo="gross"` — działa zarówno dla `percent`, jak i `fixed`. Storage rabatu w bazie nadal w netto (canonical). Domyślnie `net` — istniejące kupony zachowują obecne zachowanie.
|
|
12
|
+
- Admin coupon form (`coupon-form.svelte`) zyskuje toggle „Rabat dotyczy: netto / brutto" wzorowany na `inputMode` w `shop-field.svelte` — z helper textem objaśniającym wpływ na cenę zamówienia.
|
|
13
|
+
- `order.notes` jest teraz traktowane jako opcjonalny JSON (`{ org?, orgName?, participants?: Array<{firstName, lastName}> }`) w admin order detail (`shop-order-detail-page.svelte`) oraz w kontekście emaila (`items.pl.html`/`items.en.html` przez `email.ts`). Plain-text notatki nadal renderują się raw — JSON wykrywany przez `try/catch` parse + shape guard.
|
|
14
|
+
- Admin pola `date` i `datetime` (`date-field.svelte`, `datetime-field.svelte`) używają `bits-ui` Calendar w `Popover` zamiast natywnych `<input type="date|datetime-local">` — kalendarz z dropdownem miesiąca/roku, dwa Selecty (godzina 00–23, minuta 00/15/30/45) dla datetime, locale `pl-PL`/`en-GB` z `interfaceLanguage`. Output bez zmian: ISO datetime (`datetime`) / `YYYY-MM-DDT00:00:00.000Z` (`date`).
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- `generateAPI()` (`src/lib/core/server/generator/generator.ts`) nie wstrzykuje już importu `FormEntryMap` / `createFormSubmission` w wygenerowanym `api.ts`, gdy projekt nie ma zdefiniowanych form (`config.forms` puste). Wcześniej projekty bez form miały martwy import, który psuł type-check / build.
|
|
18
|
+
|
|
19
|
+
### Migration
|
|
20
|
+
|
|
21
|
+
```sql
|
|
22
|
+
ALTER TABLE shop_coupons ADD COLUMN IF NOT EXISTS applies_to text NOT NULL DEFAULT 'net';
|
|
23
|
+
```
|
|
24
|
+
|
|
6
25
|
## 0.34.1 — 2026-06-03
|
|
7
26
|
|
|
8
27
|
Fix: `AccountPage` eksportowany z głównego barrela `includio-cms/admin/client` (wcześniej brakowało go w eksportach, a scaffold importował z nieistniejącego subpath `includio-cms/admin/client/account`). Generowany `+page.svelte` dla konta importuje teraz z `includio-cms/admin/client`. Additive only.
|
package/DOCS.md
CHANGED
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
discountType: string;
|
|
33
33
|
discountTypePercent: string;
|
|
34
34
|
discountTypeFixed: string;
|
|
35
|
+
appliesTo: string;
|
|
36
|
+
appliesToNet: string;
|
|
37
|
+
appliesToGross: string;
|
|
38
|
+
appliesToHelperPercentNet: string;
|
|
39
|
+
appliesToHelperPercentGross: string;
|
|
40
|
+
appliesToHelperFixedNet: string;
|
|
41
|
+
appliesToHelperFixedGross: string;
|
|
35
42
|
value: string;
|
|
36
43
|
valuePercent: string;
|
|
37
44
|
valueFixed: string;
|
|
@@ -58,8 +65,19 @@
|
|
|
58
65
|
codeHint: 'Litery, cyfry, „_”, „-”. Wielkość liter zostanie ujednolicona do dużych.',
|
|
59
66
|
codePlaceholder: 'np. ARIA10',
|
|
60
67
|
discountType: 'Typ rabatu',
|
|
61
|
-
discountTypePercent: 'Procent (%
|
|
68
|
+
discountTypePercent: 'Procent (%)',
|
|
62
69
|
discountTypeFixed: 'Kwota stała (PLN)',
|
|
70
|
+
appliesTo: 'Rabat dotyczy ceny',
|
|
71
|
+
appliesToNet: 'Netto',
|
|
72
|
+
appliesToGross: 'Brutto',
|
|
73
|
+
appliesToHelperPercentNet:
|
|
74
|
+
'Procent zostanie odjęty od kwoty netto całego zamówienia.',
|
|
75
|
+
appliesToHelperPercentGross:
|
|
76
|
+
'Procent zostanie odjęty od kwoty brutto całego zamówienia.',
|
|
77
|
+
appliesToHelperFixedNet:
|
|
78
|
+
'Kwota zostanie odjęta od ceny netto całego zamówienia.',
|
|
79
|
+
appliesToHelperFixedGross:
|
|
80
|
+
'Kwota zostanie odjęta od ceny brutto całego zamówienia.',
|
|
63
81
|
value: 'Wartość',
|
|
64
82
|
valuePercent: 'Procent (0–100)',
|
|
65
83
|
valueFixed: 'Kwota (PLN)',
|
|
@@ -85,8 +103,15 @@
|
|
|
85
103
|
codeHint: 'Letters, digits, "_", "-". Case will be normalized to uppercase.',
|
|
86
104
|
codePlaceholder: 'e.g. ARIA10',
|
|
87
105
|
discountType: 'Discount type',
|
|
88
|
-
discountTypePercent: 'Percent (%
|
|
106
|
+
discountTypePercent: 'Percent (%)',
|
|
89
107
|
discountTypeFixed: 'Fixed amount (PLN)',
|
|
108
|
+
appliesTo: 'Discount applies to',
|
|
109
|
+
appliesToNet: 'Net',
|
|
110
|
+
appliesToGross: 'Gross',
|
|
111
|
+
appliesToHelperPercentNet: 'Percent will be deducted from the net subtotal.',
|
|
112
|
+
appliesToHelperPercentGross: 'Percent will be deducted from the gross subtotal.',
|
|
113
|
+
appliesToHelperFixedNet: 'Amount will be deducted from the net subtotal.',
|
|
114
|
+
appliesToHelperFixedGross: 'Amount will be deducted from the gross subtotal.',
|
|
90
115
|
value: 'Value',
|
|
91
116
|
valuePercent: 'Percent (0–100)',
|
|
92
117
|
valueFixed: 'Amount (PLN)',
|
|
@@ -140,6 +165,7 @@
|
|
|
140
165
|
const labels: Record<string, string> = {
|
|
141
166
|
code: t.code,
|
|
142
167
|
type: t.discountType,
|
|
168
|
+
appliesTo: t.appliesTo,
|
|
143
169
|
value: t.value,
|
|
144
170
|
minOrderAmount: t.minOrder,
|
|
145
171
|
maxUses: t.maxUses,
|
|
@@ -221,6 +247,40 @@
|
|
|
221
247
|
<Form.FieldErrors />
|
|
222
248
|
</Form.Fieldset>
|
|
223
249
|
|
|
250
|
+
<Form.Fieldset {form} name="appliesTo" class="space-y-2">
|
|
251
|
+
<legend class="text-sm font-medium">{t.appliesTo}</legend>
|
|
252
|
+
<label class="flex items-center gap-2 text-sm">
|
|
253
|
+
<input
|
|
254
|
+
type="radio"
|
|
255
|
+
name="coupon-applies-to"
|
|
256
|
+
value="net"
|
|
257
|
+
bind:group={$formData.appliesTo}
|
|
258
|
+
/>
|
|
259
|
+
<span>{t.appliesToNet}</span>
|
|
260
|
+
</label>
|
|
261
|
+
<label class="flex items-center gap-2 text-sm">
|
|
262
|
+
<input
|
|
263
|
+
type="radio"
|
|
264
|
+
name="coupon-applies-to"
|
|
265
|
+
value="gross"
|
|
266
|
+
bind:group={$formData.appliesTo}
|
|
267
|
+
/>
|
|
268
|
+
<span>{t.appliesToGross}</span>
|
|
269
|
+
</label>
|
|
270
|
+
<p class="text-muted-foreground text-sm">
|
|
271
|
+
{#if $formData.type === 'percent' && $formData.appliesTo === 'net'}
|
|
272
|
+
{t.appliesToHelperPercentNet}
|
|
273
|
+
{:else if $formData.type === 'percent' && $formData.appliesTo === 'gross'}
|
|
274
|
+
{t.appliesToHelperPercentGross}
|
|
275
|
+
{:else if $formData.type === 'fixed' && $formData.appliesTo === 'net'}
|
|
276
|
+
{t.appliesToHelperFixedNet}
|
|
277
|
+
{:else}
|
|
278
|
+
{t.appliesToHelperFixedGross}
|
|
279
|
+
{/if}
|
|
280
|
+
</p>
|
|
281
|
+
<Form.FieldErrors />
|
|
282
|
+
</Form.Fieldset>
|
|
283
|
+
|
|
224
284
|
<Form.ElementField name="value" {form}>
|
|
225
285
|
<Form.Control>
|
|
226
286
|
{#snippet children({ props })}
|
|
@@ -8,6 +8,7 @@ export type CouponInput = {
|
|
|
8
8
|
code: string;
|
|
9
9
|
type: 'percent' | 'fixed';
|
|
10
10
|
value: number;
|
|
11
|
+
appliesTo: 'net' | 'gross';
|
|
11
12
|
minOrderAmount: number | null;
|
|
12
13
|
maxUses: number | null;
|
|
13
14
|
expiresAt: string | null;
|
|
@@ -19,6 +20,10 @@ export declare function createCouponSchema(lang?: InterfaceLanguage): z.ZodObjec
|
|
|
19
20
|
fixed: "fixed";
|
|
20
21
|
percent: "percent";
|
|
21
22
|
}>;
|
|
23
|
+
appliesTo: z.ZodEnum<{
|
|
24
|
+
net: "net";
|
|
25
|
+
gross: "gross";
|
|
26
|
+
}>;
|
|
22
27
|
value: z.ZodNumber;
|
|
23
28
|
minOrderAmount: z.ZodNullable<z.ZodNumber>;
|
|
24
29
|
maxUses: z.ZodNullable<z.ZodNumber>;
|
|
@@ -31,6 +31,7 @@ export function createCouponSchema(lang = 'pl') {
|
|
|
31
31
|
.max(64)
|
|
32
32
|
.regex(/^[A-Za-z0-9_-]+$/, m.codeFormat),
|
|
33
33
|
type: z.enum(['percent', 'fixed']),
|
|
34
|
+
appliesTo: z.enum(['net', 'gross']),
|
|
34
35
|
value: z.number().positive(m.valuePositive),
|
|
35
36
|
minOrderAmount: z.number().int().nonnegative(m.minOrderNonneg).nullable(),
|
|
36
37
|
maxUses: z.number().int().positive(m.maxUsesPositive).nullable(),
|
|
@@ -45,6 +46,7 @@ export function createCouponSchema(lang = 'pl') {
|
|
|
45
46
|
export const defaultCoupon = {
|
|
46
47
|
code: '',
|
|
47
48
|
type: 'percent',
|
|
49
|
+
appliesTo: 'net',
|
|
48
50
|
value: 10,
|
|
49
51
|
minOrderAmount: null,
|
|
50
52
|
maxUses: null,
|
|
@@ -37,6 +37,51 @@
|
|
|
37
37
|
const orderId = $derived(page.params.id ?? '');
|
|
38
38
|
const query = $derived(remotes.getOrderForAdmin(orderId));
|
|
39
39
|
|
|
40
|
+
type ParsedNotes = {
|
|
41
|
+
org: string | null;
|
|
42
|
+
orgName: string | null;
|
|
43
|
+
participants: Array<{ firstName: string; lastName: string }>;
|
|
44
|
+
raw: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function parseOrderNotes(notes: string): ParsedNotes {
|
|
48
|
+
const trimmed = notes.trim();
|
|
49
|
+
if (!trimmed.startsWith('{')) {
|
|
50
|
+
return { org: null, orgName: null, participants: [], raw: notes };
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(trimmed) as {
|
|
54
|
+
org?: unknown;
|
|
55
|
+
orgName?: unknown;
|
|
56
|
+
participants?: unknown;
|
|
57
|
+
};
|
|
58
|
+
const org = typeof parsed.org === 'string' ? parsed.org : null;
|
|
59
|
+
const orgName = typeof parsed.orgName === 'string' ? parsed.orgName : null;
|
|
60
|
+
const participants = Array.isArray(parsed.participants)
|
|
61
|
+
? parsed.participants
|
|
62
|
+
.map((p): { firstName: string; lastName: string } | null => {
|
|
63
|
+
const fn =
|
|
64
|
+
typeof (p as { firstName?: unknown })?.firstName === 'string'
|
|
65
|
+
? (p as { firstName: string }).firstName.trim()
|
|
66
|
+
: '';
|
|
67
|
+
const ln =
|
|
68
|
+
typeof (p as { lastName?: unknown })?.lastName === 'string'
|
|
69
|
+
? (p as { lastName: string }).lastName.trim()
|
|
70
|
+
: '';
|
|
71
|
+
if (!fn && !ln) return null;
|
|
72
|
+
return { firstName: fn, lastName: ln };
|
|
73
|
+
})
|
|
74
|
+
.filter((p): p is { firstName: string; lastName: string } => p !== null)
|
|
75
|
+
: [];
|
|
76
|
+
if (!org && participants.length === 0) {
|
|
77
|
+
return { org: null, orgName: null, participants: [], raw: notes };
|
|
78
|
+
}
|
|
79
|
+
return { org, orgName, participants, raw: null };
|
|
80
|
+
} catch {
|
|
81
|
+
return { org: null, orgName: null, participants: [], raw: notes };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
40
85
|
$effect(() => {
|
|
41
86
|
const s = sidebarLang[interfaceLanguage.current].shop;
|
|
42
87
|
const number = query.current?.order?.number;
|
|
@@ -743,9 +788,34 @@
|
|
|
743
788
|
{/if}
|
|
744
789
|
|
|
745
790
|
{#if order.notes}
|
|
746
|
-
|
|
791
|
+
{@const notesData = parseOrderNotes(order.notes)}
|
|
792
|
+
<section class="border-border bg-card space-y-3 rounded-xl border p-5 text-sm">
|
|
747
793
|
<h2 class="text-base font-bold">Uwagi klienta</h2>
|
|
748
|
-
|
|
794
|
+
{#if notesData.orgName || notesData.org}
|
|
795
|
+
<div class="text-xs">
|
|
796
|
+
<span class="text-muted-foreground">Organizacja:</span>
|
|
797
|
+
<span class="font-semibold">{notesData.orgName ?? notesData.org}</span>
|
|
798
|
+
{#if notesData.orgName && notesData.org}
|
|
799
|
+
<span class="text-muted-foreground">({notesData.org})</span>
|
|
800
|
+
{/if}
|
|
801
|
+
</div>
|
|
802
|
+
{/if}
|
|
803
|
+
{#if notesData.participants.length > 0}
|
|
804
|
+
<div class="space-y-1">
|
|
805
|
+
<div class="text-muted-foreground text-xs">Uczestnicy:</div>
|
|
806
|
+
<ol class="space-y-0.5 text-xs">
|
|
807
|
+
{#each notesData.participants as p, i (i)}
|
|
808
|
+
<li class="flex gap-2">
|
|
809
|
+
<span class="text-muted-foreground tabular-nums">{i + 1}.</span>
|
|
810
|
+
<span class="font-semibold">{p.firstName} {p.lastName}</span>
|
|
811
|
+
</li>
|
|
812
|
+
{/each}
|
|
813
|
+
</ol>
|
|
814
|
+
</div>
|
|
815
|
+
{/if}
|
|
816
|
+
{#if notesData.raw !== null}
|
|
817
|
+
<p class="text-xs whitespace-pre-wrap">{notesData.raw}</p>
|
|
818
|
+
{/if}
|
|
749
819
|
</section>
|
|
750
820
|
{/if}
|
|
751
821
|
|
|
@@ -1,51 +1,105 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import { CalendarDate, type DateValue } from '@internationalized/date';
|
|
3
|
+
import { Calendar } from '../../../components/ui/calendar/index.js';
|
|
4
|
+
import * as Popover from '../../../components/ui/popover/index.js';
|
|
5
|
+
import CalendarIcon from '@lucide/svelte/icons/calendar';
|
|
3
6
|
import type { DateField } from '../../../types/fields.js';
|
|
7
|
+
import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
|
|
4
8
|
import { onMount } from 'svelte';
|
|
9
|
+
import { cn } from '../../../utils.js';
|
|
5
10
|
|
|
6
11
|
type Props = {
|
|
7
12
|
field: DateField;
|
|
8
13
|
value: string | undefined;
|
|
14
|
+
id?: string;
|
|
15
|
+
'aria-invalid'?: string | boolean | undefined;
|
|
16
|
+
'aria-describedby'?: string | undefined;
|
|
9
17
|
};
|
|
10
18
|
|
|
11
19
|
let { field, value = $bindable(), ...props }: Props = $props();
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
const interfaceLanguage = useInterfaceLanguage();
|
|
22
|
+
const localeMap: Record<string, string> = { pl: 'pl-PL', en: 'en-GB' };
|
|
23
|
+
const locale = $derived(localeMap[interfaceLanguage.current] ?? 'en-GB');
|
|
24
|
+
|
|
25
|
+
const lang = {
|
|
26
|
+
pl: { pick: 'Wybierz datę' },
|
|
27
|
+
en: { pick: 'Pick a date' }
|
|
28
|
+
} as const;
|
|
29
|
+
const t = $derived(lang[interfaceLanguage.current as 'pl' | 'en'] ?? lang.en);
|
|
30
|
+
|
|
31
|
+
function parseValue(iso: string | undefined): CalendarDate | undefined {
|
|
32
|
+
if (!iso) return undefined;
|
|
33
|
+
const ymd = iso.slice(0, 10);
|
|
34
|
+
const [y, m, d] = ymd.split('-').map(Number);
|
|
35
|
+
if (!y || !m || !d) return undefined;
|
|
36
|
+
return new CalendarDate(y, m, d);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pad(n: number): string {
|
|
40
|
+
return String(n).padStart(2, '0');
|
|
17
41
|
}
|
|
18
42
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (!local) return '';
|
|
22
|
-
return `${local}T00:00:00.000Z`;
|
|
43
|
+
function toIso(d: CalendarDate): string {
|
|
44
|
+
return `${d.year}-${pad(d.month)}-${pad(d.day)}T00:00:00.000Z`;
|
|
23
45
|
}
|
|
24
46
|
|
|
25
|
-
let
|
|
47
|
+
let dateValue = $state<DateValue | undefined>(parseValue(value));
|
|
48
|
+
let open = $state(false);
|
|
49
|
+
|
|
50
|
+
let lastEmittedValue: string | undefined = value;
|
|
26
51
|
|
|
27
52
|
$effect(() => {
|
|
28
|
-
|
|
53
|
+
if (value === lastEmittedValue) return;
|
|
54
|
+
dateValue = parseValue(value);
|
|
55
|
+
lastEmittedValue = value;
|
|
29
56
|
});
|
|
30
57
|
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
58
|
+
$effect(() => {
|
|
59
|
+
const d = dateValue;
|
|
60
|
+
const next = d ? toIso(d as CalendarDate) : '';
|
|
61
|
+
if (next !== lastEmittedValue) {
|
|
62
|
+
lastEmittedValue = next;
|
|
63
|
+
value = next;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
36
66
|
|
|
37
67
|
onMount(() => {
|
|
38
|
-
if (value === undefined)
|
|
39
|
-
|
|
40
|
-
|
|
68
|
+
if (value === undefined) value = '';
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const minDate = $derived(parseValue(field.minDate));
|
|
72
|
+
const maxDate = $derived(parseValue(field.maxDate));
|
|
73
|
+
|
|
74
|
+
const display = $derived.by(() => {
|
|
75
|
+
if (!value) return t.pick;
|
|
76
|
+
const d = new Date(value);
|
|
77
|
+
if (isNaN(d.getTime())) return t.pick;
|
|
78
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(d);
|
|
41
79
|
});
|
|
42
80
|
</script>
|
|
43
81
|
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
82
|
+
<Popover.Root bind:open>
|
|
83
|
+
<Popover.Trigger
|
|
84
|
+
{...props}
|
|
85
|
+
class={cn(
|
|
86
|
+
'border-input bg-card flex h-9 w-full items-center gap-2 rounded-md border px-3 py-1 text-left text-sm shadow-xs outline-none transition-[color,box-shadow]',
|
|
87
|
+
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
|
88
|
+
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
|
89
|
+
!value && 'text-muted-foreground'
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
<CalendarIcon class="size-4 opacity-50" />
|
|
93
|
+
<span class="flex-1 truncate">{display}</span>
|
|
94
|
+
</Popover.Trigger>
|
|
95
|
+
<Popover.Content class="w-auto p-0" align="start">
|
|
96
|
+
<Calendar
|
|
97
|
+
type="single"
|
|
98
|
+
bind:value={dateValue}
|
|
99
|
+
{locale}
|
|
100
|
+
minValue={minDate}
|
|
101
|
+
maxValue={maxDate}
|
|
102
|
+
captionLayout="dropdown"
|
|
103
|
+
/>
|
|
104
|
+
</Popover.Content>
|
|
105
|
+
</Popover.Root>
|
|
@@ -2,6 +2,9 @@ import type { DateField } from '../../../types/fields.js';
|
|
|
2
2
|
type Props = {
|
|
3
3
|
field: DateField;
|
|
4
4
|
value: string | undefined;
|
|
5
|
+
id?: string;
|
|
6
|
+
'aria-invalid'?: string | boolean | undefined;
|
|
7
|
+
'aria-describedby'?: string | undefined;
|
|
5
8
|
};
|
|
6
9
|
declare const DateField: import("svelte").Component<Props, {}, "value">;
|
|
7
10
|
type DateField = ReturnType<typeof DateField>;
|
|
@@ -1,53 +1,166 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import { CalendarDate, type DateValue } from '@internationalized/date';
|
|
3
|
+
import { Calendar } from '../../../components/ui/calendar/index.js';
|
|
4
|
+
import * as Popover from '../../../components/ui/popover/index.js';
|
|
5
|
+
import * as Select from '../../../components/ui/select/index.js';
|
|
6
|
+
import CalendarIcon from '@lucide/svelte/icons/calendar';
|
|
7
|
+
import ClockIcon from '@lucide/svelte/icons/clock';
|
|
3
8
|
import type { DateTimeField } from '../../../types/fields.js';
|
|
9
|
+
import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
|
|
4
10
|
import { onMount } from 'svelte';
|
|
11
|
+
import { cn } from '../../../utils.js';
|
|
5
12
|
|
|
6
13
|
type Props = {
|
|
7
14
|
field: DateTimeField;
|
|
8
15
|
value: string | undefined;
|
|
16
|
+
id?: string;
|
|
17
|
+
'aria-invalid'?: string | boolean | undefined;
|
|
18
|
+
'aria-describedby'?: string | undefined;
|
|
9
19
|
};
|
|
10
20
|
|
|
11
21
|
let { field, value = $bindable(), ...props }: Props = $props();
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
const interfaceLanguage = useInterfaceLanguage();
|
|
24
|
+
const localeMap: Record<string, string> = { pl: 'pl-PL', en: 'en-GB' };
|
|
25
|
+
const locale = $derived(localeMap[interfaceLanguage.current] ?? 'en-GB');
|
|
26
|
+
|
|
27
|
+
const lang = {
|
|
28
|
+
pl: { pick: 'Wybierz datę i godzinę', time: 'Godzina' },
|
|
29
|
+
en: { pick: 'Pick date and time', time: 'Time' }
|
|
30
|
+
} as const;
|
|
31
|
+
const t = $derived(lang[interfaceLanguage.current as 'pl' | 'en'] ?? lang.en);
|
|
32
|
+
|
|
33
|
+
function parseValue(iso: string | undefined): {
|
|
34
|
+
date: CalendarDate | undefined;
|
|
35
|
+
hour: string;
|
|
36
|
+
minute: string;
|
|
37
|
+
} {
|
|
38
|
+
if (!iso) return { date: undefined, hour: '09', minute: '00' };
|
|
39
|
+
const d = new Date(iso);
|
|
40
|
+
if (isNaN(d.getTime())) return { date: undefined, hour: '09', minute: '00' };
|
|
41
|
+
return {
|
|
42
|
+
date: new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate()),
|
|
43
|
+
hour: String(d.getHours()).padStart(2, '0'),
|
|
44
|
+
minute: String(d.getMinutes()).padStart(2, '0')
|
|
45
|
+
};
|
|
19
46
|
}
|
|
20
47
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
48
|
+
function parseMinMax(iso: string | undefined): CalendarDate | undefined {
|
|
49
|
+
if (!iso) return undefined;
|
|
50
|
+
const d = new Date(iso);
|
|
51
|
+
if (isNaN(d.getTime())) return undefined;
|
|
52
|
+
return new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
|
25
53
|
}
|
|
26
54
|
|
|
27
|
-
|
|
55
|
+
const initial = parseValue(value);
|
|
56
|
+
let dateValue = $state<DateValue | undefined>(initial.date);
|
|
57
|
+
let hour = $state(initial.hour);
|
|
58
|
+
let minute = $state(initial.minute);
|
|
59
|
+
let open = $state(false);
|
|
60
|
+
|
|
61
|
+
let lastEmittedValue: string | undefined = value;
|
|
28
62
|
|
|
29
63
|
$effect(() => {
|
|
30
|
-
|
|
64
|
+
if (value === lastEmittedValue) return;
|
|
65
|
+
const next = parseValue(value);
|
|
66
|
+
dateValue = next.date;
|
|
67
|
+
hour = next.hour;
|
|
68
|
+
minute = next.minute;
|
|
69
|
+
lastEmittedValue = value;
|
|
31
70
|
});
|
|
32
71
|
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
72
|
+
$effect(() => {
|
|
73
|
+
const d = dateValue;
|
|
74
|
+
const h = hour;
|
|
75
|
+
const m = minute;
|
|
38
76
|
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
77
|
+
let next: string;
|
|
78
|
+
if (!d) {
|
|
79
|
+
next = '';
|
|
80
|
+
} else {
|
|
81
|
+
const hourNum = Number.parseInt(h, 10);
|
|
82
|
+
const minuteNum = Number.parseInt(m, 10);
|
|
83
|
+
const js = new Date(
|
|
84
|
+
d.year,
|
|
85
|
+
d.month - 1,
|
|
86
|
+
d.day,
|
|
87
|
+
Number.isFinite(hourNum) ? hourNum : 0,
|
|
88
|
+
Number.isFinite(minuteNum) ? minuteNum : 0,
|
|
89
|
+
0,
|
|
90
|
+
0
|
|
91
|
+
);
|
|
92
|
+
next = js.toISOString();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (next !== lastEmittedValue) {
|
|
96
|
+
lastEmittedValue = next;
|
|
97
|
+
value = next;
|
|
42
98
|
}
|
|
43
99
|
});
|
|
100
|
+
|
|
101
|
+
onMount(() => {
|
|
102
|
+
if (value === undefined) value = '';
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const minDate = $derived(parseMinMax(field.minDate));
|
|
106
|
+
const maxDate = $derived(parseMinMax(field.maxDate));
|
|
107
|
+
|
|
108
|
+
const display = $derived.by(() => {
|
|
109
|
+
if (!value) return t.pick;
|
|
110
|
+
const d = new Date(value);
|
|
111
|
+
if (isNaN(d.getTime())) return t.pick;
|
|
112
|
+
return new Intl.DateTimeFormat(locale, {
|
|
113
|
+
dateStyle: 'long',
|
|
114
|
+
timeStyle: 'short'
|
|
115
|
+
}).format(d);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
|
119
|
+
const minutes = ['00', '15', '30', '45'];
|
|
44
120
|
</script>
|
|
45
121
|
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
122
|
+
<Popover.Root bind:open>
|
|
123
|
+
<Popover.Trigger
|
|
124
|
+
{...props}
|
|
125
|
+
class={cn(
|
|
126
|
+
'border-input bg-card flex h-9 w-full items-center gap-2 rounded-md border px-3 py-1 text-left text-sm shadow-xs outline-none transition-[color,box-shadow]',
|
|
127
|
+
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
|
128
|
+
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
|
129
|
+
!value && 'text-muted-foreground'
|
|
130
|
+
)}
|
|
131
|
+
>
|
|
132
|
+
<CalendarIcon class="size-4 opacity-50" />
|
|
133
|
+
<span class="flex-1 truncate">{display}</span>
|
|
134
|
+
</Popover.Trigger>
|
|
135
|
+
<Popover.Content class="w-auto p-0" align="start">
|
|
136
|
+
<Calendar
|
|
137
|
+
type="single"
|
|
138
|
+
bind:value={dateValue}
|
|
139
|
+
{locale}
|
|
140
|
+
minValue={minDate}
|
|
141
|
+
maxValue={maxDate}
|
|
142
|
+
captionLayout="dropdown"
|
|
143
|
+
/>
|
|
144
|
+
<div class="border-border flex items-center gap-2 border-t p-3">
|
|
145
|
+
<ClockIcon class="size-4 opacity-50" />
|
|
146
|
+
<span class="text-muted-foreground text-xs font-semibold">{t.time}</span>
|
|
147
|
+
<Select.Root type="single" bind:value={hour}>
|
|
148
|
+
<Select.Trigger class="h-8 w-[68px]">{hour}</Select.Trigger>
|
|
149
|
+
<Select.Content>
|
|
150
|
+
{#each hours as h (h)}
|
|
151
|
+
<Select.Item value={h} label={h}>{h}</Select.Item>
|
|
152
|
+
{/each}
|
|
153
|
+
</Select.Content>
|
|
154
|
+
</Select.Root>
|
|
155
|
+
<span class="text-muted-foreground">:</span>
|
|
156
|
+
<Select.Root type="single" bind:value={minute}>
|
|
157
|
+
<Select.Trigger class="h-8 w-[68px]">{minute}</Select.Trigger>
|
|
158
|
+
<Select.Content>
|
|
159
|
+
{#each minutes as m (m)}
|
|
160
|
+
<Select.Item value={m} label={m}>{m}</Select.Item>
|
|
161
|
+
{/each}
|
|
162
|
+
</Select.Content>
|
|
163
|
+
</Select.Root>
|
|
164
|
+
</div>
|
|
165
|
+
</Popover.Content>
|
|
166
|
+
</Popover.Root>
|
|
@@ -2,6 +2,9 @@ import type { DateTimeField } from '../../../types/fields.js';
|
|
|
2
2
|
type Props = {
|
|
3
3
|
field: DateTimeField;
|
|
4
4
|
value: string | undefined;
|
|
5
|
+
id?: string;
|
|
6
|
+
'aria-invalid'?: string | boolean | undefined;
|
|
7
|
+
'aria-describedby'?: string | undefined;
|
|
5
8
|
};
|
|
6
9
|
declare const DatetimeField: import("svelte").Component<Props, {}, "value">;
|
|
7
10
|
type DatetimeField = ReturnType<typeof DatetimeField>;
|