From cce10a7143089d23a85aea3511fcc32f4499eb5c Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 3 Sep 2025 17:06:29 -0700 Subject: [PATCH] fix: onboarding flow and versioning --- .../attach/checkout/previewToCheckoutRes.ts | 2 +- .../src/internal/customers/getFullCusQuery.ts | 2 +- .../internal/customers/internalCusRouter.ts | 8 +- .../previewItemUtils/getItemsForNewProduct.ts | 11 +- .../products/entitlements/entitlementUtils.ts | 7 +- .../products/internalProductRouter.ts | 134 +++++++---- .../product-items/productItemUtils.ts | 21 -- .../src/internal/products/productV2Utils.ts | 14 +- shared/utils/index.ts | 8 + shared/utils/productUtils/convertUtils.ts | 53 +++++ shared/utils/productV2Utils/mapToProductV2.ts | 57 +++++ .../productItemUtils}/mapToItem.ts | 27 ++- .../productItemUtils/productItemUtils.ts | 46 ++++ .../src/components/autumn/checkout-dialog.tsx | 5 +- vite/src/components/autumn/pricing-table.tsx | 13 +- .../product-item/validateProductItem.ts | 2 - .../AttachProductDropdown.tsx} | 9 +- .../CustomerProductList.tsx | 4 +- .../customer/product/CustomerProductView.tsx | 7 +- .../customer/product/hooks/useAttachState.tsx | 2 +- .../product/hooks/useCusProductCache.tsx | 68 ++++-- .../product/hooks/useCusProductQuery.tsx | 21 +- vite/src/views/developer/DevView.tsx | 11 +- .../onboarding-steps/ProductList.tsx | 2 +- .../views/onboarding2/ConnectStripeDialog.tsx | 20 +- .../src/views/onboarding2/OnboardingView2.tsx | 95 ++++---- vite/src/views/onboarding2/SampleApp.tsx | 32 ++- .../hooks/useOnboardingQueryState.tsx | 21 ++ .../integrate/ConnectStripeStep.tsx | 15 +- .../onboarding2/integrate/IntegrateAutumn.tsx | 20 +- .../model-pricing/AddTrialButton.tsx | 10 +- .../onboarding2/model-pricing/EditProduct.tsx | 69 +++--- .../model-pricing/EditProductOld.tsx | 182 --------------- .../model-pricing/ModelPricing.tsx | 209 ++++++++++-------- .../model-pricing/ModelPricingContext.tsx | 10 +- .../model-pricing/SelectEditProduct.tsx | 20 +- .../edit-product/EditProductDetails.tsx | 16 +- .../model-pricing-utils/modelPricingUtils.ts | 8 +- .../views/products/product/ProductProps.tsx | 13 +- .../views/products/product/ProductSidebar.tsx | 4 +- .../products/product/ProductVersions.tsx | 44 ++-- .../views/products/product/ProductView.tsx | 28 +-- .../components/UpdateProductButton.tsx | 51 ++--- .../product/free-trial/CreateFreeTrial.tsx | 33 +-- .../hooks/queries/useMigrationsQuery.tsx.tsx | 18 ++ .../hooks/queries/useProductCountsQuery.tsx | 29 +++ .../product/hooks/useProductQuery.tsx | 30 ++- .../product-item/CreateProductItem2.tsx | 1 - .../item-config-footer/ItemConfigFooter.tsx | 24 +- .../products/product/utils/updateProduct.ts | 16 +- .../versioning/ConfirmNewVersionDialog.tsx | 27 ++- .../product/versioning/CountAndMigrate.tsx | 47 ++-- .../views/products/products/ProductsPage.tsx | 2 +- ...ateProduct.tsx => CreateProductDialog.tsx} | 4 +- .../DeleteProductDialog.tsx | 16 +- 55 files changed, 865 insertions(+), 783 deletions(-) create mode 100644 shared/utils/productUtils/convertUtils.ts create mode 100644 shared/utils/productV2Utils/mapToProductV2.ts rename {server/src/internal/products/product-items => shared/utils/productV2Utils/productItemUtils}/mapToItem.ts (83%) create mode 100644 shared/utils/productV2Utils/productItemUtils/productItemUtils.ts rename vite/src/views/customers/customer/{add-product/NewProductDropdown.tsx => customer-product-list/AttachProductDropdown.tsx} (97%) create mode 100644 vite/src/views/onboarding2/hooks/useOnboardingQueryState.tsx delete mode 100644 vite/src/views/onboarding2/model-pricing/EditProductOld.tsx create mode 100644 vite/src/views/products/product/hooks/queries/useMigrationsQuery.tsx.tsx create mode 100644 vite/src/views/products/product/hooks/queries/useProductCountsQuery.tsx rename vite/src/views/products/products/components/{CreateProduct.tsx => CreateProductDialog.tsx} (97%) diff --git a/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts b/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts index e1fa4a686..51d94ea6e 100644 --- a/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts +++ b/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts @@ -21,7 +21,7 @@ import { getProductItemResponse, getProductResponse, } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; -import { toProductItem } from "@/internal/products/product-items/mapToItem.js"; +import { toProductItem } from "@autumn/shared"; import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js"; import { formatUnixToDateTime, notNullish } from "@/utils/genUtils.js"; import { isPriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js"; diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index ffc5a3bdc..472faa6d2 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -377,7 +377,7 @@ export const getPaginatedFullCusQuery = ({ WHERE c.org_id = ${orgId} AND c.env = ${env} ${ - internalCustomerIds + internalCustomerIds && internalCustomerIds.length > 0 ? sql`AND c.internal_id IN (${sql.join( internalCustomerIds.map((id) => sql`${id}`), sql`, ` diff --git a/server/src/internal/customers/internalCusRouter.ts b/server/src/internal/customers/internalCusRouter.ts index cf989e7aa..540268a84 100644 --- a/server/src/internal/customers/internalCusRouter.ts +++ b/server/src/internal/customers/internalCusRouter.ts @@ -7,24 +7,18 @@ import { CusProductStatus, ErrCode, FullCusProduct, - FullCustomerEntitlement, - FullCustomerPrice, productToCusProduct, } from "@autumn/shared"; import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js"; -import { RewardService } from "../rewards/RewardService.js"; import { EventService } from "../api/events/EventService.js"; import { createStripeCli } from "@/external/stripe/utils.js"; -import { getLatestProducts } from "../products/productUtils.js"; -import { getProductVersionCounts } from "../products/productUtils.js"; -import { notNullish, nullish } from "@/utils/genUtils.js"; import { mapToProductV2 } from "../products/productV2Utils.js"; import { RewardRedemptionService } from "../rewards/RewardRedemptionService.js"; import { CusReadService } from "./CusReadService.js"; import { StatusCodes } from "http-status-codes"; import { cusProductToProduct } from "@autumn/shared"; -import { createOrgResponse, isStripeConnected } from "../orgs/orgUtils.js"; +import { isStripeConnected } from "../orgs/orgUtils.js"; import { routeHandler } from "@/utils/routerUtils.js"; import { CusSearchService } from "./CusSearchService.js"; import { CusBatchService } from "../api/batch/CusBatchService.js"; diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts index d71ba5cf9..1d93192f3 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts @@ -33,19 +33,12 @@ import { import { newPriceToInvoiceDescription } from "../invoiceFormatUtils.js"; import { calculateProrationAmount } from "../prorationUtils.js"; import { getPricecnPrice } from "../../products/pricecn/pricecnUtils.js"; -import { toProductItem } from "../../products/product-items/mapToItem.js"; +import { toProductItem } from "@autumn/shared"; import { formatAmount } from "@/utils/formatUtils.js"; -import { - formatUnixToDate, - formatUnixToDateTime, - formatUnixToUTCDateTime, - notNullish, -} from "@/utils/genUtils.js"; +import { formatUnixToDate, notNullish } from "@/utils/genUtils.js"; import { addBillingIntervalUnix, addIntervalForProration, - getAlignedIntervalUnix, - subtractBillingIntervalUnix, subtractFromUnixTillAligned, subtractIntervalForProration, } from "../../products/prices/billingIntervalUtils.js"; diff --git a/server/src/internal/products/entitlements/entitlementUtils.ts b/server/src/internal/products/entitlements/entitlementUtils.ts index 6d219532f..f621b7974 100644 --- a/server/src/internal/products/entitlements/entitlementUtils.ts +++ b/server/src/internal/products/entitlements/entitlementUtils.ts @@ -1,5 +1,5 @@ import RecaseError from "@/utils/errorUtils.js"; -import { generateId, notNullOrUndefined } from "@/utils/genUtils.js"; + import { EntInterval, FreeTrial, @@ -7,21 +7,16 @@ import { AllowanceType, EntitlementWithFeature, FeatureType, - CreateEntitlement, - CreateEntitlementSchema, Feature, ErrCode, UsagePriceConfig, PriceType, Price, FullProduct, - BillingType, FullEntitlement, } from "@autumn/shared"; import { addDays } from "date-fns"; -import { getBillingType } from "@/internal/products/prices/priceUtils.js"; -import { features } from "process"; export const entIntervalToTrialDuration = ({ interval, diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index 233f2d4a0..23e01fec1 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -24,6 +24,7 @@ import { sortFullProducts } from "./productUtils/sortProductUtils.js"; export const productRouter: Router = Router({ mergeParams: true }); +// Get list of products productRouter.get("/products", async (req: any, res) => { try { const { db } = req; @@ -49,6 +50,7 @@ productRouter.get("/products", async (req: any, res) => { } }); +// Get counts for all products productRouter.get("/product_counts", async (req: any, res) => { try { let { db } = req; @@ -90,6 +92,7 @@ productRouter.get("/product_counts", async (req: any, res) => { } }); +// Get list of features productRouter.get("/features", async (req: any, res) => { try { res.status(200).json({ features: req.features }); @@ -99,6 +102,7 @@ productRouter.get("/features", async (req: any, res) => { } }); +// Get list of rewards productRouter.get("/rewards", async (req: any, res) => { try { const { db, orgId, env } = req; @@ -119,19 +123,28 @@ productRouter.get("/rewards", async (req: any, res) => { } }); +// Get single product data productRouter.get("/:productId/data2", async (req: any, res) => { try { const { productId } = req.params; const { version } = req.query; const { db, orgId, env } = req; - const product = await ProductService.getFull({ - db, - idOrInternalId: productId, - orgId, - env, - version: version ? parseInt(version) : undefined, - }); + const [product, latestProduct] = await Promise.all([ + ProductService.getFull({ + db, + idOrInternalId: productId, + orgId, + env, + version: version ? parseInt(version) : undefined, + }), + ProductService.getFull({ + db, + idOrInternalId: productId, + orgId, + env, + }), + ]); if (!product) { throw new RecaseError({ @@ -148,13 +161,77 @@ productRouter.get("/:productId/data2", async (req: any, res) => { features: req.features, }); - res.status(200).json({ product: productV2 }); + res + .status(200) + .json({ product: productV2, numVersions: latestProduct.version }); } catch (error) { console.error("Failed to get product", error); res.status(500).send(error); } }); +// Get counts for a single product +productRouter.get("/:productId/count", async (req: any, res) => { + try { + const { db, orgId, env } = req; + const { productId } = req.params; + const { version } = req.query; + + const product = await ProductService.get({ + db, + id: productId, + orgId, + env, + version: version ? parseInt(version) : undefined, + }); + + if (!product) { + throw new RecaseError({ + message: `Product ${productId} ${ + version ? `(v${version})` : "" + } not found`, + code: ErrCode.ProductNotFound, + statusCode: StatusCodes.NOT_FOUND, + }); + } + + // Get counts from postgres + const counts = await CusProdReadService.getCounts({ + db, + internalProductId: product.internal_id, + }); + + res.status(200).send(counts); + } catch (error) { + handleFrontendReqError({ + error, + req, + res, + action: "Get product counts (internal)", + }); + } +}); + +// Get list of migrations +productRouter.get("/migrations", async (req: any, res) => { + try { + const { db, orgId, env } = req; + const migrations = await MigrationService.getExistingJobs({ + db, + orgId, + env, + }); + res.status(200).send({ migrations }); + } catch (error) { + handleFrontendReqError({ + error, + req, + res, + action: "Get migrations", + }); + } +}); + productRouter.get("/data", async (req: any, res) => { try { let { db } = req; @@ -395,47 +472,6 @@ productRouter.get("/:productId/data", async (req: any, res) => { } }); -productRouter.get("/:productId/count", async (req: any, res) => { - try { - const { db, orgId, env } = req; - const { productId } = req.params; - const { version } = req.query; - - const product = await ProductService.get({ - db, - id: productId, - orgId, - env, - version: version ? parseInt(version) : undefined, - }); - - if (!product) { - throw new RecaseError({ - message: `Product ${productId} ${ - version ? `(v${version})` : "" - } not found`, - code: ErrCode.ProductNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } - - // Get counts from postgres - const counts = await CusProdReadService.getCounts({ - db, - internalProductId: product.internal_id, - }); - - res.status(200).send(counts); - } catch (error) { - handleFrontendReqError({ - error, - req, - res, - action: "Get product counts (internal)", - }); - } -}); - productRouter.post("/product_options", async (req: any, res: any) => { try { const { items } = req.body; diff --git a/server/src/internal/products/product-items/productItemUtils.ts b/server/src/internal/products/product-items/productItemUtils.ts index edd44f375..98e778537 100644 --- a/server/src/internal/products/product-items/productItemUtils.ts +++ b/server/src/internal/products/product-items/productItemUtils.ts @@ -74,27 +74,6 @@ export const itemToPriceOrTiers = ({ } } }; -export const getItemFeatureType = ({ - item, - features, -}: { - item: ProductItem; - features: Feature[]; -}) => { - let feature = features.find((f) => f.id == item.feature_id); - - if (feature) { - if (feature.type == FeatureType.Boolean) { - return ProductItemFeatureType.Static; - } else if (feature.type == FeatureType.CreditSystem) { - return ProductItemFeatureType.SingleUse; - } else { - return feature.config?.usage_type; - } - } - - return undefined; -}; // FOR TESTS? export const constructFeatureItem = ({ diff --git a/server/src/internal/products/productV2Utils.ts b/server/src/internal/products/productV2Utils.ts index 4887da036..40d8cccc9 100644 --- a/server/src/internal/products/productV2Utils.ts +++ b/server/src/internal/products/productV2Utils.ts @@ -1,23 +1,15 @@ import { EntitlementWithFeature, Feature, - FeatureType, - FreeTrial, - FreeTrialResponseSchema, FullProduct, Price, ProductItem, - ProductItemInterval, - ProductItemResponseSchema, - ProductResponseSchema, ProductV2, } from "@autumn/shared"; import { getEntRelatedPrice } from "./entitlements/entitlementUtils.js"; import { getPriceEntitlement } from "./prices/priceUtils.js"; -import { toProductItem } from "./product-items/mapToItem.js"; -import { getItemFeatureType } from "./product-items/productItemUtils.js"; -import { sortProductItems } from "./pricecn/pricecnUtils.js"; -import { getItemType } from "./product-items/productItemUtils/getItemType.js"; +import { toProductItem } from "@autumn/shared"; +import { getItemFeatureType } from "@autumn/shared"; export const mapToProductItems = ({ prices, @@ -42,7 +34,7 @@ export const mapToProductItems = ({ let relatedEnt = getPriceEntitlement( price, entitlements, - allowFeatureMatch, + allowFeatureMatch ); if (!relatedEnt) { diff --git a/shared/utils/index.ts b/shared/utils/index.ts index db95691f7..cb46a24ba 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -7,3 +7,11 @@ export * from "./cusProductUtils/convertCusProduct.js"; export * from "./cusProductUtils/productIdToCusProduct.js"; export * from "./cusProductUtils/cusProductConstants.js"; export * from "./utils.js"; + +// Item utils +export * from "./productV2Utils/productItemUtils/mapToItem.js"; +export * from "./productV2Utils/productItemUtils/productItemUtils.js"; +export * from "./productV2Utils/mapToProductV2.js"; + +// Product utils +export * from "./productUtils/convertUtils.js"; diff --git a/shared/utils/productUtils/convertUtils.ts b/shared/utils/productUtils/convertUtils.ts new file mode 100644 index 000000000..68004a511 --- /dev/null +++ b/shared/utils/productUtils/convertUtils.ts @@ -0,0 +1,53 @@ +import { Entitlement } from "../../models/productModels/entModels/entModels.js"; +import { Price } from "../../models/productModels/priceModels/priceModels.js"; + +// export const getEntRelatedPrice = ( +// entitlement: Entitlement, +// prices: Price[], +// allowFeatureMatch = false +// ) => { +// return prices.find((price) => { +// if (price.config?.type === PriceType.Fixed) { +// return false; +// } + +// let config = price.config as UsagePriceConfig; + +// if (allowFeatureMatch) { +// return entitlement.internal_feature_id == config.internal_feature_id; +// } + +// let entIdMatch = entitlement.id == price.entitlement_id; +// let productIdMatch = +// entitlement.internal_product_id == price.internal_product_id; +// return entIdMatch && productIdMatch; +// }); +// }; + +export const entToPrice = ({ + ent, + prices, +}: { + ent: Entitlement; + prices: Price[]; +}) => { + return prices.find( + (price) => + price.entitlement_id === ent.id && + price.internal_product_id === ent.internal_product_id + ); +}; + +export const priceToEnt = ({ + price, + entitlements, +}: { + price: Price; + entitlements: Entitlement[]; +}) => { + return entitlements.find( + (ent) => + ent.id === price.entitlement_id && + ent.internal_product_id === price.internal_product_id + ); +}; diff --git a/shared/utils/productV2Utils/mapToProductV2.ts b/shared/utils/productV2Utils/mapToProductV2.ts new file mode 100644 index 000000000..eac2adfb6 --- /dev/null +++ b/shared/utils/productV2Utils/mapToProductV2.ts @@ -0,0 +1,57 @@ +import { Feature } from "../../models/featureModels/featureModels.js"; +import { FullProduct } from "../../models/productModels/productModels.js"; +import { ProductItem } from "../../models/productV2Models/productItemModels/productItemModels.js"; +import { ProductV2 } from "../../models/productV2Models/productV2Models.js"; +import { entToPrice, priceToEnt } from "../productUtils/convertUtils.js"; +import { toProductItem } from "./productItemUtils/mapToItem.js"; +import { getItemFeatureType } from "./productItemUtils/productItemUtils.js"; + +export const mapToProductV2 = ({ + product, + features, +}: { + product: FullProduct; + features?: Feature[]; +}): ProductV2 => { + let items: ProductItem[] = []; + + for (const ent of product.entitlements) { + let relatedPrice = entToPrice({ ent, prices: product.prices }); + items.push(toProductItem({ ent, price: relatedPrice })); + } + + for (const price of product.prices) { + let relatedEnt = priceToEnt({ price, entitlements: product.entitlements }); + + if (!relatedEnt) { + items.push(toProductItem({ price })); + } + } + + if (!features) { + features = product.entitlements.map((ent) => ent.feature); + } + + for (const item of items) { + item.feature_type = getItemFeatureType({ item, features }); + } + + let productV2: ProductV2 = { + internal_id: product.internal_id, + + id: product.id, + name: product.name, + is_add_on: product.is_add_on, + is_default: product.is_default, + version: product.version, + group: product.group, + free_trial: product.free_trial, + created_at: product.created_at, + + items: items, + stripe_id: product.processor?.id || null, + archived: product.archived || false, + }; + + return productV2; +}; diff --git a/server/src/internal/products/product-items/mapToItem.ts b/shared/utils/productV2Utils/productItemUtils/mapToItem.ts similarity index 83% rename from server/src/internal/products/product-items/mapToItem.ts rename to shared/utils/productV2Utils/productItemUtils/mapToItem.ts index a4a46cc0b..3da559b15 100644 --- a/server/src/internal/products/product-items/mapToItem.ts +++ b/shared/utils/productV2Utils/productItemUtils/mapToItem.ts @@ -1,23 +1,26 @@ +import { FeatureType } from "../../../models/featureModels/featureEnums.js"; import { AllowanceType, - BillWhen, EntitlementWithFeature, - FeatureType, - FixedPriceConfig, - Infinite, - Price, - ProductItem, - UsageModel, - TierInfinite, +} from "../../../models/productModels/entModels/entModels.js"; +import { FixedPriceConfig } from "../../../models/productModels/priceModels/priceConfig/fixedPriceConfig.js"; +import { + BillWhen, UsagePriceConfig, +} from "../../../models/productModels/priceModels/priceConfig/usagePriceConfig.js"; +import { Price } from "../../../models/productModels/priceModels/priceModels.js"; +import { Infinite } from "../../../models/productModels/productEnums.js"; +import { + ProductItem, ProductItemFeatureType, -} from "@autumn/shared"; - -import { nullish } from "@/utils/genUtils.js"; + TierInfinite, + UsageModel, +} from "../../../models/productV2Models/productItemModels/productItemModels.js"; +import { nullish } from "../../utils.js"; import { billingToItemInterval, entToItemInterval, -} from "./itemIntervalUtils.js"; +} from "./productItemUtils.js"; export const toProductItem = ({ ent, diff --git a/shared/utils/productV2Utils/productItemUtils/productItemUtils.ts b/shared/utils/productV2Utils/productItemUtils/productItemUtils.ts new file mode 100644 index 000000000..214317b76 --- /dev/null +++ b/shared/utils/productV2Utils/productItemUtils/productItemUtils.ts @@ -0,0 +1,46 @@ +import { FeatureType } from "../../../models/featureModels/featureEnums.js"; +import { Feature } from "../../../models/featureModels/featureModels.js"; +import { EntInterval } from "../../../models/productModels/entModels/entEnums.js"; +import { BillingInterval } from "../../../models/productModels/priceModels/priceEnums.js"; +import { + ProductItem, + ProductItemFeatureType, + ProductItemInterval, +} from "../../../models/productV2Models/productItemModels/productItemModels.js"; + +export const entToItemInterval = (entInterval: EntInterval) => { + if (entInterval == EntInterval.Lifetime) { + return null; + } + return entInterval as unknown as ProductItemInterval; +}; + +export const billingToItemInterval = (billingInterval: BillingInterval) => { + if (billingInterval == BillingInterval.OneOff) { + return null; + } + + return billingInterval as unknown as ProductItemInterval; +}; + +export const getItemFeatureType = ({ + item, + features, +}: { + item: ProductItem; + features: Feature[]; +}) => { + let feature = features.find((f) => f.id == item.feature_id); + + if (feature) { + if (feature.type == FeatureType.Boolean) { + return ProductItemFeatureType.Static; + } else if (feature.type == FeatureType.CreditSystem) { + return ProductItemFeatureType.SingleUse; + } else { + return feature.config?.usage_type; + } + } + + return undefined; +}; diff --git a/vite/src/components/autumn/checkout-dialog.tsx b/vite/src/components/autumn/checkout-dialog.tsx index a2ce9d12c..ddc89155c 100644 --- a/vite/src/components/autumn/checkout-dialog.tsx +++ b/vite/src/components/autumn/checkout-dialog.tsx @@ -50,8 +50,7 @@ export default function CheckoutDialog(params: CheckoutDialogProps) { const [checkoutResult, setCheckoutResult] = useState< CheckoutResult | undefined >(params?.checkoutResult); - - const { mutateAutumnProducts } = useModelPricingContext(); + const { refetch } = useModelPricingContext(); useEffect(() => { if (params.checkoutResult) { @@ -102,7 +101,7 @@ export default function CheckoutDialog(params: CheckoutDialogProps) { options, openInNewTab: true, }); - await mutateAutumnProducts(); + await refetch(); setOpen(false); setLoading(false); }} diff --git a/vite/src/components/autumn/pricing-table.tsx b/vite/src/components/autumn/pricing-table.tsx index a74d3115f..3de70caf1 100644 --- a/vite/src/components/autumn/pricing-table.tsx +++ b/vite/src/components/autumn/pricing-table.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { Loader2 } from "lucide-react"; import { createContext, useContext, useState } from "react"; import { cn } from "@/lib/utils"; @@ -7,21 +8,18 @@ import { Button } from "@/components/ui/button"; import CheckoutDialog from "@/components/autumn/checkout-dialog"; import { getPricingTableContent } from "@/lib/autumn/pricing-table-content"; import type { Product, ProductItem } from "autumn-js"; -import { Loader2 } from "lucide-react"; -import { toast } from "sonner"; + import { useCustomer } from "autumn-js/react"; -import { useModelPricingContext } from "@/views/onboarding2/model-pricing/ModelPricingContext"; +import { useOrg } from "@/hooks/common/useOrg"; export default function PricingTable({ products, - stripeConnected, setConnectStripeOpen, }: { products?: Product[]; - stripeConnected: boolean; setConnectStripeOpen: (open: boolean) => void; }) { - const { mutateAutumnProducts } = useModelPricingContext(); + const { org } = useOrg(); const { checkout } = useCustomer(); const [isAnnual, setIsAnnual] = useState(false); @@ -70,8 +68,7 @@ export default function PricingTable({ product.scenario === "scheduled", onClick: async () => { - console.log("Inside onClick function"); - if (!stripeConnected) { + if (!org.stripe_connected) { setConnectStripeOpen(true); return; } diff --git a/vite/src/utils/product/product-item/validateProductItem.ts b/vite/src/utils/product/product-item/validateProductItem.ts index 04a313bf8..7d3a92b29 100644 --- a/vite/src/utils/product/product-item/validateProductItem.ts +++ b/vite/src/utils/product/product-item/validateProductItem.ts @@ -144,8 +144,6 @@ export const validateProductItem = ({ item.tiers = finalTiers; } - console.log("Final tiers:", item.tiers); - // Validate billing units if (item.billing_units && invalidNumber(item.billing_units)) { toast.error("Please enter valid billing units"); diff --git a/vite/src/views/customers/customer/add-product/NewProductDropdown.tsx b/vite/src/views/customers/customer/customer-product-list/AttachProductDropdown.tsx similarity index 97% rename from vite/src/views/customers/customer/add-product/NewProductDropdown.tsx rename to vite/src/views/customers/customer/customer-product-list/AttachProductDropdown.tsx index cdd614858..854181b61 100644 --- a/vite/src/views/customers/customer/add-product/NewProductDropdown.tsx +++ b/vite/src/views/customers/customer/customer-product-list/AttachProductDropdown.tsx @@ -9,7 +9,7 @@ import { import React, { useState } from "react"; import { useCustomerContext } from "../CustomerContext"; import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { AddProductContext } from "./CreateCheckoutContext"; +import { AddProductContext } from "../add-product/CreateCheckoutContext"; import { Input } from "@/components/ui/input"; import { useNavigate } from "react-router"; @@ -23,7 +23,7 @@ import { useOrg } from "@/hooks/common/useOrg"; import { useCustomer } from "autumn-js/react"; import { useCusQuery } from "../hooks/useCusQuery"; -function AddProduct({ +function AttachProductDropdown({ setMultiAttachOpen, }: { setMultiAttachOpen: (open: boolean) => void; @@ -143,7 +143,7 @@ function AddProduct({ ); } -export default AddProduct; +export default AttachProductDropdown; const DropdownProductItem = ({ product, @@ -159,9 +159,8 @@ const DropdownProductItem = ({ onClick={async (e) => { e.stopPropagation(); e.preventDefault(); - setIsLoading(true); + await handleAddProduct(product.id, setIsLoading); - // setIsLoading(false); }} > {isLoading && } diff --git a/vite/src/views/customers/customer/customer-product-list/CustomerProductList.tsx b/vite/src/views/customers/customer/customer-product-list/CustomerProductList.tsx index 5ee0ee9a3..ffe052589 100644 --- a/vite/src/views/customers/customer/customer-product-list/CustomerProductList.tsx +++ b/vite/src/views/customers/customer/customer-product-list/CustomerProductList.tsx @@ -14,7 +14,6 @@ import { useState } from "react"; import { Badge } from "@/components/ui/badge"; import { AdminHover } from "@/components/general/AdminHover"; -import AddProduct from "../add-product/NewProductDropdown"; import { Item, Row } from "@/components/general/TableGrid"; import { cn } from "@/lib/utils"; @@ -25,6 +24,7 @@ import { MultiAttachDialog } from "../product/multi-attach/MultiAttachDialog"; import { useCusQuery } from "../hooks/useCusQuery"; import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { getVersionCounts } from "@/utils/productUtils"; +import AttachProductDropdown from "./AttachProductDropdown"; export const CustomerProductList = () => { const navigate = useNavigate(); @@ -125,7 +125,7 @@ export const CustomerProductList = () => { open={multiAttachOpen} setOpen={setMultiAttachOpen} /> - + diff --git a/vite/src/views/customers/customer/product/CustomerProductView.tsx b/vite/src/views/customers/customer/product/CustomerProductView.tsx index 0bac8a4bc..7fd762b59 100644 --- a/vite/src/views/customers/customer/product/CustomerProductView.tsx +++ b/vite/src/views/customers/customer/product/CustomerProductView.tsx @@ -62,8 +62,6 @@ export default function CustomerProductView() { const env = useEnv(); const initialProductRef = useRef(null); - const [product, setProduct] = useState(null); - const [options, setOptions] = useState([]); const [entityId, setEntityId] = useState(entityIdParam); const [entityFeatureIds, setEntityFeatureIds] = useState([]); @@ -77,6 +75,9 @@ export default function CustomerProductView() { isLoading, error, } = useCusProductQuery(); + const [product, setProduct] = useState( + originalProduct ?? null + ); const { isLoading: cusLoading } = useCusQuery(); @@ -133,7 +134,7 @@ export default function CustomerProductView() { ); } - if (isLoading || !product || cusLoading || orgLoading || featuresLoading) + if (isLoading || cusLoading || orgLoading || featuresLoading || !product) return ; if (!customer_id || !product_id) { diff --git a/vite/src/views/customers/customer/product/hooks/useAttachState.tsx b/vite/src/views/customers/customer/product/hooks/useAttachState.tsx index eab9fb0c6..5bf2fcea7 100644 --- a/vite/src/views/customers/customer/product/hooks/useAttachState.tsx +++ b/vite/src/views/customers/customer/product/hooks/useAttachState.tsx @@ -52,7 +52,7 @@ export const useAttachState = ({ product: ProductV2 | null; setProduct: (product: ProductV2) => void; - cusProduct: FullCusProduct | undefined; + cusProduct: FullCusProduct | undefined | null; initialProductRef: React.RefObject; }) => { const [preview, setPreview] = useState(null); diff --git a/vite/src/views/customers/customer/product/hooks/useCusProductCache.tsx b/vite/src/views/customers/customer/product/hooks/useCusProductCache.tsx index 1bcf69c4a..91a861d5e 100644 --- a/vite/src/views/customers/customer/product/hooks/useCusProductCache.tsx +++ b/vite/src/views/customers/customer/product/hooks/useCusProductCache.tsx @@ -1,9 +1,13 @@ import { useQueryClient } from "@tanstack/react-query"; import { ACTIVE_STATUSES, + cusProductToProduct, FullCusProduct, FullCustomer, + mapToProductV2, + notNullish, productToCusProduct, + ProductV2, } from "@autumn/shared"; export const useCusProductCache = ({ @@ -21,7 +25,30 @@ export const useCusProductCache = ({ }) => { const queryClient = useQueryClient(); - const getCachedCusProduct = (): FullCusProduct | null => { + const getSortedProductsQueries = () => { + const queryCache = queryClient.getQueryCache(); + const productsQuery = queryCache.findAll({ + queryKey: ["products"], + }); + + // Sort by most recently updated first to get the freshest data + const sortedQueries = productsQuery.sort((a, b) => { + const aTime = a.state.dataUpdatedAt || 0; + const bTime = b.state.dataUpdatedAt || 0; + return bTime - aTime; + }); + + return sortedQueries + .map((query) => query.state.data) + .filter(notNullish) as { + products: ProductV2[]; + }[]; + }; + + const getCachedCusProduct = (): { + cusProduct: FullCusProduct | null; + product: ProductV2; + } | null => { if (!customerId || !productId) return null; // Check all cached full customers queries @@ -65,26 +92,33 @@ export const useCusProductCache = ({ // inStatuses: ACTIVE_STATUSES, }); - console.log("Cached cus product:", cusProduct); + if (cusProduct) { + const fullProduct = cusProductToProduct({ cusProduct }); + const productV2 = mapToProductV2({ product: fullProduct }); + return { + cusProduct, + product: productV2, + }; + } } - - // if (cachedData?.fullCustomers) { - // const cachedCustomer = cachedData.fullCustomers.find( - // (cusProduct) => - // cusProduct.id === customerId || - // cusProduct.internal_customer_id === customerId - // ); - - // if (cachedCustomer) { - // return cachedCustomer; - // } - // } } } - // const productQueries = queryCache.findAll({ - // queryKey: ["products"], - // }); + const sortedProductsQueries = getSortedProductsQueries(); + + for (const query of sortedProductsQueries) { + const product = query.products.find( + (p) => + p.id === productId && + (queryStates.version ? p.version === queryStates.version : true) + ); + if (product) { + return { + cusProduct: null, + product, + }; + } + } return null; }; diff --git a/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx b/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx index de41f84ce..8f2bfc5c8 100644 --- a/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx +++ b/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx @@ -35,6 +35,9 @@ export const useCusProductQuery = () => { }; try { + console.log( + `Fetching customer product ${product_id} with version ${queryStates.version}` + ); const { data } = await axiosInstance.get( `/customers/${customer_id}/product/${product_id}`, { params: queryParams } @@ -49,14 +52,24 @@ export const useCusProductQuery = () => { cusProduct: FullCusProduct; product: ProductV2; }>({ - queryKey: ["customer_product", customer_id, product_id], + queryKey: [ + "customer_product", + customer_id, + product_id, + queryStates.version, + queryStates.customer_product_id, + queryStates.entity_id, + ], queryFn: fetcher, }); + const finalData = data || cachedCusProduct; + const isLoadingWithCache = cachedCusProduct ? false : isLoading; + return { - cusProduct: data?.cusProduct, - product: data?.product, - isLoading, + cusProduct: finalData?.cusProduct, + product: finalData?.product, + isLoading: isLoadingWithCache, error, refetch, }; diff --git a/vite/src/views/developer/DevView.tsx b/vite/src/views/developer/DevView.tsx index fed00f351..a9555ba76 100644 --- a/vite/src/views/developer/DevView.tsx +++ b/vite/src/views/developer/DevView.tsx @@ -12,6 +12,7 @@ import { PublishableKeySection } from "./publishable-key"; import { useAutumnFlags } from "@/hooks/common/useAutumnFlags"; import { ConfigureStripe } from "./configure-stripe/ConfigureStripe"; import { useSecondaryTab } from "@/hooks/common/useSecondaryTab"; +import { useAppQueryStates } from "@/hooks/common/useAppQueryStates"; export default function DevScreen({ env }: { env: AppEnv }) { const { data, isLoading, mutate } = useAxiosSWR({ @@ -20,7 +21,9 @@ export default function DevScreen({ env }: { env: AppEnv }) { withAuth: true, }); - const secondaryTab = useSecondaryTab({ defaultTab: "api_keys" }); + // const secondaryTab = useSecondaryTab({ defaultTab: "api_keys" }); + const { queryStates } = useAppQueryStates({ defaultTab: "api_keys" }); + const tab = queryStates.tab; const { pkey, webhooks } = useAutumnFlags(); const apiKeys = data?.api_keys || []; @@ -32,15 +35,15 @@ export default function DevScreen({ env }: { env: AppEnv }) {

Developer

- {(secondaryTab === "api_keys" || !secondaryTab) && ( + {(tab === "api_keys" || !tab) && (
{pkey && }
)} - {secondaryTab === "stripe" && } - {secondaryTab === "webhooks" && webhooks && ( + {tab === "stripe" && } + {tab === "webhooks" && webhooks && ( )}
diff --git a/vite/src/views/onboarding/onboarding-steps/ProductList.tsx b/vite/src/views/onboarding/onboarding-steps/ProductList.tsx index 67504dd1f..97a15644d 100644 --- a/vite/src/views/onboarding/onboarding-steps/ProductList.tsx +++ b/vite/src/views/onboarding/onboarding-steps/ProductList.tsx @@ -26,7 +26,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import CreateProduct from "@/views/products/products/components/CreateProduct"; +import CreateProduct from "@/views/products/products/components/CreateProductDialog"; import { ProductsTable } from "@/views/products/products/components/ProductsTable"; export const ProductList = ({ diff --git a/vite/src/views/onboarding2/ConnectStripeDialog.tsx b/vite/src/views/onboarding2/ConnectStripeDialog.tsx index 236c5c55f..ac95268f9 100644 --- a/vite/src/views/onboarding2/ConnectStripeDialog.tsx +++ b/vite/src/views/onboarding2/ConnectStripeDialog.tsx @@ -1,11 +1,6 @@ -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useModelPricingContext } from "./model-pricing/ModelPricingContext"; -import { ConnectStripeStep } from "./integrate/ConnectStripeStep"; + import { CustomDialogBody, CustomDialogContent, @@ -16,6 +11,7 @@ import { useState } from "react"; import { Input } from "@/components/ui/input"; import { connectStripe } from "./utils/connectStripe"; import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { useOrg } from "@/hooks/common/useOrg"; export default function ConnectStripeDialog({ open, @@ -24,15 +20,15 @@ export default function ConnectStripeDialog({ open: boolean; setOpen: (open: boolean) => void; }) { - const { stripeConnected, mutate, productDataState, data } = - useModelPricingContext(); + const { org, mutate: mutateOrg } = useOrg(); + const axiosInstance = useAxiosInstance(); const [testApiKey, setTestApiKey] = useState(""); const [loading, setLoading] = useState(false); const handleConnectStripe = async () => { setLoading(true); - await connectStripe({ testApiKey, axiosInstance, mutate }); + await connectStripe({ testApiKey, axiosInstance, mutate: mutateOrg }); setOpen(false); setLoading(false); }; @@ -59,9 +55,9 @@ export default function ConnectStripeDialog({ setTestApiKey(e.target.value)} - disabled={stripeConnected} + disabled={org?.stripe_connected} /> diff --git a/vite/src/views/onboarding2/OnboardingView2.tsx b/vite/src/views/onboarding2/OnboardingView2.tsx index e8e76ccb7..6f8263e76 100644 --- a/vite/src/views/onboarding2/OnboardingView2.tsx +++ b/vite/src/views/onboarding2/OnboardingView2.tsx @@ -8,25 +8,29 @@ import { useEffect, useRef, useState } from "react"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useSearchParams } from "react-router"; import { useSession } from "@/lib/auth-client"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { useOnboardingQueryState } from "./hooks/useOnboardingQueryState"; +import { useOrg } from "@/hooks/common/useOrg"; export default function OnboardingView2() { - const [queryStates, setQueryStates] = useQueryStates( - { - page: parseAsString.withDefault("pricing"), - reactTypescript: parseAsBoolean.withDefault(true), - frontend: parseAsString.withDefault(""), - backend: parseAsString.withDefault(""), - auth: parseAsString.withDefault(""), - customerType: parseAsString.withDefault("user"), - productId: parseAsString.withDefault(""), - }, - { - history: "push", - } - ); + // const [queryStates, setQueryStates] = useQueryStates( + // { + // page: parseAsString.withDefault("pricing"), + // reactTypescript: parseAsBoolean.withDefault(true), + // frontend: parseAsString.withDefault(""), + // backend: parseAsString.withDefault(""), + // auth: parseAsString.withDefault(""), + // customerType: parseAsString.withDefault("user"), + // productId: parseAsString.withDefault(""), + // token: parseAsString.withDefault(""), + // }, + // { + // history: "push", + // } + // ); + + const { queryStates, setQueryStates } = useOnboardingQueryState(); - const [searchParams] = useSearchParams(); - const token = searchParams.get("token"); const [loading, setLoading] = useState(true); const axiosInstance = useAxiosInstance(); const hasHandledToken = useRef(false); @@ -36,28 +40,21 @@ export default function OnboardingView2() { const { products: autumnProducts, isLoading: isAutumnLoading, - mutate: mutateAutumnProducts, + mutate: refetchAutumnProducts, } = useListProducts({ customerId: "onboarding_demo_user" }); - const { - data: productsData, - mutate: productMutate, - isLoading, - } = useAxiosSWR({ url: `/products/data` }); - - const { data: productCounts, mutate: mutateCounts } = useAxiosSWR({ - url: `/products/counts?latest_version=true`, - }); + const { isLoading: productsLoading } = useProductsQuery(); + const { isLoading: orgLoading } = useOrg(); useEffect(() => { const handleToken = async () => { try { await axiosInstance.post("/onboarding", { - token, + token: queryStates.token, }); - await productMutate(); - await mutateAutumnProducts(); + // await productMutate(); + await refetchAutumnProducts(); } catch (error) { console.error(error); } finally { @@ -65,42 +62,44 @@ export default function OnboardingView2() { } }; - if (token && !hasHandledToken.current) { + if (queryStates.token && !hasHandledToken.current) { hasHandledToken.current = true; handleToken(); } - }, [searchParams, token, axiosInstance, productMutate]); + }, [queryStates.token, axiosInstance]); useEffect(() => { - if (orgId && !token) { + if (orgId && !queryStates.token) { setLoading(false); } - }, [orgId, token]); + }, [orgId, queryStates.token]); - if (isLoading || isAutumnLoading || loading) return ; + if (isAutumnLoading || loading || productsLoading || orgLoading) + return ; return ( <> {queryStates.page === "integrate" ? ( ) : ( { - await productMutate(); - await mutateAutumnProducts(); - }} - mutateAutumnProducts={mutateAutumnProducts} + // data={productsData} + // mutate={async () => { + // await productMutate(); + // await mutateAutumnProducts(); + // }} + // mutateAutumnProducts={mutateAutumnProducts} autumnProducts={autumnProducts} - productCounts={productCounts} - mutateCounts={mutateCounts} - queryStates={queryStates} - setQueryStates={setQueryStates} + refetchAutumnProducts={refetchAutumnProducts} + // productCounts={productCounts} + // mutateCounts={mutateCounts} + // queryStates={queryStates} + // setQueryStates={setQueryStates} /> )} diff --git a/vite/src/views/onboarding2/SampleApp.tsx b/vite/src/views/onboarding2/SampleApp.tsx index b0ea2c840..b437e8bc1 100644 --- a/vite/src/views/onboarding2/SampleApp.tsx +++ b/vite/src/views/onboarding2/SampleApp.tsx @@ -1,7 +1,4 @@ import { Button } from "@/components/ui/button"; -import Step from "@/components/general/OnboardingStep"; -import CheckDialog from "@/components/autumn/paywall-dialog"; - import { useEnv } from "@/utils/envUtils"; import { @@ -10,7 +7,7 @@ import { DialogTrigger, DialogTitle, } from "@/components/ui/dialog"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Dialog } from "@/components/ui/dialog"; import { toast } from "sonner"; @@ -30,12 +27,10 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import CodeBlock from "@/views/onboarding/components/CodeBlock"; -import { useListProducts } from "./model-pricing/usePricingTable"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; -export const SampleApp = ({ data }: { data: any }) => { - const env = useEnv(); +export const SampleApp = () => { const [searchParams] = useSearchParams(); - const token = searchParams.get("token"); const [open, setOpen] = useState(false); const [checkData, setCheckData] = useState(null); const [trackData, setTrackData] = useState(null); @@ -43,14 +38,17 @@ export const SampleApp = ({ data }: { data: any }) => { const [showCheckSnippet, setShowCheckSnippet] = useState(true); const [showTrackSnippet, setShowTrackSnippet] = useState(true); const [showCustomerSnippet, setShowCustomerSnippet] = useState(true); + + const { features, isLoading } = useFeaturesQuery(); + const [lastUsedFeature, setLastUsedFeature] = useState({ - featureId: data.features?.[0]?.id, + featureId: features?.[0]?.id, value: 1, }); const { customer, openBillingPortal } = useCustomer(); - const { products } = useListProducts({ customerId: "onboarding_demo_user" }); + if (isLoading) return null; return ( @@ -97,10 +95,10 @@ export const SampleApp = ({ data }: { data: any }) => {

Available Features

- {data.features + {features ?.filter((feature: any) => customer?.features?.[feature.id]) .concat( - data.features?.filter( + features?.filter( (feature: any) => !customer?.features?.[feature.id] ) || [] ) @@ -183,7 +181,7 @@ const { allowed } = useCustomer(); const handleCheckFeature = async () => { if ( !allowed({ featureId: '${ lastUsedFeature?.featureId || - data.features?.[0]?.id || + features?.[0]?.id || "feature-id" }' }) ) { alert('Feature not allowed'); @@ -207,7 +205,7 @@ const autumn = new Autumn({ const { data } = await autumn.check({ customerId: 'user_123', featureId: '${ - lastUsedFeature?.featureId || data.features?.[0]?.id || "feature-id" + lastUsedFeature?.featureId || features?.[0]?.id || "feature-id" }' }); ` @@ -261,7 +259,7 @@ const { track } = useCustomer(); const handleTrackUsage = async () => { await track({ featureId: '${ - lastUsedFeature?.featureId || data.features?.[0]?.id || "feature-id" + lastUsedFeature?.featureId || features?.[0]?.id || "feature-id" }', value: ${lastUsedFeature?.value || 1} }); @@ -283,7 +281,7 @@ const autumn = new Autumn({ const response = await autumn.track({ featureId: '${ - lastUsedFeature?.featureId || data.features?.[0]?.id || "feature-id" + lastUsedFeature?.featureId || features?.[0]?.id || "feature-id" }', value: ${lastUsedFeature?.value || 1} }); @@ -337,7 +335,7 @@ const { customer, refetch } = useCustomer(); console.log('Customer balance:', customer?.features.${ lastUsedFeature?.featureId || - data.features?.[0]?.id || + features?.[0]?.id || "feature-id" }?.balance); diff --git a/vite/src/views/onboarding2/hooks/useOnboardingQueryState.tsx b/vite/src/views/onboarding2/hooks/useOnboardingQueryState.tsx new file mode 100644 index 000000000..a2e8535c0 --- /dev/null +++ b/vite/src/views/onboarding2/hooks/useOnboardingQueryState.tsx @@ -0,0 +1,21 @@ +import { parseAsBoolean, parseAsString, useQueryStates } from "nuqs"; + +export const useOnboardingQueryState = () => { + const [queryStates, setQueryStates] = useQueryStates( + { + page: parseAsString.withDefault("pricing"), + reactTypescript: parseAsBoolean.withDefault(true), + frontend: parseAsString.withDefault(""), + backend: parseAsString.withDefault(""), + auth: parseAsString.withDefault(""), + customerType: parseAsString.withDefault("user"), + productId: parseAsString.withDefault(""), + token: parseAsString.withDefault(""), + }, + { + history: "push", + } + ); + + return { queryStates, setQueryStates }; +}; diff --git a/vite/src/views/onboarding2/integrate/ConnectStripeStep.tsx b/vite/src/views/onboarding2/integrate/ConnectStripeStep.tsx index 3c1e5e48c..bd344b0ee 100644 --- a/vite/src/views/onboarding2/integrate/ConnectStripeStep.tsx +++ b/vite/src/views/onboarding2/integrate/ConnectStripeStep.tsx @@ -7,14 +7,11 @@ import { AppEnv } from "@autumn/shared"; import { toast } from "sonner"; import { getBackendErr } from "@/utils/genUtils"; import { StepHeader } from "./StepHeader"; +import { useOrg } from "@/hooks/common/useOrg"; + +export const ConnectStripeStep = () => { + const { org, mutate: mutateOrg } = useOrg(); -export const ConnectStripeStep = ({ - mutate, - productData, -}: { - mutate: () => Promise; - productData: any; -}) => { const [testApiKey, setTestApiKey] = useState(""); const [loading, setLoading] = useState(false); @@ -29,7 +26,7 @@ export const ConnectStripeStep = ({ }); toast.success("Successfully connected to Stripe"); - await mutate(); + await mutateOrg(); } catch (error) { console.log("Failed to connect Stripe", error); toast.error(getBackendErr(error, "Failed to connect Stripe")); @@ -39,7 +36,7 @@ export const ConnectStripeStep = ({ }; // console.log("productData", productData); - const stripeConnected = productData?.org.stripe_connected; + const stripeConnected = org?.stripe_connected; return (
diff --git a/vite/src/views/onboarding2/integrate/IntegrateAutumn.tsx b/vite/src/views/onboarding2/integrate/IntegrateAutumn.tsx index 71e06c94c..8e16d4af5 100644 --- a/vite/src/views/onboarding2/integrate/IntegrateAutumn.tsx +++ b/vite/src/views/onboarding2/integrate/IntegrateAutumn.tsx @@ -14,25 +14,15 @@ import { Button } from "@/components/ui/button"; import { NextSteps } from "./NextSteps"; import { AutumnProvider } from "autumn-js/react"; import { ConnectStripeStep } from "./ConnectStripeStep"; +import { useOnboardingQueryState } from "../hooks/useOnboardingQueryState"; -export default function IntegrateAutumn({ - data, - mutate, - queryStates, - setQueryStates, -}: { - data: any; - mutate: any; - queryStates: any; - setQueryStates: any; -}) { +export default function IntegrateAutumn() { + const { queryStates, setQueryStates } = useOnboardingQueryState(); const stackSelected = queryStates.frontend && queryStates.backend && queryStates.auth; return ( - +
@@ -60,7 +50,7 @@ export default function IntegrateAutumn({ {stackSelected && queryStates.reactTypescript && ( <> - + diff --git a/vite/src/views/onboarding2/model-pricing/AddTrialButton.tsx b/vite/src/views/onboarding2/model-pricing/AddTrialButton.tsx index 22576f236..3bc387488 100644 --- a/vite/src/views/onboarding2/model-pricing/AddTrialButton.tsx +++ b/vite/src/views/onboarding2/model-pricing/AddTrialButton.tsx @@ -2,14 +2,14 @@ import { Button } from "@/components/ui/button"; import { CreateFreeTrial } from "@/views/products/product/free-trial/CreateFreeTrial"; import { useState } from "react"; import { useProductContext } from "@/views/products/product/ProductContext"; -import { PlusIcon, Trash, X } from "lucide-react"; +import { PlusIcon, X } from "lucide-react"; import { handleAutoSave } from "./model-pricing-utils/modelPricingUtils"; import { useAxiosInstance } from "@/services/useAxiosInstance"; export const AddTrialButton = () => { - const { product, setProduct, mutate, autoSave } = useProductContext(); - const [open, setOpen] = useState(false); const axiosInstance = useAxiosInstance(); + const { product, setProduct, autoSave, refetch } = useProductContext(); + const [open, setOpen] = useState(false); return ( <> @@ -40,12 +40,12 @@ export const AddTrialButton = () => { free_trial: null, }); - if (!autoSave) { + if (autoSave) { handleAutoSave({ axiosInstance, productId: product.id, product: { ...product, free_trial: null }, - mutate, + refetch, }); } }} diff --git a/vite/src/views/onboarding2/model-pricing/EditProduct.tsx b/vite/src/views/onboarding2/model-pricing/EditProduct.tsx index e467fce53..e0d441d1d 100644 --- a/vite/src/views/onboarding2/model-pricing/EditProduct.tsx +++ b/vite/src/views/onboarding2/model-pricing/EditProduct.tsx @@ -18,19 +18,25 @@ import { updateProduct } from "@/views/products/product/utils/updateProduct"; import { getBackendErr } from "@/utils/genUtils"; import { EditProductDetails } from "./edit-product/EditProductDetails"; import { ToggleDefaultProduct } from "@/views/products/product/product-sidebar/ToggleDefaultProduct"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; -export const EditProduct = ({ mutate }: { mutate: any }) => { - const [freeTrialModalOpen, setFreeTrialModalOpen] = useState(false); - const { data, productCount, productDataState, mutateCounts } = - useModelPricingContext(); +export const EditProduct = ({ + refetchAutumnProducts, +}: { + refetchAutumnProducts: () => Promise; +}) => { + const { refetch, counts, products } = useProductsQuery(); + const { productDataState } = useModelPricingContext(); const [showNewVersionDialog, setShowNewVersionDialog] = useState(false); + const [freeTrialModalOpen, setFreeTrialModalOpen] = useState(false); + + const productCount = counts?.[productDataState.product?.id]; const { product, setProduct, - features, - setFeatures, entityFeatureIds, setEntityFeatureIds, actionState, @@ -42,24 +48,16 @@ export const EditProduct = ({ mutate }: { mutate: any }) => { }); const axiosInstance = useAxiosInstance(); - const env = useEnv(); - const [saveLoading, setSaveLoading] = useState(false); const runUpdateProduct = async () => { setSaveLoading(true); - try { - await updateProduct({ - axiosInstance, - product, - mutate, - mutateCount: mutateCounts, - }); - } catch (error) { - toast.error(getBackendErr(error, "Failed to update product")); - } finally { - setSaveLoading(false); - } + await updateProduct({ + axiosInstance, + product, + onSuccess: refetch, + }); + setSaveLoading(false); }; const handleSaveClicked = async () => { @@ -73,9 +71,7 @@ export const EditProduct = ({ mutate }: { mutate: any }) => { const hasItems = product.items.length > 0; const hasCustomers = productCount?.all > 0; const showSaveButton = hasCustomers || product.version > 1; - const firstProductCreated = data.products.length > 0; - - const autoSave = !showSaveButton && firstProductCreated; + const firstProductCreated = products.length > 0; const handleToggleSettings = async (key: string) => { if (!product) return; @@ -96,7 +92,7 @@ export const EditProduct = ({ mutate }: { mutate: any }) => { axiosInstance, productId: product.id ? product.id : details.id, product: { ...product, [key]: !curValue }, - mutate, + refetch, }); } }; @@ -106,13 +102,13 @@ export const EditProduct = ({ mutate }: { mutate: any }) => {
{ + console.log("Refetching edit product..."); + await Promise.all([refetch(), refetchAutumnProducts()]); + }, entityFeatureIds, setEntityFeatureIds, isOnboarding: true, @@ -122,7 +118,6 @@ export const EditProduct = ({ mutate }: { mutate: any }) => { { >
- {/* handleToggleSettings("is_default")} - /> */}

Default Product

@@ -199,13 +187,6 @@ export const EditProduct = ({ mutate }: { mutate: any }) => {
- {/* handleToggleSettings("is_add_on")} - /> */}

Add On Product

diff --git a/vite/src/views/onboarding2/model-pricing/EditProductOld.tsx b/vite/src/views/onboarding2/model-pricing/EditProductOld.tsx deleted file mode 100644 index f7bfa0967..000000000 --- a/vite/src/views/onboarding2/model-pricing/EditProductOld.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { DialogFooter } from "@/components/ui/dialog"; -import { ProductService } from "@/services/products/ProductService"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { useEnv } from "@/utils/envUtils"; -import { getBackendErr } from "@/utils/genUtils"; -import { CreateFreeTrial } from "@/views/products/product/free-trial/CreateFreeTrial"; -import { ManageProduct } from "@/views/products/product/ManageProduct"; -import { ProductContext } from "@/views/products/product/ProductContext"; -import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; -import { X } from "lucide-react"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { toast } from "sonner"; -import { ToggleButton } from "@/components/general/ToggleButton"; - -export const EditProductDialog = ({ - product, - features, - setProduct, - setFeatures, - mutate, - open, - setOpen, - originalProduct, - entityFeatureIds, - setEntityFeatureIds, -}: { - product: any; - setProduct: (product: any) => void; - features: any[]; - setFeatures: (features: any[]) => void; - mutate: () => Promise; - open: boolean; - setOpen: (open: boolean) => void; - originalProduct: any; - entityFeatureIds: string[]; - setEntityFeatureIds: (entityFeatureIds: string[]) => void; -}) => { - const env = useEnv(); - const axiosInstance = useAxiosInstance(); - const [createProductLoading, setCreateProductLoading] = useState(false); - const [freeTrialModalOpen, setFreeTrialModalOpen] = useState(false); - - // Store the original product state when modal opens - const handleOpenChange = async (newOpen: boolean) => { - if (!newOpen && open && product?.id) { - // Modal is being closed, check if there are changes - const hasChanges = - originalProduct && - JSON.stringify(product) !== JSON.stringify(originalProduct); - - if (hasChanges) { - // Only update if there are changes - updateProduct(); - } - } - setOpen(newOpen); - }; - - const updateProduct = async () => { - setCreateProductLoading(true); - try { - const res = await ProductService.updateProduct( - axiosInstance, - product.id, - product - ); - toast.success("Product updated successfully"); - await mutate(); - setOpen(false); - } catch (error) { - toast.error(getBackendErr(error, "Failed to update product")); - } - setCreateProductLoading(false); - }; - - const handleFreeTrialClick = () => { - if (product?.free_trial) { - // Delete the free trial - setProduct({ ...product, free_trial: null }); - } else { - // Open the free trial modal - setFreeTrialModalOpen(true); - } - }; - - return ( - - - - {/* Edit Product */} - -
- - - - -
- -
-
- - setProduct({ - ...product, - is_default: !product?.is_default, - }) - } - /> - - setProduct({ ...product, is_add_on: !product?.is_add_on }) - } - /> - -
- ) : ( - //
- - // {/* */} - //
-

Add Free Trial

- )} - -
- -
- - -
- ); -}; diff --git a/vite/src/views/onboarding2/model-pricing/ModelPricing.tsx b/vite/src/views/onboarding2/model-pricing/ModelPricing.tsx index a6b0f3ded..7719e90eb 100644 --- a/vite/src/views/onboarding2/model-pricing/ModelPricing.tsx +++ b/vite/src/views/onboarding2/model-pricing/ModelPricing.tsx @@ -26,6 +26,11 @@ import { ConnectStripeStep } from "../integrate/ConnectStripeStep"; import { AutumnProvider } from "autumn-js/react"; import { useProductData } from "@/views/products/product/hooks/useProductData"; import ConnectStripeDialog from "../ConnectStripeDialog"; +import { useOnboardingQueryState } from "../hooks/useOnboardingQueryState"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { ProductV2 } from "@autumn/shared"; +import { useOrg } from "@/hooks/common/useOrg"; +import { useProductContext } from "@/views/products/product/ProductContext"; const defaultProduct = { id: "", @@ -37,46 +42,44 @@ const defaultProduct = { }; export const ModelPricing = ({ - data, - mutate, - mutateAutumnProducts, + // data, + // mutate, + // mutateAutumnProducts, + refetchAutumnProducts, autumnProducts, - productCounts, - mutateCounts, - queryStates, - setQueryStates, + // productCounts, + // mutateCounts, + // queryStates, + // setQueryStates, }: { - data: any; - mutate: any; - mutateAutumnProducts: any; + // data: any; + // mutate: any; + // mutateAutumnProducts: any; + refetchAutumnProducts: any; autumnProducts: Product[]; - productCounts: any; - mutateCounts: any; - queryStates: any; - setQueryStates: any; + // productCounts: any; + // mutateCounts: any; + // queryStates: any; + // setQueryStates: any; }) => { + const { queryStates, setQueryStates } = useOnboardingQueryState(); + const { products, refetch } = useProductsQuery(); + const getCurProduct = () => { if (queryStates.productId) { - const prod = data.products.find( - (p: Product) => p.id === queryStates.productId + const prod = products.find( + (p: ProductV2) => p.id === queryStates.productId ); - if (prod) { - return prod; - } + + if (prod) return prod; } - if (data.products.length > 0) { - return data.products[0]; - } + if (products.length > 0) return products[0]; return defaultProduct; }; const curProduct = getCurProduct(); - // // console.log("curProduct:", curProduct); - // const [firstItemCreated, setFirstItemCreated] = useState( - // autumnProducts.some((p: Product) => p.items.length > 0) - // ); const firstItemCreated = autumnProducts.some( (p: Product) => p.items.length > 0 @@ -90,14 +93,19 @@ export const ModelPricing = ({ const productDataState = useProductData({ originalProduct: curProduct as any, - originalFeatures: data.features as any, }); const { product } = productDataState; - if (!product) return null; + useEffect(() => { + if (curProduct && !queryStates.productId) { + setQueryStates({ + productId: curProduct.id, + }); + } + }, [curProduct]); - const stripeConnected = data?.org.stripe_connected; + if (!product) return null; return ( + productDataState, + + // For & + refetch: async () => { + await Promise.all([refetch(), refetchAutumnProducts()]); + }, + // product, // setProduct, - productDataState, - mutate, - data, - productCount: productCounts?.[product?.id ?? ""], - queryStates, - setQueryStates, - mutateAutumnProducts, - mutateCounts, + // mutate, + // data, + // productCount: productCounts?.[product?.id ?? ""], + // queryStates, + // setQueryStates, + // mutateAutumnProducts, + // mutateCounts, setConnectStripeOpen, }} > @@ -123,77 +139,76 @@ export const ModelPricing = ({ open={connectStripeOpen} setOpen={setConnectStripeOpen} /> - -
-
-
-
-
-

Create your products

-

- To start, model your app's pricing by creating a product for - your free plans, paid plans and any add-ons or top-ups. -

-
- {firstItemCreated && ( -
- - {data.products.length > 0 && } -
- )} -
-
- -
-
-
-
-
+
+
+
+
+

Create your products

+

+ To start, model your app's pricing by creating a product for + your free plans, paid plans and any add-ons or top-ups. +

+
+ {firstItemCreated && ( +
+ + {products.length > 0 && } +
)} - > - -
- -
-
- -
- -
+
+
+
- + +
+
+ +
+ +
+
+ +
+ +
+
+
+
); }; const NewProductPopover = () => { const [open, setOpen] = useState(false); + const { - mutate, + refetch, productDataState: { setProduct }, } = useModelPricingContext(); @@ -212,7 +227,7 @@ const NewProductPopover = () => { name: details.name, id: details.id, }); - await mutate(); + await refetch(); const newProduct = { ...defaultProduct, name: details.name, diff --git a/vite/src/views/onboarding2/model-pricing/ModelPricingContext.tsx b/vite/src/views/onboarding2/model-pricing/ModelPricingContext.tsx index a1ac58699..eb158d278 100644 --- a/vite/src/views/onboarding2/model-pricing/ModelPricingContext.tsx +++ b/vite/src/views/onboarding2/model-pricing/ModelPricingContext.tsx @@ -5,11 +5,11 @@ export const ModelPricingContext = createContext(null); export const useModelPricingContext = () => { const context = useContext(ModelPricingContext); - if (context === undefined) { - throw new Error( - "useProductContext must be used within a ProductContextProvider" - ); - } + // if (context === undefined) { + // throw new Error( + // "useProductContext must be used within a ProductContextProvider" + // ); + // } return context; }; diff --git a/vite/src/views/onboarding2/model-pricing/SelectEditProduct.tsx b/vite/src/views/onboarding2/model-pricing/SelectEditProduct.tsx index 5970e754f..66f0277d5 100644 --- a/vite/src/views/onboarding2/model-pricing/SelectEditProduct.tsx +++ b/vite/src/views/onboarding2/model-pricing/SelectEditProduct.tsx @@ -11,23 +11,27 @@ import { ChevronDownIcon, Trash } from "lucide-react"; import { DeleteProductDialog } from "@/views/products/products/product-row-toolbar/DeleteProductDialog"; import { useState } from "react"; import { cn } from "@/lib/utils"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { useOnboardingQueryState } from "../hooks/useOnboardingQueryState"; export const SelectEditProduct = () => { const { - data, productDataState: { product, setProduct }, - queryStates, - setQueryStates, } = useModelPricingContext(); + + const { products } = useProductsQuery(); + const { setQueryStates } = useOnboardingQueryState(); + const [deleteProductOpen, setDeleteProductOpen] = useState(false); const [deleteProductSelected, setDeleteProductSelected] = useState(product); const selectedClassName = "!bg-zinc-100 h-7 border"; - if (data.products.length > 3) { + if (products.length > 3) { return ( <> @@ -42,7 +46,7 @@ export const SelectEditProduct = () => { - {data.products.map((p: any) => { + {products.map((p: any) => { if (!p.name) { return null; } @@ -67,6 +71,7 @@ export const SelectEditProduct = () => { e.preventDefault(); setDeleteProductSelected(p); setDeleteProductOpen(true); + console.log("Delete product selected:", p); }} > @@ -89,10 +94,11 @@ export const SelectEditProduct = () => { product={product} open={deleteProductOpen} setOpen={setDeleteProductOpen} - > + dropdownOpen={deleteProductOpen} + /> - {data.products.map((p: any) => { + {products.map((p: any) => { if (!p.name) { return null; } diff --git a/vite/src/views/onboarding2/model-pricing/edit-product/EditProductDetails.tsx b/vite/src/views/onboarding2/model-pricing/edit-product/EditProductDetails.tsx index ccf517f56..4797d5d6f 100644 --- a/vite/src/views/onboarding2/model-pricing/edit-product/EditProductDetails.tsx +++ b/vite/src/views/onboarding2/model-pricing/edit-product/EditProductDetails.tsx @@ -10,15 +10,15 @@ import { useProductContext } from "@/views/products/product/ProductContext"; import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip"; import { getBackendErr } from "@/utils/genUtils"; import { toast } from "sonner"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; export const EditProductDetails = () => { - const { - productDataState: { product, setProduct }, - data, - mutate, - } = useModelPricingContext(); + const { productDataState } = useModelPricingContext(); + const { product, setProduct } = productDataState; - const allowCreate = data.products.length === 0; + const { products, refetch } = useProductsQuery(); + + const allowCreate = products.length === 0; const { autoSave } = useProductContext(); const [createLoading, setCreateLoading] = useState(false); @@ -44,7 +44,7 @@ export const EditProductDetails = () => { name: details.name, id: details.id, }); - await mutate(); + await refetch(); } catch (error) { toast.error(getBackendErr(error, "Failed to create product")); } finally { @@ -67,7 +67,7 @@ export const EditProductDetails = () => { name: details.name, id: details.id, }, - mutate, + refetch, }); } setProduct({ diff --git a/vite/src/views/onboarding2/model-pricing/model-pricing-utils/modelPricingUtils.ts b/vite/src/views/onboarding2/model-pricing/model-pricing-utils/modelPricingUtils.ts index bbf3dd218..059693641 100644 --- a/vite/src/views/onboarding2/model-pricing/model-pricing-utils/modelPricingUtils.ts +++ b/vite/src/views/onboarding2/model-pricing/model-pricing-utils/modelPricingUtils.ts @@ -7,12 +7,12 @@ export const handleAutoSave = async ({ axiosInstance, productId, product, - mutate, + refetch, }: { axiosInstance: AxiosInstance; productId: string; product: ProductV2; - mutate: any; + refetch?: any; }) => { if (!productId || !product.id) return; try { @@ -23,7 +23,9 @@ export const handleAutoSave = async ({ group: notNullish(product.group) ? product.group : undefined, } ); - await mutate(); + if (refetch) { + await refetch(); + } } catch (error) { console.log(error); toast.error(getBackendErr(error, "Failed to auto save product")); diff --git a/vite/src/views/products/product/ProductProps.tsx b/vite/src/views/products/product/ProductProps.tsx index a2557027f..086c61908 100644 --- a/vite/src/views/products/product/ProductProps.tsx +++ b/vite/src/views/products/product/ProductProps.tsx @@ -26,16 +26,17 @@ import { ToggleButton } from "@/components/general/ToggleButton"; import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip"; import { ToggleDefaultProduct } from "./product-sidebar/ToggleDefaultProduct"; import { getBackendErr } from "@/utils/genUtils"; +import { useProductCountsQuery } from "./hooks/queries/useProductCountsQuery"; +import { useProductQuery } from "./hooks/useProductQuery"; export const ProductProps = () => { - const { product, setProduct, counts, mutate } = useProductContext(); const axiosInstance = useAxiosInstance(); - const [defaultOpen, setDefaultOpen] = React.useState(false); - const [defaultTrialOpen, setDefaultTrialOpen] = React.useState(false); - const [addOnOpen, setAddOnOpen] = React.useState(false); + const { product, setProduct } = useProductContext(); + const { refetch } = useProductQuery(); + const { counts } = useProductCountsQuery(); + const [groupModalOpen, setGroupModalOpen] = React.useState(false); const [tempGroup, setTempGroup] = React.useState(product.group || ""); - const [archivedOpen, setArchivedOpen] = React.useState(false); return ( <> @@ -184,7 +185,7 @@ export const ProductProps = () => { { archived: value }, product.version ); - await mutate(); + await refetch(); toast.success( value ? "Product archived successfully" diff --git a/vite/src/views/products/product/ProductSidebar.tsx b/vite/src/views/products/product/ProductSidebar.tsx index a4a714276..49c3dd591 100644 --- a/vite/src/views/products/product/ProductSidebar.tsx +++ b/vite/src/views/products/product/ProductSidebar.tsx @@ -55,11 +55,11 @@ export default function ProductSidebar() {
- {/*
+
-
*/} +
{ - const { product, numVersions, customer, version } = useProductContext(); - const navigate = useNavigate(); - const env = useEnv(); + const { setQueryStates } = useProductQueryState(); + const { product, isCusProductView } = useProductContext(); + const { numVersions } = useProductQuery(); return (
@@ -34,16 +35,20 @@ export const ProductVersions = () => {

Version History

{numVersions > 1 ? (