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 + +
+
+ {fields.map((field, index) => ( +
+ ( + <> + + + + + + )} + /> + ( + <> + + + + + + )} + /> + +
+ ))} +
+
+ ) +} 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)}

+
+
+ + Overage + +

+ +

+
+
+
+ + +
+
+ +
+ ) +} 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 = ({ )} /> + } + />