feat: 🎸 basic frontend for reward feature grants

This commit is contained in:
amianthus
2026-03-24 16:13:03 +00:00
committed by Owen
parent 0f9a19c9e5
commit 13335c706b
9 changed files with 481 additions and 25 deletions

View File

@@ -37,6 +37,12 @@
}
})();
</script>
<script type="module">
if (import.meta.env.DEV) {
import("react-grab");
}
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -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",

View File

@@ -11,6 +11,7 @@ const DEFAULT_REWARD: FrontendReward = {
free_product_id: null,
discount_config: defaultDiscountConfig,
free_product_config: null,
featureGrantEntitlements: [],
};
interface RewardState {

View File

@@ -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" && (
<FreeProductRewardConfig reward={reward} setReward={setReward} />
)}
{reward.rewardCategory === "feature_grant" && (
<FeatureGrantRewardConfig reward={reward} setReward={setReward} />
)}
</div>
<SheetFooter>

View File

@@ -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<FrontendRewardEntitlement>;
}) => {
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 */}
<SheetSection title="Promo Codes">
<div className="space-y-3">
{(reward.promo_codes || []).map((promoCode, index) => (
<div key={index} className="flex items-end gap-2">
<div className="flex-1">
{index === 0 && <FormLabel>Code</FormLabel>}
<Input
value={promoCode.code}
onChange={(e) =>
updatePromoCode({
index,
code: e.target.value
.toUpperCase()
.replace(/[^A-Z0-9]/g, ""),
})
}
placeholder="PROMO2024"
/>
</div>
<div className="w-32">
{index === 0 && <FormLabel>Max Uses</FormLabel>}
<Input
type="number"
value={promoCode.max_redemptions ?? ""}
onChange={(e) =>
updateMaxRedemptions({
index,
value: e.target.value
? Number(e.target.value)
: undefined,
})
}
placeholder="Unlimited"
/>
</div>
{(reward.promo_codes || []).length > 1 && (
<button
type="button"
onClick={() => removePromoCode({ index })}
className="p-2 text-t4 hover:text-t1 transition-colors"
>
<TrashIcon size={14} />
</button>
)}
</div>
))}
<Button variant="secondary" size="sm" onClick={addPromoCode}>
<PlusIcon size={12} className="mr-1" />
Add Code
</Button>
</div>
</SheetSection>
{/* Entitlements Section */}
<SheetSection title="Feature Grants">
<div className="space-y-4">
{entitlements.map((ent, index) => (
<div
key={index}
className="relative space-y-3 rounded-lg border border-border p-3"
>
{entitlements.length > 1 && (
<button
type="button"
onClick={() => removeEntitlement({ index })}
className="absolute top-2 right-2 p-1 text-t4 hover:text-t1 transition-colors cursor-pointer"
>
<TrashIcon size={12} />
</button>
)}
{/* Feature selector */}
<div>
<FormLabel>Feature</FormLabel>
<SearchableSelect<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"
/>
</div>
{/* Allowance */}
<div>
<FormLabel>Balance Grant</FormLabel>
<Input
type="number"
value={ent.allowance || ""}
onChange={(e) =>
updateEntitlement({
index,
updates: {
allowance: Number(e.target.value),
},
})
}
placeholder="0"
/>
</div>
{/* Expiry */}
<div>
<FormLabel>Expiry</FormLabel>
{ent.expiry ? (
<div className="flex items-center gap-2">
<Input
type="number"
value={ent.expiry.length || ""}
onChange={(e) =>
updateEntitlement({
index,
updates: {
expiry: {
duration:
ent.expiry?.duration ??
FeatureGrantDuration.Month,
length: Number(e.target.value),
},
},
})
}
placeholder="30"
className="w-20"
/>
<Select
value={ent.expiry.duration}
onValueChange={(value) =>
updateEntitlement({
index,
updates: {
expiry: {
duration: value as FeatureGrantDuration,
length: ent.expiry?.length ?? 1,
},
},
})
}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={FeatureGrantDuration.Day}>
Day(s)
</SelectItem>
<SelectItem value={FeatureGrantDuration.Week}>
Week(s)
</SelectItem>
<SelectItem value={FeatureGrantDuration.Month}>
Month(s)
</SelectItem>
<SelectItem value={FeatureGrantDuration.Year}>
Year(s)
</SelectItem>
</SelectContent>
</Select>
<button
type="button"
onClick={() =>
updateEntitlement({
index,
updates: { expiry: undefined },
})
}
className="text-xs text-t4 hover:text-t1 transition-colors whitespace-nowrap"
>
Clear
</button>
</div>
) : (
<button
type="button"
onClick={() =>
updateEntitlement({
index,
updates: {
expiry: {
duration: FeatureGrantDuration.Month,
length: 1,
},
},
})
}
className="text-xs text-t4 hover:text-t1 transition-colors cursor-pointer"
>
No expiry (permanent). Click to set one.
</button>
)}
</div>
</div>
))}
<Button variant="secondary" size="sm" onClick={addEntitlement}>
<PlusIcon size={12} className="mr-1" />
Add Entitlement
</Button>
</div>
</SheetSection>
</>
);
}

View File

@@ -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={<PercentIcon size={16} color="currentColor" />}
@@ -45,23 +44,25 @@ export function SelectRewardType({ reward, setReward }: SelectRewardTypeProps) {
<div className="flex w-full items-center gap-4">
<PanelButton
isSelected={reward.rewardCategory === "free_product"}
isSelected={reward.rewardCategory === "feature_grant"}
onClick={() =>
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={<GiftIcon size={16} color="currentColor" />}
icon={<LightningIcon size={16} color="currentColor" />}
/>
<div className="flex-1">
<div className="text-body-highlight mb-1">Free Product</div>
<div className="text-body-highlight mb-1">Feature Grant</div>
<div className="text-body-secondary leading-tight">
Used to give away products in a referral program
Give your users a metered feature balance grant upon promo code
redemption
</div>
</div>
</div>

View File

@@ -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" && (
<FreeProductRewardConfig reward={reward} setReward={setReward} />
)}
{reward.rewardCategory === "feature_grant" && (
<FeatureGrantRewardConfig reward={reward} setReward={setReward} />
)}
</div>
<SheetFooter>

View File

@@ -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<CreateReward, "type"> {
// 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[];
}

View File

@@ -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,
};
}