@osimart/create-storefront 1.0.14 → 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.
@@ -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 new Promise((resolve) => {
357
- const checkInterval = setInterval(() => {
358
- if (authBus.userInfo) {
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
- const component = options.custom?.[block.type];
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"}:${limit.value}`;
69
+ const key = `blog:${baseUrl.value}:${blog.value || "all"}:${fetchCount.value}`;
65
70
  try {
66
71
  const res = await cachedRequest(key, () =>
67
- BlogServices.getPosts({ category: blog.value, limit: limit.value }, baseUrl.value)
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, limit], default: () => ({ rows: [], error: null }) }
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 ?? []).slice(0, limit.value).map((p) => ({
99
- key: p.id ?? p.slug,
100
- title: t(p.title) || "",
101
- description: t(p.short_desc ?? p.excerpt ?? p.description) || "",
102
- image: mediaUrl(p.image ?? p.cover ?? p.featured_image ?? null),
103
- imageAlt: t(p.title) || "",
104
- url: postUrl(p),
105
- author: name(p.author) || p.author_name || "",
106
- date: p.published_at || p.created_at || null,
107
- blog: name(p.category) || p.category_name || "",
108
- raw: p,
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,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
+ });
@@ -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({ status: "PUBLISHED", page_size: String(limit), ordering: "-published_at" });
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
- export default { getPosts };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osimart/create-storefront",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "Osimart storefront platform — logic, platform pages and CLI; extended by every theme repo",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",