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.
Files changed (56) 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 +339 -0
  17. package/src/design_system/portal/LoginModalController.tsx +63 -0
  18. package/src/design_system/portal/LogoutButton.tsx +23 -0
  19. package/src/design_system/portal/MessageComposer.tsx +84 -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/actions.ts +160 -0
  23. package/src/design_system/portal/index.ts +5 -0
  24. package/src/design_system/sections/index.tsx +1 -1
  25. package/src/design_system/sections/service-menu-section.tsx +7 -108
  26. package/src/lib/actions.ts +51 -115
  27. package/src/lib/consumer-session.ts +74 -0
  28. package/src/lib/hooks/index.ts +2 -0
  29. package/src/lib/hooks/use-image-cycle.ts +105 -0
  30. package/src/lib/server-api.ts +7 -6
  31. package/src/next/routes/chat.ts +30 -58
  32. package/src/next/routes/consumer-auth.ts +113 -0
  33. package/src/types/api/consumer.ts +39 -0
  34. package/src/types/api/offer.ts +1 -1
  35. package/src/types/api/package.ts +20 -0
  36. package/src/types/api/service.ts +6 -24
  37. package/src/types/index.ts +2 -0
  38. package/src/utils/phone-helpers.ts +27 -0
  39. package/dist/blog-post-DGjaJ3wf.d.ts +0 -50
  40. package/dist/contexts/index.d.ts +0 -13
  41. package/dist/design_system/elements/index.d.ts +0 -372
  42. package/dist/design_system/logo/keystone-logo.d.ts +0 -6
  43. package/dist/design_system/sections/index.d.ts +0 -237
  44. package/dist/form-CpsCONG5.d.ts +0 -151
  45. package/dist/index.d.ts +0 -76
  46. package/dist/lib/component-registry.d.ts +0 -13
  47. package/dist/lib/hooks/index.d.ts +0 -64
  48. package/dist/lib/server-api.d.ts +0 -43
  49. package/dist/themes/index.d.ts +0 -16
  50. package/dist/types/index.d.ts +0 -264
  51. package/dist/utils/cx.d.ts +0 -15
  52. package/dist/utils/gradient-placeholder.d.ts +0 -8
  53. package/dist/utils/is-react-component.d.ts +0 -21
  54. package/dist/utils/markdown-toc.d.ts +0 -14
  55. package/dist/utils/photo-helpers.d.ts +0 -37
  56. 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,160 @@
