adding code snippets for integration
This commit is contained in:
6
bun.lock
6
bun.lock
@@ -147,7 +147,7 @@
|
|||||||
"@clerk/clerk-react": "^5.24.2",
|
"@clerk/clerk-react": "^5.24.2",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
"ag-charts-community": "^12.0.2",
|
"ag-charts-community": "^12.0.2",
|
||||||
"ag-grid-community": "^34.0.2",
|
"ag-grid-community": "^34.0.2",
|
||||||
"ag-grid-react": "^34.0.2",
|
"ag-grid-react": "^34.0.2",
|
||||||
"autumn-js": "^0.0.93",
|
"autumn-js": "^0.1.0",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
"better-auth": "^1.2.9",
|
"better-auth": "^1.2.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -2693,7 +2693,7 @@
|
|||||||
|
|
||||||
"@autumn/vite/@types/node": ["@types/node@22.17.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ=="],
|
"@autumn/vite/@types/node": ["@types/node@22.17.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ=="],
|
||||||
|
|
||||||
"@autumn/vite/autumn-js": ["autumn-js@0.0.93", "", { "dependencies": { "chalk": "^5.4.1", "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^3.24.1" }, "peerDependencies": { "better-auth": "^1.2.12", "better-call": "^1.0.12" }, "optionalPeers": ["better-auth", "better-call"] }, "sha512-zGq9cfkhHL6xAHlervcZHO2WQ28mHNoxKKI4vnkvKifW7ETDcQk0T7xdJS9g/a+BJh7CLqe697gEdMblMUDfUA=="],
|
"@autumn/vite/autumn-js": ["autumn-js@0.1.0", "", { "dependencies": { "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^3.24.1" }, "peerDependencies": { "better-auth": "^1.2.12", "better-call": "^1.0.12" }, "optionalPeers": ["better-auth", "better-call"] }, "sha512-7YLnc33xYUhWEP0yCuEwWliZ+EOqvIz6ZTc4GvVKYNQwC2UIXX5USsWKf9maKfxQF1zSEMRcCmCa3hBLRcNUZg=="],
|
||||||
|
|
||||||
"@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
"@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
||||||
|
|
||||||
|
|||||||
1
server/src/external/stripe/stripeWebhooks.ts
vendored
1
server/src/external/stripe/stripeWebhooks.ts
vendored
@@ -204,6 +204,7 @@ stripeWebhookRouter.post(
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "subscription_schedule.canceled":
|
case "subscription_schedule.canceled":
|
||||||
const canceledSchedule = event.data.object;
|
const canceledSchedule = event.data.object;
|
||||||
await handleSubscriptionScheduleCanceled({
|
await handleSubscriptionScheduleCanceled({
|
||||||
|
|||||||
@@ -265,12 +265,14 @@ export class ProductService {
|
|||||||
orgId,
|
orgId,
|
||||||
env,
|
env,
|
||||||
version,
|
version,
|
||||||
|
allowNotFound = false,
|
||||||
}: {
|
}: {
|
||||||
db: DrizzleCli;
|
db: DrizzleCli;
|
||||||
idOrInternalId: string;
|
idOrInternalId: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
env: AppEnv;
|
env: AppEnv;
|
||||||
version?: number;
|
version?: number;
|
||||||
|
allowNotFound?: boolean;
|
||||||
}) {
|
}) {
|
||||||
let data = (await db.query.products.findFirst({
|
let data = (await db.query.products.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
@@ -298,7 +300,7 @@ export class ProductService {
|
|||||||
parseFreeTrials({ product: data });
|
parseFreeTrials({ product: data });
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
// return null;
|
if (allowNotFound) return null;
|
||||||
throw new RecaseError({
|
throw new RecaseError({
|
||||||
message: `Product ${idOrInternalId} not found`,
|
message: `Product ${idOrInternalId} not found`,
|
||||||
code: ErrCode.ProductNotFound,
|
code: ErrCode.ProductNotFound,
|
||||||
|
|||||||
@@ -38,14 +38,6 @@ export const handleListProductsBeta = async (req: any, res: any) =>
|
|||||||
env,
|
env,
|
||||||
logger: req.logger,
|
logger: req.logger,
|
||||||
});
|
});
|
||||||
// return await CusService.getFull({
|
|
||||||
// db,
|
|
||||||
// orgId: org.id,
|
|
||||||
// env,
|
|
||||||
// idOrInternalId: customerId as string,
|
|
||||||
// allowNotFound: true,
|
|
||||||
// entityId,
|
|
||||||
// });
|
|
||||||
})(),
|
})(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { addTaskToQueue } from "@/queue/queueUtils.js";
|
|||||||
import { JobName } from "@/queue/JobName.js";
|
import { JobName } from "@/queue/JobName.js";
|
||||||
import { productsAreSame } from "../../productUtils/compareProductUtils.js";
|
import { productsAreSame } from "../../productUtils/compareProductUtils.js";
|
||||||
import { initProductInStripe } from "../../productUtils.js";
|
import { initProductInStripe } from "../../productUtils.js";
|
||||||
|
import { handleCreateProduct } from "../handleCreateProduct.js";
|
||||||
|
|
||||||
export const handleUpdateProductV2 = async (req: any, res: any) =>
|
export const handleUpdateProductV2 = async (req: any, res: any) =>
|
||||||
routeHandler({
|
routeHandler({
|
||||||
@@ -25,7 +26,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
|
|||||||
action: "Update product",
|
action: "Update product",
|
||||||
handler: async () => {
|
handler: async () => {
|
||||||
const { productId } = req.params;
|
const { productId } = req.params;
|
||||||
const { version } = req.query;
|
const { version, upsert, disable_version } = req.query;
|
||||||
const { orgId, env, logger, db } = req;
|
const { orgId, env, logger, db } = req;
|
||||||
|
|
||||||
const [features, org, fullProduct, rewardPrograms] = await Promise.all([
|
const [features, org, fullProduct, rewardPrograms] = await Promise.all([
|
||||||
@@ -37,6 +38,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
|
|||||||
orgId,
|
orgId,
|
||||||
env,
|
env,
|
||||||
version: version ? parseInt(version) : undefined,
|
version: version ? parseInt(version) : undefined,
|
||||||
|
allowNotFound: upsert == "true",
|
||||||
}),
|
}),
|
||||||
RewardProgramService.getByProductId({
|
RewardProgramService.getByProductId({
|
||||||
db,
|
db,
|
||||||
@@ -47,6 +49,12 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!fullProduct) {
|
if (!fullProduct) {
|
||||||
|
console.log("Upserting:", upsert);
|
||||||
|
if (upsert == "true") {
|
||||||
|
await handleCreateProduct(req, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
throw new RecaseError({
|
throw new RecaseError({
|
||||||
message: "Product not found",
|
message: "Product not found",
|
||||||
code: ErrCode.ProductNotFound,
|
code: ErrCode.ProductNotFound,
|
||||||
@@ -75,6 +83,15 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
|
|||||||
let itemsExist = notNullish(req.body.items);
|
let itemsExist = notNullish(req.body.items);
|
||||||
|
|
||||||
if (cusProductExists && itemsExist) {
|
if (cusProductExists && itemsExist) {
|
||||||
|
if (disable_version == "true") {
|
||||||
|
throw new RecaseError({
|
||||||
|
message: "Cannot auto save product as there are existing customers",
|
||||||
|
code: ErrCode.InvalidRequest,
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { itemsSame, freeTrialsSame } = productsAreSame({
|
const { itemsSame, freeTrialsSame } = productsAreSame({
|
||||||
newProductV2: req.body,
|
newProductV2: req.body,
|
||||||
curProductV1: fullProduct,
|
curProductV1: fullProduct,
|
||||||
|
|||||||
@@ -42,7 +42,13 @@ const productDetailsSame = (prod1: Product, prod2: UpdateProduct) => {
|
|||||||
if (notNullish(prod2.is_default) && prod1.is_default != prod2.is_default) {
|
if (notNullish(prod2.is_default) && prod1.is_default != prod2.is_default) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
console.log("prod1.archived", prod1.archived, prod2.archived, notNullish(prod2.archived), prod1.archived !== prod2.archived);
|
console.log(
|
||||||
|
"prod1.archived",
|
||||||
|
prod1.archived,
|
||||||
|
prod2.archived,
|
||||||
|
notNullish(prod2.archived),
|
||||||
|
prod1.archived !== prod2.archived
|
||||||
|
);
|
||||||
if (notNullish(prod2.archived) && prod1.archived !== prod2.archived) {
|
if (notNullish(prod2.archived) && prod1.archived !== prod2.archived) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -71,7 +77,7 @@ const updateStripeProductNames = async ({
|
|||||||
});
|
});
|
||||||
let stripeProdId = curProduct.processor?.id;
|
let stripeProdId = curProduct.processor?.id;
|
||||||
|
|
||||||
if (!stripeProdId) {
|
if (!stripeProdId || !newName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ export * from "./models/attachModels/attachBody.js";
|
|||||||
export * from "./utils/displayUtils.js";
|
export * from "./utils/displayUtils.js";
|
||||||
export * from "./models/checkModels/checkPreviewModels.js";
|
export * from "./models/checkModels/checkPreviewModels.js";
|
||||||
export * from "./models/chatResultModels/chatResultFeature.js";
|
export * from "./models/chatResultModels/chatResultFeature.js";
|
||||||
|
export * from "./utils/productDisplayUtils/getProductItemRes.js";
|
||||||
|
export * from "./utils/productUtils.js";
|
||||||
|
export * from "./utils/productDisplayUtils/sortProductItems.js";
|
||||||
|
|
||||||
// ENUMS
|
// ENUMS
|
||||||
export * from "./enums/SuccessCode.js";
|
export * from "./enums/SuccessCode.js";
|
||||||
|
|||||||
@@ -31,3 +31,4 @@ export const ProductResponseSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type ProductResponse = z.infer<typeof ProductResponseSchema>;
|
export type ProductResponse = z.infer<typeof ProductResponseSchema>;
|
||||||
|
export type ProductProperties = z.infer<typeof ProductPropertiesSchema>;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Feature } from "../models/featureModels/featureModels.js";
|
import { Feature } from "../models/featureModels/featureModels.js";
|
||||||
import { Decimal } from "decimal.js";
|
import { Organization } from "../models/orgModels/orgTable.js";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { notNullish, nullish } from "./utils.js";
|
||||||
export const getFeatureName = ({
|
export const getFeatureName = ({
|
||||||
feature,
|
feature,
|
||||||
plural,
|
plural,
|
||||||
@@ -19,18 +20,28 @@ export const getFeatureName = ({
|
|||||||
let featureName = feature.name || "";
|
let featureName = feature.name || "";
|
||||||
|
|
||||||
if (feature.display) {
|
if (feature.display) {
|
||||||
if (units && units === 1) {
|
let finalPlural;
|
||||||
plural = false;
|
// Case 1: If units and nullish plural
|
||||||
|
if (notNullish(units) && nullish(plural)) {
|
||||||
|
finalPlural = units !== 1;
|
||||||
|
} else if (nullish(units) && notNullish(plural)) {
|
||||||
|
finalPlural = plural;
|
||||||
|
} else {
|
||||||
|
finalPlural = plural || false;
|
||||||
}
|
}
|
||||||
// if (!plural) {
|
|
||||||
// if (units && units === 1) {
|
|
||||||
// plural = false;
|
|
||||||
// } else {
|
|
||||||
// plural = true;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (plural) {
|
// if (units && units === 1) {
|
||||||
|
// plural = false;
|
||||||
|
// }
|
||||||
|
// // if (!plural) {
|
||||||
|
// // if (units && units === 1) {
|
||||||
|
// // plural = false;
|
||||||
|
// // } else {
|
||||||
|
// // plural = true;
|
||||||
|
// // }
|
||||||
|
// // }
|
||||||
|
|
||||||
|
if (finalPlural) {
|
||||||
featureName = feature.display.plural || featureName;
|
featureName = feature.display.plural || featureName;
|
||||||
} else {
|
} else {
|
||||||
featureName = feature.display.singular || featureName;
|
featureName = feature.display.singular || featureName;
|
||||||
@@ -130,3 +141,24 @@ export const getFeatureInvoiceDescription = ({
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatAmount = ({
|
||||||
|
org,
|
||||||
|
currency,
|
||||||
|
amount,
|
||||||
|
maxFractionDigits = 2,
|
||||||
|
minFractionDigits = 0,
|
||||||
|
}: {
|
||||||
|
org?: Organization;
|
||||||
|
currency?: string | null;
|
||||||
|
amount: number;
|
||||||
|
maxFractionDigits?: number;
|
||||||
|
minFractionDigits?: number;
|
||||||
|
}) => {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency || org?.default_currency || "USD",
|
||||||
|
minimumFractionDigits: minFractionDigits || 0,
|
||||||
|
maximumFractionDigits: maxFractionDigits || 2,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|||||||
32
shared/utils/featureUtils.ts
Normal file
32
shared/utils/featureUtils.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { CreditSchemaItem } from "../models/featureModels/featureConfig/creditConfig.js";
|
||||||
|
import { FeatureType } from "../models/featureModels/featureEnums.js";
|
||||||
|
import { Feature } from "../models/featureModels/featureModels.js";
|
||||||
|
import { APIFeatureSchema } from "../models/featureModels/featureResModels.js";
|
||||||
|
|
||||||
|
export const toAPIFeature = ({ feature }: { feature: Feature }) => {
|
||||||
|
// return FeatureResponseSchema.parse(feature);
|
||||||
|
// 1. Get feature type
|
||||||
|
let featureType = feature.type;
|
||||||
|
if (feature.type == FeatureType.Metered) {
|
||||||
|
featureType = feature.config.usage_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
let creditSchema = undefined;
|
||||||
|
if (feature.type == FeatureType.CreditSystem) {
|
||||||
|
creditSchema = feature.config.schema.map((s: CreditSchemaItem) => ({
|
||||||
|
metered_feature_id: s.metered_feature_id,
|
||||||
|
credit_cost: s.credit_amount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return APIFeatureSchema.parse({
|
||||||
|
id: feature.id,
|
||||||
|
name: feature.name,
|
||||||
|
type: featureType,
|
||||||
|
display: {
|
||||||
|
singular: feature.display?.singular || feature.name,
|
||||||
|
plural: feature.display?.plural || feature.name,
|
||||||
|
},
|
||||||
|
credit_schema: creditSchema,
|
||||||
|
});
|
||||||
|
};
|
||||||
202
shared/utils/productDisplayUtils.ts
Normal file
202
shared/utils/productDisplayUtils.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { Feature } from "../models/featureModels/featureModels.js";
|
||||||
|
import { Infinite } from "../models/productModels/productEnums.js";
|
||||||
|
import {
|
||||||
|
ProductItem,
|
||||||
|
ProductItemFeatureType,
|
||||||
|
} from "../models/productV2Models/productItemModels/productItemModels.js";
|
||||||
|
import {
|
||||||
|
getFeatureName,
|
||||||
|
numberWithCommas,
|
||||||
|
formatAmount,
|
||||||
|
} from "./displayUtils.js";
|
||||||
|
import {
|
||||||
|
isFeatureItem,
|
||||||
|
isFeaturePriceItem,
|
||||||
|
isPriceItem,
|
||||||
|
} from "./productDisplayUtils/getItemType.js";
|
||||||
|
import { notNullish, nullish } from "./utils.js";
|
||||||
|
|
||||||
|
export const formatTiers = ({
|
||||||
|
item,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
item: ProductItem;
|
||||||
|
currency?: string | null;
|
||||||
|
}) => {
|
||||||
|
let tiers = item.tiers;
|
||||||
|
if (tiers) {
|
||||||
|
if (tiers.length == 1) {
|
||||||
|
return formatAmount({
|
||||||
|
currency,
|
||||||
|
amount: tiers[0].amount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstPrice = tiers[0].amount;
|
||||||
|
let lastPrice = tiers[tiers.length - 1].amount;
|
||||||
|
|
||||||
|
return `${formatAmount({
|
||||||
|
currency,
|
||||||
|
amount: firstPrice,
|
||||||
|
})} - ${formatAmount({
|
||||||
|
currency,
|
||||||
|
amount: lastPrice,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFeatureItemDisplay = ({
|
||||||
|
item,
|
||||||
|
feature,
|
||||||
|
}: {
|
||||||
|
item: ProductItem;
|
||||||
|
feature?: Feature;
|
||||||
|
}) => {
|
||||||
|
if (!feature) {
|
||||||
|
throw new Error(`Feature ${item.feature_id} not found`);
|
||||||
|
}
|
||||||
|
// 1. If feature
|
||||||
|
if (item.feature_type == ProductItemFeatureType.Static) {
|
||||||
|
return {
|
||||||
|
primary_text: getFeatureName({
|
||||||
|
feature,
|
||||||
|
plural: false,
|
||||||
|
capitalize: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let featureName = getFeatureName({
|
||||||
|
feature,
|
||||||
|
units: item.included_usage,
|
||||||
|
});
|
||||||
|
|
||||||
|
let includedUsageTxt =
|
||||||
|
item.included_usage == Infinite
|
||||||
|
? "Unlimited "
|
||||||
|
: nullish(item.included_usage) || item.included_usage == 0
|
||||||
|
? ""
|
||||||
|
: `${numberWithCommas(item.included_usage!)} `;
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary_text: `${includedUsageTxt}${featureName}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPriceItemDisplay = ({
|
||||||
|
item,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
item: ProductItem;
|
||||||
|
currency?: string | null;
|
||||||
|
}) => {
|
||||||
|
let primaryText = formatAmount({
|
||||||
|
currency,
|
||||||
|
amount: item.price as number,
|
||||||
|
});
|
||||||
|
let secondaryText = item.interval ? `per ${item.interval}` : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary_text: primaryText,
|
||||||
|
secondary_text: secondaryText,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFeaturePriceItemDisplay = ({
|
||||||
|
feature,
|
||||||
|
item,
|
||||||
|
currency,
|
||||||
|
isMainPrice = false,
|
||||||
|
minifyIncluded = false,
|
||||||
|
}: {
|
||||||
|
feature?: Feature;
|
||||||
|
item: ProductItem;
|
||||||
|
currency?: string | null;
|
||||||
|
isMainPrice?: boolean;
|
||||||
|
minifyIncluded?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (!feature) {
|
||||||
|
throw new Error(`Feature ${item.feature_id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Get included usage
|
||||||
|
let includedFeatureName = getFeatureName({
|
||||||
|
feature,
|
||||||
|
units: item.included_usage,
|
||||||
|
});
|
||||||
|
|
||||||
|
let includedUsage = item.included_usage as number | null;
|
||||||
|
let includedUsageStr = "";
|
||||||
|
if (notNullish(includedUsage) && includedUsage! > 0) {
|
||||||
|
if (minifyIncluded) {
|
||||||
|
includedUsageStr = `${numberWithCommas(includedUsage!)} included`;
|
||||||
|
} else {
|
||||||
|
includedUsageStr = `${numberWithCommas(includedUsage!)} ${includedFeatureName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let priceStr = formatTiers({ item, currency });
|
||||||
|
let billingFeatureName = getFeatureName({
|
||||||
|
feature,
|
||||||
|
units: item.billing_units,
|
||||||
|
});
|
||||||
|
|
||||||
|
let priceStr2 = "";
|
||||||
|
if (item.billing_units && item.billing_units > 1) {
|
||||||
|
priceStr2 = `${numberWithCommas(item.billing_units)} ${billingFeatureName}`;
|
||||||
|
} else {
|
||||||
|
priceStr2 = `${billingFeatureName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let intervalStr = isMainPrice && item.interval ? ` per ${item.interval}` : "";
|
||||||
|
|
||||||
|
if (includedUsageStr) {
|
||||||
|
return {
|
||||||
|
primary_text: includedUsageStr,
|
||||||
|
secondary_text: `then ${priceStr} per ${priceStr2}${intervalStr}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary_text: priceStr + ` per ${priceStr2}${intervalStr}`,
|
||||||
|
// secondary_text: `per ${priceStr2}${intervalStr}`,
|
||||||
|
secondary_text: "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProductItemDisplay = ({
|
||||||
|
item,
|
||||||
|
features,
|
||||||
|
currency = "usd",
|
||||||
|
}: {
|
||||||
|
item: ProductItem;
|
||||||
|
features: Feature[];
|
||||||
|
currency?: string | null;
|
||||||
|
}) => {
|
||||||
|
if (isFeatureItem(item)) {
|
||||||
|
return getFeatureItemDisplay({
|
||||||
|
item,
|
||||||
|
feature: features.find((f) => f.id === item.feature_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPriceItem(item)) {
|
||||||
|
return getPriceItemDisplay({
|
||||||
|
item,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFeaturePriceItem(item)) {
|
||||||
|
return getFeaturePriceItemDisplay({
|
||||||
|
item,
|
||||||
|
feature: features.find((f) => f.id === item.feature_id),
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary_text: "couldn't detect item type",
|
||||||
|
secondary_text: "",
|
||||||
|
};
|
||||||
|
};
|
||||||
44
shared/utils/productDisplayUtils/getItemType.ts
Normal file
44
shared/utils/productDisplayUtils/getItemType.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
ProductItem,
|
||||||
|
ProductItemType,
|
||||||
|
} from "../../models/productV2Models/productItemModels/productItemModels.js";
|
||||||
|
import { notNullish, nullish } from "../utils.js";
|
||||||
|
|
||||||
|
export const isBooleanFeatureItem = (item: ProductItem) => {
|
||||||
|
return (
|
||||||
|
notNullish(item.feature_id) &&
|
||||||
|
(nullish(item.price) || item.price == 0) &&
|
||||||
|
nullish(item.tiers) &&
|
||||||
|
nullish(item.interval) &&
|
||||||
|
nullish(item.included_usage)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isFeatureItem = (item: ProductItem) => {
|
||||||
|
return (
|
||||||
|
notNullish(item.feature_id) &&
|
||||||
|
(nullish(item.price) || item.price == 0) &&
|
||||||
|
nullish(item.tiers)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPriceItem = (item: ProductItem) => {
|
||||||
|
return notNullish(item.price) && nullish(item.feature_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isFeaturePriceItem = (item: ProductItem) => {
|
||||||
|
return (
|
||||||
|
notNullish(item.feature_id) &&
|
||||||
|
(notNullish(item.price) || notNullish(item.tiers))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getItemType = (item: ProductItem) => {
|
||||||
|
if (isFeatureItem(item)) {
|
||||||
|
return ProductItemType.Feature;
|
||||||
|
} else if (isFeaturePriceItem(item)) {
|
||||||
|
return ProductItemType.FeaturePrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProductItemType.Price;
|
||||||
|
};
|
||||||
147
shared/utils/productDisplayUtils/getProductItemRes.ts
Normal file
147
shared/utils/productDisplayUtils/getProductItemRes.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { FeatureOptions } from "../../models/cusProductModels/cusProductModels.js";
|
||||||
|
import { Feature } from "../../models/featureModels/featureModels.js";
|
||||||
|
import { ProductItemResponseSchema } from "../../models/productV2Models/productItemModels/prodItemResponseModels.js";
|
||||||
|
import {
|
||||||
|
ProductItem,
|
||||||
|
UsageModel,
|
||||||
|
} from "../../models/productV2Models/productItemModels/productItemModels.js";
|
||||||
|
import { toAPIFeature } from "../featureUtils.js";
|
||||||
|
import { getProductItemDisplay } from "../productDisplayUtils.js";
|
||||||
|
import { notNullish } from "../utils.js";
|
||||||
|
import { getItemType } from "./getItemType.js";
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
|
||||||
|
export const calculateProrationAmount = ({
|
||||||
|
periodEnd,
|
||||||
|
periodStart,
|
||||||
|
now,
|
||||||
|
amount,
|
||||||
|
allowNegative = false,
|
||||||
|
}: {
|
||||||
|
periodEnd: number;
|
||||||
|
periodStart: number;
|
||||||
|
now: number;
|
||||||
|
amount: number;
|
||||||
|
allowNegative?: boolean;
|
||||||
|
}) => {
|
||||||
|
const num = new Decimal(periodEnd).minus(now);
|
||||||
|
const denom = new Decimal(periodEnd).minus(periodStart);
|
||||||
|
|
||||||
|
const proratedAmount = num.div(denom).mul(amount);
|
||||||
|
|
||||||
|
if (proratedAmount.lte(0) && !allowNegative) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proratedAmount.toNumber();
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Proration = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const itemToPriceOrTiers = ({
|
||||||
|
item,
|
||||||
|
proration,
|
||||||
|
now,
|
||||||
|
}: {
|
||||||
|
item: ProductItem;
|
||||||
|
proration?: Proration;
|
||||||
|
now?: number;
|
||||||
|
}) => {
|
||||||
|
now = now || Date.now();
|
||||||
|
if (item.price) {
|
||||||
|
return {
|
||||||
|
price: proration
|
||||||
|
? calculateProrationAmount({
|
||||||
|
periodEnd: proration.end,
|
||||||
|
periodStart: proration.start,
|
||||||
|
now,
|
||||||
|
amount: item.price,
|
||||||
|
})
|
||||||
|
: item.price,
|
||||||
|
tiers: undefined,
|
||||||
|
};
|
||||||
|
} else if (item.tiers) {
|
||||||
|
if (item.tiers.length > 1) {
|
||||||
|
return {
|
||||||
|
price: undefined,
|
||||||
|
tiers: item.tiers.map((tier) => ({
|
||||||
|
...tier,
|
||||||
|
amount: proration
|
||||||
|
? calculateProrationAmount({
|
||||||
|
periodEnd: proration.end,
|
||||||
|
periodStart: proration.start,
|
||||||
|
now,
|
||||||
|
amount: tier.amount,
|
||||||
|
})
|
||||||
|
: tier.amount,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
price: proration
|
||||||
|
? calculateProrationAmount({
|
||||||
|
periodEnd: proration.end,
|
||||||
|
periodStart: proration.start,
|
||||||
|
now,
|
||||||
|
amount: item.tiers[0].amount,
|
||||||
|
})
|
||||||
|
: item.tiers[0].amount,
|
||||||
|
tiers: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProductItemResponse = ({
|
||||||
|
item,
|
||||||
|
features,
|
||||||
|
currency,
|
||||||
|
withDisplay = true,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
item: ProductItem;
|
||||||
|
features: Feature[];
|
||||||
|
currency?: string | null;
|
||||||
|
withDisplay?: boolean;
|
||||||
|
options?: FeatureOptions[];
|
||||||
|
}) => {
|
||||||
|
// 1. Get item type
|
||||||
|
let type = getItemType(item);
|
||||||
|
|
||||||
|
// 2. Get display
|
||||||
|
let display = getProductItemDisplay({
|
||||||
|
item,
|
||||||
|
features,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
let priceData = itemToPriceOrTiers({ item });
|
||||||
|
|
||||||
|
let quantity = undefined;
|
||||||
|
let upcomingQuantity = undefined;
|
||||||
|
|
||||||
|
if (item.usage_model == UsageModel.Prepaid && notNullish(options)) {
|
||||||
|
let option = options!.find((o) => o.feature_id == item.feature_id);
|
||||||
|
quantity = option?.quantity
|
||||||
|
? option?.quantity * (item.billing_units ?? 1)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
upcomingQuantity = option?.upcoming_quantity
|
||||||
|
? option?.upcoming_quantity * (item.billing_units ?? 1)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let feature = features.find((f) => f.id == item.feature_id);
|
||||||
|
return ProductItemResponseSchema.parse({
|
||||||
|
type,
|
||||||
|
...item,
|
||||||
|
feature: feature ? toAPIFeature({ feature }) : null,
|
||||||
|
display: withDisplay ? display : undefined,
|
||||||
|
...priceData,
|
||||||
|
quantity,
|
||||||
|
next_cycle_quantity: upcomingQuantity,
|
||||||
|
});
|
||||||
|
};
|
||||||
47
shared/utils/productDisplayUtils/sortProductItems.ts
Normal file
47
shared/utils/productDisplayUtils/sortProductItems.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Feature } from "../../models/featureModels/featureModels.js";
|
||||||
|
import { ProductItem } from "../../models/productV2Models/productItemModels/productItemModels.js";
|
||||||
|
import { isFeaturePriceItem, isPriceItem } from "./getItemType.js";
|
||||||
|
|
||||||
|
export const sortProductItems = (items: ProductItem[], features: Feature[]) => {
|
||||||
|
items.sort((a, b) => {
|
||||||
|
let aIsPriceItem = isPriceItem(a);
|
||||||
|
let bIsPriceItem = isPriceItem(b);
|
||||||
|
|
||||||
|
if (aIsPriceItem && bIsPriceItem) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aIsPriceItem && !bIsPriceItem) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aIsPriceItem && bIsPriceItem) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Put feature price next
|
||||||
|
let aIsFeatureItem = isFeaturePriceItem(a);
|
||||||
|
let bIsFeatureItem = isFeaturePriceItem(b);
|
||||||
|
|
||||||
|
if (aIsFeatureItem && !bIsFeatureItem) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aIsFeatureItem && bIsFeatureItem) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Put feature price items in alphabetical order
|
||||||
|
let feature = features.find((f) => f.id == a.feature_id);
|
||||||
|
let aFeatureName = feature?.name;
|
||||||
|
let bFeatureName = features.find((f) => f.id == b.feature_id)?.name;
|
||||||
|
|
||||||
|
if (!aFeatureName || !bFeatureName) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return aFeatureName.localeCompare(bFeatureName);
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
85
shared/utils/productUtils.ts
Normal file
85
shared/utils/productUtils.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
BillingType,
|
||||||
|
Price,
|
||||||
|
ProductItem,
|
||||||
|
ProductResponse,
|
||||||
|
ProductV2,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import { nullish } from "./utils.js";
|
||||||
|
import {
|
||||||
|
isFeatureItem,
|
||||||
|
isFeaturePriceItem,
|
||||||
|
isPriceItem,
|
||||||
|
} from "./productDisplayUtils/getItemType.js";
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
|
||||||
|
export const isFreeProductV2 = ({ items }: { items: ProductItem[] }) => {
|
||||||
|
return items.every((item) => nullish(item.price) && nullish(item.tiers));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isProductUpgradeV2 = ({
|
||||||
|
items1,
|
||||||
|
items2,
|
||||||
|
}: {
|
||||||
|
items1: ProductItem[];
|
||||||
|
items2: ProductItem[];
|
||||||
|
}) => {
|
||||||
|
if (
|
||||||
|
!isFreeProductV2({ items: items1 }) &&
|
||||||
|
isFreeProductV2({ items: items2 })
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prices1 = items1.filter(
|
||||||
|
(item) => isFeaturePriceItem(item) || isPriceItem(item)
|
||||||
|
);
|
||||||
|
const prices2 = items2.filter(
|
||||||
|
(item) => isFeaturePriceItem(item) || isPriceItem(item)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Get total price for each product
|
||||||
|
const getTotalPrice = (items: ProductItem[]) => {
|
||||||
|
let totalPrice = new Decimal(0);
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.price) totalPrice = totalPrice.plus(item.price);
|
||||||
|
if (item.tiers) {
|
||||||
|
let tierTotal = item.tiers.reduce(
|
||||||
|
(acc, tier) => acc.plus(tier.amount),
|
||||||
|
new Decimal(0)
|
||||||
|
);
|
||||||
|
totalPrice = totalPrice.plus(tierTotal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalPrice.toNumber();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return interval...?
|
||||||
|
|
||||||
|
return getTotalPrice(prices1) < getTotalPrice(prices2);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sortProductsV2 = ({ products }: { products: ProductV2[] }) => {
|
||||||
|
products.sort((a, b) => {
|
||||||
|
if (a.is_add_on !== b.is_add_on) {
|
||||||
|
return a.is_add_on ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return products.sort((a, b) => {
|
||||||
|
// Secondary sort: by add-on status (non-add-ons first)
|
||||||
|
if (a.is_add_on !== b.is_add_on) {
|
||||||
|
return a.is_add_on ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary sort: by price (using upgrade logic)
|
||||||
|
let isUpgrade = isProductUpgradeV2({
|
||||||
|
items1: a.items,
|
||||||
|
items2: b.items,
|
||||||
|
});
|
||||||
|
|
||||||
|
return isUpgrade ? -1 : 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1 +1,2 @@
|
|||||||
export const notNullish = (value: any) => value !== null && value !== undefined;
|
export const notNullish = (value: any) => value !== null && value !== undefined;
|
||||||
|
export const nullish = (value: any) => value === null || value === undefined;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@clerk/clerk-react": "^5.24.2",
|
"@clerk/clerk-react": "^5.24.2",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"ag-charts-community": "^12.0.2",
|
"ag-charts-community": "^12.0.2",
|
||||||
"ag-grid-community": "^34.0.2",
|
"ag-grid-community": "^34.0.2",
|
||||||
"ag-grid-react": "^34.0.2",
|
"ag-grid-react": "^34.0.2",
|
||||||
"autumn-js": "^0.0.93",
|
"autumn-js": "^0.1.0",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
"better-auth": "^1.2.9",
|
"better-auth": "^1.2.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
BIN
vite/public/claude.png
Normal file
BIN
vite/public/claude.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
vite/public/cursor.png
Normal file
BIN
vite/public/cursor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -96,18 +96,20 @@ export function MainLayout() {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AutumnProvider backendUrl={import.meta.env.VITE_BACKEND_URL}>
|
<AutumnProvider
|
||||||
|
backendUrl={import.meta.env.VITE_BACKEND_URL}
|
||||||
|
includeCredentials={true}
|
||||||
|
>
|
||||||
<SidebarContext.Provider
|
<SidebarContext.Provider
|
||||||
value={{ state: sidebarState, setState: setSidebarState }}
|
value={{ state: sidebarState, setState: setSidebarState }}
|
||||||
>
|
>
|
||||||
<NuqsAdapter>
|
<NuqsAdapter>
|
||||||
<main className="w-screen h-screen flex bg-stone-100">
|
<main className="w-screen h-screen flex bg-stone-100">
|
||||||
<CustomToaster />
|
<CustomToaster />
|
||||||
<MainSidebar />
|
<MainSidebar />
|
||||||
<MainContent />
|
<MainContent />
|
||||||
</main>
|
</main>
|
||||||
|
</NuqsAdapter>
|
||||||
</NuqsAdapter>
|
|
||||||
</SidebarContext.Provider>
|
</SidebarContext.Provider>
|
||||||
</AutumnProvider>
|
</AutumnProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
459
vite/src/components/autumn/checkout-dialog.tsx
Normal file
459
vite/src/components/autumn/checkout-dialog.tsx
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
"use client";
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { getCheckoutContent } from "@/lib/autumn/checkout-content";
|
||||||
|
import { useCustomer } from "autumn-js/react";
|
||||||
|
import { ArrowRight, ChevronDown, Loader2 } from "lucide-react";
|
||||||
|
import type { CheckoutResult, ProductItem } from "autumn-js";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export interface CheckoutDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
checkoutResult: CheckoutResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = ({
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
}) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CheckoutDialog(params: CheckoutDialogProps) {
|
||||||
|
const { attach } = useCustomer();
|
||||||
|
const [checkoutResult, setCheckoutResult] = useState<
|
||||||
|
CheckoutResult | undefined
|
||||||
|
>(params?.checkoutResult);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (params.checkoutResult) {
|
||||||
|
setCheckoutResult(params.checkoutResult);
|
||||||
|
}
|
||||||
|
}, [params.checkoutResult]);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
if (!checkoutResult) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { open, setOpen } = params;
|
||||||
|
const { title, message } = getCheckoutContent(checkoutResult);
|
||||||
|
|
||||||
|
const isFree = checkoutResult?.product.properties?.is_free;
|
||||||
|
const isPaid = isFree === false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="p-0 pt-4 gap-0 text-foreground overflow-hidden text-sm">
|
||||||
|
<DialogTitle className="px-6 mb-1">{title}</DialogTitle>
|
||||||
|
<div className="px-6 mt-1 mb-4 text-muted-foreground">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPaid && checkoutResult && (
|
||||||
|
<PriceInformation
|
||||||
|
checkoutResult={checkoutResult}
|
||||||
|
setCheckoutResult={setCheckoutResult}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-col sm:flex-row justify-between gap-x-4 py-2 pl-6 pr-3 bg-secondary border-t shadow-inner">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const options = checkoutResult.options.map((option) => {
|
||||||
|
return {
|
||||||
|
featureId: option.feature_id,
|
||||||
|
quantity: option.quantity,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await attach({
|
||||||
|
productId: checkoutResult.product.id,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
setLoading(false);
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className="min-w-16 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="whitespace-nowrap flex gap-1">
|
||||||
|
Confirm
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PriceInformation({
|
||||||
|
checkoutResult,
|
||||||
|
setCheckoutResult,
|
||||||
|
}: {
|
||||||
|
checkoutResult: CheckoutResult;
|
||||||
|
setCheckoutResult: (checkoutResult: CheckoutResult) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-6 mb-4 flex flex-col gap-4">
|
||||||
|
<ProductItems
|
||||||
|
checkoutResult={checkoutResult}
|
||||||
|
setCheckoutResult={setCheckoutResult}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{checkoutResult?.has_prorations && checkoutResult.lines.length > 0 && (
|
||||||
|
<CheckoutLines checkoutResult={checkoutResult} />
|
||||||
|
)}
|
||||||
|
<DueAmounts checkoutResult={checkoutResult} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DueAmounts({ checkoutResult }: { checkoutResult: CheckoutResult }) {
|
||||||
|
const { next_cycle, product } = checkoutResult;
|
||||||
|
const nextCycleAtStr = next_cycle
|
||||||
|
? new Date(next_cycle.starts_at).toLocaleDateString()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const hasUsagePrice = product.items.some(
|
||||||
|
(item) => item.usage_model === "pay_per_use"
|
||||||
|
);
|
||||||
|
|
||||||
|
const showNextCycle = next_cycle && next_cycle.total !== checkoutResult.total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-md">Total due today</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="font-medium text-md">
|
||||||
|
{formatCurrency({
|
||||||
|
amount: checkoutResult?.total,
|
||||||
|
currency: checkoutResult?.currency,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{showNextCycle && (
|
||||||
|
<div className="flex justify-between text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
<p className="text-md">Due next cycle ({nextCycleAtStr})</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-md">
|
||||||
|
{formatCurrency({
|
||||||
|
amount: next_cycle.total,
|
||||||
|
currency: checkoutResult?.currency,
|
||||||
|
})}
|
||||||
|
{hasUsagePrice && <span> + usage prices</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductItems({
|
||||||
|
checkoutResult,
|
||||||
|
setCheckoutResult,
|
||||||
|
}: {
|
||||||
|
checkoutResult: CheckoutResult;
|
||||||
|
setCheckoutResult: (checkoutResult: CheckoutResult) => void;
|
||||||
|
}) {
|
||||||
|
const isUpdateQuantity =
|
||||||
|
checkoutResult?.product.scenario === "active" &&
|
||||||
|
checkoutResult.product.properties.updateable;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Price</p>
|
||||||
|
{checkoutResult?.product.items
|
||||||
|
.filter((item) => item.type !== "feature")
|
||||||
|
.map((item, index) => {
|
||||||
|
if (item.usage_model == "prepaid") {
|
||||||
|
return (
|
||||||
|
<PrepaidItem
|
||||||
|
key={index}
|
||||||
|
item={item}
|
||||||
|
checkoutResult={checkoutResult!}
|
||||||
|
setCheckoutResult={setCheckoutResult}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUpdateQuantity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex justify-between">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{item.feature ? item.feature.name : "Subscription"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{item.display?.primary_text} {item.display?.secondary_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckoutLines({ checkoutResult }: { checkoutResult: CheckoutResult }) {
|
||||||
|
return (
|
||||||
|
<Accordion type="single" collapsible>
|
||||||
|
<AccordionItem value="total" className="border-b-0">
|
||||||
|
<CustomAccordionTrigger className="justify-between w-full my-0 py-0 border-none">
|
||||||
|
<div className="cursor-pointer flex items-center gap-1 w-full justify-end">
|
||||||
|
<p className="font-light text-muted-foreground">
|
||||||
|
View details
|
||||||
|
</p>
|
||||||
|
<ChevronDown
|
||||||
|
className="text-muted-foreground mt-0.5 rotate-90 transition-transform duration-200 ease-in-out"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CustomAccordionTrigger>
|
||||||
|
<AccordionContent className="mt-2 mb-0 pb-2 flex flex-col gap-2">
|
||||||
|
{checkoutResult?.lines
|
||||||
|
.filter((line) => line.amount != 0)
|
||||||
|
.map((line, index) => {
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex justify-between">
|
||||||
|
<p className="text-muted-foreground">{line.description}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: checkoutResult?.currency,
|
||||||
|
}).format(line.amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomAccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]_svg]:rotate-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PrepaidItem = ({
|
||||||
|
item,
|
||||||
|
checkoutResult,
|
||||||
|
setCheckoutResult,
|
||||||
|
}: {
|
||||||
|
item: ProductItem;
|
||||||
|
checkoutResult: CheckoutResult;
|
||||||
|
setCheckoutResult: (checkoutResult: CheckoutResult) => void;
|
||||||
|
}) => {
|
||||||
|
const { quantity = 0, billing_units: billingUnits = 1 } = item;
|
||||||
|
const [quantityInput, setQuantityInput] = useState<string>(
|
||||||
|
(quantity / billingUnits).toString()
|
||||||
|
);
|
||||||
|
const { checkout } = useCustomer();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const scenario = checkoutResult.product.scenario;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const newOptions = checkoutResult.options
|
||||||
|
.filter((option) => option.feature_id !== item.feature_id)
|
||||||
|
.map((option) => {
|
||||||
|
return {
|
||||||
|
featureId: option.feature_id,
|
||||||
|
quantity: option.quantity,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
newOptions.push({
|
||||||
|
featureId: item.feature_id!,
|
||||||
|
quantity: Number(quantityInput) * billingUnits,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, error } = await checkout({
|
||||||
|
productId: checkoutResult.product.id,
|
||||||
|
options: newOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCheckoutResult(data!);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableSelection = scenario === "renew";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<p className="text-muted-foreground">{item.feature?.name}</p>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground text-xs px-1 py-0.5 rounded-md flex items-center gap-1 bg-accent/80",
|
||||||
|
disableSelection !== true &&
|
||||||
|
"hover:bg-accent hover:text-foreground"
|
||||||
|
)}
|
||||||
|
disabled={disableSelection}
|
||||||
|
>
|
||||||
|
Qty: {quantity}
|
||||||
|
<ChevronDown size={12} />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="w-80 text-sm p-4 pt-3 flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm font-medium">{item.feature?.name}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{item.display?.primary_text} {item.display?.secondary_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
className="h-7 w-16 focus:!ring-2"
|
||||||
|
value={quantityInput}
|
||||||
|
onChange={(e) => setQuantityInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{billingUnits > 1 && `x ${billingUnits} `}
|
||||||
|
{item.feature?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="w-14 !h-7 text-sm items-center bg-white text-foreground shadow-sm border border-zinc-200 hover:bg-zinc-100"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="text-muted-foreground animate-spin !w-4 !h-4" />
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{item.display?.primary_text} {item.display?.secondary_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PriceItem = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.HTMLAttributes<HTMLDivElement>) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col pb-4 sm:pb-0 gap-1 sm:flex-row justify-between sm:h-7 sm:gap-2 sm:items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PricingDialogButton = ({
|
||||||
|
children,
|
||||||
|
size,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
size?: "sm" | "lg" | "default" | "icon";
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
size={size}
|
||||||
|
className={cn(className, "shadow-sm shadow-stone-400")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ArrowRight className="!h-3" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,34 +1,37 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useCustomer, usePricingTable } from "autumn-js/react";
|
|
||||||
|
import { useCustomer, usePricingTable, ProductDetails } from "autumn-js/react";
|
||||||
import { createContext, useContext, useState } from "react";
|
import { createContext, useContext, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Check, Loader2 } from "lucide-react";
|
import CheckoutDialog from "@/components/autumn/checkout-dialog";
|
||||||
import AttachDialog from "@/components/autumn/attach-dialog";
|
|
||||||
import { getPricingTableContent } from "@/lib/autumn/pricing-table-content";
|
import { getPricingTableContent } from "@/lib/autumn/pricing-table-content";
|
||||||
import { Product, ProductItem } from "autumn-js";
|
import type { Product, ProductItem } from "autumn-js";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export default function PricingTable({
|
export default function PricingTable({
|
||||||
productDetails,
|
productDetails,
|
||||||
|
products,
|
||||||
}: {
|
}: {
|
||||||
productDetails?: any;
|
productDetails?: ProductDetails[];
|
||||||
|
products?: Product[];
|
||||||
}) {
|
}) {
|
||||||
const { attach } = useCustomer();
|
const { checkout } = useCustomer();
|
||||||
const [isAnnual, setIsAnnual] = useState(false);
|
const [isAnnual, setIsAnnual] = useState(false);
|
||||||
const { products, isLoading, error } = usePricingTable({ productDetails });
|
const { isLoading, error } = usePricingTable({ productDetails });
|
||||||
|
|
||||||
if (isLoading) {
|
// if (isLoading) {
|
||||||
return (
|
// return (
|
||||||
<div className="w-full h-full flex justify-center items-center min-h-[300px]">
|
// <div className="w-full h-full flex justify-center items-center min-h-[300px]">
|
||||||
<Loader2 className="w-6 h-6 text-zinc-400 animate-spin" />
|
// <Loader2 className="w-6 h-6 text-zinc-400 animate-spin" />
|
||||||
</div>
|
// </div>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (error) {
|
// if (error) {
|
||||||
return <div> Something went wrong...</div>;
|
// return <div> Something went wrong...</div>;
|
||||||
}
|
// }
|
||||||
|
|
||||||
const intervals = Array.from(
|
const intervals = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
@@ -38,7 +41,7 @@ export default function PricingTable({
|
|||||||
|
|
||||||
const multiInterval = intervals.length > 1;
|
const multiInterval = intervals.length > 1;
|
||||||
|
|
||||||
const intervalFilter = (product: any) => {
|
const intervalFilter = (product: Product) => {
|
||||||
if (!product.properties?.interval_group) {
|
if (!product.properties?.interval_group) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -55,10 +58,10 @@ export default function PricingTable({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("root")}>
|
<div className={cn("root w-full")}>
|
||||||
{products && (
|
{products && (
|
||||||
<PricingTableContainer
|
<PricingTableContainer
|
||||||
products={products as any}
|
products={products}
|
||||||
isAnnualToggle={isAnnual}
|
isAnnualToggle={isAnnual}
|
||||||
setIsAnnualToggle={setIsAnnual}
|
setIsAnnualToggle={setIsAnnual}
|
||||||
multiInterval={multiInterval}
|
multiInterval={multiInterval}
|
||||||
@@ -67,18 +70,18 @@ export default function PricingTable({
|
|||||||
<PricingCard
|
<PricingCard
|
||||||
key={index}
|
key={index}
|
||||||
productId={product.id}
|
productId={product.id}
|
||||||
|
className="bg-white"
|
||||||
buttonProps={{
|
buttonProps={{
|
||||||
disabled:
|
disabled:
|
||||||
product.scenario === "active" ||
|
(product.scenario === "active" &&
|
||||||
|
!product.properties.updateable) ||
|
||||||
product.scenario === "scheduled",
|
product.scenario === "scheduled",
|
||||||
|
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
if (product.id) {
|
if (product.id) {
|
||||||
await attach({
|
await checkout({
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
dialog: AttachDialog,
|
dialog: CheckoutDialog,
|
||||||
openInNewTab: true,
|
|
||||||
successUrl: window.location.href,
|
|
||||||
});
|
});
|
||||||
} else if (product.display?.button_url) {
|
} else if (product.display?.button_url) {
|
||||||
window.open(product.display?.button_url, "_blank");
|
window.open(product.display?.button_url, "_blank");
|
||||||
@@ -162,7 +165,8 @@ export const PricingTableContainer = ({
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] w-full gap-2",
|
"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] w-full gap-2 justify-items-center",
|
||||||
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -194,9 +198,10 @@ export const PricingCard = ({
|
|||||||
throw new Error(`Product with id ${productId} not found`);
|
throw new Error(`Product with id ${productId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, display: productDisplay, items } = product;
|
const { name, display: productDisplay } = product;
|
||||||
|
|
||||||
const { buttonText } = getPricingTableContent(product);
|
const { buttonText } = getPricingTableContent(product);
|
||||||
|
|
||||||
const isRecommended = productDisplay?.recommend_text ? true : false;
|
const isRecommended = productDisplay?.recommend_text ? true : false;
|
||||||
const mainPriceDisplay = product.properties?.is_free
|
const mainPriceDisplay = product.properties?.is_free
|
||||||
? {
|
? {
|
||||||
@@ -211,7 +216,7 @@ export const PricingCard = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full h-full py-6 text-foreground border rounded-lg shadow-sm max-w-xl",
|
" w-full h-full py-6 text-foreground border rounded-xs max-w-md",
|
||||||
isRecommended &&
|
isRecommended &&
|
||||||
"lg:-translate-y-6 lg:shadow-lg dark:shadow-zinc-800/80 lg:h-[calc(100%+48px)] bg-secondary/40",
|
"lg:-translate-y-6 lg:shadow-lg dark:shadow-zinc-800/80 lg:h-[calc(100%+48px)] bg-secondary/40",
|
||||||
className
|
className
|
||||||
@@ -229,8 +234,12 @@ export const PricingCard = ({
|
|||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="pb-4">
|
<div className="pb-4">
|
||||||
<h2 className="text-2xl font-semibold px-6 truncate">
|
<h2 className="text-md font-semibold px-6 truncate">
|
||||||
{productDisplay?.name || name}
|
{productDisplay?.name || name || (
|
||||||
|
<span className="text-muted-foreground font-normal">
|
||||||
|
Name this product
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
{productDisplay?.description && (
|
{productDisplay?.description && (
|
||||||
<div className="text-sm text-muted-foreground px-6 h-8">
|
<div className="text-sm text-muted-foreground px-6 h-8">
|
||||||
@@ -239,7 +248,7 @@ export const PricingCard = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<h3 className="font-semibold h-16 flex px-6 items-center border-y mb-4 bg-secondary/40">
|
<h3 className="font-semibold h-12 text-sm flex px-6 items-center border-y mb-4 bg-secondary/40">
|
||||||
<div className="line-clamp-2">
|
<div className="line-clamp-2">
|
||||||
{mainPriceDisplay?.primary_text}{" "}
|
{mainPriceDisplay?.primary_text}{" "}
|
||||||
{mainPriceDisplay?.secondary_text && (
|
{mainPriceDisplay?.secondary_text && (
|
||||||
@@ -255,7 +264,6 @@ export const PricingCard = ({
|
|||||||
<div className="flex-grow px-6 mb-6">
|
<div className="flex-grow px-6 mb-6">
|
||||||
<PricingFeatureList
|
<PricingFeatureList
|
||||||
items={featureItems}
|
items={featureItems}
|
||||||
showIcon={true}
|
|
||||||
everythingFrom={product.display?.everything_from}
|
everythingFrom={product.display?.everything_from}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,7 +274,7 @@ export const PricingCard = ({
|
|||||||
recommended={productDisplay?.recommend_text ? true : false}
|
recommended={productDisplay?.recommend_text ? true : false}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
{buttonText}
|
{productDisplay?.button_text || buttonText}
|
||||||
</PricingCardButton>
|
</PricingCardButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,12 +285,10 @@ export const PricingCard = ({
|
|||||||
// Pricing Feature List
|
// Pricing Feature List
|
||||||
export const PricingFeatureList = ({
|
export const PricingFeatureList = ({
|
||||||
items,
|
items,
|
||||||
showIcon = true,
|
|
||||||
everythingFrom,
|
everythingFrom,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
items: ProductItem[];
|
items: ProductItem[];
|
||||||
showIcon?: boolean;
|
|
||||||
everythingFrom?: string;
|
everythingFrom?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -293,10 +299,7 @@ export const PricingFeatureList = ({
|
|||||||
)}
|
)}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div key={index} className="flex items-start gap-2 text-sm">
|
<div key={index} className="flex items-start gap-1 text-sm text-t2">
|
||||||
{showIcon && (
|
|
||||||
<Check className="h-4 w-4 text-primary flex-shrink-0 mt-0.5" />
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>{item.display?.primary_text}</span>
|
<span>{item.display?.primary_text}</span>
|
||||||
{item.display?.secondary_text && (
|
{item.display?.secondary_text && (
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ export const ToggleButton = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => setValue(!value)}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setValue(!value);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
`flex justify-start items-center hover:bg-transparent bg-transparent border-none shadow-none gap-2 w-fit p-0`,
|
`flex justify-start items-center hover:bg-transparent bg-transparent border-none shadow-none gap-2 w-fit p-0`,
|
||||||
className
|
className
|
||||||
@@ -36,7 +40,6 @@ export const ToggleButton = ({
|
|||||||
{infoContent && <InfoTooltip>{infoContent}</InfoTooltip>}
|
{infoContent && <InfoTooltip>{infoContent}</InfoTooltip>}
|
||||||
<Switch
|
<Switch
|
||||||
checked={value}
|
checked={value}
|
||||||
onCheckedChange={setValue}
|
|
||||||
className="h-4 w-7 data-[state=checked]:bg-stone-500"
|
className="h-4 w-7 data-[state=checked]:bg-stone-500"
|
||||||
thumbClassName="h-3 w-3 data-[state=checked]:translate-x-3"
|
thumbClassName="h-3 w-3 data-[state=checked]:translate-x-3"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ const buttonVariants = cva(
|
|||||||
"hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 !px-2 !h-fit !py-0.5 truncate justify-start",
|
"hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 !px-2 !h-fit !py-0.5 truncate justify-start",
|
||||||
link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50",
|
link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50",
|
||||||
dashed:
|
dashed:
|
||||||
"font-semibold border border-1 border-dashed bg-gradient-to-b from-white to-stone-100 border-stone-300 text-primary shadow-sm hover:from-stone-100 hover:to-stone-200 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
|
"hover:bg-stone-100 text-t2 border border-1 border-dashed border-stone-300",
|
||||||
|
// shadow-sm hover:from-stone-100 hover:to-stone-200 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50
|
||||||
gradientPrimary:
|
gradientPrimary:
|
||||||
"bg-gradient-to-b font-semibold border-t border-purple-400 outline outline-primary rounded-sm from-primary/85 to-primary text-white hover:from-primary hover:to-primary shadow-purple-500/50 transition-[background] duration-300 !h-7.5 mt-0.25",
|
"bg-gradient-to-b font-semibold border-t border-purple-400 outline outline-primary rounded-sm from-primary/85 to-primary text-white hover:from-primary hover:to-primary shadow-purple-500/50 transition-[background] duration-300 !h-7.5 mt-0.25",
|
||||||
|
|
||||||
@@ -44,7 +45,8 @@ const buttonVariants = cva(
|
|||||||
add: "text-primary border-t border-white outline outline-zinc-200 bg-gradient-to-b from-stone-100 to-zinc-50 hover:from-stone-100 hover:to-stone-100 hover:border-primary dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 !h-9.5 rounded-none",
|
add: "text-primary border-t border-white outline outline-zinc-200 bg-gradient-to-b from-stone-100 to-zinc-50 hover:from-stone-100 hover:to-stone-100 hover:border-primary dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 !h-9.5 rounded-none",
|
||||||
// add: "border-t border-purple-400 bg-gradient-to-b from-primary/90 to-primary text-white hover:from-primary hover:to-primary !shadow-lg !h-6 rounded-md",
|
// add: "border-t border-purple-400 bg-gradient-to-b from-primary/90 to-primary text-white hover:from-primary hover:to-primary !shadow-lg !h-6 rounded-md",
|
||||||
|
|
||||||
analyse: "text-primary border-t border-white outline outline-zinc-200 bg-gradient-to-b from-stone-100 to-zinc-50 hover:from-stone-100 hover:to-stone-100 hover:border-primary dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 !h-9.5 rounded-none",
|
analyse:
|
||||||
|
"text-primary border-t border-white outline outline-zinc-200 bg-gradient-to-b from-stone-100 to-zinc-50 hover:from-stone-100 hover:to-stone-100 hover:border-primary dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 !h-9.5 rounded-none",
|
||||||
|
|
||||||
destructivePrimary:
|
destructivePrimary:
|
||||||
"bg-gradient-to-b font-semibold border-t border-red-400 outline outline-red-500 rounded-sm from-red-500/85 to-red-500 text-white hover:from-red-500 hover:to-red-500 shadow-red-500/50 transition-[background] duration-300 !h-7.5 mt-0.25",
|
"bg-gradient-to-b font-semibold border-t border-red-400 outline outline-red-500 rounded-sm from-red-500/85 to-red-500 text-white hover:from-red-500 hover:to-red-500 shadow-red-500/50 transition-[background] duration-300 !h-7.5 mt-0.25",
|
||||||
@@ -62,7 +64,7 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
@@ -97,7 +99,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
shimmer = false,
|
shimmer = false,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref
|
||||||
) => {
|
) => {
|
||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button";
|
||||||
const Button = (
|
const Button = (
|
||||||
@@ -106,7 +108,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant, size, className }),
|
buttonVariants({ variant, size, className }),
|
||||||
isIcon && `w-${dim} h-${dim} p-0`,
|
isIcon && `w-${dim} h-${dim} p-0`,
|
||||||
shimmer && "shimmer",
|
shimmer && "shimmer"
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -124,9 +126,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
{!isLoading && !startIcon && variant == "add" && !disableStartIcon && (
|
{!isLoading && !startIcon && variant == "add" && !disableStartIcon && (
|
||||||
<PlusIcon size={12} />
|
<PlusIcon size={12} />
|
||||||
)}
|
)}
|
||||||
{!isLoading && !startIcon && variant == "analyse" && !disableStartIcon && (
|
{!isLoading &&
|
||||||
<Search size={12} />
|
!startIcon &&
|
||||||
)}
|
variant == "analyse" &&
|
||||||
|
!disableStartIcon && <Search size={12} />}
|
||||||
{children}
|
{children}
|
||||||
{endIcon && !isLoading && <>{endIcon}</>}
|
{endIcon && !isLoading && <>{endIcon}</>}
|
||||||
</Comp>
|
</Comp>
|
||||||
@@ -150,7 +153,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
{...props}
|
{...props}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant, size, className }),
|
buttonVariants({ variant, size, className }),
|
||||||
isIcon && `w-${dim} h-${dim} p-0`,
|
isIcon && `w-${dim} h-${dim} p-0`
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -169,7 +172,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
{endIcon && !isLoading && <>{endIcon}</>}
|
{endIcon && !isLoading && <>{endIcon}</>}
|
||||||
</Comp>
|
</Comp>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
Button.displayName = "Button";
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
|||||||
142
vite/src/lib/autumn/checkout-content.tsx
Normal file
142
vite/src/lib/autumn/checkout-content.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { type CheckoutResult } from "autumn-js";
|
||||||
|
|
||||||
|
export const getCheckoutContent = (checkoutResult: CheckoutResult) => {
|
||||||
|
const { product, current_product, next_cycle } = checkoutResult;
|
||||||
|
const { is_one_off, is_free, has_trial, updateable } = product.properties;
|
||||||
|
const scenario = product.scenario;
|
||||||
|
|
||||||
|
const nextCycleAtStr = next_cycle
|
||||||
|
? new Date(next_cycle.starts_at).toLocaleDateString()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const productName = product.name;
|
||||||
|
|
||||||
|
if (is_one_off) {
|
||||||
|
return {
|
||||||
|
title: <p>Purchase {productName}</p>,
|
||||||
|
message: (
|
||||||
|
<p>
|
||||||
|
By clicking confirm, you will purchase {productName} and your card
|
||||||
|
will be charged immediately.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scenario == "active" && updateable) {
|
||||||
|
if (updateable) {
|
||||||
|
return {
|
||||||
|
title: <p>Update Plan</p>,
|
||||||
|
message: (
|
||||||
|
<p>
|
||||||
|
Update your prepaid quantity. You'll be charged or credited the
|
||||||
|
prorated difference based on your current billing cycle.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has_trial) {
|
||||||
|
return {
|
||||||
|
title: <p>Start trial for {productName}</p>,
|
||||||
|
message: (
|
||||||
|
<p>
|
||||||
|
By clicking confirm, you will start a free trial of {productName}{" "}
|
||||||
|
which ends on {nextCycleAtStr}.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (scenario) {
|
||||||
|
case "scheduled":
|
||||||
|
return {
|
||||||
|
title: <p>{productName} product already scheduled</p>,
|
||||||
|
message: (
|
||||||
|
<p>
|
||||||
|
You are currently on product {current_product.name} and are
|
||||||
|
scheduled to start {productName} on {nextCycleAtStr}.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "active":
|
||||||
|
return {
|
||||||
|
title: <p>Product already active</p>,
|
||||||
|
message: <p>You are already subscribed to this product.</p>,
|
||||||
|
};
|
||||||
|
|
||||||
|
case "new":
|
||||||
|
if (is_free) {
|
||||||
|
return {
|
||||||
|
title: <p>Enable {productName}</p>,
|
||||||
|
message: (
|
||||||
|
<p>
|
||||||
|
By clicking confirm, {productName} will be enabled immediately.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: <p>Subscribe to {productName}</p>,
|
||||||
|
message: (
|
||||||
|
<p>
|
||||||
|
By clicking confirm, you will be subscribed to {productName} and
|
||||||
|
your card will be charged immediately.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case "renew":
|
||||||
|
return {
|
||||||
|
title: <p>Renew</p>,
|
||||||
|
message: (
|
||||||
|
<p>
|
||||||
|
By clicking confirm, you will renew your subscription to{" "}
|
||||||
|
{productName}.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "upgrade":
|
||||||
|
return {
|
||||||
|
title: <p>Upgrade to {productName}</p>,
|
||||||
|
message: (
|
||||||
|
<p>
|
||||||
|
By clicking confirm, you will upgrade to {productName} and your
|
||||||
|
payment method will be charged immediately.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "downgrade":
|
||||||
|
return {
|
||||||
|
title: <p>Downgrade to {productName}</p>,
|
||||||
|
message: (
|
||||||
|
<p>
|
||||||
|
By clicking confirm, your current subscription to{" "}
|
||||||
|
{current_product.name} will be cancelled and a new subscription to{" "}
|
||||||
|
{productName} will begin on {nextCycleAtStr}.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "cancel":
|
||||||
|
return {
|
||||||
|
title: <p>Cancel</p>,
|
||||||
|
message: (
|
||||||
|
<p>
|
||||||
|
By clicking confirm, your subscription to {current_product.name}{" "}
|
||||||
|
will end on {nextCycleAtStr}.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: <p>Change Subscription</p>,
|
||||||
|
message: <p>You are about to change your subscription.</p>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { type CheckProductPreview } from "autumn-js";
|
import { type Product } from "autumn-js";
|
||||||
|
|
||||||
export const getPricingTableContent = (product: any) => {
|
export const getPricingTableContent = (product: Product) => {
|
||||||
const { scenario, free_trial } = product;
|
const { scenario, free_trial, properties } = product;
|
||||||
|
const { is_one_off, updateable, has_trial } = properties;
|
||||||
|
|
||||||
if (free_trial && free_trial.trial_available) {
|
if (has_trial) {
|
||||||
return {
|
return {
|
||||||
buttonText: <p>Start Free Trial</p>,
|
buttonText: <p>Start Free Trial</p>,
|
||||||
};
|
};
|
||||||
@@ -16,21 +17,27 @@ export const getPricingTableContent = (product: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
case "active":
|
case "active":
|
||||||
|
if (updateable) {
|
||||||
|
return {
|
||||||
|
buttonText: <p>Update Plan</p>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buttonText: <p>Current Plan</p>,
|
buttonText: <p>Current Plan</p>,
|
||||||
};
|
};
|
||||||
|
|
||||||
case "new":
|
case "new":
|
||||||
if (product.properties?.is_one_off) {
|
if (is_one_off) {
|
||||||
return {
|
return {
|
||||||
buttonText: <p>Purchase</p>,
|
buttonText: <p>Purchase</p>,
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
buttonText: <p>Get started</p>,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buttonText: <p>Get started</p>,
|
||||||
|
};
|
||||||
|
|
||||||
case "renew":
|
case "renew":
|
||||||
return {
|
return {
|
||||||
buttonText: <p>Renew</p>,
|
buttonText: <p>Renew</p>,
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ export const isEmptyItem = (item: ProductItem) => {
|
|||||||
|
|
||||||
export const isFeatureItem = (item: ProductItem) => {
|
export const isFeatureItem = (item: ProductItem) => {
|
||||||
return (
|
return (
|
||||||
notNullish(item.feature_id) &&
|
// notNullish(item.feature_id) &&
|
||||||
(nullish(item.price) || item.price == 0) &&
|
(nullish(item.price) || item.price == 0) && nullish(item.tiers)
|
||||||
nullish(item.tiers)
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ function CustomersView({ env }: { env: AppEnv }) {
|
|||||||
const isFirstRender = useRef(true);
|
const isFirstRender = useRef(true);
|
||||||
const paginationFirstRender = useRef(true);
|
const paginationFirstRender = useRef(true);
|
||||||
const searchParamsChanged = useRef(false);
|
const searchParamsChanged = useRef(false);
|
||||||
const hasInitiallyLoaded = useRef(false);
|
|
||||||
const isDirectNavigation = useRef(false);
|
const isDirectNavigation = useRef(false);
|
||||||
|
|
||||||
const resetPagination = () => {
|
const resetPagination = () => {
|
||||||
|
|||||||
@@ -19,112 +19,59 @@ import AttachProduct from "./AttachProduct";
|
|||||||
import SmallSpinner from "@/components/general/SmallSpinner";
|
import SmallSpinner from "@/components/general/SmallSpinner";
|
||||||
import { CustomersTable } from "../customers/CustomersTable";
|
import { CustomersTable } from "../customers/CustomersTable";
|
||||||
import { CustomersContext } from "../customers/CustomersContext";
|
import { CustomersContext } from "../customers/CustomersContext";
|
||||||
|
import { ModelPricing } from "./model-pricing/ModelPricing";
|
||||||
|
import { useListProducts } from "./model-pricing/usePricingTable";
|
||||||
|
import { parseAsString, useQueryStates } from "nuqs";
|
||||||
|
import IntegrateAutumn from "./integrate/IntegrateAutumn";
|
||||||
|
|
||||||
export default function OnboardingView2() {
|
export default function OnboardingView2() {
|
||||||
const env = useEnv();
|
const [queryStates, setQueryStates] = useQueryStates({
|
||||||
|
page: parseAsString.withDefault("integrate"),
|
||||||
const [apiKey, setApiKey] = useState("");
|
|
||||||
const [showIntegrationSteps, setShowIntegrationSteps] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [entityFeatureIds, setEntityFeatureIds] = useState<string[]>([]);
|
|
||||||
const [product, setProduct] = useState<any>(null);
|
|
||||||
const [features, setFeatures] = useState<any[]>([]);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [originalProduct, setOriginalProduct] = useState<any>(null);
|
|
||||||
|
|
||||||
const { data, mutate, isLoading } = useAxiosSWR({
|
|
||||||
url: `/products/data`,
|
|
||||||
env: env,
|
|
||||||
withAuth: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: customersData,
|
products: autumnProducts,
|
||||||
mutate: customersMutate,
|
isLoading: isAutumnLoading,
|
||||||
isLoading: customersIsLoading,
|
error,
|
||||||
} = useAxiosPostSWR({
|
mutate: mutateAutumnProducts,
|
||||||
url: `/v1/customers/all/search`,
|
} = useListProducts();
|
||||||
data: { page_size: 10 },
|
|
||||||
env: env,
|
|
||||||
withAuth: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { data, mutate, isLoading } = useAxiosSWR({ url: `/products/data` });
|
||||||
if (data) {
|
const { data: productCounts } = useAxiosSWR({ url: `/products/counts` });
|
||||||
setFeatures(data.features);
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (isLoading) return <LoadingScreen />;
|
if (isLoading || isAutumnLoading) return <LoadingScreen />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AutumnProvider backendUrl={`${import.meta.env.VITE_BACKEND_URL}/demo`}>
|
<AutumnProvider
|
||||||
<div className="w-full h-full p-10 flex flex-col">
|
backendUrl={`${import.meta.env.VITE_BACKEND_URL}/demo`}
|
||||||
|
includeCredentials={true}
|
||||||
|
>
|
||||||
|
{queryStates.page === "integrate" ? (
|
||||||
|
<IntegrateAutumn />
|
||||||
|
) : (
|
||||||
|
// <div className="w-full h-full p-10 flex flex-col items-center justify-start">
|
||||||
|
// <div className="max-w-[800px] w-full">
|
||||||
|
// <div className="flex flex-col gap-2">
|
||||||
|
// <p className="text-xl">Integrate Autumn</p>
|
||||||
|
// <p className="text-t3">
|
||||||
|
// Let's integrate Autumn and get your first customer onto one of
|
||||||
|
// your plans
|
||||||
|
// </p>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
<ModelPricing
|
||||||
|
data={data}
|
||||||
|
mutate={async () => {
|
||||||
|
await mutate();
|
||||||
|
await mutateAutumnProducts();
|
||||||
|
}}
|
||||||
|
autumnProducts={autumnProducts}
|
||||||
|
productCounts={productCounts}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* <div className="w-full h-full p-10 flex flex-col">
|
||||||
<div className="flex flex-col max-w-[800px] gap-10 pb-[200px]">
|
<div className="flex flex-col max-w-[800px] gap-10 pb-[200px]">
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<p className="text-xl font-bold">Create your plans</p>
|
|
||||||
<div className="text-t3">
|
|
||||||
<p>
|
|
||||||
Create your free and paid plans (eg. Free, Starter, Growth)
|
|
||||||
{/* products for any free plans, paid plans and any add-on or
|
|
||||||
top up products that your application offers. */}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col max-w-[1000px]">
|
|
||||||
<EditProductDialog
|
|
||||||
product={product}
|
|
||||||
setProduct={setProduct}
|
|
||||||
features={features}
|
|
||||||
setFeatures={setFeatures}
|
|
||||||
mutate={mutate}
|
|
||||||
open={open}
|
|
||||||
setOpen={setOpen}
|
|
||||||
originalProduct={originalProduct}
|
|
||||||
entityFeatureIds={entityFeatureIds}
|
|
||||||
setEntityFeatureIds={setEntityFeatureIds}
|
|
||||||
/>
|
|
||||||
<ProductsContext.Provider
|
|
||||||
value={{
|
|
||||||
products: data.products,
|
|
||||||
env,
|
|
||||||
onboarding: true,
|
|
||||||
mutate,
|
|
||||||
entityFeatureIds,
|
|
||||||
setEntityFeatureIds,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PageSectionHeader
|
|
||||||
title="Products"
|
|
||||||
isOnboarding={true}
|
|
||||||
addButton={
|
|
||||||
<>
|
|
||||||
{/* <Button variant="add">Test Data</Button> */}
|
|
||||||
<CreateProduct
|
|
||||||
onSuccess={async (newProduct: ProductV2) => {
|
|
||||||
await mutate();
|
|
||||||
// setProduct(newProduct);
|
|
||||||
// setOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
className="pr-0 border-l"
|
|
||||||
/>
|
|
||||||
<ProductsTable
|
|
||||||
products={data.products}
|
|
||||||
onRowClick={(id) => {
|
|
||||||
const selectedProduct = data.products.find(
|
|
||||||
(p: ProductV2) => p.id === id
|
|
||||||
);
|
|
||||||
setProduct(selectedProduct);
|
|
||||||
setOriginalProduct(
|
|
||||||
JSON.parse(JSON.stringify(selectedProduct))
|
|
||||||
);
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ProductsContext.Provider>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<p className="text-xl font-bold">Integrate Autumn</p>
|
<p className="text-xl font-bold">Integrate Autumn</p>
|
||||||
<p className="text-t3">
|
<p className="text-t3">
|
||||||
@@ -175,15 +122,9 @@ export default function OnboardingView2() {
|
|||||||
<CustomersTable customers={customersData?.customers || []} />
|
<CustomersTable customers={customersData?.customers || []} />
|
||||||
</CustomersContext.Provider>
|
</CustomersContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* <AttachProduct
|
|
||||||
products={data.products}
|
|
||||||
apiKey={apiKey}
|
|
||||||
number={5}
|
|
||||||
/> */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
</AutumnProvider>
|
</AutumnProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
74
vite/src/views/onboarding2/integrate/AITools.tsx
Normal file
74
vite/src/views/onboarding2/integrate/AITools.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { StepHeader } from "./StepHeader";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import CopyButton from "@/components/general/CopyButton";
|
||||||
|
import { ExternalLink, Download } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const AITools = () => {
|
||||||
|
// MCP configuration for Autumn
|
||||||
|
const mcpConfig = {
|
||||||
|
name: "autumn",
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "mcp-remote", "https://docs.useautumn.com/mcp"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Base64 encode the configuration for Cursor's install URL
|
||||||
|
const encodedConfig = btoa(JSON.stringify(mcpConfig));
|
||||||
|
const cursorInstallUrl = `https://cursor.com/install-mcp?name=autumn&config=${encodedConfig}`;
|
||||||
|
|
||||||
|
// Manual JSON configuration for copy-paste
|
||||||
|
const manualConfig = JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
autumn: mcpConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCursorInstall = () => {
|
||||||
|
window.open(cursorInstallUrl, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<StepHeader number={1} title="Add to AI tools" />
|
||||||
|
<p className="text-sm text-t3">
|
||||||
|
If you're using Cursor or Claude Code, you can install our MCP server to
|
||||||
|
use AI to integrate Autumn.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* One-click install for Cursor */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleCursorInstall}
|
||||||
|
variant="outline"
|
||||||
|
className="w-fit text-t2"
|
||||||
|
startIcon={
|
||||||
|
<img src="/cursor.png" className="w-4 h-4 mr-1" alt="Cursor" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open in Cursor
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
"claude mcp add --transport http autumn-docs https://docs.useautumn.com/mcp"
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
"Copied command to clipboard. Paste in your terminal to install."
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className="w-fit text-t2"
|
||||||
|
startIcon={
|
||||||
|
<img src="/claude.png" className="w-4 h-4 mr-1" alt="Cursor" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add to Claude Code
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
vite/src/views/onboarding2/integrate/IntegrateAutumn.tsx
Normal file
66
vite/src/views/onboarding2/integrate/IntegrateAutumn.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { AITools } from "./AITools";
|
||||||
|
import { SelectStack } from "./SelectStack";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { IntegrateContext } from "./IntegrateContext";
|
||||||
|
import { notNullish } from "@/utils/genUtils";
|
||||||
|
import { Install } from "./integration-steps/Install";
|
||||||
|
import { AutumnHandler } from "./integration-steps/AutumnHandler";
|
||||||
|
import { parseAsString, parseAsJson, useQueryStates } from "nuqs";
|
||||||
|
|
||||||
|
export default function IntegrateAutumn() {
|
||||||
|
const [stack, setStack] = useState<{
|
||||||
|
frontend: string;
|
||||||
|
backend: string;
|
||||||
|
auth: string;
|
||||||
|
customerType: string;
|
||||||
|
}>({
|
||||||
|
frontend: "",
|
||||||
|
backend: "",
|
||||||
|
auth: "",
|
||||||
|
customerType: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [queryStates, setQueryStates] = useQueryStates({
|
||||||
|
frontend: parseAsString.withDefault(""),
|
||||||
|
backend: parseAsString.withDefault(""),
|
||||||
|
auth: parseAsString.withDefault(""),
|
||||||
|
customerType: parseAsString.withDefault(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stackSelected = Object.values(stack).every(notNullish);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntegrateContext.Provider value={{ queryStates, setQueryStates }}>
|
||||||
|
<div className="w-full h-full p-10 flex flex-col items-center justify-start">
|
||||||
|
<div className="max-w-[700px] w-full flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-xl">Integrate Autumn</p>
|
||||||
|
<p className="text-t3">
|
||||||
|
Let's integrate Autumn and get your first customer onto one of
|
||||||
|
your plans
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-8 pb-40">
|
||||||
|
<AITools />
|
||||||
|
<SelectStack />
|
||||||
|
{stackSelected && (
|
||||||
|
<>
|
||||||
|
<Install />
|
||||||
|
<AutumnHandler />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* <div className="flex flex-col gap-4">
|
||||||
|
<StepHeader number={2} title="Add your secret key" />
|
||||||
|
<p className="text-md text-t3">
|
||||||
|
Create a .env file in the root of your project and add the following
|
||||||
|
environment variables:
|
||||||
|
</p>
|
||||||
|
<EnvStep />
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IntegrateContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
vite/src/views/onboarding2/integrate/IntegrateContext.tsx
Normal file
15
vite/src/views/onboarding2/integrate/IntegrateContext.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export const IntegrateContext = createContext<any>(null);
|
||||||
|
|
||||||
|
export const useIntegrateContext = () => {
|
||||||
|
const context = useContext(IntegrateContext);
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"useProductContext must be used within a ProductContextProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
183
vite/src/views/onboarding2/integrate/SelectStack.tsx
Normal file
183
vite/src/views/onboarding2/integrate/SelectStack.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { StepHeader } from "./StepHeader";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useIntegrateContext } from "./IntegrateContext";
|
||||||
|
|
||||||
|
export const SelectStack = () => {
|
||||||
|
const { queryStates, setQueryStates } = useIntegrateContext();
|
||||||
|
|
||||||
|
const frontendOptions = [
|
||||||
|
{ value: "nextjs", label: "Next.js" },
|
||||||
|
{ value: "vite", label: "Vite SPA" },
|
||||||
|
{ value: "tanstack", label: "Tanstack Start" },
|
||||||
|
{ value: "non-react", label: "Non-React" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const backendOptions = [
|
||||||
|
{ value: "nextjs", label: "Next.js" },
|
||||||
|
{ value: "react_router", label: "React Router 7" },
|
||||||
|
{ value: "hono", label: "Hono" },
|
||||||
|
{ value: "express", label: "Express" },
|
||||||
|
{ value: "elysia", label: "Elysia" },
|
||||||
|
{ value: "supabase", label: "Supabase" },
|
||||||
|
{ value: "convex", label: "Convex" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const authOptions = [
|
||||||
|
{ value: "better_auth", label: "Better Auth" },
|
||||||
|
{ value: "supabase", label: "Supabase Auth" },
|
||||||
|
{ value: "clerk", label: "Clerk" },
|
||||||
|
{ value: "other", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const customerOptions = [
|
||||||
|
{ value: "user", label: "Users" },
|
||||||
|
{ value: "org", label: "Organizations" },
|
||||||
|
// { value: "other", label: "Other (eg. Projects / Workspaces)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<StepHeader number={2} title="Select your stack" />
|
||||||
|
<p className="text-sm text-t3">
|
||||||
|
Help us customize the integration guide for your specific tech stack.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-[600px]">
|
||||||
|
{/* Frontend Framework */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-t2">Frontend</label>
|
||||||
|
<Select
|
||||||
|
value={queryStates.frontend}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setQueryStates({ ...queryStates, frontend: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select framework" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{frontendOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backend Framework */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-t2">Backend</label>
|
||||||
|
<Select
|
||||||
|
value={queryStates.backend}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setQueryStates({ ...queryStates, backend: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select backend" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{backendOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auth Provider */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-t2">Auth Provider</label>
|
||||||
|
<Select
|
||||||
|
value={queryStates.auth}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setQueryStates({ ...queryStates, auth: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select auth provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{authOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer Type */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-t2">
|
||||||
|
Your customers are
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={queryStates.customerType}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setQueryStates({ ...queryStates, customerType: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select customer type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{customerOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* {(stack.frontend ||
|
||||||
|
stack.backend ||
|
||||||
|
stack.auth ||
|
||||||
|
stack.customerType) && (
|
||||||
|
<div className="mt-4 p-3 bg-zinc-50 dark:bg-zinc-900 rounded-md border border-zinc-200 dark:border-zinc-800">
|
||||||
|
<h4 className="text-sm font-medium text-t2 mb-2">Selected Stack:</h4>
|
||||||
|
<div className="text-xs text-t3 space-y-1">
|
||||||
|
{stack.frontend && (
|
||||||
|
<p>
|
||||||
|
<strong>Frontend:</strong>{" "}
|
||||||
|
{frontendOptions.find((f) => f.value === stack.frontend)?.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{stack.backend && (
|
||||||
|
<p>
|
||||||
|
<strong>Backend:</strong>{" "}
|
||||||
|
{backendOptions.find((b) => b.value === stack.backend)?.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{stack.auth && (
|
||||||
|
<p>
|
||||||
|
<strong>Auth:</strong>{" "}
|
||||||
|
{authOptions.find((a) => a.value === stack.auth)?.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{stack.customerType && (
|
||||||
|
<p>
|
||||||
|
<strong>Customers:</strong>{" "}
|
||||||
|
{
|
||||||
|
customerOptions.find((c) => c.value === stack.customerType)
|
||||||
|
?.label
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
vite/src/views/onboarding2/integrate/StepHeader.tsx
Normal file
16
vite/src/views/onboarding2/integrate/StepHeader.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const StepHeader = ({
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-6 h-6 border-1 rounded-full bg-gradient-to-b from-stone-100 to-stone-100 text-primary font-bold flex items-center justify-center text-sm">
|
||||||
|
{number}
|
||||||
|
</div>
|
||||||
|
<p className="text-md">{title}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import CodeBlock from "@/views/onboarding/components/CodeBlock";
|
||||||
|
import { StepHeader } from "../StepHeader";
|
||||||
|
import { useIntegrateContext } from "../IntegrateContext";
|
||||||
|
|
||||||
|
import {} from "./handlerSnippets";
|
||||||
|
import {
|
||||||
|
nextjsBetterAuthOrg,
|
||||||
|
nextjsBetterAuthUser,
|
||||||
|
nextjsClerkOrg,
|
||||||
|
nextjsClerkUser,
|
||||||
|
nextjsOther,
|
||||||
|
nextjsSupabaseOrg,
|
||||||
|
nextjsSupabaseUser,
|
||||||
|
} from "./snippets/nextjsHandler";
|
||||||
|
import { rr7BetterAuth, rr7Clerk, rr7Supabase } from "./snippets/rr7Handler";
|
||||||
|
import {
|
||||||
|
honoBetterAuth,
|
||||||
|
honoClerk,
|
||||||
|
honoSupabase,
|
||||||
|
} from "./snippets/honoHandler";
|
||||||
|
import {
|
||||||
|
expressBetterAuth,
|
||||||
|
expressClerk,
|
||||||
|
expressSupabase,
|
||||||
|
} from "./snippets/expressHandler";
|
||||||
|
import { elysiaBetterAuth } from "./snippets/elysiaHandler";
|
||||||
|
|
||||||
|
const snippet = () => {
|
||||||
|
return {
|
||||||
|
nextjs: {
|
||||||
|
["better_auth"]: {
|
||||||
|
user: nextjsBetterAuthUser,
|
||||||
|
org: nextjsBetterAuthOrg,
|
||||||
|
},
|
||||||
|
["supabase"]: {
|
||||||
|
user: nextjsSupabaseUser,
|
||||||
|
org: nextjsSupabaseOrg,
|
||||||
|
},
|
||||||
|
["clerk"]: {
|
||||||
|
user: nextjsClerkUser,
|
||||||
|
org: nextjsClerkOrg,
|
||||||
|
},
|
||||||
|
["other"]: {
|
||||||
|
user: nextjsOther,
|
||||||
|
org: nextjsOther,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
["react_router"]: {
|
||||||
|
["better_auth"]: {
|
||||||
|
user: rr7BetterAuth("user"),
|
||||||
|
org: rr7BetterAuth("org"),
|
||||||
|
},
|
||||||
|
["supabase"]: {
|
||||||
|
user: rr7Supabase("user"),
|
||||||
|
org: rr7Supabase("org"),
|
||||||
|
},
|
||||||
|
["clerk"]: {
|
||||||
|
user: rr7Clerk("user"),
|
||||||
|
org: rr7Clerk("org"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
["hono"]: {
|
||||||
|
["clerk"]: {
|
||||||
|
user: honoClerk("user"),
|
||||||
|
org: honoClerk("org"),
|
||||||
|
},
|
||||||
|
["supabase"]: {
|
||||||
|
user: honoSupabase("user"),
|
||||||
|
org: honoSupabase("org"),
|
||||||
|
},
|
||||||
|
["better_auth"]: {
|
||||||
|
user: honoBetterAuth("user"),
|
||||||
|
org: honoBetterAuth("org"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
["express"]: {
|
||||||
|
["better_auth"]: {
|
||||||
|
user: expressBetterAuth("user"),
|
||||||
|
org: expressBetterAuth("org"),
|
||||||
|
},
|
||||||
|
["clerk"]: {
|
||||||
|
user: expressClerk("user"),
|
||||||
|
org: expressClerk("org"),
|
||||||
|
},
|
||||||
|
["supabase"]: {
|
||||||
|
user: expressSupabase("user"),
|
||||||
|
org: expressSupabase("org"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
["elysia"]: {
|
||||||
|
["better_auth"]: {
|
||||||
|
user: elysiaBetterAuth("user"),
|
||||||
|
org: elysiaBetterAuth("org"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AutumnHandler = () => {
|
||||||
|
const { queryStates } = useIntegrateContext();
|
||||||
|
|
||||||
|
const getSnippetContent = () => {
|
||||||
|
const backendLang = queryStates.backend;
|
||||||
|
const authProvider = queryStates.auth;
|
||||||
|
const customerType = queryStates.customerType;
|
||||||
|
|
||||||
|
const templates = snippet();
|
||||||
|
|
||||||
|
console.log("Backend Lang", backendLang);
|
||||||
|
console.log("Auth Provider", authProvider);
|
||||||
|
|
||||||
|
const template =
|
||||||
|
templates[backendLang]?.[authProvider]?.[customerType] || "";
|
||||||
|
|
||||||
|
console.log("Template", template);
|
||||||
|
|
||||||
|
// Trim \n from the template (only left and right)
|
||||||
|
return template.trim("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<StepHeader number={3} title="Install autumn-js" />
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
snippets={[
|
||||||
|
{
|
||||||
|
title: "Backend",
|
||||||
|
language: "typescript",
|
||||||
|
displayLanguage: "typescript",
|
||||||
|
content: getSnippetContent(),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import CodeBlock from "@/views/onboarding/components/CodeBlock";
|
||||||
|
import { StepHeader } from "../StepHeader";
|
||||||
|
|
||||||
|
const installCode = `npm install autumn-js`;
|
||||||
|
const installCodePnpm = `pnpm add autumn-js`;
|
||||||
|
const installCodeYarn = `yarn add autumn-js`;
|
||||||
|
const installCodeBun = `bun add autumn-js`;
|
||||||
|
|
||||||
|
export const Install = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<StepHeader number={3} title="Install autumn-js" />
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
snippets={[
|
||||||
|
{
|
||||||
|
title: "npm",
|
||||||
|
language: "bash",
|
||||||
|
displayLanguage: "bash",
|
||||||
|
content: installCode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "pnpm",
|
||||||
|
language: "bash",
|
||||||
|
displayLanguage: "bash",
|
||||||
|
content: installCodePnpm,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "yarn",
|
||||||
|
language: "bash",
|
||||||
|
displayLanguage: "bash",
|
||||||
|
content: installCodeYarn,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "bun",
|
||||||
|
language: "bash",
|
||||||
|
displayLanguage: "bash",
|
||||||
|
content: installCodeBun,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export const nextjsBetterAuthUser = `
|
||||||
|
// app/api/autumn/[...all]/route.ts
|
||||||
|
|
||||||
|
import { autumnHandler } from "autumn-js/next";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export const { GET, POST } = autumnHandler({
|
||||||
|
identify: async () => {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: session?.user.id,
|
||||||
|
customerData: {
|
||||||
|
name: session?.user.name,
|
||||||
|
email: session?.user.email,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const nextjsBetterAuthOrg = `
|
||||||
|
// app/api/autumn/[...all]/route.ts
|
||||||
|
|
||||||
|
import { autumnHandler } from "autumn-js/next";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export const { GET, POST } = autumnHandler({
|
||||||
|
identify: async () => {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: session?.session.activeOrganizationId,
|
||||||
|
customerData: {
|
||||||
|
name: session?.user.name,
|
||||||
|
email: session?.user.email,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const nextjsBetterAuthOther = `
|
||||||
|
// app/api/autumn/[...all]/route.ts
|
||||||
|
|
||||||
|
import { autumnHandler } from "autumn-js/next";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export const { GET, POST } = autumnHandler({
|
||||||
|
identify: async () => {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// From the request, retrieve the ID you want to use as the customer ID
|
||||||
|
const customerId = "customer_id";
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId,
|
||||||
|
customerData: {
|
||||||
|
name: session?.user.name,
|
||||||
|
email: session?.user.email,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export const betterAuthSnippet = (
|
||||||
|
customerType: "user" | "org",
|
||||||
|
headersString: string,
|
||||||
|
tabLevel: number = 0
|
||||||
|
) => {
|
||||||
|
const tabs = " ".repeat(tabLevel);
|
||||||
|
|
||||||
|
let snippet = ``;
|
||||||
|
if (customerType === "user") {
|
||||||
|
snippet = `${tabs}const session = await auth.api.getSession({
|
||||||
|
${tabs} headers: ${headersString},
|
||||||
|
${tabs}});
|
||||||
|
${tabs}
|
||||||
|
${tabs}return {
|
||||||
|
${tabs} customerId: session?.user.id,
|
||||||
|
${tabs} customerData: {
|
||||||
|
${tabs} name: session?.user.name,
|
||||||
|
${tabs} email: session?.user.email,
|
||||||
|
${tabs} },
|
||||||
|
${tabs}};`;
|
||||||
|
} else {
|
||||||
|
snippet = `${tabs}const session = await auth.api.getSession({
|
||||||
|
${tabs} headers: ${headersString},
|
||||||
|
${tabs}});
|
||||||
|
${tabs}
|
||||||
|
${tabs}return {
|
||||||
|
${tabs} customerId: session?.session.activeOrganizationId,
|
||||||
|
${tabs} customerData: { name: "", email: "" }
|
||||||
|
${tabs}};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snippet;
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { betterAuthSnippet } from "./betterAuth";
|
||||||
|
|
||||||
|
export const elysiaBetterAuth = (customerType: "user" | "org") => {
|
||||||
|
return `import { autumnHandler } from "autumn-js/elysia";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
|
||||||
|
const app = new Elysia({ adapter: node() })
|
||||||
|
.use(cors())
|
||||||
|
.mount(auth.handler)
|
||||||
|
.use(
|
||||||
|
autumnHandler({
|
||||||
|
identify: async (context) => {
|
||||||
|
${betterAuthSnippet(customerType, "context.headers", 4)}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.listen(8000);
|
||||||
|
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// export const elysiaOther = (customerType: "user" | "org") => {
|
||||||
|
// return `
|
||||||
|
|
||||||
|
// `;
|
||||||
|
// };
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { betterAuthSnippet } from "./betterAuth";
|
||||||
|
import { clerkSnippet, supabaseSnippet } from "./honoHandler";
|
||||||
|
|
||||||
|
export const expressBetterAuth = (customerType: "user" | "org") => {
|
||||||
|
return `import { autumnHandler } from "autumn-js/express";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
app.use(express.json()); // need to parse request body before autumnHandler
|
||||||
|
app.use(
|
||||||
|
"/api/autumn",
|
||||||
|
autumnHandler({
|
||||||
|
identify: async (req) => {
|
||||||
|
${betterAuthSnippet(customerType, "req.headers", 3)}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expressClerk = (customerType: "user" | "org") => {
|
||||||
|
return `import { autumnHandler } from "autumn-js/express";
|
||||||
|
import { clerkMiddleware, getAuth } from "@clerk/express";
|
||||||
|
|
||||||
|
app.use(express.json()); // need to parse request body before autumnHandler
|
||||||
|
app.use(clerkMiddleware());
|
||||||
|
app.use(
|
||||||
|
"/api/autumn",
|
||||||
|
autumnHandler({
|
||||||
|
identify: async (req) => {
|
||||||
|
${clerkSnippet(customerType, "req")}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expressSupabase = (customerType: "user" | "org") => {
|
||||||
|
return `import { autumnHandler } from "autumn-js/express";
|
||||||
|
import { createClient } from "./lib/supabase";
|
||||||
|
|
||||||
|
app.use(express.json()); // need to parse request body before autumnHandler
|
||||||
|
app.use(
|
||||||
|
"/api/autumn",
|
||||||
|
autumnHandler({
|
||||||
|
identify: async (req, res) => {
|
||||||
|
${supabaseSnippet(customerType, "createClient({ req, res })")}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { betterAuthSnippet } from "./betterAuth";
|
||||||
|
|
||||||
|
export const clerkSnippet = (
|
||||||
|
customerType: "user" | "org",
|
||||||
|
reqParam: string = "c"
|
||||||
|
) => {
|
||||||
|
if (customerType === "user") {
|
||||||
|
return `const auth = getAuth(${reqParam});
|
||||||
|
|
||||||
|
if (!auth?.userId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: auth.userId,
|
||||||
|
customerData: { name: "", email: "" },
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `const auth = getAuth(${reqParam});
|
||||||
|
|
||||||
|
if (!auth?.userId || !auth?.orgId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: auth.orgId,
|
||||||
|
customerData: { name: "", email: "" },
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const honoClerk = (customerType: "user" | "org") => {
|
||||||
|
return `import { autumnHandler } from "autumn-js/hono";
|
||||||
|
import { clerkMiddleware, getAuth } from "@hono/clerk-auth";
|
||||||
|
|
||||||
|
app.use("*", clerkMiddleware());
|
||||||
|
app.use(
|
||||||
|
"/api/autumn/*",
|
||||||
|
autumnHandler({
|
||||||
|
identify: async (c: Context) => {
|
||||||
|
${clerkSnippet(customerType)}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const supabaseSnippet = (
|
||||||
|
customerType: "user" | "org",
|
||||||
|
supabaseInit: string = "getSupabase(c)"
|
||||||
|
) => {
|
||||||
|
if (customerType === "user") {
|
||||||
|
return `const supabase = ${supabaseInit};
|
||||||
|
|
||||||
|
const { data, error } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!data?.user?.id) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: data.user.id,
|
||||||
|
customerData: { name: "", email: "" },
|
||||||
|
};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `const supabase = ${supabaseInit};
|
||||||
|
|
||||||
|
const { data, error } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!data?.user?.id) return null;
|
||||||
|
|
||||||
|
const orgId = "users_org_id"; // Get the orgId from your DB
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: orgId,
|
||||||
|
customerData: { name: "", email: "" },
|
||||||
|
};`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const honoSupabase = (customerType: "user" | "org") => {
|
||||||
|
return `// index.ts
|
||||||
|
|
||||||
|
import { autumnHandler } from "autumn-js/hono";
|
||||||
|
import { getSupabase, supabaseMiddleware } from "./middleware/auth.middleware.js";
|
||||||
|
|
||||||
|
app.use("*", supabaseMiddleware());
|
||||||
|
app.use(
|
||||||
|
"/api/autumn/*",
|
||||||
|
autumnHandler({
|
||||||
|
identify: async (c: Context) => {
|
||||||
|
${supabaseSnippet(customerType)}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);`;
|
||||||
|
};
|
||||||
|
export const honoBetterAuth = (customerType: "user" | "org") => {
|
||||||
|
return `// index.ts
|
||||||
|
|
||||||
|
import { autumnHandler } from "autumn-js/hono";
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
"/api/autumn/*",
|
||||||
|
autumnHandler({
|
||||||
|
identify: async (c: Context) => {
|
||||||
|
${betterAuthSnippet(customerType, "c.req.raw.headers", 3)}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
export const nextjsBetterAuthUser = `
|
||||||
|
// app/api/autumn/[...all]/route.ts
|
||||||
|
|
||||||
|
import { autumnHandler } from "autumn-js/next";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export const { GET, POST } = autumnHandler({
|
||||||
|
identify: async () => {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: session?.user.id,
|
||||||
|
customerData: {
|
||||||
|
name: session?.user.name,
|
||||||
|
email: session?.user.email,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const nextjsBetterAuthOrg = `
|
||||||
|
// app/api/autumn/[...all]/route.ts
|
||||||
|
|
||||||
|
import { autumnHandler } from "autumn-js/next";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export const { GET, POST } = autumnHandler({
|
||||||
|
identify: async () => {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: session?.session.activeOrganizationId,
|
||||||
|
customerData: {
|
||||||
|
name: session?.user.name,
|
||||||
|
email: session?.user.email,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Supabase Auth
|
||||||
|
export const nextjsSupabaseUser = `
|
||||||
|
// app/api/autumn/[...all]/route.ts
|
||||||
|
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
import { autumnHandler } from "autumn-js/next";
|
||||||
|
|
||||||
|
export const { GET, POST } = autumnHandler({
|
||||||
|
identify: async () => {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data, error } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (error || !data?.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: data.user.id,
|
||||||
|
customerData: {
|
||||||
|
name: data.user.user_metadata?.name,
|
||||||
|
email: data.user.email,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});`;
|
||||||
|
|
||||||
|
export const nextjsSupabaseOrg = `
|
||||||
|
// app/api/autumn/[...all]/route.ts
|
||||||
|
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
import { autumnHandler } from "autumn-js/next";
|
||||||
|
|
||||||
|
export const { GET, POST } = autumnHandler({
|
||||||
|
identify: async () => {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data, error } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (error || !data?.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the orgId of the user from your DB
|
||||||
|
const customerId = "users_org_id";
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId,
|
||||||
|
customerData: {
|
||||||
|
name: data.user.user_metadata?.name,
|
||||||
|
email: data.user.email,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const nextjsClerkUser = `
|
||||||
|
// app/api/autumn/[...all]/route.ts
|
||||||
|
|
||||||
|
import { autumnHandler } from "autumn-js/next";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
|
||||||
|
export const { GET, POST } = autumnHandler({
|
||||||
|
identify: async () => {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: userId,
|
||||||
|
// To store the customer name and email
|
||||||
|
customerData: { name: "", email: "" },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const nextjsClerkOrg = `
|
||||||
|
// app/api/autumn/[...all]/route.ts
|
||||||
|
|
||||||
|
import { autumnHandler } from "autumn-js/next";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
|
||||||
|
export const { GET, POST } = autumnHandler({
|
||||||
|
identify: async () => {
|
||||||
|
const { userId, orgId } = await auth();
|
||||||
|
|
||||||
|
if (!userId || !orgId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: orgId,
|
||||||
|
// To store the customer name and email
|
||||||
|
customerData: { name: "", email: "" },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const nextjsOther = `
|
||||||
|
// app/api/autumn/[...all]/route.ts
|
||||||
|
|
||||||
|
import { autumnHandler } from "autumn-js/next";
|
||||||
|
|
||||||
|
export const { GET, POST } = autumnHandler({
|
||||||
|
identify: async (request) => {
|
||||||
|
|
||||||
|
// Authenticate the request and get the customer ID
|
||||||
|
const customerId = "customer_id";
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId,
|
||||||
|
// To store the customer name and email
|
||||||
|
customerData: { name: "", email: "" },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { betterAuthSnippet } from "./betterAuth";
|
||||||
|
import { supabaseAuthSnippet } from "./supabaseAuth";
|
||||||
|
|
||||||
|
export const rr7BetterAuth = (customerType: "user" | "org") => {
|
||||||
|
return `// app/routes/api.autumn.tsx
|
||||||
|
|
||||||
|
import { autumnHandler } from "autumn-js/react-router";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
const handler = autumnHandler({
|
||||||
|
secretKey: process.env.AUTUMN_SECRET_KEY!,
|
||||||
|
identify: async (args) => {
|
||||||
|
${betterAuthSnippet(customerType, "args.request.headers", 2)}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loader = handler.loader;
|
||||||
|
export const action = handler.action;`;
|
||||||
|
};
|
||||||
|
export const rr7Supabase = (customerType: "user" | "org") => {
|
||||||
|
return `// app/routes/api.autumn.tsx
|
||||||
|
|
||||||
|
import { autumnHandler } from "autumn-js/react-router";
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
|
||||||
|
const handler = autumnHandler({
|
||||||
|
secretKey: process.env.AUTUMN_SECRET_KEY!,
|
||||||
|
identify: async (args) => {
|
||||||
|
${supabaseAuthSnippet({ customerType })}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loader = handler.loader;
|
||||||
|
export const action = handler.action;`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clerkSnippet = (customerType: "user" | "org") => {
|
||||||
|
if (customerType === "user") {
|
||||||
|
return `const { userId } = await getAuth(args);
|
||||||
|
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: userId,
|
||||||
|
customerData: { name: "", email: "" },
|
||||||
|
};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `const { userId, orgId } = await getAuth(args);
|
||||||
|
|
||||||
|
if (!userId || !orgId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: orgId,
|
||||||
|
customerData: { name: "", email: "" },
|
||||||
|
};`;
|
||||||
|
};
|
||||||
|
export const rr7Clerk = (customerType: "user" | "org") => {
|
||||||
|
return `// app/routes/api.autumn.tsx
|
||||||
|
|
||||||
|
import { autumnHandler } from "autumn-js/react-router";
|
||||||
|
import { getAuth } from "@clerk/react-router/ssr.server";
|
||||||
|
|
||||||
|
const handler = autumnHandler({
|
||||||
|
secretKey: process.env.AUTUMN_SECRET_KEY!,
|
||||||
|
identify: async (args) => {
|
||||||
|
${clerkSnippet(customerType)}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loader = handler.loader;
|
||||||
|
export const action = handler.action;`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export const supabaseAuthSnippet = ({
|
||||||
|
backendLang,
|
||||||
|
customerType,
|
||||||
|
}: {
|
||||||
|
backendLang?: string;
|
||||||
|
customerType: "user" | "org";
|
||||||
|
}) => {
|
||||||
|
if (customerType === "user") {
|
||||||
|
return `const supabase = await createClient(request);
|
||||||
|
const { data, error } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (error || !data?.user) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: "123",
|
||||||
|
customerData: {
|
||||||
|
name: data.user.user_metadata?.name,
|
||||||
|
email: data.user.email,
|
||||||
|
},
|
||||||
|
};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `const supabase = await createClient(request);
|
||||||
|
const { data, error } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (error || !data?.user) return null;
|
||||||
|
|
||||||
|
// Get the orgId from your DB
|
||||||
|
const orgId = "users_org_id";
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: orgId,
|
||||||
|
customerData: { name: "", email: "" },
|
||||||
|
};`;
|
||||||
|
};
|
||||||
64
vite/src/views/onboarding2/model-pricing/AddTrialButton.tsx
Normal file
64
vite/src/views/onboarding2/model-pricing/AddTrialButton.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CreateFreeTrial } from "@/views/products/product/free-trial/CreateFreeTrial";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useProductContext } from "@/views/products/product/ProductContext";
|
||||||
|
import { PlusIcon, Trash, X } from "lucide-react";
|
||||||
|
import { handleAutoSave } from "./model-pricing-utils/modelPricingUtils";
|
||||||
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
|
|
||||||
|
export const AddTrialButton = () => {
|
||||||
|
const { product, setProduct, mutate } = useProductContext();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const axiosInstance = useAxiosInstance();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreateFreeTrial open={open} setOpen={setOpen} />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
// className={`w-32 flex items-center gap-2`}
|
||||||
|
className="justify-start w-fit p-0 hover:bg-transparent text-t2 font-medium hover:text-t1"
|
||||||
|
>
|
||||||
|
{product?.free_trial ? (
|
||||||
|
<div className="flex items-center gap-2 justify-between w-full">
|
||||||
|
<p className="text-lime-600">
|
||||||
|
{product?.free_trial?.length} {product.free_trial?.duration} trial
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
isIcon
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setProduct({
|
||||||
|
...product,
|
||||||
|
free_trial: null,
|
||||||
|
});
|
||||||
|
handleAutoSave({
|
||||||
|
axiosInstance,
|
||||||
|
productId: product.id,
|
||||||
|
product: { ...product, free_trial: null },
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="hover:bg-zinc-300 !h-4 !w-4 text-t3 mt-0.5"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
Add Free Trial
|
||||||
|
<PlusIcon size={16} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
215
vite/src/views/onboarding2/model-pricing/EditProduct.tsx
Normal file
215
vite/src/views/onboarding2/model-pricing/EditProduct.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import FieldLabel from "@/components/general/modal-components/FieldLabel";
|
||||||
|
import { ToggleButton } from "@/components/general/ToggleButton";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { slugify } from "@/utils/formatUtils/formatTextUtils";
|
||||||
|
import { FeaturesContext } from "@/views/features/FeaturesContext";
|
||||||
|
import { CreateFreeTrial } from "@/views/products/product/free-trial/CreateFreeTrial";
|
||||||
|
import { CreateProductItem2 } from "@/views/products/product/product-item/CreateProductItem2";
|
||||||
|
import { ProductItemTable } from "@/views/products/product/product-item/ProductItemTable";
|
||||||
|
import { ProductContext } from "@/views/products/product/ProductContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AddTrialButton } from "./AddTrialButton";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useEnv } from "@/utils/envUtils";
|
||||||
|
import { handleAutoSave } from "./model-pricing-utils/modelPricingUtils";
|
||||||
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
|
import { useModelPricingContext } from "./ModelPricingContext";
|
||||||
|
import { ProductRowToolbar } from "@/views/products/components/ProductRowToolbar";
|
||||||
|
|
||||||
|
export const EditProduct = ({
|
||||||
|
data,
|
||||||
|
mutate,
|
||||||
|
product,
|
||||||
|
setProduct,
|
||||||
|
}: {
|
||||||
|
data: any;
|
||||||
|
mutate: any;
|
||||||
|
product: any;
|
||||||
|
setProduct: any;
|
||||||
|
}) => {
|
||||||
|
const [details, setDetails] = useState({
|
||||||
|
name: product.name,
|
||||||
|
id: product.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [freeTrialModalOpen, setFreeTrialModalOpen] = useState(false);
|
||||||
|
const [entityFeatureIds, setEntityFeatureIds] = useState<string[]>([]);
|
||||||
|
const [features, setFeatures] = useState<any[]>([]);
|
||||||
|
const { productCounts, editingNewProduct, setEditingNewProduct } =
|
||||||
|
useModelPricingContext();
|
||||||
|
const axiosInstance = useAxiosInstance();
|
||||||
|
const env = useEnv();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setFeatures(data.features);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const hasItems = product.items.length > 0;
|
||||||
|
|
||||||
|
const handleToggleSettings = async (key: string) => {
|
||||||
|
const curValue = product[key];
|
||||||
|
const newProduct = { ...product, [key]: !curValue };
|
||||||
|
|
||||||
|
setProduct(newProduct);
|
||||||
|
|
||||||
|
handleAutoSave({
|
||||||
|
axiosInstance,
|
||||||
|
productId: product.id ? product.id : details.id,
|
||||||
|
product: { ...product, [key]: !curValue },
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 justify-between h-full">
|
||||||
|
<div className="flex gap-4 transition-all duration-500 ease-in-out">
|
||||||
|
<FeaturesContext.Provider
|
||||||
|
value={{
|
||||||
|
env,
|
||||||
|
mutate,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProductContext.Provider
|
||||||
|
value={{
|
||||||
|
product,
|
||||||
|
setProduct,
|
||||||
|
mutate,
|
||||||
|
env,
|
||||||
|
features,
|
||||||
|
setFeatures,
|
||||||
|
entityFeatureIds,
|
||||||
|
setEntityFeatureIds,
|
||||||
|
isOnboarding: true,
|
||||||
|
autoSave: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex flex-col gap-4 transition-all duration-500 ease-in-out ${
|
||||||
|
hasItems ? "w-3/5" : "w-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 items-end justify-between">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div>
|
||||||
|
<FieldLabel className="text-t2 font-medium">
|
||||||
|
Name
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
onBlur={async () => {
|
||||||
|
await handleAutoSave({
|
||||||
|
axiosInstance,
|
||||||
|
productId: product.id ? product.id : details.id,
|
||||||
|
product: {
|
||||||
|
...product,
|
||||||
|
name: details.name,
|
||||||
|
id: details.id,
|
||||||
|
},
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
setProduct({
|
||||||
|
...product,
|
||||||
|
name: details.name,
|
||||||
|
id: details.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="Free Plan"
|
||||||
|
value={details.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const curProduct = data?.products.find(
|
||||||
|
(p: any) => p.id === details.id
|
||||||
|
);
|
||||||
|
console.log("Cur product:", curProduct);
|
||||||
|
const newIdData = editingNewProduct
|
||||||
|
? {
|
||||||
|
id: slugify(e.target.value),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
setDetails({
|
||||||
|
...details,
|
||||||
|
name: e.target.value,
|
||||||
|
...newIdData,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel className="text-t2 font-medium">ID</FieldLabel>
|
||||||
|
<Input
|
||||||
|
value={details.id}
|
||||||
|
disabled={true}
|
||||||
|
placeholder="free_plan"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center h-6">
|
||||||
|
<ProductRowToolbar
|
||||||
|
product={product}
|
||||||
|
productCounts={productCounts}
|
||||||
|
isOnboarding={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CreateFreeTrial
|
||||||
|
open={freeTrialModalOpen}
|
||||||
|
setOpen={setFreeTrialModalOpen}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`bg-white border border-zinc-200 transition-all duration-500 ease-in-out ${
|
||||||
|
hasItems ? "w-full" : "w-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ProductItemTable />
|
||||||
|
</div>
|
||||||
|
<CreateProductItem2 />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-500 ease-in-out ${
|
||||||
|
hasItems
|
||||||
|
? "w-2/5 opacity-100 translate-x-0 ml-4"
|
||||||
|
: "w-0 opacity-0 translate-x-8 overflow-hidden"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4" style={{ width: "320px" }}>
|
||||||
|
<div>
|
||||||
|
<ToggleButton
|
||||||
|
disabled={product?.is_add_on}
|
||||||
|
buttonText="Default Product"
|
||||||
|
value={product?.is_default}
|
||||||
|
className="text-t2 font-medium h-fit mb-2"
|
||||||
|
setValue={() => handleToggleSettings("is_default")}
|
||||||
|
/>
|
||||||
|
<div className="text-t3 text-sm" style={{ width: "320px" }}>
|
||||||
|
A default product is enabled by default for all new users,
|
||||||
|
typically used for your free plan.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="">
|
||||||
|
<ToggleButton
|
||||||
|
disabled={product?.is_default}
|
||||||
|
buttonText="Add-on Product"
|
||||||
|
className="text-t2 font-medium h-fit mb-2"
|
||||||
|
value={product?.is_add_on}
|
||||||
|
setValue={() => handleToggleSettings("is_add_on")}
|
||||||
|
/>
|
||||||
|
<div className="text-t3 text-sm" style={{ width: "320px" }}>
|
||||||
|
A default product is enabled by default for all new users,
|
||||||
|
typically used for your free plan.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<AddTrialButton />
|
||||||
|
<div className="text-t3 text-sm" style={{ width: "320px" }}>
|
||||||
|
Add a free trial to your product.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProductContext.Provider>
|
||||||
|
</FeaturesContext.Provider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
192
vite/src/views/onboarding2/model-pricing/EditProductOld.tsx
Normal file
192
vite/src/views/onboarding2/model-pricing/EditProductOld.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { ProductService } from "@/services/products/ProductService";
|
||||||
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
|
import { useEnv } from "@/utils/envUtils";
|
||||||
|
import { getBackendErr } from "@/utils/genUtils";
|
||||||
|
import { FeaturesContext } from "@/views/features/FeaturesContext";
|
||||||
|
import { CreateFreeTrial } from "@/views/products/product/free-trial/CreateFreeTrial";
|
||||||
|
import { ManageProduct } from "@/views/products/product/ManageProduct";
|
||||||
|
import { ProductContext } from "@/views/products/product/ProductContext";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip } from "@/components/ui/tooltip";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ToggleButton } from "@/components/general/ToggleButton";
|
||||||
|
|
||||||
|
export const EditProductDialog = ({
|
||||||
|
product,
|
||||||
|
features,
|
||||||
|
setProduct,
|
||||||
|
setFeatures,
|
||||||
|
mutate,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
originalProduct,
|
||||||
|
entityFeatureIds,
|
||||||
|
setEntityFeatureIds,
|
||||||
|
}: {
|
||||||
|
product: any;
|
||||||
|
setProduct: (product: any) => void;
|
||||||
|
features: any[];
|
||||||
|
setFeatures: (features: any[]) => void;
|
||||||
|
mutate: () => Promise<void>;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
originalProduct: any;
|
||||||
|
entityFeatureIds: string[];
|
||||||
|
setEntityFeatureIds: (entityFeatureIds: string[]) => void;
|
||||||
|
}) => {
|
||||||
|
const env = useEnv();
|
||||||
|
const axiosInstance = useAxiosInstance();
|
||||||
|
const [createProductLoading, setCreateProductLoading] = useState(false);
|
||||||
|
const [freeTrialModalOpen, setFreeTrialModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Store the original product state when modal opens
|
||||||
|
const handleOpenChange = async (newOpen: boolean) => {
|
||||||
|
if (!newOpen && open && product?.id) {
|
||||||
|
// Modal is being closed, check if there are changes
|
||||||
|
const hasChanges =
|
||||||
|
originalProduct &&
|
||||||
|
JSON.stringify(product) !== JSON.stringify(originalProduct);
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
// Only update if there are changes
|
||||||
|
updateProduct();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOpen(newOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProduct = async () => {
|
||||||
|
setCreateProductLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await ProductService.updateProduct(
|
||||||
|
axiosInstance,
|
||||||
|
product.id,
|
||||||
|
product
|
||||||
|
);
|
||||||
|
toast.success("Product updated successfully");
|
||||||
|
await mutate();
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getBackendErr(error, "Failed to update product"));
|
||||||
|
}
|
||||||
|
setCreateProductLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFreeTrialClick = () => {
|
||||||
|
if (product?.free_trial) {
|
||||||
|
// Delete the free trial
|
||||||
|
setProduct({ ...product, free_trial: null });
|
||||||
|
} else {
|
||||||
|
// Open the free trial modal
|
||||||
|
setFreeTrialModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="p-0 py-8 min-w-[500px] min-h-[300px] flex flex-col justify-between">
|
||||||
|
<DialogTitle className="text-t2 font-semibold px-10 hidden">
|
||||||
|
{/* Edit Product */}
|
||||||
|
</DialogTitle>
|
||||||
|
<div>
|
||||||
|
<FeaturesContext.Provider
|
||||||
|
value={{
|
||||||
|
env,
|
||||||
|
mutate,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProductContext.Provider
|
||||||
|
value={{
|
||||||
|
product,
|
||||||
|
setProduct,
|
||||||
|
mutate,
|
||||||
|
env,
|
||||||
|
features,
|
||||||
|
setFeatures,
|
||||||
|
entityFeatureIds,
|
||||||
|
setEntityFeatureIds,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CreateFreeTrial
|
||||||
|
open={freeTrialModalOpen}
|
||||||
|
setOpen={setFreeTrialModalOpen}
|
||||||
|
/>
|
||||||
|
<ManageProduct hideAdminHover={true} />
|
||||||
|
</ProductContext.Provider>
|
||||||
|
</FeaturesContext.Provider>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex justify-between items-center gap-2 px-10 w-full mt-6">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<ToggleButton
|
||||||
|
disabled={product?.is_add_on}
|
||||||
|
buttonText="Default"
|
||||||
|
infoContent="This product is enabled by default for all new users, typically used for your free plan"
|
||||||
|
value={product?.is_default}
|
||||||
|
setValue={() =>
|
||||||
|
setProduct({
|
||||||
|
...product,
|
||||||
|
is_default: !product?.is_default,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ToggleButton
|
||||||
|
disabled={product?.is_default}
|
||||||
|
buttonText="Add-on"
|
||||||
|
infoContent="This product is an add-on that can be bought together with your base products (eg, for top ups)"
|
||||||
|
value={product?.is_add_on}
|
||||||
|
setValue={() =>
|
||||||
|
setProduct({ ...product, is_add_on: !product?.is_add_on })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleFreeTrialClick}
|
||||||
|
className={`min-w-32 flex items-center gap-2`}
|
||||||
|
>
|
||||||
|
{product?.free_trial ? (
|
||||||
|
<div className="flex items-center gap-2 justify-between w-full">
|
||||||
|
<p>
|
||||||
|
{product?.free_trial?.length}{" "}
|
||||||
|
{product.free_trial?.duration} trial
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
isIcon
|
||||||
|
onClick={() => {
|
||||||
|
setProduct({ ...product, free_trial: null });
|
||||||
|
}}
|
||||||
|
className="hover:bg-zinc-300 !h-4 !w-4 text-t3"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// <div className="w-3 h-3 bg-lime-500 rounded-full flex items-center justify-center">
|
||||||
|
|
||||||
|
// {/* <Check className="w-2 h-2 text-white" /> */}
|
||||||
|
// </div>
|
||||||
|
<p>Add Free Trial</p>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
isLoading={createProductLoading}
|
||||||
|
variant="gradientPrimary"
|
||||||
|
onClick={updateProduct}
|
||||||
|
className="min-w-44 w-44 max-w-44"
|
||||||
|
>
|
||||||
|
Update Product
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
259
vite/src/views/onboarding2/model-pricing/ModelPricing.tsx
Normal file
259
vite/src/views/onboarding2/model-pricing/ModelPricing.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import PricingTable from "@/components/autumn/pricing-table";
|
||||||
|
import { EditProduct } from "./EditProduct";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
getProductItemResponse,
|
||||||
|
ProductProperties,
|
||||||
|
ProductV2,
|
||||||
|
sortProductsV2,
|
||||||
|
UsageModel,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import { getBackendErr, notNullish, nullish } from "@/utils/genUtils";
|
||||||
|
import { isFreeProduct, isOneOffProduct } from "@/utils/product/priceUtils";
|
||||||
|
import { Product } from "autumn-js";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import { Tabs, TabsTrigger, TabsList } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
ModelPricingContext,
|
||||||
|
useModelPricingContext,
|
||||||
|
} from "./ModelPricingContext";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { slugify } from "@/utils/formatUtils/formatTextUtils";
|
||||||
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ProductsContext } from "@/views/products/ProductsContext";
|
||||||
|
import { SelectEditProduct } from "./SelectEditProduct";
|
||||||
|
|
||||||
|
const defaultProduct = {
|
||||||
|
id: "",
|
||||||
|
name: "",
|
||||||
|
items: [],
|
||||||
|
is_default: false,
|
||||||
|
is_add_on: false,
|
||||||
|
free_trial: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ModelPricing = ({
|
||||||
|
data,
|
||||||
|
mutate,
|
||||||
|
autumnProducts,
|
||||||
|
productCounts,
|
||||||
|
}: {
|
||||||
|
data: any;
|
||||||
|
mutate: any;
|
||||||
|
autumnProducts: Product[];
|
||||||
|
productCounts: any;
|
||||||
|
}) => {
|
||||||
|
const curProduct = autumnProducts.length > 0 ? autumnProducts[0] : null;
|
||||||
|
|
||||||
|
const [product, setProduct] = useState<any>(
|
||||||
|
(curProduct &&
|
||||||
|
data.products.find((p: ProductV2) => p.id === autumnProducts[0].id)) ||
|
||||||
|
(defaultProduct as unknown as ProductV2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [firstItemCreated, setFirstItemCreated] = useState(
|
||||||
|
autumnProducts.some((p: Product) => p.items.length > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [editingNewProduct, setEditingNewProduct] = useState(
|
||||||
|
nullish(curProduct)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get latest product
|
||||||
|
const getAutumnProducts = () => {
|
||||||
|
const curProductItems = product.items.map((item: any) =>
|
||||||
|
getProductItemResponse({
|
||||||
|
item,
|
||||||
|
features: data.features,
|
||||||
|
currency: "USD",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return autumnProducts;
|
||||||
|
// return [product, ...autumnProducts];
|
||||||
|
|
||||||
|
// const properties: ProductProperties = {
|
||||||
|
// has_trial: notNullish(product.free_trial),
|
||||||
|
// is_free: isFreeProduct(product.items),
|
||||||
|
// is_one_off: isOneOffProduct(product.items),
|
||||||
|
// updateable: product.items.some(
|
||||||
|
// (item: any) => item.usage_model == UsageModel.Prepaid
|
||||||
|
// ),
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const latestProduct = {
|
||||||
|
// ...product,
|
||||||
|
// items: curProductItems,
|
||||||
|
// properties,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const curProducts = autumnProducts.filter(
|
||||||
|
// (p: Product) => p.id !== product.id
|
||||||
|
// );
|
||||||
|
|
||||||
|
// if (!firstItemCreated) {
|
||||||
|
// return [];
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const newProducts = [latestProduct, ...curProducts] as any;
|
||||||
|
// return sortProductsV2({ products: newProducts }) as Product[];
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
const curProduct = data.products.find(
|
||||||
|
(p: Product) => p.id === product.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!curProduct) {
|
||||||
|
if (data.products.length > 0) {
|
||||||
|
setProduct(data.products[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModelPricingContext.Provider
|
||||||
|
value={{
|
||||||
|
firstItemCreated,
|
||||||
|
setFirstItemCreated,
|
||||||
|
editingNewProduct,
|
||||||
|
setEditingNewProduct,
|
||||||
|
product,
|
||||||
|
setProduct,
|
||||||
|
mutate,
|
||||||
|
data,
|
||||||
|
productCounts,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProductsContext.Provider value={{ productCounts, mutate }}>
|
||||||
|
<div className="flex flex-col w-full h-full items-center justify-between overflow-hidden">
|
||||||
|
<div className="w-full p-10 flex flex-col gap-4 justify-center items-center">
|
||||||
|
<div className="max-w-[800px] w-full">
|
||||||
|
<div className="flex gap-4 items-center justify-between mb-6 h-8">
|
||||||
|
<p className="text-xl font-medium">Create your plans</p>
|
||||||
|
{firstItemCreated && (
|
||||||
|
<div className="flex gap-0 items-center">
|
||||||
|
<SelectEditProduct />
|
||||||
|
<NewProductPopover />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
<EditProduct
|
||||||
|
data={data}
|
||||||
|
mutate={mutate}
|
||||||
|
setProduct={setProduct}
|
||||||
|
product={product}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full px-10 flex flex-col gap-4 items-center transition-all duration-1000 ease-in-out overflow-hidden",
|
||||||
|
firstItemCreated
|
||||||
|
? "py-10 max-h-[500px] opacity-100 translate-y-0 rounded-t-xl shadow-[0_-2px_2px_-2px_rgba(0,0,0,0.05)] bg-stone-100 border-t border-zinc-200"
|
||||||
|
: "py-0 max-h-0 opacity-0 translate-y-4"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="gap-4 flex justify-center max-w-[800px] w-full">
|
||||||
|
<PricingTable products={getAutumnProducts()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProductsContext.Provider>
|
||||||
|
</ModelPricingContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NewProductPopover = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { mutate, data, setProduct } = useModelPricingContext();
|
||||||
|
|
||||||
|
const axiosInstance = useAxiosInstance();
|
||||||
|
const [details, setDetails] = useState({
|
||||||
|
name: "",
|
||||||
|
id: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setCreating(true);
|
||||||
|
await axiosInstance.post("/v1/products", {
|
||||||
|
name: details.name,
|
||||||
|
id: details.id,
|
||||||
|
});
|
||||||
|
await mutate();
|
||||||
|
const newProduct = {
|
||||||
|
...defaultProduct,
|
||||||
|
name: details.name,
|
||||||
|
id: details.id,
|
||||||
|
};
|
||||||
|
setProduct(newProduct);
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getBackendErr(error, "Failed to create product"));
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-fit text-primary"
|
||||||
|
onClick={async () => {}}
|
||||||
|
>
|
||||||
|
<PlusIcon size={14} />
|
||||||
|
New Product
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="p-3">
|
||||||
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
|
<p className="text-t3">New Product</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Name"
|
||||||
|
value={details.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDetails({
|
||||||
|
...details,
|
||||||
|
name: e.target.value,
|
||||||
|
id: slugify(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input placeholder="ID" disabled value={details.id} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end w-full">
|
||||||
|
<Button
|
||||||
|
className="w-fit"
|
||||||
|
variant="outline"
|
||||||
|
// size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
isLoading={creating}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export const ModelPricingContext = createContext<any>(null);
|
||||||
|
|
||||||
|
export const useModelPricingContext = () => {
|
||||||
|
const context = useContext(ModelPricingContext);
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"useProductContext must be used within a ProductContextProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useModelPricingContext } from "./ModelPricingContext";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export const SelectEditProduct = () => {
|
||||||
|
const { data, product, setProduct } = useModelPricingContext();
|
||||||
|
|
||||||
|
if (data.products.length > 3) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="w-fit text-t3">
|
||||||
|
<p className="text-t3">{product?.name || "None"}</p>
|
||||||
|
<ChevronDownIcon size={14} />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="max-h-[600px]">
|
||||||
|
{data.products.map((p: any) => {
|
||||||
|
if (!p.name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem key={p.id} onClick={() => setProduct(p)}>
|
||||||
|
{p.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabTriggerClass =
|
||||||
|
"data-[state=active]:bg-stone-200 data-[state=active]:text-t2 data-[state=active]:font-medium";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs className="" value={product.id}>
|
||||||
|
<TabsList className="gap-1 mr-1">
|
||||||
|
{data.products.map((p: any) => {
|
||||||
|
if (!p.name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TabsTrigger
|
||||||
|
key={p.id}
|
||||||
|
value={p.id}
|
||||||
|
className={tabTriggerClass}
|
||||||
|
onClick={() => setProduct(p)}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</TabsTrigger>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { getBackendErr, notNullish } from "@/utils/genUtils";
|
||||||
|
import { ProductV2 } from "@autumn/shared";
|
||||||
|
import { AxiosInstance } from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const handleAutoSave = async ({
|
||||||
|
axiosInstance,
|
||||||
|
productId,
|
||||||
|
product,
|
||||||
|
mutate,
|
||||||
|
}: {
|
||||||
|
axiosInstance: AxiosInstance;
|
||||||
|
productId: string;
|
||||||
|
product: ProductV2;
|
||||||
|
mutate: any;
|
||||||
|
}) => {
|
||||||
|
if (!productId || !product.id) return;
|
||||||
|
try {
|
||||||
|
await axiosInstance.post(
|
||||||
|
`v1/products/${productId}?upsert=true&disable_version=true`,
|
||||||
|
{
|
||||||
|
...product,
|
||||||
|
group: notNullish(product.group) ? product.group : undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await mutate();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
toast.error(getBackendErr(error, "Failed to auto save product"));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const productToPricingCard = (product: any) => {
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
items: product.items,
|
||||||
|
};
|
||||||
|
};
|
||||||
17
vite/src/views/onboarding2/model-pricing/usePricingTable.tsx
Normal file
17
vite/src/views/onboarding2/model-pricing/usePricingTable.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useAxiosSWR } from "@/services/useAxiosSwr";
|
||||||
|
|
||||||
|
export const useListProducts = () => {
|
||||||
|
const { data, isLoading, error, mutate } = useAxiosSWR({
|
||||||
|
url: "/v1/products",
|
||||||
|
options: {
|
||||||
|
refreshInterval: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
products: data?.list || [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
mutate,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -22,10 +22,12 @@ import { DeleteProductDialog } from "./DeleteProductDialog";
|
|||||||
export const ProductRowToolbar = ({
|
export const ProductRowToolbar = ({
|
||||||
product,
|
product,
|
||||||
productCounts,
|
productCounts,
|
||||||
|
isOnboarding = false,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
product: Product;
|
product: Product;
|
||||||
productCounts: ProductCounts;
|
productCounts: ProductCounts;
|
||||||
|
isOnboarding?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
@@ -69,44 +71,48 @@ export const ProductRowToolbar = ({
|
|||||||
<ToolbarButton />
|
<ToolbarButton />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="text-t2" align="end">
|
<DropdownMenuContent className="text-t2" align="end">
|
||||||
<DialogTrigger asChild>
|
{!isOnboarding && (
|
||||||
<DropdownMenuItem
|
<DialogTrigger asChild>
|
||||||
className="flex items-center text-xs"
|
<DropdownMenuItem
|
||||||
onClick={async (e) => {
|
className="flex items-center text-xs"
|
||||||
e.stopPropagation();
|
onClick={async (e) => {
|
||||||
e.preventDefault();
|
e.stopPropagation();
|
||||||
setSelectedProduct(product);
|
e.preventDefault();
|
||||||
setDialogType("copy");
|
setSelectedProduct(product);
|
||||||
setModalOpen(true);
|
setDialogType("copy");
|
||||||
}}
|
setModalOpen(true);
|
||||||
>
|
}}
|
||||||
<div className="flex items-center justify-between w-full gap-2">
|
>
|
||||||
Copy
|
<div className="flex items-center justify-between w-full gap-2">
|
||||||
{copyLoading ? (
|
Copy
|
||||||
<SmallSpinner />
|
{copyLoading ? (
|
||||||
) : (
|
<SmallSpinner />
|
||||||
<Copy size={12} className="text-t3" />
|
) : (
|
||||||
)}
|
<Copy size={12} className="text-t3" />
|
||||||
</div>
|
)}
|
||||||
</DropdownMenuItem>
|
</div>
|
||||||
</DialogTrigger>
|
</DropdownMenuItem>
|
||||||
<DialogTrigger asChild>
|
</DialogTrigger>
|
||||||
<DropdownMenuItem
|
)}
|
||||||
className="flex items-center text-xs"
|
{!isOnboarding && (
|
||||||
onClick={async (e) => {
|
<DialogTrigger asChild>
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
e.preventDefault();
|
className="flex items-center text-xs"
|
||||||
setSelectedProduct(product);
|
onClick={async (e) => {
|
||||||
setDialogType("update");
|
e.stopPropagation();
|
||||||
setModalOpen(true);
|
e.preventDefault();
|
||||||
}}
|
setSelectedProduct(product);
|
||||||
>
|
setDialogType("update");
|
||||||
<div className="flex items-center justify-between w-full gap-2">
|
setModalOpen(true);
|
||||||
Edit
|
}}
|
||||||
<Pen size={12} className="text-t3" />
|
>
|
||||||
</div>
|
<div className="flex items-center justify-between w-full gap-2">
|
||||||
</DropdownMenuItem>
|
Edit
|
||||||
</DialogTrigger>
|
<Pen size={12} className="text-t3" />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex items-center text-xs"
|
className="flex items-center text-xs"
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useProductContext } from "../ProductContext";
|
|||||||
import { FreeTrialConfig } from "./FreeTrialConfig";
|
import { FreeTrialConfig } from "./FreeTrialConfig";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { FreeTrialDuration } from "@autumn/shared";
|
import { FreeTrialDuration } from "@autumn/shared";
|
||||||
|
import { handleAutoSave } from "@/views/onboarding2/model-pricing/model-pricing-utils/modelPricingUtils";
|
||||||
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
|
|
||||||
export const CreateFreeTrial = ({
|
export const CreateFreeTrial = ({
|
||||||
open,
|
open,
|
||||||
@@ -21,7 +23,10 @@ export const CreateFreeTrial = ({
|
|||||||
// const [open, setOpen] = useState(false);
|
// const [open, setOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [price, setPrice] = useState<any>(null);
|
const [price, setPrice] = useState<any>(null);
|
||||||
const { env, product, setProduct, prices } = useProductContext();
|
const { env, product, setProduct, prices, autoSave, mutate } =
|
||||||
|
useProductContext();
|
||||||
|
|
||||||
|
const axiosInstance = useAxiosInstance();
|
||||||
|
|
||||||
const [freeTrial, setFreeTrial] = useState({
|
const [freeTrial, setFreeTrial] = useState({
|
||||||
length: 7,
|
length: 7,
|
||||||
@@ -44,6 +49,15 @@ export const CreateFreeTrial = ({
|
|||||||
duration: freeTrial.duration,
|
duration: freeTrial.duration,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (autoSave) {
|
||||||
|
handleAutoSave({
|
||||||
|
axiosInstance,
|
||||||
|
productId: product.id,
|
||||||
|
product: { ...product, free_trial: freeTrial },
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,19 +11,17 @@ import { useState } from "react";
|
|||||||
import { ProductItemConfig } from "./ProductItemConfig";
|
import { ProductItemConfig } from "./ProductItemConfig";
|
||||||
import { ProductItemContext } from "./ProductItemContext";
|
import { ProductItemContext } from "./ProductItemContext";
|
||||||
import { CreateFeature } from "@/views/features/CreateFeature";
|
import { CreateFeature } from "@/views/features/CreateFeature";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ProductItemInterval,
|
ProductItemInterval,
|
||||||
ProductItem,
|
ProductItem,
|
||||||
CreateFeature as CreateFeatureType,
|
CreateFeature as CreateFeatureType,
|
||||||
UpdateProductSchema,
|
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
|
|
||||||
import { useProductContext } from "../ProductContext";
|
import { useProductContext } from "../ProductContext";
|
||||||
import { validateProductItem } from "@/utils/product/product-item/validateProductItem";
|
import { validateProductItem } from "@/utils/product/product-item/validateProductItem";
|
||||||
|
|
||||||
import { DialogContentWrapper } from "@/components/general/modal-components/DialogContentWrapper";
|
import { DialogContentWrapper } from "@/components/general/modal-components/DialogContentWrapper";
|
||||||
import { ItemConfigFooter } from "./product-item-config/item-config-footer/ItemConfigFooter";
|
import { ItemConfigFooter } from "./product-item-config/item-config-footer/ItemConfigFooter";
|
||||||
import { ProductService } from "@/services/products/ProductService";
|
|
||||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
|
|
||||||
export const defaultProductItem: ProductItem = {
|
export const defaultProductItem: ProductItem = {
|
||||||
@@ -173,7 +171,7 @@ export function CreateProductItem() {
|
|||||||
(features.length == 0 && item.price === null) ? (
|
(features.length == 0 && item.price === null) ? (
|
||||||
<div className="" />
|
<div className="" />
|
||||||
) : (
|
) : (
|
||||||
<ItemConfigFooter />
|
<ItemConfigFooter setIntroDone={() => {}} />
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ProductItemConfig } from "./ProductItemConfig";
|
||||||
|
import { ProductItemContext } from "./ProductItemContext";
|
||||||
|
import { CreateFeature } from "@/views/features/CreateFeature";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ProductItemInterval,
|
||||||
|
ProductItem,
|
||||||
|
CreateFeature as CreateFeatureType,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
|
||||||
|
import { useProductContext } from "../ProductContext";
|
||||||
|
import { validateProductItem } from "@/utils/product/product-item/validateProductItem";
|
||||||
|
import { DialogContentWrapper } from "@/components/general/modal-components/DialogContentWrapper";
|
||||||
|
import { ItemConfigFooter } from "./product-item-config/item-config-footer/ItemConfigFooter";
|
||||||
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import { CreateItemDialogContent } from "./create-product-item/CreateItemDialogContent";
|
||||||
|
import { useModelPricingContext } from "@/views/onboarding2/model-pricing/ModelPricingContext";
|
||||||
|
|
||||||
|
const defaultProductItem: ProductItem = {
|
||||||
|
feature_id: null,
|
||||||
|
|
||||||
|
included_usage: null,
|
||||||
|
|
||||||
|
interval: ProductItemInterval.Month,
|
||||||
|
|
||||||
|
// Price config
|
||||||
|
price: null,
|
||||||
|
tiers: null,
|
||||||
|
billing_units: 1,
|
||||||
|
|
||||||
|
// Others
|
||||||
|
entity_feature_id: null,
|
||||||
|
reset_usage_when_enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CreateProductItem2() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [showCreateFeature, setShowCreateFeature] = useState(false);
|
||||||
|
const [item, setItem] = useState<ProductItem>(defaultProductItem);
|
||||||
|
const { features, product, setProduct, setFeatures } = useProductContext();
|
||||||
|
const { firstItemCreated, setFirstItemCreated } = useModelPricingContext();
|
||||||
|
|
||||||
|
const handleCreateProductItem = async (entityFeatureId?: string) => {
|
||||||
|
const validatedItem = validateProductItem({
|
||||||
|
item: {
|
||||||
|
...item,
|
||||||
|
entity_feature_id: entityFeatureId
|
||||||
|
? entityFeatureId
|
||||||
|
: item.entity_feature_id,
|
||||||
|
},
|
||||||
|
features,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validatedItem) return;
|
||||||
|
|
||||||
|
const newItems = [...product.items, validatedItem];
|
||||||
|
const newProduct = { ...product, items: newItems };
|
||||||
|
setProduct(newProduct);
|
||||||
|
setTimeout(() => {
|
||||||
|
setItem({
|
||||||
|
...defaultProductItem,
|
||||||
|
feature_id: null,
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
setFirstItemCreated(true);
|
||||||
|
return newProduct;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProductItemContext.Provider
|
||||||
|
value={{
|
||||||
|
item,
|
||||||
|
setItem,
|
||||||
|
showCreateFeature,
|
||||||
|
setShowCreateFeature,
|
||||||
|
isUpdate: false,
|
||||||
|
handleCreateProductItem,
|
||||||
|
features,
|
||||||
|
setFeatures,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
autoSave: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<div className="flex gap-0">
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="dashed"
|
||||||
|
className="w-full"
|
||||||
|
startIcon={<PlusIcon size={14} />}
|
||||||
|
>
|
||||||
|
Add Product Item
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</div>
|
||||||
|
<CreateItemDialogContent open={open} setOpen={setOpen} />
|
||||||
|
</Dialog>
|
||||||
|
</ProductItemContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,9 +22,7 @@ import { isFeaturePriceItem, isPriceItem } from "@/utils/product/getItemType";
|
|||||||
export const ProductItemConfig = () => {
|
export const ProductItemConfig = () => {
|
||||||
// HOOKS
|
// HOOKS
|
||||||
const { features } = useProductContext();
|
const { features } = useProductContext();
|
||||||
|
|
||||||
const { item, setItem } = useProductItemContext();
|
const { item, setItem } = useProductItemContext();
|
||||||
|
|
||||||
const [show, setShow] = useState(getShowParams(item));
|
const [show, setShow] = useState(getShowParams(item));
|
||||||
|
|
||||||
const handleAddPrice = () => {
|
const handleAddPrice = () => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Flag } from "lucide-react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { isFeatureItem, isPriceItem } from "@/utils/product/getItemType";
|
import { isFeatureItem, isPriceItem } from "@/utils/product/getItemType";
|
||||||
import { notNullish } from "@/utils/genUtils";
|
import { notNullish } from "@/utils/genUtils";
|
||||||
|
import { useProductContext } from "../ProductContext";
|
||||||
|
|
||||||
interface ProductItemRowProps {
|
interface ProductItemRowProps {
|
||||||
item: ProductItem;
|
item: ProductItem;
|
||||||
@@ -29,6 +30,7 @@ interface ProductItemRowProps {
|
|||||||
features: Feature[];
|
features: Feature[];
|
||||||
org: any;
|
org: any;
|
||||||
onRowClick: (item: ProductItem, index: number) => void;
|
onRowClick: (item: ProductItem, index: number) => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProductItemRow = ({
|
export const ProductItemRow = ({
|
||||||
@@ -38,7 +40,9 @@ export const ProductItemRow = ({
|
|||||||
features,
|
features,
|
||||||
org,
|
org,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
|
className,
|
||||||
}: ProductItemRowProps) => {
|
}: ProductItemRowProps) => {
|
||||||
|
const { product } = useProductContext();
|
||||||
const getName = ({
|
const getName = ({
|
||||||
featureId,
|
featureId,
|
||||||
units,
|
units,
|
||||||
@@ -200,13 +204,15 @@ export const ProductItemRow = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const itemType = getItemType(item);
|
const itemType = getItemType(item);
|
||||||
|
const isLast = index === product.items.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid grid-cols-17 gap-4 px-10 text-t2 h-10 items-center hover:bg-primary/3",
|
"grid grid-cols-17 gap-4 px-10 text-t2 h-10 items-center hover:bg-primary/3",
|
||||||
isOnboarding && "grid-cols-12 px-2"
|
isOnboarding && "grid-cols-12 px-2 h-10 min-h-10",
|
||||||
|
className
|
||||||
)}
|
)}
|
||||||
onClick={() => onRowClick(item, index)}
|
onClick={() => onRowClick(item, index)}
|
||||||
>
|
>
|
||||||
@@ -226,7 +232,12 @@ export const ProductItemRow = ({
|
|||||||
: getPaidFeatureString(item)}
|
: getPaidFeatureString(item)}
|
||||||
</AdminHover>
|
</AdminHover>
|
||||||
</span>
|
</span>
|
||||||
<span className="col-span-4 flex gap-1 justify-end w-fit ">
|
<span
|
||||||
|
className={cn(
|
||||||
|
"col-span-4 flex gap-1 justify-end w-fit",
|
||||||
|
isOnboarding && "w-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Badge
|
<Badge
|
||||||
variant="blue"
|
variant="blue"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -13,25 +13,20 @@ import {
|
|||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { EllipsisVertical } from "lucide-react";
|
import { EllipsisVertical, SaveIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EntitiesDropdownContent } from "./EntitiesDropdown";
|
import { EntitiesDropdownContent } from "./EntitiesDropdown";
|
||||||
import { CreateFreeTrial } from "../free-trial/CreateFreeTrial";
|
import { CreateFreeTrial } from "../free-trial/CreateFreeTrial";
|
||||||
import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip";
|
import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip";
|
||||||
|
|
||||||
export const ProductItemTable = ({
|
export const ProductItemTable = () => {
|
||||||
isOnboarding = false,
|
const { product, features, org, entityFeatureIds, isOnboarding } =
|
||||||
}: {
|
|
||||||
isOnboarding?: boolean;
|
|
||||||
}) => {
|
|
||||||
const { product, setProduct, features, org, entityFeatureIds } =
|
|
||||||
useProductContext();
|
useProductContext();
|
||||||
const [selectedItem, setSelectedItem] = useState<ProductItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<ProductItem | null>(null);
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const [entitiesOpen, setEntitiesOpen] = useState(false);
|
|
||||||
const [freeTrialOpen, setFreeTrialOpen] = useState(false);
|
const [freeTrialOpen, setFreeTrialOpen] = useState(false);
|
||||||
|
|
||||||
const handleRowClick = (item: ProductItem, index: number) => {
|
const handleRowClick = (item: ProductItem, index: number) => {
|
||||||
@@ -44,11 +39,11 @@ export const ProductItemTable = ({
|
|||||||
const groupedItems = entityFeatureIds.reduce(
|
const groupedItems = entityFeatureIds.reduce(
|
||||||
(acc: Record<string, ProductItem[]>, entityFeatureId: string) => {
|
(acc: Record<string, ProductItem[]>, entityFeatureId: string) => {
|
||||||
acc[entityFeatureId] = product.items.filter(
|
acc[entityFeatureId] = product.items.filter(
|
||||||
(item: ProductItem) => item.entity_feature_id === entityFeatureId,
|
(item: ProductItem) => item.entity_feature_id === entityFeatureId
|
||||||
);
|
);
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, ProductItem[]>,
|
{} as Record<string, ProductItem[]>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,65 +59,81 @@ export const ProductItemTable = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between border-y bg-stone-100 pl-10 h-10",
|
"flex items-center justify-between border-y bg-stone-100 pl-10 h-10",
|
||||||
isOnboarding && "pl-2 pr-2 border-x",
|
isOnboarding && "pl-2 !border-b !border-t-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h2 className="text-sm text-t2 font-medium flex whitespace-nowrap">
|
<h2 className="text-sm text-t2 font-medium flex whitespace-nowrap">
|
||||||
Product Items
|
Product Items
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="flex w-full h-full items-center justify-end">
|
<div className="flex w-full h-full items-center justify-end">
|
||||||
<div className="flex w-fit h-full items-center">
|
{/* <div className="flex w-fit h-full items-center"> */}
|
||||||
<CreateProductItem />
|
{!isOnboarding && <CreateProductItem />}
|
||||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="add"
|
variant="add"
|
||||||
disableStartIcon
|
disableStartIcon
|
||||||
startIcon={<EllipsisVertical size={16} />}
|
startIcon={<EllipsisVertical size={16} />}
|
||||||
className="w-10 h-10 p-0 text-purple-600 hover:text-purple-700 hover:bg-purple-50"
|
className={cn(
|
||||||
/>
|
"w-10 h-10 p-0 text-purple-600 hover:text-purple-700 hover:bg-purple-50",
|
||||||
</DropdownMenuTrigger>
|
isOnboarding && "!h-full",
|
||||||
<DropdownMenuContent align="end" className="w-36 max-w-36">
|
isOnboarding && product.items.length == 0 && "hidden"
|
||||||
<DropdownMenuSub>
|
)}
|
||||||
<DropdownMenuSubTrigger className="flex items-center gap-2">
|
/>
|
||||||
Add Entity
|
</DropdownMenuTrigger>
|
||||||
<InfoTooltip>
|
<DropdownMenuContent align="end" className="w-36 max-w-36">
|
||||||
<p>
|
<DropdownMenuSub>
|
||||||
Add an entity to group items by (eg, usage limits per
|
<DropdownMenuSubTrigger className="flex items-center gap-2">
|
||||||
users, compute instances, etc).
|
Add Entity
|
||||||
</p>
|
<InfoTooltip>
|
||||||
</InfoTooltip>
|
<p>
|
||||||
</DropdownMenuSubTrigger>
|
Add an entity to group items by (eg, usage limits per
|
||||||
<DropdownMenuSubContent className="max-w-52">
|
users, compute instances, etc).
|
||||||
<EntitiesDropdownContent />
|
</p>
|
||||||
</DropdownMenuSubContent>
|
</InfoTooltip>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuItem
|
<DropdownMenuSubContent className="max-w-52">
|
||||||
onClick={() => {
|
<EntitiesDropdownContent />
|
||||||
setDropdownOpen(false);
|
</DropdownMenuSubContent>
|
||||||
setFreeTrialOpen(true);
|
</DropdownMenuSub>
|
||||||
}}
|
<DropdownMenuItem
|
||||||
>
|
onClick={() => {
|
||||||
Add Free Trial
|
setDropdownOpen(false);
|
||||||
</DropdownMenuItem>
|
setFreeTrialOpen(true);
|
||||||
</DropdownMenuContent>
|
}}
|
||||||
</DropdownMenu>
|
>
|
||||||
<CreateFreeTrial
|
Add Free Trial
|
||||||
open={freeTrialOpen}
|
</DropdownMenuItem>
|
||||||
setOpen={setFreeTrialOpen}
|
</DropdownMenuContent>
|
||||||
/>
|
</DropdownMenu>
|
||||||
</div>
|
<CreateFreeTrial open={freeTrialOpen} setOpen={setFreeTrialOpen} />
|
||||||
|
{/* </div> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* <Button
|
||||||
|
variant="add"
|
||||||
|
className="w-fit"
|
||||||
|
startIcon={<SaveIcon size={14} className="mr-1" />}
|
||||||
|
>
|
||||||
|
Save Product
|
||||||
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col",
|
||||||
|
isOnboarding && "overflow-y-auto max-h-[200px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{/* Original product items mapping - excluding items that appear in grouped sections */}
|
{/* Original product items mapping - excluding items that appear in grouped sections */}
|
||||||
{product.items
|
{product.items
|
||||||
.filter(
|
.filter(
|
||||||
(item: ProductItem) =>
|
(item: ProductItem) =>
|
||||||
!entityFeatureIds.some(
|
!entityFeatureIds.some(
|
||||||
(entityFeatureId: string) =>
|
(entityFeatureId: string) =>
|
||||||
item.entity_feature_id === entityFeatureId,
|
item.entity_feature_id === entityFeatureId
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
.map((item: ProductItem, index: number) => (
|
.map((item: ProductItem, index: number) => (
|
||||||
<ProductItemRow
|
<ProductItemRow
|
||||||
@@ -143,10 +154,15 @@ export const ProductItemTable = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center bg-stone-50 border-b pl-10 pr-10 h-5 relative mb-2",
|
"flex items-center bg-stone-50 border-b pl-10 pr-10 h-5 relative mb-2",
|
||||||
isOnboarding && "pl-2 pr-2",
|
isOnboarding && "px-2 bg-white"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h3 className="text-t2 font-medium uppercase text-xs font-mono tracking-widest absolute top-3.5 bg-stone-50 px-3 left-7">
|
<h3
|
||||||
|
className={cn(
|
||||||
|
"text-t2 font-medium uppercase text-xs font-mono tracking-widest absolute top-3.5 bg-stone-50 px-3 left-7",
|
||||||
|
isOnboarding && "bg-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{entityFeatureId}
|
{entityFeatureId}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,7 +179,7 @@ export const ProductItemTable = ({
|
|||||||
org={org}
|
org={org}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
/>
|
/>
|
||||||
),
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show message if no items for this entityFeatureId */}
|
{/* Show message if no items for this entityFeatureId */}
|
||||||
@@ -171,7 +187,7 @@ export const ProductItemTable = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center pl-10 pr-10 h-12 text-t3 text-sm",
|
"flex items-center pl-10 pr-10 h-12 text-t3 text-sm",
|
||||||
isOnboarding && "pl-2 pr-2",
|
isOnboarding && "pl-2 pr-2"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Add the features this entity gets access to
|
Add the features this entity gets access to
|
||||||
@@ -181,8 +197,17 @@ export const ProductItemTable = ({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{product.items.length === 0 && (
|
{product.items.length === 0 && (
|
||||||
<div className="flex flex-col px-10 h-full mt-2">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col px-10 h-full my-2",
|
||||||
|
isOnboarding && "px-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<p className="text-t3">
|
<p className="text-t3">
|
||||||
|
Product items determine what customers get access to and how
|
||||||
|
they're billed. Start by adding one.
|
||||||
|
</p>
|
||||||
|
{/* <p className="text-t3">
|
||||||
Product items determine what customers get access to and how
|
Product items determine what customers get access to and how
|
||||||
they're billed{" "}
|
they're billed{" "}
|
||||||
<a
|
<a
|
||||||
@@ -208,7 +233,7 @@ export const ProductItemTable = ({
|
|||||||
<span className="font-medium text-t2">Priced Features:</span>{" "}
|
<span className="font-medium text-t2">Priced Features:</span>{" "}
|
||||||
features that have a price based on usage (eg, $1 per credit)
|
features that have a price based on usage (eg, $1 per credit)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,18 +28,19 @@ export default function UpdateProductItem({
|
|||||||
const [showCreateFeature, setShowCreateFeature] = useState(false);
|
const [showCreateFeature, setShowCreateFeature] = useState(false);
|
||||||
|
|
||||||
const handleUpdateProductItem = () => {
|
const handleUpdateProductItem = () => {
|
||||||
console.log("Selected Item: ", selectedItem);
|
|
||||||
const validatedItem = validateProductItem({
|
const validatedItem = validateProductItem({
|
||||||
item: selectedItem!,
|
item: selectedItem!,
|
||||||
features,
|
features,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validatedItem) return;
|
if (!validatedItem) return null;
|
||||||
if (notNullish(selectedIndex)) {
|
if (notNullish(selectedIndex)) {
|
||||||
const newProduct = { ...product };
|
const newProduct = { ...product };
|
||||||
newProduct.items[selectedIndex!] = validatedItem;
|
newProduct.items[selectedIndex!] = validatedItem;
|
||||||
setProduct(newProduct);
|
setProduct(newProduct);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
|
return newProduct;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ export default function UpdateProductItem({
|
|||||||
newProduct.items.splice(selectedIndex!, 1);
|
newProduct.items.splice(selectedIndex!, 1);
|
||||||
setProduct(newProduct);
|
setProduct(newProduct);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
return newProduct;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
import { useProductItemContext } from "../ProductItemContext";
|
import { useProductItemContext } from "../ProductItemContext";
|
||||||
import { useProductContext } from "../../ProductContext";
|
import { useProductContext } from "../../ProductContext";
|
||||||
import { FeatureTypeBadge } from "@/views/features/FeatureTypeBadge";
|
import { FeatureTypeBadge } from "@/views/features/FeatureTypeBadge";
|
||||||
import { Feature } from "@autumn/shared";
|
import { Feature, FeatureType, ProductItemType } from "@autumn/shared";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import { getItemType } from "@/utils/product/productItemUtils";
|
||||||
|
|
||||||
export const SelectItemFeature = ({
|
export const SelectItemFeature = ({
|
||||||
show,
|
show,
|
||||||
@@ -24,6 +25,8 @@ export const SelectItemFeature = ({
|
|||||||
const { item, setItem, setShowCreateFeature, isUpdate } =
|
const { item, setItem, setShowCreateFeature, isUpdate } =
|
||||||
useProductItemContext();
|
useProductItemContext();
|
||||||
|
|
||||||
|
const itemType = getItemType(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<Select
|
<Select
|
||||||
@@ -37,14 +40,21 @@ export const SelectItemFeature = ({
|
|||||||
<SelectValue placeholder="Select a feature" />
|
<SelectValue placeholder="Select a feature" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{features.map((feature: Feature) => (
|
{features
|
||||||
<SelectItem key={feature.id} value={feature.id!}>
|
.filter((feature: Feature) => {
|
||||||
<div className="flex gap-2 items-center max-w-sm">
|
if (itemType === ProductItemType.FeaturePrice) {
|
||||||
<span className="truncate">{feature.name}</span>
|
return feature.type !== FeatureType.Boolean;
|
||||||
<FeatureTypeBadge {...feature} />
|
}
|
||||||
</div>
|
return true;
|
||||||
</SelectItem>
|
})
|
||||||
))}
|
.map((feature: Feature) => (
|
||||||
|
<SelectItem key={feature.id} value={feature.id!}>
|
||||||
|
<div className="flex gap-2 items-center max-w-sm">
|
||||||
|
<span className="truncate">{feature.name}</span>
|
||||||
|
<FeatureTypeBadge {...feature} />
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
<Button
|
<Button
|
||||||
className="flex w-full font-medium bg-white shadow-none text-primary hover:bg-stone-200"
|
className="flex w-full font-medium bg-white shadow-none text-primary hover:bg-stone-200"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { CreateFeature } from "@/views/features/CreateFeature";
|
||||||
|
import { useProductItemContext } from "../ProductItemContext";
|
||||||
|
import {
|
||||||
|
ProductItemInterval,
|
||||||
|
ProductItem,
|
||||||
|
CreateFeature as CreateFeatureType,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
|
||||||
|
export const CreateFeatureFromItem = () => {
|
||||||
|
const {
|
||||||
|
setShowCreateFeature,
|
||||||
|
setOpen,
|
||||||
|
open,
|
||||||
|
setFeatures,
|
||||||
|
setItem,
|
||||||
|
features,
|
||||||
|
item,
|
||||||
|
} = useProductItemContext();
|
||||||
|
|
||||||
|
const setSelectedFeature = (feature: CreateFeatureType) => {
|
||||||
|
setFeatures([...features, feature]);
|
||||||
|
setItem({ ...item, feature_id: feature.id! });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader className="p-0">
|
||||||
|
<div className="flex flex-col ">
|
||||||
|
{features.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-xs py-0 px-2 w-fit -ml-5 -mt-5 hover:bg-transparent"
|
||||||
|
onClick={() => setShowCreateFeature(false)}
|
||||||
|
>
|
||||||
|
← Product
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogTitle>
|
||||||
|
{/* {showCreateFeature || (features.length == 0 && item.price === null)
|
||||||
|
? "Create Feature"
|
||||||
|
: "Add Product Item"} */}
|
||||||
|
Create Feature
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex !overflow-visible w-fit">
|
||||||
|
<div className="w-full -mt-2">
|
||||||
|
<CreateFeature
|
||||||
|
isFromEntitlement={true}
|
||||||
|
setShowFeatureCreate={setShowCreateFeature}
|
||||||
|
setSelectedFeature={setSelectedFeature}
|
||||||
|
setOpen={setOpen}
|
||||||
|
open={open}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* {showCreateFeature || (features.length == 0 && item.price === null) ? (
|
||||||
|
<div className="w-full -mt-2">
|
||||||
|
<CreateFeature
|
||||||
|
isFromEntitlement={true}
|
||||||
|
setShowFeatureCreate={setShowCreateFeature}
|
||||||
|
setSelectedFeature={setSelectedFeature}
|
||||||
|
setOpen={setOpen}
|
||||||
|
open={open}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 w-fit !overflow-visible">
|
||||||
|
<ProductItemConfig />
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { DialogContentWrapper } from "@/components/general/modal-components/DialogContentWrapper";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useProductItemContext } from "../ProductItemContext";
|
||||||
|
import { CreateFeature } from "@/views/features/CreateFeature";
|
||||||
|
import { ProductItemConfig } from "../ProductItemConfig";
|
||||||
|
import {
|
||||||
|
ProductItemInterval,
|
||||||
|
ProductItem,
|
||||||
|
CreateFeature as CreateFeatureType,
|
||||||
|
ProductItemType,
|
||||||
|
Infinite,
|
||||||
|
FeatureType,
|
||||||
|
BillingInterval,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import { ItemConfigFooter } from "../product-item-config/item-config-footer/ItemConfigFooter";
|
||||||
|
import { CreateFeatureFromItem } from "./CreateFeatureFromItem";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useProductContext } from "../../ProductContext";
|
||||||
|
import { CreateItemIntro } from "./CreateItemIntro";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getItemType } from "@/utils/product/productItemUtils";
|
||||||
|
import { getFeature } from "@/utils/product/entitlementUtils";
|
||||||
|
import { defaultPaidFeatureItem, defaultPriceItem } from "./defaultItemConfigs";
|
||||||
|
import { notNullish } from "@/utils/genUtils";
|
||||||
|
|
||||||
|
export const CreateItemDialogContent = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
showCreateFeature,
|
||||||
|
setShowCreateFeature,
|
||||||
|
features,
|
||||||
|
item,
|
||||||
|
setItem,
|
||||||
|
setFeatures,
|
||||||
|
} = useProductItemContext();
|
||||||
|
|
||||||
|
const { product } = useProductContext();
|
||||||
|
|
||||||
|
const [showIntro, setShowIntro] = useState(product.items.length === 0);
|
||||||
|
const [introDone, setIntroDone] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setShowIntro(product.items.length === 0);
|
||||||
|
setIntroDone(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const showFeatureDialog =
|
||||||
|
showCreateFeature || (features.length == 0 && item.price === null);
|
||||||
|
|
||||||
|
const showIntroDialog = !introDone;
|
||||||
|
|
||||||
|
const getTabValue = () => {
|
||||||
|
return getItemType(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabChange = (value: string) => {
|
||||||
|
if (value === ProductItemType.Feature) {
|
||||||
|
setItem({
|
||||||
|
...item,
|
||||||
|
feature_id: item.feature_id,
|
||||||
|
price: null,
|
||||||
|
tiers: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === ProductItemType.FeaturePrice) {
|
||||||
|
const feature = getFeature(item.feature_id, features);
|
||||||
|
if (!feature || feature?.type === FeatureType.Boolean) {
|
||||||
|
setItem(defaultPaidFeatureItem);
|
||||||
|
} else {
|
||||||
|
const newIncludedUsage =
|
||||||
|
item.included_usage == Infinite ? 0 : item.included_usage;
|
||||||
|
|
||||||
|
let newInterval = item.interval;
|
||||||
|
if (
|
||||||
|
notNullish(item.interval) &&
|
||||||
|
!Object.values(BillingInterval).includes(item.interval)
|
||||||
|
) {
|
||||||
|
newInterval = BillingInterval.Month;
|
||||||
|
}
|
||||||
|
|
||||||
|
setItem({
|
||||||
|
...item,
|
||||||
|
included_usage: newIncludedUsage,
|
||||||
|
interval: newInterval,
|
||||||
|
tiers: [{ to: Infinite, amount: 0 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === ProductItemType.Price) {
|
||||||
|
setItem(defaultPriceItem);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabTriggerClass =
|
||||||
|
"data-[state=active]:bg-stone-200 data-[state=active]:text-t2 data-[state=active]:font-medium";
|
||||||
|
return (
|
||||||
|
<DialogContent className="translate-y-[0%] top-[20%] flex flex-col gap-0 w-fit p-0">
|
||||||
|
<DialogContentWrapper>
|
||||||
|
{showIntroDialog ? (
|
||||||
|
<CreateItemIntro setIntroDone={setIntroDone} />
|
||||||
|
) : showFeatureDialog ? (
|
||||||
|
<CreateFeatureFromItem />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<DialogHeader className="p-0">
|
||||||
|
<DialogTitle>Add Product Item</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs value={getTabValue()} onValueChange={handleTabChange}>
|
||||||
|
<TabsList className="gap-2">
|
||||||
|
<TabsTrigger className={tabTriggerClass} value="feature">
|
||||||
|
Feature
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger className={tabTriggerClass} value="priced_feature">
|
||||||
|
Priced Feature
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger className={tabTriggerClass} value="price">
|
||||||
|
Price
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="config">
|
||||||
|
<ProductItemConfig />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 w-fit !overflow-visible">
|
||||||
|
<ProductItemConfig />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContentWrapper>
|
||||||
|
{!showIntroDialog && !showFeatureDialog && (
|
||||||
|
<ItemConfigFooter setIntroDone={setIntroDone} />
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { ProductItem, ProductItemInterval } from "@autumn/shared";
|
||||||
|
import { ChevronRight, FlagIcon } from "lucide-react";
|
||||||
|
import { useProductItemContext } from "../ProductItemContext";
|
||||||
|
import {
|
||||||
|
defaultFeatureItem,
|
||||||
|
defaultPaidFeatureItem,
|
||||||
|
defaultPriceItem,
|
||||||
|
} from "./defaultItemConfigs";
|
||||||
|
|
||||||
|
export const CreateItemIntro = ({
|
||||||
|
setIntroDone,
|
||||||
|
}: {
|
||||||
|
setIntroDone: (introDone: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const { setItem } = useProductItemContext();
|
||||||
|
|
||||||
|
const handleItemClicked = (itemType: string) => {
|
||||||
|
if (itemType === "feature") {
|
||||||
|
setItem(defaultFeatureItem);
|
||||||
|
} else if (itemType === "paid feature") {
|
||||||
|
setItem(defaultPaidFeatureItem);
|
||||||
|
} else if (itemType === "price") {
|
||||||
|
setItem(defaultPriceItem);
|
||||||
|
}
|
||||||
|
setIntroDone(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[500px] flex flex-col gap-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Select item type</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ItemTypeCard
|
||||||
|
itemType="Feature"
|
||||||
|
description="Eg. 100 credits per month"
|
||||||
|
onClick={() => handleItemClicked("feature")}
|
||||||
|
/>
|
||||||
|
<ItemTypeCard
|
||||||
|
itemType="Paid Feature"
|
||||||
|
description="Eg. $0.5 per credit"
|
||||||
|
onClick={() => handleItemClicked("paid feature")}
|
||||||
|
/>
|
||||||
|
<ItemTypeCard
|
||||||
|
itemType="Price"
|
||||||
|
description="Eg. $10 per month"
|
||||||
|
onClick={() => handleItemClicked("price")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ItemTypeCard = ({
|
||||||
|
itemType,
|
||||||
|
description,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
itemType: string;
|
||||||
|
description: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center border p-3 bg-white rounded-lg cursor-pointer hover:bg-gray-50"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center text-md font-medium gap-2">
|
||||||
|
<FlagIcon className="text-t3" size={12} />
|
||||||
|
<p className="text-t2"> {itemType}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-t3 text-sm">{description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChevronRight className="text-t3 ml-8" size={14} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ProductItem, ProductItemInterval } from "@autumn/shared";
|
||||||
|
|
||||||
|
export const defaultFeatureItem: ProductItem = {
|
||||||
|
feature_id: null,
|
||||||
|
|
||||||
|
included_usage: null,
|
||||||
|
|
||||||
|
interval: ProductItemInterval.Month,
|
||||||
|
|
||||||
|
// Price config
|
||||||
|
price: null,
|
||||||
|
tiers: null,
|
||||||
|
billing_units: 1,
|
||||||
|
|
||||||
|
// Others
|
||||||
|
entity_feature_id: null,
|
||||||
|
reset_usage_when_enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultPaidFeatureItem: ProductItem = {
|
||||||
|
feature_id: null,
|
||||||
|
included_usage: null,
|
||||||
|
interval: ProductItemInterval.Month,
|
||||||
|
|
||||||
|
// Price config
|
||||||
|
price: null,
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
amount: 0,
|
||||||
|
to: "inf",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
billing_units: 1,
|
||||||
|
|
||||||
|
// Others
|
||||||
|
entity_feature_id: null,
|
||||||
|
reset_usage_when_enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultPriceItem: ProductItem = {
|
||||||
|
feature_id: null,
|
||||||
|
included_usage: null,
|
||||||
|
|
||||||
|
interval: ProductItemInterval.Month,
|
||||||
|
|
||||||
|
// Price config
|
||||||
|
price: 0,
|
||||||
|
tiers: null,
|
||||||
|
billing_units: 1,
|
||||||
|
|
||||||
|
// Others
|
||||||
|
entity_feature_id: null,
|
||||||
|
reset_usage_when_enabled: true,
|
||||||
|
};
|
||||||
@@ -64,7 +64,7 @@ export const FeatureConfig = () => {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isFeature && (
|
{/* {isFeature && (
|
||||||
<div className="flex w-full justify-start transition-all duration-300 ease-in-out overflow-hidden">
|
<div className="flex w-full justify-start transition-all duration-300 ease-in-out overflow-hidden">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -75,7 +75,7 @@ export const FeatureConfig = () => {
|
|||||||
Add Price
|
Add Price
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
<AdvancedItemConfig />
|
<AdvancedItemConfig />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useProductItemContext } from "../../ProductItemContext";
|
import { useProductItemContext } from "../../ProductItemContext";
|
||||||
import { isEmptyItem } from "@/utils/product/getItemType";
|
import { isEmptyItem } from "@/utils/product/getItemType";
|
||||||
import { XIcon } from "lucide-react";
|
import { ArrowLeftIcon, XIcon } from "lucide-react";
|
||||||
import { useProductContext } from "../../../ProductContext";
|
import { useProductContext } from "../../../ProductContext";
|
||||||
import { AddToEntityDropdown } from "./AddToEntityDropdown";
|
import { AddToEntityDropdown } from "./AddToEntityDropdown";
|
||||||
|
import { handleAutoSave } from "@/views/onboarding2/model-pricing/model-pricing-utils/modelPricingUtils";
|
||||||
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
|
|
||||||
export const ItemConfigFooter = () => {
|
export const ItemConfigFooter = ({
|
||||||
const { entityFeatureIds } = useProductContext();
|
setIntroDone,
|
||||||
|
}: {
|
||||||
|
setIntroDone?: (introDone: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const axiosInstance = useAxiosInstance();
|
||||||
|
const { entityFeatureIds, product, mutate, autoSave } = useProductContext();
|
||||||
const {
|
const {
|
||||||
item,
|
item,
|
||||||
handleCreateProductItem,
|
handleCreateProductItem,
|
||||||
@@ -22,39 +29,94 @@ export const ItemConfigFooter = () => {
|
|||||||
|
|
||||||
const isEmpty = isEmptyItem(item);
|
const isEmpty = isEmptyItem(item);
|
||||||
|
|
||||||
|
const showIntro = product.items.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-stone-100 flex items-center h-10 gap-0 border-t border-zinc-200 justify-end",
|
"bg-stone-100 flex items-center h-10 gap-0 border-t border-zinc-200 justify-between"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* <AddPriceButton /> */}
|
{showIntro && setIntroDone ? (
|
||||||
{/* <AddFeatureButton /> */}
|
|
||||||
{handleUpdateProductItem && (
|
|
||||||
<Button
|
<Button
|
||||||
className="hover:border-red-500 text-red-500"
|
variant="ghost"
|
||||||
variant="add"
|
className="hover:!bg-zinc-200 p-1 h-6 ml-5 text-t3 rounded-md"
|
||||||
startIcon={<XIcon size={12} />}
|
onClick={() => setIntroDone?.(false)}
|
||||||
onClick={handleDeleteProductItem}
|
startIcon={<ArrowLeftIcon size={12} />}
|
||||||
>
|
>
|
||||||
Delete Item
|
Back
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{handleUpdateProductItem && (
|
|
||||||
<Button variant="add" onClick={handleUpdateProductItem}>
|
|
||||||
Update Item
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{showEntityDropdown && handleCreateProductItem && <AddToEntityDropdown />}
|
|
||||||
{!showEntityDropdown && handleCreateProductItem && (
|
|
||||||
<Button
|
|
||||||
variant="add"
|
|
||||||
onClick={() => handleCreateProductItem(null)}
|
|
||||||
disabled={isEmpty}
|
|
||||||
>
|
|
||||||
Add Item
|
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex">
|
||||||
|
{handleUpdateProductItem && (
|
||||||
|
<Button
|
||||||
|
className="hover:border-red-500 text-red-500"
|
||||||
|
variant="add"
|
||||||
|
startIcon={<XIcon size={12} />}
|
||||||
|
onClick={async () => {
|
||||||
|
const newProduct = await handleDeleteProductItem();
|
||||||
|
|
||||||
|
if (autoSave && newProduct) {
|
||||||
|
handleAutoSave({
|
||||||
|
axiosInstance,
|
||||||
|
productId: product.id,
|
||||||
|
product,
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete Item
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{handleUpdateProductItem && (
|
||||||
|
<Button
|
||||||
|
variant="add"
|
||||||
|
onClick={async () => {
|
||||||
|
const newProduct = await handleUpdateProductItem();
|
||||||
|
console.log("New product:", newProduct);
|
||||||
|
console.log("Auto save:", autoSave);
|
||||||
|
|
||||||
|
if (autoSave && newProduct) {
|
||||||
|
handleAutoSave({
|
||||||
|
axiosInstance,
|
||||||
|
productId: product.id,
|
||||||
|
product,
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Update Item
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showEntityDropdown && handleCreateProductItem && (
|
||||||
|
<AddToEntityDropdown />
|
||||||
|
)}
|
||||||
|
{!showEntityDropdown && handleCreateProductItem && (
|
||||||
|
<Button
|
||||||
|
variant="add"
|
||||||
|
onClick={async () => {
|
||||||
|
const newProduct = await handleCreateProductItem(null);
|
||||||
|
|
||||||
|
if (autoSave && newProduct) {
|
||||||
|
handleAutoSave({
|
||||||
|
axiosInstance,
|
||||||
|
productId: product.id,
|
||||||
|
product: newProduct,
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isEmpty}
|
||||||
|
>
|
||||||
|
Add Item
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user