Інженерні історії · 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
Доповнення до флагманського кейсу Lumway, що повністю розкриває одну систему: buy box підписки на сторінці товару.
Чому саме підписку треба було зробити правильно
Lumway продає жувальні добавки споживачам у США на Shopify. Каталог невеликий, а ціна передбачає зважене рішення, тож економіка сильно спирається на повторні покупки. Разове замовлення жувальних добавок коштує лише частку того, що дає передплатник за рік. Підписка це механізм утримання і більша частина lifetime value, через що опція підписки стає найважливішим з комерційного погляду елементом керування на сторінці.
Ставка, до якої я постійно повертався, проста у формулюванні і легка для помилки: пропозиція, яку бачить покупець, має дорівнювати пропозиції, за якою його списують. Якщо сторінка показує одну ціну, а Recharge списує іншу, або покупець обирає упаковку з трьох банок, а списання йде за місячним циклом, магазин втрачає замовлення, регулярний дохід за ним і отримує звернення в підтримку. На мобільних, куди приходить більшість платного трафіку Lumway, ця довіра вирішується за кілька секунд усередині вузької вертикальної смуги (упаковка, підписка чи ні, ціна, економія, додати в кошик), і будь-яка мить, коли ціна виглядає неправильно, це мить, коли покупець вагається.
Тож завдання було не «додати підписки». Воно було таким: продавати упаковки на 1, 2 і 3 банки і як разові, і як підписку, з ціною, якій покупець може довіряти, на сторінці, що має бути піксельно точною.
Чому я не використав віджет Recharge
Очевидний шлях це віджет підписки Recharge. Він створений саме для цього, швидко впроваджується, і для багатьох магазинів це правильний вибір. Для Lumway ні, з трьох причин, які зводяться до тієї самої ставки.
Він рендериться всередині 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 використовувала модель «подарункових банок»: відмінністю між упаковками були безкоштовні банки і бонус безкоштовної доставки, а зіставлення планів було бінарним порогом (кожна упаковка щомісячна або раз на два місяці, з відсіканням за налаштованою межею). Вона не обчислювала заздалегідь ціни підписки по кожній упаковці, а її цінові події не несли типу покупки, тож картки не могли перефарбувати свою економію для підписки проти разової покупки. v2 (у продакшні) це справжнє N-місячне зіставлення, описане вище, зі зниженням, попередньо обчисленим __lumSubByVariant і ціновими подіями, що несуть type, тож картки перефарбовуються правильно. Подарункові банки прибрали, ціни по упаковках перерахували, додали тримісячний план і зіставили з ним упаковки на три банки, а ту саму логіку поширили на інші шаблони товарів.
Варте виділення тут не зміна коду; це те, як я її випустив. Перед переробкою я зробив знімок v1 у замороженому файлі (purchase-type-lum-v1.liquid) і в окремій git-гілці: явний шлях відкату, тож якби нова модель показала себе гірше, повернення до старої було б revert-ом, а не реконструкцією. Ставитися до зміни ціноутворення й UX як до оборотного експерименту, а не до дверей в один бік, це різниця між тим, щоб змінювати живу дохідну поверхню впевнено, і тим, щоб робити це схрестивши пальці.
Компроміси, на які я пішов
Жодне рішення не безкоштовне. Три ціни варто назвати прямо.
Парсинг тексту регулярним виразом крихкий до нечислових назв планів. Назви план "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, точка входу шару відображення, що споживає синтетичний ціновий контекст.
Потрібен інженер Shopify, який працює на всьому комерційному шляху?
Я працюю там, де сходяться UX вітрини, логіка товару, обмеження платформи, вимірювання та релізи в продакшн.
Почати розмову