added tanstack query to customers and products page

This commit is contained in:
John Yeo
2025-09-01 18:08:38 -07:00
parent 2024fa1844
commit 76ab16bec4
99 changed files with 1556 additions and 2205 deletions

View File

@@ -48,9 +48,8 @@ const productFields = {
};
interface SearchFilters {
product_id?: string;
status?: string;
version?: string;
status?: string[];
version?: string[];
none?: string;
}
@@ -62,8 +61,8 @@ export class CusSearchService {
search,
filters,
pageSize = 50,
lastItem,
pageNumber,
// lastItem,
}: {
db: DrizzleCli;
orgId: string;
@@ -71,43 +70,43 @@ export class CusSearchService {
search: string;
filters: SearchFilters;
pageSize?: number;
lastItem?: {
internal_id: string;
created_at?: string;
name?: string;
} | null;
pageNumber: number;
// lastItem?: {
// internal_id: string;
// created_at?: string;
// name?: string;
// } | null;
}) {
// If we have a lastItem with only internal_id, fetch the full customer data for cursor pagination
let resolvedLastItem = lastItem;
if (lastItem && lastItem.internal_id && !lastItem.created_at) {
const customerData = await db
.select({
internal_id: customers.internal_id,
created_at: customers.created_at,
name: customers.name,
})
.from(customers)
.where(
and(
eq(customers.internal_id, lastItem.internal_id),
eq(customers.org_id, orgId),
eq(customers.env, env)
)
)
.limit(1);
// let resolvedLastItem = lastItem;
// if (lastItem && lastItem.internal_id && !lastItem.created_at) {
// const customerData = await db
// .select({
// internal_id: customers.internal_id,
// created_at: customers.created_at,
// name: customers.name,
// })
// .from(customers)
// .where(
// and(
// eq(customers.internal_id, lastItem.internal_id),
// eq(customers.org_id, orgId),
// eq(customers.env, env)
// )
// )
// .limit(1);
if (customerData.length > 0) {
resolvedLastItem = {
internal_id: customerData[0].internal_id,
created_at: customerData[0].created_at as any,
name: customerData[0].name || "",
};
} else {
// If customer not found, reset to no lastItem
resolvedLastItem = null;
}
}
// if (customerData.length > 0) {
// resolvedLastItem = {
// internal_id: customerData[0].internal_id,
// created_at: customerData[0].created_at as any,
// name: customerData[0].name || "",
// };
// } else {
// // If customer not found, reset to no lastItem
// resolvedLastItem = null;
// }
// }
let statuses: string[] = [];
@@ -117,10 +116,10 @@ export class CusSearchService {
eq(customerProducts.status, CusProductStatus.PastDue)
);
if (filters.status?.includes(",")) {
statuses = filters.status.split(",");
if (filters.status && filters.status.length > 0) {
statuses = filters.status;
} else {
statuses = [filters.status || ""];
statuses = [];
}
// Handle product:version combinations
@@ -128,24 +127,14 @@ export class CusSearchService {
[];
// Parse version field which now contains "productId:version,productId2:version2"
if (filters.version) {
const versionSelections = filters.version.split(",").filter(Boolean);
if (filters.version && filters.version.length > 0) {
const versionSelections = filters.version.filter(Boolean);
productVersionFilters = versionSelections.map((selection) => {
const [productId, version] = selection.split(":");
return { productId, version: parseInt(version) };
});
}
// Legacy support for product_id field (if still used)
let productIds: string[] = [];
if (filters.product_id) {
if (filters.product_id.includes(",")) {
productIds = filters.product_id.split(",").filter(Boolean);
} else {
productIds = [filters.product_id];
}
}
let filtersDrizzle = and(
// New product:version filtering
productVersionFilters.length > 0
@@ -159,9 +148,9 @@ export class CusSearchService {
)
: undefined,
// Legacy product filtering (fallback)
productIds.length > 0 && productVersionFilters.length === 0
? inArray(customerProducts.product_id, productIds)
: undefined,
// productIds.length > 0 && productVersionFilters.length === 0
// ? inArray(customerProducts.product_id, productIds)
// : undefined,
statuses.length > 0 && !statuses.includes("")
? or(
...statuses.map((status) => {
@@ -243,15 +232,14 @@ export class CusSearchService {
const whereClause = and(
shouldApplyActiveFilter ? activeProdFilter : undefined,
filtersDrizzle,
cusFilter,
resolvedLastItem && resolvedLastItem.internal_id
? lt(customers.internal_id, resolvedLastItem.internal_id)
: undefined
cusFilter
// resolvedLastItem && resolvedLastItem.internal_id
// ? lt(customers.internal_id, resolvedLastItem.internal_id)
// : undefined
);
// Execute query with appropriate pagination
const hasProductFilters =
productVersionFilters.length > 0 || productIds.length > 0;
const hasProductFilters = productVersionFilters.length > 0;
// Build the query based on pagination type
const buildQuery = () => {
@@ -281,7 +269,7 @@ export class CusSearchService {
};
let productQueryResult;
if (!resolvedLastItem && pageNumber > 1) {
if (pageNumber > 1) {
// Use offset-based pagination
const offset = (pageNumber - 1) * pageSize;
productQueryResult = buildQuery()
@@ -371,7 +359,6 @@ export class CusSearchService {
search,
filters,
pageSize = 50,
lastItem,
pageNumber,
}: {
db: DrizzleCli;
@@ -380,11 +367,6 @@ export class CusSearchService {
search: string;
filters: SearchFilters;
pageSize?: number;
lastItem?: {
internal_id: string;
created_at?: string;
name?: string;
} | null;
pageNumber: number;
}) {
const noneFilter = notExists(
@@ -413,14 +395,11 @@ export class CusSearchService {
ilike(customers.email, `%${search}%`)
)
: undefined,
noneFilter,
lastItem && lastItem.internal_id
? lt(customers.internal_id, lastItem.internal_id)
: undefined
noneFilter
);
let baseQuery;
if (!lastItem && pageNumber > 1) {
if (pageNumber > 1) {
// Use offset-based pagination
const offset = (pageNumber - 1) * pageSize;
baseQuery = db
@@ -529,12 +508,12 @@ export class CusSearchService {
search,
filters,
pageSize,
lastItem: resolvedLastItem,
// lastItem: resolvedLastItem,
pageNumber,
});
}
if (filters?.product_id || filters?.status || filters?.version) {
if (filters?.version) {
return await this.searchByProduct({
db,
orgId,
@@ -542,7 +521,7 @@ export class CusSearchService {
search,
filters,
pageSize,
lastItem: resolvedLastItem,
// lastItem: resolvedLastItem,
pageNumber,
});
}
@@ -663,3 +642,13 @@ export class CusSearchService {
return { data: finalResults, count: totalCount };
}
}
// // Legacy support for product_id field (if still used)
// let productIds: string[] = [];
// if (filters.product_id) {
// if (filters.product_id.includes(",")) {
// productIds = filters.product_id.split(",").filter(Boolean);
// } else {
// productIds = [filters.product_id];
// }
// }

View File

@@ -24,10 +24,35 @@ import { CusReadService } from "./CusReadService.js";
import { StatusCodes } from "http-status-codes";
import { cusProductToProduct } from "./cusProducts/cusProductUtils/convertCusProduct.js";
import { createOrgResponse } from "../orgs/orgUtils.js";
import { getCustomerSub } from "./attach/attachUtils/convertAttachParams.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { CusSearchService } from "./CusSearchService.js";
export const cusRouter: Router = Router();
cusRouter.post("/all/search", (req, res) =>
routeHandler({
req,
res,
action: "search customers",
handler: async (req, res) => {
const { search, page_size = 50, page = 1, last_item, filters } = req.body;
const { data: customers, count } = await CusSearchService.search({
db: req.db,
orgId: req.orgId,
env: req.env,
search,
filters,
lastItem: last_item,
pageNumber: page,
pageSize: page_size,
});
res.status(200).json({ customers, totalCount: Number(count) });
},
})
);
cusRouter.get("/:customer_id/events", async (req: any, res: any) => {
try {
const { db, org, features, env } = req;

View File

@@ -99,6 +99,26 @@ productRouter.get("/features", async (req: any, res) => {
}
});
productRouter.get("/rewards", async (req: any, res) => {
try {
const { db, orgId, env } = req;
const rewards = await RewardService.list({ db, orgId, env });
const rewardPrograms = await RewardProgramService.list({
db,
orgId,
env,
});
res.status(200).send({ rewards, rewardPrograms });
} catch (error) {
handleFrontendReqError({
error,
req,
res,
action: "Get rewards",
});
}
});
productRouter.get("/data", async (req: any, res) => {
try {
let { db } = req;

View File

@@ -3,7 +3,6 @@ import CustomerView from "./views/customers/customer/CustomerView";
import CustomerProductView from "./views/customers/customer/product/CustomerProductView";
import CustomersView from "./views/customers/CustomersView";
import DevScreen from "./views/developer/DevView";
import FeaturesView from "./views/features/FeaturesView";
import ProductView from "./views/products/product/ProductView";
import ProductsView from "./views/products/ProductsView";
import CliAuth from "./views/CliAuth";
@@ -34,14 +33,7 @@ export default function App() {
<Route path="/onboarding" element={<OnboardingView2 />} />
<Route path="/sandbox/onboarding" element={<OnboardingView2 />} />
<Route path="/cli-auth" element={<CliAuth />} />
<Route
path="/features"
element={<FeaturesView env={AppEnv.Live} />}
/>
<Route
path="/sandbox/features"
element={<FeaturesView env={AppEnv.Sandbox} />}
/>
<Route
path="/products"
element={<ProductsView env={AppEnv.Live} />}

View File

@@ -22,6 +22,8 @@ import { NuqsAdapter } from "nuqs/adapters/react-router/v7";
import { useGlobalErrorHandler } from "@/hooks/common/useGlobalErrorHandler";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { useCusSearchQuery } from "@/views/customers/hooks/useCusSearchQuery";
export function MainLayout() {
const env = useEnv();
@@ -122,6 +124,8 @@ const MainContent = () => {
useProductsQuery();
useFeaturesQuery();
useRewardsQuery();
useCusSearchQuery();
return (
<AppContext.Provider value={{}}>

View File

@@ -9,6 +9,7 @@ export const PageSectionHeader = ({
className,
classNames,
menuComponent,
isSecondary = false,
}: {
title?: string;
titleComponent?: React.ReactNode;
@@ -20,6 +21,7 @@ export const PageSectionHeader = ({
title?: string;
};
menuComponent?: React.ReactNode;
isSecondary?: boolean;
}) => {
return (
<div
@@ -32,7 +34,13 @@ export const PageSectionHeader = ({
>
<div className="flex items-center gap-2">
{title && (
<h2 className={cn("text-sm text-t2 font-medium", classNames?.title)}>
<h2
className={cn(
"text-sm text-t2 font-medium",
classNames?.title,
isSecondary && "text-sm"
)}
>
{title}
</h2>
)}

View File

@@ -0,0 +1,36 @@
import { debounce } from "lodash";
import { parseAsString, useQueryStates } from "nuqs";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
type SecondaryTabType =
| "api_keys"
| "stripe"
| "products"
| "rewards"
| "features"
| "webhooks";
export const useAppQueryStates = ({
defaultTab,
}: {
defaultTab?: SecondaryTabType;
}) => {
const [queryStates, setQueryStates] = useQueryStates({
tab: parseAsString.withDefault(defaultTab || ""),
});
const [stableStates, setStableStates] = useState(queryStates);
useEffect(() => {
const debouncedSetStableStates = debounce((queryStates: any) => {
setStableStates(queryStates);
}, 40);
debouncedSetStableStates(queryStates);
}, [queryStates]);
return {
queryStates: stableStates,
setQueryStates,
};
};

View File

@@ -1,4 +1,5 @@
import { useEffect } from "react";
import { parseAsString, useQueryStates } from "nuqs";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
type SecondaryTabType =
@@ -15,13 +16,18 @@ export const useSecondaryTab = ({
defaultTab?: SecondaryTabType;
}) => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// const [searchParams] = useSearchParams();
const [queryStates, setQueryStates] = useQueryStates({
tab: parseAsString.withDefault(defaultTab || ""),
});
const [stableStates, setStableStates] = useState(queryStates);
useEffect(() => {
if (defaultTab && !searchParams.get("tab")) {
navigate(`?tab=${defaultTab}`);
}
// if (defaultTab && !stableStates.tab) {
// navigate(`?tab=${defaultTab}`);
// }
}, [defaultTab]);
return (searchParams.get("tab") as SecondaryTabType) || "";
return (stableStates.tab as SecondaryTabType) || "";
};

View File

@@ -17,5 +17,5 @@ export const useFeaturesQuery = () => {
queryFn: fetchFeatures,
});
return { features: data?.features || [], isLoading, error, mutate: refetch };
return { features: data?.features || [], isLoading, error, refetch };
};

View File

@@ -0,0 +1,27 @@
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useQuery } from "@tanstack/react-query";
export const useGeneralQuery = ({
url,
queryKey,
enabled,
}: {
url: string;
queryKey?: string[];
enabled?: boolean;
}) => {
const axiosInstance = useAxiosInstance();
const fetcher = async () => {
const { data } = await axiosInstance.get(url);
return data;
};
const { data, isLoading, error, refetch } = useQuery({
queryKey: queryKey || ["general", url],
queryFn: fetcher,
enabled,
});
return { data, isLoading, error, refetch };
};

View File

@@ -23,12 +23,9 @@ export const useProductsQuery = () => {
queryFn: fetchProducts,
});
const {
data: countsData,
isLoading: isCountsLoading,
error: countsError,
refetch: countsRefetch,
} = useQuery<Record<string, ProductCounts>>({
const { data: countsData, refetch: countsRefetch } = useQuery<
Record<string, ProductCounts>
>({
queryKey: ["product_counts"],
queryFn: fetchProductCounts,
});
@@ -39,8 +36,11 @@ export const useProductsQuery = () => {
groupToDefaults: data?.groupToDefaults || {},
isLoading,
error,
mutate: async () => {
refetch: async () => {
await Promise.all([countsRefetch(), refetch()]);
},
// mutate: async () => {
// await Promise.all([countsRefetch(), refetch()]);
// },
};
};

View File

@@ -0,0 +1,24 @@
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useQuery } from "@tanstack/react-query";
export const useRewardsQuery = () => {
const axiosInstance = useAxiosInstance();
const fetchRewards = async () => {
const { data } = await axiosInstance.get("/products/rewards");
return data;
};
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["rewards"],
queryFn: fetchRewards,
});
return {
rewards: data?.rewards || [],
rewardPrograms: data?.rewardPrograms || [],
isLoading,
error,
refetch,
};
};

View File

@@ -7,7 +7,13 @@ import { PostHogProvider } from "posthog-js/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchInterval: 0,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>

View File

@@ -49,15 +49,62 @@ export const envToPath = (env: AppEnv, currentPath: string) => {
return null;
};
export const navigateTo = (path: string, navigate: any, env: AppEnv) => {
export const navigateTo = (path: string, navigate: any, env?: AppEnv) => {
const curPath = window.location.pathname;
const curEnv = getEnvFromPath(curPath);
path = path.replace("@", "%40");
if (env === AppEnv.Sandbox) {
if (curEnv === AppEnv.Sandbox) {
navigate(`/sandbox${path}`);
} else {
navigate(path);
}
};
export const pushPage = ({
path,
queryParams,
navigate,
preserveParams = true,
}: {
path: string;
queryParams: Record<string, string | undefined>;
navigate?: any;
preserveParams?: boolean;
}) => {
const curPath = window.location.pathname;
const curEnv = getEnvFromPath(curPath);
const curQueryParams = new URLSearchParams(window.location.search);
if (!preserveParams) {
curQueryParams.forEach((value, key) => {
curQueryParams.delete(key);
});
}
if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) {
if (value) {
curQueryParams.set(key, value);
}
}
}
path = path.replace("@", "%40");
if (curQueryParams.toString()) {
path = `${path}?${curQueryParams.toString()}`;
}
if (navigate) {
if (curEnv === AppEnv.Sandbox) {
navigate(`/sandbox${path}`);
} else {
navigate(path);
}
}
return path;
};
export const getRedirectUrl = (path: string, env: AppEnv) => {
// Replace @ with %40
path = path.replace("@", "%40");

View File

@@ -2,6 +2,7 @@ import {
BillingInterval,
Feature,
FeatureType,
FrontendOrg,
Infinite,
Organization,
ProductItem,
@@ -30,31 +31,31 @@ const getIntervalString = ({
export const getPaidFeatureString = ({
item,
org,
currency = "USD",
features,
}: {
item: ProductItem;
org: Organization;
currency?: string;
features: Feature[];
}) => {
let amountStr = "";
if (item.price) {
amountStr = formatAmount({
defaultCurrency: org?.default_currency || "USD",
defaultCurrency: currency,
amount: item.price,
});
} else if (item.tiers && item.tiers.length == 1) {
amountStr = formatAmount({
defaultCurrency: org?.default_currency || "USD",
defaultCurrency: currency,
amount: item.tiers![0].amount,
});
} else {
amountStr = `${formatAmount({
defaultCurrency: org?.default_currency || "USD",
defaultCurrency: currency,
amount: item.tiers![0].amount,
})} - ${formatAmount({
defaultCurrency: org?.default_currency || "USD",
defaultCurrency: currency,
amount: item.tiers![item.tiers!.length - 1].amount,
})}`;
}
@@ -82,12 +83,11 @@ export const getPaidFeatureString = ({
const getFixedPriceString = ({
item,
org,
currency = "USD",
}: {
item: ProductItem;
org: Organization;
currency?: string;
}) => {
const currency = org?.default_currency || "USD";
const formattedAmount = formatAmount({
defaultCurrency: currency,
amount: item.price!,
@@ -135,7 +135,7 @@ export const formatProductItemText = ({
features,
}: {
item: ProductItem;
org: Organization;
org?: FrontendOrg;
features: Feature[];
}) => {
if (!item) return "";
@@ -143,8 +143,12 @@ export const formatProductItemText = ({
const itemType = getItemType(item);
if (itemType == ProductItemType.FeaturePrice) {
return getPaidFeatureString({ item, org, features });
return getPaidFeatureString({
item,
currency: org?.default_currency,
features,
});
} else if (itemType == ProductItemType.Price) {
return getFixedPriceString({ item, org });
return getFixedPriceString({ item, currency: org?.default_currency });
}
};

View File

@@ -1,4 +1,4 @@
import { ProductItemType } from "@autumn/shared";
import { ProductItemType, ProductV2 } from "@autumn/shared";
import { ProductItem } from "@autumn/shared";
import { getItemType } from "./product/productItemUtils";
@@ -19,3 +19,10 @@ export const sortProductItems = (items: ProductItem[]) => {
return sortedItems;
};
export const getVersionCounts = (products: ProductV2[]) => {
return products.reduce((acc: any, product: any) => {
acc[product.id] = product.version;
return acc;
}, {});
};

View File

@@ -1,13 +0,0 @@
import { createContext, useContext } from "react";
export const HomeContext = createContext<any>(null);
export const useHomeContext = () => {
const context = useContext(HomeContext);
if (context === undefined) {
throw new Error("useHomeContext must be used within a HomeContextProvider");
}
return context;
};

View File

@@ -1,39 +0,0 @@
"use client";
import React, { useState } from "react";
import { HomeContext } from "./HomeContext";
import { Toaster } from "react-hot-toast";
export enum HomeTab {
Features = "features",
Credits = "credits",
Plans = "plans",
Developer = "developer",
}
function HomeView() {
const [activeTab, setActiveTab] = useState(HomeTab.Plans);
const [error, setError] = useState<string | null>(null);
return (
<HomeContext.Provider
value={{
activeTab,
setActiveTab,
error,
setError,
}}
>
<Toaster
position="bottom-center"
toastOptions={{
duration: 1000,
style: { fontSize: "14px" },
}}
/>
</HomeContext.Provider>
);
}
export default HomeView;

View File

@@ -1,79 +0,0 @@
import SmallSpinner from "@/components/general/SmallSpinner";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useState } from "react";
import { toast } from "sonner";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { Feature } from "@autumn/shared";
import { FeatureService } from "@/services/FeatureService";
import { getBackendErr } from "@/utils/genUtils";
import { useFeaturesContext } from "../features/FeaturesContext";
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
import { Delete } from "lucide-react";
import { DeleteFeatureDialog } from "../features/components/DeleteFeatureDialog";
export const CreditSystemRowToolbar = ({
creditSystem,
}: {
creditSystem: Feature;
}) => {
const { env, mutate } = useFeaturesContext();
const axiosInstance = useAxiosInstance({ env });
const [deleteLoading, setDeleteLoading] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// const handleDelete = async () => {
// setDeleteLoading(true);
// try {
// await FeatureService.deleteFeature(axiosInstance, creditSystem.id);
// await mutate();
// } catch (error) {
// toast.error(getBackendErr(error, "Failed to delete feature"));
// }
// setDeleteLoading(false);
// setDeleteOpen(false);
// };
return (
<>
<DeleteFeatureDialog
feature={creditSystem}
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
/>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<ToolbarButton />
</DropdownMenuTrigger>
<DropdownMenuContent className="text-t2" align="end">
<DropdownMenuItem
className="flex items-center"
onClick={async (e) => {
e.stopPropagation();
e.preventDefault();
setDeleteDialogOpen(true);
setDropdownOpen(false);
}}
>
<div className="flex items-center justify-between w-full gap-2">
Delete
{deleteLoading ? (
<SmallSpinner />
) : (
<Delete size={14} className="text-t3" />
)}
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};

View File

@@ -1,39 +0,0 @@
"use client";
import { AppEnv } from "@autumn/shared";
import CreateCreditSystem from "./CreateCreditSystem";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import LoadingScreen from "../general/LoadingScreen";
import { FeaturesContext } from "../features/FeaturesContext";
import { CreditSystemsTable } from "./CreditSystemsTable";
function CreditSystemsView({ env }: { env: AppEnv }) {
const { data, isLoading, error, mutate } = useAxiosSWR({
url: "/features",
env: env,
});
if (isLoading) return <LoadingScreen />;
return (
<FeaturesContext.Provider
value={{
features: data?.features,
env: env,
mutate,
}}
>
<div>
<h1 className="text-xl font-medium">Credits</h1>
<p className="text-sm text-t2">
Define a credits system to bill for your users&apos; usage. These are
made of other metered features.
</p>
</div>
<CreditSystemsTable />
<CreateCreditSystem />
</FeaturesContext.Provider>
);
}
export default CreditSystemsView;

View File

@@ -1,15 +0,0 @@
import { createContext, useContext } from "react";
export const CreditsContext = createContext<any>(null);
export const useCreditsContext = () => {
const context = useContext(CreditsContext);
if (context === undefined) {
throw new Error(
"useCreditsContext must be used within a CreditsContextProvider"
);
}
return context;
};

View File

@@ -1,296 +1,206 @@
"use client";
import React, { useEffect, useRef } from "react";
import React, { useRef } from "react";
import { AppEnv } from "@autumn/shared";
import { useAxiosPostSWR, useAxiosSWR } from "@/services/useAxiosSwr";
import { CustomersContext } from "./CustomersContext";
import { CustomersTable } from "./CustomersTable";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import CreateCustomer from "./CreateCustomer";
import { SearchBar } from "./SearchBar";
import { CustomersTable } from "./components/CustomersTable";
import LoadingScreen from "../general/LoadingScreen";
import FilterButton from "./FilterButton";
import SmallSpinner from "@/components/general/SmallSpinner";
import { useQueryStates, parseAsString, parseAsInteger } from "nuqs";
import { CustomersTopBar } from "./components/customers-top-bar/CustomersTopBar";
import { useCusSearchQuery } from "./hooks/useCusSearchQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useCustomersQueryStates } from "./hooks/useCustomersQueryStates";
import { useSavedViewsQuery } from "./hooks/useSavedViewsQuery";
function CustomersView({ env }: { env: AppEnv }) {
const pageSize = 50;
const { customers, totalCount, isLoading, error, refetch } =
useCusSearchQuery();
const [queryStates, setQueryStates] = useQueryStates(
{
q: parseAsString.withDefault(""),
status: parseAsString.withDefault(""),
product_id: parseAsString.withDefault(""),
version: parseAsString.withDefault(""),
none: parseAsString.withDefault(""),
page: parseAsInteger.withDefault(1),
lastItemId: parseAsString.withDefault(""),
},
{
history: "replace",
}
);
const { queryStates, setQueryStates } = useCustomersQueryStates();
const [searching, setSearching] = React.useState(false);
const [paginationLoading, setPaginationLoading] = React.useState(false);
const { data: productsData, isLoading: productsLoading } = useAxiosSWR({
url: `/products/data?all_versions=true`,
});
const { data: savedViewsData, mutate: mutateSavedViews } = useAxiosSWR({
url: "/saved_views",
});
const { data, isLoading, error, mutate } = useAxiosPostSWR({
url: `/v1/customers/all/search`,
env,
data: {
search: queryStates.q || "",
filters: {
status: queryStates.status,
product_id: queryStates.product_id,
version: queryStates.version,
none: queryStates.none,
},
page: queryStates.page,
page_size: pageSize,
last_item: queryStates.lastItemId
? { internal_id: queryStates.lastItemId }
: null,
},
});
const { products, isLoading: productsLoading } = useProductsQuery();
useSavedViewsQuery();
// const { data, isLoading, error, mutate } = useAxiosPostSWR({
// url: `/v1/customers/all/search`,
// env,
// data: {
// search: queryStates.q || "",
// filters: {
// status: queryStates.status,
// product_id: queryStates.product_id,
// version: queryStates.version,
// none: queryStates.none,
// },
// page: queryStates.page,
// page_size: pageSize,
// last_item: queryStates.lastItemId
// ? { internal_id: queryStates.lastItemId }
// : null,
// },
// });
const isFirstRender = useRef(true);
const paginationFirstRender = useRef(true);
const searchParamsChanged = useRef(false);
const isDirectNavigation = useRef(false);
const resetPagination = () => {
setQueryStates({
page: 1,
lastItemId: "",
});
};
// const resetPagination = () => {
// setQueryStates({
// page: 1,
// lastItemId: "",
// });
// };
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
// useEffect(() => {
// if (isFirstRender.current) {
// isFirstRender.current = false;
const hasFilters =
queryStates.q ||
queryStates.status ||
queryStates.product_id ||
queryStates.version ||
queryStates.none;
const hasDirectNavigation =
queryStates.page > 1 || !!queryStates.lastItemId;
// const hasFilters =
// queryStates.q ||
// queryStates.status ||
// queryStates.product_id ||
// queryStates.version ||
// queryStates.none;
// const hasDirectNavigation =
// queryStates.page > 1 || !!queryStates.lastItemId;
if (!hasFilters && !hasDirectNavigation) {
return;
}
// if (!hasFilters && !hasDirectNavigation) {
// return;
// }
isDirectNavigation.current = hasDirectNavigation;
// isDirectNavigation.current = hasDirectNavigation;
setPaginationLoading(true);
mutate().finally(() => {
setPaginationLoading(false);
});
return;
}
// setPaginationLoading(true);
// refetch().finally(() => {
// setPaginationLoading(false);
// });
// return;
// }
if (isDirectNavigation.current) {
isDirectNavigation.current = false;
return;
}
// if (isDirectNavigation.current) {
// isDirectNavigation.current = false;
// return;
// }
searchParamsChanged.current = true;
resetPagination();
// searchParamsChanged.current = true;
// resetPagination();
setPaginationLoading(true);
mutate().finally(() => {
setPaginationLoading(false);
});
}, [
queryStates.q,
queryStates.status,
queryStates.product_id,
queryStates.version,
queryStates.none,
]);
// setPaginationLoading(true);
// refetch().finally(() => {
// setPaginationLoading(false);
// });
// }, [
// queryStates.q,
// queryStates.status,
// queryStates.product_id,
// queryStates.version,
// queryStates.none,
// ]);
useEffect(() => {
if (paginationFirstRender.current) {
paginationFirstRender.current = false;
return;
}
// useEffect(() => {
// if (paginationFirstRender.current) {
// paginationFirstRender.current = false;
// return;
// }
if (searchParamsChanged.current) {
searchParamsChanged.current = false;
if (queryStates.page === 1) {
return;
}
}
// if (searchParamsChanged.current) {
// searchParamsChanged.current = false;
// if (queryStates.page === 1) {
// return;
// }
// }
// For direct navigation or actual pagination, we fetch the data
// setPaginationLoading(true);
// refetch().finally(() => {
// setPaginationLoading(false);
// });
// }, [queryStates.page, queryStates.lastItemId]);
setPaginationLoading(true);
mutate().finally(() => {
setPaginationLoading(false);
});
}, [queryStates.page, queryStates.lastItemId]);
const totalPages = Math.ceil((data?.totalCount || 0) / pageSize);
if (isLoading || productsLoading) {
if (productsLoading) {
return <LoadingScreen />;
}
const handleNextPage = async () => {
if (totalPages == 0 || queryStates.page === totalPages) return;
const lastItem = data?.customers[data?.customers.length - 1];
// const handleNextPage = async () => {
// if (totalPages == 0 || queryStates.page === totalPages) return;
// const lastItem = data?.customers[data?.customers.length - 1];
setQueryStates({
page: queryStates.page + 1,
lastItemId: lastItem.internal_id, // This becomes the "cursor" for the next page
});
};
// setQueryStates({
// page: queryStates.page + 1,
// lastItemId: lastItem.internal_id, // This becomes the "cursor" for the next page
// });
// };
const handlePreviousPage = async () => {
if (queryStates.page === 1) return;
// For previous page, we clear the lastItemId to force offset-based pagination
setQueryStates({
page: queryStates.page - 1,
lastItemId: "",
});
};
// const handlePreviousPage = async () => {
// if (queryStates.page === 1) return;
// // For previous page, we clear the lastItemId to force offset-based pagination
// setQueryStates({
// page: queryStates.page - 1,
// lastItemId: "",
// });
// };
const handleFilterChange = (newFilters: any) => {
const params: Record<string, string | number> = {
page: 1,
lastItemId: "",
};
// const handleFilterChange = (newFilters: any) => {
// const params: Record<string, string | number> = {
// page: 1,
// lastItemId: "",
// };
if (newFilters?.status?.length > 0) {
params.status = newFilters.status.join(",");
} else {
params.status = "";
}
// if (newFilters?.status?.length > 0) {
// params.status = newFilters.status.join(",");
// } else {
// params.status = "";
// }
// Handle new version-based filtering (productId:version format)
if (newFilters?.version) {
params.version = newFilters.version;
} else {
params.version = "";
}
// // Handle new version-based filtering (productId:version format)
// if (newFilters?.version) {
// params.version = newFilters.version;
// } else {
// params.version = "";
// }
// Legacy product_id support (keep for now)
if (newFilters?.product_id?.length > 0) {
params.product_id = newFilters.product_id.join(",");
} else {
params.product_id = "";
}
// // Legacy product_id support (keep for now)
// if (newFilters?.product_id?.length > 0) {
// params.product_id = newFilters.product_id.join(",");
// } else {
// params.product_id = "";
// }
// Handle none filter
if (newFilters?.none) {
params.none = "true";
} else {
params.none = "";
}
// // Handle none filter
// if (newFilters?.none) {
// params.none = "true";
// } else {
// params.none = "";
// }
setQueryStates(params);
mutate();
};
// setQueryStates(params);
// refetch();
// };
return (
<CustomersContext.Provider
value={{
customers: data?.customers,
env,
mutate,
filters: {
status: queryStates.status?.split(",").filter(Boolean) || [],
product_id: queryStates.product_id?.split(",").filter(Boolean) || [],
version: queryStates.version,
none: queryStates.none === "true",
},
setFilters: handleFilterChange,
products: productsData?.products,
versionCounts: productsData?.versionCounts,
setQueryStates,
mutateSavedViews,
customers,
// env,
// mutate,
// filters: {
// status: queryStates.status?.split(",").filter(Boolean) || [],
// product_id: queryStates.product_id?.split(",").filter(Boolean) || [],
// version: queryStates.version,
// none: queryStates.none === "true",
// },
// setFilters: handleFilterChange,
// products: productsData?.products,
// versionCounts: productsData?.versionCounts,
// setQueryStates,
// mutateSavedViews,
}}
>
<div className="flex flex-col gap-4 h-fit relative w-full">
<div className="flex flex-col gap-4 h-fit relative w-full ">
<h1 className="text-xl font-medium shrink-0 pt-6 pl-10">Customers</h1>
<div>
<div className="flex w-full justify-between sticky top-0 z-10 border-y pl-10 pr-7 items-center bg-stone-100 h-10">
<div className="flex items-center">
<div className="pr-4 flex items-center justify-center gap-2 h-10">
<FilterButton />
</div>
<SearchBar
query={queryStates.q || ""}
setQuery={(query: string) => setQueryStates({ q: query })}
setCurrentPage={(page: number) => {
setQueryStates({
page: page,
lastItemId: "",
});
}}
mutate={mutate}
setSearching={setSearching}
/>
<div className="w-[140px] flex justify-center items-center gap-8 text-xs text-t3 rounded-sm shrink-0 h-10 border-r">
{paginationLoading && !searching ? (
<div className="h-8 flex items-center justify-center">
<SmallSpinner />
</div>
) : (
<Pagination className="w-fit h-8 text-xs">
<PaginationContent className="w-full flex justify-between ">
<PaginationItem>
<PaginationPrevious
onClick={handlePreviousPage}
isActive={queryStates.page !== 1}
className="text-xs cursor-pointer p-1 h-6"
/>
</PaginationItem>
<PaginationItem className="">
{queryStates.page} / {Math.max(totalPages, 1)}
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={handleNextPage}
isActive={queryStates.page !== totalPages}
className="text-xs cursor-pointer p-1 h-6"
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
<div className="pl-4">
<p className="text-t2 px-1 rounded-md bg-stone-200 text-sm">
{data?.totalCount}
</p>
</div>
</div>
<div className="flex gap-4 bg-blue-100">
<CreateCustomer />
</div>
</div>
{data?.customers?.length > 0 ? (
<CustomersTopBar />
{customers?.length && customers?.length > 0 ? (
<div className="h-fit max-h-full">
<CustomersTable customers={data.customers} />
<CustomersTable customers={customers} />
</div>
) : (
<div className="flex flex-col px-10 mt-3 text-t3 text-sm w-full min-h-[60vh] gap-4">

View File

@@ -1,74 +0,0 @@
import SmallSpinner from "@/components/general/SmallSpinner";
import { debounce } from "lodash";
import { useEffect, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
export function SearchBar({
query,
setQuery,
setCurrentPage,
mutate,
setSearching,
}: {
query: string;
setQuery: (query: string) => void;
setCurrentPage: (page: number) => void;
setSearching: (searching: boolean) => void;
mutate: () => Promise<void>;
}) {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
const [loading, setLoading] = useState(false);
const prevQueryRef = useRef<string>(query);
const debouncedSearch = useMemo(
() =>
debounce(async (query: string) => {
setSearching(true);
let params = new URLSearchParams(location.search);
params.set("q", query);
navigate(`${location.pathname}?${params.toString()}`);
}, 350),
[location.search, location.pathname, navigate, setSearching]
);
const handleQueryChange = async () => {
setLoading(true);
setCurrentPage(1);
setSearching(true);
await mutate();
setLoading(false);
setSearching(false);
};
useEffect(() => {
const searchParamQuery = searchParams.get("q") || "";
if (searchParamQuery !== prevQueryRef.current) {
prevQueryRef.current = searchParamQuery;
handleQueryChange();
}
}, [searchParams]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const q = e.target.value;
debouncedSearch(q);
};
return (
<div
className="rounded-sm py-1 h-10 px-2 text-sm pl-4
flex items-center w-full max-w-lg min-w-xs text-t2 border-x"
>
<Search size={13} className="text-t3 mr-2" />
<input
onChange={handleChange}
className="outline-none w-full bg-transparent"
placeholder="Search..."
defaultValue={query}
></input>
<div className="w-5 h-5 ml-1">{loading && <SmallSpinner />}</div>
</div>
);
}

View File

@@ -17,10 +17,10 @@ import { useNavigate } from "react-router";
import { getBackendErr, navigateTo } from "@/utils/genUtils";
import { toast } from "sonner";
import { useEnv } from "@/utils/envUtils";
function CreateCustomer() {
const env = useEnv();
const navigate = useNavigate();
const axiosInstance = useAxiosInstance({ env });
const axiosInstance = useAxiosInstance();
const [fields, setFields] = useState<{ [key: string]: string }>({
name: "",
id: "",
@@ -48,8 +48,7 @@ function CreateCustomer() {
`/customers/${
customer.id || customer.autumn_id || customer.internal_id
}`,
navigate,
env
navigate
);
}
toast.success("Customer created successfully");

View File

@@ -7,18 +7,12 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useState } from "react";
import { toast } from "sonner";
import { AppEnv, Customer } from "@autumn/shared";
import { useCustomersContext } from "./CustomersContext";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
import { Customer } from "@autumn/shared";
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { Trash } from "lucide-react";
import { CusService } from "@/services/customers/CusService";
import { DeleteCustomerDialog } from "./customer/components/DeleteCustomer";
import { useEnv } from "@/utils/envUtils";
import { DeleteCustomerDialog } from "../customer/components/DeleteCustomer";
import { useCusSearchQuery } from "../hooks/useCusSearchQuery";
export const CustomerRowToolbar = ({
customer,
@@ -26,25 +20,10 @@ export const CustomerRowToolbar = ({
className?: string;
customer: Customer;
}) => {
const { mutate } = useCustomersContext();
const axiosInstance = useAxiosInstance();
const { refetch } = useCusSearchQuery();
const [deleteLoading, setDeleteLoading] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const env = useEnv();
const handleDelete = async () => {
setDeleteLoading(true);
try {
await CusService.deleteCustomer(axiosInstance, customer.id);
await mutate();
} catch (error) {
console.log("Error deleting customer", error);
toast.error(getBackendErr(error, "Failed to delete customer"));
}
setDeleteLoading(false);
setDropdownOpen(false);
};
return (
<>
@@ -53,7 +32,7 @@ export const CustomerRowToolbar = ({
open={deleteOpen}
setOpen={setDeleteOpen}
onDelete={async () => {
await mutate();
await refetch();
}}
/>
@@ -80,10 +59,6 @@ export const CustomerRowToolbar = ({
)}
</div>
</DropdownMenuItem>
{/* {env == AppEnv.Sandbox && (
)} */}
</DropdownMenuContent>
</DropdownMenu>
</>

View File

@@ -0,0 +1,63 @@
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { useCustomersQueryStates } from "../hooks/useCustomersQueryStates";
import SmallSpinner from "@/components/general/SmallSpinner";
import { useCusSearchQuery } from "../hooks/useCusSearchQuery";
export const CustomersPagination = () => {
const { isLoading, totalCount, isFetchingUncached } = useCusSearchQuery();
const { queryStates, setQueryStates } = useCustomersQueryStates();
const totalPages = Math.ceil((totalCount || 0) / 50);
const currentPage = Number(queryStates.page) || 1;
const canGoPrev = currentPage > 1;
return (
<div className="w-[140px] flex justify-center items-center gap-8 text-xs text-t3 rounded-sm shrink-0 h-10 border-r select-none">
{isLoading ? (
<div className="h-8 flex items-center justify-center">
<SmallSpinner />
</div>
) : (
<Pagination className="w-fit h-8 text-xs">
<PaginationContent className="w-full flex justify-between ">
<PaginationItem>
<PaginationPrevious
onClick={async (e) => {
e.preventDefault();
if (!canGoPrev) return;
await setQueryStates({
page: currentPage - 1,
});
}}
isActive={canGoPrev}
aria-disabled={!canGoPrev}
className={`text-xs cursor-pointer p-1 h-6 ${!canGoPrev ? "pointer-events-none opacity-50" : ""}`}
/>
</PaginationItem>
<PaginationItem className="">
{currentPage} / {Math.max(totalPages, 1)}
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={async (e) => {
e.preventDefault();
await setQueryStates({
page: currentPage + 1,
});
}}
isActive={currentPage > totalPages}
aria-disabled={currentPage === totalPages}
className={`text-xs cursor-pointer p-1 h-6 ${currentPage === totalPages ? "pointer-events-none opacity-50" : ""}`}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
};

View File

@@ -0,0 +1,58 @@
import SmallSpinner from "@/components/general/SmallSpinner";
import { debounce } from "lodash";
import { useEffect, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import { useCustomersQueryStates } from "../hooks/useCustomersQueryStates";
export function CustomersSearchBar() {
const { queryStates, setQueryStates } = useCustomersQueryStates();
const navigate = useNavigate();
const location = useLocation();
// const [loading, setLoading] = useState(false);
// const prevQueryRef = useRef<string>(queryStates.q);
const debouncedSearch = useMemo(
() =>
debounce(async (query: string) => {
setQueryStates({ q: query, page: 1 });
}, 350),
[location.search, location.pathname, navigate, setQueryStates]
);
// const handleQueryChange = async () => {
// setLoading(true);
// setLoading(false);
// };
// useEffect(() => {
// const searchParamQuery = queryStates.q || "";
// if (searchParamQuery !== prevQueryRef.current) {
// prevQueryRef.current = searchParamQuery;
// handleQueryChange();
// }
// }, [queryStates.q]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const q = e.target.value;
debouncedSearch(q);
};
return (
<div
className="rounded-sm py-1 h-10 px-2 text-sm pl-4
flex items-center w-full max-w-lg min-w-xs text-t2 border-x"
>
<Search size={13} className="text-t3 mr-2" />
<input
onChange={handleChange}
className="outline-none w-full bg-transparent"
placeholder="Search..."
defaultValue={queryStates.q}
></input>
{/* <div className="w-5 h-5 ml-1">{loading && <SmallSpinner />}</div> */}
</div>
);
}

View File

@@ -8,8 +8,10 @@ import {
} from "@autumn/shared";
import { Link, useNavigate } from "react-router";
import React from "react";
import CopyButton from "@/components/general/CopyButton";
import { getRedirectUrl, navigateTo } from "@/utils/genUtils";
import { getRedirectUrl } from "@/utils/genUtils";
import { Badge } from "@/components/ui/badge";
import { unixHasPassed } from "@/utils/dateUtils";
import { z } from "zod";
@@ -21,11 +23,10 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useEnv } from "@/utils/envUtils";
import { useCustomersContext } from "./CustomersContext";
import { Item, Row } from "@/components/general/TableGrid";
import React from "react";
import CopyButton from "@/components/general/CopyButton";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { getVersionCounts } from "@/utils/productUtils";
import { CustomerRowToolbar } from "./CustomerRowToolbar";
const CustomerWithProductsSchema = CustomerSchema.extend({
@@ -41,8 +42,8 @@ export const CustomersTable = ({
customers: CustomerWithProducts[];
}) => {
const env = useEnv();
const navigate = useNavigate();
const { versionCounts } = useCustomersContext();
const { products } = useProductsQuery();
const versionCounts = getVersionCounts(products);
// console.log("customers", customers);
const getCusProductsInfo = (customer: CustomerWithProducts) => {

View File

@@ -0,0 +1,39 @@
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
import { CustomersSearchBar } from "../CustomersSearchBar";
import CustomersFilterButton from "../filter-dropdown/CustomersFilterButton";
import { useCusSearchQuery } from "../../hooks/useCusSearchQuery";
import CreateCustomer from "../CreateCustomer";
import { CustomersPagination } from "../CustomersPagination";
export const CustomersTopBar = () => {
const { queryStates, setQueryStates } = useCustomersQueryStates();
const { totalCount } = useCusSearchQuery();
return (
<div className="flex w-full justify-between sticky top-0 z-10 border-y pl-10 pr-7 items-center bg-stone-100 h-10">
<div className="flex items-center">
<div className="pr-4 flex items-center justify-center gap-2 h-10">
<CustomersFilterButton />
</div>
<CustomersSearchBar />
<CustomersPagination />
<div className="pl-4">
<p className="text-t2 px-1 rounded-md bg-stone-200 text-sm">
{totalCount}
</p>
</div>
</div>
<div className="flex gap-4 bg-blue-100">
<CreateCustomer />
</div>
</div>
);
};

View File

@@ -4,45 +4,33 @@ import {
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useCustomersContext } from "./CustomersContext";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { Check, ListFilter, Pin, X } from "lucide-react";
import { SaveViewPopover } from "./SavedViewPopover";
import { ListFilter, X } from "lucide-react";
import { useState } from "react";
import { ProductsSubMenu } from "./filter/ProductsSubMenu";
import { Checkbox } from "@/components/ui/checkbox";
import { FilterStatusSubMenu } from "./filter/FilterStatusSubMenu";
import { Separator } from "@/components/ui/separator";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import { SavedViews } from "./filter/SavedViews";
import { FilterStatusSubMenu } from "./FilterStatusSubMenu";
import { SavedViews } from "../../filter/SavedViews";
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
import { useGeneralQuery } from "@/hooks/queries/useGeneralQuery";
import { SaveViewPopover } from "./SavedViewPopover";
import { ProductsSubMenu } from "../../filter/ProductsSubMenu";
import { useSavedViewsQuery } from "../../hooks/useSavedViewsQuery";
function FilterButton() {
const { setFilters } = useCustomersContext();
function CustomersFilterButton() {
const { setQueryStates } = useCustomersQueryStates();
const [open, setOpen] = useState(false);
const {
data: savedViewsData,
isLoading: loading,
mutate: refetchSavedViews,
} = useAxiosSWR({
url: "/saved_views",
});
const { data, refetch: refetchSavedViews } = useSavedViewsQuery();
const views = savedViewsData?.views || [];
const views = data?.views || [];
const clearFilters = () => {
setFilters({
setQueryStates({
status: [],
product_id: [],
version: "",
version: [],
none: false,
});
};
@@ -85,7 +73,7 @@ function FilterButton() {
);
}
export default FilterButton;
export default CustomersFilterButton;
export const RenderFilterTrigger = ({ setOpen }: any) => {
return (

View File

@@ -1,32 +1,29 @@
import { useCustomersContext } from "../CustomersContext";
import {
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuSeparator,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Check } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
export const FilterStatusSubMenu = () => {
const { filters, setFilters } = useCustomersContext();
const { queryStates, setQueryStates } = useCustomersQueryStates();
const statuses: string[] = ["canceled", "free_trial", "expired"];
const selectedStatuses = filters.status || [];
const selectedStatuses = queryStates.status || [];
const hasSelections = selectedStatuses.length > 0;
const toggleStatus = (status: string) => {
const selected = filters.status || [];
const selected = queryStates.status || [];
const isSelected = selected.includes(status);
const updated = isSelected
? selected.filter((s: string) => s !== status)
: [...selected, status];
setFilters({ ...filters, status: updated });
setQueryStates({ ...queryStates, status: updated });
};
return (

View File

@@ -6,11 +6,11 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useCustomersContext } from "./CustomersContext";
import { getBackendErr } from "@/utils/genUtils";
import { Save, PlusIcon, Pin } from "lucide-react";
import { Pin } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useSavedViewsQuery } from "../../hooks/useSavedViewsQuery";
interface SaveViewPopoverProps {
onClose?: () => void;
@@ -21,7 +21,7 @@ export const SaveViewPopover = ({ onClose }: SaveViewPopoverProps) => {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const axiosInstance = useAxiosInstance();
const { mutateSavedViews } = useCustomersContext();
const { refetch: refetchSavedViews } = useSavedViewsQuery();
const handleSave = async () => {
if (!name.trim()) {
@@ -50,7 +50,7 @@ export const SaveViewPopover = ({ onClose }: SaveViewPopoverProps) => {
setName("");
setOpen(false);
onClose?.(); // Close the main filter modal
mutateSavedViews(); // Refresh the views list
refetchSavedViews(); // Refresh the views list
} catch (error) {
console.error(error);
toast.error(getBackendErr(error, "Failed to save view"));

View File

@@ -8,13 +8,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useCustomersContext } from "./CustomersContext";
import { getBackendErr } from "@/utils/genUtils";
import { BookmarkIcon, Trash2 } from "lucide-react";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
import { useSavedViewsQuery } from "../../hooks/useSavedViewsQuery";
interface SavedView {
id: string;
@@ -24,13 +23,19 @@ interface SavedView {
}
export const SavedViewsDropdown = () => {
const { env, setFilters, setQueryStates, mutate } = useCustomersContext();
// const { env, setFilters, setQueryStates, mutate } = useCustomersContext();
const { queryStates, setQueryStates } = useCustomersQueryStates();
const axiosInstance = useAxiosInstance();
const {
data: savedViewsData,
isLoading: loading,
refetch: refetchSavedViews,
} = useSavedViewsQuery();
const { data: savedViewsData, isLoading: loading, mutate: refetchSavedViews } = useAxiosSWR({
url: "/saved_views",
env,
});
// const { data: savedViewsData, isLoading: loading, mutate: refetchSavedViews } = useAxiosSWR({
// url: "/saved_views",
// env,
// });
const views = savedViewsData?.views || [];
@@ -39,22 +44,26 @@ export const SavedViewsDropdown = () => {
// Decode base64 filters
const decodedParams = atob(view.filters);
const params = new URLSearchParams(decodedParams);
// Apply all parameters using setQueryStates (this will reset pagination automatically)
const queryParams: Record<string, string | number> = {
const statusParam = params.get("status") || "";
const versionParam = params.get("version") || "";
const noneParam = params.get("none");
const queryParams = {
page: 1,
lastItemId: "",
q: params.get("q") || "",
status: params.get("status") || "",
product_id: params.get("product_id") || "",
version: params.get("version") || "",
status: statusParam ? statusParam.split(",").filter(Boolean) : [],
version: versionParam ? versionParam.split(",").filter(Boolean) : [],
none: noneParam === "true",
};
setQueryStates(queryParams);
// Explicitly trigger a data refetch to ensure the view is applied immediately
await mutate();
await refetchSavedViews();
toast.success(`Applied view: ${view.name}`);
} catch (error) {
console.error(error);
@@ -62,7 +71,11 @@ export const SavedViewsDropdown = () => {
}
};
const deleteView = async (viewId: string, viewName: string, e: React.MouseEvent) => {
const deleteView = async (
viewId: string,
viewName: string,
e: React.MouseEvent
) => {
e.preventDefault();
e.stopPropagation();
@@ -79,10 +92,13 @@ export const SavedViewsDropdown = () => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="text-t3 bg-transparent shadow-none p-0">
<Button
variant="ghost"
className="text-t3 bg-transparent shadow-none p-0"
>
<BookmarkIcon size={13} className="mr-2 text-t3" />
Views
</Button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel className="text-t3 !font-regular text-xs">
@@ -91,7 +107,7 @@ export const SavedViewsDropdown = () => {
<DropdownMenuSeparator />
{loading ? (
<DropdownMenuItem disabled>Loading...</DropdownMenuItem>
) : views.length === 0 ? (
) : views.length === 0 ? (
<DropdownMenuItem disabled>No saved views</DropdownMenuItem>
) : (
<DropdownMenuGroup>
@@ -115,4 +131,4 @@ export const SavedViewsDropdown = () => {
</DropdownMenuContent>
</DropdownMenu>
);
};
};

View File

@@ -12,7 +12,6 @@ import { Link, useParams, useSearchParams } from "react-router";
import ErrorScreen from "@/views/general/ErrorScreen";
import { ProductOptions } from "./ProductOptions";
import { useEnv } from "@/utils/envUtils";
import { FeaturesContext } from "@/views/features/FeaturesContext";
import { CustomerProductBreadcrumbs } from "./components/CustomerProductBreadcrumbs";
import { FrontendProduct, useAttachState } from "./hooks/useAttachState";
import { sortProductItems } from "@/utils/productUtils";
@@ -98,9 +97,9 @@ export default function CustomerProductView() {
new Set(
product.items
.filter((item: ProductItem) => item.entity_feature_id != null)
.map((item: ProductItem) => item.entity_feature_id),
),
),
.map((item: ProductItem) => item.entity_feature_id)
)
)
);
if (product.options) {
@@ -133,53 +132,45 @@ export default function CustomerProductView() {
const { customer } = data;
return (
<FeaturesContext.Provider
<ProductContext.Provider
value={{
env,
...data,
features,
setFeatures,
mutate,
env,
product,
setProduct,
selectedEntitlementAllowance,
setSelectedEntitlementAllowance,
customer: customer as Customer,
entities: data.entities as Entity[],
entityId,
setEntityId,
attachState,
version,
entityFeatureIds,
setEntityFeatureIds,
}}
>
<ProductContext.Provider
value={{
...data,
features,
setFeatures,
mutate,
env,
product,
setProduct,
selectedEntitlementAllowance,
setSelectedEntitlementAllowance,
customer: customer as Customer,
entities: data.entities as Entity[],
entityId,
setEntityId,
attachState,
version,
entityFeatureIds,
setEntityFeatureIds,
}}
>
<CustomToaster />
<div className="flex w-full">
<div className="flex flex-col gap-4 w-full">
<CustomerProductBreadcrumbs />
<div className="flex">
<div className="flex-1 w-full min-w-sm">
{product && <ManageProduct />}
{options.length > 0 && (
<ProductOptions options={options} setOptions={setOptions} />
)}
</div>
<CustomToaster />
<div className="flex w-full">
<div className="flex flex-col gap-4 w-full">
<CustomerProductBreadcrumbs />
<div className="flex">
<div className="flex-1 w-full min-w-sm">
{product && <ManageProduct />}
{options.length > 0 && (
<ProductOptions options={options} setOptions={setOptions} />
)}
</div>
</div>
<div className="max-w-[300px] w-1/3 shrink-1 hidden lg:block">
<ProductSidebar />
</div>
</div>
</ProductContext.Provider>
</FeaturesContext.Provider>
<div className="max-w-[300px] w-1/3 shrink-1 hidden lg:block">
<ProductSidebar />
</div>
</div>
</ProductContext.Provider>
);
}

View File

@@ -5,17 +5,18 @@ import {
DropdownMenuSeparator,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Check } from "lucide-react";
import { useCustomersContext } from "../CustomersContext";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { useCustomersQueryStates } from "../hooks/useCustomersQueryStates";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { getVersionCounts } from "@/utils/productUtils";
export const ProductsSubMenu = () => {
const { products, versionCounts, filters, setFilters } =
useCustomersContext();
const selectedVersions = filters.version
? filters.version.split(",").filter(Boolean)
: [];
const { products } = useProductsQuery();
const { queryStates, setQueryStates } = useCustomersQueryStates();
const versionCounts = getVersionCounts(products);
const selectedVersions = queryStates.version;
// Deduplicate products by ID (since backend may return multiple entries per product, one per version)
const uniqueProducts =
@@ -63,19 +64,19 @@ export const ProductsSubMenu = () => {
allProductVersions.every((pv) => selectedVersions.includes(pv.key));
if (allSelected) {
// Deselect all
setFilters({
...filters,
product_id: "",
version: "",
});
// setFilters({
// ...filters,
// product_id: "",
// version: "",
// });
} else {
// Select all product:version combinations
setFilters({
...filters,
product_id: "", // Will be handled by version selections
version: allProductVersions.map((pv) => pv.key).join(","),
none: false,
});
// setFilters({
// ...filters,
// product_id: "", // Will be handled by version selections
// version: allProductVersions.map((pv) => pv.key).join(","),
// none: false,
// });
}
};
@@ -91,7 +92,7 @@ export const ProductsSubMenu = () => {
);
let newSelectedVersions;
let newNone = filters.none;
let newNone = queryStates.none;
if (allProductVersionsSelected) {
// Deselect all versions of this product
newSelectedVersions = selectedVersions.filter(
@@ -106,10 +107,9 @@ export const ProductsSubMenu = () => {
newNone = false;
}
setFilters({
...filters,
product_id: "",
version: newSelectedVersions.join(","),
setQueryStates({
...queryStates,
version: newSelectedVersions,
none: newNone,
});
};
@@ -119,7 +119,7 @@ export const ProductsSubMenu = () => {
const isSelected = selectedVersions.includes(versionKey);
let newSelectedVersions;
let newNone = filters.none;
let newNone = queryStates.none;
if (isSelected) {
newSelectedVersions = selectedVersions.filter(
(key: string) => key !== versionKey
@@ -129,10 +129,9 @@ export const ProductsSubMenu = () => {
newNone = false;
}
setFilters({
...filters,
product_id: "",
version: newSelectedVersions.join(","),
setQueryStates({
...queryStates,
version: newSelectedVersions,
none: newNone,
});
};
@@ -141,10 +140,10 @@ export const ProductsSubMenu = () => {
e.preventDefault();
e.stopPropagation();
setFilters({
...filters,
version: "",
none: !filters.none,
setQueryStates({
...queryStates,
version: [],
none: !queryStates.none,
});
};
@@ -160,7 +159,6 @@ export const ProductsSubMenu = () => {
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-64">
<div className="flex items-center justify-between px-2 h-6">
{/* <span className="text-t3 font-regular text-xs">Select products</span> */}
<button
onClick={handleSelectAll}
className="text-t3 text-xs hover:text-t1 transition-colors cursor-pointer"
@@ -171,11 +169,10 @@ export const ProductsSubMenu = () => {
onClick={handleSelectNone}
className={cn(
"px-1 h-5 flex items-center gap-1 text-t3 text-xs hover:text-t1 cursor-pointer",
filters.none &&
queryStates.none &&
"bg-yellow-100 text-yellow-600 hover:text-yellow-500 rounded-md"
)}
>
{/* {filters.none && <Check size={11} />} */}
No products
</button>
</div>

View File

@@ -13,10 +13,10 @@ import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getBackendErr } from "@/utils/genUtils";
import { useCustomersContext } from "../CustomersContext";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { Delete } from "lucide-react";
import { useState } from "react";
import { useCustomersQueryStates } from "../hooks/useCustomersQueryStates";
interface SavedView {
id: string;
@@ -34,7 +34,7 @@ export const SavedViews = ({
mutateViews: any;
setDropdownOpen: (open: boolean) => void;
}) => {
const { setQueryStates, mutate } = useCustomersContext();
const { setQueryStates } = useCustomersQueryStates();
const axiosInstance = useAxiosInstance();
const [deletingViewId, setDeletingViewId] = useState<string | null>(null);
@@ -45,20 +45,23 @@ export const SavedViews = ({
const params = new URLSearchParams(decodedParams);
// Apply all parameters using setQueryStates (this will reset pagination automatically)
const queryParams: Record<string, string | number> = {
const statusParam = params.get("status") || "";
const versionParam = params.get("version") || "";
const noneParam = params.get("none");
const queryParams = {
page: 1,
lastItemId: "",
q: params.get("q") || "",
status: params.get("status") || "",
product_id: params.get("product_id") || "",
version: params.get("version") || "",
none: params.get("none") || "",
status: statusParam ? statusParam.split(",").filter(Boolean) : [],
version: versionParam ? versionParam.split(",").filter(Boolean) : [],
none: noneParam === "true",
lastItemId: "",
};
setQueryStates(queryParams);
// Explicitly trigger a data refetch to ensure the view is applied immediately
await mutate();
// await mutate();
toast.success(`Applied filters from ${view.name} view`);
} catch (error) {

View File

@@ -0,0 +1,95 @@
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { useCustomersQueryStates } from "./useCustomersQueryStates";
import {
CusProductSchema,
CustomerSchema,
ProductSchema,
} from "@autumn/shared";
import { z } from "zod";
import { useState } from "react";
const CustomerWithProductsSchema = CustomerSchema.extend({
customer_products: z.array(
CusProductSchema.extend({ product: ProductSchema })
),
});
type CustomerWithProducts = z.infer<typeof CustomerWithProductsSchema>;
export const useCusSearchQuery = () => {
const { queryStates } = useCustomersQueryStates();
const axiosInstance = useAxiosInstance();
const fetcher = async () => {
const { data } = await axiosInstance.post(`/customers/all/search`, {
search: queryStates.q || "",
filters: {
status: queryStates.status,
version: queryStates.version,
none: queryStates.none,
},
page: queryStates.page,
page_size: 50,
});
return { customers: data.customers, totalCount: data.totalCount };
};
const {
data,
isLoading,
error,
refetch,
isRefetching,
isFetching,
isPending,
isPlaceholderData,
} = useQuery<{
customers: CustomerWithProducts[];
totalCount: number;
}>({
queryKey: [
"customers",
queryStates.page,
queryStates.status,
queryStates.version,
queryStates.none,
queryStates.q,
],
queryFn: fetcher,
placeholderData: keepPreviousData,
});
const isFetchingUncached = Boolean(
isPending || (isFetching && isPlaceholderData)
);
return {
customers: data?.customers || [],
totalCount: data?.totalCount || 0,
isLoading,
error,
refetch,
isRefetching,
isFetchingUncached,
};
};
// const { data, isLoading, error, mutate } = useAxiosPostSWR({
// url: `/v1/customers/all/search`,
// env,
// data: {
// search: queryStates.q || "",
// filters: {
// status: queryStates.status,
// product_id: queryStates.product_id,
// version: queryStates.version,
// none: queryStates.none,
// },
// page: queryStates.page,
// page_size: pageSize,
// last_item: queryStates.lastItemId
// ? { internal_id: queryStates.lastItemId }
// : null,
// },
// });

View File

@@ -0,0 +1,38 @@
import {
parseAsArrayOf,
parseAsBoolean,
parseAsInteger,
parseAsString,
} from "nuqs";
import { useQueryStates } from "nuqs";
import { useLocation } from "react-router";
import { useCallback, useEffect, useState } from "react";
import { debounce } from "lodash";
export const useCustomersQueryStates = () => {
const [queryStates, setQueryStates] = useQueryStates(
{
q: parseAsString.withDefault(""),
status: parseAsArrayOf(parseAsString).withDefault([]),
version: parseAsArrayOf(parseAsString).withDefault([]),
none: parseAsBoolean.withDefault(false),
page: parseAsInteger.withDefault(1),
lastItemId: parseAsString.withDefault(""),
},
{
history: "replace",
}
);
const [stableStates, setStableStates] = useState(queryStates);
useEffect(() => {
const debouncedSetStableStates = debounce((queryStates: any) => {
setStableStates(queryStates);
}, 50);
debouncedSetStableStates(queryStates);
}, [queryStates]);
return { queryStates: stableStates, setQueryStates };
};

View File

@@ -0,0 +1,8 @@
import { useGeneralQuery } from "@/hooks/queries/useGeneralQuery";
export const useSavedViewsQuery = () => {
return useGeneralQuery({
url: "/saved_views",
queryKey: ["saved_views"],
});
};

View File

@@ -1,101 +0,0 @@
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTrigger,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import React, { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { FeatureService } from "@/services/FeatureService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { slugify } from "@/utils/formatUtils/formatTextUtils";
import { FeatureType, AppEnv } from "@autumn/shared";
function CreateBooleanFeature() {
const axiosInstance = useAxiosInstance({ env: AppEnv.Sandbox, isAuth: true });
const [fields, setFields] = useState({
name: "",
id: "",
});
const [isLoading, setIsLoading] = useState(false);
const [idChanged, setIdChanged] = useState(false);
useEffect(() => {
setFields({
name: "",
id: "",
});
setIdChanged(false);
}, []);
const handleSubmit = async () => {
setIsLoading(true);
try {
await FeatureService.createFeature(axiosInstance, {
name: fields.name,
id: fields.id,
type: FeatureType.Boolean,
});
} catch (error) {
toast.error("Failed to create boolean feature");
}
setIsLoading(false);
};
return (
<Dialog>
<DialogTrigger asChild>
<Button>Create Boolean Feature</Button>
</DialogTrigger>
<DialogContent className="w-[500px]">
<DialogHeader>
<DialogTitle>Create Boolean Feature</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex gap-4 w-full">
<div className="w-full">
<FieldLabel>Name</FieldLabel>
<Input
placeholder="Name"
value={fields.name}
onChange={(e) => {
const newFields: any = { ...fields, name: e.target.value };
if (!idChanged) {
newFields.id = slugify(e.target.value);
}
setFields(newFields);
}}
/>
</div>
<div className="w-full">
<FieldLabel>ID</FieldLabel>
<Input
placeholder="ID"
value={fields.id}
onChange={(e) => {
setFields({ ...fields, id: e.target.value });
setIdChanged(true);
}}
/>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={handleSubmit} isLoading={isLoading}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateBooleanFeature;

View File

@@ -1,15 +0,0 @@
import { createContext, useContext } from "react";
export const FeaturesContext = createContext<any>(null);
export const useFeaturesContext = () => {
const context = useContext(FeaturesContext);
if (context === undefined) {
throw new Error(
"useFeaturesContext must be used within a FeaturesContextProvider"
);
}
return context;
};

View File

@@ -1,122 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
import { FeaturesContext } from "./FeaturesContext";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import { Feature, FeatureType } from "@autumn/shared";
import { CreateFeature, CreateFeatureDialog } from "./CreateFeature";
import { AppEnv } from "@autumn/shared";
import LoadingScreen from "../general/LoadingScreen";
import { FeaturesTable } from "./FeaturesTable";
import { CreditSystemsTable } from "../credits/CreditSystemsTable";
import CreateCreditSystem from "../credits/CreateCreditSystem";
import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton";
import ErrorScreen from "../general/ErrorScreen";
import { Banknote, DollarSign } from "lucide-react";
import { HamburgerMenu, MenuAction } from "@/components/general/table-components/HamburgerMenu";
import { PageSectionHeader } from "@/components/general/PageSectionHeader";
function FeaturesView({ env }: { env: AppEnv }) {
const [showCredits, setShowCredits] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
// const [open, setOpen] = useState(false);
// const [selectedFeature, setSelectedFeature] = useState<any>(null);
const { data, isLoading, error, mutate } = useAxiosSWR({
url: `/features`,
env: env,
withAuth: true,
options: {
refreshInterval: 0,
},
});
const creditSystems = data?.features.filter(
(feature: Feature) => feature.type === FeatureType.CreditSystem
);
useEffect(() => {
if (creditSystems?.length > 0) {
setShowCredits(true);
}
}, [creditSystems]);
if (isLoading) {
return <LoadingScreen />;
}
if (!data || error) {
return <ErrorScreen>Failed to fetch features</ErrorScreen>;
}
const features = data?.features.filter(
(feature: Feature) => feature.type !== "credit_system"
);
return (
<FeaturesContext.Provider
value={{
features: features,
dbConns: data?.dbConns,
env,
mutate,
creditSystems: creditSystems,
showArchived,
setShowArchived,
dropdownOpen,
setDropdownOpen,
}}
>
<div className="flex flex-col gap-4 h-fit relative w-full text-sm">
<h1 className="text-xl font-medium shrink-0 pt-6 pl-10">Features</h1>
<PageSectionHeader
title="Features"
titleComponent={
<span className="text-t2 px-1 rounded-md bg-stone-200">
{features?.length || 0}
</span>
}
addButton={
<>
<CreateFeatureDialog />
<HamburgerMenu
dropdownOpen={dropdownOpen}
setDropdownOpen={setDropdownOpen}
actions={[
{
type: "item",
label: showArchived
? "Show Active Features"
: "Show Archived Features",
onClick: () => setShowArchived(!showArchived),
},
]}
/>
</>
}
/>
<FeaturesTable />
{showCredits && (
<div className="flex flex-col gap-4 h-fit mt-6">
<div>
<h2 className="text-lg font-medium">Credits</h2>
<p className="text-sm text-t2">
Create a credit-based system where features consume credits from
a shared balance{" "}
<span className="text-t3">
(eg, 1 AI chat message costs 3 credits).
</span>
</p>
</div>
<CreditSystemsTable />
<CreateCreditSystem />
</div>
)}
</div>
</FeaturesContext.Provider>
);
}
export default FeaturesView;

View File

@@ -1,21 +0,0 @@
import { useState } from "react";
import { getDefaultFeature } from "../utils/defaultFeature";
export const useFeatureDialogState = ({
entityCreate,
}: {
entityCreate?: boolean;
}) => {
const [feature, setFeature] = useState(getDefaultFeature(entityCreate));
const [eventNameInput, setEventNameInput] = useState("");
const [eventNameChanged, setEventNameChanged] = useState(true);
return {
feature,
setFeature,
eventNameInput,
setEventNameInput,
eventNameChanged,
setEventNameChanged,
};
};

View File

@@ -1,98 +0,0 @@
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { SelectContent } from "@/components/ui/select";
import { SelectTrigger, SelectValue } from "@/components/ui/select";
import { SelectItem } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTrigger,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Select } from "@/components/ui/select";
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { FeatureService } from "@/services/FeatureService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
function CreateDBConnection() {
// const { env } = useFeatureContext();
// const axiosInstance = useAxiosInstance({ env: });
const [fields, setFields] = useState({
provider: "postgres",
display_name: "",
connection_string: "",
});
const [isLoading, setIsLoading] = useState(false);
const handleChange = (e: any, field: string) => {
setFields({ ...fields, [field]: e.target.value });
};
const handleSubmit = async () => {
setIsLoading(true);
try {
// await FeatureService.createDBConnection(axiosInstance, fields);
} catch (error) {
toast.error("Failed to create DB connection");
}
setIsLoading(false);
};
return (
<Dialog>
<DialogTrigger asChild>
<Button>Create DB connection</Button>
</DialogTrigger>
<DialogContent className="w-[500px]">
<DialogHeader>
<DialogTitle>Create DB connection</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex gap-4 w-full">
<div className="w-full">
<FieldLabel>Provider</FieldLabel>
<Select
value={fields.provider}
onValueChange={(value) => handleChange(value, "provider")}
>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="postgres">PostgreSQL</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-full">
<FieldLabel>Display Name</FieldLabel>
<Input
placeholder="Display Name"
value={fields.display_name}
onChange={(e) => handleChange(e, "display_name")}
/>
</div>
</div>
<div>
<FieldLabel>Connection String</FieldLabel>
<Input
placeholder="DB connection URL"
value={fields.connection_string}
onChange={(e) => handleChange(e, "connection_string")}
/>
</div>
</div>
<DialogFooter>
<Button onClick={handleSubmit} isLoading={isLoading}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateDBConnection;

View File

@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils";
import { getRedirectUrl, notNullish } from "@/utils/genUtils";
import { getRedirectUrl, notNullish, pushPage } from "@/utils/genUtils";
import { AppEnv } from "@autumn/shared";
import { Link, useSearchParams } from "react-router";
import { useEffect, useState } from "react";
@@ -108,10 +108,13 @@ export const NavButton = ({
to={
href
? href
: getRedirectUrl(
`/${value}${subValue ? `?tab=${subValue}` : ""}`,
env
)
: pushPage({
path: `/${value}`,
queryParams: {
tab: subValue,
},
preserveParams: false,
})
}
className={outerDivClass}
target={href ? "_blank" : undefined}

View File

@@ -28,8 +28,9 @@ export const SidebarGroup = ({
productGroup ? "opacity-100 my-0.5" : "opacity-0"
)}
>
{subTabs.map((subTab) => (
{subTabs.map((subTab, index) => (
<NavButton
key={index}
value={value}
subValue={subTab.value}
title={keyToTitle(subTab.title)}

View File

@@ -12,7 +12,7 @@ import CodeBlock from "../components/CodeBlock";
import { ArrowUpRightFromSquare } from "lucide-react";
import { Feature, Product } from "@autumn/shared";
import { useState } from "react";
import { FeatureTypeBadge } from "@/views/features/FeatureTypeBadge";
import { FeatureTypeBadge } from "@/views/products/features/components/FeatureTypeBadge";
const checkAccessCode = (
apiKey: string,

View File

@@ -1,224 +0,0 @@
import Step from "@/components/general/OnboardingStep";
import { FeaturesContext } from "@/views/features/FeaturesContext";
import { defaultProduct } from "@/views/products/CreateProduct";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import { ProductService } from "@/services/products/ProductService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils";
import { getBackendErr } from "@/utils/genUtils";
import { toast } from "sonner";
import { ProductContext } from "@/views/products/product/ProductContext";
import { ProductItemTable } from "@/views/products/product/product-item/ProductItemTable";
import { Input } from "@/components/ui/input";
import { slugify } from "@/utils/formatUtils/formatTextUtils";
export const CreateProductStep = ({
productId,
setProductId,
number,
}: {
productId: string;
setProductId: (productId: string) => void;
number: number;
}) => {
const env = useEnv();
const axiosInstance = useAxiosInstance({ env });
const [newProduct, setNewProduct] = useState<any>(defaultProduct);
const [createClicked, setCreateClicked] = useState(false);
const [createProductLoading, setCreateProductLoading] = useState(false);
const { data, isLoading, mutate } = useAxiosSWR({
url: `/products/${newProduct.id}/data`,
env,
enabled: createClicked,
});
const createProduct = async () => {
setCreateProductLoading(true);
try {
const res = await ProductService.createProduct(axiosInstance, newProduct);
setCreateClicked(true);
toast.success("Product created");
await mutate();
setProductId(newProduct.id);
} catch (error) {
toast.error(getBackendErr(error, "Failed to create product"));
}
setCreateProductLoading(false);
};
const updateProduct = async () => {
setCreateProductLoading(true);
try {
const res = await ProductService.updateProduct(
axiosInstance,
productId,
product,
);
toast.success("Product items successfully created");
await mutate();
} catch (error) {
toast.error(getBackendErr(error, "Failed to update product"));
}
setCreateProductLoading(false);
};
const [product, setProduct] = useState<any>();
const [features, setFeatures] = useState<any[]>();
useEffect(() => {
if (data?.product) {
setProduct(data.product);
setFeatures(data.features);
}
}, [data]);
return (
<Step
title="Create your first product"
number={number}
description={
<p>
Define your product&apos;s pricing models and what customers get
access to.
</p>
}
>
{product ? (
<FeaturesContext.Provider
value={{
env,
mutate,
}}
>
<ProductContext.Provider
value={{
...data,
mutate,
env,
product,
setProduct,
features,
setFeatures,
}}
>
<ProductItemTable isOnboarding={true} />
<div className="flex justify-end mt-4">
<Button
isLoading={createProductLoading}
variant="gradientPrimary"
onClick={updateProduct}
className="min-w-44 w-44 max-w-44"
>
Update Product
</Button>
</div>
</ProductContext.Provider>
</FeaturesContext.Provider>
) : (
<CreateProductCard
newProduct={newProduct}
setNewProduct={setNewProduct}
createProduct={createProduct}
createProductLoading={createProductLoading}
/>
)}
</Step>
);
};
const CreateProductCard = ({
newProduct,
setNewProduct,
createProduct,
createProductLoading,
}: {
newProduct: any;
setNewProduct: any;
createProduct: any;
createProductLoading: boolean;
}) => {
return (
<div className="flex gap-2 items-start">
{/* <ProductConfig
product={newProduct}
setProduct={setNewProduct}
isUpdate={false}
/> */}
<Input
placeholder="Product name"
value={newProduct.name}
onChange={(e: any) =>
setNewProduct({
...newProduct,
name: e.target.value,
id: slugify(e.target.value),
})
}
endContent={
<div className="flex gap-2 items-center">
<p className="whitespace-nowrap text-t3">{newProduct.id}</p>
</div>
}
/>
<Button
variant="gradientPrimary"
className="min-w-44 w-44 max-w-44"
onClick={createProduct}
isLoading={createProductLoading}
// startIcon={<PlusIcon size={15} />}
>
Create Product
</Button>
</div>
);
};
{
/* <FeaturesContext.Provider
value={{
features: features,
env,
mutate: productMutate,
onboarding: true,
}}
>
{productLoading ? (
<SmallSpinner />
) : (
<div className="flex flex-col gap-2">
<p className="text-t2 font-medium text-md">Features</p>
<FeaturesTable />
<CreateFeature />
</div>
)}
</FeaturesContext.Provider> */
}
{
/* <ProductsContext.Provider
value={{
...productData,
env,
mutate: productMutate,
onboarding: true,
}}
>
{productLoading ? (
<SmallSpinner />
) : (
<div className="flex flex-col gap-2">
<p className="text-t2 font-medium text-md">Products</p>
<ProductsTable products={productData?.products} />
<div>
<CreateProduct />
</div>
</div>
)}
</ProductsContext.Provider> */
}

View File

@@ -1,12 +1,11 @@
import { Button } from "@/components/ui/button";
import Step from "@/components/general/OnboardingStep";
import { FeaturesContext } from "@/views/features/FeaturesContext";
import { PageSectionHeader } from "@/components/general/PageSectionHeader";
import { useEnv } from "@/utils/envUtils";
import { ManageProduct } from "@/views/products/product/ManageProduct";
import { ProductContext } from "@/views/products/product/ProductContext";
import { ProductsContext } from "@/views/products/ProductsContext";
import { ProductsTable } from "@/views/products/ProductsTable";
import { Product, ProductItem, products, ProductV2 } from "@autumn/shared";
import {
DialogContent,
@@ -19,7 +18,6 @@ import { ProductService } from "@/services/products/ProductService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
import { toast } from "sonner";
import CreateProduct from "@/views/products/CreateProduct";
import { useSearchParams } from "react-router";
import { Check } from "lucide-react";
import { CreateFreeTrial } from "@/views/products/product/free-trial/CreateFreeTrial";
@@ -28,6 +26,8 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import CreateProduct from "@/views/products/products/components/CreateProduct";
import { ProductsTable } from "@/views/products/products/components/ProductsTable";
export const ProductList = ({
data,
@@ -118,7 +118,6 @@ export const ProductList = ({
/>
<ProductsTable
products={data.products}
onRowClick={(id) => {
const selectedProduct = data.products.find(
(p: ProductV2) => p.id === id
@@ -212,31 +211,24 @@ export const EditProductDialog = ({
{/* Edit Product */}
</DialogTitle>
<div>
<FeaturesContext.Provider
<ProductContext.Provider
value={{
env,
product,
setProduct,
mutate,
env,
features,
setFeatures,
entityFeatureIds,
setEntityFeatureIds,
}}
>
<ProductContext.Provider
value={{
product,
setProduct,
mutate,
env,
features,
setFeatures,
entityFeatureIds,
setEntityFeatureIds,
}}
>
<CreateFreeTrial
open={freeTrialModalOpen}
setOpen={setFreeTrialModalOpen}
/>
<ManageProduct hideAdminHover={true} />
</ProductContext.Provider>
</FeaturesContext.Provider>
<CreateFreeTrial
open={freeTrialModalOpen}
setOpen={setFreeTrialModalOpen}
/>
<ManageProduct hideAdminHover={true} />
</ProductContext.Provider>
</div>
<DialogFooter>
<div className="flex justify-between items-center gap-2 px-10 w-full mt-6">

View File

@@ -1,9 +1,4 @@
import ConfirmNewVersionDialog from "@/views/products/product/versioning/ConfirmNewVersionDialog";
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";
@@ -109,135 +104,126 @@ export const EditProduct = ({ mutate }: { mutate: any }) => {
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
<ProductContext.Provider
value={{
env,
groupDefaults: data.groupToDefaults?.[product?.group || ""],
product,
setProduct,
mutate,
env,
features,
setFeatures,
entityFeatureIds,
setEntityFeatureIds,
isOnboarding: true,
autoSave: !showSaveButton,
}}
>
<ProductContext.Provider
value={{
groupDefaults: data.groupToDefaults?.[product?.group || ""],
product,
setProduct,
mutate,
env,
features,
setFeatures,
entityFeatureIds,
setEntityFeatureIds,
isOnboarding: true,
autoSave: !showSaveButton,
}}
<ConfirmNewVersionDialog
open={showNewVersionDialog}
setOpen={setShowNewVersionDialog}
createProduct={runUpdateProduct}
/>
<CreateFreeTrial
open={freeTrialModalOpen}
setOpen={setFreeTrialModalOpen}
/>
<div
className={`flex flex-col gap-4 transition-all duration-500 ease-in-out ${
hasItems ? "w-3/5" : "w-full"
}`}
>
<ConfirmNewVersionDialog
open={showNewVersionDialog}
setOpen={setShowNewVersionDialog}
createProduct={runUpdateProduct}
/>
<CreateFreeTrial
open={freeTrialModalOpen}
setOpen={setFreeTrialModalOpen}
/>
<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">
<EditProductDetails />
{showSaveButton && (
<Button
className="w-fit h-8 text-xs"
startIcon={<SaveIcon size={12} className="mr-1" />}
disabled={actionState.disabled}
onClick={handleSaveClicked}
isLoading={saveLoading}
>
Save Product
</Button>
)}
</div>
{firstProductCreated && (
<>
{product.items.length == 0 ? (
<p className="text-t2 text-sm w-md mt-4">
{/* Next, add items to define what customers with this product
get access to, and how much they should be charged for it. */}
Next, add which features your customers can use on this
product and how much it should cost.
</p>
) : (
<div
className={`bg-white border border-zinc-200 transition-all duration-500 ease-in-out ${
hasItems ? "w-full" : "w-full"
}`}
>
<ProductItemTable />
</div>
)}
<CreateProductItem2
classNames={{ button: "max-w-md" }}
/>{" "}
</>
<div className="flex gap-2 items-end justify-between">
<EditProductDetails />
{showSaveButton && (
<Button
className="w-fit h-8 text-xs"
startIcon={<SaveIcon size={12} className="mr-1" />}
disabled={actionState.disabled}
onClick={handleSaveClicked}
isLoading={saveLoading}
>
Save Product
</Button>
)}
</div>
<div
// transition-all duration-500 ease-in-out
className={` ${
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
{firstProductCreated && (
<>
{product.items.length == 0 ? (
<p className="text-t2 text-sm w-md mt-4">
{/* Next, add items to define what customers with this product
get access to, and how much they should be charged for it. */}
Next, add which features your customers can use on this
product and how much it should cost.
</p>
) : (
<div
className={`bg-white border border-zinc-200 transition-all duration-500 ease-in-out ${
hasItems ? "w-full" : "w-full"
}`}
>
<ProductItemTable />
</div>
)}
<CreateProductItem2 classNames={{ button: "max-w-md" }} />{" "}
</>
)}
</div>
<div
// transition-all duration-500 ease-in-out
className={` ${
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="flex items-center text-sm text-t2 gap-2">
<p className="text-t2 font-medium">Default Product</p>
<ToggleDefaultProduct toggleKey="is_default" />
</div>
<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 className="flex items-center text-sm text-t2 gap-2">
<p className="text-t2 font-medium">Default Product</p>
<ToggleDefaultProduct toggleKey="is_default" />
</div>
<div className="">
{/* <ToggleButton
<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="flex items-center text-sm text-t2 gap-2">
<p className="text-t2 font-medium">Add On Product</p>
<ToggleDefaultProduct toggleKey="is_add_on" />
</div>
<div className="text-t3 text-sm" style={{ width: "320px" }}>
A product that can be added on top of a customer's main
plan. Eg. one time purchases or top ups.
</div>
<div className="flex items-center text-sm text-t2 gap-2">
<p className="text-t2 font-medium">Add On Product</p>
<ToggleDefaultProduct toggleKey="is_add_on" />
</div>
<div>
<AddTrialButton />
<div className="text-t3 text-sm" style={{ width: "320px" }}>
Add a free trial to your product.
</div>
<div className="text-t3 text-sm" style={{ width: "320px" }}>
A product that can be added on top of a customer's main plan.
Eg. one time purchases or top ups.
</div>
</div>
<div>
<AddTrialButton />
<div className="text-t3 text-sm" style={{ width: "320px" }}>
Add a free trial to your product.
</div>
</div>
</div>
</ProductContext.Provider>
</FeaturesContext.Provider>
</div>
</ProductContext.Provider>
</div>
</div>
);

View File

@@ -3,16 +3,13 @@ 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 { 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";
@@ -94,31 +91,24 @@ export const EditProductDialog = ({
{/* Edit Product */}
</DialogTitle>
<div>
<FeaturesContext.Provider
<ProductContext.Provider
value={{
env,
product,
setProduct,
mutate,
env,
features,
setFeatures,
entityFeatureIds,
setEntityFeatureIds,
}}
>
<ProductContext.Provider
value={{
product,
setProduct,
mutate,
env,
features,
setFeatures,
entityFeatureIds,
setEntityFeatureIds,
}}
>
<CreateFreeTrial
open={freeTrialModalOpen}
setOpen={setFreeTrialModalOpen}
/>
<ManageProduct hideAdminHover={true} />
</ProductContext.Provider>
</FeaturesContext.Provider>
<CreateFreeTrial
open={freeTrialModalOpen}
setOpen={setFreeTrialModalOpen}
/>
<ManageProduct hideAdminHover={true} />
</ProductContext.Provider>
</div>
<DialogFooter>
<div className="flex justify-between items-center gap-2 px-10 w-full mt-6">

View File

@@ -8,7 +8,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { ChevronDownIcon, Trash } from "lucide-react";
import { DeleteProductDialog } from "@/views/products/components/DeleteProductDialog";
import { DeleteProductDialog } from "@/views/products/products/product-row-toolbar/DeleteProductDialog";
import { useState } from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,226 +1,45 @@
"use client";
import LoadingScreen from "../general/LoadingScreen";
import CreateReward from "./rewards/CreateReward";
import CreateRewardProgramModal from "./reward-programs/CreateRewardProgram";
import { useEffect, useState } from "react";
import { useAxiosSWR, usePostSWR } from "@/services/useAxiosSwr";
import { Product, Feature } from "@autumn/shared";
import { useState } from "react";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import { Product } from "@autumn/shared";
import { ProductsContext } from "./ProductsContext";
import { AppEnv } from "@autumn/shared";
import { ProductsTable } from "./ProductsTable";
import { Tabs } from "@/components/ui/tabs";
import { RewardsTable } from "./rewards/RewardsTable";
import { RewardProgramsTable } from "./reward-programs/RewardProgramsTable";
import { FeaturesTable } from "../features/FeaturesTable";
import { CreateFeatureDialog } from "../features/CreateFeature";
import { CreditSystemsTable } from "../credits/CreditSystemsTable";
import { FeaturesContext } from "../features/FeaturesContext";
import { PageSectionHeader } from "@/components/general/PageSectionHeader";
import { HamburgerMenu } from "@/components/general/table-components/HamburgerMenu";
import { Badge } from "@/components/ui/badge";
import { useQueryState } from "nuqs";
import { useSecondaryTab } from "@/hooks/common/useSecondaryTab";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { ProductsPage } from "./products/ProductsPage";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { FeaturesPage } from "./features/FeaturesPage";
import { RewardsPage } from "./rewards/RewardsPage";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { useAppQueryStates } from "@/hooks/common/useAppQueryStates";
function ProductsView({ env }: { env: AppEnv }) {
const [tab, setTab] = useQueryState("tab", {
defaultValue: "products",
history: "push",
const { queryStates, setQueryStates } = useAppQueryStates({
defaultTab: "products",
});
useSecondaryTab({ defaultTab: "products" });
const { isLoading: isProductsLoading } = useProductsQuery();
const { isLoading: isFeaturesLoading } = useFeaturesQuery();
const { isLoading: isRewardsLoading } = useRewardsQuery();
const [showArchived, setShowArchived] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [showArchivedFeatures, setShowArchivedFeatures] = useState(false);
const [featuresDropdownOpen, setFeaturesDropdownOpen] = useState(false);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const { products, isLoading: isProductsLoading } = useProductsQuery();
const { features, isLoading: isFeaturesLoading } = useFeaturesQuery();
// const { data, isLoading, mutate } = usePostSWR({
// url: `/products/data`,
// data: { showArchived },
// queryKey: ["products", showArchived],
// });
const { data: allCounts, mutate: mutateCounts } = useAxiosSWR({
url: `/products/counts`,
env: env,
withAuth: true,
});
// const {
// data: featuresData,
// isLoading: isFeaturesLoading,
// mutate: mutateFeatures,
// } = useAxiosSWR({
// url: `/features?showArchived=${showArchivedFeatures}`,
// env: env,
// withAuth: true,
// });
// useEffect(() => {
// if (data?.products.length > 0 && !selectedProduct) {
// setSelectedProduct(data.products[0]);
// }
// }, [data]);
// useEffect(() => {
// mutateFeatures();
// }, [showArchivedFeatures]);
// const creditSystems =
// featuresData?.features?.filter(
// (f: Feature) => f.type === "credit_system"
// ) || [];
if (isProductsLoading || isFeaturesLoading) return <LoadingScreen />;
if (isProductsLoading || isFeaturesLoading || isRewardsLoading)
return <LoadingScreen />;
const tab = queryStates.tab;
return (
<ProductsContext.Provider
value={{
// ...data,
// groupToDefault: data?.groupToDefault || {},
env,
selectedProduct,
setSelectedProduct,
// mutate,
allCounts,
mutateCounts,
showArchived,
setShowArchived,
}}
>
<FeaturesContext.Provider
value={{
// features:
// featuresData?.features?.filter(
// (f: Feature) => f.type !== "credit_system"
// ) || [],
// creditSystems:
// featuresData?.features?.filter(
// (f: Feature) => f.type === "credit_system"
// ) || [],
// dbConns: featuresData?.dbConns || [],
// env,
// mutate: mutateFeatures,
showArchived: showArchivedFeatures,
setShowArchived: setShowArchivedFeatures,
dropdownOpen: featuresDropdownOpen,
setDropdownOpen: setFeaturesDropdownOpen,
}}
>
<div className="flex flex-col gap-4 h-fit relative w-full text-sm">
<h1 className="text-xl font-medium shrink-0 pt-6 pl-10">Products</h1>
<ProductsContext.Provider value={{}}>
<div className="flex flex-col gap-4 h-fit relative w-full text-sm">
<h1 className="text-xl font-medium shrink-0 pt-6 pl-10">Products</h1>
<Tabs
defaultValue="products"
className="w-full"
value={tab}
onValueChange={(value) => setTab(value)}
>
{tab === "products" && <ProductsPage />}
{/* {tab === "features" && (
<>
<PageSectionHeader
title="Features"
titleComponent={
<>
<span className="text-t2 px-1 rounded-md bg-stone-200 mr-2">
{featuresData?.features?.length}
</span>
{showArchived && (
<Badge className="shadow-none bg-yellow-100 border-yellow-500 text-yellow-500 hover:bg-yellow-100">
Archived
</Badge>
)}
</>
}
addButton={<CreateFeatureDialog />}
menuComponent={
<HamburgerMenu
dropdownOpen={featuresDropdownOpen}
setDropdownOpen={setFeaturesDropdownOpen}
actions={[
{
type: "item",
label: showArchivedFeatures
? "Show active features"
: "Show archived features",
onClick: () =>
setShowArchivedFeatures(!showArchivedFeatures),
},
]}
/>
}
/>
<div className="flex flex-col gap-16">
<FeaturesTable />
<div>
<div className="border-y bg-stone-100 pl-10 pr-7 h-10 flex justify-between items-center whitespace-nowrap">
<div className="flex items-center gap-2">
<h2 className="text-sm text-t2 font-medium">Credits</h2>
<span className="text-t2 px-1 rounded-md bg-stone-200">
{creditSystems.length}
</span>
</div>
<CreateCreditSystem />
</div>
<CreditSystemsTable />
</div>
</div>
</>
)}
{tab === "rewards" && (
<>
<div className="flex flex-col gap-16">
<div>
<div className="border-y bg-stone-100 pl-10 pr-7 h-10 flex justify-between items-center whitespace-nowrap">
<div className="flex items-center gap-2">
<h2 className="text-sm text-t2 font-medium">Coupons</h2>
<span className="text-t2 px-1 rounded-md bg-stone-200">
{data?.rewards?.length || 0}
</span>
</div>
<CreateReward />
</div>
<div className="">
<RewardsTable />
</div>
</div>
<div>
<div className=" z-10 border-y bg-stone-100 pl-10 pr-7 h-10 flex justify-between items-center whitespace-nowrap">
<div className="flex items-center gap-2">
<h2 className="text-sm text-t2 font-medium">
Referral Programs
</h2>
<span className="text-t2 px-1 rounded-md bg-stone-200">
{data?.rewardPrograms?.length || 0}
</span>
</div>
<CreateRewardProgramModal />
</div>
<div>
<RewardProgramsTable />
</div>
</div>
</div>
</>
)} */}
</Tabs>
</div>
</FeaturesContext.Provider>
{tab === "products" && <ProductsPage />}
{tab === "features" && <FeaturesPage />}
{tab === "rewards" && <RewardsPage />}
</div>
</ProductsContext.Provider>
);
}

View File

@@ -3,7 +3,7 @@ import {
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Product, UpdateProductSchema } from "@autumn/shared";
import { ProductV2, UpdateProductSchema } from "@autumn/shared";
import { useRef, useState } from "react";
import { ProductConfig } from "./ProductConfig";
import React from "react";
@@ -13,20 +13,18 @@ import { ProductService } from "@/services/products/ProductService";
import { useEnv } from "@/utils/envUtils";
import { toast } from "sonner";
import { getBackendErr } from "@/utils/genUtils";
import { useProductsContext } from "./ProductsContext";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
export const UpdateProductDialog = ({
selectedProduct,
setSelectedProduct,
setModalOpen,
setDropdownOpen,
}: {
selectedProduct: Product;
setSelectedProduct: (product: Product) => void;
selectedProduct: ProductV2;
setModalOpen: (open: boolean) => void;
setDropdownOpen: (open: boolean) => void;
}) => {
const { mutate } = useProductsContext();
const { refetch } = useProductsQuery();
const originalProduct = useRef(selectedProduct);
const [product, setProduct] = useState(selectedProduct);
const [saveLoading, setSaveLoading] = useState(false);
@@ -41,7 +39,7 @@ export const UpdateProductDialog = ({
await ProductService.updateProduct(axiosInstance, originalProductId, {
...UpdateProductSchema.parse(product),
});
await mutate();
await refetch();
setModalOpen(false);
toast.success(`Successfully updated product ${product.id}`);

View File

@@ -0,0 +1,82 @@
import { PageSectionHeader } from "@/components/general/PageSectionHeader";
import { Badge } from "@/components/ui/badge";
import { useProductsQueryState } from "../hooks/useProductsQueryState";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { FeatureType } from "@autumn/shared";
import { HamburgerMenu } from "@/components/general/table-components/HamburgerMenu";
import { useState } from "react";
import { FeaturesTable } from "@/views/products/features/components/FeaturesTable";
import { CreateFeatureDialog } from "@/views/products/features/components/CreateFeature";
import CreateCreditSystem from "@/views/products/features/credit-systems/CreateCreditSystem";
import { CreditSystemsTable } from "./credit-systems/CreditSystemsTable";
export const FeaturesPage = () => {
const { features } = useFeaturesQuery();
const { queryStates, setQueryStates } = useProductsQueryState();
const [featuresDropdownOpen, setFeaturesDropdownOpen] = useState(false);
const regularFeatures = features.filter(
(feature) => feature.type !== FeatureType.CreditSystem
);
const creditSystems = features.filter(
(feature) => feature.type === FeatureType.CreditSystem
);
return (
<div>
<PageSectionHeader
title="Features"
titleComponent={
<>
<span className="text-t2 px-1 rounded-md bg-stone-200 mr-2">
{features?.length}
</span>
{queryStates.showArchivedFeatures && (
<Badge className="shadow-none bg-yellow-100 border-yellow-500 text-yellow-500 hover:bg-yellow-100">
Archived
</Badge>
)}
</>
}
addButton={<CreateFeatureDialog />}
menuComponent={
<HamburgerMenu
dropdownOpen={featuresDropdownOpen}
setDropdownOpen={setFeaturesDropdownOpen}
actions={[
{
type: "item",
label: queryStates.showArchivedFeatures
? "Show active features"
: "Show archived features",
onClick: () =>
setQueryStates({
...queryStates,
showArchivedFeatures: !queryStates.showArchivedFeatures,
}),
},
]}
/>
}
/>
<div className="flex flex-col gap-16">
<FeaturesTable />
<div>
<div className="border-y bg-stone-100 pl-10 pr-7 h-10 flex justify-between items-center whitespace-nowrap">
<div className="flex items-center gap-2">
<h2 className="text-sm text-t2 font-medium">Credits</h2>
<span className="text-t2 px-1 rounded-md bg-stone-200">
{creditSystems.length}
</span>
</div>
<CreateCreditSystem />
</div>
<CreditSystemsTable />
</div>
</div>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { DialogFooter, DialogHeader } from "@/components/ui/dialog";
import { DialogHeader } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogTrigger, DialogTitle } from "@/components/ui/dialog";
@@ -8,40 +8,33 @@ import {
CreateFeature as CreateFeatureType,
FeatureType,
} from "@autumn/shared";
import { useFeaturesContext } from "./FeaturesContext";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { FeatureService } from "@/services/FeatureService";
import { FeatureConfig } from "./metered-features/FeatureConfig";
import { FeatureConfig } from "./FeatureConfig";
import { getBackendErr } from "@/utils/genUtils";
import { useEnv } from "@/utils/envUtils";
import { getDefaultFeature } from "./utils/defaultFeature";
import { getDefaultFeature } from "../utils/defaultFeature";
import {
CustomDialogBody,
CustomDialogContent,
} from "@/components/general/modal-components/DialogContentWrapper";
import { CreateFeatureFooter } from "./components/CreateFeatureFooter";
import { CreateFeatureFooter } from "./CreateFeatureFooter";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
export const CreateFeature = ({
// isFromEntitlement,
// setShowFeatureCreate,
onSuccess,
setOpen,
open,
entityCreate,
handleBack,
}: {
// isFromEntitlement: boolean;
// setShowFeatureCreate: (show: boolean) => void;
onSuccess?: (newFeature: CreateFeatureType) => Promise<void>;
setOpen: (open: boolean) => void;
open: boolean;
entityCreate?: boolean;
handleBack?: () => void;
}) => {
const env = useEnv();
const axiosInstance = useAxiosInstance({ env });
const { mutate, features } = useFeaturesContext();
const axiosInstance = useAxiosInstance();
const { refetch } = useFeaturesQuery();
const [feature, setFeature] = useState(getDefaultFeature(entityCreate));
const [eventNameInput, setEventNameInput] = useState("");
const [eventNameChanged, setEventNameChanged] = useState(true);
@@ -87,19 +80,9 @@ export const CreateFeature = ({
if (onSuccess) {
await onSuccess(createdFeature);
} else {
await mutate();
await refetch();
setOpen(false);
}
// if (isFromEntitlement) {
// if (createdFeature) {
// setSelectedFeature(createdFeature);
// }
// setShowFeatureCreate(false);
// } else {
// await mutate();
// setOpen(false);
// }
} catch (error) {
toast.error(getBackendErr(error, "Failed to create feature"));
}

View File

@@ -15,9 +15,9 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { SelectFeatureUsageType } from "./SelectFeatureUsageType";
import { notNullish, nullish } from "@/utils/genUtils";
import { SelectFeatureType } from "./SelectFeatureType";
import { SelectFeatureUsageType } from "./SelectFeatureUsageType";
export function FeatureConfig({
feature,

View File

@@ -2,18 +2,18 @@ import UpdateFeature from "./UpdateFeature";
import CopyButton from "@/components/general/CopyButton";
import { useState } from "react";
import { useNavigate } from "react-router";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { Feature, FeatureType } from "@autumn/shared";
import { FeatureRowToolbar } from "./FeatureRowToolbar";
import { useFeaturesContext } from "./FeaturesContext";
import { FeatureRowToolbar } from "../feature-row-toolbar/FeatureRowToolbar";
import { FeatureTypeBadge } from "./FeatureTypeBadge";
import { Item, Row } from "@/components/general/TableGrid";
import { AdminHover } from "@/components/general/AdminHover";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductsQueryState } from "../../hooks/useProductsQueryState";
export const FeaturesTable = () => {
const { env, features, onboarding, showArchived } = useFeaturesContext();
const navigate = useNavigate();
const { features } = useFeaturesQuery();
const { queryStates } = useProductsQueryState();
const [open, setOpen] = useState(false);
const [selectedFeature, setSelectedFeature] = useState<any>(null);
@@ -33,6 +33,13 @@ export const FeaturesTable = () => {
setOpen(true);
};
const filteredFeatures = features.filter((feature) => {
if (feature.type === FeatureType.CreditSystem) return false;
return queryStates.showArchivedFeatures
? feature.archived
: !feature.archived;
});
return (
<div>
<UpdateFeature
@@ -46,19 +53,19 @@ export const FeaturesTable = () => {
<Item className="col-span-4">Name</Item>
<Item className="col-span-4 px-1">ID</Item>
<Item className="col-span-3">Type</Item>
{!onboarding && <Item className="col-span-4">Event Names</Item>}
{!onboarding && <Item className="col-span-2">Created At</Item>}
<Item className="col-span-4">Event Names</Item>
<Item className="col-span-2">Created At</Item>
<Item className="col-span-1"></Item>
</Row>
) : (
<div className="flex justify-start items-center px-10 h-10 text-t3">
{showArchived
? "You haven't archived any features yet."
{queryStates.showArchivedFeatures
? "You haven't archived any features yet."
: "Define the features of your application you want to charge for."}
</div>
)}
{features.map((feature: Feature) => (
{filteredFeatures.map((feature: Feature) => (
<Row
key={feature.internal_id}
className="grid-cols-18 gap-2 items-center px-10 w-full text-sm h-8 cursor-pointer hover:bg-primary/5 text-t2 whitespace-nowrap"
@@ -88,20 +95,12 @@ export const FeaturesTable = () => {
<Item className="col-span-3">
<FeatureTypeBadge {...feature} />
</Item>
{!onboarding && (
<Item className="col-span-4">
<span className="truncate">{getMeteredEventNames(feature)}</span>
</Item>
)}
{!onboarding && (
<Item className="col-span-2 text-t3 text-xs">
{formatUnixToDateTime(feature.created_at).date}
{/* <span className="text-t3">
{" "}
{formatUnixToDateTime(feature.created_at).time}
</span> */}
</Item>
)}
<Item className="col-span-4">
<span className="truncate">{getMeteredEventNames(feature)}</span>
</Item>
<Item className="col-span-2 text-t3 text-xs">
{formatUnixToDateTime(feature.created_at).date}
</Item>
<Item className="col-span-1 items-center justify-end">
<FeatureRowToolbar feature={feature} />
</Item>

View File

@@ -7,7 +7,7 @@ import {
FeatureUsageType,
} from "@autumn/shared";
import { Zap, Clock, ArrowUp01, Flag } from "lucide-react";
import { defaultMeteredConfig } from "./defaultFeatureConfig";
import { defaultMeteredConfig } from "../utils/defaultFeatureConfig";
export const SelectFeatureType = ({
feature,

View File

@@ -4,8 +4,8 @@ import {
FeatureType,
FeatureUsageType,
} from "@autumn/shared";
import { Clock, ToggleLeft, Zap } from "lucide-react";
import { defaultMeteredConfig } from "./defaultFeatureConfig";
import { Clock, Zap } from "lucide-react";
import { defaultMeteredConfig } from "../utils/defaultFeatureConfig";
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { SelectType } from "@/components/general/SelectType";

View File

@@ -1,14 +1,8 @@
import { Button } from "@/components/ui/button";
import { FeatureConfig } from "./metered-features/FeatureConfig";
import {
Dialog,
DialogContent,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Dialog, DialogTitle } from "@/components/ui/dialog";
import { useEffect, useRef, useState } from "react";
import { FeatureService } from "@/services/FeatureService";
import { useFeaturesContext } from "./FeaturesContext";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { FeatureType } from "@autumn/shared";
@@ -19,6 +13,8 @@ import {
CustomDialogFooter,
} from "@/components/general/modal-components/DialogContentWrapper";
import { CircleArrowUp, Save } from "lucide-react";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { FeatureConfig } from "@/views/products/features/components/FeatureConfig";
export default function UpdateFeature({
open,
@@ -31,8 +27,8 @@ export default function UpdateFeature({
selectedFeature: any;
setSelectedFeature: (feature: any) => void;
}) {
const { env, mutate } = useFeaturesContext();
const axiosInstance = useAxiosInstance({ env });
const { refetch } = useFeaturesQuery();
const axiosInstance = useAxiosInstance();
const [updateLoading, setUpdateLoading] = useState(false);
const [eventNameInput, setEventNameInput] = useState("");
const [eventNameChanged, setEventNameChanged] = useState(true);
@@ -77,7 +73,7 @@ export default function UpdateFeature({
config: updateConfig(),
});
await mutate();
await refetch();
setOpen(false);
} catch (error) {
console.log(error);

View File

@@ -1,31 +1,25 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTrigger,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useEffect, useState } from "react";
import { FeatureService } from "@/services/FeatureService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import {
CreateFeature,
Feature,
FeatureType,
FeatureUsageType,
} from "@autumn/shared";
import { CreateFeature, FeatureType, FeatureUsageType } from "@autumn/shared";
import { getBackendErr } from "@/utils/genUtils";
import CreditSystemConfig from "./CreditSystemConfig";
import { useFeaturesContext } from "../features/FeaturesContext";
import {
CustomDialogBody,
CustomDialogContent,
CustomDialogFooter,
} from "@/components/general/modal-components/DialogContentWrapper";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { validateCreditSystem } from "./utils/validateCreditSystem";
const defaultCreditSystem = {
name: "",
id: "",
@@ -36,33 +30,9 @@ const defaultCreditSystem = {
},
};
export const validateCreditSystem = (
creditSystem: CreateFeature
): string | null => {
if (!creditSystem.id || !creditSystem.name) {
return "Please fill in all fields";
}
if (creditSystem.config.schema.length === 0) {
return "Need at least one metered feature";
}
for (const item of creditSystem.config.schema) {
if (!item.metered_feature_id) {
return "Select a metered feature";
}
if (item.feature_amount <= 0 || item.credit_amount <= 0) {
return "Credit amount must be greater than 0";
}
}
return null;
};
function CreateCreditSystem() {
const { mutate, env } = useFeaturesContext();
const axiosInstance = useAxiosInstance({ env: env });
const { refetch } = useFeaturesQuery();
const axiosInstance = useAxiosInstance();
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
@@ -91,7 +61,7 @@ function CreateCreditSystem() {
type: FeatureType.CreditSystem,
config: creditSystem.config,
});
await mutate();
await refetch();
setOpen(false);
} catch (error) {
toast.error(getBackendErr(error, "Failed to create credit system"));
@@ -125,9 +95,6 @@ function CreateCreditSystem() {
</Button>
</CustomDialogFooter>
</CustomDialogContent>
{/* <DialogContent className="w-[500px] overflow-y-auto max-h-[500px]">
</DialogContent> */}
</Dialog>
);
}

View File

@@ -16,10 +16,10 @@ import {
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { useFeaturesContext } from "../features/FeaturesContext";
import { X } from "lucide-react";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
function CreditSystemConfig({
creditSystem,
@@ -28,7 +28,7 @@ function CreditSystemConfig({
creditSystem: CreateFeature;
setCreditSystem: (creditSystem: CreateFeature) => void;
}) {
const { features } = useFeaturesContext();
const { features } = useFeaturesQuery();
const [fields, setFields] = useState<any>(
creditSystem.name
? {

View File

@@ -1,21 +1,27 @@
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { CreateFeature, Feature } from "@autumn/shared";
import { CreateFeature, Feature, FeatureType } from "@autumn/shared";
import { useState } from "react";
import { CreditSystemRowToolbar } from "./CreditSystemRowToolbar";
import { useFeaturesContext } from "../features/FeaturesContext";
import UpdateCreditSystem from "./UpdateCreditSystem";
// import { CreditSystemRowToolbar } from "./CreditSystemRowToolbar";
// import UpdateCreditSystem from "./UpdateCreditSystem";
import { Item, Row } from "@/components/general/TableGrid";
import { AdminHover } from "@/components/general/AdminHover";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import UpdateCreditSystem from "./UpdateCreditSystem";
import { FeatureRowToolbar } from "../feature-row-toolbar/FeatureRowToolbar";
export const CreditSystemsTable = () => {
const { creditSystems } = useFeaturesContext();
const { features } = useFeaturesQuery();
const [selectedCreditSystem, setSelectedCreditSystem] =
useState<CreateFeature | null>(null);
const [open, setOpen] = useState(false);
const creditSystems = features.filter(
(feature) => feature.type === FeatureType.CreditSystem
);
const handleRowClick = (id: string) => {
const creditSystem = creditSystems.find(
(creditSystem: Feature) => creditSystem.id === id,
(creditSystem: Feature) => creditSystem.id === id
);
if (!creditSystem) return;
setSelectedCreditSystem(creditSystem);
@@ -74,13 +80,10 @@ export const CreditSystemsTable = () => {
</Item>
<Item className="col-span-2 text-t3 text-xs">
{formatUnixToDateTime(creditSystem.created_at).date}
{/* <span className="text-t3">
{" "}
{formatUnixToDateTime(creditSystem.created_at).time}
</span> */}
</Item>
<Item className="col-span-1 items-center justify-end">
<CreditSystemRowToolbar creditSystem={creditSystem} />
{/* <CreditSystemRowToolbar creditSystem={creditSystem} /> */}
<FeatureRowToolbar feature={creditSystem} />
</Item>
</Row>
))}

View File

@@ -1,24 +1,19 @@
import React, { useState } from "react";
import { useState } from "react";
import CreditSystemConfig from "./CreditSystemConfig";
import { CreateFeature } from "@autumn/shared";
import {
Dialog,
DialogContent,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Dialog, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { FeatureService } from "@/services/FeatureService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { useFeaturesContext } from "../features/FeaturesContext";
import { validateCreditSystem } from "./CreateCreditSystem";
import {
CustomDialogBody,
CustomDialogContent,
CustomDialogFooter,
} from "@/components/general/modal-components/DialogContentWrapper";
import { getBackendErr } from "@/utils/genUtils";
import { validateCreditSystem } from "./utils/validateCreditSystem";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
function UpdateCreditSystem({
open,
@@ -32,8 +27,8 @@ function UpdateCreditSystem({
setSelectedCreditSystem: (creditSystem: CreateFeature) => void;
}) {
const [updateLoading, setUpdateLoading] = useState(false);
const { env, mutate } = useFeaturesContext();
const axiosInstance = useAxiosInstance({ env });
const { refetch } = useFeaturesQuery();
const axiosInstance = useAxiosInstance();
const handleUpdateCreditSystem = async () => {
const validationError = validateCreditSystem(selectedCreditSystem);
@@ -51,7 +46,7 @@ function UpdateCreditSystem({
...selectedCreditSystem,
}
);
await mutate();
await refetch();
setOpen(false);
} catch (error) {
toast.error(getBackendErr(error, "Failed to update credit system"));

View File

@@ -0,0 +1,25 @@
import { CreateFeature } from "@autumn/shared";
export const validateCreditSystem = (
creditSystem: CreateFeature
): string | null => {
if (!creditSystem.id || !creditSystem.name) {
return "Please fill in all fields";
}
if (creditSystem.config.schema.length === 0) {
return "Need at least one metered feature";
}
for (const item of creditSystem.config.schema) {
if (!item.metered_feature_id) {
return "Select a metered feature";
}
if (item.feature_amount <= 0 || item.credit_amount <= 0) {
return "Credit amount must be greater than 0";
}
}
return null;
};

View File

@@ -2,49 +2,49 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Feature } from "@autumn/shared";
import { useFeaturesContext } from "../FeaturesContext";
import { useState, useEffect } from "react";
import { FeatureService } from "@/services/FeatureService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
import { toast } from "sonner";
import { useAxiosPostSWR, useAxiosSWR } from "@/services/useAxiosSwr";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useGeneralQuery } from "@/hooks/queries/useGeneralQuery";
export const DeleteFeatureDialog = ({
feature,
open,
setOpen,
dropdownOpen,
}: {
feature: Feature;
open: boolean;
setOpen: (open: boolean) => void;
dropdownOpen: boolean;
}) => {
const { mutate, env, features } = useFeaturesContext();
const { refetch } = useFeaturesQuery();
const axiosInstance = useAxiosInstance();
const [deleteLoading, setDeleteLoading] = useState(false);
const [archiveLoading, setArchiveLoading] = useState(false);
const axiosInstance = useAxiosInstance({ env });
const {
data: deletionText,
isLoading: isDeletionTextLoading,
mutate: mutateDeletionText,
} = useAxiosSWR({
isLoading: isFeatureInfoLoading,
refetch: refetchFeatureInfo,
} = useGeneralQuery({
url: `/features/data/deletion_text/${feature.id}`,
options: {
refreshInterval: 0,
},
queryKey: ["featureInfo", feature.id],
enabled: dropdownOpen,
});
useEffect(() => {
if (open) {
mutateDeletionText();
refetchFeatureInfo();
}
}, [open, feature.id]);
@@ -76,7 +76,7 @@ export const DeleteFeatureDialog = ({
setDeleteLoading(true);
try {
await FeatureService.deleteFeature(axiosInstance, feature.id);
await mutate();
await refetch();
setOpen(false);
} catch (error) {
console.error("Error deleting feature:", error);
@@ -93,7 +93,7 @@ export const DeleteFeatureDialog = ({
await FeatureService.updateFeature(axiosInstance, feature.id, {
archived: newArchivedState,
});
await mutate();
await refetch();
toast.success(
`Feature ${feature.name} ${newArchivedState ? "archived" : "unarchived"} successfully`
);
@@ -114,6 +114,8 @@ export const DeleteFeatureDialog = ({
}
};
if (isFeatureInfoLoading) return <></>;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-md" onClick={(e) => e.stopPropagation()}>

View File

@@ -1,5 +1,3 @@
import SmallSpinner from "@/components/general/SmallSpinner";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -7,24 +5,12 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useState } from "react";
import { toast } from "sonner";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { Feature } from "@autumn/shared";
import { useFeaturesContext } from "./FeaturesContext";
import { FeatureService } from "@/services/FeatureService";
import { getBackendErr } from "@/utils/genUtils";
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
import { Delete, ArchiveRestore } from "lucide-react";
import { DeleteFeatureDialog } from "./components/DeleteFeatureDialog";
import { DeleteFeatureDialog } from "./DeleteFeatureDialog";
export const FeatureRowToolbar = ({
className,
feature,
}: {
className?: string;
feature: Feature;
}) => {
export const FeatureRowToolbar = ({ feature }: { feature: Feature }) => {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
@@ -34,6 +20,7 @@ export const FeatureRowToolbar = ({
feature={feature}
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
dropdownOpen={dropdownOpen}
/>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>

View File

@@ -1,5 +1,5 @@
import { CreateFeature, FeatureType, FeatureUsageType } from "@autumn/shared";
import { defaultMeteredConfig } from "../metered-features/defaultFeatureConfig";
import { defaultMeteredConfig } from "./defaultFeatureConfig";
export const getDefaultFeature = (entityCreate?: boolean): any => {
if (entityCreate) {

View File

@@ -0,0 +1,15 @@
import { parseAsBoolean, useQueryStates } from "nuqs";
export const useProductsQueryState = () => {
const [queryStates, setQueryStates] = useQueryStates(
{
showArchivedProducts: parseAsBoolean.withDefault(false),
showArchivedFeatures: parseAsBoolean.withDefault(false),
},
{
history: "push",
}
);
return { queryStates, setQueryStates };
};

View File

@@ -10,7 +10,6 @@ export const ManageProduct = ({
}: {
hideAdminHover?: boolean;
}) => {
const env = useEnv();
const { product, entityId, customer } = useProductContext();
return (

View File

@@ -1,28 +1,24 @@
"use client";
import { useState } from "react";
import ErrorScreen from "@/views/general/ErrorScreen";
import LoadingScreen from "@/views/general/LoadingScreen";
import ProductSidebar from "./ProductSidebar";
import ProductViewBreadcrumbs from "./components/ProductViewBreadcrumbs";
import ConfirmNewVersionDialog from "./versioning/ConfirmNewVersionDialog";
import { toast } from "sonner";
import { useState } from "react";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import { ProductContext } from "./ProductContext";
import { useParams, useSearchParams } from "react-router";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { ManageProduct } from "./ManageProduct";
import { AppEnv, UpdateProductSchema } from "@autumn/shared";
import { toast } from "sonner";
import { ProductService } from "@/services/products/ProductService";
import { getBackendErr } from "@/utils/genUtils";
import { UpdateProductButton } from "@/views/products/product/components/UpdateProductButton";
import { updateProduct as updateProductUtil } from "./utils/updateProduct";
import { isFreeProduct } from "@/utils/product/priceUtils";
import ErrorScreen from "@/views/general/ErrorScreen";
import ProductSidebar from "./ProductSidebar";
import { FeaturesContext } from "@/views/features/FeaturesContext";
import ProductViewBreadcrumbs from "./components/ProductViewBreadcrumbs";
import ConfirmNewVersionDialog from "./versioning/ConfirmNewVersionDialog";
import { useProductData } from "./hooks/useProductData";
import { useProductChangedAlert } from "./hooks/useProductChangedAlert";
import { useProductData } from "./hooks/useProductData";
function ProductView({ env }: { env: AppEnv }) {
const axiosInstance = useAxiosInstance();
@@ -112,62 +108,55 @@ function ProductView({ env }: { env: AppEnv }) {
};
return (
<FeaturesContext.Provider
<ProductContext.Provider
value={{
env,
...data,
features,
setFeatures,
mutate,
env,
product,
setProduct,
selectedEntitlementAllowance,
setSelectedEntitlementAllowance,
counts,
version,
mutateCount,
actionState,
handleCreateProduct: updateProductClicked,
entityFeatureIds,
setEntityFeatureIds,
hasChanges,
buttonLoading,
setButtonLoading,
}}
>
<ProductContext.Provider
value={{
...data,
features,
setFeatures,
mutate,
env,
product,
setProduct,
selectedEntitlementAllowance,
setSelectedEntitlementAllowance,
counts,
version,
mutateCount,
actionState,
handleCreateProduct: updateProductClicked,
entityFeatureIds,
setEntityFeatureIds,
hasChanges,
buttonLoading,
setButtonLoading,
}}
>
<ConfirmNewVersionDialog
open={showNewVersionDialog}
setOpen={setShowNewVersionDialog}
createProduct={updateProduct}
/>
<div className="flex w-full">
<div className="flex flex-col gap-4 w-full">
<ProductViewBreadcrumbs />
<ConfirmNewVersionDialog
open={showNewVersionDialog}
setOpen={setShowNewVersionDialog}
createProduct={updateProduct}
/>
<div className="flex w-full">
<div className="flex flex-col gap-4 w-full">
<ProductViewBreadcrumbs />
<div className="flex">
<div className="flex-1 w-full min-w-sm">
<ManageProduct />
</div>
</div>
<div className="flex justify-end gap-2 p-10 w-full lg:hidden">
<div className="w-fit">
<UpdateProductButton />
</div>
<div className="flex">
<div className="flex-1 w-full min-w-sm">
<ManageProduct />
</div>
</div>
<div className="flex max-w-md w-1/3 shrink-1 lg:block lg:min-w-xs sticky top-0">
<ProductSidebar />
<div className="flex justify-end gap-2 p-10 w-full lg:hidden">
<div className="w-fit">
<UpdateProductButton />
</div>
</div>
</div>
{modal}
</ProductContext.Provider>
</FeaturesContext.Provider>
<div className="flex max-w-md w-1/3 shrink-1 lg:block lg:min-w-xs sticky top-0">
<ProductSidebar />
</div>
</div>
{modal}
</ProductContext.Provider>
);
}

View File

@@ -20,7 +20,7 @@ import {
import { useState } from "react";
import { useProductContext } from "../ProductContext";
import { CheckIcon, PlusIcon } from "lucide-react";
import { CreateFeature } from "@/views/features/CreateFeature";
import { CreateFeature } from "@/views/products/features/components/CreateFeature";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { CustomDialogContent } from "@/components/general/modal-components/DialogContentWrapper";

View File

@@ -7,7 +7,7 @@ import {
} from "@/components/ui/select";
import { useProductItemContext } from "../ProductItemContext";
import { useProductContext } from "../../ProductContext";
import { FeatureTypeBadge } from "@/views/features/FeatureTypeBadge";
import { FeatureTypeBadge } from "@/views/products/features/components/FeatureTypeBadge";
import { Feature, FeatureType, ProductItemType } from "@autumn/shared";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";

View File

@@ -16,12 +16,8 @@ import { ItemConfigFooter } from "../product-item-config/item-config-footer/Item
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";
import { CreateFeature } from "@/views/features/CreateFeature";
import { CreateFeature } from "@/views/products/features/components/CreateFeature";
import { CreateItemStep } from "../utils/CreateItemStep";
import { useSteps } from "../useSteps";
import { SelectFeatureStep } from "../product-item-config/components/SelectFeature";

View File

@@ -7,7 +7,7 @@ import { useProductContext } from "@/views/products/product/ProductContext";
import { Feature, FeatureType } from "@autumn/shared";
import { useProductItemContext } from "../../ProductItemContext";
import { isFeaturePriceItem } from "@/utils/product/getItemType";
import { FeatureTypeBadge } from "@/views/features/FeatureTypeBadge";
import { FeatureTypeBadge } from "@/views/products/features/components/FeatureTypeBadge";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Plus } from "lucide-react";
import { CreateItemStep } from "../../utils/CreateItemStep";

View File

@@ -1,18 +1,17 @@
import CreateProduct from "./components/CreateProduct";
import { PageSectionHeader } from "@/components/general/PageSectionHeader";
import { Badge } from "@/components/ui/badge";
import { parseAsBoolean, useQueryStates } from "nuqs";
import CreateProduct from "./CreateProduct";
import { ProductsTable } from "../ProductsTable";
import { ProductsTable } from "./components/ProductsTable";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { HamburgerMenu } from "@/components/general/table-components/HamburgerMenu";
import { useState } from "react";
import { useProductsQueryState } from "../hooks/useProductsQueryState";
export const ProductsPage = () => {
const [{ showArchived }] = useQueryStates(
{
showArchived: parseAsBoolean.withDefault(false),
},
{
history: "push",
}
);
const [dropdownOpen, setDropdownOpen] = useState(false);
const { queryStates, setQueryStates } = useProductsQueryState();
const { products } = useProductsQuery();
return (
<div>
@@ -21,9 +20,9 @@ export const ProductsPage = () => {
titleComponent={
<>
<span className="text-t2 px-1 rounded-md bg-stone-200 mr-2">
{/* {data?.products?.length} */}
{products?.length}
</span>
{showArchived && (
{queryStates.showArchivedProducts && (
<Badge className="shadow-none bg-yellow-100 border-yellow-500 text-yellow-500 hover:bg-yellow-100">
Archived
</Badge>
@@ -31,21 +30,25 @@ export const ProductsPage = () => {
</>
}
addButton={<CreateProduct />}
// menuComponent={
// <HamburgerMenu
// dropdownOpen={dropdownOpen}
// setDropdownOpen={setDropdownOpen}
// actions={[
// {
// type: "item",
// label: showArchived
// ? `Show active products`
// : `Show archived products`,
// onClick: () => setShowArchived((prev) => !prev),
// },
// ]}
// />
// }
menuComponent={
<HamburgerMenu
dropdownOpen={dropdownOpen}
setDropdownOpen={setDropdownOpen}
actions={[
{
type: "item",
label: queryStates.showArchivedProducts
? `Show active products`
: `Show archived products`,
onClick: () =>
setQueryStates({
...queryStates,
showArchivedProducts: !queryStates.showArchivedProducts,
}),
},
]}
/>
}
/>
<ProductsTable />
</div>

View File

@@ -13,9 +13,8 @@ import { useNavigate } from "react-router";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useProductsContext } from "../ProductsContext";
import { getBackendErr, navigateTo } from "@/utils/genUtils";
import { ProductConfig } from "../ProductConfig";
import { ProductConfig } from "../../ProductConfig";
import { ProductV2 } from "@autumn/shared";
import { ToggleButton } from "@/components/general/ToggleButton";
import { WarningBox } from "@/components/general/modal-components/WarningBox";
@@ -34,12 +33,11 @@ function CreateProduct({
}: {
onSuccess?: (newProduct: ProductV2) => Promise<void>;
}) {
// const { env, mutate, groupToDefaults } = useProductsContext();
const [loading, setLoading] = useState(false);
const [product, setProduct] = useState(defaultProduct);
const [open, setOpen] = useState(false);
const { groupToDefaults } = useProductsQuery();
const { groupToDefaults, refetch } = useProductsQuery();
const axiosInstance = useAxiosInstance();
const navigate = useNavigate();
@@ -52,14 +50,14 @@ function CreateProduct({
product
);
// await mutate();
await refetch();
// if (onSuccess) {
// await onSuccess(newProduct);
// } else {
// navigateTo(`/products/${newProduct.id}`, navigate, env);
// }
// setOpen(false);
if (onSuccess) {
await onSuccess(newProduct);
} else {
navigateTo(`/products/${newProduct.id}`, navigate);
}
setOpen(false);
} catch (error) {
toast.error(getBackendErr(error, "Failed to create product"));
}

View File

@@ -1,24 +1,26 @@
import CopyButton from "@/components/general/CopyButton";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { useNavigate } from "react-router";
import { ProductRowToolbar } from "./components/ProductRowToolbar";
import { navigateTo } from "@/utils/genUtils";
import { useProductsContext } from "./ProductsContext";
import { AdminHover } from "@/components/general/AdminHover";
import { Item, Row } from "@/components/general/TableGrid";
import { cn } from "@/lib/utils";
import { ProductCountsTooltip } from "./components/ProductCountsTooltip";
import { ProductTypeBadge } from "./components/ProductTypeBadge";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useProductsContext } from "../../ProductsContext";
import { ProductCountsTooltip } from "../product-row-toolbar/ProductCountsTooltip";
import { ProductTypeBadge } from "../product-row-toolbar/ProductTypeBadge";
import { ProductRowToolbar } from "../product-row-toolbar/ProductRowToolbar";
import { useProductsQueryState } from "../../hooks/useProductsQueryState";
export const ProductsTable = ({
onRowClick,
}: {
onRowClick?: (id: string) => void;
}) => {
const { env, onboarding, showArchived } = useProductsContext();
const { allCounts } = useProductsContext();
const { products } = useProductsQuery();
const { onboarding } = useProductsContext();
const { queryStates } = useProductsQueryState();
const { products, counts } = useProductsQuery();
const navigate = useNavigate();
@@ -26,9 +28,13 @@ export const ProductsTable = ({
Boolean(product?.group?.trim())
);
const filteredProducts = products?.filter((product) =>
queryStates.showArchivedProducts ? product.archived : !product.archived
);
return (
<>
{products && products.length > 0 ? (
{filteredProducts && filteredProducts.length > 0 ? (
<Row
type="header"
className={cn(
@@ -59,7 +65,7 @@ export const ProductsTable = ({
onboarding && "px-2 mt-4"
)}
>
{showArchived ? (
{queryStates.showArchivedProducts ? (
<span>You haven't archived any products yet.</span>
) : (
<>
@@ -74,8 +80,8 @@ export const ProductsTable = ({
)
)}
{products &&
products
{filteredProducts &&
filteredProducts
.reduce(
(acc, product) => {
const existingIndex = acc.findIndex((p) => p.id === product.id);
@@ -85,7 +91,7 @@ export const ProductsTable = ({
} else {
const existing = acc[existingIndex];
if (showArchived) {
if (queryStates.showArchivedProducts) {
// If showing archived, always keep the newest version
if (product.version > existing.version) {
acc[existingIndex] = product;
@@ -106,7 +112,7 @@ export const ProductsTable = ({
return acc;
},
[] as typeof products
[] as typeof filteredProducts
)
.map((product) => (
<Row
@@ -121,7 +127,7 @@ export const ProductsTable = ({
if (onRowClick) {
onRowClick(product.id);
} else {
navigateTo(`/products/${product.id}`, navigate, env);
navigateTo(`/products/${product.id}`, navigate);
}
}}
>
@@ -171,7 +177,10 @@ export const ProductsTable = ({
onboarding && "col-span-6"
)}
>
<ProductRowToolbar />
<ProductRowToolbar
product={product}
productCounts={counts?.[product.id]}
/>
</Item>
</Row>
))}

View File

@@ -8,7 +8,7 @@ import React, { useState } from "react";
import { AppEnv } from "@autumn/shared";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils";
import { Product } from "@autumn/shared";
import { ProductV2 } from "@autumn/shared";
import { getBackendErr } from "@/utils/genUtils";
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Input } from "@/components/ui/input";
@@ -19,24 +19,24 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Select } from "@/components/ui/select";
import { useProductsContext } from "../ProductsContext";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
export const CopyDialog = ({
product,
setModalOpen,
}: {
product: Product;
product: ProductV2;
setModalOpen: (open: boolean) => void;
}) => {
const env = useEnv();
const axiosInstance = useAxiosInstance({ env });
const { mutate: productsMutate } = useProductsContext();
const { refetch } = useProductsQuery();
const [copyLoading, setCopyLoading] = useState(false);
const [name, setName] = useState(product.name);
const [id, setId] = useState(product.id);
const [toEnv, setToEnv] = useState<AppEnv>(
env == AppEnv.Live ? AppEnv.Sandbox : AppEnv.Live,
env == AppEnv.Live ? AppEnv.Sandbox : AppEnv.Live
);
const handleCopy = async () => {
@@ -54,7 +54,7 @@ export const CopyDialog = ({
name: name,
env: toEnv,
});
await productsMutate();
await refetch();
toast.success("Successfully copied product");
setModalOpen(false);
@@ -69,7 +69,7 @@ export const CopyDialog = ({
<DialogContent onClick={(e) => e.stopPropagation()}>
<DialogTitle>Copy Product</DialogTitle>
<div className="flex flex-col gap-4">
<div className="flex flex gap-2 w-full">
<div className="flex gap-2 w-full">
<div className="w-full">
<FieldLabel>Name</FieldLabel>
<Input

View File

@@ -1,23 +1,16 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DialogTrigger } from "@/components/ui/dialog";
import { AppEnv, Product, ProductCounts } from "@autumn/shared";
import { useProductsContext } from "../ProductsContext";
import { Skeleton } from "@/components/ui/skeleton";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import { useState } from "react";
import { ProductCounts, ProductV2 } from "@autumn/shared";
import { ProductService } from "@/services/products/ProductService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { ToggleButton } from "@/components/general/ToggleButton";
import { useEnv } from "@/utils/envUtils";
import {
Select,
SelectContent,
@@ -27,51 +20,35 @@ import {
} from "@/components/ui/select";
import { getBackendErr } from "@/utils/genUtils";
import { toast } from "sonner";
import { useEffect } from "react";
import { versions } from "process";
import { version } from "os";
import { useProductInfoQuery } from "./hooks/useProductInfoQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useGeneralQuery } from "@/hooks/queries/useGeneralQuery";
export const DeleteProductDialog = ({
product,
dropdownOpen,
open,
setOpen,
productCounts,
}: {
product: Product;
product: ProductV2;
dropdownOpen: boolean;
open: boolean;
setOpen: (open: boolean) => void;
productCounts?: ProductCounts;
}) => {
const { mutate } = useProductsContext();
const [deleteLoading, setDeleteLoading] = useState(false);
const [archiveLoading, setArchiveLoading] = useState(false);
const axiosInstance = useAxiosInstance();
const env = useEnv();
const { data: productInfo, isLoading } = useAxiosSWR({
const { refetch } = useProductsQuery();
const { data: productInfo, isLoading } = useGeneralQuery({
url: `/products/${product.id}/info`,
options: {
refreshInterval: 0,
},
queryKey: ["productInfo", product.id],
enabled: dropdownOpen,
});
// const {
// data: deletionText,
// isLoading: isDeletionTextLoading,
// mutate: mutateDeletionText,
// } = useAxiosSWR({
// url: `/products/data/deletion_text/${product.id}`,
// options: {
// refreshInterval: 0,
// },
// });
// useEffect(() => {
// if (open) {
// mutateDeletionText();
// }
// }, [open, product.internal_id]);
const [deleteAllVersions, setDeleteAllVersions] = useState(false);
const handleDelete = async () => {
@@ -82,7 +59,7 @@ export const DeleteProductDialog = ({
product.id,
deleteAllVersions
);
await mutate();
await refetch();
setOpen(false);
} catch (error) {
console.error("Error deleting product:", error);
@@ -135,7 +112,7 @@ export const DeleteProductDialog = ({
}
await Promise.all(updatePromises);
}
await mutate();
await refetch();
toast.success(
`Product ${product.name} ${newArchivedState ? "archived" : "unarchived"} successfully`
);
@@ -186,38 +163,6 @@ export const DeleteProductDialog = ({
},
withoutCustomers: (productText: string) =>
`Are you sure you want to delete this ${productText}? This action cannot be undone.`,
// live: {
// withCustomers: {
// single: (customerName: string, productText: string) =>
// `${customerName} is on this ${productText}. Please delete them first before deleting the ${productText}. Would you like to archive the product instead?`,
// multiple: (
// customerName: string,
// otherCount: number,
// productText: string
// ) =>
// `${customerName} and ${otherCount} other customer${otherCount > 1 ? "s" : ""} are on this ${productText}. Please delete them first before deleting the ${productText}. Would you like to archive the product instead?`,
// fallback: (productText: string) =>
// `There are customers on this ${productText}. Please delete them first before deleting the ${productText}. Would you like to archive the product instead?`,
// },
// withoutCustomers: (productText: string) =>
// `Are you sure you want to delete this ${productText}? This action cannot be undone. You can also archive the ${productText} instead.`,
// },
// sandbox: {
// withCustomers: {
// single: (customerName: string, productText: string) =>
// `${customerName} is on this ${productText}. Deleting this ${productText} will remove it from ${customerName}'s account. Are you sure you want to continue? You can also archive the product instead.`,
// multiple: (
// customerName: string,
// otherCount: number,
// productText: string
// ) =>
// `${customerName} and ${otherCount} other customer${otherCount > 1 ? "s" : ""} are on this ${productText}. Deleting this ${productText} will remove it from their accounts. Are you sure you want to continue? You can also archive the product instead.`,
// fallback: (productText: string) =>
// `There are customers on this ${productText}. Deleting this ${productText} will remove it from their accounts. Are you sure you want to continue? You can also archive the product instead.`,
// },
// withoutCustomers: (productText: string) =>
// `Are you sure you want to delete this ${productText}? This action cannot be undone.`,
// },
};
const templates = messageTemplates;
@@ -244,13 +189,12 @@ export const DeleteProductDialog = ({
} else {
return templates.withCustomers.fallback(productText);
}
return "";
} else {
return templates.withoutCustomers(productText);
}
};
if (!productInfo) {
if (!productInfo || isLoading) {
return <></>;
}

View File

@@ -7,23 +7,25 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useState } from "react";
import { toast } from "sonner";
import { Product, ProductCounts } from "@autumn/shared";
import { ProductService } from "@/services/products/ProductService";
import { useProductsContext } from "../ProductsContext";
import { ProductCounts, ProductV2 } from "@autumn/shared";
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { UpdateProductDialog } from "../UpdateProduct";
import { CopyDialog } from "./CopyDialog";
import { Copy, Delete, Pen, ArchiveRestore, Archive } from "lucide-react";
import { DeleteProductDialog } from "./DeleteProductDialog";
import { CopyDialog } from "./CopyDialog";
import { UpdateProductDialog } from "../../UpdateProduct";
export const ProductRowToolbar = ({
className,
isOnboarding = false,
product,
productCounts,
}: {
className?: string;
isOnboarding?: boolean;
className?: string;
product: ProductV2;
productCounts: ProductCounts | undefined;
}) => {
const [deleteLoading, setDeleteLoading] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
@@ -50,12 +52,12 @@ export const ProductRowToolbar = ({
open={deleteOpen}
setOpen={setDeleteOpen}
productCounts={productCounts}
dropdownOpen={dropdownOpen}
/>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
{dialogType == "update" ? (
<UpdateProductDialog
selectedProduct={product}
setSelectedProduct={setSelectedProduct}
setModalOpen={setModalOpen}
setDropdownOpen={setDeleteOpen}
/>
@@ -127,10 +129,6 @@ export const ProductRowToolbar = ({
)}
</div>
</DropdownMenuItem>
{/* {env == AppEnv.Sandbox && (
)} */}
</DropdownMenuContent>
</DropdownMenu>
</Dialog>

View File

@@ -0,0 +1,43 @@
import { PageSectionHeader } from "@/components/general/PageSectionHeader";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import LoadingScreen from "@/views/general/LoadingScreen";
import { RewardsTable } from "./components/RewardsTable";
import CreateReward from "./reward-config/CreateReward";
import { RewardProgramsTable } from "./reward-programs/RewardProgramsTable";
import CreateRewardProgram from "./reward-programs/CreateRewardProgram";
export const RewardsPage = () => {
const { rewards, rewardPrograms } = useRewardsQuery();
return (
<div className="flex flex-col gap-16">
<div>
<PageSectionHeader
title="Rewards"
titleComponent={
<span className="text-t2 px-1 rounded-md bg-stone-200 mr-2">
{rewards?.length}
</span>
}
endContent={<CreateReward />}
/>
<RewardsTable />
</div>
<div>
<PageSectionHeader
title="Referral Programs"
titleComponent={
<span className="text-t2 px-1 rounded-md bg-stone-200 mr-2">
{rewardPrograms?.length}
</span>
}
endContent={<CreateRewardProgram />}
isSecondary
/>
<RewardProgramsTable />
</div>
</div>
);
};

View File

@@ -12,14 +12,14 @@ import { toast } from "sonner";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { Reward } from "@autumn/shared";
import { getBackendErr } from "@/utils/genUtils";
import { useProductsContext } from "../ProductsContext";
import { RewardService } from "@/services/products/RewardService";
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
import { Delete } from "lucide-react";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
export const RewardRowToolbar = ({ reward }: { reward: Reward }) => {
const { env, mutate } = useProductsContext();
const axiosInstance = useAxiosInstance({ env });
const { refetch } = useRewardsQuery();
const axiosInstance = useAxiosInstance();
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@@ -31,7 +31,7 @@ export const RewardRowToolbar = ({ reward }: { reward: Reward }) => {
axiosInstance,
internalId: reward.internal_id,
});
await mutate();
await refetch();
} catch (error) {
toast.error(getBackendErr(error, "Failed to delete coupon"));
}
@@ -44,7 +44,7 @@ export const RewardRowToolbar = ({ reward }: { reward: Reward }) => {
<DropdownMenuTrigger asChild>
<ToolbarButton />
</DropdownMenuTrigger>
<DropdownMenuContent className="text-t2">
<DropdownMenuContent className="text-t2" align="end">
<DropdownMenuItem
className="flex items-center"
onClick={async (e) => {

View File

@@ -1,17 +1,21 @@
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { useProductsContext } from "../ProductsContext";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { Reward, RewardType, Product } from "@autumn/shared";
import UpdateReward from "./UpdateReward";
import { useState } from "react";
import { RewardRowToolbar } from "./RewardRowToolbar";
import { Item, Row } from "@/components/general/TableGrid";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { Reward, RewardType, Product, ProductV2 } from "@autumn/shared";
import { AdminHover } from "@/components/general/AdminHover";
import { Item, Row } from "@/components/general/TableGrid";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import UpdateReward from "../reward-config/UpdateReward";
import { RewardRowToolbar } from "./RewardRowToolbar";
import { useOrg } from "@/hooks/common/useOrg";
export const RewardsTable = () => {
const { rewards, org, products } = useProductsContext();
const [selectedReward, setSelectedReward] = useState<Reward | null>(null);
const [open, setOpen] = useState(false);
const { rewards } = useRewardsQuery();
const { products } = useProductsQuery();
const { org } = useOrg();
return (
<>
@@ -61,7 +65,7 @@ export const RewardsTable = () => {
<Item className="col-span-4">{keyToTitle(reward.type)}</Item>
<Item className="col-span-3">
{reward.type == RewardType.FreeProduct ? (
products.find((p: Product) => p.id == reward.free_product_id)
products.find((p: ProductV2) => p.id == reward.free_product_id)
?.name
) : (
<span>

View File

@@ -14,10 +14,10 @@ import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { getBackendErr } from "@/utils/genUtils";
import { useProductsContext } from "../ProductsContext";
import { useProductsContext } from "../../ProductsContext";
import { RewardService } from "@/services/products/RewardService";
import { RewardConfig } from "./RewardConfig";
import { defaultReward } from "./defaultRewardModels";
import { defaultReward } from "../utils/defaultRewardModels";
function CreateReward() {
const { mutate, env } = useProductsContext();

View File

@@ -10,7 +10,6 @@ import {
RewardType,
ProductItem,
} from "@autumn/shared";
import { useProductsContext } from "../ProductsContext";
import {
Popover,
PopoverContent,
@@ -30,6 +29,9 @@ import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { isFeatureItem } from "@/utils/product/getItemType";
import { formatProductItemText } from "@/utils/product/product-item/formatProductItem";
import { useOrg } from "@/hooks/common/useOrg";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
export const DiscountConfig = ({
reward,
@@ -38,7 +40,7 @@ export const DiscountConfig = ({
reward: Reward;
setReward: (reward: Reward) => void;
}) => {
const { org } = useProductsContext();
const { org } = useOrg();
const config = reward.discount_config!;
const setConfig = (key: any, value: any) => {
@@ -62,7 +64,7 @@ export const DiscountConfig = ({
<p className="text-t3">
{reward.type === RewardType.PercentageDiscount
? "%"
: org?.currency || "USD"}
: org?.default_currency || "USD"}
</p>
}
/>
@@ -148,7 +150,10 @@ const ProductPriceSelector = ({
reward: Reward;
setReward: (reward: Reward) => void;
}) => {
const { products, features, org } = useProductsContext();
const { org } = useOrg();
const { products } = useProductsQuery();
const { features } = useFeaturesQuery();
const [open, setOpen] = useState(false);
const config = reward.discount_config!;

View File

@@ -12,10 +12,10 @@ import {
FullProduct,
ProductV2,
} from "@autumn/shared";
import { useProductsContext } from "../ProductsContext";
import { useProductsContext } from "../../ProductsContext";
import { DiscountConfig } from "./DiscountConfig";
import { notNullish } from "@/utils/genUtils";
import { defaultDiscountConfig } from "./defaultRewardModels";
import { defaultDiscountConfig } from "../utils/defaultRewardModels";
import { isFreeProduct } from "@/utils/product/priceUtils";
export const RewardConfig = ({

View File

@@ -6,16 +6,15 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { FeatureService } from "@/services/FeatureService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { Reward } from "@autumn/shared";
import { useEnv } from "@/utils/envUtils";
import { useProductsContext } from "../ProductsContext";
import { RewardConfig } from "./RewardConfig";
import { RewardService } from "@/services/products/RewardService";
import { getBackendErr } from "@/utils/genUtils";
import { WarningBox } from "@/components/general/modal-components/WarningBox";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { RewardConfig } from "./RewardConfig";
function UpdateReward({
open,
@@ -29,7 +28,7 @@ function UpdateReward({
setSelectedReward: (reward: Reward) => void;
}) {
const [updateLoading, setUpdateLoading] = useState(false);
const { rewards, mutate } = useProductsContext();
const { refetch } = useRewardsQuery();
const env = useEnv();
const axiosInstance = useAxiosInstance({ env });
@@ -43,7 +42,7 @@ function UpdateReward({
data: selectedReward!,
});
toast.success("Reward updated successfully");
await mutate();
await refetch();
setOpen(false);
} catch (error) {
toast.error(getBackendErr(error, "Failed to update coupon"));

View File

@@ -1,7 +1,3 @@
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { SelectContent } from "@/components/ui/select";
import { SelectTrigger, SelectValue } from "@/components/ui/select";
import { SelectItem } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -11,25 +7,13 @@ import {
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Select } from "@/components/ui/select";
import React, { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { FeatureService } from "@/services/FeatureService";
import { useEffect, useState } from "react";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { PlusIcon } from "lucide-react";
import {
Reward,
CouponDurationType,
CreateReward as CreateCouponType,
DiscountType,
RewardProgram,
RewardTriggerEvent,
RewardReceivedBy,
} from "@autumn/shared";
import { getBackendErr } from "@/utils/genUtils";
import { useProductsContext } from "../ProductsContext";
import { RewardTriggerEvent, RewardReceivedBy } from "@autumn/shared";
import { getBackendErr } from "@/utils/genUtils";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { CreateRewardProgram } from "@autumn/shared";
import { RewardProgramConfig } from "./RewardProgramConfig";
@@ -49,8 +33,8 @@ const defaultRewardProgram: CreateRewardProgram = {
};
function CreateRewardProgramModal() {
const { mutate, env } = useProductsContext();
const axiosInstance = useAxiosInstance({ env: env });
const { refetch } = useRewardsQuery();
const axiosInstance = useAxiosInstance();
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
@@ -68,7 +52,7 @@ function CreateRewardProgramModal() {
try {
await axiosInstance.post("/v1/reward_programs", rewardProgram);
await mutate();
await refetch();
setOpen(false);
} catch (error) {
toast.error(getBackendErr(error, "Failed to create referral program"));

View File

@@ -13,7 +13,7 @@ import {
RewardTriggerEvent,
RewardReceivedBy,
} from "@autumn/shared";
import { useProductsContext } from "../ProductsContext";
import { useProductsContext } from "../../ProductsContext";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { useState } from "react";
import {
@@ -32,6 +32,8 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
export const RewardProgramConfig = ({
rewardProgram,
@@ -40,7 +42,7 @@ export const RewardProgramConfig = ({
rewardProgram: RewardProgram;
setRewardProgram: (rewardProgram: RewardProgram) => void;
}) => {
const { rewards } = useProductsContext();
const { rewards } = useRewardsQuery();
return (
<div className="flex flex-col gap-4">
@@ -163,7 +165,7 @@ const ProductSelector = ({
rewardProgram: RewardProgram;
setRewardProgram: (rewardProgram: RewardProgram) => void;
}) => {
const { products } = useProductsContext();
const { products } = useProductsQuery();
const [open, setOpen] = useState(false);
// Handle selection/deselection of a product

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { useProductsContext } from "../ProductsContext";
import { useProductsContext } from "../../ProductsContext";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { RewardProgram } from "@autumn/shared";
@@ -14,14 +14,15 @@ import { toast } from "sonner";
import SmallSpinner from "@/components/general/SmallSpinner";
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
import { Delete } from "lucide-react";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
export const RewardProgramRowToolbar = ({
rewardProgram,
}: {
rewardProgram: RewardProgram;
}) => {
const { env, mutate } = useProductsContext();
const axiosInstance = useAxiosInstance({ env });
const { refetch } = useRewardsQuery();
const axiosInstance = useAxiosInstance();
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@@ -30,8 +31,7 @@ export const RewardProgramRowToolbar = ({
try {
await axiosInstance.delete(`/v1/reward_programs/${rewardProgram.id}`);
await mutate();
await refetch();
} catch (error) {
toast.error(getBackendErr(error, "Failed to delete reward trigger"));
}
@@ -44,7 +44,7 @@ export const RewardProgramRowToolbar = ({
<DropdownMenuTrigger asChild>
<ToolbarButton />
</DropdownMenuTrigger>
<DropdownMenuContent className="text-t2">
<DropdownMenuContent className="text-t2" align="end">
<DropdownMenuItem
className="flex items-center"
onClick={async (e) => {

View File

@@ -1,14 +1,16 @@
import { useProductsContext } from "../ProductsContext";
// import { useProductsContext } from "../ProductsContext";
import { useState } from "react";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { RewardProgram, RewardTriggerEvent } from "@autumn/shared";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { RewardProgramRowToolbar } from "./RewardProgramRowToolbar";
// import { RewardProgramRowToolbar } from "./RewardProgramRowToolbar";
import { Item, Row } from "@/components/general/TableGrid";
import { AdminHover } from "@/components/general/AdminHover";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { RewardProgramRowToolbar } from "./RewardProgramRowToolbar";
export const RewardProgramsTable = () => {
const { rewardPrograms } = useProductsContext();
const { rewardPrograms } = useRewardsQuery();
const [selectedRewardProgram, setSelectedRewardProgram] =
useState<RewardProgram | null>(null);
const [open, setOpen] = useState(false);
@@ -65,8 +67,8 @@ export const RewardProgramsTable = () => {
{rewardProgram.when == RewardTriggerEvent.CustomerCreation
? "Sign Up"
: rewardProgram.when == RewardTriggerEvent.Checkout
? "Checkout"
: keyToTitle(rewardProgram.when)}
? "Checkout"
: keyToTitle(rewardProgram.when)}
</Item>
<Item className="col-span-2 text-t3 text-xs">
{formatUnixToDateTime(rewardProgram.created_at).date}