ppij-design-system 0.0.0-stage → 0.2.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 (151) hide show
  1. package/dist/banners.d.ts +31 -0
  2. package/dist/banners.js +18 -0
  3. package/dist/card-themes.d.ts +14 -0
  4. package/dist/card-themes.js +325 -0
  5. package/dist/components/account-menu.d.ts +24 -0
  6. package/dist/components/account-menu.js +13 -0
  7. package/dist/components/admin-drawer.d.ts +34 -0
  8. package/dist/components/admin-drawer.js +30 -0
  9. package/dist/components/alert-dialog.d.ts +16 -0
  10. package/dist/components/alert-dialog.js +39 -0
  11. package/dist/components/alert.d.ts +11 -0
  12. package/dist/components/alert.js +29 -0
  13. package/dist/components/avatar.d.ts +11 -0
  14. package/dist/components/avatar.js +22 -0
  15. package/dist/components/badge.d.ts +9 -0
  16. package/dist/components/badge.js +27 -0
  17. package/dist/components/banner-carousel.d.ts +14 -0
  18. package/dist/components/banner-carousel.js +90 -0
  19. package/dist/components/bottom-bar.d.ts +22 -0
  20. package/dist/components/bottom-bar.js +12 -0
  21. package/dist/components/button.d.ts +10 -0
  22. package/dist/components/button.js +37 -0
  23. package/dist/components/card-upload.d.ts +30 -0
  24. package/dist/components/card-upload.js +19 -0
  25. package/dist/components/card.d.ts +9 -0
  26. package/dist/components/card.js +24 -0
  27. package/dist/components/checkbox.d.ts +4 -0
  28. package/dist/components/checkbox.js +9 -0
  29. package/dist/components/data-table.d.ts +26 -0
  30. package/dist/components/data-table.js +17 -0
  31. package/dist/components/dialog.d.ts +17 -0
  32. package/dist/components/dialog.js +37 -0
  33. package/dist/components/drawer.d.ts +13 -0
  34. package/dist/components/drawer.js +34 -0
  35. package/dist/components/dropdown-menu.d.ts +25 -0
  36. package/dist/components/dropdown-menu.js +50 -0
  37. package/dist/components/editable-panel.d.ts +25 -0
  38. package/dist/components/editable-panel.js +18 -0
  39. package/dist/components/empty.d.ts +12 -0
  40. package/dist/components/empty.js +33 -0
  41. package/dist/components/file-upload.d.ts +28 -0
  42. package/dist/components/file-upload.js +21 -0
  43. package/dist/components/form-panel.d.ts +15 -0
  44. package/dist/components/form-panel.js +9 -0
  45. package/dist/components/funding-picker.d.ts +20 -0
  46. package/dist/components/funding-picker.js +16 -0
  47. package/dist/components/input.d.ts +3 -0
  48. package/dist/components/input.js +6 -0
  49. package/dist/components/invitation-card.d.ts +14 -0
  50. package/dist/components/invitation-card.js +13 -0
  51. package/dist/components/label.d.ts +4 -0
  52. package/dist/components/label.js +7 -0
  53. package/dist/components/member-card-block.d.ts +47 -0
  54. package/dist/components/member-card-block.js +329 -0
  55. package/dist/components/member-card.css +92 -0
  56. package/dist/components/member-card.d.ts +29 -0
  57. package/dist/components/member-card.js +22 -0
  58. package/dist/components/month-picker.d.ts +16 -0
  59. package/dist/components/month-picker.js +19 -0
  60. package/dist/components/offline-banner.d.ts +14 -0
  61. package/dist/components/offline-banner.js +14 -0
  62. package/dist/components/page-header.d.ts +14 -0
  63. package/dist/components/page-header.js +10 -0
  64. package/dist/components/phone-field.d.ts +25 -0
  65. package/dist/components/phone-field.js +64 -0
  66. package/dist/components/pill-radio.d.ts +21 -0
  67. package/dist/components/pill-radio.js +15 -0
  68. package/dist/components/portal-shell.d.ts +53 -0
  69. package/dist/components/portal-shell.js +19 -0
  70. package/dist/components/postcode-field.d.ts +26 -0
  71. package/dist/components/postcode-field.js +14 -0
  72. package/dist/components/progress.d.ts +4 -0
  73. package/dist/components/progress.js +8 -0
  74. package/dist/components/radio-group.d.ts +5 -0
  75. package/dist/components/radio-group.js +11 -0
  76. package/dist/components/review-panel.d.ts +20 -0
  77. package/dist/components/review-panel.js +14 -0
  78. package/dist/components/saving-indicator.d.ts +15 -0
  79. package/dist/components/saving-indicator.js +19 -0
  80. package/dist/components/scroll-area.d.ts +5 -0
  81. package/dist/components/scroll-area.js +13 -0
  82. package/dist/components/search-filter.d.ts +39 -0
  83. package/dist/components/search-filter.js +19 -0
  84. package/dist/components/segmented-progress.d.ts +16 -0
  85. package/dist/components/segmented-progress.js +12 -0
  86. package/dist/components/select.d.ts +15 -0
  87. package/dist/components/select.js +38 -0
  88. package/dist/components/separator.d.ts +4 -0
  89. package/dist/components/separator.js +7 -0
  90. package/dist/components/service-tiles.d.ts +26 -0
  91. package/dist/components/service-tiles.js +17 -0
  92. package/dist/components/sheet.d.ts +14 -0
  93. package/dist/components/sheet.js +40 -0
  94. package/dist/components/sidebar.d.ts +69 -0
  95. package/dist/components/sidebar.js +210 -0
  96. package/dist/components/skeleton.d.ts +11 -0
  97. package/dist/components/skeleton.js +11 -0
  98. package/dist/components/sonner.d.ts +9 -0
  99. package/dist/components/sonner.js +25 -0
  100. package/dist/components/spinner.d.ts +14 -0
  101. package/dist/components/spinner.js +22 -0
  102. package/dist/components/stat-card.d.ts +22 -0
  103. package/dist/components/stat-card.js +12 -0
  104. package/dist/components/status-card.d.ts +24 -0
  105. package/dist/components/status-card.js +26 -0
  106. package/dist/components/stepper.d.ts +25 -0
  107. package/dist/components/stepper.js +23 -0
  108. package/dist/components/submit-overlay.d.ts +16 -0
  109. package/dist/components/submit-overlay.js +15 -0
  110. package/dist/components/suggestion-sheet.d.ts +20 -0
  111. package/dist/components/suggestion-sheet.js +36 -0
  112. package/dist/components/switch.d.ts +6 -0
  113. package/dist/components/switch.js +8 -0
  114. package/dist/components/table.d.ts +10 -0
  115. package/dist/components/table.js +28 -0
  116. package/dist/components/tabs.d.ts +11 -0
  117. package/dist/components/tabs.js +28 -0
  118. package/dist/components/tag-input.d.ts +15 -0
  119. package/dist/components/tag-input.js +31 -0
  120. package/dist/components/textarea.d.ts +3 -0
  121. package/dist/components/textarea.js +6 -0
  122. package/dist/components/timeline.d.ts +18 -0
  123. package/dist/components/timeline.js +11 -0
  124. package/dist/components/todo-strip.d.ts +14 -0
  125. package/dist/components/todo-strip.js +11 -0
  126. package/dist/components/tooltip.d.ts +7 -0
  127. package/dist/components/tooltip.js +16 -0
  128. package/dist/components/top-bar.d.ts +23 -0
  129. package/dist/components/top-bar.js +14 -0
  130. package/dist/components/type-ahead.d.ts +20 -0
  131. package/dist/components/type-ahead.js +22 -0
  132. package/dist/components/unit-card.d.ts +26 -0
  133. package/dist/components/unit-card.js +18 -0
  134. package/dist/components/view-toggle.d.ts +20 -0
  135. package/dist/components/view-toggle.js +15 -0
  136. package/dist/components/wizard-footer.d.ts +24 -0
  137. package/dist/components/wizard-footer.js +17 -0
  138. package/dist/components/wizard-header.d.ts +29 -0
  139. package/dist/components/wizard-header.js +18 -0
  140. package/dist/hooks/use-mobile.d.ts +1 -0
  141. package/dist/hooks/use-mobile.js +11 -0
  142. package/dist/index.d.ts +70 -0
  143. package/dist/index.js +70 -0
  144. package/dist/lib/utils.d.ts +2 -0
  145. package/dist/lib/utils.js +5 -0
  146. package/dist/sample-banners.d.ts +8 -0
  147. package/dist/sample-banners.js +28 -0
  148. package/dist/tokens.css +119 -0
  149. package/dist/tokens.json +50 -0
  150. package/package.json +60 -4
  151. package/README.md +0 -3
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { IconPencil } from "@tabler/icons-react";
3
+ import { Badge } from "./badge";
4
+ import { Button } from "./button";
5
+ import { Card } from "./card";
6
+ /**
7
+ * The standard editable surface of the portal: a read-first panel.
8
+ * The panel only ever reads: title, one behaviour line, committed
9
+ * values as text, and an Edit action. Editing itself happens in
10
+ * the AdminDrawer (a right drawer on desktop, a bottom sheet on
11
+ * the phone), one panel at a time; the page owns the drawer.
12
+ * Reviewed changes leave a proposal block inside the panel with a
13
+ * Menunggu badge while the current values stay in effect. This is
14
+ * the profile pattern; every editable surface follows it.
15
+ */
16
+ export function EditablePanel({ title, description, pending = false, pendingLabel = "Menunggu", onEdit, editLabel = "Edit", proposal, children }) {
17
+ return (_jsxs(Card, { className: "gap-0 rounded-xl border border-brand-border bg-card py-0", children: [_jsxs("div", { className: "flex flex-row items-start justify-between gap-3 px-5 pt-5 pb-4", children: [_jsxs("div", { className: "grid min-w-0 gap-1.5", children: [_jsx("h2", { className: "text-base leading-6 font-bold text-brand-ink", children: title }), description ? _jsx("p", { className: "text-sm leading-5 text-brand-muted", children: description }) : null] }), pending ? _jsx(Badge, { variant: "outline", className: "shrink-0", children: pendingLabel }) : null] }), _jsxs("div", { className: "grid gap-4 px-5 pb-5", children: [children, proposal, _jsx("div", { className: "flex justify-end border-t border-brand-border pt-4", children: _jsxs(Button, { type: "button", variant: "outline", onClick: onEdit, children: [_jsx(IconPencil, { "aria-hidden": "true" }), editLabel] }) })] })] }));
18
+ }
@@ -0,0 +1,12 @@
1
+ import * as React from "react";
2
+ import { type VariantProps } from "class-variance-authority";
3
+ declare function Empty({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
4
+ declare function EmptyHeader({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
5
+ declare const emptyMediaVariants: (props?: ({
6
+ variant?: "default" | "icon" | null | undefined;
7
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
8
+ declare function EmptyMedia({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>): React.JSX.Element;
9
+ declare function EmptyTitle({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
10
+ declare function EmptyDescription({ className, ...props }: React.ComponentProps<"p">): React.JSX.Element;
11
+ declare function EmptyContent({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
12
+ export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia };
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cva } from "class-variance-authority";
3
+ import { cn } from "../lib/utils";
4
+ function Empty({ className, ...props }) {
5
+ return (_jsx("div", { "data-slot": "empty", className: cn("flex min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-brand-border bg-card p-10 text-center text-balance", className), ...props }));
6
+ }
7
+ function EmptyHeader({ className, ...props }) {
8
+ return (_jsx("div", { "data-slot": "empty-header", className: cn("flex max-w-sm flex-col items-center gap-2 text-center", className), ...props }));
9
+ }
10
+ const emptyMediaVariants = cva("flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", {
11
+ variants: {
12
+ variant: {
13
+ default: "bg-transparent [&_svg]:size-10 [&_svg]:text-brand-muted",
14
+ icon: "flex size-12 rounded-full bg-secondary text-brand-ink [&_svg]:size-6",
15
+ },
16
+ },
17
+ defaultVariants: {
18
+ variant: "default",
19
+ },
20
+ });
21
+ function EmptyMedia({ className, variant = "default", ...props }) {
22
+ return (_jsx("div", { "data-slot": "empty-icon", "data-variant": variant, className: cn(emptyMediaVariants({ variant, className })), ...props }));
23
+ }
24
+ function EmptyTitle({ className, ...props }) {
25
+ return (_jsx("div", { "data-slot": "empty-title", className: cn("text-base font-semibold tracking-tight text-brand-ink", className), ...props }));
26
+ }
27
+ function EmptyDescription({ className, ...props }) {
28
+ return (_jsx("p", { "data-slot": "empty-description", className: cn("text-sm text-brand-muted [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", className), ...props }));
29
+ }
30
+ function EmptyContent({ className, ...props }) {
31
+ return (_jsx("div", { "data-slot": "empty-content", className: cn("flex w-full max-w-sm min-w-0 flex-col items-center gap-3 text-sm text-balance", className), ...props }));
32
+ }
33
+ export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia };
@@ -0,0 +1,28 @@
1
+ import * as React from "react";
2
+ export type FileUploadStatus = "empty" | "uploading" | "done" | "error";
3
+ export interface FileUploadProps {
4
+ /** Field label, for example "Foto kartu anggota". */
5
+ label: string;
6
+ /** One quiet line of guidance: accepted formats and maximum size. */
7
+ hint?: string;
8
+ accept?: string;
9
+ status: FileUploadStatus;
10
+ /** Name of the chosen file, shown in the done and error states. */
11
+ fileName?: string;
12
+ /** Upload progress from 0 to 100, shown in the uploading state. */
13
+ progress?: number;
14
+ /** Error message shown in the error state. */
15
+ errorText?: string;
16
+ /** Called with the file the person picked. */
17
+ onPick: (file: File) => void;
18
+ /** Called when the person removes the chosen file. */
19
+ onRemove?: () => void;
20
+ className?: string;
21
+ }
22
+ /**
23
+ * One file field with its states designed: empty dropzone, uploading
24
+ * with progress, done with the file name and a remove action, error
25
+ * with the reason. The page owns the actual upload; this component
26
+ * renders whichever state the page reports.
27
+ */
28
+ export declare function FileUpload({ label, hint, accept, status, fileName, progress, errorText, onPick, onRemove, className }: FileUploadProps): React.JSX.Element;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { IconUpload, IconX, IconFileCheck, IconLoader2, IconAlertTriangle } from "@tabler/icons-react";
4
+ import { cn } from "../lib/utils";
5
+ import { Button } from "./button";
6
+ /**
7
+ * One file field with its states designed: empty dropzone, uploading
8
+ * with progress, done with the file name and a remove action, error
9
+ * with the reason. The page owns the actual upload; this component
10
+ * renders whichever state the page reports.
11
+ */
12
+ export function FileUpload({ label, hint, accept, status, fileName, progress = 0, errorText, onPick, onRemove, className }) {
13
+ const inputRef = React.useRef(null);
14
+ const openPicker = () => inputRef.current?.click();
15
+ return (_jsxs("div", { className: cn("grid gap-2", className), children: [_jsx("span", { className: "text-sm font-medium", children: label }), _jsx("input", { ref: inputRef, type: "file", accept: accept, className: "hidden", onChange: (event) => {
16
+ const file = event.target.files?.[0];
17
+ if (file)
18
+ onPick(file);
19
+ event.target.value = "";
20
+ } }), status === "empty" && (_jsxs("button", { type: "button", onClick: openPicker, className: "grid justify-items-center gap-2 rounded-lg border border-dashed border-input bg-card px-4 py-6 text-center outline-none transition-colors hover:border-brand-red/50 focus-visible:ring-2 focus-visible:ring-ring", children: [_jsx("span", { className: "flex size-10 items-center justify-center rounded-full bg-secondary text-muted-foreground", children: _jsx(IconUpload, { className: "size-5" }) }), _jsx("span", { className: "text-sm font-medium text-brand-ink", children: "Pilih berkas atau jatuhkan di sini" }), hint && _jsx("span", { className: "text-xs text-muted-foreground", children: hint })] })), status === "uploading" && (_jsxs("div", { className: "grid gap-2 rounded-lg border bg-card px-4 py-4", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(IconLoader2, { className: "size-4 animate-spin text-muted-foreground" }), _jsx("span", { className: "font-medium", children: fileName }), _jsxs("span", { className: "ml-auto text-muted-foreground", children: [progress, "%"] })] }), _jsx("div", { className: "h-1.5 overflow-hidden rounded-full bg-secondary", children: _jsx("div", { className: "h-full rounded-full bg-brand-red transition-all", style: { width: progress + "%" } }) })] })), status === "done" && (_jsxs("div", { className: "flex items-center gap-3 rounded-lg border bg-card px-4 py-3", children: [_jsx("span", { className: "flex size-9 items-center justify-center rounded-md bg-success/10 text-success", children: _jsx(IconFileCheck, { className: "size-5" }) }), _jsxs("div", { className: "grid min-w-0", children: [_jsx("span", { className: "truncate text-sm font-medium", children: fileName }), _jsx("span", { className: "text-xs text-muted-foreground", children: "Terunggah" })] }), onRemove && (_jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", className: "ml-auto", onClick: onRemove, "aria-label": "Hapus berkas", children: _jsx(IconX, {}) }))] })), status === "error" && (_jsx("div", { className: "grid gap-2", children: _jsxs("div", { className: "flex items-center gap-3 rounded-lg border border-destructive/40 bg-card px-4 py-3", children: [_jsx("span", { className: "flex size-9 items-center justify-center rounded-md bg-destructive/10 text-destructive", children: _jsx(IconAlertTriangle, { className: "size-5" }) }), _jsxs("div", { className: "grid min-w-0", children: [_jsx("span", { className: "truncate text-sm font-medium", children: fileName }), _jsx("span", { className: "text-xs text-destructive", children: errorText ?? "Unggahan gagal. Coba lagi." })] }), _jsx(Button, { type: "button", variant: "outline", size: "sm", className: "ml-auto", onClick: openPicker, children: "Coba lagi" })] }) }))] }));
21
+ }
@@ -0,0 +1,15 @@
1
+ import * as React from "react";
2
+ type FormPanelProps = {
3
+ title: string;
4
+ description?: string;
5
+ children: React.ReactNode;
6
+ footer?: React.ReactNode;
7
+ className?: string;
8
+ /**
9
+ * Element for the panel title. "div" (default) keeps step pages to their single h1; review
10
+ * screens pass "h2" so each section is a navigable heading.
11
+ */
12
+ titleAs?: "div" | "h2";
13
+ };
14
+ export declare function FormPanel({ title, description, children, footer, className, titleAs }: FormPanelProps): React.JSX.Element;
15
+ export {};
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "../lib/utils";
3
+ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./card";
4
+ export function FormPanel({ title, description, children, footer, className, titleAs = "div" }) {
5
+ const titleNode = titleAs === "h2"
6
+ ? _jsx(CardTitle, { children: _jsx("h2", { className: "text-base leading-snug font-semibold", children: title }) })
7
+ : _jsx(CardTitle, { children: title });
8
+ return (_jsxs(Card, { className: cn("bg-card py-4 shadow-none sm:py-6", className), children: [_jsxs(CardHeader, { className: "px-4 sm:px-6", children: [titleNode, description ? _jsx(CardDescription, { children: description }) : null] }), _jsx(CardContent, { className: "px-4 sm:px-6", children: children }), footer ? _jsx(CardFooter, { className: "px-4 sm:px-6", children: footer }) : null] }));
9
+ }
@@ -0,0 +1,20 @@
1
+ export interface FundingPickerProps {
2
+ /** Picked funding sources, as display labels. */
3
+ values: string[];
4
+ onChange: (values: string[]) => void;
5
+ /** Catalogue entries offered as one-tap suggestions; the lookup stays in the application. */
6
+ options: string[];
7
+ /** The wizard caps funding at 3 sources (MAX_FUNDING). */
8
+ max?: number;
9
+ placeholder?: string;
10
+ maxMessage?: string;
11
+ className?: string;
12
+ }
13
+ /**
14
+ * Funding source picker, extracted from the wizard FundingPicker:
15
+ * the minat-style multi-pick (chips in the field, catalogue
16
+ * suggestions under it, custom entries allowed) with the funding
17
+ * cap applied. The catalogue itself arrives as props; searching
18
+ * and suggesting new sources stay in the application.
19
+ */
20
+ export declare function FundingPicker({ values, onChange, options, max, placeholder, maxMessage, className, }: FundingPickerProps): import("react").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { TagInput } from "./tag-input";
3
+ /**
4
+ * Funding source picker, extracted from the wizard FundingPicker:
5
+ * the minat-style multi-pick (chips in the field, catalogue
6
+ * suggestions under it, custom entries allowed) with the funding
7
+ * cap applied. The catalogue itself arrives as props; searching
8
+ * and suggesting new sources stay in the application.
9
+ */
10
+ export function FundingPicker({ values, onChange, options, max = 3, placeholder = "Cari atau ketik sumber pendanaan", maxMessage, className, }) {
11
+ const reachedMax = values.length >= max;
12
+ return (_jsxs("div", { className: className, children: [_jsx(TagInput, { values: values, onChange: (next) => {
13
+ if (next.length <= max)
14
+ onChange(next);
15
+ }, suggestions: reachedMax ? [] : options.filter((option) => !values.includes(option)), placeholder: reachedMax ? "" : placeholder }), reachedMax && (_jsx("p", { role: "status", className: "mt-2 text-sm text-brand-muted", children: maxMessage ?? `Maksimal ${max} sumber pendanaan.` }))] }));
16
+ }
@@ -0,0 +1,3 @@
1
+ import * as React from "react";
2
+ declare function Input({ className, type, ...props }: React.ComponentProps<"input">): React.JSX.Element;
3
+ export { Input };
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cn } from "../lib/utils";
3
+ function Input({ className, type, ...props }) {
4
+ return (_jsx("input", { type: type, "data-slot": "input", className: cn("h-9 w-full min-w-0 data-[size=sm]:h-8 data-[size=lg]:h-11 rounded-md border border-input bg-card px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30", "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", className), ...props }));
5
+ }
6
+ export { Input };
@@ -0,0 +1,14 @@
1
+ export interface InvitationCardProps {
2
+ heading: string;
3
+ body: string;
4
+ actionLabel: string;
5
+ href: string;
6
+ }
7
+ /**
8
+ * The Beranda role-invitation card, extracted exactly from
9
+ * ppij-identity-sso Beranda.tsx (the me.invitation element): spans
10
+ * both columns, heading, one body line (the app composes it from
11
+ * role, unit, and the invitation end date), and an outline action.
12
+ * Real copy: heading "Undangan peran", action "Lihat undangan".
13
+ */
14
+ export declare function InvitationCard({ heading, body, actionLabel, href }: InvitationCardProps): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button } from "./button";
3
+ import { Card } from "./card";
4
+ /**
5
+ * The Beranda role-invitation card, extracted exactly from
6
+ * ppij-identity-sso Beranda.tsx (the me.invitation element): spans
7
+ * both columns, heading, one body line (the app composes it from
8
+ * role, unit, and the invitation end date), and an outline action.
9
+ * Real copy: heading "Undangan peran", action "Lihat undangan".
10
+ */
11
+ export function InvitationCard({ heading, body, actionLabel, href }) {
12
+ return (_jsxs(Card, { className: "gap-3 rounded-xl border border-brand-border bg-card py-5 md:col-span-2", children: [_jsxs("div", { className: "px-5", children: [_jsx("h2", { className: "font-bold text-brand-ink", children: heading }), _jsx("p", { className: "mt-1 text-sm text-brand-muted", children: body })] }), _jsx("div", { className: "px-5", children: _jsx(Button, { asChild: true, variant: "outline", className: "min-h-11 px-5", children: _jsx("a", { href: href, children: actionLabel }) }) })] }));
13
+ }
@@ -0,0 +1,4 @@
1
+ import * as React from "react";
2
+ import { Label as LabelPrimitive } from "radix-ui";
3
+ declare function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>): React.JSX.Element;
4
+ export { Label };
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cn } from "../lib/utils";
3
+ import { Label as LabelPrimitive } from "radix-ui";
4
+ function Label({ className, ...props }) {
5
+ return (_jsx(LabelPrimitive.Root, { "data-slot": "label", className: cn("flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", className), ...props }));
6
+ }
7
+ export { Label };
@@ -0,0 +1,47 @@
1
+ import "./member-card.css";
2
+ /**
3
+ * The member card block, ported exactly from ppij-identity-sso
4
+ * portal/MemberCard.tsx (design in member-card.css, themes in
5
+ * ../card-themes.ts). Adaptations for the design system are seams
6
+ * only: membership fields arrive as flat props, the card logo and
7
+ * back map are src props, copy comes from the locale label maps
8
+ * below (the app's i18n strings, verbatim), and theme persistence
9
+ * goes through the CardThemePreference seam (an in-memory store by
10
+ * default; the app passes its preference API).
11
+ */
12
+ export type MemberCardState = "active" | "expiring" | "change_pending" | "expired_grace" | "expired" | "suspended";
13
+ /** Where the theme choice persists. Production uses the member preference API; the harness substitutes a simulated store. */
14
+ export interface CardThemePreference {
15
+ read: (memberId: string) => Promise<string | null>;
16
+ save: (memberId: string, id: string | null) => Promise<void>;
17
+ }
18
+ export interface MemberCardBlockProps {
19
+ locale: "id" | "en";
20
+ /** Used only as the card theme preference key; never rendered on the card. */
21
+ memberId: string;
22
+ /** Defaults to an in-memory store. The app passes its member preference API. */
23
+ themePreference?: CardThemePreference;
24
+ /** Full name. The card shows it clamped to two lines; assistive technology gets the whole text. */
25
+ name: string;
26
+ /** Membership number, printed on both faces and encoded in the barcode. */
27
+ number: string;
28
+ /** Komsat display name (may carry the "Komsat " prefix; the card strips it). Null for korda-only members. */
29
+ komsat?: string | null;
30
+ /** Korda display name, e.g. "PPI Kansai". Also seeds the automatic theme. */
31
+ korda: string;
32
+ validUntil?: string | null;
33
+ graceUntil?: string | null;
34
+ suspendReason?: string | null;
35
+ state: MemberCardState;
36
+ /** The PPIJ logo shown on the front (the app serves /ppij-logo.png). */
37
+ logoSrc: string;
38
+ /** The decorative Indonesia map on the back (the app serves /id-map.svg). */
39
+ mapSrc: string;
40
+ }
41
+ /** Only the states with a valid membership show the barcode. */
42
+ export declare const hasBarcode: (state: MemberCardState) => state is "active" | "expiring" | "change_pending";
43
+ /**
44
+ * The member card with its actions. "Barcode" and the tap on the card share one flip state.
45
+ * Buttons appear only while a barcode is available (ACTIVE, expiring, change pending).
46
+ */
47
+ export declare function MemberCardBlock(props: MemberCardBlockProps): import("react").JSX.Element;
@@ -0,0 +1,329 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useLayoutEffect, useEffect, useRef, useState } from "react";
3
+ import Tilt from "react-parallax-tilt";
4
+ import Barcode from "react-barcode";
5
+ import { IconCheck } from "@tabler/icons-react";
6
+ import { AdminDrawer } from "./admin-drawer";
7
+ import { SavingIndicator } from "./saving-indicator";
8
+ import { cardThemes, resolveCardTheme } from "../card-themes";
9
+ import "./member-card.css";
10
+ const memoryStore = new Map();
11
+ /** Module-level singleton: the load effect keys on this object, so a fresh instance per render would refetch forever. */
12
+ const memoryCardThemePreference = {
13
+ read: (memberId) => Promise.resolve(memoryStore.get(memberId) ?? null),
14
+ save: (memberId, id) => {
15
+ memoryStore.set(memberId, id);
16
+ return Promise.resolve();
17
+ },
18
+ };
19
+ const LABELS = {
20
+ id: {
21
+ brand: "PPI Jepang",
22
+ chip: "Aktif",
23
+ chipExpiring: "Berakhir {date}",
24
+ chipGrace: "Masa tenggang",
25
+ chipExpired: "Kedaluwarsa",
26
+ chipSuspended: "Ditangguhkan",
27
+ pending: "Perubahan menunggu persetujuan",
28
+ flipToBack: "Balik kartu untuk menampilkan barcode",
29
+ flipToFront: "Kembali ke depan kartu",
30
+ barcode: "Barcode",
31
+ download: "Unduh",
32
+ downloading: "Menyiapkan...",
33
+ downloadDone: "Kartu disimpan sebagai gambar.",
34
+ downloadFailed: "Kartu belum bisa disimpan. Coba lagi.",
35
+ changeTheme: "Ubah tema",
36
+ themeTitle: "Ubah tema kartu",
37
+ themeHint: "Pilih tampilan yang Anda suka. Tersimpan di akun Anda dan berlaku di semua perangkat. Tema tidak mengubah keanggotaan Anda.",
38
+ themeSaveError: "Gagal menyimpan. Pilihan bertahan untuk sesi ini; pilih lagi untuk mencoba ulang.",
39
+ theme: "Tema kartu",
40
+ themeAuto: "Otomatis · sesuai korda",
41
+ themeAutoShort: "Otomatis",
42
+ themeGroup: { korda: "Korda Jepang", ppij: "PPI Jepang", seasons: "Empat musim Jepang", nusantara: "Nusantara" },
43
+ motto: "Membuka Dunia untuk Indonesia dan Membuka Indonesia untuk Dunia",
44
+ barcodeHint: "Tunjukkan ke pengurus untuk dipindai",
45
+ graceBack: "Masa tenggang sampai {date}. Perpanjang untuk memakai barcode.",
46
+ expiredBack: "Keanggotaan telah berakhir. Perpanjang untuk memakai barcode.",
47
+ suspendedBack: "Barcode dinonaktifkan selama keanggotaan ditangguhkan.",
48
+ unitLabel: "Unit",
49
+ validLabel: "Berlaku s.d.",
50
+ endedLabel: "Berakhir",
51
+ },
52
+ en: {
53
+ brand: "PPI Japan",
54
+ chip: "Active",
55
+ chipExpiring: "Ends {date}",
56
+ chipGrace: "Grace period",
57
+ chipExpired: "Expired",
58
+ chipSuspended: "Suspended",
59
+ pending: "Change awaiting approval",
60
+ flipToBack: "Flip the card to show the barcode",
61
+ flipToFront: "Flip the card back to the front",
62
+ barcode: "Barcode",
63
+ download: "Download",
64
+ downloading: "Preparing...",
65
+ downloadDone: "Card saved as an image.",
66
+ downloadFailed: "The card could not be saved. Try again.",
67
+ changeTheme: "Change theme",
68
+ themeTitle: "Change card theme",
69
+ themeHint: "Choose a look you like. Saved to your account and used on every device. Themes do not change your membership.",
70
+ themeSaveError: "Saving failed. Your choice stays for this session; pick again to retry.",
71
+ theme: "Card theme",
72
+ themeAuto: "Automatic · your korda",
73
+ themeAutoShort: "Automatic",
74
+ themeGroup: { korda: "Japan regions", ppij: "PPI Japan", seasons: "Four seasons in Japan", nusantara: "Nusantara" },
75
+ motto: "Membuka Dunia untuk Indonesia dan Membuka Indonesia untuk Dunia",
76
+ barcodeHint: "Show this to pengurus to scan",
77
+ graceBack: "Grace period until {date}. Renew to use the barcode.",
78
+ expiredBack: "Membership has ended. Renew to use the barcode.",
79
+ suspendedBack: "The barcode is off while the membership is suspended.",
80
+ unitLabel: "Unit",
81
+ validLabel: "Valid to",
82
+ endedLabel: "Ended",
83
+ },
84
+ };
85
+ const MAX_X = 10;
86
+ const MAX_Y = 14;
87
+ const CARD_WIDTH = 350;
88
+ /** The download sheet is 398 px wide (CSS) and is captured at 3x. */
89
+ const SHEET_PIXEL_RATIO = 3;
90
+ const CARD_CLASS = {
91
+ active: "mc-active",
92
+ expiring: "mc-expiring",
93
+ change_pending: "mc-active",
94
+ expired_grace: "mc-grace",
95
+ expired: "mc-expired",
96
+ suspended: "mc-suspended",
97
+ };
98
+ /** Only the states with a valid membership show the barcode. */
99
+ export const hasBarcode = (state) => state === "active" || state === "expiring" || state === "change_pending";
100
+ const INTL_LOCALE = { id: "id-ID", en: "en-GB" };
101
+ /** Day and short month, e.g. "31 Mar" (chip). Year added for the validity line, e.g. "31 Mar 2027". */
102
+ function shortDate(value, locale, withYear) {
103
+ if (!value)
104
+ return "-";
105
+ const date = new Date(`${value}T00:00:00+09:00`);
106
+ if (Number.isNaN(date.getTime()))
107
+ return "-";
108
+ return new Intl.DateTimeFormat(INTL_LOCALE[locale], {
109
+ timeZone: "Asia/Tokyo",
110
+ day: "numeric",
111
+ month: "short",
112
+ ...(withYear ? { year: "numeric" } : {}),
113
+ }).format(date);
114
+ }
115
+ /** Unit line: "Sendai · PPI Kanto". The stored unit names carry type prefixes ("Komsat Sendai");
116
+ * the card shows the real names without them. A korda member without komsat shows just the korda. */
117
+ function unitLine(p) {
118
+ const komsat = p.komsat?.replace(/^Komsat\s+/, "") ?? "";
119
+ return p.komsat ? `${komsat} · ${p.korda}` : p.korda;
120
+ }
121
+ function chipText(state, p, l) {
122
+ switch (state) {
123
+ case "active":
124
+ case "change_pending":
125
+ return l.chip;
126
+ case "expiring":
127
+ return l.chipExpiring.replace("{date}", shortDate(p.validUntil, p.locale, false));
128
+ case "expired_grace":
129
+ return l.chipGrace;
130
+ case "expired":
131
+ return l.chipExpired;
132
+ case "suspended":
133
+ return l.chipSuspended;
134
+ }
135
+ }
136
+ function backNote(state, p, l) {
137
+ switch (state) {
138
+ case "expired_grace":
139
+ return l.graceBack.replace("{date}", shortDate(p.graceUntil, p.locale, true));
140
+ case "expired":
141
+ return l.expiredBack;
142
+ case "suspended":
143
+ return l.suspendedBack;
144
+ default:
145
+ return "";
146
+ }
147
+ }
148
+ /** Front face: logo, chip, name, number, unit line and validity. */
149
+ function Front(p) {
150
+ const l = LABELS[p.locale];
151
+ const state = p.state;
152
+ const ended = state === "expired" || state === "expired_grace";
153
+ return (_jsxs("div", { className: `mc-face mc-front ${CARD_CLASS[state]}`, "data-theme": p.theme?.id, style: { "--mc-base": p.theme?.base }, children: [p.theme && !p.artworkFailed ? (_jsx("svg", { className: "mc-art", viewBox: p.theme.viewBox, preserveAspectRatio: "xMidYMid meet", "aria-hidden": "true", focusable: "false", children: _jsx("image", { href: p.theme.asset, width: p.theme.width, height: p.theme.height, onError: p.onArtworkError }) })) : null, _jsx("div", { className: "mc-shine", "aria-hidden": "true", style: p.shineStyle }), _jsxs("div", { className: "mc-row", children: [_jsx("img", { className: "mc-logo", src: p.logoSrc, alt: l.brand, width: 92, height: 63 }), _jsx("span", { className: "mc-chip", children: chipText(state, p, l) })] }), _jsxs("div", { className: "mc-id", children: [_jsx("span", { className: "mc-name", children: p.name }), state === "suspended" ? (_jsx("span", { className: "mc-note", children: p.suspendReason ?? "" })) : (_jsx("span", { className: "mc-number", children: p.number })), state === "change_pending" ? _jsx("span", { className: "mc-note", children: l.pending }) : null] }), _jsxs("div", { className: "mc-row mc-foot", children: [_jsxs("div", { className: "mc-col", children: [_jsx("span", { className: "mc-label", children: l.unitLabel }), _jsx("span", { className: "mc-val", children: unitLine(p) })] }), state !== "suspended" && p.validUntil ? (_jsxs("div", { className: "mc-col mc-right", children: [_jsx("span", { className: "mc-label", children: ended ? l.endedLabel : l.validLabel }), _jsx("span", { className: "mc-val", children: shortDate(p.validUntil, p.locale, true) })] })) : null] })] }));
154
+ }
155
+ /** Back face: motto, barcode of the membership number, printed number, hint and decorative map. */
156
+ function Back(p) {
157
+ const l = LABELS[p.locale];
158
+ const state = p.state;
159
+ return (_jsxs("div", { className: `mc-face mc-back ${CARD_CLASS[state]}`, children: [_jsx("div", { className: "mc-shine", "aria-hidden": "true", style: p.shineStyle }), state !== "suspended" ? _jsx("img", { className: "mc-map", src: p.mapSrc, alt: "", "aria-hidden": "true" }) : null, _jsx("div", { className: "mc-stripe", children: _jsx("span", { children: l.motto }) }), _jsx("div", { className: "mc-codewrap", children: hasBarcode(state) ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "mc-quiet", children: _jsx(Barcode, { value: p.number, format: "CODE128", width: 1.25, height: 58, displayValue: false, margin: 0, background: "#ffffff", lineColor: "#000000" }) }), _jsx("span", { className: "mc-number mc-small", children: p.number }), _jsx("span", { className: "mc-hint", children: l.barcodeHint })] })) : (_jsx("span", { className: "mc-hint mc-backnote", children: backNote(state, p, l) })) })] }));
160
+ }
161
+ /**
162
+ * Phone tilt follows the change in orientation from the pose the phone is held in. Android and desktop
163
+ * browsers deliver the events with no prompt; iOS needs a prompt, so it is skipped by user agent and the
164
+ * touch-drag tilt of the library is the fallback. The permission API is never called.
165
+ */
166
+ const isIOS = () => typeof navigator !== "undefined" &&
167
+ (/iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1));
168
+ const sensorWithoutPrompt = () => typeof window !== "undefined" && "DeviceOrientationEvent" in window && !isIOS();
169
+ const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi);
170
+ const reduceMotion = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
171
+ /** The tilting, flipping card. One button: click, tap, Enter and Space flip it. */
172
+ function MemberCardFace(props) {
173
+ const { name, flipped, onFlip } = props;
174
+ const l = LABELS[props.locale];
175
+ const still = reduceMotion();
176
+ const [shine, setShine] = useState({ x: 25, y: 15 });
177
+ const [gyro, setGyro] = useState(null);
178
+ const frame = useRef(0);
179
+ const fitRef = useRef(null);
180
+ const [scale, setScale] = useState(1);
181
+ // The card is drawn at 350 px and scaled as a whole on narrow screens, so nothing reflows.
182
+ useLayoutEffect(() => {
183
+ const el = fitRef.current;
184
+ if (!el)
185
+ return;
186
+ const fit = () => setScale(Math.min(1, el.clientWidth / CARD_WIDTH));
187
+ fit();
188
+ const ro = new ResizeObserver(fit);
189
+ ro.observe(el);
190
+ return () => ro.disconnect();
191
+ }, []);
192
+ useEffect(() => {
193
+ if (still || !sensorWithoutPrompt())
194
+ return;
195
+ let base = null;
196
+ const onOrient = (e) => {
197
+ if (e.beta == null || e.gamma == null)
198
+ return;
199
+ base ??= { b: e.beta, g: e.gamma };
200
+ base.b += (e.beta - base.b) * 0.03;
201
+ base.g += (e.gamma - base.g) * 0.03;
202
+ const x = clamp(-(e.beta - base.b) * 0.6, -MAX_X, MAX_X);
203
+ const y = clamp((e.gamma - base.g) * 0.6, -MAX_Y, MAX_Y);
204
+ cancelAnimationFrame(frame.current);
205
+ frame.current = requestAnimationFrame(() => {
206
+ setGyro({ x, y });
207
+ setShine({ x: Math.round(50 + (y / MAX_Y) * 45), y: Math.round(50 - (x / MAX_X) * 45) });
208
+ });
209
+ };
210
+ window.addEventListener("deviceorientation", onOrient);
211
+ return () => {
212
+ window.removeEventListener("deviceorientation", onOrient);
213
+ cancelAnimationFrame(frame.current);
214
+ };
215
+ }, [still]);
216
+ // Each face carries its own shine so it turns with the card (the library's glare layer does not).
217
+ const shineStyle = still
218
+ ? undefined
219
+ : {
220
+ background: `radial-gradient(circle at ${shine.x}% ${shine.y}%, rgba(255,255,255,0.5), rgba(255,255,255,0) 40%), radial-gradient(circle at ${100 - shine.x}% ${100 - shine.y}%, rgba(120,128,138,0.08), rgba(120,128,138,0) 60%)`,
221
+ };
222
+ const flipLabel = flipped ? l.flipToFront : l.flipToBack;
223
+ return (_jsx("div", { ref: fitRef, className: "mc-fit", style: { height: (221 + 28) * scale }, children: _jsxs("button", { type: "button", className: "mc-hit", style: { transform: `scale(${scale})`, transformOrigin: "top left" }, "aria-pressed": flipped, onClick: () => onFlip(!flipped), onPointerMove: (e) => {
224
+ if (still)
225
+ return;
226
+ const r = e.currentTarget.getBoundingClientRect();
227
+ setShine({
228
+ x: Math.round(((e.clientX - r.left) / r.width) * 100),
229
+ y: Math.round(((e.clientY - r.top) / r.height) * 100),
230
+ });
231
+ }, onPointerLeave: () => setShine({ x: 25, y: 15 }), children: [_jsx("span", { className: "mc-sr-only", children: `${name}. ${flipLabel}` }), _jsx(Tilt, { className: "mc-tilt", tiltEnable: !still, tiltMaxAngleX: MAX_X, tiltMaxAngleY: MAX_Y, perspective: 900, glareEnable: false, gyroscope: false, tiltAngleXManual: gyro ? gyro.x : null, tiltAngleYManual: gyro ? gyro.y : null, scale: 1.02, children: _jsxs("div", { className: `mc-flip${flipped ? " is-flipped" : ""}`, children: [_jsx("div", { className: "mc-face-wrap mc-front-wrap", inert: flipped, "aria-hidden": flipped, children: _jsx(Front, { ...props, shineStyle: shineStyle }) }), _jsx("div", { className: "mc-face-wrap mc-back-wrap", inert: !flipped, "aria-hidden": !flipped, children: _jsx(Back, { ...props, shineStyle: shineStyle }) })] }) })] }) }));
232
+ }
233
+ /**
234
+ * The member card with its actions. "Barcode" and the tap on the card share one flip state.
235
+ * Buttons appear only while a barcode is available (ACTIVE, expiring, change pending).
236
+ */
237
+ export function MemberCardBlock(props) {
238
+ return _jsx(MemberCardContent, { ...props }, props.memberId);
239
+ }
240
+ /** One selectable theme preview: the real clipped artwork on its base, with a labelled selected state. */
241
+ function ThemeTile({ locale, theme, selected, onPick }) {
242
+ const l = LABELS[locale];
243
+ return (_jsxs("button", { type: "button", role: "radio", "aria-checked": selected, "data-theme-id": theme?.id ?? null, onClick: onPick, className: `mc-tile${selected ? " mc-tile-on" : ""}`, children: [theme ? (_jsx("span", { className: "mc-thumb", style: { background: theme.base }, "aria-hidden": "true", children: _jsx("svg", { viewBox: theme.viewBox, preserveAspectRatio: "xMidYMid meet", "aria-hidden": "true", focusable: "false", children: _jsx("image", { href: theme.asset, width: theme.width, height: theme.height }) }) })) : (_jsx("span", { className: "mc-thumb mc-thumb-auto", "aria-hidden": "true", children: l.themeAutoShort })), _jsx("span", { className: "mc-tile-label", children: theme ? (locale === "id" ? theme.labelId : theme.labelEn) : l.themeAuto }), selected ? _jsx(IconCheck, { className: "mc-tile-check", "aria-hidden": "true" }) : null] }));
244
+ }
245
+ function MemberCardContent(props) {
246
+ const { locale, state, memberId } = props;
247
+ const l = LABELS[locale];
248
+ // Server preference: korda default renders immediately, the saved choice applies when the
249
+ // read resolves, and saving shows the shared autosave indicator. A failed save keeps the
250
+ // in-session choice with a recoverable error.
251
+ const [pref, setPref] = useState({ value: null, loaded: false });
252
+ const [saveState, setSaveState] = useState("idle");
253
+ const preference = props.themePreference ?? memoryCardThemePreference;
254
+ useEffect(() => {
255
+ let alive = true;
256
+ preference.read(memberId)
257
+ .then((value) => { if (alive)
258
+ setPref({ value, loaded: true }); })
259
+ .catch(() => { if (alive)
260
+ setPref((old) => ({ ...old, loaded: true })); });
261
+ return () => { alive = false; };
262
+ }, [memberId, preference]);
263
+ const selected = pref.loaded ? pref.value : null;
264
+ const [themeOpen, setThemeOpen] = useState(false);
265
+ const pickTheme = (id) => {
266
+ setPref({ value: id, loaded: true });
267
+ setSaveState("saving");
268
+ preference.save(memberId, id)
269
+ .then(() => setSaveState("saved"))
270
+ .catch(() => setSaveState("error"));
271
+ };
272
+ const theme = resolveCardTheme(props.korda, selected);
273
+ const [failedTheme, setFailedTheme] = useState(null);
274
+ const artworkFailed = failedTheme === theme.id;
275
+ const faceProps = { ...props, theme, artworkFailed, onArtworkError: () => setFailedTheme(theme.id) };
276
+ const [flipped, setFlipped] = useState(false);
277
+ const [busy, setBusy] = useState(false);
278
+ const [msg, setMsg] = useState(null);
279
+ const sheet = useRef(null);
280
+ async function download() {
281
+ if (!sheet.current || busy)
282
+ return;
283
+ setBusy(true);
284
+ setMsg(null);
285
+ try {
286
+ // Preload the selected artwork before cloning either face. A failed image becomes a plain card.
287
+ if (!artworkFailed) {
288
+ const loaded = await new Promise((resolve) => {
289
+ const img = new Image();
290
+ const timer = window.setTimeout(() => finish(false), 5000);
291
+ function finish(ok) { window.clearTimeout(timer); img.onload = null; img.onerror = null; resolve(ok); }
292
+ img.onload = () => finish(true);
293
+ img.onerror = () => finish(false);
294
+ img.src = theme.asset;
295
+ });
296
+ if (!loaded) {
297
+ setFailedTheme(theme.id);
298
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
299
+ }
300
+ }
301
+ // Loaded on the first tap only, so the library is not part of the first load of Beranda.
302
+ const { toBlob } = await import("html-to-image");
303
+ const blob = await toBlob(sheet.current, {
304
+ pixelRatio: SHEET_PIXEL_RATIO,
305
+ cacheBust: true,
306
+ imagePlaceholder: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')}`,
307
+ backgroundColor: "#f6f6f4",
308
+ });
309
+ if (!blob)
310
+ throw new Error("empty image");
311
+ const url = URL.createObjectURL(blob);
312
+ const a = document.createElement("a");
313
+ a.href = url;
314
+ a.download = "kartu-anggota-ppij.png";
315
+ document.body.appendChild(a);
316
+ a.click();
317
+ a.remove();
318
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
319
+ setMsg({ ok: true, text: l.downloadDone });
320
+ }
321
+ catch {
322
+ setMsg({ ok: false, text: l.downloadFailed });
323
+ }
324
+ finally {
325
+ setBusy(false);
326
+ }
327
+ }
328
+ return (_jsxs("div", { className: "mc-block", "data-card-state": state, children: [_jsx(MemberCardFace, { ...faceProps, flipped: flipped, onFlip: setFlipped }), hasBarcode(state) ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mc-actions", children: [_jsx("button", { type: "button", className: "mc-btn mc-btn-primary", "aria-pressed": flipped, onClick: () => setFlipped(!flipped), children: l.barcode }), _jsx("button", { type: "button", className: "mc-btn", disabled: busy, onClick: download, children: busy ? l.downloading : l.download }), _jsx("button", { type: "button", className: "mc-btn", "aria-haspopup": "dialog", "aria-expanded": themeOpen, onClick: () => setThemeOpen(true), children: l.changeTheme })] }), _jsx("p", { className: `mc-msg${msg && !msg.ok ? " mc-msg-err" : ""}`, role: "status", "aria-live": "polite", children: msg?.text }), _jsx("div", { className: "mc-sheet-wrap", "aria-hidden": "true", children: _jsxs("div", { className: "mc-sheet", ref: sheet, children: [_jsx(Front, { ...faceProps }), _jsx(Back, { ...faceProps })] }) })] })) : null, _jsxs(AdminDrawer, { open: themeOpen, onOpenChange: setThemeOpen, locale: locale, title: l.themeTitle, description: l.themeHint, children: [_jsx("div", { className: "mb-3 flex justify-end", children: _jsx(SavingIndicator, { status: saveState, locale: locale, errorText: l.themeSaveError }) }), _jsxs("div", { role: "radiogroup", "aria-label": l.theme, className: "mc-theme-grid", children: [_jsx(ThemeTile, { locale: locale, theme: null, selected: selected === null, onPick: () => pickTheme(null) }), ["korda", "ppij", "seasons", "nusantara"].map((group) => (_jsxs("div", { className: "mc-theme-group", children: [_jsx("h3", { children: l.themeGroup[group] }), _jsx("div", { className: "mc-theme-tiles", children: cardThemes.filter((item) => item.group === group).map((item) => (_jsx(ThemeTile, { locale: locale, theme: item, selected: selected === item.id, onPick: () => pickTheme(item.id) }, item.id))) })] }, group)))] })] })] }));
329
+ }