working on cached customer

This commit is contained in:
John Yeo
2025-09-01 23:23:19 -07:00
parent 76ab16bec4
commit 3567c1d8b0
39 changed files with 923 additions and 430 deletions

View File

@@ -14,6 +14,33 @@ import { getCustomerDetails } from "../../customers/cusUtils/getCustomerDetails.
import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js";
export class CusBatchService {
static async getByInternalIds({
db,
org,
env,
internalCustomerIds,
}: {
db: DrizzleCli;
org: Organization;
env: AppEnv;
internalCustomerIds: string[];
}) {
let query = getPaginatedFullCusQuery({
orgId: org.id,
env,
includeInvoices: true,
withEntities: true,
withTrialsUsed: false,
withSubs: true,
limit: 100,
offset: 0,
internalCustomerIds,
});
let results = await db.execute(query);
return results as unknown as FullCustomer[];
}
static async getPage({
db,
ch,
@@ -48,17 +75,17 @@ export class CusBatchService {
const withEntities = expand.includes(CusExpand.Entities);
const withTrialsUsed = expand.includes(CusExpand.TrialsUsed);
let query = getPaginatedFullCusQuery(
org.id,
let query = getPaginatedFullCusQuery({
orgId: org.id,
env,
statuses,
inStatuses: statuses,
includeInvoices,
withEntities,
withTrialsUsed,
true,
withSubs: true,
limit,
offset
);
offset,
});
let results = await db.execute(query);
let finals = [];
for (let result of results) {

View File

@@ -3,16 +3,16 @@ import { CusProductStatus } from "@autumn/shared";
import { sql, SQL } from "drizzle-orm";
const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => {
const withStatusFilter = () => {
return inStatuses
? sql`AND cp.status = ANY(ARRAY[${sql.join(
inStatuses.map((status) => sql`${status}`),
sql`, `
)}])`
: sql``;
};
const withStatusFilter = () => {
return inStatuses
? sql`AND cp.status = ANY(ARRAY[${sql.join(
inStatuses.map((status) => sql`${status}`),
sql`, `
)}])`
: sql``;
};
return sql`
return sql`
customer_products_with_prices AS (
SELECT
cp.*,
@@ -80,11 +80,11 @@ const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => {
};
const buildEntitiesCTE = (withEntities: boolean) => {
if (!withEntities) {
return sql``;
}
if (!withEntities) {
return sql``;
}
return sql`
return sql`
customer_entities AS (
SELECT
COALESCE(
@@ -99,11 +99,11 @@ const buildEntitiesCTE = (withEntities: boolean) => {
};
const buildEntityCTE = (entityId?: string) => {
if (!entityId) {
return sql``;
}
if (!entityId) {
return sql``;
}
return sql`
return sql`
entity_record AS (
SELECT * FROM entities e
WHERE e.internal_customer_id = (SELECT internal_id FROM customer_record)
@@ -116,15 +116,15 @@ const buildEntityCTE = (entityId?: string) => {
};
const buildTrialsUsedCTE = (
withTrialsUsed: boolean,
orgId: string,
env: AppEnv
withTrialsUsed: boolean,
orgId: string,
env: AppEnv
) => {
if (!withTrialsUsed) {
return sql``;
}
if (!withTrialsUsed) {
return sql``;
}
return sql`
return sql`
customer_trials_used AS (
SELECT
COALESCE(
@@ -147,14 +147,14 @@ const buildTrialsUsedCTE = (
};
const buildSubscriptionsCTE = (
withSubs: boolean,
inStatuses?: CusProductStatus[]
withSubs: boolean,
inStatuses?: CusProductStatus[]
) => {
if (!withSubs) {
return sql``;
}
if (!withSubs) {
return sql``;
}
return sql`
return sql`
customer_subscriptions AS (
SELECT
COALESCE(
@@ -171,14 +171,14 @@ const buildSubscriptionsCTE = (
};
const buildInvoicesCTE = (hasEntityCTE: boolean) => {
let entityFilter = hasEntityCTE
? sql`AND (
let entityFilter = hasEntityCTE
? sql`AND (
NOT EXISTS (SELECT 1 FROM entity_record)
OR i.internal_entity_id = (SELECT internal_id FROM entity_record LIMIT 1)
)`
: sql``;
: sql``;
return sql`
return sql`
customer_invoices AS (
SELECT
COALESCE(
@@ -194,20 +194,20 @@ const buildInvoicesCTE = (hasEntityCTE: boolean) => {
};
export const getFullCusQuery = (
idOrInternalId: string,
orgId: string,
env: AppEnv,
inStatuses: CusProductStatus[],
includeInvoices: boolean,
withEntities: boolean,
withTrialsUsed: boolean,
withSubs: boolean,
entityId?: string
idOrInternalId: string,
orgId: string,
env: AppEnv,
inStatuses: CusProductStatus[],
includeInvoices: boolean,
withEntities: boolean,
withTrialsUsed: boolean,
withSubs: boolean,
entityId?: string
) => {
const sqlChunks: SQL[] = [];
const sqlChunks: SQL[] = [];
// Step 1: Get customer record
sqlChunks.push(sql`
// Step 1: Get customer record
sqlChunks.push(sql`
WITH customer_record AS (
SELECT * FROM customers c
WHERE (
@@ -220,44 +220,44 @@ export const getFullCusQuery = (
)
`);
// Step 2: Get entities
if (withEntities) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildEntitiesCTE(withEntities));
}
// Step 2: Get entities
if (withEntities) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildEntitiesCTE(withEntities));
}
// Step 3: Get entity
if (entityId) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildEntityCTE(entityId));
}
// Step 3: Get entity
if (entityId) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildEntityCTE(entityId));
}
// Add customer products CTE
sqlChunks.push(sql`, `);
// sqlChunks.push(buildCusProductsCTE(inStatuses));
sqlChunks.push(buildOptimizedCusProductsCTE(inStatuses));
// Add customer products CTE
sqlChunks.push(sql`, `);
// sqlChunks.push(buildCusProductsCTE(inStatuses));
sqlChunks.push(buildOptimizedCusProductsCTE(inStatuses));
// Conditionally add trials used CTE
if (withTrialsUsed) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildTrialsUsedCTE(withTrialsUsed, orgId, env));
}
// Conditionally add trials used CTE
if (withTrialsUsed) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildTrialsUsedCTE(withTrialsUsed, orgId, env));
}
// Conditionally add subscriptions CTE
if (withSubs) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildSubscriptionsCTE(withSubs, inStatuses));
}
// Conditionally add subscriptions CTE
if (withSubs) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildSubscriptionsCTE(withSubs, inStatuses));
}
// Conditionally add invoices CTE
if (includeInvoices) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildInvoicesCTE(!!entityId));
}
// Conditionally add invoices CTE
if (includeInvoices) {
sqlChunks.push(sql`, `);
sqlChunks.push(buildInvoicesCTE(!!entityId));
}
// Build final SELECT
const selectFieldsChunks: SQL[] = [];
selectFieldsChunks.push(sql`
// Build final SELECT
const selectFieldsChunks: SQL[] = [];
selectFieldsChunks.push(sql`
cr.*,
COALESCE(
(SELECT json_agg(cpwp) FROM customer_products_with_prices cpwp),
@@ -265,56 +265,68 @@ export const getFullCusQuery = (
) AS customer_products
`);
// Add entities to SELECT if withEntities is true
if (withEntities) {
selectFieldsChunks.push(sql`,
// Add entities to SELECT if withEntities is true
if (withEntities) {
selectFieldsChunks.push(sql`,
(SELECT entities FROM customer_entities) AS entities`);
}
}
// Add entity to SELECT if entityId is provided
if (entityId) {
selectFieldsChunks.push(sql`,
// Add entity to SELECT if entityId is provided
if (entityId) {
selectFieldsChunks.push(sql`,
(SELECT row_to_json(er) FROM entity_record er LIMIT 1) AS entity`);
}
}
// Add trials used to SELECT if withTrialsUsed is true
if (withTrialsUsed) {
selectFieldsChunks.push(sql`,
// Add trials used to SELECT if withTrialsUsed is true
if (withTrialsUsed) {
selectFieldsChunks.push(sql`,
(SELECT trials_used FROM customer_trials_used) AS trials_used`);
}
}
// Add subscriptions to SELECT if withSubs is true
if (withSubs) {
selectFieldsChunks.push(sql`,
// Add subscriptions to SELECT if withSubs is true
if (withSubs) {
selectFieldsChunks.push(sql`,
(SELECT subscriptions FROM customer_subscriptions) AS subscriptions`);
}
}
if (includeInvoices) {
selectFieldsChunks.push(sql`,
if (includeInvoices) {
selectFieldsChunks.push(sql`,
(SELECT invoices FROM customer_invoices) AS invoices`);
}
}
sqlChunks.push(sql`
sqlChunks.push(sql`
SELECT ${sql.join(selectFieldsChunks, sql``)}
FROM customer_record cr
`);
return sql.join(sqlChunks, sql``);
return sql.join(sqlChunks, sql``);
};
export const getPaginatedFullCusQuery = (
orgId: string,
env: AppEnv,
inStatuses: CusProductStatus[],
includeInvoices: boolean,
withEntities: boolean,
withTrialsUsed: boolean,
withSubs: boolean,
limit: number = 10,
offset: number = 0,
entityId?: string
) => {
export const getPaginatedFullCusQuery = ({
orgId,
env,
inStatuses,
includeInvoices,
withEntities,
withTrialsUsed,
withSubs,
limit = 10,
offset = 0,
entityId,
internalCustomerIds,
}: {
orgId: string;
env: AppEnv;
inStatuses?: CusProductStatus[];
includeInvoices: boolean;
withEntities: boolean;
withTrialsUsed: boolean;
withSubs: boolean;
limit: number;
offset: number;
entityId?: string;
internalCustomerIds?: string[];
}) => {
const withStatusFilter = () => {
return inStatuses?.length
? sql`AND cp.status = ANY(ARRAY[${sql.join(
@@ -393,7 +405,14 @@ 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)
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()}
GROUP BY cp.id, prod.*
),
@@ -406,7 +425,9 @@ export const getPaginatedFullCusQuery = (
GROUP BY cpwp.internal_customer_id
)
${withSubs ? sql`, customer_subscriptions AS (
${
withSubs
? sql`, customer_subscriptions AS (
SELECT
cpwp.internal_customer_id,
COALESCE(
@@ -416,9 +437,13 @@ export const getPaginatedFullCusQuery = (
FROM customer_products_with_prices cpwp
JOIN subscriptions s ON s.stripe_id = ANY(cpwp.subscription_ids)
GROUP BY cpwp.internal_customer_id
)` : sql``}
)`
: sql``
}
${withEntities ? sql`, customer_entities AS (
${
withEntities
? sql`, customer_entities AS (
SELECT
e.internal_customer_id,
COALESCE(
@@ -428,9 +453,13 @@ export const getPaginatedFullCusQuery = (
FROM entities e
WHERE e.internal_customer_id IN (SELECT internal_id FROM customer_records)
GROUP BY e.internal_customer_id
)` : sql``}
)`
: sql``
}
${includeInvoices ? sql`, customer_invoices AS (
${
includeInvoices
? sql`, customer_invoices AS (
SELECT
i.internal_customer_id,
COALESCE(
@@ -440,9 +469,13 @@ export const getPaginatedFullCusQuery = (
FROM invoices i
WHERE i.internal_customer_id IN (SELECT internal_id FROM customer_records)
GROUP BY i.internal_customer_id
)` : sql``}
)`
: sql``
}
${withTrialsUsed ? sql`, customer_trials_used AS (
${
withTrialsUsed
? sql`, customer_trials_used AS (
SELECT
cp.internal_customer_id,
json_agg(json_build_object(
@@ -456,7 +489,9 @@ export const getPaginatedFullCusQuery = (
WHERE cp.internal_customer_id IN (SELECT internal_id FROM customer_records)
AND cp.free_trial_id IS NOT NULL
GROUP BY cp.internal_customer_id
)` : sql``}
)`
: sql``
}
SELECT
cr.*,
@@ -473,4 +508,4 @@ export const getPaginatedFullCusQuery = (
${withTrialsUsed ? sql`LEFT JOIN customer_trials_used ctu ON ctu.internal_customer_id = cr.internal_id` : sql``}
ORDER BY cr.created_at DESC
`;
};
};

View File

@@ -23,9 +23,10 @@ import { RewardRedemptionService } from "../rewards/RewardRedemptionService.js";
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 { createOrgResponse, isStripeConnected } from "../orgs/orgUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { CusSearchService } from "./CusSearchService.js";
import { CusBatchService } from "../api/batch/CusBatchService.js";
export const cusRouter: Router = Router();
@@ -53,6 +54,172 @@ cusRouter.post("/all/search", (req, res) =>
})
);
// Customer page
cusRouter.get("/:customer_id", async (req: any, res: any) => {
try {
const { db, org, features, env } = req;
const { customer_id } = req.params;
const orgId = req.orgId;
const fullCus = await CusService.getFull({
db,
orgId,
env,
idOrInternalId: customer_id,
withEntities: true,
expand: [CusExpand.Invoices],
inStatuses: [
CusProductStatus.Active,
CusProductStatus.PastDue,
CusProductStatus.Scheduled,
CusProductStatus.Expired,
],
});
// 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),
// versionCounts: getProductVersionCounts(products),
// invoices,
// features,
// coupons,
// events,
// discount,
// org,
// entities,
});
} catch (error) {
handleFrontendReqError({ req, error, res, action: "get customer data" });
}
});
// cusRouter.get("/:customer_id/stripe", async (req: any, res: any) => {
// try {
// const { db, org, features, env } = req;
// const { customer_id } = req.params;
// let discount = null;
// const customer = await CusService.get({
// db,
// orgId: req.orgId,
// env,
// idOrInternalId: customer_id,
// });
// 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);
// }
// }
// } catch (error) {
// handleFrontendReqError({ req, error, res, action: "get customer data" });
// }
// });
cusRouter.get("/:customer_id/events", async (req: any, res: any) => {
try {
const { db, org, features, env } = req;
@@ -215,16 +382,18 @@ cusRouter.get("/:customer_id/data", async (req: any, res: any) => {
cusRouter.get("/:customer_id/referrals", async (req: any, res: any) => {
try {
const { env, db } = req;
const { env, db, org } = req;
const { customer_id } = req.params;
const orgId = req.orgId;
console.time("get_customer");
let internalCustomer = await CusService.get({
db,
orgId,
env,
idOrInternalId: customer_id,
});
console.timeEnd("get_customer");
if (!internalCustomer) {
throw new RecaseError({
@@ -235,7 +404,7 @@ cusRouter.get("/:customer_id/referrals", async (req: any, res: any) => {
}
// Get all redemptions for this customer
let [referred, redeemed] = await Promise.all([
let [referred, redeemed, stripeCus] = await Promise.all([
RewardRedemptionService.getByReferrer({
db,
internalCustomerId: internalCustomer.internal_id,
@@ -248,6 +417,16 @@ cusRouter.get("/:customer_id/referrals", async (req: any, res: any) => {
withReferralCode: true,
limit: 100,
}),
async () => {
if (isStripeConnected({ org, env }) && internalCustomer.processor?.id) {
const stripeCli = createStripeCli({ org, env });
const stripeCus: any = await stripeCli.customers.retrieve(
internalCustomer.processor.id
);
return stripeCus;
}
return null;
},
]);
let redeemedCustomerIds = redeemed.map(
@@ -269,9 +448,12 @@ cusRouter.get("/:customer_id/referrals", async (req: any, res: any) => {
}
}
const end = performance.now();
res.status(200).send({
referred,
redeemed,
stripeCus,
});
} catch (error) {
handleFrontendReqError({
@@ -283,6 +465,42 @@ cusRouter.get("/:customer_id/referrals", async (req: any, res: any) => {
}
});
cusRouter.post("/all/full_customers", async (req: any, res: any) =>
routeHandler({
req,
res,
action: "get customer full customers",
handler: async (req, res) => {
const { db, org, env } = req;
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,
});
console.log("First customer", customers?.[0]);
const fullCustomers = await CusBatchService.getByInternalIds({
db,
org,
env,
internalCustomerIds: customers.map(
(customer: any) => customer.internal_id
),
});
res.status(200).json({ fullCustomers });
},
})
);
cusRouter.get(
"/:customer_id/product/:product_id",
async (req: any, res: any) => {

View File

@@ -1,7 +1,6 @@
import OnboardingView2 from "./views/onboarding2/OnboardingView2";
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 ProductView from "./views/products/product/ProductView";
import ProductsView from "./views/products/ProductsView";
@@ -18,6 +17,7 @@ import { AnalyticsView } from "./views/customers/customer/analytics/AnalyticsVie
import { TerminalView } from "./views/TerminalView";
import { DefaultView } from "./views/DefaultView";
import { MainLayout } from "./app/layout";
import CustomersPage from "./views/customers/CustomersPage";
export default function App() {
return (
@@ -50,14 +50,8 @@ export default function App() {
path="/sandbox/products/:product_id"
element={<ProductView env={AppEnv.Sandbox} />}
/>
<Route
path="/customers"
element={<CustomersView env={AppEnv.Sandbox} />}
/>
<Route
path="/sandbox/customers"
element={<CustomersView env={AppEnv.Sandbox} />}
/>
<Route path="/customers" element={<CustomersPage />} />
<Route path="/sandbox/customers" element={<CustomersPage />} />
<Route
path="/customers/:customer_id"
element={<CustomerView env={AppEnv.Live} />}

View File

@@ -9,9 +9,9 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchInterval: 0,
},
// queries: {
// refetchInterval: 0,
// },
},
});

View File

@@ -68,12 +68,12 @@ export const pushPage = ({
preserveParams = true,
}: {
path: string;
queryParams: Record<string, string | undefined>;
queryParams?: Record<string, string | undefined>;
navigate?: any;
preserveParams?: boolean;
}) => {
const curPath = window.location.pathname;
const curEnv = getEnvFromPath(curPath);
const pathname = window.location.pathname;
const curEnv = getEnvFromPath(pathname);
const curQueryParams = new URLSearchParams(window.location.search);
if (!preserveParams) {
@@ -95,13 +95,15 @@ export const pushPage = ({
if (curQueryParams.toString()) {
path = `${path}?${curQueryParams.toString()}`;
}
if (navigate) {
if (curEnv === AppEnv.Sandbox) {
navigate(`/sandbox${path}`);
} else {
navigate(path);
}
if (curEnv === AppEnv.Sandbox) {
path = `/sandbox${path}`;
}
if (navigate) {
navigate(path);
}
return path;
};

View File

@@ -1,24 +1,27 @@
"use client";
import React, { useRef } from "react";
import { AppEnv } from "@autumn/shared";
import { useRef } from "react";
import { CustomersContext } from "./CustomersContext";
import { CustomersTable } from "./components/CustomersTable";
import LoadingScreen from "../general/LoadingScreen";
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";
import LoadingScreen from "../general/LoadingScreen";
import { useFullCusSearchQuery } from "./hooks/useFullCusSearchQuery";
function CustomersView({ env }: { env: AppEnv }) {
function CustomersPage() {
const { customers, totalCount, isLoading, error, refetch } =
useCusSearchQuery();
const { queryStates, setQueryStates } = useCustomersQueryStates();
const { products, isLoading: productsLoading } = useProductsQuery();
useSavedViewsQuery();
useFullCusSearchQuery();
// const { data, isLoading, error, mutate } = useAxiosPostSWR({
// url: `/v1/customers/all/search`,
// env,
@@ -217,4 +220,4 @@ function CustomersView({ env }: { env: AppEnv }) {
);
}
export default CustomersView;
export default CustomersPage;

View File

@@ -10,7 +10,7 @@ import SmallSpinner from "@/components/general/SmallSpinner";
import { useCusSearchQuery } from "../hooks/useCusSearchQuery";
export const CustomersPagination = () => {
const { isLoading, totalCount, isFetchingUncached } = useCusSearchQuery();
const { isLoading, totalCount } = useCusSearchQuery();
const { queryStates, setQueryStates } = useCustomersQueryStates();
const totalPages = Math.ceil((totalCount || 0) / 50);

View File

@@ -11,12 +11,11 @@ import {
import { ListFilter, X } from "lucide-react";
import { useState } from "react";
import { FilterStatusSubMenu } from "./FilterStatusSubMenu";
import { SavedViews } from "../../filter/SavedViews";
import { SavedViews } from "./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";
import { ProductsSubMenu } from "./ProductsSubMenu";
function CustomersFilterButton() {
const { setQueryStates } = useCustomersQueryStates();

View File

@@ -7,7 +7,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { useCustomersQueryStates } from "../hooks/useCustomersQueryStates";
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { getVersionCounts } from "@/utils/productUtils";

View File

@@ -16,7 +16,7 @@ import { getBackendErr } from "@/utils/genUtils";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { Delete } from "lucide-react";
import { useState } from "react";
import { useCustomersQueryStates } from "../hooks/useCustomersQueryStates";
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
interface SavedView {
id: string;

View File

@@ -1,45 +1,45 @@
"use client";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import LoadingScreen from "@/views/general/LoadingScreen";
import { AppEnv, CusProductStatus } from "@autumn/shared";
import { CusProductStatus } from "@autumn/shared";
import { CustomerContext } from "./CustomerContext";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { CustomerEntitlementsList } from "./entitlements/CustomerEntitlementsList";
import { getRedirectUrl, notNullish } from "@/utils/genUtils";
import { CustomerEventsList } from "./CustomerEventsList";
import { notNullish } from "@/utils/genUtils";
import { useEffect, useState } from "react";
import ErrorScreen from "@/views/general/ErrorScreen";
import { InvoicesTable } from "./InvoicesTable";
import { CustomerSidebar } from "./customer-sidebar/CustomerSidebar";
import { CustomerBreadcrumbs } from "./customer-breadcrumbs";
import { SelectEntity } from "./customer-sidebar/select-entity";
import { useParams, useSearchParams } from "react-router";
import { useCusQuery } from "./hooks/useCusQuery";
import { CustomerSidebar } from "./components/customer-sidebar/CustomerSidebar";
import { CustomerPageHeader } from "./components/customer-header/CustomerPageHeader";
import { CustomerProductList } from "./customer-product-list/CustomerProductList";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { CustomerEntitlementsList } from "./entitlements/CustomerEntitlementsList";
import { useCusReferralQuery } from "./hooks/useCusReferralQuery";
export default function CustomerView({ env }: { env: AppEnv }) {
const { customer_id } = useParams();
export default function CustomerView() {
// const { customer_id } = useParams();
const [searchParams] = useSearchParams();
const entityIdParam = searchParams.get("entity_id");
const {
data,
isLoading,
mutate: cusMutate,
} = useAxiosSWR({
url: `/customers/${customer_id}/data`,
env,
});
const { customer, isLoading: cusLoading, error, refetch } = useCusQuery();
const { data: referrals } = useAxiosSWR({
url: `/customers/${customer_id}/referrals`,
env,
});
const { data: rewardsData } = useAxiosSWR({
url: `/products/rewards`,
env,
});
useCusReferralQuery();
// const {
// data,
// isLoading,
// mutate: cusMutate,
// } = useAxiosSWR({
// url: `/customers/${customer_id}/data`,
// env,
// });
// const { data: referrals } = useAxiosSWR({
// url: `/customers/${customer_id}/referrals`,
// env,
// });
// const { data: rewardsData } = useAxiosSWR({
// url: `/products/rewards`,
// env,
// });
const [setAddCouponOpen] = useState(false);
const [entityId, setEntityId] = useState(entityIdParam);
@@ -52,24 +52,24 @@ export default function CustomerView({ env }: { env: AppEnv }) {
}
}, [entityIdParam]);
if (isLoading) return <LoadingScreen />;
if (cusLoading) return <LoadingScreen />;
if (!data) {
return (
<ErrorScreen>
<div className="text-t2 text-sm">Customer not found</div>
<Link
className="text-t3 text-xs hover:underline"
to={getRedirectUrl("/customers", env)}
>
Return
</Link>
</ErrorScreen>
);
}
// if (!data) {
// return (
// <ErrorScreen>
// <div className="text-t2 text-sm">Customer not found</div>
// <Link
// className="text-t3 text-xs hover:underline"
// to={getRedirectUrl("/customers", env)}
// >
// Return
// </Link>
// </ErrorScreen>
// );
// }
const { customer, products, invoices, coupons, discount, events, entities } =
data;
// const { customer, products, invoices, coupons, discount, events, entities } =
// data;
const showEntityView = customer.customer_products.some(
(cp: any) =>
@@ -80,63 +80,44 @@ export default function CustomerView({ env }: { env: AppEnv }) {
return (
<CustomerContext.Provider
value={{
...data,
customer,
products,
invoices,
coupons,
discount,
env,
cusMutate,
setAddCouponOpen,
referrals,
// ...data,
// customer,
// products,
// invoices,
// coupons,
// discount,
// env,
// cusMutate,
// setAddCouponOpen,
// referrals,
entityId,
setEntityId,
showEntityView,
rewards: rewardsData?.rewards,
// rewards: rewardsData?.rewards,
}}
>
<div className="flex w-full overflow-auto h-full ">
<div className="flex flex-col gap-4 w-full ">
<CustomerBreadcrumbs />
<div className="flex w-full justify-between pl-10 pr-7">
<div className="flex gap-2 w-full">
<h2 className="flex text-lg text-t1 font-medium w-full max-w-md justify-start truncate">
{customer.name ? (
<span className="truncate">{customer.name}</span>
) : customer.id ? (
<span className="truncate font-mono">{customer.id}</span>
) : (
<span className="truncate">{customer.email}</span>
)}
</h2>
</div>
{/* <EntityHeader entity={entity} /> */}
<SelectEntity entityId={entityId || ""} entities={entities} />
</div>
<CustomerPageHeader />
<div className="flex w-full !pb-[50px]">
{/* main content */}
<div className="flex flex-col gap-10 w-full text-t2 text-sm">
<div className="flex flex-col gap-2">
<CustomerProductList customer={customer} products={products} />
<CustomerProductList />
</div>
<div className="flex flex-col gap-2">
<CustomerEntitlementsList />
</div>
<CustomerEntitlementsList />
<div className="flex flex-col gap-2"></div>
<InvoicesTable />
{/* <InvoicesTable />
<CustomerEventsList
customer={customer}
events={events}
env={env}
/>
/> */}
</div>
{/* customer details */}
</div>
</div>
<div className="flex max-w-md w-1/3 shrink-1 hidden lg:block lg:min-w-xs sticky top-0">
{/* <CustomerDetails /> */}
<div className="max-w-md w-1/3 shrink-1 hidden lg:block lg:min-w-xs sticky top-0">
<CustomerSidebar />
</div>
</div>

View File

@@ -13,6 +13,7 @@ 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";
const UpdateCustomerDialog = ({
selectedCustomer,
@@ -23,17 +24,18 @@ const UpdateCustomerDialog = ({
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const { cusMutate } = useCustomerContext();
const [couponSelected, setCouponSelected] = useState<Reward | null>(null);
const [customer, setCustomer] = useState<CreateCustomer>(selectedCustomer);
// const { cusMutate } = useCustomerContext();
const { customer: curCustomer, refetch } = useCusQuery();
const [customer, setCustomer] = useState<CreateCustomer>(curCustomer);
const [loading, setLoading] = useState(false);
const env = useEnv();
const axiosInstance = useAxiosInstance({ env });
const navigate = useNavigate();
useEffect(() => {
setCustomer(selectedCustomer);
}, [open]);
// useEffect(() => {
// setCustomer(selectedCustomer);
// }, [open]);
const handleAddClicked = async () => {
try {
@@ -51,7 +53,7 @@ const UpdateCustomerDialog = ({
toast.success(`Successfully updated customer`);
setOpen(false);
await cusMutate();
await refetch();
if (customer.id != selectedCustomer.id) {
navigateTo(`/customers/${customer.id}`, navigate, env);

View File

@@ -12,20 +12,25 @@ import { getBackendErr } from "@/utils/genUtils";
import { toast } from "sonner";
import { CusService } from "@/services/customers/CusService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils";
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";
const AddCouponDialogContent = ({
setOpen,
}: {
setOpen: (open: boolean) => void;
}) => {
const { cusMutate, customer, coupons, discount } = useCustomerContext();
const { stripeCus } = useCusReferralQuery();
const { customer, refetch } = useCusQuery();
const [couponSelected, setCouponSelected] = useState<Reward | null>(null);
const [loading, setLoading] = useState(false);
const env = useEnv();
const axiosInstance = useAxiosInstance({ env });
const axiosInstance = useAxiosInstance();
const { rewards } = useRewardsQuery();
const handleAddClicked = async () => {
try {
@@ -36,7 +41,7 @@ const AddCouponDialogContent = ({
coupon_id: couponSelected!.internal_id,
});
setOpen(false);
await cusMutate();
await refetch();
toast.success("Reward added to customer");
} catch (error) {
toast.error(getBackendErr(error, "Failed to create coupon"));
@@ -45,18 +50,20 @@ const AddCouponDialogContent = ({
}
};
const existingDiscount = discount;
const existingDiscount = stripeCus?.discount;
const getExistingCoupon = () => {
if (discount) {
return coupons.find(
(c: Reward) => c.id === getOriginalCouponId(discount.coupon.id),
if (existingDiscount) {
return rewards.find(
(c: Reward) => c.id === getOriginalCouponId(existingDiscount.coupon.id)
);
} else {
return null;
}
};
if (!rewards) return null;
return (
<DialogContent className="min-w-sm max-w-md">
<DialogTitle>Add Reward</DialogTitle>
@@ -70,7 +77,7 @@ const AddCouponDialogContent = ({
<Select
value={couponSelected?.internal_id}
onValueChange={(value) => {
const coupon = coupons.find((c: Reward) => c.internal_id === value);
const coupon = rewards.find((c: Reward) => c.internal_id === value);
if (coupon) {
setCouponSelected(coupon);
}
@@ -82,8 +89,8 @@ const AddCouponDialogContent = ({
<SelectContent>
{/* If empty */}
{coupons && coupons.length > 0 ? (
coupons.map((coupon: Reward) => {
{rewards && rewards.length > 0 ? (
rewards.map((coupon: Reward) => {
if (coupon.type == RewardType.FreeProduct) return null;
return (
<SelectItem

View File

@@ -4,15 +4,18 @@ import { FullCusProduct } from "@autumn/shared";
import { ExternalLinkIcon } from "lucide-react";
import { useNavigate } from "react-router";
import { useCustomerContext } from "../CustomerContext";
import { useCusQuery } from "../hooks/useCusQuery";
export const CusProductEntityItem = ({
internalEntityId,
}: {
internalEntityId?: string | null;
}) => {
const { entities } = useCustomerContext();
// console.log("Cus product", cusProduct);
const entity = entities.find((e: any) => e.internal_id === internalEntityId);
const { customer } = useCusQuery();
const entity = customer.entities.find(
(e: any) => e.internal_id === internalEntityId
);
const navigate = useNavigate();
return internalEntityId ? (
@@ -25,7 +28,6 @@ export const CusProductEntityItem = ({
},
});
}}
// icon={<ExternalLinkIcon size={12} />}
>
<span className="truncate">
{entity?.name || entity?.id || "Unknown"}

View File

@@ -9,15 +9,18 @@ import {
import { useEnv } from "@/utils/envUtils";
import { navigateTo } from "@/utils/genUtils";
import { useNavigate, useLocation } from "react-router";
import { useCustomerContext } from "./CustomerContext";
import { useCustomerContext } from "../../CustomerContext";
import { useCusQuery } from "../../hooks/useCusQuery";
export const CustomerBreadcrumbs = () => {
const env = useEnv();
const navigate = useNavigate();
const location = useLocation();
const { customer, entities, entityId, setEntityId } = useCustomerContext();
const entity = entities.find((e: any) => e.id === entityId);
const { customer } = useCusQuery();
const { entityId, setEntityId } = useCustomerContext();
const entity = customer.entities.find((e: any) => e.id === entityId);
return (
<Breadcrumb className="text-t3 pt-6 pl-10 flex justify-start ">
@@ -39,7 +42,9 @@ export const CustomerBreadcrumbs = () => {
},
{
key: "Entities",
value: (entities || []).map((e: any) => e.id).join(", "),
value: (customer.entities || [])
.map((e: any) => e.id)
.join(", "),
},
]}
>

View File

@@ -0,0 +1,30 @@
import { useCustomerContext } from "../../CustomerContext";
import { useCusQuery } from "../../hooks/useCusQuery";
import { CustomerBreadcrumbs } from "./CustomerBreadcrumbs";
import { SelectEntity } from "./SelectEntity";
export const CustomerPageHeader = () => {
const { customer } = useCusQuery();
const { entityId, setEntityId } = useCustomerContext();
return (
<div className="flex flex-col gap-4">
<CustomerBreadcrumbs />
<div className="flex w-full justify-between pl-10 pr-7">
<div className="flex gap-2 w-full">
<h2 className="flex text-lg text-t1 font-medium w-full max-w-md justify-start truncate">
{customer.name ? (
<span className="truncate">{customer.name}</span>
) : customer.id ? (
<span className="truncate font-mono">{customer.id}</span>
) : (
<span className="truncate">{customer.email}</span>
)}
</h2>
</div>
<SelectEntity entityId={entityId || ""} entities={customer.entities} />
</div>
</div>
);
};

View File

@@ -9,37 +9,36 @@ import {
import { Entity, Feature, FeatureUsageType } from "@autumn/shared";
import { useLocation, useNavigate } from "react-router";
import { CreateEntity } from "./create-entity/CreateEntity";
import { CreateEntity } from "../customer-sidebar/create-entity/CreateEntity";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { PlusIcon } from "lucide-react";
import { useCustomerContext } from "../CustomerContext";
import { useCustomerContext } from "../../CustomerContext";
import { useCusQuery } from "../../hooks/useCusQuery";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
export const SelectEntity = ({
entityId,
entities,
}: {
entityId?: string;
entities: Entity[];
}) => {
const cusContext = useCustomerContext();
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { features } = useFeaturesQuery();
if (!entities || entities.length === 0) {
// Only show create entity flow if there are continuous use features
const hasContinuousUseFeatures = cusContext?.features?.some(
const hasContinuousUseFeatures = features?.some(
(feature: Feature) =>
feature.config?.usage_type === FeatureUsageType.Continuous
);
if (!hasContinuousUseFeatures) {
return null;
}
if (!hasContinuousUseFeatures) return null;
// Create entity flow
return (
<>
<CreateEntity open={open} setOpen={setOpen} />

View File

@@ -7,9 +7,10 @@ import { ArrowUpRightFromSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Link } from "react-router";
import { useCustomerContext } from "../CustomerContext";
import { useEnv } from "@/utils/envUtils";
import { SidebarLabel } from "@/components/general/sidebar/sidebar-label";
import { useCusQuery } from "../../hooks/useCusQuery";
export const CustomerDetails = ({
setIsModalOpen,
setModalType,
@@ -17,7 +18,7 @@ export const CustomerDetails = ({
setIsModalOpen: (isModalOpen: boolean) => void;
setModalType: (modalType: string) => void;
}) => {
const { customer } = useCustomerContext();
const { customer } = useCusQuery();
const env = useEnv();
return (

View File

@@ -1,15 +1,19 @@
import { SideAccordion } from "@/components/general/SideAccordion";
import { SidebarLabel } from "@/components/general/sidebar/sidebar-label";
import { useCustomerContext } from "../CustomerContext";
import { Entity, Feature } from "@autumn/shared";
import { getFeatureName } from "@autumn/shared";
import CopyButton from "@/components/general/CopyButton";
import { useCusQuery } from "../../hooks/useCusQuery";
import { useCustomerContext } from "../../CustomerContext";
export const CustomerEntities = () => {
const { entityId, setEntityId, entities, features } = useCustomerContext();
const { customer, features } = useCusQuery();
const { entityId } = useCustomerContext();
const entities = customer.entities;
const entity = entities.find(
(entity: Entity) =>

View File

@@ -2,14 +2,17 @@ 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 "./customer-details";
import { useCustomerContext } from "../../CustomerContext";
import UpdateCustomerDialog from "../../UpdateCustomerDialog";
import { CustomerToolbar } from "../../CustomerToolbar";
import { CustomerDetails } from "./CustomerDetails";
import { CustomerEntities } from "./CustomerEntities";
import { useCusQuery } from "../../hooks/useCusQuery";
export const CustomerSidebar = () => {
const { customer, entities } = useCustomerContext();
const { customer } = useCusQuery();
const entities = customer.entities;
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalType, setModalType] = useState("coupon");

View File

@@ -7,14 +7,15 @@ import {
DialogFooter,
DialogContent,
} from "@/components/ui/dialog";
import { useCustomerContext } from "../../CustomerContext";
import { useState } from "react";
import { EntityConfig } from "./entity-config";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { getBackendErr } from "@/utils/genUtils";
import { useEnv } from "@/utils/envUtils";
import { useState } from "react";
import { useNavigate } from "react-router";
import { EntityConfig } from "./EntityConfig";
import { getBackendErr } from "@/utils/genUtils";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useCustomerContext } from "../../../CustomerContext";
import { useCusQuery } from "../../../hooks/useCusQuery";
export const CreateEntity = ({
open,
setOpen,
@@ -23,6 +24,7 @@ export const CreateEntity = ({
setOpen: (open: boolean) => void;
}) => {
const cusContext = useCustomerContext();
const { customer, refetch } = useCusQuery();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
@@ -31,13 +33,11 @@ export const CreateEntity = ({
name: "",
});
const env = useEnv();
const axiosInstance = useAxiosInstance({ env });
const axiosInstance = useAxiosInstance();
if (!cusContext) {
return null;
}
const { customer, cusMutate } = cusContext;
if (!cusContext) return null;
// const { customer, cusMutate } = cusContext;
const handleCreateClicked = async () => {
setIsLoading(true);
@@ -51,10 +51,10 @@ export const CreateEntity = ({
name: entity.name || null,
feature_id: entity.feature_id,
customer_id: customer.id,
},
}
);
await cusMutate();
await refetch();
setOpen(false);
const params = new URLSearchParams(location.search);

View File

@@ -7,8 +7,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useCustomerContext } from "../../CustomerContext";
import { useCustomerContext } from "../../../CustomerContext";
import { Feature, FeatureUsageType } from "@autumn/shared";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
export const EntityConfig = ({
entity,
@@ -17,7 +18,8 @@ export const EntityConfig = ({
entity: any;
setEntity: (entity: any) => void;
}) => {
const { features } = useCustomerContext();
const { features } = useFeaturesQuery();
return (
<>
<div className="flex gap-2">

View File

@@ -15,11 +15,20 @@ import { ArrowUpRightFromSquare } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Link } from "react-router";
import AddCouponDialogContent from "../add-coupon/AddCouponDialogContent";
import { useCustomerContext } from "../CustomerContext";
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();
// const { discount, env } = useCustomerContext();
const env = useEnv();
// const { customer, rewards } = useCusQuery();
const { referred, redeemed, stripeCus } = useCusReferralQuery();
useRewardsQuery();
const [isModalOpen, setIsModalOpen] = useState(false);
const getDiscountText = (discount: any) => {
@@ -44,9 +53,6 @@ export const CustomerRewards = () => {
}
return coupon.name;
};
const { referrals } = useCustomerContext();
// if (!referrals) return null;
return (
<div className="flex w-full border-b mt-[2.5px] p-4">
@@ -64,15 +70,15 @@ export const CustomerRewards = () => {
variant="sidebarItem"
onClick={() => setIsModalOpen(true)}
>
{discount ? (
getDiscountText(discount)
{stripeCus?.discount ? (
getDiscountText(stripeCus?.discount)
) : (
<span className="text-t3">Add Coupon</span>
)}
</Button>
</div>
</>
{referrals?.referred.length > 0 && (
{referred?.length > 0 && (
<>
<span className="text-t3 text-xs font-medium col-span-2">
Referrals
@@ -82,7 +88,7 @@ export const CustomerRewards = () => {
<div className="col-span-6 justify-end flex">
<PopoverTrigger className="">
<Button variant="sidebarItem">
{referrals.referred.length} referred
{referred.length} referred
</Button>
</PopoverTrigger>
</div>
@@ -94,11 +100,11 @@ export const CustomerRewards = () => {
sideOffset={5}
>
<div className="flex flex-col gap-1">
{referrals.referred.map((referral: any) => (
{referred.map((referral: any) => (
<Link
to={getRedirectUrl(
`/customers/${referral.customer.id}`,
env,
env
)}
className="flex justify-between hover:bg-zinc-100 items-center"
key={referral.customer.id}
@@ -120,10 +126,7 @@ export const CustomerRewards = () => {
<div className="col-span-6 justify-end flex">
<PopoverTrigger className="">
<Button variant="sidebarItem">
{
referrals.referred.filter((r: any) => r.triggered)
.length
}{" "}
{referred.filter((r: any) => r.triggered).length}{" "}
activated
</Button>
</PopoverTrigger>
@@ -136,13 +139,13 @@ export const CustomerRewards = () => {
sideOffset={5}
>
<div className="flex flex-col gap-1">
{referrals.referred
{referred
.filter((r: any) => r.triggered)
.map((referral: any) => (
<Link
to={getRedirectUrl(
`/customers/${referral.customer.id}`,
env,
env
)}
className="flex justify-between hover:bg-zinc-100 items-center"
key={referral.customer.id}
@@ -161,7 +164,7 @@ export const CustomerRewards = () => {
</Popover>
</>
)}
{referrals?.redeemed.length > 0 && (
{redeemed?.length > 0 && (
<>
<span className="text-t3 text-xs font-medium col-span-2">
Referred by
@@ -171,13 +174,13 @@ export const CustomerRewards = () => {
<Button variant="sidebarItem">
<Link
to={getRedirectUrl(
`/customers/${referrals.redeemed[0].referral_code?.customer.id}`,
env,
`/customers/${redeemed[0].referral_code?.customer.id}`,
env
)}
className="flex items-center gap-1 truncate w-full"
>
<span className="truncate">
{referrals.redeemed[0].referral_code?.customer.name}
{redeemed[0].referral_code?.customer.name}
</span>
<div className="flex items-center justify-center">
<ArrowUpRightFromSquare
@@ -195,8 +198,8 @@ export const CustomerRewards = () => {
sideOffset={5}
>
<p>
{referrals.redeemed[0].referral_code?.customer.id}{" "}
{referrals.redeemed[0].referral_code.code}
{redeemed[0].referral_code?.customer.id}{" "}
{redeemed[0].referral_code.code}
</p>
</TooltipContent>
</Tooltip>

View File

@@ -4,15 +4,16 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { useState } from "react";
import { CusProductStatus, FullCusProduct } from "@autumn/shared";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { Dialog } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { useState } from "react";
import { toast } from "sonner";
import { useCustomerContext } from "../CustomerContext";
import { getBackendErr, notNullish } from "@/utils/genUtils";
import { getBackendErr } from "@/utils/genUtils";
import { useCusQuery } from "../hooks/useCusQuery";
export const CancelProductDialog = ({
cusProduct,
@@ -23,10 +24,10 @@ export const CancelProductDialog = ({
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const axiosInstance = useAxiosInstance();
const { customer, refetch } = useCusQuery();
const [immediateLoading, setImmediateLoading] = useState(false);
const [endOfCycleLoading, setEndOfCycleLoading] = useState(false);
const axiosInstance = useAxiosInstance();
const { cusMutate, customer, entities } = useCustomerContext();
const handleClicked = async (cancelImmediately?: boolean) => {
if (cancelImmediately) {
@@ -35,7 +36,7 @@ export const CancelProductDialog = ({
setEndOfCycleLoading(true);
}
const entity = entities.find(
const entity = customer.entities.find(
(e: any) => e.internal_id === cusProduct.internal_entity_id
);
@@ -47,7 +48,7 @@ export const CancelProductDialog = ({
cancel_immediately: cancelImmediately,
prorate: false,
});
await cusMutate();
await refetch();
setOpen(false);
toast.success("Product cancelled");
} catch (error) {

View File

@@ -20,11 +20,8 @@ export const CusProductStatusItem = ({
const trialing =
cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now();
// const canceled = notNullish(cusProduct.canceled_at);
const canceled = cusProduct.canceled;
// console.log(
// `entity ID: ${cusProduct.entity_id}, canceled: ${canceled}, product: ${cusProduct.product_id}`
// );
if (canceled) return "canceled";
if (trialing) {
@@ -64,15 +61,6 @@ export const CusProductStatusItem = ({
>
{getTitle()}
</Badge>
{/* {isCanceled && (
<Badge variant="status" className="ml-2 bg-gray-500">
canceled
</Badge>
)} */}
{/* <span className="text-t3">
ends {formatUnixToDateTime(cusProduct.trial_ends_at).date}
</span> */}
<CusProductStripeLink cusProduct={cusProduct} />
</div>

View File

@@ -12,13 +12,15 @@ import { useCustomerContext } from "../CustomerContext";
import { TransferProductDialog } from "./TransferProductDialog";
import { ArrowLeftRight, ArrowRightFromLine, Delete } from "lucide-react";
import { CancelProductDialog } from "./CancelProductDialog";
import { useCusQuery } from "../hooks/useCusQuery";
export const CusProductToolbar = ({
cusProduct,
}: {
cusProduct: FullCusProduct;
}) => {
const { showEntityView, customer } = useCustomerContext();
const { customer } = useCusQuery();
const { showEntityView } = useCustomerContext();
const [dialogOpen, setDialogOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [cancelOpen, setCancelOpen] = useState(false);
@@ -65,8 +67,6 @@ export const CusProductToolbar = ({
>
<p>Cancel</p>
<Delete width={14} className="text-t3" />
{/* <ArrowRightFromLine width={14} className="text-t3" /> */}
{/* <UpdateStatusDropdownBtn cusProduct={cusProduct} /> */}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,5 +1,10 @@
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { compareStatus, navigateTo, notNullish } from "@/utils/genUtils";
import {
compareStatus,
navigateTo,
notNullish,
pushPage,
} from "@/utils/genUtils";
import { CusProduct, CusProductStatus, FullCusProduct } from "@autumn/shared";
import { useNavigate } from "react-router";
import { useCustomerContext } from "../CustomerContext";
@@ -17,24 +22,28 @@ import { CusProductStatusItem } from "../customer-product-list/CusProductStatus"
import { CusProductEntityItem } from "../components/CusProductEntityItem";
import { CusProductToolbar } from "./CusProductToolbar";
import { MultiAttachDialog } from "../product/multi-attach/MultiAttachDialog";
import { useCusQuery } from "../hooks/useCusQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { getVersionCounts } from "@/utils/productUtils";
export const CustomerProductList = ({
customer,
products,
}: {
customer: any;
products: any;
}) => {
export const CustomerProductList = () => {
const navigate = useNavigate();
const { env, versionCounts, entities, entityId, showEntityView } =
useCustomerContext();
const { customer } = useCusQuery();
const { entityId, showEntityView } = useCustomerContext();
const { products } = useProductsQuery();
const versionCounts = getVersionCounts(products);
// const { env, versionCounts, entities, entityId, showEntityView } =
// useCustomerContext();
const [showExpired, setShowExpired] = useState(false);
const [multiAttachOpen, setMultiAttachOpen] = useState(false);
const sortedProducts = customer.products
.filter((p: CusProduct & { entitlements: any[] }) => {
const entities = customer.entities;
const sortedProducts = customer.customer_products
.filter((cp: FullCusProduct) => {
if (showExpired) {
return true;
}
@@ -42,17 +51,17 @@ export const CustomerProductList = ({
const entity = entities.find((e: any) => e.id === entityId);
const entityMatches =
entity && notNullish(p.internal_entity_id)
? p.internal_entity_id === entity.internal_id ||
p.entitlements.some(
(cusEnt: any) =>
cusEnt.entities &&
Object.keys(cusEnt.entities).includes(entity.internal_id)
entity && notNullish(cp.internal_entity_id)
? cp.internal_entity_id === entity.internal_id ||
cp.customer_entitlements.some(
(ce: any) =>
ce.entities &&
Object.keys(ce.entities).includes(entity.internal_id)
)
: true;
return (
p.status !== CusProductStatus.Expired &&
cp.status !== CusProductStatus.Expired &&
(entityId ? entityMatches : true)
);
})
@@ -93,7 +102,7 @@ export const CustomerProductList = ({
return (
<div>
<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">
Products
</h2>
@@ -112,11 +121,11 @@ export const CustomerProductList = ({
</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>
@@ -150,15 +159,26 @@ export const CustomerProductList = ({
const entity = entities.find(
(e: any) => e.internal_id === cusProduct.internal_entity_id
);
navigateTo(
`/customers/${customer.id || customer.internal_id}/${
cusProduct.product_id
}?id=${cusProduct.id}${
entity ? `&entity_id=${entity.id || entity.internal_id}` : ""
}`,
pushPage({
path: `/customers/${customer.id || customer.internal_id}/${cusProduct.product_id}`,
queryParams: {
id: cusProduct.id,
entity_id: entity
? entity.id || entity.internal_id
: undefined,
},
navigate,
env
);
});
// navigateTo(
// `/customers/${customer.id || customer.internal_id}/${
// cusProduct.product_id
// }?id=${cusProduct.id}${
// entity ? `&entity_id=${entity.id || entity.internal_id}` : ""
// }`,
// navigate
// );
}}
>
<Item className="col-span-3">

View File

@@ -20,6 +20,7 @@ import { useCustomerContext } from "../CustomerContext";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { getBackendErr } from "@/utils/genUtils";
import { useCusQuery } from "../hooks/useCusQuery";
export const TransferProductDialog = ({
cusProduct,
@@ -30,13 +31,13 @@ export const TransferProductDialog = ({
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const { entities, cusMutate } = useCustomerContext();
const { customer, refetch } = useCusQuery();
const axiosInstance = useAxiosInstance();
const [loading, setLoading] = useState(false);
const filteredEntities = entities.filter(
const [selectedEntity, setSelectedEntity] = useState<any>(null);
const filteredEntities = customer.entities.filter(
(entity: any) => entity.internal_id !== cusProduct.internal_entity_id
);
const [selectedEntity, setSelectedEntity] = useState<any>(null);
useEffect(() => {
if (open) {
@@ -53,7 +54,7 @@ export const TransferProductDialog = ({
setLoading(true);
try {
const fromEntity = entities.find(
const fromEntity = customer.entities.find(
(e: any) => e.internal_id === cusProduct.internal_entity_id
);
await axiosInstance.post(
@@ -66,7 +67,7 @@ export const TransferProductDialog = ({
// customer_product_id: cusProduct.id,
}
);
await cusMutate();
await refetch();
toast.success("Product transferred successfully");
setOpen(false);
} catch (error) {
@@ -93,7 +94,9 @@ export const TransferProductDialog = ({
<Select
value={selectedEntity?.id}
onValueChange={(value) => {
setSelectedEntity(entities.find((e: any) => e.id === value));
setSelectedEntity(
customer.entities.find((e: any) => e.id === value)
);
}}
disabled={filteredEntities.length == 0}
>

View File

@@ -2,6 +2,7 @@ import {
AllowanceType,
FeatureType,
FullCusEntWithFullCusProduct,
FullCusProduct,
FullCustomerEntitlement,
} from "@autumn/shared";
@@ -22,6 +23,8 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CusProductEntityItem } from "../components/CusProductEntityItem";
import { CusEntBalance } from "./CusEntBalance";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useCusQuery } from "../hooks/useCusQuery";
export const CustomerEntitlementsList = () => {
const [featureType, setFeatureType] = useState<FeatureType>(
@@ -29,8 +32,8 @@ export const CustomerEntitlementsList = () => {
);
const [showExpired, setShowExpired] = useState(false);
const { products, customer, entities, entityId, showEntityView } =
useCustomerContext();
const { entityId, showEntityView } = useCustomerContext();
const { customer, products, features, entities } = useCusQuery();
const [selectedCusEntitlement, setSelectedCusEntitlement] =
useState<FullCustomerEntitlement | null>(null);
@@ -67,11 +70,10 @@ export const CustomerEntitlementsList = () => {
if (entityId) {
entityMatch = false;
const cusProduct = customer.products.find(
const cusProduct = customer.customer_products.find(
(p: any) => p.id === cusEnt.customer_product_id
);
// 1. Product match
const productAttachedToEntity =
cusProduct?.internal_entity_id === entity?.internal_id;
@@ -154,7 +156,7 @@ export const CustomerEntitlementsList = () => {
return (
<div>
<div className="flex items-center grid grid-cols-10 gap-8 justify-between border-y bg-stone-100 px-10 h-10">
<div className="items-center grid grid-cols-10 gap-8 justify-between border-y bg-stone-100 px-10 h-10">
<h2 className="text-sm text-t2 font-medium col-span-2 flex whitespace-nowrap">
Available Features
</h2>
@@ -231,7 +233,6 @@ export const CustomerEntitlementsList = () => {
{filteredEntitlements.map((cusEnt: FullCusEntWithFullCusProduct) => {
const entitlement = cusEnt.entitlement;
const allowanceType = entitlement.allowance_type;
return (
<Row
@@ -265,8 +266,8 @@ export const CustomerEntitlementsList = () => {
<div className="flex items-center gap-2 max-w-[150px] truncate text-t3">
{/* {getProductName(cusEnt)} */}
{cusEnt.customer_product.product.name}
{customer.products.find(
(p: any) => p.id === cusEnt.customer_product_id
{customer.customer_products.find(
(cp: FullCusProduct) => cp.id === cusEnt.customer_product_id
)?.status === "expired" && (
<Badge variant="status" className="bg-black">
expired

View File

@@ -6,7 +6,7 @@ import {
} from "@/components/ui/dialog";
import { DialogContent } from "@/components/ui/dialog";
import { Dialog } from "@/components/ui/dialog";
import { FullCustomerEntitlement } from "@autumn/shared";
import { FullCusProduct, FullCustomerEntitlement } from "@autumn/shared";
import { useEffect, useState } from "react";
import { useCustomerContext } from "../CustomerContext";
import FieldLabel from "@/components/general/modal-components/FieldLabel";
@@ -21,6 +21,7 @@ import { toast } from "sonner";
import { getBackendErr, notNullish } from "@/utils/genUtils";
import CopyButton from "@/components/general/CopyButton";
import { AlertCircle, Info, InfoIcon } from "lucide-react";
import { useCusQuery } from "../hooks/useCusQuery";
function UpdateCusEntitlement({
selectedCusEntitlement,
@@ -29,13 +30,13 @@ function UpdateCusEntitlement({
selectedCusEntitlement: FullCustomerEntitlement | null;
setSelectedCusEntitlement: (cusEnt: FullCustomerEntitlement | null) => void;
}) {
// Get customer product
const { customer, env, cusMutate, entityId } = useCustomerContext();
const axiosInstance = useAxiosInstance({ env });
const { customer, refetch } = useCusQuery();
const { entityId } = useCustomerContext();
// const { customer, env, cusMutate, entityId } = useCustomerContext();
const cusEnt = selectedCusEntitlement;
const [updateLoading, setUpdateLoading] = useState(false);
const cusEnt = selectedCusEntitlement;
const axiosInstance = useAxiosInstance();
const [updateFields, setUpdateFields] = useState<any>({
balance:
@@ -46,8 +47,8 @@ function UpdateCusEntitlement({
});
const getCusProduct = (cusEnt: FullCustomerEntitlement) => {
const cusProduct = customer.products.find(
(p: any) => p.id === cusEnt.customer_product_id
const cusProduct = customer.customer_products.find(
(cp: FullCusProduct) => cp.id === cusEnt.customer_product_id
);
return cusProduct;
};
@@ -95,7 +96,7 @@ function UpdateCusEntitlement({
}
);
toast.success("Entitlement updated successfully");
await cusMutate();
await refetch();
setSelectedCusEntitlement(null);
} catch (error) {
toast.error(getBackendErr(error, "Failed to update entitlement"));
@@ -106,8 +107,6 @@ function UpdateCusEntitlement({
const cusPrice = cusProduct?.customer_prices.find(
(cp: any) => cp.price.entitlement_id === cusEnt?.entitlement.id
);
console.log("Cus price:", cusPrice);
console.log("Cus product:", cusProduct);
return (
<Dialog
@@ -177,17 +176,3 @@ function UpdateCusEntitlement({
}
export default UpdateCusEntitlement;
// const DateInput = ({
// value,
// onChange,
// }: {
// value: string;
// onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
// }) => {
// const [date, setDate] = React.useState<Date>();
// return (
// );
// };

View File

@@ -0,0 +1,47 @@
import { useQueryClient } from "@tanstack/react-query";
import { FullCustomer } from "@autumn/shared";
export const useCachedCustomer = (customerId: string | undefined) => {
const queryClient = useQueryClient();
const getCachedCustomer = (): FullCustomer | null => {
if (!customerId) return null;
// Check all cached full customers queries
const queryCache = queryClient.getQueryCache();
const fullCustomersQueries = queryCache.findAll({
queryKey: ["full_customers"],
});
// Sort by most recently updated first to get the freshest data
const sortedQueries = fullCustomersQueries.sort((a, b) => {
const aTime = a.state.dataUpdatedAt || 0;
const bTime = b.state.dataUpdatedAt || 0;
return bTime - aTime;
});
for (const query of sortedQueries) {
// Only use data that's not stale and has been successfully fetched
if (query.state.status === "success" && query.state.data) {
const cachedData = query.state.data as
| { fullCustomers: FullCustomer[] }
| undefined;
if (cachedData?.fullCustomers) {
const cachedCustomer = cachedData.fullCustomers.find(
(customer) =>
customer.id === customerId || customer.internal_id === customerId
);
if (cachedCustomer) {
return cachedCustomer;
}
}
}
}
return null;
};
return { getCachedCustomer };
};

View File

@@ -0,0 +1,52 @@
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router";
import { useCachedCustomer } from "./useCachedCustomer";
import { useMemo } from "react";
export const useCusQuery = () => {
const { customer_id } = useParams();
const axiosInstance = useAxiosInstance();
const { getCachedCustomer } = useCachedCustomer(customer_id);
const cachedCustomer = useMemo(getCachedCustomer, [getCachedCustomer]);
const fetcher = async () => {
const { data } = await axiosInstance.get(`/customers/${customer_id}`);
return data;
};
const {
data,
isLoading: customerLoading,
error,
refetch,
} = useQuery({
queryKey: ["customer", customer_id],
queryFn: fetcher,
});
const { products, isLoading: productsLoading } = useProductsQuery();
const { features, isLoading: featuresLoading } = useFeaturesQuery();
const customer = cachedCustomer || data?.customer;
const cusWithCacheLoading = cachedCustomer ? false : customerLoading;
return {
customer: customer,
entities: customer?.entities,
products,
features,
isLoading: cusWithCacheLoading || productsLoading || featuresLoading,
error,
refetch,
};
// const { data, isLoading, error } = useQuery({
// queryKey: ["customer", customerId],
// queryFn: fetcher,
// });
};

View File

@@ -0,0 +1,37 @@
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router";
export const useCusReferralQuery = () => {
const { customer_id } = useParams();
const axiosInstance = useAxiosInstance();
const referralFetcher = async () => {
console.log("referralFetcher");
const { data } = await axiosInstance.get(
`/customers/${customer_id}/referrals`
);
return data;
};
const {
data: cusRewardData,
isLoading: cusRewardLoading,
error: cusRewardError,
refetch: cusRewardRefetch,
} = useQuery({
queryKey: ["customer_referrals", customer_id],
queryFn: referralFetcher,
});
return {
stripeCus: cusRewardData?.stripeCus,
redeemed: cusRewardData?.redeemed,
referred: cusRewardData?.referred,
cusRewardLoading,
cusRewardError,
cusRewardRefetch,
};
};

View File

@@ -4,6 +4,7 @@ import { useCustomersQueryStates } from "./useCustomersQueryStates";
import {
CusProductSchema,
CustomerSchema,
FullCustomer,
ProductSchema,
} from "@autumn/shared";
import { z } from "zod";
@@ -56,7 +57,6 @@ export const useCusSearchQuery = () => {
queryStates.q,
],
queryFn: fetcher,
placeholderData: keepPreviousData,
});

View File

@@ -0,0 +1,42 @@
import { FullCustomer } from "@autumn/shared";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useCustomersQueryStates } from "./useCustomersQueryStates";
import { useAxiosInstance } from "@/services/useAxiosInstance";
export const useFullCusSearchQuery = () => {
const { queryStates } = useCustomersQueryStates();
const axiosInstance = useAxiosInstance();
const { data: fullCustomersData } = 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);
const { data } = await axiosInstance.post(
`/customers/all/full_customers`,
{
search: queryStates.q,
page_size: 50,
page: queryStates.page,
filters: {
status: queryStates.status,
version: queryStates.version,
none: queryStates.none,
},
}
);
console.log("data", data);
return data;
},
placeholderData: keepPreviousData,
});
};

View File

@@ -36,7 +36,7 @@ export const NavButton = ({
isGroup?: boolean;
}) => {
// Get window path
env = useEnv();
const finalEnv = useEnv();
const tab = useTab();
const { expanded } = useSidebarContext();
const [searchParams] = useSearchParams();

View File

@@ -3,7 +3,7 @@ import { useProductContext } from "./ProductContext";
import { useNavigate } from "react-router";
import { useEnv } from "@/utils/envUtils";
import { ProductItemTable } from "./product-item/ProductItemTable";
import { SelectEntity } from "@/views/customers/customer/customer-sidebar/select-entity";
import { SelectEntity } from "@/views/customers/customer/components/customer-header/SelectEntity";
export const ManageProduct = ({
hideAdminHover = false,