@kahitsan/ksui 0.39.1 → 0.39.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.39.1",
3
+ "version": "0.39.3",
4
4
  "description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,5 +1,6 @@
1
1
  import { createEffect, Show, For } from "solid-js";
2
2
  import AccountAvatar from "../base/AccountAvatar";
3
+ import Button from "../base/Button";
3
4
  import type { PaymentAccountOption } from "./PaymentAccountPicker";
4
5
 
5
6
  export interface AccountRadioPickerProps {
@@ -9,6 +10,8 @@ export interface AccountRadioPickerProps {
9
10
  ariaLabel: string;
10
11
  excludeId?: string;
11
12
  autoDefault?: boolean;
13
+ compact?: boolean;
14
+ tone?: "income" | "expense" | "transfer";
12
15
  }
13
16
 
14
17
  export default function AccountRadioPicker(props: AccountRadioPickerProps) {
@@ -82,6 +85,7 @@ export default function AccountRadioPicker(props: AccountRadioPickerProps) {
82
85
  aria-label={props.ariaLabel}
83
86
  tabIndex={-1}
84
87
  class="grid max-sm:grid-cols-2 sm:grid-cols-3 gap-2"
88
+ classList={{ "ks-transaction-account-picker": props.compact }}
85
89
  onKeyDown={onKeyDown}
86
90
  >
87
91
  <For each={visible()}>
@@ -89,17 +93,29 @@ export default function AccountRadioPicker(props: AccountRadioPickerProps) {
89
93
  const selected = () => props.value === a.id.toString();
90
94
  const isTabStop = () => selected() || (!props.value && i() === 0);
91
95
  return (
92
- <button
93
- ref={(el) => (buttonRefs[i()] = el)}
96
+ <Button
97
+ ref={(el: HTMLButtonElement) => (buttonRefs[i()] = el)}
94
98
  type="button"
95
99
  role="radio"
96
100
  aria-checked={selected()}
97
101
  tabIndex={isTabStop() ? 0 : -1}
102
+ intent="secondary"
103
+ variant="clip1"
104
+ size="sm"
105
+ noGlow
106
+ noRipple
107
+ noScanline
98
108
  onClick={() => props.onChange(a.id.toString())}
99
- class="group flex items-center gap-2 rounded-lg border px-3 py-3 text-left text-sm transition-colors cursor-pointer ks-hud-clip-top-left-bottom-right"
109
+ class="group flex items-center gap-2 px-3 py-3 text-left text-sm transition-colors cursor-pointer"
100
110
  classList={{
101
111
  "border-[color-mix(in_srgb,var(--ks-accent,#fbbf24)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-accent,#fbbf24)_10%,transparent)] text-[var(--ks-accent-hover,#fcd34d)]":
102
- selected(),
112
+ selected() && (!props.compact || !props.tone),
113
+ "border-[color-mix(in_srgb,var(--ks-success,#10b981)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-success,#10b981)_10%,transparent)] text-[var(--ks-success-fg,#34d399)]":
114
+ selected() && props.compact && props.tone === "income",
115
+ "border-[color-mix(in_srgb,var(--ks-danger,#ef4444)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-danger,#ef4444)_10%,transparent)] text-[var(--ks-danger-fg,#f87171)]":
116
+ selected() && props.compact && props.tone === "expense",
117
+ "border-[color-mix(in_srgb,var(--ks-info,#38bdf8)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-info,#38bdf8)_10%,transparent)] text-[var(--ks-info,#38bdf8)]":
118
+ selected() && props.compact && props.tone === "transfer",
103
119
  "border-[var(--ks-border-strong,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-surface-raised,#1a1a1a)_50%,transparent)] text-[var(--ks-fg-muted,#a1a1aa)] hover:border-[var(--ks-border-strong,#3f3f46)] hover:bg-[var(--ks-surface-raised,#1a1a1a)]":
104
120
  !selected(),
105
121
  }}
@@ -114,7 +130,7 @@ export default function AccountRadioPicker(props: AccountRadioPickerProps) {
114
130
  }
115
131
  />
116
132
  <span class="truncate">{a.name}</span>
117
- </button>
133
+ </Button>
118
134
  );
119
135
  }}
120
136
  </For>
@@ -23,6 +23,7 @@ export interface TransactionAccountFieldsProps {
23
23
  transferFeeAmount: string;
24
24
  transferFeeEnabled: boolean;
25
25
  allowTransferFee: boolean;
26
+ compact?: boolean;
26
27
  }
27
28
 
28
29
  /**
@@ -43,6 +44,8 @@ export default function TransactionAccountFields(props: TransactionAccountFields
43
44
  value={
44
45
  props.category === "sale" ? props.destAccount : props.sourceAccount
45
46
  }
47
+ compact={props.compact}
48
+ tone={props.category === "sale" ? "income" : "expense"}
46
49
  onChange={(v) => {
47
50
  if (props.category === "sale") {
48
51
  props.setDestAccount(v);
@@ -299,10 +299,35 @@ export interface TransactionFormProps {
299
299
  }
300
300
 
301
301
  export default function TransactionForm(props: TransactionFormProps) {
302
- injectCSS("ksui-transaction-form-compact", `.ks-transaction-compact{min-height:0;font-family:var(--ks-font-body,"Inter", system-ui, sans-serif)}.ks-transaction-compact-body{display:flex;flex-direction:column;min-height:0;overflow:auto;padding:1rem 1.25rem 1.5rem;gap:1rem}.ks-transaction-compact-body>div{width:100%;flex:0 0 auto}.ks-transaction-compact-body input[data-testid="transactions-form-amount"]{min-height:3.5rem;text-align:center;font-size:2rem}.ks-transaction-compact-body input[data-testid="transactions-form-description"]{background:transparent;border-color:var(--ks-border)}.ks-transaction-compact-body>div:has(input[data-testid="transactions-form-amount"])>label,.ks-transaction-compact-body>div:has(input[data-testid="transactions-form-description"])>label{display:none}.ks-transaction-compact-body>div:has(button[aria-label="Today"])>label{font-size:0;text-transform:uppercase;letter-spacing:.12em}.ks-transaction-compact-body>div:has(button[aria-label="Today"])>label:after{content:"TRANSACTION DATE";font-size:10px}.ks-transaction-compact-body [role="radiogroup"]{display:flex;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}.ks-transaction-compact-body [role="radiogroup"]::-webkit-scrollbar{display:none}.ks-transaction-compact-body [role="radio"]{min-width:8rem}.ks-transaction-compact-footer{flex:0 0 auto}.ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has(button[data-testid="form-payee-picker"]),.ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has([data-testid="subcategory-select"]),.ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has(textarea),.ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has(button[aria-label="Add attachment"]){display:none}
303
- .ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-secondary{display:none;}
304
- .ks-transaction-compact .ks-transaction-compact-body{display:flex;flex-direction:column;min-height:0;overflow:auto;}
305
- `);
302
+ injectCSS(
303
+ "ksui-transaction-form-compact",
304
+ `.ks-transaction-compact{display:flex;flex-direction:column;min-height:0;height:100%;font-family:var(--ks-font-body,"Inter", system-ui, sans-serif)}
305
+ .ks-transaction-compact form{display:flex!important;flex:1 1 auto!important;flex-direction:column!important;min-height:0!important;overflow:hidden!important}
306
+ .ks-transaction-compact-type-switcher{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.375rem;margin:0 1.25rem .25rem;padding:.375rem;border-radius:.75rem;background:color-mix(in srgb,var(--ks-fg,#ffffff) 5%,transparent)}
307
+ .ks-transaction-compact-type-switcher>button{min-height:2.25rem;border:0;border-radius:.5rem;padding:.5rem .25rem}
308
+ .ks-transaction-compact-type-switcher>button.ks-transaction-type-active{background:color-mix(in srgb,var(--ks-primary,#c9a961) 16%,transparent);color:var(--ks-primary,#c9a961)}
309
+ .ks-transaction-compact-body{display:flex!important;flex:1 1 auto!important;flex-direction:column!important;width:100%!important;min-height:0!important;overflow-x:hidden!important;overflow-y:auto!important;padding:1rem 1.25rem 1.5rem!important;gap:1rem}
310
+ .ks-transaction-compact-body>div{width:100%!important;flex:0 0 auto!important}
311
+ .ks-transaction-compact-footer{flex:0 0 auto!important}
312
+ .ks-transaction-compact-body>.ks-transaction-compact-amount{order:2;padding:0;background:transparent}
313
+ .ks-transaction-compact-body>.ks-transaction-compact-description{order:3}
314
+ .ks-transaction-compact-body>.ks-transaction-compact-date{order:4;padding:.75rem;border:1px solid color-mix(in srgb,var(--ks-border,rgba(39,39,42,0.5)) 70%,transparent);border-radius:.75rem;background:color-mix(in srgb,var(--ks-overlay,rgba(0,0,0,0.7)) 78%,transparent)}
315
+ .ks-transaction-compact-body>.ks-transaction-compact-account{order:5}
316
+ .ks-transaction-compact-body>.ks-transaction-compact-details-toggle{order:6;display:flex;align-items:center;gap:.75rem;width:100%;padding:.25rem 0;color:var(--ks-fg-subtle,#71717a)}
317
+ .ks-transaction-compact-body>button:before,.ks-transaction-compact-body>button:after{content:"";height:1px;flex:1;background:color-mix(in srgb,var(--ks-fg,#ffffff) 8%,transparent)}
318
+ .ks-transaction-compact-body>div:has(input[data-testid="transactions-form-amount"])>label,.ks-transaction-compact-body>div:has(input[data-testid="transactions-form-description"])>label{display:none}
319
+ .ks-transaction-compact-body input,.ks-transaction-compact-body select,.ks-transaction-compact-body textarea{border-radius:.625rem}
320
+ .ks-transaction-compact-body input[data-testid="transactions-form-amount"]{height:4rem;font-family:var(--ks-font-mono,ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace);font-size:3.25rem;line-height:1;text-align:center}
321
+ .ks-transaction-compact-body input[data-testid="transactions-form-description"]{background:transparent;border-color:var(--ks-border)}
322
+ .ks-transaction-compact-body>div:has(button[aria-label="Today"])>label{font-size:10px;display:flex;align-items:center;gap:.5rem;color:color-mix(in srgb,var(--ks-fg,#ffffff) 35%,transparent);text-transform:uppercase;letter-spacing:.12em}
323
+ .ks-transaction-compact-body>div:has(button[aria-label="Today"])>label:before{content:"◷";font-size:15px;color:var(--ks-fg-muted,#a1a1aa)}
324
+ .ks-transaction-compact-body [role="radiogroup"]{display:flex;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}
325
+ .ks-transaction-compact-body [role="radiogroup"]::-webkit-scrollbar{display:none}
326
+ .ks-transaction-compact-body [role="radio"]{min-width:8rem}
327
+ .ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has([data-testid="form-payee-picker-trigger"]),.ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has([data-testid="subcategory-select"]),.ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has(textarea),.ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has([data-testid="mention-textarea"]),.ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has(button[aria-label="Add attachment"]){display:none}
328
+ .ks-transaction-compact-footer>div:last-child>button{min-height:2.75rem}
329
+ `,
330
+ );
306
331
  const compact = () => props.layout === "compact";
307
332
  const catConfig = () =>
308
333
  CATEGORY_FORM[props.category] || CATEGORY_FORM.expense;
@@ -446,6 +471,39 @@ export default function TransactionForm(props: TransactionFormProps) {
446
471
  </div>
447
472
  </Show>
448
473
 
474
+ <Show when={compact() && !props.simpleMode}>
475
+ <div class="ks-transaction-compact-type-switcher" role="tablist" aria-label="Transaction type">
476
+ <For each={categoryOptions()}>
477
+ {(cat) => (
478
+ <Button
479
+ type="button"
480
+ role="tab"
481
+ aria-selected={props.category === cat}
482
+ data-testid={`transactions-form-category-${cat}`}
483
+ intent="secondary"
484
+ variant="ghost"
485
+ size="sm"
486
+ noGlow
487
+ noRipple
488
+ noScanline
489
+ class={`ks-transaction-type-button ks-transaction-type-${cat}`}
490
+ classList={{ "ks-transaction-type-active": props.category === cat }}
491
+ onClick={() => {
492
+ props.setCategory(cat);
493
+ props.setSourceAccount("");
494
+ props.setDestAccount("");
495
+ if (cat !== "business") {
496
+ props.setTransferFeeEnabled(false);
497
+ props.setTransferFeeAmount("");
498
+ }
499
+ }}
500
+ >
501
+ {CATEGORY_FORM[cat].label}
502
+ </Button>
503
+ )}
504
+ </For>
505
+ </div>
506
+ </Show>
449
507
  <Show when={cameraOpen()}>
450
508
  <CameraCapture
451
509
  onCapture={(file) => {
@@ -474,56 +532,6 @@ export default function TransactionForm(props: TransactionFormProps) {
474
532
  </div>
475
533
  </Show>
476
534
 
477
- <Show when={!props.simpleMode}>
478
- <div>
479
- <div class="text-[10px] uppercase tracking-widest text-[var(--ks-fg-subtle,#71717a)] font-semibold mb-2">
480
- Type
481
- </div>
482
- <div
483
- class="grid gap-2"
484
- classList={{
485
- "grid-cols-3": categoryOptions().length === 3,
486
- "grid-cols-4": categoryOptions().length === 4,
487
- }}
488
- >
489
- <For each={categoryOptions()}>
490
- {(cat) => {
491
- const cfg = CATEGORY_FORM[cat];
492
- const tone = CATEGORY_TONE[cat];
493
- const tc = TONE_CLASSES[tone.tone];
494
- const Ico = tone.icon;
495
- return (
496
- <button
497
- type="button"
498
- data-testid={`transactions-form-category-${cat}`}
499
- onClick={() => {
500
- props.setCategory(cat);
501
- props.setSourceAccount("");
502
- props.setDestAccount("");
503
- if (cat !== "business") {
504
- props.setTransferFeeEnabled(false);
505
- props.setTransferFeeAmount("");
506
- }
507
- }}
508
- class="flex min-h-[42px] items-center justify-center gap-2 px-3 py-2 border text-sm transition-colors ks-hud-clip-button cursor-pointer active:opacity-80"
509
- classList={{
510
- [`${tc.bg} ${tc.border} ${tc.text}`]:
511
- props.category === cat,
512
- "border-[var(--ks-border,rgba(39,39,42,0.5))] bg-transparent text-[var(--ks-fg-subtle,#71717a)] hover:border-[var(--ks-input-border,#3f3f46)] hover:text-[var(--ks-fg,#ffffff)]":
513
- props.category !== cat,
514
- }}
515
- >
516
- <Ico size={16} />
517
- <span class="font-medium">{cfg.label}</span>
518
- </button>
519
- );
520
- }}
521
- </For>
522
- </div>
523
- <p class="text-[11px] text-[var(--ks-fg-subtle,#71717a)] mt-2">{catConfig().hint}</p>
524
- </div>
525
- </Show>
526
-
527
535
  <Show when={props.category === "sale"}>
528
536
  <SalesBodyEditor
529
537
  items={props.saleItems}
@@ -540,6 +548,7 @@ export default function TransactionForm(props: TransactionFormProps) {
540
548
  <Show
541
549
  when={!(props.category === "sale" && props.saleItems.length > 0)}
542
550
  >
551
+ <div class={compact() ? "ks-transaction-compact-amount" : undefined}>
543
552
  <FormField label={compact() ? "" : "Amount *"}>
544
553
  <div class="flex items-stretch gap-2 px-4 py-3 border bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_60%,transparent)] border-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_60%,transparent)] ks-hud-clip-button focus-within:border-[color-mix(in_srgb,var(--ks-focus-ring,#c9a961)_50%,transparent)] transition-colors">
545
554
  <span class="self-center text-3xl font-bold text-[var(--ks-fg-subtle,#71717a)] tabular-nums">
@@ -570,6 +579,7 @@ export default function TransactionForm(props: TransactionFormProps) {
570
579
  </Show>
571
580
  </div>
572
581
  </FormField>
582
+ </div>
573
583
  <Show
574
584
  when={
575
585
  props.category === "business" &&
@@ -607,6 +617,7 @@ export default function TransactionForm(props: TransactionFormProps) {
607
617
  </Show>
608
618
  </Show>
609
619
 
620
+ <div class={compact() ? "ks-transaction-compact-date" : undefined}>
610
621
  <FormField label={compact() ? "TRANSACTION DATE" : "Date *"}>
611
622
  <DatePicker
612
623
  value={props.date}
@@ -619,6 +630,7 @@ export default function TransactionForm(props: TransactionFormProps) {
619
630
  </p>
620
631
  </Show>
621
632
  </FormField>
633
+ </div>
622
634
 
623
635
  <Show when={props.isBackdated}>
624
636
  <div class="rounded-lg border border-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_20%,transparent)] bg-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_5%,transparent)] px-3 py-2">
@@ -638,6 +650,7 @@ export default function TransactionForm(props: TransactionFormProps) {
638
650
  </div>
639
651
  </Show>
640
652
 
653
+ <div class={compact() ? "ks-transaction-compact-description" : undefined}>
641
654
  <FormField label={compact() ? "" : "Description *"}>
642
655
  <input
643
656
  type="text"
@@ -649,9 +662,10 @@ export default function TransactionForm(props: TransactionFormProps) {
649
662
  required
650
663
  />
651
664
  </FormField>
665
+ </div>
652
666
 
653
667
  <Show when={catConfig().showPayee}>
654
- <div class="grid grid-cols-2 gap-4" classList={{ "ks-transaction-compact-secondary": compact() }}>
668
+ <div class="grid grid-cols-2 gap-4">
655
669
  <FormField label={catConfig().payeeLabel!}>
656
670
  <ComboBox<PayeeOption>
657
671
  testIdPrefix="form-payee-picker"
@@ -699,8 +713,7 @@ export default function TransactionForm(props: TransactionFormProps) {
699
713
  </div>
700
714
  </Show>
701
715
  <Show when={!catConfig().showPayee}>
702
- <div classList={{ "ks-transaction-compact-secondary": compact() }}>
703
- <FormField label="Reference #">
716
+ <FormField label="Reference #">
704
717
  <input
705
718
  type="text"
706
719
  value={props.refNumber}
@@ -708,13 +721,11 @@ export default function TransactionForm(props: TransactionFormProps) {
708
721
  class="w-full bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_60%,transparent)] border border-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_60%,transparent)] px-3 py-3 text-sm text-[var(--ks-fg,#ffffff)] ks-hud-clip-button focus:outline-none focus:border-[color-mix(in_srgb,var(--ks-focus-ring,#c9a961)_50%,transparent)]"
709
722
  placeholder="Reference number (optional)"
710
723
  />
711
- </FormField>
712
- </div>
724
+ </FormField>
713
725
  </Show>
714
726
 
715
727
  <Show when={subcategoryAppliesTo() !== null}>
716
- <div classList={{ "ks-transaction-compact-secondary": compact() }}>
717
- <FormField label="Category">
728
+ <FormField label="Category">
718
729
  <Show
719
730
  when={subcategoryOptionsReady()}
720
731
  fallback={
@@ -760,13 +771,11 @@ export default function TransactionForm(props: TransactionFormProps) {
760
771
  <p class="text-[10px] text-[var(--ks-fg-subtle,#71717a)] mt-0.5">
761
772
  Optional. Used for tax-prep classification.
762
773
  </p>
763
- </FormField>
764
- </div>
774
+ </FormField>
765
775
  </Show>
766
776
 
767
777
  <Show when={props.category === "payable"}>
768
- <div classList={{ "ks-transaction-compact-secondary": compact() }}>
769
- <TransactionPayableFields
778
+ <TransactionPayableFields
770
779
  payableKind={props.payableKind}
771
780
  setPayableKind={props.setPayableKind}
772
781
  dueDate={props.dueDate}
@@ -775,10 +784,10 @@ export default function TransactionForm(props: TransactionFormProps) {
775
784
  setChequeNumber={props.setChequeNumber}
776
785
  pdcStatus={props.pdcStatus}
777
786
  setPdcStatus={props.setPdcStatus}
778
- />
779
- </div>
787
+ />
780
788
  </Show>
781
789
 
790
+ <div class={compact() ? "ks-transaction-compact-account" : undefined}>
782
791
  <TransactionAccountFields
783
792
  category={props.category}
784
793
  accounts={props.accounts}
@@ -794,9 +803,10 @@ export default function TransactionForm(props: TransactionFormProps) {
794
803
  transferFeeAmount={props.transferFeeAmount}
795
804
  transferFeeEnabled={props.transferFeeEnabled}
796
805
  allowTransferFee={props.allowTransferFee}
806
+ compact={compact()}
797
807
  />
808
+ </div>
798
809
 
799
- <div classList={{ "ks-transaction-compact-secondary": compact() }}>
800
810
  <FormField label="Notes">
801
811
  <MentionTextarea
802
812
  value={props.notes}
@@ -807,7 +817,6 @@ export default function TransactionForm(props: TransactionFormProps) {
807
817
  ariaLabel="Notes"
808
818
  />
809
819
  </FormField>
810
- </div>
811
820
 
812
821
  <div>
813
822
  <div class="flex items-center gap-1 mb-2 text-xs text-[var(--ks-fg-subtle,#71717a)]">
@@ -908,7 +917,7 @@ export default function TransactionForm(props: TransactionFormProps) {
908
917
  </div>
909
918
 
910
919
  <Show when={!props.simpleMode}>
911
- <div class="flex justify-center pt-2">
920
+ <div class="flex justify-center pt-2" classList={{ "ks-transaction-compact-details-toggle": compact() }}>
912
921
  <button
913
922
  type="button"
914
923
  onClick={() =>
@@ -37,6 +37,7 @@ function voucher(over: Partial<VoucherOption> & Pick<VoucherOption, "id" | "code
37
37
  value: 20,
38
38
  max_discount_amount: null,
39
39
  applicable_packages: null,
40
+ applicable_package_lineages: null,
40
41
  minimum_purchase: 0,
41
42
  valid_from: null,
42
43
  valid_until: null,
@@ -379,6 +380,52 @@ describe("VoucherPicker dialog", () => {
379
380
  );
380
381
  });
381
382
 
383
+ it("accepts a voucher when each package matches by aligned lineage", async () => {
384
+ mockPagedFetch([
385
+ voucher({
386
+ id: 35,
387
+ code: "LINEAGEOK",
388
+ applicable_packages: [99],
389
+ applicable_package_lineages: ["day-pass"],
390
+ }),
391
+ ]);
392
+ const { getByTestId } = render(() => (
393
+ <VoucherPicker
394
+ selected={null}
395
+ onChange={vi.fn()}
396
+ subtotal={1000}
397
+ packageIds={[1]}
398
+ packageLineages={["day-pass"]}
399
+ />
400
+ ));
401
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
402
+ await waitFor(() => expect(getByTestId("voucher-picker-result-35")).toBeTruthy());
403
+ });
404
+ it("rejects a voucher when aligned package lineage does not match", async () => {
405
+ mockPagedFetch([
406
+ voucher({
407
+ id: 36,
408
+ code: "LINEAGEBAD",
409
+ applicable_packages: [99],
410
+ applicable_package_lineages: ["day-pass"],
411
+ }),
412
+ ]);
413
+ const { getByTestId, queryByTestId } = render(() => (
414
+ <VoucherPicker
415
+ selected={null}
416
+ onChange={vi.fn()}
417
+ subtotal={1000}
418
+ packageIds={[1]}
419
+ packageLineages={["single-use"]}
420
+ />
421
+ ));
422
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
423
+ await waitFor(() => expect(getByTestId("voucher-picker-inapplicable-36")).toBeTruthy());
424
+ expect(queryByTestId("voucher-picker-result-36")).toBeNull();
425
+ expect(getByTestId("voucher-picker-inapplicable-36").textContent).toContain(
426
+ "Doesn't cover every item",
427
+ );
428
+ });
382
429
  it("surfaces a load failure instead of rendering an empty list", async () => {
383
430
  vi.stubGlobal(
384
431
  "fetch",
@@ -23,6 +23,7 @@ export interface VoucherOption {
23
23
  value: string | number | null;
24
24
  max_discount_amount: string | number | null;
25
25
  applicable_packages: number[] | null;
26
+ applicable_package_lineages?: string[] | null;
26
27
  minimum_purchase: string | number;
27
28
  valid_from: string | null;
28
29
  valid_until: string | null;
@@ -42,6 +43,7 @@ interface VoucherPickerProps {
42
43
  onChange: (next: VoucherOption | null) => void;
43
44
  subtotal: number;
44
45
  packageIds: number[];
46
+ packageLineages?: (string | null)[];
45
47
  disabled?: boolean;
46
48
  compact?: boolean;
47
49
  /** Same-shape endpoint override (defaults to the vouchers plugin's own API) —
@@ -104,6 +106,7 @@ function ineligibilityReason(
104
106
  voucher: VoucherOption,
105
107
  subtotal: number,
106
108
  packageIds: number[],
109
+ packageLineages: (string | null)[],
107
110
  todayIso: string,
108
111
  ): string | null {
109
112
  if (!voucher.is_active) return "Inactive";
@@ -116,10 +119,17 @@ function ineligibilityReason(
116
119
  if (usageExhausted(voucher)) return "Fully redeemed";
117
120
  if (asNumber(voucher.minimum_purchase) > subtotal)
118
121
  return `Needs ${formatCurrency(asNumber(voucher.minimum_purchase))} minimum`;
119
- if (voucher.applicable_packages && voucher.applicable_packages.length > 0) {
122
+ const allowedIds = voucher.applicable_packages ?? [];
123
+ const allowedLineages = voucher.applicable_package_lineages ?? [];
124
+ if (allowedIds.length > 0 || allowedLineages.length > 0) {
120
125
  if (packageIds.length === 0) return "Only for specific items";
121
- const allowed = new Set(voucher.applicable_packages);
122
- if (!packageIds.every((id) => allowed.has(id))) return "Doesn't cover every item";
126
+ const allowed = new Set(allowedIds);
127
+ if (packageIds.some((id, index) =>
128
+ !allowed.has(id) &&
129
+ !(packageLineages[index] != null && allowedLineages.includes(packageLineages[index]!))
130
+ )) {
131
+ return "Doesn't cover every item";
132
+ }
123
133
  }
124
134
  return null;
125
135
  }
@@ -129,9 +139,10 @@ function isApplicable(
129
139
  voucher: VoucherOption,
130
140
  subtotal: number,
131
141
  packageIds: number[],
142
+ packageLineages: (string | null)[],
132
143
  todayIso: string,
133
144
  ): boolean {
134
- return ineligibilityReason(voucher, subtotal, packageIds, todayIso) === null;
145
+ return ineligibilityReason(voucher, subtotal, packageIds, packageLineages, todayIso) === null;
135
146
  }
136
147
 
137
148
  function formatVoucherDescription(v: VoucherOption): string {
@@ -293,16 +304,16 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
293
304
 
294
305
  const applicable = createMemo(() => {
295
306
  const today_ = today();
296
- return vouchers().filter((v) => isApplicable(v, props.subtotal, props.packageIds, today_));
307
+ return vouchers().filter((v) => isApplicable(v, props.subtotal, props.packageIds, props.packageLineages ?? [], today_));
297
308
  });
298
309
 
299
310
  const inapplicable = createMemo(() => {
300
311
  const today_ = today();
301
312
  return vouchers()
302
- .filter((v) => !isApplicable(v, props.subtotal, props.packageIds, today_))
313
+ .filter((v) => !isApplicable(v, props.subtotal, props.packageIds, props.packageLineages ?? [], today_))
303
314
  .map((v) => ({
304
315
  voucher: v,
305
- reason: ineligibilityReason(v, props.subtotal, props.packageIds, today_) ?? "",
316
+ reason: ineligibilityReason(v, props.subtotal, props.packageIds, props.packageLineages ?? [], today_) ?? "",
306
317
  }));
307
318
  });
308
319