Merge branch 'staging' of https://github.com/useautumn/autumn into staging
This commit is contained in:
@@ -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}`);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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