Merge branch 'main' into dev
This commit is contained in:
@@ -44,7 +44,12 @@
|
|||||||
"vite": {
|
"vite": {
|
||||||
"entry": ["tests/**/*.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
|
"entry": ["tests/**/*.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
|
||||||
"project": ["src/**/*.{ts,tsx}", "tests/**/*.{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": [
|
"ignoreDependencies": [
|
||||||
"tailwindcss",
|
"tailwindcss",
|
||||||
"tailwind-scrollbar-hide",
|
"tailwind-scrollbar-hide",
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import { LEAF_OAUTH_SCOPES } from "@autumn/shared/leafOAuthScopes";
|
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<string>(LEAF_OAUTH_SCOPES);
|
const leafScopeSet = new Set<string>(LEAF_OAUTH_SCOPES);
|
||||||
|
const oauthPassthroughScopeSet = new Set<string>(["offline_access"]);
|
||||||
|
const oauthProtocolScopeSet = new Set<string>(OPENID_SCOPES);
|
||||||
|
|
||||||
export const getDefaultOAuthScopes = (requestedScopes?: string[] | null) => {
|
export const getDefaultOAuthScopes = (requestedScopes?: string[] | null) => {
|
||||||
const requested =
|
const requested =
|
||||||
requestedScopes && requestedScopes.length > 0
|
requestedScopes && requestedScopes.length > 0
|
||||||
? requestedScopes
|
? requestedScopes
|
||||||
: [...LEAF_OAUTH_SCOPES];
|
: [...LEAF_OAUTH_SCOPES, ...oauthPassthroughScopeSet];
|
||||||
|
|
||||||
return [...new Set(requested)].filter((scope): scope is ScopeString =>
|
// Issued scopes must echo the client's request verbatim (better-auth
|
||||||
leafScopeSet.has(scope),
|
// 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 = <T extends string>(scopes: readonly T[]) =>
|
||||||
|
scopes.filter((scope) => !oauthProtocolScopeSet.has(scope));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { prefixOAuthToken } from "@autumn/auth";
|
import { prefixOAuthToken } from "@autumn/auth";
|
||||||
import {
|
import {
|
||||||
|
getOAuthResourceScopes,
|
||||||
getResourceFromOAuthTokenRequest,
|
getResourceFromOAuthTokenRequest,
|
||||||
returnsOAuthAccessTokenForClientId,
|
returnsOAuthAccessTokenForClientId,
|
||||||
} from "@autumn/auth/oauth";
|
} from "@autumn/auth/oauth";
|
||||||
@@ -123,7 +124,10 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => {
|
|||||||
const accessToken = getString(tokenPayload.access_token);
|
const accessToken = getString(tokenPayload.access_token);
|
||||||
if (!accessToken) return response;
|
if (!accessToken) return response;
|
||||||
|
|
||||||
const requestedScopes = scopesFromOAuthScopeString(tokenPayload.scope);
|
const parsedRequestedScopes = scopesFromOAuthScopeString(tokenPayload.scope);
|
||||||
|
const requestedScopes = parsedRequestedScopes
|
||||||
|
? getOAuthResourceScopes(parsedRequestedScopes)
|
||||||
|
: null;
|
||||||
let apiKeyResult: Awaited<ReturnType<typeof getExternalOAuthApiKeyForToken>>;
|
let apiKeyResult: Awaited<ReturnType<typeof getExternalOAuthApiKeyForToken>>;
|
||||||
try {
|
try {
|
||||||
const tokenRecord = await getOAuthAccessTokenRecord({
|
const tokenRecord = await getOAuthAccessTokenRecord({
|
||||||
@@ -142,15 +146,15 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => {
|
|||||||
const issuedScopes = await getOAuthConsentScopeGrant({
|
const issuedScopes = await getOAuthConsentScopeGrant({
|
||||||
db,
|
db,
|
||||||
organizationId: tokenRecord.referenceId,
|
organizationId: tokenRecord.referenceId,
|
||||||
requestedScopes: tokenRecord.scopes,
|
requestedScopes: parsedRequestedScopes ?? tokenRecord.scopes,
|
||||||
userId: tokenRecord.userId,
|
userId: tokenRecord.userId,
|
||||||
});
|
});
|
||||||
tokenRecord.scopes = issuedScopes;
|
tokenRecord.scopes = getOAuthResourceScopes(issuedScopes);
|
||||||
if (tokenRecord.id) {
|
if (tokenRecord.id) {
|
||||||
await oauthAccessTokenRepo.updateScopes({
|
await oauthAccessTokenRepo.updateScopes({
|
||||||
db,
|
db,
|
||||||
id: tokenRecord.id,
|
id: tokenRecord.id,
|
||||||
scopes: issuedScopes,
|
scopes: tokenRecord.scopes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (tokenRecord.refreshId) {
|
if (tokenRecord.refreshId) {
|
||||||
|
|||||||
@@ -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 { ErrCode, isScopeSubset, RecaseError } from "@autumn/shared";
|
||||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||||
import { getScopesForUserInOrg } from "@/utils/authUtils/customSessionScopes.js";
|
import { getScopesForUserInOrg } from "@/utils/authUtils/customSessionScopes.js";
|
||||||
@@ -21,14 +24,21 @@ export const getOAuthConsentScopeGrant = async ({
|
|||||||
organizationId,
|
organizationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const grant = finalRequestedScopes.filter((scope) =>
|
const resourceScopes = getOAuthResourceScopes(finalRequestedScopes);
|
||||||
|
const resourceGrant = resourceScopes.filter((scope) =>
|
||||||
isScopeSubset([scope], userScopes),
|
isScopeSubset([scope], userScopes),
|
||||||
);
|
);
|
||||||
if (grant.length > 0) return grant;
|
if (resourceGrant.length === 0) {
|
||||||
|
|
||||||
throw new RecaseError({
|
throw new RecaseError({
|
||||||
message: "No requested scopes can be granted to this OAuth client",
|
message: "No requested scopes can be granted to this OAuth client",
|
||||||
code: ErrCode.InsufficientScopes,
|
code: ErrCode.InsufficientScopes,
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const grantedResourceScopes = new Set(resourceGrant);
|
||||||
|
return finalRequestedScopes.filter(
|
||||||
|
(scope) =>
|
||||||
|
!resourceScopes.includes(scope) || grantedResourceScopes.has(scope),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||||
import { isMcpOAuthClientId } from "@/internal/auth/oauth/mcpOAuthScopes.js";
|
import { isMcpOAuthClientId } from "@/internal/auth/oauth/mcpOAuthScopes.js";
|
||||||
import {
|
import {
|
||||||
@@ -84,7 +90,15 @@ export const handleCreateOAuthApiKeys = createRoute({
|
|||||||
statusCode: 401,
|
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 })) {
|
if (await isMcpOAuthClientId({ clientId, ctx })) {
|
||||||
throw new RecaseError({
|
throw new RecaseError({
|
||||||
message: "MCP OAuth clients must use OAuth access tokens directly",
|
message: "MCP OAuth clients must use OAuth access tokens directly",
|
||||||
|
|||||||
@@ -1,46 +1,105 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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 { LEAF_OAUTH_SCOPES } from "@autumn/shared";
|
||||||
import { Scopes } from "@autumn/shared/scopeDefinitions";
|
import { Scopes } from "@autumn/shared/scopeDefinitions";
|
||||||
import { getRequestedScopesForMcpClient } from "@/internal/auth/actions/registerMcpOAuthClient.js";
|
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", () => {
|
describe("getRequestedScopesForMcpClient", () => {
|
||||||
test("defaults Slack MCP clients to Leaf OAuth scopes", () => {
|
test("defaults Slack MCP clients to Leaf scopes plus offline access", () => {
|
||||||
expect(
|
expect(
|
||||||
getRequestedScopesForMcpClient({ clientType: "slack", scope: undefined }),
|
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(
|
expect(
|
||||||
getRequestedScopesForMcpClient({ clientType: "codex", scope: undefined }),
|
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(
|
expect(
|
||||||
getRequestedScopesForMcpClient({
|
getRequestedScopesForMcpClient({
|
||||||
clientType: "dynamic",
|
clientType: "dynamic",
|
||||||
scope: undefined,
|
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(
|
expect(
|
||||||
getRequestedScopesForMcpClient({
|
getRequestedScopesForMcpClient({
|
||||||
clientType: "slack",
|
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(
|
expect(
|
||||||
getDefaultOAuthScopes([
|
getDefaultOAuthScopes([
|
||||||
Scopes.Customers.Read,
|
Scopes.Customers.Read,
|
||||||
Scopes.ApiKeys.Write,
|
Scopes.ApiKeys.Write,
|
||||||
Scopes.Analytics.Read,
|
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]);
|
).toEqual([Scopes.Customers.Read, Scopes.Analytics.Read]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import type { ProductItem } from "@autumn/shared";
|
import type { ProductItem, ProductV2 } from "@autumn/shared";
|
||||||
import { Check, X } from "@phosphor-icons/react";
|
import { PackageIcon, XIcon } from "@phosphor-icons/react";
|
||||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
import { Checkbox } from "@/components/v2/checkboxes/Checkbox";
|
||||||
import { SelectGroup, SelectLabel } from "@/components/v2/selects/Select";
|
import {
|
||||||
import { TagSelect } from "@/components/v2/selects/TagSelect";
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/v2/dropdowns/DropdownMenu";
|
||||||
import { useOrg } from "@/hooks/common/useOrg";
|
import { useOrg } from "@/hooks/common/useOrg";
|
||||||
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
|
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
|
||||||
import { useProductsByPriceIdsQuery } from "@/hooks/queries/useProductsByPriceIdsQuery";
|
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 { formatProductItemText } from "@/utils/product/product-item/formatProductItem";
|
||||||
import type { FrontendReward } from "../../types/frontendReward";
|
import type { FrontendReward } from "../../types/frontendReward";
|
||||||
|
|
||||||
|
const MAX_VISIBLE_CHIPS = 3;
|
||||||
|
|
||||||
interface ProductPriceSelectorProps {
|
interface ProductPriceSelectorProps {
|
||||||
reward: FrontendReward;
|
reward: FrontendReward;
|
||||||
setReward: (reward: FrontendReward) => void;
|
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({
|
export function ProductPriceSelector({
|
||||||
reward,
|
reward,
|
||||||
setReward,
|
setReward,
|
||||||
@@ -25,175 +41,268 @@ export function ProductPriceSelector({
|
|||||||
const { features } = useFeaturesQuery();
|
const { features } = useFeaturesQuery();
|
||||||
|
|
||||||
const config = reward.discount_config!;
|
const config = reward.discount_config!;
|
||||||
|
const priceIds = config.price_ids ?? [];
|
||||||
|
const applyToAll = config.apply_to_all ?? false;
|
||||||
|
|
||||||
const linkedPriceIds = config.price_ids ?? [];
|
// Selected price IDs may belong to historical versions absent from the
|
||||||
const { products: linkedProductVersions } =
|
// latest-versions list; resolve their owning product for chip labels.
|
||||||
useProductsByPriceIdsQuery(linkedPriceIds);
|
const { products: linkedProductVersions, isLoading: linkedVersionsLoading } =
|
||||||
|
useProductsByPriceIdsQuery(priceIds);
|
||||||
|
|
||||||
const setConfig = (key: string, value: any) => {
|
const setPriceIds = (nextPriceIds: string[]) =>
|
||||||
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
|
|
||||||
setReward({
|
setReward({
|
||||||
...reward,
|
...reward,
|
||||||
discount_config: {
|
discount_config: {
|
||||||
...config,
|
...config,
|
||||||
apply_to_all: false,
|
apply_to_all: false,
|
||||||
price_ids: newPriceIds,
|
price_ids: nextPriceIds,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const handleApplyToAllToggle = () => {
|
const toggleApplyToAll = () =>
|
||||||
const newApplyToAll = !config.apply_to_all;
|
|
||||||
|
|
||||||
if (newApplyToAll) {
|
|
||||||
// Enabling "Apply to all" clears price_ids
|
|
||||||
setReward({
|
setReward({
|
||||||
...reward,
|
...reward,
|
||||||
discount_config: {
|
discount_config: {
|
||||||
...config,
|
...config,
|
||||||
apply_to_all: true,
|
apply_to_all: !applyToAll,
|
||||||
price_ids: [],
|
price_ids: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// Disabling "Apply to all" just sets it to false
|
const togglePrice = (priceId: string) =>
|
||||||
setConfig("apply_to_all", false);
|
setPriceIds(
|
||||||
}
|
priceIds.includes(priceId)
|
||||||
|
? priceIds.filter((id) => id !== priceId)
|
||||||
|
: [...priceIds, priceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 formatPriceTag = (priceId: string) => {
|
const availableProducts = products.filter(
|
||||||
const product = linkedProductVersions.find((p: any) =>
|
(product) => priceItemsOf(product).length > 0,
|
||||||
p.items.find((i: any) => i.price_id === priceId),
|
|
||||||
);
|
);
|
||||||
const item = product?.items.find((i: any) => i.price_id === priceId);
|
|
||||||
|
|
||||||
if (!item || !product) return "Unknown Price";
|
// 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 });
|
const priceText = formatProductItemText({ item, org, features });
|
||||||
return `${product.name} v${product.version} — ${priceText}`;
|
return `${product.name} v${product.version} — ${priceText}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get products with non-feature items
|
type Chip = { key: string; label: string; onRemove?: () => void };
|
||||||
const availableProducts = products.filter((product: any) => {
|
|
||||||
const nonFeatureItems = product.items?.filter(
|
// Collapse a product's prices into a single product chip when all are selected.
|
||||||
(item: ProductItem) => !isFeatureItem(item),
|
const buildChips = (): Chip[] => {
|
||||||
);
|
if (applyToAll) return [{ key: "__all__", label: "All products" }];
|
||||||
return nonFeatureItems && nonFeatureItems.length > 0;
|
|
||||||
|
const chips: Chip[] = [];
|
||||||
|
const seenProducts = new Set<string>();
|
||||||
|
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;
|
||||||
if (!products || products.length === 0) {
|
|
||||||
return <p className="text-sm text-tertiary-foreground">No products available</p>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build options list - not used for display, just for reference
|
chips.push({
|
||||||
const priceOptions = availableProducts.flatMap((product: any) => {
|
key: priceId,
|
||||||
const nonFeatureItems = product.items.filter(
|
label: chipLabel(priceId),
|
||||||
(item: ProductItem) => !isFeatureItem(item),
|
onRemove: () => togglePrice(priceId),
|
||||||
);
|
|
||||||
return nonFeatureItems.map((item: any) => ({
|
|
||||||
value: item.price_id,
|
|
||||||
label: formatProductItemText({ item, org, features }),
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
|
||||||
<TagSelect
|
|
||||||
value={config.price_ids || []}
|
|
||||||
onChange={(values) => 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 */}
|
|
||||||
<SelectGroup>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
handleApplyToAllToggle();
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleApplyToAllToggle();
|
|
||||||
setOpen(false);
|
|
||||||
}
|
}
|
||||||
}}
|
return chips;
|
||||||
>
|
};
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<span>Apply to all products</span>
|
|
||||||
{config.apply_to_all && (
|
|
||||||
<Check size={14} className="text-primary ml-2" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SelectGroup>
|
|
||||||
|
|
||||||
{/* Product groups */}
|
if (!products || products.length === 0)
|
||||||
{availableProducts.map((product: any) => {
|
return (
|
||||||
const nonFeatureItems = product.items.filter(
|
<p className="text-sm text-tertiary-foreground">No products available</p>
|
||||||
(item: ProductItem) => !isFeatureItem(item),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const chips = buildChips();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectGroup key={product.id}>
|
<div className="min-w-0 w-full">
|
||||||
<SelectLabel>{product.name}</SelectLabel>
|
<DropdownMenu>
|
||||||
{nonFeatureItems.map((item: any) => {
|
<DropdownMenuTrigger className="flex h-8 w-full min-w-0 cursor-pointer items-center gap-1.5 overflow-hidden rounded-xl px-3 input-base input-state-open-tiny text-sm">
|
||||||
const isSelected = config.price_ids?.includes(item.price_id);
|
{chips.length === 0 ? (
|
||||||
return (
|
<span className="text-tertiary-foreground">
|
||||||
<div
|
Select plans or apply to all...
|
||||||
key={item.price_id}
|
</span>
|
||||||
role="button"
|
) : (
|
||||||
tabIndex={0}
|
<>
|
||||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
|
{chips.slice(0, MAX_VISIBLE_CHIPS).map((chip) => (
|
||||||
onClick={() => handlePriceToggle(item.price_id)}
|
<span
|
||||||
onKeyDown={(e) => {
|
className="flex h-4.5 max-w-48 shrink-0 items-center gap-0.5 rounded border border-border bg-accent px-1 text-[10px] text-foreground"
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
key={chip.key}
|
||||||
e.preventDefault();
|
>
|
||||||
handlePriceToggle(item.price_id);
|
<span className="shrink-0 [&_svg]:size-3">
|
||||||
}
|
<PackageIcon
|
||||||
}}
|
className="text-tertiary-foreground"
|
||||||
>
|
size={12}
|
||||||
<div className="flex items-center justify-between w-full">
|
weight="duotone"
|
||||||
<span className="truncate">
|
/>
|
||||||
{formatProductItemText({
|
</span>
|
||||||
item,
|
<span className="truncate">{chip.label}</span>
|
||||||
org,
|
{chip.onRemove && (
|
||||||
features,
|
<span
|
||||||
})}
|
className="ml-0.5 cursor-pointer text-tertiary-foreground hover:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
chip.onRemove?.();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<XIcon size={10} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{chips.length > MAX_VISIBLE_CHIPS && (
|
||||||
|
<span className="shrink-0 px-1 text-sm text-tertiary-foreground">
|
||||||
|
+{chips.length - MAX_VISIBLE_CHIPS}
|
||||||
</span>
|
</span>
|
||||||
{isSelected && (
|
|
||||||
<Check size={14} className="text-primary ml-2" />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectGroup>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="flex cursor-pointer items-center gap-2 font-medium"
|
||||||
|
closeOnClick={false}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleApplyToAll();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={applyToAll} className="border-border" />
|
||||||
|
<span className="truncate">Apply to all products</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="max-h-72 overflow-y-auto">
|
||||||
|
{availableProducts.map((product) => {
|
||||||
|
const priceItems = priceItemsOf(product);
|
||||||
|
|
||||||
|
if (priceItems.length === 1) {
|
||||||
|
const priceId = priceItems[0].price_id;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="flex cursor-pointer items-center gap-2 font-medium"
|
||||||
|
closeOnClick={false}
|
||||||
|
key={product.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePrice(priceId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={!applyToAll && priceIds.includes(priceId)}
|
||||||
|
className="border-border"
|
||||||
/>
|
/>
|
||||||
|
<span className="truncate">{product.name}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<DropdownMenuSub key={product.id}>
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
className="flex cursor-pointer items-center gap-2 font-medium"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleProduct(product);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={someSelected && !allSelected}
|
||||||
|
className="border-border"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{product.name}</span>
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="flex cursor-pointer items-center gap-2 font-medium"
|
||||||
|
closeOnClick={false}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleProduct(product);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={someSelected && !allSelected}
|
||||||
|
className="border-border"
|
||||||
|
/>
|
||||||
|
All prices
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{priceItems.map((item) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="flex cursor-pointer items-center gap-2 text-sm"
|
||||||
|
closeOnClick={false}
|
||||||
|
key={item.price_id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePrice(item.price_id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
!applyToAll && priceIds.includes(item.price_id)
|
||||||
|
}
|
||||||
|
className="border-border"
|
||||||
|
/>
|
||||||
|
<span className="truncate">
|
||||||
|
{formatProductItemText({ item, org, features })}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import type { AxiosError } from "axios";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton";
|
import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/v2/dialogs/Dialog";
|
||||||
import {
|
import {
|
||||||
SheetFooter,
|
SheetFooter,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
@@ -40,6 +48,7 @@ export function UpdateRewardSheet({
|
|||||||
const { features } = useFeaturesQuery();
|
const { features } = useFeaturesQuery();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [confirmCouponOpen, setConfirmCouponOpen] = useState(false);
|
||||||
|
|
||||||
const reward = useRewardStore((s) => s.reward);
|
const reward = useRewardStore((s) => s.reward);
|
||||||
const setReward = useRewardStore((s) => s.setReward);
|
const setReward = useRewardStore((s) => s.setReward);
|
||||||
@@ -106,8 +115,8 @@ export function UpdateRewardSheet({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const performUpdate = async () => {
|
||||||
if (!selectedReward || !isFormValid()) return;
|
if (!selectedReward) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -124,6 +133,7 @@ export function UpdateRewardSheet({
|
|||||||
|
|
||||||
await refetch();
|
await refetch();
|
||||||
toast.success("Reward updated successfully");
|
toast.success("Reward updated successfully");
|
||||||
|
setConfirmCouponOpen(false);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -134,11 +144,24 @@ 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 = () => {
|
const handleCancel = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
<SheetContent className="flex flex-col overflow-hidden">
|
<SheetContent className="flex flex-col overflow-hidden">
|
||||||
<SheetHeader
|
<SheetHeader
|
||||||
@@ -184,5 +207,39 @@ export function UpdateRewardSheet({
|
|||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
<Dialog open={confirmCouponOpen} onOpenChange={setConfirmCouponOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Update coupon?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Stripe doesn't have functionality to update coupons. This will
|
||||||
|
delete it and recreate it. Existing customers that have this
|
||||||
|
coupon will be unaffected.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter className="grid grid-cols-2 gap-2">
|
||||||
|
<ShortcutButton
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setConfirmCouponOpen(false)}
|
||||||
|
singleShortcut="escape"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ShortcutButton>
|
||||||
|
<ShortcutButton
|
||||||
|
className="w-full"
|
||||||
|
onClick={performUpdate}
|
||||||
|
metaShortcut="enter"
|
||||||
|
isLoading={loading}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</ShortcutButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,25 +5,16 @@ import {
|
|||||||
RewardReceivedBy,
|
RewardReceivedBy,
|
||||||
RewardTriggerEvent,
|
RewardTriggerEvent,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { Check, ChevronsUpDown, X } from "lucide-react";
|
import { PackageIcon, XIcon } from "@phosphor-icons/react";
|
||||||
import { useId, useState } from "react";
|
import { useId } from "react";
|
||||||
import FieldLabel from "@/components/general/modal-components/FieldLabel";
|
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 { Checkbox } from "@/components/v2/checkboxes/Checkbox";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/v2/dropdowns/DropdownMenu";
|
||||||
import { Input } from "@/components/v2/inputs/Input";
|
import { Input } from "@/components/v2/inputs/Input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -188,6 +179,8 @@ export const RewardProgramConfig = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_VISIBLE_CHIPS = 3;
|
||||||
|
|
||||||
const ProductSelector = ({
|
const ProductSelector = ({
|
||||||
rewardProgram,
|
rewardProgram,
|
||||||
setRewardProgram,
|
setRewardProgram,
|
||||||
@@ -196,89 +189,89 @@ const ProductSelector = ({
|
|||||||
setRewardProgram: (rewardProgram: RewardProgram) => void;
|
setRewardProgram: (rewardProgram: RewardProgram) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { products } = useProductsQuery();
|
const { products } = useProductsQuery();
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
// Handle selection/deselection of a product
|
const productIds = rewardProgram.product_ids ?? [];
|
||||||
const handleProductToggle = (productId: string) => {
|
|
||||||
let newProductIds = [...(rewardProgram.product_ids || [])];
|
const toggleProduct = (productId: string) =>
|
||||||
if (newProductIds.includes(productId)) {
|
|
||||||
newProductIds = newProductIds.filter((id) => id !== productId);
|
|
||||||
} else {
|
|
||||||
newProductIds = [...newProductIds, productId];
|
|
||||||
}
|
|
||||||
setRewardProgram({
|
setRewardProgram({
|
||||||
...rewardProgram,
|
...rewardProgram,
|
||||||
product_ids: newProductIds,
|
product_ids: productIds.includes(productId)
|
||||||
|
? productIds.filter((id) => id !== productId)
|
||||||
|
: [...productIds, productId],
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
if (!products || products.length === 0) {
|
if (!products || products.length === 0) {
|
||||||
return <p className="text-sm text-tertiary-foreground">No products available</p>;
|
return (
|
||||||
|
<p className="text-sm text-tertiary-foreground">No products available</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getProductText = (productId: string) => {
|
const getProductName = (productId: string) =>
|
||||||
const product = products.find((p: ProductV2) => p.id === productId);
|
products.find((p: ProductV2) => p.id === productId)?.name ?? "Unknown plan";
|
||||||
return product?.name || "Unknown Plan";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover modal open={open} onOpenChange={setOpen}>
|
<div className="min-w-0 w-full">
|
||||||
<PopoverTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button
|
<DropdownMenuTrigger className="flex h-8 w-full min-w-0 cursor-pointer items-center gap-1.5 overflow-hidden rounded-xl px-3 input-base input-state-open-tiny text-sm">
|
||||||
variant="muted"
|
{productIds.length === 0 ? (
|
||||||
role="combobox"
|
<span className="text-tertiary-foreground">Select plans...</span>
|
||||||
aria-expanded={open}
|
) : (
|
||||||
className="w-full min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative data-[state=open]:border-focus data-[state=open]:shadow-focus"
|
<>
|
||||||
>
|
{productIds.slice(0, MAX_VISIBLE_CHIPS).map((productId) => (
|
||||||
{rewardProgram.product_ids?.length === 0
|
<span
|
||||||
? "Select Plans"
|
className="flex h-4.5 max-w-48 shrink-0 items-center gap-0.5 rounded border border-border bg-accent px-1 text-[10px] text-foreground"
|
||||||
: rewardProgram.product_ids?.map((productId: string) => (
|
|
||||||
<div
|
|
||||||
key={productId}
|
key={productId}
|
||||||
className="py-0 px-3 text-xs text-tertiary-foreground border-zinc-300 bg-zinc-100 rounded-full w-fit flex items-center gap-2 h-fit"
|
|
||||||
>
|
>
|
||||||
<p className="text-muted-foreground">{getProductText(productId)}</p>
|
<span className="shrink-0 [&_svg]:size-3">
|
||||||
<Button
|
<PackageIcon
|
||||||
variant="skeleton"
|
className="text-tertiary-foreground"
|
||||||
size="sm"
|
size={12}
|
||||||
|
weight="duotone"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{getProductName(productId)}</span>
|
||||||
|
<span
|
||||||
|
className="ml-0.5 cursor-pointer text-tertiary-foreground hover:text-destructive"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleProductToggle(productId);
|
toggleProduct(productId);
|
||||||
}}
|
}}
|
||||||
className="bg-transparent hover:bg-transparent p-0 w-5 h-5"
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<X size={12} className="text-tertiary-foreground" />
|
<XIcon size={10} />
|
||||||
</Button>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
))}
|
))}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 absolute right-2" />
|
{productIds.length > MAX_VISIBLE_CHIPS && (
|
||||||
</Button>
|
<span className="shrink-0 px-1 text-sm text-tertiary-foreground">
|
||||||
</PopoverTrigger>
|
+{productIds.length - MAX_VISIBLE_CHIPS}
|
||||||
<PopoverContent className="w-[400px] p-0" align="start">
|
</span>
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search plans..." className="h-9" />
|
|
||||||
<CommandList className="max-h-[300px] overflow-y-auto">
|
|
||||||
<ScrollArea>
|
|
||||||
<CommandEmpty>No products found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{products.map((product: ProductV2) => (
|
|
||||||
<CommandItem
|
|
||||||
key={product.id}
|
|
||||||
value={product.id}
|
|
||||||
onSelect={() => handleProductToggle(product.id)}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">{product.name}</div>
|
|
||||||
{rewardProgram.product_ids?.includes(product.id) && (
|
|
||||||
<Check size={12} className="text-tertiary-foreground" />
|
|
||||||
)}
|
)}
|
||||||
</CommandItem>
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
|
<div className="max-h-72 overflow-y-auto">
|
||||||
|
{products.map((product: ProductV2) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="flex cursor-pointer items-center gap-2 font-medium"
|
||||||
|
closeOnClick={false}
|
||||||
|
key={product.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleProduct(product.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={productIds.includes(product.id)}
|
||||||
|
className="border-border"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{product.name}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</div>
|
||||||
</ScrollArea>
|
</DropdownMenuContent>
|
||||||
</CommandList>
|
</DropdownMenu>
|
||||||
</Command>
|
</div>
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user