diff --git a/server/src/internal/customers/CusSearchService.ts b/server/src/internal/customers/CusSearchService.ts index e39260b00..f7a0578e5 100644 --- a/server/src/internal/customers/CusSearchService.ts +++ b/server/src/internal/customers/CusSearchService.ts @@ -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]; +// } +// } diff --git a/server/src/internal/customers/internalCusRouter.ts b/server/src/internal/customers/internalCusRouter.ts index 8420a123b..a7ef5e547 100644 --- a/server/src/internal/customers/internalCusRouter.ts +++ b/server/src/internal/customers/internalCusRouter.ts @@ -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; diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index c59f66be1..499c267e8 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -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; diff --git a/vite/src/App.tsx b/vite/src/App.tsx index 7beea402a..88e873b7a 100644 --- a/vite/src/App.tsx +++ b/vite/src/App.tsx @@ -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() { } /> } /> } /> - } - /> - } - /> + } diff --git a/vite/src/app/layout.tsx b/vite/src/app/layout.tsx index cd763fe2a..7f6a1f64f 100644 --- a/vite/src/app/layout.tsx +++ b/vite/src/app/layout.tsx @@ -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 ( diff --git a/vite/src/components/general/PageSectionHeader.tsx b/vite/src/components/general/PageSectionHeader.tsx index 03e863e8d..7c4fc845e 100644 --- a/vite/src/components/general/PageSectionHeader.tsx +++ b/vite/src/components/general/PageSectionHeader.tsx @@ -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 (
{title && ( -

+

{title}

)} diff --git a/vite/src/hooks/common/useAppQueryStates.tsx b/vite/src/hooks/common/useAppQueryStates.tsx new file mode 100644 index 000000000..4014f9f27 --- /dev/null +++ b/vite/src/hooks/common/useAppQueryStates.tsx @@ -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, + }; +}; diff --git a/vite/src/hooks/common/useSecondaryTab.tsx b/vite/src/hooks/common/useSecondaryTab.tsx index b97e7c1ba..a650cef18 100644 --- a/vite/src/hooks/common/useSecondaryTab.tsx +++ b/vite/src/hooks/common/useSecondaryTab.tsx @@ -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) || ""; }; diff --git a/vite/src/hooks/queries/useFeaturesQuery.tsx b/vite/src/hooks/queries/useFeaturesQuery.tsx index 12a103271..29005c71d 100644 --- a/vite/src/hooks/queries/useFeaturesQuery.tsx +++ b/vite/src/hooks/queries/useFeaturesQuery.tsx @@ -17,5 +17,5 @@ export const useFeaturesQuery = () => { queryFn: fetchFeatures, }); - return { features: data?.features || [], isLoading, error, mutate: refetch }; + return { features: data?.features || [], isLoading, error, refetch }; }; diff --git a/vite/src/hooks/queries/useGeneralQuery.tsx b/vite/src/hooks/queries/useGeneralQuery.tsx new file mode 100644 index 000000000..654db0b50 --- /dev/null +++ b/vite/src/hooks/queries/useGeneralQuery.tsx @@ -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 }; +}; diff --git a/vite/src/hooks/queries/useProductsQuery.tsx b/vite/src/hooks/queries/useProductsQuery.tsx index d7901ee0e..64693a32d 100644 --- a/vite/src/hooks/queries/useProductsQuery.tsx +++ b/vite/src/hooks/queries/useProductsQuery.tsx @@ -23,12 +23,9 @@ export const useProductsQuery = () => { queryFn: fetchProducts, }); - const { - data: countsData, - isLoading: isCountsLoading, - error: countsError, - refetch: countsRefetch, - } = useQuery>({ + const { data: countsData, refetch: countsRefetch } = useQuery< + Record + >({ 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()]); + // }, }; }; diff --git a/vite/src/hooks/queries/useRewardsQuery.tsx b/vite/src/hooks/queries/useRewardsQuery.tsx new file mode 100644 index 000000000..e29eeb293 --- /dev/null +++ b/vite/src/hooks/queries/useRewardsQuery.tsx @@ -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, + }; +}; diff --git a/vite/src/main.tsx b/vite/src/main.tsx index ecc2bc672..137ee843c 100644 --- a/vite/src/main.tsx +++ b/vite/src/main.tsx @@ -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( diff --git a/vite/src/utils/genUtils.ts b/vite/src/utils/genUtils.ts index f1bc7249f..31bc30af9 100644 --- a/vite/src/utils/genUtils.ts +++ b/vite/src/utils/genUtils.ts @@ -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; + 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"); diff --git a/vite/src/utils/product/product-item/formatProductItem.ts b/vite/src/utils/product/product-item/formatProductItem.ts index 673da5b69..e13a58022 100644 --- a/vite/src/utils/product/product-item/formatProductItem.ts +++ b/vite/src/utils/product/product-item/formatProductItem.ts @@ -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 }); } }; diff --git a/vite/src/utils/productUtils.ts b/vite/src/utils/productUtils.ts index d1fbcb8af..8051c6d74 100644 --- a/vite/src/utils/productUtils.ts +++ b/vite/src/utils/productUtils.ts @@ -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; + }, {}); +}; diff --git a/vite/src/views/HomeContext.tsx b/vite/src/views/HomeContext.tsx deleted file mode 100644 index 0c829b9a8..000000000 --- a/vite/src/views/HomeContext.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createContext, useContext } from "react"; - -export const HomeContext = createContext(null); - -export const useHomeContext = () => { - const context = useContext(HomeContext); - - if (context === undefined) { - throw new Error("useHomeContext must be used within a HomeContextProvider"); - } - - return context; -}; diff --git a/vite/src/views/HomeView.tsx b/vite/src/views/HomeView.tsx deleted file mode 100644 index 3852cb257..000000000 --- a/vite/src/views/HomeView.tsx +++ /dev/null @@ -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(null); - - return ( - - - - ); -} - -export default HomeView; diff --git a/vite/src/views/credits/CreditSystemRowToolbar.tsx b/vite/src/views/credits/CreditSystemRowToolbar.tsx deleted file mode 100644 index 3b62d838d..000000000 --- a/vite/src/views/credits/CreditSystemRowToolbar.tsx +++ /dev/null @@ -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 ( - <> - - - - - - - - { - e.stopPropagation(); - e.preventDefault(); - setDeleteDialogOpen(true); - setDropdownOpen(false); - }} - > -
- Delete - {deleteLoading ? ( - - ) : ( - - )} -
-
-
-
- - ); -}; diff --git a/vite/src/views/credits/CreditSystemsView.tsx b/vite/src/views/credits/CreditSystemsView.tsx deleted file mode 100644 index d7a2c38cb..000000000 --- a/vite/src/views/credits/CreditSystemsView.tsx +++ /dev/null @@ -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 ; - - return ( - -
-

Credits

-

- Define a credits system to bill for your users' usage. These are - made of other metered features. -

-
- - -
- ); -} - -export default CreditSystemsView; diff --git a/vite/src/views/credits/CreditsContext.tsx b/vite/src/views/credits/CreditsContext.tsx deleted file mode 100644 index 89afe8a75..000000000 --- a/vite/src/views/credits/CreditsContext.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext, useContext } from "react"; - -export const CreditsContext = createContext(null); - -export const useCreditsContext = () => { - const context = useContext(CreditsContext); - - if (context === undefined) { - throw new Error( - "useCreditsContext must be used within a CreditsContextProvider" - ); - } - - return context; -}; diff --git a/vite/src/views/customers/CustomersView.tsx b/vite/src/views/customers/CustomersView.tsx index 4bc96274f..ad00eb2a5 100644 --- a/vite/src/views/customers/CustomersView.tsx +++ b/vite/src/views/customers/CustomersView.tsx @@ -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 ; } - 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 = { - page: 1, - lastItemId: "", - }; + // const handleFilterChange = (newFilters: any) => { + // const params: Record = { + // 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 ( -
+

