@payhook/extension-button 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +309 -0
  2. package/package.json +37 -0
  3. package/styles.css +118 -0
package/index.js ADDED
@@ -0,0 +1,309 @@
1
+ import { isValidEmail } from '@payhook/core'
2
+
3
+ const DEFAULT_LABELS = {
4
+ upgrade: 'Upgrade',
5
+ manage: 'Manage plan',
6
+ loading: 'Loading…',
7
+ processing: 'Processing…',
8
+ emailPlaceholder: 'your@email.com',
9
+ emailRequired: 'Enter a valid email to continue',
10
+ checkoutError: 'Checkout failed. Please try again.',
11
+ portalError: 'Could not open billing portal. Please try again.',
12
+ emailSent: 'Check your email inbox to unlock'
13
+ }
14
+
15
+ export class ExtensionButton {
16
+ constructor (client, options = {}) {
17
+ if (!client) throw new Error('PayhookClient is required')
18
+
19
+ this.client = client
20
+ this.plans = Array.isArray(options.plans) ? options.plans : []
21
+ this.labels = { ...DEFAULT_LABELS, ...(options.labels || {}) }
22
+ this.ui = {
23
+ showPlanPicker: options.ui?.showPlanPicker !== false && this.plans.length > 1,
24
+ showEmailInput: options.ui?.showEmailInput !== false,
25
+ manageForLifetime: options.ui?.manageForLifetime === true,
26
+ className: options.ui?.className || 'payhook-btn'
27
+ }
28
+ this.checkout = {
29
+ cancelUrl: options.checkout?.cancelUrl,
30
+ successUrl: options.checkout?.successUrl,
31
+ popupOptions: options.checkout?.popupOptions
32
+ }
33
+ this.billing = {
34
+ returnUrl: options.billing?.returnUrl,
35
+ popupOptions: options.billing?.popupOptions
36
+ }
37
+ this.onStateChange = typeof options.onStateChange === 'function'
38
+ ? options.onStateChange
39
+ : () => {}
40
+
41
+ this._container = null
42
+ this._state = 'loading'
43
+ this._selectedPlan = this._getDefaultPlan()
44
+ this._email = options.email || ''
45
+ this._message = null
46
+ this._messageType = null
47
+ this._unsubscribe = client.onEntitlementChange(() => this._syncState())
48
+ }
49
+
50
+ _getDefaultPlan () {
51
+ if (this.plans.length === 0) return null
52
+ return this.plans.find((plan) => plan.recommended) || this.plans[0]
53
+ }
54
+
55
+ getState () {
56
+ return {
57
+ state: this._state,
58
+ entitlement: this.client.getEntitlement(),
59
+ selectedPlan: this._selectedPlan,
60
+ message: this._message
61
+ }
62
+ }
63
+
64
+ _setState (state, { message, messageType } = {}) {
65
+ this._state = state
66
+ this._message = message ?? null
67
+ this._messageType = messageType ?? null
68
+ this.onStateChange(this.getState())
69
+ this._render()
70
+ }
71
+
72
+ _syncState () {
73
+ const entitlement = this.client.getEntitlement()
74
+
75
+ if (this._state === 'checkout-loading' || this._state === 'portal-loading') {
76
+ return
77
+ }
78
+
79
+ if (entitlement.active) {
80
+ if (entitlement.mode === 'subscription' || this.ui.manageForLifetime) {
81
+ this._setState('manage')
82
+ } else {
83
+ this._setState('pro')
84
+ }
85
+ return
86
+ }
87
+
88
+ this._setState('upgrade')
89
+ }
90
+
91
+ mount (container) {
92
+ this._container = typeof container === 'string'
93
+ ? document.querySelector(container)
94
+ : container
95
+
96
+ if (!this._container) {
97
+ throw new Error('Container element not found')
98
+ }
99
+
100
+ this._container.classList.add(this.ui.className)
101
+ this._syncState()
102
+ return this
103
+ }
104
+
105
+ destroy () {
106
+ if (this._unsubscribe) this._unsubscribe()
107
+ if (this._container) this._container.innerHTML = ''
108
+ }
109
+
110
+ async _resolveEmail () {
111
+ const email = this._email || await this.client.resolveEmail()
112
+ return email
113
+ }
114
+
115
+ async _handleUpgradeClick () {
116
+ const plan = this._selectedPlan || this.plans[0]
117
+ if (!plan?.productId || !plan?.priceId) {
118
+ this._setState('upgrade', {
119
+ message: 'Plan configuration is missing productId or priceId',
120
+ messageType: 'error'
121
+ })
122
+ return
123
+ }
124
+
125
+ const email = await this._resolveEmail()
126
+ if (!isValidEmail(email)) {
127
+ this._setState('upgrade', {
128
+ message: this.labels.emailRequired,
129
+ messageType: 'error'
130
+ })
131
+ return
132
+ }
133
+
134
+ this._setState('checkout-loading')
135
+
136
+ try {
137
+ const result = await this.client.openCheckout({
138
+ productId: plan.productId,
139
+ priceId: plan.priceId,
140
+ priceIdByCountry: plan.priceIdByCountry,
141
+ promoId: plan.promoId,
142
+ email,
143
+ cancelUrl: this.checkout.cancelUrl,
144
+ successUrl: this.checkout.successUrl,
145
+ popupOptions: this.checkout.popupOptions
146
+ })
147
+
148
+ if (result.challengeType === 'email_link') {
149
+ this._setState('upgrade', {
150
+ message: this.labels.emailSent,
151
+ messageType: 'success'
152
+ })
153
+ return
154
+ }
155
+
156
+ this._syncState()
157
+ } catch (error) {
158
+ this._setState('upgrade', {
159
+ message: error.message || this.labels.checkoutError,
160
+ messageType: 'error'
161
+ })
162
+ }
163
+ }
164
+
165
+ async _handleManageClick () {
166
+ if (!this.billing.returnUrl) {
167
+ this._setState('manage', {
168
+ message: 'billing.returnUrl is required',
169
+ messageType: 'error'
170
+ })
171
+ return
172
+ }
173
+
174
+ this._setState('portal-loading')
175
+
176
+ try {
177
+ await this.client.openBillingPortal({
178
+ returnUrl: this.billing.returnUrl,
179
+ popupOptions: this.billing.popupOptions
180
+ })
181
+ this._syncState()
182
+ } catch (error) {
183
+ this._setState('manage', {
184
+ message: error.message || this.labels.portalError,
185
+ messageType: 'error'
186
+ })
187
+ }
188
+ }
189
+
190
+ _renderPlans () {
191
+ if (!this.ui.showPlanPicker || this.plans.length <= 1) return ''
192
+
193
+ return `
194
+ <div class="payhook-btn__plans" role="list">
195
+ ${this.plans.map((plan) => `
196
+ <button
197
+ type="button"
198
+ class="payhook-btn__plan ${this._selectedPlan?.key === plan.key ? 'payhook-btn__plan--selected' : ''}"
199
+ data-plan-key="${plan.key}"
200
+ >
201
+ <span class="payhook-btn__plan-label">${plan.label || plan.key}</span>
202
+ ${plan.priceLabel ? `<span class="payhook-btn__plan-price">${plan.priceLabel}</span>` : ''}
203
+ ${plan.perYearHint ? `<span class="payhook-btn__plan-hint">${plan.perYearHint}</span>` : ''}
204
+ </button>
205
+ `).join('')}
206
+ </div>
207
+ `
208
+ }
209
+
210
+ _renderEmailRow () {
211
+ if (!this.ui.showEmailInput) return ''
212
+
213
+ return `
214
+ <input
215
+ class="payhook-btn__email"
216
+ type="email"
217
+ value="${this._email || ''}"
218
+ placeholder="${this.labels.emailPlaceholder}"
219
+ />
220
+ `
221
+ }
222
+
223
+ _renderMessage () {
224
+ if (!this._message) return ''
225
+
226
+ return `
227
+ <p class="payhook-btn__message payhook-btn__message--${this._messageType || 'info'}">
228
+ ${this._message}
229
+ </p>
230
+ `
231
+ }
232
+
233
+ _render () {
234
+ if (!this._container) return
235
+
236
+ let body = ''
237
+
238
+ if (this._state === 'loading' || this._state === 'checkout-loading' || this._state === 'portal-loading') {
239
+ body = `
240
+ <button type="button" class="payhook-btn__button" disabled>
241
+ ${this.labels.loading}
242
+ </button>
243
+ `
244
+ } else if (this._state === 'manage') {
245
+ body = `
246
+ <button type="button" class="payhook-btn__button payhook-btn__button--manage">
247
+ ${this.labels.manage}
248
+ </button>
249
+ `
250
+ } else if (this._state === 'pro') {
251
+ body = `
252
+ <button type="button" class="payhook-btn__button payhook-btn__button--pro" disabled>
253
+ Pro
254
+ </button>
255
+ `
256
+ } else {
257
+ body = `
258
+ ${this._renderPlans()}
259
+ <div class="payhook-btn__email-row">
260
+ ${this._renderEmailRow()}
261
+ <button type="button" class="payhook-btn__button payhook-btn__button--upgrade">
262
+ ${this.labels.upgrade}
263
+ </button>
264
+ </div>
265
+ `
266
+ }
267
+
268
+ this._container.innerHTML = `
269
+ <div class="payhook-btn__root">
270
+ ${body}
271
+ ${this._renderMessage()}
272
+ </div>
273
+ `
274
+
275
+ this._container.querySelectorAll('[data-plan-key]').forEach((button) => {
276
+ button.addEventListener('click', () => {
277
+ const plan = this.plans.find((item) => item.key === button.dataset.planKey)
278
+ if (plan) {
279
+ this._selectedPlan = plan
280
+ this._render()
281
+ }
282
+ })
283
+ })
284
+
285
+ const emailInput = this._container.querySelector('.payhook-btn__email')
286
+ if (emailInput) {
287
+ emailInput.addEventListener('input', (event) => {
288
+ this._email = event.target.value
289
+ })
290
+ }
291
+
292
+ const upgradeButton = this._container.querySelector('.payhook-btn__button--upgrade')
293
+ if (upgradeButton) {
294
+ upgradeButton.addEventListener('click', () => this._handleUpgradeClick())
295
+ }
296
+
297
+ const manageButton = this._container.querySelector('.payhook-btn__button--manage')
298
+ if (manageButton) {
299
+ manageButton.addEventListener('click', () => this._handleManageClick())
300
+ }
301
+ }
302
+ }
303
+
304
+ export default ExtensionButton
305
+
306
+ if (typeof self !== 'undefined') {
307
+ self.Payhook = self.Payhook || {}
308
+ self.Payhook.ExtensionButton = ExtensionButton
309
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@payhook/extension-button",
3
+ "version": "0.1.0",
4
+ "description": "Plug-and-play Payhook upgrade button for browser extensions",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./styles.css": "./styles.css"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "styles.css"
14
+ ],
15
+ "scripts": {
16
+ "build": "node ../../scripts/bundle-package.js extension-button",
17
+ "prepublishOnly": "npm run build",
18
+ "test": "node --test index.test.js"
19
+ },
20
+ "dependencies": {
21
+ "@payhook/core": "0.1.0",
22
+ "@payhook/extension": "0.1.0"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/payhook/js.git",
27
+ "directory": "packages/extension-button"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/payhook/js/issues"
31
+ },
32
+ "homepage": "https://payhook.link",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "license": "ISC"
37
+ }
package/styles.css ADDED
@@ -0,0 +1,118 @@
1
+ .payhook-btn {
2
+ --payhook-btn-bg: #3a5678;
3
+ --payhook-btn-bg-hover: #2c4563;
4
+ --payhook-btn-color: #ffffff;
5
+ --payhook-btn-radius: 999px;
6
+ --payhook-btn-font-size: 0.875rem;
7
+ --payhook-btn-border: 1px solid rgba(44, 69, 99, 0.2);
8
+ --payhook-plan-bg: #ffffff;
9
+ --payhook-plan-border: rgba(44, 69, 99, 0.16);
10
+ --payhook-plan-selected-border: #3a5678;
11
+ --payhook-text-color: #2c4563;
12
+ font-family: inherit;
13
+ color: var(--payhook-text-color);
14
+ }
15
+
16
+ .payhook-btn__root {
17
+ display: flex;
18
+ flex-direction: column;
19
+ gap: 0.75rem;
20
+ }
21
+
22
+ .payhook-btn__plans {
23
+ display: grid;
24
+ gap: 0.5rem;
25
+ }
26
+
27
+ .payhook-btn__plan {
28
+ display: flex;
29
+ flex-direction: column;
30
+ align-items: flex-start;
31
+ gap: 0.15rem;
32
+ padding: 0.65rem 0.75rem;
33
+ border: 1px solid var(--payhook-plan-border);
34
+ border-radius: 0.75rem;
35
+ background: var(--payhook-plan-bg);
36
+ cursor: pointer;
37
+ text-align: left;
38
+ }
39
+
40
+ .payhook-btn__plan--selected {
41
+ border-color: var(--payhook-plan-selected-border);
42
+ box-shadow: 0 0 0 1px var(--payhook-plan-selected-border);
43
+ }
44
+
45
+ .payhook-btn__plan-label {
46
+ font-weight: 600;
47
+ font-size: 0.875rem;
48
+ }
49
+
50
+ .payhook-btn__plan-price {
51
+ font-size: 0.8125rem;
52
+ }
53
+
54
+ .payhook-btn__plan-hint {
55
+ font-size: 0.75rem;
56
+ opacity: 0.75;
57
+ }
58
+
59
+ .payhook-btn__email-row {
60
+ display: flex;
61
+ gap: 0.5rem;
62
+ align-items: stretch;
63
+ }
64
+
65
+ .payhook-btn__email {
66
+ flex: 1;
67
+ min-width: 0;
68
+ margin: 0;
69
+ padding: 0.55rem 0.75rem;
70
+ font: inherit;
71
+ font-size: var(--payhook-btn-font-size);
72
+ color: var(--payhook-text-color);
73
+ background: #ffffff;
74
+ border: var(--payhook-btn-border);
75
+ border-radius: var(--payhook-btn-radius);
76
+ }
77
+
78
+ .payhook-btn__email:focus {
79
+ outline: none;
80
+ border-color: var(--payhook-btn-bg);
81
+ box-shadow: 0 0 0 2px rgba(58, 86, 120, 0.2);
82
+ }
83
+
84
+ .payhook-btn__button {
85
+ display: inline-flex;
86
+ align-items: center;
87
+ justify-content: center;
88
+ padding: 0.55rem 1.1rem;
89
+ border: none;
90
+ border-radius: var(--payhook-btn-radius);
91
+ font: inherit;
92
+ font-size: var(--payhook-btn-font-size);
93
+ font-weight: 600;
94
+ cursor: pointer;
95
+ background: var(--payhook-btn-bg);
96
+ color: var(--payhook-btn-color);
97
+ }
98
+
99
+ .payhook-btn__button:hover:not(:disabled) {
100
+ background: var(--payhook-btn-bg-hover);
101
+ }
102
+
103
+ .payhook-btn__button:disabled {
104
+ opacity: 0.7;
105
+ cursor: not-allowed;
106
+ }
107
+
108
+ .payhook-btn__message {
109
+ font-size: 0.8125rem;
110
+ }
111
+
112
+ .payhook-btn__message--error {
113
+ color: #b42318;
114
+ }
115
+
116
+ .payhook-btn__message--success {
117
+ color: #027a48;
118
+ }