docs: fixed readme.md
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -81,4 +81,8 @@ shared/drizzle
|
||||
|
||||
|
||||
run.sh
|
||||
commands.sh
|
||||
commands.sh
|
||||
env.sh
|
||||
server/env.sh
|
||||
server/run.sh
|
||||
server/test.sh
|
||||
135
README.md
135
README.md
@@ -3,7 +3,7 @@
|
||||

|
||||
|
||||

|
||||

|
||||
[](https://x.com/autumnpricing)
|
||||

|
||||
[](https://app.useautumn.com)
|
||||
[](https://docs.useautumn.com)
|
||||
@@ -17,85 +17,96 @@
|
||||
|
||||
All this without having to handle webhooks, upgrades/downgrades, cancellations or payment fails.
|
||||
|
||||
**Docs**: https://docs.useautumn.com
|
||||
|
||||
## Getting Started
|
||||
|
||||
**Cloud**: The quickest way to start using Autum is through our cloud service [here](https://app.useautumn.com).
|
||||
|
||||
**Self Hosted**: If you'd like to self-host Autumn:
|
||||
|
||||
1. Make sure you have `node.js` and `pnpm` installed
|
||||
2. Run our set up script:
|
||||
```bash
|
||||
pnpm run setup
|
||||
```
|
||||
3. Run Autumn:
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up
|
||||
```
|
||||
|
||||
That's it! You should be able to see the Autumn dashboard on `http://localhost:3000`
|
||||
|
||||
> ℹ️ Our set up script initializes the required env vars and (optionally) a Supabase instance. If you'd like to use your own Postgres instance, you can do so -- just paste the connection string in the `DATABASE_URL` env variable at `server/.env`
|
||||
|
||||
## Why Autumn
|
||||
|
||||
**1️⃣ Billing infra gets complex fast**
|
||||
|
||||
More than payments: it's building permission management, metering, usage limits with cron jobs, and connecting it to upgrade, downgrade, cancellation and failed payments states. Race conditions, edge cases, and other bugs will slow you down.
|
||||
|
||||
**2️⃣ Billing and app logic should be decoupled**
|
||||
|
||||
Growing companies iterate on pricing often: raising prices, experimenting with credits or charging for a new feature. DB migrations, rebuilding in-app flows, internal dashboards for custom pricing and grandfathering users on old pricing is a nightmare.
|
||||
|
||||
|
||||
## How it works
|
||||
First, create your products and plans on the dashboard. We support **any** pricing model. Some popular ones we've seen include:
|
||||
|
||||
### 1. Define your products and plans in Autumn
|
||||
1. **Usage & Overage** ⚡
|
||||
- Set real-time usage limits and choose when they reset. Charge users if they go over.
|
||||
2. **Credits** 💰
|
||||
- Users can access monetary or arbitrary credits that many features can draw from
|
||||
3. **Seat-based with per-seat limits** 👥:
|
||||
- Bill customers for their users (or other entities). Set usage-limits per user.
|
||||
4. **Pay upfront** 💳
|
||||
- let users purchase a fixed quantity of a feature upfront, which is used over time
|
||||
|
||||
Create your products in Autumn's dashboard, and set the available features for each product.
|
||||
|
||||

|
||||
Next, all your billing logic can be implemented through just 3 functions:
|
||||
|
||||
Features can be `boolean`, which are used to enable/disable features. Or, they can be `metered`, for tracking usage-based aspects of your product.
|
||||
1. `/attach`: One function call for all purchase flows. We return a Stripe Checkout URL, or handle an upgrade/downgrade.
|
||||
|
||||

|
||||
```tsx
|
||||
const { attach } = useAutumn();
|
||||
<button
|
||||
onClick={async () => {
|
||||
await attach({ productId: "pro" });
|
||||
}}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</button>
|
||||
```
|
||||
|
||||
You define the level of granularity you want: whether that's 1 single boolean feature flag (eg: `pro-features`) to enable all your paid features, or splitting them each into their own feature.
|
||||
2. `/check`: Check whether a customer has access to a product, feature or remaining usage.
|
||||
```ts
|
||||
const { check } = useAutumn();
|
||||
|
||||
Once defined, you can check if a user has access to any of the defined features with a simple API call from your codebase.
|
||||
|
||||
### 2. Embed the products in your application
|
||||
There are only 2 functions you need to call from your codebase:
|
||||
|
||||
1. `/entitled` - check if a user has access to a feature
|
||||
2. `/events` - *(optional)* track the usage of metered features
|
||||
|
||||
```javascript
|
||||
// 1. Check if a user has access to a feature
|
||||
const response = await fetch('https://api.useautumn.com/v1/entitled', {
|
||||
method: "POST",
|
||||
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
"customer_id": "my_personal_id",
|
||||
"feature_id": "premium_msg"
|
||||
})
|
||||
const { data } = await check({
|
||||
productId: "ai_tokens"
|
||||
})
|
||||
|
||||
// 2. Check if user is allowed
|
||||
let data = await response.json()
|
||||
if (!data.allowed) {
|
||||
throw new Error(`feature not allowed`)
|
||||
}
|
||||
!data.allowed && alert("AI limit reached")
|
||||
```
|
||||
|
||||
// 3. Send event if user accesses feature
|
||||
await fetch('https://api.useautumn.com/v1/events', {
|
||||
method: "POST",
|
||||
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
"customer_id": "my_personal_id",
|
||||
"event_name": "premium_msg"
|
||||
})
|
||||
3. `/track`: When a customer uses a usage-based feature, record a usage event.
|
||||
|
||||
```ts
|
||||
const { track } = useAutumn();
|
||||
|
||||
await track({
|
||||
featureId: "ai_tokens",
|
||||
value: 1312
|
||||
})
|
||||
```
|
||||
|
||||
No need to handle any customer logic. You can use your own customer IDs, and if the customer doesn't exist in Autumn, they'll be created automatically (and assigned any default plans).
|
||||
## Others
|
||||
|
||||
### 3. Attach a product to a customer on purchase
|
||||
**Contributing** 🤝: If you're interested in contributing, you can check out our guide [here](/.github/CONTRIBUTING.md). All types of help are appreciated :)
|
||||
|
||||
Whenever a customer attempts to purchase a product, you can assign it to them using the `/attach` function. If the card is not on file, Autumn will return a Stripe checkout URL to collect payment.
|
||||
**Support** 💬: If you need any type of support, we're typically most responsive on our [Discord channel](https://discord.gg/STqxY92zuS), but feel free to email us `hey@useautumn.com` too!
|
||||
|
||||
Otherwise, it will automatically handle any upgrade, downgrade or pro-ration logic.
|
||||
|
||||
```javascript
|
||||
const response = await fetch('https://api.useautumn.com/v1/attach', {
|
||||
method: "POST",
|
||||
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
"customer_id": "my_personal_id",
|
||||
"product_id": "prod_2rwydCAcuUp913PqoZwOjpy4aDM"
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.checkout_url) {
|
||||
// Redirect user to checkout_url
|
||||
}
|
||||
```
|
||||
|
||||
## Congratulations!
|
||||
<!-- ## Congratulations!
|
||||
|
||||
You've embedded a full billing system into your application within a few minutes. You can make any pricing model changes you need, or handle custom plans without needing to alter your codebase.
|
||||
|
||||
Feel free to self-host Autumn, or use our hosted version at https://useautumn.com. And let us know any questions, thoughts or feedback at hey@useautumn.com.
|
||||
Feel free to self-host Autumn, or use our hosted version at https://useautumn.com. And let us know any questions, thoughts or feedback at hey@useautumn.com. -->
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
valkey:
|
||||
image: docker.io/bitnami/valkey:8.0
|
||||
@@ -32,10 +32,24 @@ services:
|
||||
context: .
|
||||
dockerfile: docker/prod.dockerfile
|
||||
target: server-prod
|
||||
volumes:
|
||||
- ./server:/app/server
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: always
|
||||
|
||||
localtunnel:
|
||||
image: node:20-alpine
|
||||
build:
|
||||
dockerfile: docker/prod.dockerfile
|
||||
context: .
|
||||
target: localtunnel
|
||||
volumes:
|
||||
- ./server:/app/server
|
||||
depends_on:
|
||||
- server
|
||||
restart: unless-stopped
|
||||
|
||||
workers:
|
||||
environment:
|
||||
- REDIS_URL=redis://valkey:6379
|
||||
@@ -43,4 +57,10 @@ services:
|
||||
context: .
|
||||
dockerfile: docker/prod.dockerfile
|
||||
target: workers-prod
|
||||
restart: always
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
valkey-data:
|
||||
shared-dist:
|
||||
shared-node-modules:
|
||||
root-node-modules:
|
||||
@@ -10,11 +10,10 @@ COPY vite/package*.json ./vite/
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
|
||||
# # ---- Build shared ----
|
||||
# FROM base AS shared-build
|
||||
# COPY shared/ ./shared/
|
||||
# RUN pnpm -F shared build
|
||||
FROM base AS localtunnel
|
||||
WORKDIR /app
|
||||
COPY localtunnel-start.sh ./
|
||||
CMD ["sh", "localtunnel-start.sh"]
|
||||
|
||||
# # ---- Build frontend (vite) ----
|
||||
FROM base AS vite-build
|
||||
@@ -44,17 +43,3 @@ EXPOSE 8080
|
||||
WORKDIR /app
|
||||
CMD ["pnpm", "run", "server:workers"]
|
||||
|
||||
# # ---- Production backend image ----
|
||||
# FROM node:18-alpine AS server-prod
|
||||
# COPY --from=server-build /app/server/dist ./dist
|
||||
# COPY --from=server-build /app/server/package.json ./
|
||||
# COPY --from=server-build /app/server/node_modules ./node_modules
|
||||
# EXPOSE 8080
|
||||
# CMD ["pnpm", "run", "server:start"]
|
||||
|
||||
# # ---- Production workers image ----
|
||||
# FROM node:18-alpine AS workers-prod
|
||||
# COPY --from=server-build /app/server/dist ./dist
|
||||
# COPY --from=server-build /app/server/package.json ./
|
||||
# COPY --from=server-build /app/server/node_modules ./node_modules
|
||||
# CMD ["pnpm", "run", "server:workers"]
|
||||
@@ -1,112 +0,0 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
integer,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified")
|
||||
.$defaultFn(() => false)
|
||||
.notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.$defaultFn(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.$defaultFn(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
role: text("role"),
|
||||
banned: boolean("banned"),
|
||||
banReason: text("ban_reason"),
|
||||
banExpires: timestamp("ban_expires"),
|
||||
}).enableRLS();
|
||||
|
||||
export const session = pgTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
impersonatedBy: text("impersonated_by"),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
}).enableRLS();
|
||||
|
||||
export const account = pgTable("account", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at", {
|
||||
withTimezone: true,
|
||||
}),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", {
|
||||
withTimezone: true,
|
||||
}),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull(),
|
||||
}).enableRLS();
|
||||
|
||||
export const verification = pgTable("verification", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).$defaultFn(
|
||||
() => /* @__PURE__ */ new Date(),
|
||||
),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).$defaultFn(
|
||||
() => /* @__PURE__ */ new Date(),
|
||||
),
|
||||
}).enableRLS();
|
||||
|
||||
export const organization = pgTable("organization", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").unique(),
|
||||
logo: text("logo"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
||||
metadata: text("metadata"),
|
||||
}).enableRLS();
|
||||
|
||||
export const member = pgTable("member", {
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").default("member").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
||||
}).enableRLS();
|
||||
|
||||
export const invitation = pgTable("invitation", {
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: text("role"),
|
||||
status: text("status").default("pending").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
inviterId: text("inviter_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
}).enableRLS();
|
||||
2
server/src/external/logtail/logtailUtils.ts
vendored
2
server/src/external/logtail/logtailUtils.ts
vendored
@@ -9,6 +9,8 @@ const pinoLogger = initLogger();
|
||||
const createLogMethod = (pinoMethod: any, logtailMethod: any) => {
|
||||
function rewriteAppPath(str: string) {
|
||||
if (typeof str !== "string") return str;
|
||||
// Replace file:///app/ with ./
|
||||
str = str.replace("file:///app/", "./");
|
||||
return str.replace(/\/app\//g, "./");
|
||||
}
|
||||
|
||||
|
||||
6
server/src/external/stripe/utils.ts
vendored
6
server/src/external/stripe/utils.ts
vendored
@@ -110,3 +110,9 @@ export const stripeToAutumnInterval = ({
|
||||
return BillingInterval.Year;
|
||||
}
|
||||
};
|
||||
export const subItemToAutumnInterval = (item: Stripe.SubscriptionItem) => {
|
||||
return stripeToAutumnInterval({
|
||||
interval: item.price.recurring?.interval!,
|
||||
intervalCount: item.price.recurring?.interval_count!,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiAuthMiddleware } from "@/middleware/apiMiddleware.js";
|
||||
import { apiAuthMiddleware } from "@/middleware/apiAuthMiddleware.js";
|
||||
import { Router } from "express";
|
||||
import { eventsRouter } from "./events/eventRouter.js";
|
||||
import { cusRouter } from "./cusRouter.js";
|
||||
|
||||
@@ -94,7 +94,7 @@ export class FeatureService {
|
||||
env: updatedFeatures[0].env as AppEnv,
|
||||
});
|
||||
|
||||
return updatedFeatures as Feature[];
|
||||
return updatedFeatures.length > 0 ? updatedFeatures[0] : null;
|
||||
}
|
||||
|
||||
static async insert({
|
||||
|
||||
@@ -149,12 +149,12 @@ export const getObjectsUsingFeature = async ({
|
||||
export const runSaveFeatureDisplayTask = async ({
|
||||
db,
|
||||
feature,
|
||||
org,
|
||||
|
||||
logger,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
feature: Feature;
|
||||
org: Organization;
|
||||
|
||||
logger: any;
|
||||
}) => {
|
||||
let display;
|
||||
@@ -166,9 +166,7 @@ export const runSaveFeatureDisplayTask = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Generating feature display for ${feature.id} (org: ${org.slug})`,
|
||||
);
|
||||
logger.info(`Generating feature display for ${feature.id}`);
|
||||
display = await generateFeatureDisplay(feature);
|
||||
logger.info(`Result: ${JSON.stringify(display)}`);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const BillingIntervalOrder = [
|
||||
];
|
||||
|
||||
const ReversedBillingIntervalOrder = [
|
||||
BillingInterval.OneOff,
|
||||
// BillingInterval.OneOff,
|
||||
BillingInterval.Month,
|
||||
BillingInterval.Quarter,
|
||||
BillingInterval.SemiAnnual,
|
||||
|
||||
@@ -53,7 +53,6 @@ const initWorker = ({
|
||||
await runSaveFeatureDisplayTask({
|
||||
db,
|
||||
feature: job.data.feature,
|
||||
org: job.data.org,
|
||||
logger: logtail,
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { subItemToAutumnInterval } from "@/external/stripe/utils.js";
|
||||
import Stripe from "stripe";
|
||||
import {
|
||||
stripeToAutumnInterval,
|
||||
subItemToAutumnInterval,
|
||||
} from "tests/utils/stripeUtils.js";
|
||||
|
||||
export const logSubItems = (sub: Stripe.Subscription) => {
|
||||
for (const item of sub.items.data) {
|
||||
|
||||
@@ -243,6 +243,17 @@ button:focus-visible {
|
||||
--color-focus: rgba(139, 92, 246, 0.95);
|
||||
}
|
||||
|
||||
/* Hide number input spinners globally */
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield; /* Firefox */
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none; /* Chrome, Safari, Edge */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
---break--- */
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PriceItem } from "@/components/pricing/attach-pricing-dialog";
|
||||
import { formatAmount } from "@/utils/product/productItemUtils";
|
||||
import { AttachBranch } from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export const DueToday = () => {
|
||||
const { attachState, product, org } = useProductContext();
|
||||
@@ -67,7 +68,7 @@ export const DueToday = () => {
|
||||
<span>
|
||||
{product.name} - {feature_name}
|
||||
</span>
|
||||
<QuantityInput
|
||||
{/* <QuantityInput
|
||||
key={feature_name}
|
||||
value={quantity ? quantity / billing_units : ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -77,7 +78,22 @@ export const DueToday = () => {
|
||||
setOptions(newOptions);
|
||||
}}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
|
||||
</QuantityInput> */}
|
||||
<div className="flex items-center gap-2 ">
|
||||
<Input
|
||||
type="number"
|
||||
value={quantity ? quantity / billing_units : ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newOptions = [...options];
|
||||
newOptions[index].quantity =
|
||||
parseInt(e.target.value) * billing_units;
|
||||
setOptions(newOptions);
|
||||
}}
|
||||
className="w-12 h-7"
|
||||
/>
|
||||
|
||||
<span className="text-muted-foreground truncate max-w-40">
|
||||
×{" "}
|
||||
{formatAmount({
|
||||
defaultCurrency: currency,
|
||||
@@ -86,7 +102,7 @@ export const DueToday = () => {
|
||||
})}{" "}
|
||||
per {billing_units === 1 ? " " : billing_units} {feature_name}
|
||||
</span>
|
||||
</QuantityInput>
|
||||
</div>
|
||||
</PriceItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -55,17 +55,21 @@ const ConfigureWebhookSection = ({ dashboardUrl }: any) => {
|
||||
<div className="bg-white">
|
||||
<PageSectionHeader title="Webhooks" />
|
||||
|
||||
<AppPortal
|
||||
url={dashboardUrl}
|
||||
style={{
|
||||
height: "100%",
|
||||
borderRadius: "none",
|
||||
marginTop: "0.5rem",
|
||||
// paddingLeft: "1rem",
|
||||
// paddingRight: "1rem",
|
||||
}}
|
||||
fullSize
|
||||
/>
|
||||
{dashboardUrl ? (
|
||||
<AppPortal
|
||||
url={dashboardUrl}
|
||||
style={{
|
||||
height: "100%",
|
||||
borderRadius: "none",
|
||||
marginTop: "0.5rem",
|
||||
// paddingLeft: "1rem",
|
||||
// paddingRight: "1rem",
|
||||
}}
|
||||
fullSize
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground">Dashboard URL not found.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import FieldLabel from "@/components/general/modal-components/FieldLabel";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
|
||||
import { SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { keyToTitle, slugify } from "@/utils/formatUtils/formatTextUtils";
|
||||
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
|
||||
import {
|
||||
Reward,
|
||||
CouponDurationType,
|
||||
DiscountType,
|
||||
RewardType,
|
||||
ProductItem,
|
||||
} from "@autumn/shared";
|
||||
@@ -156,11 +154,11 @@ const ProductPriceSelector = ({
|
||||
|
||||
// Handle selection/deselection of a price
|
||||
const handlePriceToggle = (priceId: string) => {
|
||||
let newPriceIds = [...config.price_ids];
|
||||
if (config.price_ids.includes(priceId)) {
|
||||
newPriceIds = config.price_ids.filter((id) => id !== priceId);
|
||||
let newPriceIds = [...(config.price_ids || [])];
|
||||
if (config.price_ids?.includes(priceId)) {
|
||||
newPriceIds = config.price_ids?.filter((id) => id !== priceId) || [];
|
||||
} else {
|
||||
newPriceIds = [...config.price_ids, priceId];
|
||||
newPriceIds = [...(config.price_ids || []), priceId];
|
||||
}
|
||||
setConfig("price_ids", newPriceIds);
|
||||
};
|
||||
@@ -190,11 +188,11 @@ const ProductPriceSelector = ({
|
||||
>
|
||||
{config.apply_to_all ? (
|
||||
"All Products"
|
||||
) : config.price_ids.length == 0 ? (
|
||||
) : config.price_ids?.length == 0 ? (
|
||||
"Select Products"
|
||||
) : (
|
||||
<>
|
||||
{config.price_ids.map((priceId) => (
|
||||
{config.price_ids?.map((priceId) => (
|
||||
<div
|
||||
key={priceId}
|
||||
className="py-1 px-3 text-xs text-t3 border-zinc-300 bg-zinc-100 rounded-full w-fit flex items-center gap-2 h-fit max-w-full"
|
||||
@@ -270,7 +268,7 @@ const ProductPriceSelector = ({
|
||||
})}
|
||||
</span>
|
||||
|
||||
{config.price_ids.includes(item.price_id) && (
|
||||
{config.price_ids?.includes(item.price_id) && (
|
||||
<Check size={12} className="text-t3" />
|
||||
)}
|
||||
</CommandItem>
|
||||
|
||||
Reference in New Issue
Block a user