1
+ 'use server';
2
+
3
+ import { cookies } from 'next/headers';
4
+ import { CONSUMER_TOKEN_COOKIE } from '../../lib/consumer-session';
5
+
6
+ const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
7
+
8
+ function getApiUrl(): string {
9
+ return process.env.API_URL || 'http://localhost:3000/api/v1';
10
+ }
11
+
12
+ function getApiKey(): string {
13
+ return process.env.API_KEY || '';
14
+ }
15
+
16
+ async function setConsumerCookie(token: string): Promise<void> {
17
+ const cookieStore = await cookies();
18
+ cookieStore.set(CONSUMER_TOKEN_COOKIE, token, {
19
+ httpOnly: true,
20
+ secure: process.env.NODE_ENV === 'production',
21
+ sameSite: 'lax',
22
+ path: '/',
23
+ maxAge: COOKIE_MAX_AGE,
24
+ });
25
+ }
26
+
27
+ export async function loginAction(payload: {
28
+ email: string | null;
29
+ phone: string | null;
30
+ password: string;
31
+ }): Promise<{ error?: string; code?: string }> {
32
+ const { email, phone, password } = payload;
33
+
34
+ if (!email && !phone) return { error: 'Email or phone is required.' };
35
+ if (!password) return { error: 'Password is required.' };
36
+
37
+ const apiKey = getApiKey();
38
+ const res = await fetch(`${getApiUrl()}/consumer/auth/login`, {
39
+ method: 'POST',
40
+ headers: {
41
+ 'Content-Type': 'application/json',
42
+ ...(apiKey ? { 'X-API-Key': apiKey } : {}),
43
+ },
44
+ body: JSON.stringify({ email: email || undefined, phone: phone || undefined, password }),
45
+ });
46
+
47
+ const rawBody = await res.text();
48
+ let json: Record<string, unknown> = {};
49
+ try { json = JSON.parse(rawBody); } catch { /* ignore */ }
50
+ if (!res.ok) return { error: (json as { error?: string }).error || 'Login failed. Please try again.', code: (json as { code?: string }).code };
51
+
52
+ const token = (json.data as Record<string, unknown> | undefined)?.token as string | undefined;
53
+ if (!token) return { error: 'No token received from server.' };
54
+
55
+ await setConsumerCookie(token);
56
+ return {};
57
+ }
58
+
59
+ export async function initiateAuthAction(payload: {
60
+ email: string | null;
61
+ phone: string | null;
62
+ }): Promise<{ exists: boolean | null; firstName?: string; hasPassword?: boolean }> {
63
+ const { email, phone } = payload;
64
+ if (!email && !phone) return { exists: null };
65
+ try {
66
+ const apiKey = getApiKey();
67
+ const res = await fetch(`${getApiUrl()}/consumer/auth/initiate`, {
68
+ method: 'POST',
69
+ headers: {
70
+ 'Content-Type': 'application/json',
71
+ ...(apiKey ? { 'X-API-Key': apiKey } : {}),
72
+ },
73
+ body: JSON.stringify({ email: email || undefined, phone: phone || undefined }),
74
+ });
75
+ const rawBody = await res.text();
76
+ if (res.status === 404) return { exists: false };
77
+ if (!res.ok) return { exists: null };
78
+ let json: Record<string, unknown> = {};
79
+ try { json = JSON.parse(rawBody); } catch { /* ignore */ }
80
+ const data = json.data as Record<string, unknown> | undefined;
81
+ return { exists: true, firstName: data?.first_name as string | undefined, hasPassword: data?.has_password != null ? Boolean(data.has_password) : true };
82
+ } catch {
83
+ return { exists: null };
84
+ }
85
+ }
86
+
87
+ export async function signupAction(payload: {
88
+ email: string | null;
89
+ phone: string | null;
90
+ password: string;
91
+ password_confirmation: string;
92
+ first_name?: string;
93
+ last_name?: string;
94
+ }): Promise<{ error?: string; claimed?: boolean }> {
95
+ const { email, phone, password, password_confirmation, first_name, last_name } = payload;
96
+
97
+ if (!email && !phone) return { error: 'Email or phone is required.' };
98
+
99
+ const apiKey = getApiKey();
100
+ const res = await fetch(`${getApiUrl()}/consumer/auth/signup`, {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ ...(apiKey ? { 'X-API-Key': apiKey } : {}),
105
+ },
106
+ body: JSON.stringify({
107
+ email: email || undefined,
108
+ phone: phone || undefined,
109
+ password,
110
+ password_confirmation,
111
+ first_name: first_name || undefined,
112
+ last_name: last_name || undefined,
113
+ }),
114
+ });
115
+
116
+ const rawBody = await res.text();
117
+ let json: Record<string, unknown> = {};
118
+ try { json = JSON.parse(rawBody); } catch { /* ignore */ }
119
+ if (!res.ok) return { error: (json as { error?: string }).error || 'Signup failed. Please try again.' };
120
+
121
+ const token = (json.data as Record<string, unknown> | undefined)?.token as string | undefined;
122
+ if (!token) return { error: 'No token received from server.' };
123
+
124
+ await setConsumerCookie(token);
125
+ return { claimed: (json.data as Record<string, unknown> | undefined)?.claimed as boolean ?? false };
126
+ }
127
+
128
+ export async function sendMessageAction(payload: {
129
+ contactId: number;
130
+ body: string;
131
+ }): Promise<{ error?: string }> {
132
+ const { contactId, body } = payload;
133
+ if (!body.trim()) return { error: 'Message cannot be empty.' };
134
+
135
+ const apiKey = getApiKey();
136
+ if (!apiKey) return { error: 'Service not configured.' };
137
+
138
+ try {
139
+ const res = await fetch(`${getApiUrl()}/public/messages`, {
140
+ method: 'POST',
141
+ headers: {
142
+ 'Content-Type': 'application/json',
143
+ 'X-API-Key': apiKey,
144
+ },
145
+ body: JSON.stringify({ contact_id: contactId, body }),
146
+ });
147
+ if (!res.ok) {
148
+ const json = await res.json().catch(() => ({}));
149
+ return { error: json.error || 'Failed to send message.' };
150
+ }
151
+ return {};
152
+ } catch {
153
+ return { error: 'Failed to send message.' };
154
+ }
155
+ }
156
+
157
+ export async function logoutAction(): Promise<void> {
158
+ const cookieStore = await cookies();
159
+ cookieStore.delete(CONSUMER_TOKEN_COOKIE);
160
+ }
@@ -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';