From 28b9f495dfeb926ada9d1469ee89eeea0d1be784 Mon Sep 17 00:00:00 2001 From: Ludovic Levalleux Date: Tue, 4 Nov 2025 15:12:44 +0000 Subject: [PATCH 1/6] feat: allow creating price discovery offers --- src/components/product/TermsOfExchange.tsx | 29 ++++++--- .../coreTermsOfSale/CoreTermsOfSale.tsx | 60 +++++++++++++++---- src/components/product/utils/initialValues.ts | 1 + .../product/utils/useInitialValues.ts | 3 + .../product/utils/validationSchema.ts | 4 +- .../seller/products/SellerProductsTable.tsx | 38 ++++++++++++ .../create-product/CreateProductInner.tsx | 5 +- .../utils/getOfferDataFromMetadata.ts | 4 ++ 8 files changed, 124 insertions(+), 20 deletions(-) diff --git a/src/components/product/TermsOfExchange.tsx b/src/components/product/TermsOfExchange.tsx index f97a5aa34..8e3afb06e 100644 --- a/src/components/product/TermsOfExchange.tsx +++ b/src/components/product/TermsOfExchange.tsx @@ -108,22 +108,37 @@ export default function TermsOfExchange() { ); const decimals = exchangeToken?.decimals; const step = 10 ** -(decimals || 0); + const optionsUnitCurrencyOnly = useMemo( + () => ({ + value: optionUnitKeys.fixed, + label: currency + }), + [currency] + ); const optionsUnitWithCurrency = useMemo( () => OPTIONS_UNIT.map((option) => { if (option.value === optionUnitKeys.fixed) { - return { - value: option.value, - label: currency - }; + return optionsUnitCurrencyOnly; } return option; }), - [currency] + [optionsUnitCurrencyOnly] ); + const isPriceDiscoveryOffer = + !isMultiVariant && !!values["coreTermsOfSale"].isPriceDiscoveryOffer; const optionsUnitToShow = useMemo(() => { - return isMultiVariant ? PERCENT_OPTIONS_UNIT : optionsUnitWithCurrency; - }, [isMultiVariant, optionsUnitWithCurrency]); + return isMultiVariant + ? PERCENT_OPTIONS_UNIT + : isPriceDiscoveryOffer + ? [optionsUnitCurrencyOnly] + : optionsUnitWithCurrency; + }, [ + isMultiVariant, + optionsUnitWithCurrency, + optionsUnitCurrencyOnly, + isPriceDiscoveryOffer + ]); useEffect(() => { const buyerUnit = ( optionsUnitToShow as { value: string; label: string }[] diff --git a/src/components/product/coreTermsOfSale/CoreTermsOfSale.tsx b/src/components/product/coreTermsOfSale/CoreTermsOfSale.tsx index 51af97f5f..07f2774fd 100644 --- a/src/components/product/coreTermsOfSale/CoreTermsOfSale.tsx +++ b/src/components/product/coreTermsOfSale/CoreTermsOfSale.tsx @@ -1,4 +1,8 @@ import { useConfigContext } from "components/config/ConfigContext"; +import { SwitchForm } from "components/form/Switch"; +import { Typography } from "components/ui/Typography"; +import { colors } from "lib/styles/colors"; +import { useEffect } from "react"; import styled from "styled-components"; import { useForm } from "../../../lib/utils/hooks/useForm"; @@ -23,6 +27,10 @@ const ProductInformationButtonGroup = styled(ProductButtonGroup)` margin-top: 1.563px; `; +const gridProps = { + justifyContent: "flex-end" +} as const; + interface Props { isMultiVariant: boolean; } @@ -31,10 +39,18 @@ export default function CoreTermsOfSale({ isMultiVariant }: Props) { const { config } = useConfigContext(); const OPTIONS_CURRENCIES = getOptionsCurrencies(config.envConfig); - const { nextIsDisabled } = useForm(); + const { values, setFieldValue, nextIsDisabled } = useForm(); const prefix = isMultiVariant ? "variantsCoreTermsOfSale" : "coreTermsOfSale"; + const isPriceDiscoveryOffer = + !isMultiVariant && !!values["coreTermsOfSale"].isPriceDiscoveryOffer; + useEffect(() => { + if (isPriceDiscoveryOffer) { + setFieldValue(`${prefix}.price`, 0); + } + }, [isPriceDiscoveryOffer, setFieldValue, prefix]); + return ( Core Terms of Sale @@ -43,18 +59,40 @@ export default function CoreTermsOfSale({ isMultiVariant }: Props) { ( + + use a price discovery mechanism + + )} + /> + } > -
- -
+ {!isPriceDiscoveryOffer && ( +
+ +
+ )}
+ + + {!isRangeReserved && ( +
+ 0 ? "1" : "0"} + step="1" + max={`${availableQuantity}`} + /> +
+ )} +
+ + {isRangeReserved && ( +
+ 0 ? "1" : "0"} + step="1" + max={values.rangeLength - alreadyMinted} + /> +
+ )} +
+ {children} + + ); +}; diff --git a/src/components/modal/components/PremintVouchersModal/PremintVouchersModal.tsx b/src/components/modal/components/PremintVouchersModal/PremintVouchersModal.tsx new file mode 100644 index 000000000..b1c929ecb --- /dev/null +++ b/src/components/modal/components/PremintVouchersModal/PremintVouchersModal.tsx @@ -0,0 +1,200 @@ +import { + PreMintButton, + Provider, + ReserveRangeButton, + Typography +} from "@bosonprotocol/react-kit"; +import { useConfigContext } from "components/config/ConfigContext"; +import { Form, Formik } from "formik"; +import { colors } from "lib/styles/colors"; +import { Offer } from "lib/types/offer"; +import { useSigner } from "lib/utils/hooks/connection/connection"; +import React, { useEffect, useState } from "react"; +import styled from "styled-components"; + +import SimpleError from "../../../error/SimpleError"; +import { useModal } from "../../useModal"; +import { FormType, validationSchemas } from "./form"; +import { PremintVouchersForm } from "./PremintVouchersForm"; + +interface PremintVouchersModalProps { + offer?: Offer; + offerId?: string; + refetch: () => void; +} + +const PremintButtonWrapper = styled.div` + button { + background: transparent; + border-color: ${colors.orange}; + color: ${colors.orange}; + &:hover { + background: ${colors.orange}; + border-color: ${colors.orange}; + color: ${colors.white}; + } + } +`; + +const ReserveRangeButtonWrapper = styled.div` + button { + background: transparent; + border-color: ${colors.orange}; + color: ${colors.orange}; + &:hover { + background: ${colors.orange}; + border-color: ${colors.orange}; + color: ${colors.white}; + } + } +`; + +export const PremintVouchersModal: React.FC = ({ + offerId, + offer, + refetch +}) => { + const [hasError, setError] = useState(false); + const [isLoading, setLoading] = useState(false); + const { hideModal } = useModal(); + const signer = useSigner(); + const { config } = useConfigContext(); + + // If the offer doesnt have reserved range, the seller shall specify the quantity to be reserved, then the ReserveRange button shall be clicked + // When the offer has already a reserved range, we prefill the form's rangeQuantity it can't be changed + // The number of vouchers to be preminted is specified in the premintQuantity field and can't exceed the reserved range length + the number of already minted vouchers + + const [rangeLength, setRangeLength] = useState(0); + const [minted, setMinted] = useState(0); + const rangeToContract = + offer?.range && + offer.collection && + offer.range.owner === offer.collection.collectionContract.address; + const initialValues = { + to: rangeToContract + ? { value: "contract", label: "voucher contract", disabled: false } + : { value: "seller", label: "seller wallet", disabled: false }, + rangeLength: rangeLength || Number(offer?.quantityAvailable || "1"), + premintQuantity: rangeLength - minted + }; + const [values, setValues] = useState(initialValues); + useEffect(() => { + if (offer?.range) { + setRangeLength(Number(offer.range.end) - Number(offer.range.start) + 1); + setMinted(Number(offer.range.minted)); + } + }, [offer?.range]); + + return ( +
+ What are preminted vouchers? + + The seller can premint one or more vouchers for an offer. A preminted + voucher is an NFT that can be traded on an NFT marketplace. When a buyer + acquires the NFT, the resulting NFT transfer is causing the offer to be + committed to on behalf of the buyer. + +

+ + initialValues={initialValues} + onSubmit={async (_values) => { + console.log("Submitting form with values: ", _values); + // setValues(_values); + }} + validate={async (_values) => { + console.log("Validating form with values: ", _values); + setValues(_values); + }} + validateOnMount + validationSchema={ + rangeLength > 0 + ? validationSchemas.premintVouchers + : validationSchemas.reserveRange + } + enableReinitialize + validateOnChange + > + {() => { + return ( +
+ 0} + alreadyMinted={minted} + availableQuantity={Number(offer?.quantityAvailable || "0")} + onValidityChanged={(isValid) => { + setError(!isValid); + }} + > + {hasError && } + {rangeLength === 0 ? ( + + { + setLoading(true); + }} + onPendingTransaction={() => { + setLoading(true); + }} + onSuccess={async (_, { length }) => { + setLoading(false); + refetch(); + setRangeLength(length); + }} + onError={(error) => { + setLoading(false); + console.error(error); + setError(true); + }} + /> + + ) : ( + + { + setLoading(true); + }} + onPendingTransaction={() => { + setLoading(true); + }} + onSuccess={async () => { + setLoading(false); + refetch(); + hideModal(); + }} + onError={() => { + setLoading(false); + setError(true); + }} + /> + + )} + +
+ ); + }} + +
+ ); +}; diff --git a/src/components/modal/components/PremintVouchersModal/form.ts b/src/components/modal/components/PremintVouchersModal/form.ts new file mode 100644 index 000000000..f740bc004 --- /dev/null +++ b/src/components/modal/components/PremintVouchersModal/form.ts @@ -0,0 +1,26 @@ +import * as Yup from "yup"; + +export const validationSchemas = { + reserveRange: Yup.object({ + to: Yup.object({ + label: Yup.string().required(), + value: Yup.string().required(), + disabled: Yup.boolean() + }), + rangeLength: Yup.number() + .required() + .min(1, "Must be greater than 0") + .typeError("This is not a number") + }), + premintVouchers: Yup.object({ + premintQuantity: Yup.number() + .required() + .min(1, "Must be greater than 0") + .typeError("This is not a number") + }) +}; + +export type FormType = Yup.InferType< + typeof validationSchemas.premintVouchers & + typeof validationSchemas.reserveRange +>; diff --git a/src/components/seller/products/SellerProductsTable.tsx b/src/components/seller/products/SellerProductsTable.tsx index 4f30af30d..97ad95990 100644 --- a/src/components/seller/products/SellerProductsTable.tsx +++ b/src/components/seller/products/SellerProductsTable.tsx @@ -909,13 +909,13 @@ export default function SellerProductsTable({ showModal( modalTypes.PREMINT_VOUCHERS, { - title: "Premint vouchers", - productUuid: offer.additional?.product.uuid, - version: offer.additional?.product.version, - sellerSalesChannels: salesChannels, + title: "Premint Vouchers", + offerId: offer.id, + offer, + refetch, onClose: () => { setTimeout(() => { - refetchSellers(); + refetch(); }, 10000); // give enough time to the subgraph to reindex } }, From 90370be62aaa1c997645be6329085045febd7125 Mon Sep 17 00:00:00 2001 From: Ludovic Levalleux Date: Wed, 5 Nov 2025 15:53:57 +0000 Subject: [PATCH 4/6] upgrade core-components dependency --- package-lock.json | 63 ++++++++++++++++++++++++++++++++++++++--------- package.json | 4 +-- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 839424949..17fc41b22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "dependencies": { "@apollo/client": "^3.8.1", "@bosonprotocol/chat-sdk": "^1.4.5", - "@bosonprotocol/common": "^1.32.0", - "@bosonprotocol/react-kit": "^0.41.0", + "@bosonprotocol/common": "^1.32.0-alpha.6", + "@bosonprotocol/react-kit": "^0.42.0-alpha.0", "@davatar/react": "^1.10.4", "@ethersproject/address": "^5.6.1", "@ethersproject/units": "^5.7.0", @@ -3424,6 +3424,19 @@ "yup": "^1.5.0" } }, + "node_modules/@bosonprotocol/chat-sdk/node_modules/@bosonprotocol/common": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@bosonprotocol/common/-/common-1.32.0.tgz", + "integrity": "sha512-YSMpjqPyBXw5WRYlpFKdwCQwyXgSWxNDF1OqTRwFU9wdO+8R/VdG4QV0YTi+uEuy/9rFLyQZvrbxwLNVO4ZdBg==", + "dependencies": { + "@bosonprotocol/metadata": "^1.16.3", + "@ethersproject/abi": "^5.5.0", + "@ethersproject/address": "^5.5.0", + "@ethersproject/bignumber": "^5.5.0", + "@ethersproject/constants": "^5.5.0", + "@ethersproject/units": "^5.5.0" + } + }, "node_modules/@bosonprotocol/chat-sdk/node_modules/@noble/curves": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.2.tgz", @@ -3566,9 +3579,9 @@ } }, "node_modules/@bosonprotocol/common": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/@bosonprotocol/common/-/common-1.32.0.tgz", - "integrity": "sha512-YSMpjqPyBXw5WRYlpFKdwCQwyXgSWxNDF1OqTRwFU9wdO+8R/VdG4QV0YTi+uEuy/9rFLyQZvrbxwLNVO4ZdBg==", + "version": "1.32.0-alpha.6", + "resolved": "https://registry.npmjs.org/@bosonprotocol/common/-/common-1.32.0-alpha.6.tgz", + "integrity": "sha512-fAJxG1XfmBIXPUFb5h7BP8QRkcyDVOqZpW5DwzKGltiWx0HCVgK66gUQosm+SYq30CVMhC34EJN/SwWpuwtLag==", "dependencies": { "@bosonprotocol/metadata": "^1.16.3", "@ethersproject/abi": "^5.5.0", @@ -3579,9 +3592,9 @@ } }, "node_modules/@bosonprotocol/core-sdk": { - "version": "1.45.0", - "resolved": "https://registry.npmjs.org/@bosonprotocol/core-sdk/-/core-sdk-1.45.0.tgz", - "integrity": "sha512-HmDFEmQZB9kWwFl8esVfrgfu1bB3ApjrJ31Rg3FNVoSnswTR2RfSx+5q6VwZAMBKRzNs08k2kbDkjuzdS3537Q==", + "version": "1.46.0-alpha.0", + "resolved": "https://registry.npmjs.org/@bosonprotocol/core-sdk/-/core-sdk-1.46.0-alpha.0.tgz", + "integrity": "sha512-8mE6qjDoxqnks7CwxcK2Di2BdyOHvCGJVjexJvG0pmDjSUWQ0YXJxbJjQnSiGSy4TemsIc/zIyq5cX+iXaeyZg==", "dependencies": { "@bosonprotocol/common": "^1.32.0", "@ethersproject/abi": "^5.5.0", @@ -3598,6 +3611,19 @@ "schema-to-yup": "^1.11.11" } }, + "node_modules/@bosonprotocol/core-sdk/node_modules/@bosonprotocol/common": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@bosonprotocol/common/-/common-1.32.0.tgz", + "integrity": "sha512-YSMpjqPyBXw5WRYlpFKdwCQwyXgSWxNDF1OqTRwFU9wdO+8R/VdG4QV0YTi+uEuy/9rFLyQZvrbxwLNVO4ZdBg==", + "dependencies": { + "@bosonprotocol/metadata": "^1.16.3", + "@ethersproject/abi": "^5.5.0", + "@ethersproject/address": "^5.5.0", + "@ethersproject/bignumber": "^5.5.0", + "@ethersproject/constants": "^5.5.0", + "@ethersproject/units": "^5.5.0" + } + }, "node_modules/@bosonprotocol/ethers-sdk": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/@bosonprotocol/ethers-sdk/-/ethers-sdk-1.17.0.tgz", @@ -3609,6 +3635,19 @@ "ethers": "^5.5.0" } }, + "node_modules/@bosonprotocol/ethers-sdk/node_modules/@bosonprotocol/common": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@bosonprotocol/common/-/common-1.32.0.tgz", + "integrity": "sha512-YSMpjqPyBXw5WRYlpFKdwCQwyXgSWxNDF1OqTRwFU9wdO+8R/VdG4QV0YTi+uEuy/9rFLyQZvrbxwLNVO4ZdBg==", + "dependencies": { + "@bosonprotocol/metadata": "^1.16.3", + "@ethersproject/abi": "^5.5.0", + "@ethersproject/address": "^5.5.0", + "@ethersproject/bignumber": "^5.5.0", + "@ethersproject/constants": "^5.5.0", + "@ethersproject/units": "^5.5.0" + } + }, "node_modules/@bosonprotocol/ipfs-storage": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/@bosonprotocol/ipfs-storage/-/ipfs-storage-1.13.0.tgz", @@ -3639,12 +3678,12 @@ "license": "Apache-2.0" }, "node_modules/@bosonprotocol/react-kit": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@bosonprotocol/react-kit/-/react-kit-0.41.0.tgz", - "integrity": "sha512-Z6NRR8HwU9GLlFoMDi2YXpHiA6yWu+LQEbQx6US4wE9IPFeef3oig5WzwnJjqfTCxbB/ozUkEpa0UkM37tJFtA==", + "version": "0.42.0-alpha.0", + "resolved": "https://registry.npmjs.org/@bosonprotocol/react-kit/-/react-kit-0.42.0-alpha.0.tgz", + "integrity": "sha512-hqiq0SpiJpioc7IOVtHnsAIOkvBqQ9l0pSt85dZISZl8FGU+OEC5a+bAVj8fWT8qNYapWCHdMbiD+2RIhsOW8w==", "dependencies": { "@bosonprotocol/chat-sdk": "^1.3.1-alpha.20", - "@bosonprotocol/core-sdk": "^1.45.0", + "@bosonprotocol/core-sdk": "^1.46.0-alpha.0", "@bosonprotocol/ethers-sdk": "^1.17.0", "@bosonprotocol/ipfs-storage": "^1.13.0", "@davatar/react": "1.11.1", diff --git a/package.json b/package.json index 513760b17..04fa77539 100644 --- a/package.json +++ b/package.json @@ -47,8 +47,8 @@ "dependencies": { "@apollo/client": "^3.8.1", "@bosonprotocol/chat-sdk": "^1.4.5", - "@bosonprotocol/common": "^1.32.0", - "@bosonprotocol/react-kit": "^0.41.0", + "@bosonprotocol/common": "^1.32.0-alpha.6", + "@bosonprotocol/react-kit": "^0.42.0-alpha.0", "@davatar/react": "^1.10.4", "@ethersproject/address": "^5.6.1", "@ethersproject/units": "^5.7.0", From 257de7296f45b2628debb8ae180f88577fbc7c51 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:20:37 +0000 Subject: [PATCH 5/6] feat: allow creating price discovery offers (#1154) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> From 5360405368cbb0a5c548a4bacfba83fa437ffb71 Mon Sep 17 00:00:00 2001 From: Ludovic Levalleux Date: Thu, 6 Nov 2025 13:37:55 +0000 Subject: [PATCH 6/6] adapt offer preview --- src/components/product/utils/usePreviewOffers.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/product/utils/usePreviewOffers.ts b/src/components/product/utils/usePreviewOffers.ts index cce9a0329..b5151ebfe 100644 --- a/src/components/product/utils/usePreviewOffers.ts +++ b/src/components/product/utils/usePreviewOffers.ts @@ -1,3 +1,4 @@ +import { PriceType } from "@bosonprotocol/common"; import { ProductV1Metadata, subgraph } from "@bosonprotocol/react-kit"; import { useConfigContext } from "components/config/ConfigContext"; import { Token } from "components/convertion-rate/ConvertionRateContext"; @@ -245,7 +246,10 @@ export const usePreviewOffers = ({ }, productV1Seller }, - condition + condition, + priceType: values.coreTermsOfSale.isPriceDiscoveryOffer + ? PriceType.Discovery + : PriceType.Static } as unknown as Offer; return offer; },