@osimart/create-storefront 1.0.12 → 1.0.13
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.
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DISCOUNT_CODE — a code to copy, in a popup.
|
|
3
|
+
*
|
|
4
|
+
* REVEAL ALWAYS, or AFTER_SIGNUP: hidden until the popup's Email signup
|
|
5
|
+
* succeeds (inject 'osiPopup'), then it appears as the reward. The
|
|
6
|
+
* editor always shows it, tagged "After signing up".
|
|
7
|
+
* STYLE DASHED (a coupon), SOLID, MINIMAL.
|
|
8
|
+
*
|
|
9
|
+
* Copy uses the Clipboard API, with a select-the-text fallback.
|
|
10
|
+
*/
|
|
11
|
+
export default function useLogic({ props }) {
|
|
12
|
+
const t = useT();
|
|
13
|
+
const popup = inject("osiPopup", null);
|
|
14
|
+
|
|
15
|
+
const s = computed(() => props.node.s ?? {});
|
|
16
|
+
const code = computed(() => String(s.value.CODE || "").trim());
|
|
17
|
+
const afterSignup = computed(() => String(s.value.REVEAL || "ALWAYS").toUpperCase() === "AFTER_SIGNUP");
|
|
18
|
+
const preview = computed(() => !!popup?.preview?.value);
|
|
19
|
+
const visible = computed(() => !!code.value && (!afterSignup.value || preview.value || !!popup?.subscribed?.value));
|
|
20
|
+
const style = computed(() => String(s.value.STYLE || "DASHED").toLowerCase());
|
|
21
|
+
|
|
22
|
+
const label = computed(() => t(s.value.LABEL) || "");
|
|
23
|
+
const copyLabel = computed(() => t(s.value.COPY_LABEL) || "Copy");
|
|
24
|
+
const copiedLabel = computed(() => t(s.value.COPIED_LABEL) || "Copied!");
|
|
25
|
+
|
|
26
|
+
const codeEl = ref(null);
|
|
27
|
+
const copied = ref(false);
|
|
28
|
+
let timer = null;
|
|
29
|
+
|
|
30
|
+
async function copy() {
|
|
31
|
+
try {
|
|
32
|
+
await navigator.clipboard.writeText(code.value);
|
|
33
|
+
} catch {
|
|
34
|
+
// No clipboard access (http, old browser): select it for Ctrl/Cmd+C.
|
|
35
|
+
const el = unref(codeEl);
|
|
36
|
+
if (el) {
|
|
37
|
+
const range = document.createRange();
|
|
38
|
+
range.selectNodeContents(el);
|
|
39
|
+
const sel = window.getSelection();
|
|
40
|
+
sel.removeAllRanges();
|
|
41
|
+
sel.addRange(range);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
copied.value = true;
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
timer = setTimeout(() => (copied.value = false), 2200);
|
|
47
|
+
}
|
|
48
|
+
onBeforeUnmount(() => clearTimeout(timer));
|
|
49
|
+
|
|
50
|
+
return { s, code, afterSignup, preview, visible, style, label, copyLabel, copiedLabel, codeEl, copied, copy };
|
|
51
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import NewsletterServices from "#osi/services/NewsletterServices";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NEWSLETTER_FORM — the popup's email signup (PopupSection's content tree).
|
|
5
|
+
*
|
|
6
|
+
* Subscribes through the same endpoint as the Newsletter section
|
|
7
|
+
* (NewsletterServices), with source "POPUP:<popup name>" so sign-ups can be
|
|
8
|
+
* told apart. On success it tells the popup (inject 'osiPopup'), which
|
|
9
|
+
* remembers it — the popup stops showing, an AFTER_SIGNUP discount code
|
|
10
|
+
* appears — and swaps the form for SUCCESS_HEADING / SUCCESS_MESSAGE.
|
|
11
|
+
*
|
|
12
|
+
* The editor never signs anyone up: submitting there shows the success view,
|
|
13
|
+
* so it can be checked.
|
|
14
|
+
*/
|
|
15
|
+
export default function useLogic({ props }) {
|
|
16
|
+
const t = useT();
|
|
17
|
+
const analytics = useAnalytics();
|
|
18
|
+
const { showApiError } = useApiErrorToast();
|
|
19
|
+
const popup = inject("osiPopup", null);
|
|
20
|
+
|
|
21
|
+
const s = computed(() => props.node.s ?? {});
|
|
22
|
+
const stacked = computed(() => String(s.value.LAYOUT || "INLINE").toUpperCase() === "STACKED");
|
|
23
|
+
const fullWidth = computed(() => s.value.FULL_WIDTH !== false);
|
|
24
|
+
|
|
25
|
+
const email = ref("");
|
|
26
|
+
const submitting = ref(false);
|
|
27
|
+
const error = ref("");
|
|
28
|
+
const done = ref(false);
|
|
29
|
+
// Signed up before (this browser): straight to the thank-you.
|
|
30
|
+
const success = computed(() => done.value || !!popup?.subscribed?.value);
|
|
31
|
+
|
|
32
|
+
const placeholder = computed(() => t(s.value.PLACEHOLDER) || "Enter your email");
|
|
33
|
+
const buttonLabel = computed(() => t(s.value.BUTTON_LABEL) || "Subscribe");
|
|
34
|
+
const successHeading = computed(() => t(s.value.SUCCESS_HEADING) || "");
|
|
35
|
+
const successMessage = computed(() => t(s.value.SUCCESS_MESSAGE) || "");
|
|
36
|
+
const consent = computed(() => t(s.value.CONSENT_TEXT) || "");
|
|
37
|
+
|
|
38
|
+
const inputId = `pp-email-${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
|
|
39
|
+
|
|
40
|
+
async function submit() {
|
|
41
|
+
error.value = "";
|
|
42
|
+
const value = email.value.trim();
|
|
43
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
44
|
+
error.value = value ? "Enter a valid email address." : "Enter your email address.";
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (popup?.preview?.value) {
|
|
48
|
+
done.value = true;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
submitting.value = true;
|
|
52
|
+
try {
|
|
53
|
+
await NewsletterServices.subscribe(value, popup?.source?.value ?? "POPUP");
|
|
54
|
+
analytics?.lead?.("popup");
|
|
55
|
+
done.value = true;
|
|
56
|
+
email.value = "";
|
|
57
|
+
popup?.markSubscribed?.();
|
|
58
|
+
} catch (err) {
|
|
59
|
+
error.value = showApiError(err, "Couldn't subscribe right now.");
|
|
60
|
+
} finally {
|
|
61
|
+
submitting.value = false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { s, stacked, fullWidth, email, submitting, error, success, placeholder, buttonLabel, successHeading, successMessage, consent, inputId, submit };
|
|
66
|
+
}
|
|
@@ -48,6 +48,12 @@ const list = (sectionGroups.value?.header ?? []).filter((s) => !s.disabled);
|
|
|
48
48
|
? (sectionGroups.value?.footer ?? []).filter((s) => !s.disabled)
|
|
49
49
|
: []
|
|
50
50
|
);
|
|
51
|
+
// The overlay group (popups): over every full page — never checkout.
|
|
52
|
+
const overlaySections = computed(() =>
|
|
53
|
+
chrome.value === "full"
|
|
54
|
+
? (sectionGroups.value?.overlay ?? []).filter((s) => !s.disabled)
|
|
55
|
+
: []
|
|
56
|
+
);
|
|
51
57
|
|
|
52
58
|
// Drawers are useState-backed so they survive the page component swapping out.
|
|
53
59
|
// That's deliberate, and it means navigation has to close them explicitly.
|
|
@@ -62,5 +68,5 @@ const list = (sectionGroups.value?.header ?? []).filter((s) => !s.disabled);
|
|
|
62
68
|
eventBus.off("cartUpdated", fetchCart);
|
|
63
69
|
});
|
|
64
70
|
|
|
65
|
-
return { route, closeAll, fetchCart, chrome, showHeader, showFooter, showNav, sectionGroups, isPreview, headerSections, footerSections, resolveSection };
|
|
66
|
-
}
|
|
71
|
+
return { route, closeAll, fetchCart, chrome, showHeader, showFooter, showNav, sectionGroups, isPreview, headerSections, footerSections, overlaySections, resolveSection };
|
|
72
|
+
}
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { PopupNewsletter, PopupDiscount, FooterBlock } from "#components";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* POPUP — Shopify's overlay-group popup (OVERLAY_GROUP, over every page).
|
|
5
|
+
*
|
|
6
|
+
* when TRIGGER: DELAY (DELAY s) | SCROLL (SCROLL_PERCENT %) |
|
|
7
|
+
* EXIT (leaving: the pointer heads for the tab bar; on phones, a
|
|
8
|
+
* quick scroll back up) | MANUAL (a link / the teaser only).
|
|
9
|
+
* Any link to #popup-<POPUP_ID> opens it, whatever the trigger.
|
|
10
|
+
* how often FREQUENCY: ALWAYS (every page — nothing stored: closing it
|
|
11
|
+
* lasts until the next page) | SESSION (every visit) | DAYS
|
|
12
|
+
* (FREQUENCY_DAYS) | ONCE, counted from when it was closed;
|
|
13
|
+
* HIDE_AFTER_SIGNUP stops it for good once the visitor signs up.
|
|
14
|
+
* Kept in the visitor's browser (localStorage / sessionStorage),
|
|
15
|
+
* per popup — except ALWAYS.
|
|
16
|
+
* who AUDIENCE (EVERYONE | GUESTS), DEVICES (ALL | DESKTOP | MOBILE),
|
|
17
|
+
* PAGES (ALL | HOME | NOT_HOME). The layout never draws it on
|
|
18
|
+
* checkout.
|
|
19
|
+
* teaser its own block (POPUP_TEASER): the button that reopens it —
|
|
20
|
+
* once closed (SHOW AFTER_CLOSE) or until it opens (ALWAYS); for
|
|
21
|
+
* MANUAL always. Text + icon / image or custom HTML; pill, edge
|
|
22
|
+
* tab, round badge or card; colours; an animation (pulse, bounce,
|
|
23
|
+
* wiggle, shine, float) non-stop, every few seconds or once; its
|
|
24
|
+
* own scoped Custom CSS. Gone once the visitor signs up or hides
|
|
25
|
+
* it. Selected in the editor, it shows on its own (popup closed).
|
|
26
|
+
*
|
|
27
|
+
* One popup at a time: an automatic trigger waits while another is open.
|
|
28
|
+
*
|
|
29
|
+
* The editor: nothing opens by itself. The popup is open while it — or any
|
|
30
|
+
* of its blocks — is selected, so it can be designed in place; its close
|
|
31
|
+
* button closes it until the selection changes.
|
|
32
|
+
*
|
|
33
|
+
* Blocks are a content tree (Text, Button, Image, Icon, Spacer, Groups) plus
|
|
34
|
+
* NEWSLETTER_FORM, DISCOUNT_CODE (PopupNewsletter / PopupDiscount) and the
|
|
35
|
+
* footer's social links. Those two share state through provide('osiPopup'):
|
|
36
|
+
* signing up flips `subscribed`, which reveals an AFTER_SIGNUP code.
|
|
37
|
+
*/
|
|
38
|
+
const STORAGE = "osi-popup:";
|
|
39
|
+
|
|
40
|
+
function readJSON(storage, key) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(storage.getItem(key) || "null");
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function writeJSON(storage, key, value) {
|
|
48
|
+
try {
|
|
49
|
+
storage.setItem(key, JSON.stringify(value));
|
|
50
|
+
} catch {
|
|
51
|
+
/* private mode / blocked: the popup just isn't remembered */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function useLogic({ props, panelEl }) {
|
|
56
|
+
const t = useT();
|
|
57
|
+
const route = useRoute();
|
|
58
|
+
const { mediaUrl } = useMediaUrl();
|
|
59
|
+
const { isLoggedIn } = useAuth();
|
|
60
|
+
const { isPreview, selectedKey } = useBuilderPreview();
|
|
61
|
+
|
|
62
|
+
const s = computed(() => props.section.settings ?? {});
|
|
63
|
+
const upper = (v, d) => String(v || d).toUpperCase();
|
|
64
|
+
const num = (v, d) => (v !== "" && v != null && Number.isFinite(Number(v)) ? Number(v) : d);
|
|
65
|
+
|
|
66
|
+
const id = computed(() => String(s.value.POPUP_ID || props.section.key || "popup").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"));
|
|
67
|
+
const storeKey = computed(() => `${STORAGE}${props.section.key || id.value}`);
|
|
68
|
+
|
|
69
|
+
/* ---------------------------------------------------------------- look */
|
|
70
|
+
|
|
71
|
+
const placement = computed(() => {
|
|
72
|
+
const v = upper(s.value.PLACEMENT, "CENTER");
|
|
73
|
+
return ["CENTER", "BOTTOM_RIGHT", "BOTTOM_LEFT", "BOTTOM"].includes(v) ? v : "CENTER";
|
|
74
|
+
});
|
|
75
|
+
const sheetOnMobile = computed(() => upper(s.value.MOBILE_STYLE, "SHEET") === "SHEET");
|
|
76
|
+
const modal = computed(() => placement.value === "CENTER");
|
|
77
|
+
const scheme = useColorScheme(computed(() => s.value.COLOR_SCHEME));
|
|
78
|
+
|
|
79
|
+
const image = computed(() => mediaUrl(s.value.IMAGE));
|
|
80
|
+
const imagePosition = computed(() => {
|
|
81
|
+
const v = upper(s.value.IMAGE_POSITION, "LEFT");
|
|
82
|
+
// A bar has no room beside or above: the image goes behind it.
|
|
83
|
+
if (placement.value === "BOTTOM" && v !== "BACKGROUND") return "LEFT";
|
|
84
|
+
return ["LEFT", "RIGHT", "TOP", "BACKGROUND"].includes(v) ? v : "LEFT";
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const { tree } = useContentTree(() => props.section, {
|
|
88
|
+
// Getters: read inside the tree's computed, so they stay live.
|
|
89
|
+
rootDefaults: {
|
|
90
|
+
get DIRECTION() {
|
|
91
|
+
return placement.value === "BOTTOM" ? "HORIZONTAL" : "VERTICAL";
|
|
92
|
+
},
|
|
93
|
+
ALIGNMENT: "CENTER",
|
|
94
|
+
GAP: 14,
|
|
95
|
+
},
|
|
96
|
+
custom: {
|
|
97
|
+
NEWSLETTER_FORM: PopupNewsletter,
|
|
98
|
+
DISCOUNT_CODE: PopupDiscount,
|
|
99
|
+
FOOTER_SOCIAL: FooterBlock,
|
|
100
|
+
},
|
|
101
|
+
index: () => 99,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** The first text block, as the dialog's accessible name. */
|
|
105
|
+
const label = computed(() => {
|
|
106
|
+
const text = (props.section.blocks ?? []).find((b) => b.type === "TEXT" && !b.disabled);
|
|
107
|
+
const html = t(text?.settings?.TEXT) || "";
|
|
108
|
+
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() || "Popup";
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const vars = computed(() => {
|
|
112
|
+
const v = s.value;
|
|
113
|
+
return {
|
|
114
|
+
...scheme.value.style,
|
|
115
|
+
"--pp-w": `${num(v.WIDTH, 760)}px`,
|
|
116
|
+
"--pp-pad": `${num(v.PADDING, 40)}px`,
|
|
117
|
+
"--pp-pad-m": `${num(v.PADDING_MOBILE, 24)}px`,
|
|
118
|
+
"--pp-radius": `${num(v.RADIUS, 16)}px`,
|
|
119
|
+
"--pp-img-w": `${num(v.IMAGE_WIDTH, 45)}%`,
|
|
120
|
+
"--pp-img-h": `${num(v.IMAGE_HEIGHT, 200)}px`,
|
|
121
|
+
"--pp-img-dim": String(num(v.IMAGE_OVERLAY, 40) / 100),
|
|
122
|
+
"--pp-backdrop": String(num(v.BACKDROP_OPACITY, 55) / 100),
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const panelClasses = computed(() => [
|
|
127
|
+
`at-${placement.value.toLowerCase().replace("_", "-")}`,
|
|
128
|
+
`anim-${upper(s.value.ANIMATION, "RISE").toLowerCase()}`,
|
|
129
|
+
{
|
|
130
|
+
"sheet-mobile": sheetOnMobile.value,
|
|
131
|
+
"has-image": !!image.value,
|
|
132
|
+
[`img-${imagePosition.value.toLowerCase()}`]: !!image.value,
|
|
133
|
+
"img-hide-mobile": !!image.value && s.value.IMAGE_ON_MOBILE === false,
|
|
134
|
+
"close-plain": upper(s.value.CLOSE_STYLE, "ROUND") === "PLAIN",
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
const blurBackdrop = computed(() => s.value.BACKDROP_BLUR !== false);
|
|
138
|
+
const dismissLabel = computed(() => t(s.value.DISMISS_LABEL) || "");
|
|
139
|
+
const transitionName = computed(() => `osi-pp-${upper(s.value.ANIMATION, "RISE").toLowerCase()}`);
|
|
140
|
+
|
|
141
|
+
/* ------------------------------------------------------------ memory */
|
|
142
|
+
|
|
143
|
+
const mounted = ref(false);
|
|
144
|
+
const isPhone = ref(false); // a phone-sized screen (client only)
|
|
145
|
+
const memory = ref({ closedAt: 0, subscribed: false, teaserHidden: false });
|
|
146
|
+
const closedThisSession = ref(false);
|
|
147
|
+
// Closed / teaser hidden on this page only (ALWAYS, REMEMBER_HIDDEN off):
|
|
148
|
+
// forgotten on the next page.
|
|
149
|
+
const closedThisPage = ref(false);
|
|
150
|
+
const teaserHiddenThisPage = ref(false);
|
|
151
|
+
|
|
152
|
+
function loadMemory() {
|
|
153
|
+
memory.value = { closedAt: 0, subscribed: false, teaserHidden: false, ...(readJSON(localStorage, storeKey.value) ?? {}) };
|
|
154
|
+
closedThisSession.value = !!readJSON(sessionStorage, storeKey.value);
|
|
155
|
+
}
|
|
156
|
+
function remember(patch) {
|
|
157
|
+
memory.value = { ...memory.value, ...patch };
|
|
158
|
+
writeJSON(localStorage, storeKey.value, memory.value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const subscribed = computed(() => !!memory.value.subscribed);
|
|
162
|
+
const doneForGood = computed(() => subscribed.value && s.value.HIDE_AFTER_SIGNUP !== false);
|
|
163
|
+
|
|
164
|
+
/** May an automatic trigger open it now? (A link always may.) */
|
|
165
|
+
const dueAgain = computed(() => {
|
|
166
|
+
if (doneForGood.value) return false;
|
|
167
|
+
const freq = upper(s.value.FREQUENCY, "DAYS");
|
|
168
|
+
if (freq === "ALWAYS") return !closedThisPage.value;
|
|
169
|
+
const closedAt = memory.value.closedAt || 0;
|
|
170
|
+
if (freq === "SESSION") return !closedThisSession.value;
|
|
171
|
+
if (freq === "ONCE") return !closedAt;
|
|
172
|
+
if (!closedAt) return true;
|
|
173
|
+
return Date.now() - closedAt > num(s.value.FREQUENCY_DAYS, 7) * 86400000;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/* ---------------------------------------------------------- targeting */
|
|
177
|
+
|
|
178
|
+
const onThisPage = computed(() => {
|
|
179
|
+
const home = route.path === "/" || route.path === "";
|
|
180
|
+
const pages = upper(s.value.PAGES, "ALL");
|
|
181
|
+
if (pages === "HOME") return home;
|
|
182
|
+
if (pages === "NOT_HOME") return !home;
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
function deviceMatches() {
|
|
186
|
+
const devices = upper(s.value.DEVICES, "ALL");
|
|
187
|
+
if (devices === "ALL" || !import.meta.client) return true;
|
|
188
|
+
const phone = window.matchMedia("(max-width: 767px)").matches;
|
|
189
|
+
return devices === "MOBILE" ? phone : !phone;
|
|
190
|
+
}
|
|
191
|
+
const audienceMatches = computed(() => upper(s.value.AUDIENCE, "EVERYONE") !== "GUESTS" || !isLoggedIn.value);
|
|
192
|
+
|
|
193
|
+
/* -------------------------------------------------------------- state */
|
|
194
|
+
|
|
195
|
+
// Which popup is open, across the overlay group: one at a time.
|
|
196
|
+
const openPopup = useState("osi-popup-open", () => null);
|
|
197
|
+
const open = computed(() => openPopup.value === props.section.key);
|
|
198
|
+
|
|
199
|
+
// The editor: open while selected; × closes it until the selection moves.
|
|
200
|
+
const editorClosed = ref(false);
|
|
201
|
+
// The teaser isn't part of the popup's content: selecting it shows the
|
|
202
|
+
// teaser on its own, so the popup doesn't cover it.
|
|
203
|
+
const ownKeys = computed(
|
|
204
|
+
() =>
|
|
205
|
+
new Set([
|
|
206
|
+
props.section.key,
|
|
207
|
+
...(props.section.blocks ?? []).filter((b) => b.type !== "POPUP_TEASER").map((b) => b.key),
|
|
208
|
+
])
|
|
209
|
+
);
|
|
210
|
+
const selectedHere = computed(() => isPreview.value && ownKeys.value.has(selectedKey.value));
|
|
211
|
+
watch(selectedKey, () => {
|
|
212
|
+
editorClosed.value = false;
|
|
213
|
+
});
|
|
214
|
+
watch(
|
|
215
|
+
[selectedHere, editorClosed],
|
|
216
|
+
([here, closed]) => {
|
|
217
|
+
if (!isPreview.value) return;
|
|
218
|
+
if (here && !closed) openPopup.value = props.section.key;
|
|
219
|
+
else if (open.value) openPopup.value = null;
|
|
220
|
+
},
|
|
221
|
+
{ immediate: true }
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
let lastFocus = null;
|
|
225
|
+
function show() {
|
|
226
|
+
if (open.value) return;
|
|
227
|
+
if (openPopup.value && openPopup.value !== props.section.key) return; // another one is up
|
|
228
|
+
lastFocus = import.meta.client ? document.activeElement : null;
|
|
229
|
+
openPopup.value = props.section.key;
|
|
230
|
+
disarm();
|
|
231
|
+
}
|
|
232
|
+
function close({ forGood = false } = {}) {
|
|
233
|
+
if (!open.value) return;
|
|
234
|
+
openPopup.value = null;
|
|
235
|
+
if (isPreview.value) {
|
|
236
|
+
editorClosed.value = true;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
closedThisPage.value = true;
|
|
240
|
+
if (upper(s.value.FREQUENCY, "DAYS") === "ALWAYS") {
|
|
241
|
+
// Nothing stored: "No thanks" just hides the teaser on this page too.
|
|
242
|
+
if (forGood) teaserHiddenThisPage.value = true;
|
|
243
|
+
} else {
|
|
244
|
+
remember({ closedAt: Date.now(), ...(forGood ? { teaserHidden: true } : {}) });
|
|
245
|
+
closedThisSession.value = true;
|
|
246
|
+
writeJSON(sessionStorage, storeKey.value, true);
|
|
247
|
+
}
|
|
248
|
+
nextTick(() => lastFocus?.focus?.());
|
|
249
|
+
}
|
|
250
|
+
function markSubscribed() {
|
|
251
|
+
if (isPreview.value) return;
|
|
252
|
+
remember({ subscribed: true });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
provide("osiPopup", {
|
|
256
|
+
subscribed: computed(() => subscribed.value),
|
|
257
|
+
preview: isPreview,
|
|
258
|
+
source: computed(() => `POPUP:${id.value}`),
|
|
259
|
+
markSubscribed,
|
|
260
|
+
close: () => close(),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
/* ------------------------------------------------------------- teaser */
|
|
264
|
+
|
|
265
|
+
const teaserBlock = computed(
|
|
266
|
+
() => (props.section.blocks ?? []).find((b) => b.type === "POPUP_TEASER" && !b.disabled) ?? null
|
|
267
|
+
);
|
|
268
|
+
const ts = computed(() => teaserBlock.value?.settings ?? {});
|
|
269
|
+
const teaserKey = computed(() => teaserBlock.value?.key);
|
|
270
|
+
const teaserSelected = computed(() => isPreview.value && !!teaserKey.value && selectedKey.value === teaserKey.value);
|
|
271
|
+
|
|
272
|
+
const TEASER_ICONS = {
|
|
273
|
+
GIFT: "M20 12v8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-8M2 7h20v5H2zM12 21V7M12 7H7.5a2.5 2.5 0 1 1 0-5C11 2 12 7 12 7zM12 7h4.5a2.5 2.5 0 1 0 0-5C13 2 12 7 12 7z",
|
|
274
|
+
PERCENT: "M19 5 5 19M6.5 9a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM17.5 20a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z",
|
|
275
|
+
TAG: "M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0L3 13V3h10l7.6 7.6a2 2 0 0 1 0 2.8zM7.5 7.5h.01",
|
|
276
|
+
MAIL: "M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM3 7l9 6 9-6",
|
|
277
|
+
STAR: "M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.9l-5.2 2.7 1-5.8L3.5 9.7l5.9-.9z",
|
|
278
|
+
SPARKLE: "M12 3v4M12 17v4M3 12h4M17 12h4M12 8l1.5 2.5L16 12l-2.5 1.5L12 16l-1.5-2.5L8 12l2.5-1.5z",
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const teaserMode = computed(() => (upper(ts.value.MODE, "TEXT") === "HTML" ? "HTML" : "TEXT"));
|
|
282
|
+
const teaserLabel = computed(() => t(ts.value.LABEL) || "");
|
|
283
|
+
const teaserHtml = computed(() => (teaserMode.value === "HTML" ? String(ts.value.HTML || "") : ""));
|
|
284
|
+
const teaserImage = computed(() => (teaserMode.value === "TEXT" ? mediaUrl(ts.value.IMAGE) : ""));
|
|
285
|
+
const teaserIcon = computed(() =>
|
|
286
|
+
teaserMode.value === "TEXT" && !teaserImage.value ? TEASER_ICONS[upper(ts.value.ICON, "GIFT")] ?? "" : ""
|
|
287
|
+
);
|
|
288
|
+
const teaserShape = computed(() => {
|
|
289
|
+
const v = upper(ts.value.SHAPE, "PILL");
|
|
290
|
+
return ["PILL", "TAB", "BADGE", "CARD", "IMAGE"].includes(v) ? v : "PILL";
|
|
291
|
+
});
|
|
292
|
+
const teaserPosition = computed(() => {
|
|
293
|
+
const v = upper(ts.value.POSITION, "BOTTOM_LEFT");
|
|
294
|
+
return ["BOTTOM_LEFT", "BOTTOM_RIGHT", "SIDE_LEFT", "SIDE_RIGHT"].includes(v) ? v : "BOTTOM_LEFT";
|
|
295
|
+
});
|
|
296
|
+
/** Something to show: text, an icon / image, or custom HTML. */
|
|
297
|
+
const teaserHasContent = computed(() =>
|
|
298
|
+
teaserMode.value === "HTML" ? !!teaserHtml.value.trim() : !!(teaserLabel.value || teaserImage.value || teaserIcon.value)
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const teaserAnimation = computed(() => {
|
|
302
|
+
const v = upper(ts.value.ANIMATION, "NONE");
|
|
303
|
+
return ["PULSE", "BOUNCE", "WIGGLE", "SHINE", "FLOAT"].includes(v) ? v : "NONE";
|
|
304
|
+
});
|
|
305
|
+
const teaserRepeat = computed(() => upper(ts.value.ANIMATION_REPEAT, "INTERVAL"));
|
|
306
|
+
|
|
307
|
+
const teaserClasses = computed(() => [
|
|
308
|
+
`at-${teaserPosition.value.toLowerCase().replace("_", "-")}`,
|
|
309
|
+
`shape-${teaserShape.value.toLowerCase()}`,
|
|
310
|
+
`shadow-${upper(ts.value.SHADOW, "SOFT").toLowerCase()}`,
|
|
311
|
+
`anim-${teaserAnimation.value.toLowerCase()}`,
|
|
312
|
+
`repeat-${teaserRepeat.value.toLowerCase()}`,
|
|
313
|
+
{
|
|
314
|
+
"is-playing": teaserPlaying.value,
|
|
315
|
+
"hide-mobile": ts.value.SHOW_ON_MOBILE === false,
|
|
316
|
+
"is-html": teaserMode.value === "HTML",
|
|
317
|
+
"has-label": !!teaserLabel.value && teaserMode.value === "TEXT",
|
|
318
|
+
},
|
|
319
|
+
String(ts.value.CUSTOM_CLASS || "").trim() || null,
|
|
320
|
+
]);
|
|
321
|
+
const teaserVars = computed(() => {
|
|
322
|
+
const v = ts.value;
|
|
323
|
+
return Object.fromEntries(
|
|
324
|
+
Object.entries({
|
|
325
|
+
"--ppt-x": `${num(v.OFFSET_X, 20)}px`,
|
|
326
|
+
"--ppt-y": `${num(v.OFFSET_Y, 20)}px`,
|
|
327
|
+
"--ppt-fs": `${num(v.SIZE, 13)}px`,
|
|
328
|
+
"--ppt-img": `${num(v.IMAGE_SIZE, 18)}px`,
|
|
329
|
+
"--ppt-bg": v.BACKGROUND || undefined,
|
|
330
|
+
"--ppt-color": v.COLOR || undefined,
|
|
331
|
+
"--ppt-border": v.BORDER_COLOR || undefined,
|
|
332
|
+
"--ppt-bw": `${num(v.BORDER_WIDTH, 0)}px`,
|
|
333
|
+
"--ppt-radius": `${num(v.RADIUS, 999)}px`,
|
|
334
|
+
// Image only: the default "fully round" would crop the picture to a
|
|
335
|
+
// pill — it gets soft corners unless a real radius is set.
|
|
336
|
+
"--ppt-radius-img": num(v.RADIUS, 999) >= 999 ? "14px" : `${num(v.RADIUS, 999)}px`,
|
|
337
|
+
// Empty: the shape's own padding.
|
|
338
|
+
"--ppt-px": v.PADDING_X !== "" && v.PADDING_X != null ? `${num(v.PADDING_X, 0)}px` : undefined,
|
|
339
|
+
"--ppt-py": v.PADDING_Y !== "" && v.PADDING_Y != null ? `${num(v.PADDING_Y, 0)}px` : undefined,
|
|
340
|
+
}).filter(([, val]) => val !== undefined)
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// The teaser's own Custom CSS, scoped to it ("&" is the teaser itself).
|
|
345
|
+
useScopedCss(
|
|
346
|
+
() => ts.value.CUSTOM_CSS,
|
|
347
|
+
() => (teaserKey.value ? `[data-osi-block="${teaserKey.value}"]` : ""),
|
|
348
|
+
`osi-popup-teaser-css-${props.section.key}`
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const showTeaser = computed(() => {
|
|
352
|
+
if (!mounted.value || open.value || !teaserBlock.value || !teaserHasContent.value) return false;
|
|
353
|
+
if (isPreview.value) return true; // so it can be seen and styled
|
|
354
|
+
if (ts.value.SHOW_ON_MOBILE === false && isPhone.value) return false;
|
|
355
|
+
if (!onThisPage.value || !audienceMatches.value || doneForGood.value) return false;
|
|
356
|
+
if (memory.value.teaserHidden || teaserHiddenThisPage.value) return false;
|
|
357
|
+
if (upper(s.value.TRIGGER, "DELAY") === "MANUAL") return true;
|
|
358
|
+
if (upper(ts.value.SHOW, "AFTER_CLOSE") === "ALWAYS") return true;
|
|
359
|
+
// ALWAYS forgets: only a close on this page counts.
|
|
360
|
+
if (upper(s.value.FREQUENCY, "DAYS") === "ALWAYS") return closedThisPage.value;
|
|
361
|
+
return !!memory.value.closedAt || closedThisSession.value || closedThisPage.value;
|
|
362
|
+
});
|
|
363
|
+
const teaserDismissible = computed(() => ts.value.DISMISSIBLE !== false);
|
|
364
|
+
function hideTeaser() {
|
|
365
|
+
if (isPreview.value) return;
|
|
366
|
+
// Remembered for good, or (REMEMBER_HIDDEN off) just on this page.
|
|
367
|
+
if (ts.value.REMEMBER_HIDDEN === false) teaserHiddenThisPage.value = true;
|
|
368
|
+
else remember({ teaserHidden: true });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Bounce / pulse / wiggle / shine: played by toggling .is-playing —
|
|
372
|
+
// non-stop, once when it appears, or every ANIMATION_INTERVAL seconds.
|
|
373
|
+
const teaserPlaying = ref(false);
|
|
374
|
+
let teaserTimer = null;
|
|
375
|
+
function playTeaser() {
|
|
376
|
+
teaserPlaying.value = false;
|
|
377
|
+
requestAnimationFrame(() => requestAnimationFrame(() => (teaserPlaying.value = true)));
|
|
378
|
+
}
|
|
379
|
+
watch(
|
|
380
|
+
[showTeaser, teaserAnimation, teaserRepeat, () => ts.value.ANIMATION_INTERVAL],
|
|
381
|
+
([visible, anim, repeat]) => {
|
|
382
|
+
clearInterval(teaserTimer);
|
|
383
|
+
teaserTimer = null;
|
|
384
|
+
teaserPlaying.value = false;
|
|
385
|
+
if (!import.meta.client || !visible || anim === "NONE" || anim === "FLOAT") return;
|
|
386
|
+
if (repeat === "LOOP") return void (teaserPlaying.value = true);
|
|
387
|
+
// A beat after it appears, so the entrance and the animation don't overlap.
|
|
388
|
+
setTimeout(playTeaser, 700);
|
|
389
|
+
if (repeat === "INTERVAL") teaserTimer = setInterval(playTeaser, num(ts.value.ANIMATION_INTERVAL, 4) * 1000);
|
|
390
|
+
},
|
|
391
|
+
{ immediate: true }
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
/* ----------------------------------------------------------- triggers */
|
|
395
|
+
|
|
396
|
+
let timer = null;
|
|
397
|
+
let cleanups = [];
|
|
398
|
+
function disarm() {
|
|
399
|
+
clearTimeout(timer);
|
|
400
|
+
timer = null;
|
|
401
|
+
cleanups.forEach((fn) => fn());
|
|
402
|
+
cleanups = [];
|
|
403
|
+
}
|
|
404
|
+
function listen(target, event, fn, opts) {
|
|
405
|
+
target.addEventListener(event, fn, opts);
|
|
406
|
+
cleanups.push(() => target.removeEventListener(event, fn, opts));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Automatic triggers never fire in the editor — it may connect after mount.
|
|
410
|
+
const autoShow = () => {
|
|
411
|
+
if (!isPreview.value) show();
|
|
412
|
+
};
|
|
413
|
+
watch(isPreview, (on) => on && disarm());
|
|
414
|
+
|
|
415
|
+
function arm() {
|
|
416
|
+
disarm();
|
|
417
|
+
if (isPreview.value || !onThisPage.value || !audienceMatches.value || !deviceMatches() || !dueAgain.value) return;
|
|
418
|
+
const trigger = upper(s.value.TRIGGER, "DELAY");
|
|
419
|
+
|
|
420
|
+
if (trigger === "DELAY") {
|
|
421
|
+
timer = setTimeout(autoShow, num(s.value.DELAY, 5) * 1000);
|
|
422
|
+
} else if (trigger === "SCROLL") {
|
|
423
|
+
const target = num(s.value.SCROLL_PERCENT, 40) / 100;
|
|
424
|
+
const onScroll = () => {
|
|
425
|
+
const max = document.documentElement.scrollHeight - window.innerHeight;
|
|
426
|
+
if (max <= 0 || window.scrollY / max >= target) autoShow();
|
|
427
|
+
};
|
|
428
|
+
listen(window, "scroll", onScroll, { passive: true });
|
|
429
|
+
onScroll();
|
|
430
|
+
} else if (trigger === "EXIT") {
|
|
431
|
+
// Desktop: the pointer leaves through the top of the window.
|
|
432
|
+
listen(document, "mouseout", (e) => {
|
|
433
|
+
if (!e.relatedTarget && e.clientY <= 0) autoShow();
|
|
434
|
+
});
|
|
435
|
+
// Touch: scrolled a fair way, then back up quickly.
|
|
436
|
+
let deepest = 0;
|
|
437
|
+
let lastY = window.scrollY;
|
|
438
|
+
let lastT = performance.now();
|
|
439
|
+
listen(
|
|
440
|
+
window,
|
|
441
|
+
"scroll",
|
|
442
|
+
() => {
|
|
443
|
+
const y = window.scrollY;
|
|
444
|
+
const now = performance.now();
|
|
445
|
+
deepest = Math.max(deepest, y);
|
|
446
|
+
const speed = (lastY - y) / Math.max(1, now - lastT); // px / ms, up is positive
|
|
447
|
+
if (deepest > window.innerHeight && deepest - y > window.innerHeight * 0.35 && speed > 1.2) autoShow();
|
|
448
|
+
lastY = y;
|
|
449
|
+
lastT = now;
|
|
450
|
+
},
|
|
451
|
+
{ passive: true }
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Links to #popup-<id>: open it (and keep the URL clean). */
|
|
457
|
+
function onLinkClick(e) {
|
|
458
|
+
const a = e.target?.closest?.("a[href]");
|
|
459
|
+
if (!a) return;
|
|
460
|
+
const href = a.getAttribute("href") || "";
|
|
461
|
+
if (!href.endsWith(`#popup-${id.value}`)) return;
|
|
462
|
+
e.preventDefault();
|
|
463
|
+
if (isPreview.value) return;
|
|
464
|
+
show();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/* --------------------------------------------------- keyboard / focus */
|
|
468
|
+
|
|
469
|
+
function focusables() {
|
|
470
|
+
const el = unref(panelEl);
|
|
471
|
+
if (!el) return [];
|
|
472
|
+
return [...el.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])')].filter(
|
|
473
|
+
(n) => n.offsetParent !== null
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
function onKeydown(e) {
|
|
477
|
+
if (!open.value) return;
|
|
478
|
+
if (e.key === "Escape") {
|
|
479
|
+
e.stopPropagation();
|
|
480
|
+
close();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (e.key !== "Tab" || !modal.value) return;
|
|
484
|
+
const list = focusables();
|
|
485
|
+
if (!list.length) return;
|
|
486
|
+
const first = list[0];
|
|
487
|
+
const last = list[list.length - 1];
|
|
488
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
489
|
+
e.preventDefault();
|
|
490
|
+
last.focus();
|
|
491
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
492
|
+
e.preventDefault();
|
|
493
|
+
first.focus();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
watch(open, (now) => {
|
|
497
|
+
if (!now || !import.meta.client || isPreview.value) return;
|
|
498
|
+
nextTick(() => {
|
|
499
|
+
// The email field if there is one, else the dialog itself.
|
|
500
|
+
const el = unref(panelEl);
|
|
501
|
+
(el?.querySelector('input[type="email"]') ?? el)?.focus?.({ preventScroll: true });
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
function onBackdrop() {
|
|
505
|
+
if (s.value.CLOSE_ON_BACKDROP !== false) close();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// The page behind a centred popup (or a phone sheet) doesn't scroll.
|
|
509
|
+
const locks = computed(() => open.value && !isPreview.value && (modal.value || (isPhone.value && sheetOnMobile.value)));
|
|
510
|
+
useBodyScrollLock(locks);
|
|
511
|
+
|
|
512
|
+
/* ---------------------------------------------- sheet: swipe to close */
|
|
513
|
+
|
|
514
|
+
let dragStartY = null;
|
|
515
|
+
const dragOffset = ref(0);
|
|
516
|
+
function onDragStart(e) {
|
|
517
|
+
dragStartY = e.clientY;
|
|
518
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
519
|
+
}
|
|
520
|
+
function onDragMove(e) {
|
|
521
|
+
if (dragStartY === null) return;
|
|
522
|
+
dragOffset.value = Math.max(0, e.clientY - dragStartY);
|
|
523
|
+
}
|
|
524
|
+
function onDragEnd() {
|
|
525
|
+
if (dragStartY === null) return;
|
|
526
|
+
const far = dragOffset.value > 90;
|
|
527
|
+
dragStartY = null;
|
|
528
|
+
dragOffset.value = 0;
|
|
529
|
+
if (far) close();
|
|
530
|
+
}
|
|
531
|
+
const dragStyle = computed(() => (dragOffset.value ? { transform: `translateY(${dragOffset.value}px)`, transition: "none" } : null));
|
|
532
|
+
|
|
533
|
+
/* --------------------------------------------------------- lifecycle */
|
|
534
|
+
|
|
535
|
+
let phoneQuery = null;
|
|
536
|
+
const onPhoneChange = () => (isPhone.value = !!phoneQuery?.matches);
|
|
537
|
+
|
|
538
|
+
onMounted(() => {
|
|
539
|
+
loadMemory();
|
|
540
|
+
mounted.value = true;
|
|
541
|
+
phoneQuery = window.matchMedia("(max-width: 767px)");
|
|
542
|
+
onPhoneChange();
|
|
543
|
+
phoneQuery.addEventListener?.("change", onPhoneChange);
|
|
544
|
+
document.addEventListener("click", onLinkClick, true);
|
|
545
|
+
window.addEventListener("keydown", onKeydown);
|
|
546
|
+
if (!isPreview.value && window.location.hash === `#popup-${id.value}`) show();
|
|
547
|
+
else arm();
|
|
548
|
+
});
|
|
549
|
+
// A new page: the page / home targeting may now match (or not), and what
|
|
550
|
+
// was only closed / hidden for that page comes back.
|
|
551
|
+
watch(() => route.fullPath, () => {
|
|
552
|
+
if (!mounted.value || open.value) return;
|
|
553
|
+
closedThisPage.value = false;
|
|
554
|
+
teaserHiddenThisPage.value = false;
|
|
555
|
+
arm();
|
|
556
|
+
});
|
|
557
|
+
// Edited in the editor: nothing to re-arm, but keep memory in step.
|
|
558
|
+
watch(storeKey, () => mounted.value && loadMemory());
|
|
559
|
+
|
|
560
|
+
onBeforeUnmount(() => {
|
|
561
|
+
disarm();
|
|
562
|
+
clearInterval(teaserTimer);
|
|
563
|
+
phoneQuery?.removeEventListener?.("change", onPhoneChange);
|
|
564
|
+
if (import.meta.client) {
|
|
565
|
+
document.removeEventListener("click", onLinkClick, true);
|
|
566
|
+
window.removeEventListener("keydown", onKeydown);
|
|
567
|
+
}
|
|
568
|
+
if (open.value) openPopup.value = null;
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
s, t, tree, label, vars, panelClasses, modal, placement, image, imagePosition, blurBackdrop,
|
|
573
|
+
dismissLabel, transitionName, open, show, close, onBackdrop, showTeaser, teaserLabel,
|
|
574
|
+
teaserPosition, hideTeaser, teaserKey, teaserSelected, teaserMode, teaserHtml, teaserImage,
|
|
575
|
+
teaserIcon, teaserShape, teaserClasses, teaserVars, teaserDismissible, sheetOnMobile, isPhone, onDragStart, onDragMove, onDragEnd,
|
|
576
|
+
dragStyle, isPreview, subscribed,
|
|
577
|
+
};
|
|
578
|
+
}
|
package/package.json
CHANGED