@osimart/create-storefront 1.0.7 → 1.0.10

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 CHANGED
@@ -1,239 +1,15 @@
1
1
  <script setup>
2
- // Global on purpose: every button and field in the storefront reads it.
3
- import "#osi/assets/css/osi-controls.css";
4
- import { parseIds } from "#osi/utils/analytics";
5
-
6
- const { load, dir, store, error } = useStorefront();
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
- <main v-if="shown" class="osi-unavailable" dir="ltr">
89
- <a :href="osimartUrl" class="osi-unavailable-back">
90
- <span aria-hidden="true">&larr;</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
- <div id="osi-teleport-root" />
110
- </div>
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
- const selectedValues = computed(() => asArray(route.query.value).map(String));
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
- return (filtersData.data.value ?? [])
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
- return pushQuery({ value: [...new Set(ids.map(String))], page: undefined });
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
- return pushQuery({ value: undefined, ordering: undefined, page: undefined });
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) sp.append("variant_values", v);
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
 
@@ -13,7 +13,7 @@
13
13
  * /categories/<slug> CLASSIFICATION (resource.type = CATEGORY)
14
14
  * /collections/<slug> CLASSIFICATION (resource.type = COLLECTION)
15
15
  * /products/<slug> PRODUCT
16
- * /pages/<handle> PAGE
16
+ * /<handle> PAGE (one segment; /pages/<handle> 301s here)
17
17
  * /search SEARCH
18
18
  * /cart CART
19
19
  * /account ACCOUNT
@@ -45,7 +45,7 @@ export function useMenu() {
45
45
  return `/products/${relation.product.slugified_name}`;
46
46
  }
47
47
  if (relation.page?.handle) {
48
- return `/pages/${relation.page.handle}`;
48
+ return `/${relation.page.handle}`;
49
49
  }
50
50
 
51
51
  return "/";
