Merge pull request #223 from sidgaikwad/Fixes/Update-logic-reward-program
Fixes/update logic reward program
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
37
vite/src/services/products/RewardProgramService.tsx
Normal file
37
vite/src/services/products/RewardProgramService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user