Skip to content

Commit 46f38f5

Browse files
authored
feat(paywall): add base component, API interface, script (#702)
1 parent 2dfb002 commit 46f38f5

14 files changed

Lines changed: 339 additions & 4 deletions

File tree

api/src/routes/get-profile.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ app.get(
3131
const { tool } = req.valid('param')
3232
const { wa: walletAddress, id: profileId } = req.valid('query')
3333

34+
if (tool === 'paywall') {
35+
return json(getDefaultProfile('paywall'))
36+
}
37+
3438
const storage = new ConfigStorageService({ ...env, AWS_PREFIX })
3539

3640
try {

cdn/@types/index.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import type { PaymentWidget } from '@tools/components'
1+
import type { PaymentWidget, Paywall } from '@tools/components'
22
import type { Banner } from '@tools/components/banner'
33

44
declare global {
55
interface HTMLElementTagNameMap {
66
'wm-banner': Banner
77
'wm-payment-widget': PaymentWidget
8+
'wm-paywall': Paywall
89
}
910
}

cdn/src/paywall.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { PaymentStatus } from 'publisher-tools-api'
2+
import { API_URL } from '@shared/defines'
3+
import { sleep } from '@shared/utils'
4+
import { Paywall } from '@tools/components'
5+
import {
6+
fetchProfile,
7+
fetchQuote,
8+
getScriptParams,
9+
getWallet,
10+
initiatePayment,
11+
} from './utils'
12+
13+
const NAME = 'wm-paywall'
14+
customElements.define(NAME, Paywall)
15+
16+
const params = getScriptParams('paywall')
17+
18+
drawPaywall()
19+
function drawPaywall() {
20+
const price = params.otherAttributes.price?.trim()
21+
if (!price) throw new Error('Missing data-price attribute on script')
22+
if (!/^\d+(\.\d+)?$/.test(price)) {
23+
throw new Error(`Invalid data-price="${price}"`)
24+
}
25+
26+
const element = document.createElement('wm-paywall')
27+
element.setPrice(price)
28+
element.setController({
29+
receiverWalletAddressUrl: params.walletAddress,
30+
fetchConfig: () => fetchProfile(API_URL, 'paywall', params),
31+
async checkEntitlement() {
32+
return 'no-access' // TODO: create and call API
33+
},
34+
async saveEntitlement() {
35+
// TODO: create and call API
36+
},
37+
getWallet: (walletAddressUrl) => getWallet(API_URL, walletAddressUrl),
38+
fetchQuote({ sender, receiver, amount }) {
39+
const receiveAmount = Number(amount)
40+
return fetchQuote(API_URL, { sender, receiver, receiveAmount })
41+
},
42+
async initiatePayment({ sender, receiver, amount, note }) {
43+
const receiveAmount = Number(amount)
44+
const redirectUrl = window.location.href
45+
return initiatePayment(API_URL, {
46+
sender,
47+
receiver,
48+
receiveAmount,
49+
note,
50+
redirectUrl,
51+
})
52+
},
53+
async *getStatus(paymentId, signal) {
54+
const url = new URL(`/payment/status/${paymentId}`, API_URL).href
55+
while (true) {
56+
try {
57+
signal?.throwIfAborted()
58+
const res = await fetch(url, { signal })
59+
if (!res.ok) {
60+
throw new Error('Failed to check payment status: ' + res.statusText)
61+
}
62+
63+
const status: PaymentStatus = await res.json()
64+
yield status
65+
66+
if (status.type === 'PENDING_GRANT_INTERACTION') {
67+
await sleep(3000)
68+
} else if (status.type === 'OUTGOING_PAYMENT_CREATED') {
69+
await sleep(1500)
70+
} else if (
71+
status.type === 'OUTGOING_PAYMENT_DONE' ||
72+
status.type === 'GRANT_REJECTED'
73+
) {
74+
break
75+
} else {
76+
throw new Error('Unknown payment status: ' + JSON.stringify(status))
77+
}
78+
} catch (err) {
79+
if (err instanceof Error && err.name === 'AbortError') {
80+
break
81+
} else if (isAbortSignalTimeout(err) || isTimeoutError(err)) {
82+
throw new Error('Payment authorization timed out')
83+
} else {
84+
throw new Error('Failed to check payment status', { cause: err })
85+
}
86+
}
87+
}
88+
},
89+
})
90+
91+
document.body.appendChild(element)
92+
}
93+
94+
function isAbortSignalTimeout(ev: unknown) {
95+
return (
96+
ev instanceof Event &&
97+
ev.target instanceof AbortSignal &&
98+
isTimeoutError(ev.target.reason)
99+
)
100+
}
101+
102+
function isTimeoutError(err: unknown) {
103+
return err instanceof DOMException && err.name === 'TimeoutError'
104+
}

cdn/src/utils/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function getScriptParams(tool: Tool) {
1818
}
1919
const cdnUrl = new URL(script.src).origin
2020

21-
const { walletAddress, walletAddressId, tag } = script.dataset
21+
const { walletAddress, walletAddressId, tag, ...rest } = script.dataset
2222

2323
if (!walletAddress) {
2424
throw new Error(`Missing data-wallet-address for ${tool}.js script`)
@@ -44,7 +44,13 @@ export function getScriptParams(tool: Tool) {
4444
throw new Error(`Missing data-tag for ${tool}.js script`)
4545
}
4646

47-
return { walletAddress, walletAddressId, profileId: tag as ProfileId, cdnUrl }
47+
return {
48+
walletAddress,
49+
walletAddressId,
50+
profileId: tag as ProfileId,
51+
cdnUrl,
52+
otherAttributes: rest,
53+
}
4854
}
4955

5056
export async function fetchProfile<T extends Tool>(

components/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
},
1818
"devDependencies": {
1919
"@interledger/open-payments": "^7.4.0",
20+
"@shared/default-data": "workspace:^",
2021
"@shared/types": "workspace:^",
2122
"@shared/utils": "workspace:^",
2223
"publisher-tools-api": "workspace:^",

components/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './widget'
22
export * from './banner.js'
3+
export * from './paywall/index.js'
34
export * from './offerwall/index.js'
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { PaymentStatus, WalletAddressInfo } from 'publisher-tools-api'
2+
import type { PendingGrant } from '@interledger/open-payments'
3+
import { createDefaultPaywallProfile } from '@shared/default-data'
4+
import type { PaywallProfile } from '@shared/types'
5+
6+
type WalletAddressUrl = string
7+
/** The amount sender wants to send (like "1.05"), does not include fees */
8+
type UserAmount = number | PaymentCurrencyAmount['value']
9+
10+
interface QuoteInput {
11+
sender: WalletAddressInfo
12+
receiver: WalletAddressInfo
13+
amount: UserAmount
14+
}
15+
type QuoteResult =
16+
| { debitAmount: PaymentCurrencyAmount; receiveAmount: PaymentCurrencyAmount }
17+
| { error: string; minSendAmount?: PaymentCurrencyAmount }
18+
19+
interface InitiatePaymentInput {
20+
sender: WalletAddressInfo
21+
receiver: WalletAddressInfo
22+
amount: UserAmount
23+
note: string
24+
}
25+
interface InitiatePaymentResult {
26+
paymentId: string
27+
grantRedirectUrl: PendingGrant['interact']['redirect']
28+
}
29+
30+
type Entitlement = 'no-access' | 'auth-required' | 'has-access'
31+
32+
export interface Controller {
33+
receiverWalletAddressUrl: string
34+
35+
fetchConfig(): Promise<PaywallProfile>
36+
37+
/** Check if given wallet address is entitled to access */
38+
checkEntitlement(walletAddressUrl: WalletAddressUrl): Promise<Entitlement>
39+
/** Store the entitlement after a successful payment in some backend */
40+
saveEntitlement(
41+
walletAddressUrl: WalletAddressUrl,
42+
details: {
43+
outgoingPaymentId: string
44+
incomingPaymentId: string
45+
paymentId: string
46+
},
47+
): Promise<void>
48+
49+
getWallet(walletAddressUrl: WalletAddressUrl): Promise<WalletAddressInfo>
50+
fetchQuote(request: QuoteInput): Promise<QuoteResult>
51+
initiatePayment(request: InitiatePaymentInput): Promise<InitiatePaymentResult>
52+
getStatus(
53+
paymentId: string,
54+
signal?: AbortSignal,
55+
): AsyncGenerator<PaymentStatus>
56+
57+
isPreviewMode?: boolean
58+
}
59+
60+
export const NO_OP_CONTROLLER: Controller = {
61+
receiverWalletAddressUrl: 'https://example.com/pay',
62+
fetchConfig: () => Promise.resolve(createDefaultPaywallProfile('')),
63+
checkEntitlement: () => Promise.resolve('no-access'),
64+
saveEntitlement: () => Promise.resolve(),
65+
getWallet(walletAddressUrl) {
66+
return Promise.resolve({
67+
$url: walletAddressUrl,
68+
id: walletAddressUrl,
69+
assetCode: 'USD',
70+
assetScale: 2,
71+
authServer: 'https://auth.example.com',
72+
resourceServer: 'https://resource.example.com',
73+
publicName: 'Wallet (Preview)',
74+
})
75+
},
76+
fetchQuote({ amount, sender, receiver }) {
77+
amount = String(amount)
78+
const debitAmount = { value: amount, currency: sender.assetCode }
79+
const receiveAmount = { value: amount, currency: receiver.assetCode }
80+
return Promise.resolve({ debitAmount, receiveAmount })
81+
},
82+
initiatePayment() {
83+
return Promise.resolve({
84+
paymentId: 'payment-id',
85+
grantRedirectUrl: 'https://example.com/redirect',
86+
})
87+
},
88+
async *getStatus() {
89+
yield {
90+
type: 'OUTGOING_PAYMENT_DONE',
91+
outgoingPaymentId: '',
92+
result: 'success',
93+
}
94+
},
95+
}

components/src/paywall/index.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/* eslint-disable no-unused-private-class-members */
2+
import { html, LitElement, nothing, unsafeCSS } from 'lit'
3+
import { state } from 'lit/decorators.js'
4+
import type { WalletAddressInfo } from 'publisher-tools-api'
5+
import { type Controller, NO_OP_CONTROLLER } from '@c/paywall/controller'
6+
import type { PaywallProfile } from '@shared/types'
7+
import styles from './styles.css?raw'
8+
import styleTokens from './vars.css?raw'
9+
10+
export class Paywall extends LitElement {
11+
#config_!: Promise<PaywallProfile>
12+
#config!: PaywallProfile
13+
#receiver!: Promise<WalletAddressInfo>
14+
15+
static styles = [unsafeCSS(styleTokens), unsafeCSS(styles)]
16+
17+
@state() _configReady = false
18+
19+
connectedCallback(): void {
20+
super.connectedCallback()
21+
22+
if (this.#controller === NO_OP_CONTROLLER) {
23+
throw new Error('setController() before mount')
24+
}
25+
if (!this.#price) {
26+
throw new Error('Price is not set')
27+
}
28+
29+
this.#config_ = this.#controller.fetchConfig().then((conf) => {
30+
this.#config = conf
31+
this._configReady = true
32+
return Promise.resolve(conf)
33+
})
34+
this.#receiver = this.#controller.getWallet(
35+
this.#controller.receiverWalletAddressUrl,
36+
)
37+
}
38+
39+
#controller = NO_OP_CONTROLLER
40+
setController(controller: Controller) {
41+
if (this.#controller === controller) return
42+
if (this.#controller !== NO_OP_CONTROLLER) {
43+
throw new Error('controller is already set')
44+
}
45+
this.#controller = controller
46+
}
47+
48+
#price = ''
49+
setPrice(price: string) {
50+
if (this.#price === '' || this.#controller.isPreviewMode) {
51+
this.#price = price
52+
} else {
53+
throw new Error('Price is already set')
54+
}
55+
}
56+
57+
render() {
58+
if (!this._configReady) return nothing
59+
60+
return html`<pre>${JSON.stringify(this.#config)}</pre>`
61+
}
62+
}

components/src/paywall/styles.css

Whitespace-only changes.

components/src/paywall/vars.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
:host {
2+
--font-family: var(--wm-font-family, system-ui, sans-serif);
3+
--background: var(--wm-background, #ffffff);
4+
--heading-color: var(--wm-heading-color, #363636);
5+
--text-color: var(--wm-text-color, #6b6e76);
6+
--accent-color: var(--wm-accent-color, #56b7b5);
7+
--border-radius: var(--wm-border-radius, 1rem);
8+
9+
--Paddings-lg: 1.5rem;
10+
--Paddings-sm: 0.75rem;
11+
--Spacings-lg: 1.5rem;
12+
--Spacings-xs: 0.5rem;
13+
--Radius-Moderate-rounding: 0.5rem;
14+
}
15+
16+
.sr-only {
17+
position: absolute;
18+
width: 1px;
19+
height: 1px;
20+
padding: 0;
21+
margin: -1px;
22+
overflow: hidden;
23+
clip: rect(0, 0, 0, 0);
24+
white-space: nowrap;
25+
border-width: 0;
26+
}

0 commit comments

Comments
 (0)