Merge branch 'staging' of https://github.com/useautumn/autumn into staging

This commit is contained in:
John Yeo
2025-09-22 13:25:11 +01:00
8 changed files with 648 additions and 376 deletions

View File

@@ -7,164 +7,163 @@ import { notNullish } from "@/utils/genUtils.js";
import { createStripeCli } from "../utils.js";
export async function handleCusDiscountDeleted({
db,
org,
discount,
env,
logger,
res,
db,
org,
discount,
env,
logger,
res,
}: {
db: DrizzleCli;
org: any;
discount: any;
env: any;
logger: any;
res: any;
db: DrizzleCli;
org: any;
discount: any;
env: any;
logger: any;
res: any;
}) {
const customer = await CusService.getByStripeId({
db,
stripeId: discount.customer,
});
const customer = await CusService.getByStripeId({
db,
stripeId: discount.customer,
});
if (!customer) {
logger.warn(`discount.deleted: customer ${discount.customer} not found`);
return;
}
if (!customer) {
logger.warn(`discount.deleted: customer ${discount.customer} not found`);
return;
}
if (customer.env !== env || customer.org_id !== org.id) {
logger.info(
`discount.deleted: env or org mismatch, skipping, ${customer.env} !== ${env} || ${customer.org_id} !== ${org.id}`,
);
return;
}
if (customer.env !== env || customer.org_id !== org.id) {
logger.info(
`discount.deleted: env or org mismatch, skipping, ${customer.env} !== ${env} || ${customer.org_id} !== ${org.id}`
);
return;
}
// Check if any redemptions available, and apply to customer if so
const redemptions = await RewardRedemptionService.getUnappliedRedemptions({
db,
internalCustomerId: customer.internal_id,
});
// Check if any redemptions available, and apply to customer if so
const redemptions = await RewardRedemptionService.getUnappliedRedemptions({
db,
internalCustomerId: customer.internal_id,
});
logger.info(
`discount.deleted:, discount ID: ${discount.id}, found ${redemptions.length} redemptions`,
);
logger.info(
`discount.deleted:, discount ID: ${discount.id}, found ${redemptions.length} redemptions`
);
if (redemptions.length == 0) return;
if (redemptions.length == 0) return;
const paidProductRedemption = redemptions.find(
(r) =>
r.reward_program.reward.id ===
(typeof discount.coupon == "string"
? discount.coupon
: discount.coupon.id),
);
const paidProductRedemption = redemptions.find(
(r) =>
r.reward_program.reward.id ===
(typeof discount.coupon == "string"
? discount.coupon
: discount.coupon.id)
);
if (discount.subscription) {
logger.info(
`Discount is a subscription, paidProductRedemption: ${paidProductRedemption?.id}`,
);
if (discount.subscription) {
logger.info(
`Discount is a subscription, paidProductRedemption: ${paidProductRedemption?.id}`
);
if (!paidProductRedemption) return;
if (!paidProductRedemption) return;
// Re-apply coupon and mark applied / redeemer applied to true
const stripeCli = createStripeCli({
org,
env,
});
// Re-apply coupon and mark applied / redeemer applied to true
const stripeCli = createStripeCli({
org,
env,
});
// Mark reward redemption as applied / redeemer applied to true
// Mark reward redemption as applied / redeemer applied to true
const sub = await stripeCli.subscriptions.retrieve(discount.subscription);
const sub = await stripeCli.subscriptions.retrieve(discount.subscription);
// can't really test because it modifies subscription affected by test clock...
try {
await stripeCli.subscriptions.update(discount.subscription, {
discounts: [
...(sub.discounts as string[]).map((d: string) => ({
discount: d,
})),
{
coupon: paidProductRedemption.reward_program.reward.id as string,
},
],
});
} catch (error: any) {
logger.error(
`Failed to update subscription ${discount.subscription} with paid product coupon, error: ${error.message}`
);
throw error;
}
// can't really test because it modifies subscription affected by test clock...
try {
await stripeCli.subscriptions.update(discount.subscription, {
discounts: [
...(sub.discounts as string[]).map((d: string) => ({
discount: d,
})),
{
coupon: paidProductRedemption.reward_program.reward.id as string,
},
],
});
} catch (error: any) {
logger.error(
`Failed to update subscription ${discount.subscription} with paid product coupon, error: ${error.message}`,
);
throw error;
}
// Mark reward redemption as applied / redeemer applied to true
const isReferrer =
paidProductRedemption.referral_code.internal_customer_id ===
customer.internal_id;
// Mark reward redemption as applied / redeemer applied to true
const isReferrer =
paidProductRedemption.referral_code.internal_customer_id ===
customer.internal_id;
await RewardRedemptionService.update({
db,
id: paidProductRedemption.id,
updates: {
applied: isReferrer ? true : undefined,
redeemer_applied: isReferrer ? undefined : true,
},
});
await RewardRedemptionService.update({
db,
id: paidProductRedemption.id,
updates: {
applied: isReferrer ? true : undefined,
redeemer_applied: isReferrer ? undefined : true,
},
});
return;
}
return;
}
const redemption = redemptions[0];
const redemption = redemptions[0];
// Apply redemption to customer
const stripeCli = createStripeCli({
org,
env,
});
// Apply redemption to customer
const stripeCli = createStripeCli({
org,
env,
});
const stripeCus = (await stripeCli.customers.retrieve(
discount.customer
)) as Stripe.Customer;
const stripeCus = (await stripeCli.customers.retrieve(
discount.customer,
)) as Stripe.Customer;
if (stripeCus && notNullish(stripeCus.discount)) {
logger.info(
`discount.deleted: stripe customer ${discount.customer} already has a discount`
);
return;
}
if (stripeCus && notNullish(stripeCus.discount)) {
logger.info(
`discount.deleted: stripe customer ${discount.customer} already has a discount`,
);
return;
}
const reward = await RewardService.get({
db,
orgId: org.id,
env,
idOrInternalId: redemption.reward_program.internal_reward_id!,
});
const reward = await RewardService.get({
db,
orgId: org.id,
env,
idOrInternalId: redemption.reward_program.internal_reward_id!,
});
if (!reward) {
logger.warn(
`discount.deleted: reward ${redemption.reward_program.internal_id} not found`
);
return;
}
if (!reward) {
logger.warn(
`discount.deleted: reward ${redemption.reward_program.internal_id} not found`,
);
return;
}
const legacyStripe = createStripeCli({
org,
env,
legacyVersion: true,
});
const legacyStripe = createStripeCli({
org,
env,
legacyVersion: true,
});
await legacyStripe.customers.update(discount.customer, {
// @ts-expect-error
coupon: reward.id,
});
await legacyStripe.customers.update(discount.customer, {
// @ts-expect-error
coupon: reward.id,
});
await RewardRedemptionService.update({
db,
id: redemption.id,
updates: {
applied: true,
},
});
await RewardRedemptionService.update({
db,
id: redemption.id,
updates: {
applied: true,
},
});
logger.info(
`discount.deleted: applied reward ${reward.name} on customer ${customer.name} (${customer.id})`,
);
logger.info(`Redemption ID: ${redemption.id}`);
logger.info(
`discount.deleted: applied reward ${reward.name} on customer ${customer.name} (${customer.id})`
);
logger.info(`Redemption ID: ${redemption.id}`);
}

