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.
Files changed (41) hide show
  1. package/API.md +4 -2
  2. package/CHANGELOG.md +19 -0
  3. package/DOCS.md +1 -1
  4. package/dist/admin/client/shop/coupon-edit-page.svelte +1 -0
  5. package/dist/admin/client/shop/coupon-form.svelte +62 -2
  6. package/dist/admin/client/shop/coupon-schema.d.ts +5 -0
  7. package/dist/admin/client/shop/coupon-schema.js +2 -0
  8. package/dist/admin/client/shop/shop-order-detail-page.svelte +72 -2
  9. package/dist/admin/components/fields/date-field.svelte +81 -27
  10. package/dist/admin/components/fields/date-field.svelte.d.ts +3 -0
  11. package/dist/admin/components/fields/datetime-field.svelte +142 -29
  12. package/dist/admin/components/fields/datetime-field.svelte.d.ts +3 -0
  13. package/dist/admin/remote/shop.remote.d.ts +6 -0
  14. package/dist/admin/remote/shop.remote.js +4 -0
  15. package/dist/core/server/generator/generator.js +3 -2
  16. package/dist/db-postgres/schema/shop/coupons.d.ts +20 -0
  17. package/dist/db-postgres/schema/shop/coupons.js +3 -0
  18. package/dist/paraglide/messages/_index.d.ts +36 -3
  19. package/dist/paraglide/messages/_index.js +71 -3
  20. package/dist/paraglide/messages/en.d.ts +5 -0
  21. package/dist/paraglide/messages/en.js +14 -0
  22. package/dist/paraglide/messages/pl.d.ts +5 -0
  23. package/dist/paraglide/messages/pl.js +14 -0
  24. package/dist/shop/client/index.d.ts +1 -0
  25. package/dist/shop/http/order-handler.js +2 -1
  26. package/dist/shop/pricing.d.ts +18 -6
  27. package/dist/shop/pricing.js +33 -8
  28. package/dist/shop/server/coupons.js +3 -2
  29. package/dist/shop/server/email.js +30 -0
  30. package/dist/shop/templates/_partials/items.en.html +10 -0
  31. package/dist/shop/templates/_partials/items.pl.html +10 -0
  32. package/dist/updates/0.35.0/index.d.ts +2 -0
  33. package/dist/updates/0.35.0/index.js +16 -0
  34. package/dist/updates/index.js +3 -1
  35. package/package.json +1 -1
  36. package/dist/paraglide/messages/hello_world.d.ts +0 -5
  37. package/dist/paraglide/messages/hello_world.js +0 -33
  38. package/dist/paraglide/messages/login_hello.d.ts +0 -16
  39. package/dist/paraglide/messages/login_hello.js +0 -34
  40. package/dist/paraglide/messages/login_please_login.d.ts +0 -16
  41. package/dist/paraglide/messages/login_please_login.js +0 -34
package/API.md CHANGED
@@ -1,8 +1,8 @@
1
- # includio-cms — Public API v0.34.1
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: **488** · Experimental: **4**
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
@@ -1,4 +1,4 @@
1
- # Includio CMS Documentation (v0.34.1)
1
+ # Includio CMS Documentation (v0.35.0)
2
2
 
3
3
  > This file is auto-generated from the docs site. For the latest version, update the package.
4
4
 
@@ -114,6 +114,7 @@
114
114
  initial={{
115
115
  code: c.code,
116
116
  type: c.type,
117
+ appliesTo: c.appliesTo ?? 'net',
117
118
  value: Number(c.value),
118
119
  minOrderAmount: c.minOrderAmount,
119
120
  maxUses: c.maxUses,
@@ -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 (% od kwoty netto)',
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 (% off net amount)',
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
- <section class="border-border bg-card space-y-1 rounded-xl border p-5 text-sm">
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
- <p class="text-xs whitespace-pre-wrap">{order.notes}</p>
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 Input from '../../../components/ui/input/input.svelte';
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
- // Convert ISO date to YYYY-MM-DD for input
14
- function toInputFormat(iso: string | undefined): string {
15
- if (!iso) return '';
16
- return iso.slice(0, 10);
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
- // Convert YYYY-MM-DD to ISO date string
20
- function toIso(local: string): string {
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 localValue = $state(toInputFormat(value));
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
- localValue = toInputFormat(value);
53
+ if (value === lastEmittedValue) return;
54
+ dateValue = parseValue(value);
55
+ lastEmittedValue = value;
29
56
  });
30
57
 
31
- function handleInput(e: Event) {
32
- const target = e.target as HTMLInputElement;
33
- localValue = target.value;
34
- value = target.value ? toIso(target.value) : '';
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
- value = '';
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
- <Input
45
- {...props}
46
- value={localValue}
47
- oninput={handleInput}
48
- type="date"
49
- min={field.minDate ? toInputFormat(field.minDate) : undefined}
50
- max={field.maxDate ? toInputFormat(field.maxDate) : undefined}
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 Input from '../../../components/ui/input/input.svelte';
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
- // Convert ISO datetime to datetime-local format (YYYY-MM-DDTHH:mm)
14
- function toDatetimeLocal(iso: string | undefined): string {
15
- if (!iso) return '';
16
- const date = new Date(iso);
17
- if (isNaN(date.getTime())) return '';
18
- return date.toISOString().slice(0, 16);
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
- // Convert datetime-local to ISO format
22
- function toIso(local: string): string {
23
- if (!local) return '';
24
- return new Date(local).toISOString();
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
- let localValue = $state(toDatetimeLocal(value));
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
- localValue = toDatetimeLocal(value);
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
- function handleInput(e: Event) {
34
- const target = e.target as HTMLInputElement;
35
- localValue = target.value;
36
- value = target.value ? toIso(target.value) : '';
37
- }
72
+ $effect(() => {
73
+ const d = dateValue;
74
+ const h = hour;
75
+ const m = minute;
38
76
 
39
- onMount(() => {
40
- if (value === undefined) {
41
- value = '';
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
- <Input
47
- {...props}
48
- value={localValue}
49
- oninput={handleInput}
50
- type="datetime-local"
51
- min={field.minDate ? toDatetimeLocal(field.minDate) : undefined}
52
- max={field.maxDate ? toDatetimeLocal(field.maxDate) : undefined}
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>;