@kahitsan/ksui 0.3.0 → 0.4.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/README.md +109 -26
- package/host-ui.d.ts +4 -4
- package/package.json +7 -3
- package/src/components/{AccountAvatar.tsx → base/AccountAvatar.tsx} +10 -10
- package/src/components/base/ChartLegend.tsx +34 -0
- package/src/components/base/CopyButton.tsx +53 -0
- package/src/components/base/DetailRow.tsx +17 -0
- package/src/components/{ExistingAttachmentTile.tsx → base/ExistingAttachmentTile.tsx} +2 -2
- package/src/components/base/FormErrorBanner.tsx +27 -0
- package/src/components/base/FormField.tsx +19 -0
- package/src/components/base/ImageCropper.tsx +275 -0
- package/src/components/base/KpiCard.tsx +125 -0
- package/src/components/base/ProgressBar.tsx +328 -0
- package/src/components/base/RadioCardGroup.tsx +146 -0
- package/src/components/base/SegmentedFilter.tsx +50 -0
- package/src/components/base/StatusPill.tsx +90 -0
- package/src/components/base/TagPill.tsx +24 -0
- package/src/components/base/Tooltip.tsx +64 -0
- package/src/components/{ClientPicker.tsx → composite/ClientPicker.tsx} +1 -1
- package/src/components/composite/FormActions.tsx +79 -0
- package/src/components/composite/LiveTimer.tsx +434 -0
- package/src/components/{MarkdownNotes.tsx → composite/MarkdownNotes.tsx} +1 -1
- package/src/components/{PaymentAccountPicker.tsx → composite/PaymentAccountPicker.tsx} +2 -2
- package/src/components/composite/SecretReveal.tsx +63 -0
- package/src/components/{VoucherPicker.tsx → composite/VoucherPicker.tsx} +1 -1
- package/src/index.ts +84 -27
- package/src/utils/INPUT_CLASS.ts +7 -0
- package/src/{lib → utils}/account-logo-url.ts +1 -1
- package/src/{lib → utils}/accounts-index.tsx +2 -2
- package/src/{lib → utils}/attachments.ts +3 -3
- package/src/utils/formatFullDate.ts +14 -0
- package/src/utils/formatPHP.ts +13 -0
- package/src/utils/formatShortDate.ts +17 -0
- /package/src/components/{AddAttachmentTile.tsx → base/AddAttachmentTile.tsx} +0 -0
- /package/src/components/{CameraCapture.tsx → base/CameraCapture.tsx} +0 -0
- /package/src/components/{MentionTextarea.tsx → composite/MentionTextarea.tsx} +0 -0
- /package/src/{lib → utils}/account-icons.ts +0 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createUniqueId, type JSX } from "solid-js";
|
|
2
|
+
|
|
3
|
+
interface TooltipProps {
|
|
4
|
+
/** Text or JSX to show inside the tooltip bubble. */
|
|
5
|
+
content: JSX.Element;
|
|
6
|
+
/** Where the bubble sits relative to the trigger. Default "top". */
|
|
7
|
+
placement?: "top" | "bottom";
|
|
8
|
+
/** Allow the trigger to be any element; the wrapper is a span. */
|
|
9
|
+
children: JSX.Element;
|
|
10
|
+
/** Extra classes on the wrapper. The wrapper is inline-flex so it sits
|
|
11
|
+
* cleanly inside flex rows next to the trigger element. */
|
|
12
|
+
class?: string;
|
|
13
|
+
/** Optional explicit id for the tooltip element. Auto-generated via
|
|
14
|
+
* `createUniqueId()` when omitted. Callers can pass this id forward
|
|
15
|
+
* as `aria-describedby` on a deeper focusable trigger when the wrapper
|
|
16
|
+
* span isn't the focusable surface — the W3C tooltip pattern wants
|
|
17
|
+
* describedby on the trigger itself, not on an ancestor. The wrapper
|
|
18
|
+
* also carries the same describedby so SRs that read by element type
|
|
19
|
+
* surface the description on the way through. */
|
|
20
|
+
id?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Lightweight hover/focus tooltip. CSS-only — no JS positioning, no portal.
|
|
24
|
+
// Anchors to the trigger via group-hover and stays out of layout (absolute).
|
|
25
|
+
// For dense / clipped containers, prefer a portal-based picker instead; this
|
|
26
|
+
// is for short copy on buttons and chips ("Mark as payer", "Removes
|
|
27
|
+
// selections; nothing else is lost", etc.) that needs to read more clearly
|
|
28
|
+
// than the native `title=` attribute.
|
|
29
|
+
//
|
|
30
|
+
// A11y notes:
|
|
31
|
+
// - The bubble carries `role="tooltip"` and a stable id, so assistive
|
|
32
|
+
// tech can find it via the AT tree. We removed an earlier `aria-hidden`
|
|
33
|
+
// because it actively contradicted the role: ATs saw "tooltip" and
|
|
34
|
+
// then "skip me", which left screen-reader users with a tooltip
|
|
35
|
+
// advertised but unreadable.
|
|
36
|
+
// - The wrapper carries `aria-describedby={tooltipId}` so navigating
|
|
37
|
+
// into the wrapper surfaces the description. The W3C tooltip pattern
|
|
38
|
+
// prefers describedby on the focusable trigger itself; since the
|
|
39
|
+
// trigger is `props.children` (any element) and Solid has no
|
|
40
|
+
// cloneElement, the trigger-level wiring lives with the caller in
|
|
41
|
+
// specific cases that need it. See #627 for the broader sweep that
|
|
42
|
+
// introduces a directive-based wiring API.
|
|
43
|
+
export default function Tooltip(props: TooltipProps): JSX.Element {
|
|
44
|
+
const tooltipId = props.id ?? createUniqueId();
|
|
45
|
+
const placement = () => props.placement ?? "top";
|
|
46
|
+
return (
|
|
47
|
+
<span
|
|
48
|
+
class={`relative inline-flex group/tt ${props.class ?? ""}`}
|
|
49
|
+
data-tooltip-wrap
|
|
50
|
+
aria-describedby={tooltipId}
|
|
51
|
+
>
|
|
52
|
+
{props.children}
|
|
53
|
+
<span
|
|
54
|
+
role="tooltip"
|
|
55
|
+
id={tooltipId}
|
|
56
|
+
class={`pointer-events-none absolute left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 border border-zinc-700/80 text-[11px] text-zinc-100 whitespace-nowrap opacity-0 group-hover/tt:opacity-100 group-focus-within/tt:opacity-100 transition-opacity duration-150 delay-150 z-50 shadow-lg ${
|
|
57
|
+
placement() === "bottom" ? "top-full mt-1" : "bottom-full mb-1"
|
|
58
|
+
}`}
|
|
59
|
+
>
|
|
60
|
+
{props.content}
|
|
61
|
+
</span>
|
|
62
|
+
</span>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Source: KahitSan/kserp src/components/ClientPicker.tsx (vendored into the plugin remote).
|
|
2
2
|
//
|
|
3
3
|
// Cross-plugin picker: fetches the SIBLING clients plugin's public API at
|
|
4
|
-
// /api/clients. Degrades gracefully
|
|
4
|
+
// /api/clients. Degrades gracefully: when the clients plugin isn't deployed
|
|
5
5
|
// the fetch 404s/fails, the popup shows an inline "couldn't load" notice, and
|
|
6
6
|
// the rest of the transaction modal still works (the sale just has no
|
|
7
7
|
// billed-to client). highlightMatch comes from the host UI kit.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Cancel / Submit modal footer row, composing the host Button.
|
|
2
|
+
//
|
|
3
|
+
// The same footer is repeated across plugin modals: a
|
|
4
|
+
// `flex flex-col-reverse sm:flex-row gap-2 sm:justify-end` row with a
|
|
5
|
+
// secondary Cancel button and a primary submit button (often disabled while
|
|
6
|
+
// saving, sometimes with a leading icon). This promotes that footer layout and
|
|
7
|
+
// the standard Cancel/Submit wiring, leaving all domain concerns (labels,
|
|
8
|
+
// handlers, saving state, optional icon) as props. No fetch/API call lives
|
|
9
|
+
// here — the caller owns the submit handler.
|
|
10
|
+
//
|
|
11
|
+
// ModalShell is deliberately NOT promoted: the host Modal already supplies the
|
|
12
|
+
// backdrop, card, title/close-X path, aria-modal, focus trap, size and tone
|
|
13
|
+
// tokens. Only the body chrome the host Modal does not cover (FormErrorBanner +
|
|
14
|
+
// FormActions) is promoted.
|
|
15
|
+
|
|
16
|
+
import type { JSX } from "solid-js";
|
|
17
|
+
import { Show } from "solid-js";
|
|
18
|
+
import { Button } from "@kserp/host-ui";
|
|
19
|
+
|
|
20
|
+
export interface FormActionsProps {
|
|
21
|
+
/** Invoked when the Cancel button is clicked. */
|
|
22
|
+
onCancel: () => void;
|
|
23
|
+
/**
|
|
24
|
+
* Invoked when the Submit button is clicked. Omit when `submitType="submit"`
|
|
25
|
+
* and let the wrapping <form> handle submission.
|
|
26
|
+
*/
|
|
27
|
+
onSubmit?: () => void;
|
|
28
|
+
/** Primary button label. Defaults to "Save". */
|
|
29
|
+
submitLabel?: string;
|
|
30
|
+
/** Secondary button label. Defaults to "Cancel". */
|
|
31
|
+
cancelLabel?: string;
|
|
32
|
+
/** When true, disables the submit button (in-flight save). */
|
|
33
|
+
submitting?: boolean;
|
|
34
|
+
/** Extra condition to disable the submit button. */
|
|
35
|
+
submitDisabled?: boolean;
|
|
36
|
+
/** Optional leading icon rendered before the submit label. */
|
|
37
|
+
submitIcon?: JSX.Element;
|
|
38
|
+
/**
|
|
39
|
+
* Button type for the submit action. Defaults to "button" (handler-driven).
|
|
40
|
+
* Set to "submit" to let a wrapping <form> handle the action; in that case
|
|
41
|
+
* `onSubmit` is ignored.
|
|
42
|
+
*/
|
|
43
|
+
submitType?: "button" | "submit";
|
|
44
|
+
/** Swap the primary button to a destructive tone. */
|
|
45
|
+
danger?: boolean;
|
|
46
|
+
/** Extra classes appended to the footer row. */
|
|
47
|
+
class?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function FormActions(props: FormActionsProps): JSX.Element {
|
|
51
|
+
const isSubmitType = () => props.submitType === "submit";
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
class={
|
|
55
|
+
"flex flex-col-reverse sm:flex-row gap-2 sm:justify-end" +
|
|
56
|
+
(props.class ? " " + props.class : "")
|
|
57
|
+
}
|
|
58
|
+
>
|
|
59
|
+
<Button
|
|
60
|
+
type="button"
|
|
61
|
+
onClick={props.onCancel}
|
|
62
|
+
intent="secondary"
|
|
63
|
+
class="w-full sm:w-auto"
|
|
64
|
+
>
|
|
65
|
+
{props.cancelLabel ?? "Cancel"}
|
|
66
|
+
</Button>
|
|
67
|
+
<Button
|
|
68
|
+
type={isSubmitType() ? "submit" : "button"}
|
|
69
|
+
onClick={isSubmitType() ? undefined : props.onSubmit}
|
|
70
|
+
disabled={(props.submitting ?? false) || (props.submitDisabled ?? false)}
|
|
71
|
+
intent={props.danger ? "danger" : "primary"}
|
|
72
|
+
class="gap-2 w-full sm:w-auto"
|
|
73
|
+
>
|
|
74
|
+
<Show when={props.submitIcon}>{props.submitIcon}</Show>
|
|
75
|
+
{props.submitLabel ?? "Save"}
|
|
76
|
+
</Button>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// Source: archive/pillar app/pillar-ui/composite/LiveTimer/LiveTimer.tsx
|
|
2
|
+
// Reusable realtime / overdue progress display. The ticker module-state
|
|
3
|
+
// is shared across all LiveTimer instances on the page so we don't run
|
|
4
|
+
// N intervals when N cards are on screen — the home page session
|
|
5
|
+
// manager already proved this pattern at scale, so we lift it as-is.
|
|
6
|
+
|
|
7
|
+
import type { Component, JSX } from "solid-js";
|
|
8
|
+
import { Show, createMemo, createSignal, onCleanup, splitProps } from "solid-js";
|
|
9
|
+
import Clock from "lucide-solid/icons/clock";
|
|
10
|
+
import Play from "lucide-solid/icons/play";
|
|
11
|
+
import AlertTriangle from "lucide-solid/icons/triangle-alert";
|
|
12
|
+
import Check from "lucide-solid/icons/check";
|
|
13
|
+
import Calendar from "lucide-solid/icons/calendar";
|
|
14
|
+
import ProgressBar from "../base/ProgressBar";
|
|
15
|
+
|
|
16
|
+
export interface LiveTimerProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "class"> {
|
|
17
|
+
// Core timing
|
|
18
|
+
startAt: Date | string | number;
|
|
19
|
+
endAt?: Date | string | number | null;
|
|
20
|
+
// When true, the timer keeps counting past `endAt` (red overdue
|
|
21
|
+
// visualization) instead of switching to the completed look.
|
|
22
|
+
overdue?: boolean;
|
|
23
|
+
// Optional icon override; default picks one based on scenario.
|
|
24
|
+
icon?: Component<{ size: number; class?: string }>;
|
|
25
|
+
// Optional class merged onto the underlying ProgressBar.
|
|
26
|
+
class?: string;
|
|
27
|
+
// Overrides the scenario's default right-side label (e.g. swap the
|
|
28
|
+
// built-in "Remaining" for a total-duration label like "9h").
|
|
29
|
+
// `null` hides the label entirely.
|
|
30
|
+
label?: string | null;
|
|
31
|
+
// Force-hide the percentage readout regardless of scenario default.
|
|
32
|
+
// Counter pills don't surface percentages; the home page might.
|
|
33
|
+
hidePercentage?: boolean;
|
|
34
|
+
// When set, swaps the pill layout to total-left / countdown-right:
|
|
35
|
+
// [icon] {totalLabel} {countdown}
|
|
36
|
+
// Caller-provided string sits on the LEFT next to the icon; the live
|
|
37
|
+
// countdown moves to the RIGHT where the percentage used to sit.
|
|
38
|
+
// Counter cards use this so cashiers see "4h total" + "5h 23:45" on
|
|
39
|
+
// one row without ellipsis.
|
|
40
|
+
totalLabel?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Dynamic tick — 1s under 24h remaining, 5min over. Shared module-state
|
|
44
|
+
// so the page runs at most ONE interval no matter how many LiveTimers
|
|
45
|
+
// are mounted.
|
|
46
|
+
let dynamicTickSignal: (() => number) | null = null;
|
|
47
|
+
let dynamicIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
48
|
+
let dynamicSubscriberCount = 0;
|
|
49
|
+
let currentInterval = 1000;
|
|
50
|
+
|
|
51
|
+
function getDynamicTick(startSec: number, endSec: number | null, overdue: boolean): () => number {
|
|
52
|
+
if (!dynamicTickSignal) {
|
|
53
|
+
const [tick, setTick] = createSignal(Date.now());
|
|
54
|
+
dynamicTickSignal = tick;
|
|
55
|
+
|
|
56
|
+
const updateInterval = () => {
|
|
57
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
58
|
+
const HOURS_24 = 24 * 3600;
|
|
59
|
+
let newInterval = 1000;
|
|
60
|
+
if (nowSec <= startSec) {
|
|
61
|
+
const rem = startSec - nowSec;
|
|
62
|
+
newInterval = rem > HOURS_24 ? 300000 : 1000;
|
|
63
|
+
} else if (endSec !== null && nowSec < endSec) {
|
|
64
|
+
const rem = endSec - nowSec;
|
|
65
|
+
newInterval = rem > HOURS_24 ? 300000 : 1000;
|
|
66
|
+
} else if (endSec !== null && nowSec >= endSec && overdue) {
|
|
67
|
+
const od = nowSec - endSec;
|
|
68
|
+
newInterval = od > HOURS_24 ? 300000 : 1000;
|
|
69
|
+
}
|
|
70
|
+
if (newInterval !== currentInterval) {
|
|
71
|
+
currentInterval = newInterval;
|
|
72
|
+
if (dynamicIntervalId) clearInterval(dynamicIntervalId);
|
|
73
|
+
dynamicIntervalId = setInterval(() => {
|
|
74
|
+
setTick(Date.now());
|
|
75
|
+
updateInterval();
|
|
76
|
+
}, currentInterval);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
dynamicIntervalId = setInterval(() => {
|
|
81
|
+
setTick(Date.now());
|
|
82
|
+
updateInterval();
|
|
83
|
+
}, currentInterval);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
dynamicSubscriberCount++;
|
|
87
|
+
|
|
88
|
+
onCleanup(() => {
|
|
89
|
+
dynamicSubscriberCount--;
|
|
90
|
+
if (dynamicSubscriberCount === 0 && dynamicIntervalId) {
|
|
91
|
+
clearInterval(dynamicIntervalId);
|
|
92
|
+
dynamicIntervalId = null;
|
|
93
|
+
dynamicTickSignal = null;
|
|
94
|
+
currentInterval = 1000;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return dynamicTickSignal;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Slow tick — 10s, for heavy calculations (progress + scenario) that
|
|
102
|
+
// don't need second-level accuracy.
|
|
103
|
+
let slowTickSignal: (() => number) | null = null;
|
|
104
|
+
let slowIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
105
|
+
let slowSubscriberCount = 0;
|
|
106
|
+
|
|
107
|
+
function getSlowTick(): () => number {
|
|
108
|
+
if (!slowTickSignal) {
|
|
109
|
+
const [tick, setTick] = createSignal(Date.now());
|
|
110
|
+
slowTickSignal = tick;
|
|
111
|
+
slowIntervalId = setInterval(() => setTick(Date.now()), 10000);
|
|
112
|
+
}
|
|
113
|
+
slowSubscriberCount++;
|
|
114
|
+
onCleanup(() => {
|
|
115
|
+
slowSubscriberCount--;
|
|
116
|
+
if (slowSubscriberCount === 0 && slowIntervalId) {
|
|
117
|
+
clearInterval(slowIntervalId);
|
|
118
|
+
slowIntervalId = null;
|
|
119
|
+
slowTickSignal = null;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return slowTickSignal;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ensureDate(value: Date | string | number | null | undefined): Date | undefined {
|
|
126
|
+
if (value == null) return undefined;
|
|
127
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value;
|
|
128
|
+
try {
|
|
129
|
+
const d = new Date(value);
|
|
130
|
+
if (Number.isNaN(d.getTime())) return undefined;
|
|
131
|
+
return d;
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const PAD = ["00", "01", "02", "03", "04", "05", "06", "07", "08", "09"];
|
|
138
|
+
function pad2(n: number): string {
|
|
139
|
+
return n < 10 ? PAD[n] : String(n);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Compact countdown / elapsed format. Spec from the counter cards:
|
|
143
|
+
// * >= 1 day → "Xd Yh" (seconds aren't worth watching at this scale)
|
|
144
|
+
// * >= 1 hour → "Xh MM:SS" (mixed unit, mm:ss keeps it readable)
|
|
145
|
+
// * < 1 hour → "MM:SS" (compact urgency)
|
|
146
|
+
// Returns "0:00" for negatives so an overdue render that briefly hits 0
|
|
147
|
+
// doesn't blink to "-1:..".
|
|
148
|
+
function formatLiveTimer(totalSeconds: number): string {
|
|
149
|
+
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return "00:00";
|
|
150
|
+
const HOURS_24 = 24 * 3600;
|
|
151
|
+
if (totalSeconds >= HOURS_24) {
|
|
152
|
+
const d = Math.floor(totalSeconds / 86400);
|
|
153
|
+
const h = Math.floor((totalSeconds % 86400) / 3600);
|
|
154
|
+
return `${d}d ${h}h`;
|
|
155
|
+
}
|
|
156
|
+
if (totalSeconds >= 3600) {
|
|
157
|
+
const h = Math.floor(totalSeconds / 3600);
|
|
158
|
+
const m = Math.floor((totalSeconds % 3600) / 60);
|
|
159
|
+
const s = Math.floor(totalSeconds % 60);
|
|
160
|
+
return `${h}h ${pad2(m)}:${pad2(s)}`;
|
|
161
|
+
}
|
|
162
|
+
const m = Math.floor(totalSeconds / 60);
|
|
163
|
+
const s = Math.floor(totalSeconds % 60);
|
|
164
|
+
return `${pad2(m)}:${pad2(s)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Back-compat alias for any caller still expecting the older name.
|
|
168
|
+
function formatTimeWithDays(totalSeconds: number): string {
|
|
169
|
+
return formatLiveTimer(totalSeconds);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function formatDuration(totalSeconds: number): string {
|
|
173
|
+
const h = Math.floor(totalSeconds / 3600);
|
|
174
|
+
const m = Math.floor((totalSeconds % 3600) / 60);
|
|
175
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
176
|
+
return `${m}m`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const SCENARIO_COUNTDOWN_TO_START = 0;
|
|
180
|
+
const SCENARIO_OPEN_TIMER = 1;
|
|
181
|
+
const SCENARIO_OVERDUE = 2;
|
|
182
|
+
const SCENARIO_COMPLETED = 3;
|
|
183
|
+
const SCENARIO_COUNTDOWN_TIMER = 4;
|
|
184
|
+
type Scenario =
|
|
185
|
+
| typeof SCENARIO_COUNTDOWN_TO_START
|
|
186
|
+
| typeof SCENARIO_OPEN_TIMER
|
|
187
|
+
| typeof SCENARIO_OVERDUE
|
|
188
|
+
| typeof SCENARIO_COMPLETED
|
|
189
|
+
| typeof SCENARIO_COUNTDOWN_TIMER;
|
|
190
|
+
|
|
191
|
+
interface ScenarioConfig {
|
|
192
|
+
position: "left" | "right";
|
|
193
|
+
colorClass: string;
|
|
194
|
+
label: string | undefined;
|
|
195
|
+
hidePercentage: boolean;
|
|
196
|
+
shimmer: boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const SCENARIO_CONFIGS: Record<Scenario, ScenarioConfig> = {
|
|
200
|
+
[SCENARIO_COUNTDOWN_TO_START]: {
|
|
201
|
+
position: "right",
|
|
202
|
+
colorClass: "border border-blue-600/60 text-blue-400 hover:border-blue-500",
|
|
203
|
+
// Active board card uses this to read "[icon] 4h total · Starts in
|
|
204
|
+
// <countdown>" so the cashier sees both the package total and that
|
|
205
|
+
// the rental is a future booking, not an in-progress one.
|
|
206
|
+
label: "Starts in",
|
|
207
|
+
hidePercentage: true,
|
|
208
|
+
shimmer: false,
|
|
209
|
+
},
|
|
210
|
+
[SCENARIO_OPEN_TIMER]: {
|
|
211
|
+
position: "left",
|
|
212
|
+
colorClass: "border border-green-600/60 text-green-400 hover:border-green-500",
|
|
213
|
+
label: "Open time",
|
|
214
|
+
hidePercentage: true,
|
|
215
|
+
shimmer: true,
|
|
216
|
+
},
|
|
217
|
+
[SCENARIO_OVERDUE]: {
|
|
218
|
+
position: "left",
|
|
219
|
+
colorClass: "border border-purple-600/60 text-purple-400 hover:border-purple-500",
|
|
220
|
+
label: "Overdue",
|
|
221
|
+
hidePercentage: false,
|
|
222
|
+
shimmer: false,
|
|
223
|
+
},
|
|
224
|
+
[SCENARIO_COMPLETED]: {
|
|
225
|
+
position: "left",
|
|
226
|
+
colorClass: "border border-zinc-600/60 text-zinc-400 hover:border-zinc-500",
|
|
227
|
+
label: "Completed",
|
|
228
|
+
hidePercentage: false,
|
|
229
|
+
shimmer: false,
|
|
230
|
+
},
|
|
231
|
+
[SCENARIO_COUNTDOWN_TIMER]: {
|
|
232
|
+
position: "left",
|
|
233
|
+
colorClass: "",
|
|
234
|
+
label: "Remaining",
|
|
235
|
+
hidePercentage: false,
|
|
236
|
+
shimmer: false,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const COLOR_GREEN = "border border-green-600/60 text-green-400 hover:border-green-500";
|
|
241
|
+
const COLOR_AMBER = "border border-amber-600/60 text-amber-400 hover:border-amber-500";
|
|
242
|
+
const COLOR_RED = "border border-red-600/60 text-red-400 hover:border-red-500";
|
|
243
|
+
|
|
244
|
+
const LiveTimer: Component<LiveTimerProps> = (props) => {
|
|
245
|
+
const [local, others] = splitProps(props, [
|
|
246
|
+
"startAt",
|
|
247
|
+
"endAt",
|
|
248
|
+
"overdue",
|
|
249
|
+
"icon",
|
|
250
|
+
"class",
|
|
251
|
+
"label",
|
|
252
|
+
"hidePercentage",
|
|
253
|
+
"totalLabel",
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
const timestamps = createMemo(() => {
|
|
257
|
+
const start = ensureDate(local.startAt);
|
|
258
|
+
if (!start) throw new Error("LiveTimer: startAt must be a valid Date or date string");
|
|
259
|
+
const end = ensureDate(local.endAt ?? null);
|
|
260
|
+
return {
|
|
261
|
+
startSec: Math.floor(start.getTime() / 1000),
|
|
262
|
+
endSec: end ? Math.floor(end.getTime() / 1000) : null,
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Subscribe to the shared tickers.
|
|
267
|
+
const { startSec, endSec } = timestamps();
|
|
268
|
+
const dynamicTick = getDynamicTick(startSec, endSec, local.overdue ?? false);
|
|
269
|
+
const slowTick = getSlowTick();
|
|
270
|
+
|
|
271
|
+
const scenario = createMemo<Scenario>(() => {
|
|
272
|
+
const nowSec = Math.floor(slowTick() / 1000);
|
|
273
|
+
const { startSec: s, endSec: e } = timestamps();
|
|
274
|
+
if (nowSec <= s) return SCENARIO_COUNTDOWN_TO_START;
|
|
275
|
+
if (e === null) return SCENARIO_OPEN_TIMER;
|
|
276
|
+
const done = nowSec >= e;
|
|
277
|
+
if (done && local.overdue) return SCENARIO_OVERDUE;
|
|
278
|
+
if (done) return SCENARIO_COMPLETED;
|
|
279
|
+
return SCENARIO_COUNTDOWN_TIMER;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const progress = createMemo(() => {
|
|
283
|
+
const nowSec = Math.floor(slowTick() / 1000);
|
|
284
|
+
const { startSec: s, endSec: e } = timestamps();
|
|
285
|
+
switch (scenario()) {
|
|
286
|
+
case SCENARIO_COUNTDOWN_TO_START: {
|
|
287
|
+
const rem = s - nowSec;
|
|
288
|
+
return Math.min(100, (rem / 3600) * 100);
|
|
289
|
+
}
|
|
290
|
+
case SCENARIO_OPEN_TIMER:
|
|
291
|
+
return 95;
|
|
292
|
+
case SCENARIO_OVERDUE: {
|
|
293
|
+
const od = nowSec - (e ?? nowSec);
|
|
294
|
+
const total = (e ?? 0) - s;
|
|
295
|
+
const pct = total > 0 ? (od / total) * 100 : 0;
|
|
296
|
+
return 100 + pct;
|
|
297
|
+
}
|
|
298
|
+
case SCENARIO_COMPLETED:
|
|
299
|
+
return 100;
|
|
300
|
+
case SCENARIO_COUNTDOWN_TIMER: {
|
|
301
|
+
const rem = (e ?? nowSec) - nowSec;
|
|
302
|
+
const total = (e ?? 0) - s;
|
|
303
|
+
const elapsed = total - rem;
|
|
304
|
+
return total > 0 ? Math.min(100, (elapsed / total) * 100) : 100;
|
|
305
|
+
}
|
|
306
|
+
default:
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const statusLabel = createMemo(() => {
|
|
312
|
+
const nowSec = Math.floor(dynamicTick() / 1000);
|
|
313
|
+
const { startSec: s, endSec: e } = timestamps();
|
|
314
|
+
switch (scenario()) {
|
|
315
|
+
case SCENARIO_COUNTDOWN_TO_START:
|
|
316
|
+
return formatTimeWithDays(s - nowSec);
|
|
317
|
+
case SCENARIO_OPEN_TIMER:
|
|
318
|
+
return formatTimeWithDays(nowSec - s);
|
|
319
|
+
case SCENARIO_OVERDUE:
|
|
320
|
+
return formatTimeWithDays(nowSec - (e ?? nowSec));
|
|
321
|
+
case SCENARIO_COMPLETED:
|
|
322
|
+
return formatDuration((e ?? 0) - s);
|
|
323
|
+
case SCENARIO_COUNTDOWN_TIMER:
|
|
324
|
+
return formatTimeWithDays((e ?? nowSec) - nowSec);
|
|
325
|
+
default:
|
|
326
|
+
return "00:00:00";
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const staticConfig = createMemo(() => SCENARIO_CONFIGS[scenario()]);
|
|
331
|
+
|
|
332
|
+
const icon = createMemo<Component<{ size: number; class?: string }>>(() => {
|
|
333
|
+
if (local.icon) return local.icon;
|
|
334
|
+
switch (scenario()) {
|
|
335
|
+
case SCENARIO_COUNTDOWN_TO_START:
|
|
336
|
+
return Calendar;
|
|
337
|
+
case SCENARIO_OPEN_TIMER:
|
|
338
|
+
return Play;
|
|
339
|
+
case SCENARIO_OVERDUE:
|
|
340
|
+
return AlertTriangle;
|
|
341
|
+
case SCENARIO_COMPLETED:
|
|
342
|
+
return Check;
|
|
343
|
+
case SCENARIO_COUNTDOWN_TIMER:
|
|
344
|
+
default:
|
|
345
|
+
return Clock;
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const colorClass = createMemo(() => {
|
|
350
|
+
if (scenario() === SCENARIO_COUNTDOWN_TIMER) {
|
|
351
|
+
const p = progress();
|
|
352
|
+
return p <= 25 ? COLOR_GREEN : p <= 75 ? COLOR_AMBER : COLOR_RED;
|
|
353
|
+
}
|
|
354
|
+
return staticConfig().colorClass;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const finalClass = createMemo(() => {
|
|
358
|
+
const user = local.class ?? "";
|
|
359
|
+
if (user.includes("border-") && user.includes("text-")) return user;
|
|
360
|
+
return `${colorClass()} ${user}`.trim();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const resolvedLabel = createMemo(() => {
|
|
364
|
+
// `null` is the explicit "no label" signal from the caller; an
|
|
365
|
+
// undefined `local.label` falls through to the scenario default
|
|
366
|
+
// ("Remaining" etc.).
|
|
367
|
+
if (local.label === null) return undefined;
|
|
368
|
+
if (local.label !== undefined) return local.label;
|
|
369
|
+
return staticConfig().label;
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const resolvedHidePercentage = createMemo(() => {
|
|
373
|
+
return local.hidePercentage ?? staticConfig().hidePercentage;
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Scenario context label sits after the totalLabel on the left so the
|
|
377
|
+
// cashier sees "[icon] 4h total · Starts in" / "… · Overdue" without
|
|
378
|
+
// hiding the countdown. Running countdowns (the common case) skip it
|
|
379
|
+
// — the right-side ticker already carries the time-remaining cue.
|
|
380
|
+
const scenarioContextLabel = createMemo(() => {
|
|
381
|
+
switch (scenario()) {
|
|
382
|
+
case SCENARIO_COUNTDOWN_TO_START:
|
|
383
|
+
return "Starts in";
|
|
384
|
+
case SCENARIO_OVERDUE:
|
|
385
|
+
return "Overdue";
|
|
386
|
+
case SCENARIO_OPEN_TIMER:
|
|
387
|
+
return "Open";
|
|
388
|
+
default:
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Layout mode A — caller passed `totalLabel`. Show the total label on
|
|
394
|
+
// the left and the live countdown on the right. No secondary label,
|
|
395
|
+
// no percentage. This is what the counter cards use so cashiers read
|
|
396
|
+
// "4h total" + "5h 23:45" without competing text.
|
|
397
|
+
// Layout mode B — default. statusLabel carries the countdown on the
|
|
398
|
+
// left, scenario label sits after it, percentage renders on the
|
|
399
|
+
// right (unless `hidePercentage`). Matches the home page session
|
|
400
|
+
// manager's original look.
|
|
401
|
+
return (
|
|
402
|
+
<Show
|
|
403
|
+
when={local.totalLabel !== undefined}
|
|
404
|
+
fallback={
|
|
405
|
+
<ProgressBar
|
|
406
|
+
progress={progress()}
|
|
407
|
+
icon={icon()}
|
|
408
|
+
statusLabel={statusLabel()}
|
|
409
|
+
label={resolvedLabel()}
|
|
410
|
+
position={staticConfig().position}
|
|
411
|
+
hidePercentage={resolvedHidePercentage()}
|
|
412
|
+
shimmer={staticConfig().shimmer}
|
|
413
|
+
class={finalClass()}
|
|
414
|
+
{...others}
|
|
415
|
+
/>
|
|
416
|
+
}
|
|
417
|
+
>
|
|
418
|
+
<ProgressBar
|
|
419
|
+
progress={progress()}
|
|
420
|
+
icon={icon()}
|
|
421
|
+
statusLabel={local.totalLabel}
|
|
422
|
+
label={scenarioContextLabel()}
|
|
423
|
+
position={staticConfig().position}
|
|
424
|
+
hidePercentage
|
|
425
|
+
rightLabel={statusLabel()}
|
|
426
|
+
shimmer={staticConfig().shimmer}
|
|
427
|
+
class={finalClass()}
|
|
428
|
+
{...others}
|
|
429
|
+
/>
|
|
430
|
+
</Show>
|
|
431
|
+
);
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
export default LiveTimer;
|
|
@@ -334,7 +334,7 @@ function MentionChip(props: { name: string; clientId: number | null }): JSX.Elem
|
|
|
334
334
|
const perms = usePermissions();
|
|
335
335
|
canViewClients = () => perms.has("clients.view");
|
|
336
336
|
} catch {
|
|
337
|
-
/* no provider in context
|
|
337
|
+
/* no provider in context, leave canViewClients() returning false */
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
return (
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "solid-js";
|
|
12
12
|
import Banknote from "lucide-solid/icons/banknote";
|
|
13
13
|
import Loader2 from "lucide-solid/icons/loader-2";
|
|
14
|
-
import AccountAvatar from "
|
|
14
|
+
import AccountAvatar from "../base/AccountAvatar";
|
|
15
15
|
|
|
16
16
|
export interface PaymentAccountOption {
|
|
17
17
|
id: number;
|
|
@@ -102,7 +102,7 @@ export default function PaymentAccountPicker(props: PaymentAccountPickerProps):
|
|
|
102
102
|
}
|
|
103
103
|
};
|
|
104
104
|
|
|
105
|
-
// Single fetch at mount
|
|
105
|
+
// Single fetch at mount. The list rarely changes within a cart session,
|
|
106
106
|
// and Charge gating already depends on the count it reports up via
|
|
107
107
|
// onCountChange. If the picker ever needs to refetch on org switch,
|
|
108
108
|
// swap this for a createEffect tracking the active-org signal.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Source: kplugin_user-api-keys/ui/remote/index.tsx (reveal-once callout)
|
|
2
|
+
// and the identical block in kplugin_api-keys. A presentational, domain-free
|
|
3
|
+
// "save this secret now" callout: an amber bordered box with a warning line, a
|
|
4
|
+
// monospace code showing the secret value, a copy button, and an optional
|
|
5
|
+
// footer caption. The caller decides when to mount it (wrap in
|
|
6
|
+
// <Show when={revealed()}>); this component holds no fetch, no plugin state.
|
|
7
|
+
|
|
8
|
+
import type { Component } from "solid-js";
|
|
9
|
+
import { Show, createSignal, splitProps } from "solid-js";
|
|
10
|
+
import Copy from "lucide-solid/icons/copy";
|
|
11
|
+
|
|
12
|
+
export interface SecretRevealProps {
|
|
13
|
+
/** The secret string to display and copy. */
|
|
14
|
+
secret: string;
|
|
15
|
+
/** Warning line shown above the secret. */
|
|
16
|
+
warning?: string;
|
|
17
|
+
/** Optional footer caption, e.g. "Key for: agentic-laptop". */
|
|
18
|
+
caption?: string;
|
|
19
|
+
/** Optional test id applied to the monospace secret code element. */
|
|
20
|
+
testId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_WARNING = "Save this now - it will not be shown again.";
|
|
24
|
+
|
|
25
|
+
const SecretReveal: Component<SecretRevealProps> = (props) => {
|
|
26
|
+
const [local] = splitProps(props, ["secret", "warning", "caption", "testId"]);
|
|
27
|
+
const [copied, setCopied] = createSignal(false);
|
|
28
|
+
|
|
29
|
+
const copy = () => {
|
|
30
|
+
navigator.clipboard.writeText(local.secret).then(() => {
|
|
31
|
+
setCopied(true);
|
|
32
|
+
setTimeout(() => setCopied(false), 1500);
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div class="mb-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
|
38
|
+
<p class="text-xs text-amber-300 font-medium mb-2">{local.warning ?? DEFAULT_WARNING}</p>
|
|
39
|
+
<div class="flex items-center gap-2">
|
|
40
|
+
<code
|
|
41
|
+
data-testid={local.testId}
|
|
42
|
+
class="flex-1 px-2 py-1.5 rounded bg-zinc-900 border border-zinc-700 text-amber-200 text-xs font-mono break-all"
|
|
43
|
+
>
|
|
44
|
+
{local.secret}
|
|
45
|
+
</code>
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
onClick={copy}
|
|
49
|
+
class="ks-interactive px-2 py-1.5 rounded bg-zinc-800 border border-zinc-700 text-zinc-300 hover:text-white text-xs flex items-center gap-1"
|
|
50
|
+
aria-label="Copy secret"
|
|
51
|
+
>
|
|
52
|
+
<Copy size={12} />
|
|
53
|
+
{copied() ? "Copied" : "Copy"}
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
<Show when={local.caption}>
|
|
57
|
+
<p class="text-[10px] text-zinc-500 mt-2">{local.caption}</p>
|
|
58
|
+
</Show>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export default SecretReveal;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Source: KahitSan/kserp src/components/VoucherPicker.tsx (vendored into the plugin remote).
|
|
2
2
|
//
|
|
3
3
|
// Cross-plugin picker: fetches the SIBLING vouchers plugin's public API at
|
|
4
|
-
// /api/vouchers and degrades gracefully
|
|
4
|
+
// /api/vouchers and degrades gracefully: when the vouchers plugin isn't
|
|
5
5
|
// deployed the popup shows a "couldn't load" notice and the sale records with
|
|
6
6
|
// no voucher (the manual-discount field stays available).
|
|
7
7
|
|