View File

@@ -1,8 +1,92 @@
import express, { type Router } from "express";
import { handleCreateRewardProgram, handleDeleteRewardProgram } from "./handlers/rewardPrograms/index.js";
import {
handleCreateRewardProgram,
handleDeleteRewardProgram,
} from "./handlers/rewardPrograms/index.js";
import { routeHandler } from "@/utils/routerUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import {
CreateRewardProgram,
ErrCode,
nullish,
RewardTriggerEvent,
} from "@autumn/shared";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { constructRewardProgram } from "@/internal/rewards/rewardTriggerUtils.js";
export const rewardProgramRouter: Router = express.Router();
rewardProgramRouter.post("", handleCreateRewardProgram);
rewardProgramRouter.delete("/:id", handleDeleteRewardProgram);
rewardProgramRouter.delete("/:id", handleDeleteRewardProgram);
rewardProgramRouter.put("/:id", (req, res) =>
routeHandler({
req,
res,
action: "update reward program",
handler: async (req: any, res: any) => {
const { orgId, env, db } = req;
const { id } = req.params;
const body = req.body;
if (!body.internal_reward_id) {
throw new RecaseError({
message: "Please select a reward to link this program to",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
// Ensure program exists
let existingProgram = await RewardProgramService.get({
db,
idOrInternalId: id,
orgId,
env,
});
if (!existingProgram) {
throw new RecaseError({
message: `Program with ID ${id} does not exist`,
code: ErrCode.InvalidRequest,
statusCode: 404,
});
}
const rewardProgram = constructRewardProgram({
rewardProgramData: CreateRewardProgram.parse({
...body,
id: existingProgram.id, // ID cannot be changed
}),
orgId,
env,
});
// Update on existing redemptions? (should be none unless affecting stacked rewards...)
if (
rewardProgram.when == RewardTriggerEvent.Checkout &&
(nullish(rewardProgram.product_ids) ||
rewardProgram.product_ids!.length == 0)
) {
throw new RecaseError({
message:
"When `Redeem On` is set to `Checkout`, must specify at least one product",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
let updatedRewardProgram = await RewardProgramService.update({
db,
idOrInternalId: id,
orgId,
env,
data: rewardProgram,
});
return res.status(200).json(updatedRewardProgram);
},
})
);

View File

@@ -1,4 +1,4 @@
import { and, arrayContains, count, eq, inArray } from "drizzle-orm";
import { and, arrayContains, count, eq, inArray, or } from "drizzle-orm";
import RecaseError from "@/utils/errorUtils.js";
import {
ErrCode,
@@ -14,22 +14,25 @@ import { referralCodes, rewardRedemptions } from "@autumn/shared";
export class RewardProgramService {
static async get({
db,
id,
idOrInternalId,
orgId,
env,
errorIfNotFound = false,
}: {
db: DrizzleCli;
id: string;
idOrInternalId: string;
orgId: string;
env: string;
errorIfNotFound?: boolean;
}) {
let result = await db.query.rewardPrograms.findFirst({
where: and(
eq(rewardPrograms.id, id),
or(
eq(rewardPrograms.id, idOrInternalId),
eq(rewardPrograms.internal_id, idOrInternalId)
),
eq(rewardPrograms.org_id, orgId),
eq(rewardPrograms.env, env),
eq(rewardPrograms.env, env)
),
});
@@ -79,7 +82,7 @@ export class RewardProgramService {
eq(rewardPrograms.org_id, orgId),
eq(rewardPrograms.env, env),
eq(rewardPrograms.when, RewardTriggerEvent.Checkout),
arrayContains(rewardPrograms.product_ids, productIds),
arrayContains(rewardPrograms.product_ids, productIds)
),
});
@@ -104,7 +107,7 @@ export class RewardProgramService {
eq(referralCodes.internal_customer_id, internalCustomerId),
eq(referralCodes.internal_reward_program_id, internalRewardProgramId),
eq(referralCodes.org_id, orgId),
eq(referralCodes.env, env),
eq(referralCodes.env, env)
),
});
@@ -139,12 +142,12 @@ export class RewardProgramService {
static async delete({
db,
id,
idOrInternalId,
orgId,
env,
}: {
db: DrizzleCli;
id: string;
idOrInternalId: string;
orgId: string;
env: string;
}) {
@@ -152,10 +155,13 @@ export class RewardProgramService {
.delete(rewardPrograms)
.where(
and(
eq(rewardPrograms.id, id),
or(
eq(rewardPrograms.id, idOrInternalId),
eq(rewardPrograms.internal_id, idOrInternalId)
),
eq(rewardPrograms.org_id, orgId),
eq(rewardPrograms.env, env),
),
eq(rewardPrograms.env, env)
)
)
.returning();
@@ -187,7 +193,7 @@ export class RewardProgramService {
where: and(
eq(referralCodes.code, code),
eq(referralCodes.org_id, orgId),
eq(referralCodes.env, env),
eq(referralCodes.env, env)
),
with: withRewardProgram
? {
@@ -247,10 +253,48 @@ export class RewardProgramService {
.where(
and(
eq(rewardRedemptions.referral_code_id, referralCodeId),
eq(rewardRedemptions.triggered, true),
),
eq(rewardRedemptions.triggered, true)
)
);
return result[0].count;
}
static async update({
db,
idOrInternalId,
orgId,
env,
data,
}: {
db: DrizzleCli;
idOrInternalId: string;
orgId: string;
env: string;
data: RewardProgram;
}) {
let result = await db
.update(rewardPrograms)
.set(data as any)
.where(
and(
or(
eq(rewardPrograms.id, idOrInternalId),
eq(rewardPrograms.internal_id, idOrInternalId)
),
eq(rewardPrograms.org_id, orgId),
eq(rewardPrograms.env, env)
)
)
.returning();
if (result.length === 0) {
throw new RecaseError({
message: "Reward program not found",
code: ErrCode.RewardNotFound,
});
}
return result[0] as RewardProgram;
}
}

View File

@@ -0,0 +1,37 @@
import { RewardProgram, CreateRewardProgram } from "@autumn/shared";
import { AxiosInstance } from "axios";
export class RewardProgramService {
static async createReward({
axiosInstance,
data,
}: {
axiosInstance: AxiosInstance;
data: CreateRewardProgram;
}) {
await axiosInstance.post("/v1/reward_programs", data);
}
static async deleteReward({
axiosInstance,
internalId,
}: {
axiosInstance: AxiosInstance;
internalId: string;
}) {
await axiosInstance.delete(`/v1/reward_programs/${internalId}`);
}
static async updateReward({
axiosInstance,
internalId,
data,
}: {
axiosInstance: AxiosInstance;
internalId: string;
data: RewardProgram;
}) {
await axiosInstance.put(`/v1/reward_programs/${internalId}`, data);
}
}

View File

@@ -43,6 +43,17 @@ function CreateProduct({
const navigate = useNavigate();
const handleCreateClicked = async () => {
const productName = product.name?.trim() || "";
if (!/^[a-zA-Z0-9 _-]+$/.test(productName)) {
toast.error(
!productName
? "Product name is required"
: "Product name can only contain alphanumeric characters, dashes (-), and underscores (_)"
);
return;
}
setLoading(true);
try {
const newProduct = await ProductService.createProduct(

View File

@@ -1,257 +1,264 @@
import {
type Reward,
type RewardProgram,
RewardReceivedBy,
RewardTriggerEvent,
type Reward,
type RewardProgram,
RewardReceivedBy,
RewardTriggerEvent,
} from "@autumn/shared";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useState } from "react";
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
export const RewardProgramConfig = ({
rewardProgram,
setRewardProgram,
rewardProgram,
setRewardProgram,
isUpdate,
}: {
rewardProgram: RewardProgram;
setRewardProgram: (rewardProgram: RewardProgram) => void;
rewardProgram: RewardProgram;
setRewardProgram: (rewardProgram: RewardProgram) => void;
isUpdate?: boolean;
}) => {
const { rewards } = useRewardsQuery();
const { rewards } = useRewardsQuery();
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel>Program ID</FieldLabel>
<Input
value={rewardProgram.id || ""}
onChange={(e) =>
setRewardProgram({ ...rewardProgram, id: e.target.value })
}
/>
</div>
<div className="w-6/12">
<FieldLabel>Reward</FieldLabel>
<Select
value={rewardProgram.internal_reward_id}
onValueChange={(value) =>
setRewardProgram({ ...rewardProgram, internal_reward_id: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Select a reward" />
</SelectTrigger>
<SelectContent>
{rewards.map((reward: Reward) => (
<SelectItem key={reward.name} value={reward.internal_id}>
{reward.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel>Redeem On</FieldLabel>
<Select
defaultValue={RewardTriggerEvent.CustomerCreation}
value={rewardProgram.when}
onValueChange={(value) =>
setRewardProgram({
...rewardProgram,
when: value as RewardTriggerEvent,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select a redeem on" />
</SelectTrigger>
<SelectContent>
{Object.values(RewardTriggerEvent).map((event) => (
<SelectItem key={event} value={event}>
{keyToTitle(event, { exclusionMap: { [RewardTriggerEvent.CustomerCreation]: "Customer Redemption" } })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-6/12">
<FieldLabel>Max Redemptions</FieldLabel>
<Input
type="number"
value={rewardProgram.max_redemptions}
onChange={(e) =>
setRewardProgram({
...rewardProgram,
max_redemptions: parseInt(e.target.value),
})
}
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-full">
<FieldLabel>Received by</FieldLabel>
<Select
value={rewardProgram.received_by}
onValueChange={(value) =>
setRewardProgram({
...rewardProgram,
received_by: value as RewardReceivedBy,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Who should receive the reward" />
</SelectTrigger>
<SelectContent>
{Object.values(RewardReceivedBy).map((receivedBy) => (
<SelectItem key={receivedBy} value={receivedBy}>
{receivedBy === RewardReceivedBy.All
? "Referrer & Redeemer"
: keyToTitle(receivedBy)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
{rewardProgram.when === RewardTriggerEvent.Checkout && (
<div className="w-full">
<FieldLabel>Products</FieldLabel>
<ProductSelector
rewardProgram={rewardProgram}
setRewardProgram={setRewardProgram}
/>
</div>
)}
</div>
</div>
);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel>Program ID</FieldLabel>
<Input
value={rewardProgram.id || ""}
onChange={(e) =>
setRewardProgram({ ...rewardProgram, id: e.target.value })
}
/>
</div>
<div className="w-6/12">
<FieldLabel>Reward</FieldLabel>
<Select
value={rewardProgram.internal_reward_id}
onValueChange={(value) =>
setRewardProgram({ ...rewardProgram, internal_reward_id: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Select a reward" />
</SelectTrigger>
<SelectContent>
{rewards.map((reward: Reward) => (
<SelectItem key={reward.name} value={reward.internal_id}>
{reward.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel>Redeem On</FieldLabel>
<Select
defaultValue={RewardTriggerEvent.CustomerCreation}
value={rewardProgram.when}
onValueChange={(value) =>
setRewardProgram({
...rewardProgram,
when: value as RewardTriggerEvent,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select a redeem on" />
</SelectTrigger>
<SelectContent>
{Object.values(RewardTriggerEvent).map((event) => (
<SelectItem key={event} value={event}>
{keyToTitle(event, {
exclusionMap: {
[RewardTriggerEvent.CustomerCreation]:
"Customer Redemption",
},
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-6/12">
<FieldLabel>Max Redemptions</FieldLabel>
<Input
type="number"
value={rewardProgram.max_redemptions}
onChange={(e) =>
setRewardProgram({
...rewardProgram,
max_redemptions: parseInt(e.target.value),
})
}
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-full">
<FieldLabel>Received by</FieldLabel>
<Select
value={rewardProgram.received_by}
onValueChange={(value) =>
setRewardProgram({
...rewardProgram,
received_by: value as RewardReceivedBy,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Who should receive the reward" />
</SelectTrigger>
<SelectContent>
{Object.values(RewardReceivedBy).map((receivedBy) => (
<SelectItem key={receivedBy} value={receivedBy}>
{receivedBy === RewardReceivedBy.All
? "Referrer & Redeemer"
: keyToTitle(receivedBy)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
{rewardProgram.when === RewardTriggerEvent.Checkout && (
<div className="w-full">
<FieldLabel>Products</FieldLabel>
<ProductSelector
rewardProgram={rewardProgram}
setRewardProgram={setRewardProgram}
/>
</div>
)}
</div>
</div>
);
};
const ProductSelector = ({
rewardProgram,
setRewardProgram,
rewardProgram,
setRewardProgram,
}: {
rewardProgram: RewardProgram;
setRewardProgram: (rewardProgram: RewardProgram) => void;
rewardProgram: RewardProgram;
setRewardProgram: (rewardProgram: RewardProgram) => void;
}) => {
const { products } = useProductsQuery();
const [open, setOpen] = useState(false);
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];
}
setRewardProgram({
...rewardProgram,
product_ids: newProductIds,
});
};
// 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];
}
setRewardProgram({
...rewardProgram,
product_ids: newProductIds,
});
};
if (!products || products.length === 0) {
return <p className="text-sm text-t3">No products available</p>;
}
if (!products || products.length === 0) {
return <p className="text-sm text-t3">No products available</p>;
}
const getProductText = (productId: string) => {
const product = products.find((p: any) => p.id === productId);
return product?.name || "Unknown Product";
};
const getProductText = (productId: string) => {
const product = products.find((p: any) => p.id === productId);
return product?.name || "Unknown Product";
};
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50 data-[state=open]:border-focus data-[state=open]:shadow-focus"
>
{rewardProgram.product_ids?.length === 0 ? (
"Select Products"
) : (
<>
{rewardProgram.product_ids?.map((productId: string) => (
<div
key={productId}
className="py-0 px-3 text-xs text-t3 border-zinc-300 bg-zinc-100 rounded-full w-fit flex items-center gap-2 h-fit"
>
<p className="text-t2">{getProductText(productId)}</p>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleProductToggle(productId);
}}
className="bg-transparent hover:bg-transparent p-0 w-5 h-5"
>
<X size={12} className="text-t3" />
</Button>
</div>
))}
</>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 absolute right-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search products..." className="h-9" />
<CommandList className="max-h-[300px] overflow-y-auto">
<ScrollArea>
<CommandEmpty>No products found.</CommandEmpty>
<CommandGroup>
{products.map((product: any) => (
<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-t3" />
)}
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50 data-[state=open]:border-focus data-[state=open]:shadow-focus"
>
{rewardProgram.product_ids?.length === 0 ? (
"Select Products"
) : (
<>
{rewardProgram.product_ids?.map((productId: string) => (
<div
key={productId}
className="py-0 px-3 text-xs text-t3 border-zinc-300 bg-zinc-100 rounded-full w-fit flex items-center gap-2 h-fit"
>
<p className="text-t2">{getProductText(productId)}</p>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleProductToggle(productId);
}}
className="bg-transparent hover:bg-transparent p-0 w-5 h-5"
>
<X size={12} className="text-t3" />
</Button>
</div>
))}
</>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 absolute right-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search products..." className="h-9" />
<CommandList className="max-h-[300px] overflow-y-auto">
<ScrollArea>
<CommandEmpty>No products found.</CommandEmpty>
<CommandGroup>
{products.map((product: any) => (
<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-t3" />
)}
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};

View File

@@ -8,6 +8,7 @@ import { Item, Row } from "@/components/general/TableGrid";
import { AdminHover } from "@/components/general/AdminHover";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { RewardProgramRowToolbar } from "./RewardProgramRowToolbar";
import UpdateRewardProgram from "./UpdateRewardPrograms";
export const RewardProgramsTable = () => {
const { rewardPrograms } = useRewardsQuery();
@@ -17,6 +18,12 @@ export const RewardProgramsTable = () => {
return (
<>
<UpdateRewardProgram
open={open}
setOpen={setOpen}
selectedRewardProgram={selectedRewardProgram}
setSelectedRewardProgram={setSelectedRewardProgram}
/>
{/* <UpdateRewardProgram component here /> */}
{rewardPrograms && rewardPrograms.length > 0 ? (

View File

@@ -0,0 +1,83 @@
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { RewardProgram } from "@autumn/shared";
import { useEnv } from "@/utils/envUtils";
import { getBackendErr } from "@/utils/genUtils";
import { WarningBox } from "@/components/general/modal-components/WarningBox";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { RewardProgramConfig } from "./RewardProgramConfig";
import { RewardProgramService } from "@/services/products/RewardProgramService";
function UpdateRewardProgram({
open,
setOpen,
selectedRewardProgram,
setSelectedRewardProgram,
}: {
open: boolean;
setOpen: (open: boolean) => void;
selectedRewardProgram: RewardProgram | null;
setSelectedRewardProgram: (reward: RewardProgram) => void;
}) {
const [updateLoading, setUpdateLoading] = useState(false);
const { refetch } = useRewardsQuery();
const env = useEnv();
const axiosInstance = useAxiosInstance({ env });
const handleUpdate = async () => {
setUpdateLoading(true);
try {
await RewardProgramService.updateReward({
axiosInstance,
internalId: selectedRewardProgram!.internal_id,
data: selectedRewardProgram!,
});
toast.success("Reward updated successfully");
await refetch();
setOpen(false);
} catch (error) {
toast.error(getBackendErr(error, "Failed to update reward program"));
}
setUpdateLoading(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[500px]">
<DialogTitle>Update Reward Program</DialogTitle>
{/* <WarningBox>
Existing customers with this reward program will not be affected
</WarningBox> */}
{selectedRewardProgram && (
<RewardProgramConfig
rewardProgram={selectedRewardProgram}
setRewardProgram={setSelectedRewardProgram}
isUpdate={true}
/>
)}
<DialogFooter>
<Button
isLoading={updateLoading}
onClick={() => handleUpdate()}
variant="gradientPrimary"
>
Update
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default UpdateRewardProgram;