@@ -62,30 +62,29 @@ export function parsePriceRange(value) {
62
62
 
63
63
  /* ------------------------------------------------------------- the source */
64
64
 
65
- /**
66
- * The full product for the current route.
67
- *
68
- * Two calls, because the list endpoint is the only one that takes a slug and
69
- * the detail endpoint is the only one that returns variants and the full
70
- * gallery. Merged rather than replaced — the list row carries fields
71
- * (categories, price_range) the detail response doesn't always repeat.
72
- */
73
- async function loadProduct(slug) {
74
- const list = await ProductServices.getProductId(slug);
75
- const summary = list?.results?.[0];
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 (!summary?.id) {
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
  /**
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * /categories/<slug> CLASSIFICATION (already matched)
19
19
  * /collections/<slug> CLASSIFICATION (already matched)
20
- * /pages/<handle> PAGE (already matched)
20
+ * /<handle> PAGE (one segment only)
21
21
  * /<category>/<slug> PRODUCT ← needs adding, matched LAST so it
22
22
  * can't shadow the prefixes above
23
23
  *
@@ -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>
@@ -14,7 +14,7 @@ import { useProductResource } from "#osi/composables/useProductDetail";
14
14
  *
15
15
  * /categories/<slug> CLASSIFICATION
16
16
  * /collections/<slug> CLASSIFICATION
17
- * /pages/<handle> PAGE
17
+ * /<handle> PAGE (one segment, never meets this route)
18
18
  * /products/<slug> PRODUCT (keep — uncategorised products use it)
19
19
  * /<category>/<slug> PRODUCT ← last
20
20
  *
@@ -0,0 +1,82 @@
1
+ <template>
2
+ <div>
3
+ <SectionRenderer v-if="data?.sections?.length" :sections="data.sections" />
4
+
5
+ <div v-else-if="!error" class="osi-empty">
6
+ <p>This page hasn't been set up yet.</p>
7
+ </div>
8
+
9
+ <div v-else class="osi-empty">
10
+ <p>We couldn't load this page.</p>
11
+ <pre v-if="isDev" class="osi-empty-detail">{{ error.message }}</pre>
12
+ </div>
13
+ </div>
14
+ </template>
15
+ <script setup>
16
+ /**
17
+ * A merchant page — `/<handle>` (Shopify's /pages/<handle>, without the prefix).
18
+ *
19
+ * One segment only. Nuxt matches static routes first (checkout, login,
20
+ * search…), then this, then [...slug] — so a platform route always wins, and
21
+ * the backend refuses page handles that would collide with one
22
+ * (RESERVED_HANDLES).
23
+ *
24
+ * Because this catches every unknown one-segment path (/whatever), only a PAGE
25
+ * payload counts. Anything else — NOT_FOUND, a 404 — is a real 404, drawn by
26
+ * error.vue with the theme's 404 page and a 404 status.
27
+ *
28
+ * The old /pages/<handle> URLs 301 here (pages/pages/[handle].vue).
29
+ */
30
+ import { useRenderContext } from '#osi/composables/useRenderContext';
31
+
32
+ definePageMeta({ name: 'page' });
33
+
34
+ const route = useRoute();
35
+ const { render, store } = useStorefront();
36
+ const { setRenderContext, resource } = useRenderContext();
37
+ const origin = useRequestURL().origin;
38
+
39
+ const { data, error } = await useAsyncData(
40
+ `render:${route.path}`,
41
+ () => render(route.path)
42
+ );
43
+
44
+ const templateName = (payload) => {
45
+ const t = payload?.template;
46
+ return String((t && typeof t === 'object' ? t.name : t) ?? '').toUpperCase();
47
+ };
48
+
49
+ const status = error.value?.status ?? error.value?.statusCode;
50
+ const isPage = !!data.value && templateName(data.value) === 'PAGE';
51
+
52
+ if (status === 404 || (!error.value && !isPage)) {
53
+ throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true });
54
+ }
55
+
56
+ setRenderContext(data.value);
57
+ watch(data, (payload) => setRenderContext(payload));
58
+
59
+ const isDev = import.meta.dev;
60
+
61
+ const seo = computed(() => data.value?.seo ?? {});
62
+
63
+ useHead({
64
+ title: () => seo.value.title_full ?? seo.value.title ?? resource.value?.name ?? store.value?.name ?? '',
65
+ titleTemplate: (title) => title ?? '',
66
+ meta: () => [
67
+ { name: 'description', content: seo.value.description ?? undefined },
68
+ { property: 'og:title', content: seo.value.title_full ?? seo.value.title ?? undefined },
69
+ { property: 'og:description', content: seo.value.description ?? undefined },
70
+ { property: 'og:image', content: seo.value.image ?? undefined },
71
+ { name: 'robots', content: seo.value.no_index ? 'noindex, nofollow' : 'index, follow' },
72
+ ],
73
+ link: () => seo.value.canonical_path
74
+ ? [{ rel: 'canonical', href: `${origin}${seo.value.canonical_path}` }]
75
+ : [],
76
+ });
77
+ </script>
78
+
79
+ <style scoped>
80
+ .osi-empty { padding: 6rem 1rem; text-align: center; color: var(--osi-text); opacity: 0.7; }
81
+ .osi-empty-detail { margin-top: 1rem; font-size: 12px; white-space: pre-wrap; }
82
+ </style>
@@ -1,58 +1,20 @@
1
- <template>
2
- <div>
3
- <SectionRenderer v-if="data?.sections?.length" :sections="data.sections" />
4
-
5
- <div v-else-if="!error" class="osi-empty">
6
- <p>This page hasn't been set up yet.</p>
7
- </div>
8
-
9
- <div v-else class="osi-empty">
10
- <p>We couldn't load this page.</p>
11
- <pre v-if="isDev" class="osi-empty-detail">{{ error.message }}</pre>
12
- </div>
13
- </div>
14
- </template>
15
1
  <script setup>
16
- import { useRenderContext } from '#osi/composables/useRenderContext';
17
-
18
- const route = useRoute();
19
- const { render, store } = useStorefront();
20
- const { setRenderContext, resource } = useRenderContext();
21
-
22
- const { data, error } = await useAsyncData(
23
- `render:${route.path}`,
24
- () => render(route.path)
25
- );
26
-
27
- if (error.value?.status === 404) {
28
- throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true });
29
- }
30
-
31
- setRenderContext(data.value);
32
- watch(data, (payload) => setRenderContext(payload));
33
-
34
- const isDev = import.meta.dev;
35
-
36
- const seo = computed(() => data.value?.seo ?? {});
37
- const origin = useRequestURL().origin;
38
-
39
- useHead({
40
- title: () => seo.value.title_full ?? seo.value.title ?? resource.value?.name ?? store.value?.name ?? '',
41
- titleTemplate: (title) => title ?? '',
42
- meta: () => [
43
- { name: 'description', content: seo.value.description ?? undefined },
44
- { property: 'og:title', content: seo.value.title_full ?? seo.value.title ?? undefined },
45
- { property: 'og:description', content: seo.value.description ?? undefined },
46
- { property: 'og:image', content: seo.value.image ?? undefined },
47
- { name: 'robots', content: seo.value.no_index ? 'noindex, nofollow' : 'index, follow' },
48
- ],
49
- link: () => seo.value.canonical_path
50
- ? [{ rel: 'canonical', href: `${origin}${seo.value.canonical_path}` }]
51
- : [],
2
+ /**
3
+ * Old page URL — `/pages/<handle>` → `/<handle>`, 301.
4
+ *
5
+ * Kept so links already shared, indexed or saved in a menu's custom URL keep
6
+ * working. The redirect happens in route middleware, before anything renders,
7
+ * so the server answers with the 301 itself (good for search engines).
8
+ */
9
+ definePageMeta({
10
+ middleware: (to) =>
11
+ navigateTo(
12
+ { path: `/${to.params.handle}`, query: to.query, hash: to.hash },
13
+ { redirectCode: 301 }
14
+ ),
52
15
  });
53
16
  </script>
54
17
 
55
- <style scoped>
56
- .osi-empty { padding: 6rem 1rem; text-align: center; color: var(--osi-text); opacity: 0.7; }
57
- .osi-empty-detail { margin-top: 1rem; font-size: 12px; white-space: pre-wrap; }
58
- </style>
18
+ <template>
19
+ <div />
20
+ </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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osimart/create-storefront",
3
- "version": "1.0.7",
3
+ "version": "1.0.10",
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",