keystone-design-bootstrap 1.0.90 → 1.0.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.90",
3
+ "version": "1.0.92",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -74,26 +74,29 @@ export function ChatWidget({
74
74
  const [messages, setMessages] = useState<Message[]>([]);
75
75
  const [inputValue, setInputValue] = useState('');
76
76
  const [isLoading, setIsLoading] = useState(false);
77
- const [sessionId, setSessionId] = useState<string>('');
77
+ // Resolve the anonymous session id once, up front. Authenticated visitors
78
+ // (contactId) don't need one. The value is never rendered, so deriving it
79
+ // lazily is hydration-safe and avoids a setState-in-effect cascade.
80
+ const [sessionId] = useState<string>(() => {
81
+ if (contactId) return '';
82
+ if (providedSessionId) return providedSessionId;
83
+ if (typeof window === 'undefined') return '';
84
+ return (
85
+ localStorage.getItem('keystone_chat_session_id') ??
86
+ `session_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
87
+ );
88
+ });
78
89
  const [waitingForReply, setWaitingForReply] = useState(false);
79
90
  const messagesEndRef = useRef<HTMLDivElement>(null);
80
91
 
81
- // When authenticated (contactId), skip session management. Otherwise, generate/retrieve session ID.
92
+ // Persist a freshly generated anonymous session id. The write lives in an
93
+ // effect (out of render) and never calls setState, so renders don't cascade.
82
94
  useEffect(() => {
83
- if (contactId) return;
84
- if (providedSessionId) {
85
- setSessionId(providedSessionId);
86
- } else {
87
- const stored = localStorage.getItem('keystone_chat_session_id');
88
- if (stored) {
89
- setSessionId(stored);
90
- } else {
91
- const newId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
92
- localStorage.setItem('keystone_chat_session_id', newId);
93
- setSessionId(newId);
94
- }
95
+ if (contactId || providedSessionId || !sessionId) return;
96
+ if (localStorage.getItem('keystone_chat_session_id') !== sessionId) {
97
+ localStorage.setItem('keystone_chat_session_id', sessionId);
95
98
  }
96
- }, [contactId, providedSessionId]);
99
+ }, [contactId, providedSessionId, sessionId]);
97
100
 
98
101
  const loadMessages = useCallback(async () => {
99
102
  // Need either a contactId or an active session before fetching.
@@ -115,9 +118,12 @@ export function ChatWidget({
115
118
  return [];
116
119
  }, [contactId, sessionId]);
117
120
 
118
- // Load message history when opened (either authenticated or session is ready)
121
+ // Load message history when opened (either authenticated or session is ready).
122
+ // loadMessages is async — any setState happens after the fetch resolves, not
123
+ // synchronously during the effect, so it doesn't trigger a render cascade.
119
124
  useEffect(() => {
120
125
  if (isOpen && (contactId || sessionId)) {
126
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch-on-open; state updates after await
121
127
  loadMessages();
122
128
  }
123
129
  }, [isOpen, contactId, sessionId, loadMessages]);
@@ -5,9 +5,14 @@ import React, { useState, useEffect, useRef } from 'react';
5
5
  interface BookIframePanelProps {
6
6
  bookingHref: string;
7
7
  businessName: string;
8
+ /**
9
+ * When false, the blurred preview is shown but the overlay card becomes a
10
+ * sign-in prompt instead of the "Start Booking" / "Open in New Tab" actions.
11
+ */
12
+ isLoggedIn?: boolean;
8
13
  }
9
14
 
10
- export function BookIframePanel({ bookingHref, businessName }: BookIframePanelProps) {
15
+ export function BookIframePanel({ bookingHref, businessName, isLoggedIn = true }: BookIframePanelProps) {
11
16
  const [hasOpened, setHasOpened] = useState(false);
12
17
  const [modalOpen, setModalOpen] = useState(false);
13
18
  const [isVisible, setIsVisible] = useState(false);
@@ -67,31 +72,51 @@ export function BookIframePanel({ bookingHref, businessName }: BookIframePanelPr
67
72
 
68
73
  {/* Backdrop blur + message overlay */}
69
74
  <div className="absolute inset-0 flex flex-col items-center justify-center px-6 py-8 backdrop-blur-[3.9px] bg-primary/70">
70
- <div className="w-full max-w-sm rounded-component border border-secondary bg-primary px-6 py-6 text-center shadow-sm">
71
- <p className="text-xl font-bold text-primary">Booking Instructions</p>
72
- <p className="mt-5 text-sm text-secondary leading-relaxed">
73
- We use a separate platform that may ask for an additional sign in or intake details to fully create your profile and collect your appointment information.
74
- </p>
75
- <div className="mt-5 flex flex-wrap items-center justify-center gap-3">
75
+ {isLoggedIn ? (
76
+ <div className="w-full max-w-sm rounded-component border border-secondary bg-primary px-6 py-6 text-center shadow-sm">
77
+ <p className="text-xl font-bold text-primary">Booking Instructions</p>
78
+ <p className="mt-5 text-sm text-secondary leading-relaxed">
79
+ We use a separate platform that may ask for an additional sign in or intake details to fully create your profile and collect your appointment information.
80
+ </p>
81
+ <div className="mt-5 flex flex-wrap items-center justify-center gap-3">
82
+ <button
83
+ onClick={openModal}
84
+ className="inline-flex items-center gap-2 rounded-interactive bg-brand-solid px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-solid_hover"
85
+ >
86
+ Start Booking
87
+ </button>
88
+ <a
89
+ href={bookingHref}
90
+ target="_blank"
91
+ rel="noopener noreferrer"
92
+ className="inline-flex items-center gap-1.5 rounded-interactive border border-secondary bg-secondary px-5 py-2.5 text-sm font-semibold text-primary transition-colors hover:bg-secondary_hover"
93
+ >
94
+ Open in New Tab
95
+ <svg className="size-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
96
+ <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
97
+ </svg>
98
+ </a>
99
+ </div>
100
+ </div>
101
+ ) : (
102
+ <div className="w-full max-w-sm rounded-component border border-secondary bg-primary px-6 py-6 text-center shadow-sm">
103
+ <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-secondary">
104
+ <svg className="size-5 text-quaternary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
105
+ <path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
106
+ </svg>
107
+ </div>
108
+ <p className="text-xl font-bold text-primary">Sign in to book</p>
109
+ <p className="mt-3 text-sm text-secondary leading-relaxed">
110
+ Create a free account or sign in to book your appointment.
111
+ </p>
76
112
  <button
77
- onClick={openModal}
78
- className="inline-flex items-center gap-2 rounded-interactive bg-brand-solid px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-solid_hover"
113
+ data-open-login-modal
114
+ className="mt-5 inline-flex cursor-pointer items-center gap-2 rounded-interactive bg-brand-solid px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-solid_hover"
79
115
  >
80
- Start Booking
116
+ Sign in
81
117
  </button>
82
- <a
83
- href={bookingHref}
84
- target="_blank"
85
- rel="noopener noreferrer"
86
- className="inline-flex items-center gap-1.5 rounded-interactive border border-secondary bg-secondary px-5 py-2.5 text-sm font-semibold text-primary transition-colors hover:bg-secondary_hover"
87
- >
88
- Open in New Tab
89
- <svg className="size-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
90
- <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
91
- </svg>
92
- </a>
93
118
  </div>
94
- </div>
119
+ )}
95
120
  </div>
96
121
  </div>
97
122
 
@@ -538,19 +538,23 @@ function BookPanel({
538
538
  isLoggedIn: boolean;
539
539
  businessName: string;
540
540
  }) {
541
- if (!isLoggedIn) {
542
- return <LoginWall message="Continue to view booking options." cta="View Booking Options" />;
543
- }
544
-
541
+ // When the platform supports embedding, always show the blurred preview.
542
+ // The overlay card itself adapts: a sign-in prompt when logged out, the
543
+ // booking instructions + actions when logged in.
545
544
  if (bookingAllowsIframe) {
546
545
  return (
547
546
  <>
548
- <PortalTabTracker event="InitiateCheckout" tab="booking" />
549
- <BookIframePanel bookingHref={bookingHref} businessName={businessName} />
547
+ {isLoggedIn && <PortalTabTracker event="InitiateCheckout" tab="booking" />}
548
+ <BookIframePanel bookingHref={bookingHref} businessName={businessName} isLoggedIn={isLoggedIn} />
550
549
  </>
551
550
  );
552
551
  }
553
552
 
553
+ // No embeddable preview to tease — keep booking gated behind the login wall.
554
+ if (!isLoggedIn) {
555
+ return <LoginWall message="Continue to view booking options." cta="View Booking Options" />;
556
+ }
557
+
554
558
  return (
555
559
  <>
556
560
  <PortalTabTracker event="InitiateCheckout" tab="booking" />
@@ -738,7 +742,7 @@ export async function PortalPage({
738
742
  <div className="flex gap-1 overflow-x-auto pb-px scrollbar-none">
739
743
  {tabs.map((t) => {
740
744
  const isActive = tab === t.id;
741
- const isGated = !isLoggedIn && (t.id === 'specials' || t.id === 'messages' || t.id === 'book');
745
+ const isGated = !isLoggedIn && (t.id === 'specials' || t.id === 'messages');
742
746
  const href = `${portalHref}?tab=${t.id}`;
743
747
  const className = `shrink-0 rounded-interactive px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
744
748
  isActive ? 'bg-brand-solid text-white' : 'text-secondary hover:bg-secondary hover:text-primary'
@@ -0,0 +1,20 @@
1
+ declare module '@rails/actioncable' {
2
+ export interface Cable {
3
+ subscriptions: {
4
+ create(
5
+ channelName: string | { channel: string; [key: string]: unknown },
6
+ callbacks?: {
7
+ connected?(): void;
8
+ disconnected?(): void;
9
+ received?(data: unknown): void;
10
+ rejected?(): void;
11
+ }
12
+ ): {
13
+ unsubscribe(): void;
14
+ };
15
+ };
16
+ disconnect(): void;
17
+ }
18
+
19
+ export function createConsumer(url?: string): Cable;
20
+ }