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/packages/auth/src/oauth/leafOAuth.ts b/packages/auth/src/oauth/leafOAuth.ts index b91ef7de3..1bd92ebfc 100644 --- a/packages/auth/src/oauth/leafOAuth.ts +++ b/packages/auth/src/oauth/leafOAuth.ts @@ -1,15 +1,27 @@ import { LEAF_OAUTH_SCOPES } from "@autumn/shared/leafOAuthScopes"; -import type { ScopeString } from "@autumn/shared/scopeDefinitions"; +import { + LEGACY_SCOPE_ALIASES, + OPENID_SCOPES, +} from "@autumn/shared/scopeDefinitions"; const leafScopeSet = new Set(LEAF_OAUTH_SCOPES); +const oauthPassthroughScopeSet = new Set(["offline_access"]); +const oauthProtocolScopeSet = new Set(OPENID_SCOPES); export const getDefaultOAuthScopes = (requestedScopes?: string[] | null) => { const requested = requestedScopes && requestedScopes.length > 0 ? requestedScopes - : [...LEAF_OAUTH_SCOPES]; + : [...LEAF_OAUTH_SCOPES, ...oauthPassthroughScopeSet]; - return [...new Set(requested)].filter((scope): scope is ScopeString => - leafScopeSet.has(scope), + // Issued scopes must echo the client's request verbatim (better-auth + // rejects rewrites), so legacy CRUDL aliases are used for filtering only. + return [...new Set(requested)].filter( + (scope) => + leafScopeSet.has(LEGACY_SCOPE_ALIASES[scope] ?? scope) || + oauthPassthroughScopeSet.has(scope), ); }; + +export const getOAuthResourceScopes = (scopes: readonly T[]) => + scopes.filter((scope) => !oauthProtocolScopeSet.has(scope)); diff --git a/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts b/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts index 7b933a553..2e4ea2068 100644 --- a/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts +++ b/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts @@ -1,5 +1,6 @@ import { prefixOAuthToken } from "@autumn/auth"; import { + getOAuthResourceScopes, getResourceFromOAuthTokenRequest, returnsOAuthAccessTokenForClientId, } from "@autumn/auth/oauth"; @@ -123,7 +124,10 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => { const accessToken = getString(tokenPayload.access_token); if (!accessToken) return response; - const requestedScopes = scopesFromOAuthScopeString(tokenPayload.scope); + const parsedRequestedScopes = scopesFromOAuthScopeString(tokenPayload.scope); + const requestedScopes = parsedRequestedScopes + ? getOAuthResourceScopes(parsedRequestedScopes) + : null; let apiKeyResult: Awaited>; try { const tokenRecord = await getOAuthAccessTokenRecord({ @@ -142,15 +146,15 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => { const issuedScopes = await getOAuthConsentScopeGrant({ db, organizationId: tokenRecord.referenceId, - requestedScopes: tokenRecord.scopes, + requestedScopes: parsedRequestedScopes ?? tokenRecord.scopes, userId: tokenRecord.userId, }); - tokenRecord.scopes = issuedScopes; + tokenRecord.scopes = getOAuthResourceScopes(issuedScopes); if (tokenRecord.id) { await oauthAccessTokenRepo.updateScopes({ db, id: tokenRecord.id, - scopes: issuedScopes, + scopes: tokenRecord.scopes, }); } if (tokenRecord.refreshId) { diff --git a/server/src/internal/auth/oauth/oauthConsentScopes.ts b/server/src/internal/auth/oauth/oauthConsentScopes.ts index eaa21f06d..cf1392f6e 100644 --- a/server/src/internal/auth/oauth/oauthConsentScopes.ts +++ b/server/src/internal/auth/oauth/oauthConsentScopes.ts @@ -1,4 +1,7 @@ -import { getDefaultOAuthScopes } from "@autumn/auth/oauth"; +import { + getDefaultOAuthScopes, + getOAuthResourceScopes, +} from "@autumn/auth/oauth"; import { ErrCode, isScopeSubset, RecaseError } from "@autumn/shared"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { getScopesForUserInOrg } from "@/utils/authUtils/customSessionScopes.js"; @@ -21,14 +24,21 @@ export const getOAuthConsentScopeGrant = async ({ organizationId, }); - const grant = finalRequestedScopes.filter((scope) => + const resourceScopes = getOAuthResourceScopes(finalRequestedScopes); + const resourceGrant = resourceScopes.filter((scope) => isScopeSubset([scope], userScopes), ); - if (grant.length > 0) return grant; + if (resourceGrant.length === 0) { + throw new RecaseError({ + message: "No requested scopes can be granted to this OAuth client", + code: ErrCode.InsufficientScopes, + statusCode: 403, + }); + } - throw new RecaseError({ - message: "No requested scopes can be granted to this OAuth client", - code: ErrCode.InsufficientScopes, - statusCode: 403, - }); + const grantedResourceScopes = new Set(resourceGrant); + return finalRequestedScopes.filter( + (scope) => + !resourceScopes.includes(scope) || grantedResourceScopes.has(scope), + ); }; diff --git a/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts b/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts index 08ab45a13..b6836a175 100644 --- a/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts +++ b/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts @@ -1,4 +1,10 @@ -import { AppEnv, ErrCode, RecaseError, Scopes } from "@autumn/shared"; +import { + AppEnv, + ErrCode, + LEGACY_SCOPE_ALIASES, + RecaseError, + Scopes, +} from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { isMcpOAuthClientId } from "@/internal/auth/oauth/mcpOAuthScopes.js"; import { @@ -84,7 +90,15 @@ export const handleCreateOAuthApiKeys = createRoute({ statusCode: 401, }); } - const apiKeyScopes = requestedScopes ?? tokenRecord.scopes; + // Tokens may carry legacy CRUDL scopes (old CLI); store modern R/W on keys. + const apiKeyScopes = [ + ...new Set( + (requestedScopes ?? tokenRecord.scopes).map( + (scope) => LEGACY_SCOPE_ALIASES[scope] ?? scope, + ), + ), + ]; + if (await isMcpOAuthClientId({ clientId, ctx })) { throw new RecaseError({ message: "MCP OAuth clients must use OAuth access tokens directly", diff --git a/server/tests/unit/auth/registerMcpOAuthClient.test.ts b/server/tests/unit/auth/registerMcpOAuthClient.test.ts index 4819fd086..b437a53cd 100644 --- a/server/tests/unit/auth/registerMcpOAuthClient.test.ts +++ b/server/tests/unit/auth/registerMcpOAuthClient.test.ts @@ -1,46 +1,105 @@ import { describe, expect, test } from "bun:test"; -import { getDefaultOAuthScopes } from "@autumn/auth/oauth"; +import { + getDefaultOAuthScopes, + getOAuthResourceScopes, +} from "@autumn/auth/oauth"; import { LEAF_OAUTH_SCOPES } from "@autumn/shared"; import { Scopes } from "@autumn/shared/scopeDefinitions"; import { getRequestedScopesForMcpClient } from "@/internal/auth/actions/registerMcpOAuthClient.js"; +const OFFLINE_ACCESS_SCOPE = "offline_access"; +const DEFAULT_MCP_SCOPES = [...LEAF_OAUTH_SCOPES, OFFLINE_ACCESS_SCOPE]; + describe("getRequestedScopesForMcpClient", () => { - test("defaults Slack MCP clients to Leaf OAuth scopes", () => { + test("defaults Slack MCP clients to Leaf scopes plus offline access", () => { expect( getRequestedScopesForMcpClient({ clientType: "slack", scope: undefined }), - ).toEqual([...LEAF_OAUTH_SCOPES]); + ).toEqual(DEFAULT_MCP_SCOPES); }); - test("defaults Codex MCP clients to Leaf OAuth scopes", () => { + test("defaults Codex MCP clients to Leaf scopes plus offline access", () => { expect( getRequestedScopesForMcpClient({ clientType: "codex", scope: undefined }), - ).toEqual([...LEAF_OAUTH_SCOPES]); + ).toEqual(DEFAULT_MCP_SCOPES); }); - test("defaults dynamic MCP clients to Leaf OAuth scopes", () => { + test("defaults dynamic MCP clients to Leaf scopes plus offline access", () => { expect( getRequestedScopesForMcpClient({ clientType: "dynamic", scope: undefined, }), - ).toEqual([...LEAF_OAUTH_SCOPES]); + ).toEqual(DEFAULT_MCP_SCOPES); }); - test("caps explicit requested scopes to Leaf scopes", () => { + test("caps explicit requested scopes to Leaf scopes plus offline access", () => { expect( getRequestedScopesForMcpClient({ clientType: "slack", - scope: `${Scopes.Customers.Read} ${Scopes.Plans.Write} ${Scopes.ApiKeys.Write} invalid`, + scope: `${Scopes.Customers.Read} ${Scopes.Plans.Write} ${Scopes.ApiKeys.Write} ${OFFLINE_ACCESS_SCOPE} invalid`, }), - ).toEqual([Scopes.Customers.Read, Scopes.Plans.Write]); + ).toEqual([ + Scopes.Customers.Read, + Scopes.Plans.Write, + OFFLINE_ACCESS_SCOPE, + ]); }); - test("caps OAuth grants to Leaf scopes", () => { + test("preserves offline access in default OAuth scopes", () => { + expect(getDefaultOAuthScopes()).toEqual(DEFAULT_MCP_SCOPES); + }); + + test("caps OAuth grants to Leaf scopes plus offline access", () => { expect( getDefaultOAuthScopes([ Scopes.Customers.Read, Scopes.ApiKeys.Write, Scopes.Analytics.Read, + OFFLINE_ACCESS_SCOPE, + ]), + ).toEqual([ + Scopes.Customers.Read, + Scopes.Analytics.Read, + OFFLINE_ACCESS_SCOPE, + ]); + }); + + test("keeps legacy CRUDL scopes (old CLI) whose alias is leaf-allowed, verbatim", () => { + expect( + getDefaultOAuthScopes([ + "customers:create", + "customers:read", + "customers:list", + "customers:update", + "customers:delete", + "features:create", + "features:read", + "plans:update", + "apiKeys:create", + "organisation:read", + ]), + ).toEqual([ + "customers:create", + "customers:read", + "customers:list", + "customers:update", + "customers:delete", + "features:create", + "features:read", + "plans:update", + "organisation:read", + ]); + }); + + test("strips OAuth protocol scopes from resource scopes", () => { + expect( + getOAuthResourceScopes([ + Scopes.Customers.Read, + OFFLINE_ACCESS_SCOPE, + "openid", + "profile", + "email", + Scopes.Analytics.Read, ]), ).toEqual([Scopes.Customers.Read, Scopes.Analytics.Read]); }); diff --git a/vite/src/views/products/rewards/reward-config/components/ProductPriceSelector.tsx b/vite/src/views/products/rewards/reward-config/components/ProductPriceSelector.tsx index ac0c8fe83..cdad0daeb 100644 --- a/vite/src/views/products/rewards/reward-config/components/ProductPriceSelector.tsx +++ b/vite/src/views/products/rewards/reward-config/components/ProductPriceSelector.tsx @@ -1,8 +1,16 @@ -import type { ProductItem } from "@autumn/shared"; -import { Check, X } from "@phosphor-icons/react"; -import { IconButton } from "@/components/v2/buttons/IconButton"; -import { SelectGroup, SelectLabel } from "@/components/v2/selects/Select"; -import { TagSelect } from "@/components/v2/selects/TagSelect"; +import type { ProductItem, ProductV2 } from "@autumn/shared"; +import { PackageIcon, XIcon } from "@phosphor-icons/react"; +import { Checkbox } from "@/components/v2/checkboxes/Checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/v2/dropdowns/DropdownMenu"; import { useOrg } from "@/hooks/common/useOrg"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useProductsByPriceIdsQuery } from "@/hooks/queries/useProductsByPriceIdsQuery"; @@ -11,11 +19,19 @@ import { isFeatureItem } from "@/utils/product/getItemType"; import { formatProductItemText } from "@/utils/product/product-item/formatProductItem"; import type { FrontendReward } from "../../types/frontendReward"; +const MAX_VISIBLE_CHIPS = 3; + interface ProductPriceSelectorProps { reward: FrontendReward; setReward: (reward: FrontendReward) => 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 7664114d2..9a2aeba96 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); @@ -106,8 +115,8 @@ export function UpdateRewardSheet({ return true; }; - const handleUpdate = async () => { - if (!selectedReward || !isFormValid()) return; + const performUpdate = async () => { + if (!selectedReward) return; setLoading(true); try { @@ -124,6 +133,7 @@ export function UpdateRewardSheet({ await refetch(); toast.success("Reward updated successfully"); + setConfirmCouponOpen(false); setOpen(false); } catch (error: unknown) { toast.error( @@ -134,55 +144,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} + + ))} +
+
+ + ); };