diff --git a/api/src/routes/get-profile.ts b/api/src/routes/get-profile.ts index 808c0cd67..43f3a4059 100644 --- a/api/src/routes/get-profile.ts +++ b/api/src/routes/get-profile.ts @@ -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 { diff --git a/cdn/@types/index.d.ts b/cdn/@types/index.d.ts index 0db2dd88c..27c8f9e47 100644 --- a/cdn/@types/index.d.ts +++ b/cdn/@types/index.d.ts @@ -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 } } diff --git a/cdn/src/paywall.ts b/cdn/src/paywall.ts new file mode 100644 index 000000000..5aba7b848 --- /dev/null +++ b/cdn/src/paywall.ts @@ -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' +} diff --git a/cdn/src/utils/index.ts b/cdn/src/utils/index.ts index 79af4e340..86603d430 100644 --- a/cdn/src/utils/index.ts +++ b/cdn/src/utils/index.ts @@ -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`) @@ -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( diff --git a/components/package.json b/components/package.json index ecd30d973..a9740a6bc 100644 --- a/components/package.json +++ b/components/package.json @@ -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:^", diff --git a/components/src/index.ts b/components/src/index.ts index 3827dc0ba..e61ce4ba2 100644 --- a/components/src/index.ts +++ b/components/src/index.ts @@ -1,3 +1,4 @@ export * from './widget' export * from './banner.js' +export * from './paywall/index.js' export * from './offerwall/index.js' diff --git a/components/src/paywall/controller.ts b/components/src/paywall/controller.ts new file mode 100644 index 000000000..3d576b4b9 --- /dev/null +++ b/components/src/paywall/controller.ts @@ -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 + + /** Check if given wallet address is entitled to access */ + checkEntitlement(walletAddressUrl: WalletAddressUrl): Promise + /** Store the entitlement after a successful payment in some backend */ + saveEntitlement( + walletAddressUrl: WalletAddressUrl, + details: { + outgoingPaymentId: string + incomingPaymentId: string + paymentId: string + }, + ): Promise + + getWallet(walletAddressUrl: WalletAddressUrl): Promise + fetchQuote(request: QuoteInput): Promise + initiatePayment(request: InitiatePaymentInput): Promise + getStatus( + paymentId: string, + signal?: AbortSignal, + ): AsyncGenerator + + 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', + } + }, +} diff --git a/components/src/paywall/index.ts b/components/src/paywall/index.ts new file mode 100644 index 000000000..1d9c82550 --- /dev/null +++ b/components/src/paywall/index.ts @@ -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 + #config!: PaywallProfile + #receiver!: Promise + + 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`
${JSON.stringify(this.#config)}
` + } +} diff --git a/components/src/paywall/styles.css b/components/src/paywall/styles.css new file mode 100644 index 000000000..e69de29bb diff --git a/components/src/paywall/vars.css b/components/src/paywall/vars.css new file mode 100644 index 000000000..d16de807c --- /dev/null +++ b/components/src/paywall/vars.css @@ -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; +} diff --git a/frontend/app/utils/sanitize.server.ts b/frontend/app/utils/sanitize.server.ts index 38cb95e91..dad16e17e 100644 --- a/frontend/app/utils/sanitize.server.ts +++ b/frontend/app/utils/sanitize.server.ts @@ -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' @@ -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) => ToolProfile } export function sanitizeProfileFields( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a18dc9de0..e63db461b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: '@interledger/open-payments': specifier: ^7.4.0 version: 7.4.0 + '@shared/default-data': + specifier: workspace:^ + version: link:../shared/default-data '@shared/types': specifier: workspace:^ version: link:../shared/types diff --git a/shared/default-data/index.ts b/shared/default-data/index.ts index 7dc463999..e975b59a5 100644 --- a/shared/default-data/index.ts +++ b/shared/default-data/index.ts @@ -7,10 +7,12 @@ import { TOOL_BANNER, TOOL_WIDGET, TOOL_OFFERWALL, + TOOL_PAYWALL, } from '@shared/types' import type { BannerProfile, OfferwallProfile, + PaywallProfile, Tool, ToolProfile, WidgetProfile, @@ -87,6 +89,16 @@ export const createDefaultWidgetProfile = ( }, }) +export const createDefaultPaywallProfile = ( + profileName: string, +): PaywallProfile => { + return { + $version: '0.0.1', + $name: profileName, + $modifiedAt: '', + } +} + export const createDefaultOfferwallProfile = ( profileName: string, ): OfferwallProfile => ({ @@ -113,6 +125,8 @@ export function getDefaultProfile(tool: Tool): ToolProfile { return createDefaultBannerProfile('Default') case TOOL_WIDGET: return createDefaultWidgetProfile('Default') + case TOOL_PAYWALL: + return createDefaultPaywallProfile('Default') case TOOL_OFFERWALL: return createDefaultOfferwallProfile('Default') diff --git a/shared/types/index.ts b/shared/types/index.ts index 132c3085e..48e761dab 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -1,7 +1,13 @@ export const TOOL_BANNER = 'banner' export const TOOL_WIDGET = 'widget' +export const TOOL_PAYWALL = 'paywall' export const TOOL_OFFERWALL = 'offerwall' -export const TOOLS = [TOOL_BANNER, TOOL_WIDGET, TOOL_OFFERWALL] as const +export const TOOLS = [ + TOOL_BANNER, + TOOL_WIDGET, + TOOL_PAYWALL, + TOOL_OFFERWALL, +] as const export type Tool = (typeof TOOLS)[number] export const PROFILE_A = 'version1' @@ -27,6 +33,9 @@ export interface Configuration { widget?: { [presetId in ProfileId]?: WidgetProfile } + paywall?: { + [presetId in ProfileId]?: PaywallProfile + } offerwall?: { [presetId in ProfileId]?: OfferwallProfile } @@ -37,6 +46,7 @@ export type ToolProfiles = Configuration[T] export type ToolProfile = { banner: BannerProfile widget: WidgetProfile + paywall: PaywallProfile offerwall: OfferwallProfile }[T] @@ -137,6 +147,9 @@ export interface WidgetProfile extends BaseToolProfile { } } +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface PaywallProfile extends BaseToolProfile {} + export interface OfferwallProfile extends BaseToolProfile { font: { name: FontFamilyKey