@osimart/create-storefront 1.0.13 → 1.0.16
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/app/composables/useBlogPost.js +131 -0
- package/app/composables/useCheckout.js +4 -17
- package/app/composables/useContentTree.js +43 -12
- package/app/logic/components/BlogPostPart.js +237 -0
- package/app/logic/components/CardList.js +6 -3
- package/app/logic/components/ProductGallery.js +14 -2
- package/app/logic/sections/BlogPostsSection.js +220 -0
- package/app/logic/sections/ContentNode.js +23 -2
- package/app/logic/sections/MainBlogPostSection.js +47 -0
- package/app/pages/blogs/[blog]/[slug].vue +154 -0
- package/app/plugins/auth.client.js +14 -0
- package/app/services/AuthBus.js +14 -1
- package/app/services/BlogServices.js +17 -0
- package/package.json +1 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import BlogServices from "#osi/services/BlogServices";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The blog post a page shows — /blogs/<blog>/<post>, Shopify's article.
|
|
5
|
+
*
|
|
6
|
+
* Called by the page (awaited, so the post is in the SSR payload) and by
|
|
7
|
+
* MAIN_BLOG_POST (not awaited — same key, it reads that payload instead of
|
|
8
|
+
* fetching again), like useProductResource.
|
|
9
|
+
*
|
|
10
|
+
* Which post:
|
|
11
|
+
* the route /blogs/<blog>/<slug> (route "blog-post"), or the render
|
|
12
|
+
* payload's resource { type: 'BLOG_POST', slug }
|
|
13
|
+
* the editor no route: the store's newest published post, so the
|
|
14
|
+
* merchant designs against a real title, image and body; a
|
|
15
|
+
* sample post while the store has none
|
|
16
|
+
*
|
|
17
|
+
* `post` is normalised for the blocks:
|
|
18
|
+
* { id, slug, title, image, imageAlt, html, excerpt, author, authorImage,
|
|
19
|
+
* date, blog, blogSlug, tags, seo, url, placeholder }
|
|
20
|
+
*/
|
|
21
|
+
export function useBlogPost() {
|
|
22
|
+
const route = useRoute();
|
|
23
|
+
const { resource } = useRenderContext();
|
|
24
|
+
const placeholders = usePlaceholders();
|
|
25
|
+
const { baseUrl } = useStoreInit();
|
|
26
|
+
const { mediaUrl } = useMediaUrl();
|
|
27
|
+
const t = useT();
|
|
28
|
+
|
|
29
|
+
const slug = computed(() => {
|
|
30
|
+
if (route.name === "blog-post" && route.params.slug) return String(route.params.slug);
|
|
31
|
+
if (resource.value?.type === "BLOG_POST" && resource.value.slug) return String(resource.value.slug);
|
|
32
|
+
return null;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Resolved here, before any await (see useStorefront on SSR context).
|
|
36
|
+
const base = baseUrl.value;
|
|
37
|
+
|
|
38
|
+
async function loadBySlug(value) {
|
|
39
|
+
try {
|
|
40
|
+
return await BlogServices.getPost(value, base);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (err?.status === 404) return null;
|
|
43
|
+
throw createError({ statusCode: 500, statusMessage: "Couldn't load this post", fatal: true });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The editor: the newest published post, with its body. */
|
|
48
|
+
async function loadLatest() {
|
|
49
|
+
const res = await BlogServices.getPosts({ limit: 1 }, base).catch(() => null);
|
|
50
|
+
const first = (Array.isArray(res) ? res : res?.results ?? [])[0];
|
|
51
|
+
if (!first) return null;
|
|
52
|
+
if (!first.slug) return first;
|
|
53
|
+
const detail = await BlogServices.getPost(first.slug, base).catch(() => null);
|
|
54
|
+
return detail ? { ...first, ...detail } : first;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const asyncData = useAsyncData(
|
|
58
|
+
`blog-post:${slug.value ?? "preview"}`,
|
|
59
|
+
() => (slug.value ? loadBySlug(slug.value) : loadLatest()),
|
|
60
|
+
{
|
|
61
|
+
watch: [slug],
|
|
62
|
+
default: () => null,
|
|
63
|
+
getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key] ?? nuxtApp.static.data[key],
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
const { data, pending, error } = asyncData;
|
|
67
|
+
|
|
68
|
+
const name = (v) => (v && typeof v === "object" ? v.name || v.title || v.full_name || "" : String(v || ""));
|
|
69
|
+
const slugOf = (v) => (v && typeof v === "object" ? v.slug || "" : "");
|
|
70
|
+
|
|
71
|
+
function normalise(p) {
|
|
72
|
+
const blogSlug = slugOf(p.category) || p.category_slug || "news";
|
|
73
|
+
const author = p.author && typeof p.author === "object" ? p.author : null;
|
|
74
|
+
return {
|
|
75
|
+
id: p.id ?? null,
|
|
76
|
+
slug: p.slug ?? "",
|
|
77
|
+
title: t(p.title) || "",
|
|
78
|
+
image: mediaUrl(p.image ?? p.cover ?? p.featured_image ?? null) || "",
|
|
79
|
+
imageAlt: t(p.image_alt) || t(p.title) || "",
|
|
80
|
+
html: t(p.description ?? p.content ?? p.body) || "",
|
|
81
|
+
excerpt: t(p.short_desc ?? p.excerpt) || "",
|
|
82
|
+
author: name(p.author) || p.author_name || "",
|
|
83
|
+
authorImage: author?.image ? mediaUrl(author.image) : "",
|
|
84
|
+
date: p.published_at || p.created_at || null,
|
|
85
|
+
blog: name(p.category) || p.category_name || "",
|
|
86
|
+
blogSlug,
|
|
87
|
+
tags: (p.tags ?? []).map((tag) => ({ name: name(tag), slug: slugOf(tag) })).filter((tag) => tag.name),
|
|
88
|
+
seo: { title: t(p.seo_title) || "", description: t(p.seo_description) || "" },
|
|
89
|
+
url: p.slug ? `/blogs/${blogSlug}/${p.slug}` : "",
|
|
90
|
+
placeholder: false,
|
|
91
|
+
raw: p,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The editor with no posts yet: a sample to design with. */
|
|
96
|
+
const SAMPLE = {
|
|
97
|
+
id: null,
|
|
98
|
+
slug: "",
|
|
99
|
+
title: "Behind the scenes of our new collection",
|
|
100
|
+
image: "",
|
|
101
|
+
imageAlt: "",
|
|
102
|
+
html:
|
|
103
|
+
"<p>This is where your blog post appears. Write posts in Blog posts, then " +
|
|
104
|
+
"every one of them uses this page's design.</p>" +
|
|
105
|
+
"<h2>A heading inside the post</h2>" +
|
|
106
|
+
"<p>Paragraphs, <a href=\"#\">links</a>, lists and images keep the theme's " +
|
|
107
|
+
"fonts and colours. Long posts read best at about seventy characters a line.</p>" +
|
|
108
|
+
"<ul><li>Share the story behind a product</li><li>Announce what's new</li>" +
|
|
109
|
+
"<li>Answer the questions customers ask most</li></ul>" +
|
|
110
|
+
"<blockquote><p>A quote from a maker or a customer stands out like this.</p></blockquote>",
|
|
111
|
+
excerpt: "How we chose every fabric, colour and stitch.",
|
|
112
|
+
author: "Jane Doe",
|
|
113
|
+
authorImage: "",
|
|
114
|
+
date: new Date().toISOString(),
|
|
115
|
+
blog: "News",
|
|
116
|
+
blogSlug: "news",
|
|
117
|
+
tags: [{ name: "Behind the scenes", slug: "" }, { name: "Makers", slug: "" }],
|
|
118
|
+
seo: { title: "", description: "" },
|
|
119
|
+
url: "",
|
|
120
|
+
placeholder: true,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const post = computed(() => {
|
|
124
|
+
if (data.value) return normalise(data.value);
|
|
125
|
+
return placeholders.allowed?.value && !slug.value ? SAMPLE : null;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const loading = computed(() => pending.value && !data.value);
|
|
129
|
+
|
|
130
|
+
return { post, slug, loading, error, asyncData, isPlaceholder: computed(() => !!post.value?.placeholder) };
|
|
131
|
+
}
|
|
@@ -352,23 +352,10 @@ export function useCheckout() {
|
|
|
352
352
|
|
|
353
353
|
async function waitForUserInfoIfNeeded() {
|
|
354
354
|
if (!authBus.isLoggedIn) return;
|
|
355
|
-
|
|
356
|
-
await
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
clearInterval(checkInterval);
|
|
360
|
-
handleUserInfo();
|
|
361
|
-
resolve();
|
|
362
|
-
}
|
|
363
|
-
}, 0);
|
|
364
|
-
|
|
365
|
-
// Fallback in case user info never arrives
|
|
366
|
-
setTimeout(() => {
|
|
367
|
-
clearInterval(checkInterval);
|
|
368
|
-
needsUserInfo.value = false;
|
|
369
|
-
resolve();
|
|
370
|
-
}, 0);
|
|
371
|
-
});
|
|
355
|
+
// Same request the auth.client plugin started — no duplicate fetch.
|
|
356
|
+
await authBus.init();
|
|
357
|
+
handleUserInfo();
|
|
358
|
+
needsUserInfo.value = false;
|
|
372
359
|
}
|
|
373
360
|
|
|
374
361
|
// ================================================================
|
|
@@ -266,6 +266,13 @@ export function useContentTree(section, options = {}) {
|
|
|
266
266
|
const v = block.settings ?? {};
|
|
267
267
|
const base = { key: block.id ?? block.key, type: block.type, s: v, children };
|
|
268
268
|
|
|
269
|
+
// A section's own component for a type wins over the built-in drawing
|
|
270
|
+
// (the blog post page's Details is the Blog posts card's BLOG_POST_INFO,
|
|
271
|
+
// but drawn from the page's post, not a card item).
|
|
272
|
+
if (options.custom?.[block.type] && !options.raw?.includes(block.type)) {
|
|
273
|
+
return customNode(block, base, v);
|
|
274
|
+
}
|
|
275
|
+
|
|
269
276
|
switch (block.type) {
|
|
270
277
|
// A Slide is a Group with a background — drawn as one (type GROUP), marked
|
|
271
278
|
// `slide` so the slideshow can find it.
|
|
@@ -519,6 +526,25 @@ export function useContentTree(section, options = {}) {
|
|
|
519
526
|
};
|
|
520
527
|
}
|
|
521
528
|
|
|
529
|
+
// Blog posts' card: the post's author / date / blog line.
|
|
530
|
+
case "BLOG_POST_INFO":
|
|
531
|
+
return {
|
|
532
|
+
...base,
|
|
533
|
+
...sizeOf(v, "WIDTH", null, "FIT"),
|
|
534
|
+
box: boxOf(v),
|
|
535
|
+
...textLook(v, "CUSTOM"),
|
|
536
|
+
part: "info",
|
|
537
|
+
info: {
|
|
538
|
+
author: v.SHOW_AUTHOR !== false,
|
|
539
|
+
authorPrefix: t(v.AUTHOR_PREFIX) ?? "",
|
|
540
|
+
date: v.SHOW_DATE !== false,
|
|
541
|
+
dateFormat: upper(v.DATE_FORMAT, "MEDIUM"),
|
|
542
|
+
blog: !!v.SHOW_BLOG,
|
|
543
|
+
separator: { DOT: "·", PIPE: "|", DASH: "—", SLASH: "/" }[upper(v.SEPARATOR, "DOT")] ?? "·",
|
|
544
|
+
},
|
|
545
|
+
clamp: false,
|
|
546
|
+
};
|
|
547
|
+
|
|
522
548
|
case "CARD_TITLE":
|
|
523
549
|
case "CARD_COUNT":
|
|
524
550
|
case "CARD_DESCRIPTION":
|
|
@@ -712,22 +738,27 @@ export function useContentTree(section, options = {}) {
|
|
|
712
738
|
// options.raw: types the section draws itself from the tree (the
|
|
713
739
|
// classification page's Toolbar / Product grid) — kept as plain nodes.
|
|
714
740
|
if (options.raw?.includes(block.type)) return base;
|
|
715
|
-
|
|
716
|
-
if (!component) return null;
|
|
717
|
-
// options.customText: { TYPE: default preset } — blocks with the Text
|
|
718
|
-
// block's look (a banner's title…) get it resolved like Text does.
|
|
719
|
-
const textDefault = options.customText?.[block.type];
|
|
720
|
-
return {
|
|
721
|
-
...base,
|
|
722
|
-
...sizeOf(v, "WIDTH", "WIDTH_MOBILE", "FILL"),
|
|
723
|
-
box: boxOf(v, { background: false }),
|
|
724
|
-
...(textDefault ? textLook(v, textDefault) : {}),
|
|
725
|
-
component: markRaw(component),
|
|
726
|
-
};
|
|
741
|
+
return customNode(block, base, v);
|
|
727
742
|
}
|
|
728
743
|
}
|
|
729
744
|
}
|
|
730
745
|
|
|
746
|
+
/** A block drawn by the section's own component (options.custom). */
|
|
747
|
+
function customNode(block, base, v) {
|
|
748
|
+
const component = options.custom?.[block.type];
|
|
749
|
+
if (!component) return null;
|
|
750
|
+
// options.customText: { TYPE: default preset } — blocks with the Text
|
|
751
|
+
// block's look (a banner's title…) get it resolved like Text does.
|
|
752
|
+
const textDefault = options.customText?.[block.type];
|
|
753
|
+
return {
|
|
754
|
+
...base,
|
|
755
|
+
...sizeOf(v, "WIDTH", "WIDTH_MOBILE", "FILL"),
|
|
756
|
+
box: boxOf(v, { background: false }),
|
|
757
|
+
...(textDefault ? textLook(v, textDefault) : {}),
|
|
758
|
+
component: markRaw(component),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
731
762
|
/**
|
|
732
763
|
* VISIBILITY (every content block): shown everywhere, or only on phones /
|
|
733
764
|
* only on larger screens. A class the parent puts on the node's element
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The blog post page's parts (MAIN_BLOG_POST's own blocks), drawn from what
|
|
3
|
+
* MainBlogPostSection provides as "osiBlogPost" (the post, the section's
|
|
4
|
+
* settings):
|
|
5
|
+
*
|
|
6
|
+
* BLOG_POST_IMAGE the featured image: RATIO (or a FIXED height), FIT,
|
|
7
|
+
* RADIUS, an overlay
|
|
8
|
+
* BLOG_POST_TITLE the title
|
|
9
|
+
* BLOG_POST_INFO author (with photo) · date · blog
|
|
10
|
+
* BLOG_POST_EXCERPT the short description
|
|
11
|
+
* BLOG_POST_CONTENT the body (HTML): its own text size, line height,
|
|
12
|
+
* paragraph spacing, headings, links, quotes, images,
|
|
13
|
+
* drop cap
|
|
14
|
+
* BLOG_POST_TAGS the tags: pills, outlined pills or #text
|
|
15
|
+
* SHARE share links and copy link
|
|
16
|
+
*
|
|
17
|
+
* Every part's size: Width (on its wrapper, by useContentTree), then a
|
|
18
|
+
* maximum width — its own MAX_WIDTH, or "" = the section's TEXT_WIDTH — and
|
|
19
|
+
* POSITION when it's narrower than the section. Title, Details, Excerpt,
|
|
20
|
+
* Tags and Share take the Text block's look, resolved onto the node.
|
|
21
|
+
*/
|
|
22
|
+
const RATIOS = { SQUARE: "1 / 1", PORTRAIT_4_5: "4 / 5", PORTRAIT_3_4: "3 / 4", LANDSCAPE: "4 / 3", WIDE: "16 / 9" };
|
|
23
|
+
const SEPARATORS = { DOT: "·", PIPE: "|", DASH: "—", SLASH: "/" };
|
|
24
|
+
const DATE_FORMATS = {
|
|
25
|
+
SHORT: { day: "2-digit", month: "2-digit", year: "numeric" },
|
|
26
|
+
MEDIUM: { day: "numeric", month: "short", year: "numeric" },
|
|
27
|
+
LONG: { day: "numeric", month: "long", year: "numeric" },
|
|
28
|
+
};
|
|
29
|
+
const WIDTHS = { NONE: "none", SMALL: "560px", MEDIUM: "760px", LARGE: "960px" };
|
|
30
|
+
const MARGINS = {
|
|
31
|
+
LEFT: { marginLeft: "0", marginRight: "auto" },
|
|
32
|
+
CENTER: { marginLeft: "auto", marginRight: "auto" },
|
|
33
|
+
RIGHT: { marginLeft: "auto", marginRight: "0" },
|
|
34
|
+
};
|
|
35
|
+
const ALIGN = { LEFT: "left", CENTER: "center", RIGHT: "right", JUSTIFY: "justify" };
|
|
36
|
+
|
|
37
|
+
const SHARE_ICONS = {
|
|
38
|
+
FACEBOOK:
|
|
39
|
+
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13.5 21v-7.5h2.5l.4-3h-2.9V8.6c0-.9.3-1.5 1.5-1.5h1.5V4.4c-.3 0-1.2-.1-2.2-.1-2.2 0-3.7 1.3-3.7 3.8v2.4H8v3h2.6V21h2.9z"/></svg>',
|
|
40
|
+
X: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.8 3h3.1l-6.8 7.7L22 21h-6.2l-4.9-6.4L5.3 21H2.2l7.2-8.3L1.8 3h6.4l4.4 5.8L17.8 3zm-1.1 16.2h1.7L7.4 4.7H5.6l11.1 14.5z"/></svg>',
|
|
41
|
+
WHATSAPP:
|
|
42
|
+
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 0 0-8.6 15.1L2 22l5-1.3A10 10 0 1 0 12 2zm0 18.2c-1.5 0-3-.4-4.2-1.2l-.3-.2-3 .8.8-2.9-.2-.3A8.2 8.2 0 1 1 12 20.2zm4.5-6.1c-.2-.1-1.5-.7-1.7-.8-.2-.1-.4-.1-.6.1l-.8 1c-.1.2-.3.2-.5.1a6.7 6.7 0 0 1-3.3-2.9c-.3-.4.2-.4.7-1.4.1-.2 0-.3 0-.4l-.8-1.8c-.2-.5-.4-.4-.6-.4h-.5c-.2 0-.4.1-.6.3-.2.2-.8.8-.8 2s.8 2.3 1 2.5c.1.2 1.7 2.6 4.1 3.6 1.5.7 2.1.7 2.9.6.5-.1 1.5-.6 1.7-1.2.2-.6.2-1.1.1-1.2l-.4-.4z"/></svg>',
|
|
43
|
+
LINKEDIN:
|
|
44
|
+
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9.5h4V21H3V9.5zm6.5 0h3.8v1.6h.1c.5-1 1.8-2 3.8-2 4 0 4.8 2.6 4.8 6V21h-4v-5.1c0-1.2 0-2.8-1.7-2.8s-2 1.3-2 2.7V21h-4V9.5z"/></svg>',
|
|
45
|
+
PINTEREST:
|
|
46
|
+
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 0 0-3.6 19.3c-.1-.8-.2-2 0-2.9l1.2-5s-.3-.6-.3-1.5c0-1.4.8-2.4 1.8-2.4.9 0 1.3.6 1.3 1.4 0 .9-.5 2.1-.8 3.3-.2 1 .5 1.8 1.5 1.8 1.8 0 3.1-1.9 3.1-4.6 0-2.4-1.7-4.1-4.2-4.1-2.9 0-4.6 2.1-4.6 4.4 0 .9.3 1.8.8 2.3.1.1.1.2.1.3l-.3 1.2c0 .2-.2.2-.4.1-1.3-.6-2.1-2.5-2.1-4 0-3.3 2.4-6.3 6.9-6.3 3.6 0 6.4 2.6 6.4 6 0 3.6-2.3 6.5-5.4 6.5-1.1 0-2.1-.6-2.4-1.2l-.7 2.5c-.2.9-.9 2.1-1.3 2.8A10 10 0 1 0 12 2z"/></svg>',
|
|
47
|
+
EMAIL:
|
|
48
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3.5 6.5 12 13l8.5-6.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|
49
|
+
COPY_LINK:
|
|
50
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1" stroke-linecap="round"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" stroke-linecap="round"/></svg>',
|
|
51
|
+
COPIED:
|
|
52
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M5 12.5l4.2 4.2L19 7" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|
53
|
+
};
|
|
54
|
+
const SHARE_NAMES = {
|
|
55
|
+
FACEBOOK: "Facebook", X: "X", WHATSAPP: "WhatsApp", LINKEDIN: "LinkedIn",
|
|
56
|
+
PINTEREST: "Pinterest", EMAIL: "Email", COPY_LINK: "Copy link",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export default function useLogic({ props }) {
|
|
60
|
+
const t = useT();
|
|
61
|
+
const ctx = inject("osiBlogPost", null);
|
|
62
|
+
const { isPreview } = useBuilderPreview();
|
|
63
|
+
const locale = useState("locale", () => "en");
|
|
64
|
+
const origin = useRequestURL().origin;
|
|
65
|
+
|
|
66
|
+
const s = computed(() => props.node.s ?? {});
|
|
67
|
+
const type = computed(() => props.node.type);
|
|
68
|
+
const post = computed(() => ctx?.post.value ?? null);
|
|
69
|
+
const section = computed(() => ctx?.section?.value ?? {});
|
|
70
|
+
|
|
71
|
+
const upper = (v, d) => String(v || d).toUpperCase();
|
|
72
|
+
const has = (v) => v !== "" && v != null && Number.isFinite(Number(v));
|
|
73
|
+
const num = (v, d) => (has(v) ? Number(v) : d);
|
|
74
|
+
const px = (v) => `${v}px`;
|
|
75
|
+
|
|
76
|
+
/* -------------------------------------------------------------- size */
|
|
77
|
+
|
|
78
|
+
/** Max width + position. "" = the section's Text width. */
|
|
79
|
+
const sizeStyle = computed(() => {
|
|
80
|
+
const v = s.value;
|
|
81
|
+
let max;
|
|
82
|
+
if (has(v.MAX_WIDTH)) {
|
|
83
|
+
max = px(Number(v.MAX_WIDTH)); // a number saved by the first version
|
|
84
|
+
} else {
|
|
85
|
+
const own = upper(v.MAX_WIDTH, "");
|
|
86
|
+
const key = own || upper(section.value.TEXT_WIDTH, "MEDIUM");
|
|
87
|
+
const custom = own ? v.MAX_WIDTH_CUSTOM : section.value.TEXT_WIDTH_CUSTOM;
|
|
88
|
+
max = key === "CUSTOM" ? px(num(custom, 760)) : WIDTHS[key] ?? WIDTHS.MEDIUM;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
width: "100%",
|
|
92
|
+
maxWidth: max,
|
|
93
|
+
"--cb-max": max,
|
|
94
|
+
...(max === "none" ? {} : MARGINS[upper(v.POSITION, "CENTER")] ?? MARGINS.CENTER),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/* ------------------------------------------------------------- image */
|
|
99
|
+
|
|
100
|
+
const imageVars = computed(() => {
|
|
101
|
+
const v = s.value;
|
|
102
|
+
const ratio = upper(v.RATIO, "WIDE");
|
|
103
|
+
const fixed = ratio === "FIXED";
|
|
104
|
+
const out = {
|
|
105
|
+
"--bpp-ratio": fixed || ratio === "NATURAL" ? "auto" : RATIOS[ratio] ?? "16 / 9",
|
|
106
|
+
"--bpp-fit": upper(v.FIT, "COVER") === "CONTAIN" ? "contain" : "cover",
|
|
107
|
+
"--bpp-radius": px(num(v.RADIUS, 0)),
|
|
108
|
+
};
|
|
109
|
+
if (fixed) {
|
|
110
|
+
out["--bpp-h"] = px(num(v.IMAGE_HEIGHT, 320));
|
|
111
|
+
out["--bpp-h-m"] = px(num(v.IMAGE_HEIGHT_MOBILE, num(v.IMAGE_HEIGHT, 320)));
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
});
|
|
115
|
+
const imageFixed = computed(() => upper(s.value.RATIO, "WIDE") === "FIXED");
|
|
116
|
+
const imageNatural = computed(() => upper(s.value.RATIO, "WIDE") === "NATURAL");
|
|
117
|
+
const overlay = computed(() => {
|
|
118
|
+
const opacity = num(s.value.OVERLAY_OPACITY, 0);
|
|
119
|
+
return opacity > 0 ? { background: s.value.OVERLAY_COLOR || "#000", opacity: opacity / 100 } : null;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/* ------------------------------------------------------------ details */
|
|
123
|
+
|
|
124
|
+
const infoParts = computed(() => {
|
|
125
|
+
const p = post.value;
|
|
126
|
+
const v = s.value;
|
|
127
|
+
if (!p) return [];
|
|
128
|
+
const parts = [];
|
|
129
|
+
if (v.SHOW_AUTHOR !== false && p.author) {
|
|
130
|
+
parts.push({
|
|
131
|
+
kind: "author",
|
|
132
|
+
text: [t(v.AUTHOR_PREFIX) ?? "", p.author].filter(Boolean).join(" "),
|
|
133
|
+
avatar: v.SHOW_AVATAR !== false ? p.authorImage || "" : "",
|
|
134
|
+
initial: v.SHOW_AVATAR !== false && !p.authorImage ? p.author.trim().charAt(0).toUpperCase() : "",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (v.SHOW_DATE !== false && p.date) {
|
|
138
|
+
const d = new Date(p.date);
|
|
139
|
+
if (!Number.isNaN(d.getTime())) {
|
|
140
|
+
const format = DATE_FORMATS[upper(v.DATE_FORMAT, "MEDIUM")] ?? DATE_FORMATS.MEDIUM;
|
|
141
|
+
parts.push({ kind: "date", text: new Intl.DateTimeFormat(locale.value || undefined, format).format(d), iso: d.toISOString() });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (v.SHOW_BLOG && p.blog) parts.push({ kind: "blog", text: p.blog });
|
|
145
|
+
return parts;
|
|
146
|
+
});
|
|
147
|
+
const separator = computed(() => SEPARATORS[upper(s.value.SEPARATOR, "DOT")] ?? "·");
|
|
148
|
+
const avatarSize = computed(() => px(num(s.value.AVATAR_SIZE, 32)));
|
|
149
|
+
|
|
150
|
+
/* ------------------------------------------------------------ content */
|
|
151
|
+
|
|
152
|
+
const contentVars = computed(() => {
|
|
153
|
+
const v = s.value;
|
|
154
|
+
return {
|
|
155
|
+
"--bpp-fs": px(num(v.FONT_SIZE, 17)),
|
|
156
|
+
"--bpp-fs-m": px(num(v.FONT_SIZE_MOBILE, num(v.FONT_SIZE, 16))),
|
|
157
|
+
"--bpp-lh": String(num(v.LINE_HEIGHT, 170) / 100),
|
|
158
|
+
"--bpp-ps": px(num(v.PARAGRAPH_SPACING, 20)),
|
|
159
|
+
"--bpp-hs": String(num(v.HEADING_SCALE, 100) / 100),
|
|
160
|
+
"--bpp-hf": upper(v.HEADING_FONT, "HEADING") === "BODY" ? "var(--osi-font-body)" : "var(--osi-font-heading)",
|
|
161
|
+
"--bpp-media-r": px(num(v.MEDIA_RADIUS, 8)),
|
|
162
|
+
...(v.COLOR ? { "--bpp-color": v.COLOR } : {}),
|
|
163
|
+
...(v.HEADING_COLOR ? { "--bpp-hcolor": v.HEADING_COLOR } : {}),
|
|
164
|
+
...(v.LINK_COLOR ? { "--bpp-link": v.LINK_COLOR } : {}),
|
|
165
|
+
textAlign: ALIGN[upper(v.ALIGNMENT, "LEFT")] ?? "left",
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
const contentClasses = computed(() => [
|
|
169
|
+
`link-${upper(s.value.LINK_STYLE, "UNDERLINE").toLowerCase()}`,
|
|
170
|
+
`quote-${upper(s.value.QUOTE_STYLE, "LINE").toLowerCase()}`,
|
|
171
|
+
{ "has-dropcap": !!s.value.DROP_CAP },
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
/* --------------------------------------------------------------- tags */
|
|
175
|
+
|
|
176
|
+
const tagStyle = computed(() => upper(s.value.STYLE, "PILLS").toLowerCase());
|
|
177
|
+
const tagLabel = computed(() => t(s.value.LABEL) || "");
|
|
178
|
+
|
|
179
|
+
/* -------------------------------------------------------------- share */
|
|
180
|
+
|
|
181
|
+
const shareUrl = computed(() => {
|
|
182
|
+
const path = post.value?.url || "";
|
|
183
|
+
return path ? `${origin}${path}` : origin;
|
|
184
|
+
});
|
|
185
|
+
const shareStyle = computed(() => upper(s.value.STYLE, "ICONS").toLowerCase());
|
|
186
|
+
const shareLabel = computed(() => t(s.value.LABEL) ?? "Share");
|
|
187
|
+
const iconSize = computed(() => px(num(s.value.ICON_SIZE, 18)));
|
|
188
|
+
const copied = ref(false);
|
|
189
|
+
|
|
190
|
+
const shareLinks = computed(() => {
|
|
191
|
+
const v = s.value;
|
|
192
|
+
const url = encodeURIComponent(shareUrl.value);
|
|
193
|
+
const title = encodeURIComponent(post.value?.title || "");
|
|
194
|
+
const image = encodeURIComponent(post.value?.image || "");
|
|
195
|
+
const hrefs = {
|
|
196
|
+
FACEBOOK: `https://www.facebook.com/sharer/sharer.php?u=${url}`,
|
|
197
|
+
X: `https://twitter.com/intent/tweet?url=${url}&text=${title}`,
|
|
198
|
+
WHATSAPP: `https://wa.me/?text=${title}%20${url}`,
|
|
199
|
+
LINKEDIN: `https://www.linkedin.com/sharing/share-offsite/?url=${url}`,
|
|
200
|
+
PINTEREST: `https://pinterest.com/pin/create/button/?url=${url}&media=${image}&description=${title}`,
|
|
201
|
+
EMAIL: `mailto:?subject=${title}&body=${url}`,
|
|
202
|
+
};
|
|
203
|
+
const defaults = { FACEBOOK: true, X: true, WHATSAPP: true, COPY_LINK: true };
|
|
204
|
+
return Object.keys(SHARE_NAMES)
|
|
205
|
+
.filter((key) => (v[key] ?? defaults[key] ?? false) === true)
|
|
206
|
+
.map((key) => ({
|
|
207
|
+
key,
|
|
208
|
+
name: key === "COPY_LINK" && copied.value ? "Copied" : SHARE_NAMES[key],
|
|
209
|
+
href: hrefs[key] ?? null,
|
|
210
|
+
icon: key === "COPY_LINK" && copied.value ? SHARE_ICONS.COPIED : SHARE_ICONS[key],
|
|
211
|
+
}));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
function onShare(event, link) {
|
|
215
|
+
// The editor previews the page; it doesn't leave it.
|
|
216
|
+
if (isPreview.value) {
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (link.key !== "COPY_LINK") return;
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
const done = () => {
|
|
223
|
+
copied.value = true;
|
|
224
|
+
setTimeout(() => (copied.value = false), 1800);
|
|
225
|
+
};
|
|
226
|
+
navigator.clipboard?.writeText(shareUrl.value).then(done, done);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
t, s, type, post, isPreview, sizeStyle,
|
|
231
|
+
imageVars, imageFixed, imageNatural, overlay,
|
|
232
|
+
infoParts, separator, avatarSize,
|
|
233
|
+
contentVars, contentClasses,
|
|
234
|
+
tagStyle, tagLabel,
|
|
235
|
+
shareLinks, shareStyle, shareLabel, iconSize, copied, onShare,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
|
|
2
1
|
export default function useLogic({ props }) {
|
|
3
2
|
const s = computed(() => props.settings ?? {});
|
|
3
|
+
const { isMobile } = useLayoutShell();
|
|
4
4
|
const layout = computed(() => String(s.value.LAYOUT ?? "GRID").toUpperCase());
|
|
5
|
-
|
|
5
|
+
// CAROUSEL_ON_MOBILE (Blog posts): a grid / editorial that swipes on phones.
|
|
6
|
+
const isCarousel = computed(
|
|
7
|
+
() => layout.value === "CAROUSEL" || (!!s.value.CAROUSEL_ON_MOBILE && isMobile.value)
|
|
8
|
+
);
|
|
6
9
|
const config = computed(() => ({ template: props.template }));
|
|
7
10
|
|
|
8
11
|
/* ---------------------------------------------------------------- variables */
|
|
@@ -95,4 +98,4 @@ export default function useLogic({ props }) {
|
|
|
95
98
|
);
|
|
96
99
|
|
|
97
100
|
return { s, layout, isCarousel, config, styleVars, perView, carouselOptions, carousel, headerArrows };
|
|
98
|
-
}
|
|
101
|
+
}
|
|
@@ -15,5 +15,17 @@ export default function useLogic({ props }) {
|
|
|
15
15
|
|
|
16
16
|
const modules = [Pagination];
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
const isDesktop = ref(false);
|
|
19
|
+
let mql;
|
|
20
|
+
const update = (e) => (isDesktop.value = e.matches);
|
|
21
|
+
|
|
22
|
+
onMounted(() => {
|
|
23
|
+
mql = window.matchMedia("(min-width: 1024px)");
|
|
24
|
+
isDesktop.value = mql.matches;
|
|
25
|
+
mql.addEventListener("change", update);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
onBeforeUnmount(() => mql?.removeEventListener("change", update));
|
|
29
|
+
|
|
30
|
+
return { detail, gallery, images, zoomable, modules, isDesktop };
|
|
31
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import BlogServices from "#osi/services/BlogServices";
|
|
2
|
+
import { cachedRequest } from "#osi/utils/requestCache";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* BLOG_POSTS — Shopify Horizon's "Blog posts" (Storytelling).
|
|
6
|
+
*
|
|
7
|
+
* which posts BLOG (one blog = a blog category; empty = every blog),
|
|
8
|
+
* LIMIT, newest first, published only — fetched here
|
|
9
|
+
* (BlogServices), cached per blog / count like every
|
|
10
|
+
* storefront request.
|
|
11
|
+
* the header ordinary blocks (a Text "Blog posts" by default).
|
|
12
|
+
* the card the one Card block, drawn once per post by CardList: Featured
|
|
13
|
+
* image, Post title, Post details (BLOG_POST_INFO: author /
|
|
14
|
+
* date / blog), Excerpt, Read more. Where the Card sits among
|
|
15
|
+
* the blocks is where the list goes.
|
|
16
|
+
* the layout Shopify's settings, translated to CardList's (shared with
|
|
17
|
+
* the product / category lists): GRID | CAROUSEL | EDITORIAL,
|
|
18
|
+
* COLUMNS → also the carousel's slides per view,
|
|
19
|
+
* MOBILE_CARD_SIZE → slides per view on phones, ICONS_STYLE /
|
|
20
|
+
* ICONS_SHAPE → the arrows, CAROUSEL_ON_MOBILE.
|
|
21
|
+
*
|
|
22
|
+
* Sample posts (editor only) stand in when there is nothing real to design
|
|
23
|
+
* with: no blog picked and the store has no posts yet, or the posts request
|
|
24
|
+
* failed — then with a notice saying why. A picked blog that has no
|
|
25
|
+
* published posts shows the empty state, never samples.
|
|
26
|
+
*/
|
|
27
|
+
const ARROW_SVG =
|
|
28
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M13 6l6 6-6 6"/></svg>';
|
|
29
|
+
const CHEVRON_SVG =
|
|
30
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>';
|
|
31
|
+
|
|
32
|
+
export default function useLogic({ props }) {
|
|
33
|
+
const { s, t, shellStyle, innerStyle, customClass } = useSectionShell(() => props.section);
|
|
34
|
+
const { mediaUrl } = useMediaUrl();
|
|
35
|
+
const placeholders = usePlaceholders();
|
|
36
|
+
const { baseUrl } = useStoreInit();
|
|
37
|
+
|
|
38
|
+
const num = (v, d) => (v !== "" && v != null && Number.isFinite(Number(v)) ? Number(v) : d);
|
|
39
|
+
const upper = (v, d) => String(v || d).toUpperCase();
|
|
40
|
+
|
|
41
|
+
/* -------------------------------------------------------------- posts */
|
|
42
|
+
|
|
43
|
+
const idOf = (v) => (v && typeof v === "object" ? v.id ?? null : v === "" ? null : v ?? null);
|
|
44
|
+
// One blog; a list saved by an earlier build counts as its first id.
|
|
45
|
+
const blog = computed(() => {
|
|
46
|
+
const v = s.value.BLOG;
|
|
47
|
+
const id = idOf(Array.isArray(v) ? v[0] : v);
|
|
48
|
+
return id == null ? "" : String(id);
|
|
49
|
+
});
|
|
50
|
+
const limit = computed(() => Math.min(10, Math.max(1, num(s.value.LIMIT, 3))));
|
|
51
|
+
// On a blog post page: "more posts" never repeats the one being read — one
|
|
52
|
+
// extra is fetched and that post dropped.
|
|
53
|
+
const { resource } = useRenderContext();
|
|
54
|
+
const reading = computed(() => (resource.value?.type === "BLOG_POST" ? String(resource.value.slug || "") : ""));
|
|
55
|
+
const fetchCount = computed(() => limit.value + (reading.value ? 1 : 0));
|
|
56
|
+
|
|
57
|
+
// Published only: the request asks for it, and this guards an endpoint that
|
|
58
|
+
// ignores the filter — no drafts / archived, no posts dated in the future.
|
|
59
|
+
const isLive = (p) => {
|
|
60
|
+
if (String(p?.status ?? "PUBLISHED").toUpperCase() !== "PUBLISHED") return false;
|
|
61
|
+
const at = p?.published_at ? Date.parse(p.published_at) : NaN;
|
|
62
|
+
return !(at > Date.now());
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// { rows, error } — the error travels with the data so SSR hands it over too.
|
|
66
|
+
const { data: result, pending: loading } = useAsyncData(
|
|
67
|
+
`blog-posts:${props.section.key}`,
|
|
68
|
+
async () => {
|
|
69
|
+
const key = `blog:${baseUrl.value}:${blog.value || "all"}:${fetchCount.value}`;
|
|
70
|
+
try {
|
|
71
|
+
const res = await cachedRequest(key, () =>
|
|
72
|
+
BlogServices.getPosts({ blog: blog.value, limit: fetchCount.value }, baseUrl.value)
|
|
73
|
+
);
|
|
74
|
+
const rows = Array.isArray(res) ? res : res?.results ?? [];
|
|
75
|
+
return { rows: rows.filter(isLive), error: null };
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return { rows: [], error: e?.status ? `HTTP ${e.status}` : e?.message || "network error" };
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
{ watch: [blog, fetchCount], default: () => ({ rows: [], error: null }) }
|
|
81
|
+
);
|
|
82
|
+
const posts = computed(() => result.value?.rows ?? []);
|
|
83
|
+
const failed = computed(() => result.value?.error ?? null);
|
|
84
|
+
|
|
85
|
+
const name = (v) => (v && typeof v === "object" ? v.name || v.title || v.full_name || "" : String(v || ""));
|
|
86
|
+
const slugOf = (v) => (v && typeof v === "object" ? v.slug || "" : "");
|
|
87
|
+
|
|
88
|
+
function postUrl(p) {
|
|
89
|
+
if (p.external_link) return p.external_link;
|
|
90
|
+
const blogSlug = slugOf(p.category) || p.category_slug || "news";
|
|
91
|
+
return `/blogs/${blogSlug}/${p.slug || p.id}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const SAMPLES = [
|
|
95
|
+
{ title: "Behind the scenes of our new collection", short_desc: "How we chose every fabric, colour and stitch — from the first sketch to the shelf." },
|
|
96
|
+
{ title: "Five ways to style the season's essentials", short_desc: "Simple pairings that take you from weekday mornings to weekend dinners." },
|
|
97
|
+
{ title: "Meet the makers", short_desc: "The small workshops and hands behind the pieces you love." },
|
|
98
|
+
{ title: "A guide to caring for what you own", short_desc: "Little habits that keep your favourites looking new for years." },
|
|
99
|
+
{ title: "What we're reading this month", short_desc: "Books, journals and stories that inspired the team lately." },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const items = computed(() => {
|
|
103
|
+
const rows = (posts.value ?? [])
|
|
104
|
+
.filter((p) => !reading.value || String(p.slug ?? "") !== reading.value)
|
|
105
|
+
.slice(0, limit.value)
|
|
106
|
+
.map((p) => ({
|
|
107
|
+
key: p.id ?? p.slug,
|
|
108
|
+
title: t(p.title) || "",
|
|
109
|
+
description: t(p.short_desc ?? p.excerpt ?? p.description) || "",
|
|
110
|
+
image: mediaUrl(p.image ?? p.cover ?? p.featured_image ?? null),
|
|
111
|
+
imageAlt: t(p.title) || "",
|
|
112
|
+
url: postUrl(p),
|
|
113
|
+
author: name(p.author) || p.author_name || "",
|
|
114
|
+
date: p.published_at || p.created_at || null,
|
|
115
|
+
blog: name(p.category) || p.category_name || "",
|
|
116
|
+
raw: p,
|
|
117
|
+
}));
|
|
118
|
+
if (rows.length || !placeholders.allowed?.value) return rows;
|
|
119
|
+
// A picked blog that loaded fine but is empty: the real (empty) state.
|
|
120
|
+
if (blog.value && !failed.value) return rows;
|
|
121
|
+
// The editor, nothing real to show: sample posts to design with.
|
|
122
|
+
const day = 86400000;
|
|
123
|
+
return Array.from({ length: limit.value }, (_, i) => ({
|
|
124
|
+
key: `sample-${i}`,
|
|
125
|
+
...SAMPLES[i % SAMPLES.length],
|
|
126
|
+
description: SAMPLES[i % SAMPLES.length].short_desc,
|
|
127
|
+
image: null,
|
|
128
|
+
url: "",
|
|
129
|
+
author: "Jane Doe",
|
|
130
|
+
date: new Date(Date.now() - i * 4 * day).toISOString(),
|
|
131
|
+
blog: "News",
|
|
132
|
+
placeholder: true,
|
|
133
|
+
}));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/* ------------------------------------------------------------- layout */
|
|
137
|
+
|
|
138
|
+
const { tree } = useContentTree(() => props.section, {
|
|
139
|
+
rootDefaults: { DIRECTION: "VERTICAL", GAP: 12 },
|
|
140
|
+
index: () => props.index,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// No Card block (deleted): a default post card, so the list never vanishes.
|
|
144
|
+
const fallback = useContentTree(() => ({
|
|
145
|
+
settings: {},
|
|
146
|
+
blocks: [
|
|
147
|
+
{ key: "__card", type: "CARD", settings: { GAP: 8, HOVER: "ZOOM" } },
|
|
148
|
+
{ key: "__image", type: "CARD_IMAGE", parent_key: "__card", settings: { RATIO: "LANDSCAPE", RADIUS: 8 } },
|
|
149
|
+
{ key: "__title", type: "CARD_TITLE", parent_key: "__card", settings: { PRESET: "H4" } },
|
|
150
|
+
{ key: "__info", type: "BLOG_POST_INFO", parent_key: "__card", settings: {} },
|
|
151
|
+
{ key: "__excerpt", type: "CARD_DESCRIPTION", parent_key: "__card", settings: { LINES: 3, PADDING_TOP: 8 } },
|
|
152
|
+
],
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
const cardNode = computed(() => tree.value.children.find((n) => n.type === "CARD") ?? null);
|
|
156
|
+
const template = computed(() => cardNode.value ?? fallback.tree.value.children[0]);
|
|
157
|
+
|
|
158
|
+
/** The page, in block order; the list goes where the Card is (else last). */
|
|
159
|
+
const parts = computed(() => {
|
|
160
|
+
const out = tree.value.children.map((node) =>
|
|
161
|
+
node.type === "CARD" ? { kind: "list", key: "list" } : { kind: "block", key: node.key, node }
|
|
162
|
+
);
|
|
163
|
+
if (!cardNode.value) out.push({ kind: "list", key: "list" });
|
|
164
|
+
return out;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/** Shopify's settings in CardList's terms. */
|
|
168
|
+
const listSettings = computed(() => {
|
|
169
|
+
const v = s.value;
|
|
170
|
+
const layout = ["GRID", "CAROUSEL", "EDITORIAL"].includes(upper(v.LAYOUT, "GRID")) ? upper(v.LAYOUT, "GRID") : "GRID";
|
|
171
|
+
const cols = num(v.COLUMNS, 3);
|
|
172
|
+
const icons = upper(v.ICONS_STYLE, "ARROW");
|
|
173
|
+
const shape = upper(v.ICONS_SHAPE, "NONE");
|
|
174
|
+
const large = icons.endsWith("_LARGE");
|
|
175
|
+
const plain = shape === "NONE";
|
|
176
|
+
return {
|
|
177
|
+
...v,
|
|
178
|
+
LAYOUT: layout,
|
|
179
|
+
COLUMNS: cols,
|
|
180
|
+
COLUMNS_TABLET: Math.min(cols, 2),
|
|
181
|
+
COLUMNS_MOBILE: num(v.COLUMNS_MOBILE, 2),
|
|
182
|
+
GAP_X: num(v.GAP_X, 16),
|
|
183
|
+
GAP_Y: num(v.GAP_Y, 16),
|
|
184
|
+
// Carousel: the columns are the cards in view; phones show ~1 or ~2.
|
|
185
|
+
SLIDES_PER_VIEW: cols,
|
|
186
|
+
SLIDES_PER_VIEW_TABLET: Math.min(cols, 2.5),
|
|
187
|
+
SLIDES_PER_VIEW_MOBILE: upper(v.MOBILE_CARD_SIZE, "LARGE") === "SMALL" ? 2.2 : 1.4,
|
|
188
|
+
CAROUSEL_LOOP: false,
|
|
189
|
+
// Navigation icons
|
|
190
|
+
SHOW_ARROWS: icons !== "NONE",
|
|
191
|
+
ARROW_POSITION: "SIDES",
|
|
192
|
+
ARROW_SVG: icons.startsWith("CHEVRON") ? CHEVRON_SVG : ARROW_SVG,
|
|
193
|
+
ARROW_SIZE: large ? 52 : 40,
|
|
194
|
+
ARROW_ICON_SIZE: large ? 24 : 18,
|
|
195
|
+
ARROW_SHAPE: shape === "SQUARE" ? "SQUARE" : "CIRCLE",
|
|
196
|
+
ARROW_BG: plain ? "transparent" : undefined,
|
|
197
|
+
ARROW_BORDER_WIDTH: plain ? 0 : 1,
|
|
198
|
+
SHOW_DOTS: false,
|
|
199
|
+
// Editorial: three staggered columns of varied shapes (one on phones).
|
|
200
|
+
EDITORIAL_COLUMNS: Math.min(3, Math.max(2, limit.value)),
|
|
201
|
+
EDITORIAL_COLUMNS_MOBILE: 1,
|
|
202
|
+
EDITORIAL_VARY: true,
|
|
203
|
+
CAROUSEL_ON_MOBILE: !!v.CAROUSEL_ON_MOBILE && layout !== "CAROUSEL",
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const stackGap = computed(() => `${num(s.value.CONTENT_GAP, 12)}px`);
|
|
208
|
+
|
|
209
|
+
const scopeAttr = computed(() => `[data-osi-section="${props.section.id ?? props.section.key}"]`);
|
|
210
|
+
useScopedCss(() => s.value.CUSTOM_CSS, scopeAttr, `osi-section-css-${props.section.id ?? props.section.key}`);
|
|
211
|
+
|
|
212
|
+
// Editor-only line above the list when the samples hide a failed request.
|
|
213
|
+
const notice = computed(() =>
|
|
214
|
+
placeholders.allowed?.value && failed.value
|
|
215
|
+
? `Couldn't load blog posts (${failed.value}) — showing sample posts.`
|
|
216
|
+
: ""
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return { s, t, shellStyle, innerStyle, customClass, items, loading, notice, tree, template, parts, listSettings, stackGap };
|
|
220
|
+
}
|
|
@@ -51,12 +51,13 @@ export default function useLogic({ props }) {
|
|
|
51
51
|
const wrapperTag = computed(() => (n.value.link && !props.item ? NuxtLinkComponent : "div"));
|
|
52
52
|
|
|
53
53
|
/* ---- card template parts */
|
|
54
|
+
const locale = useState("locale", () => "en");
|
|
54
55
|
const partText = computed(() => {
|
|
55
56
|
const item = props.item;
|
|
56
57
|
const node = n.value;
|
|
57
58
|
if (!item) {
|
|
58
59
|
// Drawn without an item (e.g. an empty list in the builder): sample text.
|
|
59
|
-
return { title: "Category name", count: "12 products", description: "A short description." }[node.part] ?? "";
|
|
60
|
+
return { title: "Category name", count: "12 products", description: "A short description.", info: "by Jane Doe · Mar 12, 2026" }[node.part] ?? "";
|
|
60
61
|
}
|
|
61
62
|
if (node.part === "title") return [node.prefix, item.title].filter(Boolean).join(" ");
|
|
62
63
|
if (node.part === "count") {
|
|
@@ -64,8 +65,28 @@ export default function useLogic({ props }) {
|
|
|
64
65
|
return `${item.count} ${item.count === 1 ? node.singular : node.plural}`;
|
|
65
66
|
}
|
|
66
67
|
if (node.part === "description") return item.description || "";
|
|
68
|
+
if (node.part === "info") return postInfo(item, node.info ?? {});
|
|
67
69
|
return "";
|
|
68
70
|
});
|
|
71
|
+
|
|
72
|
+
/** Blog posts: "by Jane · Mar 12, 2026 · News" from the card's item. */
|
|
73
|
+
const DATE_FORMATS = {
|
|
74
|
+
SHORT: { day: "2-digit", month: "2-digit", year: "numeric" },
|
|
75
|
+
MEDIUM: { day: "numeric", month: "short", year: "numeric" },
|
|
76
|
+
LONG: { day: "numeric", month: "long", year: "numeric" },
|
|
77
|
+
};
|
|
78
|
+
function postInfo(item, opts) {
|
|
79
|
+
const parts = [];
|
|
80
|
+
if (opts.author && item.author) parts.push([opts.authorPrefix, item.author].filter(Boolean).join(" "));
|
|
81
|
+
if (opts.date && item.date) {
|
|
82
|
+
const d = new Date(item.date);
|
|
83
|
+
if (!Number.isNaN(d.getTime())) {
|
|
84
|
+
parts.push(new Intl.DateTimeFormat(locale.value || undefined, DATE_FORMATS[opts.dateFormat] ?? DATE_FORMATS.MEDIUM).format(d));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (opts.blog && item.blog) parts.push(item.blog);
|
|
88
|
+
return parts.join(` ${opts.separator || "·"} `);
|
|
89
|
+
}
|
|
69
90
|
const cardButton = computed(() => {
|
|
70
91
|
if (n.value.type !== "CARD_BUTTON") return null;
|
|
71
92
|
const s = n.value.s;
|
|
@@ -186,4 +207,4 @@ export default function useLogic({ props }) {
|
|
|
186
207
|
});
|
|
187
208
|
|
|
188
209
|
return { isPreview, NuxtLinkComponent, STAR, n, ICONS, WISH_ICONS, BTN_ICONS, WISH_DEFAULT, button, wrapperTag, partText, cardButton, isProduct, analytics, onCardClick, productButton, onCardButton, onCardButtonKey, onWishlist, RichTag, productBadges, badgeGroups, swatchGroups, h };
|
|
189
|
-
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { BlogPostPart } from "#components";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MAIN_BLOG_POST — the blog post page's "Blog post" section (BLOG_POST
|
|
5
|
+
* template), like Shopify's.
|
|
6
|
+
*
|
|
7
|
+
* The same block-built section as Rich text (MediaContentSection +
|
|
8
|
+
* ContentNode), plus the post's parts, drawn by BlogPostPart from what's
|
|
9
|
+
* provided here as "osiBlogPost": the post (useBlogPost — the route's, or the
|
|
10
|
+
* newest one in the editor) and the section's settings (TEXT_WIDTH, the
|
|
11
|
+
* reading column every part follows unless it sets its own maximum width).
|
|
12
|
+
*
|
|
13
|
+
* BLOG_POST_IMAGE Featured image
|
|
14
|
+
* BLOG_POST_TITLE Title
|
|
15
|
+
* BLOG_POST_INFO Details — author (photo) · date · blog
|
|
16
|
+
* BLOG_POST_EXCERPT Excerpt
|
|
17
|
+
* BLOG_POST_CONTENT Content
|
|
18
|
+
* BLOG_POST_TAGS Tags
|
|
19
|
+
* SHARE Share
|
|
20
|
+
*/
|
|
21
|
+
export default function useLogic({ props }) {
|
|
22
|
+
const blogPost = useBlogPost();
|
|
23
|
+
const section = computed(() => props.section?.settings ?? {});
|
|
24
|
+
provide("osiBlogPost", { ...blogPost, section });
|
|
25
|
+
|
|
26
|
+
const ROOT_DEFAULTS = { DIRECTION: "VERTICAL", ALIGNMENT: "CENTER", POSITION: "TOP", GAP: 24 };
|
|
27
|
+
const CUSTOM = {
|
|
28
|
+
BLOG_POST_IMAGE: BlogPostPart,
|
|
29
|
+
BLOG_POST_TITLE: BlogPostPart,
|
|
30
|
+
BLOG_POST_INFO: BlogPostPart,
|
|
31
|
+
BLOG_POST_EXCERPT: BlogPostPart,
|
|
32
|
+
BLOG_POST_CONTENT: BlogPostPart,
|
|
33
|
+
BLOG_POST_TAGS: BlogPostPart,
|
|
34
|
+
SHARE: BlogPostPart,
|
|
35
|
+
};
|
|
36
|
+
// The Text look (preset, size, font, colour, alignment) for the text parts.
|
|
37
|
+
// Content has its own typography fields.
|
|
38
|
+
const CUSTOM_TEXT = {
|
|
39
|
+
BLOG_POST_TITLE: "CUSTOM",
|
|
40
|
+
BLOG_POST_INFO: "CUSTOM",
|
|
41
|
+
BLOG_POST_EXCERPT: "CUSTOM",
|
|
42
|
+
BLOG_POST_TAGS: "CUSTOM",
|
|
43
|
+
SHARE: "CUSTOM",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return { blogPost, ROOT_DEFAULTS, CUSTOM, CUSTOM_TEXT };
|
|
47
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* Blog post — `/blogs/<blog>/<post>`, Shopify's article URL (<blog> is the
|
|
4
|
+
* blog category's slug, <post> the post's).
|
|
5
|
+
*
|
|
6
|
+
* Same lines every theme-driven route has: ask the backend which template
|
|
7
|
+
* serves this path, publish the payload, hand the sections to
|
|
8
|
+
* SectionRenderer. The post's layout lives in MAIN_BLOG_POST (BLOG_POST
|
|
9
|
+
* template), not here.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ Backend prerequisite — resolve_path() matches the path with
|
|
12
|
+
* builder.utils.match_blog_post() (next to match_search()). Until it does,
|
|
13
|
+
* render() answers something else and this page falls back to the default
|
|
14
|
+
* layout: the post itself, still correct, just not the merchant's design.
|
|
15
|
+
*
|
|
16
|
+
* The post is awaited here so it lands in the SSR payload; MAIN_BLOG_POST
|
|
17
|
+
* re-reads it from there. Unknown, unpublished or future-dated -> 404.
|
|
18
|
+
*/
|
|
19
|
+
definePageMeta({ name: "blog-post" });
|
|
20
|
+
|
|
21
|
+
// Every composable before the first await (SSR context, see useStorefront).
|
|
22
|
+
const route = useRoute();
|
|
23
|
+
const { render, store } = useStorefront();
|
|
24
|
+
const { setRenderContext } = useRenderContext();
|
|
25
|
+
const origin = useRequestURL().origin;
|
|
26
|
+
|
|
27
|
+
const blogPost = useBlogPost();
|
|
28
|
+
const renderData = useAsyncData(`render:${route.path}`, () => render(route.path).catch(() => null));
|
|
29
|
+
|
|
30
|
+
await blogPost.asyncData;
|
|
31
|
+
const { data: rendered } = await renderData;
|
|
32
|
+
|
|
33
|
+
// A fetch error lands in the asyncData's `error`, never rethrown: surface it.
|
|
34
|
+
if (!blogPost.asyncData.data.value) {
|
|
35
|
+
const failed = blogPost.error.value?.statusCode === 500;
|
|
36
|
+
throw createError({
|
|
37
|
+
statusCode: failed ? 500 : 404,
|
|
38
|
+
statusMessage: failed ? "Couldn't load this post" : "Blog post not found",
|
|
39
|
+
fatal: true,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MAIN = "MAIN_BLOG_POST";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The page when render() didn't answer with the BLOG_POST template (the
|
|
47
|
+
* backend doesn't route /blogs/... yet, or the theme has no template): the
|
|
48
|
+
* Blog post section with its default blocks — the base theme's layout — so
|
|
49
|
+
* the post still shows instead of an empty section.
|
|
50
|
+
*/
|
|
51
|
+
const DEFAULT_MAIN = {
|
|
52
|
+
key: "main-blog-post",
|
|
53
|
+
type: MAIN,
|
|
54
|
+
settings: {
|
|
55
|
+
CONTAINER: "NORMAL", TEXT_WIDTH: "MEDIUM", DIRECTION: "VERTICAL", ALIGNMENT: "CENTER",
|
|
56
|
+
POSITION: "TOP", GAP: 24, PADDING_TOP: 40, PADDING_BOTTOM: 64,
|
|
57
|
+
},
|
|
58
|
+
blocks: [
|
|
59
|
+
{
|
|
60
|
+
key: "blog-post-title", type: "BLOG_POST_TITLE", parent_key: "",
|
|
61
|
+
settings: { ALIGNMENT: "CENTER", PRESET: "CUSTOM", TAG: "H1", FONT: "HEADING", SIZE: 44, SIZE_MOBILE: 30, LINE_HEIGHT: 115 },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
key: "blog-post-details", type: "BLOG_POST_INFO", parent_key: "",
|
|
65
|
+
settings: { ALIGNMENT: "CENTER", SHOW_BLOG: true, DATE_FORMAT: "LONG", SIZE: 13 },
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: "blog-post-image", type: "BLOG_POST_IMAGE", parent_key: "",
|
|
69
|
+
settings: { RATIO: "WIDE", RADIUS: 12, MAX_WIDTH: "NONE", PADDING_TOP: 8 },
|
|
70
|
+
},
|
|
71
|
+
{ key: "blog-post-content", type: "BLOG_POST_CONTENT", parent_key: "", settings: {} },
|
|
72
|
+
{ key: "blog-post-tags", type: "BLOG_POST_TAGS", parent_key: "", settings: { SIZE: 13 } },
|
|
73
|
+
{ key: "blog-post-share", type: "SHARE", parent_key: "", settings: { SIZE: 13, LABEL: "Share" } },
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
const templateName = (payload) => {
|
|
77
|
+
const t = payload?.template;
|
|
78
|
+
return String((t && typeof t === "object" ? t.name : t) ?? "").toUpperCase();
|
|
79
|
+
};
|
|
80
|
+
const isBlogPostPayload = (payload) => !!payload && templateName(payload) === "BLOG_POST";
|
|
81
|
+
|
|
82
|
+
/** The template's sections, MAIN_BLOG_POST always there. */
|
|
83
|
+
const data = computed(() => {
|
|
84
|
+
const base = isBlogPostPayload(rendered.value) ? rendered.value : null;
|
|
85
|
+
const sections = base?.sections ?? [];
|
|
86
|
+
const post = blogPost.post.value;
|
|
87
|
+
return {
|
|
88
|
+
...(base ?? { template: "BLOG_POST", seo: {} }),
|
|
89
|
+
resource: {
|
|
90
|
+
type: "BLOG_POST",
|
|
91
|
+
id: post?.id ?? null,
|
|
92
|
+
slug: String(route.params.slug ?? ""),
|
|
93
|
+
blog: String(route.params.blog ?? ""),
|
|
94
|
+
},
|
|
95
|
+
sections: sections.some((section) => section.type === MAIN)
|
|
96
|
+
? sections
|
|
97
|
+
: [DEFAULT_MAIN, ...sections],
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (import.meta.dev && !isBlogPostPayload(rendered.value)) {
|
|
102
|
+
console.warn(
|
|
103
|
+
`[blog-post] render() did not resolve ${route.path} to BLOG_POST; using the default layout. ` +
|
|
104
|
+
"resolve_path() needs builder.utils.match_blog_post()."
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
setRenderContext(data.value);
|
|
109
|
+
watch(data, (payload) => setRenderContext(payload));
|
|
110
|
+
|
|
111
|
+
/* ------------------------------------------------------------------- SEO */
|
|
112
|
+
|
|
113
|
+
const post = blogPost.post;
|
|
114
|
+
const seo = computed(() => data.value?.seo ?? {});
|
|
115
|
+
const strip = (html) => String(html ?? "").replace(/<[^>]*>?/gm, " ").replace(/\s+/g, " ").trim();
|
|
116
|
+
|
|
117
|
+
// The post's own SEO first: render()'s seo for /blogs/... is the store-level
|
|
118
|
+
// fallback (resolve_route doesn't look the post up), so its title would be
|
|
119
|
+
// just the store's name. It still decides no_index.
|
|
120
|
+
const title = computed(() => {
|
|
121
|
+
const own = post.value?.seo.title || post.value?.title;
|
|
122
|
+
return own ? [own, store.value?.name].filter(Boolean).join(" | ") : seo.value.title_full ?? store.value?.name ?? "";
|
|
123
|
+
});
|
|
124
|
+
const description = computed(
|
|
125
|
+
() => post.value?.seo.description || post.value?.excerpt || strip(post.value?.html).slice(0, 300)
|
|
126
|
+
);
|
|
127
|
+
const image = computed(() => post.value?.image || undefined);
|
|
128
|
+
|
|
129
|
+
useHead({
|
|
130
|
+
title,
|
|
131
|
+
titleTemplate: (value) => value ?? "",
|
|
132
|
+
meta: () => [
|
|
133
|
+
{ name: "description", content: description.value || undefined },
|
|
134
|
+
{ property: "og:type", content: "article" },
|
|
135
|
+
{ property: "og:title", content: post.value?.title || undefined },
|
|
136
|
+
{ property: "og:description", content: description.value || undefined },
|
|
137
|
+
{ property: "og:image", content: image.value },
|
|
138
|
+
{ property: "article:published_time", content: post.value?.date || undefined },
|
|
139
|
+
{ property: "article:author", content: post.value?.author || undefined },
|
|
140
|
+
{ name: "twitter:card", content: image.value ? "summary_large_image" : "summary" },
|
|
141
|
+
{ name: "twitter:title", content: post.value?.title || undefined },
|
|
142
|
+
{ name: "twitter:description", content: description.value || undefined },
|
|
143
|
+
{ name: "twitter:image", content: image.value },
|
|
144
|
+
{ name: "robots", content: seo.value.no_index ? "noindex, nofollow" : "index, follow" },
|
|
145
|
+
],
|
|
146
|
+
link: () => [{ rel: "canonical", href: `${origin}${post.value?.url || route.path}` }],
|
|
147
|
+
});
|
|
148
|
+
</script>
|
|
149
|
+
|
|
150
|
+
<template>
|
|
151
|
+
<div>
|
|
152
|
+
<SectionRenderer :sections="data.sections" />
|
|
153
|
+
</div>
|
|
154
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { authBus } from "#osi/services/AuthBus";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hydrates the logged-in customer on every page load / refresh.
|
|
5
|
+
* Without this, authBus.userInfo stays null until something explicitly calls
|
|
6
|
+
* checkAuth() (login, verification, profile save), even though the token
|
|
7
|
+
* cookie is present.
|
|
8
|
+
*
|
|
9
|
+
* Not awaited, so it doesn't block hydration; anything that needs the user
|
|
10
|
+
* awaits authBus.init() (same promise, one request).
|
|
11
|
+
*/
|
|
12
|
+
export default defineNuxtPlugin(() => {
|
|
13
|
+
authBus.init();
|
|
14
|
+
});
|
package/app/services/AuthBus.js
CHANGED
|
@@ -15,6 +15,18 @@ export const authBus = reactive({
|
|
|
15
15
|
isRefreshing: false,
|
|
16
16
|
failedRequests: [],
|
|
17
17
|
isLoggingOut: false,
|
|
18
|
+
_initPromise: null,
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fetch customer-info once per page load. Safe to call from anywhere;
|
|
22
|
+
* every caller gets the same in-flight promise.
|
|
23
|
+
*/
|
|
24
|
+
init() {
|
|
25
|
+
if (!this._initPromise) {
|
|
26
|
+
this._initPromise = this.checkAuth().catch(() => {});
|
|
27
|
+
}
|
|
28
|
+
return this._initPromise;
|
|
29
|
+
},
|
|
18
30
|
|
|
19
31
|
async checkAuth() {
|
|
20
32
|
const token = tokenService.getToken();
|
|
@@ -154,9 +166,10 @@ export const authBus = reactive({
|
|
|
154
166
|
if (typeof localStorage !== 'undefined') localStorage.clear();
|
|
155
167
|
this.isLoggedIn = false;
|
|
156
168
|
this.userInfo = null;
|
|
169
|
+
this._initPromise = null;
|
|
157
170
|
eventBus.emit("cartUpdated");
|
|
158
171
|
eventBus.emit("authChanged", { isLoggedIn: false });
|
|
159
172
|
this.isLoggingOut = false;
|
|
160
173
|
}
|
|
161
174
|
},
|
|
162
|
-
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { apiService, getBaseUrl } from "./ApiServices";
|
|
2
|
+
|
|
3
|
+
const ENDPOINT = "store/apis/blog/posts/";
|
|
4
|
+
|
|
5
|
+
const getPosts = async ({ blog = null, limit = 3 } = {}, baseUrl) => {
|
|
6
|
+
baseUrl = baseUrl || getBaseUrl();
|
|
7
|
+
const params = new URLSearchParams({page_size: String(limit) });
|
|
8
|
+
if (blog) params.set("category", String(blog));
|
|
9
|
+
return apiService(`${baseUrl}${ENDPOINT}?${params}`, { method: "GET" });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const getPost = async (slug, baseUrl) => {
|
|
13
|
+
baseUrl = baseUrl || getBaseUrl();
|
|
14
|
+
return apiService(`${baseUrl}${ENDPOINT}${encodeURIComponent(String(slug))}/`, { method: "GET" });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default { getPosts, getPost };
|
package/package.json
CHANGED