diff --git a/knip.json b/knip.json index a6aa1e07c..48587130b 100644 --- a/knip.json +++ b/knip.json @@ -44,7 +44,12 @@ "vite": { "entry": ["tests/**/*.{ts,tsx}", "src/**/*.test.{ts,tsx}"], "project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"], - "ignore": ["src/components/ai-elements/**", "src/hooks/useControllableState.ts", "src/types/**/*.d.ts"], + "ignore": [ + "src/components/ai-elements/**", + "src/components/ui/scroll-area.tsx", + "src/hooks/useControllableState.ts", + "src/types/**/*.d.ts" + ], "ignoreDependencies": [ "tailwindcss", "tailwind-scrollbar-hide", diff --git a/vite/src/views/products/rewards/reward-config/components/FeatureGrantRewardConfig.tsx b/vite/src/views/products/rewards/reward-config/components/FeatureGrantRewardConfig.tsx index 960033dec..f2c4c26b6 100644 --- a/vite/src/views/products/rewards/reward-config/components/FeatureGrantRewardConfig.tsx +++ b/vite/src/views/products/rewards/reward-config/components/FeatureGrantRewardConfig.tsx @@ -253,7 +253,7 @@ export function FeatureGrantRewardConfig({ {entitlements.map((ent, index) => ( void; } +const priceItemsOf = (product: ProductV2) => + (product.items ?? []).filter( + (item): item is ProductItem & { price_id: string } => + !isFeatureItem(item) && Boolean(item.price_id), + ); + export function ProductPriceSelector({ reward, setReward, @@ -25,175 +41,268 @@ export function ProductPriceSelector({ const { features } = useFeaturesQuery(); const config = reward.discount_config!; + const priceIds = config.price_ids ?? []; + const applyToAll = config.apply_to_all ?? false; - const linkedPriceIds = config.price_ids ?? []; - const { products: linkedProductVersions } = - useProductsByPriceIdsQuery(linkedPriceIds); + // Selected price IDs may belong to historical versions absent from the + // latest-versions list; resolve their owning product for chip labels. + const { products: linkedProductVersions, isLoading: linkedVersionsLoading } = + useProductsByPriceIdsQuery(priceIds); - const setConfig = (key: string, value: any) => { - setReward({ - ...reward, - discount_config: { ...config, [key]: value }, - }); - }; - - const handlePriceToggle = (priceId: string) => { - const currentPriceIds = config.price_ids || []; - let newPriceIds: string[]; - - if (currentPriceIds.includes(priceId)) { - newPriceIds = currentPriceIds.filter((id) => id !== priceId); - } else { - newPriceIds = [...currentPriceIds, priceId]; - } - - // If selecting a specific price, clear apply_to_all + const setPriceIds = (nextPriceIds: string[]) => setReward({ ...reward, discount_config: { ...config, apply_to_all: false, - price_ids: newPriceIds, + price_ids: nextPriceIds, }, }); - }; - const handleApplyToAllToggle = () => { - const newApplyToAll = !config.apply_to_all; + const toggleApplyToAll = () => + setReward({ + ...reward, + discount_config: { + ...config, + apply_to_all: !applyToAll, + price_ids: [], + }, + }); - if (newApplyToAll) { - // Enabling "Apply to all" clears price_ids - setReward({ - ...reward, - discount_config: { - ...config, - apply_to_all: true, - price_ids: [], - }, - }); - } else { - // Disabling "Apply to all" just sets it to false - setConfig("apply_to_all", false); - } - }; - - const formatPriceTag = (priceId: string) => { - const product = linkedProductVersions.find((p: any) => - p.items.find((i: any) => i.price_id === priceId), + const togglePrice = (priceId: string) => + setPriceIds( + priceIds.includes(priceId) + ? priceIds.filter((id) => id !== priceId) + : [...priceIds, priceId], ); - const item = product?.items.find((i: any) => i.price_id === priceId); - if (!item || !product) return "Unknown Price"; + const toggleProduct = (product: ProductV2) => { + const ids = priceItemsOf(product).map((item) => item.price_id); + const allSelected = ids.every((id) => priceIds.includes(id)); + setPriceIds( + allSelected + ? priceIds.filter((id) => !ids.includes(id)) + : [...priceIds, ...ids.filter((id) => !priceIds.includes(id))], + ); + }; + const availableProducts = products.filter( + (product) => priceItemsOf(product).length > 0, + ); + + // Prefer latest versions already on the client; fall back to the async + // query only for prices owned by historical versions. + const productVersionOf = (priceId: string) => + [...products, ...linkedProductVersions].find((product) => + product.items?.some((item) => item.price_id === priceId), + ); + + const chipLabel = (priceId: string) => { + const product = productVersionOf(priceId); + const item = product?.items?.find((i) => i.price_id === priceId); + if (!item || !product) + return linkedVersionsLoading ? "Loading…" : "Unknown price"; const priceText = formatProductItemText({ item, org, features }); return `${product.name} v${product.version} — ${priceText}`; }; - // Get products with non-feature items - const availableProducts = products.filter((product: any) => { - const nonFeatureItems = product.items?.filter( - (item: ProductItem) => !isFeatureItem(item), - ); - return nonFeatureItems && nonFeatureItems.length > 0; - }); + type Chip = { key: string; label: string; onRemove?: () => void }; - if (!products || products.length === 0) { - return

No products available

; - } + // Collapse a product's prices into a single product chip when all are selected. + const buildChips = (): Chip[] => { + if (applyToAll) return [{ key: "__all__", label: "All products" }]; - // Build options list - not used for display, just for reference - const priceOptions = availableProducts.flatMap((product: any) => { - const nonFeatureItems = product.items.filter( - (item: ProductItem) => !isFeatureItem(item), + const chips: Chip[] = []; + const seenProducts = new Set(); + for (const priceId of priceIds) { + const product = productVersionOf(priceId); + const productPriceIds = product + ? priceItemsOf(product).map((item) => item.price_id) + : []; + const allPricesSelected = + productPriceIds.length > 0 && + productPriceIds.every((id) => priceIds.includes(id)); + + if (product && allPricesSelected) { + const productKey = `${product.id}:${product.version}`; + if (seenProducts.has(productKey)) continue; + seenProducts.add(productKey); + chips.push({ + key: productKey, + label: product.name, + onRemove: () => + setPriceIds(priceIds.filter((id) => !productPriceIds.includes(id))), + }); + continue; + } + + chips.push({ + key: priceId, + label: chipLabel(priceId), + onRemove: () => togglePrice(priceId), + }); + } + return chips; + }; + + if (!products || products.length === 0) + return ( +

No products available

); - return nonFeatureItems.map((item: any) => ({ - value: item.price_id, - label: formatProductItemText({ item, org, features }), - })); - }); + + const chips = buildChips(); return ( - setConfig("price_ids", values)} - options={priceOptions} - placeholder="Select plans or apply to all" - formatTag={formatPriceTag} - showAllProducts={config.apply_to_all} - renderContent={(setOpen) => ( - <> - {/* Apply to all option */} - -
{ - handleApplyToAllToggle(); - setOpen(false); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleApplyToAllToggle(); - setOpen(false); - } - }} - > -
- Apply to all products - {config.apply_to_all && ( - - )} -
-
-
+
+ + + {chips.length === 0 ? ( + + Select plans or apply to all... + + ) : ( + <> + {chips.slice(0, MAX_VISIBLE_CHIPS).map((chip) => ( + + + + + {chip.label} + {chip.onRemove && ( + { + e.stopPropagation(); + chip.onRemove?.(); + }} + onPointerDown={(e) => e.stopPropagation()} + > + + + )} + + ))} + {chips.length > MAX_VISIBLE_CHIPS && ( + + +{chips.length - MAX_VISIBLE_CHIPS} + + )} + + )} + + + { + e.preventDefault(); + toggleApplyToAll(); + }} + > + + Apply to all products + + +
+ {availableProducts.map((product) => { + const priceItems = priceItemsOf(product); - {/* Product groups */} - {availableProducts.map((product: any) => { - const nonFeatureItems = product.items.filter( - (item: ProductItem) => !isFeatureItem(item), - ); + if (priceItems.length === 1) { + const priceId = priceItems[0].price_id; + return ( + { + e.preventDefault(); + togglePrice(priceId); + }} + > + + {product.name} + + ); + } - return ( - - {product.name} - {nonFeatureItems.map((item: any) => { - const isSelected = config.price_ids?.includes(item.price_id); - return ( -
handlePriceToggle(item.price_id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handlePriceToggle(item.price_id); - } + const ids = priceItems.map((item) => item.price_id); + const selectedCount = ids.filter((id) => + priceIds.includes(id), + ).length; + const allSelected = !applyToAll && selectedCount === ids.length; + const someSelected = !applyToAll && selectedCount > 0; + + return ( + + { + e.preventDefault(); + toggleProduct(product); + }} + > + + {product.name} + + + { + e.preventDefault(); + toggleProduct(product); }} > -
+ + All prices + + + {priceItems.map((item) => ( + { + e.preventDefault(); + togglePrice(item.price_id); + }} + > + - {formatProductItemText({ - item, - org, - features, - })} + {formatProductItemText({ item, org, features })} - {isSelected && ( - - )} -
-
- ); - })} -
- ); - })} - - )} - /> + + ))} + + + ); + })} +
+
+
+
); } diff --git a/vite/src/views/products/rewards/reward-config/components/UpdateRewardSheet.tsx b/vite/src/views/products/rewards/reward-config/components/UpdateRewardSheet.tsx index dcebf6e10..f76485ca5 100644 --- a/vite/src/views/products/rewards/reward-config/components/UpdateRewardSheet.tsx +++ b/vite/src/views/products/rewards/reward-config/components/UpdateRewardSheet.tsx @@ -3,6 +3,14 @@ import type { AxiosError } from "axios"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; import { SheetFooter, SheetHeader, @@ -40,6 +48,7 @@ export function UpdateRewardSheet({ const { features } = useFeaturesQuery(); const [loading, setLoading] = useState(false); + const [confirmCouponOpen, setConfirmCouponOpen] = useState(false); const reward = useRewardStore((s) => s.reward); const setReward = useRewardStore((s) => s.setReward); @@ -102,8 +111,8 @@ export function UpdateRewardSheet({ return true; }; - const handleUpdate = async () => { - if (!selectedReward || !isFormValid()) return; + const performUpdate = async () => { + if (!selectedReward) return; setLoading(true); try { @@ -120,6 +129,7 @@ export function UpdateRewardSheet({ await refetch(); toast.success("Reward updated successfully"); + setConfirmCouponOpen(false); setOpen(false); } catch (error: unknown) { toast.error( @@ -130,55 +140,102 @@ export function UpdateRewardSheet({ } }; + const handleUpdate = async () => { + if (!selectedReward || !isFormValid()) return; + + // Stripe can't update coupons in place, so discount updates delete & recreate. + if (reward.rewardCategory === "discount") { + setConfirmCouponOpen(true); + return; + } + + await performUpdate(); + }; + const handleCancel = () => { setOpen(false); }; return ( - - - + <> + + + -
- - +
+ + - {reward.rewardCategory === "discount" && ( - - )} + {reward.rewardCategory === "discount" && ( + + )} - {reward.rewardCategory === "free_product" && ( - - )} + {reward.rewardCategory === "free_product" && ( + + )} - {reward.rewardCategory === "feature_grant" && ( - - )} -
+ {reward.rewardCategory === "feature_grant" && ( + + )} +
- - - Cancel - - - Update reward - - -
-
+ + + Cancel + + + Update reward + + +
+
+ + + + + Update coupon? + + Stripe doesn't have functionality to update coupons. This will + delete it and recreate it. Existing customers that have this + coupon will be unaffected. + + + + + setConfirmCouponOpen(false)} + singleShortcut="escape" + disabled={loading} + > + Cancel + + + Confirm + + + + + ); } diff --git a/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx b/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx index 9d9b11731..6dad41841 100644 --- a/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx +++ b/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx @@ -5,25 +5,16 @@ import { RewardReceivedBy, RewardTriggerEvent, } from "@autumn/shared"; -import { Check, ChevronsUpDown, X } from "lucide-react"; -import { useId, useState } from "react"; +import { PackageIcon, XIcon } from "@phosphor-icons/react"; +import { useId } from "react"; import FieldLabel from "@/components/general/modal-components/FieldLabel"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Button } from "@/components/v2/buttons/Button"; import { Checkbox } from "@/components/v2/checkboxes/Checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/v2/dropdowns/DropdownMenu"; import { Input } from "@/components/v2/inputs/Input"; import { Select, @@ -188,6 +179,8 @@ export const RewardProgramConfig = ({ ); }; +const MAX_VISIBLE_CHIPS = 3; + const ProductSelector = ({ rewardProgram, setRewardProgram, @@ -196,89 +189,89 @@ const ProductSelector = ({ setRewardProgram: (rewardProgram: RewardProgram) => void; }) => { const { products } = useProductsQuery(); - const [open, setOpen] = useState(false); - // Handle selection/deselection of a product - const handleProductToggle = (productId: string) => { - let newProductIds = [...(rewardProgram.product_ids || [])]; - if (newProductIds.includes(productId)) { - newProductIds = newProductIds.filter((id) => id !== productId); - } else { - newProductIds = [...newProductIds, productId]; - } + const productIds = rewardProgram.product_ids ?? []; + + const toggleProduct = (productId: string) => setRewardProgram({ ...rewardProgram, - product_ids: newProductIds, + product_ids: productIds.includes(productId) + ? productIds.filter((id) => id !== productId) + : [...productIds, productId], }); - }; if (!products || products.length === 0) { - return

No products available

; + return ( +

No products available

+ ); } - const getProductText = (productId: string) => { - const product = products.find((p: ProductV2) => p.id === productId); - return product?.name || "Unknown Plan"; - }; + const getProductName = (productId: string) => + products.find((p: ProductV2) => p.id === productId)?.name ?? "Unknown plan"; return ( - - - - + + + ))} - - - - - - - - - No products found. - - {products.map((product: ProductV2) => ( - handleProductToggle(product.id)} - className="cursor-pointer" - > -
{product.name}
- {rewardProgram.product_ids?.includes(product.id) && ( - - )} -
- ))} -
-
-
-
-
-
+ {productIds.length > MAX_VISIBLE_CHIPS && ( + + +{productIds.length - MAX_VISIBLE_CHIPS} + + )} + + )} + + +
+ {products.map((product: ProductV2) => ( + { + e.preventDefault(); + toggleProduct(product.id); + }} + > + + {product.name} + + ))} +
+
+ + ); };