@lindaui/blocks 0.4.2 → 0.5.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 +56 -0
- package/dist/ai-cost-card-f403ypsg.js +99 -0
- package/dist/ai-performance-card-cggm09nc.js +191 -0
- package/dist/analytics/ai-cost-card.d.ts +3 -0
- package/dist/analytics/ai-cost-card.d.ts.map +1 -0
- package/dist/analytics/ai-performance-card.d.ts +3 -0
- package/dist/analytics/ai-performance-card.d.ts.map +1 -0
- package/dist/analytics/analytics-card.d.ts +3 -0
- package/dist/analytics/analytics-card.d.ts.map +1 -0
- package/dist/analytics/analytics-skeleton.d.ts +5 -0
- package/dist/analytics/analytics-skeleton.d.ts.map +1 -0
- package/dist/analytics/entity-health-card.d.ts +3 -0
- package/dist/analytics/entity-health-card.d.ts.map +1 -0
- package/dist/analytics/status-distribution-chart.d.ts +3 -0
- package/dist/analytics/status-distribution-chart.d.ts.map +1 -0
- package/dist/analytics/time-range-picker.d.ts +3 -0
- package/dist/analytics/time-range-picker.d.ts.map +1 -0
- package/dist/analytics/top-entities-table.d.ts +3 -0
- package/dist/analytics/top-entities-table.d.ts.map +1 -0
- package/dist/analytics/types.d.ts +104 -0
- package/dist/analytics/types.d.ts.map +1 -0
- package/dist/analytics-card-bcmijpt6.js +38 -0
- package/dist/analytics-skeleton-imve37du.js +164 -0
- package/dist/analytics.d.ts +11 -0
- package/dist/analytics.d.ts.map +1 -0
- package/dist/analytics.js +8 -0
- package/dist/app-shell/drawer-focus.d.ts +8 -0
- package/dist/app-shell/drawer-focus.d.ts.map +1 -0
- package/dist/app-shell/footer.d.ts +39 -0
- package/dist/app-shell/footer.d.ts.map +1 -0
- package/dist/app-shell/logout-dialog.d.ts +6 -0
- package/dist/app-shell/logout-dialog.d.ts.map +1 -0
- package/dist/app-shell/navigation.d.ts +15 -0
- package/dist/app-shell/navigation.d.ts.map +1 -0
- package/dist/app-shell/types.d.ts +55 -0
- package/dist/app-shell/types.d.ts.map +1 -0
- package/dist/app-shell.d.ts +7 -0
- package/dist/app-shell.d.ts.map +1 -0
- package/dist/app-shell.js +254 -0
- package/dist/channel-settings.d.ts +24 -0
- package/dist/channel-settings.d.ts.map +1 -0
- package/dist/channel-settings.js +60 -0
- package/dist/chat-avatar-jdmeyf2o.js +30 -0
- package/dist/chat-layout-fwx3x26g.js +25 -0
- package/dist/chat.d.ts +22 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +66 -0
- package/dist/command-palette.d.ts +22 -0
- package/dist/command-palette.d.ts.map +1 -0
- package/dist/command-palette.js +141 -0
- package/dist/compat-chat/chat-avatar.d.ts +16 -0
- package/dist/compat-chat/chat-avatar.d.ts.map +1 -0
- package/dist/compat-chat/chat-header.d.ts +9 -0
- package/dist/compat-chat/chat-header.d.ts.map +1 -0
- package/dist/compat-chat/chat-layout.d.ts +10 -0
- package/dist/compat-chat/chat-layout.d.ts.map +1 -0
- package/dist/compat-chat/conversation-list-item.d.ts +10 -0
- package/dist/compat-chat/conversation-list-item.d.ts.map +1 -0
- package/dist/compat-chat/conversation-list.d.ts +24 -0
- package/dist/compat-chat/conversation-list.d.ts.map +1 -0
- package/dist/compat-chat/conversation-thread.d.ts +18 -0
- package/dist/compat-chat/conversation-thread.d.ts.map +1 -0
- package/dist/compat-chat/date-divider.d.ts +5 -0
- package/dist/compat-chat/date-divider.d.ts.map +1 -0
- package/dist/compat-chat/empty-chat-view.d.ts +6 -0
- package/dist/compat-chat/empty-chat-view.d.ts.map +1 -0
- package/dist/compat-chat/image-lightbox.d.ts +7 -0
- package/dist/compat-chat/image-lightbox.d.ts.map +1 -0
- package/dist/compat-chat/message-bubble.d.ts +6 -0
- package/dist/compat-chat/message-bubble.d.ts.map +1 -0
- package/dist/compat-chat/message-composer.d.ts +11 -0
- package/dist/compat-chat/message-composer.d.ts.map +1 -0
- package/dist/compat-chat/time-divider.d.ts +5 -0
- package/dist/compat-chat/time-divider.d.ts.map +1 -0
- package/dist/compat-chat/time-grouping.d.ts +5 -0
- package/dist/compat-chat/time-grouping.d.ts.map +1 -0
- package/dist/compat-chat/types.d.ts +35 -0
- package/dist/compat-chat/types.d.ts.map +1 -0
- package/dist/compat-chat/whatsapp-audio-player.d.ts +7 -0
- package/dist/compat-chat/whatsapp-audio-player.d.ts.map +1 -0
- package/dist/compat.d.ts +9 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +4 -0
- package/dist/connected-panel-lhwy25yk.js +132 -0
- package/dist/connection-panel/connected-panel.d.ts +26 -0
- package/dist/connection-panel/connected-panel.d.ts.map +1 -0
- package/dist/connection-panel/disconnected-panel.d.ts +12 -0
- package/dist/connection-panel/disconnected-panel.d.ts.map +1 -0
- package/dist/connection-panel/instructions.d.ts +5 -0
- package/dist/connection-panel/instructions.d.ts.map +1 -0
- package/dist/connection-panel/qr-panel.d.ts +14 -0
- package/dist/connection-panel/qr-panel.d.ts.map +1 -0
- package/dist/connection-panel/use-qr-refresh.d.ts +18 -0
- package/dist/connection-panel/use-qr-refresh.d.ts.map +1 -0
- package/dist/connection-panel.d.ts +39 -0
- package/dist/connection-panel.d.ts.map +1 -0
- package/dist/connection-panel.js +328 -0
- package/dist/conversation-list-ekq2dzca.js +133 -0
- package/dist/conversation-list-item-fy6t5af2.js +55 -0
- package/dist/conversation-thread-c60431oa.js +205 -0
- package/dist/entity-card.d.ts +17 -0
- package/dist/entity-card.d.ts.map +1 -0
- package/dist/entity-card.js +69 -0
- package/dist/entity-health-card-ggnmrtrq.js +192 -0
- package/dist/entity-switcher.d.ts +33 -0
- package/dist/entity-switcher.d.ts.map +1 -0
- package/dist/entity-switcher.js +137 -0
- package/dist/filter-bar.d.ts +41 -0
- package/dist/filter-bar.d.ts.map +1 -0
- package/dist/filter-bar.js +155 -0
- package/dist/footer-f7r5e42b.js +192 -0
- package/dist/image-lightbox-ci3arb6z.js +62 -0
- package/dist/kanban/drop-utils.d.ts +18 -0
- package/dist/kanban/drop-utils.d.ts.map +1 -0
- package/dist/kanban/navigation-buttons.d.ts +9 -0
- package/dist/kanban/navigation-buttons.d.ts.map +1 -0
- package/dist/kanban/scroll-indicators.d.ts +6 -0
- package/dist/kanban/scroll-indicators.d.ts.map +1 -0
- package/dist/kanban/use-keyboard-navigation.d.ts +4 -0
- package/dist/kanban/use-keyboard-navigation.d.ts.map +1 -0
- package/dist/kanban-board.d.ts +31 -0
- package/dist/kanban-board.d.ts.map +1 -0
- package/dist/kanban-board.js +128 -0
- package/dist/kanban-column.d.ts +13 -0
- package/dist/kanban-column.d.ts.map +1 -0
- package/dist/kanban-column.js +58 -0
- package/dist/legacy-kpi-card.d.ts +16 -0
- package/dist/legacy-kpi-card.d.ts.map +1 -0
- package/dist/legacy-kpi-card.js +4 -0
- package/dist/logout-dialog-bbv59tbi.js +93 -0
- package/dist/message-bubble-odjvhroq.js +110 -0
- package/dist/message-composer-m2n6soo7.js +59 -0
- package/dist/navigation-buttons-e77mshh0.js +65 -0
- package/dist/navigation-hotcrqoq.js +115 -0
- package/dist/save-bar.d.ts +15 -0
- package/dist/save-bar.d.ts.map +1 -0
- package/dist/save-bar.js +38 -0
- package/dist/scroll-indicators-gvxapogd.js +39 -0
- package/dist/settings-layout.d.ts +20 -0
- package/dist/settings-layout.d.ts.map +1 -0
- package/dist/settings-layout.js +56 -0
- package/dist/source-selector.d.ts +18 -0
- package/dist/source-selector.d.ts.map +1 -0
- package/dist/source-selector.js +59 -0
- package/dist/status-distribution-chart-oucedzv7.js +87 -0
- package/dist/status-filters.d.ts +12 -0
- package/dist/status-filters.d.ts.map +1 -0
- package/dist/status-filters.js +31 -0
- package/dist/stepper.d.ts +15 -0
- package/dist/stepper.d.ts.map +1 -0
- package/dist/stepper.js +42 -0
- package/dist/time-range-picker-mu38hul4.js +123 -0
- package/dist/top-entities-table-o7xzpq96.js +78 -0
- package/dist/use-keyboard-navigation-he1xmriz.js +52 -0
- package/dist/whatsapp-audio-player-nooty6jb.js +277 -0
- package/llms.txt +87 -4
- package/package.json +191 -46
- package/src/analytics/ai-cost-card.tsx +31 -0
- package/src/analytics/ai-performance-card.tsx +48 -0
- package/src/analytics/analytics-card.tsx +14 -0
- package/src/analytics/analytics-skeleton.tsx +15 -0
- package/src/analytics/entity-health-card.tsx +50 -0
- package/src/analytics/status-distribution-chart.tsx +20 -0
- package/src/analytics/time-range-picker.tsx +43 -0
- package/src/analytics/top-entities-table.tsx +24 -0
- package/src/analytics/types.ts +43 -0
- package/src/analytics.ts +10 -0
- package/src/app-shell/drawer-focus.ts +42 -0
- package/src/app-shell/footer.tsx +160 -0
- package/src/app-shell/logout-dialog.tsx +68 -0
- package/src/app-shell/navigation.tsx +147 -0
- package/src/app-shell/types.ts +57 -0
- package/src/app-shell.tsx +166 -0
- package/src/area-chart.tsx +85 -0
- package/src/audio-player.tsx +74 -0
- package/src/auth-layout.tsx +132 -0
- package/src/auth-provider.tsx +29 -0
- package/src/bar-chart.tsx +78 -0
- package/src/channel-settings.tsx +76 -0
- package/src/chart-card.tsx +45 -0
- package/src/chat-avatar.tsx +57 -0
- package/src/chat-date-divider.tsx +18 -0
- package/src/chat-header.tsx +24 -0
- package/src/chat-layout.tsx +41 -0
- package/src/chat-time-divider.tsx +20 -0
- package/src/chat-types.ts +32 -0
- package/src/chat.ts +21 -0
- package/src/command-palette.tsx +110 -0
- package/src/compat-chat/chat-avatar.tsx +23 -0
- package/src/compat-chat/chat-header.tsx +12 -0
- package/src/compat-chat/chat-layout.tsx +14 -0
- package/src/compat-chat/conversation-list-item.tsx +27 -0
- package/src/compat-chat/conversation-list.tsx +49 -0
- package/src/compat-chat/conversation-thread.tsx +92 -0
- package/src/compat-chat/date-divider.tsx +7 -0
- package/src/compat-chat/empty-chat-view.tsx +9 -0
- package/src/compat-chat/image-lightbox.tsx +22 -0
- package/src/compat-chat/message-bubble.tsx +28 -0
- package/src/compat-chat/message-composer.tsx +26 -0
- package/src/compat-chat/time-divider.tsx +5 -0
- package/src/compat-chat/time-grouping.ts +19 -0
- package/src/compat-chat/types.ts +31 -0
- package/src/compat-chat/whatsapp-audio-player.tsx +100 -0
- package/src/compat.ts +8 -0
- package/src/confirm-dialog.tsx +85 -0
- package/src/connection-panel/connected-panel.tsx +106 -0
- package/src/connection-panel/disconnected-panel.tsx +48 -0
- package/src/connection-panel/instructions.tsx +9 -0
- package/src/connection-panel/qr-panel.tsx +62 -0
- package/src/connection-panel/use-qr-refresh.ts +52 -0
- package/src/connection-panel.tsx +141 -0
- package/src/conversation-list-item.tsx +53 -0
- package/src/conversation-list.tsx +67 -0
- package/src/conversation-thread.tsx +64 -0
- package/src/detail-panel.tsx +123 -0
- package/src/document-panel.tsx +109 -0
- package/src/document-reader.tsx +29 -0
- package/src/empty-chat-view.tsx +25 -0
- package/src/entity-card.tsx +74 -0
- package/src/entity-health-card.tsx +53 -0
- package/src/entity-switcher.tsx +126 -0
- package/src/fetching-indicator.tsx +16 -0
- package/src/field-array-form.tsx +166 -0
- package/src/filter-bar.tsx +203 -0
- package/src/image-viewer.tsx +129 -0
- package/src/kanban/drop-utils.ts +31 -0
- package/src/kanban/navigation-buttons.tsx +80 -0
- package/src/kanban/scroll-indicators.tsx +47 -0
- package/src/kanban/use-keyboard-navigation.ts +43 -0
- package/src/kanban-board.tsx +160 -0
- package/src/kanban-column.tsx +62 -0
- package/src/legacy-kpi-card.tsx +38 -0
- package/src/line-chart.tsx +58 -0
- package/src/list-item.tsx +63 -0
- package/src/list-pane.tsx +143 -0
- package/src/login-form.tsx +128 -0
- package/src/message-bubble.tsx +70 -0
- package/src/message-composer.tsx +52 -0
- package/src/pie-chart.tsx +70 -0
- package/src/radar-chart.tsx +55 -0
- package/src/radial-chart.tsx +103 -0
- package/src/recording-overlay.tsx +159 -0
- package/src/save-bar.tsx +35 -0
- package/src/settings-layout.tsx +79 -0
- package/src/source-selector.tsx +71 -0
- package/src/split-workspace.tsx +74 -0
- package/src/stat-card.tsx +166 -0
- package/src/stats-grid.tsx +23 -0
- package/src/status-filters.tsx +28 -0
- package/src/stepper.tsx +47 -0
- package/src/table-pane.tsx +194 -0
- package/src/time-range-picker.tsx +89 -0
- package/src/workspace-back-button.tsx +32 -0
- package/dist/chat-types.test.d.ts +0 -2
- package/dist/chat-types.test.d.ts.map +0 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button, Spinner, TextArea } from "@lindaui/ui/compound";
|
|
4
|
+
import { Mic, Send } from "lucide-react";
|
|
5
|
+
import { useCallback } from "react";
|
|
6
|
+
|
|
7
|
+
export interface MessageComposerProps { disabled?: boolean; isSending?: boolean; onSend: () => void; onValueChange: (value: string) => void; placeholder?: string; value: string; }
|
|
8
|
+
/** Controlled glass composer; Enter sends and Shift+Enter retains a newline. */
|
|
9
|
+
export function MessageComposer({ value, onValueChange, onSend, isSending = false, placeholder, disabled = false }: MessageComposerProps) {
|
|
10
|
+
const hasText = value.trim().length > 0;
|
|
11
|
+
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => onValueChange(e.target.value), [onValueChange]);
|
|
12
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
13
|
+
if (e.key === "Enter" && !e.shiftKey && !isSending) {
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
if (hasText) onSend();
|
|
16
|
+
}
|
|
17
|
+
}, [hasText, isSending, onSend]);
|
|
18
|
+
return <div className="absolute inset-x-0 bottom-0 flex justify-center px-4 py-3">
|
|
19
|
+
<div className="flex w-full items-end gap-2 rounded-3xl border border-white/30 bg-white/60 px-4 py-2 shadow-2xl backdrop-blur-2xl backdrop-saturate-200 transition-all lg:w-[70%] dark:border-white/15 dark:bg-slate-800/40">
|
|
20
|
+
<TextArea aria-label={placeholder} className="field-sizing-content max-h-30 min-h-0 flex-1 resize-none overflow-y-auto border-none bg-transparent py-1 text-foreground shadow-none dark:text-white" disabled={disabled || isSending} onChange={handleChange} onKeyDown={handleKeyDown} placeholder={placeholder} rows={1} value={value} />
|
|
21
|
+
<Button className="rounded-full bg-[#00a884] text-white" isDisabled={disabled || isSending} isIconOnly isPending={isSending} onPress={hasText ? onSend : undefined} size="sm" variant="primary">
|
|
22
|
+
{({ isPending }) => isPending ? <Spinner color="current" size="sm" /> : hasText ? <Send className="h-4 w-4" /> : <Mic className="h-4 w-4" />}
|
|
23
|
+
</Button>
|
|
24
|
+
</div>
|
|
25
|
+
</div>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { cn } from "@lindaui/ui/cn";
|
|
2
|
+
|
|
3
|
+
export function TimeDivider({ label, className }: { className?: string; label: string }) {
|
|
4
|
+
return <div className={cn("sticky top-0 z-10 bg-background/80 px-4 py-2 backdrop-blur-sm", className)}><span className="font-medium text-[11px] text-muted">{label}</span></div>;
|
|
5
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import dayjs from "dayjs";
|
|
2
|
+
|
|
3
|
+
export type TimeGroup = "Hoy" | "Ayer" | "Esta semana" | "Este mes" | "Más antiguos";
|
|
4
|
+
export const TIME_GROUP_ORDER = ["Hoy", "Ayer", "Esta semana", "Este mes", "Más antiguos"] as const satisfies readonly TimeGroup[];
|
|
5
|
+
export function getTimeGroup(date: string | Date): TimeGroup {
|
|
6
|
+
const now = dayjs();
|
|
7
|
+
const target = dayjs(date);
|
|
8
|
+
if (target.isSame(now, "day")) return "Hoy";
|
|
9
|
+
if (target.isSame(now.subtract(1, "day"), "day")) return "Ayer";
|
|
10
|
+
if (target.isAfter(now.subtract(7, "day"))) return "Esta semana";
|
|
11
|
+
if (target.isAfter(now.subtract(30, "day"))) return "Este mes";
|
|
12
|
+
return "Más antiguos";
|
|
13
|
+
}
|
|
14
|
+
export function groupByTime<T>(items: T[], getDate: (item: T) => string | Date): Record<TimeGroup, T[]> {
|
|
15
|
+
const grouped = {} as Record<TimeGroup, T[]>;
|
|
16
|
+
for (const group of TIME_GROUP_ORDER) grouped[group] = [];
|
|
17
|
+
items.forEach((item) => grouped[getTimeGroup(getDate(item))].push(item));
|
|
18
|
+
return grouped;
|
|
19
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** Presentation-only types: map domain data in the consumer. */
|
|
4
|
+
export type MessageSide = "center" | "left" | "right";
|
|
5
|
+
export type MessageMediaKind = "audio" | "image" | "pdf";
|
|
6
|
+
export interface GenericMessageMedia { kind: MessageMediaKind; label?: string; url: string; }
|
|
7
|
+
export interface GenericMessage {
|
|
8
|
+
authorTag?: string;
|
|
9
|
+
id: string;
|
|
10
|
+
media?: GenericMessageMedia;
|
|
11
|
+
side: MessageSide;
|
|
12
|
+
text: string;
|
|
13
|
+
timestamp: Date | string;
|
|
14
|
+
}
|
|
15
|
+
export interface ConversationSummary {
|
|
16
|
+
avatarUrl?: string;
|
|
17
|
+
badge?: ReactNode;
|
|
18
|
+
id: string;
|
|
19
|
+
isSelected?: boolean;
|
|
20
|
+
preview?: string;
|
|
21
|
+
subtitle?: string;
|
|
22
|
+
timestamp?: Date | string;
|
|
23
|
+
title: string;
|
|
24
|
+
unreadCount?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface ConversationThreadLabels {
|
|
27
|
+
errorLoading: string;
|
|
28
|
+
loading: string;
|
|
29
|
+
noMessages: string;
|
|
30
|
+
pollingFallback?: string;
|
|
31
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
export interface WhatsAppAudioPlayerProps { isBot?: boolean; src: string; }
|
|
6
|
+
|
|
7
|
+
/** URL-seeded waveform: deterministic across renders and server/client. */
|
|
8
|
+
function generateWaveform(seed: string, count = 60): number[] {
|
|
9
|
+
let h = 0;
|
|
10
|
+
for (let i = 0; i < seed.length; i += 1) h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0;
|
|
11
|
+
return Array.from({ length: count }, () => {
|
|
12
|
+
h = (Math.imul(1_664_525, h) + 1_013_904_223) | 0;
|
|
13
|
+
return 0.12 + ((h >>> 0) / 4_294_967_295) * 0.88;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function formatSeconds(s: number): string {
|
|
17
|
+
if (!Number.isFinite(s) || s < 0) return "0:00";
|
|
18
|
+
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, "0")}`;
|
|
19
|
+
}
|
|
20
|
+
function MicIcon({ color }: { color: string }) {
|
|
21
|
+
return <svg aria-hidden="true" fill={color} focusable="false" height="18" role="img" viewBox="0 0 24 24" width="18" xmlns="http://www.w3.org/2000/svg"><title>Micrófono</title><path d="M12 1a4 4 0 0 1 4 4v7a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v7a2 2 0 1 0 4 0V5a2 2 0 0 0-2-2zm-7 8a1 1 0 0 1 .993.883L6 12a6 6 0 0 0 11.996.2L18 12a1 1 0 0 1 2 0 8 8 0 0 1-7 7.938V21h2a1 1 0 0 1 0 2H9a1 1 0 0 1 0-2h2v-1.062A8 8 0 0 1 4 12a1 1 0 0 1 1-1z" /></svg>;
|
|
22
|
+
}
|
|
23
|
+
function PlayIcon({ color }: { color: string }) { return <svg aria-hidden="true" fill={color} focusable="false" height="16" role="img" viewBox="0 0 24 24" width="16"><title>Reproducir</title><path d="M8 5.14v14l11-7-11-7z" /></svg>; }
|
|
24
|
+
function PauseIcon({ color }: { color: string }) { return <svg aria-hidden="true" fill={color} focusable="false" height="16" role="img" viewBox="0 0 24 24" width="16"><title>Pausar</title><rect height="14" rx="1.5" width="4" x="6" y="5" /><rect height="14" rx="1.5" width="4" x="14" y="5" /></svg>; }
|
|
25
|
+
function SpinnerIcon({ color }: { color: string }) { return <svg aria-hidden="true" className="animate-spin" fill="none" focusable="false" height="16" role="img" stroke={color} strokeWidth="2.5" viewBox="0 0 24 24" width="16"><title>Cargando</title><circle className="opacity-25" cx="12" cy="12" r="10" stroke={color} /><path className="opacity-75" d="M4 12a8 8 0 018-8" stroke={color} /></svg>; }
|
|
26
|
+
function ButtonIcon({ isLoading, isPlaying, color }: { isLoading: boolean; isPlaying: boolean; color: string }) {
|
|
27
|
+
if (isLoading) return <SpinnerIcon color={color} />;
|
|
28
|
+
return isPlaying ? <PauseIcon color={color} /> : <PlayIcon color={color} />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Voice-note playback, buffering, duration tracking and click-to-seek. */
|
|
32
|
+
export function WhatsAppAudioPlayer({ src, isBot = false }: WhatsAppAudioPlayerProps) {
|
|
33
|
+
const audioRef = useRef<HTMLAudioElement>(null);
|
|
34
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
35
|
+
const [progress, setProgress] = useState(0);
|
|
36
|
+
const [duration, setDuration] = useState(0);
|
|
37
|
+
const [currentTime, setCurrentTime] = useState(0);
|
|
38
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
39
|
+
const bars = useMemo(() => generateWaveform(src), [src]);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const audio = audioRef.current;
|
|
42
|
+
if (!audio) return;
|
|
43
|
+
const onTimeUpdate = () => { setCurrentTime(audio.currentTime); setProgress(audio.duration > 0 ? audio.currentTime / audio.duration : 0); };
|
|
44
|
+
const onDurationChange = () => { if (Number.isFinite(audio.duration)) setDuration(audio.duration); };
|
|
45
|
+
const onEnded = () => { setIsPlaying(false); setProgress(0); setCurrentTime(0); audio.currentTime = 0; };
|
|
46
|
+
const onWaiting = () => setIsLoading(true);
|
|
47
|
+
const onCanPlay = () => setIsLoading(false);
|
|
48
|
+
const onPlay = () => setIsPlaying(true);
|
|
49
|
+
const onPause = () => setIsPlaying(false);
|
|
50
|
+
audio.addEventListener("timeupdate", onTimeUpdate);
|
|
51
|
+
audio.addEventListener("durationchange", onDurationChange);
|
|
52
|
+
audio.addEventListener("loadedmetadata", onDurationChange);
|
|
53
|
+
audio.addEventListener("ended", onEnded);
|
|
54
|
+
audio.addEventListener("waiting", onWaiting);
|
|
55
|
+
audio.addEventListener("canplay", onCanPlay);
|
|
56
|
+
audio.addEventListener("play", onPlay);
|
|
57
|
+
audio.addEventListener("pause", onPause);
|
|
58
|
+
return () => {
|
|
59
|
+
audio.removeEventListener("timeupdate", onTimeUpdate);
|
|
60
|
+
audio.removeEventListener("durationchange", onDurationChange);
|
|
61
|
+
audio.removeEventListener("loadedmetadata", onDurationChange);
|
|
62
|
+
audio.removeEventListener("ended", onEnded);
|
|
63
|
+
audio.removeEventListener("waiting", onWaiting);
|
|
64
|
+
audio.removeEventListener("canplay", onCanPlay);
|
|
65
|
+
audio.removeEventListener("play", onPlay);
|
|
66
|
+
audio.removeEventListener("pause", onPause);
|
|
67
|
+
};
|
|
68
|
+
}, []);
|
|
69
|
+
const togglePlay = useCallback(async () => {
|
|
70
|
+
const audio = audioRef.current;
|
|
71
|
+
if (!audio) return;
|
|
72
|
+
if (isPlaying) audio.pause();
|
|
73
|
+
else await audio.play();
|
|
74
|
+
}, [isPlaying]);
|
|
75
|
+
const handleWaveformClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
|
76
|
+
const audio = audioRef.current;
|
|
77
|
+
if (!audio || !Number.isFinite(audio.duration)) return;
|
|
78
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
79
|
+
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
80
|
+
audio.currentTime = ratio * audio.duration;
|
|
81
|
+
setProgress(ratio);
|
|
82
|
+
}, []);
|
|
83
|
+
const colors = isBot ? {
|
|
84
|
+
barPlayed: "rgba(255,255,255,0.9)", barUnplayed: "rgba(255,255,255,0.32)", btnBg: "rgba(0,0,0,0.15)", btnIcon: "#ffffff", micBg: "rgba(0,0,0,0.12)", micIcon: "rgba(255,255,255,0.85)", timer: "rgba(255,255,255,0.7)",
|
|
85
|
+
} : {
|
|
86
|
+
barPlayed: "#00a884", barUnplayed: "#c8d3d8", btnBg: "#00a884", btnIcon: "#ffffff", micBg: "#d9fdd3", micIcon: "#00a884", timer: "#8696a0",
|
|
87
|
+
};
|
|
88
|
+
const displayTime = isPlaying || progress > 0 ? currentTime : duration;
|
|
89
|
+
return <div className="flex w-full items-center gap-2 py-0.5 sm:min-w-[440px]">
|
|
90
|
+
<audio preload="metadata" ref={audioRef} src={src} />
|
|
91
|
+
<div aria-hidden="true" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full" style={{ backgroundColor: colors.micBg }}><MicIcon color={colors.micIcon} /></div>
|
|
92
|
+
<button aria-label={isPlaying ? "Pausar audio" : "Reproducir audio"} className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full transition-opacity hover:opacity-80 active:scale-95 focus:outline-none focus:ring-2 focus:ring-offset-1" onClick={togglePlay} style={{ backgroundColor: colors.btnBg }} type="button"><ButtonIcon color={colors.btnIcon} isLoading={isLoading} isPlaying={isPlaying} /></button>
|
|
93
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
94
|
+
<button aria-label="Cambiar posición del audio" className="flex h-8 w-full items-center gap-[2px] focus:outline-none" onClick={handleWaveformClick} type="button">
|
|
95
|
+
{bars.map((height, i) => <span className="rounded-full transition-colors duration-75" key={i} style={{ backgroundColor: i / bars.length < progress ? colors.barPlayed : colors.barUnplayed, flex: "1", height: `${Math.round(height * 28)}px`, maxHeight: "28px", minHeight: "3px", width: "2px" }} />)}
|
|
96
|
+
</button>
|
|
97
|
+
<span className="select-none text-[10px] leading-none tabular-nums" style={{ color: colors.timer }}>{formatSeconds(displayTime)}</span>
|
|
98
|
+
</div>
|
|
99
|
+
</div>;
|
|
100
|
+
}
|
package/src/compat.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Legacy presentation API isolated from the existing lindaui entry points. */
|
|
2
|
+
export * from "@lindaui/ui/compat";
|
|
3
|
+
export { KPICard } from "./legacy-kpi-card";
|
|
4
|
+
export type { KPICardProps, KPICardColor } from "./legacy-kpi-card";
|
|
5
|
+
export { StatusFilters } from "./status-filters";
|
|
6
|
+
export type { StatusFiltersProps } from "./status-filters";
|
|
7
|
+
export { DateDivider } from "./compat-chat/date-divider";
|
|
8
|
+
export type { DateDividerProps } from "./compat-chat/date-divider";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Dialog } from "@lindaui/ui/dialog";
|
|
4
|
+
import { Button } from "@lindaui/ui/button";
|
|
5
|
+
|
|
6
|
+
export interface ConfirmDialogProps {
|
|
7
|
+
open: boolean;
|
|
8
|
+
onOpenChange: (open: boolean) => void;
|
|
9
|
+
onConfirm: () => void;
|
|
10
|
+
title: ReactNode;
|
|
11
|
+
description?: ReactNode;
|
|
12
|
+
confirmLabel?: string;
|
|
13
|
+
cancelLabel?: string;
|
|
14
|
+
icon?: ReactNode;
|
|
15
|
+
/** Acción irreversible: botón de confirmar en rojo + icon box destructivo. */
|
|
16
|
+
destructive?: boolean;
|
|
17
|
+
/** Chip del actor responsable (ej. firmante) bajo la descripción. */
|
|
18
|
+
identity?: { name: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getInitials(name: string): string {
|
|
22
|
+
return name
|
|
23
|
+
.split(" ")
|
|
24
|
+
.filter((p) => p.length > 0)
|
|
25
|
+
.map((p) => p[0])
|
|
26
|
+
.join("")
|
|
27
|
+
.slice(0, 2)
|
|
28
|
+
.toUpperCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ConfirmDialog({
|
|
32
|
+
open,
|
|
33
|
+
onOpenChange,
|
|
34
|
+
onConfirm,
|
|
35
|
+
title,
|
|
36
|
+
description,
|
|
37
|
+
confirmLabel = "Confirmar",
|
|
38
|
+
cancelLabel = "Cancelar",
|
|
39
|
+
icon,
|
|
40
|
+
destructive = false,
|
|
41
|
+
identity,
|
|
42
|
+
}: ConfirmDialogProps) {
|
|
43
|
+
return (
|
|
44
|
+
<Dialog
|
|
45
|
+
open={open}
|
|
46
|
+
onClose={() => onOpenChange(false)}
|
|
47
|
+
title={title}
|
|
48
|
+
footer={
|
|
49
|
+
<div className="flex flex-col-reverse gap-2 w-full sm:flex-row sm:justify-end sm:w-auto">
|
|
50
|
+
<Button variant="ghost" onPress={() => onOpenChange(false)} className="w-full sm:w-auto">
|
|
51
|
+
{cancelLabel}
|
|
52
|
+
</Button>
|
|
53
|
+
<Button
|
|
54
|
+
variant={destructive ? "danger" : "primary"}
|
|
55
|
+
onPress={onConfirm}
|
|
56
|
+
className="w-full sm:w-auto"
|
|
57
|
+
>
|
|
58
|
+
{confirmLabel}
|
|
59
|
+
</Button>
|
|
60
|
+
</div>
|
|
61
|
+
}
|
|
62
|
+
>
|
|
63
|
+
{icon && (
|
|
64
|
+
<div
|
|
65
|
+
className={`w-10 h-10 rounded-[10px] grid place-items-center mb-3.5 ${
|
|
66
|
+
destructive ? "bg-destructive/10 text-destructive" : "bg-primary/10 text-primary"
|
|
67
|
+
}`}
|
|
68
|
+
>
|
|
69
|
+
{icon}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
{description && (
|
|
73
|
+
<p className="text-sm text-muted-foreground">{description}</p>
|
|
74
|
+
)}
|
|
75
|
+
{identity && (
|
|
76
|
+
<div className="mt-4 px-3.5 py-3 rounded-[calc(var(--radius)-2px)] bg-muted flex items-center gap-2.5">
|
|
77
|
+
<div className="w-8 h-8 rounded-full bg-secondary text-secondary-foreground grid place-items-center text-xs font-semibold shrink-0">
|
|
78
|
+
{getInitials(identity.name)}
|
|
79
|
+
</div>
|
|
80
|
+
<span className="text-xs font-semibold">{identity.name}</span>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</Dialog>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button, Modal, Spinner } from "@lindaui/ui/compound";
|
|
4
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
export interface ConnectionDetail {
|
|
7
|
+
id: string;
|
|
8
|
+
label: ReactNode;
|
|
9
|
+
value: ReactNode;
|
|
10
|
+
valueClassName?: string;
|
|
11
|
+
dateTime?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ConnectedPanelLabels {
|
|
15
|
+
disconnectHint: ReactNode;
|
|
16
|
+
disconnect: ReactNode;
|
|
17
|
+
disconnecting: ReactNode;
|
|
18
|
+
disconnectError: ReactNode;
|
|
19
|
+
confirmTitle: ReactNode;
|
|
20
|
+
confirmDescription: ReactNode;
|
|
21
|
+
cancel: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ConnectedPanelProps {
|
|
25
|
+
details: readonly ConnectionDetail[];
|
|
26
|
+
labels: ConnectedPanelLabels;
|
|
27
|
+
error?: ReactNode;
|
|
28
|
+
onDisconnect: () => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Connection metadata and an async, non-dismissable-while-pending confirmation. */
|
|
32
|
+
export function ConnectedPanel({ details, labels, error: connectionError, onDisconnect }: ConnectedPanelProps) {
|
|
33
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
34
|
+
const [isPending, setIsPending] = useState(false);
|
|
35
|
+
const [error, setError] = useState<ReactNode>(null);
|
|
36
|
+
const pending = useRef(false);
|
|
37
|
+
|
|
38
|
+
async function disconnect() {
|
|
39
|
+
if (pending.current) return;
|
|
40
|
+
pending.current = true;
|
|
41
|
+
setIsPending(true);
|
|
42
|
+
setError(null);
|
|
43
|
+
try {
|
|
44
|
+
await onDisconnect();
|
|
45
|
+
setIsOpen(false);
|
|
46
|
+
} catch {
|
|
47
|
+
setError(labels.disconnectError);
|
|
48
|
+
} finally {
|
|
49
|
+
pending.current = false;
|
|
50
|
+
setIsPending(false);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="space-y-6">
|
|
56
|
+
<dl className="divide-y divide-border/60 text-sm">
|
|
57
|
+
{details.map(({ id, label, value, valueClassName, dateTime }) => (
|
|
58
|
+
<div className="flex flex-col gap-1 py-4 sm:flex-row sm:justify-between sm:gap-6" key={id}>
|
|
59
|
+
<dt className="text-muted">{label}</dt>
|
|
60
|
+
<dd className={valueClassName}>
|
|
61
|
+
{dateTime ? <time dateTime={dateTime}>{value}</time> : value}
|
|
62
|
+
</dd>
|
|
63
|
+
</div>
|
|
64
|
+
))}
|
|
65
|
+
</dl>
|
|
66
|
+
<div className="flex flex-col items-start gap-3 border-border/60 border-t pt-5 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
|
|
67
|
+
<p className="max-w-sm text-muted text-xs leading-relaxed">{labels.disconnectHint}</p>
|
|
68
|
+
<Button
|
|
69
|
+
className="min-h-11 shrink-0"
|
|
70
|
+
onPress={() => { setError(null); setIsOpen(true); }}
|
|
71
|
+
variant="danger-soft"
|
|
72
|
+
>
|
|
73
|
+
{labels.disconnect}
|
|
74
|
+
</Button>
|
|
75
|
+
</div>
|
|
76
|
+
{connectionError && !isOpen && <p className="text-danger text-sm" role="alert">{connectionError}</p>}
|
|
77
|
+
<Modal.Backdrop
|
|
78
|
+
isDismissable={!isPending}
|
|
79
|
+
isKeyboardDismissDisabled={isPending}
|
|
80
|
+
isOpen={isOpen}
|
|
81
|
+
onOpenChange={(open) => { if (!pending.current) setIsOpen(open); }}
|
|
82
|
+
>
|
|
83
|
+
<Modal.Container>
|
|
84
|
+
<Modal.Dialog className="sm:max-w-md" role="alertdialog">
|
|
85
|
+
<Modal.Header>
|
|
86
|
+
<Modal.Heading>{labels.confirmTitle}</Modal.Heading>
|
|
87
|
+
</Modal.Header>
|
|
88
|
+
<Modal.Body>
|
|
89
|
+
<p className="text-muted text-sm leading-relaxed">{labels.confirmDescription}</p>
|
|
90
|
+
{error && <p className="mt-3 text-danger text-sm" role="alert">{error}</p>}
|
|
91
|
+
</Modal.Body>
|
|
92
|
+
<Modal.Footer>
|
|
93
|
+
<Button autoFocus isDisabled={isPending} onPress={() => setIsOpen(false)} variant="tertiary">
|
|
94
|
+
{labels.cancel}
|
|
95
|
+
</Button>
|
|
96
|
+
<Button isPending={isPending} onPress={disconnect} variant="danger">
|
|
97
|
+
{isPending && <Spinner color="current" size="sm" />}
|
|
98
|
+
{isPending ? labels.disconnecting : labels.disconnect}
|
|
99
|
+
</Button>
|
|
100
|
+
</Modal.Footer>
|
|
101
|
+
</Modal.Dialog>
|
|
102
|
+
</Modal.Container>
|
|
103
|
+
</Modal.Backdrop>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Button, Spinner } from "@lindaui/ui/compound";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { ConnectionPanelLabels } from "../connection-panel";
|
|
4
|
+
import { ConnectionInstructions } from "./instructions";
|
|
5
|
+
|
|
6
|
+
interface DisconnectedPanelProps {
|
|
7
|
+
generating: boolean;
|
|
8
|
+
pending: boolean;
|
|
9
|
+
labels: ConnectionPanelLabels;
|
|
10
|
+
error?: ReactNode;
|
|
11
|
+
onConnect: () => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function DisconnectedPanel({ generating, pending, labels, error, onConnect }: DisconnectedPanelProps) {
|
|
15
|
+
return (
|
|
16
|
+
<>
|
|
17
|
+
{generating && (
|
|
18
|
+
<div className="space-y-3" role="status">
|
|
19
|
+
<div className="mx-auto flex size-48 items-center justify-center rounded-xl bg-surface-secondary">
|
|
20
|
+
<Spinner size="lg" />
|
|
21
|
+
</div>
|
|
22
|
+
<p className="text-muted text-sm">{labels.generating}</p>
|
|
23
|
+
</div>
|
|
24
|
+
)}
|
|
25
|
+
<div className="space-y-5">
|
|
26
|
+
<p className="max-w-md text-muted text-sm leading-relaxed">{labels.disconnectedDescription}</p>
|
|
27
|
+
<ConnectionInstructions items={labels.disconnectedInstructions} />
|
|
28
|
+
</div>
|
|
29
|
+
{error && <p className="text-danger text-sm" role="alert">{error}</p>}
|
|
30
|
+
{!generating && (
|
|
31
|
+
<Button
|
|
32
|
+
className="mt-2 min-h-11 w-full whitespace-nowrap sm:w-auto"
|
|
33
|
+
isPending={pending}
|
|
34
|
+
onPress={onConnect}
|
|
35
|
+
size="lg"
|
|
36
|
+
variant="secondary"
|
|
37
|
+
>
|
|
38
|
+
{({ isPending }) => (
|
|
39
|
+
<>
|
|
40
|
+
{isPending && <Spinner color="current" size="sm" />}
|
|
41
|
+
{labels.connect}
|
|
42
|
+
</>
|
|
43
|
+
)}
|
|
44
|
+
</Button>
|
|
45
|
+
)}
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export function ConnectionInstructions({ items }: { items: readonly ReactNode[] }) {
|
|
4
|
+
return (
|
|
5
|
+
<ol className="mx-auto w-fit list-inside list-decimal space-y-2 text-left text-muted text-sm">
|
|
6
|
+
{items.map((item, index) => <li key={index}>{item}</li>)}
|
|
7
|
+
</ol>
|
|
8
|
+
);
|
|
9
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Button, Spinner } from "@lindaui/ui/compound";
|
|
2
|
+
import { RefreshCw } from "lucide-react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import type { ConnectionPanelLabels } from "../connection-panel";
|
|
5
|
+
import { ConnectionInstructions } from "./instructions";
|
|
6
|
+
|
|
7
|
+
interface QRPanelProps {
|
|
8
|
+
qrCode: ReactNode;
|
|
9
|
+
qrFormat?: "ascii" | "image";
|
|
10
|
+
labels: ConnectionPanelLabels;
|
|
11
|
+
timeRemaining: number;
|
|
12
|
+
isRefreshing: boolean;
|
|
13
|
+
error?: ReactNode;
|
|
14
|
+
onRefresh: () => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatTime(seconds: number) {
|
|
18
|
+
return `${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, "0")}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function QRPanel({ qrCode, qrFormat, labels, timeRemaining, isRefreshing, error, onRefresh }: QRPanelProps) {
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
<div className="flex justify-center">
|
|
25
|
+
<div
|
|
26
|
+
className={qrFormat === "ascii"
|
|
27
|
+
? "relative overflow-hidden rounded-xl bg-white p-2 shadow-md ring-1 ring-black/5"
|
|
28
|
+
: "relative flex aspect-square w-48 items-center justify-center rounded-xl bg-white p-3 shadow-md ring-1 ring-black/5"}
|
|
29
|
+
>
|
|
30
|
+
{qrCode}
|
|
31
|
+
{timeRemaining <= 10 && (
|
|
32
|
+
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/60 backdrop-blur-sm">
|
|
33
|
+
<div className="text-center">
|
|
34
|
+
<p className="font-bold text-2xl text-white">{formatTime(timeRemaining)}</p>
|
|
35
|
+
<p className="text-white/80 text-xs">{labels.expiresSoon}</p>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<div className="space-y-2 text-center">
|
|
42
|
+
<div className="flex items-center justify-center gap-2 text-muted" role="status">
|
|
43
|
+
<Spinner color="current" size="sm" />
|
|
44
|
+
<span className="font-medium text-sm">{labels.waiting}</span>
|
|
45
|
+
</div>
|
|
46
|
+
<p className="text-muted text-xs">{labels.expiresIn(formatTime(timeRemaining))}</p>
|
|
47
|
+
</div>
|
|
48
|
+
<div className="border-border/60 border-t pt-5">
|
|
49
|
+
<ConnectionInstructions items={labels.instructions} />
|
|
50
|
+
</div>
|
|
51
|
+
{error && <p className="text-danger text-sm" role="alert">{error}</p>}
|
|
52
|
+
<Button className="min-h-11 w-full sm:w-auto" isPending={isRefreshing} onPress={onRefresh} variant="tertiary">
|
|
53
|
+
{({ isPending }) => (
|
|
54
|
+
<>
|
|
55
|
+
{isPending ? <Spinner color="current" size="sm" /> : <RefreshCw className="h-4 w-4" />}
|
|
56
|
+
{labels.refresh}
|
|
57
|
+
</>
|
|
58
|
+
)}
|
|
59
|
+
</Button>
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface QRRefreshOptions {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
expirySeconds: number;
|
|
6
|
+
error?: ReactNode;
|
|
7
|
+
errorLabel: ReactNode;
|
|
8
|
+
onRefresh: () => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Pause hidden challenges and never overlap refreshes or automatically retry failures. */
|
|
12
|
+
export function useQRRefresh({ enabled, expirySeconds, error, errorLabel, onRefresh }: QRRefreshOptions) {
|
|
13
|
+
const [timeRemaining, setTimeRemaining] = useState(expirySeconds);
|
|
14
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
15
|
+
const [actionError, setActionError] = useState<ReactNode>(null);
|
|
16
|
+
const pending = useRef(false);
|
|
17
|
+
|
|
18
|
+
const refresh = useCallback(async () => {
|
|
19
|
+
if (pending.current) return;
|
|
20
|
+
pending.current = true;
|
|
21
|
+
setIsRefreshing(true);
|
|
22
|
+
setActionError(null);
|
|
23
|
+
try {
|
|
24
|
+
await onRefresh();
|
|
25
|
+
setTimeRemaining(expirySeconds);
|
|
26
|
+
} catch {
|
|
27
|
+
setActionError(errorLabel);
|
|
28
|
+
} finally {
|
|
29
|
+
pending.current = false;
|
|
30
|
+
setIsRefreshing(false);
|
|
31
|
+
}
|
|
32
|
+
}, [onRefresh, expirySeconds, errorLabel]);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!enabled) {
|
|
36
|
+
setTimeRemaining(expirySeconds);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (isRefreshing || error || actionError) return;
|
|
40
|
+
const timer = setInterval(() => setTimeRemaining((value) => Math.max(0, value - 1)), 1000);
|
|
41
|
+
return () => clearInterval(timer);
|
|
42
|
+
}, [enabled, expirySeconds, isRefreshing, error, actionError]);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (enabled && timeRemaining === 0 && !error && !actionError && !isRefreshing) {
|
|
46
|
+
setTimeRemaining(expirySeconds);
|
|
47
|
+
void refresh();
|
|
48
|
+
}
|
|
49
|
+
}, [enabled, timeRemaining, expirySeconds, error, actionError, isRefreshing, refresh]);
|
|
50
|
+
|
|
51
|
+
return { timeRemaining, isRefreshing, actionError, setActionError, refresh };
|
|
52
|
+
}
|