Skip to content

Commit

Permalink
New statistic - Customer Renetion Rate
Browse files Browse the repository at this point in the history
  • Loading branch information
radoslaw-sz committed May 19, 2024
1 parent 3029a79 commit 264b861
Show file tree
Hide file tree
Showing 11 changed files with 293 additions and 3 deletions.
9 changes: 9 additions & 0 deletions src/api/admin/customers-analytics/[kind]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ export const GET = async (
dateRangeToCompareTo ? new Date(Number(dateRangeToCompareTo)) : undefined,
);
break;
case 'retention-customer-rate':
result = await customersAnalyticsService.getRetentionRate(
orderStatuses,
dateRangeFrom ? new Date(Number(dateRangeFrom)) : undefined,
dateRangeTo ? new Date(Number(dateRangeTo)) : undefined,
dateRangeFromCompareTo ? new Date(Number(dateRangeFromCompareTo)) : undefined,
dateRangeToCompareTo ? new Date(Number(dateRangeToCompareTo)) : undefined,
);
break;
}
res.status(200).json({
analytics: result
Expand Down
113 changes: 113 additions & 0 deletions src/services/customersAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ type CustomersOrdersDistribution = {
previous: Distributions
}

type CustomersRetentionRate = {
dateRangeFrom?: number
dateRangeTo?: number,
dateRangeFromCompareTo?: number,
dateRangeToCompareTo?: number,
current: number,
previous: number
}

export default class CustomersAnalyticsService extends TransactionBaseService {

private readonly customerService: CustomerService;
Expand Down Expand Up @@ -395,12 +404,19 @@ export default class CustomersAnalyticsService extends TransactionBaseService {
.orderBy('date', 'ASC')
.getRawMany();


const beforeCustomers = await this.activeManager_.getRepository(Customer)
.createQueryBuilder('customer')
.select(`COUNT(*) AS cumulative_count`)
.where(`customer.created_at < :dateRangeFromCompareTo`, { dateRangeFromCompareTo })
.getRawOne();

// Start from 0 as customer count will be added from beforeCustomers, so first entry will include past count
afterCustomers.push({
date: dateRangeFromCompareTo,
cumulative_count: '0'
});

for (const afterCustomer of afterCustomers) {
afterCustomer.cumulative_count = parseInt(afterCustomer.cumulative_count);
afterCustomer.cumulative_count += parseInt(beforeCustomers.cumulative_count);
Expand Down Expand Up @@ -490,4 +506,101 @@ export default class CustomersAnalyticsService extends TransactionBaseService {
previous: []
}
}

// Customers which purchased something in the time period / Total customers
async getRetentionRate(orderStatuses: OrderStatus[], from?: Date, to?: Date, dateRangeFromCompareTo?: Date, dateRangeToCompareTo?: Date) : Promise<CustomersRetentionRate> {

// Use the same query like finding for Orders, but include Customers
let startQueryFrom: Date | undefined;
const orderStatusesAsStrings = Object.values(orderStatuses);
if (orderStatusesAsStrings.length) {

const totalNumberCustomers = await this.customerService.count();

if (!dateRangeFromCompareTo) {
if (from) {
startQueryFrom = from;
} else {
// All time
const lastOrder = await this.activeManager_.getRepository(Order).find({
skip: 0,
take: 1,
order: { created_at: "ASC"},
where: { status: In(orderStatusesAsStrings) }
})

if (lastOrder.length > 0) {
startQueryFrom = lastOrder[0].created_at;
}
}
} else {
startQueryFrom = dateRangeFromCompareTo;
}
const orders: Order[] = await this.orderService.list({
created_at: startQueryFrom ? { gte: startQueryFrom } : undefined,
status: In(orderStatusesAsStrings)
}, {
select: [
"id",
"created_at",
"updated_at",
"customer_id",
],
order: { created_at: "DESC" },
})

if (dateRangeFromCompareTo && from && to && dateRangeToCompareTo) {
const previousOrders = orders.filter(order => order.created_at < from);
const currentOrders = orders.filter(order => order.created_at >= from);

const previousCustomersSet: Set<string> = previousOrders.reduce((acc, order) => {
acc.add(order.customer_id);
return acc;
}, new Set<string>());

const currentCustomersSet: Set<string> = currentOrders.reduce((acc, order) => {
acc.add(order.customer_id);
return acc;
}, new Set<string>());

const retentionCustomerRatePreviousValue = previousCustomersSet.size * 100 / totalNumberCustomers;
const retentionCustomerRateCurrentValue = currentCustomersSet.size * 100 / totalNumberCustomers;

return {
dateRangeFrom: from.getTime(),
dateRangeTo: to.getTime(),
dateRangeFromCompareTo: dateRangeFromCompareTo.getTime(),
dateRangeToCompareTo: dateRangeToCompareTo.getTime(),
current: retentionCustomerRateCurrentValue,
previous: retentionCustomerRatePreviousValue
}
}

if (startQueryFrom) {
const currentCustomersSet: Set<string> = orders.reduce((acc, order) => {
acc.add(order.customer_id);
return acc;
}, new Set<string>());

const retentionCustomerRateCurrentValue = currentCustomersSet.size * 100 / totalNumberCustomers;

return {
dateRangeFrom: startQueryFrom.getTime(),
dateRangeTo: to ? to.getTime(): new Date(Date.now()).getTime(),
dateRangeFromCompareTo: undefined,
dateRangeToCompareTo: undefined,
current: retentionCustomerRateCurrentValue,
previous: undefined
}
}
}
return {
dateRangeFrom: undefined,
dateRangeTo: undefined,
dateRangeFromCompareTo: undefined,
dateRangeToCompareTo: undefined,
current: undefined,
previous: undefined
}
}
}
2 changes: 1 addition & 1 deletion src/ui-components/common/popularity-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export const PopularityTable = ({valueColumnName, tableRows, enableComparing} :
</Grid>
</Grid>
{tableRows.length > 0 ? tableRows.map(tableRow => (
<Grid item xs={12}>
<Grid item xs={12} key={tableRow.name}>
<Grid container justifyContent={'space-between'}>
<Grid item>
<Text>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright 2024 RSC-Labs, https://rsoftcon.com/
*
* MIT License
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Heading } from "@medusajs/ui";
import { Grid } from "@mui/material";
import { PercentageComparison } from "../../common/percentage-comparison";
import { IconComparison } from "../../common/icon-comparison";
import { CustomersRetentionCustomerRateResponse } from "../types"

export const RetentionCustomerRateNumber = ({retentionCustomerRateResponse, compareEnabled} : {retentionCustomerRateResponse: CustomersRetentionCustomerRateResponse, compareEnabled?: boolean}) => {
const currentPercentage: number | undefined =
retentionCustomerRateResponse.analytics.current !== undefined ?
parseInt(retentionCustomerRateResponse.analytics.current) : undefined;
const previousPercentage: number | undefined =
retentionCustomerRateResponse.analytics.previous !== undefined ?
parseInt(retentionCustomerRateResponse.analytics.previous) : undefined;

return (
<Grid container alignItems={'center'} spacing={2}>
<Grid item>
{currentPercentage !== undefined ?
<Heading level="h1">
{`${currentPercentage}%`}
</Heading> :
<Heading level="h3">
{`No orders or customers`}
</Heading>
}
</Grid>
{compareEnabled && retentionCustomerRateResponse.analytics.dateRangeFromCompareTo && currentPercentage !== undefined &&
<Grid item>
<Grid container alignItems={'center'}>
<Grid item>
<IconComparison current={currentPercentage} previous={previousPercentage ? previousPercentage : undefined}/>
</Grid>
{previousPercentage !== undefined && <Grid item>
<PercentageComparison current={currentPercentage.toString()} previous={previousPercentage.toString()} label="%"/>
</Grid>}
</Grid>
</Grid>
}
</Grid>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright 2024 RSC-Labs, https://rsoftcon.com/
*
* MIT License
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Heading, Alert } from "@medusajs/ui";
import { ShoppingBag } from "@medusajs/icons";
import { CircularProgress, Grid } from "@mui/material";
import { useAdminCustomQuery } from "medusa-react"
import { DateRange } from "../../utils/types";
import { CustomersRetentionCustomerRateResponse } from "../types";
import { RetentionCustomerRateNumber } from "./customers-retention-customer-rate-number";
import { OrderStatus } from "../../utils/types";

type AdminCustomersStatisticsQuery = {
orderStatuses: string[],
dateRangeFrom: number,
dateRangeTo: number,
dateRangeFromCompareTo?: number,
dateRangeToCompareTo?: number,
}

const RetentionCustomerRateDetails = ({orderStatuses, dateRange, dateRangeCompareTo, compareEnabled} :
{orderStatuses: OrderStatus[], dateRange?: DateRange, dateRangeCompareTo?: DateRange, compareEnabled?: boolean}) => {
const { data, isLoading, isError, error } = useAdminCustomQuery<
AdminCustomersStatisticsQuery,
CustomersRetentionCustomerRateResponse
>(
`/customers-analytics/retention-customer-rate`,
[orderStatuses, dateRange, dateRangeCompareTo],
{
orderStatuses: Object.values(orderStatuses),
dateRangeFrom: dateRange ? dateRange.from.getTime() : undefined,
dateRangeTo: dateRange ? dateRange.to.getTime() : undefined,
dateRangeFromCompareTo: dateRangeCompareTo ? dateRangeCompareTo.from.getTime() : undefined,
dateRangeToCompareTo: dateRangeCompareTo ? dateRangeCompareTo.to.getTime() : undefined,
}
)

if (isLoading) {
return <CircularProgress size={12}/>
}

if (isError) {
const trueError = error as any;
const errorText = `Error when loading data. It shouldn't have happened - please raise an issue. For developer: ${trueError?.response?.data?.message}`
return <Alert variant="error">{errorText}</Alert>
}

if (data.analytics == undefined) {
return <Heading level="h3">Cannot get orders or customers</Heading>
}

if (data.analytics.dateRangeFrom) {
return (
<Grid container>
<Grid item xs={12} md={12}>
<RetentionCustomerRateNumber retentionCustomerRateResponse={data} compareEnabled={compareEnabled}/>
</Grid>
</Grid>
)
} else {
return <Heading level="h3">No orders or customers</Heading>
}
}

export const CustomersRetentionCustomerRate = ({orderStatuses, dateRange, dateRangeCompareTo, compareEnabled} :
{orderStatuses: OrderStatus[], dateRange?: DateRange, dateRangeCompareTo?: DateRange, compareEnabled: boolean}) => {
return (
<Grid container paddingBottom={2} spacing={3}>
<Grid item xs={12} md={12}>
<Grid container spacing={2}>
<Grid item>
<ShoppingBag/>
</Grid>
<Grid item>
<Heading level="h2">
Retention customer rate
</Heading>
</Grid>
</Grid>
</Grid>
<Grid item xs={12} md={12}>
<RetentionCustomerRateDetails orderStatuses={orderStatuses} dateRange={dateRange} dateRangeCompareTo={dateRangeCompareTo} compareEnabled={compareEnabled}/>
</Grid>
</Grid>
)
}
11 changes: 11 additions & 0 deletions src/ui-components/customers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,15 @@ export type CustomersRepeatCustomerRateResponse = {
current: Distributions,
previous: Distributions
}
}

export type CustomersRetentionCustomerRateResponse = {
analytics: {
dateRangeFrom: number
dateRangeTo: number,
dateRangeFromCompareTo?: number,
dateRangeToCompareTo?: number,
current?: string,
previous?: string
}
}
1 change: 1 addition & 0 deletions src/ui-components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export { RegionsPopularityCard } from './sales/regions-popularity-card'

export { CustomersOverviewCard } from './customers/customers-overview-card'
export { CustomersRepeatCustomerRate } from './customers/repeat-customer-rate/customers-repeat-customer-rate';
export { CustomersRetentionCustomerRate } from './customers/retention-customer-rate/customers-retention-customer-rate';
export { CumulativeCustomersCard } from './customers/cumulative-history/cumulative-customers-card';

export { VariantsTopByCountCard } from './products/variants-top-by-count';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function transformToVariantTopTable(result: OutOfTheStockVariantsCountResult): O

result.current.forEach(currentItem => {
currentMap.set(currentItem.variantId, {
variantId: currentItem.variantId,
productId: currentItem.productId,
productTitle: currentItem.productTitle,
variantTitle: currentItem.variantTitle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Box, Divider, Grid } from "@mui/material";
import { Link } from "react-router-dom"

export type OutOfTheStockVariantsTableRow = {
variantId: string,
productId: string,
productTitle: string,
variantTitle: string,
Expand All @@ -37,7 +38,7 @@ export const OutOfTheStockVariantsTable = ({tableRows} : {tableRows: OutOfTheSto
</Grid>
</Grid>
{tableRows.length > 0 ? tableRows.map(tableRow => (
<Grid item xs={12}>
<Grid item xs={12} key={tableRow.variantId}>
<Grid container justifyContent={'space-between'}>
<Grid item>
<Link to={`../products/${tableRow.productId}`}>
Expand Down
2 changes: 1 addition & 1 deletion src/ui-components/products/variants-top-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const VariantsTopTable = ({tableRows} : {tableRows: VariantsTopTableRow[]
</Grid>
</Grid>
{tableRows.length > 0 ? tableRows.map(tableRow => (
<Grid item xs={12}>
<Grid item xs={12} key={tableRow.productId}>
<Grid container justifyContent={'space-between'}>
<Grid item>
<Link to={`../products/${tableRow.productId}`}>
Expand Down
6 changes: 6 additions & 0 deletions src/ui-components/tabs/customers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
CustomersOverviewCard,
CustomersRepeatCustomerRate,
CumulativeCustomersCard,
CustomersRetentionCustomerRate,
OrderStatus,
DateRange
} from '..';
Expand All @@ -39,6 +40,11 @@ const CustomersTab = ({orderStatuses, dateRange, dateRangeCompareTo, compareEnab
<CustomersRepeatCustomerRate orderStatuses={orderStatuses} dateRange={dateRange} dateRangeCompareTo={dateRangeCompareTo} compareEnabled={compareEnabled}/>
</Container>
</Grid>
<Grid item xs={6} md={6} xl={6}>
<Container>
<CustomersRetentionCustomerRate orderStatuses={orderStatuses} dateRange={dateRange} dateRangeCompareTo={dateRangeCompareTo} compareEnabled={compareEnabled}/>
</Container>
</Grid>
</Grid>
)
}
Expand Down

0 comments on commit 264b861

Please sign in to comment.