Перейти до вмісту

Інженерні історії · 01

Власний buy box підписки на вбудованих selling plans

  • Selling plans
  • Recharge

Побудова buy box із вибором «підписка / разова покупка» на нативних selling plans Shopify, де Recharge виступає обробником нижче за потоком, щоб ціна, яку бачить покупець, завжди дорівнювала ціні, за якою його списують.

Shopify OS 2.0 · Native selling plans · Recharge as processor · Vanilla JS · Subscription commerce

Доповнення до флагманського кейсу, що повністю розкриває одну систему: buy box підписки на сторінці товару.


Чому саме підписку треба було зробити правильно

Це американський wellness-бренд добавок, що продає безпосередньо споживачам на Shopify. Каталог невеликий, а ціна передбачає зважене рішення, тож економіка сильно спирається на повторні покупки. Разове замовлення коштує лише частку того, що дає передплатник за рік. Підписка це механізм утримання і більша частина lifetime value, через що опція підписки стає найважливішим з комерційного погляду елементом керування на сторінці.

Ставка, до якої я постійно повертався, проста у формулюванні і легка для помилки: пропозиція, яку бачить покупець, має дорівнювати пропозиції, за якою його списують. Якщо сторінка показує одну ціну, а Recharge списує іншу, або покупець обирає упаковку з трьох банок, а списання йде за місячним циклом, магазин втрачає замовлення, регулярний дохід за ним і отримує звернення в підтримку. На мобільних, куди приходить більшість платного трафіку магазину, ця довіра вирішується за кілька секунд усередині вузької вертикальної смуги (упаковка, підписка чи ні, ціна, економія, додати в кошик), і будь-яка мить, коли ціна виглядає неправильно, це мить, коли покупець вагається.

Тож завдання було не «додати підписки». Воно було таким: продавати упаковки на 1, 2 і 3 банки і як разові, і як підписку, з ціною, якій покупець може довіряти, на сторінці, що має бути піксельно точною.


Чому я не використав віджет Recharge

Очевидний шлях це віджет підписки Recharge. Він створений саме для цього, швидко впроваджується, і для багатьох магазинів це правильний вибір. Для цього магазину ні, з трьох причин, які зводяться до тієї самої ставки.

Він рендериться всередині shadow DOM, межі, крізь яку не можуть дістати правила типографіки, відступів та взаємодії теми, а піксельна точність тут була жорсткою вимогою, а не побажанням. Він володіє власним станом ціни та плану, але buy box уже мав два інші елементи керування, що змінюють ціну (селектор упаковки 1/2/3 банки і показ ціни за банку проти загальної суми упаковки), і мені потрібно було, щоб видима ціна, обрана упаковка й план для списання лишалися синхронними, коли покупець перемикає; передати стан плану компоненту за межею shadow означало узгоджувати два джерела істини на кожній взаємодії. І він підтягує власний JavaScript Recharge, який я не хотів додавати на найважливішу мобільну сторінку магазину заради компонента, який я все одно збирався перестилізувати.

Інсайт, який відкрив альтернативу: Recharge не потрібен його віджет, щоб виконувати свою роботу. Recharge поновлює замовлення, коли рядок несе нативний Shopify selling_plan. Це і є весь контракт. Запиши правильний selling_plan у рядок кошика, і checkout лишається на Shopify, замовлення створюється нативно, а Recharge підхоплює його нижче за потоком як обробник. Віджет це один зі способів створити цю властивість рядка. Але не єдиний.


Обмеження, з якими я проєктував

  • Піксельна точність до дизайну, включно з карткою підписки та бейджем економії.
  • Один buy box, три рухомі частини. Селектор упаковки, тип покупки та додавання в кошик не мають спільного DOM, але мусять узгоджувати ціну щомиті; контракт, що тримає їх узгодженими, описано в глибокому розборі 02.
  • Редагується мерчантом, нічого не задано жорстко. Контент, ціни та плани живуть у конфігурації.
  • Переживати переналаштування Recharge. Перейменування, видалення чи повторне створення плану ніколи не має вимагати деплою коду.
  • Жодного нового вендорського JavaScript у buy box.

