@osimart/create-storefront 1.0.7 → 1.0.9
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/app.vue +8 -232
- package/app/components/NotFoundPage.vue +155 -0
- package/app/components/StorefrontShell.vue +252 -0
- package/app/composables/useClassificationPage.js +106 -5
- package/app/composables/useProductDetail.js +16 -17
- package/app/composables/useTextStyles.js +1 -1
- package/app/error.vue +88 -0
- package/app/logic/sections/MainNotFoundSection.js +15 -0
- package/app/pages/[...slug].vue +13 -0
- package/app/services/ProductServices.js +1 -1
- package/package.json +1 -1
package/app/app.vue
CHANGED
|
@@ -1,239 +1,15 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
// Captured before the first await (the request event needs Nuxt's context).
|
|
8
|
-
const requestEvent = import.meta.server ? useRequestEvent() : null;
|
|
9
|
-
const { tokens, rootClasses, fontLinks } = useThemeTokens();
|
|
10
|
-
const { start: startBuilderPreview, stop: stopBuilderPreview } = useBuilderPreview();
|
|
11
|
-
|
|
12
|
-
const { error: fetchError } = await useAsyncData("storefront", () => load());
|
|
13
|
-
const shown = computed(() => error.value ?? fetchError.value ?? null);
|
|
14
|
-
|
|
15
|
-
const unavailableStatus = computed(() => {
|
|
16
|
-
const e = shown.value;
|
|
17
|
-
if (!e) return null;
|
|
18
|
-
const status = Number(e.status ?? e.statusCode) || 0;
|
|
19
|
-
return status >= 400 && status < 500 ? status : 503;
|
|
20
|
-
});
|
|
21
|
-
if (requestEvent && unavailableStatus.value) {
|
|
22
|
-
setResponseStatus(requestEvent, unavailableStatus.value);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const publicConfig = useRuntimeConfig().public;
|
|
26
|
-
const osimartUrl = String(publicConfig.osimartUrl || "https://osimart.com").replace(/\/+$/, "");
|
|
27
|
-
const signupUrl = publicConfig.osimartSignupUrl || `${osimartUrl}/`;
|
|
28
|
-
|
|
29
|
-
const isDev = import.meta.dev;
|
|
30
|
-
|
|
31
|
-
onMounted(() => {
|
|
32
|
-
if (!import.meta.client) return;
|
|
33
|
-
let isFramed = false;
|
|
34
|
-
try {
|
|
35
|
-
isFramed = window.parent !== window || window.self !== window.top;
|
|
36
|
-
} catch {
|
|
37
|
-
isFramed = true;
|
|
38
|
-
}
|
|
39
|
-
const route = useRoute();
|
|
40
|
-
if (
|
|
41
|
-
isFramed ||
|
|
42
|
-
route?.path?.includes("__builder-preview") ||
|
|
43
|
-
route?.query?.osi_preview !== undefined ||
|
|
44
|
-
route?.query?.preview !== undefined
|
|
45
|
-
) {
|
|
46
|
-
startBuilderPreview();
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
onBeforeUnmount(() => {
|
|
51
|
-
stopBuilderPreview();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// Favicon, title format, default description / share image, custom CSS.
|
|
55
|
-
useThemeSeo();
|
|
56
|
-
|
|
57
|
-
useHead({
|
|
58
|
-
title: computed(() => (shown.value ? "Store unavailable" : undefined)),
|
|
59
|
-
meta: computed(() => (shown.value ? [{ name: "robots", content: "noindex" }] : [])),
|
|
60
|
-
htmlAttrs: computed(() => ({ dir: dir.value })),
|
|
61
|
-
noscript: computed(() =>
|
|
62
|
-
parseIds(store.value?.meta_pixel_id, "pixel").map((id) => ({
|
|
63
|
-
tagPosition: "bodyOpen",
|
|
64
|
-
innerHTML: `<img height="1" width="1" style="display:none" alt="" src="https://www.facebook.com/tr?id=${id}&ev=PageView&noscript=1" />`,
|
|
65
|
-
})),
|
|
66
|
-
),
|
|
67
|
-
bodyAttrs: computed(() =>
|
|
68
|
-
shown.value
|
|
69
|
-
? { class: "osi-unavailable-body" }
|
|
70
|
-
: {
|
|
71
|
-
style: tokens.value,
|
|
72
|
-
class: rootClasses.value.filter((c) => /^osi-(btns|inputs|labels)/.test(c)),
|
|
73
|
-
}
|
|
74
|
-
),
|
|
75
|
-
// A getter, not a snapshot: a font picked in the editor (or a theme that
|
|
76
|
-
// loads later) must add its stylesheet.
|
|
77
|
-
link: computed(() => [
|
|
78
|
-
...fontLinks.value,
|
|
79
|
-
{
|
|
80
|
-
rel: "stylesheet",
|
|
81
|
-
href: "/css/output.css",
|
|
82
|
-
},
|
|
83
|
-
]),
|
|
84
|
-
})
|
|
2
|
+
/**
|
|
3
|
+
* The storefront app: StorefrontShell (store bootstrap, theme, head, the
|
|
4
|
+
* store-unavailable screen) around the layout and the routed page.
|
|
5
|
+
* error.vue uses the same shell for the 404 page.
|
|
6
|
+
*/
|
|
85
7
|
</script>
|
|
86
8
|
|
|
87
9
|
<template>
|
|
88
|
-
<
|
|
89
|
-
<a :href="osimartUrl" class="osi-unavailable-back">
|
|
90
|
-
<span aria-hidden="true">←</span>
|
|
91
|
-
Osimart
|
|
92
|
-
</a>
|
|
93
|
-
<h1 class="osi-unavailable-title">This store is currently unavailable.</h1>
|
|
94
|
-
<a :href="signupUrl" class="osi-unavailable-cta">Start a free trial</a>
|
|
95
|
-
|
|
96
|
-
<pre v-if="isDev" class="osi-fatal-detail">{{ shown.status ? `HTTP ${shown.status} — ` : "" }}{{ shown.message }}<template v-if="shown.data">
|
|
97
|
-
{{ shown.data }}</template></pre>
|
|
98
|
-
</main>
|
|
99
|
-
|
|
100
|
-
<div v-else class="osi-root" :class="rootClasses" :style="tokens" :dir="dir">
|
|
101
|
-
<NuxtLoadingIndicator
|
|
102
|
-
color="var(--osi-primary-color)"
|
|
103
|
-
error-color="#dc2626"
|
|
104
|
-
:height="3"
|
|
105
|
-
/>
|
|
10
|
+
<StorefrontShell>
|
|
106
11
|
<NuxtLayout>
|
|
107
12
|
<NuxtPage />
|
|
108
13
|
</NuxtLayout>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
</template>
|
|
112
|
-
|
|
113
|
-
<style>
|
|
114
|
-
body {
|
|
115
|
-
font-family: var(--osi-font-body);
|
|
116
|
-
font-size: var(--osi-body-size);
|
|
117
|
-
color: var(--osi-text, #121212);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
.osi-root {
|
|
121
|
-
font-family: var(--osi-font-body);
|
|
122
|
-
font-size: var(--osi-body-size);
|
|
123
|
-
color: var(--osi-text, #121212);
|
|
124
|
-
background: var(--osi-bg, #ffffff);
|
|
125
|
-
}
|
|
126
|
-
/* Section padding: useSectionShell writes desktop and phone values as
|
|
127
|
-
variables; these pick the one that applies. */
|
|
128
|
-
.osi-section {
|
|
129
|
-
--osi-sec-pt-now: var(--osi-sec-pt);
|
|
130
|
-
--osi-sec-pb-now: var(--osi-sec-pb);
|
|
131
|
-
--osi-sec-pl-now: var(--osi-sec-pl);
|
|
132
|
-
--osi-sec-pr-now: var(--osi-sec-pr);
|
|
133
|
-
}
|
|
134
|
-
@media (max-width: 749px) {
|
|
135
|
-
.osi-section {
|
|
136
|
-
--osi-sec-pt-now: var(--osi-sec-pt-m, var(--osi-sec-pt));
|
|
137
|
-
--osi-sec-pb-now: var(--osi-sec-pb-m, var(--osi-sec-pb));
|
|
138
|
-
--osi-sec-pl-now: var(--osi-sec-pl-m, var(--osi-sec-pl));
|
|
139
|
-
--osi-sec-pr-now: var(--osi-sec-pr-m, var(--osi-sec-pr));
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
.osi-root h1,
|
|
143
|
-
.osi-root h2,
|
|
144
|
-
.osi-root h3,
|
|
145
|
-
.osi-root h4,
|
|
146
|
-
.osi-root h5,
|
|
147
|
-
.osi-root h6 {
|
|
148
|
-
font-family: var(--osi-font-heading);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/* The old utility classes (login, account, checkout, cart… ) carry fixed
|
|
152
|
-
families from output.css; point them at the theme's fonts. `body` in front
|
|
153
|
-
beats the single-class utility and still reaches teleported drawers,
|
|
154
|
-
which sit outside .osi-root. */
|
|
155
|
-
body .font-body,
|
|
156
|
-
body .font-poppins,
|
|
157
|
-
body .font-thoren {
|
|
158
|
-
font-family: var(--osi-font-body);
|
|
159
|
-
}
|
|
160
|
-
body .font-heading {
|
|
161
|
-
font-family: var(--osi-font-heading);
|
|
162
|
-
}
|
|
163
|
-
/* ---- Store unavailable (Shopify-style) — no theme tokens on purpose. */
|
|
164
|
-
body.osi-unavailable-body {
|
|
165
|
-
margin: 0;
|
|
166
|
-
background: #20336b;
|
|
167
|
-
}
|
|
168
|
-
.osi-unavailable {
|
|
169
|
-
min-height: 100vh;
|
|
170
|
-
min-height: 100dvh;
|
|
171
|
-
box-sizing: border-box;
|
|
172
|
-
display: flex;
|
|
173
|
-
flex-direction: column;
|
|
174
|
-
align-items: center;
|
|
175
|
-
padding: clamp(5rem, 18vh, 10.5rem) 1.5rem 4rem;
|
|
176
|
-
background: #20336b;
|
|
177
|
-
color: #fff;
|
|
178
|
-
text-align: center;
|
|
179
|
-
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
180
|
-
-webkit-font-smoothing: antialiased;
|
|
181
|
-
}
|
|
182
|
-
.osi-unavailable a {
|
|
183
|
-
color: inherit;
|
|
184
|
-
text-decoration: none;
|
|
185
|
-
}
|
|
186
|
-
.osi-unavailable-back {
|
|
187
|
-
display: inline-flex;
|
|
188
|
-
align-items: center;
|
|
189
|
-
gap: 0.6rem;
|
|
190
|
-
font-size: 1.25rem;
|
|
191
|
-
line-height: 1;
|
|
192
|
-
opacity: 0.95;
|
|
193
|
-
transition: opacity 0.15s ease;
|
|
194
|
-
}
|
|
195
|
-
.osi-unavailable-back:hover {
|
|
196
|
-
opacity: 0.7;
|
|
197
|
-
}
|
|
198
|
-
.osi-unavailable-title {
|
|
199
|
-
margin: 2.25rem 0 0;
|
|
200
|
-
max-width: 18ch;
|
|
201
|
-
font-size: clamp(2.5rem, 5.6vw, 4.75rem);
|
|
202
|
-
font-weight: 400;
|
|
203
|
-
line-height: 1.05;
|
|
204
|
-
letter-spacing: -0.035em;
|
|
205
|
-
text-wrap: balance;
|
|
206
|
-
}
|
|
207
|
-
@media (min-width: 900px) {
|
|
208
|
-
.osi-unavailable-title {
|
|
209
|
-
max-width: none;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
.osi-unavailable-cta {
|
|
213
|
-
margin-top: 1.75rem;
|
|
214
|
-
padding-bottom: 0.15rem;
|
|
215
|
-
font-size: 1.375rem;
|
|
216
|
-
line-height: 1.3;
|
|
217
|
-
border-bottom: 2px solid currentColor;
|
|
218
|
-
transition: opacity 0.15s ease;
|
|
219
|
-
}
|
|
220
|
-
.osi-unavailable-cta:hover {
|
|
221
|
-
opacity: 0.75;
|
|
222
|
-
}
|
|
223
|
-
.osi-unavailable :focus-visible {
|
|
224
|
-
outline: 2px solid #fff;
|
|
225
|
-
outline-offset: 4px;
|
|
226
|
-
border-radius: 2px;
|
|
227
|
-
}
|
|
228
|
-
.osi-fatal-detail {
|
|
229
|
-
display: inline-block;
|
|
230
|
-
margin-top: 3rem;
|
|
231
|
-
padding: 0.75rem 1rem;
|
|
232
|
-
text-align: start;
|
|
233
|
-
background: #fef2f2;
|
|
234
|
-
color: #991b1b;
|
|
235
|
-
border-radius: 6px;
|
|
236
|
-
font-size: 12px;
|
|
237
|
-
white-space: pre-wrap;
|
|
238
|
-
}
|
|
239
|
-
</style>
|
|
14
|
+
</StorefrontShell>
|
|
15
|
+
</template>
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* The 404 page — Shopify's 404 template: the theme's NOT_FOUND template,
|
|
4
|
+
* built in the editor like any page (its "404 page" section, MAIN_NOT_FOUND,
|
|
5
|
+
* plus whatever the merchant added: products, collections, a banner…).
|
|
6
|
+
*
|
|
7
|
+
* Drawn by error.vue for every 404 thrown by a page (unknown product,
|
|
8
|
+
* category, page) and by pages/[...slug].vue for paths no route matches —
|
|
9
|
+
* inside the store's layout, so the shopper keeps the header and footer.
|
|
10
|
+
*
|
|
11
|
+
* The template is fetched as `render("/404")`: a path nothing routes, which
|
|
12
|
+
* the backend answers with the NOT_FOUND template. A backend that sends it
|
|
13
|
+
* with a 404 status is fine too (apiService puts the body on `error.data`).
|
|
14
|
+
* No usable template (an empty one, a theme without the section's component,
|
|
15
|
+
* backend down): a built-in message, so the page is never blank.
|
|
16
|
+
*/
|
|
17
|
+
import { resolveSection } from "#osi/theme/registry";
|
|
18
|
+
import { useRenderContext } from "#osi/composables/useRenderContext";
|
|
19
|
+
|
|
20
|
+
// Captured before the first await (needs Nuxt's context).
|
|
21
|
+
const requestEvent = import.meta.server ? useRequestEvent() : null;
|
|
22
|
+
const { render, store } = useStorefront();
|
|
23
|
+
const { setRenderContext } = useRenderContext();
|
|
24
|
+
|
|
25
|
+
const NOT_FOUND_PATH = "/404";
|
|
26
|
+
|
|
27
|
+
const templateName = (payload) => {
|
|
28
|
+
const t = payload?.template;
|
|
29
|
+
return String((t && typeof t === "object" ? t.name : t) ?? "").toUpperCase();
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const { data } = await useAsyncData(`render:${NOT_FOUND_PATH}`, async () => {
|
|
33
|
+
try {
|
|
34
|
+
return await render(NOT_FOUND_PATH);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e?.data?.sections) return e.data;
|
|
37
|
+
// Kept for the dev note below: why there's no template to draw.
|
|
38
|
+
return { __failed: { status: e?.status ?? e?.statusCode ?? null, message: e?.message ?? String(e) } };
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Only the NOT_FOUND template counts (a store with a page at /404 doesn't).
|
|
43
|
+
const payload = computed(() => {
|
|
44
|
+
const p = data.value;
|
|
45
|
+
if (!p || p.__failed) return null;
|
|
46
|
+
const name = templateName(p);
|
|
47
|
+
return !name || name === "NOT_FOUND" ? p : null;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const sections = computed(() =>
|
|
51
|
+
(payload.value?.sections ?? []).filter((s) => !s.disabled && resolveSection(s.type))
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Dev only: why the built-in message shows instead of the theme's 404 page.
|
|
56
|
+
* Each case is one thing to fix — backend routing, an empty template, or a
|
|
57
|
+
* theme that has no component for the section yet.
|
|
58
|
+
*/
|
|
59
|
+
const isDev = import.meta.dev;
|
|
60
|
+
const devNote = computed(() => {
|
|
61
|
+
if (!isDev || sections.value.length) return "";
|
|
62
|
+
const p = data.value;
|
|
63
|
+
if (!p) return `render("${NOT_FOUND_PATH}") returned nothing.`;
|
|
64
|
+
if (p.__failed) {
|
|
65
|
+
return `render("${NOT_FOUND_PATH}") failed${p.__failed.status ? ` (HTTP ${p.__failed.status})` : ""}: ` +
|
|
66
|
+
`${p.__failed.message}. The backend has to answer ${NOT_FOUND_PATH} with the NOT_FOUND template.`;
|
|
67
|
+
}
|
|
68
|
+
const name = templateName(p);
|
|
69
|
+
if (name && name !== "NOT_FOUND") {
|
|
70
|
+
return `render("${NOT_FOUND_PATH}") answered with the ${name} template, not NOT_FOUND — ` +
|
|
71
|
+
`resolve_path() must send unknown paths (and ${NOT_FOUND_PATH}) to NOT_FOUND.`;
|
|
72
|
+
}
|
|
73
|
+
const all = p.sections ?? [];
|
|
74
|
+
if (!all.length) return "The NOT_FOUND template has no sections. Open \"404 page\" in the editor once — it adds the 404 section.";
|
|
75
|
+
const live = all.filter((s) => !s.disabled);
|
|
76
|
+
if (!live.length) return "Every section of the NOT_FOUND template is hidden.";
|
|
77
|
+
const missing = [...new Set(live.map((s) => s.type))].filter((t) => !resolveSection(t));
|
|
78
|
+
return `No theme component for: ${missing.join(", ")}. Add app/sections/<Name>Section.vue ` +
|
|
79
|
+
`(MAIN_NOT_FOUND → MainNotFoundSection.vue) and restart \`nuxt dev\` — the section map is built at start.`;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (payload.value) setRenderContext(payload.value);
|
|
83
|
+
|
|
84
|
+
// The shopper sees a page; search engines and the browser see a 404.
|
|
85
|
+
if (requestEvent) setResponseStatus(requestEvent, 404);
|
|
86
|
+
|
|
87
|
+
const title = computed(() => {
|
|
88
|
+
const seo = payload.value?.seo ?? {};
|
|
89
|
+
return seo.title_full ?? seo.title ?? ["Page not found", store.value?.name].filter(Boolean).join(" | ");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
useHead({
|
|
93
|
+
title,
|
|
94
|
+
titleTemplate: (value) => value ?? "",
|
|
95
|
+
meta: [{ name: "robots", content: "noindex" }],
|
|
96
|
+
});
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<template>
|
|
100
|
+
<div class="osi-not-found">
|
|
101
|
+
<SectionRenderer v-if="sections.length" :sections="sections" />
|
|
102
|
+
|
|
103
|
+
<section v-else class="osi-404-fallback">
|
|
104
|
+
<p class="osi-404-code">404</p>
|
|
105
|
+
<h1 class="osi-404-title">Page not found</h1>
|
|
106
|
+
<p class="osi-404-text">The page you were looking for doesn't exist or has moved.</p>
|
|
107
|
+
<NuxtLink to="/categories/all" class="osi-btn osi-404-btn">Continue shopping</NuxtLink>
|
|
108
|
+
<pre v-if="devNote" class="osi-404-dev">{{ devNote }}</pre>
|
|
109
|
+
</section>
|
|
110
|
+
</div>
|
|
111
|
+
</template>
|
|
112
|
+
|
|
113
|
+
<style scoped>
|
|
114
|
+
.osi-404-fallback {
|
|
115
|
+
display: flex;
|
|
116
|
+
flex-direction: column;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: 1rem;
|
|
119
|
+
max-width: 36rem;
|
|
120
|
+
margin: 0 auto;
|
|
121
|
+
padding: clamp(4rem, 12vh, 7rem) 1.25rem;
|
|
122
|
+
text-align: center;
|
|
123
|
+
color: var(--osi-text, inherit);
|
|
124
|
+
}
|
|
125
|
+
.osi-404-code {
|
|
126
|
+
margin: 0;
|
|
127
|
+
font-size: 0.875rem;
|
|
128
|
+
letter-spacing: 0.12em;
|
|
129
|
+
opacity: 0.6;
|
|
130
|
+
}
|
|
131
|
+
.osi-404-title {
|
|
132
|
+
margin: 0;
|
|
133
|
+
font-family: var(--osi-font-heading);
|
|
134
|
+
font-size: clamp(2rem, 5vw, 3rem);
|
|
135
|
+
line-height: 1.1;
|
|
136
|
+
}
|
|
137
|
+
.osi-404-text {
|
|
138
|
+
margin: 0;
|
|
139
|
+
opacity: 0.75;
|
|
140
|
+
}
|
|
141
|
+
.osi-404-btn {
|
|
142
|
+
margin-top: 0.75rem;
|
|
143
|
+
}
|
|
144
|
+
.osi-404-dev {
|
|
145
|
+
max-width: 100%;
|
|
146
|
+
margin-top: 1.5rem;
|
|
147
|
+
padding: 0.75rem 1rem;
|
|
148
|
+
text-align: start;
|
|
149
|
+
font-size: 12px;
|
|
150
|
+
white-space: pre-wrap;
|
|
151
|
+
background: #fef2f2;
|
|
152
|
+
color: #991b1b;
|
|
153
|
+
border-radius: 6px;
|
|
154
|
+
}
|
|
155
|
+
</style>
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* Everything around a storefront page: the store's bootstrap, theme tokens,
|
|
4
|
+
* fonts, head tags, the builder-preview bridge — and the store-unavailable
|
|
5
|
+
* screen when there's no store to draw.
|
|
6
|
+
*
|
|
7
|
+
* Shared by app.vue (every page) and error.vue (the 404 page and errors), so a
|
|
8
|
+
* missing page still gets the store's header, footer, colours and fonts.
|
|
9
|
+
* Only this file loads the store: app.vue and error.vue just wrap their page.
|
|
10
|
+
*/
|
|
11
|
+
// Global on purpose: every button and field in the storefront reads it.
|
|
12
|
+
import "#osi/assets/css/osi-controls.css";
|
|
13
|
+
import { parseIds } from "#osi/utils/analytics";
|
|
14
|
+
|
|
15
|
+
const { load, dir, store, error } = useStorefront();
|
|
16
|
+
// Captured before the first await (the request event needs Nuxt's context).
|
|
17
|
+
const requestEvent = import.meta.server ? useRequestEvent() : null;
|
|
18
|
+
const { tokens, rootClasses, fontLinks } = useThemeTokens();
|
|
19
|
+
const { start: startBuilderPreview, stop: stopBuilderPreview } = useBuilderPreview();
|
|
20
|
+
|
|
21
|
+
const { error: fetchError } = await useAsyncData("storefront", () => load());
|
|
22
|
+
const shown = computed(() => error.value ?? fetchError.value ?? null);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Store unavailable (unknown address, switched off, backend down): a page of
|
|
26
|
+
* its own, like Shopify's — no theme, no header / footer around it. The
|
|
27
|
+
* response carries a real status: the backend's 4xx, else 503.
|
|
28
|
+
*/
|
|
29
|
+
const unavailableStatus = computed(() => {
|
|
30
|
+
const e = shown.value;
|
|
31
|
+
if (!e) return null;
|
|
32
|
+
const status = Number(e.status ?? e.statusCode) || 0;
|
|
33
|
+
return status >= 400 && status < 500 ? status : 503;
|
|
34
|
+
});
|
|
35
|
+
if (requestEvent && unavailableStatus.value) {
|
|
36
|
+
setResponseStatus(requestEvent, unavailableStatus.value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const publicConfig = useRuntimeConfig().public;
|
|
40
|
+
const osimartUrl = String(publicConfig.osimartUrl || "https://osimart.com").replace(/\/+$/, "");
|
|
41
|
+
const signupUrl = publicConfig.osimartSignupUrl || `${osimartUrl}/`;
|
|
42
|
+
|
|
43
|
+
const isDev = import.meta.dev;
|
|
44
|
+
|
|
45
|
+
onMounted(() => {
|
|
46
|
+
if (!import.meta.client) return;
|
|
47
|
+
let isFramed = false;
|
|
48
|
+
try {
|
|
49
|
+
isFramed = window.parent !== window || window.self !== window.top;
|
|
50
|
+
} catch {
|
|
51
|
+
isFramed = true;
|
|
52
|
+
}
|
|
53
|
+
const route = useRoute();
|
|
54
|
+
if (
|
|
55
|
+
isFramed ||
|
|
56
|
+
route?.path?.includes("__builder-preview") ||
|
|
57
|
+
route?.query?.osi_preview !== undefined ||
|
|
58
|
+
route?.query?.preview !== undefined
|
|
59
|
+
) {
|
|
60
|
+
startBuilderPreview();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
onBeforeUnmount(() => {
|
|
65
|
+
stopBuilderPreview();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Favicon, title format, default description / share image, custom CSS.
|
|
69
|
+
useThemeSeo();
|
|
70
|
+
|
|
71
|
+
useHead({
|
|
72
|
+
title: computed(() => (shown.value ? "Store unavailable" : undefined)),
|
|
73
|
+
meta: computed(() => (shown.value ? [{ name: "robots", content: "noindex" }] : [])),
|
|
74
|
+
htmlAttrs: computed(() => ({ dir: dir.value })),
|
|
75
|
+
noscript: computed(() =>
|
|
76
|
+
parseIds(store.value?.meta_pixel_id, "pixel").map((id) => ({
|
|
77
|
+
tagPosition: "bodyOpen",
|
|
78
|
+
innerHTML: `<img height="1" width="1" style="display:none" alt="" src="https://www.facebook.com/tr?id=${id}&ev=PageView&noscript=1" />`,
|
|
79
|
+
})),
|
|
80
|
+
),
|
|
81
|
+
bodyAttrs: computed(() =>
|
|
82
|
+
shown.value
|
|
83
|
+
? { class: "osi-unavailable-body" }
|
|
84
|
+
: {
|
|
85
|
+
style: tokens.value,
|
|
86
|
+
class: rootClasses.value.filter((c) => /^osi-(btns|inputs|labels)/.test(c)),
|
|
87
|
+
}
|
|
88
|
+
),
|
|
89
|
+
// A getter, not a snapshot: a font picked in the editor (or a theme that
|
|
90
|
+
// loads later) must add its stylesheet.
|
|
91
|
+
link: computed(() => [
|
|
92
|
+
...fontLinks.value,
|
|
93
|
+
{
|
|
94
|
+
rel: "stylesheet",
|
|
95
|
+
href: "/css/output.css",
|
|
96
|
+
},
|
|
97
|
+
]),
|
|
98
|
+
})
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<template>
|
|
102
|
+
<main v-if="shown" class="osi-unavailable" dir="ltr">
|
|
103
|
+
<a :href="osimartUrl" class="osi-unavailable-back">
|
|
104
|
+
<span aria-hidden="true">←</span>
|
|
105
|
+
Osimart
|
|
106
|
+
</a>
|
|
107
|
+
<h1 class="osi-unavailable-title">This store is currently unavailable.</h1>
|
|
108
|
+
<a :href="signupUrl" class="osi-unavailable-cta">Start a free trial</a>
|
|
109
|
+
|
|
110
|
+
<pre v-if="isDev" class="osi-fatal-detail">{{ shown.status ? `HTTP ${shown.status} — ` : "" }}{{ shown.message }}<template v-if="shown.data">
|
|
111
|
+
{{ shown.data }}</template></pre>
|
|
112
|
+
</main>
|
|
113
|
+
|
|
114
|
+
<div v-else class="osi-root" :class="rootClasses" :style="tokens" :dir="dir">
|
|
115
|
+
<NuxtLoadingIndicator
|
|
116
|
+
color="var(--osi-primary-color)"
|
|
117
|
+
error-color="#dc2626"
|
|
118
|
+
:height="3"
|
|
119
|
+
/>
|
|
120
|
+
<!-- The page: app.vue's NuxtLayout + NuxtPage, or error.vue's 404 / error. -->
|
|
121
|
+
<slot />
|
|
122
|
+
<div id="osi-teleport-root" />
|
|
123
|
+
</div>
|
|
124
|
+
</template>
|
|
125
|
+
|
|
126
|
+
<style>
|
|
127
|
+
body {
|
|
128
|
+
font-family: var(--osi-font-body);
|
|
129
|
+
font-size: var(--osi-body-size);
|
|
130
|
+
color: var(--osi-text, #121212);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.osi-root {
|
|
134
|
+
font-family: var(--osi-font-body);
|
|
135
|
+
font-size: var(--osi-body-size);
|
|
136
|
+
color: var(--osi-text, #121212);
|
|
137
|
+
background: var(--osi-bg, #ffffff);
|
|
138
|
+
}
|
|
139
|
+
/* Section padding: useSectionShell writes desktop and phone values as
|
|
140
|
+
variables; these pick the one that applies. */
|
|
141
|
+
.osi-section {
|
|
142
|
+
--osi-sec-pt-now: var(--osi-sec-pt);
|
|
143
|
+
--osi-sec-pb-now: var(--osi-sec-pb);
|
|
144
|
+
--osi-sec-pl-now: var(--osi-sec-pl);
|
|
145
|
+
--osi-sec-pr-now: var(--osi-sec-pr);
|
|
146
|
+
}
|
|
147
|
+
@media (max-width: 749px) {
|
|
148
|
+
.osi-section {
|
|
149
|
+
--osi-sec-pt-now: var(--osi-sec-pt-m, var(--osi-sec-pt));
|
|
150
|
+
--osi-sec-pb-now: var(--osi-sec-pb-m, var(--osi-sec-pb));
|
|
151
|
+
--osi-sec-pl-now: var(--osi-sec-pl-m, var(--osi-sec-pl));
|
|
152
|
+
--osi-sec-pr-now: var(--osi-sec-pr-m, var(--osi-sec-pr));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
.osi-root h1,
|
|
156
|
+
.osi-root h2,
|
|
157
|
+
.osi-root h3,
|
|
158
|
+
.osi-root h4,
|
|
159
|
+
.osi-root h5,
|
|
160
|
+
.osi-root h6 {
|
|
161
|
+
font-family: var(--osi-font-heading);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* The old utility classes (login, account, checkout, cart… ) carry fixed
|
|
165
|
+
families from output.css; point them at the theme's fonts. `body` in front
|
|
166
|
+
beats the single-class utility and still reaches teleported drawers,
|
|
167
|
+
which sit outside .osi-root. */
|
|
168
|
+
body .font-body,
|
|
169
|
+
body .font-poppins,
|
|
170
|
+
body .font-thoren {
|
|
171
|
+
font-family: var(--osi-font-body);
|
|
172
|
+
}
|
|
173
|
+
body .font-heading {
|
|
174
|
+
font-family: var(--osi-font-heading);
|
|
175
|
+
}
|
|
176
|
+
body.osi-unavailable-body {
|
|
177
|
+
margin: 0;
|
|
178
|
+
background: #20336b;
|
|
179
|
+
}
|
|
180
|
+
.osi-unavailable {
|
|
181
|
+
min-height: 100vh;
|
|
182
|
+
min-height: 100dvh;
|
|
183
|
+
box-sizing: border-box;
|
|
184
|
+
display: flex;
|
|
185
|
+
flex-direction: column;
|
|
186
|
+
align-items: center;
|
|
187
|
+
padding: clamp(5rem, 18vh, 10.5rem) 1.5rem 4rem;
|
|
188
|
+
background: #20336b;
|
|
189
|
+
color: #fff;
|
|
190
|
+
text-align: center;
|
|
191
|
+
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
192
|
+
-webkit-font-smoothing: antialiased;
|
|
193
|
+
}
|
|
194
|
+
.osi-unavailable a {
|
|
195
|
+
color: inherit;
|
|
196
|
+
text-decoration: none;
|
|
197
|
+
}
|
|
198
|
+
.osi-unavailable-back {
|
|
199
|
+
display: inline-flex;
|
|
200
|
+
align-items: center;
|
|
201
|
+
gap: 0.6rem;
|
|
202
|
+
font-size: 18px;
|
|
203
|
+
line-height: 1;
|
|
204
|
+
opacity: 0.95;
|
|
205
|
+
transition: opacity 0.15s ease;
|
|
206
|
+
}
|
|
207
|
+
.osi-unavailable-back:hover {
|
|
208
|
+
opacity: 0.7;
|
|
209
|
+
}
|
|
210
|
+
.osi-unavailable-title {
|
|
211
|
+
margin: 2.25rem 0 0;
|
|
212
|
+
max-width: 18ch;
|
|
213
|
+
|
|
214
|
+
font-size: clamp(44px, 4.15vw, 64px);
|
|
215
|
+
font-weight: 400;
|
|
216
|
+
line-height: 1.05;
|
|
217
|
+
letter-spacing: -0.035em;
|
|
218
|
+
text-wrap: balance;
|
|
219
|
+
}
|
|
220
|
+
@media (min-width: 900px) {
|
|
221
|
+
.osi-unavailable-title {
|
|
222
|
+
max-width: none;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
.osi-unavailable-cta {
|
|
226
|
+
margin-top: 1.75rem;
|
|
227
|
+
padding-bottom: 0.15rem;
|
|
228
|
+
font-size: 20px;
|
|
229
|
+
line-height: 1.3;
|
|
230
|
+
border-bottom: 2px solid currentColor;
|
|
231
|
+
transition: opacity 0.15s ease;
|
|
232
|
+
}
|
|
233
|
+
.osi-unavailable-cta:hover {
|
|
234
|
+
opacity: 0.75;
|
|
235
|
+
}
|
|
236
|
+
.osi-unavailable :focus-visible {
|
|
237
|
+
outline: 2px solid #fff;
|
|
238
|
+
outline-offset: 4px;
|
|
239
|
+
border-radius: 2px;
|
|
240
|
+
}
|
|
241
|
+
.osi-fatal-detail {
|
|
242
|
+
display: inline-block;
|
|
243
|
+
margin-top: 3rem;
|
|
244
|
+
padding: 0.75rem 1rem;
|
|
245
|
+
text-align: start;
|
|
246
|
+
background: #fef2f2;
|
|
247
|
+
color: #252121;
|
|
248
|
+
border-radius: 6px;
|
|
249
|
+
font-size: 12px;
|
|
250
|
+
white-space: pre-wrap;
|
|
251
|
+
}
|
|
252
|
+
</style>
|
|
@@ -140,6 +140,47 @@ const SORTS = {
|
|
|
140
140
|
|
|
141
141
|
const asArray = (v) => (Array.isArray(v) ? v : v == null || v === "" ? [] : [v]);
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Filter groups for the catalog's own lists — category, collection, brand —
|
|
145
|
+
* next to the variant filters (size, colour…). Each is a toggle on the
|
|
146
|
+
* toolbar block (FILTER_BY_*), off until turned on, and is never offered on a page of its own kind:
|
|
147
|
+
* a collection page filters by category and brand, a category page by
|
|
148
|
+
* collection and brand; "All products" and search get all three.
|
|
149
|
+
*
|
|
150
|
+
* query what the storefront URL carries (?brand=3&category=5)
|
|
151
|
+
* api the products API parameter it becomes (repeated for several)
|
|
152
|
+
* prefix how its values are told apart from variant values in the UI
|
|
153
|
+
*/
|
|
154
|
+
const LIST_FILTERS = [
|
|
155
|
+
{ key: "category", kind: "CATEGORY", query: "category", api: "categories",
|
|
156
|
+
setting: "FILTER_BY_CATEGORY", label: "FILTER_CATEGORY_LABEL", fallback: "Category" },
|
|
157
|
+
{ key: "collection", kind: "COLLECTION", query: "collection", api: "collections",
|
|
158
|
+
setting: "FILTER_BY_COLLECTION", label: "FILTER_COLLECTION_LABEL", fallback: "Collection" },
|
|
159
|
+
{ key: "brand", kind: "BRAND", query: "brand", api: "brand",
|
|
160
|
+
setting: "FILTER_BY_BRAND", label: "FILTER_BRAND_LABEL", fallback: "Brand" },
|
|
161
|
+
];
|
|
162
|
+
const LIST_FILTER_BY_KEY = Object.fromEntries(LIST_FILTERS.map((d) => [d.key, d]));
|
|
163
|
+
|
|
164
|
+
/** "brand:3" -> { def, id: "3" }; a plain variant value id -> null. */
|
|
165
|
+
function splitValue(value) {
|
|
166
|
+
const at = String(value).indexOf(":");
|
|
167
|
+
if (at < 0) return null;
|
|
168
|
+
const def = LIST_FILTER_BY_KEY[String(value).slice(0, at)];
|
|
169
|
+
return def ? { def, id: String(value).slice(at + 1) } : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Categories arrive nested (children); the filter lists them all. */
|
|
173
|
+
function flattenRows(rows, out = []) {
|
|
174
|
+
for (const row of rows ?? []) {
|
|
175
|
+
if (!row) continue;
|
|
176
|
+
out.push(row);
|
|
177
|
+
flattenRows(row.children, out);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const rowCount = (row) => row?.product_count ?? row?.products_count ?? row?.count ?? null;
|
|
183
|
+
|
|
143
184
|
/**
|
|
144
185
|
* @param {() => object} section the MAIN_CLASSIFICATION section (getter)
|
|
145
186
|
*/
|
|
@@ -356,7 +397,15 @@ export function useClassificationPage(section) {
|
|
|
356
397
|
});
|
|
357
398
|
|
|
358
399
|
const ordering = computed(() => String(route.query.ordering ?? ""));
|
|
359
|
-
|
|
400
|
+
/**
|
|
401
|
+
* Everything selected, as one list of ids: variant values as they are, the
|
|
402
|
+
* category / collection / brand picks prefixed ("brand:3"). The filter UI
|
|
403
|
+
* only ever sees this list; setValues() splits it back into the URL.
|
|
404
|
+
*/
|
|
405
|
+
const selectedValues = computed(() => [
|
|
406
|
+
...asArray(route.query.value).map(String),
|
|
407
|
+
...LIST_FILTERS.flatMap((def) => asArray(route.query[def.query]).map((id) => `${def.key}:${id}`)),
|
|
408
|
+
]);
|
|
360
409
|
const perPage = computed(() => Math.max(1, Number(s.value.PRODUCTS_PER_PAGE) || 12));
|
|
361
410
|
const pagination = computed(() => String(s.value.PAGINATION || "INFINITE").toUpperCase());
|
|
362
411
|
const page = computed(() =>
|
|
@@ -369,12 +418,49 @@ export function useClassificationPage(section) {
|
|
|
369
418
|
{ default: () => [], server: false, lazy: true }
|
|
370
419
|
);
|
|
371
420
|
|
|
421
|
+
// Brands for the brand filter. Only fetched when it's offered.
|
|
422
|
+
const brandFilterOn = computed(() => s.value.FILTER_BY_BRAND === true && kind.value !== "BRAND");
|
|
423
|
+
const brandsData = useAsyncData(
|
|
424
|
+
"osi-catalog:brands",
|
|
425
|
+
() =>
|
|
426
|
+
brandFilterOn.value
|
|
427
|
+
? apiService(`${baseUrl.value}store/apis/brands/`, { method: "GET" }).then(rowsOf).catch(() => [])
|
|
428
|
+
: Promise.resolve([]),
|
|
429
|
+
{ default: () => [], server: false, lazy: true, watch: [brandFilterOn] }
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const listRows = {
|
|
433
|
+
category: () => flattenRows(catalog.categories.data.value),
|
|
434
|
+
collection: () => catalog.collections.data.value ?? [],
|
|
435
|
+
brand: () => brandsData.data.value ?? [],
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
/** Category / collection / brand groups, as variant-filter-shaped groups. */
|
|
439
|
+
const listFilters = computed(() =>
|
|
440
|
+
LIST_FILTERS.filter((def) => def.kind !== kind.value && s.value[def.setting] === true)
|
|
441
|
+
.map((def) => ({
|
|
442
|
+
id: `osi-${def.key}`,
|
|
443
|
+
name: t(s.value[def.label]) || def.fallback,
|
|
444
|
+
isColor: false,
|
|
445
|
+
values: listRows[def.key]()
|
|
446
|
+
.filter((row) => row?.id != null && rowCount(row) !== 0)
|
|
447
|
+
.map((row) => ({
|
|
448
|
+
id: `${def.key}:${row.id}`,
|
|
449
|
+
name: row.name ?? row.title ?? String(row.id),
|
|
450
|
+
color: null,
|
|
451
|
+
image: null,
|
|
452
|
+
})),
|
|
453
|
+
}))
|
|
454
|
+
.filter((f) => f.values.length)
|
|
455
|
+
);
|
|
456
|
+
|
|
372
457
|
const filters = computed(() => {
|
|
373
458
|
const hidden = String(s.value.FILTER_HIDE ?? "")
|
|
374
459
|
.split(",")
|
|
375
460
|
.map((x) => x.trim().toLowerCase())
|
|
376
461
|
.filter(Boolean);
|
|
377
|
-
|
|
462
|
+
const lists = listFilters.value.filter((f) => !hidden.includes(String(f.name).toLowerCase()));
|
|
463
|
+
const variants = (filtersData.data.value ?? [])
|
|
378
464
|
.filter((f) => f?.values?.length)
|
|
379
465
|
.filter((f) => !hidden.includes(String(f.name ?? "").toLowerCase()))
|
|
380
466
|
.map((f) => ({
|
|
@@ -388,6 +474,7 @@ export function useClassificationPage(section) {
|
|
|
388
474
|
image: v.image?.path ? mediaUrl(v.image) : null,
|
|
389
475
|
})),
|
|
390
476
|
}));
|
|
477
|
+
return [...lists, ...variants];
|
|
391
478
|
});
|
|
392
479
|
|
|
393
480
|
const activeFilters = computed(() => {
|
|
@@ -409,7 +496,14 @@ export function useClassificationPage(section) {
|
|
|
409
496
|
return pushQuery({ ordering: value || undefined, page: undefined });
|
|
410
497
|
}
|
|
411
498
|
function setValues(ids) {
|
|
412
|
-
|
|
499
|
+
const variant = [];
|
|
500
|
+
const lists = Object.fromEntries(LIST_FILTERS.map((def) => [def.query, []]));
|
|
501
|
+
for (const id of new Set(ids.map(String))) {
|
|
502
|
+
const hit = splitValue(id);
|
|
503
|
+
if (hit) lists[hit.def.query].push(hit.id);
|
|
504
|
+
else variant.push(id);
|
|
505
|
+
}
|
|
506
|
+
return pushQuery({ value: variant, ...lists, page: undefined });
|
|
413
507
|
}
|
|
414
508
|
function toggleValue(id) {
|
|
415
509
|
const set = new Set(selectedValues.value);
|
|
@@ -418,7 +512,8 @@ export function useClassificationPage(section) {
|
|
|
418
512
|
return setValues([...set]);
|
|
419
513
|
}
|
|
420
514
|
function clearFilters() {
|
|
421
|
-
|
|
515
|
+
const lists = Object.fromEntries(LIST_FILTERS.map((def) => [def.query, undefined]));
|
|
516
|
+
return pushQuery({ value: undefined, ...lists, ordering: undefined, page: undefined });
|
|
422
517
|
}
|
|
423
518
|
function goToPage(n) {
|
|
424
519
|
return pushQuery({ page: n > 1 ? n : undefined });
|
|
@@ -436,7 +531,13 @@ export function useClassificationPage(section) {
|
|
|
436
531
|
sp.set("page_size", perPage.value);
|
|
437
532
|
const order = ordering.value || defaultOrdering.value;
|
|
438
533
|
if (order) sp.set("ordering", order);
|
|
439
|
-
for (const v of selectedValues.value)
|
|
534
|
+
for (const v of selectedValues.value) {
|
|
535
|
+
const hit = splitValue(v);
|
|
536
|
+
if (!hit) sp.append("variant_values", v);
|
|
537
|
+
// The page's own kind is already its filter (a stray ?category= on a
|
|
538
|
+
// category page would widen it, not narrow it).
|
|
539
|
+
else if (hit.def.kind !== kind.value) sp.append(hit.def.api, hit.id);
|
|
540
|
+
}
|
|
440
541
|
return apiService(`${baseUrl.value}store/apis/products/?${sp.toString()}`, { method: "GET" });
|
|
441
542
|
}
|
|
442
543
|
|
|
@@ -62,30 +62,29 @@ export function parsePriceRange(value) {
|
|
|
62
62
|
|
|
63
63
|
/* ------------------------------------------------------------- the source */
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
65
|
+
async function loadProduct(idOrSlug) {
|
|
66
|
+
let product = null;
|
|
67
|
+
try {
|
|
68
|
+
product = await ProductServices.getProductDetail(encodeURIComponent(String(idOrSlug)));
|
|
69
|
+
} catch (err) {
|
|
70
|
+
// A real failure (5xx, network) is a 500, not "not found".
|
|
71
|
+
if (err?.status !== 404) {
|
|
72
|
+
throw createError({
|
|
73
|
+
statusCode: 500,
|
|
74
|
+
statusMessage: "Couldn't load this product",
|
|
75
|
+
fatal: true,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
76
79
|
|
|
77
|
-
if (!
|
|
80
|
+
if (!product?.id) {
|
|
78
81
|
throw createError({
|
|
79
82
|
statusCode: 404,
|
|
80
83
|
statusMessage: "Product not found",
|
|
81
84
|
fatal: true,
|
|
82
85
|
});
|
|
83
86
|
}
|
|
84
|
-
|
|
85
|
-
// A failure here must not 404 a product that exists — the summary alone is
|
|
86
|
-
// enough to render a card-level page.
|
|
87
|
-
const detail = await ProductServices.getProductDetail(summary.id).catch(() => null);
|
|
88
|
-
return detail?.id ? { ...summary, ...detail } : summary;
|
|
87
|
+
return product;
|
|
89
88
|
}
|
|
90
89
|
|
|
91
90
|
/**
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* @param {object} [options.defaults] size / weight / font / transform
|
|
32
32
|
* @returns {ComputedRef<object>}
|
|
33
33
|
*/
|
|
34
|
-
const FONT_WEIGHTS = { REGULAR: 400, MEDIUM: 500, SEMIBOLD: 600, BOLD: 700 };
|
|
34
|
+
const FONT_WEIGHTS = { LIGHT: 300, REGULAR: 400, MEDIUM: 500, SEMIBOLD: 600, BOLD: 700 };
|
|
35
35
|
const FONT_FAMILIES = { HEADING: "var(--osi-font-heading)", BODY: "var(--osi-font-body)" };
|
|
36
36
|
const TEXT_TRANSFORMS = {
|
|
37
37
|
NONE: "none",
|
package/app/error.vue
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* Nuxt's error page, inside the store's shell and layout.
|
|
4
|
+
*
|
|
5
|
+
* 404 the theme's 404 page (NotFoundPage): a missing product, category or
|
|
6
|
+
* page — anything a page throws a 404 for — keeps the header and
|
|
7
|
+
* footer, like Shopify.
|
|
8
|
+
* else a short "something went wrong" with a way back.
|
|
9
|
+
*
|
|
10
|
+
* Following any link clears the error (Nuxt does that on navigation).
|
|
11
|
+
*/
|
|
12
|
+
const props = defineProps({
|
|
13
|
+
error: { type: Object, default: null },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const status = computed(() => Number(props.error?.statusCode ?? props.error?.status) || 500);
|
|
17
|
+
const notFound = computed(() => status.value === 404);
|
|
18
|
+
const isDev = import.meta.dev;
|
|
19
|
+
|
|
20
|
+
useHead({
|
|
21
|
+
title: computed(() => (notFound.value ? undefined : "Something went wrong")),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function goHome() {
|
|
25
|
+
clearError({ redirect: "/" });
|
|
26
|
+
}
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<StorefrontShell>
|
|
31
|
+
<NuxtLayout>
|
|
32
|
+
<NotFoundPage v-if="notFound" />
|
|
33
|
+
|
|
34
|
+
<section v-else class="osi-error">
|
|
35
|
+
<p class="osi-error-code">{{ status }}</p>
|
|
36
|
+
<h1 class="osi-error-title">Something went wrong</h1>
|
|
37
|
+
<p class="osi-error-text">We couldn't load this page. Please try again in a moment.</p>
|
|
38
|
+
<button type="button" class="osi-btn osi-error-btn" @click="goHome">Back to the homepage</button>
|
|
39
|
+
<pre v-if="isDev && error" class="osi-error-detail">{{ error.message }}</pre>
|
|
40
|
+
</section>
|
|
41
|
+
</NuxtLayout>
|
|
42
|
+
</StorefrontShell>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<style scoped>
|
|
46
|
+
.osi-error {
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
align-items: center;
|
|
50
|
+
gap: 1rem;
|
|
51
|
+
max-width: 36rem;
|
|
52
|
+
margin: 0 auto;
|
|
53
|
+
padding: clamp(4rem, 12vh, 7rem) 1.25rem;
|
|
54
|
+
text-align: center;
|
|
55
|
+
color: var(--osi-text, inherit);
|
|
56
|
+
}
|
|
57
|
+
.osi-error-code {
|
|
58
|
+
margin: 0;
|
|
59
|
+
font-size: 0.875rem;
|
|
60
|
+
letter-spacing: 0.12em;
|
|
61
|
+
opacity: 0.6;
|
|
62
|
+
}
|
|
63
|
+
.osi-error-title {
|
|
64
|
+
margin: 0;
|
|
65
|
+
font-family: var(--osi-font-heading);
|
|
66
|
+
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
|
67
|
+
line-height: 1.15;
|
|
68
|
+
}
|
|
69
|
+
.osi-error-text {
|
|
70
|
+
margin: 0;
|
|
71
|
+
opacity: 0.75;
|
|
72
|
+
}
|
|
73
|
+
.osi-error-btn {
|
|
74
|
+
margin-top: 0.75rem;
|
|
75
|
+
}
|
|
76
|
+
.osi-error-detail {
|
|
77
|
+
max-width: 100%;
|
|
78
|
+
margin-top: 1.5rem;
|
|
79
|
+
padding: 0.75rem 1rem;
|
|
80
|
+
overflow: auto;
|
|
81
|
+
text-align: start;
|
|
82
|
+
font-size: 12px;
|
|
83
|
+
white-space: pre-wrap;
|
|
84
|
+
background: #fef2f2;
|
|
85
|
+
color: #991b1b;
|
|
86
|
+
border-radius: 6px;
|
|
87
|
+
}
|
|
88
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ClassificationPart } from "#components";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MAIN_NOT_FOUND — the 404 page's section ("404 page").
|
|
5
|
+
*
|
|
6
|
+
* The same block-built section as Rich text (MediaContentSection +
|
|
7
|
+
* ContentNode), stacked and centred, plus the search page's Search box block
|
|
8
|
+
* (drawn by ClassificationPart; submitting goes to /search?q=).
|
|
9
|
+
*/
|
|
10
|
+
export default function useLogic() {
|
|
11
|
+
const ROOT_DEFAULTS = { DIRECTION: "VERTICAL", ALIGNMENT: "CENTER", POSITION: "CENTER", GAP: 16 };
|
|
12
|
+
const CUSTOM = { SEARCH_INPUT: ClassificationPart };
|
|
13
|
+
|
|
14
|
+
return { ROOT_DEFAULTS, CUSTOM };
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* Any path no other route matches (/whatever, /a/b/c): the theme's 404 page,
|
|
4
|
+
* in the store's layout, with a 404 status. Missing products, categories and
|
|
5
|
+
* pages throw a 404 from their own route and land on the same page through
|
|
6
|
+
* error.vue.
|
|
7
|
+
*/
|
|
8
|
+
definePageMeta({ name: "not-found" });
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<NotFoundPage />
|
|
13
|
+
</template>
|
|
@@ -6,7 +6,7 @@ import { apiService, getBaseUrl } from "./ApiServices";
|
|
|
6
6
|
const getProductDetail = async (id, baseUrl) => {
|
|
7
7
|
try {
|
|
8
8
|
baseUrl = baseUrl || getBaseUrl();
|
|
9
|
-
const responseData = await apiService(`${baseUrl}store/apis/products/${id}
|
|
9
|
+
const responseData = await apiService(`${baseUrl}store/apis/products/${id}/`, {
|
|
10
10
|
method: 'GET',
|
|
11
11
|
credentials: "include",
|
|
12
12
|
});
|
package/package.json
CHANGED