-
Notifications
You must be signed in to change notification settings - Fork 12
feat(paywall): add base component, API interface, script #702
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
974e5df
feat(paywall): add base component, API interface, script
sidvishnoi fcbe9b5
Merge branch 'main' into paywall-script-component-base
sidvishnoi 7f18848
cdn: reuse API utils
sidvishnoi 4199bd0
Merge branch 'main' into paywall-script-component-base
sidvishnoi 4008593
Merge branch 'main' into paywall-script-component-base
sidvishnoi fcfa4ee
cleanup, lint fix
sidvishnoi bd21113
remove `setBaseConfig`; move receiver address to controller, add cust…
sidvishnoi efdaf5e
Merge branch 'main' into paywall-script-component-base
sidvishnoi e84c0d0
nit
sidvishnoi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,10 @@ | ||
| import type { PaymentWidget } from '@tools/components' | ||
| import type { PaymentWidget, Paywall } from '@tools/components' | ||
| import type { Banner } from '@tools/components/banner' | ||
|
|
||
| declare global { | ||
| interface HTMLElementTagNameMap { | ||
| 'wm-banner': Banner | ||
| 'wm-payment-widget': PaymentWidget | ||
| 'wm-paywall': Paywall | ||
| } | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import type { PaymentStatus } from 'publisher-tools-api' | ||
| import { API_URL } from '@shared/defines' | ||
| import { sleep } from '@shared/utils' | ||
| import { Paywall } from '@tools/components' | ||
| import { | ||
| fetchProfile, | ||
| fetchQuote, | ||
| getScriptParams, | ||
| getWallet, | ||
| initiatePayment, | ||
| } from './utils' | ||
|
|
||
| const NAME = 'wm-paywall' | ||
| customElements.define(NAME, Paywall) | ||
|
|
||
| const params = getScriptParams('paywall') | ||
|
|
||
| drawPaywall() | ||
| function drawPaywall() { | ||
| const price = params.otherAttributes.price?.trim() | ||
| if (!price) throw new Error('Missing data-price attribute on script') | ||
| if (!/^\d+(\.\d+)?$/.test(price)) { | ||
| throw new Error(`Invalid data-price="${price}"`) | ||
| } | ||
|
|
||
| const element = document.createElement('wm-paywall') | ||
| element.setPrice(price) | ||
| element.setController({ | ||
| receiverWalletAddressUrl: params.walletAddress, | ||
| fetchConfig: () => fetchProfile(API_URL, 'paywall', params), | ||
| async checkEntitlement() { | ||
| return 'no-access' // TODO: create and call API | ||
| }, | ||
| async saveEntitlement() { | ||
| // TODO: create and call API | ||
| }, | ||
| getWallet: (walletAddressUrl) => getWallet(API_URL, walletAddressUrl), | ||
| fetchQuote({ sender, receiver, amount }) { | ||
| const receiveAmount = Number(amount) | ||
| return fetchQuote(API_URL, { sender, receiver, receiveAmount }) | ||
| }, | ||
| async initiatePayment({ sender, receiver, amount, note }) { | ||
| const receiveAmount = Number(amount) | ||
| const redirectUrl = window.location.href | ||
| return initiatePayment(API_URL, { | ||
| sender, | ||
| receiver, | ||
| receiveAmount, | ||
| note, | ||
| redirectUrl, | ||
| }) | ||
| }, | ||
| async *getStatus(paymentId, signal) { | ||
| const url = new URL(`/payment/status/${paymentId}`, API_URL).href | ||
| while (true) { | ||
| try { | ||
| signal?.throwIfAborted() | ||
| const res = await fetch(url, { signal }) | ||
| if (!res.ok) { | ||
| throw new Error('Failed to check payment status: ' + res.statusText) | ||
| } | ||
|
|
||
| const status: PaymentStatus = await res.json() | ||
| yield status | ||
|
|
||
| if (status.type === 'PENDING_GRANT_INTERACTION') { | ||
| await sleep(3000) | ||
| } else if (status.type === 'OUTGOING_PAYMENT_CREATED') { | ||
| await sleep(1500) | ||
| } else if ( | ||
| status.type === 'OUTGOING_PAYMENT_DONE' || | ||
| status.type === 'GRANT_REJECTED' | ||
| ) { | ||
| break | ||
| } else { | ||
| throw new Error('Unknown payment status: ' + JSON.stringify(status)) | ||
| } | ||
| } catch (err) { | ||
| if (err instanceof Error && err.name === 'AbortError') { | ||
| break | ||
| } else if (isAbortSignalTimeout(err) || isTimeoutError(err)) { | ||
| throw new Error('Payment authorization timed out') | ||
| } else { | ||
| throw new Error('Failed to check payment status', { cause: err }) | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| }) | ||
|
|
||
| document.body.appendChild(element) | ||
| } | ||
|
|
||
| function isAbortSignalTimeout(ev: unknown) { | ||
| return ( | ||
| ev instanceof Event && | ||
| ev.target instanceof AbortSignal && | ||
| isTimeoutError(ev.target.reason) | ||
| ) | ||
| } | ||
|
|
||
| function isTimeoutError(err: unknown) { | ||
| return err instanceof DOMException && err.name === 'TimeoutError' | ||
| } | ||
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| export * from './widget' | ||
| export * from './banner.js' | ||
| export * from './paywall/index.js' | ||
| export * from './offerwall/index.js' |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import type { PaymentStatus, WalletAddressInfo } from 'publisher-tools-api' | ||
| import type { PendingGrant } from '@interledger/open-payments' | ||
| import { createDefaultPaywallProfile } from '@shared/default-data' | ||
| import type { PaywallProfile } from '@shared/types' | ||
|
|
||
| type WalletAddressUrl = string | ||
| /** The amount sender wants to send (like "1.05"), does not include fees */ | ||
| type UserAmount = number | PaymentCurrencyAmount['value'] | ||
|
|
||
| interface QuoteInput { | ||
| sender: WalletAddressInfo | ||
| receiver: WalletAddressInfo | ||
| amount: UserAmount | ||
| } | ||
| type QuoteResult = | ||
| | { debitAmount: PaymentCurrencyAmount; receiveAmount: PaymentCurrencyAmount } | ||
| | { error: string; minSendAmount?: PaymentCurrencyAmount } | ||
|
|
||
| interface InitiatePaymentInput { | ||
| sender: WalletAddressInfo | ||
| receiver: WalletAddressInfo | ||
| amount: UserAmount | ||
| note: string | ||
| } | ||
| interface InitiatePaymentResult { | ||
| paymentId: string | ||
| grantRedirectUrl: PendingGrant['interact']['redirect'] | ||
| } | ||
|
|
||
| type Entitlement = 'no-access' | 'auth-required' | 'has-access' | ||
|
|
||
| export interface Controller { | ||
| receiverWalletAddressUrl: string | ||
|
|
||
| fetchConfig(): Promise<PaywallProfile> | ||
|
|
||
| /** Check if given wallet address is entitled to access */ | ||
| checkEntitlement(walletAddressUrl: WalletAddressUrl): Promise<Entitlement> | ||
| /** Store the entitlement after a successful payment in some backend */ | ||
| saveEntitlement( | ||
| walletAddressUrl: WalletAddressUrl, | ||
| details: { | ||
| outgoingPaymentId: string | ||
| incomingPaymentId: string | ||
| paymentId: string | ||
| }, | ||
| ): Promise<void> | ||
|
|
||
| getWallet(walletAddressUrl: WalletAddressUrl): Promise<WalletAddressInfo> | ||
| fetchQuote(request: QuoteInput): Promise<QuoteResult> | ||
| initiatePayment(request: InitiatePaymentInput): Promise<InitiatePaymentResult> | ||
| getStatus( | ||
| paymentId: string, | ||
| signal?: AbortSignal, | ||
| ): AsyncGenerator<PaymentStatus> | ||
|
|
||
| isPreviewMode?: boolean | ||
| } | ||
|
|
||
| export const NO_OP_CONTROLLER: Controller = { | ||
| receiverWalletAddressUrl: 'https://example.com/pay', | ||
| fetchConfig: () => Promise.resolve(createDefaultPaywallProfile('')), | ||
| checkEntitlement: () => Promise.resolve('no-access'), | ||
| saveEntitlement: () => Promise.resolve(), | ||
| getWallet(walletAddressUrl) { | ||
| return Promise.resolve({ | ||
| $url: walletAddressUrl, | ||
| id: walletAddressUrl, | ||
| assetCode: 'USD', | ||
| assetScale: 2, | ||
| authServer: 'https://auth.example.com', | ||
| resourceServer: 'https://resource.example.com', | ||
| publicName: 'Wallet (Preview)', | ||
| }) | ||
| }, | ||
| fetchQuote({ amount, sender, receiver }) { | ||
| amount = String(amount) | ||
| const debitAmount = { value: amount, currency: sender.assetCode } | ||
| const receiveAmount = { value: amount, currency: receiver.assetCode } | ||
| return Promise.resolve({ debitAmount, receiveAmount }) | ||
| }, | ||
| initiatePayment() { | ||
| return Promise.resolve({ | ||
| paymentId: 'payment-id', | ||
| grantRedirectUrl: 'https://example.com/redirect', | ||
| }) | ||
| }, | ||
| async *getStatus() { | ||
| yield { | ||
| type: 'OUTGOING_PAYMENT_DONE', | ||
| outgoingPaymentId: '', | ||
| result: 'success', | ||
| } | ||
| }, | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| /* eslint-disable no-unused-private-class-members */ | ||
| import { html, LitElement, nothing, unsafeCSS } from 'lit' | ||
| import { state } from 'lit/decorators.js' | ||
| import type { WalletAddressInfo } from 'publisher-tools-api' | ||
| import { type Controller, NO_OP_CONTROLLER } from '@c/paywall/controller' | ||
| import type { PaywallProfile } from '@shared/types' | ||
| import styles from './styles.css?raw' | ||
| import styleTokens from './vars.css?raw' | ||
|
|
||
| export class Paywall extends LitElement { | ||
| #config_!: Promise<PaywallProfile> | ||
| #config!: PaywallProfile | ||
| #receiver!: Promise<WalletAddressInfo> | ||
|
|
||
| static styles = [unsafeCSS(styleTokens), unsafeCSS(styles)] | ||
|
|
||
| @state() _configReady = false | ||
|
|
||
| connectedCallback(): void { | ||
| super.connectedCallback() | ||
|
|
||
| if (this.#controller === NO_OP_CONTROLLER) { | ||
| throw new Error('setController() before mount') | ||
| } | ||
| if (!this.#price) { | ||
| throw new Error('Price is not set') | ||
| } | ||
|
|
||
| this.#config_ = this.#controller.fetchConfig().then((conf) => { | ||
| this.#config = conf | ||
| this._configReady = true | ||
| return Promise.resolve(conf) | ||
| }) | ||
| this.#receiver = this.#controller.getWallet( | ||
| this.#controller.receiverWalletAddressUrl, | ||
| ) | ||
| } | ||
|
|
||
| #controller = NO_OP_CONTROLLER | ||
| setController(controller: Controller) { | ||
| if (this.#controller === controller) return | ||
| if (this.#controller !== NO_OP_CONTROLLER) { | ||
| throw new Error('controller is already set') | ||
| } | ||
| this.#controller = controller | ||
| } | ||
|
|
||
| #price = '' | ||
| setPrice(price: string) { | ||
| if (this.#price === '' || this.#controller.isPreviewMode) { | ||
| this.#price = price | ||
| } else { | ||
| throw new Error('Price is already set') | ||
| } | ||
| } | ||
|
|
||
| render() { | ||
| if (!this._configReady) return nothing | ||
|
|
||
| return html`<pre>${JSON.stringify(this.#config)}</pre>` | ||
| } | ||
| } |
Empty file.
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| :host { | ||
| --font-family: var(--wm-font-family, system-ui, sans-serif); | ||
| --background: var(--wm-background, #ffffff); | ||
| --heading-color: var(--wm-heading-color, #363636); | ||
| --text-color: var(--wm-text-color, #6b6e76); | ||
| --accent-color: var(--wm-accent-color, #56b7b5); | ||
| --border-radius: var(--wm-border-radius, 1rem); | ||
|
|
||
| --Paddings-lg: 1.5rem; | ||
| --Paddings-sm: 0.75rem; | ||
| --Spacings-lg: 1.5rem; | ||
| --Spacings-xs: 0.5rem; | ||
| --Radius-Moderate-rounding: 0.5rem; | ||
| } | ||
|
|
||
| .sr-only { | ||
| position: absolute; | ||
| width: 1px; | ||
| height: 1px; | ||
| padding: 0; | ||
| margin: -1px; | ||
| overflow: hidden; | ||
| clip: rect(0, 0, 0, 0); | ||
| white-space: nowrap; | ||
| border-width: 0; | ||
| } |
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
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
duplicate of cdn
widget.ts.getStatusis identical in both files. sinceinitiatePayment,fetchQuote, andgetWalletare already extracted to a shared module, this should follow the same pattern maybe ?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was thinking if we'll need this in same manner as widget. If we need it as same as widget, will extract then. So leaving as is until we actually implement it.