Skip to content

Instantly share code, notes, and snippets.

@jonwolfe
Created March 27, 2026 17:38
Show Gist options
  • Select an option

  • Save jonwolfe/3ccb9af65036649011aa086a3711d188 to your computer and use it in GitHub Desktop.

Select an option

Save jonwolfe/3ccb9af65036649011aa086a3711d188 to your computer and use it in GitHub Desktop.
churnkey CSS & JS hacks
// 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
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