@lightspeed/crane 1.2.3 → 1.2.5

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.
@@ -0,0 +1,11 @@
1
+ <template>
2
+ <div>
3
+ <slot :name="Slot.CATEGORY_TITLE" />
4
+ <slot :name="Slot.PRODUCT_LIST" />
5
+ <slot :name="Slot.BOTTOM_BAR" />
6
+ </div>
7
+ </template>
8
+
9
+ <script setup lang="ts">
10
+ import { CategoryLayoutSlot as Slot } from '@lightspeed/crane';
11
+ </script>
@@ -0,0 +1,35 @@
1
+ <template>
2
+ <div :class="{ 'product-details--top-title-navigation': isProductNameAlwaysFirstOnMobile }">
3
+ <slot :name="Slot.TOP_BAR" />
4
+ <div
5
+ :class="availableProductClasses"
6
+ itemtype="http://schema.org/Product"
7
+ :itemscope="showProductDetailsProductPrice"
8
+ >
9
+ <slot :name="Slot.GALLERY" />
10
+ <slot :name="Slot.SIDEBAR" />
11
+ <slot :name="Slot.DESCRIPTION" />
12
+ <div class="clearboth" />
13
+ </div>
14
+ <slot :name="Slot.REVIEW_LIST" />
15
+ <slot :name="Slot.RELATED_PRODUCTS" />
16
+ <slot :name="Slot.BOTTOM_BAR" />
17
+ </div>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ import { HTMLAttributes } from 'vue';
22
+ import { ProductLayoutSlot as Slot } from '@lightspeed/crane';
23
+
24
+ interface Props {
25
+ availableProductClasses?: HTMLAttributes['class'],
26
+ showProductDetailsProductPrice?: boolean,
27
+ isProductNameAlwaysFirstOnMobile?: boolean,
28
+ }
29
+
30
+ withDefaults(defineProps<Props>(), {
31
+ isProductNameAlwaysFirstOnMobile: false,
32
+ availableProductClasses: '',
33
+ showProductDetailsProductPrice: true,
34
+ });
35
+ </script>
@@ -2,7 +2,8 @@ export default {
2
2
  sections: [
3
3
  {
4
4
  type: 'default',
5
- id: 'product-browser',
5
+ id: 'catalog',
6
+ layout_id: 'example-catalog',
6
7
  },
7
8
  ],
8
9
  };
@@ -2,7 +2,8 @@ export default {
2
2
  sections: [
3
3
  {
4
4
  type: 'default',
5
- id: 'product-browser',
5
+ id: 'category',
6
+ layout_id: 'example-category',
6
7
  },
7
8
  ],
8
9
  };
@@ -2,7 +2,8 @@ export default {
2
2
  sections: [
3
3
  {
4
4
  type: 'default',
5
- id: 'product-browser',
5
+ id: 'product',
6
+ layout_id: 'example-product',
6
7
  },
7
8
  ],
8
9
  };
@@ -0,0 +1,89 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Section Preview</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+
8
+ <!-- Vue 3 -->
9
+ <script src="https://cdn.jsdelivr.net/npm/vue@3.2.33/dist/vue.global.prod.js"></script>
10
+
11
+ <!-- Bootstrap and Fonts -->
12
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
13
+ <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
14
+
15
+ <style>
16
+ .top-bar {
17
+ position: fixed;
18
+ top: 0;
19
+ left: 0;
20
+ right: 0;
21
+ background-color: #fff;
22
+ border-bottom: 1px solid #dee2e6;
23
+ padding: 12px 20px;
24
+ z-index: 1000;
25
+ display: flex;
26
+ align-items: center;
27
+ gap: 12px;
28
+ }
29
+
30
+ .label-text {
31
+ font-size: 30px;
32
+ margin: 0;
33
+ }
34
+ </style>
35
+ </head>
36
+ <body>
37
+
38
+ <!-- Top Bar -->
39
+ <div class="top-bar">
40
+ <span class="label-text">Section Preview |</span>
41
+ <select id="showcaseDropdown" class="form-select w-auto"></select>
42
+ </div>
43
+
44
+ <!-- Main Content -->
45
+ <div id="app"></div>
46
+ <div id="blanket"></div>
47
+
48
+ <!-- SSR Mocking -->
49
+ <script type="module">
50
+ window.EcVue = {
51
+ ...window.Vue,
52
+ ssrUtils: {
53
+ ensureValidVNode: vnode => vnode,
54
+ },
55
+ ssrContextKey: Symbol('ssrContextKey'),
56
+ initDirectivesForSSR: () => {},
57
+ };
58
+ </script>
59
+
60
+ <!-- Main Script -->
61
+ <script type="module">
62
+ import { renderShowcase, dropdownOptions, setDistFolderPath } from "../shared/preview.js";
63
+
64
+ setDistFolderPath("../../../dist");
65
+
66
+ const showcaseModules = import.meta.glob('../../dist/sections/*/js/showcases/*.mjs');
67
+ const select = document.getElementById('showcaseDropdown');
68
+ const showCaseOptions = dropdownOptions(showcaseModules);
69
+
70
+ for (const { value, label } of showCaseOptions) {
71
+ const option = document.createElement('option');
72
+ option.value = value;
73
+ option.textContent = label;
74
+ select.appendChild(option);
75
+ }
76
+
77
+ select.addEventListener('change', async (e) => {
78
+ const [sectionName, showcaseId] = e.target.value.split(':');
79
+ renderShowcase(sectionName, showcaseId);
80
+ });
81
+
82
+ // Render initial showcase
83
+ if (select.value) {
84
+ const [sectionName, showcaseId] = select.value.split(':');
85
+ renderShowcase(sectionName, showcaseId);
86
+ }
87
+ </script>
88
+ </body>
89
+ </html>
@@ -0,0 +1,300 @@
1
+ /* eslint-disable no-console */
2
+ /* eslint-disable @typescript-eslint/no-explicit-any */
3
+ import { loadModule} from "./utils.ts";
4
+
5
+ let distFolderPath: string | null = null;
6
+
7
+ export function setDistFolderPath(path: string): void {
8
+ distFolderPath = path;
9
+ }
10
+
11
+ interface HSLColor {
12
+ h: number;
13
+ s: number;
14
+ l: number;
15
+ }
16
+
17
+ interface RGBAColor {
18
+ r: number;
19
+ g: number;
20
+ b: number;
21
+ a: number;
22
+ }
23
+
24
+ interface ColorObject {
25
+ hex: string;
26
+ hsl: HSLColor;
27
+ rgba: RGBAColor;
28
+ }
29
+
30
+ function hexToColorObject(hex: string): ColorObject {
31
+ const match = /^#?([0-9a-fA-F]{6})$/.exec(hex);
32
+ if (!match) throw new Error("Invalid hex color format");
33
+ const cleanHex = match[1].toLowerCase();
34
+ const r = parseInt(cleanHex.substring(0, 2), 16);
35
+ const g = parseInt(cleanHex.substring(2, 4), 16);
36
+ const b = parseInt(cleanHex.substring(4, 6), 16);
37
+ const a = 255;
38
+ const rNorm = r / 255, gNorm = g / 255, bNorm = b / 255;
39
+ const max = Math.max(rNorm, gNorm, bNorm), min = Math.min(rNorm, gNorm, bNorm);
40
+ const delta = max - min;
41
+ let h = 0, s = 0, l = (max + min) / 2;
42
+ if (delta !== 0) {
43
+ s = delta / (1 - Math.abs(2 * l - 1));
44
+ switch (max) {
45
+ case rNorm: h = ((gNorm - bNorm) / delta) % 6; break;
46
+ case gNorm: h = (bNorm - rNorm) / delta + 2; break;
47
+ case bNorm: h = (rNorm - gNorm) / delta + 4; break;
48
+ }
49
+ h *= 60;
50
+ if (h < 0) h += 360;
51
+ }
52
+ return {
53
+ hex: `#${cleanHex}${a.toString(16).padStart(2, '0')}`,
54
+ hsl: { h: Math.round(h), s: +(s * 100).toFixed(1), l: +(l * 100).toFixed(1) },
55
+ rgba: { r, g, b, a: 1 },
56
+ };
57
+ }
58
+
59
+ function updateHexColors(obj: any): any {
60
+ // Matches either 3-digit (#RGB) or 6-digit (#RRGGBB) hex
61
+ const hexRegex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
62
+
63
+ function recurse(value: any): any {
64
+ if (Array.isArray(value)) return value.map(recurse);
65
+ if (value && typeof value === 'object') {
66
+ for (const key in value) {
67
+ const v = value[key];
68
+ if (key === 'color' && typeof v === 'string' && hexRegex.test(v)) {
69
+ // If it’s a 3-digit hex, expand it to 6 digits
70
+ const expanded = v.replace(
71
+ /^#?([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/,
72
+ (_m, r, g, b) => `#${r}${r}${g}${g}${b}${b}`
73
+ );
74
+ value[key] = hexToColorObject(expanded);
75
+ } else {
76
+ value[key] = recurse(v);
77
+ }
78
+ }
79
+ }
80
+ return value;
81
+ }
82
+ return recurse(obj);
83
+ }
84
+
85
+ const replaceGlobalFont = (obj: any, font: string): void => {
86
+ Object.entries(obj).forEach(([k, v]) => {
87
+ if (v && typeof v === 'object') {
88
+ replaceGlobalFont(v, font);
89
+ } else if (k === 'font' && v === 'global.fontFamily.body') {
90
+ obj[k] = font;
91
+ }
92
+ });
93
+ };
94
+
95
+ function overrideSettingsFromShowcase(content: any, showcases: any): any {
96
+ return Object.fromEntries(Object.entries(content).map(([k, v]) => [k, showcases[k] ?? v]));
97
+ }
98
+
99
+ export function mergeDesign(
100
+ design: Record<string, any>,
101
+ showcase: Record<string, any>
102
+ ): Record<string, any> {
103
+ const result: Record<string, any> = { ...design };
104
+
105
+ Object.keys(design).forEach((key) => {
106
+ if (key in showcase) {
107
+ const base = design[key];
108
+ const override = showcase[key];
109
+
110
+ if (
111
+ base !== null && override !== null &&
112
+ typeof base === 'object' && typeof override === 'object' &&
113
+ !Array.isArray(base) && !Array.isArray(override)
114
+ ) {
115
+ // shallow merge nested objects
116
+ result[key] = { ...base, ...override };
117
+ } else {
118
+ // replace with showcase value (covers primitives & arrays)
119
+ result[key] = override;
120
+ }
121
+ }
122
+ });
123
+
124
+ return result;
125
+ }
126
+
127
+ export function designTransformer(design: Record<string, any>, showCaseDesign: Record<string, any> ): Record<string, any> {
128
+ const parsedDesign: Record<string, any> = {};
129
+ const parsedShowcaseDesign: Record<string, any> = {};
130
+ Object.entries(design).forEach(([key, comp]) => {
131
+ parsedDesign[key] = comp?.defaults;
132
+ });
133
+
134
+ Object.entries(showCaseDesign).forEach(([key, comp]) => {
135
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
136
+ const { type, ...withoutType } = comp;
137
+ parsedShowcaseDesign[key] = type == 'TEXT' ? { ... withoutType, visible: true} : withoutType
138
+ });
139
+
140
+ let overridenDesign = mergeDesign(parsedDesign, parsedShowcaseDesign)
141
+ overridenDesign = updateHexColors(overridenDesign);
142
+ replaceGlobalFont(overridenDesign, 'Roboto');
143
+ return overridenDesign;
144
+ }
145
+
146
+ function processImage(component: any, sectionName: string, key: string): Record<string, any> {
147
+ const assetLocation = `${distFolderPath}/sections/${sectionName}/assets/`;
148
+ const set = component.defaults?.set || component.imageData?.set;
149
+ const newSet = {
150
+ 'cropped-webp-100x200': { url: assetLocation + set.MOBILE_WEBP_LOW_RES.url },
151
+ 'cropped-webp-1000x2000': { url: assetLocation + set.MOBILE_WEBP_HI_RES.url },
152
+ 'webp-200x200': { url: assetLocation + set.WEBP_LOW_RES.url },
153
+ 'webp-2000x2000': { url: assetLocation + set.WEBP_HI_2X_RES.url },
154
+ };
155
+ return {
156
+ [key]: {
157
+ set: newSet,
158
+ ...(component.imageData?.borderInfo && { borderInfo: component.imageData.borderInfo }),
159
+ ...(component.imageData && { bucket: {} })
160
+ },
161
+ };
162
+ }
163
+
164
+ function processComponent(key: string, component: any, translations: any, sectionName?: string): any {
165
+ if (!component?.type) return '';
166
+ switch (component.type) {
167
+ case 'INPUTBOX':
168
+ case 'TEXTAREA': {
169
+ const text = component?.defaults?.text ?? component?.text ?? component?.placeholder;
170
+ return { [key]: translations[text] };
171
+ }
172
+ case 'DIVIDER':
173
+ return { [key]: translations[component.label] };
174
+ case 'DECK': {
175
+ const defaultSettings = component.cards?.defaultCardContent?.settings;
176
+ if (defaultSettings) {
177
+ const result: Record<string, any> = {};
178
+ Object.entries(defaultSettings).forEach(([k, c]) => {
179
+ Object.assign(result, processComponent(k, c, translations));
180
+ });
181
+ return { [key]: result };
182
+ } else {
183
+ const cards = component.cards.map((card: any) => {
184
+ const cardContent: Record<string, any> = {};
185
+ Object.entries(card.settings).forEach(([k, c]) => {
186
+ Object.assign(cardContent, processComponent(k, c, translations, sectionName));
187
+ });
188
+ return { settings: cardContent };
189
+ });
190
+ return { [key]: { cards } };
191
+ }
192
+ }
193
+ case 'BUTTON': {
194
+ const button = component.defaults || component;
195
+ return {
196
+ [key]: {
197
+ title: translations[button.title],
198
+ type: button.buttonType,
199
+ link: button.link,
200
+ },
201
+ };
202
+ }
203
+ case 'TOGGLE':
204
+ return { [key]: component.defaults };
205
+ case 'SELECTBOX':
206
+ return { [key]: component.defaults.value };
207
+ case 'IMAGE':
208
+ return processImage(component, sectionName!, key);
209
+ default:
210
+ console.warn(`Unknown type: ${component.type}`);
211
+ return '';
212
+ }
213
+ }
214
+
215
+ function getContentToRender(
216
+ content: any,
217
+ showcase: any,
218
+ contentTranslations: any,
219
+ showcaseTranslations: any,
220
+ sectionName: string
221
+ ): any {
222
+ const parsedContent = Object.entries(content).reduce((acc, [k, c]) => {
223
+ return { ...acc, ...processComponent(k, c, contentTranslations, sectionName) };
224
+ }, {});
225
+
226
+ const parsedShowcase = Object.entries(showcase).reduce((acc, [k, c]) => {
227
+ return { ...acc, ...processComponent(k, c, showcaseTranslations, sectionName) };
228
+ }, {});
229
+
230
+ return overrideSettingsFromShowcase(parsedContent, parsedShowcase);
231
+ }
232
+
233
+ export function dropdownOptions(showcaseModules: Record<string, any>): Array<{ value: string; label: string }> {
234
+ return Object.keys(showcaseModules).map(path => {
235
+ const match = path.match(/\/sections\/([^/]+)\/js\/showcases\/(\d+)\.mjs$/);
236
+ if (!match) return null;
237
+ return {
238
+ value: `${match[1]}:${match[2]}`,
239
+ label: `${match[1]}: showcase ${match[2]}`,
240
+ };
241
+ }).filter(Boolean) as Array<{ value: string; label: string }>;
242
+ }
243
+
244
+ export function loadSectionCss(sectionName: string): void {
245
+ const link = document.createElement('link');
246
+ link.rel = 'stylesheet';
247
+ link.href = `${distFolderPath}/sections/${sectionName}/js/main/client/assets/client.css`;
248
+ document.head.appendChild(link);
249
+ }
250
+
251
+ export async function renderShowcase(sectionName: string, showcaseId: string): Promise<void> {
252
+ const client = await loadModule(`${distFolderPath}/sections/${sectionName}/js/main/client/client.js`);
253
+ const content = await loadModule(`${distFolderPath}/sections/${sectionName}/js/settings/content.mjs`);
254
+ const contentTranslations = await loadModule(`${distFolderPath}/sections/${sectionName}/js/settings/translations.mjs`);
255
+ const showcaseTranslations = await loadModule(`${distFolderPath}/sections/${sectionName}/js/showcases/translations.mjs`);
256
+ const showcase = await loadModule(`${distFolderPath}/sections/${sectionName}/js/showcases/${showcaseId}.mjs`);
257
+ const design = await loadModule(`${distFolderPath}/sections/${sectionName}/js/settings/design.mjs`);
258
+
259
+ const { mount } = client.default.init();
260
+
261
+ const ovveridenDesign = designTransformer(design.default, showcase.default.design || {});
262
+
263
+ loadSectionCss(sectionName);
264
+
265
+ const backgroundDesign = {
266
+ background: {
267
+ background: {
268
+ type: 'solid',
269
+ solid: {
270
+ color: {
271
+ raw: '#F9F9F9',
272
+ hex: '#F9F9F9',
273
+ rgba: { r: 19, g: 19, b: 19, a: 1.0 },
274
+ },
275
+ },
276
+ color: 'global.color.background',
277
+ },
278
+ },
279
+ };
280
+
281
+ const overriddenContent = getContentToRender(
282
+ content.default,
283
+ showcase.default.content || {},
284
+ contentTranslations.default.en,
285
+ showcaseTranslations.default.en,
286
+ sectionName
287
+ );
288
+
289
+ mount('#app', {
290
+ context: {
291
+ globalDesign: { color: 'global.color.background' },
292
+ },
293
+ data: {
294
+ content: overriddenContent,
295
+ design: { ...ovveridenDesign, ...backgroundDesign },
296
+ defaults: {},
297
+ background: {},
298
+ },
299
+ });
300
+ }
@@ -0,0 +1,3 @@
1
+ export async function loadModule(path: string): Promise<any> {
2
+ return import(path);
3
+ }
@@ -0,0 +1,32 @@
1
+ import { defineConfig } from 'vite';
2
+
3
+ export default defineConfig({
4
+ root: '.',
5
+ server: {
6
+ fs: { strict: false },
7
+ },
8
+ plugins: [
9
+ {
10
+ name: 'print-preview-url',
11
+ configureServer(server) {
12
+ server.httpServer?.once('listening', () => {
13
+ const baseUrl = `http://localhost:${server.config.server.port || 5173}`;
14
+ console.log("Preview URL: ", baseUrl + '/preview/sections/preview.html\n');
15
+ console.log('🛑 Press Ctrl+C to stop the local server 🛑\n');
16
+ });
17
+ },
18
+ },
19
+ {
20
+ name: 'no-vite-cache',
21
+ configureServer(server) {
22
+ // invalidate caches so that after build when user refreshes the page, the latest .mjs files are fetched
23
+ server.middlewares.use((req, res, next) => {
24
+ if (req.url?.includes('/sections/')) {
25
+ server.moduleGraph.invalidateAll();
26
+ }
27
+ next();
28
+ });
29
+ },
30
+ },
31
+ ],
32
+ });
package/types.d.ts CHANGED
@@ -9,13 +9,13 @@ declare module '*.vue' {
9
9
 
10
10
  type ActionLinkType
11
11
  = 'SCROLL_TO_TILE'
12
- | 'HYPER_LINK'
13
- | 'MAIL_LINK'
14
- | 'TEL_LINK'
15
- | 'GO_TO_STORE'
16
- | 'GO_TO_STORE_LINK'
17
- | 'GO_TO_PAGE'
18
- | 'GO_TO_CATEGORY';
12
+ | 'HYPER_LINK'
13
+ | 'MAIL_LINK'
14
+ | 'TEL_LINK'
15
+ | 'GO_TO_STORE'
16
+ | 'GO_TO_STORE_LINK'
17
+ | 'GO_TO_PAGE'
18
+ | 'GO_TO_CATEGORY';
19
19
 
20
20
  interface ButtonContentData {
21
21
  readonly title: string;
@@ -78,7 +78,7 @@ type LogoType = 'TEXT' | 'IMAGE';
78
78
  interface LogoContentData {
79
79
  readonly type: LogoType;
80
80
  readonly text: string;
81
- readonly image: ImageContentData
81
+ readonly image: ImageContentData;
82
82
  }
83
83
 
84
84
  type GlobalColorsString =
@@ -382,7 +382,7 @@ type ContentEditor =
382
382
 
383
383
  type InferContentType<T extends Record<string, ContentEditor>> = {
384
384
  readonly [P in keyof T]: MapEditorContentTypes[T[P]['type']]
385
- }
385
+ };
386
386
 
387
387
  type MapEditorDesignTypes = {
388
388
  readonly TEXT: string;
@@ -444,8 +444,8 @@ interface LogoDesignEditor {
444
444
  }
445
445
 
446
446
  interface DividerDesignEditor {
447
- readonly type: 'DIVIDER';
448
- readonly label: string | Record<string, string>;
447
+ readonly type: 'DIVIDER';
448
+ readonly label: string | Record<string, string>;
449
449
  }
450
450
 
451
451
  type DesignEditor =
@@ -461,6 +461,6 @@ type DesignEditor =
461
461
 
462
462
  type InferDesignType<T extends Record<string, DesignEditor>> = {
463
463
  readonly [P in keyof T]: MapEditorDesignTypes[T[P]['type']]
464
- }
464
+ };
465
465
 
466
466
  type SettingsEditor = DesignEditor | ContentEditor;