Created
March 27, 2026 17:38
-
-
Save jonwolfe/3ccb9af65036649011aa086a3711d188 to your computer and use it in GitHub Desktop.
churnkey CSS & JS hacks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // fix for churnkey appearing beneath app left nav | |
| ck-widget | |
| position: relative | |
| z-index: 200 | |
| .ck-background-overlay | |
| backdrop-filter: blur(4px) | |
| // Churnkey's overflow-hidden class is stripped by churnkey_controller.js | |
| // (their @layer utilities !important beats any CSS-only override). | |
| // Once that class is removed, these rules provide scrollability: | |
| .ck-modal | |
| max-height: 90vh | |
| overflow-y: auto |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Controller } from '@hotwired/stimulus' | |
| import { patch } from '@rails/request.js' | |
| export default class ChurnkeyController extends Controller { | |
| static values = { | |
| apiKey: String, | |
| stripeId: String, | |
| token: String, | |
| planChangeUrl: { type: String, default: '/app/subscription/plan_change' }, | |
| canceledUrl: { type: String, default: '/app/subscription/canceled' }, | |
| discountAppliedUrl: { type: String, default: '/app/subscription/discount_applied' }, | |
| planChangedUrl: { type: String, default: '/app/subscription/plan_changed' } | |
| } | |
| connect() { | |
| this.loadChurnkey() | |
| this.setupTurboListeners() | |
| } | |
| disconnect() { | |
| this._overflowObserver?.disconnect() | |
| } | |
| markWidgetPermanent() { | |
| const widget = document.querySelector('ck-widget') | |
| if (widget && !widget.hasAttribute('data-turbo-permanent')) { | |
| widget.id = 'churnkey-widget' | |
| widget.setAttribute('data-turbo-permanent', '') | |
| } | |
| } | |
| setupTurboListeners() { | |
| if (this.turboListenersSetup) { return } | |
| this.turboListenersSetup = true | |
| document.addEventListener('DOMContentLoaded', () => { | |
| this.markWidgetPermanent() | |
| }, { once: true }) | |
| document.addEventListener('turbo:before-cache', () => { | |
| if (window.churnkey) { | |
| window.churnkey.hide?.() | |
| window.churnkey.clearState?.() | |
| window.churnkey.initialized = false | |
| } | |
| }, { once: true }) | |
| } | |
| loadChurnkey() { | |
| if (window.churnkey && window.churnkey.init) { | |
| return Promise.resolve() | |
| } | |
| if (this.churnkeyLoadPromise) { | |
| return this.churnkeyLoadPromise | |
| } | |
| this.churnkeyLoadPromise = new Promise((resolve, reject) => { | |
| const script = document.createElement('script') | |
| script.src = 'https://assets.churnkey.co/js/app.js?appId=p1awc7z72' | |
| script.async = true | |
| script.onload = () => { | |
| this.markWidgetPermanent() | |
| resolve() | |
| } | |
| script.onerror = () => reject(new Error('Failed to load Churnkey')) | |
| const firstScript = document.getElementsByTagName('script')[0] | |
| firstScript.parentNode.insertBefore(script, firstScript) | |
| }) | |
| return this.churnkeyLoadPromise | |
| } | |
| async launch(event) { | |
| event.preventDefault() | |
| event.stopPropagation() | |
| if (!window.churnkey) { | |
| try { | |
| await this.loadChurnkey() | |
| } catch (e) { | |
| console.error('Failed to load Churnkey:', e) | |
| return | |
| } | |
| } | |
| if (!window.churnkey || !window.churnkey.init) { | |
| const error = new Error('Churnkey not available after load attempt') | |
| console.error(error.message) | |
| return | |
| } | |
| const config = { | |
| customerId: this.stripeIdValue, | |
| authHash: this.apiKeyValue, | |
| appId: 'p1awc7z72', | |
| mode: process.env.RAILS_ENV === 'production' ? 'live' : 'test', | |
| provider: 'stripe', | |
| handlePlanChange: async (customer, { plan }) => { | |
| try { | |
| await this.confirmPlanChange(customer, plan) | |
| } catch (e) { | |
| Sentry.captureException(e) | |
| throw { message: 'There was an issue changing your subscription. Please reach out to us at support@vocalvideo.com' } | |
| } | |
| }, | |
| handleSupportRequest: () => { | |
| if (Beacon && Beacon instanceof Function) { | |
| Beacon('open') | |
| } | |
| }, | |
| onCancel: () => { | |
| Turbo.visit(this.canceledUrlValue) | |
| }, | |
| onDiscount: () => { | |
| Turbo.visit(this.discountAppliedUrlValue) | |
| }, | |
| onPlanChange: () => { | |
| Turbo.visit(this.planChangedUrlValue) | |
| } | |
| } | |
| window.churnkey.init('show', config) | |
| this._fixModalOverflow() | |
| } | |
| _fixModalOverflow() { | |
| // Churnkey's CSS uses @layer utilities { .ck-style .overflow-hidden { overflow: hidden !important } } | |
| // Layered !important beats unlayered !important, so no CSS hack can override it. | |
| // Instead, strip the overflow-hidden class from .ck-modal whenever Churnkey adds it. | |
| this._overflowObserver?.disconnect() | |
| this._overflowObserver = new MutationObserver(() => { | |
| document.querySelectorAll('.ck-modal.overflow-hidden').forEach(el => { | |
| el.classList.remove('overflow-hidden') | |
| }) | |
| }) | |
| const widget = document.querySelector('ck-widget') | |
| if (widget) { | |
| this._overflowObserver.observe(widget, { subtree: true, attributes: true, attributeFilter: ['class'], childList: true }) | |
| // Fix any already-rendered modal | |
| document.querySelectorAll('.ck-modal.overflow-hidden').forEach(el => { | |
| el.classList.remove('overflow-hidden') | |
| }) | |
| } | |
| } | |
| async confirmPlanChange(customer, plan) { | |
| const formData = new FormData() | |
| formData.append('customer_id', customer.id) | |
| formData.append('plan_id', plan.id) | |
| formData.append('token', this.tokenValue) | |
| const response = await patch(this.planChangeUrlValue, { | |
| body: formData | |
| }) | |
| if (!response.ok) { | |
| throw new Error(`Request failed with status ${response.statusCode}`) | |
| } | |
| return response | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment