diff --git a/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/[id]/ClientPage.tsx b/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/[id]/ClientPage.tsx
index 3d74b6a38f..b40d43ab67 100644
--- a/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/[id]/ClientPage.tsx
+++ b/clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/[id]/ClientPage.tsx
@@ -1,6 +1,7 @@
'use client'
import { CustomerContextView } from '@/components/Customer/CustomerContextView'
+import { CustomerUsageView } from '@/components/Customer/CustomerUsageView'
import { DashboardBody } from '@/components/Layout/DashboardLayout'
import MetricChart from '@/components/Metrics/MetricChart'
import { InlineModal } from '@/components/Modal/InlineModal'
@@ -8,6 +9,7 @@ import { useModal } from '@/components/Modal/useModal'
import AmountLabel from '@/components/Shared/AmountLabel'
import { SubscriptionModal } from '@/components/Subscriptions/SubscriptionModal'
import { SubscriptionStatusLabel } from '@/components/Subscriptions/utils'
+import { usePostHog } from '@/hooks/posthog'
import { useListSubscriptions, useMetrics } from '@/hooks/queries'
import { useOrders } from '@/hooks/queries/orders'
import { Customer, Organization } from '@polar-sh/api'
@@ -15,6 +17,12 @@ import Button from '@polar-sh/ui/components/atoms/Button'
import { DataTable } from '@polar-sh/ui/components/atoms/DataTable'
import FormattedDateTime from '@polar-sh/ui/components/atoms/FormattedDateTime'
import ShadowBox from '@polar-sh/ui/components/atoms/ShadowBox'
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from '@polar-sh/ui/components/atoms/Tabs'
import { RowSelectionState } from '@tanstack/react-table'
import Link from 'next/link'
import React, { useEffect, useState } from 'react'
@@ -77,6 +85,8 @@ const ClientPage: React.FC = ({ organization, customer }) => {
}
}, [selectedSubscription, showSubscriptionModal, hideSubscriptionModal])
+ const { isFeatureEnabled } = usePostHog()
+
return (
= ({ organization, customer }) => {
}
contextView={}
- className="gap-12"
>
- {metrics.data?.metrics && (
-
-
-
-
Revenue
-
- Since Customer first was seen
-
+
+
+ Overview
+ {isFeatureEnabled('usage_based_billing') && (
+ Usage
+ )}
+
+
+ {metrics.data?.metrics && (
+
+
+
+
Revenue
+
+ Since Customer first was seen
+
+
+
+
+
+
+
-
-
+
+
Subscriptions
+
(
+ {original.product.name}
+ ),
+ },
+ {
+ header: 'Status',
+ accessorKey: 'status',
+ cell: ({ row: { original } }) => (
+
+ ),
+ },
+ {
+ header: 'Amount',
+ accessorKey: 'amount',
+ cell: ({ row: { original } }) =>
+ original.amount && original.currency ? (
+
+ ) : (
+ —
+ ),
+ },
+ ]}
+ isLoading={subscriptionsLoading}
+ className="text-sm"
+ onRowSelectionChange={(row) => {
+ setSelectedSubscriptionState(row)
+ }}
+ rowSelection={selectedSubscriptionState}
+ getRowId={(row) => row.id.toString()}
+ enableRowSelection
/>
-
-
-
-
- )}
-
-
-
Subscriptions
-
(
- {original.product.name}
- ),
- },
- {
- header: 'Status',
- accessorKey: 'status',
- cell: ({ row: { original } }) => (
-
- ),
- },
- {
- header: 'Amount',
- accessorKey: 'amount',
- cell: ({ row: { original } }) =>
- original.amount && original.currency ? (
-
- ) : (
- —
- ),
- },
- ]}
- isLoading={subscriptionsLoading}
- className="text-sm"
- onRowSelectionChange={(row) => {
- setSelectedSubscriptionState(row)
- }}
- rowSelection={selectedSubscriptionState}
- getRowId={(row) => row.id.toString()}
- enableRowSelection
- />
- {
+ setSelectedSubscriptionState({})
+ hideSubscriptionModal()
+ }}
/>
- }
- isShown={isSubscriptionModalShown}
- hide={() => {
- setSelectedSubscriptionState({})
- hideSubscriptionModal()
- }}
- />
-
-
-
Orders
+
+
+
Orders
-
(
-
- {original.product.name}
-
- ),
- },
- {
- header: 'Created At',
- accessorKey: 'created_at',
- cell: ({ row: { original } }) => (
-
-
-
- ),
- },
- {
- header: 'Amount',
- accessorKey: 'amount',
- cell: ({ row: { original } }) => (
-
- ),
- },
- {
- header: '',
- accessorKey: 'action',
- cell: ({ row: { original } }) => (
-
-
-
-
-
- ),
- },
- ]}
- isLoading={ordersLoading}
- className="text-sm"
- />
-
-
+
(
+
+ {original.product.name}
+
+ ),
+ },
+ {
+ header: 'Created At',
+ accessorKey: 'created_at',
+ cell: ({ row: { original } }) => (
+
+
+
+ ),
+ },
+ {
+ header: 'Amount',
+ accessorKey: 'amount',
+ cell: ({ row: { original } }) => (
+
+ ),
+ },
+ {
+ header: '',
+ accessorKey: 'action',
+ cell: ({ row: { original } }) => (
+
+
+
+
+
+ ),
+ },
+ ]}
+ isLoading={ordersLoading}
+ className="text-sm"
+ />
+
+
+
+ {isFeatureEnabled('usage_based_billing') && (
+
+ )}
+
)
}
diff --git a/clients/apps/web/src/components/Benefit/BenefitForm.tsx b/clients/apps/web/src/components/Benefit/BenefitForm.tsx
index a609806aec..3d5cf159bd 100644
--- a/clients/apps/web/src/components/Benefit/BenefitForm.tsx
+++ b/clients/apps/web/src/components/Benefit/BenefitForm.tsx
@@ -1,3 +1,4 @@
+import { usePostHog } from '@/hooks/posthog'
import { useDiscordGuild } from '@/hooks/queries'
import { getBotDiscordAuthorizeURL } from '@/utils/auth'
import {
@@ -35,6 +36,7 @@ import { useFormContext } from 'react-hook-form'
import { DownloadablesBenefitForm } from './Downloadables/BenefitForm'
import { GitHubRepositoryBenefitForm } from './GitHubRepositoryBenefitForm'
import { LicenseKeysBenefitForm } from './LicenseKeys/BenefitForm'
+import { UsageBenefitForm } from './Usage/UsageBenefitForm'
import { benefitsDisplayNames } from './utils'
export const NewBenefitForm = ({
@@ -62,7 +64,7 @@ export const UpdateBenefitForm = ({
interface BenefitFormProps {
organization: Organization
- type: BenefitType
+ type: BenefitType | 'usage'
update?: boolean
}
@@ -108,6 +110,7 @@ export const BenefitForm = ({
/>
{!update ?
: null}
+ {type === 'usage' &&
}
{type === 'custom' &&
}
{type === 'ads' &&
}
{type === 'discord' &&
}
@@ -333,6 +336,9 @@ export const DiscordBenefitForm = () => {
const BenefitTypeSelect = ({}) => {
const { control } = useFormContext
()
+
+ const { isFeatureEnabled } = usePostHog()
+
return (
{
{benefitsDisplayNames[value]}
))}
+ {isFeatureEnabled('usage_benefits') && (
+
+ Usage
+
+ )}
diff --git a/clients/apps/web/src/components/Benefit/Usage/UsageBenefitForm.tsx b/clients/apps/web/src/components/Benefit/Usage/UsageBenefitForm.tsx
new file mode 100644
index 0000000000..1fd67e9576
--- /dev/null
+++ b/clients/apps/web/src/components/Benefit/Usage/UsageBenefitForm.tsx
@@ -0,0 +1,155 @@
+'use client'
+
+import { useMeters } from '@/hooks/queries/meters'
+import { MaintainerOrganizationContext } from '@/providers/maintainerOrganization'
+import Input from '@polar-sh/ui/components/atoms/Input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+} from '@polar-sh/ui/components/atoms/Select'
+import { Tabs, TabsList, TabsTrigger } from '@polar-sh/ui/components/atoms/Tabs'
+import {
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@polar-sh/ui/components/ui/form'
+import { useContext } from 'react'
+import { useFormContext } from 'react-hook-form'
+
+interface UsageBenefitCreate {
+ properties: {
+ meterId: string
+ credits: number
+ aggregation: 'sum' | 'count'
+ overage:
+ | {
+ enabled: boolean
+ price: {
+ currency: 'USD'
+ amount: number
+ }
+ }
+ | undefined
+ }
+}
+
+export const UsageBenefitForm = ({ update: _ }: { update: boolean }) => {
+ const { control, watch } = useFormContext()
+
+ const { organization } = useContext(MaintainerOrganizationContext)
+ const { data: meters } = useMeters(organization.id)
+
+ const meterId = watch('properties.meterId', undefined)
+ const meter = meters?.items.find((meter) => meter.id === meterId)
+
+ const aggregationType = watch('properties.aggregation', 'sum')
+
+ return (
+ <>
+ {
+ return (
+
+
+ Meter
+
+ Meter which will be used to track usage
+
+
+
+
+
+
+
+ )
+ }}
+ />
+
+ {
+ return (
+
+
+ Credits
+
+ A preset amount of units that will be deducted from the total
+ usage every period
+
+
+
+
+
+
+
+ )
+ }}
+ />
+
+ {
+ const triggerClassName =
+ 'dark:data-[state=active]:bg-polar-900 data-[state=active]:bg-white w-full'
+
+ return (
+
+
+ Aggregation Type
+
+ Aggregation type to use when calculating usage
+
+
+
+
+
+
+ Sum
+
+
+ Count
+
+
+
+
+
+ )
+ }}
+ />
+ >
+ )
+}
diff --git a/clients/apps/web/src/components/Benefit/utils.tsx b/clients/apps/web/src/components/Benefit/utils.tsx
index 04739fe301..6b9b9e01d9 100644
--- a/clients/apps/web/src/components/Benefit/utils.tsx
+++ b/clients/apps/web/src/components/Benefit/utils.tsx
@@ -40,8 +40,10 @@ export const resolveBenefitIcon = (
return resolveBenefitCategoryIcon(benefit?.type, fontSize, className)
}
-export const resolveBenefitTypeDisplayName = (type: BenefitType) => {
+export const resolveBenefitTypeDisplayName = (type: BenefitType | 'usage') => {
switch (type) {
+ case 'usage':
+ return 'Usage'
case BenefitType.ADS:
return 'Advertisement Spot'
case BenefitType.DISCORD:
@@ -84,7 +86,8 @@ export const DiscordIcon = ({
export const benefitsDisplayNames: {
[key in BenefitType]: string
-} = {
+} & { usage: string } = {
+ usage: 'Usage',
[BenefitType.LICENSE_KEYS]: 'License Keys',
[BenefitType.GITHUB_REPOSITORY]: 'GitHub Repository Access',
[BenefitType.DISCORD]: 'Discord Invite',
diff --git a/clients/apps/web/src/components/Customer/CreateCustomerModal.tsx b/clients/apps/web/src/components/Customer/CreateCustomerModal.tsx
index 1852b38f1a..3eca997527 100644
--- a/clients/apps/web/src/components/Customer/CreateCustomerModal.tsx
+++ b/clients/apps/web/src/components/Customer/CreateCustomerModal.tsx
@@ -19,6 +19,11 @@ import {
} from '@polar-sh/ui/components/ui/form'
import { useForm } from 'react-hook-form'
import { toast } from '../Toast/use-toast'
+import { CustomerMetadataForm } from './CustomerMetadataForm'
+
+export type CustomerCreateForm = Omit & {
+ metadata: { key: string; value: string | number | boolean }[]
+}
export const CreateCustomerModal = ({
organization,
@@ -27,17 +32,25 @@ export const CreateCustomerModal = ({
organization: Organization
onClose: () => void
}) => {
- const form = useForm({
+ const form = useForm({
defaultValues: {
organization_id: organization.id,
- metadata: {},
+ metadata: [],
},
})
const createCustomer = useCreateCustomer(organization.id)
- const handleCreateCustomer = (customerCreate: CustomerCreate) => {
+ const handleCreateCustomer = (customerCreate: CustomerCreateForm) => {
+ const data = {
+ ...customerCreate,
+ metadata: customerCreate.metadata?.reduce(
+ (acc, { key, value }) => ({ ...acc, [key]: value }),
+ {},
+ ),
+ }
+
createCustomer
- .mutateAsync(customerCreate)
+ .mutateAsync(data)
.then(async (customer) => {
toast({
title: 'Customer Created',
@@ -113,6 +126,11 @@ export const CreateCustomerModal = ({
)}
/>
+ }
+ />
)}
+
+
+ {Object.entries(customer.metadata).map(([key, value]) => (
+
+ ))}
+
{
+ const { control } = useFormContext()
+
+ const { fields, append, remove } = useFieldArray({
+ control,
+ name: 'metadata',
+ rules: {
+ maxLength: 50,
+ },
+ })
+
+ return (
+
+
+ Metadata
+
+
+
+
+ )
+}
diff --git a/clients/apps/web/src/components/Customer/CustomerUsageView.tsx b/clients/apps/web/src/components/Customer/CustomerUsageView.tsx
new file mode 100644
index 0000000000..4c16925d55
--- /dev/null
+++ b/clients/apps/web/src/components/Customer/CustomerUsageView.tsx
@@ -0,0 +1,106 @@
+import { Interval, MetricType } from '@polar-sh/api'
+
+import { Meter, MeterEvent } from '@/app/api/meters/data'
+import { MeterChart } from '@/components/Meter/MeterChart'
+import { useMeterEvents, useMeters } from '@/hooks/queries/meters'
+import { Customer } from '@polar-sh/api'
+import Button from '@polar-sh/ui/components/atoms/Button'
+import ShadowBox from '@polar-sh/ui/components/atoms/ShadowBox'
+import { Status } from '@polar-sh/ui/components/atoms/Status'
+import { TabsContent } from '@polar-sh/ui/components/atoms/Tabs'
+import { twMerge } from 'tailwind-merge'
+import AmountLabel from '../Shared/AmountLabel'
+
+export const CustomerUsageView = ({ customer }: { customer: Customer }) => {
+ const { data: meters } = useMeters(customer.organization_id)
+
+ console.log(meters)
+
+ return (
+
+
+ {meters?.items.map((meter) => (
+
+ ))}
+
+
+ )
+}
+
+const CustomerMeter = ({ meter }: { meter: Meter }) => {
+ const { data: meterEvents } = useMeterEvents(meter?.slug)
+
+ const mockedMeterData = Array.from({ length: 7 }, (_, i) => {
+ const date = new Date()
+ date.setDate(date.getDate() - i)
+ return {
+ timestamp: date,
+ usage:
+ meterEvents?.items
+ .filter((event: MeterEvent) => {
+ const eventDate = new Date(event.created_at)
+ return eventDate.toDateString() === date.toDateString()
+ })
+ .reduce(
+ (total: number, event: MeterEvent) => total + event.value,
+ 0,
+ ) ?? 0,
+ }
+ }).reverse()
+
+ if (!meter) return null
+
+ return (
+
+
+
+
+
{meter.name}
+
+
+
+
+ Last 7 Days
+
+
{meter.value}
+
+
+
+ Credits Remaining
+
+
{Math.max(0, 100 - meter.value)}
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/clients/apps/web/src/components/Customer/EditCustomerModal.tsx b/clients/apps/web/src/components/Customer/EditCustomerModal.tsx
index 4e0777dc51..c56d83d9ad 100644
--- a/clients/apps/web/src/components/Customer/EditCustomerModal.tsx
+++ b/clients/apps/web/src/components/Customer/EditCustomerModal.tsx
@@ -12,6 +12,11 @@ import {
} from '@polar-sh/ui/components/ui/form'
import { useForm } from 'react-hook-form'
import { toast } from '../Toast/use-toast'
+import { CustomerMetadataForm } from './CustomerMetadataForm'
+
+export type CustomerUpdateForm = Omit & {
+ metadata: { key: string; value: string | number | boolean }[]
+}
export const EditCustomerModal = ({
customer,
@@ -20,10 +25,14 @@ export const EditCustomerModal = ({
customer: Customer
onClose: () => void
}) => {
- const form = useForm({
+ const form = useForm({
defaultValues: {
name: customer.name || '',
email: customer.email || '',
+ metadata: Object.entries(customer.metadata).map(([key, value]) => ({
+ key,
+ value,
+ })),
},
})
@@ -32,9 +41,17 @@ export const EditCustomerModal = ({
customer.organization_id,
)
- const handleUpdateCustomer = (customerUpdate: CustomerUpdate) => {
+ const handleUpdateCustomer = (customerUpdate: CustomerUpdateForm) => {
+ const data = {
+ ...customerUpdate,
+ metadata: customerUpdate.metadata?.reduce(
+ (acc, { key, value }) => ({ ...acc, [key]: value }),
+ {},
+ ),
+ }
+
updateCustomer
- .mutateAsync(customerUpdate)
+ .mutateAsync(data)
.then(async (customer) => {
toast({
title: 'Customer Updated',
@@ -95,6 +112,11 @@ export const EditCustomerModal = ({
)}
/>
+ }
+ />