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

View File

@@ -202,6 +202,7 @@ export const getFullCusQuery = (
withEntities: boolean, withEntities: boolean,
withTrialsUsed: boolean, withTrialsUsed: boolean,
withSubs: boolean, withSubs: boolean,
withEvents: boolean,
entityId?: string entityId?: string
) => { ) => {
const sqlChunks: SQL[] = []; const sqlChunks: SQL[] = [];
@@ -255,6 +256,32 @@ export const getFullCusQuery = (
sqlChunks.push(buildInvoicesCTE(!!entityId)); 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 // Build final SELECT
const selectFieldsChunks: SQL[] = []; const selectFieldsChunks: SQL[] = [];
selectFieldsChunks.push(sql` selectFieldsChunks.push(sql`
@@ -294,6 +321,11 @@ export const getFullCusQuery = (
(SELECT invoices FROM customer_invoices) AS invoices`); (SELECT invoices FROM customer_invoices) AS invoices`);
} }
if (withEvents) {
selectFieldsChunks.push(sql`,
(SELECT events FROM customer_events) AS events`);
}
sqlChunks.push(sql` sqlChunks.push(sql`
SELECT ${sql.join(selectFieldsChunks, sql``)} SELECT ${sql.join(selectFieldsChunks, sql``)}
FROM customer_record cr FROM customer_record cr
@@ -312,6 +344,7 @@ export const getPaginatedFullCusQuery = ({
withSubs, withSubs,
limit = 10, limit = 10,
offset = 0, offset = 0,
withEvents = false,
entityId, entityId,
internalCustomerIds, internalCustomerIds,
}: { }: {
@@ -324,6 +357,7 @@ export const getPaginatedFullCusQuery = ({
withSubs: boolean; withSubs: boolean;
limit: number; limit: number;
offset: number; offset: number;
withEvents?: boolean;
entityId?: string; entityId?: string;
internalCustomerIds?: string[]; internalCustomerIds?: string[];
}) => { }) => {
@@ -342,6 +376,14 @@ export const getPaginatedFullCusQuery = ({
FROM customers c FROM customers c
WHERE c.org_id = ${orgId} WHERE c.org_id = ${orgId}
AND c.env = ${env} 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 ORDER BY c.created_at DESC
LIMIT ${limit} OFFSET ${offset} 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 customer_prices cpr ON cpr.customer_product_id = cp.id
LEFT JOIN prices p ON cpr.price_id = p.id LEFT JOIN prices p ON cpr.price_id = p.id
LEFT JOIN customer_entitlements ce ON ce.customer_product_id = cp.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) ${ 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``
}
${withStatusFilter()} ${withStatusFilter()}
GROUP BY cp.id, prod.* GROUP BY cp.id, prod.*
), ),
@@ -500,12 +535,14 @@ export const getPaginatedFullCusQuery = ({
${withEntities ? sql`, COALESCE(ce.entities, '[]'::json) AS entities` : sql``} ${withEntities ? sql`, COALESCE(ce.entities, '[]'::json) AS entities` : sql``}
${includeInvoices ? sql`, COALESCE(ci.invoices, '[]'::json) AS invoices` : sql``} ${includeInvoices ? sql`, COALESCE(ci.invoices, '[]'::json) AS invoices` : sql``}
${withTrialsUsed ? sql`, COALESCE(ctu.trials_used, '[]'::json) AS trials_used` : 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 FROM customer_records cr
LEFT JOIN customer_products_aggregated cpa ON cpa.internal_customer_id = cr.internal_id 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``} ${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``} ${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``} ${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``} ${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 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({ res.status(200).json({
customer: fullCus, customer: fullCus,
// products: getLatestProducts(products), // 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) => { // cusRouter.get("/:customer_id/stripe", async (req: any, res: any) => {
// try { // try {
// const { db, org, features, env } = req; // 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) => { // cusRouter.get("/:customer_id/events", async (req: any, res: any) => {
try { // try {
const { db, org, features, env } = req; // const { db, org, features, env } = req;
const { customer_id } = req.params; // const { customer_id } = req.params;
const orgId = req.orgId; // const orgId = req.orgId;
const limit = req.query.limit || 10; // const limit = req.query.limit || 10;
const period = req.query.period || "all"; // const period = req.query.period || "all";
const events = await EventService.getByCustomerId({ // console.log("Fetching events for customer:", customer_id);
db,
internalCustomerId: customer_id,
env,
orgId: orgId,
limit,
});
res.status(200).json({ events }); // const events = await EventService.getByCustomerId({
} catch (error) { // db,
handleFrontendReqError({ req, error, res, action: "get customer events" }); // 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) => { cusRouter.get("/:customer_id/data", async (req: any, res: any) => {
try { try {
@@ -485,8 +429,6 @@ cusRouter.post("/all/full_customers", async (req: any, res: any) =>
pageSize: page_size, pageSize: page_size,
}); });
console.log("First customer", customers?.[0]);
const fullCustomers = await CusBatchService.getByInternalIds({ const fullCustomers = await CusBatchService.getByInternalIds({
db, db,
org, org,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -119,13 +119,13 @@ export const CustomerProductList = () => {
> >
Show Expired Show Expired
</Button> </Button>
{/* <CreateEntitlement buttonType={"feature"} /> */}
<div className="flex items-center gap-0"> <div className="flex items-center gap-0">
{/* <MultiAttachDialog <MultiAttachDialog
open={multiAttachOpen} open={multiAttachOpen}
setOpen={setMultiAttachOpen} setOpen={setMultiAttachOpen}
/> />
<AddProduct setMultiAttachOpen={setMultiAttachOpen} /> */} <AddProduct setMultiAttachOpen={setMultiAttachOpen} />
</div> </div>
</div> </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 { products, isLoading: productsLoading } = useProductsQuery();
const { features, isLoading: featuresLoading } = useFeaturesQuery(); const { features, isLoading: featuresLoading } = useFeaturesQuery();
const customer = cachedCustomer || data?.customer; const customer = data?.customer || cachedCustomer;
const cusWithCacheLoading = cachedCustomer ? false : customerLoading; const cusWithCacheLoading = cachedCustomer ? false : customerLoading;
return { return {

View File

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

View File

@@ -1,5 +1,6 @@
import { FullCustomer } from "@autumn/shared"; import { FullCustomer } from "@autumn/shared";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { useCustomersQueryStates } from "./useCustomersQueryStates"; import { useCustomersQueryStates } from "./useCustomersQueryStates";
import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useAxiosInstance } from "@/services/useAxiosInstance";
@@ -7,19 +8,12 @@ export const useFullCusSearchQuery = () => {
const { queryStates } = useCustomersQueryStates(); const { queryStates } = useCustomersQueryStates();
const axiosInstance = useAxiosInstance(); const axiosInstance = useAxiosInstance();
const { data: fullCustomersData } = useQuery<{ const { refetch } = useQuery<{
fullCustomers: FullCustomer[]; fullCustomers: FullCustomer[];
}>({ }>({
queryKey: [ queryKey: ["full_customers"],
"full_customers", // Pass AbortSignal so previous requests are canceled when a new refetch starts
queryStates.page, queryFn: async ({ signal }) => {
queryStates.status,
queryStates.version,
queryStates.none,
queryStates.q,
],
queryFn: async () => {
console.log("Fetching full customers: ", queryStates.q);
const { data } = await axiosInstance.post( const { data } = await axiosInstance.post(
`/customers/all/full_customers`, `/customers/all/full_customers`,
{ {
@@ -31,12 +25,31 @@ export const useFullCusSearchQuery = () => {
version: queryStates.version, version: queryStates.version,
none: queryStates.none, none: queryStates.none,
}, },
} },
{ signal }
); );
console.log("data", data); console.log(`Fetched ${data?.fullCustomers.length} full customers`);
return data; return data;
}, },
placeholderData: keepPreviousData, 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 { AppEnv, UpdateProductSchema } from "@autumn/shared";
import { ProductService } from "@/services/products/ProductService"; import { ProductService } from "@/services/products/ProductService";
import { getBackendErr } from "@/utils/genUtils"; import { getBackendErr } from "@/utils/genUtils";
import { UpdateProductButton } from "@/views/products/product/components/UpdateProductButton";
import { useProductChangedAlert } from "./hooks/useProductChangedAlert"; import { useProductChangedAlert } from "./hooks/useProductChangedAlert";
import { useProductData } from "./hooks/useProductData"; import { useProductData } from "./hooks/useProductData";
import { UpdateProductButton } from "./components/UpdateProductButton";
function ProductView({ env }: { env: AppEnv }) { function ProductView({ env }: { env: AppEnv }) {
const axiosInstance = useAxiosInstance(); const axiosInstance = useAxiosInstance();
const { product_id } = useParams(); const { product_id } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const version = searchParams.get("version"); 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>
);
};