@osimart/create-storefront 1.0.14 → 1.0.17
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 +24 -12
- package/app/logic/components/BlogPostPart.js +237 -0
- package/app/logic/sections/BlogPostsSection.js +24 -16
- package/app/logic/sections/MainBlogPostSection.js +47 -0
- package/app/middleware/auth.js +11 -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 +7 -2
- 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.
|
|
@@ -731,22 +738,27 @@ export function useContentTree(section, options = {}) {
|
|
|
731
738
|
// options.raw: types the section draws itself from the tree (the
|
|
732
739
|
// classification page's Toolbar / Product grid) — kept as plain nodes.
|
|
733
740
|
if (options.raw?.includes(block.type)) return base;
|
|
734
|
-
|
|
735
|
-
if (!component) return null;
|
|
736
|
-
// options.customText: { TYPE: default preset } — blocks with the Text
|
|
737
|
-
// block's look (a banner's title…) get it resolved like Text does.
|
|
738
|
-
const textDefault = options.customText?.[block.type];
|
|
739
|
-
return {
|
|
740
|
-
...base,
|
|
741
|
-
...sizeOf(v, "WIDTH", "WIDTH_MOBILE", "FILL"),
|
|
742
|
-
box: boxOf(v, { background: false }),
|
|
743
|
-
...(textDefault ? textLook(v, textDefault) : {}),
|
|
744
|
-
component: markRaw(component),
|
|
745
|
-
};
|
|
741
|
+
return customNode(block, base, v);
|
|
746
742
|
}
|
|
747
743
|
}
|
|
748
744
|
}
|
|
749
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
|
+
|
|
750
762
|
/**
|
|
751
763
|
* VISIBILITY (every content block): shown everywhere, or only on phones /
|
|
752
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
|
+
}
|
|
@@ -48,6 +48,11 @@ export default function useLogic({ props }) {
|
|
|
48
48
|
return id == null ? "" : String(id);
|
|
49
49
|
});
|
|
50
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));
|
|
51
56
|
|
|
52
57
|
// Published only: the request asks for it, and this guards an endpoint that
|
|
53
58
|
// ignores the filter — no drafts / archived, no posts dated in the future.
|
|
@@ -61,10 +66,10 @@ export default function useLogic({ props }) {
|
|
|
61
66
|
const { data: result, pending: loading } = useAsyncData(
|
|
62
67
|
`blog-posts:${props.section.key}`,
|
|
63
68
|
async () => {
|
|
64
|
-
const key = `blog:${baseUrl.value}:${blog.value || "all"}:${
|
|
69
|
+
const key = `blog:${baseUrl.value}:${blog.value || "all"}:${fetchCount.value}`;
|
|
65
70
|
try {
|
|
66
71
|
const res = await cachedRequest(key, () =>
|
|
67
|
-
BlogServices.getPosts({
|
|
72
|
+
BlogServices.getPosts({ blog: blog.value, limit: fetchCount.value }, baseUrl.value)
|
|
68
73
|
);
|
|
69
74
|
const rows = Array.isArray(res) ? res : res?.results ?? [];
|
|
70
75
|
return { rows: rows.filter(isLive), error: null };
|
|
@@ -72,7 +77,7 @@ export default function useLogic({ props }) {
|
|
|
72
77
|
return { rows: [], error: e?.status ? `HTTP ${e.status}` : e?.message || "network error" };
|
|
73
78
|
}
|
|
74
79
|
},
|
|
75
|
-
{ watch: [blog,
|
|
80
|
+
{ watch: [blog, fetchCount], default: () => ({ rows: [], error: null }) }
|
|
76
81
|
);
|
|
77
82
|
const posts = computed(() => result.value?.rows ?? []);
|
|
78
83
|
const failed = computed(() => result.value?.error ?? null);
|
|
@@ -95,18 +100,21 @@ export default function useLogic({ props }) {
|
|
|
95
100
|
];
|
|
96
101
|
|
|
97
102
|
const items = computed(() => {
|
|
98
|
-
const rows = (posts.value ?? [])
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
+
}));
|
|
110
118
|
if (rows.length || !placeholders.allowed?.value) return rows;
|
|
111
119
|
// A picked blog that loaded fine but is empty: the real (empty) state.
|
|
112
120
|
if (blog.value && !failed.value) return rows;
|
|
@@ -209,4 +217,4 @@ export default function useLogic({ props }) {
|
|
|
209
217
|
);
|
|
210
218
|
|
|
211
219
|
return { s, t, shellStyle, innerStyle, customClass, items, loading, notice, tree, template, parts, listSettings, stackGap };
|
|
212
|
-
}
|
|
220
|
+
}
|
|
@@ -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,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards customer-only pages (user-profile, etc.).
|
|
3
|
+
* Reads the same "token" cookie TokenService writes; useCookie works on
|
|
4
|
+
* both SSR and client, so the redirect happens before the page renders.
|
|
5
|
+
*/
|
|
6
|
+
export default defineNuxtRouteMiddleware((to) => {
|
|
7
|
+
const token = useCookie("token");
|
|
8
|
+
if (!token.value) {
|
|
9
|
+
return navigateTo({ path: "/login", query: { redirect: to.fullPath } });
|
|
10
|
+
}
|
|
11
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -4,9 +4,14 @@ const ENDPOINT = "store/apis/blog/posts/";
|
|
|
4
4
|
|
|
5
5
|
const getPosts = async ({ blog = null, limit = 3 } = {}, baseUrl) => {
|
|
6
6
|
baseUrl = baseUrl || getBaseUrl();
|
|
7
|
-
const params = new URLSearchParams({
|
|
7
|
+
const params = new URLSearchParams({page_size: String(limit) });
|
|
8
8
|
if (blog) params.set("category", String(blog));
|
|
9
9
|
return apiService(`${baseUrl}${ENDPOINT}?${params}`, { method: "GET" });
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
|
|
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