@osimart/create-storefront 1.0.13 → 1.0.14
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/useContentTree.js +19 -0
- package/app/logic/components/CardList.js +6 -3
- package/app/logic/components/ProductGallery.js +14 -2
- package/app/logic/sections/BlogPostsSection.js +212 -0
- package/app/logic/sections/ContentNode.js +23 -2
- package/app/services/BlogServices.js +12 -0
- package/package.json +1 -1
|
@@ -519,6 +519,25 @@ export function useContentTree(section, options = {}) {
|
|
|
519
519
|
};
|
|
520
520
|
}
|
|
521
521
|
|
|
522
|
+
// Blog posts' card: the post's author / date / blog line.
|
|
523
|
+
case "BLOG_POST_INFO":
|
|
524
|
+
return {
|
|
525
|
+
...base,
|
|
526
|
+
...sizeOf(v, "WIDTH", null, "FIT"),
|
|
527
|
+
box: boxOf(v),
|
|
528
|
+
...textLook(v, "CUSTOM"),
|
|
529
|
+
part: "info",
|
|
530
|
+
info: {
|
|
531
|
+
author: v.SHOW_AUTHOR !== false,
|
|
532
|
+
authorPrefix: t(v.AUTHOR_PREFIX) ?? "",
|
|
533
|
+
date: v.SHOW_DATE !== false,
|
|
534
|
+
dateFormat: upper(v.DATE_FORMAT, "MEDIUM"),
|
|
535
|
+
blog: !!v.SHOW_BLOG,
|
|
536
|
+
separator: { DOT: "·", PIPE: "|", DASH: "—", SLASH: "/" }[upper(v.SEPARATOR, "DOT")] ?? "·",
|
|
537
|
+
},
|
|
538
|
+
clamp: false,
|
|
539
|
+
};
|
|
540
|
+
|
|
522
541
|
case "CARD_TITLE":
|
|
523
542
|
case "CARD_COUNT":
|
|
524
543
|
case "CARD_DESCRIPTION":
|
|
@@ -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,212 @@
|
|
|
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
|
+
|
|
52
|
+
// Published only: the request asks for it, and this guards an endpoint that
|
|
53
|
+
// ignores the filter — no drafts / archived, no posts dated in the future.
|
|
54
|
+
const isLive = (p) => {
|
|
55
|
+
if (String(p?.status ?? "PUBLISHED").toUpperCase() !== "PUBLISHED") return false;
|
|
56
|
+
const at = p?.published_at ? Date.parse(p.published_at) : NaN;
|
|
57
|
+
return !(at > Date.now());
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// { rows, error } — the error travels with the data so SSR hands it over too.
|
|
61
|
+
const { data: result, pending: loading } = useAsyncData(
|
|
62
|
+
`blog-posts:${props.section.key}`,
|
|
63
|
+
async () => {
|
|
64
|
+
const key = `blog:${baseUrl.value}:${blog.value || "all"}:${limit.value}`;
|
|
65
|
+
try {
|
|
66
|
+
const res = await cachedRequest(key, () =>
|
|
67
|
+
BlogServices.getPosts({ category: blog.value, limit: limit.value }, baseUrl.value)
|
|
68
|
+
);
|
|
69
|
+
const rows = Array.isArray(res) ? res : res?.results ?? [];
|
|
70
|
+
return { rows: rows.filter(isLive), error: null };
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return { rows: [], error: e?.status ? `HTTP ${e.status}` : e?.message || "network error" };
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{ watch: [blog, limit], default: () => ({ rows: [], error: null }) }
|
|
76
|
+
);
|
|
77
|
+
const posts = computed(() => result.value?.rows ?? []);
|
|
78
|
+
const failed = computed(() => result.value?.error ?? null);
|
|
79
|
+
|
|
80
|
+
const name = (v) => (v && typeof v === "object" ? v.name || v.title || v.full_name || "" : String(v || ""));
|
|
81
|
+
const slugOf = (v) => (v && typeof v === "object" ? v.slug || "" : "");
|
|
82
|
+
|
|
83
|
+
function postUrl(p) {
|
|
84
|
+
if (p.external_link) return p.external_link;
|
|
85
|
+
const blogSlug = slugOf(p.category) || p.category_slug || "news";
|
|
86
|
+
return `/blogs/${blogSlug}/${p.slug || p.id}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const SAMPLES = [
|
|
90
|
+
{ 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." },
|
|
91
|
+
{ title: "Five ways to style the season's essentials", short_desc: "Simple pairings that take you from weekday mornings to weekend dinners." },
|
|
92
|
+
{ title: "Meet the makers", short_desc: "The small workshops and hands behind the pieces you love." },
|
|
93
|
+
{ title: "A guide to caring for what you own", short_desc: "Little habits that keep your favourites looking new for years." },
|
|
94
|
+
{ title: "What we're reading this month", short_desc: "Books, journals and stories that inspired the team lately." },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
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
|
+
}));
|
|
110
|
+
if (rows.length || !placeholders.allowed?.value) return rows;
|
|
111
|
+
// A picked blog that loaded fine but is empty: the real (empty) state.
|
|
112
|
+
if (blog.value && !failed.value) return rows;
|
|
113
|
+
// The editor, nothing real to show: sample posts to design with.
|
|
114
|
+
const day = 86400000;
|
|
115
|
+
return Array.from({ length: limit.value }, (_, i) => ({
|
|
116
|
+
key: `sample-${i}`,
|
|
117
|
+
...SAMPLES[i % SAMPLES.length],
|
|
118
|
+
description: SAMPLES[i % SAMPLES.length].short_desc,
|
|
119
|
+
image: null,
|
|
120
|
+
url: "",
|
|
121
|
+
author: "Jane Doe",
|
|
122
|
+
date: new Date(Date.now() - i * 4 * day).toISOString(),
|
|
123
|
+
blog: "News",
|
|
124
|
+
placeholder: true,
|
|
125
|
+
}));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/* ------------------------------------------------------------- layout */
|
|
129
|
+
|
|
130
|
+
const { tree } = useContentTree(() => props.section, {
|
|
131
|
+
rootDefaults: { DIRECTION: "VERTICAL", GAP: 12 },
|
|
132
|
+
index: () => props.index,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// No Card block (deleted): a default post card, so the list never vanishes.
|
|
136
|
+
const fallback = useContentTree(() => ({
|
|
137
|
+
settings: {},
|
|
138
|
+
blocks: [
|
|
139
|
+
{ key: "__card", type: "CARD", settings: { GAP: 8, HOVER: "ZOOM" } },
|
|
140
|
+
{ key: "__image", type: "CARD_IMAGE", parent_key: "__card", settings: { RATIO: "LANDSCAPE", RADIUS: 8 } },
|
|
141
|
+
{ key: "__title", type: "CARD_TITLE", parent_key: "__card", settings: { PRESET: "H4" } },
|
|
142
|
+
{ key: "__info", type: "BLOG_POST_INFO", parent_key: "__card", settings: {} },
|
|
143
|
+
{ key: "__excerpt", type: "CARD_DESCRIPTION", parent_key: "__card", settings: { LINES: 3, PADDING_TOP: 8 } },
|
|
144
|
+
],
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
const cardNode = computed(() => tree.value.children.find((n) => n.type === "CARD") ?? null);
|
|
148
|
+
const template = computed(() => cardNode.value ?? fallback.tree.value.children[0]);
|
|
149
|
+
|
|
150
|
+
/** The page, in block order; the list goes where the Card is (else last). */
|
|
151
|
+
const parts = computed(() => {
|
|
152
|
+
const out = tree.value.children.map((node) =>
|
|
153
|
+
node.type === "CARD" ? { kind: "list", key: "list" } : { kind: "block", key: node.key, node }
|
|
154
|
+
);
|
|
155
|
+
if (!cardNode.value) out.push({ kind: "list", key: "list" });
|
|
156
|
+
return out;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/** Shopify's settings in CardList's terms. */
|
|
160
|
+
const listSettings = computed(() => {
|
|
161
|
+
const v = s.value;
|
|
162
|
+
const layout = ["GRID", "CAROUSEL", "EDITORIAL"].includes(upper(v.LAYOUT, "GRID")) ? upper(v.LAYOUT, "GRID") : "GRID";
|
|
163
|
+
const cols = num(v.COLUMNS, 3);
|
|
164
|
+
const icons = upper(v.ICONS_STYLE, "ARROW");
|
|
165
|
+
const shape = upper(v.ICONS_SHAPE, "NONE");
|
|
166
|
+
const large = icons.endsWith("_LARGE");
|
|
167
|
+
const plain = shape === "NONE";
|
|
168
|
+
return {
|
|
169
|
+
...v,
|
|
170
|
+
LAYOUT: layout,
|
|
171
|
+
COLUMNS: cols,
|
|
172
|
+
COLUMNS_TABLET: Math.min(cols, 2),
|
|
173
|
+
COLUMNS_MOBILE: num(v.COLUMNS_MOBILE, 2),
|
|
174
|
+
GAP_X: num(v.GAP_X, 16),
|
|
175
|
+
GAP_Y: num(v.GAP_Y, 16),
|
|
176
|
+
// Carousel: the columns are the cards in view; phones show ~1 or ~2.
|
|
177
|
+
SLIDES_PER_VIEW: cols,
|
|
178
|
+
SLIDES_PER_VIEW_TABLET: Math.min(cols, 2.5),
|
|
179
|
+
SLIDES_PER_VIEW_MOBILE: upper(v.MOBILE_CARD_SIZE, "LARGE") === "SMALL" ? 2.2 : 1.4,
|
|
180
|
+
CAROUSEL_LOOP: false,
|
|
181
|
+
// Navigation icons
|
|
182
|
+
SHOW_ARROWS: icons !== "NONE",
|
|
183
|
+
ARROW_POSITION: "SIDES",
|
|
184
|
+
ARROW_SVG: icons.startsWith("CHEVRON") ? CHEVRON_SVG : ARROW_SVG,
|
|
185
|
+
ARROW_SIZE: large ? 52 : 40,
|
|
186
|
+
ARROW_ICON_SIZE: large ? 24 : 18,
|
|
187
|
+
ARROW_SHAPE: shape === "SQUARE" ? "SQUARE" : "CIRCLE",
|
|
188
|
+
ARROW_BG: plain ? "transparent" : undefined,
|
|
189
|
+
ARROW_BORDER_WIDTH: plain ? 0 : 1,
|
|
190
|
+
SHOW_DOTS: false,
|
|
191
|
+
// Editorial: three staggered columns of varied shapes (one on phones).
|
|
192
|
+
EDITORIAL_COLUMNS: Math.min(3, Math.max(2, limit.value)),
|
|
193
|
+
EDITORIAL_COLUMNS_MOBILE: 1,
|
|
194
|
+
EDITORIAL_VARY: true,
|
|
195
|
+
CAROUSEL_ON_MOBILE: !!v.CAROUSEL_ON_MOBILE && layout !== "CAROUSEL",
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const stackGap = computed(() => `${num(s.value.CONTENT_GAP, 12)}px`);
|
|
200
|
+
|
|
201
|
+
const scopeAttr = computed(() => `[data-osi-section="${props.section.id ?? props.section.key}"]`);
|
|
202
|
+
useScopedCss(() => s.value.CUSTOM_CSS, scopeAttr, `osi-section-css-${props.section.id ?? props.section.key}`);
|
|
203
|
+
|
|
204
|
+
// Editor-only line above the list when the samples hide a failed request.
|
|
205
|
+
const notice = computed(() =>
|
|
206
|
+
placeholders.allowed?.value && failed.value
|
|
207
|
+
? `Couldn't load blog posts (${failed.value}) — showing sample posts.`
|
|
208
|
+
: ""
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
return { s, t, shellStyle, innerStyle, customClass, items, loading, notice, tree, template, parts, listSettings, stackGap };
|
|
212
|
+
}
|
|
@@ -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,12 @@
|
|
|
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({ status: "PUBLISHED", page_size: String(limit), ordering: "-published_at" });
|
|
8
|
+
if (blog) params.set("category", String(blog));
|
|
9
|
+
return apiService(`${baseUrl}${ENDPOINT}?${params}`, { method: "GET" });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default { getPosts };
|
package/package.json
CHANGED