keystone-design-bootstrap 1.0.55 → 1.0.56
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/dist/design_system/elements/index.js +8 -3
- package/dist/design_system/elements/index.js.map +1 -1
- package/dist/design_system/sections/index.js +203 -106
- package/dist/design_system/sections/index.js.map +1 -1
- package/dist/index.js +303 -247
- package/dist/index.js.map +1 -1
- package/dist/lib/hooks/index.js +72 -0
- package/dist/lib/hooks/index.js.map +1 -1
- package/dist/lib/server-api.js.map +1 -1
- package/dist/utils/phone-helpers.js +26 -0
- package/dist/utils/phone-helpers.js.map +1 -0
- package/package.json +5 -2
- package/src/design_system/components/ChatWidget.tsx +51 -34
- package/src/design_system/components/DynamicFormFields.tsx +1 -24
- package/src/design_system/elements/modal/modal.tsx +54 -35
- package/src/design_system/portal/LoginForm.tsx +339 -0
- package/src/design_system/portal/LoginModalController.tsx +63 -0
- package/src/design_system/portal/LogoutButton.tsx +23 -0
- package/src/design_system/portal/MessageComposer.tsx +84 -0
- package/src/design_system/portal/PortalPage.tsx +754 -0
- package/src/design_system/portal/RowThumbnail.tsx +76 -0
- package/src/design_system/portal/actions.ts +160 -0
- package/src/design_system/portal/index.ts +5 -0
- package/src/design_system/sections/index.tsx +1 -1
- package/src/design_system/sections/service-menu-section.tsx +7 -108
- package/src/lib/actions.ts +51 -115
- package/src/lib/consumer-session.ts +74 -0
- package/src/lib/hooks/index.ts +2 -0
- package/src/lib/hooks/use-image-cycle.ts +105 -0
- package/src/lib/server-api.ts +7 -6
- package/src/next/routes/chat.ts +30 -58
- package/src/next/routes/consumer-auth.ts +113 -0
- package/src/types/api/consumer.ts +39 -0
- package/src/types/api/offer.ts +1 -1
- package/src/types/api/package.ts +20 -0
- package/src/types/api/service.ts +6 -24
- package/src/types/index.ts +2 -0
- package/src/utils/phone-helpers.ts +27 -0
- package/dist/blog-post-DGjaJ3wf.d.ts +0 -50
- package/dist/contexts/index.d.ts +0 -13
- package/dist/design_system/elements/index.d.ts +0 -372
- package/dist/design_system/logo/keystone-logo.d.ts +0 -6
- package/dist/design_system/sections/index.d.ts +0 -237
- package/dist/form-CpsCONG5.d.ts +0 -151
- package/dist/index.d.ts +0 -76
- package/dist/lib/component-registry.d.ts +0 -13
- package/dist/lib/hooks/index.d.ts +0 -64
- package/dist/lib/server-api.d.ts +0 -43
- package/dist/themes/index.d.ts +0 -16
- package/dist/types/index.d.ts +0 -264
- package/dist/utils/cx.d.ts +0 -15
- package/dist/utils/gradient-placeholder.d.ts +0 -8
- package/dist/utils/is-react-component.d.ts +0 -21
- package/dist/utils/markdown-toc.d.ts +0 -14
- package/dist/utils/photo-helpers.d.ts +0 -37
- package/dist/website-photos-Bm-CBK9g.d.ts +0 -47
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import { cookies } from 'next/headers';
|
|
4
|
+
import { KeystoneLogoMinimal } from '../logo/keystone-logo-minimal';
|
|
5
|
+
import { LogoutButton } from './LogoutButton';
|
|
6
|
+
import { LoginModalController } from './LoginModalController';
|
|
7
|
+
import { MessageComposer } from './MessageComposer';
|
|
8
|
+
import { RowThumbnail } from './RowThumbnail';
|
|
9
|
+
import {
|
|
10
|
+
CONSUMER_TOKEN_COOKIE,
|
|
11
|
+
fetchConsumerMe,
|
|
12
|
+
fetchConsumerConversations,
|
|
13
|
+
fetchConsumerMessages,
|
|
14
|
+
} from '../../lib/consumer-session';
|
|
15
|
+
import {
|
|
16
|
+
getServices,
|
|
17
|
+
getPackages,
|
|
18
|
+
getCompanyInformation,
|
|
19
|
+
} from '../../lib/server-api';
|
|
20
|
+
import type { Message, ContactSummary } from '../../types/api/consumer';
|
|
21
|
+
import type { Service, ServiceItem } from '../../types/api/service';
|
|
22
|
+
import type { Package } from '../../types/api/package';
|
|
23
|
+
import type { OfferPublic } from '../../types/api/offer';
|
|
24
|
+
|
|
25
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
interface SpecialItem {
|
|
28
|
+
id: number;
|
|
29
|
+
name: string;
|
|
30
|
+
value_terms: string | null | undefined;
|
|
31
|
+
expires_at: string | null | undefined;
|
|
32
|
+
parentName: string;
|
|
33
|
+
parentType: 'service' | 'package';
|
|
34
|
+
photoAttachments: OfferPublic['photo_attachments'];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PortalPageProps {
|
|
38
|
+
searchParams?: Promise<Record<string, string>> | Record<string, string>;
|
|
39
|
+
portalHref?: string;
|
|
40
|
+
bookingHref?: string | null;
|
|
41
|
+
bookingLabel?: string;
|
|
42
|
+
/**
|
|
43
|
+
* When true, always opens booking in a new tab even if the URL supports iframes.
|
|
44
|
+
* Useful when the booking page's iframe experience is broken or undesirable.
|
|
45
|
+
*/
|
|
46
|
+
forceExternalBooking?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Path to the site's contact page, shown when no booking URL is configured.
|
|
49
|
+
* Defaults to "/contact".
|
|
50
|
+
*/
|
|
51
|
+
contactHref?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/** HEAD-request the booking URL and inspect framing headers to decide whether to embed in an iframe. */
|
|
57
|
+
async function checkIframeAllowed(url: string): Promise<boolean> {
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(url, {
|
|
60
|
+
method: 'HEAD',
|
|
61
|
+
redirect: 'follow',
|
|
62
|
+
// Cache for 1 hour — booking platforms rarely change their embedding policy.
|
|
63
|
+
next: { revalidate: 3600 },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const xfo = res.headers.get('x-frame-options')?.toUpperCase();
|
|
67
|
+
if (xfo === 'DENY' || xfo === 'SAMEORIGIN') return false;
|
|
68
|
+
|
|
69
|
+
const csp = res.headers.get('content-security-policy');
|
|
70
|
+
if (csp) {
|
|
71
|
+
const match = csp.match(/frame-ancestors\s+([^;]+)/i);
|
|
72
|
+
if (match) {
|
|
73
|
+
const policy = match[1].trim();
|
|
74
|
+
if (policy === "'none'") return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return true;
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatCents(cents: number): string {
|
|
85
|
+
return new Intl.NumberFormat('en-US', {
|
|
86
|
+
style: 'currency',
|
|
87
|
+
currency: 'USD',
|
|
88
|
+
minimumFractionDigits: 0,
|
|
89
|
+
}).format(cents / 100);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatTime(iso: string): string {
|
|
93
|
+
return new Date(iso).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getInitials(str: string): string {
|
|
97
|
+
return str.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function aggregateSpecials(services: Service[], packages: Package[]): SpecialItem[] {
|
|
101
|
+
const seen = new Set<number>();
|
|
102
|
+
const specials: SpecialItem[] = [];
|
|
103
|
+
|
|
104
|
+
for (const service of services) {
|
|
105
|
+
for (const item of service.service_items ?? []) {
|
|
106
|
+
for (const offer of item.offers ?? []) {
|
|
107
|
+
if (offer.active === false || offer.expired) continue;
|
|
108
|
+
if (seen.has(offer.id)) continue;
|
|
109
|
+
seen.add(offer.id);
|
|
110
|
+
specials.push({
|
|
111
|
+
id: offer.id,
|
|
112
|
+
name: offer.name,
|
|
113
|
+
value_terms: offer.value_terms,
|
|
114
|
+
expires_at: offer.expires_at,
|
|
115
|
+
parentName: item.name,
|
|
116
|
+
parentType: 'service',
|
|
117
|
+
photoAttachments: item.photo_attachments,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const pkg of packages) {
|
|
124
|
+
for (const offer of pkg.offers ?? []) {
|
|
125
|
+
if (offer.active === false || offer.expired) continue;
|
|
126
|
+
if (seen.has(offer.id)) continue;
|
|
127
|
+
seen.add(offer.id);
|
|
128
|
+
specials.push({
|
|
129
|
+
id: offer.id,
|
|
130
|
+
name: offer.name,
|
|
131
|
+
value_terms: offer.value_terms,
|
|
132
|
+
expires_at: offer.expires_at,
|
|
133
|
+
parentName: pkg.name,
|
|
134
|
+
parentType: 'package',
|
|
135
|
+
photoAttachments: pkg.photo_attachments,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return specials;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Shared sub-components ────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function LockButton({ label = 'View price' }: { label?: string }) {
|
|
146
|
+
return (
|
|
147
|
+
<button
|
|
148
|
+
data-open-login-modal
|
|
149
|
+
className="cursor-pointer rounded px-1.5 py-0.5 text-xs text-quaternary underline underline-offset-2 transition-colors hover:text-secondary"
|
|
150
|
+
>
|
|
151
|
+
{label}
|
|
152
|
+
</button>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function LoginWall({ message, cta = 'Sign in' }: { message: string; cta?: string }) {
|
|
157
|
+
return (
|
|
158
|
+
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
159
|
+
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-secondary">
|
|
160
|
+
<svg className="size-5 text-quaternary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
161
|
+
<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" />
|
|
162
|
+
</svg>
|
|
163
|
+
</div>
|
|
164
|
+
<p className="text-sm font-medium text-primary">{message}</p>
|
|
165
|
+
<button
|
|
166
|
+
data-open-login-modal
|
|
167
|
+
className="mt-4 cursor-pointer rounded-lg bg-gray-900 px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-gray-700"
|
|
168
|
+
>
|
|
169
|
+
{cta}
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function EmptyState({ message }: { message: string }) {
|
|
176
|
+
return (
|
|
177
|
+
<div className="rounded-xl border border-secondary bg-secondary py-16 text-center">
|
|
178
|
+
<p className="text-sm text-tertiary">{message}</p>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Services Panel ───────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function ServiceItemRow({
|
|
186
|
+
item,
|
|
187
|
+
isLoggedIn,
|
|
188
|
+
specialsHref,
|
|
189
|
+
}: {
|
|
190
|
+
item: ServiceItem;
|
|
191
|
+
isLoggedIn: boolean;
|
|
192
|
+
specialsHref: string;
|
|
193
|
+
}) {
|
|
194
|
+
const activeOffers = (item.offers ?? []).filter((o) => o.active !== false && !o.expired);
|
|
195
|
+
return (
|
|
196
|
+
<div className="group flex items-center gap-3 px-4 py-3">
|
|
197
|
+
<RowThumbnail
|
|
198
|
+
photoAttachments={item.photo_attachments}
|
|
199
|
+
seed={`item-${item.id}`}
|
|
200
|
+
alt={item.name}
|
|
201
|
+
/>
|
|
202
|
+
<div className="flex-1 min-w-0 flex items-center justify-between gap-3">
|
|
203
|
+
<div className="min-w-0">
|
|
204
|
+
<p className="text-base font-medium text-primary">{item.name}</p>
|
|
205
|
+
{item.summary && <p className="mt-0.5 text-sm text-tertiary">{item.summary}</p>}
|
|
206
|
+
{item.duration_minutes != null && item.duration_minutes > 0 && (
|
|
207
|
+
<p className="mt-0.5 text-xs text-quaternary">{item.duration_minutes} min</p>
|
|
208
|
+
)}
|
|
209
|
+
{activeOffers.length > 0 && (
|
|
210
|
+
<div className="mt-1.5 flex flex-wrap gap-1">
|
|
211
|
+
{activeOffers.map((offer) =>
|
|
212
|
+
isLoggedIn ? (
|
|
213
|
+
<Link
|
|
214
|
+
key={offer.id}
|
|
215
|
+
href={specialsHref}
|
|
216
|
+
className="inline-flex items-center rounded-full bg-brand-50 border border-brand-200 px-2 py-0.5 text-xs font-medium text-brand-700 hover:bg-brand-100 transition-colors"
|
|
217
|
+
>
|
|
218
|
+
Special: {offer.name}
|
|
219
|
+
</Link>
|
|
220
|
+
) : (
|
|
221
|
+
<button
|
|
222
|
+
key={offer.id}
|
|
223
|
+
data-open-login-modal
|
|
224
|
+
data-login-redirect={specialsHref}
|
|
225
|
+
className="inline-flex items-center rounded-full bg-brand-50 border border-brand-200 px-2 py-0.5 text-xs font-medium text-brand-700 hover:bg-brand-100 transition-colors cursor-pointer"
|
|
226
|
+
>
|
|
227
|
+
Special: {offer.name}
|
|
228
|
+
</button>
|
|
229
|
+
)
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
<div className="shrink-0 text-right">
|
|
235
|
+
{isLoggedIn ? (
|
|
236
|
+
item.price_cents != null ? (
|
|
237
|
+
<span className="text-sm font-semibold text-primary">{formatCents(item.price_cents)}</span>
|
|
238
|
+
) : item.pricing_info ? (
|
|
239
|
+
<span className="text-sm text-secondary">{item.pricing_info}</span>
|
|
240
|
+
) : null
|
|
241
|
+
) : (item.price_cents != null || item.pricing_info) ? (
|
|
242
|
+
<LockButton />
|
|
243
|
+
) : null}
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function ServicesPanel({
|
|
251
|
+
services,
|
|
252
|
+
isLoggedIn,
|
|
253
|
+
portalHref,
|
|
254
|
+
}: {
|
|
255
|
+
services: Service[];
|
|
256
|
+
isLoggedIn: boolean;
|
|
257
|
+
portalHref: string;
|
|
258
|
+
}) {
|
|
259
|
+
const activeServices = services.filter((s) => (s.service_items?.length ?? 0) > 0);
|
|
260
|
+
const specialsHref = `${portalHref}?tab=specials`;
|
|
261
|
+
|
|
262
|
+
if (activeServices.length === 0) {
|
|
263
|
+
return <EmptyState message="No services listed yet." />;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div className="divide-y divide-tertiary rounded-xl border border-secondary bg-primary overflow-hidden">
|
|
268
|
+
{activeServices.map((service) => (
|
|
269
|
+
<details key={service.id} className="group">
|
|
270
|
+
<summary className="flex cursor-pointer list-none items-center justify-between px-5 py-4 hover:bg-secondary transition-colors">
|
|
271
|
+
<div>
|
|
272
|
+
<span className="font-medium text-primary">{service.name}</span>
|
|
273
|
+
{service.summary && (
|
|
274
|
+
<p className="mt-0.5 text-sm text-tertiary">{service.summary}</p>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
<div className="ml-4 flex items-center gap-2 shrink-0">
|
|
278
|
+
<span className="text-xs text-quaternary">{service.service_items?.length ?? 0} items</span>
|
|
279
|
+
<svg className="size-4 text-quaternary transition-transform group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
280
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
281
|
+
</svg>
|
|
282
|
+
</div>
|
|
283
|
+
</summary>
|
|
284
|
+
{service.service_items && service.service_items.length > 0 && (
|
|
285
|
+
<div className="border-t border-tertiary bg-secondary divide-y divide-tertiary">
|
|
286
|
+
{service.service_items.map((item) => (
|
|
287
|
+
<ServiceItemRow
|
|
288
|
+
key={item.id}
|
|
289
|
+
item={item}
|
|
290
|
+
isLoggedIn={isLoggedIn}
|
|
291
|
+
specialsHref={specialsHref}
|
|
292
|
+
/>
|
|
293
|
+
))}
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
</details>
|
|
297
|
+
))}
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─── Packages Panel ───────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
function PackagesPanel({
|
|
305
|
+
packages,
|
|
306
|
+
isLoggedIn,
|
|
307
|
+
portalHref,
|
|
308
|
+
}: {
|
|
309
|
+
packages: Package[];
|
|
310
|
+
isLoggedIn: boolean;
|
|
311
|
+
portalHref: string;
|
|
312
|
+
}) {
|
|
313
|
+
const specialsHref = `${portalHref}?tab=specials`;
|
|
314
|
+
|
|
315
|
+
if (packages.length === 0) return <EmptyState message="No packages available yet." />;
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
319
|
+
{packages.map((pkg) => {
|
|
320
|
+
const activeOffers = (pkg.offers ?? []).filter((o) => o.active !== false && !o.expired);
|
|
321
|
+
return (
|
|
322
|
+
<div key={pkg.id} className="group rounded-xl border border-secondary bg-primary p-4 flex flex-col gap-3">
|
|
323
|
+
<div className="flex items-start gap-3">
|
|
324
|
+
<RowThumbnail
|
|
325
|
+
photoAttachments={pkg.photo_attachments}
|
|
326
|
+
seed={`pkg-${pkg.id}`}
|
|
327
|
+
alt={pkg.name}
|
|
328
|
+
sizeClassName="w-16 h-16"
|
|
329
|
+
/>
|
|
330
|
+
<div className="flex-1 min-w-0 flex items-start justify-between gap-2">
|
|
331
|
+
<div className="min-w-0">
|
|
332
|
+
<h4 className="font-semibold text-primary">{pkg.name}</h4>
|
|
333
|
+
{pkg.summary && <p className="mt-0.5 text-sm text-tertiary">{pkg.summary}</p>}
|
|
334
|
+
</div>
|
|
335
|
+
<div className="shrink-0 text-right">
|
|
336
|
+
{isLoggedIn ? (
|
|
337
|
+
pkg.price_cents != null ? (
|
|
338
|
+
<span className="text-xl font-bold text-primary">{formatCents(pkg.price_cents)}</span>
|
|
339
|
+
) : pkg.pricing_info ? (
|
|
340
|
+
<span className="text-sm font-medium text-secondary">{pkg.pricing_info}</span>
|
|
341
|
+
) : null
|
|
342
|
+
) : (pkg.price_cents != null || pkg.pricing_info) ? (
|
|
343
|
+
<LockButton />
|
|
344
|
+
) : null}
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
{pkg.package_items && pkg.package_items.length > 0 && (
|
|
350
|
+
<ul className="space-y-1.5">
|
|
351
|
+
{pkg.package_items.map((item, idx) => (
|
|
352
|
+
<li key={idx} className="flex items-center gap-2 text-sm text-secondary">
|
|
353
|
+
<span className="size-1.5 rounded-full bg-quaternary shrink-0" />
|
|
354
|
+
{item.quantity > 1 && <span className="font-medium text-primary">{item.quantity}×</span>}
|
|
355
|
+
{item.service_item?.name ?? '—'}
|
|
356
|
+
</li>
|
|
357
|
+
))}
|
|
358
|
+
</ul>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{activeOffers.length > 0 && (
|
|
362
|
+
<div className="flex flex-wrap gap-1.5">
|
|
363
|
+
{activeOffers.map((offer) =>
|
|
364
|
+
isLoggedIn ? (
|
|
365
|
+
<Link
|
|
366
|
+
key={offer.id}
|
|
367
|
+
href={specialsHref}
|
|
368
|
+
className="inline-flex items-center rounded-full bg-brand-50 border border-brand-200 px-2.5 py-0.5 text-xs font-medium text-brand-700 hover:bg-brand-100 transition-colors"
|
|
369
|
+
>
|
|
370
|
+
Special: {offer.name}
|
|
371
|
+
</Link>
|
|
372
|
+
) : (
|
|
373
|
+
<button
|
|
374
|
+
key={offer.id}
|
|
375
|
+
data-open-login-modal
|
|
376
|
+
data-login-redirect={specialsHref}
|
|
377
|
+
className="inline-flex items-center rounded-full bg-brand-50 border border-brand-200 px-2.5 py-0.5 text-xs font-medium text-brand-700 hover:bg-brand-100 transition-colors cursor-pointer"
|
|
378
|
+
>
|
|
379
|
+
Special: {offer.name}
|
|
380
|
+
</button>
|
|
381
|
+
)
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
})}
|
|
388
|
+
</div>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ─── Specials Panel ───────────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
function SpecialsPanel({ specials }: { specials: SpecialItem[] }) {
|
|
395
|
+
if (specials.length === 0) {
|
|
396
|
+
return <EmptyState message="No active specials right now. Check back soon." />;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
<div className="space-y-3">
|
|
401
|
+
{specials.map((special) => (
|
|
402
|
+
<div key={special.id} className="group flex items-start gap-3 rounded-xl border border-secondary bg-primary px-4 py-4">
|
|
403
|
+
<RowThumbnail
|
|
404
|
+
photoAttachments={special.photoAttachments}
|
|
405
|
+
seed={`special-${special.id}`}
|
|
406
|
+
alt={special.parentName}
|
|
407
|
+
/>
|
|
408
|
+
<div className="flex-1 min-w-0">
|
|
409
|
+
<p className="font-semibold text-primary">{special.name}</p>
|
|
410
|
+
{special.value_terms && (
|
|
411
|
+
<p className="mt-0.5 text-sm text-secondary">{special.value_terms}</p>
|
|
412
|
+
)}
|
|
413
|
+
<p className="mt-1 text-xs text-quaternary">
|
|
414
|
+
{special.parentType === 'service' ? 'Service' : 'Package'}: {special.parentName}
|
|
415
|
+
</p>
|
|
416
|
+
{special.expires_at && (
|
|
417
|
+
<p className="mt-0.5 text-xs text-quaternary">
|
|
418
|
+
Expires {new Date(special.expires_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
419
|
+
</p>
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
))}
|
|
424
|
+
</div>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ─── Messages Panel ───────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
function MessagesPanel({
|
|
431
|
+
messages,
|
|
432
|
+
contactSummary,
|
|
433
|
+
contactId,
|
|
434
|
+
businessName,
|
|
435
|
+
}: {
|
|
436
|
+
messages: Message[];
|
|
437
|
+
contactSummary: ContactSummary | null;
|
|
438
|
+
contactId: number | null;
|
|
439
|
+
businessName: string;
|
|
440
|
+
}) {
|
|
441
|
+
const threadBusiness =
|
|
442
|
+
contactSummary?.business?.company_name ||
|
|
443
|
+
contactSummary?.business?.name ||
|
|
444
|
+
businessName;
|
|
445
|
+
|
|
446
|
+
return (
|
|
447
|
+
<div className="flex flex-col rounded-xl border border-secondary bg-primary overflow-hidden">
|
|
448
|
+
{/* Thread header */}
|
|
449
|
+
<div className="flex items-center gap-2.5 border-b border-secondary px-4 py-3 shrink-0">
|
|
450
|
+
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-gray-900 text-xs font-semibold text-white shrink-0">
|
|
451
|
+
{getInitials(threadBusiness)}
|
|
452
|
+
</div>
|
|
453
|
+
<span className="text-sm font-semibold text-primary">{threadBusiness}</span>
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
{/* Scrollable message list */}
|
|
457
|
+
<div className="overflow-y-auto px-4 py-4 space-y-3" style={{ height: '70vh' }}>
|
|
458
|
+
{messages.length === 0 ? (
|
|
459
|
+
<div className="flex flex-col items-center justify-center h-full py-16 text-center">
|
|
460
|
+
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-secondary">
|
|
461
|
+
<svg className="size-5 text-quaternary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
462
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
|
463
|
+
</svg>
|
|
464
|
+
</div>
|
|
465
|
+
<p className="text-sm font-medium text-primary">No messages yet</p>
|
|
466
|
+
<p className="mt-1 text-xs text-tertiary">Send a message to start the conversation.</p>
|
|
467
|
+
</div>
|
|
468
|
+
) : (
|
|
469
|
+
messages.map((m) => {
|
|
470
|
+
const isOutbound = m.direction === 'outbound';
|
|
471
|
+
return (
|
|
472
|
+
<div key={m.id} className={`flex ${isOutbound ? 'justify-start' : 'justify-end'}`}>
|
|
473
|
+
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm ${
|
|
474
|
+
isOutbound ? 'bg-secondary text-primary rounded-tl-sm' : 'bg-gray-900 text-white rounded-tr-sm'
|
|
475
|
+
}`}>
|
|
476
|
+
{m.sender_display_name && (
|
|
477
|
+
<p className="mb-0.5 text-xs font-medium opacity-60">{m.sender_display_name}</p>
|
|
478
|
+
)}
|
|
479
|
+
<p className="whitespace-pre-wrap leading-relaxed">{m.body || '—'}</p>
|
|
480
|
+
<p className="mt-1 text-right text-[10px] opacity-50">{formatTime(m.created_at)}</p>
|
|
481
|
+
</div>
|
|
482
|
+
</div>
|
|
483
|
+
);
|
|
484
|
+
})
|
|
485
|
+
)}
|
|
486
|
+
</div>
|
|
487
|
+
|
|
488
|
+
{/* Composer — fixed to bottom of panel */}
|
|
489
|
+
{contactId && <MessageComposer contactId={contactId} />}
|
|
490
|
+
</div>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ─── Book Panel ───────────────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
function BookPanel({
|
|
497
|
+
bookingHref,
|
|
498
|
+
bookingLabel,
|
|
499
|
+
bookingAllowsIframe,
|
|
500
|
+
isLoggedIn,
|
|
501
|
+
}: {
|
|
502
|
+
bookingHref: string;
|
|
503
|
+
bookingLabel: string;
|
|
504
|
+
bookingAllowsIframe: boolean;
|
|
505
|
+
isLoggedIn: boolean;
|
|
506
|
+
}) {
|
|
507
|
+
if (!isLoggedIn) {
|
|
508
|
+
return <LoginWall message="Sign in to view booking options." />;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (bookingAllowsIframe) {
|
|
512
|
+
return (
|
|
513
|
+
<div className="rounded-xl border border-secondary overflow-hidden" style={{ height: '70vh' }}>
|
|
514
|
+
<iframe
|
|
515
|
+
src={bookingHref}
|
|
516
|
+
className="w-full h-full"
|
|
517
|
+
title="Book appointment"
|
|
518
|
+
allow="payment"
|
|
519
|
+
/>
|
|
520
|
+
</div>
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return (
|
|
525
|
+
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
526
|
+
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-secondary">
|
|
527
|
+
<svg className="size-5 text-quaternary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
528
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 9v7.5" />
|
|
529
|
+
</svg>
|
|
530
|
+
</div>
|
|
531
|
+
<p className="text-sm font-medium text-primary">Ready to book?</p>
|
|
532
|
+
<p className="mt-1 text-sm text-tertiary">You'll be taken to our booking system.</p>
|
|
533
|
+
<a
|
|
534
|
+
href={bookingHref}
|
|
535
|
+
target="_blank"
|
|
536
|
+
rel="noopener noreferrer"
|
|
537
|
+
className="mt-5 inline-flex items-center gap-2 rounded-lg bg-gray-900 px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-gray-700"
|
|
538
|
+
>
|
|
539
|
+
{bookingLabel}
|
|
540
|
+
<svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
541
|
+
<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" />
|
|
542
|
+
</svg>
|
|
543
|
+
</a>
|
|
544
|
+
</div>
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ─── Main Portal Page (Self-Contained Async Server Component) ─────────────────
|
|
549
|
+
|
|
550
|
+
const VALID_TABS = ['services', 'packages', 'specials', 'messages', 'book'] as const;
|
|
551
|
+
type Tab = (typeof VALID_TABS)[number];
|
|
552
|
+
|
|
553
|
+
export async function PortalPage({
|
|
554
|
+
searchParams,
|
|
555
|
+
portalHref = '/portal',
|
|
556
|
+
bookingHref,
|
|
557
|
+
bookingLabel = 'Book Now',
|
|
558
|
+
forceExternalBooking = false,
|
|
559
|
+
contactHref = '/contact',
|
|
560
|
+
}: PortalPageProps) {
|
|
561
|
+
const params = await searchParams ?? {};
|
|
562
|
+
const tab: Tab = VALID_TABS.includes(params.tab as Tab) ? (params.tab as Tab) : 'services';
|
|
563
|
+
const contactIdParsed = params.contact ? parseInt(params.contact, 10) : NaN;
|
|
564
|
+
const contactId = Number.isFinite(contactIdParsed) ? contactIdParsed : null;
|
|
565
|
+
|
|
566
|
+
const cookieStore = await cookies();
|
|
567
|
+
const token = cookieStore.get(CONSUMER_TOKEN_COOKIE)?.value ?? null;
|
|
568
|
+
|
|
569
|
+
// Fetch public data + consumer in parallel.
|
|
570
|
+
// Conversations are always fetched when logged in — needed for display name and messages.
|
|
571
|
+
const [services, packages, companyInformation, consumer, conversations] = await Promise.all([
|
|
572
|
+
getServices(),
|
|
573
|
+
getPackages(),
|
|
574
|
+
getCompanyInformation(),
|
|
575
|
+
token ? fetchConsumerMe(token) : Promise.resolve(null),
|
|
576
|
+
token ? fetchConsumerConversations(token) : Promise.resolve([]),
|
|
577
|
+
]);
|
|
578
|
+
|
|
579
|
+
const resolvedBookingHref =
|
|
580
|
+
bookingHref ?? companyInformation?.external_management_url ?? null;
|
|
581
|
+
|
|
582
|
+
// Auto-detect iframe support by inspecting the booking URL's framing headers.
|
|
583
|
+
// Only runs when the book tab is active to avoid unnecessary requests.
|
|
584
|
+
// Skipped entirely when forceExternalBooking is set.
|
|
585
|
+
const bookingAllowsIframe =
|
|
586
|
+
!forceExternalBooking && tab === 'book' && resolvedBookingHref
|
|
587
|
+
? await checkIframeAllowed(resolvedBookingHref)
|
|
588
|
+
: false;
|
|
589
|
+
|
|
590
|
+
const businessName =
|
|
591
|
+
companyInformation?.company_name ||
|
|
592
|
+
'Member Portal';
|
|
593
|
+
|
|
594
|
+
const isLoggedIn = !!consumer;
|
|
595
|
+
|
|
596
|
+
// Use the first conversation's contact_id to identify this business's contact —
|
|
597
|
+
// conversations are filtered server-side via API key, so they're always scoped correctly.
|
|
598
|
+
const resolvedContactId = contactId ?? conversations[0]?.contact_id ?? null;
|
|
599
|
+
|
|
600
|
+
// Find display name from the contact linked to this business's conversation thread,
|
|
601
|
+
// falling back to any known identifier on the consumer record.
|
|
602
|
+
const businessContact = consumer?.contacts?.find((c) => c.id === resolvedContactId);
|
|
603
|
+
const consumerDisplayName =
|
|
604
|
+
businessContact?.display_name ||
|
|
605
|
+
consumer?.contacts?.[0]?.display_name ||
|
|
606
|
+
consumer?.primary_identifier ||
|
|
607
|
+
consumer?.email ||
|
|
608
|
+
consumer?.phone ||
|
|
609
|
+
null;
|
|
610
|
+
|
|
611
|
+
const messageData =
|
|
612
|
+
token && tab === 'messages' && resolvedContactId
|
|
613
|
+
? await fetchConsumerMessages(token, resolvedContactId)
|
|
614
|
+
: { messages: [], contact: null };
|
|
615
|
+
|
|
616
|
+
const serviceList = services ?? [];
|
|
617
|
+
const packageList = packages ?? [];
|
|
618
|
+
const specials = aggregateSpecials(serviceList, packageList);
|
|
619
|
+
|
|
620
|
+
const tabs: Array<{ id: Tab; label: string }> = [
|
|
621
|
+
{ id: 'services', label: 'Services' },
|
|
622
|
+
{ id: 'packages', label: 'Packages' },
|
|
623
|
+
{ id: 'specials', label: 'Specials' },
|
|
624
|
+
{ id: 'messages', label: 'Messages' },
|
|
625
|
+
{ id: 'book', label: bookingLabel },
|
|
626
|
+
];
|
|
627
|
+
|
|
628
|
+
return (
|
|
629
|
+
<div className="min-h-screen bg-primary">
|
|
630
|
+
{/* Portal Header */}
|
|
631
|
+
<div className="border-b border-secondary bg-primary sticky top-0 z-40 shrink-0">
|
|
632
|
+
<div className="mx-auto max-w-4xl px-4 py-4">
|
|
633
|
+
<div className="flex items-center justify-between gap-4">
|
|
634
|
+
<div>
|
|
635
|
+
<p className="text-[10px] font-semibold uppercase tracking-widest text-quaternary">
|
|
636
|
+
Member Portal
|
|
637
|
+
</p>
|
|
638
|
+
<h1 className="text-xl font-bold text-primary leading-tight">{businessName}</h1>
|
|
639
|
+
</div>
|
|
640
|
+
|
|
641
|
+
<div className="flex items-center gap-3">
|
|
642
|
+
{isLoggedIn ? (
|
|
643
|
+
<div className="flex items-center gap-3">
|
|
644
|
+
{consumerDisplayName && (
|
|
645
|
+
<div className="hidden sm:flex items-center gap-2 rounded-full border border-secondary bg-secondary px-3 py-1.5">
|
|
646
|
+
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-900 text-[9px] font-bold text-white shrink-0">
|
|
647
|
+
{getInitials(consumerDisplayName)}
|
|
648
|
+
</div>
|
|
649
|
+
<span className="text-xs font-medium text-secondary max-w-[120px] truncate">
|
|
650
|
+
{consumerDisplayName}
|
|
651
|
+
</span>
|
|
652
|
+
</div>
|
|
653
|
+
)}
|
|
654
|
+
<LogoutButton />
|
|
655
|
+
</div>
|
|
656
|
+
) : (
|
|
657
|
+
<button
|
|
658
|
+
data-open-login-modal
|
|
659
|
+
className="cursor-pointer rounded-lg border border-secondary px-3 py-1.5 text-xs font-medium text-secondary hover:bg-secondary transition-colors"
|
|
660
|
+
>
|
|
661
|
+
Sign in
|
|
662
|
+
</button>
|
|
663
|
+
)}
|
|
664
|
+
|
|
665
|
+
<div className="flex items-center gap-1.5 border-l border-tertiary pl-3">
|
|
666
|
+
<KeystoneLogoMinimal className="size-5" />
|
|
667
|
+
<span className="hidden sm:block text-[10px] font-medium text-quaternary">Keystone</span>
|
|
668
|
+
</div>
|
|
669
|
+
</div>
|
|
670
|
+
</div>
|
|
671
|
+
|
|
672
|
+
{/* Tabs */}
|
|
673
|
+
<div className="mt-4 flex gap-1 overflow-x-auto pb-px scrollbar-none">
|
|
674
|
+
{tabs.map((t) => {
|
|
675
|
+
const isActive = tab === t.id;
|
|
676
|
+
const isGated = !isLoggedIn && (t.id === 'specials' || t.id === 'messages' || t.id === 'book');
|
|
677
|
+
const href = `${portalHref}?tab=${t.id}`;
|
|
678
|
+
const className = `shrink-0 rounded-lg px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
|
679
|
+
isActive ? 'bg-gray-900 text-white' : 'text-secondary hover:bg-secondary hover:text-primary'
|
|
680
|
+
}`;
|
|
681
|
+
if (isGated) {
|
|
682
|
+
return (
|
|
683
|
+
<button
|
|
684
|
+
key={t.id}
|
|
685
|
+
data-open-login-modal
|
|
686
|
+
data-login-redirect={href}
|
|
687
|
+
className={className}
|
|
688
|
+
>
|
|
689
|
+
{t.label}
|
|
690
|
+
</button>
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
return (
|
|
694
|
+
<Link key={t.id} href={href} className={className}>
|
|
695
|
+
{t.label}
|
|
696
|
+
</Link>
|
|
697
|
+
);
|
|
698
|
+
})}
|
|
699
|
+
</div>
|
|
700
|
+
</div>
|
|
701
|
+
</div>
|
|
702
|
+
|
|
703
|
+
{/* Tab Content */}
|
|
704
|
+
<div className="mx-auto max-w-4xl px-4 py-8">
|
|
705
|
+
{tab === 'services' && (
|
|
706
|
+
<ServicesPanel services={serviceList} isLoggedIn={isLoggedIn} portalHref={portalHref} />
|
|
707
|
+
)}
|
|
708
|
+
{tab === 'packages' && (
|
|
709
|
+
<PackagesPanel packages={packageList} isLoggedIn={isLoggedIn} portalHref={portalHref} />
|
|
710
|
+
)}
|
|
711
|
+
{tab === 'specials' && <SpecialsPanel specials={specials} />}
|
|
712
|
+
{tab === 'messages' && (
|
|
713
|
+
isLoggedIn ? (
|
|
714
|
+
<MessagesPanel
|
|
715
|
+
messages={messageData.messages}
|
|
716
|
+
contactSummary={messageData.contact}
|
|
717
|
+
contactId={resolvedContactId}
|
|
718
|
+
businessName={businessName}
|
|
719
|
+
/>
|
|
720
|
+
) : (
|
|
721
|
+
<LoginWall message="Sign in to view your messages." />
|
|
722
|
+
)
|
|
723
|
+
)}
|
|
724
|
+
{tab === 'book' && (
|
|
725
|
+
resolvedBookingHref ? (
|
|
726
|
+
<BookPanel
|
|
727
|
+
bookingHref={resolvedBookingHref}
|
|
728
|
+
bookingLabel={bookingLabel}
|
|
729
|
+
bookingAllowsIframe={bookingAllowsIframe}
|
|
730
|
+
isLoggedIn={isLoggedIn}
|
|
731
|
+
/>
|
|
732
|
+
) : (
|
|
733
|
+
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
734
|
+
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-secondary">
|
|
735
|
+
<svg className="size-5 text-quaternary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
736
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 9v7.5" />
|
|
737
|
+
</svg>
|
|
738
|
+
</div>
|
|
739
|
+
<p className="text-sm font-medium text-primary">Ready to book?</p>
|
|
740
|
+
<p className="mt-1 text-sm text-tertiary max-w-xs">
|
|
741
|
+
Give us a call or submit a request on our{' '}
|
|
742
|
+
<a href={contactHref} className="underline underline-offset-2 hover:text-primary transition-colors">contact page</a>
|
|
743
|
+
{' '}and we'll get you scheduled.
|
|
744
|
+
</p>
|
|
745
|
+
</div>
|
|
746
|
+
)
|
|
747
|
+
)}
|
|
748
|
+
</div>
|
|
749
|
+
|
|
750
|
+
{/* Single login modal controller — listens for [data-open-login-modal] clicks */}
|
|
751
|
+
<LoginModalController />
|
|
752
|
+
</div>
|
|
753
|
+
);
|
|
754
|
+
}
|