Архітектура, яку я обрав

Повністю власний інтерфейс підписка/разова покупка, побудований на нативних Shopify selling plans, з Recharge як обробником і checkout на Shopify. Ніякого Recharge JS чи SDK ніде в buy box. Єдина присутність Recharge у репозиторії це трохи CSS, що перестилізовує iframe клієнтського порталу, косметика, не пов'язана з цим box.

Елемент керування типом покупки рендерить дві radio-картки: разова і підписка. Картка підписки з'являється лише тоді, коли товар справді має плани:

{% if product.selling_plan_groups.size > 0 %}
  {# render the subscribe card #}
{% endif %}

Кожен товар несе три плани: 1-місячний, 2-місячний і 3-місячний, зі знижкою 15%. Ці 15% не промокод і не число, яке я вписав у JavaScript. Воно живе всередині selling plan як цінове коригування, а інтерфейс зчитує його назад.

Box спілкується з рештою buy box через глобальні змінні window і події DOM, а не через спільний DOM: щойно визначено стан підписки, він публікує window.__lumResolvedSub (план і ціну для обраної упаковки) і попередньо обчислює window.__lumSubByVariant (ціну підписки для кожної упаковки), тож картки упаковок малюють власні бейджі економії, а липка панель показує ту саму ціну без повторного обчислення. Це розчеплення описано в глибокому розборі 02.


Найскладніше: визначити план без жорсткого задання ID

Центр цієї системи це одна функція, resolvePlanForVariant(). Її завдання звучить тривіально, але таким не є: за упаковкою, яку обрав покупець, повернути правильний план підписки і правильну ціну підписки.

Наївна версія прописує це жорстко. Упаковка один використовує план 680..., упаковка два використовує план 681..., і так далі. Це працює до першого разу, коли хтось у Recharge перейменує план, видалить і створить його заново (що породжує новий ID) або змінить порядок у групі. Тоді buy box вказує на мертвий ID, і ніхто про це не дізнається, доки передплатнику не спишуть неправильно. На живому магазині, який редагують не інженери, жорстко задані ID це бомба сповільненої дії.

Тож зіставлення виводиться з тексту плану, а не ID, у три кроки.

Позиція упаковки стає бажаною кількістю місяців. Упаковка з N банок має підписуватися на N-місячний план. Позиція це індекс варіанта, variantIndex(v) + 1: перша упаковка хоче один місяць, друга хоче два, третя хоче три.

Текст плану стає інтервалом. Для будь-якого плану я будую haystack з усього, що на ньому можна прочитати (значення опцій, назви опцій і назва плану), а потім витягую перше число, схоже на кількість місяців:

var m = hay.match(/(\d+)\s*month/i) || hay.match(/(\d+)/);
return m ? parseInt(m[1], 10) : null;

План з назвою "3 Month" дає months = 3, як і "Every 3 months" чи "3-month delivery." ID плану ніколи не бере участі в рішенні.

Місяці стають планом. planForMonths(n) сканує групу і повертає план, чий виведений з тексту інтервал дорівнює n.

Саме вибір «текст, а не ID» є несучим. Мерчант може перейменувати, видалити чи створити план у Recharge заново, і поки він досі вказує, на скільки місяців поновлюється, box далі правильно зіставляє упаковки без зміни коду. Зіставлення переживає саме ту операцію, що ламає жорстко задану інтеграцію.

Плавне зниження, коли плану бракує

Товари не завжди несуть усі три плани; метаболічний товар постачається зі скороченим набором. Якщо упаковка на три банки хоче тримісячний план, а його немає, повернути нічого означало б залишити покупцеві зламану картку підписки. Тож резолвер знижується до найближчого меншого інтервалу:

for (var mm = pos; mm >= 1 && !plan; mm--) plan = planForMonths(mm);
if (!plan) { var ks = Object.keys(plansById); if (ks.length) plan = plansById[ks[0]]; }

Він пробує точний інтервал, потім спускається по одному місяцю за раз, і лише якщо нічого взагалі не збігається, вдається до першого плану в групі. Відсутній план деградує до розумного сусіда, а не до мертвого елемента керування.

Два режими та запасний вихід

Резолвер працює в одному з двох режимів, який зчитується з data-атрибута. mapped (за замовчуванням) це логіка «позиція в інтервал» вище. auto читає власні selling_plan_allocations варіанта, те, що Recharge додає, коли плани призначені на рівні варіанта, і використовує їх напряму. Два необов'язкові налаштування схеми, monthly_plan_id і bimonthly_plan_id, явно закріплюють позиції один і два: запасний вихід для товару з нечисловими назвами планів, вимкнений за замовчуванням і застосовується лише тоді, коли шлях через текст не справляється.

Читати знижку, ніколи не записувати її

Ціна підписки береться з власного цінового коригування плану, хоч би яким воно було:

if (a.value_type === 'percentage')   return Math.round(price * (1 - a.value / 100));
if (a.value_type === 'fixed_amount') return Math.max(0, price - a.value);
if (a.value_type === 'price')        return a.value;

Ці 15% читаються з value_type: 'percentage' на живому плані. Зміни пропозицію на 20% чи фіксовану ціну, і box піде за нею без деплою. У знижці немає нічого заданого жорстко, що лишає мерчанту контроль над власним ціноутворенням.


Запобігання подвійній знижці

Ось баг, який ніколи не потрапив у продакшн, бо архітектура йому запобігла.

Buy box має шар відображення, що форматує ціни для карток, кнопки та липкої панелі, і вміє застосовувати цінове коригування плану. Але на момент, коли ціна підписки досягає цього шару, resolvePlanForVariant() вже застосував 15%. Якби шар відображення застосував відсоток плану ще раз, покупець побачив би мінус 15% від уже здешевленого числа, і ціна повзла б униз із кожним рендером.

Виправлення це синтетичний ціновий контекст. Коли обрано підписку, я передаю шару відображення сфабрикований об'єкт плану, що несе визначену ціну як абсолютне значення, а не відсоток:

var synthetic = { id: r.planId, priceAdjustments: [{ value_type: 'price', value: r.subPrice }] };
window.Elixir_SetSubscriptionContext(r.planId, v, synthetic);

Оскільки синтетичне коригування має value_type: 'price', шар відображення повертає значення дослівно (гілка return a.value вище) і не може повторно накласти відсоток згори. Ціна обчислюється один раз, у резолвері, і кожен споживач нижче за потоком трактує її як остаточну. Два шляхи в коді, одне число.


Як selling_plan дістається до checkout

Уся ця робота з визначенням не має значення, якщо план не потрапляє в замовлення. Ось де ставка на нативні selling plans себе виправдовує: контракт із checkout це один прихований input.

Компонент додавання в кошик рендерить справжню форму товару Shopify і позначає її класом:

{%- form 'product', product, class: 'product-buy-form-pdp ...' -%}
  <input type="hidden" name="selling_plan" value="">

Коли покупець обирає підписку, невеликий помічник записує визначений ID плану в це поле, обмежене формою самого buy box і жодною іншою:

var form = document.querySelector('form[action*="cart/add"].product-buy-form-pdp');
var input = form.querySelector('input[name="selling_plan"]'); // created if missing
input.value = String(sellingPlanId);

Додавання в кошик це Ajax-надсилання: воно будує FormData з цієї форми і робить POST на /cart/add.js. Оскільки поле selling_plan всередині форми, воно їде разом у payload, і рядок створюється прив'язаним до плану, яким керує Recharge, а Recharge потім його поновлює. Перемикання назад на разову покупку очищає поле, тож разовий рядок не несе плану і ніколи не поновлюється. Обмеження форми (чому це .product-buy-form-pdp, а не запит на весь документ) існує, бо друга, прихована форма товару на лендінгах хапала неправильне поле; ця історія живе в глибокому розборі 02.

Результат це box підписки з нулем коду Recharge всередині, який усе одно правильно керує Recharge, бо говорить власною мовою платформи.


Разова покупка проти підписки, і два подання ціни

Box тримає два стани ціни і два подання, і всі чотири мусять лишатися узгодженими. Стани це разова покупка і підписка; перемикання перераховує і перефарбовує бейдж економії для кожного, бо математика різна (упаковка «купи 2, отримай 1» економить інакше, ніж підписка). Подання це ціна за банку і загальна сума упаковки: картки показують ціну за банку, бо покупець, порівнюючи упаковки, хоче бачити, як одиниця дешевшає зі зростанням упаковки, тоді як кнопка й липка панель показують загальну суму упаковки, бо саме її списують. Утримання узгодженості між картками з ціною за банку і кнопкою із загальною сумою упаковки в обох станах це саме те, чому ціна підписки для кожної упаковки обчислюється заздалегідь, а не виводиться на ходу в трьох місцях.


v1 і v2: дві комерційні моделі на одній поверхні виручки

Поточний резолвер це версія два. Версія один була іншою комерційною моделлю, і я її не видалив. Я її заморозив. Зміна цінової моделі на живій сторінці товару це не рефакторинг: це зміна того, що покупець вважає вигодою, тож розберу обидві.

Що продавала v1

У першій версії упаковки відрізнялися не кількістю, а подарунком. Покупець бачив три картки: одна банка, дві плюс одна безкоштовно, три плюс дві безкоштовно. Економія на бейджі рахувалася від загальної кількості банок на руках, а не від ціни за банку, і на старші упаковки йшла безкоштовна доставка. Модель зрозуміла й агресивна: чим більше береш, тим більше "безкоштовного".

Зіставлення упаковки з планом було бінарним. Кожна упаковка потрапляла або на щомісячне списання, або на списання раз на два місяці, за налаштованою межею. Зв'язку між розміром упаковки та інтервалом не існувало: упаковка просто опинялася по один чи по інший бік порога.

Важкою v1 робила не модель, а обв'язка навколо неї, і вся вона була в руках магазину. Кожна картка упаковки налаштовувалася в редакторі теми: платні банки, безкоштовні банки, підпис, підзаголовок, необов'язковий бейдж на кшталт «Best Deal» чи «Family Pack» і рядок економії, який можна було вписати вручну або порахувати автоматично: ціна порівняння, помножена на загальну кількість банок. Картка підписки несла до шести рядків переваг, у кожного свій прапорець «лише для упаковок»: безкоштовна доставка показувалася на двох і трьох банках і сама ховалася на одній. Резолвер планів мав перемикач режиму, налаштовуваний поріг, явні ID планів як перевизначення і формат підпису інтервалу. Магазин міг переоцінити всю пропозицію без розробника. Ціна цього: кожне з налаштувань мало сходитися з усіма іншими у трьох сніпетах, які не ділять стан.

Чому модель змінили

Рішення змінити модель ухвалював магазин, не я, і йшлося про ризик, а не про код. Безкоштовні банки це реальний товар, який їде зі складу; команда не хотіла й далі його роздавати й обрала модель із передбачуваною маржею. Моя думка була іншою: у v1 гачок сильніший. Безкоштовна банка це найзрозуміліша пропозиція, яку можна дати покупцеві, і конвертувати вона цілком могла краще. Я це сказав, а потім зібрав те, що магазин попросив. Подарункові банки прибрали, ціни по упаковках перерахували, додали тримісячний план і зіставили з ним упаковки на три банки, ту саму логіку поширили на інші шаблони товарів. Нова модель простіше формулюється: упаковка з N банок підписується на N-місячний план, і наступне списання приходить, коли запас закінчується.

Що v2 полагодила дорогою

Ніщо з цього не було причиною зміни моделі. Це те, що я виправив, поки перезбирав компонент, і тому v2 краща як інженерія, навіть якщо v1 була кращою як пропозиція.

Ціни підписки по упаковках не обчислювалися заздалегідь. Ціна виводилася на місці, у момент відмальовування, тож картка упаковки і кнопка могли порахувати її незалежно. Тримати їх у згоді було задачею дисципліни, а не архітектури. У v2 ціна підписки для кожної упаковки рахується заздалегідь і лежить в одному місці, звідки її читають усі споживачі.

Цінові події не несли типу покупки. Подія казала "ціна змінилася", але не казала, разова це покупка чи підписка. У разової покупки й у підписки математика економії різна, а даних, щоб обрати потрібну, картка не мала, тож бейдж не перефарбовувався. У v2 подія несе type.

Зіставлення було порогом, а не відображенням. Бінарна межа працює рівно до першого нового плану. Щойно з'явився тримісячний, поріг перестав описувати реальність. Зіставлення за кількістю місяців, виведеною з тексту плану, пережило цю зміну без жодної правки в коді.

Як перемикання зробили оборотним

Перед переробкою я зробив знімок v1 у заморожений файл purchase-type-lum-v1.liquid і окрему git-гілку. Це явний шлях відкату: якщо нова модель покаже себе гірше на живому трафіку, повернення до старої стає відкатом, а не реконструкцією з пам'яті. Відстань між цими двома словами вимірюється днями простою на сторінці, яка приносить гроші.

Два застереження, без яких картина буде надто гладкою. Заморожений файл не підтримується: сусідні сніпети їдуть уперед, і за кілька місяців відкат перестане бути безкоштовним, тобто шлях відкату має термін придатності. І зміну моделі ми не міряли як експеримент: це була заміна, а не A/B на одному трафіку, тож стверджувати, що v2 продає краще саме через модель, я не можу. Подарункова модель цілком могла продавати більше. Саме тому v1 заморожена, а не видалена: якщо магазин вирішить, що безкоштовні банки варті своїх грошей, повернення це один відкат.

Заморожена v1 тепер лежить поруч із живою v2 у відкритих витягах коду: purchase-type-v1.liquid і purchase-type.liquid у папці buy-box.

Що б я зробив інакше

v1 і так була налаштовуваною до останнього гвинтика, тож урок не в тому, щоб «винести в налаштування». Жодне налаштування не могло змінити форму правила: упаковки визначалися подарунками, інтервали порогом. Зміна форми означала реліз. Наступного разу я б описав саме правило як дані: упаковка зіставлена з планом, а подарунки це необов'язкова властивість упаковки. Тоді подарункова й інтервальна моделі це два рядки однієї таблиці, а не дві версії компонента.


Компроміси, на які я пішов

Жодне рішення не безкоштовне. Три ціни варто назвати прямо.

Парсинг тексту регулярним виразом крихкий до нечислових назв планів. Назви план "Monthly" без цифри, і регулярний вираз інтервалу нічого не знайде, тож резолвер вдасться до запасного варіанта. Це справжня крихкість, пом'якшена перевизначеннями monthly_plan_id / bimonthly_plan_id, але пом'якшення ручне. Ставка в тому, що числові назви це норма, а перевизначення покриває винятки.

Зіставлення на основі позиції припускає, що порядок варіантів дорівнює зростанню розміру упаковки. Перший варіант це одна банка, другий це дві, третій це три. Зміни порядок варіантів, і зіставлення ламається. Воно тримається, бо упаковки завжди налаштовані в порядку зростання, але це домовленість, якій код довіряє, а не факт, який він перевіряє.

Зв'язність через глобальні змінні чутлива до таймінгу. Три компоненти координуються через глобальні змінні window і події, тож порядок ініціалізації має значення, а пізні сторонні скрипти чи перерендери Theme Editor можуть заважати. Box переініціалізовується з невеликим часовим зсувом і на подію завантаження секції Shopify, щоб лишатися коректним попри це. Це працює, але це координація, яку платформа не забезпечує примусово.

Жоден з них не прихований. Це ціна, яку я заплатив за box, що піксельно точний, не несе вендорського JS і переживає зміни в Recharge, і я знову пішов би на той самий компроміс.


Як я це перевірив

Box перевірений наскрізно в чистому headless Chrome через Puppeteer-обв'язку, а не клацанням у власному браузері (у моєму профілі працює розширення, що заважає висувній панелі кошика, тож ручне тестування там бреше). Обв'язка проходить усю матрицю, кожна упаковка помножена на разову покупку і підписку, перевіряючи, що форма несе правильний variant, що визначений selling_plan присутній і правильний, і що ціни збігаються до копійки в картках, кнопці та липкій панелі. Вона працює на сторінці товару і на лендінгах, де розміщено той самий box, даючи мені підтверджену технічну впевненість: додавання в кошик записує правильний план, подвійна знижка ніколи не виникає, а чотири цінові поверхні ніколи не суперечать одна одній.

Міграція живих передплатників магазину на Recharge, інша половина історії Recharge, має власний опис у глибокому розборі 04.


Результат і вплив на бізнес

Підтверджений технічний результат: buy box підписки на нативних selling plans, зі зіставленням «упаковка в інтервал», визначеним з тексту плану і без жорстко заданих ID планів, без JavaScript Recharge у box, без подвійної знижки, і з правильним selling_plan, що дістається до checkout, тож Recharge поновлює замовлення, усе підтверджено в ізольованій обв'язці.

Очікувану бізнес-цінність я формулюю як захист і уможливлення, а не як опубліковане число: комерційні показники магазину належать бренду і перебувають під NDA. Box захищає те, від чого залежить дохід від підписки: ціна, яку бачить покупець, це ціна, яку списує Recharge, а упаковка, яку він обирає, це цикл, який він отримує. Це узгодження знижує ризик помилок регулярних замовлень і відтоку, що непомітно знекровлюють бізнес підписки. Читання знижки з плану дозволяє операційній команді змінювати пропозицію без інженера, а виведення зіставлення з тексту плану тримає box у роботі попри рутинні зміни в Recharge, які інакше стали б інцидентами в продакшні.


Що я візьму в наступний проєкт

Читай конфігурацію, не зашивай її в код. Те, що робить цей box довговічним, це виведення зіставлення «упаковка в план» з даних, які контролює мерчант (текст плану і цінові коригування), а не з констант, які контролює інженер. Він переживає зміни, що ламають жорстко задані інтеграції, і повертає контроль над ціноутворенням людям, яким належить пропозиція.

Обчисли ціну один раз і зроби її остаточною. Синтетичний ціновий контекст існує для того, щоб «уже здешевлене» ніколи не можна було здешевити ще раз. Коли число може торкнутися більш ніж один шлях у коді, виріши, який з них ним володіє, і дай іншим значення, яке вони не можуть змінити.

Говори контрактом платформи, а не SDK вендора. Архітектура спирається на один факт: Recharge поновлює будь-що, що несе нативний selling_plan. Побудова під цей контракт замість віджета купила піксельно точний контроль, нуль доданого вендорського JavaScript і нативний checkout, ціною правильного запису одного прихованого input. А заморозка v1 наперед зробила зміну живої дохідної поверхні оборотним експериментом, що на будь-чому, дотичному до грошей, є відповідальним стандартом за замовчуванням.


Технічні посилання

Файли, з яких складається ця система:

  • snippets/purchase-type-lum.liquid: картки підписка/разова покупка і резолвер (resolvePlanForVariant, intervalMonthsFromPlan, planForMonths, applyAdjustment, синтетичний контекст).
  • snippets/purchase-type-lum-v1.liquid: заморожена модель v1 «подарункові банки», збережена разом зі своєю git-гілкою як шлях відкату.
  • snippets/bundle-selector-lum.liquid: селектор упаковки 1/2/3 банки, що встановлює variant і публікує window.__lumBundlePack.
  • snippets/subscription-plans-data.liquid: дані планів, що живлять box.
  • snippets/add-to-cart-lum.liquid: справжня форма товару (.product-buy-form-pdp), Ajax-додавання і липка панель ціни.
  • assets/product-form-controller.js: Elixir_SetProductFormSellingPlan, що записує приховане поле selling_plan у обмежену форму.
  • assets/theme-loaders.js: Elixir_SetSubscriptionContext, точка входу шару відображення, що споживає синтетичний ціновий контекст.
КодВідкрити знеособлений код, про який ця статтяНазад до кейсуWellness DTC-бренд

Передайте вітрину й вважайте питання закритим.

Надішліть задачу й мету. Далі розберуся в коді, доведу роботу до релізу й перевірю сам.

Почати розмову