From e555666f98684616d125adb0e757a7f86920c037 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 2 Oct 2025 12:39:01 +0100 Subject: [PATCH] fix: create reward select price dialog --- server/tests/advanced/coupons/coupon1.ts | 43 ++--- server/tests/advanced/coupons/coupon2.ts | 56 +++---- .../features/components/FeatureConfig.tsx | 48 +++--- .../rewards/reward-config/CreateReward.tsx | 11 ++ .../rewards/reward-config/DiscountConfig.tsx | 155 ++++++++++-------- .../rewards/reward-config/UpdateReward.tsx | 10 ++ .../rewards/utils/defaultRewardModels.ts | 2 +- 7 files changed, 180 insertions(+), 145 deletions(-) diff --git a/server/tests/advanced/coupons/coupon1.ts b/server/tests/advanced/coupons/coupon1.ts index 7ab9b8d23..6b8c94be6 100644 --- a/server/tests/advanced/coupons/coupon1.ts +++ b/server/tests/advanced/coupons/coupon1.ts @@ -1,33 +1,36 @@ -import chalk from "chalk"; -import Stripe from "stripe"; -import { APIVersion, AppEnv, Customer, Organization } from "@autumn/shared"; -import { getOriginalCouponId } from "@/internal/rewards/rewardUtils.js"; +import { + APIVersion, + type AppEnv, + type Customer, + type Organization, +} from "@autumn/shared"; import { expect } from "chai"; +import chalk from "chalk"; import { addHours, addMonths } from "date-fns"; +import type Stripe from "stripe"; +import { setupBefore } from "tests/before.js"; import { rewards } from "tests/global.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; +import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; import { timeout } from "tests/utils/genUtils.js"; - +import { createProducts } from "tests/utils/productUtils.js"; import { advanceTestClock, completeCheckoutForm, getDiscount, } from "tests/utils/stripeUtils.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { setupBefore } from "tests/before.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { TestFeature } from "tests/setup/v2Features.js"; import { addPrefixToProducts, getBasePrice, } from "tests/utils/testProductUtils/testProductUtils.js"; -import { createProducts } from "tests/utils/productUtils.js"; -import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; -import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js"; -import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { getOriginalCouponId } from "@/internal/rewards/rewardUtils.js"; +import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; const testCase = "coupon1"; @@ -65,7 +68,7 @@ const simulateOneCycle = async ({ }); // Expected invoice total - let expectedTotal = await getExpectedInvoiceTotal({ + const expectedTotal = await getExpectedInvoiceTotal({ usage: [{ featureId: TestFeature.Words, value: usage }], customerId, productId: pro.id, @@ -117,7 +120,7 @@ describe( `${testCase} - Testing invoice credits reward, apply to all product`, ), () => { - let customerId = "coupon1"; + const customerId = "coupon1"; let stripeCli: Stripe; let customer: Customer; let testClockId: string; @@ -125,7 +128,7 @@ describe( let org: Organization; let env: AppEnv; - let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + const autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); let couponAmount = rewards.rolloverAll.discount_config.discount_value; let curUnix = new Date().getTime(); diff --git a/server/tests/advanced/coupons/coupon2.ts b/server/tests/advanced/coupons/coupon2.ts index cfaa8db4e..84f961913 100644 --- a/server/tests/advanced/coupons/coupon2.ts +++ b/server/tests/advanced/coupons/coupon2.ts @@ -1,43 +1,35 @@ -import { - advanceClockForInvoice, - completeCheckoutForm, - getDiscount, -} from "tests/utils/stripeUtils.js"; - -import chalk from "chalk"; -import Stripe from "stripe"; - -import { expect } from "chai"; import { APIVersion, - AppEnv, + type AppEnv, CouponDurationType, - CreateReward, - Organization, + type CreateReward, + type Organization, RewardType, } from "@autumn/shared"; -import { getOriginalCouponId } from "@/internal/rewards/rewardUtils.js"; -import { getPriceForOverage } from "@/internal/products/prices/priceUtils.js"; - -import { timeout } from "tests/utils/genUtils.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { DrizzleCli } from "@/db/initDrizzle.js"; +import { expect } from "chai"; +import chalk from "chalk"; +import { addHours, addMonths } from "date-fns"; +import { Decimal } from "decimal.js"; +import type Stripe from "stripe"; import { setupBefore } from "tests/before.js"; -import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { TestFeature } from "tests/setup/v2Features.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; +import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +import { timeout } from "tests/utils/genUtils.js"; +import { createProducts, createReward } from "tests/utils/productUtils.js"; +import { completeCheckoutForm, getDiscount } from "tests/utils/stripeUtils.js"; import { addPrefixToProducts, getBasePrice, } from "tests/utils/testProductUtils/testProductUtils.js"; -import { createProducts, createReward } from "tests/utils/productUtils.js"; -import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; -import { Decimal } from "decimal.js"; -import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { getOriginalCouponId } from "@/internal/rewards/rewardUtils.js"; +import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; -import { addHours, addMonths } from "date-fns"; -import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; const pro = constructProduct({ type: "pro", @@ -66,11 +58,11 @@ describe( chalk.yellow(`${testCase} - Testing one-off rollover, apply to usage only`), () => { let logger: any; - let customerId = testCase; + const customerId = testCase; let stripeCli: Stripe; let testClockId: string; - let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + const autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); let org: Organization; let env: AppEnv; let db: DrizzleCli; @@ -163,7 +155,7 @@ describe( value: usage, }); - let usageTotal = await getExpectedInvoiceTotal({ + const usageTotal = await getExpectedInvoiceTotal({ org, env, db, @@ -174,7 +166,7 @@ describe( onlyIncludeUsage: true, }); - let basePrice = getBasePrice({ product: pro }); + const basePrice = getBasePrice({ product: pro }); couponAmount = couponAmount - usageTotal; diff --git a/vite/src/views/products/features/components/FeatureConfig.tsx b/vite/src/views/products/features/components/FeatureConfig.tsx index 48ec0617e..51cb82fcb 100644 --- a/vite/src/views/products/features/components/FeatureConfig.tsx +++ b/vite/src/views/products/features/components/FeatureConfig.tsx @@ -1,21 +1,23 @@ -import { useEffect, useState } from "react"; -import FieldLabel from "@/components/general/modal-components/FieldLabel"; -import { Input } from "@/components/ui/input"; -import { slugify } from "@/utils/formatUtils/formatTextUtils"; -import { cn } from "@/lib/utils"; -import { Clock, InfoIcon, PlusIcon, XIcon, Zap } from "lucide-react"; -import { Expression, FeatureUsageType, MeteredConfig } from "@autumn/shared"; -import { FeatureType } from "@autumn/shared"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; - -import { Button } from "@/components/ui/button"; +import { + type Expression, + FeatureType, + FeatureUsageType, + type MeteredConfig, +} from "@autumn/shared"; +import { PlusIcon, XIcon } from "lucide-react"; +import { useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; +import FieldLabel from "@/components/general/modal-components/FieldLabel"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { notNullish, nullish } from "@/utils/genUtils"; +import { cn } from "@/lib/utils"; +import { slugify } from "@/utils/formatUtils/formatTextUtils"; +import { nullish } from "@/utils/genUtils"; import { SelectFeatureType } from "./SelectFeatureType"; import { SelectFeatureUsageType } from "./SelectFeatureUsageType"; @@ -70,6 +72,14 @@ export function FeatureConfig({ ); const [idChanged, setIdChanged] = useState(!!feature.id); + // Helper function to update meteredConfig and sync to parent + const updateMeteredConfig = (newConfig: MeteredConfig) => { + setMeteredConfig(newConfig); + if (feature.type === FeatureType.Metered) { + setFeature({ ...feature, config: newConfig }); + } + }; + const showNameAndId = () => { if (nullish(feature.type)) { return false; @@ -92,7 +102,7 @@ export function FeatureConfig({ when purchasing a product - {feature.type == FeatureType.Metered && ( + {feature.type === FeatureType.Metered && ( )} @@ -139,7 +149,7 @@ export function FeatureConfig({ here. @@ -219,7 +230,7 @@ export const FilterInput = ({ const enterClicked = () => { let newFilter: Expression; - if (filter.value.length == 0) { + if (filter.value.length === 0) { newFilter = { property: "", operator: "", @@ -244,12 +255,6 @@ export const FilterInput = ({ enableOnFormTags: ["input"], enabled: inputFocused, }); - - // useHotkeys(["meta+enter"], enterClicked, { - // enableOnContentEditable: true, - // enabled: inputFocused, - // }); - return (
{value}
- {/* {config.duration_type !== CouponDurationType.OneOff && - reward.type === RewardType.FixedDiscount && ( -
- - setConfig("should_rollover", checked === true) - } - /> -

Rollover credits to next invoice

-
- )} */} -
{/*

Products

*/} Products @@ -179,6 +166,24 @@ const ProductPriceSelector = ({ setConfig("price_ids", newPriceIds); }; + // Handle toggling "Apply to all products" + const handleApplyToAllToggle = () => { + const newApplyToAll = !config.apply_to_all; + // If enabling "Apply to all", clear the price_ids + if (newApplyToAll) { + setReward({ + ...reward, + discount_config: { + ...config, + apply_to_all: newApplyToAll, + price_ids: [], + }, + }); + } else { + setConfig("apply_to_all", newApplyToAll); + } + }; + if (!products || products.length === 0) { return

No products available

; } @@ -192,48 +197,54 @@ const ProductPriceSelector = ({ aria-expanded={open} className="w-full min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50" > - {config.apply_to_all - ? "All Products" - : config.price_ids?.length === 0 - ? "Select Products" - : config.price_ids?.map((priceId) => { - const item = products - .find((p: any) => - p.items.find((i: any) => i.price_id === priceId), - ) - ?.items.find((i: any) => i.price_id === priceId); + {config.apply_to_all ? ( + All Products + ) : config.price_ids?.length === 0 ? ( + Select products or apply to all + ) : ( + config.price_ids?.map((priceId) => { + const item = products + .find((p: any) => + p.items.find((i: any) => i.price_id === priceId), + ) + ?.items.find((i: any) => i.price_id === priceId); - const text = item - ? formatProductItemText({ - item, - org, - features, - }) - : "Unknown Price"; - return ( -
-

{text}

- -
- ); - })} + const text = item + ? formatProductItemText({ + item, + org, + features, + }) + : "Unknown Price"; + return ( +
+

{text}

+ +
+ ); + }) + )} - + @@ -241,9 +252,7 @@ const ProductPriceSelector = ({ No prices found. { - setConfig("apply_to_all", !config.apply_to_all); - }} + onSelect={handleApplyToAllToggle} className="cursor-pointer" >

Apply to all products

@@ -253,19 +262,27 @@ const ProductPriceSelector = ({
{!config.apply_to_all && - products.map((product: any) => ( - - {product.items.length > 0 ? ( - product.items - ?.filter((item: ProductItem) => { - return !isFeatureItem(item); - }) - .map((item: any) => ( + products + .filter((product: any) => { + // Filter out products that have no non-feature items + const nonFeatureItems = product.items?.filter( + (item: ProductItem) => !isFeatureItem(item), + ); + return nonFeatureItems && nonFeatureItems.length > 0; + }) + .map((product: any) => { + const nonFeatureItems = product.items.filter( + (item: ProductItem) => !isFeatureItem(item), + ); + + return ( + + {nonFeatureItems.map((item: any) => ( handlePriceToggle(item.price_id)} - className="cursor-pointer overflow-x-hidden max-w-[380px]" + className="cursor-pointer overflow-x-hidden" > {formatProductItemText({ @@ -279,14 +296,10 @@ const ProductPriceSelector = ({ )} - )) - ) : ( - -

No prices available

-
- )} -
- ))} + ))} +
+ ); + })}
diff --git a/vite/src/views/products/rewards/reward-config/UpdateReward.tsx b/vite/src/views/products/rewards/reward-config/UpdateReward.tsx index 043c54b96..8421d5f14 100644 --- a/vite/src/views/products/rewards/reward-config/UpdateReward.tsx +++ b/vite/src/views/products/rewards/reward-config/UpdateReward.tsx @@ -72,6 +72,16 @@ function UpdateReward({ const handleUpdate = async () => { setUpdateLoading(true); try { + // Validate product selection for discount rewards + if (selectedReward.discount_config) { + const { apply_to_all, price_ids } = selectedReward.discount_config; + if (!apply_to_all && (!price_ids || price_ids.length === 0)) { + toast.error("Please select price(s) to apply this reward to"); + setUpdateLoading(false); + return; + } + } + // Check migration status and show warning if needed if (products) { const migrationResult = checkRewardMigration(selectedReward, products); diff --git a/vite/src/views/products/rewards/utils/defaultRewardModels.ts b/vite/src/views/products/rewards/utils/defaultRewardModels.ts index 4537b998b..3db4d6be8 100644 --- a/vite/src/views/products/rewards/utils/defaultRewardModels.ts +++ b/vite/src/views/products/rewards/utils/defaultRewardModels.ts @@ -11,7 +11,7 @@ export const defaultDiscountConfig: DiscountConfig = { duration_type: CouponDurationType.Months, duration_value: 0, should_rollover: true, - apply_to_all: true, + apply_to_all: false, price_ids: [], };