diff --git a/docs/assets/redemption-widget/6-redeem-confirmation-frontend-interrupted.jpg b/docs/assets/redemption-widget/6-redeem-confirmation-frontend-interrupted.jpg new file mode 100644 index 0000000..b5dfe4a Binary files /dev/null and b/docs/assets/redemption-widget/6-redeem-confirmation-frontend-interrupted.jpg differ diff --git a/docs/assets/redemption-widget/6-redeem-confirmation-frontend-resume.jpg b/docs/assets/redemption-widget/6-redeem-confirmation-frontend-resume.jpg new file mode 100644 index 0000000..5b68170 Binary files /dev/null and b/docs/assets/redemption-widget/6-redeem-confirmation-frontend-resume.jpg differ diff --git a/docs/assets/redemption-widget/6-redeem-confirmation-frontend.jpg b/docs/assets/redemption-widget/6-redeem-confirmation-frontend.jpg new file mode 100644 index 0000000..cc33734 Binary files /dev/null and b/docs/assets/redemption-widget/6-redeem-confirmation-frontend.jpg differ diff --git a/docs/assets/redemption-widget/7-congratulations-3.jpg b/docs/assets/redemption-widget/7-congratulations-3.jpg new file mode 100644 index 0000000..51b795b Binary files /dev/null and b/docs/assets/redemption-widget/7-congratulations-3.jpg differ diff --git a/docs/assets/redemption-widget/Redemption Widget - Frontend redemption flow.jpg b/docs/assets/redemption-widget/Redemption Widget - Frontend redemption flow.jpg new file mode 100644 index 0000000..108527a Binary files /dev/null and b/docs/assets/redemption-widget/Redemption Widget - Frontend redemption flow.jpg differ diff --git a/docs/assets/redemption-widget/Redemption Widget - Redemption flow with frontend messages (interrupted).jpg b/docs/assets/redemption-widget/Redemption Widget - Redemption flow with frontend messages (interrupted).jpg new file mode 100644 index 0000000..dcd3985 Binary files /dev/null and b/docs/assets/redemption-widget/Redemption Widget - Redemption flow with frontend messages (interrupted).jpg differ diff --git a/docs/redemption-widget.md b/docs/redemption-widget.md index 65cba32..4565a37 100644 --- a/docs/redemption-widget.md +++ b/docs/redemption-widget.md @@ -112,6 +112,9 @@ When using the Redeem Button, as shown above, the parameters must be passed as H | exchangeState | data-exchange-state | no | "Committed" | State of the exchanges when shown in the Select Exchange step. Relevant when widgetAction is SELECT_EXCHANGE. | ```"Redeemed"``` | showRedemptionOverview | data-show-redemption-overview | no | true | set to 'false' to skip the Redemption Overview (first step in the [user flow](./redemption-widget/default-redemption-flow.md)) | ```false``` | deliveryInfo | data-delivery-info | no | none | specify the delivery details that shall prefill the Redeem form, or be recapped on Confirm Redeem step. | ```'{"name":"TITI","streetNameAndNumber":"1 grand place","city":"LILLE","state":"NORD","zip":"59000","country":"FR","email":"toto@mail.com","phone":"+33123456789"}'``` +| sendDeliveryInfoThroughXMTP | data-send-delivery-info-XMTP | yes | none | whether the widget should send the delivery information to the seller via XMTP | `true` +| targetOrigin | data-target-origin | no | none | If set, the widget will send frontend messages (`boson-delivery-info`, `boson-redemption-submitted` and `boson-redemption-confirmed`) to this origin when appropriate | `"https://myshop.com"` +| shouldWaitForResponse | data-wait-for-response | no | none | whether the widget should wait for a response (`boson-delivery-info-response`) to the deliveryInfo message (`boson-delivery-info`). If false, the widget does not wait and progress further with the redemption flow | `false` | postDeliveryInfoUrl | data-post-delivery-info-url | no | none | this is the URL to which the widget will post the ***DeliveryInfo*** HTTP request with the delivery Details (see [Redemption with 3rd party eCommerce backend](./redemption-widget/backend-redemption-flow.md)) | ```"https://myshop.com/deliveryInfo"``` | postDeliveryInfoHeaders | data-post-delivery-info-headers | no | none | optionally specifies some request headers that must be added to the ***DeliveryInfo*** HTTP request | ```'{"authorization":"Bearer eyJhbGciOiJIUzL1Ni2sInR5cCI6IkpXVCJ7","another-header":"*****"}'``` | postRedemptionSubmittedUrl | data-post-redemption-submitted-url | no | none | this is the URL to which the widget will post the ***RedemptionSubmitted*** HTTP request with the delivery Details | ```"https://myshop.com/redemptionSubmitted"``` @@ -127,6 +130,7 @@ Hereafter are detailed examples of the redemption flows supported by the widget. - [Default redemption flow](./redemption-widget/default-redemption-flow.md) - [Marketplace redemption flow](./redemption-widget/marketplace-redemption-flow.md) - [Redemption with 3rd party eCommerce backend](./redemption-widget/backend-redemption-flow.md) +- [Redemption with frontend messaging](./redemption-widget/frontend-redemption-flow.md) The redemption widget can also support other usecases: - [Default cancellation flow](./redemption-widget/default-cancellation-flow.md) diff --git a/docs/redemption-widget/backend-redemption-flow.md b/docs/redemption-widget/backend-redemption-flow.md index e1d2992..24ae999 100644 --- a/docs/redemption-widget/backend-redemption-flow.md +++ b/docs/redemption-widget/backend-redemption-flow.md @@ -6,10 +6,12 @@ This flow supposes a 3r-party eCommerce system is used to manage the redemption. -Compared to the [default flow](./default-redemption-flow.md), the main difference is that the delivery information filled by the user is sent via an HTTP POST request to a backend URL passed as a parameter (***postDeliveryInfoUrl***), instead of being sent to the Seller via XMTP. +Compared to the [default flow](./default-redemption-flow.md), the main difference is that the delivery information filled by the user is sent via an HTTP POST request to a backend URL passed as a parameter (***postDeliveryInfoUrl***). + +*Note: sending the delivery information to XMTP can still be activated, depending on the value given to the `sendDeliveryInfoThroughXMTP` widget parameter.* When replying to the postDeliveryInfo request, it's possible for the backend to decide if the widget should continue to the next step or not. This can be useful: - - in case the delivery details can't be validated + - in case the delivery details are not valid, in which case the user can provide other delivery details, or choose to cancel the voucher - in case the flow needs to be interrupted, for instance, to perform Shipping Cost checkout before the on-chain transaction is submitted In addition, the on-chain Redeem transaction submission and confirmation can be relayed to the backend using respective parameters ***postRedemptionSubmittedUrl*** and ***postRedemptionConfirmedUrl***. @@ -22,6 +24,7 @@ If required, every backend request can contain specific headers (for instance us | ------ | -------- | ------- | | configId | yes | the Boson Protocol environment the widget is linked to (see [Boson Environments](../boson-environments.md)) | | sellerIds | no | specifies the list of sellerIds to filter the exchanges shown to the user ([step #3 below](#Select-Exchange)) +| sendDeliveryInfoThroughXMTP | yes | whether the widget should send the delivery information to the seller via XMTP | postDeliveryInfoUrl | yes - in this present case | this is the URL to which the widget will post the ***DeliveryInfo*** HTTP request with the delivery Details ([step #6.2 below](#postDeliveryInfo)) | postDeliveryInfoHeaders | no | specifies some request headers that must be added to the ***DeliveryInfo*** HTTP request | postRedemptionSubmittedUrl | no | this is the URL to which the widget will post the ***RedemptionSubmitted*** HTTP request with the delivery Details ([step #6.4 below](#postRedemptionSubmitted)) diff --git a/docs/redemption-widget/frontend-redemption-flow.md b/docs/redemption-widget/frontend-redemption-flow.md new file mode 100644 index 0000000..88d5206 --- /dev/null +++ b/docs/redemption-widget/frontend-redemption-flow.md @@ -0,0 +1,171 @@ +[![banner](../assets/banner.png)](https://bosonprotocol.io) + +< [Redemption Widget](../redemption-widget.md) + +## Redemption Flow with frontend + +This flow allows the widget embedded in a web page as an iFrame, to send the redemption information and the transaction progress to the hosting web page. + +Compared to the [Redemption flow with backend](./backend-redemption-flow.md), the main difference is that the delivery information filled by the user is sent to the hosting web page using cross-origin communication featured by the web browser (see [reference](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage)). + +Replying to the `boson-delivery-info` message with a `boson-delivery-info-response` one, is possible for the web page to decide if the widget should continue to the next step or not. This can be useful: + - in case the delivery details are not valid, in which case the user can provide other delivery details, or choose to cancel the voucher + - in case the flow needs to be interrupted, for instance, to perform Shipping Cost checkout before the on-chain transaction is submitted. + +In addition, the on-chain Redeem transaction submission and confirmation can be relayed to the frontend using appropriate messages (`boson-redemption-submitted` and `boson-redemption-confirmed`). + +### Widget Parameters + +| parameter | required | purpose | +| ------ | -------- | ------- | +| configId | yes | the Boson Protocol environment the widget is linked to (see [Boson Environments](../boson-environments.md)) | +| sellerIds | no | specifies the list of sellerIds to filter the exchanges shown to the user ([step #3 below](#Select-Exchange)) +| sendDeliveryInfoThroughXMTP | yes | whether the widget should send the delivery information to the seller via XMTP +| targetOrigin | yes - in this present case | If set, the widget will send frontend messages (`boson-delivery-info`, `boson-redemption-submitted` and `boson-redemption-confirmed`) to this origin when appropriate +| shouldWaitForResponse | no | whether the widget should wait for a response (`boson-delivery-info-response`) to the deliveryInfo message (`boson-delivery-info`). If false, the widget does not wait and progress further with the redemption flow + +### Main Flow (continuous) + +When the Seller website activates the Redemption Widget with the adequate options, the user is guided through the following steps. + +1. Wallet connection (if needed) + + ![Wallet connection](./../assets/redemption-widget/1-wallet-connection.png) + In case the user wallet is already connected, this step is skipped + +
+2. Redemption Overview + + ![Redemption Overview](./../assets/redemption-widget/2-redemption-overview.png) + +
+3. Select Exchange + + ![Select Exchange](./../assets/redemption-widget/3-select-exchange-filtered.png) + + Committed exchanges are shown to the user. These are the rNFT owned by the wallet and that the user can redeem. In this example, the ***sellerIds*** parameter is used to show only the exchanges of a unique seller. + + The user selects an rNFT and clicks it to show the "Exchange Card". Optionally, the Redeem button can be directly clicked, which leads the user directly to the Redeem Form + +4. Exchange Card + + ![Exchange Card](./../assets/redemption-widget/4-exchange-card-2.png) + This view shows details about the exchange, and presents a Redeem button (in case the rNFT is redeemable) that the user can click to jump to the Redeem Form + +5. Redeem Form + + ![Redeem Form](./../assets/redemption-widget/5-redeem-form.png) + The user fills in the delivery details to get their physical item delivered + +6. Redeem Confirmation + + ![Redeem Confirmation](./../assets/redemption-widget/6-redeem-confirmation-frontend.jpg) + + 6.1. User Signature + + First, the user is asked to sign the delivery details with their wallet to allow the backend to verify the request is coming from the real buyer. + +
+ 6.2 Post `boson-delivery-info` message + + Once the message is signed by the wallet, a frontend `boson-delivery-info` message is sent to the `targetOrigin`, containing the delivery information, details about the redeemed exchange, and the user signature. + + The receiving frontend might verify, from the signature, that the signer is the buyer wallet, then store the delivery information for this exchange. + + If OK and if `shouldWaitForResponse`, it must reply posting a `boson-delivery-info-response` message with the following content: ```{accepted: yes, resume: yes}```. + - ```accepted: yes``` means the delivery information is accepted so the redemption can be confirmed + - ```resume: yes``` means the widget can go through the next step to get the redemption confirmed + + 6.3 Sign/Send Redemption Transaction + + Now the user is asked to click on **Confirm Redemption** to send the Redeem transaction on-chain (to be signed/confirmed by the user with their wallet) + +
+ 6.4 Post `boson-redemption-submitted` message + + Once the Redeem transaction is signed by the wallet and sent on-chain, a frontend `boson-redemption-submitted` message is sent to the `targetOrigin`, containing the details about the redeemed exchange and the expected transaction hash *(Note: the hash of the real transaction may be different than the expected one, for instance in case the wallet resubmits with higher fees, to speed it up)*. + + No response to this message is expected by the widget. + +7. Congratulations + ![Congratulations](./../assets/redemption-widget/7-congratulations-3.jpg) + Once the Redeem transaction is confirmed on-chain, a congratulation message is shown to the user. + +
+ Once the Redeem transaction is confirmed on-chain, a frontend `boson-redemption-confirmed` message is sent to the `targetOrigin`, containing the details about the redeemed exchange, the effective transaction hash and the blockNumber where the transaction has been validated. + + No response to this message is expected by the widget. + + The user can: + - close the widget + - go back to select another rNFT. + +#### Complete diagram + +![Redemption Widget - Redemption Widget Flow with callbacks (continuous)](../assets/redemption-widget/Redemption%20Widget%20-%20Frontend%20redemption%20flow.jpg) + +### Interrupted Flow + +It is possible, for the hosting web page, to interrupt the redemption flow after the redemption information is set by the user and before the redeem transaction is sent on-chain. + +This is useful to allow full redemption to include an additional step or verification between these 2 steps. + +Interruption is triggered by the `boson-delivery-info-response` response the `targetOrigin` sends back to the widget when replying to the `boson-delivery-info` message ([step #6.2 above](#postDeliveryInfo)). + + +6. Redeem Confirmation + + ![Redeem Confirmation](./../assets/redemption-widget/6-redeem-confirmation-frontend-interrupted.jpg) + + 6.1. User Signature + + Identical to the continuous flow above. + + 6.2 Post `boson-delivery-info` message (interrupted) + + As for the continuous flow, a frontend `boson-delivery-info` message is sent to the `targetOrigin`, containing the delivery information, details about the redeemed exchange, and the user signature. When `shouldWaitForResponse` is true, the widget waits for a response message (`boson-delivery-info-response`) which is used to interrupt the flow, as follow. + - ```accepted: yes``` means the delivery information is accepted so the redemption can be confirmed + - ```resume: NO``` means the widget shall interrupt and not go on with the following step + + The next step is, for the frontend, to close/hide the widget, while dealing with the delivery information. + + When done, the widget can be called again, with adequate parameters, to end the redemption flow. + +### End of Redemption confirmation, following an interrupted flow + +To start the widget directly on the Redemption Confirmation flow, the following parameters shall be set: + +| option | required | purpose | +| ------ | -------- | ------- | +| configId | yes | the Boson Protocol environment the widget is linked to (see [Boson Environments](../boson-environments.md)) | +| exchangeId | yes - in this present case | the ID of the exchange being redeemed. +| widgetAction | yes - in this present case | **"CONFIRM_REDEEM"**: the action the widget is going to jump on +| showRedemptionOverview | yes - in this present case | **false**: to skip the Redemption Overview ([step #2 above](#showRedemptionOverview)) +| deliveryInfo | yes - in this present case | the delivery details that have been validated by the eCommerce backend for this redemption, shown to the user before they confirm the redemption. +| targetOrigin | yes - in this present case | If set, the widget will send frontend messages (`boson-delivery-info`, `boson-redemption-submitted` and `boson-redemption-confirmed`) to this origin when appropriate +| shouldWaitForResponse | no | whether the widget should wait for a response (`boson-delivery-info-response`) to the deliveryInfo message (`boson-delivery-info`). If false, the widget does not wait and progress further with the redemption flow + +6. Redeem Confirmation (follow-up) + + ![Redeem Confirmation](./../assets/redemption-widget/6-redeem-confirmation-frontend-resume.jpg) + + 6.3 Sign/Send Redemption Transaction + + Similarly to the continuous flow, the user is asked to click on **Confirm Redemption** to send the Redeem transaction on-chain (to be signed/confirmed by the user with their wallet) + + *Note that the user can decide to go back to the previous step to Edit the delivery information, that will replay the step 5 and 6 of the redemption flow (possibly interrupted, depending on the frontend response)* + + 6.4 Post `boson-redemption-submitted` message + + Identical to the continuous flow. + +7. Congratulations + + ![Congratulations](./../assets/redemption-widget/7-congratulations-3.jpg) + Identical to the continuous flow. + +#### Complete diagram + +![Redemption Widget - Redemption Widget Flow with callbacks (interrupted)](../assets/redemption-widget/Redemption%20Widget%20-%20Redemption%20flow%20with%20frontend%20messages%20(interrupted).jpg) + + diff --git a/package-lock.json b/package-lock.json index 9139e99..91eef1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "widgets", "version": "0.1.0", "dependencies": { - "@bosonprotocol/react-kit": "^0.22.0-alpha.7", + "@bosonprotocol/react-kit": "^0.22.0-alpha.14", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -2152,9 +2152,9 @@ } }, "node_modules/@bosonprotocol/common": { - "version": "1.24.0-alpha.4", - "resolved": "https://registry.npmjs.org/@bosonprotocol/common/-/common-1.24.0-alpha.4.tgz", - "integrity": "sha512-0DLju01OrJilnRqDnpCwuLs5Ow+JHArNnRz/9Gdkb4UWMSk9puXNCpaTBMpGUr+AJp7UBCi9cApICkLF89QLKA==", + "version": "1.24.0-alpha.11", + "resolved": "https://registry.npmjs.org/@bosonprotocol/common/-/common-1.24.0-alpha.11.tgz", + "integrity": "sha512-Rhf9Hptm1dvhUNgjPARyw5GMtRZsxyG9sGRWDiHiq6JgeTbYUDosv/0QWk6/oywhTt2mc7XNY+kqLmLK5TqjyQ==", "dependencies": { "@bosonprotocol/metadata": "^1.13.0", "@ethersproject/abi": "^5.5.0", @@ -2166,11 +2166,11 @@ } }, "node_modules/@bosonprotocol/core-sdk": { - "version": "1.33.0-alpha.4", - "resolved": "https://registry.npmjs.org/@bosonprotocol/core-sdk/-/core-sdk-1.33.0-alpha.4.tgz", - "integrity": "sha512-MXeicxf3xc3TwEra2qFggsicEqcG27dqe2v4ie5Tccl9+/MgVZYEgl1speleow0JLgl8l4zksTEpIeCpd0AqPg==", + "version": "1.33.0-alpha.11", + "resolved": "https://registry.npmjs.org/@bosonprotocol/core-sdk/-/core-sdk-1.33.0-alpha.11.tgz", + "integrity": "sha512-flc5gK5zF1uHyuGpN0+aYuxYGeEMoyfRUHEuEZR18vPCLGwtuaYOtH/PCggAKnSyPJdua85Ya1Kw4mWf4aCQAw==", "dependencies": { - "@bosonprotocol/common": "^1.24.0-alpha.4", + "@bosonprotocol/common": "^1.24.0-alpha.11", "@ethersproject/abi": "^5.5.0", "@ethersproject/address": "^5.5.0", "@ethersproject/bignumber": "^5.5.0", @@ -2199,11 +2199,11 @@ } }, "node_modules/@bosonprotocol/ethers-sdk": { - "version": "1.12.10-alpha.4", - "resolved": "https://registry.npmjs.org/@bosonprotocol/ethers-sdk/-/ethers-sdk-1.12.10-alpha.4.tgz", - "integrity": "sha512-Q1nDJCt7V4+Ix2PkbX33xco4mRhkFwrpTKgEbmCbs7L2bVdne9qzXSZ+ilZdGteYJRqQG7G8UONNrd9GEx0oBA==", + "version": "1.12.10-alpha.11", + "resolved": "https://registry.npmjs.org/@bosonprotocol/ethers-sdk/-/ethers-sdk-1.12.10-alpha.11.tgz", + "integrity": "sha512-NAlYNMT2lXwf/FL3ZNARYAZF1TqN3BDZHuODikJ5v1ujh3I6817C3I3SPJaPhi+yERr2ImyYNVVsC/A1DZi+Ww==", "dependencies": { - "@bosonprotocol/common": "^1.24.0-alpha.4" + "@bosonprotocol/common": "^1.24.0-alpha.11" }, "peerDependencies": { "ethers": "^5.5.0" @@ -2229,13 +2229,13 @@ } }, "node_modules/@bosonprotocol/react-kit": { - "version": "0.22.0-alpha.7", - "resolved": "https://registry.npmjs.org/@bosonprotocol/react-kit/-/react-kit-0.22.0-alpha.7.tgz", - "integrity": "sha512-hAWvJNi28fQpicSGBvnbmbf/Ci3fSihdCHBUx9Cr3M7cDqoYowfoczVHQlbq0XK/k9uo4oCjcjRVEbmpSNVRJA==", + "version": "0.22.0-alpha.14", + "resolved": "https://registry.npmjs.org/@bosonprotocol/react-kit/-/react-kit-0.22.0-alpha.14.tgz", + "integrity": "sha512-sMYPWCSSeqtu9dkn5MnlUZT8PgsT74Mn2rJTGkh4l3F4Fyx23AeCWcD56YKuMZvUTM7o5FJkmhGfUAE0CbvC2g==", "dependencies": { "@bosonprotocol/chat-sdk": "^1.3.1-alpha.9", - "@bosonprotocol/core-sdk": "^1.33.0-alpha.4", - "@bosonprotocol/ethers-sdk": "^1.12.10-alpha.4", + "@bosonprotocol/core-sdk": "^1.33.0-alpha.11", + "@bosonprotocol/ethers-sdk": "^1.12.10-alpha.11", "@bosonprotocol/ipfs-storage": "^1.10.10", "@davatar/react": "1.11.1", "@ethersproject/units": "5.6.0", @@ -2245,6 +2245,7 @@ "@tippyjs/react": "4.2.6", "@uniswap/sdk-core": "^4.0.7", "dayjs": "1.11.7", + "eth-revert-reason": "^1.0.3", "formik": "2.2.9", "graphql-request": "5.2.0", "lodash.merge": "4.6.2", @@ -14098,6 +14099,68 @@ "xtend": "^4.0.1" } }, + "node_modules/eth-revert-reason": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/eth-revert-reason/-/eth-revert-reason-1.0.3.tgz", + "integrity": "sha512-HD4pB+s2Jr4cwdywbcTcdLXPwxOZNrnH/luVuploeqEBlCNvFZQGzOwfgOy8WTSTqqEvjZOrIt+cNPt5Zl4pXw==", + "dependencies": { + "ethers": "^4.0.46" + }, + "bin": { + "getRevertReason": "cli.js" + } + }, + "node_modules/eth-revert-reason/node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + }, + "node_modules/eth-revert-reason/node_modules/ethers": { + "version": "4.0.49", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-4.0.49.tgz", + "integrity": "sha512-kPltTvWiyu+OktYy1IStSO16i2e7cS9D9OxZ81q2UUaiNPVrm/RTcbxamCXF9VUSKzJIdJV68EAIhTEVBalRWg==", + "dependencies": { + "aes-js": "3.0.0", + "bn.js": "^4.11.9", + "elliptic": "6.5.4", + "hash.js": "1.1.3", + "js-sha3": "0.5.7", + "scrypt-js": "2.0.4", + "setimmediate": "1.0.4", + "uuid": "2.0.1", + "xmlhttprequest": "1.8.0" + } + }, + "node_modules/eth-revert-reason/node_modules/hash.js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.3.tgz", + "integrity": "sha512-/UETyP0W22QILqS+6HowevwhEFJ3MBJnwTf75Qob9Wz9t0DPuisL8kW8YZMK62dHAKE1c1p+gY1TtOLY+USEHA==", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/eth-revert-reason/node_modules/js-sha3": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.5.7.tgz", + "integrity": "sha512-GII20kjaPX0zJ8wzkTbNDYMY7msuZcTWk8S5UOh6806Jq/wz1J8/bnr8uGU0DAUmYDjj2Mr4X1cW8v/GLYnR+g==" + }, + "node_modules/eth-revert-reason/node_modules/scrypt-js": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/scrypt-js/-/scrypt-js-2.0.4.tgz", + "integrity": "sha512-4KsaGcPnuhtCZQCxFxN3GVYIhKFPTdLd8PLC552XwbMndtD0cjRFAhDuuydXQ0h08ZfPgzqe6EKHozpuH74iDw==" + }, + "node_modules/eth-revert-reason/node_modules/setimmediate": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.4.tgz", + "integrity": "sha512-/TjEmXQVEzdod/FFskf3o7oOAsGhHf2j1dZqRFbDzq4F3mvvxflIIi4Hd3bLQE9y/CpwqfSQam5JakI/mi3Pog==" + }, + "node_modules/eth-revert-reason/node_modules/uuid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.1.tgz", + "integrity": "sha512-nWg9+Oa3qD2CQzHIP4qKUqwNfzKn8P0LtFhotaCTFchsV7ZfDhAybeip/HZVeMIpZi9JgY1E3nUlwaCmZT1sEg==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details." + }, "node_modules/eth-rpc-errors": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/eth-rpc-errors/-/eth-rpc-errors-4.0.2.tgz", @@ -29411,6 +29474,14 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, + "node_modules/xmlhttprequest": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz", + "integrity": "sha512-58Im/U0mlVBLM38NdZjHyhuMtCqa61469k2YP/AaPbvCoV9aQGUpbJBj1QRm2ytRiVQBD/fsw7L2bJGDVQswBA==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -31041,9 +31112,9 @@ } }, "@bosonprotocol/common": { - "version": "1.24.0-alpha.4", - "resolved": "https://registry.npmjs.org/@bosonprotocol/common/-/common-1.24.0-alpha.4.tgz", - "integrity": "sha512-0DLju01OrJilnRqDnpCwuLs5Ow+JHArNnRz/9Gdkb4UWMSk9puXNCpaTBMpGUr+AJp7UBCi9cApICkLF89QLKA==", + "version": "1.24.0-alpha.11", + "resolved": "https://registry.npmjs.org/@bosonprotocol/common/-/common-1.24.0-alpha.11.tgz", + "integrity": "sha512-Rhf9Hptm1dvhUNgjPARyw5GMtRZsxyG9sGRWDiHiq6JgeTbYUDosv/0QWk6/oywhTt2mc7XNY+kqLmLK5TqjyQ==", "requires": { "@bosonprotocol/metadata": "^1.13.0", "@ethersproject/abi": "^5.5.0", @@ -31055,11 +31126,11 @@ } }, "@bosonprotocol/core-sdk": { - "version": "1.33.0-alpha.4", - "resolved": "https://registry.npmjs.org/@bosonprotocol/core-sdk/-/core-sdk-1.33.0-alpha.4.tgz", - "integrity": "sha512-MXeicxf3xc3TwEra2qFggsicEqcG27dqe2v4ie5Tccl9+/MgVZYEgl1speleow0JLgl8l4zksTEpIeCpd0AqPg==", + "version": "1.33.0-alpha.11", + "resolved": "https://registry.npmjs.org/@bosonprotocol/core-sdk/-/core-sdk-1.33.0-alpha.11.tgz", + "integrity": "sha512-flc5gK5zF1uHyuGpN0+aYuxYGeEMoyfRUHEuEZR18vPCLGwtuaYOtH/PCggAKnSyPJdua85Ya1Kw4mWf4aCQAw==", "requires": { - "@bosonprotocol/common": "^1.24.0-alpha.4", + "@bosonprotocol/common": "^1.24.0-alpha.11", "@ethersproject/abi": "^5.5.0", "@ethersproject/address": "^5.5.0", "@ethersproject/bignumber": "^5.5.0", @@ -31087,11 +31158,11 @@ } }, "@bosonprotocol/ethers-sdk": { - "version": "1.12.10-alpha.4", - "resolved": "https://registry.npmjs.org/@bosonprotocol/ethers-sdk/-/ethers-sdk-1.12.10-alpha.4.tgz", - "integrity": "sha512-Q1nDJCt7V4+Ix2PkbX33xco4mRhkFwrpTKgEbmCbs7L2bVdne9qzXSZ+ilZdGteYJRqQG7G8UONNrd9GEx0oBA==", + "version": "1.12.10-alpha.11", + "resolved": "https://registry.npmjs.org/@bosonprotocol/ethers-sdk/-/ethers-sdk-1.12.10-alpha.11.tgz", + "integrity": "sha512-NAlYNMT2lXwf/FL3ZNARYAZF1TqN3BDZHuODikJ5v1ujh3I6817C3I3SPJaPhi+yERr2ImyYNVVsC/A1DZi+Ww==", "requires": { - "@bosonprotocol/common": "^1.24.0-alpha.4" + "@bosonprotocol/common": "^1.24.0-alpha.11" } }, "@bosonprotocol/ipfs-storage": { @@ -31114,13 +31185,13 @@ } }, "@bosonprotocol/react-kit": { - "version": "0.22.0-alpha.7", - "resolved": "https://registry.npmjs.org/@bosonprotocol/react-kit/-/react-kit-0.22.0-alpha.7.tgz", - "integrity": "sha512-hAWvJNi28fQpicSGBvnbmbf/Ci3fSihdCHBUx9Cr3M7cDqoYowfoczVHQlbq0XK/k9uo4oCjcjRVEbmpSNVRJA==", + "version": "0.22.0-alpha.14", + "resolved": "https://registry.npmjs.org/@bosonprotocol/react-kit/-/react-kit-0.22.0-alpha.14.tgz", + "integrity": "sha512-sMYPWCSSeqtu9dkn5MnlUZT8PgsT74Mn2rJTGkh4l3F4Fyx23AeCWcD56YKuMZvUTM7o5FJkmhGfUAE0CbvC2g==", "requires": { "@bosonprotocol/chat-sdk": "^1.3.1-alpha.9", - "@bosonprotocol/core-sdk": "^1.33.0-alpha.4", - "@bosonprotocol/ethers-sdk": "^1.12.10-alpha.4", + "@bosonprotocol/core-sdk": "^1.33.0-alpha.11", + "@bosonprotocol/ethers-sdk": "^1.12.10-alpha.11", "@bosonprotocol/ipfs-storage": "^1.10.10", "@davatar/react": "1.11.1", "@ethersproject/units": "5.6.0", @@ -31130,6 +31201,7 @@ "@tippyjs/react": "4.2.6", "@uniswap/sdk-core": "^4.0.7", "dayjs": "1.11.7", + "eth-revert-reason": "^1.0.3", "formik": "2.2.9", "graphql-request": "5.2.0", "lodash.merge": "4.6.2", @@ -40159,6 +40231,66 @@ "xtend": "^4.0.1" } }, + "eth-revert-reason": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/eth-revert-reason/-/eth-revert-reason-1.0.3.tgz", + "integrity": "sha512-HD4pB+s2Jr4cwdywbcTcdLXPwxOZNrnH/luVuploeqEBlCNvFZQGzOwfgOy8WTSTqqEvjZOrIt+cNPt5Zl4pXw==", + "requires": { + "ethers": "^4.0.46" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + }, + "ethers": { + "version": "4.0.49", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-4.0.49.tgz", + "integrity": "sha512-kPltTvWiyu+OktYy1IStSO16i2e7cS9D9OxZ81q2UUaiNPVrm/RTcbxamCXF9VUSKzJIdJV68EAIhTEVBalRWg==", + "requires": { + "aes-js": "3.0.0", + "bn.js": "^4.11.9", + "elliptic": "6.5.4", + "hash.js": "1.1.3", + "js-sha3": "0.5.7", + "scrypt-js": "2.0.4", + "setimmediate": "1.0.4", + "uuid": "2.0.1", + "xmlhttprequest": "1.8.0" + } + }, + "hash.js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.3.tgz", + "integrity": "sha512-/UETyP0W22QILqS+6HowevwhEFJ3MBJnwTf75Qob9Wz9t0DPuisL8kW8YZMK62dHAKE1c1p+gY1TtOLY+USEHA==", + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.0" + } + }, + "js-sha3": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.5.7.tgz", + "integrity": "sha512-GII20kjaPX0zJ8wzkTbNDYMY7msuZcTWk8S5UOh6806Jq/wz1J8/bnr8uGU0DAUmYDjj2Mr4X1cW8v/GLYnR+g==" + }, + "scrypt-js": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/scrypt-js/-/scrypt-js-2.0.4.tgz", + "integrity": "sha512-4KsaGcPnuhtCZQCxFxN3GVYIhKFPTdLd8PLC552XwbMndtD0cjRFAhDuuydXQ0h08ZfPgzqe6EKHozpuH74iDw==" + }, + "setimmediate": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.4.tgz", + "integrity": "sha512-/TjEmXQVEzdod/FFskf3o7oOAsGhHf2j1dZqRFbDzq4F3mvvxflIIi4Hd3bLQE9y/CpwqfSQam5JakI/mi3Pog==" + }, + "uuid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.1.tgz", + "integrity": "sha512-nWg9+Oa3qD2CQzHIP4qKUqwNfzKn8P0LtFhotaCTFchsV7ZfDhAybeip/HZVeMIpZi9JgY1E3nUlwaCmZT1sEg==" + } + } + }, "eth-rpc-errors": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/eth-rpc-errors/-/eth-rpc-errors-4.0.2.tgz", @@ -51461,6 +51593,11 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, + "xmlhttprequest": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz", + "integrity": "sha512-58Im/U0mlVBLM38NdZjHyhuMtCqa61469k2YP/AaPbvCoV9aQGUpbJBj1QRm2ytRiVQBD/fsw7L2bJGDVQswBA==" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index c088eba..463a8a0 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@bosonprotocol/react-kit": "^0.22.0-alpha.7", + "@bosonprotocol/react-kit": "^0.22.0-alpha.14", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", diff --git a/public/example.html b/public/example.html index a05ba44..b5d0c73 100644 --- a/public/example.html +++ b/public/example.html @@ -77,7 +77,7 @@

Redemption Widget

- + @@ -109,6 +109,15 @@

Redemption Widget

+ + Send DeliveryInfo Through XMTP + + + + + + + DeliveryInfo @@ -116,7 +125,7 @@

Redemption Widget

- + @@ -138,6 +147,24 @@

Redemption Widget

+ + TargetOrigin + + + + + + + + + Should Wait For Response + + + + + + + PostRedemptionSubmittedURL @@ -202,12 +229,19 @@

Redemption Widget

element[attribute] = value element.onchange() } + function getValue(elementId, attribute = "value") { + let element = document.getElementById(elementId) + return element[attribute] + } function clearRedeemInputs() { setValue('input-redeem-exchange-id', '') setValue('input-redeem-seller-ids', '') setValue('select-redeem-widget-action', 'SELECT_EXCHANGE') setValue('input-redeem-show-overview', true, 'checked') setValue('select-redeem-exchange-state', 'Committed') + setValue('input-send-delivery-info-XMTP', true, 'checked') + setValue('input-target-origin', '') + setValue('input-wait-for-response', true, 'checked') setValue('input-post-delivery-info-url', '') setValue('input-post-delivery-info-header', '') setValue('input-post-redemption-submitted-url', '') @@ -227,6 +261,50 @@

Redemption Widget

} return s2 } + window.addEventListener("message", (event) => { + if (event.data.type === constants.deliveryInfoMessage) { + console.log( + `Received message '${event.data.type}' from '${event.origin}'. Content: '${JSON.stringify(event.data.message)}'` + ); + if (getValue('input-wait-for-response', 'checked')) { + // wait for a bit and send a response to the iFrame (only relevant when ) + setTimeout(() => { + const el = getIFrame(); + if (el) { + const target = event.origin; + console.log( + `Post response message '${constants.deliveryInfoMessageResponse}' to the iFrame '${target}'` + ); + // https://stackoverflow.com/questions/40991114/issue-communication-with-postmessage-from-parent-to-child-iframe + el.contentWindow.postMessage( + { + type: constants.deliveryInfoMessageResponse, + message: { + accepted: true, + reason: "", + resume: true + } + }, + target + ); + } else { + console.error("Unable to retrieve the iFrame"); + } + }, 5000); + } + } + if (event.data.type === constants.redemptionSubmittedMessage) { + console.log( + `Received message '${event.data.type}' from '${event.origin}'. Content: '${JSON.stringify(event.data.message)}'` + ); + } + if (event.data.type === constants.redemptionConfirmedMessage) { + console.log( + `Received message '${event.data.type}' from '${event.origin}'. Content: '${JSON.stringify(event.data.message)}'` + ); + } + }); + \ No newline at end of file diff --git a/public/scripts/boson-widgets.js b/public/scripts/boson-widgets.js index 86df733..987fd47 100644 --- a/public/scripts/boson-widgets.js +++ b/public/scripts/boson-widgets.js @@ -19,6 +19,9 @@ const constants = { deliveryInfoTag: "data-delivery-info", postDeliveryInfoUrlTag: "data-post-delivery-info-url", postDeliveryInfoHeadersTag: "data-post-delivery-info-headers", + dataTargetOrigin: "data-target-origin", + dataWaitForResponse: "data-wait-for-response", + dataSendDeliveryInfoXMTP: "data-send-delivery-info-XMTP", postRedemptionSubmittedUrlTag: "data-post-redemption-submitted-url", postRedemptionSubmittedHeadersTag: "data-post-redemption-submitted-headers", postRedemptionConfirmedUrlTag: "data-post-redemption-confirmed-url", @@ -26,6 +29,10 @@ const constants = { accountTag: "data-account", hideModalId: "boson-hide-modal", hideModalMessage: "boson-close-iframe", + deliveryInfoMessage: "boson-delivery-info", + deliveryInfoMessageResponse: "boson-delivery-info-response", + redemptionSubmittedMessage: "boson-redemption-submitted", + redemptionConfirmedMessage: "boson-redemption-confirmed", financeUrl: (widgetsHost) => `${widgetsHost}/#/finance`, redeemUrl: (widgetsHost) => `${widgetsHost}/#/redeem` }; @@ -81,8 +88,11 @@ const createIFrame = (src, onLoad) => { bosonModal.onload = onLoad; document.body.appendChild(bosonModal); }; +const getIFrame = () => { + return document.getElementById(constants.iFrameId); +}; const hideIFrame = () => { - const el = document.getElementById(constants.iFrameId); + const el = getIFrame(); if (el) { el.remove(); } @@ -152,6 +162,13 @@ function bosonWidgetReload() { ?.value; const configId = showRedeemId.attributes[constants.configIdTag]?.value; const account = showRedeemId.attributes[constants.accountTag]?.value; + const targetOrigin = + showRedeemId.attributes[constants.dataTargetOrigin]?.value; + const shouldWaitForResponse = + showRedeemId.attributes[constants.dataWaitForResponse]?.value; + const sendDeliveryInfoThroughXMTP = + showRedeemId.attributes[constants.dataSendDeliveryInfoXMTP]?.value; + bosonWidgetShowRedeem({ exchangeId, sellerId, @@ -167,7 +184,10 @@ function bosonWidgetReload() { postRedemptionConfirmedUrl, postRedemptionConfirmedHeaders, configId, - account + account, + targetOrigin, + shouldWaitForResponse, + sendDeliveryInfoThroughXMTP }); }; } @@ -211,7 +231,13 @@ function bosonWidgetShowRedeem(args) { value: args.postRedemptionConfirmedHeaders }, { tag: "configId", value: args.configId }, - { tag: "account", value: args.account } + { tag: "account", value: args.account }, + { tag: "targetOrigin", value: args.targetOrigin }, + { tag: "shouldWaitForResponse", value: args.shouldWaitForResponse }, + { + tag: "sendDeliveryInfoThroughXMTP", + value: args.sendDeliveryInfoThroughXMTP + } ]); showLoading(); hideIFrame(); diff --git a/src/components/widgets/redeem/Redeem.tsx b/src/components/widgets/redeem/Redeem.tsx index 3f7b37e..12816ea 100644 --- a/src/components/widgets/redeem/Redeem.tsx +++ b/src/components/widgets/redeem/Redeem.tsx @@ -4,6 +4,7 @@ import { RedemptionWidgetAction, subgraph } from "@bosonprotocol/react-kit"; +import { DeliveryInfoCallbackResponse } from "@bosonprotocol/react-kit/dist/cjs/hooks/callbacks/useRedemptionCallbacks"; import { useSearchParams } from "react-router-dom"; import { CONFIG, getMetaTxConfig } from "../../../config"; @@ -12,10 +13,10 @@ export const redeemPath = "/redeem"; export function Redeem() { const [searchParams] = useSearchParams(); const exchangeId = searchParams.get("exchangeId") || undefined; - const showRedemptionOverviewStr = searchParams.get("showRedemptionOverview"); - const showRedemptionOverview = showRedemptionOverviewStr - ? /^true$/i.test(showRedemptionOverviewStr) - : true; // default value + const showRedemptionOverview = extractBooleanParam( + searchParams.get("showRedemptionOverview"), + true + ); const widgetAction: RedemptionWidgetAction = checkWidgetAction( searchParams.get("widgetAction") || undefined ); @@ -36,6 +37,15 @@ export function Redeem() { ); } } + const sendDeliveryInfoThroughXMTP = extractBooleanParam( + searchParams.get("sendDeliveryInfoThroughXMTP"), + true + ); + const targetOrigin = searchParams.get("targetOrigin") || undefined; + const shouldWaitForResponse = extractBooleanParam( + searchParams.get("shouldWaitForResponse"), + false + ); const postDeliveryInfoUrl = searchParams.get("postDeliveryInfoUrl") || undefined; @@ -96,9 +106,15 @@ export function Redeem() { ? [sellerId] : undefined; + // In case the deliveryInfo shall be transferred between frontend windows, the targetOrigin + // the deliveryInfo message shall be posted to + // (see https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#targetorigin) + // deliveryInfoTargetOrigin?: string; + return ( { + try { + const event = { + type: "boson-delivery-info", + message + }; + // precaution: register to the response before posting the message + const responseType = "boson-delivery-info-response"; + const _waitForResponse = shouldWaitForResponse + ? (waitForResponse(responseType) as Promise<{ + message: DeliveryInfoCallbackResponse; + origin: string; + }>) + : undefined; + // post the message + console.log( + `Post '${event.type}' message to '${targetOrigin}'` + ); + window.parent.postMessage(event, targetOrigin); + if (_waitForResponse) { + console.log(`Wait for response '${responseType}' message`); + const response = await _waitForResponse; + console.log( + `Received response '${responseType}' from '${ + response.origin + }'. Content: '${JSON.stringify( + JSON.stringify(response.message) + )}'` + ); + return response.message; + } + return { + accepted: true, + resume: true, + reason: "" + }; + } catch (e) { + console.error(`Unable to post message ${e}`); + return { + accepted: false, + reason: "", + resume: false + }; + } + } + : undefined + } + redemptionSubmittedHandler={ + targetOrigin + ? async (message) => { + try { + const event = { + type: "boson-redemption-submitted", + message + }; + // post the message + console.log(`Post ${event.type} message to ${targetOrigin}`); + window.parent.postMessage(event, targetOrigin); + return { + accepted: true, + reason: "" + }; + } catch (e) { + console.error(`Unable to post message ${e}`); + return { + accepted: false, + reason: "" + }; + } + } + : undefined + } + redemptionConfirmedHandler={ + targetOrigin + ? async (message) => { + try { + const event = { + type: "boson-redemption-confirmed", + message + }; + // post the message + console.log(`Post ${event.type} message to ${targetOrigin}`); + window.parent.postMessage(event, targetOrigin); + return { + accepted: true, + reason: "" + }; + } catch (e) { + console.error(`Unable to post message ${e}`); + return { + accepted: false, + reason: "" + }; + } + } + : undefined + } modalMargin="2%" widgetAction={widgetAction} deliveryInfo={deliveryInfoDecoded} @@ -190,3 +305,32 @@ function checkExchangeState( } throw new Error(`Not supported exchange state '${exchangeStateStr}'`); } + +function extractBooleanParam( + paramStr: string | null, + defaultValue: boolean +): boolean { + return paramStr ? /^true$/i.test(paramStr) : defaultValue; +} + +async function waitForResponse( + response: string +): Promise<{ message: unknown; origin: string }> { + return new Promise<{ message: unknown; origin: string }>( + (resolve, reject) => { + const eventType = "message"; + const listener = (event: MessageEvent | undefined) => { + try { + if (event?.data?.type === response) { + // Ensure the listener won't be called again. Do not use "once" option because some other message could be received in the meanwhile + window.removeEventListener(eventType, listener); + resolve({ message: event.data.message, origin: event.origin }); + } + } catch (e) { + reject(e); + } + }; + window.addEventListener(eventType, listener); + } + ); +} diff --git a/tests/www/test1.html b/tests/www/test1.html new file mode 100644 index 0000000..831128a --- /dev/null +++ b/tests/www/test1.html @@ -0,0 +1,23 @@ + + + + Widget Integration Example + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/www/test2.html b/tests/www/test2.html new file mode 100644 index 0000000..b3bfa87 --- /dev/null +++ b/tests/www/test2.html @@ -0,0 +1,65 @@ + + + + Widget Integration Example + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/www/test3.html b/tests/www/test3.html new file mode 100644 index 0000000..a4e35da --- /dev/null +++ b/tests/www/test3.html @@ -0,0 +1,22 @@ + + + + Widget Integration Example + + + + + + + + + + + \ No newline at end of file