@osimart/create-storefront 0.0.0-stage → 1.0.1
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.js +24 -0
- package/app/plugins/reveal.client.js +53 -0
- package/app/plugins/toast.js +10 -0
- package/app/plugins/vue-query.js +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 +282 -0
- package/nuxt/osi-sections.ts +56 -0
- package/nuxt.config.ts +60 -0
- package/package.json +44 -4
- 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,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COUNTDOWN — time remaining until a date.
|
|
3
|
+
*
|
|
4
|
+
* Four things make this section fiddly:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Hydration.** A server-rendered "04:12:33" is stale by the time it
|
|
7
|
+
* reaches the browser, and Vue warns about the mismatch. So the server
|
|
8
|
+
* renders dashes and the real numbers appear on mount. That's one frame of
|
|
9
|
+
* placeholder, versus a console full of hydration errors and a clock that
|
|
10
|
+
* can be minutes wrong on a cached page.
|
|
11
|
+
* 2. **Timezone.** END_DATE is a naive datetime from the dashboard; the shopper
|
|
12
|
+
* is in their own zone. TIMEZONE_MODE says which is meant — STORE treats it
|
|
13
|
+
* as a fixed instant via an explicit offset, VISITOR as local wall-clock.
|
|
14
|
+
* Getting this wrong ends a sale an hour early for half the world.
|
|
15
|
+
* 3. **Settings drift.** Different callers of this section have shipped data
|
|
16
|
+
* with END_DATE/ENDS_AT and BUTTON_LABEL/CTA_LABEL interchangeably. A
|
|
17
|
+
* missing key here doesn't error — it just quietly hides the whole
|
|
18
|
+
* section (expired-with-no-target -> ON_EXPIRE=HIDE). So setting
|
|
19
|
+
* resolution below accepts both spellings rather than trusting one.
|
|
20
|
+
* 4. **Box styling is fully merchant-controlled**, including color-scheme
|
|
21
|
+
* fallbacks. Every box/value/label style is computed once and applied via
|
|
22
|
+
* :style rather than scoped classes, so an empty setting cleanly falls
|
|
23
|
+
* back to the store's color scheme instead of leaving a blank value.
|
|
24
|
+
*
|
|
25
|
+
* Settings: HEADING, SUBHEADING, END_DATE (alias: ENDS_AT), TIMEZONE_MODE
|
|
26
|
+
* (STORE|VISITOR), STORE_UTC_OFFSET, ON_EXPIRE (HIDE|MESSAGE|KEEP),
|
|
27
|
+
* EXPIRED_MESSAGE, SHOW_DAYS, SHOW_LABELS, BUTTON_LABEL (alias:
|
|
28
|
+
* CTA_LABEL), BUTTON_URL (alias: CTA_URL), DIGIT_STYLE
|
|
29
|
+
* (BOX|SOLID|MINIMAL), URGENT_MINUTES (default 60, 0 disables),
|
|
30
|
+
* BOX_BACKGROUND, BOX_BORDER_COLOR, BOX_BORDER_WIDTH, BOX_RADIUS,
|
|
31
|
+
* BOX_PADDING, UNIT_GAP, VALUE_SIZE, VALUE_COLOR, LABEL_COLOR,
|
|
32
|
+
* LABEL_SIZE, URGENT_BACKGROUND, URGENT_VALUE_COLOR,
|
|
33
|
+
* HEADING_TEXT_*, SUBHEADING_TEXT_* (size/weight/color/line-height/
|
|
34
|
+
* letter-spacing/font, per text_style()), CTA_VARIANT
|
|
35
|
+
* (PRIMARY|SECONDARY|LINK), CTA_BACKGROUND, CTA_TEXT_COLOR,
|
|
36
|
+
* CTA_RADIUS, CTA_FULL_WIDTH
|
|
37
|
+
*
|
|
38
|
+
* Note: the button only renders when BOTH a label and a URL resolve — a
|
|
39
|
+
* label with no destination isn't shown rather than rendering a dead link.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
export default function useLogic({ props }) {
|
|
44
|
+
const { s, t, shellStyle, innerStyle, align, customClass, heading } =
|
|
45
|
+
useSectionShell(() => props.section);
|
|
46
|
+
|
|
47
|
+
// Defensive aliasing — different section-data producers have used different
|
|
48
|
+
// key spellings for the same field. Prefer the canonical key, fall back to
|
|
49
|
+
// the alternates seen in the wild.
|
|
50
|
+
const rawEndDate = computed(() => s.value.END_DATE ?? s.value.ENDS_AT ?? s.value.END_AT);
|
|
51
|
+
const rawButtonLabel = computed(() => s.value.BUTTON_LABEL ?? s.value.CTA_LABEL);
|
|
52
|
+
const rawButtonUrl = computed(() => s.value.BUTTON_URL ?? s.value.CTA_URL);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* No end date yet: in the editor, count down to a week from now so the
|
|
56
|
+
* merchant sees a working timer while designing (the schema also pre-fills a
|
|
57
|
+
* date a week out). On the live store a missing date shows nothing, rather
|
|
58
|
+
* than a made-up deadline.
|
|
59
|
+
*/
|
|
60
|
+
const placeholders = usePlaceholders();
|
|
61
|
+
const demoTarget = new Date(Date.now() + 7 * 86400 * 1000);
|
|
62
|
+
const isDemo = computed(() => !rawEndDate.value && !!placeholders.allowed?.value);
|
|
63
|
+
|
|
64
|
+
const target = computed(() => {
|
|
65
|
+
const raw = rawEndDate.value;
|
|
66
|
+
if (!raw) return isDemo.value ? demoTarget : null;
|
|
67
|
+
|
|
68
|
+
if (s.value.TIMEZONE_MODE === "STORE") {
|
|
69
|
+
const offset = s.value.STORE_UTC_OFFSET ?? "+00:00";
|
|
70
|
+
// Only append an offset when the string doesn't already carry one.
|
|
71
|
+
const hasZone = /([+-]\d{2}:?\d{2}|Z)$/.test(raw);
|
|
72
|
+
const value = new Date(hasZone ? raw : `${raw}${offset}`);
|
|
73
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const value = new Date(raw);
|
|
77
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const remaining = ref(null); // null until mounted — see the hydration note
|
|
81
|
+
let timer = null;
|
|
82
|
+
|
|
83
|
+
function tick() {
|
|
84
|
+
if (!target.value) {
|
|
85
|
+
remaining.value = 0;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
remaining.value = Math.max(0, target.value.getTime() - Date.now());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onMounted(() => {
|
|
92
|
+
tick();
|
|
93
|
+
timer = setInterval(tick, 1000);
|
|
94
|
+
});
|
|
95
|
+
onBeforeUnmount(() => clearInterval(timer));
|
|
96
|
+
|
|
97
|
+
// Recompute immediately when the merchant edits the date in the builder.
|
|
98
|
+
watch(target, () => {
|
|
99
|
+
if (import.meta.client) tick();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const parts = computed(() => {
|
|
103
|
+
if (remaining.value === null) return null;
|
|
104
|
+
|
|
105
|
+
const total = Math.floor(remaining.value / 1000);
|
|
106
|
+
const showDays = s.value.SHOW_DAYS !== false;
|
|
107
|
+
|
|
108
|
+
const days = showDays ? Math.floor(total / 86400) : 0;
|
|
109
|
+
// Hours absorb the days when the days unit is hidden, so a 3-day sale doesn't
|
|
110
|
+
// show as "0 hours".
|
|
111
|
+
const hours = Math.floor((total - days * 86400) / 3600);
|
|
112
|
+
|
|
113
|
+
return [
|
|
114
|
+
...(showDays ? [{ id: "d", value: days, label: "Days" }] : []),
|
|
115
|
+
{ id: "h", value: hours, label: "Hours" },
|
|
116
|
+
{ id: "m", value: Math.floor((total % 3600) / 60), label: "Minutes" },
|
|
117
|
+
{ id: "s", value: total % 60, label: "Seconds" },
|
|
118
|
+
];
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const expired = computed(() => remaining.value !== null && remaining.value <= 0);
|
|
122
|
+
// Default MESSAGE, not HIDE: a section that silently vanishes when its date
|
|
123
|
+
// passes (or was never set) reads as "the countdown is broken".
|
|
124
|
+
const onExpire = computed(() => s.value.ON_EXPIRE ?? "MESSAGE");
|
|
125
|
+
const hidden = computed(
|
|
126
|
+
() => (!target.value && !isDemo.value) || (expired.value && onExpire.value === "HIDE")
|
|
127
|
+
);
|
|
128
|
+
const showZeros = computed(() => !expired.value || onExpire.value === "KEEP");
|
|
129
|
+
|
|
130
|
+
// Urgency: the closing stretch gets a distinct, merchant-defined visual
|
|
131
|
+
// state rather than looking identical to a countdown with three days left.
|
|
132
|
+
const urgentMinutes = computed(() => {
|
|
133
|
+
const raw = s.value.URGENT_MINUTES;
|
|
134
|
+
return raw === 0 ? 0 : raw ?? 60;
|
|
135
|
+
});
|
|
136
|
+
const isUrgent = computed(() => {
|
|
137
|
+
if (!urgentMinutes.value || remaining.value === null || expired.value) return false;
|
|
138
|
+
return remaining.value <= urgentMinutes.value * 60 * 1000;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
142
|
+
|
|
143
|
+
const buttonLabel = computed(() => t(rawButtonLabel.value));
|
|
144
|
+
const buttonUrl = computed(() => rawButtonUrl.value || null);
|
|
145
|
+
const digitStyle = computed(() => (s.value.DIGIT_STYLE || "BOX").toLowerCase());
|
|
146
|
+
|
|
147
|
+
// --- Fully merchant-controlled box styling -------------------------------
|
|
148
|
+
// Every value here has a sensible fallback to the theme's color-scheme
|
|
149
|
+
// variables, so an untouched section still looks intentional out of the box.
|
|
150
|
+
|
|
151
|
+
const px = (value, fallback) => `${value ?? fallback}px`;
|
|
152
|
+
|
|
153
|
+
const unitsStyle = computed(() => ({
|
|
154
|
+
gap: px(s.value.UNIT_GAP, 12),
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
const boxStyle = computed(() => {
|
|
158
|
+
const style = digitStyle.value === "minimal" ? {} : {};
|
|
159
|
+
|
|
160
|
+
// Background: SOLID defaults to the theme button color; BOX defaults to
|
|
161
|
+
// transparent (just a border); MINIMAL never gets a background.
|
|
162
|
+
if (digitStyle.value !== "minimal") {
|
|
163
|
+
const defaultBg = digitStyle.value === "solid" ? "var(--osi-btn-bg)" : "transparent";
|
|
164
|
+
style.background = isUrgent.value
|
|
165
|
+
? s.value.URGENT_BACKGROUND || s.value.BOX_BACKGROUND || defaultBg
|
|
166
|
+
: s.value.BOX_BACKGROUND || defaultBg;
|
|
167
|
+
style.borderRadius = px(s.value.BOX_RADIUS, 4);
|
|
168
|
+
style.padding = px(s.value.BOX_PADDING, 12);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (digitStyle.value === "box") {
|
|
172
|
+
style.borderWidth = px(s.value.BOX_BORDER_WIDTH, 1);
|
|
173
|
+
style.borderStyle = "solid";
|
|
174
|
+
style.borderColor = s.value.BOX_BORDER_COLOR || "var(--osi-border)";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return style;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const valueStyle = computed(() => {
|
|
181
|
+
const defaultColor = digitStyle.value === "solid" ? "var(--osi-btn-text)" : "inherit";
|
|
182
|
+
return {
|
|
183
|
+
fontSize: px(s.value.VALUE_SIZE, 44),
|
|
184
|
+
color: isUrgent.value
|
|
185
|
+
? s.value.URGENT_VALUE_COLOR || s.value.VALUE_COLOR || defaultColor
|
|
186
|
+
: s.value.VALUE_COLOR || defaultColor,
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const labelStyle = computed(() => ({
|
|
191
|
+
fontSize: px(s.value.LABEL_SIZE, 11),
|
|
192
|
+
color: s.value.LABEL_COLOR || undefined,
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
// --- CTA button styling ---------------------------------------------------
|
|
196
|
+
// Variant sets the base look; CTA_BACKGROUND/CTA_TEXT_COLOR/CTA_RADIUS layer
|
|
197
|
+
// on top as explicit overrides, same override-over-default pattern as the
|
|
198
|
+
// box styling above.
|
|
199
|
+
|
|
200
|
+
const ctaVariant = computed(() => s.value.CTA_VARIANT || "PRIMARY");
|
|
201
|
+
|
|
202
|
+
// CTA_* are this section's own button settings; anything left empty follows
|
|
203
|
+
// the theme's Buttons group via .osi-btn.
|
|
204
|
+
const ctaStyle = computed(() => {
|
|
205
|
+
const style = {};
|
|
206
|
+
if (s.value.CTA_BACKGROUND) style["--btn-bg"] = s.value.CTA_BACKGROUND;
|
|
207
|
+
if (s.value.CTA_TEXT_COLOR) style["--btn-fg"] = s.value.CTA_TEXT_COLOR;
|
|
208
|
+
if (s.value.CTA_RADIUS !== undefined && s.value.CTA_RADIUS !== null && s.value.CTA_RADIUS !== "") {
|
|
209
|
+
style["--btn-radius"] = `${s.value.CTA_RADIUS}px`;
|
|
210
|
+
}
|
|
211
|
+
return style;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const ctaClasses = computed(() => [
|
|
215
|
+
"osi-btn",
|
|
216
|
+
"osi-cd-button",
|
|
217
|
+
{
|
|
218
|
+
PRIMARY: "",
|
|
219
|
+
// SECONDARY = outline in the CTA's own colour when set, else the
|
|
220
|
+
// theme's secondary colours.
|
|
221
|
+
SECONDARY: s.value.CTA_BACKGROUND ? "osi-btn--outline" : "osi-btn--secondary",
|
|
222
|
+
LINK: "osi-btn--underline",
|
|
223
|
+
}[ctaVariant.value] ?? "",
|
|
224
|
+
{ "osi-btn--block": !!s.value.CTA_FULL_WIDTH },
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
// --- Vertical spacing between blocks --------------------------------------
|
|
228
|
+
// Previously a single hardcoded `gap: 1rem` on the flex column applied the
|
|
229
|
+
// same spacing everywhere. These give heading->subheading, text->countdown,
|
|
230
|
+
// and countdown->button independent control. Only applied when the element
|
|
231
|
+
// above actually rendered, so an untouched HEADING doesn't leave a phantom
|
|
232
|
+
// gap at the top when no button is shown, etc.
|
|
233
|
+
|
|
234
|
+
const hasHeading = computed(() => !!heading.value);
|
|
235
|
+
const hasSubheading = computed(() => !!t(s.value.SUBHEADING));
|
|
236
|
+
const hasTextBlock = computed(() => hasHeading.value || hasSubheading.value);
|
|
237
|
+
|
|
238
|
+
const subheadingStyle = computed(() =>
|
|
239
|
+
hasHeading.value ? { marginTop: px(s.value.HEADING_GAP, 12) } : {}
|
|
240
|
+
);
|
|
241
|
+
const countdownBlockStyle = computed(() =>
|
|
242
|
+
hasTextBlock.value ? { marginTop: px(s.value.CONTENT_GAP, 20) } : {}
|
|
243
|
+
);
|
|
244
|
+
const buttonStyle_spacing = computed(() => ({ marginTop: px(s.value.BUTTON_GAP, 20) }));
|
|
245
|
+
|
|
246
|
+
// A once-per-minute plain-language string for screen readers, instead of
|
|
247
|
+
// re-announcing every second like the visual timer does.
|
|
248
|
+
const srText = computed(() => {
|
|
249
|
+
if (!parts.value) return "";
|
|
250
|
+
return parts.value.map((p) => `${p.value} ${p.label}`).join(", ") + " remaining";
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return { s, t, shellStyle, innerStyle, align, customClass, heading, rawEndDate, rawButtonLabel, rawButtonUrl, placeholders, demoTarget, isDemo, target, remaining, timer, tick, parts, expired, onExpire, hidden, showZeros, urgentMinutes, isUrgent, pad, buttonLabel, buttonUrl, digitStyle, px, unitsStyle, boxStyle, valueStyle, labelStyle, ctaVariant, ctaStyle, ctaClasses, hasHeading, hasSubheading, hasTextBlock, subheadingStyle, countdownBlockStyle, buttonStyle_spacing, srText };
|
|
254
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CUSTOM_HTML — merchant-authored markup, with optional scoped CSS.
|
|
3
|
+
*
|
|
4
|
+
* ── What v-html does and doesn't do ────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* Inline <script> tags in v-html do NOT execute — the browser ignores scripts
|
|
7
|
+
* inserted via innerHTML. Event-handler attributes (onclick, onerror, onload)
|
|
8
|
+
* DO fire, and so do <iframe>, <object> and <embed>. So this is not a sandbox.
|
|
9
|
+
*
|
|
10
|
+
* That's an accepted risk here, at the same trust level as RICH_TEXT's BODY:
|
|
11
|
+
* the author is the store owner, editing their own storefront, and the payload
|
|
12
|
+
* only reaches this component through an authenticated dashboard write. If
|
|
13
|
+
* section content ever becomes writable by anyone less trusted — a staff role
|
|
14
|
+
* with limited permissions, an app integration, an import — this needs
|
|
15
|
+
* server-side sanitisation before that ships, not after.
|
|
16
|
+
*
|
|
17
|
+
* What is enforced: on SSR the markup renders as-is, but a merchant pasting a
|
|
18
|
+
* full page (<html>, <head>, <body>) would nest document tags inside a
|
|
19
|
+
* <section>, which browsers silently discard — so those wrappers are stripped
|
|
20
|
+
* and the contents kept, rather than the section rendering blank with no
|
|
21
|
+
* explanation.
|
|
22
|
+
*
|
|
23
|
+
* Settings: HTML, CUSTOM_CSS, FULL_WIDTH
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
export default function useLogic({ props }) {
|
|
28
|
+
const { s, t, shellStyle, innerStyle, customClass } = useSectionShell(
|
|
29
|
+
() => props.section
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const html = computed(() => {
|
|
33
|
+
const raw = t(s.value.HTML) || "";
|
|
34
|
+
if (!raw) return "";
|
|
35
|
+
|
|
36
|
+
return raw
|
|
37
|
+
.replace(/<\/?(?:html|head|body)\b[^>]*>/gi, "")
|
|
38
|
+
// <title> and <meta> inside the body are inert and confuse the outline.
|
|
39
|
+
.replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, "")
|
|
40
|
+
.replace(/<meta\b[^>]*>/gi, "");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Scoped per section instance so two CUSTOM_HTML blocks on one page can't
|
|
44
|
+
// overwrite each other's rules.
|
|
45
|
+
const scopeAttr = computed(() => `[data-osi-section="${props.section.key}"]`);
|
|
46
|
+
useScopedCss(() => s.value.CUSTOM_CSS, scopeAttr, `osi-html-css-${props.section.key}`);
|
|
47
|
+
|
|
48
|
+
const fullWidth = computed(() => !!s.value.FULL_WIDTH);
|
|
49
|
+
|
|
50
|
+
return { s, t, shellStyle, innerStyle, customClass, html, scopeAttr, fullWidth };
|
|
51
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DIVIDER — a visible line between sections.
|
|
3
|
+
*
|
|
4
|
+
* Spacing above and below is the section's own padding (common settings,
|
|
5
|
+
* with phone values), so a divider can hug the sections around it or stand
|
|
6
|
+
* apart. SPACER is the empty-space sibling.
|
|
7
|
+
*
|
|
8
|
+
* Settings: DIVIDER_STYLE (SOLID | DASHED | DOTTED | DOUBLE), DIVIDER_THICKNESS,
|
|
9
|
+
* DIVIDER_WIDTH (%), DIVIDER_COLOR, DIVIDER_ALIGNMENT, DIVIDER_LABEL
|
|
10
|
+
* (optional text in the middle of the line)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export default function useLogic({ props }) {
|
|
15
|
+
const { s, t, shellStyle, innerStyle, customClass } = useSectionShell(() => props.section);
|
|
16
|
+
|
|
17
|
+
const has = (v) => v !== undefined && v !== null && v !== "";
|
|
18
|
+
const label = computed(() => t(s.value.DIVIDER_LABEL));
|
|
19
|
+
|
|
20
|
+
const vars = computed(() => ({
|
|
21
|
+
"--dv-style": String(s.value.DIVIDER_STYLE || "SOLID").toLowerCase(),
|
|
22
|
+
"--dv-w": `${has(s.value.DIVIDER_THICKNESS) ? Number(s.value.DIVIDER_THICKNESS) : 1}px`,
|
|
23
|
+
"--dv-width": `${has(s.value.DIVIDER_WIDTH) ? Number(s.value.DIVIDER_WIDTH) : 100}%`,
|
|
24
|
+
"--dv-color": s.value.DIVIDER_COLOR || "var(--osi-border, #e5e5e5)",
|
|
25
|
+
"--dv-justify":
|
|
26
|
+
{ LEFT: "flex-start", CENTER: "center", RIGHT: "flex-end" }[String(s.value.DIVIDER_ALIGNMENT || "CENTER").toUpperCase()] ??
|
|
27
|
+
"center",
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
return { s, t, shellStyle, innerStyle, customClass, has, label, vars };
|
|
31
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
|
|
2
|
+
export default function useLogic({ props }) {
|
|
3
|
+
const ROOT_DEFAULTS = { DIRECTION: "VERTICAL", ALIGNMENT: "LEFT", POSITION: "TOP", GAP: 24 };
|
|
4
|
+
|
|
5
|
+
const { t } = useSectionShell(() => props.section);
|
|
6
|
+
|
|
7
|
+
const structuredData = computed(() => {
|
|
8
|
+
const blocks = props.section.blocks ?? [];
|
|
9
|
+
const byKey = new Map(blocks.map((b) => [b.key, b]));
|
|
10
|
+
|
|
11
|
+
// Live only if the block and every group above it are enabled and present.
|
|
12
|
+
const live = (block, depth = 0) => {
|
|
13
|
+
if (!block || block.disabled || depth > 16) return false;
|
|
14
|
+
if (!block.parent_key) return true;
|
|
15
|
+
return live(byKey.get(block.parent_key), depth + 1);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const entries = blocks
|
|
19
|
+
.filter((b) => b.type === "FAQ_ITEM" && live(b))
|
|
20
|
+
.filter((b) => {
|
|
21
|
+
const accordion = byKey.get(b.parent_key);
|
|
22
|
+
return accordion?.type === "ACCORDION" && accordion.settings?.FAQ_SCHEMA !== false;
|
|
23
|
+
})
|
|
24
|
+
.map((b) => ({ question: t(b.settings?.QUESTION), answer: t(b.settings?.ANSWER) }))
|
|
25
|
+
.filter((e) => e.question && e.answer);
|
|
26
|
+
|
|
27
|
+
if (!entries.length) return null;
|
|
28
|
+
return {
|
|
29
|
+
"@context": "https://schema.org",
|
|
30
|
+
"@type": "FAQPage",
|
|
31
|
+
mainEntity: entries.map((e) => ({
|
|
32
|
+
"@type": "Question",
|
|
33
|
+
name: e.question,
|
|
34
|
+
acceptedAnswer: { "@type": "Answer", text: e.answer },
|
|
35
|
+
})),
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
useHead(() => ({
|
|
40
|
+
script: structuredData.value
|
|
41
|
+
? [
|
|
42
|
+
{
|
|
43
|
+
key: `faq-ld-${props.section.key}`,
|
|
44
|
+
type: "application/ld+json",
|
|
45
|
+
// "</" can't appear inside a script element's text.
|
|
46
|
+
innerHTML: JSON.stringify(structuredData.value).replace(/</g, "\\u003c"),
|
|
47
|
+
},
|
|
48
|
+
]
|
|
49
|
+
: [],
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
return { ROOT_DEFAULTS, t, structuredData };
|
|
53
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
|
|
2
|
+
export default function useLogic({ props }) {
|
|
3
|
+
const DEFAULT_BLOCKS = [
|
|
4
|
+
{ id: "fp-title", type: "TITLE", settings: {} },
|
|
5
|
+
{ id: "fp-price", type: "PRICE", settings: {} },
|
|
6
|
+
{ id: "fp-picker", type: "VARIANT_PICKER", settings: {} },
|
|
7
|
+
{ id: "fp-buy", type: "BUY_BUTTONS", settings: { BUTTON_HEIGHT: 44 } },
|
|
8
|
+
{ id: "fp-description", type: "DESCRIPTION", settings: { DESCRIPTION_COLLAPSE: true, DESCRIPTION_CLAMP: 3 } },
|
|
9
|
+
{
|
|
10
|
+
id: "fp-more",
|
|
11
|
+
type: "BUTTON",
|
|
12
|
+
settings: { LABEL: "View full details", LINK_TO_PRODUCT: true, BUTTON_STYLE: "UNDERLINE" },
|
|
13
|
+
},
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The gallery is always a carousel here, desktop and phones (the section's
|
|
18
|
+
* schema has no layout picker). Unless the merchant set them: image and
|
|
19
|
+
* details share the row 50 / 50, the image square, the details filling their
|
|
20
|
+
* column. Side by side from tablets up (`featured` below), stacked on phones.
|
|
21
|
+
*/
|
|
22
|
+
const FIXED = { MEDIA_LAYOUT: "CAROUSEL", MOBILE_MEDIA_LAYOUT: "CAROUSEL" };
|
|
23
|
+
const SMALLER = { MEDIA_WIDTH: 50, MEDIA_RATIO: "SQUARE" };
|
|
24
|
+
const blank = (v) => v === undefined || v === null || v === "";
|
|
25
|
+
|
|
26
|
+
const withBlocks = computed(() => {
|
|
27
|
+
const settings = { ...(props.section.settings ?? {}) };
|
|
28
|
+
for (const [key, value] of Object.entries(SMALLER)) if (blank(settings[key])) settings[key] = value;
|
|
29
|
+
Object.assign(settings, FIXED);
|
|
30
|
+
return {
|
|
31
|
+
...props.section,
|
|
32
|
+
settings,
|
|
33
|
+
blocks: (props.section.blocks ?? []).length ? props.section.blocks : DEFAULT_BLOCKS,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const productId = computed(() => props.section.settings?.PRODUCT ?? props.section.data?.product ?? null);
|
|
38
|
+
|
|
39
|
+
return { DEFAULT_BLOCKS, FIXED, SMALLER, blank, withBlocks, productId };
|
|
40
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { FooterBlock } from "#components";
|
|
2
|
+
|
|
3
|
+
export default function useLogic({ props }) {
|
|
4
|
+
const { tree } = useContentTree(() => props.section, {
|
|
5
|
+
rootDefaults: { DIRECTION: "HORIZONTAL", ALIGNMENT: "SPACE_BETWEEN", POSITION: "TOP", GAP: 40 },
|
|
6
|
+
custom: {
|
|
7
|
+
FOOTER_MENU: FooterBlock,
|
|
8
|
+
FOOTER_LOGO: FooterBlock,
|
|
9
|
+
FOOTER_NEWSLETTER: FooterBlock,
|
|
10
|
+
FOOTER_SOCIAL: FooterBlock,
|
|
11
|
+
},
|
|
12
|
+
index: () => props.index,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
return { tree };
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
|
|
2
|
+
export default function useLogic({ props }) {
|
|
3
|
+
// The blocks' keys, so SiteFooter can tag their parts for the editor.
|
|
4
|
+
const keyOf = (type) =>
|
|
5
|
+
(props.section.blocks ?? []).find((b) => b.type === type && !b.disabled)?.key ?? null;
|
|
6
|
+
// …and their places: the bar shows its parts in block order (drag to reorder).
|
|
7
|
+
const orderOf = (type) =>
|
|
8
|
+
(props.section.blocks ?? []).filter((b) => !b.disabled).findIndex((b) => b.type === type);
|
|
9
|
+
provide(
|
|
10
|
+
"osiUtilityBlocks",
|
|
11
|
+
computed(() => ({
|
|
12
|
+
copyright: keyOf("UTILITY_COPYRIGHT"),
|
|
13
|
+
policies: keyOf("UTILITY_POLICIES"),
|
|
14
|
+
social: keyOf("UTILITY_SOCIAL"),
|
|
15
|
+
order: {
|
|
16
|
+
copyright: orderOf("UTILITY_COPYRIGHT"),
|
|
17
|
+
policies: orderOf("UTILITY_POLICIES"),
|
|
18
|
+
social: orderOf("UTILITY_SOCIAL"),
|
|
19
|
+
},
|
|
20
|
+
}))
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return { keyOf, orderOf };
|
|
24
|
+
}
|