adding code snippets for integration

This commit is contained in:
John Yeo
2025-08-03 08:05:30 -07:00
parent 84063ab5ed
commit a9db3f163f
68 changed files with 4081 additions and 351 deletions

View File

@@ -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=="],

View File

@@ -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({

View File

@@ -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,

View File

@@ -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,
// });
})(), })(),
]); ]);

View File

@@ -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,

View File

@@ -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;
} }

View File

@@ -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";

View File

@@ -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>;

View File

@@ -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);
};

View 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,
});
};

View 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: "",
};
};

View 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;
};

View 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,
});
};

View 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;
};

View 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;
});
};

View File

@@ -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;

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
vite/public/cursor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -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>
); );

View 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>
);
};

View File

@@ -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 && (

View File

@@ -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"
/> />

View File

@@ -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";

View 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&apos;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>,
};
}
};

View File

@@ -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>,

View File

@@ -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)
); );
}; };

View File

@@ -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 = () => {

View File

@@ -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>
); );
} }

View 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>
);
};

View 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>
);
}

View 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;
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
},
};
},
});
`;

View File

@@ -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;
};

View File

@@ -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 `
// `;
// };

View File

@@ -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 })")}
},
})
);`;
};

View File

@@ -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)}
},
})
);`;
};

View File

@@ -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: "" },
};
},
});
`;

View File

@@ -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;`;
};

View File

@@ -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: "" },
};`;
};

View 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>
</>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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;
};

View File

@@ -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>
);
};

View File

@@ -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"));
}
};

View File

@@ -0,0 +1,7 @@
export const productToPricingCard = (product: any) => {
return {
id: product.id,
name: product.name,
items: product.items,
};
};

View 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,
};
};

View File

@@ -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) => {

View File

@@ -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);
}; };

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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 = () => {

View File

@@ -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(

View File

@@ -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>

View File

@@ -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;
} }
}; };

View File

@@ -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) => {

View File

@@ -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>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
};

View File

@@ -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 />
</> </>
); );

View File

@@ -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>
); );
}; };