diff --git a/vite/index.html b/vite/index.html index bacd47f28..fa38f218c 100644 --- a/vite/index.html +++ b/vite/index.html @@ -37,6 +37,12 @@ } })(); + +
diff --git a/vite/package.json b/vite/package.json index 6f207ad71..de56c2a2d 100644 --- a/vite/package.json +++ b/vite/package.json @@ -76,6 +76,7 @@ "react": "^18.2.0", "react-day-picker": "^8.10.1", "react-dom": "^18.2.0", + "react-grab": "^0.1.29", "react-hotkeys-hook": "^4.6.1", "react-router": "^7.3.0", "react-router-dom": "^7.6.2", diff --git a/vite/src/hooks/stores/useRewardStore.ts b/vite/src/hooks/stores/useRewardStore.ts index 60cb3bf87..21b1b566d 100644 --- a/vite/src/hooks/stores/useRewardStore.ts +++ b/vite/src/hooks/stores/useRewardStore.ts @@ -11,6 +11,7 @@ const DEFAULT_REWARD: FrontendReward = { free_product_id: null, discount_config: defaultDiscountConfig, free_product_config: null, + featureGrantEntitlements: [], }; interface RewardState { diff --git a/vite/src/views/products/rewards/reward-config/components/CreateRewardSheet.tsx b/vite/src/views/products/rewards/reward-config/components/CreateRewardSheet.tsx index 2b5a941cf..1be9c97a0 100644 --- a/vite/src/views/products/rewards/reward-config/components/CreateRewardSheet.tsx +++ b/vite/src/views/products/rewards/reward-config/components/CreateRewardSheet.tsx @@ -12,6 +12,7 @@ import { SheetContent, SheetTrigger, } from "@/components/v2/sheets/Sheet"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery"; import { useRewardStore } from "@/hooks/stores/useRewardStore"; import { RewardService } from "@/services/products/RewardService"; @@ -19,6 +20,7 @@ import { useAxiosInstance } from "@/services/useAxiosInstance"; import { getBackendErr } from "@/utils/genUtils"; import { mapFrontendToApiReward } from "../../utils/rewardMappers"; import { DiscountRewardConfig } from "./DiscountRewardConfig"; +import { FeatureGrantRewardConfig } from "./FeatureGrantRewardConfig"; import { FreeProductRewardConfig } from "./FreeProductRewardConfig"; import { RewardDetails } from "./RewardDetails"; import { SelectRewardType } from "./SelectRewardType"; @@ -34,6 +36,7 @@ export function CreateRewardSheet({ }: CreateRewardSheetProps = {}) { const axiosInstance = useAxiosInstance(); const { refetch } = useRewardsQuery(); + const { features } = useFeaturesQuery(); const [loading, setLoading] = useState(false); const [internalOpen, setInternalOpen] = useState(false); @@ -86,9 +89,31 @@ export function CreateRewardSheet({ return; } + if (reward.rewardCategory === "feature_grant") { + const validEntitlements = reward.featureGrantEntitlements.filter( + (e) => e.feature_id && e.allowance > 0, + ); + if (validEntitlements.length === 0) { + toast.error( + "Please add at least one entitlement with a feature and balance", + ); + return; + } + if ( + !reward.promo_codes?.length || + !reward.promo_codes.some((pc) => pc.code) + ) { + toast.error("Please add at least one promo code"); + return; + } + } + setLoading(true); try { - const apiReward = mapFrontendToApiReward(reward); + const apiReward = mapFrontendToApiReward({ + frontendReward: reward, + features, + }); await RewardService.createReward({ axiosInstance, @@ -135,6 +160,10 @@ export function CreateRewardSheet({ {reward.rewardCategory === "free_product" && ( )} + + {reward.rewardCategory === "feature_grant" && ( + + )} diff --git a/vite/src/views/products/rewards/reward-config/components/FeatureGrantRewardConfig.tsx b/vite/src/views/products/rewards/reward-config/components/FeatureGrantRewardConfig.tsx new file mode 100644 index 000000000..979d3c4e5 --- /dev/null +++ b/vite/src/views/products/rewards/reward-config/components/FeatureGrantRewardConfig.tsx @@ -0,0 +1,347 @@ +import { + type Feature, + FeatureGrantDuration, + FeatureType, +} from "@autumn/shared"; +import { PlusIcon, TrashIcon } from "@phosphor-icons/react"; +import { Button } from "@/components/v2/buttons/Button"; +import { FormLabel } from "@/components/v2/form/FormLabel"; +import { Input } from "@/components/v2/inputs/Input"; +import { SearchableSelect } from "@/components/v2/selects/SearchableSelect"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/v2/selects/Select"; +import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import type { + FrontendReward, + FrontendRewardEntitlement, +} from "../../types/frontendReward"; + +interface FeatureGrantRewardConfigProps { + reward: FrontendReward; + setReward: (reward: FrontendReward) => void; +} + +export function FeatureGrantRewardConfig({ + reward, + setReward, +}: FeatureGrantRewardConfigProps) { + const { features } = useFeaturesQuery(); + + // Filter to metered, non-boolean features only + const meteredFeatures = features.filter( + (f) => f.type === FeatureType.Metered, + ); + + const entitlements = reward.featureGrantEntitlements; + + const updateEntitlement = ({ + index, + updates, + }: { + index: number; + updates: Partial; + }) => { + const updated = [...entitlements]; + updated[index] = { ...updated[index], ...updates }; + setReward({ ...reward, featureGrantEntitlements: updated }); + }; + + const addEntitlement = () => { + // Infer expiry from the first entitlement that has one + const existingExpiry = entitlements.find((e) => e.expiry)?.expiry; + setReward({ + ...reward, + featureGrantEntitlements: [ + ...entitlements, + { + feature_id: "", + allowance: 0, + expiry: existingExpiry ? { ...existingExpiry } : undefined, + }, + ], + }); + }; + + const removeEntitlement = ({ index }: { index: number }) => { + setReward({ + ...reward, + featureGrantEntitlements: entitlements.filter((_, i) => i !== index), + }); + }; + + const updatePromoCode = ({ + index, + code, + }: { + index: number; + code: string; + }) => { + const updated = [...(reward.promo_codes || [])]; + updated[index] = { ...updated[index], code }; + setReward({ ...reward, promo_codes: updated }); + }; + + const updateMaxRedemptions = ({ + index, + value, + }: { + index: number; + value: number | undefined; + }) => { + const updated = [...(reward.promo_codes || [])]; + updated[index] = { ...updated[index], max_redemptions: value }; + setReward({ ...reward, promo_codes: updated }); + }; + + const addPromoCode = () => { + // Infer max_redemptions from first promo code + const existingMax = reward.promo_codes?.find( + (pc) => pc.max_redemptions, + )?.max_redemptions; + setReward({ + ...reward, + promo_codes: [ + ...(reward.promo_codes || []), + { code: "", max_redemptions: existingMax }, + ], + }); + }; + + const removePromoCode = ({ index }: { index: number }) => { + setReward({ + ...reward, + promo_codes: (reward.promo_codes || []).filter((_, i) => i !== index), + }); + }; + + // Exclude features already selected in other entitlements + const getAvailableFeatures = ({ currentIndex }: { currentIndex: number }) => { + const selectedIds = entitlements + .filter((_, i) => i !== currentIndex) + .map((e) => e.feature_id); + return meteredFeatures.filter((f) => !selectedIds.includes(f.id)); + }; + + return ( + <> + {/* Promo Codes Section */} + +
+ {(reward.promo_codes || []).map((promoCode, index) => ( +
+
+ {index === 0 && Code} + + updatePromoCode({ + index, + code: e.target.value + .toUpperCase() + .replace(/[^A-Z0-9]/g, ""), + }) + } + placeholder="PROMO2024" + /> +
+
+ {index === 0 && Max Uses} + + updateMaxRedemptions({ + index, + value: e.target.value + ? Number(e.target.value) + : undefined, + }) + } + placeholder="Unlimited" + /> +
+ {(reward.promo_codes || []).length > 1 && ( + + )} +
+ ))} + +
+
+ + {/* Entitlements Section */} + +
+ {entitlements.map((ent, index) => ( +
+ {entitlements.length > 1 && ( + + )} + + {/* Feature selector */} +
+ Feature + + value={ent.feature_id || null} + onValueChange={(value) => + updateEntitlement({ + index, + updates: { feature_id: value }, + }) + } + options={getAvailableFeatures({ + currentIndex: index, + })} + getOptionValue={(f) => f.id} + getOptionLabel={(f) => f.name} + placeholder="Select a metered feature..." + searchable + searchPlaceholder="Search features..." + emptyText="No metered features found" + triggerClassName="cursor-pointer" + /> +
+ + {/* Allowance */} +
+ Balance Grant + + updateEntitlement({ + index, + updates: { + allowance: Number(e.target.value), + }, + }) + } + placeholder="0" + /> +
+ + {/* Expiry */} +
+ Expiry + {ent.expiry ? ( +
+ + updateEntitlement({ + index, + updates: { + expiry: { + duration: + ent.expiry?.duration ?? + FeatureGrantDuration.Month, + length: Number(e.target.value), + }, + }, + }) + } + placeholder="30" + className="w-20" + /> + + +
+ ) : ( + + )} +
+
+ ))} + + +
+
+ + ); +} diff --git a/vite/src/views/products/rewards/reward-config/components/SelectRewardType.tsx b/vite/src/views/products/rewards/reward-config/components/SelectRewardType.tsx index 279ab8a8a..8dad7bacb 100644 --- a/vite/src/views/products/rewards/reward-config/components/SelectRewardType.tsx +++ b/vite/src/views/products/rewards/reward-config/components/SelectRewardType.tsx @@ -1,4 +1,4 @@ -import { GiftIcon, PercentIcon } from "@phosphor-icons/react"; +import { LightningIcon, PercentIcon } from "@phosphor-icons/react"; import { PanelButton } from "@/components/v2/buttons/PanelButton"; import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; import { @@ -23,13 +23,12 @@ export function SelectRewardType({ reward, setReward }: SelectRewardTypeProps) { onClick={() => setReward({ ...reward, - rewardCategory: FrontendRewardCategory.Discount, discountType: FrontendDiscountType.Percentage, - discount_config: defaultDiscountConfig, free_product_id: null, free_product_config: null, + featureGrantEntitlements: [], }) } icon={} @@ -45,23 +44,25 @@ export function SelectRewardType({ reward, setReward }: SelectRewardTypeProps) {
setReward({ ...reward, - rewardCategory: FrontendRewardCategory.FreeProduct, + rewardCategory: FrontendRewardCategory.FeatureGrant, discountType: null, discount_config: null, free_product_id: null, free_product_config: null, + featureGrantEntitlements: [{ feature_id: "", allowance: 0 }], }) } - icon={} + icon={} />
-
Free Product
+
Feature Grant
- Used to give away products in a referral program + Give your users a metered feature balance grant upon promo code + redemption
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 259eef410..8be1eb424 100644 --- a/vite/src/views/products/rewards/reward-config/components/UpdateRewardSheet.tsx +++ b/vite/src/views/products/rewards/reward-config/components/UpdateRewardSheet.tsx @@ -8,6 +8,7 @@ import { SheetHeader, } from "@/components/v2/sheets/SharedSheetComponents"; import { Sheet, SheetContent } from "@/components/v2/sheets/Sheet"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery"; import { useRewardStore } from "@/hooks/stores/useRewardStore"; import { RewardService } from "@/services/products/RewardService"; @@ -18,6 +19,7 @@ import { mapFrontendToApiReward, } from "../../utils/rewardMappers"; import { DiscountRewardConfig } from "./DiscountRewardConfig"; +import { FeatureGrantRewardConfig } from "./FeatureGrantRewardConfig"; import { FreeProductRewardConfig } from "./FreeProductRewardConfig"; import { RewardDetails } from "./RewardDetails"; import { SelectRewardType } from "./SelectRewardType"; @@ -35,6 +37,7 @@ export function UpdateRewardSheet({ }: UpdateRewardSheetProps) { const axiosInstance = useAxiosInstance(); const { refetch } = useRewardsQuery(); + const { features } = useFeaturesQuery(); const [loading, setLoading] = useState(false); @@ -45,7 +48,10 @@ export function UpdateRewardSheet({ // Initialize reward store when selectedReward changes useEffect(() => { if (open && selectedReward) { - const frontendReward = mapApiToFrontendReward(selectedReward); + const frontendReward = mapApiToFrontendReward({ + apiReward: selectedReward, + features, + }); setReward(frontendReward); setBaseReward(frontendReward); @@ -89,7 +95,10 @@ export function UpdateRewardSheet({ setLoading(true); try { - const apiReward = mapFrontendToApiReward(reward); + const apiReward = mapFrontendToApiReward({ + frontendReward: reward, + features, + }); await RewardService.updateReward({ axiosInstance, @@ -132,6 +141,10 @@ export function UpdateRewardSheet({ {reward.rewardCategory === "free_product" && ( )} + + {reward.rewardCategory === "feature_grant" && ( + + )} diff --git a/vite/src/views/products/rewards/types/frontendReward.ts b/vite/src/views/products/rewards/types/frontendReward.ts index 38aed12bd..0ec8df5bf 100644 --- a/vite/src/views/products/rewards/types/frontendReward.ts +++ b/vite/src/views/products/rewards/types/frontendReward.ts @@ -1,4 +1,4 @@ -import type { CreateReward } from "@autumn/shared"; +import type { CreateReward, FeatureGrantDuration } from "@autumn/shared"; /** * Frontend-only reward category to separate UI concerns from API types @@ -6,6 +6,7 @@ import type { CreateReward } from "@autumn/shared"; export enum FrontendRewardCategory { Discount = "discount", FreeProduct = "free_product", + FeatureGrant = "feature_grant", } /** @@ -17,6 +18,16 @@ export enum FrontendDiscountType { InvoiceCredits = "invoice_credits", } +/** Frontend entitlement config for feature grant rewards */ +export interface FrontendRewardEntitlement { + feature_id: string; + allowance: number; + expiry?: { + duration: FeatureGrantDuration; + length: number; + }; +} + /** * Extended reward type for frontend with separated concerns */ @@ -24,4 +35,6 @@ export interface FrontendReward extends Omit { // Frontend-specific fields rewardCategory: FrontendRewardCategory | null; discountType: FrontendDiscountType | null; + // Feature grant entitlements (frontend uses feature_id, mapped to internal_feature_id on submit) + featureGrantEntitlements: FrontendRewardEntitlement[]; } diff --git a/vite/src/views/products/rewards/utils/rewardMappers.ts b/vite/src/views/products/rewards/utils/rewardMappers.ts index 553bf131f..3933155a5 100644 --- a/vite/src/views/products/rewards/utils/rewardMappers.ts +++ b/vite/src/views/products/rewards/utils/rewardMappers.ts @@ -1,4 +1,4 @@ -import { type CreateReward, RewardType } from "@autumn/shared"; +import { type CreateReward, type Feature, RewardType } from "@autumn/shared"; import type { FrontendDiscountType, FrontendReward, @@ -8,15 +8,26 @@ import type { /** * Maps frontend reward to API reward type */ -export function mapFrontendToApiReward( - frontendReward: FrontendReward, -): CreateReward { - const { rewardCategory, discountType, ...baseReward } = frontendReward; +export function mapFrontendToApiReward({ + frontendReward, + features, +}: { + frontendReward: FrontendReward; + features?: Feature[]; +}): CreateReward { + const { + rewardCategory, + discountType, + featureGrantEntitlements, + ...baseReward + } = frontendReward; // Determine the API reward type based on frontend category and discount type let type: RewardType; - if (rewardCategory === "free_product") { + if (rewardCategory === "feature_grant") { + type = RewardType.FeatureGrant; + } else if (rewardCategory === "free_product") { type = RewardType.FreeProduct; } else if (discountType === "percentage") { type = RewardType.PercentageDiscount; @@ -25,26 +36,47 @@ export function mapFrontendToApiReward( } else if (discountType === "invoice_credits") { type = RewardType.InvoiceCredits; } else { - // Default fallback type = RewardType.PercentageDiscount; } - return { + const result: CreateReward = { ...baseReward, type, }; + + // Map frontend feature_id → internal_feature_id for feature grant entitlements + if (rewardCategory === "feature_grant" && featureGrantEntitlements?.length) { + result.entitlements = featureGrantEntitlements + .filter((e) => e.feature_id && e.allowance > 0) + .map((e) => { + const feature = features?.find((f) => f.id === e.feature_id); + return { + internal_feature_id: feature?.internal_id ?? e.feature_id, + allowance: e.allowance, + expiry: e.expiry, + }; + }); + } + + return result; } /** * Maps API reward to frontend reward */ -export function mapApiToFrontendReward( - apiReward: CreateReward, -): FrontendReward { +export function mapApiToFrontendReward({ + apiReward, + features, +}: { + apiReward: CreateReward; + features?: Feature[]; +}): FrontendReward { let rewardCategory: FrontendRewardCategory | null = null; let discountType: FrontendDiscountType | null = null; - if (apiReward.type === RewardType.FreeProduct) { + if (apiReward.type === RewardType.FeatureGrant) { + rewardCategory = "feature_grant"; + } else if (apiReward.type === RewardType.FreeProduct) { rewardCategory = "free_product"; } else { rewardCategory = "discount"; @@ -57,11 +89,24 @@ export function mapApiToFrontendReward( } } - const { type, ...baseReward } = apiReward; + const { type, entitlements, ...baseReward } = apiReward; + + // Map internal_feature_id → feature_id for display + const featureGrantEntitlements = (entitlements ?? []).map((e) => { + const feature = features?.find( + (f) => f.internal_id === e.internal_feature_id, + ); + return { + feature_id: feature?.id ?? e.internal_feature_id, + allowance: e.allowance, + expiry: e.expiry, + }; + }); return { ...baseReward, rewardCategory, discountType, + featureGrantEntitlements, }; }