keystone-design-bootstrap 1.0.65 → 1.0.66

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.65",
3
+ "version": "1.0.66",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,132 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useRef } from 'react';
4
+
5
+ interface BookIframePanelProps {
6
+ bookingHref: string;
7
+ businessName: string;
8
+ }
9
+
10
+ export function BookIframePanel({ bookingHref, businessName }: BookIframePanelProps) {
11
+ const [hasOpened, setHasOpened] = useState(false);
12
+ const [modalOpen, setModalOpen] = useState(false);
13
+ const [isVisible, setIsVisible] = useState(false);
14
+ const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
15
+
16
+ const openModal = () => {
17
+ setHasOpened(true);
18
+ setModalOpen(true);
19
+ };
20
+
21
+ const closeModal = () => {
22
+ setIsVisible(false);
23
+ closeTimerRef.current = setTimeout(() => setModalOpen(false), 300);
24
+ };
25
+
26
+ // Clear any pending close timer on unmount.
27
+ useEffect(() => {
28
+ return () => {
29
+ if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
30
+ };
31
+ }, []);
32
+
33
+ // Animate in after mount — double rAF ensures the transition fires after the
34
+ // element is painted rather than immediately on insertion.
35
+ useEffect(() => {
36
+ if (!modalOpen) return;
37
+ let cancelled = false;
38
+ const id = requestAnimationFrame(() =>
39
+ requestAnimationFrame(() => {
40
+ if (!cancelled) setIsVisible(true);
41
+ })
42
+ );
43
+ return () => {
44
+ cancelled = true;
45
+ cancelAnimationFrame(id);
46
+ };
47
+ }, [modalOpen]);
48
+
49
+ // Prevent body scroll while modal is open.
50
+ useEffect(() => {
51
+ document.body.style.overflow = modalOpen ? 'hidden' : '';
52
+ return () => { document.body.style.overflow = ''; };
53
+ }, [modalOpen]);
54
+
55
+ return (
56
+ <>
57
+ {/* Blurred iframe preview with overlay message */}
58
+ <div className="relative rounded-component border border-secondary overflow-hidden" style={{ height: '70vh' }}>
59
+ {/* iframe — purely visual, non-interactive */}
60
+ <iframe
61
+ src={bookingHref}
62
+ className="w-full h-full pointer-events-none select-none"
63
+ title="Booking preview"
64
+ tabIndex={-1}
65
+ aria-hidden="true"
66
+ />
67
+
68
+ {/* Backdrop blur + message overlay */}
69
+ <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">
76
+ <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"
79
+ >
80
+ Start Booking
81
+ </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
+ </div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+
98
+ {/* Full-screen modal — kept mounted after first open so the iframe session persists on close/reopen */}
99
+ {hasOpened && (
100
+ <div
101
+ className={`fixed inset-0 z-50 flex flex-col bg-primary transition-[opacity,transform] duration-300 ease-out ${
102
+ modalOpen && isVisible
103
+ ? 'opacity-100 translate-y-0'
104
+ : 'opacity-0 translate-y-3 pointer-events-none'
105
+ }`}
106
+ >
107
+ {/* Modal header */}
108
+ <div className="flex shrink-0 items-center justify-between border-b border-secondary bg-primary px-4 py-3">
109
+ <span className="text-sm font-semibold text-primary">{businessName} Booking</span>
110
+ <button
111
+ onClick={closeModal}
112
+ className="inline-flex items-center gap-2 rounded-interactive bg-brand-solid px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-brand-solid_hover"
113
+ >
114
+ <svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
115
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
116
+ </svg>
117
+ Close
118
+ </button>
119
+ </div>
120
+
121
+ {/* iframe fills the rest of the screen */}
122
+ <iframe
123
+ src={bookingHref}
124
+ className="min-h-0 flex-1 w-full"
125
+ title="Book appointment"
126
+ allow="payment"
127
+ />
128
+ </div>
129
+ )}
130
+ </>
131
+ );
132
+ }
@@ -7,6 +7,7 @@ import { LoginModalController } from './LoginModalController';
7
7
  import { MessageComposer } from './MessageComposer';