Customers

-
-
-
- -
- - setQueryStates({ q: query })} - setCurrentPage={(page: number) => { - setQueryStates({ - page: page, - lastItemId: "", - }); - }} - mutate={mutate} - setSearching={setSearching} - /> -
- {paginationLoading && !searching ? ( -
- -
- ) : ( - - - - - - - {queryStates.page} / {Math.max(totalPages, 1)} - - - - - - - )} -
-
-

- {data?.totalCount} -

-
-
-
- -
-
- {data?.customers?.length > 0 ? ( + + {customers?.length && customers?.length > 0 ? (
- +
) : (
diff --git a/vite/src/views/customers/SearchBar.tsx b/vite/src/views/customers/SearchBar.tsx deleted file mode 100644 index 04e2ed1f5..000000000 --- a/vite/src/views/customers/SearchBar.tsx +++ /dev/null @@ -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; -}) { - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - const location = useLocation(); - const [loading, setLoading] = useState(false); - const prevQueryRef = useRef(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) => { - const q = e.target.value; - debouncedSearch(q); - }; - - return ( -
- - -
{loading && }
-
- ); -} diff --git a/vite/src/views/customers/CreateCustomer.tsx b/vite/src/views/customers/components/CreateCustomer.tsx similarity index 96% rename from vite/src/views/customers/CreateCustomer.tsx rename to vite/src/views/customers/components/CreateCustomer.tsx index 0e1da0682..394064392 100644 --- a/vite/src/views/customers/CreateCustomer.tsx +++ b/vite/src/views/customers/components/CreateCustomer.tsx @@ -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"); diff --git a/vite/src/views/customers/CustomerRowToolbar.tsx b/vite/src/views/customers/components/CustomerRowToolbar.tsx similarity index 61% rename from vite/src/views/customers/CustomerRowToolbar.tsx rename to vite/src/views/customers/components/CustomerRowToolbar.tsx index 11e25acfd..648a44999 100644 --- a/vite/src/views/customers/CustomerRowToolbar.tsx +++ b/vite/src/views/customers/components/CustomerRowToolbar.tsx @@ -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 = ({ )}
- - {/* {env == AppEnv.Sandbox && ( - - )} */} diff --git a/vite/src/views/customers/components/CustomersPagination.tsx b/vite/src/views/customers/components/CustomersPagination.tsx new file mode 100644 index 000000000..2874e18dd --- /dev/null +++ b/vite/src/views/customers/components/CustomersPagination.tsx @@ -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 ( +
+ {isLoading ? ( +
+ +
+ ) : ( + + + + { + 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" : ""}`} + /> + + + {currentPage} / {Math.max(totalPages, 1)} + + + { + 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" : ""}`} + /> + + + + )} +
+ ); +}; diff --git a/vite/src/views/customers/components/CustomersSearchBar.tsx b/vite/src/views/customers/components/CustomersSearchBar.tsx new file mode 100644 index 000000000..862b3aa72 --- /dev/null +++ b/vite/src/views/customers/components/CustomersSearchBar.tsx @@ -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(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) => { + const q = e.target.value; + debouncedSearch(q); + }; + + return ( +
+ + + {/*
{loading && }
*/} +
+ ); +} diff --git a/vite/src/views/customers/CustomersTable.tsx b/vite/src/views/customers/components/CustomersTable.tsx similarity index 96% rename from vite/src/views/customers/CustomersTable.tsx rename to vite/src/views/customers/components/CustomersTable.tsx index b6e0d2d35..e4731495a 100644 --- a/vite/src/views/customers/CustomersTable.tsx +++ b/vite/src/views/customers/components/CustomersTable.tsx @@ -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) => { diff --git a/vite/src/views/customers/components/customers-top-bar/CustomersTopBar.tsx b/vite/src/views/customers/components/customers-top-bar/CustomersTopBar.tsx new file mode 100644 index 000000000..daa7e6f53 --- /dev/null +++ b/vite/src/views/customers/components/customers-top-bar/CustomersTopBar.tsx @@ -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 ( +
+
+
+ +
+ + + +
+

+ {totalCount} +

+
+
+
+ +
+
+ ); +}; diff --git a/vite/src/views/customers/FilterButton.tsx b/vite/src/views/customers/components/filter-dropdown/CustomersFilterButton.tsx similarity index 65% rename from vite/src/views/customers/FilterButton.tsx rename to vite/src/views/customers/components/filter-dropdown/CustomersFilterButton.tsx index 07716d6b2..ad7de74cb 100644 --- a/vite/src/views/customers/FilterButton.tsx +++ b/vite/src/views/customers/components/filter-dropdown/CustomersFilterButton.tsx @@ -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 ( diff --git a/vite/src/views/customers/filter/FilterStatusSubMenu.tsx b/vite/src/views/customers/components/filter-dropdown/FilterStatusSubMenu.tsx similarity index 84% rename from vite/src/views/customers/filter/FilterStatusSubMenu.tsx rename to vite/src/views/customers/components/filter-dropdown/FilterStatusSubMenu.tsx index 86c38d270..f81d3338c 100644 --- a/vite/src/views/customers/filter/FilterStatusSubMenu.tsx +++ b/vite/src/views/customers/components/filter-dropdown/FilterStatusSubMenu.tsx @@ -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 ( diff --git a/vite/src/views/customers/SavedViewPopover.tsx b/vite/src/views/customers/components/filter-dropdown/SavedViewPopover.tsx similarity index 94% rename from vite/src/views/customers/SavedViewPopover.tsx rename to vite/src/views/customers/components/filter-dropdown/SavedViewPopover.tsx index c606a7aad..6e064a434 100644 --- a/vite/src/views/customers/SavedViewPopover.tsx +++ b/vite/src/views/customers/components/filter-dropdown/SavedViewPopover.tsx @@ -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")); diff --git a/vite/src/views/customers/SavedViewsDropdown.tsx b/vite/src/views/customers/components/filter-dropdown/SavedViewsDropdown.tsx similarity index 70% rename from vite/src/views/customers/SavedViewsDropdown.tsx rename to vite/src/views/customers/components/filter-dropdown/SavedViewsDropdown.tsx index a2a383350..2911955b1 100644 --- a/vite/src/views/customers/SavedViewsDropdown.tsx +++ b/vite/src/views/customers/components/filter-dropdown/SavedViewsDropdown.tsx @@ -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 = { + 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 ( - + @@ -91,7 +107,7 @@ export const SavedViewsDropdown = () => { {loading ? ( Loading... - ) : views.length === 0 ? ( + ) : views.length === 0 ? ( No saved views ) : ( @@ -115,4 +131,4 @@ export const SavedViewsDropdown = () => { ); -}; \ No newline at end of file +}; diff --git a/vite/src/views/customers/customer/product/CustomerProductView.tsx b/vite/src/views/customers/customer/product/CustomerProductView.tsx index 5d09fddbe..ab405e7b9 100644 --- a/vite/src/views/customers/customer/product/CustomerProductView.tsx +++ b/vite/src/views/customers/customer/product/CustomerProductView.tsx @@ -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 ( - - - - -
-
- -
-
- {product && } - {options.length > 0 && ( - - )} -
+ +
+
+ +
+
+ {product && } + {options.length > 0 && ( + + )}
-
- -
- - +
+ +
+
+ ); } diff --git a/vite/src/views/customers/filter/ProductsSubMenu.tsx b/vite/src/views/customers/filter/ProductsSubMenu.tsx index 9d1783510..c564f9806 100644 --- a/vite/src/views/customers/filter/ProductsSubMenu.tsx +++ b/vite/src/views/customers/filter/ProductsSubMenu.tsx @@ -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 = () => {
- {/* Select products */}
diff --git a/vite/src/views/customers/filter/SavedViews.tsx b/vite/src/views/customers/filter/SavedViews.tsx index 9e91d2c08..c3fde31d2 100644 --- a/vite/src/views/customers/filter/SavedViews.tsx +++ b/vite/src/views/customers/filter/SavedViews.tsx @@ -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(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 = { + 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) { diff --git a/vite/src/views/customers/hooks/useCusSearchQuery.tsx b/vite/src/views/customers/hooks/useCusSearchQuery.tsx new file mode 100644 index 000000000..738cc289a --- /dev/null +++ b/vite/src/views/customers/hooks/useCusSearchQuery.tsx @@ -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; + +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, +// }, +// }); diff --git a/vite/src/views/customers/hooks/useCustomersQueryStates.tsx b/vite/src/views/customers/hooks/useCustomersQueryStates.tsx new file mode 100644 index 000000000..f020ce320 --- /dev/null +++ b/vite/src/views/customers/hooks/useCustomersQueryStates.tsx @@ -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 }; +}; diff --git a/vite/src/views/customers/hooks/useSavedViewsQuery.tsx b/vite/src/views/customers/hooks/useSavedViewsQuery.tsx new file mode 100644 index 000000000..e7b4ef7f8 --- /dev/null +++ b/vite/src/views/customers/hooks/useSavedViewsQuery.tsx @@ -0,0 +1,8 @@ +import { useGeneralQuery } from "@/hooks/queries/useGeneralQuery"; + +export const useSavedViewsQuery = () => { + return useGeneralQuery({ + url: "/saved_views", + queryKey: ["saved_views"], + }); +}; diff --git a/vite/src/views/features/CreateBooleanFeature.tsx b/vite/src/views/features/CreateBooleanFeature.tsx deleted file mode 100644 index 975378212..000000000 --- a/vite/src/views/features/CreateBooleanFeature.tsx +++ /dev/null @@ -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 ( - - - - - - - Create Boolean Feature - - -
-
-
- Name - { - const newFields: any = { ...fields, name: e.target.value }; - if (!idChanged) { - newFields.id = slugify(e.target.value); - } - setFields(newFields); - }} - /> -
-
- ID - { - setFields({ ...fields, id: e.target.value }); - setIdChanged(true); - }} - /> -
-
-
- - - -
-
- ); -} - -export default CreateBooleanFeature; diff --git a/vite/src/views/features/FeaturesContext.tsx b/vite/src/views/features/FeaturesContext.tsx deleted file mode 100644 index 09a6c0ddf..000000000 --- a/vite/src/views/features/FeaturesContext.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext, useContext } from "react"; - -export const FeaturesContext = createContext(null); - -export const useFeaturesContext = () => { - const context = useContext(FeaturesContext); - - if (context === undefined) { - throw new Error( - "useFeaturesContext must be used within a FeaturesContextProvider" - ); - } - - return context; -}; diff --git a/vite/src/views/features/FeaturesView.tsx b/vite/src/views/features/FeaturesView.tsx deleted file mode 100644 index e615e9c1d..000000000 --- a/vite/src/views/features/FeaturesView.tsx +++ /dev/null @@ -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(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 ; - } - - if (!data || error) { - return Failed to fetch features; - } - - const features = data?.features.filter( - (feature: Feature) => feature.type !== "credit_system" - ); - - return ( - -
-

Features

- - {features?.length || 0} - - } - addButton={ - <> - - setShowArchived(!showArchived), - }, - ]} - /> - - } - /> - - {showCredits && ( -
-
-

Credits

-

- Create a credit-based system where features consume credits from - a shared balance{" "} - - (eg, 1 AI chat message costs 3 credits). - -

-
- - -
- )} -
-
- ); -} - -export default FeaturesView; diff --git a/vite/src/views/features/hooks/useFeatureDialogState.tsx b/vite/src/views/features/hooks/useFeatureDialogState.tsx deleted file mode 100644 index b5cc66a1d..000000000 --- a/vite/src/views/features/hooks/useFeatureDialogState.tsx +++ /dev/null @@ -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, - }; -}; diff --git a/vite/src/views/features/metered-features/CreateDBConnection.tsx b/vite/src/views/features/metered-features/CreateDBConnection.tsx deleted file mode 100644 index b2fe098d1..000000000 --- a/vite/src/views/features/metered-features/CreateDBConnection.tsx +++ /dev/null @@ -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 ( - - - - - - - Create DB connection - -
-
-
- Provider - -
-
- Display Name - handleChange(e, "display_name")} - /> -
-
-
- Connection String - handleChange(e, "connection_string")} - /> -
-
- - - -
-
- ); -} - -export default CreateDBConnection; diff --git a/vite/src/views/main-sidebar/NavButton.tsx b/vite/src/views/main-sidebar/NavButton.tsx index a36225c6f..c3feb54da 100644 --- a/vite/src/views/main-sidebar/NavButton.tsx +++ b/vite/src/views/main-sidebar/NavButton.tsx @@ -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} diff --git a/vite/src/views/main-sidebar/SidebarGroup.tsx b/vite/src/views/main-sidebar/SidebarGroup.tsx index 55734f6bc..1f3adeae6 100644 --- a/vite/src/views/main-sidebar/SidebarGroup.tsx +++ b/vite/src/views/main-sidebar/SidebarGroup.tsx @@ -28,8 +28,9 @@ export const SidebarGroup = ({ productGroup ? "opacity-100 my-0.5" : "opacity-0" )} > - {subTabs.map((subTab) => ( + {subTabs.map((subTab, index) => ( void; - number: number; -}) => { - const env = useEnv(); - const axiosInstance = useAxiosInstance({ env }); - - const [newProduct, setNewProduct] = useState(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(); - const [features, setFeatures] = useState(); - - useEffect(() => { - if (data?.product) { - setProduct(data.product); - setFeatures(data.features); - } - }, [data]); - - return ( - - Define your product's pricing models and what customers get - access to. -

- } - > - {product ? ( - - - -
- -
-
-
- ) : ( - - )} -
- ); -}; - -const CreateProductCard = ({ - newProduct, - setNewProduct, - createProduct, - createProductLoading, -}: { - newProduct: any; - setNewProduct: any; - createProduct: any; - createProductLoading: boolean; -}) => { - return ( -
- {/* */} - - setNewProduct({ - ...newProduct, - name: e.target.value, - id: slugify(e.target.value), - }) - } - endContent={ -
-

{newProduct.id}

-
- } - /> - - -
- ); -}; - -{ - /* - {productLoading ? ( - - ) : ( -
-

Features

- - -
- )} -
*/ -} -{ - /* - {productLoading ? ( - - ) : ( -
-

Products

- -
- -
-
- )} -
*/ -} diff --git a/vite/src/views/onboarding/onboarding-steps/ProductList.tsx b/vite/src/views/onboarding/onboarding-steps/ProductList.tsx index f100692cd..67504dd1f 100644 --- a/vite/src/views/onboarding/onboarding-steps/ProductList.tsx +++ b/vite/src/views/onboarding/onboarding-steps/ProductList.tsx @@ -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 = ({ /> { const selectedProduct = data.products.find( (p: ProductV2) => p.id === id @@ -212,31 +211,24 @@ export const EditProductDialog = ({ {/* Edit Product */}
- - - - - - + + +
diff --git a/vite/src/views/onboarding2/model-pricing/EditProduct.tsx b/vite/src/views/onboarding2/model-pricing/EditProduct.tsx index 059c17431..e467fce53 100644 --- a/vite/src/views/onboarding2/model-pricing/EditProduct.tsx +++ b/vite/src/views/onboarding2/model-pricing/EditProduct.tsx @@ -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 (
- - + + +
- - - -
-
- - {showSaveButton && ( - - )} -
- - {firstProductCreated && ( - <> - {product.items.length == 0 ? ( -

- {/* 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. -

- ) : ( -
- -
- )} - {" "} - +
+ + {showSaveButton && ( + )}
-
-
-
- {/* + {product.items.length == 0 ? ( +

+ {/* 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. +

+ ) : ( +
+ +
+ )} + {" "} + + )} +
+ +
+
+
+ {/* handleToggleSettings("is_default")} /> */} -
-

Default Product

- -
-
- A default product is enabled by default for all new users, - typically used for your free plan. -
+
+

Default Product

+
-
- {/* + A default product is enabled by default for all new users, + typically used for your free plan. +
+
+
+ {/* handleToggleSettings("is_add_on")} /> */} -
-

Add On Product

- -
-
- A product that can be added on top of a customer's main - plan. Eg. one time purchases or top ups. -
+
+

Add On Product

+
-
- -
- Add a free trial to your product. -
+
+ A product that can be added on top of a customer's main plan. + Eg. one time purchases or top ups. +
+
+
+ +
+ Add a free trial to your product.
- - +
+
); diff --git a/vite/src/views/onboarding2/model-pricing/EditProductOld.tsx b/vite/src/views/onboarding2/model-pricing/EditProductOld.tsx index 2f088a441..f7bfa0967 100644 --- a/vite/src/views/onboarding2/model-pricing/EditProductOld.tsx +++ b/vite/src/views/onboarding2/model-pricing/EditProductOld.tsx @@ -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 */}
- - - - - - + + +
diff --git a/vite/src/views/onboarding2/model-pricing/SelectEditProduct.tsx b/vite/src/views/onboarding2/model-pricing/SelectEditProduct.tsx index 559d538ef..5970e754f 100644 --- a/vite/src/views/onboarding2/model-pricing/SelectEditProduct.tsx +++ b/vite/src/views/onboarding2/model-pricing/SelectEditProduct.tsx @@ -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"; diff --git a/vite/src/views/products/ProductsView.tsx b/vite/src/views/products/ProductsView.tsx index 92237a540..b294733c6 100644 --- a/vite/src/views/products/ProductsView.tsx +++ b/vite/src/views/products/ProductsView.tsx @@ -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(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 ; + if (isProductsLoading || isFeaturesLoading || isRewardsLoading) + return ; + const tab = queryStates.tab; return ( - - 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, - }} - > -
-

Products

+ +
+

Products

- setTab(value)} - > - {tab === "products" && } - - {/* {tab === "features" && ( - <> - - - {featuresData?.features?.length} - - {showArchived && ( - - Archived - - )} - - } - addButton={} - menuComponent={ - - setShowArchivedFeatures(!showArchivedFeatures), - }, - ]} - /> - } - /> - -
- - -
-
-
-

Credits

- - {creditSystems.length} - -
- -
- -
-
- - )} - - {tab === "rewards" && ( - <> -
-
-
-
-

Coupons

- - {data?.rewards?.length || 0} - -
- -
-
- -
-
- -
-
-
-

- Referral Programs -

- - {data?.rewardPrograms?.length || 0} - -
- -
-
- -
-
-
- - )} */} -
-
- + {tab === "products" && } + {tab === "features" && } + {tab === "rewards" && } +
); } diff --git a/vite/src/views/products/UpdateProduct.tsx b/vite/src/views/products/UpdateProduct.tsx index 849a23d1a..8a200ecff 100644 --- a/vite/src/views/products/UpdateProduct.tsx +++ b/vite/src/views/products/UpdateProduct.tsx @@ -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}`); diff --git a/vite/src/views/products/features/FeaturesPage.tsx b/vite/src/views/products/features/FeaturesPage.tsx new file mode 100644 index 000000000..f62cbf64f --- /dev/null +++ b/vite/src/views/products/features/FeaturesPage.tsx @@ -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 ( +
+ + + {features?.length} + + {queryStates.showArchivedFeatures && ( + + Archived + + )} + + } + addButton={} + menuComponent={ + + setQueryStates({ + ...queryStates, + showArchivedFeatures: !queryStates.showArchivedFeatures, + }), + }, + ]} + /> + } + /> + +
+ + +
+
+
+

Credits

+ + {creditSystems.length} + +
+ +
+ +
+
+
+ ); +}; diff --git a/vite/src/views/features/CreateFeature.tsx b/vite/src/views/products/features/components/CreateFeature.tsx similarity index 81% rename from vite/src/views/features/CreateFeature.tsx rename to vite/src/views/products/features/components/CreateFeature.tsx index 2df3c88aa..38680b176 100644 --- a/vite/src/views/features/CreateFeature.tsx +++ b/vite/src/views/products/features/components/CreateFeature.tsx @@ -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; 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")); } diff --git a/vite/src/views/features/components/CreateFeatureFooter.tsx b/vite/src/views/products/features/components/CreateFeatureFooter.tsx similarity index 100% rename from vite/src/views/features/components/CreateFeatureFooter.tsx rename to vite/src/views/products/features/components/CreateFeatureFooter.tsx diff --git a/vite/src/views/features/metered-features/FeatureConfig.tsx b/vite/src/views/products/features/components/FeatureConfig.tsx similarity index 100% rename from vite/src/views/features/metered-features/FeatureConfig.tsx rename to vite/src/views/products/features/components/FeatureConfig.tsx index bd0097ebb..92bd6827d 100644 --- a/vite/src/views/features/metered-features/FeatureConfig.tsx +++ b/vite/src/views/products/features/components/FeatureConfig.tsx @@ -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, diff --git a/vite/src/views/features/FeatureTypeBadge.tsx b/vite/src/views/products/features/components/FeatureTypeBadge.tsx similarity index 100% rename from vite/src/views/features/FeatureTypeBadge.tsx rename to vite/src/views/products/features/components/FeatureTypeBadge.tsx diff --git a/vite/src/views/features/FeaturesTable.tsx b/vite/src/views/products/features/components/FeaturesTable.tsx similarity index 73% rename from vite/src/views/features/FeaturesTable.tsx rename to vite/src/views/products/features/components/FeaturesTable.tsx index 97fb55121..c294057b6 100644 --- a/vite/src/views/features/FeaturesTable.tsx +++ b/vite/src/views/products/features/components/FeaturesTable.tsx @@ -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(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 (
{ Name ID Type - {!onboarding && Event Names} - {!onboarding && Created At} + Event Names + Created At ) : (
- {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."}
)} - {features.map((feature: Feature) => ( + {filteredFeatures.map((feature: Feature) => ( { - {!onboarding && ( - - {getMeteredEventNames(feature)} - - )} - {!onboarding && ( - - {formatUnixToDateTime(feature.created_at).date} - {/* - {" "} - {formatUnixToDateTime(feature.created_at).time} - */} - - )} + + {getMeteredEventNames(feature)} + + + {formatUnixToDateTime(feature.created_at).date} + diff --git a/vite/src/views/features/metered-features/SelectFeatureType.tsx b/vite/src/views/products/features/components/SelectFeatureType.tsx similarity index 97% rename from vite/src/views/features/metered-features/SelectFeatureType.tsx rename to vite/src/views/products/features/components/SelectFeatureType.tsx index 206769a50..96d2d067f 100644 --- a/vite/src/views/features/metered-features/SelectFeatureType.tsx +++ b/vite/src/views/products/features/components/SelectFeatureType.tsx @@ -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, diff --git a/vite/src/views/features/metered-features/SelectFeatureUsageType.tsx b/vite/src/views/products/features/components/SelectFeatureUsageType.tsx similarity index 94% rename from vite/src/views/features/metered-features/SelectFeatureUsageType.tsx rename to vite/src/views/products/features/components/SelectFeatureUsageType.tsx index ae2d553cf..03ac12ffc 100644 --- a/vite/src/views/features/metered-features/SelectFeatureUsageType.tsx +++ b/vite/src/views/products/features/components/SelectFeatureUsageType.tsx @@ -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"; diff --git a/vite/src/views/features/UpdateFeature.tsx b/vite/src/views/products/features/components/UpdateFeature.tsx similarity index 89% rename from vite/src/views/features/UpdateFeature.tsx rename to vite/src/views/products/features/components/UpdateFeature.tsx index 5477264e4..1b8c9646f 100644 --- a/vite/src/views/features/UpdateFeature.tsx +++ b/vite/src/views/products/features/components/UpdateFeature.tsx @@ -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); diff --git a/vite/src/views/credits/CreateCreditSystem.tsx b/vite/src/views/products/features/credit-systems/CreateCreditSystem.tsx similarity index 72% rename from vite/src/views/credits/CreateCreditSystem.tsx rename to vite/src/views/products/features/credit-systems/CreateCreditSystem.tsx index 1bee7153d..aa1e65526 100644 --- a/vite/src/views/credits/CreateCreditSystem.tsx +++ b/vite/src/views/products/features/credit-systems/CreateCreditSystem.tsx @@ -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() { - {/* - - */} ); } diff --git a/vite/src/views/credits/CreditSystemConfig.tsx b/vite/src/views/products/features/credit-systems/CreditSystemConfig.tsx similarity index 97% rename from vite/src/views/credits/CreditSystemConfig.tsx rename to vite/src/views/products/features/credit-systems/CreditSystemConfig.tsx index 99969e781..272ed4d5c 100644 --- a/vite/src/views/credits/CreditSystemConfig.tsx +++ b/vite/src/views/products/features/credit-systems/CreditSystemConfig.tsx @@ -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( creditSystem.name ? { diff --git a/vite/src/views/credits/CreditSystemsTable.tsx b/vite/src/views/products/features/credit-systems/CreditSystemsTable.tsx similarity index 80% rename from vite/src/views/credits/CreditSystemsTable.tsx rename to vite/src/views/products/features/credit-systems/CreditSystemsTable.tsx index df10efa3a..7e3f2e106 100644 --- a/vite/src/views/credits/CreditSystemsTable.tsx +++ b/vite/src/views/products/features/credit-systems/CreditSystemsTable.tsx @@ -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(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 = () => { {formatUnixToDateTime(creditSystem.created_at).date} - {/* - {" "} - {formatUnixToDateTime(creditSystem.created_at).time} - */} - + {/* */} + ))} diff --git a/vite/src/views/credits/UpdateCreditSystem.tsx b/vite/src/views/products/features/credit-systems/UpdateCreditSystem.tsx similarity index 84% rename from vite/src/views/credits/UpdateCreditSystem.tsx rename to vite/src/views/products/features/credit-systems/UpdateCreditSystem.tsx index eba2895b6..f075c2d0a 100644 --- a/vite/src/views/credits/UpdateCreditSystem.tsx +++ b/vite/src/views/products/features/credit-systems/UpdateCreditSystem.tsx @@ -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")); diff --git a/vite/src/views/products/features/credit-systems/utils/validateCreditSystem.ts b/vite/src/views/products/features/credit-systems/utils/validateCreditSystem.ts new file mode 100644 index 000000000..1dfe7d741 --- /dev/null +++ b/vite/src/views/products/features/credit-systems/utils/validateCreditSystem.ts @@ -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; +}; diff --git a/vite/src/views/features/components/DeleteFeatureDialog.tsx b/vite/src/views/products/features/feature-row-toolbar/DeleteFeatureDialog.tsx similarity index 89% rename from vite/src/views/features/components/DeleteFeatureDialog.tsx rename to vite/src/views/products/features/feature-row-toolbar/DeleteFeatureDialog.tsx index bccd34d9e..0876893a0 100644 --- a/vite/src/views/features/components/DeleteFeatureDialog.tsx +++ b/vite/src/views/products/features/feature-row-toolbar/DeleteFeatureDialog.tsx @@ -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 ( e.stopPropagation()}> diff --git a/vite/src/views/features/FeatureRowToolbar.tsx b/vite/src/views/products/features/feature-row-toolbar/FeatureRowToolbar.tsx similarity index 75% rename from vite/src/views/features/FeatureRowToolbar.tsx rename to vite/src/views/products/features/feature-row-toolbar/FeatureRowToolbar.tsx index dc190f761..38c1965fb 100644 --- a/vite/src/views/features/FeatureRowToolbar.tsx +++ b/vite/src/views/products/features/feature-row-toolbar/FeatureRowToolbar.tsx @@ -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} /> diff --git a/vite/src/views/features/utils/defaultFeature.ts b/vite/src/views/products/features/utils/defaultFeature.ts similarity index 84% rename from vite/src/views/features/utils/defaultFeature.ts rename to vite/src/views/products/features/utils/defaultFeature.ts index d700f7f49..5c68487f3 100644 --- a/vite/src/views/features/utils/defaultFeature.ts +++ b/vite/src/views/products/features/utils/defaultFeature.ts @@ -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) { diff --git a/vite/src/views/features/metered-features/defaultFeatureConfig.ts b/vite/src/views/products/features/utils/defaultFeatureConfig.ts similarity index 100% rename from vite/src/views/features/metered-features/defaultFeatureConfig.ts rename to vite/src/views/products/features/utils/defaultFeatureConfig.ts diff --git a/vite/src/views/products/hooks/useProductsQueryState.tsx b/vite/src/views/products/hooks/useProductsQueryState.tsx new file mode 100644 index 000000000..800d35871 --- /dev/null +++ b/vite/src/views/products/hooks/useProductsQueryState.tsx @@ -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 }; +}; diff --git a/vite/src/views/products/product/ManageProduct.tsx b/vite/src/views/products/product/ManageProduct.tsx index 7b8960446..c08396ae3 100644 --- a/vite/src/views/products/product/ManageProduct.tsx +++ b/vite/src/views/products/product/ManageProduct.tsx @@ -10,7 +10,6 @@ export const ManageProduct = ({ }: { hideAdminHover?: boolean; }) => { - const env = useEnv(); const { product, entityId, customer } = useProductContext(); return ( diff --git a/vite/src/views/products/product/ProductView.tsx b/vite/src/views/products/product/ProductView.tsx index c3f03df54..c9d69d60b 100644 --- a/vite/src/views/products/product/ProductView.tsx +++ b/vite/src/views/products/product/ProductView.tsx @@ -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 ( - - - -
-
- + +
+
+ -
-
- -
-
-
-
- -
+
+
+
-
- +
+
+ +
- {modal} - - +
+ +
+
+ {modal} + ); } diff --git a/vite/src/views/products/product/product-item/EntitiesDropdown.tsx b/vite/src/views/products/product/product-item/EntitiesDropdown.tsx index a50b5917a..729439583 100644 --- a/vite/src/views/products/product/product-item/EntitiesDropdown.tsx +++ b/vite/src/views/products/product/product-item/EntitiesDropdown.tsx @@ -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"; diff --git a/vite/src/views/products/product/product-item/components/SelectItemFeature.tsx b/vite/src/views/products/product/product-item/components/SelectItemFeature.tsx index 584731064..32435e4b6 100644 --- a/vite/src/views/products/product/product-item/components/SelectItemFeature.tsx +++ b/vite/src/views/products/product/product-item/components/SelectItemFeature.tsx @@ -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"; diff --git a/vite/src/views/products/product/product-item/create-product-item/CreateItemDialogContent.tsx b/vite/src/views/products/product/product-item/create-product-item/CreateItemDialogContent.tsx index 070fa8ce2..503e96ea2 100644 --- a/vite/src/views/products/product/product-item/create-product-item/CreateItemDialogContent.tsx +++ b/vite/src/views/products/product/product-item/create-product-item/CreateItemDialogContent.tsx @@ -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"; diff --git a/vite/src/views/products/product/product-item/product-item-config/components/SelectFeature.tsx b/vite/src/views/products/product/product-item/product-item-config/components/SelectFeature.tsx index fa1243c3c..806e8c996 100644 --- a/vite/src/views/products/product/product-item/product-item-config/components/SelectFeature.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/components/SelectFeature.tsx @@ -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"; diff --git a/vite/src/views/products/products/ProductsPage.tsx b/vite/src/views/products/products/ProductsPage.tsx index d03637d39..fb7adf640 100644 --- a/vite/src/views/products/products/ProductsPage.tsx +++ b/vite/src/views/products/products/ProductsPage.tsx @@ -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 (
@@ -21,9 +20,9 @@ export const ProductsPage = () => { titleComponent={ <> - {/* {data?.products?.length} */} + {products?.length} - {showArchived && ( + {queryStates.showArchivedProducts && ( Archived @@ -31,21 +30,25 @@ export const ProductsPage = () => { } addButton={} - // menuComponent={ - // setShowArchived((prev) => !prev), - // }, - // ]} - // /> - // } + menuComponent={ + + setQueryStates({ + ...queryStates, + showArchivedProducts: !queryStates.showArchivedProducts, + }), + }, + ]} + /> + } />
diff --git a/vite/src/views/products/products/CreateProduct.tsx b/vite/src/views/products/products/components/CreateProduct.tsx similarity index 89% rename from vite/src/views/products/products/CreateProduct.tsx rename to vite/src/views/products/products/components/CreateProduct.tsx index dbd376fb0..291150a93 100644 --- a/vite/src/views/products/products/CreateProduct.tsx +++ b/vite/src/views/products/products/components/CreateProduct.tsx @@ -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; }) { - // 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")); } diff --git a/vite/src/views/products/ProductsTable.tsx b/vite/src/views/products/products/components/ProductsTable.tsx similarity index 84% rename from vite/src/views/products/ProductsTable.tsx rename to vite/src/views/products/products/components/ProductsTable.tsx index ce565c0b3..0a9191843 100644 --- a/vite/src/views/products/ProductsTable.tsx +++ b/vite/src/views/products/products/components/ProductsTable.tsx @@ -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 ? ( - {showArchived ? ( + {queryStates.showArchivedProducts ? ( You haven't archived any products yet. ) : ( <> @@ -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) => ( @@ -171,7 +177,10 @@ export const ProductsTable = ({ onboarding && "col-span-6" )} > - + ))} diff --git a/vite/src/views/products/components/CopyDialog.tsx b/vite/src/views/products/products/product-row-toolbar/CopyDialog.tsx similarity index 91% rename from vite/src/views/products/components/CopyDialog.tsx rename to vite/src/views/products/products/product-row-toolbar/CopyDialog.tsx index 116209427..286a98358 100644 --- a/vite/src/views/products/components/CopyDialog.tsx +++ b/vite/src/views/products/products/product-row-toolbar/CopyDialog.tsx @@ -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( - 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 = ({ e.stopPropagation()}> Copy Product
-
+
Name 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 <>; } diff --git a/vite/src/views/products/components/ProductCountsTooltip.tsx b/vite/src/views/products/products/product-row-toolbar/ProductCountsTooltip.tsx similarity index 100% rename from vite/src/views/products/components/ProductCountsTooltip.tsx rename to vite/src/views/products/products/product-row-toolbar/ProductCountsTooltip.tsx diff --git a/vite/src/views/products/components/ProductRowToolbar.tsx b/vite/src/views/products/products/product-row-toolbar/ProductRowToolbar.tsx similarity index 91% rename from vite/src/views/products/components/ProductRowToolbar.tsx rename to vite/src/views/products/products/product-row-toolbar/ProductRowToolbar.tsx index 88bc4c752..daefe3a7a 100644 --- a/vite/src/views/products/components/ProductRowToolbar.tsx +++ b/vite/src/views/products/products/product-row-toolbar/ProductRowToolbar.tsx @@ -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} /> {dialogType == "update" ? ( @@ -127,10 +129,6 @@ export const ProductRowToolbar = ({ )}
- - {/* {env == AppEnv.Sandbox && ( - - )} */}
diff --git a/vite/src/views/products/components/ProductTypeBadge.tsx b/vite/src/views/products/products/product-row-toolbar/ProductTypeBadge.tsx similarity index 100% rename from vite/src/views/products/components/ProductTypeBadge.tsx rename to vite/src/views/products/products/product-row-toolbar/ProductTypeBadge.tsx diff --git a/vite/src/views/products/rewards/RewardsPage.tsx b/vite/src/views/products/rewards/RewardsPage.tsx new file mode 100644 index 000000000..c5b01d70b --- /dev/null +++ b/vite/src/views/products/rewards/RewardsPage.tsx @@ -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 ( +
+
+ + {rewards?.length} + + } + endContent={} + /> + + +
+ +
+ + {rewardPrograms?.length} + + } + endContent={} + isSecondary + /> + +
+
+ ); +}; diff --git a/vite/src/views/products/rewards/RewardRowToolbar.tsx b/vite/src/views/products/rewards/components/RewardRowToolbar.tsx similarity index 88% rename from vite/src/views/products/rewards/RewardRowToolbar.tsx rename to vite/src/views/products/rewards/components/RewardRowToolbar.tsx index a0950286d..1e634b27b 100644 --- a/vite/src/views/products/rewards/RewardRowToolbar.tsx +++ b/vite/src/views/products/rewards/components/RewardRowToolbar.tsx @@ -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 }) => { - + { diff --git a/vite/src/views/products/rewards/RewardsTable.tsx b/vite/src/views/products/rewards/components/RewardsTable.tsx similarity index 85% rename from vite/src/views/products/rewards/RewardsTable.tsx rename to vite/src/views/products/rewards/components/RewardsTable.tsx index da44e66a3..ca75ce7d9 100644 --- a/vite/src/views/products/rewards/RewardsTable.tsx +++ b/vite/src/views/products/rewards/components/RewardsTable.tsx @@ -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(null); const [open, setOpen] = useState(false); + const { rewards } = useRewardsQuery(); + const { products } = useProductsQuery(); + const { org } = useOrg(); return ( <> @@ -61,7 +65,7 @@ export const RewardsTable = () => { {keyToTitle(reward.type)} {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 ) : ( diff --git a/vite/src/views/products/rewards/CreateReward.tsx b/vite/src/views/products/rewards/reward-config/CreateReward.tsx similarity index 93% rename from vite/src/views/products/rewards/CreateReward.tsx rename to vite/src/views/products/rewards/reward-config/CreateReward.tsx index a1c6c51a2..085dddfe7 100644 --- a/vite/src/views/products/rewards/CreateReward.tsx +++ b/vite/src/views/products/rewards/reward-config/CreateReward.tsx @@ -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(); diff --git a/vite/src/views/products/rewards/DiscountConfig.tsx b/vite/src/views/products/rewards/reward-config/DiscountConfig.tsx similarity index 96% rename from vite/src/views/products/rewards/DiscountConfig.tsx rename to vite/src/views/products/rewards/reward-config/DiscountConfig.tsx index 7860af863..7100175b2 100644 --- a/vite/src/views/products/rewards/DiscountConfig.tsx +++ b/vite/src/views/products/rewards/reward-config/DiscountConfig.tsx @@ -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 = ({

{reward.type === RewardType.PercentageDiscount ? "%" - : org?.currency || "USD"} + : org?.default_currency || "USD"}

} /> @@ -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!; diff --git a/vite/src/views/products/rewards/RewardConfig.tsx b/vite/src/views/products/rewards/reward-config/RewardConfig.tsx similarity index 97% rename from vite/src/views/products/rewards/RewardConfig.tsx rename to vite/src/views/products/rewards/reward-config/RewardConfig.tsx index 0c9d1de8f..01c5b21f9 100644 --- a/vite/src/views/products/rewards/RewardConfig.tsx +++ b/vite/src/views/products/rewards/reward-config/RewardConfig.tsx @@ -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 = ({ diff --git a/vite/src/views/products/rewards/UpdateReward.tsx b/vite/src/views/products/rewards/reward-config/UpdateReward.tsx similarity index 91% rename from vite/src/views/products/rewards/UpdateReward.tsx rename to vite/src/views/products/rewards/reward-config/UpdateReward.tsx index 8bfb00a85..c57a3cd20 100644 --- a/vite/src/views/products/rewards/UpdateReward.tsx +++ b/vite/src/views/products/rewards/reward-config/UpdateReward.tsx @@ -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")); diff --git a/vite/src/views/products/reward-programs/CreateRewardProgram.tsx b/vite/src/views/products/rewards/reward-programs/CreateRewardProgram.tsx similarity index 71% rename from vite/src/views/products/reward-programs/CreateRewardProgram.tsx rename to vite/src/views/products/rewards/reward-programs/CreateRewardProgram.tsx index 306d10eec..adda88548 100644 --- a/vite/src/views/products/reward-programs/CreateRewardProgram.tsx +++ b/vite/src/views/products/rewards/reward-programs/CreateRewardProgram.tsx @@ -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")); diff --git a/vite/src/views/products/reward-programs/RewardProgramConfig.tsx b/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx similarity index 96% rename from vite/src/views/products/reward-programs/RewardProgramConfig.tsx rename to vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx index a2ef1a4ac..340b5954f 100644 --- a/vite/src/views/products/reward-programs/RewardProgramConfig.tsx +++ b/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx @@ -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 (
@@ -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 diff --git a/vite/src/views/products/reward-programs/RewardProgramRowToolbar.tsx b/vite/src/views/products/rewards/reward-programs/RewardProgramRowToolbar.tsx similarity index 86% rename from vite/src/views/products/reward-programs/RewardProgramRowToolbar.tsx rename to vite/src/views/products/rewards/reward-programs/RewardProgramRowToolbar.tsx index 13317a99d..588ee9d7b 100644 --- a/vite/src/views/products/reward-programs/RewardProgramRowToolbar.tsx +++ b/vite/src/views/products/rewards/reward-programs/RewardProgramRowToolbar.tsx @@ -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 = ({ - + { diff --git a/vite/src/views/products/reward-programs/RewardProgramsTable.tsx b/vite/src/views/products/rewards/reward-programs/RewardProgramsTable.tsx similarity index 90% rename from vite/src/views/products/reward-programs/RewardProgramsTable.tsx rename to vite/src/views/products/rewards/reward-programs/RewardProgramsTable.tsx index 1f74b5d2c..4fd223a3d 100644 --- a/vite/src/views/products/reward-programs/RewardProgramsTable.tsx +++ b/vite/src/views/products/rewards/reward-programs/RewardProgramsTable.tsx @@ -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(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)} {formatUnixToDateTime(rewardProgram.created_at).date} diff --git a/vite/src/views/products/rewards/defaultRewardModels.ts b/vite/src/views/products/rewards/utils/defaultRewardModels.ts similarity index 100% rename from vite/src/views/products/rewards/defaultRewardModels.ts rename to vite/src/views/products/rewards/utils/defaultRewardModels.ts