finished with customer page tanstack

This commit is contained in:
John Yeo
2025-09-02 15:13:23 -07:00
parent 3567c1d8b0
commit f52c932200
24 changed files with 259 additions and 533 deletions

View File

@@ -34,6 +34,7 @@ export class CusService {
expand,
withSubs = false,
allowNotFound = false,
withEvents = false,
}: {
db: DrizzleCli;
idOrInternalId: string;
@@ -45,6 +46,7 @@ export class CusService {
expand?: (CusExpand | EntityExpand)[];
withSubs?: boolean;
allowNotFound?: boolean;
withEvents?: boolean;
}): Promise<FullCustomer> {
const includeInvoices = expand?.includes(CusExpand.Invoices) || false;
const withTrialsUsed = expand?.includes(CusExpand.TrialsUsed) || false;
@@ -70,6 +72,7 @@ export class CusService {
withEntities,
withTrialsUsed,
withSubs,
withEvents,
entityId
);

View File

@@ -202,6 +202,7 @@ export const getFullCusQuery = (
withEntities: boolean,
withTrialsUsed: boolean,
withSubs: boolean,
withEvents: boolean,
entityId?: string
) => {
const sqlChunks: SQL[] = [];
@@ -255,6 +256,32 @@ export const getFullCusQuery = (
sqlChunks.push(buildInvoicesCTE(!!entityId));
}
// Conditionally add events CTE
if (withEvents) {
sqlChunks.push(sql`, `);
sqlChunks.push(sql`
customer_events AS (
SELECT
COALESCE(
json_agg(
json_build_object(
'id', e.id,
'event_name', e.event_name,
'value', e.value,
'timestamp', e.timestamp,
'properties', e.properties
)
ORDER BY e.timestamp DESC, e.id DESC
) FILTER (WHERE e.id IS NOT NULL),
'[]'::json
) AS events
FROM events e
WHERE e.internal_customer_id = (SELECT internal_id FROM customer_record)
AND e.set_usage = false
)
`);
}
// Build final SELECT
const selectFieldsChunks: SQL[] = [];
selectFieldsChunks.push(sql`
@@ -294,6 +321,11 @@ export const getFullCusQuery = (
(SELECT invoices FROM customer_invoices) AS invoices`);
}
if (withEvents) {
selectFieldsChunks.push(sql`,
(SELECT events FROM customer_events) AS events`);
}
sqlChunks.push(sql`
SELECT ${sql.join(selectFieldsChunks, sql``)}
FROM customer_record cr
@@ -312,6 +344,7 @@ export const getPaginatedFullCusQuery = ({
withSubs,
limit = 10,
offset = 0,
withEvents = false,
entityId,
internalCustomerIds,
}: {
@@ -324,6 +357,7 @@ export const getPaginatedFullCusQuery = ({
withSubs: boolean;
limit: number;
offset: number;
withEvents?: boolean;
entityId?: string;
internalCustomerIds?: string[];
}) => {
@@ -342,6 +376,14 @@ export const getPaginatedFullCusQuery = ({
FROM customers c
WHERE c.org_id = ${orgId}
AND c.env = ${env}
${
internalCustomerIds
? sql`AND c.internal_id IN (${sql.join(
internalCustomerIds.map((id) => sql`${id}`),
sql`, `
)})`
: sql``
}
ORDER BY c.created_at DESC
LIMIT ${limit} OFFSET ${offset}
),
@@ -405,14 +447,7 @@ export const getPaginatedFullCusQuery = ({
LEFT JOIN customer_prices cpr ON cpr.customer_product_id = cp.id
LEFT JOIN prices p ON cpr.price_id = p.id
LEFT JOIN customer_entitlements ce ON ce.customer_product_id = cp.id
WHERE cp.internal_customer_id IN (SELECT internal_id FROM customer_records) ${
internalCustomerIds
? sql`AND cp.internal_customer_id IN (${sql.join(
internalCustomerIds.map((id) => sql`${id}`),
sql`, `
)})`
: sql``
}
WHERE cp.internal_customer_id IN (SELECT internal_id FROM customer_records)
${withStatusFilter()}
GROUP BY cp.id, prod.*
),
@@ -500,12 +535,14 @@ export const getPaginatedFullCusQuery = ({
${withEntities ? sql`, COALESCE(ce.entities, '[]'::json) AS entities` : sql``}
${includeInvoices ? sql`, COALESCE(ci.invoices, '[]'::json) AS invoices` : sql``}
${withTrialsUsed ? sql`, COALESCE(ctu.trials_used, '[]'::json) AS trials_used` : sql``}
${withEvents ? sql`, COALESCE(cev.events, '[]'::json) AS events` : sql``}
FROM customer_records cr
LEFT JOIN customer_products_aggregated cpa ON cpa.internal_customer_id = cr.internal_id
${withSubs ? sql`LEFT JOIN customer_subscriptions cs ON cs.internal_customer_id = cr.internal_id` : sql``}
${withEntities ? sql`LEFT JOIN customer_entities ce ON ce.internal_customer_id = cr.internal_id` : sql``}
${includeInvoices ? sql`LEFT JOIN customer_invoices ci ON ci.internal_customer_id = cr.internal_id` : sql``}
${withTrialsUsed ? sql`LEFT JOIN customer_trials_used ctu ON ctu.internal_customer_id = cr.internal_id` : sql``}
${withEvents ? sql`LEFT JOIN customer_events cev ON cev.internal_customer_id = cr.internal_id` : sql``}
ORDER BY cr.created_at DESC
`;
};

View File

@@ -76,100 +76,6 @@ cusRouter.get("/:customer_id", async (req: any, res: any) => {
],
});
// const [coupons, products, customer] = await Promise.all([
// RewardService.list({
// db,
// orgId: orgId,
// env,
// }),
// ProductService.listFull({ db, orgId, env, returnAll: true }),
// ]);
// let invoices = customer.invoices;
// let entities = customer.entities;
// const events = await EventService.getByCustomerId({
// db,
// internalCustomerId: customer.internal_id,
// env,
// orgId: orgId,
// limit: 10,
// });
// let fullCustomer = customer as any;
// let cusProducts = fullCustomer.customer_products;
// fullCustomer.products = fullCustomer.customer_products;
// fullCustomer.entitlements = cusProducts.flatMap(
// (product: FullCusProduct) => product.customer_entitlements
// );
// fullCustomer.prices = cusProducts.flatMap(
// (product: FullCusProduct) => product.customer_prices
// );
// for (const product of fullCustomer.products) {
// product.entitlements = product.customer_entitlements.map(
// (cusEnt: FullCustomerEntitlement) => {
// return cusEnt.entitlement;
// }
// );
// product.prices = product.customer_prices.map(
// (cusPrice: FullCustomerPrice) => {
// return cusPrice.price;
// }
// );
// }
// let discount = null;
// if (org.stripe_config && customer.processor?.id) {
// try {
// const stripeCli = createStripeCli({ org, env });
// const stripeCus: any = await stripeCli.customers.retrieve(
// customer.processor.id
// );
// if (stripeCus.discount) {
// discount = stripeCus.discount;
// }
// } catch (error) {
// console.log("error", error);
// }
// }
// for (const invoice of invoices || []) {
// invoice.product_ids = invoice.product_ids.sort();
// invoice.internal_product_ids = invoice.internal_product_ids.sort();
// }
// fullCustomer.entitlements = fullCustomer.entitlements.sort(
// (a: any, b: any) => {
// const productA = fullCustomer.products.find(
// (p: any) => p.id === a.customer_product_id
// );
// const productB = fullCustomer.products.find(
// (p: any) => p.id === b.customer_product_id
// );
// return (
// new Date(b.created_at).getTime() - new Date(a.created_at).getTime() ||
// b.id.localeCompare(a.id)
// );
// }
// );
// for (const cusEnt of fullCustomer.entitlements) {
// // let entitlement = cusEnt.entitlement;
// // Show used, limit, etc.
// let { balance, unused } = getCusEntMasterBalance({
// cusEnt,
// entities,
// });
// cusEnt.balance = balance;
// cusEnt.unused = unused;
// }
res.status(200).json({
customer: fullCus,
// products: getLatestProducts(products),
@@ -187,6 +93,40 @@ cusRouter.get("/:customer_id", async (req: any, res: any) => {
}
});
cusRouter.get("/:customer_id/events", async (req: any, res: any) => {
try {
const { db, org, features, env } = req;
const { customer_id } = req.params;
const orgId = req.orgId;
const customer = await CusService.get({
db,
orgId,
env,
idOrInternalId: customer_id,
});
if (!customer) {
throw new RecaseError({
message: "Customer not found",
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
const events = await EventService.getByCustomerId({
db,
internalCustomerId: customer.internal_id,
env,
orgId: orgId,
});
res.status(200).json({ events });
} catch (error) {
handleFrontendReqError({ req, error, res, action: "get customer events" });
}
});
// cusRouter.get("/:customer_id/stripe", async (req: any, res: any) => {
// try {
// const { db, org, features, env } = req;
@@ -220,27 +160,31 @@ cusRouter.get("/:customer_id", async (req: any, res: any) => {
// }
// });
cusRouter.get("/:customer_id/events", async (req: any, res: any) => {
try {
const { db, org, features, env } = req;
const { customer_id } = req.params;
const orgId = req.orgId;
const limit = req.query.limit || 10;
const period = req.query.period || "all";
// cusRouter.get("/:customer_id/events", async (req: any, res: any) => {
// try {
// const { db, org, features, env } = req;
// const { customer_id } = req.params;
// const orgId = req.orgId;
// const limit = req.query.limit || 10;
// const period = req.query.period || "all";
const events = await EventService.getByCustomerId({
db,
internalCustomerId: customer_id,
env,
orgId: orgId,
limit,
});
// console.log("Fetching events for customer:", customer_id);
res.status(200).json({ events });
} catch (error) {
handleFrontendReqError({ req, error, res, action: "get customer events" });
}
});
// const events = await EventService.getByCustomerId({
// db,
// internalCustomerId: customer_id,
// env,
// orgId: orgId,
// limit,
// });
// console.log("Events:", events);
// res.status(200).json({ events });
// } catch (error) {
// handleFrontendReqError({ req, error, res, action: "get customer events" });
// }
// });
cusRouter.get("/:customer_id/data", async (req: any, res: any) => {
try {
@@ -485,8 +429,6 @@ cusRouter.post("/all/full_customers", async (req: any, res: any) =>
pageSize: page_size,
});
console.log("First customer", customers?.[0]);
const fullCustomers = await CusBatchService.getByInternalIds({
db,
org,

View File

@@ -1,4 +1,5 @@
import { FullCusProduct } from "../cusProductModels/cusProductModels.js";
import { Event } from "../eventModels/eventTable.js";
import { Subscription } from "../subModels/subModels.js";
import { Customer } from "./cusModels.js";
import { Entity } from "./entityModels/entityModels.js";
@@ -15,4 +16,5 @@ export type FullCustomer = Customer & {
}[];
invoices?: Invoice[];
subscriptions?: Subscription[];
events?: Event[];
};

View File

@@ -12,24 +12,18 @@ import {
TooltipContent,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { useNavigate } from "react-router";
import { AppEnv } from "@autumn/shared";
import { useNavigate, useParams } from "react-router";
import { useCustomerContext } from "./CustomerContext";
import { cn } from "@/lib/utils";
import { CusProductEntityItem } from "./components/CusProductEntityItem";
import { useCusQuery } from "./hooks/useCusQuery";
import { useCusEventsQuery } from "./hooks/useCusEventsQuery";
export const CustomerEventsList = ({
events,
customer,
env,
}: {
events: any;
customer: any;
env: AppEnv;
}) => {
const [selectedEvent, setSelectedEvent] = useState<any>(null);
export const CustomerEventsList = () => {
const navigate = useNavigate();
const [selectedEvent, setSelectedEvent] = useState<any>(null);
const { customer_id } = useParams();
const { showEntityView } = useCustomerContext();
const { events, isLoading, error } = useCusEventsQuery();
return (
<div>
@@ -45,18 +39,14 @@ export const CustomerEventsList = ({
</DialogContent>
</Dialog>
<div className="flex items-center grid grid-cols-10 gap-8 justify-between border-y bg-stone-100 pl-10 pr-7 h-10">
<div className="items-center grid grid-cols-10 gap-8 justify-between border-y bg-stone-100 pl-10 pr-7 h-10">
<h2 className="text-sm text-t2 font-medium col-span-2 flex">Events</h2>
<div className="flex w-full h-full items-center col-span-8 justify-end">
<div className="flex w-fit h-full items-center gap-4">
<Button
variant="analyse"
onClick={() =>
navigateTo(
`/analytics?customer_id=${customer.id}`,
navigate,
env
)
navigateTo(`/analytics?customer_id=${customer_id}`, navigate)
}
>
Analyse Events
@@ -65,7 +55,13 @@ export const CustomerEventsList = ({
</div>
</div>
{events.length === 0 ? (
{isLoading ? (
<div className="flex pl-10 items-center h-10">
<p className="text-t3 text-sm shimmer">
Loading events for this customer...
</p>
</div>
) : events && events.length === 0 ? (
<div className="flex pl-10 items-center h-10">
<p className="text-t3 text-sm">
No events received for this customer
@@ -86,38 +82,42 @@ export const CustomerEventsList = ({
</Row>
)}
{events.map((event: any) => (
<Row
key={event.id}
className={cn("grid-cols-12 pr-0", showEntityView && "grid-cols-15")}
onClick={() => setSelectedEvent(event)}
>
<Item className="col-span-3 font-mono">{event.event_name}</Item>
{events &&
events.map((event: any) => (
<Row
key={event.id}
className={cn(
"grid-cols-12 pr-0",
showEntityView && "grid-cols-15"
)}
onClick={() => setSelectedEvent(event)}
>
<Item className="col-span-3 font-mono">{event.event_name}</Item>
<Item className="col-span-3 relative">
<span className="font-mono truncate">
{event.value || event.properties.value || 1}
</span>
</Item>
<Item className="col-span-3 font-mono">
<span className="text-t3">POST </span>
<span className="text-lime-600">200</span>
</Item>
{showEntityView && <Item className="col-span-3"></Item>}
<Item className="col-span-2 text-t3 text-xs">
<Tooltip>
<TooltipTrigger>
{formatUnixToDateTime(event.timestamp).date}{" "}
{formatUnixToDateTime(event.timestamp).time}{" "}
</TooltipTrigger>
<TooltipContent>
{formatUnixToDateTimeWithMs(event.timestamp)}
</TooltipContent>
</Tooltip>
</Item>
<Item className="col-span-1" />
</Row>
))}
<Item className="col-span-3 relative">
<span className="font-mono truncate">
{event.value || event.properties.value || 1}
</span>
</Item>
<Item className="col-span-3 font-mono">
<span className="text-t3">POST </span>
<span className="text-lime-600">200</span>
</Item>
{showEntityView && <Item className="col-span-3"></Item>}
<Item className="col-span-2 text-t3 text-xs">
<Tooltip>
<TooltipTrigger>
{formatUnixToDateTime(event.timestamp).date}{" "}
{formatUnixToDateTime(event.timestamp).time}{" "}
</TooltipTrigger>
<TooltipContent>
{formatUnixToDateTimeWithMs(event.timestamp)}
</TooltipContent>
</Tooltip>
</Item>
<Item className="col-span-1" />
</Row>
))}
<p className="text-t3 text-xs w-full text-center mt-2">
Showing last 10 events

View File

@@ -1,5 +1,8 @@
import React from "react";
import SmallSpinner from "@/components/general/SmallSpinner";
import AddCouponDialogContent from "./components/add-coupon/AddCouponDialogContent";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -14,23 +17,11 @@ import { Customer } from "@autumn/shared";
import { useCustomerContext } from "./CustomerContext";
import { CusService } from "@/services/customers/CusService";
import { useNavigate } from "react-router";
import { navigateTo } from "@/utils/genUtils";
import React from "react";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import AddCouponDialogContent from "./add-coupon/AddCouponDialogContent";
import { cn } from "@/lib/utils";
import UpdateCustomerDialog from "./UpdateCustomerDialog";
import {
Delete,
Pen,
Pencil,
Settings,
Settings2,
Ticket,
Trash,
} from "lucide-react";
import UpdateCustomerDialog from "./components/UpdateCustomerDialog";
import { Delete, Settings } from "lucide-react";
export const CustomerToolbar = ({
className,

View File

@@ -13,6 +13,8 @@ import { CustomerProductList } from "./customer-product-list/CustomerProductList
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { CustomerEntitlementsList } from "./entitlements/CustomerEntitlementsList";
import { useCusReferralQuery } from "./hooks/useCusReferralQuery";
import { InvoicesTable } from "./InvoicesTable";
import { CustomerEventsList } from "./CustomerEventsList";
export default function CustomerView() {
// const { customer_id } = useParams();
@@ -96,7 +98,7 @@ export default function CustomerView() {
// rewards: rewardsData?.rewards,
}}
>
<div className="flex w-full overflow-auto h-full ">
<div className="flex w-full overflow-y-scroll h-full">
<div className="flex flex-col gap-4 w-full ">
<CustomerPageHeader />
<div className="flex w-full !pb-[50px]">
@@ -108,12 +110,8 @@ export default function CustomerView() {
<CustomerEntitlementsList />
<div className="flex flex-col gap-2"></div>
{/* <InvoicesTable />
<CustomerEventsList
customer={customer}
events={events}
env={env}
/> */}
<InvoicesTable />
<CustomerEventsList />
</div>
</div>
</div>

View File

@@ -8,11 +8,15 @@ import { Row, Item } from "@/components/general/TableGrid";
import { AdminHover } from "@/components/general/AdminHover";
import { cn } from "@/lib/utils";
import { CusProductEntityItem } from "./components/CusProductEntityItem";
import { useCusQuery } from "./hooks/useCusQuery";
export const InvoicesTable = () => {
const { env, invoices, products, entityId, entities, showEntityView } =
useCustomerContext();
const axiosInstance = useAxiosInstance({ env });
// const { env, invoices, products, entityId, entities, showEntityView } =
// useCustomerContext();
const { entityId, showEntityView } = useCustomerContext();
const { customer, products, entities } = useCusQuery();
const axiosInstance = useAxiosInstance();
const invoices = customer.invoices;
const entity = entities.find(
(e: any) => e.id === entityId || e.internal_id === entityId
@@ -100,7 +104,7 @@ export const InvoicesTable = () => {
>
{invoice.product_ids
.map((p: string) => {
return products.find((product: Product) => product.id === p)
return products.find((product: any) => product.id === p)
?.name;
})
.join(", ")}

View File

@@ -16,18 +16,21 @@ import { useNavigate } from "react-router";
import { getRedirectUrl, navigateTo } from "@/utils/genUtils";
import { toast } from "sonner";
import { OrgService } from "@/services/OrgService";
import { CusProductStatus, Entity, Product } from "@autumn/shared";
import { CusProductStatus, Entity, Product, ProductV2 } from "@autumn/shared";
import SmallSpinner from "@/components/general/SmallSpinner";
import { Blend, Search } from "lucide-react";
import { useOrg } from "@/hooks/common/useOrg";
import { useCustomer } from "autumn-js/react";
import { useCusQuery } from "../hooks/useCusQuery";
function AddProduct({
setMultiAttachOpen,
}: {
setMultiAttachOpen: (open: boolean) => void;
}) {
const { products, customer, env, entityId, entities } = useCustomerContext();
const { env, entityId } = useCustomerContext();
const { products, customer, entities } = useCusQuery();
const axiosInstance = useAxiosInstance({ env });
const { customer: autumnCustomer } = useCustomer();
@@ -36,7 +39,7 @@ function AddProduct({
const [open, setOpen] = useState(false);
const { org } = useOrg();
const filteredProducts = products.filter((product: Product) => {
const filteredProducts = products.filter((product: ProductV2) => {
if (product.is_add_on && !searchQuery) return true;
const entity = entities.find((e: Entity) => e.id === entityId);
@@ -114,7 +117,7 @@ function AddProduct({
No new products found
</div>
) : (
filteredProducts.map((product: Product) => (
filteredProducts.map((product: ProductV2) => (
<DropdownProductItem
key={product.id}
product={product}
@@ -146,7 +149,7 @@ const DropdownProductItem = ({
product,
handleAddProduct,
}: {
product: Product;
product: ProductV2;
handleAddProduct: any;
}) => {
const [isLoading, setIsLoading] = useState(false);

View File

@@ -1,19 +1,17 @@
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { getBackendErr, navigateTo } from "@/utils/genUtils";
import { Reward, CreateCustomer, Customer } from "@autumn/shared";
import { useEffect, useState } from "react";
import { CreateCustomer, Customer } from "@autumn/shared";
import { useState } from "react";
import { toast } from "sonner";
import { DialogTitle } from "@/components/ui/dialog";
import { DialogContent } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useCustomerContext } from "./CustomerContext";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils";
import { CustomerConfig } from "./CustomerConfig";
import { CusService } from "@/services/customers/CusService";
import { useNavigate } from "react-router";
import { useCusQuery } from "./hooks/useCusQuery";
import { useCusQuery } from "../hooks/useCusQuery";
const UpdateCustomerDialog = ({
selectedCustomer,

View File

@@ -7,7 +7,6 @@ import { Select, SelectItem } from "@/components/ui/select";
import { DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useCustomerContext } from "../CustomerContext";
import { getBackendErr } from "@/utils/genUtils";
import { toast } from "sonner";
import { CusService } from "@/services/customers/CusService";
@@ -15,8 +14,8 @@ import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getOriginalCouponId } from "@/utils/product/couponUtils";
import { WarningBox } from "@/components/general/modal-components/WarningBox";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { useCusQuery } from "../hooks/useCusQuery";
import { useCusReferralQuery } from "../hooks/useCusReferralQuery";
import { useCusQuery } from "../../hooks/useCusQuery";
import { useCusReferralQuery } from "../../hooks/useCusReferralQuery";
const AddCouponDialogContent = ({
setOpen,

View File

@@ -1,9 +1,8 @@
import UpdateCustomerDialog from "../UpdateCustomerDialog";
import { useState } from "react";
import { Accordion } from "@/components/ui/accordion";
import { Dialog } from "@/components/ui/dialog";
import { CustomerRewards } from "./customer-rewards";
import { useCustomerContext } from "../../CustomerContext";
import UpdateCustomerDialog from "../../UpdateCustomerDialog";
import { CustomerToolbar } from "../../CustomerToolbar";
import { CustomerDetails } from "./CustomerDetails";
import { CustomerEntities } from "./CustomerEntities";

View File

@@ -1,3 +1,4 @@
import AddCouponDialogContent from "../../components/add-coupon/AddCouponDialogContent";
import { SideAccordion } from "@/components/general/SideAccordion";
import { getRedirectUrl } from "@/utils/genUtils";
import { Dialog } from "@/components/ui/dialog";
@@ -15,11 +16,9 @@ import { ArrowUpRightFromSquare } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Link } from "react-router";
import { useCusQuery } from "../../hooks/useCusQuery";
import { useEnv } from "@/utils/envUtils";
import { useCusReferralQuery } from "../../hooks/useCusReferralQuery";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import AddCouponDialogContent from "../../add-coupon/AddCouponDialogContent";
export const CustomerRewards = () => {
// const { discount, env } = useCustomerContext();

View File

@@ -119,13 +119,13 @@ export const CustomerProductList = () => {
>
Show Expired
</Button>
{/* <CreateEntitlement buttonType={"feature"} /> */}
<div className="flex items-center gap-0">
{/* <MultiAttachDialog
<MultiAttachDialog
open={multiAttachOpen}
setOpen={setMultiAttachOpen}
/>
<AddProduct setMultiAttachOpen={setMultiAttachOpen} /> */}
<AddProduct setMultiAttachOpen={setMultiAttachOpen} />
</div>
</div>
</div>

View File

@@ -0,0 +1,24 @@
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router";
export const useCusEventsQuery = () => {
const axiosInstance = useAxiosInstance();
const { customer_id } = useParams();
const fetcher = async () => {
console.log("Fetching events for customer:", customer_id);
const { data } = await axiosInstance.get(
`/customers/${customer_id}/events`
);
console.log("Events:", data);
return data;
};
const { data, isLoading, error } = useQuery({
queryKey: ["customer_events", customer_id],
queryFn: fetcher,
});
return { events: data?.events, isLoading, error };
};

View File

@@ -32,7 +32,7 @@ export const useCusQuery = () => {
const { products, isLoading: productsLoading } = useProductsQuery();
const { features, isLoading: featuresLoading } = useFeaturesQuery();
const customer = cachedCustomer || data?.customer;
const customer = data?.customer || cachedCustomer;
const cusWithCacheLoading = cachedCustomer ? false : customerLoading;
return {

View File

@@ -18,7 +18,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useEffect, useState } from "react";
import { FullProduct } from "@autumn/shared";
import { FullProduct, ProductV2 } from "@autumn/shared";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { useAxiosInstance } from "@/services/useAxiosInstance";
@@ -31,6 +31,8 @@ import { formatAmount } from "@/utils/product/productItemUtils";
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
import { AddRewardButton, MultiAttachRewards } from "./MultiAttachRewards";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import { useCusQuery } from "../../hooks/useCusQuery";
import { useOrg } from "@/hooks/common/useOrg";
export const MultiAttachDialog = ({
open,
@@ -39,7 +41,9 @@ export const MultiAttachDialog = ({
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const { customer, cusMutate, products, org } = useCustomerContext();
// const { customer, cusMutate, products, org } = useCustomerContext();
const { org } = useOrg();
const { customer, products, refetch } = useCusQuery();
const axiosInstance = useAxiosInstance();
@@ -160,7 +164,7 @@ export const MultiAttachDialog = ({
window.open(getStripeInvoiceLink(data.invoice), "_blank");
}
await cusMutate();
await refetch();
toast.success("Products attached successfully");
setOpen(false);
} catch (error) {
@@ -204,12 +208,14 @@ export const MultiAttachDialog = ({
<SelectContent className="max-h-[300px] overflow-y-auto">
{products
.filter(
(p: FullProduct) =>
(p: ProductV2) =>
!productOptions
.map((o, i) => (i !== index ? o.product : null))
.map((o, i) =>
i !== index ? o.product_id : null
)
.includes(p.id)
)
.map((product: FullProduct) => (
.map((product: ProductV2) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>

View File

@@ -1,5 +1,6 @@
import { FullCustomer } from "@autumn/shared";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { useCustomersQueryStates } from "./useCustomersQueryStates";
import { useAxiosInstance } from "@/services/useAxiosInstance";
@@ -7,19 +8,12 @@ export const useFullCusSearchQuery = () => {
const { queryStates } = useCustomersQueryStates();
const axiosInstance = useAxiosInstance();
const { data: fullCustomersData } = useQuery<{
const { refetch } = useQuery<{
fullCustomers: FullCustomer[];
}>({
queryKey: [
"full_customers",
queryStates.page,
queryStates.status,
queryStates.version,
queryStates.none,
queryStates.q,
],
queryFn: async () => {
console.log("Fetching full customers: ", queryStates.q);
queryKey: ["full_customers"],
// Pass AbortSignal so previous requests are canceled when a new refetch starts
queryFn: async ({ signal }) => {
const { data } = await axiosInstance.post(
`/customers/all/full_customers`,
{
@@ -31,12 +25,31 @@ export const useFullCusSearchQuery = () => {
version: queryStates.version,
none: queryStates.none,
},
}
},
{ signal }
);
console.log("data", data);
console.log(`Fetched ${data?.fullCustomers.length} full customers`);
return data;
},
placeholderData: keepPreviousData,
enabled: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
useEffect(() => {
// One controlled refetch per dependency change
refetch();
}, [
// Trigger on all state changes that affect the payload
queryStates.page,
queryStates.status,
queryStates.version,
queryStates.none,
queryStates.q,
refetch,
]);
};

View File

@@ -1,95 +0,0 @@
import SmallSpinner from "@/components/general/SmallSpinner";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ProductV2 } from "@autumn/shared";
import { ProductService } from "@/services/products/ProductService";
import { useNavigate } from "react-router";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useProductContext } from "./ProductContext";
import { getBackendErr, navigateTo } from "@/utils/genUtils";
import { Delete, Settings } from "lucide-react";
export const EditProductToolbar = ({
className,
product,
}: {
className?: string;
product: ProductV2;
}) => {
const { mutate, env, numVersions, version } = useProductContext();
const axiosInstance = useAxiosInstance({ env });
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const navigate = useNavigate();
const handleDelete = async () => {
try {
if (version && version < numVersions) {
toast.error("Can't delete earlier version of a product");
return;
}
setDeleteLoading(true);
await ProductService.deleteProduct(axiosInstance, product.id);
if (numVersions > 1) {
navigateTo(
`/products/${product.id}?version=${numVersions - 1}`,
navigate,
env
);
toast.success(
`${product.name} (version ${numVersions}) deleted successfully`
);
} else {
navigateTo(`/products`, navigate, env);
toast.success(`${product.name} deleted successfully`);
}
} catch (error) {
toast.error(getBackendErr(error, "Failed to delete product"));
} finally {
setDeleteLoading(false);
}
};
return (
<DropdownMenu open={deleteOpen} onOpenChange={setDeleteOpen}>
<DropdownMenuTrigger asChild>
<Button
isIcon
variant="ghost"
dim={6}
className={cn("rounded-full", className)}
>
<Settings size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="text-t2" align="end">
<DropdownMenuItem
// className="flex items-center bg-red-500 text-white"
className="flex items-center text-red-500 hover:!bg-red-500 hover:!text-white text-xs"
onClick={async (e) => {
e.stopPropagation();
e.preventDefault();
await handleDelete();
}}
>
<div className="flex items-center justify-between w-full gap-2">
{numVersions > 1 ? "Delete version" : "Delete"}
{deleteLoading ? <SmallSpinner /> : <Delete size={12} />}
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -16,13 +16,12 @@ import { ManageProduct } from "./ManageProduct";
import { AppEnv, UpdateProductSchema } from "@autumn/shared";
import { ProductService } from "@/services/products/ProductService";
import { getBackendErr } from "@/utils/genUtils";
import { UpdateProductButton } from "@/views/products/product/components/UpdateProductButton";
import { useProductChangedAlert } from "./hooks/useProductChangedAlert";
import { useProductData } from "./hooks/useProductData";
import { UpdateProductButton } from "./components/UpdateProductButton";
function ProductView({ env }: { env: AppEnv }) {
const axiosInstance = useAxiosInstance();
const { product_id } = useParams();
const [searchParams] = useSearchParams();
const version = searchParams.get("version");

View File

@@ -1,88 +0,0 @@
import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Feature } from "@autumn/shared";
import { EllipsisVertical, MinusIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
export default function MoreMenuButton({
fields,
setFields,
showPerEntity,
setShowPerEntity,
selectedFeature,
}: {
fields: any;
setFields: (fields: any) => void;
showPerEntity: boolean;
setShowPerEntity: (showPerEntity: boolean) => void;
selectedFeature: Feature | null;
}) {
const [showPopover, setShowPopover] = useState(false);
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-t3 text-xs bg-transparent border-none shadow-none justify-start"
onClick={() => setShowPopover(!showPopover)}
// disabled={!selectedFeature}
>
<EllipsisVertical size={14} className="mr-1" />
More
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-2 flex flex-col text-xs" align="end">
<div className="flex items-center space-x-2">
<Button
variant="secondary"
className="text-xs text-t3 shadow-none border-none"
onClick={() => {
setFields({
...fields,
carry_from_previous: !fields.carry_from_previous,
});
}}
>
<Checkbox
className="border-t3 mr-1"
checked={fields.carry_from_previous}
onCheckedChange={(checked) =>
setFields({
...fields,
carry_from_previous: Boolean(checked),
})
}
/>
Keep usage on upgrade
</Button>
</div>
<Button
className="h-7 shadow-none text-t3 text-xs justify-start border-none"
variant="outline"
startIcon={
showPerEntity ? (
<MinusIcon size={14} className="ml-0.5 mr-1" />
) : (
<PlusIcon size={14} className="ml-0.5 mr-1" />
)
}
onClick={() => {
setShowPerEntity(!showPerEntity);
// hide the popover
setShowPopover(false);
}}
>
{showPerEntity ? "Remove Per Entity" : "Add Per Entity"}
</Button>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,108 +0,0 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { EllipsisVertical, MinusIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useProductItemContext } from "./ProductItemContext";
import { ProductItem, UsageModel } from "@autumn/shared";
export default function MoreMenuButton({
show,
setShow,
}: {
show: any;
setShow: (show: any) => void;
}) {
const [showPopover, setShowPopover] = useState(false);
const {
item,
setItem,
}: { item: ProductItem; setItem: (item: ProductItem) => void } =
useProductItemContext();
return (
<Popover open={showPopover} onOpenChange={setShowPopover}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-t3 text-xs bg-transparent border-none shadow-none justify-start"
onClick={() => setShowPopover(!showPopover)}
>
<EllipsisVertical size={14} className="" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-fit min-w-48 p-0 py-1 flex flex-col text-xs"
align="end"
>
<div className="flex items-center space-x-2">
<Button
variant="secondary"
className="text-xs text-t2 shadow-none border-none w-full justify-start"
onClick={() => {
setItem({
...item,
reset_usage_when_enabled: !item.reset_usage_when_enabled,
});
}}
>
<Checkbox
className="border-t3 mr-1"
checked={item.reset_usage_when_enabled || false}
/>
Reset usage when product is enabled
</Button>
</div>
</PopoverContent>
</Popover>
);
}
export const MoreMenuPriceButton = () => {
const [showPopover, setShowPopover] = useState(false);
const { item, setItem } = useProductItemContext();
return (
<Popover open={showPopover} onOpenChange={setShowPopover}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-t3 text-xs bg-transparent border-none shadow-none justify-start"
onClick={() => setShowPopover(!showPopover)}
>
<EllipsisVertical size={14} className="" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0 flex flex-col text-xs" align="end">
<div className="flex items-center space-x-2">
<Button
variant="secondary"
className="text-xs text-t2 shadow-none border-none w-full justify-start"
onClick={() => {
setItem({
...item,
usage_model:
item.usage_model == UsageModel.Prepaid
? UsageModel.PayPerUse
: UsageModel.Prepaid,
});
}}
>
<Checkbox
className="border-t3 mr-1"
checked={item.usage_model == UsageModel.Prepaid}
/>
Usage is Prepaid
</Button>
</div>
</PopoverContent>
</Popover>
);
};