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.
Files changed (55) hide show
  1. package/dist/design_system/elements/index.js +8 -3
  2. package/dist/design_system/elements/index.js.map +1 -1
  3. package/dist/design_system/sections/index.js +203 -106
  4. package/dist/design_system/sections/index.js.map +1 -1
  5. package/dist/index.js +303 -247
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/hooks/index.js +72 -0
  8. package/dist/lib/hooks/index.js.map +1 -1
  9. package/dist/lib/server-api.js.map +1 -1
  10. package/dist/utils/phone-helpers.js +26 -0
  11. package/dist/utils/phone-helpers.js.map +1 -0
  12. package/package.json +5 -2
  13. package/src/design_system/components/ChatWidget.tsx +51 -34
  14. package/src/design_system/components/DynamicFormFields.tsx +1 -24
  15. package/src/design_system/elements/modal/modal.tsx +54 -35
  16. package/src/design_system/portal/LoginForm.tsx +358 -0
  17. package/src/design_system/portal/LoginModalController.tsx +63 -0
  18. package/src/design_system/portal/LogoutButton.tsx +22 -0
  19. package/src/design_system/portal/MessageComposer.tsx +92 -0
  20. package/src/design_system/portal/PortalPage.tsx +754 -0
  21. package/src/design_system/portal/RowThumbnail.tsx +76 -0
  22. package/src/design_system/portal/index.ts +5 -0
  23. package/src/design_system/sections/index.tsx +1 -1
  24. package/src/design_system/sections/service-menu-section.tsx +7 -108
  25. package/src/lib/actions.ts +51 -115
  26. package/src/lib/consumer-session.ts +74 -0
  27. package/src/lib/hooks/index.ts +2 -0
  28. package/src/lib/hooks/use-image-cycle.ts +105 -0
  29. package/src/lib/server-api.ts +7 -6
  30. package/src/next/routes/chat.ts +30 -58
  31. package/src/next/routes/consumer-auth.ts +180 -0
  32. package/src/types/api/consumer.ts +39 -0
  33. package/src/types/api/offer.ts +1 -1
  34. package/src/types/api/package.ts +20 -0
  35. package/src/types/api/service.ts +6 -24
  36. package/src/types/index.ts +2 -0
  37. package/src/utils/phone-helpers.ts +27 -0
  38. package/dist/blog-post-DGjaJ3wf.d.ts +0 -50
  39. package/dist/contexts/index.d.ts +0 -13
  40. package/dist/design_system/elements/index.d.ts +0 -372
  41. package/dist/design_system/logo/keystone-logo.d.ts +0 -6
  42. package/dist/design_system/sections/index.d.ts +0 -237
  43. package/dist/form-CpsCONG5.d.ts +0 -151
  44. package/dist/index.d.ts +0 -76
  45. package/dist/lib/component-registry.d.ts +0 -13
  46. package/dist/lib/hooks/index.d.ts +0 -64
  47. package/dist/lib/server-api.d.ts +0 -43
  48. package/dist/themes/index.d.ts +0 -16
  49. package/dist/types/index.d.ts +0 -264
  50. package/dist/utils/cx.d.ts +0 -15
  51. package/dist/utils/gradient-placeholder.d.ts +0 -8
  52. package/dist/utils/is-react-component.d.ts +0 -21
  53. package/dist/utils/markdown-toc.d.ts +0 -14
  54. package/dist/utils/photo-helpers.d.ts +0 -37
  55. 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
+ }
@@ -0,0 +1,5 @@
1
+ export { PortalPage } from './PortalPage';
2
+ export type { PortalPageProps } from './PortalPage';
3
+ export { LoginForm } from './LoginForm';
4
+ export { LogoutButton } from './LogoutButton';
5
+ export { LoginModalController } from './LoginModalController';
@@ -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, PackagePublic } from './service-menu-section';
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, useMemo } from 'react';
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 PackagePublic {
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?: PackagePublic[] | null;
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 = useCycledPhotoList(photoAttachments, seed);
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
- // Timer: every N ms, start a crossfade
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 (
@@ -1,10 +1,10 @@
1
+ 'use server';
2
+
1
3
  /**
2
- * Server Actions for form submissions
3
- * These run on the server and can safely use API_KEY without exposing it to the browser
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
- * Submit contact form via Server Action
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
- // Extract and validate form data
25
- const firstName = formData.get('firstName')?.toString().trim() || '';
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
- const data = await response.json();
61
-
62
- if (!response.ok) {
63
- return {
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
- try {
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
- const eventId = data.data?.event_id ?? undefined;
66
+ if (!firstName || !lastName || !email || !message) {
67
+ return { success: false, error: 'Please fill in all required fields.' };
68
+ }
130
69
 
131
- return {
132
- success: true,
133
- message: data.message || 'Thank you! We\'ll be in touch soon.',
134
- ...(eventId && { eventId }),
135
- };
136
- } catch (error) {
137
- console.error('Lead form submission error:', error);
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
+ }
@@ -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
+ }
@@ -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")