@osimart/create-storefront 0.0.0-stage → 1.0.0
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/README.md +80 -2
- package/THEME_DEV_API.md +80 -0
- package/app/app.vue +154 -0
- package/app/assets/css/osi-controls.css +824 -0
- package/app/components/SectionRenderer.vue +46 -0
- package/app/components/builder/BuilderOverlay.vue +582 -0
- package/app/components/checkout/ExtraFees.vue +329 -0
- package/app/components/checkout/PaymentHandoff.vue +388 -0
- package/app/components/common/Banner.vue +35 -0
- package/app/components/common/ConfirmDeleteModal.vue +117 -0
- package/app/components/common/DropdownSelect.vue +432 -0
- package/app/components/common/Loader.vue +32 -0
- package/app/components/common/OsiField.vue +218 -0
- package/app/components/common/PhoneNumInput.vue +434 -0
- package/app/composables/UseBodyScrollLock.js +122 -0
- package/app/composables/UseThemeGroup.js +575 -0
- package/app/composables/useAddToCart.js +87 -0
- package/app/composables/useAnalytics.js +15 -0
- package/app/composables/useAnnouncements.js +29 -0
- package/app/composables/useApiErrorToast.js +104 -0
- package/app/composables/useAuth.js +99 -0
- package/app/composables/useBanners.js +25 -0
- package/app/composables/useBuilderPreview.js +320 -0
- package/app/composables/useButtonStyle.js +107 -0
- package/app/composables/useCarousel.js +160 -0
- package/app/composables/useCart.js +109 -0
- package/app/composables/useCheckout.js +76 -0
- package/app/composables/useClassificationItems.js +269 -0
- package/app/composables/useClassificationPage.js +545 -0
- package/app/composables/useClassificationUrl.js +63 -0
- package/app/composables/useColorScheme.js +44 -0
- package/app/composables/useContactForm.js +53 -0
- package/app/composables/useContentTree.js +770 -0
- package/app/composables/useDragScroll.js +73 -0
- package/app/composables/useFooterSocials.js +107 -0
- package/app/composables/useInputStyle.js +45 -0
- package/app/composables/useLayoutShell.js +114 -0
- package/app/composables/useLazyVideo.js +36 -0
- package/app/composables/useMediaTypes.js +66 -0
- package/app/composables/useMediaUrl.js +71 -0
- package/app/composables/useMenu.js +100 -0
- package/app/composables/useNavStyle.js +219 -0
- package/app/composables/usePlaceholders.js +294 -0
- package/app/composables/usePolicies.js +26 -0
- package/app/composables/useProductCardMedia.js +135 -0
- package/app/composables/useProductCardStyle.js +132 -0
- package/app/composables/useProductDetail.js +982 -0
- package/app/composables/useProductGallery.js +166 -0
- package/app/composables/useProductItems.js +270 -0
- package/app/composables/useProductUrl.js +60 -0
- package/app/composables/useProductVariants.js +210 -0
- package/app/composables/useQuickShop.js +115 -0
- package/app/composables/useRelatedProducts.js +186 -0
- package/app/composables/useRenderContext.js +45 -0
- package/app/composables/useScopedCss.js +106 -0
- package/app/composables/useSearch.js +35 -0
- package/app/composables/useSectionData.js +462 -0
- package/app/composables/useSectionShell.js +113 -0
- package/app/composables/useSidebar.js +69 -0
- package/app/composables/useStoreData.js +57 -0
- package/app/composables/useStoreInit.js +52 -0
- package/app/composables/useStorefront.js +239 -0
- package/app/composables/useT.js +21 -0
- package/app/composables/useTextStyles.js +84 -0
- package/app/composables/useThemeControls.js +34 -0
- package/app/composables/useThemeSeo.js +91 -0
- package/app/composables/useThemeTokens.js +171 -0
- package/app/composables/useWishlist.js +66 -0
- package/app/logic/components/AnnouncementBar.js +118 -0
- package/app/logic/components/CardGrid.js +125 -0
- package/app/logic/components/CardList.js +98 -0
- package/app/logic/components/CartDrawer.js +273 -0
- package/app/logic/components/CartItem.js +31 -0
- package/app/logic/components/ClassificationBento.js +6 -0
- package/app/logic/components/ClassificationCard.js +6 -0
- package/app/logic/components/ClassificationCarousel.js +115 -0
- package/app/logic/components/ClassificationEditorial.js +41 -0
- package/app/logic/components/ClassificationFeatured.js +57 -0
- package/app/logic/components/ClassificationGrid.js +6 -0
- package/app/logic/components/ClassificationNav.js +61 -0
- package/app/logic/components/ClassificationPart.js +72 -0
- package/app/logic/components/FooterBlock.js +91 -0
- package/app/logic/components/FooterSocials.js +60 -0
- package/app/logic/components/HeaderDropdownList.js +29 -0
- package/app/logic/components/HeaderIcon.js +68 -0
- package/app/logic/components/HeaderLogo.js +39 -0
- package/app/logic/components/HeaderNav.js +268 -0
- package/app/logic/components/MobileMenu.js +280 -0
- package/app/logic/components/ProductBuyButtons.js +66 -0
- package/app/logic/components/ProductCard.js +160 -0
- package/app/logic/components/ProductFilters.js +37 -0
- package/app/logic/components/ProductGallery.js +19 -0
- package/app/logic/components/ProductInfoBlock.js +411 -0
- package/app/logic/components/ProductLightbox.js +12 -0
- package/app/logic/components/ProductQuantity.js +31 -0
- package/app/logic/components/ProductVariantPicker.js +125 -0
- package/app/logic/components/QuickBuy.js +194 -0
- package/app/logic/components/SearchOverlay.js +41 -0
- package/app/logic/components/SidebarMenuItem.js +39 -0
- package/app/logic/components/SiteFooter.js +476 -0
- package/app/logic/components/SiteHeader.js +266 -0
- package/app/logic/components/WhatsAppButton.js +75 -0
- package/app/logic/layouts/default.js +66 -0
- package/app/logic/sections/AnnouncementBarSection.js +6 -0
- package/app/logic/sections/BlockCarousel.js +103 -0
- package/app/logic/sections/ClassificationListSection.js +40 -0
- package/app/logic/sections/ContactFormSection.js +294 -0
- package/app/logic/sections/ContactInfoSection.js +419 -0
- package/app/logic/sections/ContentNode.js +189 -0
- package/app/logic/sections/CountdownSection.js +254 -0
- package/app/logic/sections/CustomHtmlSection.js +51 -0
- package/app/logic/sections/DividerSection.js +31 -0
- package/app/logic/sections/FaqSection.js +53 -0
- package/app/logic/sections/FeatureListSection.js +6 -0
- package/app/logic/sections/FeaturedProductSection.js +40 -0
- package/app/logic/sections/FooterSection.js +16 -0
- package/app/logic/sections/FooterUtilitiesSection.js +24 -0
- package/app/logic/sections/GallerySection.js +232 -0
- package/app/logic/sections/HeaderSection.js +24 -0
- package/app/logic/sections/HeroSection.js +24 -0
- package/app/logic/sections/LogoListSection.js +6 -0
- package/app/logic/sections/MainClassificationSection.js +473 -0
- package/app/logic/sections/MainProductSection.js +211 -0
- package/app/logic/sections/MainSearchSection.js +6 -0
- package/app/logic/sections/MapSection.js +92 -0
- package/app/logic/sections/MediaContentSection.js +38 -0
- package/app/logic/sections/MediaWithTextSection.js +238 -0
- package/app/logic/sections/NewsletterSection.js +126 -0
- package/app/logic/sections/ProductHighlightSection.js +201 -0
- package/app/logic/sections/ProductListSection.js +45 -0
- package/app/logic/sections/ProductRecommendationsSection.js +113 -0
- package/app/logic/sections/RichTextSection.js +6 -0
- package/app/logic/sections/ScrollingTextSection.js +69 -0
- package/app/logic/sections/SearchHeaderSection.js +22 -0
- package/app/logic/sections/SlideshowSection.js +118 -0
- package/app/logic/sections/SpacerSection.js +37 -0
- package/app/logic/sections/SplitShowcaseSection.js +6 -0
- package/app/logic/sections/TestimonialListSection.js +6 -0
- package/app/logic/sections/VideoSection.js +235 -0
- package/app/pages/[category]/[product].vue +196 -0
- package/app/pages/__builder-preview.vue +38 -0
- package/app/pages/categories/[slug].vue +208 -0
- package/app/pages/checkout.vue +1972 -0
- package/app/pages/forgot-password.vue +115 -0
- package/app/pages/index.vue +64 -0
- package/app/pages/login.vue +585 -0
- package/app/pages/order-detail.vue +442 -0
- package/app/pages/pages/[handle].vue +58 -0
- package/app/pages/privacy-policy.vue +46 -0
- package/app/pages/resetpassword.vue +138 -0
- package/app/pages/resetverificationcode.vue +163 -0
- package/app/pages/result.vue +203 -0
- package/app/pages/return-policy.vue +50 -0
- package/app/pages/search.vue +100 -0
- package/app/pages/shipping-policy.vue +46 -0
- package/app/pages/user-profile/components/AddAddressModal.vue +452 -0
- package/app/pages/user-profile/components/Whishlist.vue +137 -0
- package/app/pages/user-profile/components/left-bar.vue +233 -0
- package/app/pages/user-profile/components/orders.vue +162 -0
- package/app/pages/user-profile/components/profile-details.vue +566 -0
- package/app/pages/user-profile/components/settings.vue +194 -0
- package/app/pages/user-profile/index.vue +252 -0
- package/app/pages/verificationcode.vue +188 -0
- package/app/plugins/analytics.client.js +99 -0
- package/app/plugins/geo.ts +24 -0
- package/app/plugins/reveal.client.js +53 -0
- package/app/plugins/toast.js +10 -0
- package/app/plugins/vue-query.ts +5 -0
- package/app/services/ApiServices.js +74 -0
- package/app/services/AuthBus.js +162 -0
- package/app/services/Authentication/AuthenticationService.js +133 -0
- package/app/services/Authentication/TokenService.js +62 -0
- package/app/services/CartServices.js +91 -0
- package/app/services/CartToken.js +26 -0
- package/app/services/CategoryServices.js +54 -0
- package/app/services/CheckoutServices.js +113 -0
- package/app/services/HomeServices.js +133 -0
- package/app/services/NewsletterServices.js +29 -0
- package/app/services/PaymentServices.js +71 -0
- package/app/services/PoliciesServices.js +16 -0
- package/app/services/ProductServices.js +79 -0
- package/app/services/SearchServices.js +18 -0
- package/app/services/UserServices.js +105 -0
- package/app/services/UtilsServices.js +46 -0
- package/app/services/WishlistServices.js +51 -0
- package/app/services/storefront.service.js +57 -0
- package/app/stores/userStore.js +13 -0
- package/app/theme/categoryTree.js +68 -0
- package/app/theme/controls.js +88 -0
- package/app/theme/registry.js +56 -0
- package/app/utils/analytics.js +864 -0
- package/app/utils/device.js +50 -0
- package/app/utils/eventBus.js +24 -0
- package/app/utils/icon-presets.js +24 -0
- package/app/utils/productOffers.js +118 -0
- package/app/utils/requestCache.js +36 -0
- package/app/utils/scrollToError.js +59 -0
- package/app/utils/toPlainError.js +30 -0
- package/bin/osimart-theme.mjs +259 -0
- package/nuxt/osi-sections.ts +56 -0
- package/nuxt.config.ts +60 -0
- package/package.json +43 -3
- package/public/css/input.css +29 -0
- package/public/css/output.css +2716 -0
- package/public/favicon-32x32.png +0 -0
- package/public/robots.txt +2 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import HomeServices from "#osi/services/HomeServices";
|
|
2
|
+
import CategoryServices from "#osi/services/CategoryServices";
|
|
3
|
+
import { apiService } from "#osi/services/ApiServices";
|
|
4
|
+
import { childrenOf as treeChildren, isOn, parentIdOf } from "#osi/theme/categoryTree";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Everything the category / collection page needs, for both kinds.
|
|
8
|
+
*
|
|
9
|
+
* Categories and collections render the same page — a heading, a way to move
|
|
10
|
+
* between siblings, a filterable product grid — and differ only in which query
|
|
11
|
+
* parameter narrows the products (`categories` vs `collections`) and where the
|
|
12
|
+
* navigation comes from (a tree vs a flat list). So there is one page
|
|
13
|
+
* (pages/categories/[slug].vue, aliased to /collections/:slug) and one section
|
|
14
|
+
* (MAIN_CLASSIFICATION), and this is the only place that knows the difference.
|
|
15
|
+
*
|
|
16
|
+
* `/collections/all` (or `/categories/all`) is the "All products" page: kind ALL,
|
|
17
|
+
* no narrowing parameter.
|
|
18
|
+
*
|
|
19
|
+
* The search page (MAIN_SEARCH, /search?q=…) is the same listing: kind SEARCH,
|
|
20
|
+
* narrowed by `search`. No item, no category navigation; nothing is fetched
|
|
21
|
+
* until something is searched.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const ALL_SLUG = "all";
|
|
25
|
+
|
|
26
|
+
/* ------------------------------------------------------------------ catalog */
|
|
27
|
+
|
|
28
|
+
function rowsOf(response) {
|
|
29
|
+
if (Array.isArray(response)) return response;
|
|
30
|
+
return response?.results ?? [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function slugOf(row) {
|
|
34
|
+
return row?.slugified_name || row?.slug || row?.handle || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Walks the category tree once into lookups. `children` is what the categories
|
|
39
|
+
* endpoint nests; `parent` / `parent_category` are read too for flat payloads.
|
|
40
|
+
*/
|
|
41
|
+
function indexTree(roots) {
|
|
42
|
+
const bySlug = new Map();
|
|
43
|
+
const byId = new Map();
|
|
44
|
+
const parentOf = new Map();
|
|
45
|
+
|
|
46
|
+
const walk = (list, parent) => {
|
|
47
|
+
for (const row of list ?? []) {
|
|
48
|
+
if (!row) continue;
|
|
49
|
+
const slug = slugOf(row);
|
|
50
|
+
if (slug) bySlug.set(slug, row);
|
|
51
|
+
if (row.id != null) byId.set(row.id, row);
|
|
52
|
+
if (parent) parentOf.set(row.id, parent);
|
|
53
|
+
walk(treeChildren(row), row);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
walk(roots, null);
|
|
57
|
+
|
|
58
|
+
// Flat payloads: resolve parent ids after the fact.
|
|
59
|
+
for (const row of byId.values()) {
|
|
60
|
+
if (parentOf.has(row.id)) continue;
|
|
61
|
+
const pid = parentIdOf(row);
|
|
62
|
+
if (pid != null && byId.has(pid)) parentOf.set(row.id, byId.get(pid));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const childrenOf = (row) => {
|
|
66
|
+
if (!row) return [];
|
|
67
|
+
const nested = treeChildren(row);
|
|
68
|
+
if (nested.length) return nested;
|
|
69
|
+
return [...byId.values()].filter((r) => parentOf.get(r.id)?.id === row.id);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const roots_ = (roots ?? []).filter((r) => !parentOf.has(r.id));
|
|
73
|
+
|
|
74
|
+
return { bySlug, byId, parentOf, childrenOf, roots: roots_ };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Categories + collections, fetched once per page load and shared (same
|
|
79
|
+
* useAsyncData keys everywhere, so the page and the section don't double up).
|
|
80
|
+
*/
|
|
81
|
+
export function useClassificationCatalog() {
|
|
82
|
+
const baseUrl = useState("base-url", () => "");
|
|
83
|
+
|
|
84
|
+
const categories = useAsyncData(
|
|
85
|
+
"osi-catalog:categories",
|
|
86
|
+
() => HomeServices.getCategories(baseUrl.value).then(rowsOf).catch(() => []),
|
|
87
|
+
{ default: () => [] }
|
|
88
|
+
);
|
|
89
|
+
const collections = useAsyncData(
|
|
90
|
+
"osi-catalog:collections",
|
|
91
|
+
() => HomeServices.getCollections(baseUrl.value).then(rowsOf).catch(() => []),
|
|
92
|
+
{ default: () => [] }
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const tree = computed(() => indexTree(categories.data.value ?? []));
|
|
96
|
+
const collectionBySlug = computed(() => {
|
|
97
|
+
const map = new Map();
|
|
98
|
+
for (const row of collections.data.value ?? []) {
|
|
99
|
+
const slug = slugOf(row);
|
|
100
|
+
if (slug) map.set(slug, row);
|
|
101
|
+
}
|
|
102
|
+
return map;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
function find(kind, slug) {
|
|
106
|
+
if (!slug) return null;
|
|
107
|
+
return kind === "COLLECTION"
|
|
108
|
+
? collectionBySlug.value.get(slug) ?? null
|
|
109
|
+
: tree.value.bySlug.get(slug) ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const ready = Promise.all([categories, collections]);
|
|
113
|
+
|
|
114
|
+
return { categories, collections, tree, collectionBySlug, find, ready };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Depth-first: each parent followed by its children. */
|
|
118
|
+
function flattenTree(roots, childrenOf) {
|
|
119
|
+
const out = [];
|
|
120
|
+
const walk = (list) => {
|
|
121
|
+
for (const row of list ?? []) {
|
|
122
|
+
out.push(row);
|
|
123
|
+
walk(childrenOf(row));
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
walk(roots);
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* -------------------------------------------------------------- the page */
|
|
131
|
+
|
|
132
|
+
const SORTS = {
|
|
133
|
+
FEATURED: { label: "Featured", value: "" },
|
|
134
|
+
PRICE_ASC: { label: "Price, low to high", value: "min_price" },
|
|
135
|
+
PRICE_DESC: { label: "Price, high to low", value: "-max_price" },
|
|
136
|
+
NEWEST: { label: "Newest", value: "-created_at" },
|
|
137
|
+
NAME_ASC: { label: "Alphabetically, A-Z", value: "name" },
|
|
138
|
+
NAME_DESC: { label: "Alphabetically, Z-A", value: "-name" },
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const asArray = (v) => (Array.isArray(v) ? v : v == null || v === "" ? [] : [v]);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {() => object} section the MAIN_CLASSIFICATION section (getter)
|
|
145
|
+
*/
|
|
146
|
+
export function useClassificationPage(section) {
|
|
147
|
+
const route = useRoute();
|
|
148
|
+
const router = useRouter();
|
|
149
|
+
const t = useT();
|
|
150
|
+
const { mediaUrl } = useMediaUrl();
|
|
151
|
+
const { classificationUrl } = useClassificationUrl();
|
|
152
|
+
const { resource } = useRenderContext();
|
|
153
|
+
const placeholders = usePlaceholders();
|
|
154
|
+
const baseUrl = useState("base-url", () => "");
|
|
155
|
+
const catalog = useClassificationCatalog();
|
|
156
|
+
const { isPreview } = useBuilderPreview();
|
|
157
|
+
|
|
158
|
+
const s = computed(() => toValue(section)?.settings ?? {});
|
|
159
|
+
const sectionKey = computed(() => toValue(section)?.key ?? "main");
|
|
160
|
+
const isSearch = computed(() => toValue(section)?.type === "MAIN_SEARCH");
|
|
161
|
+
/** What was searched (/search?q=…). */
|
|
162
|
+
const terms = computed(() => (isSearch.value ? String(route.query.q ?? "").trim() : ""));
|
|
163
|
+
|
|
164
|
+
/* ---------------------------------------------------------- current item */
|
|
165
|
+
|
|
166
|
+
const kind = computed(() => {
|
|
167
|
+
if (isSearch.value) return "SEARCH";
|
|
168
|
+
const slug = route.params.slug ?? resource.value?.slug;
|
|
169
|
+
if (slug === ALL_SLUG || resource.value?.type === "ALL") return "ALL";
|
|
170
|
+
// Dashboard preview: no route, no resource. The CLASSIFICATION template is
|
|
171
|
+
// previewed as /categories/all — every product, the real catalog.
|
|
172
|
+
if (!slug && !resource.value?.id) return "ALL";
|
|
173
|
+
const rt = String(resource.value?.type ?? "").toUpperCase();
|
|
174
|
+
if (rt === "COLLECTION") return "COLLECTION";
|
|
175
|
+
if (rt === "CATEGORY") return "CATEGORY";
|
|
176
|
+
return route.path.startsWith("/collections") ? "COLLECTION" : "CATEGORY";
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const slug = computed(
|
|
180
|
+
() =>
|
|
181
|
+
route.params.slug ||
|
|
182
|
+
resource.value?.slug ||
|
|
183
|
+
resource.value?.slugified_name ||
|
|
184
|
+
null
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The full row (image, description, children) for the current page. The
|
|
189
|
+
* render payload's `resource` is used when the catalog doesn't have the row,
|
|
190
|
+
* and in the builder — which has no route — the first category stands in so
|
|
191
|
+
* the merchant sees a real page while designing.
|
|
192
|
+
*/
|
|
193
|
+
const current = computed(() => {
|
|
194
|
+
if (kind.value === "SEARCH") return { id: null, name: terms.value, slugified_name: null };
|
|
195
|
+
if (kind.value === "ALL") {
|
|
196
|
+
return { id: null, name: t(s.value.ALL_TITLE) || "All products", slugified_name: ALL_SLUG };
|
|
197
|
+
}
|
|
198
|
+
const row = catalog.find(kind.value, slug.value);
|
|
199
|
+
if (row) return row;
|
|
200
|
+
return resource.value ?? null;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const title = computed(() => current.value?.name || current.value?.title || "");
|
|
204
|
+
const description = computed(() => current.value?.description || "");
|
|
205
|
+
const image = computed(() =>
|
|
206
|
+
mediaUrl(
|
|
207
|
+
s.value.BANNER_IMAGE ??
|
|
208
|
+
current.value?.banner ??
|
|
209
|
+
current.value?.main_image ??
|
|
210
|
+
current.value?.image ??
|
|
211
|
+
null
|
|
212
|
+
)
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const parent = computed(() =>
|
|
216
|
+
kind.value === "CATEGORY" && current.value?.id != null
|
|
217
|
+
? catalog.tree.value.parentOf.get(current.value.id) ?? null
|
|
218
|
+
: null
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const breadcrumbs = computed(() => {
|
|
222
|
+
const trail = [];
|
|
223
|
+
let node = parent.value;
|
|
224
|
+
while (node) {
|
|
225
|
+
trail.unshift({ title: node.name, url: classificationUrl({ ...node, kind: "CATEGORY" }) });
|
|
226
|
+
node = catalog.tree.value.parentOf.get(node.id) ?? null;
|
|
227
|
+
}
|
|
228
|
+
return [{ title: t(s.value.BREADCRUMB_HOME_LABEL) || "Home", url: "/" }, ...trail, { title: title.value, url: null }];
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/* -------------------------------------------------------------- nav items */
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Which prefix the current URL uses. "All products" stays under it, so a
|
|
235
|
+
* shopper browsing /categories/... gets /categories/all, not /collections/all.
|
|
236
|
+
*/
|
|
237
|
+
const onCollectionsPath = computed(() => route.path.startsWith("/collections"));
|
|
238
|
+
|
|
239
|
+
/** The "All products" link for a nav listing rows of `rowKind`. */
|
|
240
|
+
function allUrlFor(rowKind) {
|
|
241
|
+
if (rowKind === "COLLECTION") return `/collections/${ALL_SLUG}`;
|
|
242
|
+
if (rowKind === "CATEGORY") return `/categories/${ALL_SLUG}`;
|
|
243
|
+
return `/${onCollectionsPath.value ? "collections" : "categories"}/${ALL_SLUG}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function toItem(row, rowKind) {
|
|
247
|
+
const rowSlug = slugOf(row);
|
|
248
|
+
return {
|
|
249
|
+
key: `${rowKind}-${row.id ?? rowSlug}`,
|
|
250
|
+
id: row.id,
|
|
251
|
+
title: row.name || row.title || "",
|
|
252
|
+
image: mediaUrl(row.main_image ?? row.image ?? row.media ?? null),
|
|
253
|
+
url: classificationUrl({ ...row, kind: rowKind }, rowKind),
|
|
254
|
+
count: row.products_count ?? row.product_count ?? null,
|
|
255
|
+
active: rowKind === kind.value && rowSlug != null && rowSlug === slug.value,
|
|
256
|
+
isAll: false,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Which rows the navigation lists, per NAV_SOURCE. */
|
|
261
|
+
const navSource = computed(() => {
|
|
262
|
+
const mode = String(s.value.NAV_SOURCE || "AUTO").toUpperCase();
|
|
263
|
+
const tree = catalog.tree.value;
|
|
264
|
+
const cur = current.value;
|
|
265
|
+
const children = kind.value === "CATEGORY" ? tree.childrenOf(cur) : [];
|
|
266
|
+
const siblings = parent.value ? tree.childrenOf(parent.value) : tree.roots;
|
|
267
|
+
const collections = catalog.collections.data.value ?? [];
|
|
268
|
+
|
|
269
|
+
switch (mode) {
|
|
270
|
+
case "NONE":
|
|
271
|
+
return { rows: [], rowKind: "CATEGORY", allTarget: null };
|
|
272
|
+
case "CHILDREN":
|
|
273
|
+
return { rows: children, rowKind: "CATEGORY", allTarget: "CURRENT" };
|
|
274
|
+
case "SIBLINGS":
|
|
275
|
+
return { rows: siblings, rowKind: "CATEGORY", allTarget: parent.value ? "PARENT" : "ALL" };
|
|
276
|
+
case "TOP_LEVEL":
|
|
277
|
+
return { rows: tree.roots, rowKind: "CATEGORY", allTarget: "ALL" };
|
|
278
|
+
case "ALL_CATEGORIES":
|
|
279
|
+
// Same switch as the home page's category list: top level only, or
|
|
280
|
+
// every category at every level (parents followed by their children).
|
|
281
|
+
return {
|
|
282
|
+
rows: isOn(s.value.NAV_PARENTS_ONLY, true) ? tree.roots : flattenTree(tree.roots, tree.childrenOf),
|
|
283
|
+
rowKind: "CATEGORY",
|
|
284
|
+
allTarget: "ALL",
|
|
285
|
+
};
|
|
286
|
+
case "COLLECTIONS":
|
|
287
|
+
return { rows: collections, rowKind: "COLLECTION", allTarget: "ALL" };
|
|
288
|
+
default: {
|
|
289
|
+
// AUTO: a collection page lists collections; a category page lists
|
|
290
|
+
// its children, or its siblings when it has none — so a leaf category
|
|
291
|
+
// still lets the shopper step sideways.
|
|
292
|
+
// /collections/all lists collections; /categories/all lists categories.
|
|
293
|
+
if (kind.value === "COLLECTION" || (kind.value === "ALL" && onCollectionsPath.value))
|
|
294
|
+
return { rows: collections, rowKind: "COLLECTION", allTarget: "ALL" };
|
|
295
|
+
if (kind.value === "CATEGORY" && children.length)
|
|
296
|
+
return { rows: children, rowKind: "CATEGORY", allTarget: "CURRENT" };
|
|
297
|
+
return { rows: siblings, rowKind: "CATEGORY", allTarget: parent.value ? "PARENT" : "ALL" };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const navItems = computed(() => {
|
|
303
|
+
if (kind.value === "SEARCH") return [];
|
|
304
|
+
const { rows, rowKind, allTarget } = navSource.value;
|
|
305
|
+
const mode = String(s.value.NAV_SOURCE || "AUTO").toUpperCase();
|
|
306
|
+
// Editor with an empty catalog: sample rows so the nav is designable.
|
|
307
|
+
const source = rows?.length || mode === "NONE" ? rows : placeholders.classifications([], 6);
|
|
308
|
+
let items = (source ?? []).filter(Boolean).map((row) => toItem(row, rowKind));
|
|
309
|
+
|
|
310
|
+
if (s.value.NAV_HIDE_EMPTY) items = items.filter((i) => i.count == null || i.count > 0);
|
|
311
|
+
const limit = Number(s.value.NAV_LIMIT);
|
|
312
|
+
if (limit > 0) items = items.slice(0, limit);
|
|
313
|
+
|
|
314
|
+
if (items.length && s.value.NAV_SHOW_ALL !== false && allTarget) {
|
|
315
|
+
let url = allUrlFor(rowKind);
|
|
316
|
+
let active = kind.value === "ALL";
|
|
317
|
+
let image = mediaUrl(s.value.NAV_ALL_IMAGE ?? null);
|
|
318
|
+
if (allTarget === "CURRENT") {
|
|
319
|
+
url = classificationUrl({ ...current.value, kind: kind.value }, kind.value);
|
|
320
|
+
active = true;
|
|
321
|
+
image = image || mediaUrl(current.value?.main_image ?? current.value?.image ?? null);
|
|
322
|
+
} else if (allTarget === "PARENT") {
|
|
323
|
+
url = classificationUrl({ ...parent.value, kind: "CATEGORY" }, "CATEGORY");
|
|
324
|
+
active = false;
|
|
325
|
+
image = image || mediaUrl(parent.value?.main_image ?? parent.value?.image ?? null);
|
|
326
|
+
}
|
|
327
|
+
// "All" is only highlighted when nothing more specific is.
|
|
328
|
+
if (items.some((i) => i.active)) active = false;
|
|
329
|
+
items = [
|
|
330
|
+
{ key: "nav-all", id: null, title: t(s.value.NAV_ALL_LABEL) || "All", image, url, count: null, active, isAll: true },
|
|
331
|
+
...items,
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
return items;
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
/* ------------------------------------------------------------ sort/filter */
|
|
338
|
+
|
|
339
|
+
const sortOptions = computed(() => {
|
|
340
|
+
const keys = ["FEATURED", "PRICE_ASC", "PRICE_DESC"];
|
|
341
|
+
if (s.value.SORT_SHOW_NEWEST) keys.push("NEWEST");
|
|
342
|
+
if (s.value.SORT_SHOW_NAME) keys.push("NAME_ASC", "NAME_DESC");
|
|
343
|
+
return keys.map((k) => ({
|
|
344
|
+
key: k,
|
|
345
|
+
...SORTS[k],
|
|
346
|
+
// Unsorted search results come back by relevance.
|
|
347
|
+
...(k === "FEATURED" && kind.value === "SEARCH" ? { label: "Relevance" } : {}),
|
|
348
|
+
}));
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const defaultOrdering = computed(() => {
|
|
352
|
+
const configured = SORTS[s.value.DEFAULT_SORT]?.value;
|
|
353
|
+
if (configured) return configured;
|
|
354
|
+
// "Featured" = the merchant's manual order inside the category.
|
|
355
|
+
return kind.value === "CATEGORY" ? "product_category_order" : "";
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const ordering = computed(() => String(route.query.ordering ?? ""));
|
|
359
|
+
const selectedValues = computed(() => asArray(route.query.value).map(String));
|
|
360
|
+
const perPage = computed(() => Math.max(1, Number(s.value.PRODUCTS_PER_PAGE) || 12));
|
|
361
|
+
const pagination = computed(() => String(s.value.PAGINATION || "INFINITE").toUpperCase());
|
|
362
|
+
const page = computed(() =>
|
|
363
|
+
pagination.value === "PAGES" ? Math.max(1, Number(route.query.page) || 1) : 1
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const filtersData = useAsyncData(
|
|
367
|
+
"osi-catalog:variant-types",
|
|
368
|
+
() => CategoryServices.getFilters(baseUrl.value).then(rowsOf).catch(() => []),
|
|
369
|
+
{ default: () => [], server: false, lazy: true }
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const filters = computed(() => {
|
|
373
|
+
const hidden = String(s.value.FILTER_HIDE ?? "")
|
|
374
|
+
.split(",")
|
|
375
|
+
.map((x) => x.trim().toLowerCase())
|
|
376
|
+
.filter(Boolean);
|
|
377
|
+
return (filtersData.data.value ?? [])
|
|
378
|
+
.filter((f) => f?.values?.length)
|
|
379
|
+
.filter((f) => !hidden.includes(String(f.name ?? "").toLowerCase()))
|
|
380
|
+
.map((f) => ({
|
|
381
|
+
id: f.id,
|
|
382
|
+
name: f.name,
|
|
383
|
+
isColor: String(f.display_type ?? "").toLowerCase() === "color",
|
|
384
|
+
values: f.values.map((v) => ({
|
|
385
|
+
id: String(v.id),
|
|
386
|
+
name: v.name,
|
|
387
|
+
color: v.hex_code || null,
|
|
388
|
+
image: v.image?.path ? mediaUrl(v.image) : null,
|
|
389
|
+
})),
|
|
390
|
+
}));
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const activeFilters = computed(() => {
|
|
394
|
+
const byId = new Map();
|
|
395
|
+
for (const f of filters.value) for (const v of f.values) byId.set(v.id, { ...v, group: f.name });
|
|
396
|
+
return selectedValues.value.map((id) => byId.get(id) ?? { id, name: id, group: "" });
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
function pushQuery(patch) {
|
|
400
|
+
const next = { ...route.query, ...patch };
|
|
401
|
+
for (const k of Object.keys(next)) {
|
|
402
|
+
const v = next[k];
|
|
403
|
+
if (v == null || v === "" || (Array.isArray(v) && !v.length)) delete next[k];
|
|
404
|
+
}
|
|
405
|
+
return router.push({ path: route.path, query: next });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function setOrdering(value) {
|
|
409
|
+
return pushQuery({ ordering: value || undefined, page: undefined });
|
|
410
|
+
}
|
|
411
|
+
function setValues(ids) {
|
|
412
|
+
return pushQuery({ value: [...new Set(ids.map(String))], page: undefined });
|
|
413
|
+
}
|
|
414
|
+
function toggleValue(id) {
|
|
415
|
+
const set = new Set(selectedValues.value);
|
|
416
|
+
const key = String(id);
|
|
417
|
+
set.has(key) ? set.delete(key) : set.add(key);
|
|
418
|
+
return setValues([...set]);
|
|
419
|
+
}
|
|
420
|
+
function clearFilters() {
|
|
421
|
+
return pushQuery({ value: undefined, ordering: undefined, page: undefined });
|
|
422
|
+
}
|
|
423
|
+
function goToPage(n) {
|
|
424
|
+
return pushQuery({ page: n > 1 ? n : undefined });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/* --------------------------------------------------------------- products */
|
|
428
|
+
|
|
429
|
+
function fetchPage(n) {
|
|
430
|
+
const sp = new URLSearchParams();
|
|
431
|
+
const id = current.value?.id;
|
|
432
|
+
if (kind.value === "CATEGORY" && id != null) sp.append("categories", id);
|
|
433
|
+
if (kind.value === "COLLECTION" && id != null) sp.append("collections", id);
|
|
434
|
+
if (kind.value === "SEARCH" && terms.value) sp.set("search", terms.value);
|
|
435
|
+
sp.set("page", n);
|
|
436
|
+
sp.set("page_size", perPage.value);
|
|
437
|
+
const order = ordering.value || defaultOrdering.value;
|
|
438
|
+
if (order) sp.set("ordering", order);
|
|
439
|
+
for (const v of selectedValues.value) sp.append("variant_values", v);
|
|
440
|
+
return apiService(`${baseUrl.value}store/apis/products/?${sp.toString()}`, { method: "GET" });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const queryKey = computed(() =>
|
|
444
|
+
[kind.value, current.value?.id, terms.value, ordering.value, selectedValues.value.join("."), page.value, perPage.value].join("|")
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
const canFetch = computed(() => {
|
|
448
|
+
// The editor (no ?q=) shows the catalog, so the grid and card can be
|
|
449
|
+
// designed with real products.
|
|
450
|
+
if (kind.value === "SEARCH") return !!terms.value || isPreview.value;
|
|
451
|
+
return kind.value === "ALL" || (current.value?.id != null && !current.value?.is_placeholder);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const first = useAsyncData(
|
|
455
|
+
`plp:${route.path}:${sectionKey.value}`,
|
|
456
|
+
() =>
|
|
457
|
+
canFetch.value
|
|
458
|
+
? fetchPage(page.value).catch(() => ({ results: [], count: 0, next: null }))
|
|
459
|
+
: Promise.resolve({ results: [], count: 0, next: null }),
|
|
460
|
+
{ watch: [queryKey], default: () => ({ results: [], count: 0, next: null }) }
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
const extra = ref([]);
|
|
464
|
+
const nextPage = ref(2);
|
|
465
|
+
const loadingMore = ref(false);
|
|
466
|
+
const lastHadNext = ref(null);
|
|
467
|
+
|
|
468
|
+
watch(queryKey, () => {
|
|
469
|
+
extra.value = [];
|
|
470
|
+
nextPage.value = 2;
|
|
471
|
+
lastHadNext.value = null;
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const total = computed(() => Number(first.data.value?.count) || 0);
|
|
475
|
+
|
|
476
|
+
const products = computed(() => {
|
|
477
|
+
const rows = [...(first.data.value?.results ?? []), ...extra.value];
|
|
478
|
+
if (rows.length || !canFetch.value) {
|
|
479
|
+
// Builder / placeholder category: show dummy cards so the grid is designable.
|
|
480
|
+
return rows.length ? rows : placeholders.products([], perPage.value);
|
|
481
|
+
}
|
|
482
|
+
return rows;
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const hasMore = computed(() => {
|
|
486
|
+
if (pagination.value === "PAGES") return false;
|
|
487
|
+
if (lastHadNext.value !== null) return lastHadNext.value;
|
|
488
|
+
if (first.data.value?.next != null) return !!first.data.value.next;
|
|
489
|
+
return products.value.length < total.value;
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
|
|
493
|
+
|
|
494
|
+
async function loadMore() {
|
|
495
|
+
if (loadingMore.value || !hasMore.value || !canFetch.value) return;
|
|
496
|
+
loadingMore.value = true;
|
|
497
|
+
try {
|
|
498
|
+
const res = await fetchPage(nextPage.value);
|
|
499
|
+
extra.value = [...extra.value, ...(res?.results ?? [])];
|
|
500
|
+
nextPage.value += 1;
|
|
501
|
+
lastHadNext.value = res?.next != null ? !!res.next : extra.value.length + (first.data.value?.results?.length ?? 0) < total.value;
|
|
502
|
+
} catch {
|
|
503
|
+
lastHadNext.value = false;
|
|
504
|
+
} finally {
|
|
505
|
+
loadingMore.value = false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
s,
|
|
511
|
+
kind,
|
|
512
|
+
terms,
|
|
513
|
+
slug,
|
|
514
|
+
current,
|
|
515
|
+
title,
|
|
516
|
+
description,
|
|
517
|
+
image,
|
|
518
|
+
parent,
|
|
519
|
+
breadcrumbs,
|
|
520
|
+
navItems,
|
|
521
|
+
sortOptions,
|
|
522
|
+
ordering,
|
|
523
|
+
defaultOrdering,
|
|
524
|
+
selectedValues,
|
|
525
|
+
filters,
|
|
526
|
+
filtersPending: filtersData.pending,
|
|
527
|
+
activeFilters,
|
|
528
|
+
setOrdering,
|
|
529
|
+
setValues,
|
|
530
|
+
toggleValue,
|
|
531
|
+
clearFilters,
|
|
532
|
+
goToPage,
|
|
533
|
+
products,
|
|
534
|
+
total,
|
|
535
|
+
page,
|
|
536
|
+
pageCount,
|
|
537
|
+
perPage,
|
|
538
|
+
pagination,
|
|
539
|
+
pending: first.pending,
|
|
540
|
+
hasMore,
|
|
541
|
+
loadingMore,
|
|
542
|
+
loadMore,
|
|
543
|
+
catalog,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The href for a category or a collection.
|
|
3
|
+
*
|
|
4
|
+
* There were three copies of this before: useMenu().itemUrl(), a private
|
|
5
|
+
* classificationUrl() inside ClassificationListSection, and viewAllUrl() inside
|
|
6
|
+
* ProductListSection. Three copies of a route table is three chances to disagree
|
|
7
|
+
* with the backend's resolve_path(), and the failure is silent — a path the
|
|
8
|
+
* backend doesn't recognise falls through to NOT_FOUND, which renders a real
|
|
9
|
+
* page with the wrong content.
|
|
10
|
+
*
|
|
11
|
+
* The table, unchanged, from builder's resolve_path():
|
|
12
|
+
*
|
|
13
|
+
* /categories/<slug> CLASSIFICATION (resource.type = CATEGORY)
|
|
14
|
+
* /collections/<slug> CLASSIFICATION (resource.type = COLLECTION)
|
|
15
|
+
*
|
|
16
|
+
* Deciding which prefix an item takes is the whole job, and it can't be guessed
|
|
17
|
+
* from the shape of the object — categories and collections serialize almost
|
|
18
|
+
* identically. So `kind` is carried explicitly on every normalized item (see
|
|
19
|
+
* useClassificationItems), and this only falls back to sniffing when it's
|
|
20
|
+
* missing on a raw API row.
|
|
21
|
+
*/
|
|
22
|
+
export function useClassificationUrl() {
|
|
23
|
+
/**
|
|
24
|
+
* 'CATEGORY' | 'COLLECTION' for anything the API or the editor hands over.
|
|
25
|
+
*
|
|
26
|
+
* The editor stores a pick as { kind, id }; the storefront API returns rows
|
|
27
|
+
* that may carry `type`; a row read out of data.classifications may carry
|
|
28
|
+
* neither, in which case the caller already knew which endpoint it came from
|
|
29
|
+
* and should have stamped it. Defaults to CATEGORY because that's the older
|
|
30
|
+
* behaviour, and a wrong guess is at least a consistent wrong guess.
|
|
31
|
+
*/
|
|
32
|
+
function classificationKind(item, fallback = "CATEGORY") {
|
|
33
|
+
const raw = String(
|
|
34
|
+
item?.kind ?? item?.type ?? item?.resource_type ?? fallback
|
|
35
|
+
).toUpperCase();
|
|
36
|
+
return raw.includes("COLLECTION") ? "COLLECTION" : "CATEGORY";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function classificationSlug(item) {
|
|
40
|
+
return item?.slugified_name || item?.slug || item?.handle || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {object} item a normalized item, an API row, or an editor pick
|
|
45
|
+
* @param {string} [fallbackKind] used when the item carries no kind at all
|
|
46
|
+
*/
|
|
47
|
+
function classificationUrl(item, fallbackKind = "CATEGORY") {
|
|
48
|
+
if (!item) return "/";
|
|
49
|
+
|
|
50
|
+
// A backend that pre-resolved the path wins — it knows about routing
|
|
51
|
+
// changes this file may not.
|
|
52
|
+
if (item.url) return item.url;
|
|
53
|
+
|
|
54
|
+
const slug = classificationSlug(item);
|
|
55
|
+
if (!slug) return "/";
|
|
56
|
+
|
|
57
|
+
return classificationKind(item, fallbackKind) === "COLLECTION"
|
|
58
|
+
? `/collections/${slug}`
|
|
59
|
+
: `/categories/${slug}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { classificationUrl, classificationKind, classificationSlug };
|
|
63
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const FALLBACK = {
|
|
2
|
+
id: "SCHEME_1",
|
|
3
|
+
background: "#ffffff",
|
|
4
|
+
text: "#121212",
|
|
5
|
+
button: "#121212",
|
|
6
|
+
button_label: "#ffffff",
|
|
7
|
+
border: "#e5e5e5",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a section's COLOR_SCHEME id against the theme's palette list.
|
|
12
|
+
*
|
|
13
|
+
* A section stores "SCHEME_1", never a colour. That indirection is the whole
|
|
14
|
+
* reason a merchant can restyle every section at once by editing one palette.
|
|
15
|
+
*
|
|
16
|
+
* Falls back rather than throwing: a merchant can delete a scheme that sections
|
|
17
|
+
* still reference, and a missing palette must not blank the page.
|
|
18
|
+
*
|
|
19
|
+
* No preview handling here — useStorefront().theme already carries the
|
|
20
|
+
* builder's unsaved edits, so editing a palette repaints every section live.
|
|
21
|
+
*
|
|
22
|
+
* @param {string|Ref<string>} schemeId
|
|
23
|
+
*/
|
|
24
|
+
export function useColorScheme(schemeId) {
|
|
25
|
+
const { theme } = useStorefront();
|
|
26
|
+
|
|
27
|
+
return computed(() => {
|
|
28
|
+
const active = theme.value ?? {};
|
|
29
|
+
const id = unref(schemeId) || active.DEFAULT_SCHEME || "SCHEME_1";
|
|
30
|
+
const list = active.COLOR_SCHEMES ?? [];
|
|
31
|
+
const scheme = list.find((s) => s.id === id) ?? list[0] ?? FALLBACK;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
scheme,
|
|
35
|
+
style: {
|
|
36
|
+
"--osi-bg": scheme.background,
|
|
37
|
+
"--osi-text": scheme.text,
|
|
38
|
+
"--osi-btn-bg": scheme.button,
|
|
39
|
+
"--osi-btn-text": scheme.button_label,
|
|
40
|
+
"--osi-border": scheme.border,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
}
|