Skip to content

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.

You’ll be making four edits:

  1. Add the notifly-widget snippet file to your theme
  2. Add the notifly-modal snippet file to your theme
  3. Add the notifly-widget.css and notifly-widget.js files to your theme assets folder
  4. Edit theme.liquid to load the CSS, JavaScript, and modal globally
  5. Edit product.liquid (or product-template.liquid) to render the widget on product pages

In your Shopify admin go to Online Store → Themes → Edit code.

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>

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>

Under Assets, click Add a new file, name it notifly-widget.css, and paste the following:

extensions/notifly-widget/assets/notifly-widget.css
: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%;
}
}

Under Assets, click Add a new file, name it notifly-widget.js, and paste the following:

extensions/notifly-widget/assets/notifly-widget.js
(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(/&quot;/g, '"'));
} catch (_) {
return [];
}
})(),
optionNames: (() => {
try {
return JSON.parse((widget.dataset.optionNames || '[]').replace(/&quot;/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);
});
})();

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>

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.


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

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>