Older Themes (Debut & Legacy)
import { Aside } from ‘@astrojs/starlight/components’;
Older Shopify themes — including Debut, Brooklyn, Minimal, and Supply — don’t support app blocks. For these themes, Notifly is installed by adding a few snippets directly to your theme files.
Overview of changes
Section titled “Overview of changes”You’ll be making four edits:
- Add the
notifly-widgetsnippet file to your theme - Add the
notifly-modalsnippet file to your theme - Add the
notifly-widget.cssandnotifly-widget.jsfiles to your theme assets folder - Edit
theme.liquidto load the CSS, JavaScript, and modal globally - Edit
product.liquid(orproduct-template.liquid) to render the widget on product pages
Step 1 — Add the snippet files
Section titled “Step 1 — Add the snippet files”In your Shopify admin go to Online Store → Themes → Edit code.
Create notifly-widget.liquid
Section titled “Create notifly-widget.liquid”Under Snippets, click Add a new snippet, name it notifly-widget, and paste the following:
{% comment %} Notifly Widget Snippet Rendered inside product.liquid via {% render 'notifly-widget' %} CSS + JS are loaded globally in theme.liquid — do NOT re-add them here.{% endcomment %}
{% assign notifly_api_base = shop.metafields.notifly.app_url.value | default: '' %}
<div class="notifly-widget" data-api-base="{{ notifly_api_base }}" data-shop="{{ shop.permanent_domain }}" data-product-id="{{ product.id }}" data-product-title="{{ product.title | escape }}" data-product-handle="{{ product.handle }}" data-product-price="{{ product.selected_or_first_available_variant.price | money_without_currency }}" data-variant-id="{{ product.selected_or_first_available_variant.id }}" data-variant-title="{{ product.selected_or_first_available_variant.title | escape }}" data-available="{{ product.selected_or_first_available_variant.available }}" data-variants-count="{{ product.variants.size }}" data-variants-json="{{ product.variants | json | escape }}" data-option-names="{{ product.options | json | escape }}" data-enable-back-in-stock="true" data-enable-price-drop="true" data-customer-email="{{ customer.email | default: '' }}" data-customer-first-name="{{ customer.first_name | default: '' }}" data-customer-last-name="{{ customer.last_name | default: '' }}" data-customer-phone="{{ customer.phone | default: '' }}"> <button type="button" class="notifly-trigger" data-trigger-type="back_in_stock" style="display:none;"> Notify Me When Back in Stock </button> <button type="button" class="notifly-trigger" data-trigger-type="price_drop" style="display:none;"> Alert Me to Price Drops </button></div>Create notifly-modal.liquid
Section titled “Create notifly-modal.liquid”Under Snippets, click Add a new snippet, name it notifly-modal, and paste the following:
{% comment %} Notifly Modal — render once globally in theme.liquid{% endcomment %}
<div class="notifly-modal" id="notifly-modal" style="display: none;"> <div class="notifly-modal__overlay"></div> <div class="notifly-modal__content"> <button type="button" class="notifly-modal__close" aria-label="Close"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </svg> </button>
<div class="notifly-modal__header"> <h2 class="notifly-modal__title"></h2> <p class="notifly-modal__subtitle"></p> </div>
<form class="notifly-form" id="notifly-form">
<div class="notifly-form__group" id="notifly-scope-group" style="display: none;"> <label for="notifly-scope-select" class="notifly-form__label">Notify me for</label> <select id="notifly-scope-select" name="subscriptionScope" class="notifly-form__input"></select> <p class="notifly-form__help" id="notifly-scope-help"></p> </div>
<div class="notifly-form__group" id="notifly-threshold-group" style="display: none;"> <label for="notifly-price-threshold" class="notifly-form__label"> Notify me when price drops to <span style="font-weight: 400; opacity: 0.65;">(optional)</span> </label> <div style="position: relative; display: flex; align-items: center;"> <span style="position: absolute; left: 12px; color: #6b7280; font-size: 14px;">$</span> <input type="number" id="notifly-price-threshold" name="priceThreshold" class="notifly-form__input" style="padding-left: 28px;" placeholder="{{ product.price | money_without_currency }}" min="0.01" step="0.01" /> </div> <p class="notifly-form__help">Current price: {{ product.price | money }}. Leave blank to be notified of any price drop.</p> </div>
<div class="notifly-form__group"> <label for="notifly-email" class="notifly-form__label">Email Address</label> <input type="email" id="notifly-email" name="email" class="notifly-form__input" placeholder="your@email.com" autocomplete="email"/> </div>
<div class="notifly-form__group" id="notifly-phone-group"> <label class="notifly-form__label"> Phone Number <span style="font-weight: 400; opacity: 0.65;">(optional)</span> </label> <div class="notifly-phone-row"> <select id="notifly-country-select" class="notifly-country-select" aria-label="Country dial code"></select> <input type="tel" id="notifly-phone" name="phone" class="notifly-form__input notifly-phone-number" placeholder="Local number" autocomplete="tel-national" inputmode="numeric" /> </div> <p class="notifly-form__help">For SMS/WhatsApp notifications</p> </div>
<div class="notifly-form__group notifly-form__group--row"> <div class="notifly-form__group--half"> <label for="notifly-first-name" class="notifly-form__label">First Name (optional)</label> <input type="text" id="notifly-first-name" name="firstName" class="notifly-form__input" placeholder="John" autocomplete="given-name"/> </div> <div class="notifly-form__group--half"> <label for="notifly-last-name" class="notifly-form__label">Last Name (optional)</label> <input type="text" id="notifly-last-name" name="lastName" class="notifly-form__input" placeholder="Doe" autocomplete="family-name"/> </div> </div>
<div class="notifly-form__channels" id="notifly-channels"> <p class="notifly-form__label">Notification Preferences</p> <label class="notifly-form__checkbox"> <input type="checkbox" name="channel" value="email"/> <span>📧 Email</span> </label> <label class="notifly-form__checkbox" style="display: none;" data-channel="sms"> <input type="checkbox" name="channel" value="sms" id="notifly-sms-checkbox"/> <span>📱 SMS</span> </label> <label class="notifly-form__checkbox" style="display: none;" data-channel="whatsapp"> <input type="checkbox" name="channel" value="whatsapp" id="notifly-whatsapp-checkbox"/> <span>💬 WhatsApp</span> </label> <p class="notifly-form__help" style="margin-top: 8px; font-size: 0.875rem;">Select at least one notification method</p> </div>
<div class="notifly-form__consent"> <label class="notifly-form__checkbox"> <input type="checkbox" name="consent" required/> <span class="notifly-form__consent-text">I agree to receive notifications about this product. I can unsubscribe at any time.</span> </label> </div>
<div class="notifly-form__actions"> <button type="submit" class="notifly-form__submit"> <span class="notifly-form__submit-text">Subscribe</span> <span class="notifly-form__submit-loader" style="display: none;"> <svg class="notifly-spinner" width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"> <circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-dasharray="40" stroke-dashoffset="30"/> </svg> Subscribing... </span> </button> </div>
<div class="notifly-form__message" style="display: none;"></div> </form>
<div class="notifly-success" style="display: none;"> <div class="notifly-success__icon"> <svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"> <circle cx="32" cy="32" r="30" fill="#10b981" opacity="0.1"/> <path d="M20 32L28 40L44 24" stroke="#10b981" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> </svg> </div> <h3 class="notifly-success__title">You're all set!</h3> <p class="notifly-success__message"></p> <button type="button" class="notifly-success__close">Close</button> </div> </div></div>Create notifly-widget.css
Section titled “Create notifly-widget.css”Under Assets, click Add a new file, name it notifly-widget.css, and paste the following:
:root { --button-color: #0ea5e9;}
/* Widget Container */.notifly-widget { display: flex; flex-direction: column; gap: 0.75rem; margin: 1.5rem 0;}
/* Trigger Buttons */.notifly-trigger { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.75rem 1.5rem; font-size: 1rem; font-weight: 600; line-height: 1.5; text-align: center; border: 2px solid var(--button-color, #000); border-radius: 0.5rem; cursor: pointer; transition: all 0.2s ease; background: transparent; color: var(--button-color, #000); width: 100%; max-width: 400px;}
.notifly-trigger:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);}
.notifly-trigger:active { transform: translateY(0);}
.notifly-trigger--primary { background: var(--button-color, #000); color: white;}
.notifly-trigger--primary:hover { opacity: 0.9;}
.notifly-trigger--secondary { background: rgba(0, 0, 0, 0.05); border-color: rgba(0, 0, 0, 0.2); color: var(--button-color, #000);}
.notifly-trigger--outline { background: transparent;}
.notifly-trigger svg { flex-shrink: 0;}
/* Modal */.notifly-modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 999999; align-items: center; justify-content: center; padding: 1rem;}
.notifly-modal__overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.65); backdrop-filter: blur(4px); animation: fadeIn 0.2s ease; z-index: 1;}
.notifly-modal__content { position: relative; background: white; border-radius: 1rem; max-width: 750px; /* wide enough for merge modal's two-column account cards */ width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); animation: slideUp 0.3s ease; padding: 2rem 2.25rem; /* generous horizontal padding benefits both form and merge */ z-index: 2;}
.notifly-modal__close { position: absolute; top: 1rem; right: 1rem; width: 2.5rem; height: 2.5rem; display: flex; align-items: center; justify-content: center; background: transparent; border: none; border-radius: 0.5rem; cursor: pointer; color: #6b7280; transition: all 0.2s ease; z-index: 3;}
.notifly-modal__close:hover { background: #f3f4f6; color: #111827;}
.notifly-modal__header { margin-bottom: 1.5rem; /* reduced from 2rem */ padding-right: 2rem;}
.notifly-modal__title { font-size: 20px; /* reduced from 1.5rem — less visual weight needed */ font-weight: 700; color: #111827; margin: 0 0 0.375rem 0;}
.notifly-modal__subtitle { font-size: 14px; color: #6b7280; margin: 0; line-height: 1.5;}
/* ── Form ───────────────────────────────────────────────────────────────────── */
.notifly-form { display: flex; flex-direction: column; gap: 0; /* remove gap — spacing is now controlled per-group with padding-top */}
.notifly-form__group { display: flex; flex-direction: column; gap: 0.3rem; /* label → input: tight, they belong together */ padding-top: 1.125rem; /* space above the label = separation from previous input */}
.notifly-form__group:first-child { padding-top: 0; /* no top padding on the very first group */}
.notifly-form__group--row { flex-direction: row; gap: 1rem; padding-top: 1.125rem;}
.notifly-form__group--half { flex: 1; display: flex; flex-direction: column; gap: 0.3rem; padding-top: 0; /* half groups sit inside a row group which already has padding */}
.notifly-form__label { font-size: 13px; /* slightly smaller than 0.875rem — less dominant */ font-weight: 600; color: #374151; /* Reset any browser <p> margins when the label is a <p> tag */ margin: 0; padding: 0;}
.notifly-form__label .required { color: #ef4444;}
.notifly-form__input { padding: 0.625rem 0.875rem; /* slightly less vertical padding than 0.75/1rem */ font-size: 15px !important; border: 1.5px solid #e5e7eb; /* 1.5px instead of 2px — less heavy */ border-radius: 0.5rem; transition: border-color 0.2s ease; font-family: inherit; color: #111827;}
.notifly-form__input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);}
.notifly-form__help { font-size: 12px; color: #9ca3af; margin: 0.125rem 0 0 0; /* tiny top gap from input, no bottom — gap handles separation */ padding: 0;}
/* ── Channel checkboxes ─────────────────────────────────────────────────────── */
.notifly-form__channels { display: flex; flex-direction: column; gap: 0.625rem; padding-top: 1.125rem; /* same rhythm as form groups */}
/* The "Notification Preferences" label inside channels is a <p> tag. Reset its margins so it sits flush against the first checkbox below it. */.notifly-form__channels > .notifly-form__label { margin-bottom: 0.125rem;}
.notifly-form__checkbox { display: flex; align-items: center; gap: 0.5rem; font-size: 14px; color: #374151; cursor: pointer; user-select: none;}
.notifly-form__checkbox input[type="checkbox"] { width: 1.125rem; /* slightly smaller than 1.25rem */ height: 1.125rem; cursor: pointer; accent-color: #3b82f6; flex-shrink: 0;}
.notifly-form__checkbox input[type="checkbox"]:disabled { cursor: not-allowed; opacity: 0.5;}
/* ── Consent ────────────────────────────────────────────────────────────────── */
.notifly-form__consent { padding: 0.75rem; margin-top: calc(0.825rem); background: #f9fafb; border-radius: 0.5rem; border: 1px solid #f3f4f6;}
.notifly-form__consent-text { font-size: 12px; line-height: 1.5; color: #6b7280;}
/* ── Submit ─────────────────────────────────────────────────────────────────── */
.notifly-form__actions { display: flex; gap: 1rem; padding-top: 1.125rem; /* consistent with group rhythm */ margin-top: 0;}
.notifly-form__submit { flex: 1; display: flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.8125rem 1.5rem; font-size: 16px; font-weight: 600; background: var(--button-color, #000); color: white; border: none; border-radius: 0.5rem; cursor: pointer; transition: all 0.2s ease;}
.notifly-form__submit:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);}
.notifly-form__submit:disabled { opacity: 0.6; cursor: not-allowed;}
.notifly-form__submit-loader { display: none; align-items: center; gap: 0.5rem;}
.notifly-spinner { animation: spin 1s linear infinite;}
.notifly-form__message { padding: 0.75rem 1rem; border-radius: 0.5rem; font-size: 14px; line-height: 1.5;}
.notifly-form__message--error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca;}
.notifly-form__message--success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0;}
/* ── Scope dropdown ─────────────────────────────────────────────────────────── */
#notifly-scope-group .notifly-form__label { margin-bottom: 0.25rem;}
#notifly-scope-select { margin-top: 0;}
/* ── Success state ──────────────────────────────────────────────────────────── */
.notifly-success { display: none; flex-direction: column; align-items: center; text-align: center; gap: 1.25rem; padding: 1.5rem 0;}
.notifly-success__icon svg { animation: scaleIn 0.3s ease;}
.notifly-success__title { font-size: 24px; font-weight: 700; color: #111827; margin: 0;}
.notifly-success__message { font-size: 16px; color: #6b7280; line-height: 1.5; margin: 0;}
.notifly-success__close { padding: 0.75rem 2rem; font-size: 16px; font-weight: 600; background: #f3f4f6; color: #374151; border: none; border-radius: 0.5rem; cursor: pointer; transition: all 0.2s ease;}
.notifly-success__close:hover { background: #e5e7eb;}
/* ── Verification ───────────────────────────────────────────────────────────── */
.notifly-verification { padding: 32px; text-align: center;}
.notifly-verification__content { max-width: 575px; margin: 0 auto;}
.notifly-verification__icon { font-size: 48px; margin-bottom: 16px;}
.notifly-verification__title { font-size: 24px; font-weight: 600; margin-bottom: 8px; color: #111827;}
.notifly-verification__text { font-size: 14px; color: #6b7280; margin-bottom: 24px;}
.notifly-verification__section { margin-bottom: 24px; text-align: left;}
.notifly-verification__section-title { font-size: 14px; font-weight: 600; color: #111827; margin: 0 0 8px 0;}
.notifly-verification__section-text { font-size: 14px; color: #6b7280; margin: 0 0 12px 0; line-height: 1.5;}
.notifly-verification__form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px;}
.notifly-verification__input { flex: 1; padding: 12px 16px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 20px; text-align: center; letter-spacing: 8px; font-family: monospace;}
.notifly-verification__input:focus { outline: none; border-color: #0ea5e9; box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);}
.notifly-verification__submit { padding: 12px 24px; background-color: #0ea5e9; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: background-color 0.2s;}
.notifly-verification__submit:hover { background-color: #0284c7;}
.notifly-verification__submit:disabled { background-color: #9ca3af; cursor: not-allowed;}
.notifly-verification__message { display: none; padding: 12px; border-radius: 6px; margin-bottom: 16px; font-size: 14px;}
.notifly-verification__message--error { background-color: #fee2e2; color: #991b1b;}
.notifly-verification__message--success { background-color: #d1fae5; color: #065f46;}
.notifly-verification__help { font-size: 12px; color: #9ca3af;}
.notifly-verification__help a { color: #0ea5e9; text-decoration: none;}
.notifly-verification__help a:hover { text-decoration: underline;}
.notifly-verification__timer { font-size: 12px; color: #6b7280; margin: 12px 0; text-align: center; font-weight: 500;}
.notifly-verification__footer { font-size: 12px; color: #9ca3af; margin-top: 16px;}
.notifly-verification__status { display: flex; align-items: center; gap: 8px; padding: 12px; background: #f9fafb; border-radius: 6px; margin-top: 12px; font-size: 14px; color: #6b7280;}
.notifly-verification__status-icon { font-size: 18px;}
.notifly-verification__status-text { flex: 1;}
/* ── Phone input row ────────────────────────────────────────────────────────── */
.notifly-phone-row { display: flex; gap: 8px; align-items: stretch;}
.notifly-country-select { flex: 0 0 auto; width: 100px; padding: 10px 6px; border: 1.5px solid #e5e7eb; border-radius: 6px; background: #ffffff; font-size: 14px; color: #111827; cursor: pointer; appearance: auto; line-height: 1.4;}
.notifly-country-select:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);}
.notifly-phone-number { flex: 1 1 auto; min-width: 0;}
/* ── Merge flow ─────────────────────────────────────────────────────────────── */
.notifly-merge { padding: 30px;}
.notifly-merge__content { text-align: center;}
.notifly-merge__icon { font-size: 48px; margin-bottom: 20px;}
.notifly-merge__title { font-size: 24px; font-weight: 600; color: #1f2937; margin: 0 0 12px 0;}
.notifly-merge__text { font-size: 16px; color: #6b7280; margin: 0 0 30px 0; line-height: 1.5;}
.notifly-merge__accounts { display: flex; align-items: center; justify-content: center; gap: 20px; margin: 30px 0; padding: 20px; background: #f9fafb; border-radius: 8px;}
.notifly-merge__account { flex: 1; padding: 20px; background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);}
.notifly-merge__account-icon { font-size: 32px; margin-bottom: 10px;}
.notifly-merge__account-label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: #9ca3af; margin-bottom: 8px; font-weight: 600;}
.notifly-merge__account-value { font-size: 16px; font-weight: 600; color: #1f2937; margin-bottom: 8px; word-break: break-all;}
.notifly-merge__account-meta { font-size: 14px; color: #6b7280;}
.notifly-merge__divider { font-size: 24px; font-weight: 600; color: #0ea5e9;}
.notifly-merge__info { text-align: left; padding: 20px; background: #eff6ff; border-left: 4px solid #0ea5e9; border-radius: 4px; margin: 20px 0;}
.notifly-merge__info strong { display: block; margin-bottom: 12px; color: #1f2937; font-size: 14px;}
.notifly-merge__info ul { margin: 0; padding-left: 20px;}
.notifly-merge__info li { font-size: 14px; color: #374151; margin-bottom: 8px; line-height: 1.5;}
.notifly-merge__security { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; background: #fef3c7; border-radius: 6px; font-size: 14px; color: #92400e; margin: 20px 0;}
.notifly-merge__actions { display: flex; gap: 12px; margin: 30px 0 20px 0;}
.notifly-merge__btn { flex: 1; padding: 14px 24px; border: none; border-radius: 6px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s;}
.notifly-merge__btn--cancel { background: #f3f4f6; color: #374151;}
.notifly-merge__btn--cancel:hover { background: #e5e7eb;}
.notifly-merge__btn--confirm { background: #0ea5e9; color: white;}
.notifly-merge__btn--confirm:hover { background: #0284c7;}
.notifly-merge__expires { font-size: 12px; color: #9ca3af; font-style: italic;}
/* ── Animations ─────────────────────────────────────────────────────────────── */
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; }}
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); }}
@keyframes scaleIn { from { opacity: 0; transform: scale(0.8); } to { opacity: 1; transform: scale(1); }}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); }}
/* ── Responsive ─────────────────────────────────────────────────────────────── */
@media (max-width: 640px) { .notifly-modal__content { padding: 1.25rem; border-radius: 1rem 1rem 0 0; margin-top: auto; max-height: 85vh; /* slightly less on mobile to ensure scrollability */ }
.notifly-form__group--row { flex-direction: column; gap: 1.25rem; }
.notifly-trigger { max-width: none; }
.notifly-merge__accounts { flex-direction: column; }
.notifly-merge__divider { transform: rotate(90deg); }
.notifly-merge__actions { flex-direction: column; }}
@media (max-width: 320px) { .notifly-phone-row { flex-direction: column; }
.notifly-country-select { width: 100%; }}Create notifly-widget.js
Section titled “Create notifly-widget.js”Under Assets, click Add a new file, name it notifly-widget.js, and paste the following:
(function () { 'use strict';
if (window.__notifly_widget_loaded && !(window.Shopify && window.Shopify.designMode)) { console.warn('Notifly already loaded — skipping second initialization'); return; } window.__notifly_widget_loaded = true;
// ============================================ // INTERNATIONAL PHONE: COUNTRY CODE DATA // ============================================ const COUNTRY_CODES = [ {code: 'US', flag: '🇺🇸', name: 'United States', dial: '1', placeholder: '(555) 234 5678'}, {code: 'GB', flag: '🇬🇧', name: 'United Kingdom', dial: '44', placeholder: '07911 123456'}, {code: 'CA', flag: '🇨🇦', name: 'Canada', dial: '1', placeholder: '(604) 234 5678'}, {code: 'AU', flag: '🇦🇺', name: 'Australia', dial: '61', placeholder: '0412 345 678'}, {code: 'NZ', flag: '🇳🇿', name: 'New Zealand', dial: '64', placeholder: '021 123 4567'}, {code: 'IE', flag: '🇮🇪', name: 'Ireland', dial: '353', placeholder: '087 123 4567'}, {code: 'DE', flag: '🇩🇪', name: 'Germany', dial: '49', placeholder: '0151 12345678'}, {code: 'FR', flag: '🇫🇷', name: 'France', dial: '33', placeholder: '06 12 34 56 78'}, {code: 'ES', flag: '🇪🇸', name: 'Spain', dial: '34', placeholder: '612 345 678'}, {code: 'IT', flag: '🇮🇹', name: 'Italy', dial: '39', placeholder: '312 345 6789'}, {code: 'NL', flag: '🇳🇱', name: 'Netherlands', dial: '31', placeholder: '06 12345678'}, {code: 'BE', flag: '🇧🇪', name: 'Belgium', dial: '32', placeholder: '0470 12 34 56'}, {code: 'CH', flag: '🇨🇭', name: 'Switzerland', dial: '41', placeholder: '076 123 45 67'}, {code: 'AT', flag: '🇦🇹', name: 'Austria', dial: '43', placeholder: '0664 1234567'}, {code: 'SE', flag: '🇸🇪', name: 'Sweden', dial: '46', placeholder: '070-123 45 67'}, {code: 'NO', flag: '🇳🇴', name: 'Norway', dial: '47', placeholder: '412 34 567'}, {code: 'DK', flag: '🇩🇰', name: 'Denmark', dial: '45', placeholder: '20 12 34 56'}, {code: 'FI', flag: '🇫🇮', name: 'Finland', dial: '358', placeholder: '050 1234567'}, {code: 'PT', flag: '🇵🇹', name: 'Portugal', dial: '351', placeholder: '912 345 678'}, {code: 'PL', flag: '🇵🇱', name: 'Poland', dial: '48', placeholder: '512 345 678'}, {code: 'CZ', flag: '🇨🇿', name: 'Czech Republic', dial: '420', placeholder: '601 123 456'}, {code: 'HU', flag: '🇭🇺', name: 'Hungary', dial: '36', placeholder: '20 123 4567'}, {code: 'RO', flag: '🇷🇴', name: 'Romania', dial: '40', placeholder: '0712 345 678'}, {code: 'GR', flag: '🇬🇷', name: 'Greece', dial: '30', placeholder: '694 123 4567'}, {code: 'SK', flag: '🇸🇰', name: 'Slovakia', dial: '421', placeholder: '0910 123 456'}, {code: 'HR', flag: '🇭🇷', name: 'Croatia', dial: '385', placeholder: '091 123 4567'}, {code: 'MX', flag: '🇲🇽', name: 'Mexico', dial: '52', placeholder: '55 1234 5678'}, {code: 'BR', flag: '🇧🇷', name: 'Brazil', dial: '55', placeholder: '11 91234-5678'}, {code: 'AR', flag: '🇦🇷', name: 'Argentina', dial: '54', placeholder: '011 15-1234-5678'}, {code: 'CL', flag: '🇨🇱', name: 'Chile', dial: '56', placeholder: '9 1234 5678'}, {code: 'CO', flag: '🇨🇴', name: 'Colombia', dial: '57', placeholder: '310 123 4567'}, {code: 'PE', flag: '🇵🇪', name: 'Peru', dial: '51', placeholder: '912 345 678'}, {code: 'IN', flag: '🇮🇳', name: 'India', dial: '91', placeholder: '98765 43210'}, {code: 'CN', flag: '🇨🇳', name: 'China', dial: '86', placeholder: '138 1234 5678'}, {code: 'JP', flag: '🇯🇵', name: 'Japan', dial: '81', placeholder: '090-1234-5678'}, {code: 'KR', flag: '🇰🇷', name: 'South Korea', dial: '82', placeholder: '010-1234-5678'}, {code: 'SG', flag: '🇸🇬', name: 'Singapore', dial: '65', placeholder: '8123 4567'}, {code: 'HK', flag: '🇭🇰', name: 'Hong Kong', dial: '852', placeholder: '9123 4567'}, {code: 'TW', flag: '🇹🇼', name: 'Taiwan', dial: '886', placeholder: '0912 345 678'}, {code: 'MY', flag: '🇲🇾', name: 'Malaysia', dial: '60', placeholder: '012-345 6789'}, {code: 'TH', flag: '🇹🇭', name: 'Thailand', dial: '66', placeholder: '081 234 5678'}, {code: 'PH', flag: '🇵🇭', name: 'Philippines', dial: '63', placeholder: '0917 123 4567'}, {code: 'ID', flag: '🇮🇩', name: 'Indonesia', dial: '62', placeholder: '0812-345-6789'}, {code: 'VN', flag: '🇻🇳', name: 'Vietnam', dial: '84', placeholder: '091 234 56 78'}, {code: 'PK', flag: '🇵🇰', name: 'Pakistan', dial: '92', placeholder: '0300 1234567'}, {code: 'BD', flag: '🇧🇩', name: 'Bangladesh', dial: '880', placeholder: '01812 345678'}, {code: 'AE', flag: '🇦🇪', name: 'UAE', dial: '971', placeholder: '050 123 4567'}, {code: 'SA', flag: '🇸🇦', name: 'Saudi Arabia', dial: '966', placeholder: '050 123 4567'}, {code: 'IL', flag: '🇮🇱', name: 'Israel', dial: '972', placeholder: '050-123-4567'}, {code: 'TR', flag: '🇹🇷', name: 'Turkey', dial: '90', placeholder: '0532 123 45 67'}, {code: 'ZA', flag: '🇿🇦', name: 'South Africa', dial: '27', placeholder: '071 123 4567'}, {code: 'NG', flag: '🇳🇬', name: 'Nigeria', dial: '234', placeholder: '0802 345 6789'}, {code: 'KE', flag: '🇰🇪', name: 'Kenya', dial: '254', placeholder: '0712 345678'}, {code: 'GH', flag: '🇬🇭', name: 'Ghana', dial: '233', placeholder: '024 123 4567'}, {code: 'EG', flag: '🇪🇬', name: 'Egypt', dial: '20', placeholder: '010 1234 5678'}, ];
const DIAL_BY_CODE = Object.fromEntries(COUNTRY_CODES.map(c => [c.code, c.dial]));
const EXPECTED_LOCAL_DIGITS = { '1': [10, 10], '44': [9, 10], '61': [9, 9], '64': [8, 9], '353': [7, 9], '49': [10, 11], '33': [9, 9], '34': [9, 9], '39': [9, 11], '31': [9, 9], '46': [9, 9], '47': [8, 8], '45': [8, 8], '91': [10, 10], '86': [11, 11], '81': [10, 10], '82': [9, 10], '65': [8, 8], '55': [10, 11], '52': [10, 10], '27': [9, 9], '971': [9, 9], };
function formatVariantLabel(variant) { const names = productData ? (productData.optionNames || []) : []; const values = variant.options || []; if (names.length === 0 || values.length === 0) return variant.title; return names .map((name, i) => values[i] ? `${name} - ${values[i]}` : name) .join(' / '); }
function detectCountryCode() { try { const locale = navigator.language || navigator.languages?.[0] || ''; const parts = locale.split('-'); if (parts.length >= 2) { const country = parts[parts.length - 1].toUpperCase(); if (DIAL_BY_CODE[country]) return country; } } catch (_) { /* ignore */ } return 'US'; }
function buildCountrySelect(selectEl, selectedCode) { selectEl.innerHTML = ''; COUNTRY_CODES.forEach(c => { const opt = document.createElement('option'); opt.value = c.dial; opt.dataset.code = c.code; opt.dataset.placeholder = c.placeholder; opt.textContent = `${c.flag} +${c.dial}`; opt.title = `${c.name} (+${c.dial})`; if (c.code === selectedCode) opt.selected = true; selectEl.appendChild(opt); }); }
function getE164Phone() { const select = document.getElementById('notifly-country-select'); const input = document.getElementById('notifly-phone'); if (!select || !input) return null;
const localRaw = input.value.trim(); if (!localRaw) return null;
const dialCode = select.value; let digits = localRaw.replace(/\D/g, '');
if (digits.startsWith('0')) digits = digits.slice(1); if (digits.length > 0 && digits.startsWith(dialCode)) { digits = digits.slice(dialCode.length); } if (!digits) return null;
const expected = EXPECTED_LOCAL_DIGITS[dialCode]; if (expected) { const [min, max] = expected; if (digits.length < min || digits.length > max) { const range = min === max ? `${min}` : `${min}–${max}`; showError( `Phone number looks ${digits.length < min ? 'too short' : 'too long'} for the selected country (expected ${range} digits after the country code).` ); return null; } }
return `+${dialCode}${digits}`; }
function updatePhonePlaceholder(selectEl) { const selected = selectEl.options[selectEl.selectedIndex]; const phoneInput = document.getElementById('notifly-phone'); if (!phoneInput) return; if (phoneInput && selected?.dataset.placeholder) { phoneInput.placeholder = selected.dataset.placeholder; } }
function initCountrySelect() { const selectEl = document.getElementById('notifly-country-select'); if (!selectEl) return; const detectedCode = detectCountryCode(); buildCountrySelect(selectEl, detectedCode); updatePhonePlaceholder(selectEl); selectEl.addEventListener('change', () => { updatePhonePlaceholder(selectEl); const phoneInput = document.getElementById('notifly-phone'); if (phoneInput) phoneInput.dispatchEvent(new Event('input')); }); }
// ============================================ // CONFIGURATION // ============================================ let API_BASE = window.NOTIFLY_CONFIG?.apiBase || window.location.origin; const isShopifyTheme = window.location.hostname.includes('myshopify.com'); let currentMergeConflict = null;
if (isShopifyTheme) { const widget = document.querySelector('.notifly-widget'); if (widget?.dataset.apiBase) { API_BASE = widget.dataset.apiBase; } else if (!window.NOTIFLY_CONFIG) { console.error('Notifly: API base URL not configured.'); } }
// ── State ──────────────────────────────────────────────────────────────────── let currentTriggerType = null; let productData = null; let shopConfig = null; let shopConfigLoading = false; let notificationStatus = {backInStock: false, priceDrop: false};
let notificationStatusDetail = { variant: {backInStock: false, priceDrop: false}, product: {backInStock: false, priceDrop: false}, notifiedVariantIds: [], };
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); }
async function init() { document.querySelectorAll('.notifly-widget').forEach(widget => setupWidget(widget)); setupModal(); }
// ============================================ // SHOP CONFIG // ============================================
async function fetchShopConfig() { if (!productData?.shop) return; if (shopConfigLoading) { while (shopConfigLoading) await new Promise(r => setTimeout(r, 100)); return; } shopConfigLoading = true; try { const response = await fetch(`${API_BASE}/api/public/shop-config?shop=${productData.shop}`); if (response.ok) { const result = await response.json(); shopConfig = result.data; } else { shopConfig = {tier: 'free', availableChannels: ['email']}; } } catch (error) { shopConfig = {tier: 'free', availableChannels: ['email']}; } finally { shopConfigLoading = false; } }
function isChannelAvailable(channel) { if (!shopConfig) return channel === 'email'; return shopConfig.availableChannels.includes(channel); }
// ============================================ // WIDGET SETUP // ============================================ function setupWidget(widget) { productData = { productId: widget.dataset.productId, productTitle: widget.dataset.productTitle, productHandle: widget.dataset.productHandle, productPrice: parseFloat(widget.dataset.productPrice), variantId: widget.dataset.variantId, variantTitle: widget.dataset.variantTitle, available: widget.dataset.available === 'true', shop: widget.dataset.shop, variantsCount: parseInt(widget.dataset.variantsCount || '1', 10), variants: (() => { try { return JSON.parse((widget.dataset.variantsJson || '[]').replace(/"/g, '"')); } catch (_) { return []; } })(), optionNames: (() => { try { return JSON.parse((widget.dataset.optionNames || '[]').replace(/"/g, '"')); } catch (_) { return []; } })(), };
const selectedVariant = productData.variants.find( v => String(v.id) === String(productData.variantId) ); if (selectedVariant) { productData.productPrice = selectedVariant.price / 100; }
productData.customerEmail = widget.dataset.customerEmail || ''; productData.customerFirstName = widget.dataset.customerFirstName || ''; productData.customerLastName = widget.dataset.customerLastName || ''; productData.customerPhone = widget.dataset.customerPhone || '';
if (window.Shopify && window.Shopify.designMode) { widget.querySelectorAll('.notifly-trigger').forEach(btn => btn.style.display = 'flex'); fetchShopConfig(); widget.querySelectorAll('.notifly-trigger').forEach(t => t.addEventListener('click', handleTriggerClick)); observeVariantChanges(widget); return; }
widget.querySelectorAll('.notifly-trigger').forEach(btn => btn.style.display = 'none'); fetchShopConfig(); fetchNotificationStatus(widget); widget.querySelectorAll('.notifly-trigger').forEach(t => t.addEventListener('click', handleTriggerClick)); observeVariantChanges(widget); }
async function fetchNotificationStatus(widget) { if (!productData?.shop || !productData?.productId) return; try { const params = new URLSearchParams({shop: productData.shop, productId: productData.productId}); if (productData.variantId) params.set('variantId', productData.variantId);
const response = await fetch(API_BASE + '/api/public/notification-status?' + params); if (!response.ok) return;
const status = await response.json();
notificationStatus = { backInStock: !!status.backInStock, priceDrop: !!status.priceDrop, };
notificationStatusDetail = { variant: status.variant || {backInStock: false, priceDrop: false}, product: status.product || {backInStock: false, priceDrop: false}, notifiedVariantIds: status.notifiedVariantIds || [], };
console.log('Notifly notification status:', notificationStatus, notificationStatusDetail); applyNotificationStatus(widget); } catch (error) { console.error('Notifly: notification status fetch failed', error); } }
function applyNotificationStatus(widget) { const backInStockBtn = widget.querySelector('[data-trigger-type="back_in_stock"]'); const priceDropBtn = widget.querySelector('[data-trigger-type="price_drop"]'); const enableBackInStock = widget.dataset.enableBackInStock !== 'false'; const enablePriceDrop = widget.dataset.enablePriceDrop !== 'false'; const showForInStock = widget.dataset.showForInStock !== 'false';
if (backInStockBtn) { const shouldShow = enableBackInStock && notificationStatus.backInStock && !productData.available; backInStockBtn.style.display = shouldShow ? 'flex' : 'none'; }
if (priceDropBtn) { const shouldShow = enablePriceDrop && notificationStatus.priceDrop && (showForInStock || !productData.available); priceDropBtn.style.display = shouldShow ? 'flex' : 'none'; } }
function handleTriggerClick(e) { e.preventDefault(); currentTriggerType = e.currentTarget.dataset.triggerType; openModal(); }
// ============================================ // VARIANT CHANGE DETECTION // ============================================ function observeVariantChanges(widget) { const observer = new MutationObserver(() => { const newVariantId = widget.dataset.variantId; if (newVariantId !== productData.variantId || widget.dataset.available !== String(productData.available)) { updateWidgetForVariantId(widget, newVariantId); } }); observer.observe(widget, {attributes: true, attributeFilter: ['data-variant-id', 'data-available']});
if (!window.__notifly_history_patched) { window.__notifly_history_patched = true;
const patchHistoryMethod = (method) => { const original = history[method]; history[method] = function (state, title, url) { original.apply(this, arguments); if (url) { const variantId = extractVariantFromUrl(String(url)); if (variantId) { document.querySelectorAll('.notifly-widget').forEach(w => updateWidgetForVariantId(w, variantId)); } } }; }; patchHistoryMethod('pushState'); patchHistoryMethod('replaceState');
window.addEventListener('popstate', () => { const variantId = extractVariantFromUrl(window.location.search); if (variantId) { document.querySelectorAll('.notifly-widget').forEach(w => updateWidgetForVariantId(w, variantId)); } });
['variant:change', 'theme:variant:change', 'variantChange'].forEach(eventName => { document.addEventListener(eventName, (e) => { const detail = e.detail; if (!detail) return; const variantId = detail.id || detail.variantId || detail.variant?.id; if (variantId) { document.querySelectorAll('.notifly-widget').forEach(w => updateWidgetForVariantId(w, String(variantId))); } }); });
document.addEventListener('change', (e) => { if (e.target && e.target.name === 'id' && e.target.closest('form[action*="/cart/add"]')) { const variantId = e.target.value; if (variantId) { document.querySelectorAll('.notifly-widget').forEach(w => updateWidgetForVariantId(w, variantId)); } } }); } }
function extractVariantFromUrl(urlOrSearch) { try { const search = urlOrSearch.includes('?') ? urlOrSearch.slice(urlOrSearch.indexOf('?')) : urlOrSearch; return new URLSearchParams(search).get('variant') || null; } catch (_) { return null; } }
function updateWidgetForVariantId(widget, variantId) { if (!productData || !variantId) return; if (String(variantId) === String(productData.variantId)) return;
const variant = productData.variants.find(v => String(v.id) === String(variantId)); if (variant) { productData.variantId = String(variant.id); productData.variantTitle = variant.title; productData.available = variant.available; productData.productPrice = variant.price / 100; } else { productData.variantId = String(variantId); }
fetchNotificationStatus(widget);
const modal = document.getElementById('notifly-modal'); if (modal && modal.style.display === 'flex') { const thresholdInput = document.getElementById('notifly-price-threshold'); if (thresholdInput) { thresholdInput.placeholder = productData.productPrice.toFixed(2); thresholdInput.value = ''; } const thresholdGroup = document.getElementById('notifly-threshold-group'); const thresholdHelp = thresholdGroup?.querySelector('.notifly-form__help'); if (thresholdHelp) { thresholdHelp.textContent = `Current price: $${productData.productPrice.toFixed(2)}`; } } }
// ============================================ // MODAL // ============================================ async function openModal() { if (!shopConfig) await fetchShopConfig();
const modal = document.getElementById('notifly-modal'); const form = document.getElementById('notifly-form'); const success = modal.querySelector('.notifly-success');
form.reset(); form.style.display = 'block'; success.style.display = 'none'; form.dataset.submitting = 'false';
const submitBtn = form.querySelector('.notifly-form__submit'); if (submitBtn) { submitBtn.disabled = false; submitBtn.querySelector('.notifly-form__submit-text').style.display = 'inline'; submitBtn.querySelector('.notifly-form__submit-loader').style.display = 'none'; }
if (productData) { const emailInput = document.getElementById('notifly-email'); const firstNameInput = document.getElementById('notifly-first-name'); const lastNameInput = document.getElementById('notifly-last-name'); if (emailInput && productData.customerEmail) emailInput.value = productData.customerEmail; if (firstNameInput && productData.customerFirstName) firstNameInput.value = productData.customerFirstName; if (lastNameInput && productData.customerLastName) lastNameInput.value = productData.customerLastName;
if (productData.customerPhone) { const phone = productData.customerPhone; if (phone.startsWith('+')) { const sorted = [...COUNTRY_CODES].sort((a, b) => b.dial.length - a.dial.length); const match = sorted.find(c => phone.startsWith(`+${c.dial}`)); if (match) { const countrySelect = document.getElementById('notifly-country-select'); const phoneInput = document.getElementById('notifly-phone'); if (countrySelect && phoneInput) { setTimeout(() => { countrySelect.value = match.dial; const localNumber = phone.slice(match.dial.length + 1); phoneInput.value = localNumber; phoneInput.dispatchEvent(new Event('input')); }, 0); } } } } }
const title = modal.querySelector('.notifly-modal__title'); const subtitle = modal.querySelector('.notifly-modal__subtitle'); if (currentTriggerType === 'back_in_stock') { title.textContent = 'Notify Me When Back in Stock'; subtitle.textContent = `Get notified when "${productData.productTitle}" is available again.`; } else { title.textContent = 'Price Drop Alert'; subtitle.textContent = `Get notified when the price of "${productData.productTitle}" drops.`; }
const thresholdGroup = document.getElementById('notifly-threshold-group'); if (thresholdGroup) { thresholdGroup.style.display = currentTriggerType === 'price_drop' ? 'block' : 'none'; }
const thresholdInput = document.getElementById('notifly-price-threshold'); if (thresholdInput && productData) { thresholdInput.placeholder = productData.productPrice.toFixed(2); thresholdInput.value = ''; }
const thresholdHelp = thresholdGroup?.querySelector('.notifly-form__help'); if (thresholdHelp && productData) { thresholdHelp.textContent = `Current price: $${productData.productPrice.toFixed(2)}`; }
initCountrySelect(); updateChannelOptions(); updateScopeDropdown();
modal.style.display = 'flex'; document.body.style.overflow = 'hidden';
setTimeout(() => document.getElementById('notifly-email')?.focus(), 100); }
function updateScopeDropdown() { const scopeGroup = document.getElementById('notifly-scope-group'); const scopeSelect = document.getElementById('notifly-scope-select'); const scopeHelp = document.getElementById('notifly-scope-help'); if (!scopeGroup || !scopeSelect) return;
if (productData.variantsCount <= 1) { scopeGroup.style.display = 'none'; return; }
const {notifiedVariantIds, product: productStatus} = notificationStatusDetail; const hasProductLevel = productStatus.backInStock || productStatus.priceDrop;
const variantOptions = productData.variants .filter(v => notifiedVariantIds.includes(String(v.id))) .sort((a, b) => { const aVal = a.options?.[0] ?? a.title ?? ''; const bVal = b.options?.[0] ?? b.title ?? ''; const aNum = parseFloat(aVal); const bNum = parseFloat(bVal); const bothNumeric = !isNaN(aNum) && !isNaN(bNum); return bothNumeric ? aNum - bNum : aVal.localeCompare(bVal); }) .map(v => ({value: String(v.id), label: formatVariantLabel(v)}));
if (variantOptions.length < 2 && !hasProductLevel) { scopeGroup.style.display = 'none'; return; }
scopeSelect.innerHTML = '';
variantOptions.forEach(({value, label}) => { const opt = document.createElement('option'); opt.value = value; const isCurrent = String(value) === String(productData.variantId); opt.textContent = isCurrent ? `${label} (current)` : label; if (isCurrent) opt.selected = true; scopeSelect.appendChild(opt); });
const allOpt = document.createElement('option'); allOpt.value = 'all'; allOpt.textContent = 'All variants of this product'; scopeSelect.appendChild(allOpt);
if (!scopeSelect.querySelector(`option[value="${productData.variantId}"]`)) { allOpt.selected = true; }
if (scopeHelp) scopeHelp.textContent = ''; scopeGroup.style.display = 'block';
scopeSelect.addEventListener('change', () => { const selectedValue = scopeSelect.value; if (selectedValue === 'all') return;
const variant = productData.variants.find(v => String(v.id) === String(selectedValue)); if (!variant) return;
const variantPrice = variant.price / 100;
const thresholdInput = document.getElementById('notifly-price-threshold'); if (thresholdInput) { thresholdInput.placeholder = variantPrice.toFixed(2); thresholdInput.value = ''; } const thresholdGroup = document.getElementById('notifly-threshold-group'); const thresholdHelp = thresholdGroup?.querySelector('.notifly-form__help'); if (thresholdHelp) { thresholdHelp.textContent = `Current price: $${variantPrice.toFixed(2)}`; } }); }
function closeModal() { const modal = document.getElementById('notifly-modal'); const verificationDiv = modal.querySelector('.notifly-verification'); const mergeDiv = modal.querySelector('.notifly-merge'); const success = modal.querySelector('.notifly-success'); const phoneVerificationInput = document.getElementById('verification-code'); const hasPhoneVerification = verificationDiv && phoneVerificationInput;
if (hasPhoneVerification && success.style.display !== 'flex') { const confirmClose = confirm('Verification in progress. Are you sure you want to close? You\'ll need to start over.'); if (!confirmClose) return; }
const form = document.getElementById('notifly-form'); currentMergeConflict = null; if (verificationDiv) verificationDiv.remove(); if (mergeDiv) mergeDiv.remove();
if (form) { form.reset(); form.style.display = 'block'; form.dataset.submitting = 'false'; const submitBtn = form.querySelector('.notifly-form__submit'); if (submitBtn) { submitBtn.disabled = false; submitBtn.querySelector('.notifly-form__submit-text').style.display = 'inline'; submitBtn.querySelector('.notifly-form__submit-loader').style.display = 'none'; } const messageDiv = form.querySelector('.notifly-form__message'); if (messageDiv) { messageDiv.style.display = 'none'; messageDiv.textContent = ''; } }
if (success) success.style.display = 'none'; modal.style.display = 'none'; document.body.style.overflow = ''; }
function setupModal() { const modal = document.getElementById('notifly-modal'); if (!modal) return; if (modal.parentNode !== document.body) document.body.appendChild(modal);
modal.querySelector('.notifly-modal__close')?.addEventListener('click', closeModal); modal.querySelector('.notifly-modal__overlay')?.addEventListener('click', closeModal); modal.querySelector('.notifly-success__close')?.addEventListener('click', closeModal);
const form = document.getElementById('notifly-form'); if (form) { form.removeEventListener('submit', handleFormSubmit); form.addEventListener('submit', handleFormSubmit); }
const phoneInput = document.getElementById('notifly-phone'); if (phoneInput) phoneInput.addEventListener('input', handlePhoneInput);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.style.display === 'flex') closeModal(); }); }
function handlePhoneInput(e) { const hasPhone = e.target.value.trim().length > 0; const smsCheckbox = document.querySelector('[data-channel="sms"]'); const whatsappCheckbox = document.querySelector('[data-channel="whatsapp"]'); if (hasPhone) { if (smsCheckbox && isChannelAvailable('sms')) smsCheckbox.style.display = 'flex'; if (whatsappCheckbox && isChannelAvailable('whatsapp')) whatsappCheckbox.style.display = 'flex'; } else { if (smsCheckbox) smsCheckbox.style.display = 'none'; if (whatsappCheckbox) whatsappCheckbox.style.display = 'none'; } }
async function handleFormSubmit(e) { e.preventDefault();
const form = e.target; if (form.dataset.submitting === 'true') return; form.dataset.submitting = 'true';
const submitBtn = form.querySelector('.notifly-form__submit'); const submitText = submitBtn.querySelector('.notifly-form__submit-text'); const submitLoader = submitBtn.querySelector('.notifly-form__submit-loader'); const messageDiv = form.querySelector('.notifly-form__message');
const formData = new FormData(form); const email = formData.get('email'); const firstName = formData.get('firstName'); const lastName = formData.get('lastName'); const e164Phone = getE164Phone();
const scopeSelect = document.getElementById('notifly-scope-select'); const scopeGroup = document.getElementById('notifly-scope-group'); const scopeVisible = scopeGroup && scopeGroup.style.display !== 'none'; const scopeValue = scopeVisible && scopeSelect ? scopeSelect.value : productData.variantId; const subscribeToAll = scopeValue === 'all'; const selectedVariantId = subscribeToAll ? null : scopeValue; const selectedVariant = selectedVariantId ? productData.variants.find(v => String(v.id) === String(selectedVariantId)) : null;
let scopePrice = productData.productPrice; if (selectedVariant) { scopePrice = selectedVariant.price / 100; }
const priceThresholdInput = document.getElementById('notifly-price-threshold'); const priceThresholdRaw = priceThresholdInput ? priceThresholdInput.value.trim() : ''; let priceThreshold = priceThresholdRaw ? parseFloat(priceThresholdRaw) : null;
if (currentTriggerType === 'price_drop') { if (priceThreshold === null) { priceThreshold = scopePrice; }
if (isNaN(priceThreshold) || priceThreshold <= 0) { showError('Please enter a valid target price.'); form.dataset.submitting = 'false'; return; } if (priceThreshold > scopePrice) { showError(`Target price must be lower than or equal to the current price ($${scopePrice.toFixed(2)})`); form.dataset.submitting = 'false'; return; } }
const channels = []; form.querySelectorAll('input[name="channel"]:checked').forEach(cb => { if (!cb.disabled) channels.push(cb.value); });
if (channels.length === 0) { showError('Please select at least one notification method.'); form.dataset.submitting = 'false'; return; } if (channels.includes('email') && !email) { showError('Please enter your email address.'); form.dataset.submitting = 'false'; return; } if ((channels.includes('sms') || channels.includes('whatsapp')) && !e164Phone) { showError('Please enter your phone number for SMS/WhatsApp notifications.'); form.dataset.submitting = 'false'; return; }
submitBtn.disabled = true; submitText.style.display = 'none'; submitLoader.style.display = 'flex'; messageDiv.style.display = 'none';
try { const subscriptionData = { email: email || null, phone: e164Phone || null, firstName: firstName || null, lastName: lastName || null, channels, productId: productData.productId, variantId: selectedVariantId, productTitle: productData.productTitle, variantTitle: subscribeToAll ? null : selectedVariant ? formatVariantLabel(selectedVariant) : null, productHandle: productData.productHandle, productPrice: productData.productPrice, triggerType: currentTriggerType, priceThreshold: priceThreshold, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, };
const response = await fetch(`${API_BASE}/api/public/subscribe?shop=${productData.shop}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(subscriptionData), });
const result = await response.json();
if (response.status === 409 && result.needsMerge) { currentMergeConflict = result.mergeConflict; showMergeConfirmation(result.mergeConflict, result.message); return; }
if (response.ok && result.success) { if (result.needsVerification && result.verificationNeeded?.length > 0) { showDualVerification(result.verificationNeeded, result.message); } else { showSuccess(result.message || 'Successfully subscribed!'); } } else { if (result.details) { throw new Error(result.details.map(d => d.substring(d.indexOf(':') + 1).trim()).join('\n')); } throw new Error('Subscription failed'); } } catch (error) { console.error('Subscription error:', error); showError(error.message || 'Something went wrong. Please try again.'); submitBtn.disabled = false; submitText.style.display = 'inline'; submitLoader.style.display = 'none'; } finally { form.dataset.submitting = 'false'; } }
// ============================================ // DUAL VERIFICATION // ============================================ function showDualVerification(verificationNeeded, message) { const form = document.getElementById('notifly-form'); if (document.querySelector('.notifly-verification')) return;
const hasEmail = verificationNeeded.find(v => v.type === 'email'); const hasPhone = verificationNeeded.find(v => v.type === 'phone');
let html = ` <div class="notifly-verification__content"> <div class="notifly-verification__icon">📧📱</div> <h3 class="notifly-verification__title">Verify Your Contact Info</h3> <p class="notifly-verification__text">${message}</p> `;
if (hasEmail) { html += ` <div class="notifly-verification__section"> <h4 class="notifly-verification__section-title">✅ Email Verification ${hasEmail.isPending ? '(updating email)' : ''}</h4> <p class="notifly-verification__section-text">We sent a verification link to:<br><strong>${hasEmail.contact}</strong></p> <p class="notifly-verification__help">Check your inbox and click the link to verify.</p> </div> `; }
if (hasPhone) { html += ` <div class="notifly-verification__section"> <h4 class="notifly-verification__section-title">📱 Phone Verification ${hasPhone.isPending ? '(updating phone)' : ''}</h4> <p class="notifly-verification__section-text">Enter the 6-digit code sent to:<br><strong>${hasPhone.contact}</strong></p> <div class="notifly-verification__form"> <input type="text" id="verification-code" class="notifly-verification__input" placeholder="Enter 6-digit code" maxlength="6" pattern="[0-9]{6}" autocomplete="one-time-code"/> <button type="button" class="notifly-verification__submit" onclick="window.Notifly.verifyCode('${hasPhone.contact}')">Verify Phone</button> </div> <div class="notifly-verification__timer"></div> <div class="notifly-verification__message"></div> <p class="notifly-verification__help">Didn't receive it? <a href="#" onclick="window.Notifly.resendCode('${hasPhone.contact}'); return false;">Resend code</a></p> </div> `; }
if (hasEmail && hasPhone) { html += `<p class="notifly-verification__footer">You can verify in any order. Both must be verified to receive notifications on each channel.</p>`; }
html += `</div>`;
const verificationDiv = document.createElement('div'); verificationDiv.className = 'notifly-verification'; verificationDiv.innerHTML = html;
form.style.display = 'none'; form.parentNode.insertBefore(verificationDiv, form);
if (hasPhone) { startVerificationTimer(600); setTimeout(() => document.getElementById('verification-code')?.focus(), 100); } }
function startVerificationTimer(expiresInSeconds) { const timerElement = document.querySelector('.notifly-verification__timer'); if (!timerElement) return; let timeLeft = expiresInSeconds;
const updateTimer = () => { const minutes = Math.floor(timeLeft / 60); const seconds = timeLeft % 60; timerElement.textContent = `Code expires in ${minutes}:${seconds.toString().padStart(2, '0')}`; timerElement.style.cssText = 'font-size:12px;color:#6b7280;margin:12px 0;text-align:center;';
if (timeLeft <= 0) { timerElement.textContent = 'Code expired - please request a new one'; timerElement.style.color = '#ef4444'; const submitBtn = document.querySelector('.notifly-verification__submit'); const codeInput = document.getElementById('verification-code'); if (submitBtn) { submitBtn.disabled = true; submitBtn.style.opacity = '0.5'; submitBtn.style.cursor = 'not-allowed'; } if (codeInput) { codeInput.disabled = true; codeInput.style.opacity = '0.5'; } } else if (timeLeft <= 60) { timerElement.style.color = '#f59e0b'; } if (timeLeft > 0) { timeLeft--; setTimeout(updateTimer, 1000); } }; updateTimer(); }
async function verifySMSCode(phone) { const codeInput = document.getElementById('verification-code'); const code = codeInput.value.trim(); const messageDiv = document.querySelector('.notifly-verification__message'); const submitBtn = document.querySelector('.notifly-verification__submit');
if (!code || code.length !== 6) { messageDiv.textContent = 'Please enter a 6-digit code'; messageDiv.className = 'notifly-verification__message notifly-verification__message--error'; messageDiv.style.display = 'block'; return; }
submitBtn.disabled = true; submitBtn.textContent = 'Verifying...'; messageDiv.style.display = 'none';
try { const response = await fetch(`${API_BASE}/api/verify-sms?shop=${productData.shop}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({phone, code}), }); const result = await response.json(); if (response.ok && result.success) { document.querySelector('.notifly-verification')?.remove(); showSuccess('Phone verified! You\'ll receive notifications when available.'); } else { throw new Error(result.error || 'Verification failed'); } } catch (error) { messageDiv.textContent = error.message || 'Invalid code. Please try again.'; messageDiv.className = 'notifly-verification__message notifly-verification__message--error'; messageDiv.style.display = 'block'; } finally { submitBtn.disabled = false; submitBtn.textContent = 'Verify Phone'; } }
async function resendSMSCode(phone) { const messageDiv = document.querySelector('.notifly-verification__message'); try { const response = await fetch(`${API_BASE}/api/resend-verification?shop=${productData.shop}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({phone}), }); const result = await response.json(); if (response.ok && result.success) { messageDiv.textContent = 'Code resent! Check your messages.'; messageDiv.className = 'notifly-verification__message notifly-verification__message--success'; } else { throw new Error(result.error || 'Failed to resend code'); } } catch (error) { messageDiv.textContent = error.message || 'Failed to resend code'; messageDiv.className = 'notifly-verification__message notifly-verification__message--error'; } messageDiv.style.display = 'block'; }
// ============================================ // MERGE FLOW // ============================================ function showMergeConfirmation(mergeConflict, message) { currentMergeConflict = mergeConflict; const form = document.getElementById('notifly-form'); document.querySelector('.notifly-merge')?.remove();
const mergeDiv = document.createElement('div'); mergeDiv.className = 'notifly-merge'; mergeDiv.innerHTML = ` <div class="notifly-merge__content"> <div class="notifly-merge__icon">⚠️</div> <h3 class="notifly-merge__title">You Have Two Separate Accounts</h3> <p class="notifly-merge__text">${message}</p> <div class="notifly-merge__accounts"> <div class="notifly-merge__account"> <div class="notifly-merge__account-icon">📧</div> <div class="notifly-merge__account-label">Email Account</div> <div class="notifly-merge__account-value">${mergeConflict.emailAccount.email}</div> <div class="notifly-merge__account-meta">${mergeConflict.emailAccount.subscriptionCount} active notification${mergeConflict.emailAccount.subscriptionCount !== 1 ? 's' : ''}</div> </div> <div class="notifly-merge__divider">+</div> <div class="notifly-merge__account"> <div class="notifly-merge__account-icon">📱</div> <div class="notifly-merge__account-label">Phone Account</div> <div class="notifly-merge__account-value">${mergeConflict.phoneAccount.phone}</div> <div class="notifly-merge__account-meta">${mergeConflict.phoneAccount.subscriptionCount} active notification${mergeConflict.phoneAccount.subscriptionCount !== 1 ? 's' : ''}</div> </div> </div> <div class="notifly-merge__info"> <strong>What happens when you merge?</strong> <ul> <li>All notifications will be combined into one account</li> <li>You'll receive notifications on both email and phone</li> <li>Your subscription history will be preserved</li> </ul> </div> <div class="notifly-merge__security">🔒 For security, you'll need to verify both your email and phone number</div> <div class="notifly-merge__actions"> <button type="button" class="notifly-merge__btn notifly-merge__btn--cancel" onclick="window.Notifly.cancelMerge()">Cancel</button> <button type="button" class="notifly-merge__btn notifly-merge__btn--confirm" onclick="window.Notifly.confirmMerge()">Yes, Merge My Accounts</button> </div> <div class="notifly-merge__expires">This merge request expires in 15 minutes</div> </div> `;
form.style.display = 'none'; form.parentNode.insertBefore(mergeDiv, form); }
async function confirmMerge() { if (!currentMergeConflict) { alert('Error: Merge information lost. Please close the modal and try again.'); return; }
const confirmBtn = document.querySelector('.notifly-merge__btn--confirm'); const cancelBtn = document.querySelector('.notifly-merge__btn--cancel'); if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = 'Sending verification codes...'; } if (cancelBtn) cancelBtn.disabled = true;
try { const response = await fetch(`${API_BASE}/api/send-merge-verifications?shop=${productData.shop}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({mergeToken: currentMergeConflict.mergeToken}), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || `Server error: ${response.status}`); } showMergeDualVerification(currentMergeConflict); } catch (error) { alert(error.message || 'Failed to send verification codes. Please try again.'); if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = 'Yes, Merge My Accounts'; } if (cancelBtn) cancelBtn.disabled = false; } }
function showMergeDualVerification(mergeConflict) { const mergeDiv = document.querySelector('.notifly-merge'); mergeDiv.innerHTML = ` <div class="notifly-verification__content"> <div class="notifly-verification__icon">🔐</div> <h3 class="notifly-verification__title">Verify Your Accounts</h3> <p class="notifly-verification__text">To complete the merge, please verify both your email and phone number.</p> <div class="notifly-verification__section"> <h4 class="notifly-verification__section-title">✅ Email Verification</h4> <p class="notifly-verification__section-text">We sent a verification link to:<br><strong>${mergeConflict.emailAccount.email}</strong></p> <p class="notifly-verification__help">Check your inbox and click the link to verify.</p> <div class="notifly-verification__status" data-type="email"> <span class="notifly-verification__status-icon">⏳</span> <span class="notifly-verification__status-text">Waiting for verification...</span> </div> </div> <div class="notifly-verification__section"> <h4 class="notifly-verification__section-title">📱 Phone Verification</h4> <p class="notifly-verification__section-text">Enter the 6-digit code sent to:<br><strong>${mergeConflict.phoneAccount.phone}</strong></p> <div class="notifly-verification__form"> <input type="text" id="merge-verification-code" class="notifly-verification__input" placeholder="Enter 6-digit code" maxlength="6" pattern="[0-9]{6}" autocomplete="one-time-code"/> <button type="button" class="notifly-verification__submit" onclick="window.Notifly.verifyMergePhone()">Verify Phone</button> </div> <div class="notifly-verification__status" data-type="phone"> <span class="notifly-verification__status-icon">⏳</span> <span class="notifly-verification__status-text">Waiting for verification...</span> </div> <div class="notifly-verification__message"></div> </div> <p class="notifly-verification__footer">Both must be verified to complete the merge.</p> </div> `; startEmailVerificationPolling(mergeConflict.mergeToken); }
let emailPollingInterval = null;
function startEmailVerificationPolling(mergeToken) { if (emailPollingInterval) clearInterval(emailPollingInterval); emailPollingInterval = setInterval(() => { }, 3000); setTimeout(() => { if (emailPollingInterval) { clearInterval(emailPollingInterval); emailPollingInterval = null; } }, 15 * 60 * 1000); }
async function verifyMergePhone() { const codeInput = document.getElementById('merge-verification-code'); const code = codeInput.value.trim(); const messageDiv = document.querySelector('.notifly-verification__message'); const submitBtn = document.querySelector('.notifly-verification__submit'); const phoneStatus = document.querySelector('.notifly-verification__status[data-type="phone"]'); const emailStatus = document.querySelector('.notifly-verification__status[data-type="email"]');
if (!code || code.length !== 6) { messageDiv.textContent = 'Please enter a 6-digit code'; messageDiv.className = 'notifly-verification__message notifly-verification__message--error'; messageDiv.style.display = 'block'; return; } if (!currentMergeConflict) return;
submitBtn.disabled = true; submitBtn.textContent = 'Verifying...'; messageDiv.style.display = 'none';
let result; try { const response = await fetch(`${API_BASE}/api/verify-merge?shop=${productData.shop}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({mergeToken: currentMergeConflict.mergeToken, type: 'phone', verificationCode: code}), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || `Server error: ${response.status}`); } result = await response.json();
if (result.success) { if (phoneStatus) { phoneStatus.querySelector('.notifly-verification__status-icon').textContent = '✅'; phoneStatus.querySelector('.notifly-verification__status-text').textContent = 'Phone verified!'; phoneStatus.style.color = '#10b981'; } if (result.merged || result.bothVerified) { if (emailPollingInterval) { clearInterval(emailPollingInterval); emailPollingInterval = null; } document.querySelector('.notifly-merge')?.remove(); showSuccess('Accounts merged successfully! You\'ll now receive notifications on both email and phone.'); } else { messageDiv.textContent = '✅ Phone verified! Now check your email to complete the merge.'; messageDiv.className = 'notifly-verification__message notifly-verification__message--success'; messageDiv.style.display = 'block'; codeInput.disabled = true; codeInput.style.opacity = '0.5'; submitBtn.disabled = true; submitBtn.textContent = '✅ Phone Verified'; submitBtn.style.opacity = '0.5'; if (emailStatus) { emailStatus.style.cssText = 'background:#fef3c7;padding:12px;border-radius:8px;border:2px solid #f59e0b;'; emailStatus.querySelector('.notifly-verification__status-icon').textContent = '⏳'; const statusText = emailStatus.querySelector('.notifly-verification__status-text'); statusText.textContent = 'Please check your email and click the verification link!'; statusText.style.fontWeight = 'bold'; } } } else { throw new Error(result.error || 'Verification failed'); } } catch (error) { let errorMessage = 'Invalid code. Please try again.'; if (error.message.includes('expired')) errorMessage = 'Merge request has expired. Please start over.'; else if (error.message) errorMessage = error.message; messageDiv.textContent = errorMessage; messageDiv.className = 'notifly-verification__message notifly-verification__message--error'; messageDiv.style.display = 'block'; } finally { if (!result?.success) { submitBtn.disabled = false; submitBtn.textContent = 'Verify Phone'; } } }
async function cancelMerge() { if (!currentMergeConflict) { closeModal(); return; } if (!confirm('Are you sure you want to cancel? You can subscribe with just your email or just your phone instead.')) return;
try { await fetch(`${API_BASE}/api/cancel-merge?shop=${productData.shop}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({mergeToken: currentMergeConflict.mergeToken}), }); } catch (error) { console.error('Cancel merge error:', error); } finally { currentMergeConflict = null; document.querySelector('.notifly-merge')?.remove(); const form = document.getElementById('notifly-form'); if (form) { form.style.display = 'block'; form.reset(); form.dataset.submitting = 'false'; const submitBtn = form.querySelector('.notifly-form__submit'); if (submitBtn) { submitBtn.disabled = false; submitBtn.querySelector('.notifly-form__submit-text').style.display = 'inline'; submitBtn.querySelector('.notifly-form__submit-loader').style.display = 'none'; } const messageDiv = form.querySelector('.notifly-form__message'); if (messageDiv) { messageDiv.style.display = 'none'; messageDiv.textContent = ''; } } } }
// ============================================ // UI HELPERS // ============================================ function showSuccess(message) { const form = document.getElementById('notifly-form'); const success = document.querySelector('.notifly-success'); success.querySelector('.notifly-success__message').textContent = message; form.dataset.submitting = 'false'; const submitBtn = form.querySelector('.notifly-form__submit'); if (submitBtn) { submitBtn.disabled = false; submitBtn.querySelector('.notifly-form__submit-text').style.display = 'inline'; submitBtn.querySelector('.notifly-form__submit-loader').style.display = 'none'; } form.style.display = 'none'; success.style.display = 'flex'; }
function showError(message) { const messageDiv = document.querySelector('.notifly-form__message'); messageDiv.textContent = message; messageDiv.className = 'notifly-form__message notifly-form__message--error'; messageDiv.style.display = 'block'; }
function updateChannelOptions() { const smsCheckbox = document.querySelector('[data-channel="sms"]'); const whatsappCheckbox = document.querySelector('[data-channel="whatsapp"]'); const phoneGroup = document.getElementById('notifly-phone-group');
const smsAvailable = isChannelAvailable('sms'); const whatsappAvailable = isChannelAvailable('whatsapp');
if (phoneGroup) phoneGroup.style.display = (smsAvailable || whatsappAvailable) ? 'block' : 'none';
if (smsCheckbox) { const input = smsCheckbox.querySelector('input[type="checkbox"]'); smsCheckbox.style.display = 'none'; if (input) input.disabled = !smsAvailable; if (!smsAvailable && input) input.checked = false; } if (whatsappCheckbox) { const input = whatsappCheckbox.querySelector('input[type="checkbox"]'); whatsappCheckbox.style.display = 'none'; if (input) input.disabled = !whatsappAvailable; if (!whatsappAvailable && input) input.checked = false; }
showTierMessage(); }
function showTierMessage() { if (!shopConfig) return; const channelsSection = document.getElementById('notifly-channels'); channelsSection?.querySelector('.notifly-tier-message')?.remove(); if (shopConfig.tier === 'free') { const tierMessage = document.createElement('p'); tierMessage.className = 'notifly-tier-message'; tierMessage.style.cssText = 'font-size:0.75rem;color:#0ea5e9;margin-top:8px;padding:8px;background:#f0f9ff;border-radius:4px;'; tierMessage.textContent = '💡 Upgrade to unlock SMS, and WhatsApp notifications'; channelsSection?.appendChild(tierMessage); } }
// ============================================ // PUBLIC API // ============================================ window.Notifly = window.Notifly || {}; Object.assign(window.Notifly, { open: openModal, close: closeModal, setTriggerType: (type) => { currentTriggerType = type; }, verifyCode: verifySMSCode, resendCode: resendSMSCode, confirmMerge, cancelMerge, verifyMergePhone, });
document.addEventListener('notifly:reinit', function (event) { var widget = event.detail && event.detail.block; if (widget) setupWidget(widget); });
})();Step 2 — Edit theme.liquid
Section titled “Step 2 — Edit theme.liquid”Open Layout → theme.liquid. Find the closing </body> tag and paste the following just before it:
{% comment %} Notifly — modal and assets loaded once globally {% endcomment %}{% render 'notifly-modal' %}{{ 'notifly-widget.css' | asset_url | stylesheet_tag }}<script src="{{ 'notifly-widget.js' | asset_url }}" defer></script>Step 3 — Edit product.liquid
Section titled “Step 3 — Edit product.liquid”Open Sections → product.liquid (in some themes this is called product-template.liquid).
Find the Add to Cart button — it will be inside a {% form 'product', product %} block and look something like:
<button type="submit" name="add" ...> Add to cart</button>Add the following line directly after the closing </button> tag:
{% render 'notifly-widget' %}It should look like this when done:
<button type="submit" name="add" id="AddToCart-{{ section.id }}" ...> <span id="AddToCartText-{{ section.id }}"> {{ 'products.product.add_to_cart' | t }} </span></button>{% render 'notifly-widget' %}Save the file.
Step 4 — Verify the widget
Section titled “Step 4 — Verify the widget”Visit a product page on your storefront:
- For a sold-out product, you should see the “Notify Me When Back in Stock” button
- For an in-stock product, you should see the “Alert Me to Price Drops” button
- Clicking either button should open the Notifly subscription modal
Customizing button text
Section titled “Customizing button text”Unlike the OS 2.0 app block, legacy themes don’t have a settings panel for button text. To change the default button labels, open Snippets → notifly-widget.liquid and edit the button text directly:
<button type="button" class="notifly-trigger" data-trigger-type="back_in_stock" style="display:none;"> Notify Me When Back in Stock ← edit this</button><button type="button" class="notifly-trigger" data-trigger-type="price_drop" style="display:none;"> Alert Me to Price Drops ← edit this</button>