From 3d66bd915c8b63e7e8e5dfe5f4f828d8b722f492 Mon Sep 17 00:00:00 2001 From: Kaspar Kallas Date: Fri, 27 Oct 2023 14:08:11 +0300 Subject: [PATCH] Add pricing table and use global configuration Customer on Stripe (#12) * ensure configuration customer on Stripe and use its theme * add typed backend client to frontend * implement pricing page into frontend * lint & format --- apps/backend/package.json | 2 +- .../checkout-session.controller.ts | 2 +- .../superfluid-stripe-converter.controller.ts | 71 +++++-- .../superfluid-stripe-converter.service.ts | 69 +++++- apps/frontend/package.json | 6 +- apps/frontend/pnpm-lock.yaml | 85 ++++++-- apps/frontend/src/backend-openapi-client.d.ts | 196 ++++++++++++++++++ apps/frontend/src/components/Layout.tsx | 21 ++ .../components/SuperfluidWidgetProvider.tsx | 6 +- apps/frontend/src/pages/[product].tsx | 72 ++++--- apps/frontend/src/pages/_app.tsx | 8 +- apps/frontend/src/pages/about.tsx | 35 ---- .../frontend/src/pages/api/create-session.tsx | 21 +- apps/frontend/src/pages/index.tsx | 44 ++-- apps/frontend/src/pages/pricing.tsx | 182 ++++++++++++++++ ...23-10-02-use-typescript-for-development.md | 2 +- .../2023-10-04-use-bullmq-for-job-queues.md | 4 - 17 files changed, 680 insertions(+), 146 deletions(-) create mode 100644 apps/frontend/src/backend-openapi-client.d.ts create mode 100644 apps/frontend/src/components/Layout.tsx delete mode 100644 apps/frontend/src/pages/about.tsx create mode 100644 apps/frontend/src/pages/pricing.tsx diff --git a/apps/backend/package.json b/apps/backend/package.json index 0e1624b..3864a1a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -24,7 +24,7 @@ "clean": "npm-run-all -s clean:*", "clean:dist": "rimraf \"./dist\"", "clean:node-modules": "rimraf \"./node_modules\"", - "generate:accounting-client": "pnpm exec openapi-typescript https://accounting.superfluid.dev/static/api-docs.yaml -o ./src/super-token-accounting/client/types.d.ts" + "generate:accounting-openapi-client": "pnpm exec openapi-typescript https://accounting.superfluid.dev/static/api-docs.yaml -o ./src/super-token-accounting/client/types.d.ts" }, "jest": { "collectCoverageFrom": [ diff --git a/apps/backend/src/checkout-session/checkout-session.controller.ts b/apps/backend/src/checkout-session/checkout-session.controller.ts index bdb8765..cb8b41d 100644 --- a/apps/backend/src/checkout-session/checkout-session.controller.ts +++ b/apps/backend/src/checkout-session/checkout-session.controller.ts @@ -69,7 +69,7 @@ export class CheckoutSessionController { await this.queue.add(CHECKOUT_SESSION_JOB_NAME, validationResult.data, { jobId: jobId, // Remove finished job ASAP in case a new fresh job is triggered. - removeOnComplete: true, + removeOnComplete: false, removeOnFail: true, }); } diff --git a/apps/backend/src/superfluid-stripe-converter/superfluid-stripe-converter.controller.ts b/apps/backend/src/superfluid-stripe-converter/superfluid-stripe-converter.controller.ts index 623e5c2..891e02c 100644 --- a/apps/backend/src/superfluid-stripe-converter/superfluid-stripe-converter.controller.ts +++ b/apps/backend/src/superfluid-stripe-converter/superfluid-stripe-converter.controller.ts @@ -3,8 +3,10 @@ import { Controller, Get, Logger, Query } from '@nestjs/common'; import { WidgetProps } from '@superfluid-finance/widget'; import Stripe from 'stripe'; import { SuperfluidStripeConverterService } from './superfluid-stripe-converter.service'; +import { DEFAULT_PAGING } from 'src/stripe-module-config'; -type Response = { +type ProductResponse = { + stripeProduct: Stripe.Product; productDetails: WidgetProps['productDetails']; paymentDetails: WidgetProps['paymentDetails']; }; @@ -17,31 +19,64 @@ export class SuperfluidStripeConverterController { ) {} // TODO: Does this need auth? - @Get('checkout-widget') + @Get('product') async mapStripeProductToCheckoutWidget( @Query('product-id') productId: string, - ): Promise { - const [stripeProductsResponse, stripePricesResponse] = await Promise.all([ + ): Promise { + const [stripeProduct, stripePrices, configurationCustomer] = await Promise.all([ this.stripeClient.products.retrieve(productId), - this.stripeClient.prices.list({ - product: productId, - active: true, - }), + this.stripeClient.prices + .list({ + product: productId, + active: true, + }) + .autoPagingToArray(DEFAULT_PAGING), + this.superfluidStripeConverterService.ensureConfigurationCustomer(), ]); - const config = this.superfluidStripeConverterService.mapStripeProductToWidgetConfig({ - product: stripeProductsResponse, - prices: stripePricesResponse.data, + // check eligibility somewhere? + + const config = await this.superfluidStripeConverterService.mapStripeProductToWidgetConfig({ + configurationCustomer, + product: stripeProduct, + prices: stripePrices, }); - // logger.debug({ - // stripeProductsResponse, - // stripePricesResponse, - // productId, - // config, - // }); + return { ...config, stripeProduct: stripeProduct }; + } + + @Get('products') + async products(): Promise { + const [stripeProducts, stripePrices, configurationCustomer] = await Promise.all([ + this.stripeClient.products + .list({ + active: true, + }) + .autoPagingToArray(DEFAULT_PAGING), + this.stripeClient.prices + .list({ + active: true, + }) + .autoPagingToArray(DEFAULT_PAGING), + this.superfluidStripeConverterService.ensureConfigurationCustomer(), + ]); + + // check eligibility somewhere? + + const results = await Promise.all( + stripeProducts.map(async (stripeProduct) => { + const pricesForProduct = stripePrices.filter((price) => price.product === stripeProduct.id); + + const config = await this.superfluidStripeConverterService.mapStripeProductToWidgetConfig({ + product: stripeProduct, + prices: pricesForProduct, + }); + + return { ...config, stripeProduct }; + }), + ); - return config; + return results; } } diff --git a/apps/backend/src/superfluid-stripe-converter/superfluid-stripe-converter.service.ts b/apps/backend/src/superfluid-stripe-converter/superfluid-stripe-converter.service.ts index 4ef55e5..35bd39e 100644 --- a/apps/backend/src/superfluid-stripe-converter/superfluid-stripe-converter.service.ts +++ b/apps/backend/src/superfluid-stripe-converter/superfluid-stripe-converter.service.ts @@ -11,19 +11,25 @@ import { import { ChainId, PaymentOption, ProductDetails, WidgetProps } from '@superfluid-finance/widget'; import { currencyDecimalMapping } from 'src/stripe-currencies'; import { formatUnits } from 'viem'; +import { InjectStripeClient } from '@golevelup/nestjs-stripe'; +import { DEFAULT_PAGING } from 'src/stripe-module-config'; + +const configurationCustomerEmail = 'auto-generated@superfluid.finance' as const; type Input = { product: Stripe.Product; prices: Stripe.Price[]; // NOTE: These need to be fetched separately from the Product based on Product ID. + configurationCustomer?: Stripe.Customer; }; type Output = { productDetails: ProductDetails; paymentDetails: WidgetProps['paymentDetails']; + theme: any; // TODO: get rid of any }; interface StripeProductToWidgetConfigMapper { - mapStripeProductToWidgetConfig(stripe: Input): Output; + mapStripeProductToWidgetConfig(stripe: Input): Promise; } type PriceId = string; @@ -34,14 +40,55 @@ interface SuperTokenToStripeCurrencyMapper { }): PriceId | undefined; } +// Rename to "global config"? +interface ConfigurationCustomerManager { + ensureConfigurationCustomer(): Promise; +} + @Injectable() export class SuperfluidStripeConverterService - implements StripeProductToWidgetConfigMapper, SuperTokenToStripeCurrencyMapper + implements + StripeProductToWidgetConfigMapper, + SuperTokenToStripeCurrencyMapper, + ConfigurationCustomerManager { // TODO(KK): Inject private readonly chainToSuperTokenReceiverMap = defaultChainToSuperTokenReceiverMap; private readonly stripeCurrencyToSuperTokenMap = defaultStripeCurrencyToSuperTokenMap; + constructor(@InjectStripeClient() private readonly stripeClient: Stripe) {} + + async ensureConfigurationCustomer(): Promise { + // TODO: caching + // TODO: use better constants + + let configurationCustomer: Stripe.Customer; + + const customers = await this.stripeClient.customers + .list({ + email: configurationCustomerEmail, + }) + .autoPagingToArray(DEFAULT_PAGING); + + if (customers.length === 1) { + configurationCustomer = customers[0]; + } else if (customers.length === 0) { + configurationCustomer = await this.stripeClient.customers.create({ + email: configurationCustomerEmail, + metadata: { + note: 'Auto-generated. Be careful when editing!', + theme: `{"palette":{"mode":"light","primary":{"main":"#3f51b5"},"secondary":{"main":"#f50057"}}}`, + }, + }); + } else { + throw new Error( + `There should not be more than one Superfluid-Stripe configuration customer. Please remove one of the customers with e-mail: [${configurationCustomerEmail}]`, + ); + } + + return configurationCustomer; + } + mapSuperTokenToStripeCurrency(superToken: { chainId: number; address: string; @@ -60,9 +107,12 @@ export class SuperfluidStripeConverterService return undefined; } - mapStripeProductToWidgetConfig(stripe: Input): Output { + async mapStripeProductToWidgetConfig(stripe: Input): Promise { // TODO(KK): Enforce it's a subscription-based product? + const configurationCustomer = + stripe.configurationCustomer ?? (await this.ensureConfigurationCustomer()); + const productDetails: Output['productDetails'] = { name: stripe.product.name, description: stripe.product.description ?? '', // TODO(KK): Stripe product might not have a description. The Product Card of the widget should still look good. @@ -112,9 +162,22 @@ export class SuperfluidStripeConverterService paymentOptions, }; + // TODO: use Zod for validation? + // TODO: get rid of any + let theme: any; + try { + theme = JSON.parse(configurationCustomer.metadata['theme']); + } catch (e) { + logger.error(e); + } + + logger.debug('theme'); + logger.debug(theme); + return { productDetails, paymentDetails, + theme, }; } } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 64f5447..666f653 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -16,7 +16,8 @@ "clean:node-modules": "rimraf \"./node_modules\"", "docker": "pnpm docker:build && pnpm docker:run", "docker:build": "docker build -t superfluid-finance/stripe-frontend .", - "docker:run": "docker run -d -p 8000:8000 superfluid-finance/stripe-frontend" + "docker:run": "docker run -d -p 8000:8000 superfluid-finance/stripe-frontend", + "generate:backend-openapi-client": "pnpm exec openapi-typescript http://localhost:3001/swagger-json -o ./src/backend-openapi-client.d.ts" }, "dependencies": { "@emotion/cache": "latest", @@ -29,8 +30,11 @@ "clsx": "^2.0.0", "connectkit": "^1.5.3", "next": "latest", + "openapi-fetch": "^0.8.1", + "openapi-typescript": "^6.7.0", "react": "latest", "react-dom": "latest", + "stripe": "^14.1.0", "viem": "^1.17.1", "wagmi": "^1.4.5", "zod": "^3.22.4" diff --git a/apps/frontend/pnpm-lock.yaml b/apps/frontend/pnpm-lock.yaml index bf87a31..6ea3a0b 100644 --- a/apps/frontend/pnpm-lock.yaml +++ b/apps/frontend/pnpm-lock.yaml @@ -35,12 +35,21 @@ dependencies: next: specifier: latest version: 13.5.6(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0) + openapi-fetch: + specifier: ^0.8.1 + version: 0.8.1 + openapi-typescript: + specifier: ^6.7.0 + version: 6.7.0 react: specifier: latest version: 18.2.0 react-dom: specifier: latest version: 18.2.0(react@18.2.0) + stripe: + specifier: ^14.1.0 + version: 14.2.0 viem: specifier: ^1.17.1 version: 1.17.1(typescript@5.2.2)(zod@3.22.4) @@ -529,6 +538,11 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@fastify/busboy@2.0.0: + resolution: {integrity: sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==} + engines: {node: '>=14'} + dev: false + /@floating-ui/core@1.5.0: resolution: {integrity: sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==} dependencies: @@ -1089,12 +1103,10 @@ packages: dependencies: '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 - dev: true /@nodelib/fs.stat@2.0.5: resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} - dev: true /@nodelib/fs.walk@1.2.8: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} @@ -1102,7 +1114,6 @@ packages: dependencies: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 - dev: true /@popperjs/core@2.11.8: resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -2070,6 +2081,11 @@ packages: uri-js: 4.4.1 dev: true + /ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + dev: false + /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2088,7 +2104,6 @@ packages: /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: true /aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -2293,7 +2308,6 @@ packages: engines: {node: '>=8'} dependencies: fill-range: 7.0.1 - dev: true /browserslist@4.22.1: resolution: {integrity: sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==} @@ -3131,7 +3145,6 @@ packages: glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.5 - dev: true /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -3158,7 +3171,6 @@ packages: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: reusify: 1.0.4 - dev: true /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} @@ -3176,7 +3188,6 @@ packages: engines: {node: '>=8'} dependencies: to-regex-range: 5.0.1 - dev: true /filter-obj@1.1.0: resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} @@ -3303,7 +3314,6 @@ packages: engines: {node: '>= 6'} dependencies: is-glob: 4.0.3 - dev: true /glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} @@ -3564,7 +3574,6 @@ packages: /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - dev: true /is-finalizationregistry@1.0.2: resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} @@ -3588,7 +3597,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: is-extglob: 2.1.1 - dev: true /is-map@2.0.2: resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} @@ -3609,7 +3617,6 @@ packages: /is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - dev: true /is-path-inside@3.0.3: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} @@ -3747,7 +3754,6 @@ packages: hasBin: true dependencies: argparse: 2.0.1 - dev: true /jsesc@2.5.2: resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} @@ -3953,7 +3959,6 @@ packages: /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - dev: true /micromatch@4.0.5: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} @@ -3961,7 +3966,6 @@ packages: dependencies: braces: 3.0.2 picomatch: 2.3.1 - dev: true /minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -4191,6 +4195,28 @@ packages: dependencies: wrappy: 1.0.2 + /openapi-fetch@0.8.1: + resolution: {integrity: sha512-xmzMaBCydPTMd0TKy4P2DYx/JOe9yjXtPIky1n1GV7nJJdZ3IZgSHvAWVbe06WsPD8EreR7E97IAiskPr6sa2g==} + dependencies: + openapi-typescript-helpers: 0.0.4 + dev: false + + /openapi-typescript-helpers@0.0.4: + resolution: {integrity: sha512-Q0MTapapFAG993+dx8lNw33X6P/6EbFr31yNymJHq56fNc6dODyRm8tWyRnGxuC74lyl1iCRMV6nQCGQsfVNKg==} + dev: false + + /openapi-typescript@6.7.0: + resolution: {integrity: sha512-eoUfJwhnMEug7euZ1dATG7iRiDVsEROwdPkhLUDiaFjcClV4lzft9F0Ii0fYjULCPNIiWiFi0BqMpSxipuvAgQ==} + hasBin: true + dependencies: + ansi-colors: 4.1.3 + fast-glob: 3.3.1 + js-yaml: 4.1.0 + supports-color: 9.4.0 + undici: 5.27.0 + yargs-parser: 21.1.1 + dev: false + /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -4450,7 +4476,6 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true /quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -4648,7 +4673,6 @@ packages: /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} @@ -4673,7 +4697,6 @@ packages: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: queue-microtask: 1.2.3 - dev: true /rxjs@6.6.7: resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} @@ -4971,6 +4994,14 @@ packages: engines: {node: '>=8'} dev: true + /stripe@14.2.0: + resolution: {integrity: sha512-lMjDOyJbt+NVSDvkTathSP7uEV35l7oU8UrhBJrYD8lUi43BWujq8E9QHd3o9D2KPBR1Cze5DCw5s1btnLfdMA==} + engines: {node: '>=12.*'} + dependencies: + '@types/node': 20.8.9 + qs: 6.11.2 + dev: false + /style-value-types@5.0.0: resolution: {integrity: sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA==} dependencies: @@ -5047,6 +5078,11 @@ packages: has-flag: 4.0.0 dev: true + /supports-color@9.4.0: + resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==} + engines: {node: '>=12'} + dev: false + /supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -5091,7 +5127,6 @@ packages: engines: {node: '>=8.0'} dependencies: is-number: 7.0.0 - dev: true /toggle-selection@1.0.6: resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} @@ -5206,6 +5241,13 @@ packages: /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + /undici@5.27.0: + resolution: {integrity: sha512-l3ydWhlhOJzMVOYkymLykcRRXqbUaQriERtR70B9LzNkZ4bX52Fc8wbTDneMiwo8T+AemZXvXaTx+9o5ROxrXg==} + engines: {node: '>=14.0'} + dependencies: + '@fastify/busboy': 2.0.0 + dev: false + /update-browserslist-db@1.0.13(browserslist@4.22.1): resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} hasBin: true @@ -5524,6 +5566,11 @@ packages: decamelize: 1.2.0 dev: false + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + dev: false + /yargs@15.4.1: resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} engines: {node: '>=8'} diff --git a/apps/frontend/src/backend-openapi-client.d.ts b/apps/frontend/src/backend-openapi-client.d.ts new file mode 100644 index 0000000..d1e28d0 --- /dev/null +++ b/apps/frontend/src/backend-openapi-client.d.ts @@ -0,0 +1,196 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + '/stripe/webhook': { + post: operations['StripeWebhookController_handleWebhook']; + }; + '/checkout-session/create': { + post: operations['CheckoutSessionController_createSession']; + }; + '/superfluid-stripe-converter/product': { + get: operations['SuperfluidStripeConverterController_mapStripeProductToCheckoutWidget']; + }; + '/superfluid-stripe-converter/products': { + get: operations['SuperfluidStripeConverterController_products']; + }; + '/health': { + get: operations['HealthController_check']; + }; +} + +export type webhooks = Record; + +export interface components { + schemas: { + CreateSessionData: { + productId: string; + chainId: number; + superTokenAddress: string; + senderAddress: string; + receiverAddress: string; + email: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} + +export type $defs = Record; + +export type external = Record; + +export interface operations { + StripeWebhookController_handleWebhook: { + parameters: { + header: { + 'stripe-signature': string; + }; + }; + responses: { + 201: { + content: never; + }; + }; + }; + CheckoutSessionController_createSession: { + parameters: { + header: { + 'x-api-key': string; + }; + }; + requestBody: { + content: { + 'application/json': components['schemas']['CreateSessionData']; + }; + }; + responses: { + 201: { + content: never; + }; + }; + }; + SuperfluidStripeConverterController_mapStripeProductToCheckoutWidget: { + parameters: { + query: { + 'product-id': string; + }; + }; + responses: { + 200: { + content: never; + }; + }; + }; + SuperfluidStripeConverterController_products: { + responses: { + 200: { + content: never; + }; + }; + }; + HealthController_check: { + responses: { + /** @description The Health Check is successful */ + 200: { + content: { + 'application/json': { + /** @example ok */ + status?: string; + /** + * @example { + * "database": { + * "status": "up" + * } + * } + */ + info?: { + [key: string]: { + status?: string; + [key: string]: string | undefined; + }; + } | null; + /** @example {} */ + error?: { + [key: string]: { + status?: string; + [key: string]: string | undefined; + }; + } | null; + /** + * @example { + * "database": { + * "status": "up" + * } + * } + */ + details?: { + [key: string]: { + status?: string; + [key: string]: string | undefined; + }; + }; + }; + }; + }; + /** @description The Health Check is not successful */ + 503: { + content: { + 'application/json': { + /** @example error */ + status?: string; + /** + * @example { + * "database": { + * "status": "up" + * } + * } + */ + info?: { + [key: string]: { + status?: string; + [key: string]: string | undefined; + }; + } | null; + /** + * @example { + * "redis": { + * "status": "down", + * "message": "Could not connect" + * } + * } + */ + error?: { + [key: string]: { + status?: string; + [key: string]: string | undefined; + }; + } | null; + /** + * @example { + * "database": { + * "status": "up" + * }, + * "redis": { + * "status": "down", + * "message": "Could not connect" + * } + * } + */ + details?: { + [key: string]: { + status?: string; + [key: string]: string | undefined; + }; + }; + }; + }; + }; + }; + }; +} diff --git a/apps/frontend/src/components/Layout.tsx b/apps/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..df8e8d4 --- /dev/null +++ b/apps/frontend/src/components/Layout.tsx @@ -0,0 +1,21 @@ +import { Box, CssBaseline, Paper, Stack, Theme, ThemeOptions, ThemeProvider, createTheme } from "@mui/material" +import { PropsWithChildren, useEffect, useMemo } from "react" + +type Props = PropsWithChildren<{ + themeOptions: ThemeOptions +}> + +export default function Layout({ children, themeOptions }: Props) { + // TODO(KK): optimize, expose theme from widget? + const theme = useMemo(() => createTheme(themeOptions), [themeOptions.palette?.mode]); + + return ( + + + { + children + } + + + ) +} \ No newline at end of file diff --git a/apps/frontend/src/components/SuperfluidWidgetProvider.tsx b/apps/frontend/src/components/SuperfluidWidgetProvider.tsx index 72a1c63..eb33f84 100644 --- a/apps/frontend/src/components/SuperfluidWidgetProvider.tsx +++ b/apps/frontend/src/components/SuperfluidWidgetProvider.tsx @@ -19,6 +19,7 @@ type Props = { productDetails: WidgetProps['productDetails']; paymentDetails: WidgetProps['paymentDetails']; personalData: WidgetProps['personalData']; + theme: WidgetProps['theme']; }; export default function SupefluidWidgetProvider({ @@ -26,6 +27,7 @@ export default function SupefluidWidgetProvider({ paymentDetails, productDetails, personalData, + theme }: Props) { const { open, setOpen } = useModal(); @@ -57,11 +59,8 @@ export default function SupefluidWidgetProvider({ () => ({ onPaymentOptionUpdate: (paymentOption) => setPaymentOption(paymentOption), onRouteChange: (arg) => { - console.log('onRouteChange'); - const email = arg?.data?.["email"]; if (email && accountAddress && paymentOption && arg?.route === 'transactions') { - console.log('creating session'); const data: CreateSessionData = { productId, chainId: paymentOption.chainId, @@ -85,6 +84,7 @@ export default function SupefluidWidgetProvider({ paymentDetails={paymentDetails} productDetails={productDetails} personalData={personalData} + theme={theme} /> ); } diff --git a/apps/frontend/src/pages/[product].tsx b/apps/frontend/src/pages/[product].tsx index 45975b9..967a8a7 100644 --- a/apps/frontend/src/pages/[product].tsx +++ b/apps/frontend/src/pages/[product].tsx @@ -1,15 +1,20 @@ import ConnectKitProvider from '@/components/ConnectKitProvider'; +import Layout from '@/components/Layout'; import SupefluidWidgetProvider from '@/components/SuperfluidWidgetProvider'; import WagmiProvider from '@/components/WagmiProvider'; import internalConfig from '@/internalConfig'; +import { ThemeOptions, ThemeProvider } from '@mui/material'; import { WidgetProps } from '@superfluid-finance/widget'; import { GetServerSideProps } from 'next'; import { use, useEffect, useState } from 'react'; +import { paths } from '@/backend-openapi-client'; +import createClient from 'openapi-fetch'; type Props = { product: string; productDetails: WidgetProps['productDetails']; paymentDetails: WidgetProps['paymentDetails']; + theme: ThemeOptions } export default function Product({ product: productId, ...config }: Props) { @@ -18,48 +23,57 @@ export default function Product({ product: productId, ...config }: Props) { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); + // TODO(KK): handle theme any return ( - - - {!!config && mounted && ( - + + + {!!config && mounted && ( + - )} - - + //This doesn't work + // EmailField + ]} + /> + )} + + + ); } export const getServerSideProps = (async (context) => { const productId = context.query.product as string; - const url = new URL(`/superfluid-stripe-converter/checkout-widget?product-id=${productId}`, internalConfig.getBackendBaseUrl()); - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, + const client = createClient({ + baseUrl: internalConfig.getBackendBaseUrl().toString() + }); + + const { response } = await client.GET("/superfluid-stripe-converter/product", { + params: { + query: { + "product-id": productId + } + } }); const config = (await response.json()) as { productDetails: WidgetProps['productDetails']; paymentDetails: WidgetProps['paymentDetails']; + theme: ThemeOptions; }; return { diff --git a/apps/frontend/src/pages/_app.tsx b/apps/frontend/src/pages/_app.tsx index e379364..8206729 100644 --- a/apps/frontend/src/pages/_app.tsx +++ b/apps/frontend/src/pages/_app.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import Head from 'next/head'; import { AppProps } from 'next/app'; -import { ThemeProvider } from '@mui/material/styles'; +import { ThemeProvider as MUI_ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; -import { CacheProvider, EmotionCache } from '@emotion/react'; +import { CacheProvider, EmotionCache, Global, css } from '@emotion/react'; import theme from '../theme'; import createEmotionCache from '../createEmotionCache'; @@ -21,11 +21,11 @@ export default function MyApp(props: MyAppProps) { - + {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} - + ); } diff --git a/apps/frontend/src/pages/about.tsx b/apps/frontend/src/pages/about.tsx deleted file mode 100644 index 8e4f6f3..0000000 --- a/apps/frontend/src/pages/about.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from 'react'; -import Container from '@mui/material/Container'; -import Typography from '@mui/material/Typography'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Link from '../Link'; -import ProTip from '../ProTip'; -import Copyright from '../Copyright'; - -export default function About() { - return ( - - - - Material UI - Next.js example in TypeScript - - - - - - - - - ); -} diff --git a/apps/frontend/src/pages/api/create-session.tsx b/apps/frontend/src/pages/api/create-session.tsx index 6691e41..a877198 100644 --- a/apps/frontend/src/pages/api/create-session.tsx +++ b/apps/frontend/src/pages/api/create-session.tsx @@ -1,5 +1,9 @@ +import { paths } from '@/backend-openapi-client'; import internalConfig from '@/internalConfig'; import type { NextApiRequest, NextApiResponse } from 'next'; +import createClient from 'openapi-fetch'; + +// import { } "@/backend-openapi-client" export type CreateSessionData = { productId: string; @@ -14,14 +18,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (req.method === 'POST') { const createSessionRequest: CreateSessionData = req.body as CreateSessionData - const url = new URL("/checkout-session/create", internalConfig.getBackendBaseUrl()); - const response = await fetch(url, { - method: "POST", - headers: { - "x-api-key": internalConfig.getApiKey(), - 'Content-Type': 'application/json', + const client = createClient({ + baseUrl: internalConfig.getBackendBaseUrl().toString(), + }); + + const { response } = await client.POST("/checkout-session/create", { + params: { + header: { + "x-api-key": internalConfig.getApiKey() + } }, - body: JSON.stringify(createSessionRequest) + body: createSessionRequest }); const status = response.status; diff --git a/apps/frontend/src/pages/index.tsx b/apps/frontend/src/pages/index.tsx index 516d40d..7e5b0a2 100644 --- a/apps/frontend/src/pages/index.tsx +++ b/apps/frontend/src/pages/index.tsx @@ -5,28 +5,32 @@ import Box from '@mui/material/Box'; import Link from '../Link'; import ProTip from '../ProTip'; import Copyright from '../Copyright'; +import theme from '../theme'; +import Layout from '@/components/Layout'; export default function Home() { return ( - - - - Material UI - Next.js example in TypeScript - - - Go to the about page - - - - - + + + + + Material UI - Next.js example in TypeScript + + + Go to the about page + + + + + + ); } diff --git a/apps/frontend/src/pages/pricing.tsx b/apps/frontend/src/pages/pricing.tsx new file mode 100644 index 0000000..162d7aa --- /dev/null +++ b/apps/frontend/src/pages/pricing.tsx @@ -0,0 +1,182 @@ +import { createTheme, ThemeOptions, ThemeProvider } from '@mui/material/styles'; +import AppBar from '@mui/material/AppBar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardActions from '@mui/material/CardActions'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import CssBaseline from '@mui/material/CssBaseline'; +import Grid from '@mui/material/Grid'; +import StarIcon from '@mui/icons-material/StarBorder'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import Link from '@mui/material/Link'; +import GlobalStyles from '@mui/material/GlobalStyles'; +import Container from '@mui/material/Container'; +import Layout from '@/components/Layout'; +import { GetServerSideProps } from 'next'; +import createClient from 'openapi-fetch'; +import { paths } from '@/backend-openapi-client'; +import internalConfig from '@/internalConfig'; +import { WidgetProps } from '@superfluid-finance/widget'; +import Stripe from "stripe"; +import { useEffect, useMemo } from 'react'; + +type Tier = { + title: string; + price: string; + description: string[]; + buttonText: string; + buttonVariant: string; + productId: string +} + +// TODO(KK): anys +type Props = { + products: { + stripeProduct: Stripe.Product, + productDetails: WidgetProps['productDetails'], + paymentDetails: WidgetProps['paymentDetails'], + theme: ThemeOptions + }[], +} + +export default function Pricing({ + products +}: Props) { + const tiers = useMemo(() => products.map(p => ({ + title: p.stripeProduct.name, + description: p.stripeProduct.features.map(f => f.name), + price: "X", + buttonText: "Get Started", + buttonVariant: "contained", + productId: p.stripeProduct.id + })), [products]); + + return ( + + {/* Hero unit */} + < Container disableGutters maxWidth="sm" component="main" sx={{ pt: 8, pb: 6 }}> + + Pricing + + + Quickly build an effective pricing table for your potential customers with + this layout. It's built with default MUI components with little + customization. + + + {/* End hero unit */} + < Container maxWidth="md" component="main" > + + {tiers.map((tier) => ( + // Enterprise card is full width at sm breakpoint + + + : null} + subheaderTypographyProps={{ + align: 'center', + }} + sx={{ + backgroundColor: (theme) => + theme.palette.mode === 'light' + ? theme.palette.grey[200] + : theme.palette.grey[700], + }} + /> + + + + ${tier.price} + + + /mo + + +
    + {tier.description.map((line) => ( + + {/* TODO(KK): clean up */} + {/* {line} */} + + ))} +
+
+ + + +
+
+ ))} +
+ +
+ ); +} + +export const getServerSideProps = (async (context) => { + const productId = context.query.product as string; + + const client = createClient({ + baseUrl: internalConfig.getBackendBaseUrl().toString() + }); + + const { response } = await client.GET("/superfluid-stripe-converter/products", { + params: { + query: { + "product-id": productId + } + } + }); + + // TODO(KK): type the .json responses better + + const configs = (await response.json()) as { + stripeProduct: Stripe.Product, + productDetails: WidgetProps['productDetails']; + paymentDetails: WidgetProps['paymentDetails']; + theme: ThemeOptions; + }[]; + + return { + props: { + products: configs + } + } +}) satisfies GetServerSideProps \ No newline at end of file diff --git a/docs/decisions/2023-10-02-use-typescript-for-development.md b/docs/decisions/2023-10-02-use-typescript-for-development.md index 9d8962b..bb6bf38 100644 --- a/docs/decisions/2023-10-02-use-typescript-for-development.md +++ b/docs/decisions/2023-10-02-use-typescript-for-development.md @@ -1,4 +1,4 @@ -# Adopt TypeScript for Development +# Use TypeScript for Development ## Context diff --git a/docs/decisions/2023-10-04-use-bullmq-for-job-queues.md b/docs/decisions/2023-10-04-use-bullmq-for-job-queues.md index 6c71226..883049e 100644 --- a/docs/decisions/2023-10-04-use-bullmq-for-job-queues.md +++ b/docs/decisions/2023-10-04-use-bullmq-for-job-queues.md @@ -1,9 +1,5 @@ # Use BullMQ for Job Queues -## Status - -Accepted - ## Context In a system-to-system integration project, using a job queue becomes essential to manage and sequence tasks securely and efficiently. Factors such as reliability, idempotency and monitorability are paramount when integrating systems over the internet.