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"; import { createStripeCli } from "../utils.js";
export async function handleCusDiscountDeleted({ export async function handleCusDiscountDeleted({
db, db,
org, org,
discount, discount,
env, env,
logger, logger,
res, res,
}: { }: {
db: DrizzleCli; db: DrizzleCli;
org: any; org: any;
discount: any; discount: any;
env: any; env: any;
logger: any; logger: any;
res: any; res: any;
}) { }) {
const customer = await CusService.getByStripeId({ const customer = await CusService.getByStripeId({
db, db,
stripeId: discount.customer, stripeId: discount.customer,
}); });
if (!customer) { if (!customer) {
logger.warn(`discount.deleted: customer ${discount.customer} not found`); logger.warn(`discount.deleted: customer ${discount.customer} not found`);
return; return;
} }
if (customer.env !== env || customer.org_id !== org.id) { if (customer.env !== env || customer.org_id !== org.id) {
logger.info( logger.info(
`discount.deleted: env or org mismatch, skipping, ${customer.env} !== ${env} || ${customer.org_id} !== ${org.id}`, `discount.deleted: env or org mismatch, skipping, ${customer.env} !== ${env} || ${customer.org_id} !== ${org.id}`
); );
return; return;
} }
// Check if any redemptions available, and apply to customer if so // Check if any redemptions available, and apply to customer if so
const redemptions = await RewardRedemptionService.getUnappliedRedemptions({ const redemptions = await RewardRedemptionService.getUnappliedRedemptions({
db, db,
internalCustomerId: customer.internal_id, internalCustomerId: customer.internal_id,
}); });
logger.info( logger.info(
`discount.deleted:, discount ID: ${discount.id}, found ${redemptions.length} redemptions`, `discount.deleted:, discount ID: ${discount.id}, found ${redemptions.length} redemptions`
); );
if (redemptions.length == 0) return; if (redemptions.length == 0) return;
const paidProductRedemption = redemptions.find( const paidProductRedemption = redemptions.find(
(r) => (r) =>
r.reward_program.reward.id === r.reward_program.reward.id ===
(typeof discount.coupon == "string" (typeof discount.coupon == "string"
? discount.coupon ? discount.coupon
: discount.coupon.id), : discount.coupon.id)
); );
if (discount.subscription) { if (discount.subscription) {
logger.info( logger.info(
`Discount is a subscription, paidProductRedemption: ${paidProductRedemption?.id}`, `Discount is a subscription, paidProductRedemption: ${paidProductRedemption?.id}`
); );
if (!paidProductRedemption) return; if (!paidProductRedemption) return;
// Re-apply coupon and mark applied / redeemer applied to true // Re-apply coupon and mark applied / redeemer applied to true
const stripeCli = createStripeCli({ const stripeCli = createStripeCli({
org, org,
env, 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... // Mark reward redemption as applied / redeemer applied to true
try { const isReferrer =
await stripeCli.subscriptions.update(discount.subscription, { paidProductRedemption.referral_code.internal_customer_id ===
discounts: [ customer.internal_id;
...(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 await RewardRedemptionService.update({
const isReferrer = db,
paidProductRedemption.referral_code.internal_customer_id === id: paidProductRedemption.id,
customer.internal_id; updates: {
applied: isReferrer ? true : undefined,
redeemer_applied: isReferrer ? undefined : true,
},
});
await RewardRedemptionService.update({ return;
db, }
id: paidProductRedemption.id,
updates: {
applied: isReferrer ? true : undefined,
redeemer_applied: isReferrer ? undefined : true,
},
});
return; const redemption = redemptions[0];
}
const redemption = redemptions[0]; // Apply redemption to customer
const stripeCli = createStripeCli({
org,
env,
});
// Apply redemption to customer const stripeCus = (await stripeCli.customers.retrieve(
const stripeCli = createStripeCli({ discount.customer
org, )) as Stripe.Customer;
env,
});
const stripeCus = (await stripeCli.customers.retrieve( if (stripeCus && notNullish(stripeCus.discount)) {
discount.customer, logger.info(
)) as Stripe.Customer; `discount.deleted: stripe customer ${discount.customer} already has a discount`
);
return;
}
if (stripeCus && notNullish(stripeCus.discount)) { const reward = await RewardService.get({
logger.info( db,
`discount.deleted: stripe customer ${discount.customer} already has a discount`, orgId: org.id,
); env,
return; idOrInternalId: redemption.reward_program.internal_reward_id!,
} });
const reward = await RewardService.get({ if (!reward) {
db, logger.warn(
orgId: org.id, `discount.deleted: reward ${redemption.reward_program.internal_id} not found`
env, );
idOrInternalId: redemption.reward_program.internal_reward_id!, return;
}); }
if (!reward) { const legacyStripe = createStripeCli({
logger.warn( org,
`discount.deleted: reward ${redemption.reward_program.internal_id} not found`, env,
); legacyVersion: true,
return; });
}
const legacyStripe = createStripeCli({ await legacyStripe.customers.update(discount.customer, {
org, // @ts-expect-error
env, coupon: reward.id,
legacyVersion: true, });
});
await legacyStripe.customers.update(discount.customer, { await RewardRedemptionService.update({
// @ts-expect-error db,
coupon: reward.id, id: redemption.id,
}); updates: {
applied: true,
},
});
await RewardRedemptionService.update({ logger.info(
db, `discount.deleted: applied reward ${reward.name} on customer ${customer.name} (${customer.id})`
id: redemption.id, );
updates: { logger.info(`Redemption ID: ${redemption.id}`);
applied: true,
},
});
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 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(); export const rewardProgramRouter: Router = express.Router();
rewardProgramRouter.post("", handleCreateRewardProgram); 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 RecaseError from "@/utils/errorUtils.js";
import { import {
ErrCode, ErrCode,
@@ -14,22 +14,25 @@ import { referralCodes, rewardRedemptions } from "@autumn/shared";
export class RewardProgramService { export class RewardProgramService {
static async get({ static async get({
db, db,
id, idOrInternalId,
orgId, orgId,
env, env,
errorIfNotFound = false, errorIfNotFound = false,
}: { }: {
db: DrizzleCli; db: DrizzleCli;
id: string; idOrInternalId: string;
orgId: string; orgId: string;
env: string; env: string;
errorIfNotFound?: boolean; errorIfNotFound?: boolean;
}) { }) {
let result = await db.query.rewardPrograms.findFirst({ let result = await db.query.rewardPrograms.findFirst({
where: and( where: and(
eq(rewardPrograms.id, id), or(
eq(rewardPrograms.id, idOrInternalId),
eq(rewardPrograms.internal_id, idOrInternalId)
),
eq(rewardPrograms.org_id, orgId), 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.org_id, orgId),
eq(rewardPrograms.env, env), eq(rewardPrograms.env, env),
eq(rewardPrograms.when, RewardTriggerEvent.Checkout), 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_customer_id, internalCustomerId),
eq(referralCodes.internal_reward_program_id, internalRewardProgramId), eq(referralCodes.internal_reward_program_id, internalRewardProgramId),
eq(referralCodes.org_id, orgId), eq(referralCodes.org_id, orgId),
eq(referralCodes.env, env), eq(referralCodes.env, env)
), ),
}); });
@@ -139,12 +142,12 @@ export class RewardProgramService {
static async delete({ static async delete({
db, db,
id, idOrInternalId,
orgId, orgId,
env, env,
}: { }: {
db: DrizzleCli; db: DrizzleCli;
id: string; idOrInternalId: string;
orgId: string; orgId: string;
env: string; env: string;
}) { }) {
@@ -152,10 +155,13 @@ export class RewardProgramService {
.delete(rewardPrograms) .delete(rewardPrograms)
.where( .where(
and( and(
eq(rewardPrograms.id, id), or(
eq(rewardPrograms.id, idOrInternalId),
eq(rewardPrograms.internal_id, idOrInternalId)
),
eq(rewardPrograms.org_id, orgId), eq(rewardPrograms.org_id, orgId),
eq(rewardPrograms.env, env), eq(rewardPrograms.env, env)
), )
) )
.returning(); .returning();
@@ -187,7 +193,7 @@ export class RewardProgramService {
where: and( where: and(
eq(referralCodes.code, code), eq(referralCodes.code, code),
eq(referralCodes.org_id, orgId), eq(referralCodes.org_id, orgId),
eq(referralCodes.env, env), eq(referralCodes.env, env)
), ),
with: withRewardProgram with: withRewardProgram
? { ? {
@@ -247,10 +253,48 @@ export class RewardProgramService {
.where( .where(
and( and(
eq(rewardRedemptions.referral_code_id, referralCodeId), eq(rewardRedemptions.referral_code_id, referralCodeId),
eq(rewardRedemptions.triggered, true), eq(rewardRedemptions.triggered, true)
), )
); );
return result[0].count; 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 navigate = useNavigate();
const handleCreateClicked = async () => { 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); setLoading(true);
try { try {
const newProduct = await ProductService.createProduct( const newProduct = await ProductService.createProduct(

View File

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

View File

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