added tanstack query to customers and products page
This commit is contained in:
@@ -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];
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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={{}}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
36
vite/src/hooks/common/useAppQueryStates.tsx
Normal file
36
vite/src/hooks/common/useAppQueryStates.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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) || "";
|
||||
};
|
||||
|
||||
@@ -17,5 +17,5 @@ export const useFeaturesQuery = () => {
|
||||
queryFn: fetchFeatures,
|
||||
});
|
||||
|
||||
return { features: data?.features || [], isLoading, error, mutate: refetch };
|
||||
return { features: data?.features || [], isLoading, error, refetch };
|
||||
};
|
||||
|
||||
27
vite/src/hooks/queries/useGeneralQuery.tsx
Normal file
27
vite/src/hooks/queries/useGeneralQuery.tsx
Normal 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 };
|
||||
};
|
||||
@@ -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()]);
|
||||
// },
|
||||
};
|
||||
};
|
||||
|
||||
24
vite/src/hooks/queries/useRewardsQuery.tsx
Normal file
24
vite/src/hooks/queries/useRewardsQuery.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}, {});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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' usage. These are
|
||||
made of other metered features.
|
||||
</p>
|
||||
</div>
|
||||
<CreditSystemsTable />
|
||||
<CreateCreditSystem />
|
||||
</FeaturesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreditSystemsView;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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>
|
||||
</>
|
||||
63
vite/src/views/customers/components/CustomersPagination.tsx
Normal file
63
vite/src/views/customers/components/CustomersPagination.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
58
vite/src/views/customers/components/CustomersSearchBar.tsx
Normal file
58
vite/src/views/customers/components/CustomersSearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
@@ -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 (
|
||||
@@ -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"));
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
95
vite/src/views/customers/hooks/useCusSearchQuery.tsx
Normal file
95
vite/src/views/customers/hooks/useCusSearchQuery.tsx
Normal 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,
|
||||
// },
|
||||
// });
|
||||
38
vite/src/views/customers/hooks/useCustomersQueryStates.tsx
Normal file
38
vite/src/views/customers/hooks/useCustomersQueryStates.tsx
Normal 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 };
|
||||
};
|
||||
8
vite/src/views/customers/hooks/useSavedViewsQuery.tsx
Normal file
8
vite/src/views/customers/hooks/useSavedViewsQuery.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useGeneralQuery } from "@/hooks/queries/useGeneralQuery";
|
||||
|
||||
export const useSavedViewsQuery = () => {
|
||||
return useGeneralQuery({
|
||||
url: "/saved_views",
|
||||
queryKey: ["saved_views"],
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'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> */
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
82
vite/src/views/products/features/FeaturesPage.tsx
Normal file
82
vite/src/views/products/features/FeaturesPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
? {
|
||||
@@ -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>
|
||||
))}
|
||||
@@ -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"));
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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()}>
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
15
vite/src/views/products/hooks/useProductsQueryState.tsx
Normal file
15
vite/src/views/products/hooks/useProductsQueryState.tsx
Normal 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 };
|
||||
};
|
||||
@@ -10,7 +10,6 @@ export const ManageProduct = ({
|
||||
}: {
|
||||
hideAdminHover?: boolean;
|
||||
}) => {
|
||||
const env = useEnv();
|
||||
const { product, entityId, customer } = useProductContext();
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
@@ -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
|
||||
@@ -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 <></>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
43
vite/src/views/products/rewards/RewardsPage.tsx
Normal file
43
vite/src/views/products/rewards/RewardsPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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) => {
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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!;
|
||||
@@ -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 = ({
|
||||
@@ -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"));
|
||||
@@ -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"));
|
||||
@@ -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
|
||||
@@ -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) => {
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user