Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api/src/routes/get-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ app.get(
const { tool } = req.valid('param')
const { wa: walletAddress, id: profileId } = req.valid('query')

if (tool === 'paywall') {
return json(getDefaultProfile('paywall'))
}

const storage = new ConfigStorageService({ ...env, AWS_PREFIX })

try {
Expand Down
3 changes: 2 additions & 1 deletion cdn/@types/index.d.ts
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
}
}
104 changes: 104 additions & 0 deletions cdn/src/paywall.ts
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) {
Copy link
Copy Markdown
Member

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 .
getStatus is identical in both files. since initiatePayment, fetchQuote, and getWallet are already extracted to a shared module, this should follow the same pattern maybe ?

Copy link
Copy Markdown
Member Author

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.

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'
}
10 changes: 8 additions & 2 deletions cdn/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function getScriptParams(tool: Tool) {
}
const cdnUrl = new URL(script.src).origin

const { walletAddress, walletAddressId, tag } = script.dataset
const { walletAddress, walletAddressId, tag, ...rest } = script.dataset

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

return { walletAddress, walletAddressId, profileId: tag as ProfileId, cdnUrl }
return {
walletAddress,
walletAddressId,
profileId: tag as ProfileId,
cdnUrl,
otherAttributes: rest,
}
}

export async function fetchProfile<T extends Tool>(
Expand Down
1 change: 1 addition & 0 deletions components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
},
"devDependencies": {
"@interledger/open-payments": "^7.4.0",
"@shared/default-data": "workspace:^",
"@shared/types": "workspace:^",
"@shared/utils": "workspace:^",
"publisher-tools-api": "workspace:^",
Expand Down
1 change: 1 addition & 0 deletions components/src/index.ts
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'
95 changes: 95 additions & 0 deletions components/src/paywall/controller.ts
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',
}
},
}
62 changes: 62 additions & 0 deletions components/src/paywall/index.ts
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.
26 changes: 26 additions & 0 deletions components/src/paywall/vars.css
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;
}
5 changes: 5 additions & 0 deletions frontend/app/utils/sanitize.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import sanitizeHtml from 'sanitize-html'
import {
type BannerProfile,
type OfferwallProfile,
type PaywallProfile,
type WidgetProfile,
type ToolProfile,
type Tool,
TOOL_WIDGET,
TOOL_BANNER,
TOOL_PAYWALL,
TOOL_OFFERWALL,
} from '@shared/types'
import { ApiError, INVALID_PAYLOAD_ERROR } from '~/lib/helpers'
Expand Down Expand Up @@ -88,6 +90,9 @@ const sanitizers = {
[TOOL_OFFERWALL](offerwall: OfferwallProfile): OfferwallProfile {
return { ...offerwall, $name: sanitizeText(offerwall.$name) }
},
[TOOL_PAYWALL](paywall: PaywallProfile): PaywallProfile {
return { ...paywall, $name: sanitizeText(paywall.$name) }
},
} satisfies { [K in Tool]: (profile: ToolProfile<K>) => ToolProfile<K> }

export function sanitizeProfileFields<T extends Tool>(
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading