keystone-design-bootstrap 1.0.55 → 1.0.57
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 +358 -0
- package/src/design_system/portal/LoginModalController.tsx +63 -0
- package/src/design_system/portal/LogoutButton.tsx +22 -0
- package/src/design_system/portal/MessageComposer.tsx +92 -0
- package/src/design_system/portal/PortalPage.tsx +754 -0
- package/src/design_system/portal/RowThumbnail.tsx +76 -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 +180 -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,76 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import type { PhotoAttachment } from '../../types/api/photos';
|
|
5
|
+
import { useImageCycle, CROSSFADE_DURATION_MS } from '../../lib/hooks/use-image-cycle';
|
|
6
|
+
|
|
7
|
+
const CROSSFADE_STYLE = { transitionDuration: `${CROSSFADE_DURATION_MS}ms` };
|
|
8
|
+
|
|
9
|
+
interface RowThumbnailProps {
|
|
10
|
+
photoAttachments?: PhotoAttachment[];
|
|
11
|
+
seed: string;
|
|
12
|
+
alt: string;
|
|
13
|
+
/** Tailwind size classes. Defaults to w-14 h-14. */
|
|
14
|
+
sizeClassName?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function RowThumbnail({
|
|
18
|
+
photoAttachments,
|
|
19
|
+
seed,
|
|
20
|
+
alt,
|
|
21
|
+
sizeClassName = 'w-14 h-14',
|
|
22
|
+
}: RowThumbnailProps) {
|
|
23
|
+
const { list, currentIndex, nextIndex, transitioning } = useImageCycle(photoAttachments, seed);
|
|
24
|
+
|
|
25
|
+
const displayAlt = list[currentIndex]?.alt || alt;
|
|
26
|
+
|
|
27
|
+
if (list.length === 0) {
|
|
28
|
+
return (
|
|
29
|
+
<div className={`${sizeClassName} shrink-0 rounded-lg bg-secondary border border-tertiary flex items-center justify-center`}>
|
|
30
|
+
<svg className="size-5 text-quaternary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
31
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
|
32
|
+
</svg>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (list.length === 1) {
|
|
38
|
+
return (
|
|
39
|
+
<div className={`${sizeClassName} shrink-0 rounded-lg overflow-hidden`}>
|
|
40
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
41
|
+
<img
|
|
42
|
+
src={list[0]!.url}
|
|
43
|
+
alt={list[0]!.alt || alt}
|
|
44
|
+
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className={`${sizeClassName} shrink-0 rounded-lg overflow-hidden relative`}>
|
|
52
|
+
<div
|
|
53
|
+
className={`absolute inset-0 ${transitioning ? 'transition-opacity ease-in-out' : 'transition-none'}`}
|
|
54
|
+
style={{ opacity: transitioning ? 0 : 1, ...(transitioning ? CROSSFADE_STYLE : {}) }}
|
|
55
|
+
>
|
|
56
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
57
|
+
<img
|
|
58
|
+
src={list[currentIndex]!.url}
|
|
59
|
+
alt={list[currentIndex]?.alt ?? displayAlt}
|
|
60
|
+
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
<div
|
|
64
|
+
className={`absolute inset-0 ${transitioning ? 'transition-opacity ease-in-out' : 'transition-none'}`}
|
|
65
|
+
style={{ opacity: transitioning ? 1 : 0, ...(transitioning ? CROSSFADE_STYLE : {}) }}
|
|
66
|
+
>
|
|
67
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
68
|
+
<img
|
|
69
|
+
src={list[nextIndex]!.url}
|
|
70
|
+
alt={list[nextIndex]?.alt ?? displayAlt}
|
|
71
|
+
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -184,7 +184,7 @@ export type { EmailSignupSectionProps } from './email-signup-section';
|
|
|
184
184
|
|
|
185
185
|
// Service Menu: packages + treatments; nested specials as badges / modal callouts
|
|
186
186
|
export { ServiceMenuSection } from './service-menu-section';
|
|
187
|
-
export type { ServiceMenuSectionProps,
|
|
187
|
+
export type { ServiceMenuSectionProps, PackageForMenu } from './service-menu-section';
|
|
188
188
|
|
|
189
189
|
// Re-export types
|
|
190
190
|
export type { Theme } from '../../themes';
|
|
@@ -1,44 +1,20 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState, useEffect, useCallback
|
|
3
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
5
|
import { PhotoWithFallback, Carousel, Modal, MarkdownRenderer, Button } from '../elements';
|
|
6
6
|
import type { OfferPublic } from '../../types/api/offer';
|
|
7
7
|
import type { Service, ServiceItem } from '../../types/api/service';
|
|
8
|
+
import type { Package } from '../../types/api/package';
|
|
8
9
|
import type { WebsitePhotos } from '../../types/api/website-photos';
|
|
9
10
|
import type { CompanyInformation } from '../../types/api/company-information';
|
|
10
11
|
import type { PhotoAttachment } from '../../types/api/photos';
|
|
12
|
+
import { useImageCycle } from '../../lib/hooks/use-image-cycle';
|
|
11
13
|
|
|
12
14
|
const SERVICE_MENU_MODAL_ROOT_ID = 'service-menu-modal-root';
|
|
13
|
-
const CYCLE_INTERVAL_MIN_MS = 6000;
|
|
14
|
-
const CYCLE_INTERVAL_MAX_MS = 8000;
|
|
15
|
-
|
|
16
|
-
/** Returns a stable value in [0, 1) from seed (for per-card random interval). */
|
|
17
|
-
function seedToUnit(seed: string): number {
|
|
18
|
-
let h = 2166136261 >>> 0;
|
|
19
|
-
for (let i = 0; i < seed.length; i++) {
|
|
20
|
-
h ^= seed.charCodeAt(i);
|
|
21
|
-
h = (Math.imul(h, 16777619) >>> 0) >>> 0;
|
|
22
|
-
}
|
|
23
|
-
return (h >>> 0) / 4294967296;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Minimal package from public API (id, name, slug, summary, photo_attachments, description_markdown). */
|
|
27
|
-
export interface PackagePublic {
|
|
28
|
-
id: number;
|
|
29
|
-
name: string;
|
|
30
|
-
slug: string;
|
|
31
|
-
summary?: string | null;
|
|
32
|
-
description_markdown?: string | null;
|
|
33
|
-
pricing_info?: string | null;
|
|
34
|
-
price_cents?: number | null;
|
|
35
|
-
photo_attachments?: PhotoAttachment[];
|
|
36
|
-
package_items?: Array<{ quantity: number; service_item?: { id: number; name: string; slug: string; summary?: string | null } }>;
|
|
37
|
-
offers?: OfferPublic[];
|
|
38
|
-
}
|
|
39
15
|
|
|
40
16
|
/** Package with category names and first-service description fallback (derived from services). */
|
|
41
|
-
export interface PackageForMenu extends
|
|
17
|
+
export interface PackageForMenu extends Package {
|
|
42
18
|
category_names?: string[];
|
|
43
19
|
first_service_description_markdown?: string | null;
|
|
44
20
|
}
|
|
@@ -46,7 +22,7 @@ export interface PackageForMenu extends PackagePublic {
|
|
|
46
22
|
export interface ServiceMenuSectionProps {
|
|
47
23
|
title?: string;
|
|
48
24
|
subtitle?: string;
|
|
49
|
-
packages?:
|
|
25
|
+
packages?: Package[] | null;
|
|
50
26
|
/** Services (used to derive service items for the third row). */
|
|
51
27
|
services?: Service[] | null;
|
|
52
28
|
websitePhotos?: WebsitePhotos | null;
|
|
@@ -76,54 +52,6 @@ function getActivePublicOffers(offers: OfferPublic[] | undefined | null): OfferP
|
|
|
76
52
|
return offers.filter((o) => o.active !== false && o.expired !== true);
|
|
77
53
|
}
|
|
78
54
|
|
|
79
|
-
function photoAttachmentDisplayUrl(pa: PhotoAttachment): string | undefined {
|
|
80
|
-
return pa.photo?.large_url || pa.photo?.medium_url;
|
|
81
|
-
}
|
|
82
|
-
function photoAttachmentAlt(pa: PhotoAttachment): string {
|
|
83
|
-
return pa.photo?.alt_text || pa.photo?.title || '';
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Seeded shuffle: same seed => same order (SSR-safe). Different seeds => very different orders. */
|
|
87
|
-
function shuffleWithSeed<T>(array: T[], seed: string): T[] {
|
|
88
|
-
if (array.length <= 1) return array;
|
|
89
|
-
const arr = [...array];
|
|
90
|
-
// FNV-1a style hash so small seed changes (e.g. index 0 vs 1 vs 2) produce very different values
|
|
91
|
-
let h = 2166136261 >>> 0;
|
|
92
|
-
for (let i = 0; i < seed.length; i++) {
|
|
93
|
-
h ^= seed.charCodeAt(i);
|
|
94
|
-
h = (Math.imul(h, 16777619) >>> 0) >>> 0;
|
|
95
|
-
}
|
|
96
|
-
const next = (step: number) => {
|
|
97
|
-
h = (Math.imul(1664525, (h + step) >>> 0) + 1013904223) >>> 0;
|
|
98
|
-
return (h >>> 0) / 4294967296;
|
|
99
|
-
};
|
|
100
|
-
for (let i = arr.length - 1; i > 0; i--) {
|
|
101
|
-
const j = Math.floor(next(i) * (i + 1));
|
|
102
|
-
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
103
|
-
}
|
|
104
|
-
return arr;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const CROSSFADE_DURATION_MS = 600;
|
|
108
|
-
|
|
109
|
-
/** Returns shuffled list of { url, alt }. Card owns all cycle/transition state. */
|
|
110
|
-
function useCycledPhotoList(
|
|
111
|
-
photoAttachments: PhotoAttachment[] | undefined,
|
|
112
|
-
seed: string
|
|
113
|
-
): Array<{ url: string; alt: string }> {
|
|
114
|
-
return useMemo(
|
|
115
|
-
() => {
|
|
116
|
-
const arr = Array.isArray(photoAttachments) && photoAttachments.length > 0 ? photoAttachments : [];
|
|
117
|
-
if (arr.length === 0) return [];
|
|
118
|
-
const shuffled = shuffleWithSeed(arr, seed);
|
|
119
|
-
return shuffled
|
|
120
|
-
.map((pa) => ({ url: photoAttachmentDisplayUrl(pa) ?? '', alt: photoAttachmentAlt(pa) }))
|
|
121
|
-
.filter((x) => x.url);
|
|
122
|
-
},
|
|
123
|
-
[photoAttachments, seed]
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
55
|
/** Card with image area. Cycles through photos every N ms with a simple crossfade. */
|
|
128
56
|
function GridCardWithImage({
|
|
129
57
|
photoAttachments,
|
|
@@ -152,38 +80,9 @@ function GridCardWithImage({
|
|
|
152
80
|
hasSpecial?: boolean;
|
|
153
81
|
}) {
|
|
154
82
|
const seed = cycleSeed ?? String(fallbackId);
|
|
155
|
-
const list =
|
|
156
|
-
const [currentIndex, setCurrentIndex] = useState(0);
|
|
157
|
-
const [transitioning, setTransitioning] = useState(false);
|
|
158
|
-
|
|
159
|
-
// Per-card random interval 6–8s so cards don't all transition in sync
|
|
160
|
-
const intervalMs = useMemo(
|
|
161
|
-
() =>
|
|
162
|
-
CYCLE_INTERVAL_MIN_MS +
|
|
163
|
-
Math.floor(seedToUnit(seed) * (CYCLE_INTERVAL_MAX_MS - CYCLE_INTERVAL_MIN_MS + 1)),
|
|
164
|
-
[seed]
|
|
165
|
-
);
|
|
83
|
+
const { list, currentIndex, nextIndex, transitioning } = useImageCycle(photoAttachments, seed);
|
|
166
84
|
|
|
167
|
-
|
|
168
|
-
useEffect(() => {
|
|
169
|
-
if (list.length <= 1) return;
|
|
170
|
-
const id = setInterval(() => setTransitioning(true), intervalMs);
|
|
171
|
-
return () => clearInterval(id);
|
|
172
|
-
}, [list.length, intervalMs]);
|
|
173
|
-
|
|
174
|
-
// When transitioning, after crossfade duration advance index and stop transitioning
|
|
175
|
-
useEffect(() => {
|
|
176
|
-
if (!transitioning || list.length <= 1) return;
|
|
177
|
-
const t = setTimeout(() => {
|
|
178
|
-
setCurrentIndex((i) => (i + 1) % list.length);
|
|
179
|
-
setTransitioning(false);
|
|
180
|
-
}, CROSSFADE_DURATION_MS);
|
|
181
|
-
return () => clearTimeout(t);
|
|
182
|
-
}, [transitioning, list.length]);
|
|
183
|
-
|
|
184
|
-
const nextIndex = list.length > 1 ? (currentIndex + 1) % list.length : 0;
|
|
185
|
-
const currentItem = list[currentIndex];
|
|
186
|
-
const displayAlt = currentItem?.alt || fallbackAlt;
|
|
85
|
+
const displayAlt = list[currentIndex]?.alt || fallbackAlt;
|
|
187
86
|
const singleUrl = list[0]?.url;
|
|
188
87
|
|
|
189
88
|
return (
|
package/src/lib/actions.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
* Server Actions for form submissions
|
|
3
|
-
*
|
|
4
|
+
* Server Actions for form submissions.
|
|
5
|
+
* Runs on the server — API_KEY is never exposed to the browser.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
|
-
'use server';
|
|
7
|
-
|
|
8
8
|
const API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';
|
|
9
9
|
const API_KEY = process.env.API_KEY || '';
|
|
10
10
|
|
|
@@ -16,128 +16,64 @@ interface ContactFormResult {
|
|
|
16
16
|
eventId?: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
function extractFormFields(formData: FormData) {
|
|
20
|
+
return {
|
|
21
|
+
firstName: formData.get('firstName')?.toString().trim() || '',
|
|
22
|
+
lastName: formData.get('lastName')?.toString().trim() || '',
|
|
23
|
+
email: formData.get('email')?.toString().trim() || '',
|
|
24
|
+
phone: formData.get('phone')?.toString().trim() || '',
|
|
25
|
+
message: formData.get('message')?.toString().trim() || '',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function postFormSubmission(payload: Record<string, unknown>): Promise<Response> {
|
|
30
|
+
return fetch(`${API_URL}/public/form_submissions`, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: {
|
|
33
|
+
'Content-Type': 'application/json',
|
|
34
|
+
'X-API-Key': API_KEY,
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify(payload),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
export async function submitContactFormAction(formData: FormData): Promise<ContactFormResult> {
|
|
41
|
+
const { firstName, lastName, email, phone, message } = extractFormFields(formData);
|
|
42
|
+
|
|
43
|
+
if (!firstName || !lastName || !email || !message) {
|
|
44
|
+
return { success: false, error: 'Please fill in all required fields.' };
|
|
45
|
+
}
|
|
46
|
+
|
|
23
47
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const lastName = formData.get('lastName')?.toString().trim() || '';
|
|
27
|
-
const email = formData.get('email')?.toString().trim() || '';
|
|
28
|
-
const phone = formData.get('phone')?.toString().trim() || '';
|
|
29
|
-
const message = formData.get('message')?.toString().trim() || '';
|
|
30
|
-
|
|
31
|
-
// Basic validation
|
|
32
|
-
if (!firstName || !lastName || !email || !message) {
|
|
33
|
-
return {
|
|
34
|
-
success: false,
|
|
35
|
-
error: 'Please fill in all required fields.',
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Combine first and last name
|
|
40
|
-
const name = `${firstName} ${lastName}`;
|
|
41
|
-
|
|
42
|
-
const payload = {
|
|
43
|
-
name,
|
|
48
|
+
const res = await postFormSubmission({
|
|
49
|
+
name: `${firstName} ${lastName}`,
|
|
44
50
|
email,
|
|
45
51
|
phone,
|
|
46
52
|
message,
|
|
47
|
-
source: 'contact_form'
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
// Make API call server-side
|
|
51
|
-
const response = await fetch(`${API_URL}/public/form_submissions`, {
|
|
52
|
-
method: 'POST',
|
|
53
|
-
headers: {
|
|
54
|
-
'Content-Type': 'application/json',
|
|
55
|
-
'X-API-Key': API_KEY,
|
|
56
|
-
},
|
|
57
|
-
body: JSON.stringify(payload),
|
|
53
|
+
source: 'contact_form',
|
|
58
54
|
});
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
success: false,
|
|
65
|
-
error: data.error || 'Failed to submit contact form. Please try again.',
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
success: true,
|
|
71
|
-
message: data.message || 'Thank you for contacting us! We\'ll get back to you soon.',
|
|
72
|
-
};
|
|
73
|
-
} catch (error) {
|
|
74
|
-
console.error('Contact form submission error:', error);
|
|
75
|
-
return {
|
|
76
|
-
success: false,
|
|
77
|
-
error: 'Network error. Please try again later.',
|
|
78
|
-
};
|
|
55
|
+
const data = await res.json();
|
|
56
|
+
if (!res.ok) return { success: false, error: data.error || 'Failed to submit. Please try again.' };
|
|
57
|
+
return { success: true, message: data.message || "Thank you for contacting us! We'll get back to you soon." };
|
|
58
|
+
} catch {
|
|
59
|
+
return { success: false, error: 'Network error. Please try again later.' };
|
|
79
60
|
}
|
|
80
61
|
}
|
|
81
62
|
|
|
82
|
-
/**
|
|
83
|
-
* Submit lead form via Server Action
|
|
84
|
-
*/
|
|
85
63
|
export async function submitLeadFormAction(formData: FormData): Promise<ContactFormResult> {
|
|
86
|
-
|
|
87
|
-
// Extract and validate form data
|
|
88
|
-
const firstName = formData.get('firstName')?.toString().trim() || '';
|
|
89
|
-
const lastName = formData.get('lastName')?.toString().trim() || '';
|
|
90
|
-
const email = formData.get('email')?.toString().trim() || '';
|
|
91
|
-
const phone = formData.get('phone')?.toString().trim() || '';
|
|
92
|
-
const message = formData.get('message')?.toString().trim() || '';
|
|
93
|
-
|
|
94
|
-
// Basic validation
|
|
95
|
-
if (!firstName || !lastName || !email || !message) {
|
|
96
|
-
return {
|
|
97
|
-
success: false,
|
|
98
|
-
error: 'Please fill in all required fields.',
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const payload = {
|
|
103
|
-
formType: 'lead',
|
|
104
|
-
firstName,
|
|
105
|
-
lastName,
|
|
106
|
-
email,
|
|
107
|
-
phone,
|
|
108
|
-
message,
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
const response = await fetch(`${API_URL}/public/form_submissions`, {
|
|
112
|
-
method: 'POST',
|
|
113
|
-
headers: {
|
|
114
|
-
'Content-Type': 'application/json',
|
|
115
|
-
'X-API-Key': API_KEY,
|
|
116
|
-
},
|
|
117
|
-
body: JSON.stringify(payload),
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
const data = await response.json();
|
|
121
|
-
|
|
122
|
-
if (!response.ok) {
|
|
123
|
-
return {
|
|
124
|
-
success: false,
|
|
125
|
-
error: data.error || 'Failed to submit lead form. Please try again.',
|
|
126
|
-
};
|
|
127
|
-
}
|
|
64
|
+
const { firstName, lastName, email, phone, message } = extractFormFields(formData);
|
|
128
65
|
|
|
129
|
-
|
|
66
|
+
if (!firstName || !lastName || !email || !message) {
|
|
67
|
+
return { success: false, error: 'Please fill in all required fields.' };
|
|
68
|
+
}
|
|
130
69
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return {
|
|
139
|
-
success: false,
|
|
140
|
-
error: 'Network error. Please try again later.',
|
|
141
|
-
};
|
|
70
|
+
try {
|
|
71
|
+
const res = await postFormSubmission({ formType: 'lead', firstName, lastName, email, phone, message });
|
|
72
|
+
const data = await res.json();
|
|
73
|
+
if (!res.ok) return { success: false, error: data.error || 'Failed to submit. Please try again.' };
|
|
74
|
+
const eventId: string | undefined = data.data?.event_id ?? undefined;
|
|
75
|
+
return { success: true, message: data.message || "Thank you! We'll be in touch soon.", ...(eventId && { eventId }) };
|
|
76
|
+
} catch {
|
|
77
|
+
return { success: false, error: 'Network error. Please try again later.' };
|
|
142
78
|
}
|
|
143
79
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side consumer session helpers.
|
|
3
|
+
* Runs on the server only — accepts the JWT token read from cookies by the page component.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const CONSUMER_TOKEN_COOKIE = 'ks_consumer_token';
|
|
7
|
+
|
|
8
|
+
import type { Consumer, ConversationSummary, Message, ContactSummary } from '../types/api/consumer';
|
|
9
|
+
export type { Consumer, ConversationSummary, Message, ContactSummary };
|
|
10
|
+
|
|
11
|
+
function getApiUrl(): string {
|
|
12
|
+
return process.env.API_URL || 'http://localhost:3000/api/v1';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function consumerFetch<T>(path: string, token: string): Promise<T | null> {
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(`${getApiUrl()}${path}`, {
|
|
18
|
+
headers: {
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
Authorization: `Bearer ${token}`,
|
|
21
|
+
},
|
|
22
|
+
cache: 'no-store',
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) return null;
|
|
25
|
+
const json = await res.json();
|
|
26
|
+
return (json.data ?? json) as T;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function fetchConsumerMe(token: string): Promise<Consumer | null> {
|
|
33
|
+
return consumerFetch<Consumer>('/consumer/me', token);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function fetchConsumerConversations(token: string): Promise<ConversationSummary[]> {
|
|
37
|
+
const apiKey = process.env.API_KEY;
|
|
38
|
+
const headers: Record<string, string> = {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
Authorization: `Bearer ${token}`,
|
|
41
|
+
...(apiKey ? { 'X-API-Key': apiKey } : {}),
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(`${getApiUrl()}/consumer/me/conversations`, { headers, cache: 'no-store' });
|
|
45
|
+
if (!res.ok) return [];
|
|
46
|
+
const json = await res.json();
|
|
47
|
+
return (json.data ?? []) as ConversationSummary[];
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function fetchConsumerMessages(
|
|
54
|
+
token: string,
|
|
55
|
+
contactId: number
|
|
56
|
+
): Promise<{ messages: Message[]; contact: ContactSummary | null }> {
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(
|
|
59
|
+
`${getApiUrl()}/consumer/me/contacts/${contactId}/messages?per_page=100`,
|
|
60
|
+
{
|
|
61
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
62
|
+
cache: 'no-store',
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
if (!res.ok) return { messages: [], contact: null };
|
|
66
|
+
const json = await res.json();
|
|
67
|
+
return {
|
|
68
|
+
messages: (json.data ?? []) as Message[],
|
|
69
|
+
contact: (json.contact ?? null) as ContactSummary | null,
|
|
70
|
+
};
|
|
71
|
+
} catch {
|
|
72
|
+
return { messages: [], contact: null };
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/lib/hooks/index.ts
CHANGED
|
@@ -6,3 +6,5 @@
|
|
|
6
6
|
export { useBreakpoint } from './use-breakpoint';
|
|
7
7
|
export { useClipboard } from './use-clipboard';
|
|
8
8
|
export { useResizeObserver } from './use-resize-observer';
|
|
9
|
+
export { useImageCycle } from './use-image-cycle';
|
|
10
|
+
export type { CycledImage, UseImageCycleResult } from './use-image-cycle';
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
4
|
+
import type { PhotoAttachment } from '../../types/api/photos';
|
|
5
|
+
|
|
6
|
+
const CYCLE_INTERVAL_MIN_MS = 6000;
|
|
7
|
+
const CYCLE_INTERVAL_MAX_MS = 8000;
|
|
8
|
+
export const CROSSFADE_DURATION_MS = 600;
|
|
9
|
+
|
|
10
|
+
export interface CycledImage {
|
|
11
|
+
url: string;
|
|
12
|
+
alt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UseImageCycleResult {
|
|
16
|
+
list: CycledImage[];
|
|
17
|
+
currentIndex: number;
|
|
18
|
+
nextIndex: number;
|
|
19
|
+
transitioning: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Stable value in [0, 1) derived from seed string (FNV-1a hash). Same seed always produces same value. */
|
|
23
|
+
function seedToUnit(seed: string): number {
|
|
24
|
+
let h = 2166136261 >>> 0;
|
|
25
|
+
for (let i = 0; i < seed.length; i++) {
|
|
26
|
+
h ^= seed.charCodeAt(i);
|
|
27
|
+
h = (Math.imul(h, 16777619) >>> 0) >>> 0;
|
|
28
|
+
}
|
|
29
|
+
return (h >>> 0) / 4294967296;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Seeded Fisher-Yates shuffle. Same seed produces same order (SSR-safe). */
|
|
33
|
+
function shuffleWithSeed<T>(array: T[], seed: string): T[] {
|
|
34
|
+
if (array.length <= 1) return array;
|
|
35
|
+
const arr = [...array];
|
|
36
|
+
let h = 2166136261 >>> 0;
|
|
37
|
+
for (let i = 0; i < seed.length; i++) {
|
|
38
|
+
h ^= seed.charCodeAt(i);
|
|
39
|
+
h = (Math.imul(h, 16777619) >>> 0) >>> 0;
|
|
40
|
+
}
|
|
41
|
+
const next = (step: number) => {
|
|
42
|
+
h = (Math.imul(1664525, (h + step) >>> 0) + 1013904223) >>> 0;
|
|
43
|
+
return (h >>> 0) / 4294967296;
|
|
44
|
+
};
|
|
45
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
46
|
+
const j = Math.floor(next(i) * (i + 1));
|
|
47
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
48
|
+
}
|
|
49
|
+
return arr;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function photoUrlFromAttachment(pa: PhotoAttachment): string | undefined {
|
|
53
|
+
return pa.photo?.large_url || pa.photo?.medium_url || pa.photo?.thumbnail_url;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function photoAltFromAttachment(pa: PhotoAttachment): string {
|
|
57
|
+
return pa.photo?.alt_text || pa.photo?.title || '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Cycles through a list of photo attachments with a seeded shuffle and crossfade transitions.
|
|
62
|
+
*
|
|
63
|
+
* Each card uses a unique seed so intervals are staggered — cards don't all transition in sync.
|
|
64
|
+
* The shuffle is deterministic (SSR-safe) and the timer only activates when there are 2+ images.
|
|
65
|
+
*/
|
|
66
|
+
export function useImageCycle(
|
|
67
|
+
photoAttachments: PhotoAttachment[] | undefined,
|
|
68
|
+
seed: string
|
|
69
|
+
): UseImageCycleResult {
|
|
70
|
+
const list = useMemo<CycledImage[]>(() => {
|
|
71
|
+
const arr = Array.isArray(photoAttachments) && photoAttachments.length > 0 ? photoAttachments : [];
|
|
72
|
+
if (arr.length === 0) return [];
|
|
73
|
+
return shuffleWithSeed(arr, seed)
|
|
74
|
+
.map((pa) => ({ url: photoUrlFromAttachment(pa) ?? '', alt: photoAltFromAttachment(pa) }))
|
|
75
|
+
.filter((x) => x.url);
|
|
76
|
+
}, [photoAttachments, seed]);
|
|
77
|
+
|
|
78
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
79
|
+
const [transitioning, setTransitioning] = useState(false);
|
|
80
|
+
|
|
81
|
+
// Per-card random interval (6–8 s) so cards don't all transition in sync
|
|
82
|
+
const intervalMs = useMemo(
|
|
83
|
+
() => CYCLE_INTERVAL_MIN_MS + Math.floor(seedToUnit(seed) * (CYCLE_INTERVAL_MAX_MS - CYCLE_INTERVAL_MIN_MS + 1)),
|
|
84
|
+
[seed]
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (list.length <= 1) return;
|
|
89
|
+
const id = setInterval(() => setTransitioning(true), intervalMs);
|
|
90
|
+
return () => clearInterval(id);
|
|
91
|
+
}, [list.length, intervalMs]);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!transitioning || list.length <= 1) return;
|
|
95
|
+
const t = setTimeout(() => {
|
|
96
|
+
setCurrentIndex((i) => (i + 1) % list.length);
|
|
97
|
+
setTransitioning(false);
|
|
98
|
+
}, CROSSFADE_DURATION_MS);
|
|
99
|
+
return () => clearTimeout(t);
|
|
100
|
+
}, [transitioning, list.length]);
|
|
101
|
+
|
|
102
|
+
const nextIndex = list.length > 1 ? (currentIndex + 1) % list.length : 0;
|
|
103
|
+
|
|
104
|
+
return { list, currentIndex, nextIndex, transitioning };
|
|
105
|
+
}
|
package/src/lib/server-api.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { CompanyInformation } from '../types/api/company-information';
|
|
7
7
|
import type { Service } from '../types/api/service';
|
|
8
|
+
import type { Package } from '../types/api/package';
|
|
8
9
|
import type { WebsitePhotos } from '../types/api/website-photos';
|
|
9
10
|
|
|
10
11
|
const API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';
|
|
@@ -77,8 +78,8 @@ export function getMetaPixelId(adsConfig: { meta_pixel_id?: string } | null | un
|
|
|
77
78
|
return str !== '' && str !== 'null' && /^\d+$/.test(str) ? str : null;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
export async function getServices() {
|
|
81
|
-
return serverFetch('/public/services', defaultOptions);
|
|
81
|
+
export async function getServices(): Promise<Service[] | null> {
|
|
82
|
+
return serverFetch<Service[]>('/public/services', defaultOptions);
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
export async function getService(slug: string): Promise<Service | null> {
|
|
@@ -130,12 +131,12 @@ export async function getSocialPosts() {
|
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
/** Packages (bundles of service items). */
|
|
133
|
-
export async function getPackages() {
|
|
134
|
-
return serverFetch('/public/packages', defaultOptions);
|
|
134
|
+
export async function getPackages(): Promise<Package[] | null> {
|
|
135
|
+
return serverFetch<Package[]>('/public/packages', defaultOptions);
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
export async function getPackage(slug: string) {
|
|
138
|
-
return serverFetch(`/public/packages/by_slug/${encodeURIComponent(slug)}`, defaultOptions);
|
|
138
|
+
export async function getPackage(slug: string): Promise<Package | null> {
|
|
139
|
+
return serverFetch<Package>(`/public/packages/by_slug/${encodeURIComponent(slug)}`, defaultOptions);
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
// Alias for testimonials (API uses "reviews")
|