fix: archive / delete on product row toolbar
This commit is contained in:
@@ -15,8 +15,8 @@ import { mapToProductV2 } from "./productV2Utils.js";
|
||||
import { isFeaturePriceItem } from "./product-items/productItemUtils/getItemType.js";
|
||||
|
||||
import RecaseError, {
|
||||
handleFrontendReqError,
|
||||
handleRequestError,
|
||||
handleFrontendReqError,
|
||||
handleRequestError,
|
||||
} from "@/utils/errorUtils.js";
|
||||
import { createOrgResponse } from "../orgs/orgUtils.js";
|
||||
import { sortFullProducts } from "./productUtils/sortProductUtils.js";
|
||||
@@ -24,349 +24,343 @@ import { sortFullProducts } from "./productUtils/sortProductUtils.js";
|
||||
export const productRouter: Router = Router({ mergeParams: true });
|
||||
|
||||
productRouter.get("/data", async (req: any, res) => {
|
||||
try {
|
||||
let { db } = req;
|
||||
try {
|
||||
let { db } = req;
|
||||
|
||||
const [products, features, org, coupons, rewardPrograms] =
|
||||
await Promise.all([
|
||||
ProductService.listFull({
|
||||
db,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
returnAll: true,
|
||||
showOnlyArchived: false,
|
||||
}),
|
||||
FeatureService.getFromReq(req),
|
||||
OrgService.getFromReq(req),
|
||||
RewardService.list({ db, orgId: req.orgId, env: req.env }),
|
||||
RewardProgramService.list({
|
||||
db,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
}),
|
||||
]);
|
||||
const [products, features, org, coupons, rewardPrograms] =
|
||||
await Promise.all([
|
||||
ProductService.listFull({
|
||||
db,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
returnAll: true,
|
||||
showOnlyArchived: false,
|
||||
}),
|
||||
FeatureService.getFromReq(req),
|
||||
OrgService.getFromReq(req),
|
||||
RewardService.list({ db, orgId: req.orgId, env: req.env }),
|
||||
RewardProgramService.list({
|
||||
db,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
}),
|
||||
]);
|
||||
|
||||
sortFullProducts({
|
||||
products: getLatestProducts(products),
|
||||
});
|
||||
sortFullProducts({
|
||||
products: getLatestProducts(products),
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
products: products.map((product) => {
|
||||
return mapToProductV2({ product, features });
|
||||
}),
|
||||
versionCounts: getProductVersionCounts(products),
|
||||
features,
|
||||
org: createOrgResponse(org),
|
||||
rewards: coupons,
|
||||
rewardPrograms,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get products", error);
|
||||
res.status(500).send(error);
|
||||
}
|
||||
res.status(200).json({
|
||||
products: products.map((product) => {
|
||||
return mapToProductV2({ product, features });
|
||||
}),
|
||||
versionCounts: getProductVersionCounts(products),
|
||||
features,
|
||||
org: createOrgResponse(org),
|
||||
rewards: coupons,
|
||||
rewardPrograms,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get products", error);
|
||||
res.status(500).send(error);
|
||||
}
|
||||
});
|
||||
|
||||
productRouter.post("/data", async (req: any, res) => {
|
||||
try {
|
||||
let { db } = req;
|
||||
let { showArchived } = req.body;
|
||||
console.log("showArchived", showArchived);
|
||||
try {
|
||||
let { db } = req;
|
||||
let { showArchived } = req.body;
|
||||
console.log("showArchived", showArchived);
|
||||
|
||||
const [products, features, org, coupons, rewardPrograms] =
|
||||
await Promise.all([
|
||||
ProductService.listFull({
|
||||
db,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
returnAll: true,
|
||||
showOnlyArchived: showArchived,
|
||||
}),
|
||||
FeatureService.getFromReq(req),
|
||||
OrgService.getFromReq(req),
|
||||
RewardService.list({ db, orgId: req.orgId, env: req.env }),
|
||||
RewardProgramService.list({
|
||||
db,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
}),
|
||||
]);
|
||||
const [products, features, org, coupons, rewardPrograms] =
|
||||
await Promise.all([
|
||||
ProductService.listFull({
|
||||
db,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
returnAll: true,
|
||||
showOnlyArchived: showArchived,
|
||||
}),
|
||||
FeatureService.getFromReq(req),
|
||||
OrgService.getFromReq(req),
|
||||
RewardService.list({ db, orgId: req.orgId, env: req.env }),
|
||||
RewardProgramService.list({
|
||||
db,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
}),
|
||||
]);
|
||||
|
||||
res.status(200).json({
|
||||
products: sortFullProducts({ products }).map((product) => {
|
||||
return mapToProductV2({ product, features });
|
||||
}),
|
||||
versionCounts: getProductVersionCounts(products),
|
||||
features,
|
||||
org: createOrgResponse(org),
|
||||
rewards: coupons,
|
||||
rewardPrograms,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get products", error);
|
||||
res.status(500).send(error);
|
||||
}
|
||||
res.status(200).json({
|
||||
products: sortFullProducts({ products }).map((product) => {
|
||||
return mapToProductV2({ product, features });
|
||||
}),
|
||||
versionCounts: getProductVersionCounts(products),
|
||||
features,
|
||||
org: createOrgResponse(org),
|
||||
rewards: coupons,
|
||||
rewardPrograms,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get products", error);
|
||||
res.status(500).send(error);
|
||||
}
|
||||
});
|
||||
|
||||
productRouter.get("/data/deletion_text/:internal_product_id", async (req: any, res) => {
|
||||
try {
|
||||
let { db, orgId, env } = req;
|
||||
let { internal_product_id } = req.params;
|
||||
productRouter.get(
|
||||
"/data/deletion_text/:internal_product_id",
|
||||
async (req: any, res) => {
|
||||
try {
|
||||
let { db, orgId, env } = req;
|
||||
let { internal_product_id } = req.params;
|
||||
|
||||
let deletionText = await ProductService.getDeletionText({
|
||||
db,
|
||||
internal_product_id,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
let deletionText = await ProductService.getDeletionText({
|
||||
db,
|
||||
internal_product_id,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
console.log("deletionText", deletionText);
|
||||
|
||||
res.status(200).send({
|
||||
customerName: deletionText[0]?.customerName,
|
||||
totalCount: deletionText[0]?.totalCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get deletion text", error);
|
||||
res.status(500).send(error);
|
||||
}
|
||||
});
|
||||
res.status(200).send({
|
||||
customerName: deletionText[0]?.customerName,
|
||||
totalCount: deletionText[0]?.totalCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get deletion text", error);
|
||||
res.status(500).send(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
productRouter.get("/counts", async (req: any, res) => {
|
||||
try {
|
||||
let { db } = req;
|
||||
let products = await ProductService.listFull({
|
||||
db,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
returnAll: true,
|
||||
});
|
||||
try {
|
||||
let { db } = req;
|
||||
let products = await ProductService.listFull({
|
||||
db,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
// returnAll: true,
|
||||
});
|
||||
|
||||
let counts = await Promise.all(
|
||||
products.map(async (product) => {
|
||||
return CusProdReadService.getCounts({
|
||||
db,
|
||||
internalProductId: product.internal_id,
|
||||
});
|
||||
})
|
||||
);
|
||||
let counts = await Promise.all(
|
||||
products.map(async (product) => {
|
||||
return CusProdReadService.getCountsForAllVersions({
|
||||
db,
|
||||
productId: product.id,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
let result: { [key: string]: any } = {};
|
||||
for (let i = 0; i < products.length; i++) {
|
||||
if (!result[products[i].id]) {
|
||||
result[products[i].id] = counts[i];
|
||||
} else {
|
||||
for (let key in counts[i]) {
|
||||
let countVal =
|
||||
counts[i][key as keyof (typeof counts)[number]] || 0;
|
||||
result[products[i].id][key] += countVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
let result: { [key: string]: any } = {};
|
||||
for (let i = 0; i < products.length; i++) {
|
||||
if (!result[products[i].id]) {
|
||||
result[products[i].id] = counts[i];
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).send(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to get product counts", error);
|
||||
res.status(500).send(error);
|
||||
}
|
||||
res.status(200).send(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to get product counts", error);
|
||||
res.status(500).send(error);
|
||||
}
|
||||
});
|
||||
|
||||
productRouter.get("/:productId/data", async (req: any, res) => {
|
||||
try {
|
||||
const { productId } = req.params;
|
||||
const { version } = req.query;
|
||||
const { db, orgId, env } = req;
|
||||
try {
|
||||
const { productId } = req.params;
|
||||
const { version } = req.query;
|
||||
const { db, orgId, env } = req;
|
||||
|
||||
const [product, features, org, numVersions, existingMigrations] =
|
||||
await Promise.all([
|
||||
ProductService.getFull({
|
||||
db,
|
||||
idOrInternalId: productId,
|
||||
orgId,
|
||||
env,
|
||||
version: version ? parseInt(version) : undefined,
|
||||
}),
|
||||
FeatureService.getFromReq(req),
|
||||
OrgService.getFromReq(req),
|
||||
ProductService.getProductVersionCount({
|
||||
db,
|
||||
productId,
|
||||
orgId,
|
||||
env,
|
||||
}),
|
||||
MigrationService.getExistingJobs({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
}),
|
||||
]);
|
||||
const [product, features, org, numVersions, existingMigrations] =
|
||||
await Promise.all([
|
||||
ProductService.getFull({
|
||||
db,
|
||||
idOrInternalId: productId,
|
||||
orgId,
|
||||
env,
|
||||
version: version ? parseInt(version) : undefined,
|
||||
}),
|
||||
FeatureService.getFromReq(req),
|
||||
OrgService.getFromReq(req),
|
||||
ProductService.getProductVersionCount({
|
||||
db,
|
||||
productId,
|
||||
orgId,
|
||||
env,
|
||||
}),
|
||||
MigrationService.getExistingJobs({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
throw new RecaseError({
|
||||
message: `Product ${productId} ${
|
||||
version ? `(v${version})` : ""
|
||||
} not found`,
|
||||
code: ErrCode.ProductNotFound,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
let entitlements = product.entitlements;
|
||||
let prices = product.prices;
|
||||
if (!product) {
|
||||
throw new RecaseError({
|
||||
message: `Product ${productId} ${
|
||||
version ? `(v${version})` : ""
|
||||
} not found`,
|
||||
code: ErrCode.ProductNotFound,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
let entitlements = product.entitlements;
|
||||
let prices = product.prices;
|
||||
|
||||
entitlements = entitlements.sort((a: any, b: any) => {
|
||||
return b.feature.id.localeCompare(a.feature.id);
|
||||
});
|
||||
entitlements = entitlements.sort((a: any, b: any) => {
|
||||
return b.feature.id.localeCompare(a.feature.id);
|
||||
});
|
||||
|
||||
prices = prices.sort((a: any, b: any) => {
|
||||
return b.id.localeCompare(a.id);
|
||||
});
|
||||
prices = prices.sort((a: any, b: any) => {
|
||||
return b.id.localeCompare(a.id);
|
||||
});
|
||||
|
||||
let productV2 = mapToProductV2({ product, features });
|
||||
let productV2 = mapToProductV2({ product, features });
|
||||
|
||||
res.status(200).send({
|
||||
product: productV2,
|
||||
entitlements,
|
||||
prices,
|
||||
features,
|
||||
org: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
test_pkey: org.test_pkey,
|
||||
live_pkey: org.live_pkey,
|
||||
default_currency: org.default_currency,
|
||||
},
|
||||
numVersions,
|
||||
existingMigrations,
|
||||
});
|
||||
} catch (error) {
|
||||
handleFrontendReqError({
|
||||
error,
|
||||
req,
|
||||
res,
|
||||
action: "Get product data (internal)",
|
||||
});
|
||||
}
|
||||
res.status(200).send({
|
||||
product: productV2,
|
||||
entitlements,
|
||||
prices,
|
||||
features,
|
||||
org: {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
test_pkey: org.test_pkey,
|
||||
live_pkey: org.live_pkey,
|
||||
default_currency: org.default_currency,
|
||||
},
|
||||
numVersions,
|
||||
existingMigrations,
|
||||
});
|
||||
} catch (error) {
|
||||
handleFrontendReqError({
|
||||
error,
|
||||
req,
|
||||
res,
|
||||
action: "Get product data (internal)",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
productRouter.get("/:productId/count", async (req: any, res) => {
|
||||
try {
|
||||
const { db, orgId, env } = req;
|
||||
const { productId } = req.params;
|
||||
const { version } = req.query;
|
||||
try {
|
||||
const { db, orgId, env } = req;
|
||||
const { productId } = req.params;
|
||||
const { version } = req.query;
|
||||
|
||||
const product = await ProductService.get({
|
||||
db,
|
||||
id: productId,
|
||||
orgId,
|
||||
env,
|
||||
version: version ? parseInt(version) : undefined,
|
||||
});
|
||||
const product = await ProductService.get({
|
||||
db,
|
||||
id: productId,
|
||||
orgId,
|
||||
env,
|
||||
version: version ? parseInt(version) : undefined,
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new RecaseError({
|
||||
message: `Product ${productId} ${
|
||||
version ? `(v${version})` : ""
|
||||
} not found`,
|
||||
code: ErrCode.ProductNotFound,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
if (!product) {
|
||||
throw new RecaseError({
|
||||
message: `Product ${productId} ${
|
||||
version ? `(v${version})` : ""
|
||||
} not found`,
|
||||
code: ErrCode.ProductNotFound,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
|
||||
// Get counts from postgres
|
||||
const counts = await CusProdReadService.getCounts({
|
||||
db,
|
||||
internalProductId: product.internal_id,
|
||||
});
|
||||
// Get counts from postgres
|
||||
const counts = await CusProdReadService.getCounts({
|
||||
db,
|
||||
internalProductId: product.internal_id,
|
||||
});
|
||||
|
||||
res.status(200).send(counts);
|
||||
} catch (error) {
|
||||
handleFrontendReqError({
|
||||
error,
|
||||
req,
|
||||
res,
|
||||
action: "Get product counts (internal)",
|
||||
});
|
||||
}
|
||||
res.status(200).send(counts);
|
||||
} catch (error) {
|
||||
handleFrontendReqError({
|
||||
error,
|
||||
req,
|
||||
res,
|
||||
action: "Get product counts (internal)",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
productRouter.post("/product_options", async (req: any, res: any) => {
|
||||
try {
|
||||
const { items } = req.body;
|
||||
try {
|
||||
const { items } = req.body;
|
||||
|
||||
const features = await FeatureService.getFromReq(req);
|
||||
const featureToOptions: { [key: string]: FeatureOptions } = {};
|
||||
const features = await FeatureService.getFromReq(req);
|
||||
const featureToOptions: { [key: string]: FeatureOptions } = {};
|
||||
|
||||
for (const item of items) {
|
||||
if (
|
||||
isFeaturePriceItem(item) &&
|
||||
item.usage_model == UsageModel.Prepaid
|
||||
) {
|
||||
featureToOptions[item.feature_id] = {
|
||||
feature_id: item.feature_id,
|
||||
quantity: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const item of items) {
|
||||
if (isFeaturePriceItem(item) && item.usage_model == UsageModel.Prepaid) {
|
||||
featureToOptions[item.feature_id] = {
|
||||
feature_id: item.feature_id,
|
||||
quantity: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).send({ options: Object.values(featureToOptions) });
|
||||
} catch (error) {
|
||||
handleFrontendReqError({
|
||||
error,
|
||||
req,
|
||||
res,
|
||||
action: "Get product options",
|
||||
});
|
||||
}
|
||||
res.status(200).send({ options: Object.values(featureToOptions) });
|
||||
} catch (error) {
|
||||
handleFrontendReqError({
|
||||
error,
|
||||
req,
|
||||
res,
|
||||
action: "Get product options",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
productRouter.get("/:productId/info", async (req: any, res: any) => {
|
||||
try {
|
||||
// 1. Get number of versions
|
||||
const { db, orgId, env } = req;
|
||||
let product = await ProductService.get({
|
||||
db,
|
||||
id: req.params.productId,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
});
|
||||
try {
|
||||
// 1. Get number of versions
|
||||
const { db, orgId, env } = req;
|
||||
let product = await ProductService.get({
|
||||
db,
|
||||
id: req.params.productId,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
});
|
||||
|
||||
// let numVersions = await ProductService.getProductVersionCount({
|
||||
// db,
|
||||
// productId: req.params.productId,
|
||||
// orgId: req.orgId,
|
||||
// env: req.env,
|
||||
// });
|
||||
if (!product) {
|
||||
throw new RecaseError({
|
||||
message: `Product ${req.params.productId} not found`,
|
||||
code: ErrCode.ProductNotFound,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
// let numVersions = await ProductService.getProductVersionCount({
|
||||
// db,
|
||||
// productId: req.params.productId,
|
||||
// orgId: req.orgId,
|
||||
// env: req.env,
|
||||
// });
|
||||
if (!product) {
|
||||
throw new RecaseError({
|
||||
message: `Product ${req.params.productId} not found`,
|
||||
code: ErrCode.ProductNotFound,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
|
||||
let [allVersions, latestVersion] = await Promise.all([
|
||||
CusProdReadService.existsForProduct({
|
||||
db,
|
||||
productId: req.params.productId,
|
||||
}),
|
||||
CusProdReadService.existsForProduct({
|
||||
db,
|
||||
internalProductId: product.internal_id,
|
||||
}),
|
||||
]);
|
||||
let [allVersions, latestVersion] = await Promise.all([
|
||||
CusProdReadService.existsForProduct({
|
||||
db,
|
||||
productId: req.params.productId,
|
||||
}),
|
||||
CusProdReadService.existsForProduct({
|
||||
db,
|
||||
internalProductId: product.internal_id,
|
||||
}),
|
||||
]);
|
||||
|
||||
// 2. Get cus products
|
||||
// 2. Get cus products
|
||||
|
||||
res.status(200).send({
|
||||
numVersion: product.version,
|
||||
hasCusProducts: allVersions,
|
||||
hasCusProductsLatest: latestVersion,
|
||||
});
|
||||
} catch (error) {
|
||||
handleRequestError({
|
||||
error,
|
||||
req,
|
||||
res,
|
||||
action: "Get product info",
|
||||
});
|
||||
}
|
||||
res.status(200).send({
|
||||
numVersion: product.version,
|
||||
hasCusProducts: allVersions,
|
||||
hasCusProductsLatest: latestVersion,
|
||||
});
|
||||
} catch (error) {
|
||||
handleRequestError({
|
||||
error,
|
||||
req,
|
||||
res,
|
||||
action: "Get product info",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,7 +6,50 @@ import {
|
||||
Organization,
|
||||
products,
|
||||
} from "@autumn/shared";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
|
||||
const clearCustomersInBatches = async ({
|
||||
db,
|
||||
org,
|
||||
batchSize = 450,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
org: Organization;
|
||||
batchSize?: number;
|
||||
}) => {
|
||||
let deletedCount = 0;
|
||||
|
||||
while (true) {
|
||||
// Get a batch of customer IDs to delete
|
||||
const customerBatch = await db
|
||||
.select({ internalId: customers.internal_id })
|
||||
.from(customers)
|
||||
.where(
|
||||
and(eq(customers.org_id, org.id), eq(customers.env, AppEnv.Sandbox))
|
||||
)
|
||||
.limit(batchSize);
|
||||
|
||||
if (customerBatch.length === 0) {
|
||||
break; // No more customers to delete
|
||||
}
|
||||
|
||||
// Delete the batch
|
||||
const customerIds = customerBatch
|
||||
.map((c) => c.internalId)
|
||||
.filter((id) => id !== null);
|
||||
|
||||
await db
|
||||
.delete(customers)
|
||||
.where(inArray(customers.internal_id, customerIds));
|
||||
|
||||
deletedCount += customerBatch.length;
|
||||
console.log(
|
||||
`Deleted ${customerBatch.length} customers (total: ${deletedCount})`
|
||||
);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
};
|
||||
|
||||
export const clearOrg = async ({
|
||||
db,
|
||||
@@ -15,13 +58,8 @@ export const clearOrg = async ({
|
||||
db: DrizzleCli;
|
||||
org: Organization;
|
||||
}) => {
|
||||
await db
|
||||
.delete(customers)
|
||||
.where(
|
||||
and(eq(customers.org_id, org.id), eq(customers.env, AppEnv.Sandbox)),
|
||||
);
|
||||
|
||||
console.log("Cleared customers");
|
||||
const deletedCount = await clearCustomersInBatches({ db, org });
|
||||
console.log(`Cleared ${deletedCount} customers`);
|
||||
|
||||
await db
|
||||
.delete(products)
|
||||
|
||||
@@ -57,7 +57,7 @@ export const FrontendProductSchema = ProductSchema.omit({
|
||||
entitlements: z.array(
|
||||
EntitlementSchema.extend({
|
||||
feature: FeatureSchema,
|
||||
}),
|
||||
})
|
||||
),
|
||||
free_trial: FreeTrialSchema,
|
||||
options: z.any(),
|
||||
@@ -68,13 +68,21 @@ export const FullProductSchema = ProductSchema.extend({
|
||||
entitlements: z.array(
|
||||
EntitlementSchema.extend({
|
||||
feature: FeatureSchema,
|
||||
}),
|
||||
})
|
||||
),
|
||||
free_trial: FreeTrialSchema.nullish(),
|
||||
free_trials: z.array(FreeTrialSchema).nullish(),
|
||||
free_trial_ids: z.array(z.string()).nullish(),
|
||||
});
|
||||
|
||||
export type ProductCounts = {
|
||||
active: number;
|
||||
canceled: number;
|
||||
custom: number;
|
||||
trialing: number;
|
||||
all: number;
|
||||
};
|
||||
|
||||
export type Product = z.infer<typeof ProductSchema>;
|
||||
export type FrontendProduct = z.infer<typeof FrontendProductSchema>;
|
||||
export type FullProduct = z.infer<typeof FullProductSchema>;
|
||||
|
||||
@@ -12,179 +12,172 @@ import { ProductCountsTooltip } from "./components/ProductCountsTooltip";
|
||||
import { ProductTypeBadge } from "./components/ProductTypeBadge";
|
||||
|
||||
export const ProductsTable = ({
|
||||
products,
|
||||
onRowClick,
|
||||
products,
|
||||
onRowClick,
|
||||
}: {
|
||||
products: Product[];
|
||||
onRowClick?: (id: string) => void;
|
||||
products: Product[];
|
||||
onRowClick?: (id: string) => void;
|
||||
}) => {
|
||||
const { env, onboarding, showArchived } = useProductsContext();
|
||||
const navigate = useNavigate();
|
||||
const { allCounts } = useProductsContext();
|
||||
const { env, onboarding, showArchived } = useProductsContext();
|
||||
const navigate = useNavigate();
|
||||
const { allCounts } = useProductsContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
{products && products.length > 0 ? (
|
||||
<Row
|
||||
type="header"
|
||||
className={cn(
|
||||
"grid-cols-18 -mb-1",
|
||||
onboarding && "grid-cols-12"
|
||||
)}
|
||||
isOnboarding={onboarding}
|
||||
>
|
||||
<Item className="col-span-3">Name</Item>
|
||||
<Item className="col-span-3">Product ID</Item>
|
||||
{!onboarding && (
|
||||
<>
|
||||
<Item className="col-span-3">Active</Item>
|
||||
<Item className="col-span-3">Type</Item>
|
||||
<Item className="col-span-3">Group</Item>
|
||||
<Item className="col-span-2">Created At</Item>
|
||||
</>
|
||||
)}
|
||||
<Item
|
||||
className={cn("col-span-1", onboarding && "col-span-6")}
|
||||
></Item>
|
||||
</Row>
|
||||
) : (
|
||||
!onboarding && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col justify-center items-center h-10 px-10 text-t3 min-h-[60vh] gap-4",
|
||||
"justify-start items-start mt-3",
|
||||
onboarding && "px-2 mt-4"
|
||||
)}
|
||||
>
|
||||
{showArchived ? (
|
||||
<span>You haven't archived any products yet.</span>
|
||||
) : (
|
||||
<>
|
||||
{/* <img
|
||||
return (
|
||||
<>
|
||||
{products && products.length > 0 ? (
|
||||
<Row
|
||||
type="header"
|
||||
className={cn("grid-cols-18 -mb-1", onboarding && "grid-cols-12")}
|
||||
isOnboarding={onboarding}
|
||||
>
|
||||
<Item className="col-span-3">Name</Item>
|
||||
<Item className="col-span-3">Product ID</Item>
|
||||
{!onboarding && (
|
||||
<>
|
||||
<Item className="col-span-3">Active</Item>
|
||||
<Item className="col-span-3">Type</Item>
|
||||
<Item className="col-span-3">Group</Item>
|
||||
<Item className="col-span-2">Created At</Item>
|
||||
</>
|
||||
)}
|
||||
<Item className={cn("col-span-1", onboarding && "col-span-6")}></Item>
|
||||
</Row>
|
||||
) : (
|
||||
!onboarding && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col justify-center items-center h-10 px-10 text-t3 min-h-[60vh] gap-4",
|
||||
"justify-start items-start mt-3",
|
||||
onboarding && "px-2 mt-4"
|
||||
)}
|
||||
>
|
||||
{showArchived ? (
|
||||
<span>You haven't archived any products yet.</span>
|
||||
) : (
|
||||
<>
|
||||
{/* <img
|
||||
src="./product.png"
|
||||
alt="Products"
|
||||
className="w-48 h-48 opacity-60 filter grayscale"
|
||||
// className="w-48 h-48 opacity-80 filter brightness-0 invert" // this is for dark mode
|
||||
/> */}
|
||||
<span>
|
||||
Each product defines features your customers
|
||||
get access to and how much they cost. Create
|
||||
separate products for any free plans, paid
|
||||
plans and any add-on or top up products ☝️
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<span>
|
||||
Each product defines features your customers get access to and
|
||||
how much they cost. Create separate products for any free
|
||||
plans, paid plans and any add-on or top up products ☝️
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{products &&
|
||||
products
|
||||
.reduce((acc, product) => {
|
||||
const existingIndex = acc.findIndex(p => p.id === product.id);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
acc.push(product);
|
||||
} else {
|
||||
const existing = acc[existingIndex];
|
||||
|
||||
if (showArchived) {
|
||||
// If showing archived, always keep the newest version
|
||||
if (product.version > existing.version) {
|
||||
acc[existingIndex] = product;
|
||||
}
|
||||
} else {
|
||||
// If not showing archived, prefer non-archived versions
|
||||
if (product.archived && !existing.archived) {
|
||||
// Keep existing non-archived version
|
||||
} else if (!product.archived && existing.archived) {
|
||||
// Replace archived with non-archived
|
||||
acc[existingIndex] = product;
|
||||
} else if (product.version > existing.version) {
|
||||
// Both have same archived status, keep newer version
|
||||
acc[existingIndex] = product;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as typeof products)
|
||||
.map((product) => (
|
||||
<Row
|
||||
key={product.id}
|
||||
className={cn(
|
||||
"grid-cols-18 gap-2 items-center text-sm cursor-pointer hover:bg-primary/5 text-t2 whitespace-nowrap",
|
||||
onboarding && "grid-cols-12"
|
||||
)}
|
||||
isOnboarding={onboarding}
|
||||
onClick={() => {
|
||||
if (onRowClick) {
|
||||
onRowClick(product.id);
|
||||
} else {
|
||||
navigateTo(
|
||||
`/products/${product.id}`,
|
||||
navigate,
|
||||
env
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Item className="col-span-3">
|
||||
<AdminHover
|
||||
texts={[
|
||||
{
|
||||
key: "Internal ID",
|
||||
value: product.internal_id,
|
||||
},
|
||||
{
|
||||
key: "Version",
|
||||
value: product.version.toString(),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<span className="truncate">{product.name}</span>
|
||||
</AdminHover>
|
||||
</Item>
|
||||
<Item className="col-span-3 font-mono -translate-x-1">
|
||||
<CopyButton
|
||||
text={product.id || ""}
|
||||
className="bg-transparent text-t3 border-none px-1 shadow-none max-w-full"
|
||||
>
|
||||
<span className="truncate">{product.id}</span>
|
||||
</CopyButton>
|
||||
</Item>
|
||||
{!onboarding && (
|
||||
<>
|
||||
<Item className="col-span-3">
|
||||
<ProductCountsTooltip
|
||||
allCounts={allCounts}
|
||||
product={product}
|
||||
/>
|
||||
</Item>
|
||||
<Item className="col-span-3">
|
||||
<ProductTypeBadge product={product} />
|
||||
</Item>
|
||||
<Item className="col-span-3">
|
||||
{!onboarding && product.group}
|
||||
</Item>
|
||||
<Item className="col-span-2 lg:overflow-visible text-t3 text-xs">
|
||||
{
|
||||
formatUnixToDateTime(product.created_at)
|
||||
.date
|
||||
}
|
||||
</Item>
|
||||
</>
|
||||
)}
|
||||
<Item
|
||||
className={cn(
|
||||
"col-span-1 items-center justify-end",
|
||||
onboarding && "col-span-6"
|
||||
)}
|
||||
>
|
||||
<ProductRowToolbar product={product} />
|
||||
</Item>
|
||||
</Row>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
{products &&
|
||||
products
|
||||
.reduce(
|
||||
(acc, product) => {
|
||||
const existingIndex = acc.findIndex((p) => p.id === product.id);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
acc.push(product);
|
||||
} else {
|
||||
const existing = acc[existingIndex];
|
||||
|
||||
if (showArchived) {
|
||||
// If showing archived, always keep the newest version
|
||||
if (product.version > existing.version) {
|
||||
acc[existingIndex] = product;
|
||||
}
|
||||
} else {
|
||||
// If not showing archived, prefer non-archived versions
|
||||
if (product.archived && !existing.archived) {
|
||||
// Keep existing non-archived version
|
||||
} else if (!product.archived && existing.archived) {
|
||||
// Replace archived with non-archived
|
||||
acc[existingIndex] = product;
|
||||
} else if (product.version > existing.version) {
|
||||
// Both have same archived status, keep newer version
|
||||
acc[existingIndex] = product;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as typeof products
|
||||
)
|
||||
.map((product) => (
|
||||
<Row
|
||||
key={product.id}
|
||||
className={cn(
|
||||
"grid-cols-18 gap-2 items-center text-sm cursor-pointer hover:bg-primary/5 text-t2 whitespace-nowrap",
|
||||
onboarding && "grid-cols-12"
|
||||
)}
|
||||
isOnboarding={onboarding}
|
||||
onClick={() => {
|
||||
if (onRowClick) {
|
||||
onRowClick(product.id);
|
||||
} else {
|
||||
navigateTo(`/products/${product.id}`, navigate, env);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Item className="col-span-3">
|
||||
<AdminHover
|
||||
texts={[
|
||||
{
|
||||
key: "Internal ID",
|
||||
value: product.internal_id,
|
||||
},
|
||||
{
|
||||
key: "Version",
|
||||
value: product.version.toString(),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<span className="truncate">{product.name}</span>
|
||||
</AdminHover>
|
||||
</Item>
|
||||
<Item className="col-span-3 font-mono -translate-x-1">
|
||||
<CopyButton
|
||||
text={product.id || ""}
|
||||
className="bg-transparent text-t3 border-none px-1 shadow-none max-w-full"
|
||||
>
|
||||
<span className="truncate">{product.id}</span>
|
||||
</CopyButton>
|
||||
</Item>
|
||||
{!onboarding && (
|
||||
<>
|
||||
<Item className="col-span-3">
|
||||
<ProductCountsTooltip
|
||||
allCounts={allCounts}
|
||||
product={product}
|
||||
/>
|
||||
</Item>
|
||||
<Item className="col-span-3">
|
||||
<ProductTypeBadge product={product} />
|
||||
</Item>
|
||||
<Item className="col-span-3">
|
||||
{!onboarding && product.group}
|
||||
</Item>
|
||||
<Item className="col-span-2 lg:overflow-visible text-t3 text-xs">
|
||||
{formatUnixToDateTime(product.created_at).date}
|
||||
</Item>
|
||||
</>
|
||||
)}
|
||||
<Item
|
||||
className={cn(
|
||||
"col-span-1 items-center justify-end",
|
||||
onboarding && "col-span-6"
|
||||
)}
|
||||
>
|
||||
<ProductRowToolbar
|
||||
product={product}
|
||||
productCounts={allCounts[product.id]}
|
||||
/>
|
||||
</Item>
|
||||
</Row>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export const ProductCountsTooltip = ({
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<p className="rounded-full text-t3 px-2 font-mono py-0">
|
||||
<p className="rounded-full text-t3 font-mono py-0">
|
||||
{(allCounts && allCounts[product.id]?.active) || 0}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -9,26 +9,24 @@ import {
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Product } from "@autumn/shared";
|
||||
import { Product, ProductCounts } from "@autumn/shared";
|
||||
import { ProductService } from "@/services/products/ProductService";
|
||||
import { useProductsContext } from "../ProductsContext";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
|
||||
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { UpdateProductDialog } from "../UpdateProduct";
|
||||
import { CopyDialog } from "./CopyDialog";
|
||||
import { Copy, Delete, Pen, ArchiveRestore } from "lucide-react";
|
||||
import { Copy, Delete, Pen, ArchiveRestore, Archive } from "lucide-react";
|
||||
import { DeleteProductDialog } from "./DeleteProductDialog";
|
||||
|
||||
export const ProductRowToolbar = ({
|
||||
product,
|
||||
productCounts,
|
||||
}: {
|
||||
className?: string;
|
||||
product: Product;
|
||||
productCounts: ProductCounts;
|
||||
}) => {
|
||||
const { mutate, env } = useProductsContext();
|
||||
const axiosInstance = useAxiosInstance({ env });
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [copyLoading, setCopyLoading] = useState(false);
|
||||
@@ -38,18 +36,9 @@ export const ProductRowToolbar = ({
|
||||
const [dialogType, setDialogType] = useState<"update" | "copy">("update");
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await ProductService.deleteProduct(axiosInstance, product.id);
|
||||
await mutate();
|
||||
} catch (error) {
|
||||
console.log("Error deleting product", error);
|
||||
toast.error(getBackendErr(error, "Failed to delete product"));
|
||||
}
|
||||
setDeleteLoading(false);
|
||||
setDeleteOpen(false);
|
||||
};
|
||||
const allCount = productCounts.all;
|
||||
const deleteText = allCount > 0 ? "Archive" : "Delete";
|
||||
const DeleteIcon = allCount > 0 ? Archive : Delete;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -122,13 +111,13 @@ export const ProductRowToolbar = ({
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
{product.archived ? 'Unarchive' : 'Delete'}
|
||||
{deleteText}
|
||||
{deleteLoading ? (
|
||||
<SmallSpinner />
|
||||
) : product.archived ? (
|
||||
<ArchiveRestore size={12} className="text-t3" />
|
||||
) : (
|
||||
<Delete size={12} className="text-t3" />
|
||||
<DeleteIcon size={12} className="text-t3" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
Reference in New Issue
Block a user