8
8
  import { RowThumbnail } from './RowThumbnail';
9
9
  import { PortalTabTracker } from './PortalTabTracker';
10
+ import { BookIframePanel } from './BookIframePanel';
10
11
  import {
11
12
  CONSUMER_TOKEN_COOKIE,
12
13
  fetchConsumerMe,
@@ -508,11 +509,13 @@ function BookPanel({
508
509
  bookingLabel,
509
510
  bookingAllowsIframe,
510
511
  isLoggedIn,
512
+ businessName,
511
513
  }: {
512
514
  bookingHref: string;
513
515
  bookingLabel: string;
514
516
  bookingAllowsIframe: boolean;
515
517
  isLoggedIn: boolean;
518
+ businessName: string;
516
519
  }) {
517
520
  if (!isLoggedIn) {
518
521
  return <LoginWall message="Continue to view booking options." cta="View Booking Options" />;
@@ -522,14 +525,7 @@ function BookPanel({
522
525
  return (
523
526
  <>
524
527
  <PortalTabTracker event="InitiateCheckout" />
525
- <div className="rounded-component border border-secondary overflow-hidden" style={{ height: '70vh' }}>
526
- <iframe
527
- src={bookingHref}
528
- className="w-full h-full"
529
- title="Book appointment"
530
- allow="payment"
531
- />
532
- </div>
528
+ <BookIframePanel bookingHref={bookingHref} businessName={businessName} />
533
529
  </>
534
530
  );
535
531
  }
@@ -759,6 +755,7 @@ export async function PortalPage({
759
755
  bookingLabel={bookingLabel}
760
756
  bookingAllowsIframe={bookingAllowsIframe}
761
757
  isLoggedIn={isLoggedIn}
758
+ businessName={businessName}
762
759
  />
763
760
  ) : (
764
761
  <div className="flex flex-col items-center justify-center py-20 text-center">
@@ -9,7 +9,7 @@ import { cx } from '../../utils/cx';
9
9
  import { getLogoUrl } from '../../utils/photo-helpers';
10
10
  import type { HeaderComponentProps } from './header-navigation';
11
11
  import type { NavItem } from '../../types/config';
12
- import { resolveCtaUrls, isExternalCtaUrl } from '../../lib/cta-urls';
12
+ import { resolveCtaUrls, isExternalCtaUrl, resolvePortalPath } from '../../lib/cta-urls';
13
13
 
14
14
  // Maximum items to show before "View All" link
15
15
  const MAX_DROPDOWN_ITEMS = 3;
@@ -62,11 +62,7 @@ export function HeaderNavigation({
62
62
  // Hide the sticky bottom bar when the user is already on the portal page —
63
63
  // the portal has its own Book Now tab so the bar is redundant and confusing.
64
64
  const pathname = usePathname();
65
- const portalPath = (() => {
66
- const url = companyInformation?.portal_url?.trim();
67
- if (!url) return null;
68
- try { return new URL(url).pathname; } catch { return url.startsWith('/') ? url : null; }
69
- })();
65
+ const portalPath = resolvePortalPath(companyInformation);
70
66
  const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
71
67
 
72
68
  // Cancel any pending close timeout
@@ -8,7 +8,7 @@ import { Button } from '../elements';
8
8
  import { getLogoUrl } from '../../utils/photo-helpers';
9
9
  import type { HeaderComponentProps } from './header-navigation';
10
10
  import type { NavItem } from '../../types/config';
11
- import { resolveCtaUrls, isExternalCtaUrl } from '../../lib/cta-urls';
11
+ import { resolveCtaUrls, isExternalCtaUrl, resolvePortalPath } from '../../lib/cta-urls';
12
12
 
13
13
  export function HeaderNavigation({
14
14
  props,
@@ -41,11 +41,7 @@ export function HeaderNavigation({
41
41
  // Hide the sticky bottom bar when the user is already on the portal page —
42
42
  // the portal has its own Book Now tab so the bar is redundant and confusing.
43
43
  const pathname = usePathname();
44
- const portalPath = (() => {
45
- const url = companyInformation?.portal_url?.trim();
46
- if (!url) return null;
47
- try { return new URL(url).pathname; } catch { return url.startsWith('/') ? url : null; }
48
- })();
44
+ const portalPath = resolvePortalPath(companyInformation);
49
45
  const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
50
46
 
51
47
  const cancelCloseTimeout = useCallback(() => {
@@ -8,7 +8,7 @@ import { Button } from '../elements';
8
8
  import { getLogoUrl } from '../../utils/photo-helpers';
9
9
  import type { HeaderComponentProps } from './header-navigation';
10
10
  import type { NavItem } from '../../types/config';
11
- import { resolveCtaUrls, isExternalCtaUrl } from '../../lib/cta-urls';
11
+ import { resolveCtaUrls, isExternalCtaUrl, resolvePortalPath } from '../../lib/cta-urls';
12
12
 
13
13
  // Maximum items to show before "View All" link
14
14
  const MAX_DROPDOWN_ITEMS = 6;
@@ -47,11 +47,7 @@ export function HeaderNavigation({
47
47
  // Hide the sticky bottom bar when the user is already on the portal page —
48
48
  // the portal has its own Book Now tab so the bar is redundant and confusing.
49
49
  const pathname = usePathname();
50
- const portalPath = (() => {
51
- const url = companyInformation?.portal_url?.trim();
52
- if (!url) return null;
53
- try { return new URL(url).pathname; } catch { return url.startsWith('/') ? url : null; }
54
- })();
50
+ const portalPath = resolvePortalPath(companyInformation);
55
51
  const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
56
52
 
57
53
  // Cancel any pending close timeout
@@ -12,7 +12,7 @@ import { getLogoUrl } from '../../utils/photo-helpers';
12
12
  import type { CompanyInformation } from '../../types/api/company-information';
13
13
  import type { WebsitePhotos } from '../../types/api/website-photos';
14
14
  import type { NavItem, SiteConfig } from '../../types/config';
15
- import { resolveCtaUrls, isExternalCtaUrl } from '../../lib/cta-urls';
15
+ import { resolveCtaUrls, isExternalCtaUrl, resolvePortalPath } from '../../lib/cta-urls';
16
16
 
17
17
  export interface HeaderProps {
18
18
  logo: {
@@ -112,11 +112,7 @@ export function HeaderNavigation({
112
112
  // Hide the sticky bottom bar when the user is already on the portal page —
113
113
  // the portal has its own Book Now tab so the bar is redundant and confusing.
114
114
  const pathname = usePathname();
115
- const portalPath = (() => {
116
- const url = companyInformation?.portal_url?.trim();
117
- if (!url) return null;
118
- try { return new URL(url).pathname; } catch { return url.startsWith('/') ? url : null; }
119
- })();
115
+ const portalPath = resolvePortalPath(companyInformation);
120
116
  const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
121
117
 
122
118
  const getVariantClasses = () => {
@@ -25,6 +25,22 @@ export interface ResolvedCtaUrls {
25
25
  hasSecondary: boolean;
26
26
  }
27
27
 
28
+ /**
29
+ * Extracts the pathname from portal_url for use in client-side route comparisons.
30
+ * Handles both absolute URLs (https://domain.com/portal → /portal) and relative paths (/portal).
31
+ */
32
+ export function resolvePortalPath(
33
+ companyInformation?: CompanyInformation | null
34
+ ): string | null {
35
+ const url = companyInformation?.portal_url?.trim();
36
+ if (!url) return null;
37
+ try {
38
+ return new URL(url).pathname;
39
+ } catch {
40
+ return url.startsWith('/') ? url : null;
41
+ }
42
+ }
43
+
28
44
  export function resolveCtaUrls(
29
45
  companyInformation?: CompanyInformation | null
30
46
  ): ResolvedCtaUrls {