keystone-design-bootstrap 1.0.48 → 1.0.49
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/{blog-post-CvRhU9ss.d.ts → blog-post-D7HFCDp1.d.ts} +2 -2
- package/dist/design_system/elements/index.d.ts +25 -1
- package/dist/design_system/elements/index.js +103 -3
- package/dist/design_system/elements/index.js.map +1 -1
- package/dist/design_system/sections/index.d.ts +64 -3
- package/dist/design_system/sections/index.js +1305 -458
- package/dist/design_system/sections/index.js.map +1 -1
- package/dist/form-BLZuTGkr.d.ts +137 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.js +1335 -487
- package/dist/index.js.map +1 -1
- package/dist/lib/server-api.d.ts +49 -4
- package/dist/lib/server-api.js +17 -0
- package/dist/lib/server-api.js.map +1 -1
- package/dist/photos-8jMeetqV.d.ts +47 -0
- package/dist/types/index.d.ts +6 -5
- package/dist/utils/photo-helpers.d.ts +6 -15
- package/dist/utils/photo-helpers.js +10 -1
- package/dist/utils/photo-helpers.js.map +1 -1
- package/package.json +1 -1
- package/src/design_system/elements/index.tsx +4 -0
- package/src/design_system/elements/modal/modal.tsx +129 -0
- package/src/design_system/sections/index.tsx +20 -2
- package/src/design_system/sections/offer-detail.tsx +46 -0
- package/src/design_system/sections/offers-gallery.tsx +40 -0
- package/src/design_system/sections/offers-grid.tsx +108 -0
- package/src/design_system/sections/offers-section.tsx +90 -0
- package/src/design_system/sections/service-menu-section.tsx +813 -0
- package/src/lib/server-api.ts +63 -0
- package/src/types/api/photos.ts +11 -10
- package/src/types/api/service.ts +21 -0
- package/src/utils/photo-helpers.ts +3 -14
- package/dist/company-information-C_k_sLSB.d.ts +0 -46
- package/dist/form-CWXC-IHT.d.ts +0 -88
- package/dist/website-photos-_n2g24IM.d.ts +0 -20
package/dist/lib/server-api.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { S as Service, F as FormDefinition } from '../form-
|
|
2
|
-
import {
|
|
3
|
-
import { W as WebsitePhotos } from '../website-photos-_n2g24IM.js';
|
|
1
|
+
import { C as CompanyInformation, S as Service, F as FormDefinition } from '../form-BLZuTGkr.js';
|
|
2
|
+
import { P as PhotoAttachment, W as WebsitePhotos } from '../photos-8jMeetqV.js';
|
|
4
3
|
|
|
5
4
|
interface FetchOptions {
|
|
6
5
|
cache?: RequestCache;
|
|
@@ -34,8 +33,54 @@ declare function getWebsitePhotos(): Promise<WebsitePhotos | null>;
|
|
|
34
33
|
declare function getJobPostings(): Promise<unknown>;
|
|
35
34
|
declare function getJobPosting(slug: string): Promise<unknown>;
|
|
36
35
|
declare function getSocialPosts(): Promise<unknown>;
|
|
36
|
+
/** Packages (bundles of service items). */
|
|
37
|
+
declare function getPackages(): Promise<unknown>;
|
|
38
|
+
declare function getPackage(slug: string): Promise<unknown>;
|
|
37
39
|
declare function getTestimonials(): Promise<unknown>;
|
|
38
40
|
/** Form definition for dynamic form rendering (fields may include optional placeholder). */
|
|
39
41
|
declare function getForm(formType: string): Promise<FormDefinition | null>;
|
|
42
|
+
/** Minimal service for offer detail (from GET /public/offers/:id). */
|
|
43
|
+
interface OfferServiceSummary {
|
|
44
|
+
id: number;
|
|
45
|
+
name: string;
|
|
46
|
+
slug: string;
|
|
47
|
+
summary?: string | null;
|
|
48
|
+
}
|
|
49
|
+
/** Package summary (nested on each offer in list and single-offer responses). */
|
|
50
|
+
interface OfferPackageSummary {
|
|
51
|
+
id: number;
|
|
52
|
+
name: string;
|
|
53
|
+
slug: string;
|
|
54
|
+
summary?: string | null;
|
|
55
|
+
description_markdown?: string | null;
|
|
56
|
+
}
|
|
57
|
+
/** Offer from public API. */
|
|
58
|
+
interface OfferPublic {
|
|
59
|
+
id: number;
|
|
60
|
+
name: string;
|
|
61
|
+
description: string | null;
|
|
62
|
+
value_terms: string | null;
|
|
63
|
+
/** Optional; when absent or null, offer has no expiration. */
|
|
64
|
+
expires_at?: string | null;
|
|
65
|
+
expired?: boolean;
|
|
66
|
+
/** Service IDs (used for category_names fallback when not provided by API). */
|
|
67
|
+
service_ids?: number[];
|
|
68
|
+
/** Package IDs (included in list response; nested services/packages are the source of truth). */
|
|
69
|
+
package_ids?: number[];
|
|
70
|
+
/** Category names from API (services + service_items + packages). */
|
|
71
|
+
category_names?: string[];
|
|
72
|
+
/** Photo attachments from related services (same shape as Service.photo_attachments). */
|
|
73
|
+
photo_attachments?: PhotoAttachment[];
|
|
74
|
+
/** Present when fetching single offer (GET /public/offers/:id). */
|
|
75
|
+
services?: OfferServiceSummary[];
|
|
76
|
+
/** Specific menu items this offer applies to (single offer only). */
|
|
77
|
+
service_items?: OfferServiceSummary[];
|
|
78
|
+
/** Packages this offer applies to (single offer only). */
|
|
79
|
+
packages?: OfferPackageSummary[];
|
|
80
|
+
}
|
|
81
|
+
/** List offers for the current account (API key scoped). */
|
|
82
|
+
declare function getOffers(): Promise<OfferPublic[] | null>;
|
|
83
|
+
/** Single offer with services for detail page. */
|
|
84
|
+
declare function getOffer(id: number | string): Promise<OfferPublic | null>;
|
|
40
85
|
|
|
41
|
-
export { getAdsConfig, getBlogPost, getBlogPosts, getCompanyInformation, getFAQs, getForm, getJobPosting, getJobPostings, getLocation, getLocations, getMetaPixelId, getReviews, getService, getServices, getSocialPosts, getTeamMembers, getTestimonials, getWebsitePhotos, serverApi };
|
|
86
|
+
export { type OfferPackageSummary, type OfferPublic, type OfferServiceSummary, getAdsConfig, getBlogPost, getBlogPosts, getCompanyInformation, getFAQs, getForm, getJobPosting, getJobPostings, getLocation, getLocations, getMetaPixelId, getOffer, getOffers, getPackage, getPackages, getReviews, getService, getServices, getSocialPosts, getTeamMembers, getTestimonials, getWebsitePhotos, serverApi };
|
package/dist/lib/server-api.js
CHANGED
|
@@ -84,12 +84,25 @@ async function getJobPosting(slug) {
|
|
|
84
84
|
async function getSocialPosts() {
|
|
85
85
|
return serverFetch("/public/social_posts", defaultOptions);
|
|
86
86
|
}
|
|
87
|
+
async function getPackages() {
|
|
88
|
+
return serverFetch("/public/packages", defaultOptions);
|
|
89
|
+
}
|
|
90
|
+
async function getPackage(slug) {
|
|
91
|
+
return serverFetch(`/public/packages/by_slug/${encodeURIComponent(slug)}`, defaultOptions);
|
|
92
|
+
}
|
|
87
93
|
async function getTestimonials() {
|
|
88
94
|
return getReviews();
|
|
89
95
|
}
|
|
90
96
|
async function getForm(formType) {
|
|
91
97
|
return serverFetch(`/public/forms/${encodeURIComponent(formType)}`, defaultOptions);
|
|
92
98
|
}
|
|
99
|
+
async function getOffers() {
|
|
100
|
+
const data = await serverFetch("/public/offers", defaultOptions);
|
|
101
|
+
return Array.isArray(data) ? data : null;
|
|
102
|
+
}
|
|
103
|
+
async function getOffer(id) {
|
|
104
|
+
return serverFetch(`/public/offers/${id}`, defaultOptions);
|
|
105
|
+
}
|
|
93
106
|
export {
|
|
94
107
|
getAdsConfig,
|
|
95
108
|
getBlogPost,
|
|
@@ -102,6 +115,10 @@ export {
|
|
|
102
115
|
getLocation,
|
|
103
116
|
getLocations,
|
|
104
117
|
getMetaPixelId,
|
|
118
|
+
getOffer,
|
|
119
|
+
getOffers,
|
|
120
|
+
getPackage,
|
|
121
|
+
getPackages,
|
|
105
122
|
getReviews,
|
|
106
123
|
getService,
|
|
107
124
|
getServices,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/server-api.ts"],"sourcesContent":["/**\n * Server-side API client for SSR\n * API key is stored securely on the server - never exposed to browser\n */\n\nimport type { CompanyInformation } from '../types/api/company-information';\nimport type { Service } from '../types/api/service';\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nconst API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';\nconst API_KEY = process.env.API_KEY || '';\n\ninterface FetchOptions {\n cache?: RequestCache;\n revalidate?: number;\n}\n\nasync function serverFetch<T>(endpoint: string, options: FetchOptions = {}): Promise<T | null> {\n const url = `${API_URL}${endpoint}`;\n \n try {\n const fetchOptions: RequestInit & { next?: { revalidate?: number } } = {\n headers: {\n 'X-API-Key': API_KEY,\n 'Content-Type': 'application/json',\n },\n cache: options.cache,\n };\n \n if (options.revalidate) {\n fetchOptions.next = { revalidate: options.revalidate };\n }\n \n const response = await fetch(url, fetchOptions);\n\n if (!response.ok) {\n console.error(`[Server API] Error ${response.status} for ${endpoint}`);\n return null;\n }\n\n const json = await response.json();\n \n // Rails API returns { data: [...], meta: {...} }\n return json.data ?? json;\n } catch (error) {\n console.error(`[Server API] Failed to fetch ${endpoint}:`, error);\n return null;\n }\n}\n\n/**\n * Generic serverApi object for flexible endpoint access\n */\nexport const serverApi = {\n get: <T = unknown>(endpoint: string, options?: FetchOptions): Promise<T | null> => {\n return serverFetch<T>(endpoint, options || { revalidate: 60 });\n }\n};\n\n// Revalidate data every 60 seconds (ISR)\nconst defaultOptions: FetchOptions = { revalidate: 60 };\n\nexport async function getCompanyInformation(): Promise<CompanyInformation | null> {\n return serverFetch<CompanyInformation>('/public/company_information', defaultOptions);\n}\n\n/** Ads config (e.g. Meta Pixel). Returns { meta_pixel_id?: string } or {}. Only present when account has connected Meta Ads. */\nexport async function getAdsConfig(): Promise<{ meta_pixel_id?: string } | null> {\n const data = await serverFetch<{ meta_pixel_id?: string }>('/public/ads_config', defaultOptions);\n return data ?? null;\n}\n\n/** Extract Meta Pixel ID from ads config for use with <MetaPixel pixelId={...} />. Only returns a value when present and valid (numeric). */\nexport function getMetaPixelId(adsConfig: { meta_pixel_id?: string } | null | undefined): string | null {\n const id = adsConfig && typeof adsConfig === 'object' && 'meta_pixel_id' in adsConfig && adsConfig.meta_pixel_id;\n const str = id != null && id !== '' ? String(id).trim() : '';\n return str !== '' && str !== 'null' && /^\\d+$/.test(str) ? str : null;\n}\n\nexport async function getServices() {\n return serverFetch('/public/services', defaultOptions);\n}\n\nexport async function getService(slug: string): Promise<Service | null> {\n return serverFetch<Service>(`/public/services/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getLocations() {\n return serverFetch('/public/locations', defaultOptions);\n}\n\nexport async function getLocation(slug: string) {\n return serverFetch(`/public/locations/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getReviews() {\n return serverFetch('/public/reviews', defaultOptions);\n}\n\nexport async function getFAQs() {\n return serverFetch('/public/faq_questions', defaultOptions);\n}\n\nexport async function getBlogPosts() {\n return serverFetch('/public/blog_posts', defaultOptions);\n}\n\nexport async function getBlogPost(slug: string) {\n return serverFetch(`/public/blog_posts/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getTeamMembers() {\n return serverFetch('/public/team_members', defaultOptions);\n}\n\nexport async function getWebsitePhotos(): Promise<WebsitePhotos | null> {\n return serverFetch<WebsitePhotos>('/public/website_photos', defaultOptions);\n}\n\nexport async function getJobPostings() {\n return serverFetch('/public/job_postings', defaultOptions);\n}\n\nexport async function getJobPosting(slug: string) {\n return serverFetch(`/public/job_postings/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getSocialPosts() {\n return serverFetch('/public/social_posts', defaultOptions);\n}\n\n// Alias for testimonials (API uses \"reviews\")\nexport async function getTestimonials() {\n return getReviews();\n}\n\n/** Form definition for dynamic form rendering (fields may include optional placeholder). */\nexport async function getForm(formType: string) {\n type FormDefinition = import('../types/api/form').FormDefinition;\n return serverFetch<FormDefinition>(`/public/forms/${encodeURIComponent(formType)}`, defaultOptions);\n}\n\n"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../src/lib/server-api.ts"],"sourcesContent":["/**\n * Server-side API client for SSR\n * API key is stored securely on the server - never exposed to browser\n */\n\nimport type { CompanyInformation } from '../types/api/company-information';\nimport type { PhotoAttachment } from '../types/api/photos';\nimport type { Service } from '../types/api/service';\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nconst API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';\nconst API_KEY = process.env.API_KEY || '';\n\ninterface FetchOptions {\n cache?: RequestCache;\n revalidate?: number;\n}\n\nasync function serverFetch<T>(endpoint: string, options: FetchOptions = {}): Promise<T | null> {\n const url = `${API_URL}${endpoint}`;\n \n try {\n const fetchOptions: RequestInit & { next?: { revalidate?: number } } = {\n headers: {\n 'X-API-Key': API_KEY,\n 'Content-Type': 'application/json',\n },\n cache: options.cache,\n };\n \n if (options.revalidate) {\n fetchOptions.next = { revalidate: options.revalidate };\n }\n \n const response = await fetch(url, fetchOptions);\n\n if (!response.ok) {\n console.error(`[Server API] Error ${response.status} for ${endpoint}`);\n return null;\n }\n\n const json = await response.json();\n \n // Rails API returns { data: [...], meta: {...} }\n return json.data ?? json;\n } catch (error) {\n console.error(`[Server API] Failed to fetch ${endpoint}:`, error);\n return null;\n }\n}\n\n/**\n * Generic serverApi object for flexible endpoint access\n */\nexport const serverApi = {\n get: <T = unknown>(endpoint: string, options?: FetchOptions): Promise<T | null> => {\n return serverFetch<T>(endpoint, options || { revalidate: 60 });\n }\n};\n\n// Revalidate data every 60 seconds (ISR)\nconst defaultOptions: FetchOptions = { revalidate: 60 };\n\nexport async function getCompanyInformation(): Promise<CompanyInformation | null> {\n return serverFetch<CompanyInformation>('/public/company_information', defaultOptions);\n}\n\n/** Ads config (e.g. Meta Pixel). Returns { meta_pixel_id?: string } or {}. Only present when account has connected Meta Ads. */\nexport async function getAdsConfig(): Promise<{ meta_pixel_id?: string } | null> {\n const data = await serverFetch<{ meta_pixel_id?: string }>('/public/ads_config', defaultOptions);\n return data ?? null;\n}\n\n/** Extract Meta Pixel ID from ads config for use with <MetaPixel pixelId={...} />. Only returns a value when present and valid (numeric). */\nexport function getMetaPixelId(adsConfig: { meta_pixel_id?: string } | null | undefined): string | null {\n const id = adsConfig && typeof adsConfig === 'object' && 'meta_pixel_id' in adsConfig && adsConfig.meta_pixel_id;\n const str = id != null && id !== '' ? String(id).trim() : '';\n return str !== '' && str !== 'null' && /^\\d+$/.test(str) ? str : null;\n}\n\nexport async function getServices() {\n return serverFetch('/public/services', defaultOptions);\n}\n\nexport async function getService(slug: string): Promise<Service | null> {\n return serverFetch<Service>(`/public/services/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getLocations() {\n return serverFetch('/public/locations', defaultOptions);\n}\n\nexport async function getLocation(slug: string) {\n return serverFetch(`/public/locations/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getReviews() {\n return serverFetch('/public/reviews', defaultOptions);\n}\n\nexport async function getFAQs() {\n return serverFetch('/public/faq_questions', defaultOptions);\n}\n\nexport async function getBlogPosts() {\n return serverFetch('/public/blog_posts', defaultOptions);\n}\n\nexport async function getBlogPost(slug: string) {\n return serverFetch(`/public/blog_posts/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getTeamMembers() {\n return serverFetch('/public/team_members', defaultOptions);\n}\n\nexport async function getWebsitePhotos(): Promise<WebsitePhotos | null> {\n return serverFetch<WebsitePhotos>('/public/website_photos', defaultOptions);\n}\n\nexport async function getJobPostings() {\n return serverFetch('/public/job_postings', defaultOptions);\n}\n\nexport async function getJobPosting(slug: string) {\n return serverFetch(`/public/job_postings/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getSocialPosts() {\n return serverFetch('/public/social_posts', defaultOptions);\n}\n\n/** Packages (bundles of service items). */\nexport async function getPackages() {\n return serverFetch('/public/packages', defaultOptions);\n}\n\nexport async function getPackage(slug: string) {\n return serverFetch(`/public/packages/by_slug/${encodeURIComponent(slug)}`, defaultOptions);\n}\n\n// Alias for testimonials (API uses \"reviews\")\nexport async function getTestimonials() {\n return getReviews();\n}\n\n/** Form definition for dynamic form rendering (fields may include optional placeholder). */\nexport async function getForm(formType: string) {\n type FormDefinition = import('../types/api/form').FormDefinition;\n return serverFetch<FormDefinition>(`/public/forms/${encodeURIComponent(formType)}`, defaultOptions);\n}\n\n/** Minimal service for offer detail (from GET /public/offers/:id). */\nexport interface OfferServiceSummary {\n id: number;\n name: string;\n slug: string;\n summary?: string | null;\n}\n\n/** Package summary (nested on each offer in list and single-offer responses). */\nexport interface OfferPackageSummary {\n id: number;\n name: string;\n slug: string;\n summary?: string | null;\n description_markdown?: string | null;\n}\n\n/** Offer from public API. */\nexport interface OfferPublic {\n id: number;\n name: string;\n description: string | null;\n value_terms: string | null;\n /** Optional; when absent or null, offer has no expiration. */\n expires_at?: string | null;\n expired?: boolean;\n /** Service IDs (used for category_names fallback when not provided by API). */\n service_ids?: number[];\n /** Package IDs (included in list response; nested services/packages are the source of truth). */\n package_ids?: number[];\n /** Category names from API (services + service_items + packages). */\n category_names?: string[];\n /** Photo attachments from related services (same shape as Service.photo_attachments). */\n photo_attachments?: PhotoAttachment[];\n /** Present when fetching single offer (GET /public/offers/:id). */\n services?: OfferServiceSummary[];\n /** Specific menu items this offer applies to (single offer only). */\n service_items?: OfferServiceSummary[];\n /** Packages this offer applies to (single offer only). */\n packages?: OfferPackageSummary[];\n}\n\n/** List offers for the current account (API key scoped). */\nexport async function getOffers(): Promise<OfferPublic[] | null> {\n const data = await serverFetch<OfferPublic[]>('/public/offers', defaultOptions);\n return Array.isArray(data) ? data : null;\n}\n\n/** Single offer with services for detail page. */\nexport async function getOffer(id: number | string): Promise<OfferPublic | null> {\n return serverFetch<OfferPublic>(`/public/offers/${id}`, defaultOptions);\n}\n\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
interface WebsitePhoto {
|
|
2
|
+
id: number;
|
|
3
|
+
url: string;
|
|
4
|
+
thumbnail_url?: string;
|
|
5
|
+
medium_url?: string;
|
|
6
|
+
alt: string;
|
|
7
|
+
source: 'account' | 'industry';
|
|
8
|
+
}
|
|
9
|
+
interface WebsitePhotos {
|
|
10
|
+
logo?: WebsitePhoto | null;
|
|
11
|
+
favicon?: WebsitePhoto | null;
|
|
12
|
+
hero?: WebsitePhoto | null;
|
|
13
|
+
contact?: WebsitePhoto | null;
|
|
14
|
+
about?: WebsitePhoto | null;
|
|
15
|
+
careers?: WebsitePhoto | null;
|
|
16
|
+
preview_image?: WebsitePhoto | null;
|
|
17
|
+
stock_photos?: WebsitePhoto[];
|
|
18
|
+
}
|
|
19
|
+
type WebsitePhotosResponse = WebsitePhotos;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Photo and PhotoAttachment type definitions
|
|
23
|
+
*/
|
|
24
|
+
interface Photo {
|
|
25
|
+
id?: number;
|
|
26
|
+
title?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
alt_text?: string;
|
|
29
|
+
thumbnail_url?: string;
|
|
30
|
+
medium_url?: string;
|
|
31
|
+
large_url?: string;
|
|
32
|
+
original_url?: string;
|
|
33
|
+
generated?: boolean;
|
|
34
|
+
created_at?: string;
|
|
35
|
+
updated_at?: string;
|
|
36
|
+
}
|
|
37
|
+
interface PhotoAttachment {
|
|
38
|
+
id: number;
|
|
39
|
+
/** May be omitted in some API responses. */
|
|
40
|
+
photo?: Photo;
|
|
41
|
+
featured?: boolean;
|
|
42
|
+
sort_order?: number;
|
|
43
|
+
created_at?: string;
|
|
44
|
+
updated_at?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type { PhotoAttachment as P, WebsitePhotos as W, Photo as a, WebsitePhoto as b, WebsitePhotosResponse as c };
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { Theme } from '../themes/index.js';
|
|
2
|
-
export { B as BlogPost,
|
|
3
|
-
export { C as CompanyInformation, a as CompanyInformationResponse } from '../
|
|
4
|
-
import {
|
|
5
|
-
export {
|
|
6
|
-
export { a as WebsitePhoto, W as WebsitePhotos, b as WebsitePhotosResponse } from '../website-photos-_n2g24IM.js';
|
|
2
|
+
export { B as BlogPost, c as BlogPostAuthor, a as BlogPostParams, b as BlogPostResponse, d as BlogPostTag } from '../blog-post-D7HFCDp1.js';
|
|
3
|
+
export { C as CompanyInformation, a as CompanyInformationResponse, F as FormDefinition, b as FormFieldDefinition, c as FormFieldItem, d as FormType, S as Service, e as ServiceItem, f as ServiceParams, g as ServiceResponse } from '../form-BLZuTGkr.js';
|
|
4
|
+
import { P as PhotoAttachment } from '../photos-8jMeetqV.js';
|
|
5
|
+
export { a as Photo, b as WebsitePhoto, W as WebsitePhotos, c as WebsitePhotosResponse } from '../photos-8jMeetqV.js';
|
|
7
6
|
|
|
8
7
|
interface NavItem {
|
|
9
8
|
label: string;
|
|
@@ -115,6 +114,8 @@ interface SocialPost {
|
|
|
115
114
|
updated_at: string;
|
|
116
115
|
/** Image URLs from photo_attachments (preferred for display) */
|
|
117
116
|
image_urls?: string[];
|
|
117
|
+
/** Video URLs from photo_attachments (exclude these when choosing img src) */
|
|
118
|
+
video_urls?: string[];
|
|
118
119
|
/** Photo attachments (same pattern as blog post, team member, etc.) */
|
|
119
120
|
photo_attachments?: PhotoAttachment[];
|
|
120
121
|
/** Legacy; prefer image_urls / photo_attachments */
|
|
@@ -1,22 +1,13 @@
|
|
|
1
|
-
import { W as WebsitePhotos } from '../
|
|
1
|
+
import { P as PhotoAttachment, W as WebsitePhotos } from '../photos-8jMeetqV.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Helper functions for extracting photo URLs from photo associations
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
title: string;
|
|
12
|
-
thumbnail_url?: string;
|
|
13
|
-
medium_url?: string;
|
|
14
|
-
large_url?: string;
|
|
15
|
-
original_url?: string;
|
|
16
|
-
};
|
|
17
|
-
featured: boolean;
|
|
18
|
-
sort_order: number;
|
|
19
|
-
}
|
|
7
|
+
/**
|
|
8
|
+
* True if the URL looks like a video by path extension. Used to avoid using video URLs in img src.
|
|
9
|
+
*/
|
|
10
|
+
declare function isVideoUrl(url: string | null | undefined): boolean;
|
|
20
11
|
/**
|
|
21
12
|
* Get the best available photo URL from a photos array
|
|
22
13
|
* Priority: featured photo > first photo > fallback
|
|
@@ -43,4 +34,4 @@ declare function getFeaturedImageUrl(photos?: PhotoAttachment[]): string | null;
|
|
|
43
34
|
*/
|
|
44
35
|
declare function getLogoUrl(websitePhotos?: WebsitePhotos | null): string | undefined;
|
|
45
36
|
|
|
46
|
-
export {
|
|
37
|
+
export { PhotoAttachment, getAvatarUrl, getFeaturedImageUrl, getLogoUrl, getPhotoUrl, isVideoUrl };
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
// src/utils/photo-helpers.ts
|
|
2
|
+
var VIDEO_EXTENSIONS = [".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv", ".flv", ".wmv"];
|
|
3
|
+
function isVideoUrl(url) {
|
|
4
|
+
var _a;
|
|
5
|
+
if (!url || typeof url !== "string") return false;
|
|
6
|
+
const path = (_a = url.split("?")[0]) != null ? _a : "";
|
|
7
|
+
const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
|
|
8
|
+
return VIDEO_EXTENSIONS.includes(ext);
|
|
9
|
+
}
|
|
2
10
|
function getPhotoUrl(photos) {
|
|
3
11
|
if (photos && photos.length > 0) {
|
|
4
12
|
const featuredPhoto = photos.find((pa) => pa.featured);
|
|
@@ -27,6 +35,7 @@ export {
|
|
|
27
35
|
getAvatarUrl,
|
|
28
36
|
getFeaturedImageUrl,
|
|
29
37
|
getLogoUrl,
|
|
30
|
-
getPhotoUrl
|
|
38
|
+
getPhotoUrl,
|
|
39
|
+
isVideoUrl
|
|
31
40
|
};
|
|
32
41
|
//# sourceMappingURL=photo-helpers.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/photo-helpers.ts"],"sourcesContent":["/**\n * Helper functions for extracting photo URLs from photo associations\n */\n\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nexport
|
|
1
|
+
{"version":3,"sources":["../../src/utils/photo-helpers.ts"],"sourcesContent":["/**\n * Helper functions for extracting photo URLs from photo associations\n */\n\nimport type { PhotoAttachment } from '../types/api/photos';\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nexport type { PhotoAttachment } from '../types/api/photos';\n\n/** Video file extensions treated as video (match backend ExternalPhotoService.video_url?) */\nconst VIDEO_EXTENSIONS = ['.mp4', '.mov', '.webm', '.m4v', '.avi', '.mkv', '.flv', '.wmv'];\n\n/**\n * True if the URL looks like a video by path extension. Used to avoid using video URLs in img src.\n */\nexport function isVideoUrl(url: string | null | undefined): boolean {\n if (!url || typeof url !== 'string') return false;\n const path = url.split('?')[0] ?? '';\n const ext = path.slice(path.lastIndexOf('.')).toLowerCase();\n return VIDEO_EXTENSIONS.includes(ext);\n}\n\n/**\n * Get the best available photo URL from a photos array\n * Priority: featured photo > first photo > fallback\n */\nexport function getPhotoUrl(\n photos?: PhotoAttachment[]\n): string | null {\n // Priority 1: Featured photo from photos array\n if (photos && photos.length > 0) {\n const featuredPhoto = photos.find(pa => pa.featured);\n const photoToUse = featuredPhoto || photos[0];\n const photo = photoToUse.photo;\n \n if (photo) {\n return photo.large_url || photo.medium_url || photo.thumbnail_url || photo.original_url || null;\n }\n }\n \n // Fallback (gradient or null)\n return null;\n}\n\n/**\n * Get avatar URL for team members or authors.\n * Returns a URL only when there is a real photo in attachments; otherwise null.\n * Callers should use PhotoWithFallback (gradient fallback) or Avatar with initials when null.\n */\n/** Optional fallbackId/name reserved for future use (e.g. deterministic fallback). */\n/* eslint-disable @typescript-eslint/no-unused-vars -- _fallbackId, _name reserved for API */\nexport function getAvatarUrl(\n photos?: PhotoAttachment[],\n _fallbackId?: number | string,\n _name?: string\n): string | null {\n return getPhotoUrl(photos);\n}\n/* eslint-enable @typescript-eslint/no-unused-vars */\n\n/**\n * Get featured image URL for blog posts\n */\nexport function getFeaturedImageUrl(\n photos?: PhotoAttachment[]\n): string | null {\n return getPhotoUrl(photos);\n}\n\n/**\n * Get logo URL from website_photos API (which aggregates from account_photos)\n * \n * The website_photos API endpoint returns logos from account_photos with photo_type: 'logo',\n * with industry fallback. This is the primary and only source for logos.\n * \n * Returns undefined if no logo is available (for PhotoWithFallback gradient fallback)\n */\nexport function getLogoUrl(\n websitePhotos?: WebsitePhotos | null\n): string | undefined {\n if (websitePhotos?.logo?.url) {\n return websitePhotos.logo.url;\n }\n \n return undefined;\n}\n\n"],"mappings":";AAUA,IAAM,mBAAmB,CAAC,QAAQ,QAAQ,SAAS,QAAQ,QAAQ,QAAQ,QAAQ,MAAM;AAKlF,SAAS,WAAW,KAAyC;AAfpE;AAgBE,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,QAAO,SAAI,MAAM,GAAG,EAAE,CAAC,MAAhB,YAAqB;AAClC,QAAM,MAAM,KAAK,MAAM,KAAK,YAAY,GAAG,CAAC,EAAE,YAAY;AAC1D,SAAO,iBAAiB,SAAS,GAAG;AACtC;AAMO,SAAS,YACd,QACe;AAEf,MAAI,UAAU,OAAO,SAAS,GAAG;AAC/B,UAAM,gBAAgB,OAAO,KAAK,QAAM,GAAG,QAAQ;AACnD,UAAM,aAAa,iBAAiB,OAAO,CAAC;AAC5C,UAAM,QAAQ,WAAW;AAEzB,QAAI,OAAO;AACT,aAAO,MAAM,aAAa,MAAM,cAAc,MAAM,iBAAiB,MAAM,gBAAgB;AAAA,IAC7F;AAAA,EACF;AAGA,SAAO;AACT;AASO,SAAS,aACd,QACA,aACA,OACe;AACf,SAAO,YAAY,MAAM;AAC3B;AAMO,SAAS,oBACd,QACe;AACf,SAAO,YAAY,MAAM;AAC3B;AAUO,SAAS,WACd,eACoB;AA/EtB;AAgFE,OAAI,oDAAe,SAAf,mBAAqB,KAAK;AAC5B,WAAO,cAAc,KAAK;AAAA,EAC5B;AAEA,SAAO;AACT;","names":[]}
|
package/package.json
CHANGED
|
@@ -132,3 +132,7 @@ export { ComboBox } from './select/combobox';
|
|
|
132
132
|
// Video components (no theming needed)
|
|
133
133
|
export { VideoModal } from './video-modal';
|
|
134
134
|
export { VideoPlayButton } from './video-play-button';
|
|
135
|
+
|
|
136
|
+
// Shared modal for detail views, confirmations, etc.
|
|
137
|
+
export { Modal } from './modal/modal';
|
|
138
|
+
export type { ModalProps } from './modal/modal';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface ModalProps {
|
|
6
|
+
/** Whether the modal is open */
|
|
7
|
+
isOpen: boolean;
|
|
8
|
+
/** Called when the user requests close (button, Escape, or overlay click) */
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
/** Optional title for the dialog (used for aria-labelledby) */
|
|
11
|
+
title?: string;
|
|
12
|
+
/** Optional id for the title element (must be set if title is set for a11y) */
|
|
13
|
+
titleId?: string;
|
|
14
|
+
/** Dialog content */
|
|
15
|
+
children: React.ReactNode;
|
|
16
|
+
/** Optional className for the overlay (backdrop) */
|
|
17
|
+
overlayClassName?: string;
|
|
18
|
+
/** Optional className for the dialog panel */
|
|
19
|
+
panelClassName?: string;
|
|
20
|
+
/** Optional max width class for the panel (default: max-w-md). Use max-w-lg, max-w-2xl, etc. */
|
|
21
|
+
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_WIDTH_CLASSES: Record<NonNullable<ModalProps['maxWidth']>, string> = {
|
|
25
|
+
sm: 'max-w-sm',
|
|
26
|
+
md: 'max-w-md',
|
|
27
|
+
lg: 'max-w-lg',
|
|
28
|
+
xl: 'max-w-xl',
|
|
29
|
+
'2xl': 'max-w-2xl',
|
|
30
|
+
'3xl': 'max-w-3xl',
|
|
31
|
+
'4xl': 'max-w-4xl',
|
|
32
|
+
'5xl': 'max-w-5xl',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Shared modal (dialog) component. Accessible: focus trap, Escape to close, aria-modal, overlay click to close.
|
|
37
|
+
* Use for detail views, confirmations, and other overlay content. Renders in place (use a portal from the consumer if needed).
|
|
38
|
+
*/
|
|
39
|
+
export function Modal({
|
|
40
|
+
isOpen,
|
|
41
|
+
onClose,
|
|
42
|
+
title,
|
|
43
|
+
titleId = 'modal-title',
|
|
44
|
+
children,
|
|
45
|
+
overlayClassName,
|
|
46
|
+
panelClassName,
|
|
47
|
+
maxWidth = 'md',
|
|
48
|
+
}: ModalProps) {
|
|
49
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
50
|
+
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!isOpen) return;
|
|
54
|
+
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
55
|
+
closeButtonRef.current?.focus();
|
|
56
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
57
|
+
if (e.key === 'Escape') onClose();
|
|
58
|
+
};
|
|
59
|
+
document.addEventListener('keydown', handleEscape);
|
|
60
|
+
document.body.style.overflow = 'hidden';
|
|
61
|
+
return () => {
|
|
62
|
+
document.removeEventListener('keydown', handleEscape);
|
|
63
|
+
document.body.style.overflow = '';
|
|
64
|
+
previouslyFocused?.focus();
|
|
65
|
+
};
|
|
66
|
+
}, [isOpen, onClose]);
|
|
67
|
+
|
|
68
|
+
if (!isOpen) return null;
|
|
69
|
+
|
|
70
|
+
const maxWidthClass = MAX_WIDTH_CLASSES[maxWidth];
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
ref={overlayRef}
|
|
75
|
+
className={
|
|
76
|
+
overlayClassName ??
|
|
77
|
+
'fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/50'
|
|
78
|
+
}
|
|
79
|
+
role="dialog"
|
|
80
|
+
aria-modal="true"
|
|
81
|
+
aria-labelledby={title ? titleId : undefined}
|
|
82
|
+
onClick={(e) => e.target === overlayRef.current && onClose()}
|
|
83
|
+
>
|
|
84
|
+
<div
|
|
85
|
+
className={
|
|
86
|
+
panelClassName ??
|
|
87
|
+
`bg-primary border border-secondary rounded-lg shadow-xl w-full overflow-hidden ${maxWidthClass} max-h-[90vh] flex flex-col`
|
|
88
|
+
}
|
|
89
|
+
onClick={(e) => e.stopPropagation()}
|
|
90
|
+
>
|
|
91
|
+
<div className="flex items-start justify-between gap-4 p-4 md:p-6 border-b border-secondary flex-shrink-0">
|
|
92
|
+
{title ? (
|
|
93
|
+
<h2
|
|
94
|
+
id={titleId}
|
|
95
|
+
className="font-display text-lg font-normal text-fg-primary md:text-xl flex-1 min-w-0"
|
|
96
|
+
>
|
|
97
|
+
{title}
|
|
98
|
+
</h2>
|
|
99
|
+
) : (
|
|
100
|
+
<span className="flex-1" aria-hidden />
|
|
101
|
+
)}
|
|
102
|
+
<button
|
|
103
|
+
ref={closeButtonRef}
|
|
104
|
+
type="button"
|
|
105
|
+
onClick={onClose}
|
|
106
|
+
className="shrink-0 p-1 text-fg-primary hover:text-brand-accent rounded focus:outline-none focus:ring-2 focus:ring-brand-accent"
|
|
107
|
+
aria-label="Close"
|
|
108
|
+
>
|
|
109
|
+
<svg
|
|
110
|
+
className="w-5 h-5"
|
|
111
|
+
fill="none"
|
|
112
|
+
stroke="currentColor"
|
|
113
|
+
viewBox="0 0 24 24"
|
|
114
|
+
aria-hidden
|
|
115
|
+
>
|
|
116
|
+
<path
|
|
117
|
+
strokeLinecap="round"
|
|
118
|
+
strokeLinejoin="round"
|
|
119
|
+
strokeWidth={2}
|
|
120
|
+
d="M6 18L18 6M6 6l12 12"
|
|
121
|
+
/>
|
|
122
|
+
</svg>
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="overflow-y-auto flex-1 p-4 md:p-6">{children}</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -46,7 +46,9 @@ import { HomeHeroComponent as BaseHomeHeroComponent } from './home-hero-componen
|
|
|
46
46
|
import { JobDetailHero as BaseJobDetailHero } from './hero-job-detail';
|
|
47
47
|
import { JobDetailSection as BaseJobDetailSection } from './job-detail-section';
|
|
48
48
|
import { PolicyDocumentSection as BasePolicyDocumentSection } from './policy-document-section';
|
|
49
|
-
|
|
49
|
+
import { OffersSection as BaseOffersSection } from './offers-section';
|
|
50
|
+
import { OffersGallery as BaseOffersGallery } from './offers-gallery';
|
|
51
|
+
import { OfferDetailSection as BaseOfferDetailSection } from './offer-detail';
|
|
50
52
|
// Import variant files to trigger their registration
|
|
51
53
|
// Aman theme variants
|
|
52
54
|
import './hero-home.aman';
|
|
@@ -92,7 +94,7 @@ import './social-media-grid.barelux';
|
|
|
92
94
|
import './contact-section.barelux';
|
|
93
95
|
import './footer-home.barelux';
|
|
94
96
|
|
|
95
|
-
// Balance theme variants
|
|
97
|
+
// Balance theme variants (offers-section uses base for balance)
|
|
96
98
|
import './hero-home.balance';
|
|
97
99
|
import './header-navigation.balance';
|
|
98
100
|
import './footer-home.balance';
|
|
@@ -179,5 +181,21 @@ export const HomeHeroComponent = createThemedExport('home-hero-component', BaseH
|
|
|
179
181
|
// Re-export application form (client component, no theme variants)
|
|
180
182
|
export { JobApplicationForm } from './job-application-form';
|
|
181
183
|
|
|
184
|
+
// Offers section (themed; embeddable on multiple pages)
|
|
185
|
+
export const OffersSection = createThemedExport('offers-section', BaseOffersSection as unknown as React.ComponentType<Record<string, unknown>>);
|
|
186
|
+
|
|
187
|
+
// Full offers page gallery (themed; use on /offers page, Aman = blog-style)
|
|
188
|
+
export const OffersGallery = createThemedExport('offers-gallery', BaseOffersGallery as unknown as React.ComponentType<Record<string, unknown>>);
|
|
189
|
+
|
|
190
|
+
// Single offer detail (themed; use on /offers/[id] page)
|
|
191
|
+
export const OfferDetailSection = createThemedExport('offer-detail', BaseOfferDetailSection as unknown as React.ComponentType<Record<string, unknown>>);
|
|
192
|
+
|
|
193
|
+
// Full offers page grid (no theme variant; fallback when gallery not used)
|
|
194
|
+
export { OffersGrid } from './offers-grid';
|
|
195
|
+
|
|
196
|
+
// Service Menu: up to 3 carousel rows (Offers, Packages, Services) with smaller cards
|
|
197
|
+
export { ServiceMenuSection } from './service-menu-section';
|
|
198
|
+
export type { ServiceMenuSectionProps, PackagePublic } from './service-menu-section';
|
|
199
|
+
|
|
182
200
|
// Re-export types
|
|
183
201
|
export type { Theme } from '../../themes';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { registerThemeVariant } from '../../lib/component-registry';
|
|
5
|
+
import { Button } from '../elements';
|
|
6
|
+
import type { OfferPublic } from '../../lib/server-api';
|
|
7
|
+
|
|
8
|
+
interface OfferDetailSectionProps {
|
|
9
|
+
offer?: OfferPublic | null;
|
|
10
|
+
websitePhotos?: unknown;
|
|
11
|
+
companyInformation?: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Default/fallback offer detail when no theme variant overrides. */
|
|
15
|
+
export const OfferDetailSection = ({ offer }: OfferDetailSectionProps) => {
|
|
16
|
+
if (!offer) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="text-center py-12">
|
|
19
|
+
<div className="text-6xl mb-4">🎁</div>
|
|
20
|
+
<h3 className="text-xl font-semibold text-gray-900 mb-2">Offer Not Found</h3>
|
|
21
|
+
<p className="text-gray-600 mb-4">The offer you're looking for doesn't exist or has expired.</p>
|
|
22
|
+
<Button href="/offers">View All Offers</Button>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="mx-auto max-w-3xl px-4 py-12">
|
|
29
|
+
<h1 className="text-2xl font-semibold text-gray-900 mb-4">{offer.name}</h1>
|
|
30
|
+
{offer.value_terms && <p className="text-gray-600 mb-2">{offer.value_terms}</p>}
|
|
31
|
+
{offer.description && <p className="text-gray-600 mb-4">{offer.description}</p>}
|
|
32
|
+
{offer.expires_at && (() => {
|
|
33
|
+
const d = new Date(offer.expires_at);
|
|
34
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
35
|
+
return (
|
|
36
|
+
<p className="text-sm text-gray-500 mb-6">
|
|
37
|
+
Expires {d.toLocaleDateString()}
|
|
38
|
+
</p>
|
|
39
|
+
);
|
|
40
|
+
})()}
|
|
41
|
+
<Button href="/offers">Back to Offers</Button>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
registerThemeVariant('offer-detail', 'classic', OfferDetailSection);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { registerThemeVariant } from '../../lib/component-registry';
|
|
5
|
+
import { OffersGrid } from './offers-grid';
|
|
6
|
+
import type { OfferPublic } from '../../lib/server-api';
|
|
7
|
+
import type { WebsitePhotos } from '../../types/api/website-photos';
|
|
8
|
+
import type { CompanyInformation } from '../../types/api/company-information';
|
|
9
|
+
|
|
10
|
+
interface OffersGalleryProps {
|
|
11
|
+
offers?: OfferPublic[] | null;
|
|
12
|
+
title?: string;
|
|
13
|
+
subtitle?: string;
|
|
14
|
+
offersPerPage?: number;
|
|
15
|
+
className?: string;
|
|
16
|
+
websitePhotos?: WebsitePhotos | null;
|
|
17
|
+
companyInformation?: CompanyInformation | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Default/fallback: use grid layout when no theme variant overrides. */
|
|
21
|
+
export const OffersGallery = ({
|
|
22
|
+
offers,
|
|
23
|
+
title = 'Offers',
|
|
24
|
+
subtitle = 'See our current offers.',
|
|
25
|
+
websitePhotos,
|
|
26
|
+
companyInformation,
|
|
27
|
+
className = '',
|
|
28
|
+
}: OffersGalleryProps) => (
|
|
29
|
+
<section className={className}>
|
|
30
|
+
<OffersGrid
|
|
31
|
+
offers={offers}
|
|
32
|
+
title={title}
|
|
33
|
+
subtitle={subtitle}
|
|
34
|
+
websitePhotos={websitePhotos}
|
|
35
|
+
companyInformation={companyInformation}
|
|
36
|
+
/>
|
|
37
|
+
</section>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
registerThemeVariant('offers-gallery', 'classic', OffersGallery);
|