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
@@ -5,6 +5,10 @@
5
5
  * // app/api/chat/route.ts
6
6
  * export { GET, POST } from 'keystone-design-bootstrap/next/routes/chat';
7
7
  *
8
+ * Supports both anonymous (session-based) and authenticated (contact-driven) flows.
9
+ * Both flows hit the same backend endpoint (`/public/messages`); the discriminator
10
+ * is whether `identifier` (session string) or `contact_id` (integer) is supplied.
11
+ *
8
12
  * Env (server-side only):
9
13
  * - API_URL (default: http://localhost:3000/api/v1)
10
14
  * - API_KEY
@@ -26,105 +30,73 @@ export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResp
26
30
  const json: JsonResponder = deps?.NextResponse?.json ?? ((body, init) => Response.json(body, init));
27
31
 
28
32
  return {
33
+ // POST /api/chat — send a message (session-based or contact-driven)
29
34
  POST: async (request: Request): Promise<Response> => {
30
35
  try {
31
36
  const body = await request.json();
32
- const { identifier, body: messageBody, display_name, page_url } = body;
37
+ const { identifier, contact_id, body: messageBody, display_name, page_url } = body;
33
38
 
34
- if (!identifier || !messageBody) {
39
+ if (!messageBody) {
40
+ return json({ success: false, error: 'Message body is required.' }, { status: 400 });
41
+ }
42
+ if (!identifier && !contact_id) {
35
43
  return json(
36
- { success: false, error: 'Identifier and message body are required.' },
44
+ { success: false, error: 'identifier or contact_id is required.' },
37
45
  { status: 400 }
38
46
  );
39
47
  }
40
48
 
41
49
  const response = await fetch(`${API_URL}/public/messages`, {
42
50
  method: 'POST',
43
- headers: {
44
- 'Content-Type': 'application/json',
45
- 'X-API-Key': API_KEY,
46
- },
47
- body: JSON.stringify({
48
- identifier,
49
- body: messageBody,
50
- display_name,
51
- page_url,
52
- }),
51
+ headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
52
+ body: JSON.stringify({ identifier, contact_id, body: messageBody, display_name, page_url }),
53
53
  });
54
54
 
55
55
  const data = await response.json();
56
-
57
56
  if (!response.ok) {
58
57
  return json(
59
- {
60
- success: false,
61
- error: data.error || 'Failed to send message. Please try again.',
62
- },
58
+ { success: false, error: data.error || 'Failed to send message. Please try again.' },
63
59
  { status: response.status }
64
60
  );
65
61
  }
66
-
67
- return json({
68
- success: true,
69
- data: data.data,
70
- });
62
+ return json({ success: true, data: data.data });
71
63
  } catch (error) {
72
64
  console.error('Chat message error:', error);
73
- return json(
74
- {
75
- success: false,
76
- error: 'Network error. Please try again later.',
77
- },
78
- { status: 500 }
79
- );
65
+ return json({ success: false, error: 'Network error. Please try again later.' }, { status: 500 });
80
66
  }
81
67
  },
82
68
 
69
+ // GET /api/chat?identifier=... or ?contact_id=... — load message history
83
70
  GET: async (request: Request): Promise<Response> => {
84
71
  try {
85
72
  const { searchParams } = new URL(request.url);
86
73
  const identifier = searchParams.get('identifier');
74
+ const contactId = searchParams.get('contact_id');
87
75
 
88
- if (!identifier) {
89
- return json({ success: false, error: 'Identifier is required.' }, { status: 400 });
76
+ if (!identifier && !contactId) {
77
+ return json({ success: false, error: 'identifier or contact_id is required.' }, { status: 400 });
90
78
  }
91
79
 
92
- const response = await fetch(
93
- `${API_URL}/public/messages?identifier=${encodeURIComponent(identifier)}`,
94
- {
95
- headers: {
96
- 'X-API-Key': API_KEY,
97
- },
98
- }
99
- );
80
+ const query = contactId
81
+ ? `contact_id=${encodeURIComponent(contactId)}`
82
+ : `identifier=${encodeURIComponent(identifier!)}`;
100
83
 
101
- const data = await response.json();
84
+ const response = await fetch(`${API_URL}/public/messages?${query}`, {
85
+ headers: { 'X-API-Key': API_KEY },
86
+ });
102
87
 
88
+ const data = await response.json();
103
89
  if (!response.ok) {
104
90
  return json(
105
- {
106
- success: false,
107
- error: data.error || 'Failed to load messages.',
108
- },
91
+ { success: false, error: data.error || 'Failed to load messages.' },
109
92
  { status: response.status }
110
93
  );
111
94
  }
112
-
113
- return json({
114
- success: true,
115
- data: data.data || [],
116
- });
95
+ return json({ success: true, data: data.data || [] });
117
96
  } catch (error) {
118
97
  console.error('Chat history error:', error);
119
- return json(
120
- {
121
- success: false,
122
- error: 'Network error. Please try again later.',
123
- },
124
- { status: 500 }
125
- );
98
+ return json({ success: false, error: 'Network error. Please try again later.' }, { status: 500 });
126
99
  }
127
100
  },
128
101
  };
129
102
  }
130
-
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Consumer auth API route handlers for Next.js App Router.
3
+ *
4
+ * Usage in a customer site:
5
+ * // app/api/consumer/[action]/route.ts
6
+ * import { NextResponse } from 'next/server';
7
+ * import { createConsumerAuthHandlers } from 'keystone-design-bootstrap/next/routes/consumer-auth';
8
+ * export const { POST } = createConsumerAuthHandlers({ NextResponse });
9
+ *
10
+ * Handles: initiate, login, signup, logout
11
+ *
12
+ * Env (server-side only):
13
+ * - API_URL (default: http://localhost:3000/api/v1)
14
+ * - API_KEY
15
+ */
16
+
17
+ import { CONSUMER_TOKEN_COOKIE } from '../../lib/consumer-session';
18
+
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ type NextResponseLike = { json: (body: unknown, init?: ResponseInit) => any };
21
+
22
+ const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
23
+
24
+ function getApiUrl(): string {
25
+ return process.env.API_URL || 'http://localhost:3000/api/v1';
26
+ }
27
+
28
+ function getApiKey(): string {
29
+ return process.env.API_KEY || '';
30
+ }
31
+
32
+ function apiHeaders(): Record<string, string> {
33
+ const key = getApiKey();
34
+ return {
35
+ 'Content-Type': 'application/json',
36
+ ...(key ? { 'X-API-Key': key } : {}),
37
+ };
38
+ }
39
+
40
+ // POST /api/consumer/initiate
41
+ // Looks up an existing user by email or phone, creating a Contact if needed.
42
+ // Returns { exists, firstName, hasPassword }.
43
+ async function handleInitiate(request: Request, NR: NextResponseLike): Promise<Response> {
44
+ const body = await request.json().catch(() => ({})) as Record<string, unknown>;
45
+ const { email, phone } = body;
46
+
47
+ if (!email && !phone) {
48
+ return NR.json({ exists: null });
49
+ }
50
+
51
+ try {
52
+ const res = await fetch(`${getApiUrl()}/consumer/auth/initiate`, {
53
+ method: 'POST',
54
+ headers: apiHeaders(),
55
+ body: JSON.stringify({ email: email || undefined, phone: phone || undefined }),
56
+ });
57
+
58
+ if (res.status === 404) return NR.json({ exists: false });
59
+ if (!res.ok) return NR.json({ exists: null });
60
+
61
+ const json = await res.json().catch(() => ({}));
62
+ const data = json.data as Record<string, unknown> | undefined;
63
+ return NR.json({
64
+ exists: true,
65
+ firstName: data?.first_name ?? undefined,
66
+ hasPassword: data?.has_password != null ? Boolean(data.has_password) : true,
67
+ });
68
+ } catch {
69
+ return NR.json({ exists: null });
70
+ }
71
+ }
72
+
73
+ // POST /api/consumer/login
74
+ // Authenticates an existing user. Sets HttpOnly JWT cookie on success.
75
+ // Returns {} on success or { error, code } on failure.
76
+ async function handleLogin(request: Request, NR: NextResponseLike): Promise<Response> {
77
+ const body = await request.json().catch(() => ({})) as Record<string, string>;
78
+ const { email, phone, password } = body;
79
+
80
+ if (!email && !phone) return NR.json({ error: 'Email or phone is required.' }, { status: 422 });
81
+ if (!password) return NR.json({ error: 'Password is required.' }, { status: 422 });
82
+
83
+ const res = await fetch(`${getApiUrl()}/consumer/auth/login`, {
84
+ method: 'POST',
85
+ headers: apiHeaders(),
86
+ body: JSON.stringify({ email: email || undefined, phone: phone || undefined, password }),
87
+ });
88
+
89
+ const json = await res.json().catch(() => ({}));
90
+ if (!res.ok) {
91
+ return NR.json(
92
+ { error: json.error || 'Login failed. Please try again.', code: json.code },
93
+ { status: res.status }
94
+ );
95
+ }
96
+
97
+ const token = json.data?.token;
98
+ if (!token) return NR.json({ error: 'No token received.' }, { status: 500 });
99
+
100
+ const response = NR.json({});
101
+ response.cookies.set(CONSUMER_TOKEN_COOKIE, token, {
102
+ httpOnly: true,
103
+ secure: process.env.NODE_ENV === 'production',
104
+ sameSite: 'lax',
105
+ path: '/',
106
+ maxAge: COOKIE_MAX_AGE,
107
+ });
108
+ return response;
109
+ }
110
+
111
+ // POST /api/consumer/signup
112
+ // Creates a new account (or claims an existing contact). Sets HttpOnly JWT cookie on success.
113
+ // Returns {} on success or { error } on failure.
114
+ async function handleSignup(request: Request, NR: NextResponseLike): Promise<Response> {
115
+ const body = await request.json().catch(() => ({})) as Record<string, string>;
116
+ const { email, phone, password, password_confirmation, first_name, last_name } = body;
117
+
118
+ if (!email && !phone) return NR.json({ error: 'Email or phone is required.' }, { status: 422 });
119
+
120
+ const res = await fetch(`${getApiUrl()}/consumer/auth/signup`, {
121
+ method: 'POST',
122
+ headers: apiHeaders(),
123
+ body: JSON.stringify({
124
+ email: email || undefined,
125
+ phone: phone || undefined,
126
+ password,
127
+ password_confirmation,
128
+ first_name: first_name || undefined,
129
+ last_name: last_name || undefined,
130
+ }),
131
+ });
132
+
133
+ const json = await res.json().catch(() => ({}));
134
+ if (!res.ok) {
135
+ return NR.json({ error: json.error || 'Signup failed. Please try again.' }, { status: res.status });
136
+ }
137
+
138
+ const token = json.data?.token;
139
+ if (!token) return NR.json({ error: 'No token received.' }, { status: 500 });
140
+
141
+ const response = NR.json({ claimed: json.data?.claimed ?? false });
142
+ response.cookies.set(CONSUMER_TOKEN_COOKIE, token, {
143
+ httpOnly: true,
144
+ secure: process.env.NODE_ENV === 'production',
145
+ sameSite: 'lax',
146
+ path: '/',
147
+ maxAge: COOKIE_MAX_AGE,
148
+ });
149
+ return response;
150
+ }
151
+
152
+ // POST /api/consumer/logout
153
+ // Clears the JWT cookie.
154
+ async function handleLogout(_request: Request, NR: NextResponseLike): Promise<Response> {
155
+ const response = NR.json({ success: true });
156
+ response.cookies.delete(CONSUMER_TOKEN_COOKIE);
157
+ return response;
158
+ }
159
+
160
+ /**
161
+ * Creates a single POST handler that routes to the correct auth action based on
162
+ * the dynamic `[action]` path segment.
163
+ *
164
+ * Compatible with Next.js 14 (sync params) and 15 (async params).
165
+ */
166
+ export function createConsumerAuthHandlers({ NextResponse }: { NextResponse: NextResponseLike }) {
167
+ return {
168
+ POST: async (
169
+ request: Request,
170
+ context: { params: Promise<{ action: string }> | { action: string } }
171
+ ): Promise<Response> => {
172
+ const { action } = await Promise.resolve(context.params);
173
+ if (action === 'initiate') return handleInitiate(request, NextResponse);
174
+ if (action === 'login') return handleLogin(request, NextResponse);
175
+ if (action === 'signup') return handleSignup(request, NextResponse);
176
+ if (action === 'logout') return handleLogout(request, NextResponse);
177
+ return NextResponse.json({ error: 'Not found' }, { status: 404 });
178
+ },
179
+ };
180
+ }
@@ -0,0 +1,39 @@
1
+ /** Consumer and messaging types (aligned with Rails ConsumerSerializer and conversation endpoints). */
2
+
3
+ export interface Consumer {
4
+ id: number;
5
+ email: string | null;
6
+ phone: string | null;
7
+ primary_identifier: string | null;
8
+ contacts?: ConsumerContact[];
9
+ }
10
+
11
+ export interface ConsumerContact {
12
+ id: number;
13
+ display_name: string;
14
+ account?: { id: number; name: string; slug?: string };
15
+ }
16
+
17
+ export interface ConversationSummary {
18
+ contact_id: number;
19
+ business?: { id: number; name: string; company_name?: string };
20
+ last_message_at: string | null;
21
+ last_message_preview?: string | null;
22
+ message_count: number;
23
+ }
24
+
25
+ export interface Message {
26
+ id: number;
27
+ body: string | null;
28
+ /** "outbound" = sent by the business; "inbound" = sent by the contact/member. */
29
+ direction: 'inbound' | 'outbound';
30
+ sender_type: 'contact' | 'agent' | 'human';
31
+ sender_display_name?: string;
32
+ created_at: string;
33
+ }
34
+
35
+ export interface ContactSummary {
36
+ id: number;
37
+ display_name: string;
38
+ business?: { id: number; name: string; company_name?: string };
39
+ }
@@ -6,7 +6,7 @@ export interface OfferPublic {
6
6
  name: string;
7
7
  description: string | null;
8
8
  value_terms: string | null;
9
- active: boolean;
9
+ active?: boolean;
10
10
  expires_at?: string | null;
11
11
  expired?: boolean;
12
12
  photo_attachments?: PhotoAttachment[];
@@ -0,0 +1,20 @@
1
+ import type { PhotoAttachment } from './photos';
2
+ import type { OfferPublic } from './offer';
3
+
4
+ export interface PackageItem {
5
+ quantity: number;
6
+ service_item?: { id: number; name: string; slug: string; summary?: string | null };
7
+ }
8
+
9
+ export interface Package {
10
+ id: number;
11
+ name: string;
12
+ slug: string;
13
+ summary?: string | null;
14
+ description_markdown?: string | null;
15
+ pricing_info?: string | null;
16
+ price_cents?: number | null;
17
+ photo_attachments?: PhotoAttachment[];
18
+ package_items?: PackageItem[];
19
+ offers?: OfferPublic[];
20
+ }
@@ -1,4 +1,6 @@
1
- // Service type definitions (aligned with Rails ServiceSerializer)
1
+ import type { PhotoAttachment } from './photos';
2
+ import type { OfferPublic } from './offer';
3
+
2
4
  export interface Service {
3
5
  id: number;
4
6
  name: string;
@@ -9,27 +11,12 @@ export interface Service {
9
11
  features_markdown?: string;
10
12
  featured: boolean;
11
13
  sort_order: number;
12
- photo_attachments?: Array<{
13
- id: number;
14
- featured: boolean;
15
- attachable_id?: number;
16
- attachable_type?: string;
17
- photo?: {
18
- id: number;
19
- title: string;
20
- thumbnail_url?: string;
21
- medium_url?: string;
22
- large_url?: string;
23
- original_url?: string;
24
- };
25
- }>;
26
- /** Menu items under this service category (when loaded with associations). */
14
+ photo_attachments?: PhotoAttachment[];
27
15
  service_items?: ServiceItem[];
28
16
  created_at: string;
29
17
  updated_at: string;
30
18
  }
31
19
 
32
- /** Single menu item under a service category. */
33
20
  export interface ServiceItem {
34
21
  id: number;
35
22
  name: string;
@@ -41,13 +28,8 @@ export interface ServiceItem {
41
28
  duration_minutes?: number | null;
42
29
  sort_order: number;
43
30
  service_id?: number;
44
- /** Present when loaded for public/menu (from related service’s photos). */
45
- photo_attachments?: Array<{
46
- id: number;
47
- photo?: { thumbnail_url?: string; medium_url?: string; large_url?: string; original_url?: string; title?: string; alt_text?: string };
48
- }>;
49
- /** Active offers that apply to this menu item (public API). */
50
- offers?: import('./offer').OfferPublic[];
31
+ photo_attachments?: PhotoAttachment[];
32
+ offers?: OfferPublic[];
51
33
  }
52
34
 
53
35
  export interface ServiceParams {
@@ -9,12 +9,14 @@ export * from './config';
9
9
  // Re-export API types
10
10
  export * from './api/blog-post';
11
11
  export * from './api/company-information';
12
+ export * from './api/consumer';
12
13
  export * from './api/contact';
13
14
  export * from './api/form';
14
15
  export * from './api/faq';
15
16
  export * from './api/job-posting';
16
17
  export * from './api/location';
17
18
  export * from './api/offer';
19
+ export * from './api/package';
18
20
  export * from './api/photos';
19
21
  export * from './api/service';
20
22
  export * from './api/social-post';
@@ -0,0 +1,27 @@
1
+ import type countries from './countries';
2
+
3
+ type Country = (typeof countries)[0];
4
+
5
+ /** Get national-format mask from country by stripping the country code prefix (e.g. "+1 (###) ###-####" → "(###) ###-####"). */
6
+ export function getNationalMask(country: Country | undefined): string {
7
+ if (!country?.phoneMask) return '';
8
+ const code = country.phoneCode.startsWith('+') ? country.phoneCode : `+${country.phoneCode}`;
9
+ const escaped = code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10
+ return country.phoneMask.replace(new RegExp(`^\\s*${escaped}[\\s-]*`), '').trim();
11
+ }
12
+
13
+ /** Format a raw digit string into a mask pattern where '#' represents one digit. No trailing literals so backspace works naturally. */
14
+ export function formatDigitsToMask(digits: string, mask: string): string {
15
+ if (digits.length === 0) return '';
16
+ let i = 0;
17
+ let out = '';
18
+ for (const c of mask) {
19
+ if (c === '#') {
20
+ if (i < digits.length) out += digits[i++];
21
+ else break;
22
+ } else if (i < digits.length) {
23
+ out += c;
24
+ }
25
+ }
26
+ return out;
27
+ }
@@ -1,50 +0,0 @@
1
- import { P as PhotoAttachment } from './website-photos-Bm-CBK9g.js';
2
-
3
- interface BlogPost {
4
- id: number;
5
- title: string;
6
- slug: string;
7
- status: string;
8
- published_at?: string;
9
- excerpt_markdown?: string;
10
- content_markdown: string;
11
- featured: boolean;
12
- seo_title?: string;
13
- seo_description?: string;
14
- seo_keywords?: string;
15
- created_at: string;
16
- updated_at: string;
17
- photo_attachments?: PhotoAttachment[];
18
- blog_post_authors?: BlogPostAuthor[];
19
- blog_post_tags?: BlogPostTag[];
20
- }
21
- interface BlogPostParams {
22
- status?: string;
23
- author_id?: number;
24
- tag_id?: number;
25
- q?: string;
26
- page?: number;
27
- per_page?: number;
28
- featured?: boolean;
29
- }
30
- type BlogPostResponse = BlogPost[];
31
- interface BlogPostAuthor {
32
- id: number;
33
- name: string;
34
- slug: string;
35
- bio_markdown?: string;
36
- active: boolean;
37
- created_at: string;
38
- updated_at: string;
39
- photo_attachments?: PhotoAttachment[];
40
- }
41
- interface BlogPostTag {
42
- id: number;
43
- name: string;
44
- slug: string;
45
- description?: string;
46
- created_at: string;
47
- updated_at: string;
48
- }
49
-
50
- export type { BlogPost as B, BlogPostAuthor as a, BlogPostParams as b, BlogPostResponse as c, BlogPostTag as d };
@@ -1,13 +0,0 @@
1
- import * as React$1 from 'react';
2
- import { Theme } from '../themes/index.js';
3
-
4
- interface ThemeContextValue {
5
- theme: Theme;
6
- }
7
- declare function ThemeProvider({ theme, children }: {
8
- theme: Theme;
9
- children: React.ReactNode;
10
- }): React$1.JSX.Element;
11
- declare function useTheme(): ThemeContextValue;
12
-
13
- export { ThemeProvider, useTheme };