From 836dd01b6ead802daf782c6b7bc9e10153c58088 Mon Sep 17 00:00:00 2001 From: Emil Widlund Date: Tue, 18 Feb 2025 09:59:41 +0100 Subject: [PATCH 01/15] refactor customer portal --- .../portal/(topbar)/usage/ClientPage.tsx | 0 .../portal/(topbar)/usage/page.tsx | 0 .../(main)/[organization]/portal/layout.tsx | 9 +- .../CustomerPortal/CustomerPortal.tsx | 257 ++++-------------- .../CustomerPortal/CustomerPortalOverview.tsx | 115 ++++++++ .../CustomerPortalSubscription.tsx | 107 ++++---- .../CustomerSubscriptionDetails.tsx | 23 +- 7 files changed, 230 insertions(+), 281 deletions(-) create mode 100644 clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/usage/ClientPage.tsx create mode 100644 clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/usage/page.tsx create mode 100644 clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/usage/ClientPage.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/usage/ClientPage.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/usage/page.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/usage/page.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx index e3b074f251..0170d21c78 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx @@ -1,3 +1,4 @@ +import PublicLayout from '@/components/Layout/PublicLayout' import { getServerSideAPI } from '@/utils/client/serverside' import { getOrganizationOrNotFound } from '@/utils/customerPortal' @@ -12,8 +13,12 @@ export default async function Layout({ await getOrganizationOrNotFound(api, params.organization) return ( -
+ {children} -
+ ) } diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx index d7ef3c5186..e1dfdc3053 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx @@ -1,42 +1,20 @@ 'use client' -import { useCustomer, useCustomerPaymentMethods } from '@/hooks/queries' import { createClientSideAPI } from '@/utils/client' import { Client, schemas } from '@polar-sh/client' import Avatar from '@polar-sh/ui/components/atoms/Avatar' -import Button from '@polar-sh/ui/components/atoms/Button' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@polar-sh/ui/components/atoms/Tabs' import Link from 'next/link' -import { parseAsString, useQueryState } from 'nuqs' -import { useCallback, useEffect, useMemo, useState } from 'react' import { twMerge } from 'tailwind-merge' import { SubscriptionStatusLabel } from '../Subscriptions/utils' -import AddPaymentMethod from './AddPaymentMethod' -import BillingAddress from './BillingAddress' import CustomerPortalOrder from './CustomerPortalOrder' +import { CustomerPortalOverview } from './CustomerPortalOverview' import CustomerPortalSubscription from './CustomerPortalSubscription' -import EditBillingDetails from './EditBillingDetails' -import PaymentMethod from './PaymentMethod' - -const PortalSectionLayout = ({ - className, - children, -}: { - className?: string - children: React.ReactNode -}) => { - return ( -
-
- {children} -
-
- ) -} export interface CustomerPortalProps { organization: schemas['Organization'] @@ -54,70 +32,48 @@ export const CustomerPortal = ({ customerSessionToken, }: CustomerPortalProps) => { const api = createClientSideAPI(customerSessionToken) - const { data: customer } = useCustomer(api) - const { data: paymentMethods } = useCustomerPaymentMethods(api) - - const [selectedItemId, setSelectedItemId] = useQueryState( - 'id', - parseAsString.withDefault(''), - ) - - const selectedItem = useMemo(() => { - return ( - subscriptions.find((s) => s.id === selectedItemId) || - orders.find((o) => o.id === selectedItemId) - ) - }, [selectedItemId, subscriptions, orders]) - - useEffect(() => { - const firstItemId = subscriptions[0]?.id ?? orders[0]?.id - - if (selectedItemId === '' && firstItemId) { - setSelectedItemId(firstItemId) - } - }, [selectedItemId, subscriptions, orders, setSelectedItemId]) - - const [showEditBillingDetails, setShowEditBillingDetails] = useState(false) - - const [showAddPaymentMethod, setShowAddPaymentMethod] = useState(false) - const onAddPaymentMethod = useCallback(async () => { - setShowAddPaymentMethod(true) - }, []) - const onPaymentMethodAdded = useCallback(() => { - setShowAddPaymentMethod(false) - }, []) return ( -
- -
- +
+ +

{organization.name}

+
+
+

Customer Portal

+
+ + + + Overview + Subscriptions + Orders + Usage + + + -

{organization.name}

-
-
-

Customer Portal

-
-
- {subscriptions.length > 0 && ( -
-
-

Active Subscriptions

-
- {subscriptions.map((s) => ( - setSelectedItemId(s.id)} - selected={selectedItem?.id === s.id} - customerSessionToken={customerSessionToken} - /> - ))} -
- )} + + +
+ {subscriptions.map((s) => ( + + ))} +
+
+ {orders.length > 0 && (
@@ -128,131 +84,28 @@ export const CustomerPortal = ({ setSelectedItemId(order.id)} - selected={selectedItem?.id === order.id} customerSessionToken={customerSessionToken} /> ))}
)} - {customer && ( -
-
-

Billing details

- -
- {showEditBillingDetails ? ( - setShowEditBillingDetails(false)} - /> - ) : ( -
-
- - Email - - {customer.email} -
-
- - Name - - {customer.name} -
-
- - Billing Address - - - {customer.billing_address ? ( - - ) : ( - '—' - )} - -
-
- - Tax ID - - {customer.tax_id ? customer.tax_id[0] : '—'} -
-
- )} -
- )} - {paymentMethods && ( -
-
-

Payment methods

- -
-
- {paymentMethods.items.map((paymentMethod) => ( - - ))} -
- {customer && showAddPaymentMethod && ( - - )} -
- )} -
-
- - {selectedItem && ( - - )} - + +
) } const OrderItem = ({ item, - onClick, - selected, customerSessionToken, }: { item: schemas['CustomerSubscription'] | schemas['CustomerOrder'] - onClick: () => void - selected: boolean customerSessionToken?: string }) => { const content = (
-
+

{item.product.name}

{'recurring_interval' in item ? ( @@ -274,18 +127,8 @@ const OrderItem = ({ return ( <> -
- {content} -
{ + const multipleSubscriptions = + organization.subscription_settings.allow_multiple_subscriptions + + const activeSubscription = subscriptions.find((s) => s.status === 'active') + + return ( +
+ {multipleSubscriptions ? ( + + ) : ( + + )} +
+ ) +} + +interface SingleSubscriptionOverviewProps { + organization: schemas['Organization'] + subscription?: schemas['CustomerSubscription'] + api: Client + products: schemas['CustomerProduct'][] +} + +const SingleSubscriptionOverview = ({ + subscription, + api, + products, +}: SingleSubscriptionOverviewProps) => { + const onSubscriptionUpdate = useCallback(async () => { + await revalidate(`customer_portal`) + }, []) + + return ( +
+
+ {subscription ? ( + + ) : ( +
+

+ No active subscription +

+
+ )} +
+
+ ) +} + +interface MultipleSubscriptionOverviewProps { + organization: schemas['Organization'] + subscriptions: schemas['CustomerSubscription'][] + products: schemas['CustomerProduct'][] + api: Client +} + +const MultipleSubscriptionOverview = ({ + organization, + subscriptions, + products, + api, +}: MultipleSubscriptionOverviewProps) => { + const onSubscriptionUpdate = useCallback(async () => { + await revalidate(`customer_portal`) + }, []) + + return ( +
+
+ {subscriptions.map((s) => ( + + ))} +
+
+ ) +} diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalSubscription.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSubscription.tsx index 888dcb2e04..862bce48c9 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortalSubscription.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSubscription.tsx @@ -1,21 +1,18 @@ 'use client' -import revalidate from '@/app/actions' import { BenefitGrant } from '@/components/Benefit/BenefitGrant' import { useCustomerBenefitGrants, - useCustomerCancelSubscription, useCustomerOrderInvoice, useCustomerOrders, } from '@/hooks/queries' -import { ReceiptOutlined } from '@mui/icons-material' import { Client, schemas } from '@polar-sh/client' 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 { List, ListItem } from '@polar-sh/ui/components/atoms/List' import { formatCurrencyAndAmount } from '@polar-sh/ui/lib/money' import { useCallback } from 'react' -import CustomerSubscriptionDetails from '../Subscriptions/CustomerSubscriptionDetails' const CustomerPortalSubscription = ({ api, @@ -38,10 +35,6 @@ const CustomerPortalSubscription = ({ sorting: ['-created_at'], }) - const onSubscriptionUpdate = useCallback(async () => { - await revalidate(`customer_portal`) - }, []) - const orderInvoiceMutation = useCustomerOrderInvoice(api) const openInvoice = useCallback( async (order: schemas['CustomerOrder']) => { @@ -53,26 +46,10 @@ const CustomerPortalSubscription = ({ const hasInvoices = orders?.items && orders.items.length > 0 - const cancelSubscription = useCustomerCancelSubscription(api) - const isCanceled = - cancelSubscription.isPending || - cancelSubscription.isSuccess || - !!subscription.ended_at || - !!subscription.ends_at - return ( <> -
+
- - {(benefitGrants?.items.length ?? 0) > 0 && (
@@ -91,43 +68,59 @@ const CustomerPortalSubscription = ({
{hasInvoices && ( -
-

Invoices

- - {orders.items?.map((order) => ( - -
- - - +
+

Invoices

+ ( + + ), + }, + { + accessorKey: 'product.name', + header: 'Product', + cell: ({ row }) => row.original.product.name, + }, + { + accessorKey: 'amount', + header: 'Amount', + cell: ({ row }) => ( {formatCurrencyAndAmount( - order.amount, - order.currency, + row.original.amount, + row.original.currency, 0, )} -
- - - ))} - + ), + }, + { + accessorKey: 'id', + header: '', + cell: ({ row }) => ( + + + + ), + }, + ]} + />
)}
diff --git a/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx b/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx index 0e8ee4d17d..eeadca5fc0 100644 --- a/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx +++ b/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx @@ -8,7 +8,6 @@ import { useCustomerUncancelSubscription, } from '@/hooks/queries' import { Client, schemas } from '@polar-sh/client' -import Avatar from '@polar-sh/ui/components/atoms/Avatar' import Button from '@polar-sh/ui/components/atoms/Button' import ShadowBox from '@polar-sh/ui/components/atoms/ShadowBox' import { useMemo, useState } from 'react' @@ -20,22 +19,26 @@ const CustomerSubscriptionDetails = ({ subscription, products, api, - cancelSubscription, onUserSubscriptionUpdate, - isCanceled, }: { subscription: schemas['CustomerSubscription'] products: schemas['CustomerProduct'][] api: Client - cancelSubscription: ReturnType onUserSubscriptionUpdate: ( subscription: schemas['CustomerSubscription'], ) => void - isCanceled: boolean }) => { const [showChangePlanModal, setShowChangePlanModal] = useState(false) const [showCancelModal, setShowCancelModal] = useState(false) + const cancelSubscription = useCustomerCancelSubscription(api) + + const isCanceled = + cancelSubscription.isPending || + cancelSubscription.isSuccess || + !!subscription.ended_at || + !!subscription.ends_at + const organization = subscription.product.organization const uncancelSubscription = useCustomerUncancelSubscription(api) @@ -81,16 +84,6 @@ const CustomerSubscriptionDetails = ({

{subscription.product.name}

-
- -

- {organization.name} -

-
From 1cab0da920c862c5e87a20018c9e398316dfacfd Mon Sep 17 00:00:00 2001 From: Emil Widlund Date: Wed, 19 Feb 2025 07:59:57 +0100 Subject: [PATCH 02/15] usage: fix customer view for metrics usage --- .../(main)/[organization]/portal/layout.tsx | 10 +- .../components/Customer/CustomerUsageView.tsx | 143 +++++++------- .../CustomerPortal/CustomerPortal.tsx | 174 ++++++++---------- clients/apps/web/src/hooks/queries/meters.ts | 12 +- clients/apps/web/src/utils/metrics.ts | 1 + 5 files changed, 166 insertions(+), 174 deletions(-) diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx index 0170d21c78..d86d938b95 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx @@ -12,13 +12,5 @@ export default async function Layout({ const api = getServerSideAPI() await getOrganizationOrNotFound(api, params.organization) - return ( - - {children} - - ) + return {children} } diff --git a/clients/apps/web/src/components/Customer/CustomerUsageView.tsx b/clients/apps/web/src/components/Customer/CustomerUsageView.tsx index fe1aa5eee0..4eb146f1d9 100644 --- a/clients/apps/web/src/components/Customer/CustomerUsageView.tsx +++ b/clients/apps/web/src/components/Customer/CustomerUsageView.tsx @@ -1,6 +1,12 @@ -import { useMeters } from '@/hooks/queries/meters' +import { useMeterQuantities, useMeters } from '@/hooks/queries/meters' +import { computeCumulativeValue } from '@/utils/metrics' +import { MoreVert } from '@mui/icons-material' import { schemas } from '@polar-sh/client' +import Button from '@polar-sh/ui/components/atoms/Button' +import ShadowBox from '@polar-sh/ui/components/atoms/ShadowBox' import { TabsContent } from '@polar-sh/ui/components/atoms/Tabs' +import { useMemo } from 'react' +import { MeterChart } from '../Meter/MeterChart' export const CustomerUsageView = ({ customer, @@ -13,81 +19,82 @@ export const CustomerUsageView = ({
{meters?.items.map((meter) => ( - + ))}
) } -const CustomerMeter = ({ meter }: { meter: schemas['Meter'] }) => { - // const { data } = useMeterEvents(meter.id) - // const meterEvents = useMemo(() => { - // if (!data) return [] - // return data.pages.flatMap((page) => page.items) - // }, [data]) +const CustomerMeter = ({ + meter, + customerId, +}: { + meter: schemas['Meter'] + customerId: string +}) => { + const startDate = useMemo( + () => new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + [], + ) + + const endDate = useMemo(() => new Date(), []) + + const { data: quantities } = useMeterQuantities( + meter.id, + startDate, + endDate, + 'day', + customerId, + ) - // const mockedMeterData = Array.from({ length: 7 }, (_, i) => { - // const date = new Date() - // date.setDate(date.getDate() - i) - // return { - // timestamp: date, - // usage: - // meterEvents - // .filter((event) => { - // const eventDate = new Date(event.timestamp) - // return eventDate.toDateString() === date.toDateString() - // }) - // .reduce((total: number, event) => total + event.value, 0) ?? 0, - // } - // }).reverse() + const periodValue = computeCumulativeValue( + { + slug: 'quantity', + display_name: 'Quantity', + type: 'scalar', + }, + quantities?.quantities.map((q) => q.quantity) ?? [], + ) - if (!meter) return null + const formatter = Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, + }) - return null + if (!meter || !quantities) return null - // return ( - // - //
- //
- //
- //

{meter.name}

- //
- //
- // - // Last 7 Days - // - //

{meter.value}

- //
- //
- // - // Credits Remaining - // - //

{Math.max(0, 100 - meter.value)}

- //
- //
- // - // Overage - // - //

- // - //

- //
- //
- //
- // - // - //
- //
- // - //
- // ) + return ( + +
+
+
+

{meter.name}

+
+
+ + Last 7 Days + +

{formatter.format(periodValue)}

+
+
+ + Overage + +

+
+
+
+ + +
+
+ +
+ ) } diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx index e1dfdc3053..1f72d68ef9 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx @@ -1,17 +1,14 @@ 'use client' +import { useCustomerOrderInvoice } from '@/hooks/queries' import { createClientSideAPI } from '@/utils/client' import { Client, schemas } from '@polar-sh/client' import Avatar from '@polar-sh/ui/components/atoms/Avatar' -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from '@polar-sh/ui/components/atoms/Tabs' -import Link from 'next/link' -import { twMerge } from 'tailwind-merge' -import { SubscriptionStatusLabel } from '../Subscriptions/utils' +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 { formatCurrencyAndAmount } from '@polar-sh/ui/lib/money' +import { useCallback } from 'react' import CustomerPortalOrder from './CustomerPortalOrder' import { CustomerPortalOverview } from './CustomerPortalOverview' import CustomerPortalSubscription from './CustomerPortalSubscription' @@ -33,6 +30,16 @@ export const CustomerPortal = ({ }: CustomerPortalProps) => { const api = createClientSideAPI(customerSessionToken) + const orderInvoiceMutation = useCustomerOrderInvoice(api) + + const openInvoice = useCallback( + async (order: schemas['CustomerOrder']) => { + const { url } = await orderInvoiceMutation.mutateAsync({ id: order.id }) + window.open(url, '_blank') + }, + [orderInvoiceMutation], + ) + return (
@@ -47,96 +54,73 @@ export const CustomerPortal = ({

Customer Portal

- - - Overview - Subscriptions - Orders - Usage - - - - - -
- {subscriptions.map((s) => ( - - ))} + + + {orders.length > 0 && ( +
+
+

Orders

- - - {orders.length > 0 && ( -
-
-

Orders

-
-
- {orders.map((order) => ( - ( + - ))} -
-
- )} -
- -
- ) -} - -const OrderItem = ({ - item, - customerSessionToken, -}: { - item: schemas['CustomerSubscription'] | schemas['CustomerOrder'] - customerSessionToken?: string -}) => { - const content = ( -
-
-

{item.product.name}

- {'recurring_interval' in item ? ( - - ) : ( -

- {new Date(item.created_at).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} -

- )} -
+ ), + }, + { + accessorKey: 'product.name', + header: 'Product', + cell: ({ row }) => row.original.product.name, + }, + { + accessorKey: 'amount', + header: 'Amount', + cell: ({ row }) => ( + + {formatCurrencyAndAmount( + row.original.amount, + row.original.currency, + 0, + )} + + ), + }, + { + accessorKey: 'id', + header: '', + cell: ({ row }) => ( + + + + ), + }, + ]} + /> +
+ )}
) - - const className = - 'dark:bg-polar-800 dark:hover:bg-polar-700 w-full cursor-pointer rounded-2xl bg-gray-200 px-6 py-4 hover:bg-white transition-colors duration-75' - - return ( - <> - - {content} - - - ) } const SelectedItemDetails = ({ diff --git a/clients/apps/web/src/hooks/queries/meters.ts b/clients/apps/web/src/hooks/queries/meters.ts index 61be8989cc..9cc0a5019a 100644 --- a/clients/apps/web/src/hooks/queries/meters.ts +++ b/clients/apps/web/src/hooks/queries/meters.ts @@ -65,16 +65,24 @@ export const useMeterQuantities = ( startTimestamp: Date, endTimestamp: Date, interval: schemas['TimeInterval'], + customerId?: string, parameters?: Omit< NonNullable, - 'id' | 'startTimestamp' | 'endTimestamp' | 'interval' + 'id' | 'startTimestamp' | 'endTimestamp' | 'interval' | 'customer_id' >, ): UseQueryResult => useQuery({ queryKey: [ 'meters', 'quantities', - { id, startTimestamp, endTimestamp, interval, ...(parameters || {}) }, + { + id, + startTimestamp, + endTimestamp, + interval, + customerId, + ...(parameters || {}), + }, ], queryFn: async () => { const result = await unwrap( diff --git a/clients/apps/web/src/utils/metrics.ts b/clients/apps/web/src/utils/metrics.ts index 48c499b82a..a618706a90 100644 --- a/clients/apps/web/src/utils/metrics.ts +++ b/clients/apps/web/src/utils/metrics.ts @@ -296,6 +296,7 @@ export const metricToCumulativeType: Record< renewed_subscriptions_revenue: 'sum', active_subscriptions: 'lastValue', monthly_recurring_revenue: 'lastValue', + quantity: 'sum', } export type MetricCumulativeType = 'sum' | 'average' | 'lastValue' From 1f5ee8ce73e2d754ce8a5be37023169b5d6503e5 Mon Sep 17 00:00:00 2001 From: Emil Widlund Date: Wed, 19 Feb 2025 17:43:04 +0100 Subject: [PATCH 03/15] implement usage --- .../portal/(topbar)/usage/ClientPage.tsx | 0 .../portal/(topbar)/usage/page.tsx | 0 .../[organization]/portal/Navigation.tsx | 50 ++++ .../(main)/[organization]/portal/layout.tsx | 41 +++- .../app/(main)/[organization]/portal/page.tsx | 1 + .../[organization]/portal/usage/page.tsx | 114 +++++++++ .../src/components/Customer/CustomerMeter.tsx | 63 +++++ .../components/Customer/CustomerUsageView.tsx | 94 ++------ .../CustomerPortal/CustomerPortal.tsx | 15 +- .../CustomerPortal/CustomerPortalOverview.tsx | 79 ++++++- .../CustomerPortal/CustomerUsage.tsx | 219 ++++++++++++++++++ clients/apps/web/src/hooks/queries/meters.ts | 2 +- 12 files changed, 582 insertions(+), 96 deletions(-) delete mode 100644 clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/usage/ClientPage.tsx delete mode 100644 clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/usage/page.tsx create mode 100644 clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx create mode 100644 clients/apps/web/src/app/(main)/[organization]/portal/usage/page.tsx create mode 100644 clients/apps/web/src/components/Customer/CustomerMeter.tsx create mode 100644 clients/apps/web/src/components/CustomerPortal/CustomerUsage.tsx diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/usage/ClientPage.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/usage/ClientPage.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/usage/page.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/usage/page.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx new file mode 100644 index 0000000000..bc4ba17382 --- /dev/null +++ b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx @@ -0,0 +1,50 @@ +'use client' + +import { schemas } from '@polar-sh/client' +import Link from 'next/link' +import { usePathname, useSearchParams } from 'next/navigation' +import { twMerge } from 'tailwind-merge' + +const links = [ + { href: '/', label: 'Overview' }, + { href: '/subscriptions', label: 'Subscriptions' }, + { href: '/usage', label: 'Usage' }, + { href: '/orders', label: 'Orders' }, +] + +export const Navigation = ({ + organization, +}: { + organization: schemas['Organization'] +}) => { + const currentPath = usePathname() + const searchParams = useSearchParams() + + const buildPath = (path: string) => { + const url = new URL(window.location.origin + currentPath) + url.pathname = `/${organization.slug}/portal${path}` + + for (const [key, value] of searchParams.entries()) { + url.searchParams.set(key, value) + } + + return url.toString() + } + + return ( + + ) +} diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx index d86d938b95..41c959dd33 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx @@ -1,6 +1,15 @@ -import PublicLayout from '@/components/Layout/PublicLayout' import { getServerSideAPI } from '@/utils/client/serverside' import { getOrganizationOrNotFound } from '@/utils/customerPortal' +import Avatar from '@polar-sh/ui/components/atoms/Avatar' +import { Separator } from '@polar-sh/ui/components/ui/separator' +import { Navigation } from './Navigation' + +const links = [ + { href: '/', label: 'Overview' }, + { href: '/subscriptions', label: 'Subscriptions' }, + { href: '/usage', label: 'Usage' }, + { href: '/orders', label: 'Orders' }, +] export default async function Layout({ params, @@ -10,7 +19,33 @@ export default async function Layout({ children: React.ReactNode }) { const api = getServerSideAPI() - await getOrganizationOrNotFound(api, params.organization) + const { organization } = await getOrganizationOrNotFound( + api, + params.organization, + ) - return {children} + return ( +
+
+
+ +

{organization.name}

+
+
+

Customer Portal

+
+
+ +
+ +
+ {children} +
+
+
+ ) } diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/page.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/page.tsx index 5abcb0b568..8032fe18ba 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/page.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/page.tsx @@ -80,6 +80,7 @@ export default async function Page({ }, ...cacheConfig, }) + const { data: oneTimePurchases, error: oneTimePurchasesError, diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/usage/page.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/usage/page.tsx new file mode 100644 index 0000000000..78e2c11c13 --- /dev/null +++ b/clients/apps/web/src/app/(main)/[organization]/portal/usage/page.tsx @@ -0,0 +1,114 @@ +import { CustomerUsage } from '@/components/CustomerPortal/CustomerUsage' +import { getServerSideAPI } from '@/utils/client/serverside' +import { getOrganizationOrNotFound } from '@/utils/customerPortal' +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' + +const cacheConfig = { + cache: 'no-store' as RequestCache, + next: { + tags: ['customer_portal'], + }, +} + +export async function generateMetadata({ + params, +}: { + params: { organization: string } +}): Promise { + const api = getServerSideAPI() + const { organization } = await getOrganizationOrNotFound( + api, + params.organization, + ) + + return { + title: `Customer Portal | ${organization.name}`, // " | Polar is added by the template" + openGraph: { + title: `Customer Portal | ${organization.name} on Polar`, + description: `Customer Portal | ${organization.name} on Polar`, + siteName: 'Polar', + type: 'website', + images: [ + { + url: `https://polar.sh/og?org=${organization.slug}`, + width: 1200, + height: 630, + }, + ], + }, + twitter: { + images: [ + { + url: `https://polar.sh/og?org=${organization.slug}`, + width: 1200, + height: 630, + alt: `${organization.name} on Polar`, + }, + ], + card: 'summary_large_image', + title: `Customer Portal | ${organization.name} on Polar`, + description: `Customer Portal | ${organization.name} on Polar`, + }, + } +} + +export default async function Page({ + params, + searchParams, +}: { + params: { organization: string } + searchParams: { customer_session_token?: string } +}) { + const api = getServerSideAPI(searchParams.customer_session_token) + const { organization, products } = await getOrganizationOrNotFound( + api, + params.organization, + ) + + const { + data: subscriptions, + error: subscriptionsError, + response: subscriptionsResponse, + } = await api.GET('/v1/customer-portal/subscriptions/', { + params: { + query: { + organization_id: organization.id, + active: true, + limit: 100, + }, + }, + ...cacheConfig, + }) + + const { + data: oneTimePurchases, + error: oneTimePurchasesError, + response: oneTimePurchasesResponse, + } = await api.GET('/v1/customer-portal/orders/', { + params: { + query: { + organization_id: organization.id, + limit: 100, + }, + }, + ...cacheConfig, + }) + + if ( + subscriptionsResponse.status === 401 || + oneTimePurchasesResponse.status === 401 + ) { + redirect(`/${organization.slug}/portal/request`) + } + + if (subscriptionsError) { + throw subscriptionsError + } + + if (oneTimePurchasesError) { + throw oneTimePurchasesError + } + + return +} diff --git a/clients/apps/web/src/components/Customer/CustomerMeter.tsx b/clients/apps/web/src/components/Customer/CustomerMeter.tsx new file mode 100644 index 0000000000..d271bbefc6 --- /dev/null +++ b/clients/apps/web/src/components/Customer/CustomerMeter.tsx @@ -0,0 +1,63 @@ +import { ParsedMeterQuantities } from '@/hooks/queries/meters' +import { computeCumulativeValue } from '@/utils/metrics' +import { MoreVert } from '@mui/icons-material' +import { schemas } from '@polar-sh/client' +import Button from '@polar-sh/ui/components/atoms/Button' +import ShadowBox from '@polar-sh/ui/components/atoms/ShadowBox' +import { MeterChart } from '../Meter/MeterChart' + +export const CustomerMeter = ({ + meter, + data: { quantities }, +}: { + meter: schemas['Meter'] + data: ParsedMeterQuantities +}) => { + const periodValue = computeCumulativeValue( + { + slug: 'quantity', + display_name: 'Quantity', + type: 'scalar', + }, + quantities.map((q) => q.quantity) ?? [], + ) + + const formatter = Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, + }) + + if (!meter || !quantities) return null + + return ( + +
+
+

{meter.name}

+
+ + +
+
+
+
+ + Last 7 Days + +

{formatter.format(periodValue)}

+
+
+ + Overage + +

$0.00

+
+
+
+
+ +
+
+ ) +} diff --git a/clients/apps/web/src/components/Customer/CustomerUsageView.tsx b/clients/apps/web/src/components/Customer/CustomerUsageView.tsx index 4eb146f1d9..473c38f59b 100644 --- a/clients/apps/web/src/components/Customer/CustomerUsageView.tsx +++ b/clients/apps/web/src/components/Customer/CustomerUsageView.tsx @@ -1,12 +1,8 @@ import { useMeterQuantities, useMeters } from '@/hooks/queries/meters' -import { computeCumulativeValue } from '@/utils/metrics' -import { MoreVert } from '@mui/icons-material' import { schemas } from '@polar-sh/client' -import Button from '@polar-sh/ui/components/atoms/Button' -import ShadowBox from '@polar-sh/ui/components/atoms/ShadowBox' import { TabsContent } from '@polar-sh/ui/components/atoms/Tabs' import { useMemo } from 'react' -import { MeterChart } from '../Meter/MeterChart' +import { CustomerMeter } from './CustomerMeter' export const CustomerUsageView = ({ customer, @@ -15,28 +11,6 @@ export const CustomerUsageView = ({ }) => { const { data: meters } = useMeters(customer.organization_id) - return ( - -
- {meters?.items.map((meter) => ( - - ))} -
-
- ) -} - -const CustomerMeter = ({ - meter, - customerId, -}: { - meter: schemas['Meter'] - customerId: string -}) => { const startDate = useMemo( () => new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), [], @@ -44,57 +18,23 @@ const CustomerMeter = ({ const endDate = useMemo(() => new Date(), []) - const { data: quantities } = useMeterQuantities( - meter.id, - startDate, - endDate, - 'day', - customerId, - ) - - const periodValue = computeCumulativeValue( - { - slug: 'quantity', - display_name: 'Quantity', - type: 'scalar', - }, - quantities?.quantities.map((q) => q.quantity) ?? [], - ) - - const formatter = Intl.NumberFormat('en-US', { - maximumFractionDigits: 2, - }) - - if (!meter || !quantities) return null - return ( - -
-
-
-

{meter.name}

-
-
- - Last 7 Days - -

{formatter.format(periodValue)}

-
-
- - Overage - -

-
-
-
- - -
+ +
+ {meters?.items.map((meter) => { + const { data } = useMeterQuantities( + meter.id, + startDate, + endDate, + 'day', + customer.id, + ) + + if (!data) return null + + return + })}
- - +
) } diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx index 1f72d68ef9..2abd92276e 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx @@ -3,7 +3,6 @@ import { useCustomerOrderInvoice } from '@/hooks/queries' import { createClientSideAPI } from '@/utils/client' import { Client, schemas } from '@polar-sh/client' -import Avatar from '@polar-sh/ui/components/atoms/Avatar' 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' @@ -41,19 +40,7 @@ export const CustomerPortal = ({ ) return ( -
-
- -

{organization.name}

-
-
-

Customer Portal

-
- +
)} + +
+
+
+

Overview

+
+ + Current Billing Cycle + + Jan 27, 9:00 - Feb 27, 9:00 +
+
+ +
+
+
+ Product +
+
+ Included +
+
+ On-demand +
+
+ Charge +
+ + {[ + { + name: 'Observability Events', + included: '1M / 1M', + onDemand: '~15M', + charge: '$13.25', + }, + { + name: 'Edge Middleware Invocations', + included: '1M / 1M', + onDemand: '~2.7M', + charge: '$2.60', + }, + { + name: 'Function Invocations', + included: '1M / 1M', + onDemand: '~2.3M', + charge: '$1.80', + }, + { + name: 'Edge Function Execution Units', + included: '125k / 1M', + onDemand: '$0', + charge: '$0', + }, + { + name: 'Edge Requests', + included: '10M / 1M', + onDemand: '$0', + charge: '$0', + }, + ].map((item) => ( +
+
+
+ {item.name} +
+
{item.included}
+
{item.onDemand}
+
{item.charge}
+
+ ))} +
+
+
+
+ +
) } @@ -69,7 +146,7 @@ const SingleSubscriptionOverview = ({ onUserSubscriptionUpdate={onSubscriptionUpdate} /> ) : ( -
+

No active subscription

diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerUsage.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerUsage.tsx new file mode 100644 index 0000000000..51e33027e8 --- /dev/null +++ b/clients/apps/web/src/components/CustomerPortal/CustomerUsage.tsx @@ -0,0 +1,219 @@ +'use client' + +import { computeCumulativeValue } from '@/utils/metrics' +import { Search } from '@mui/icons-material' +import { schemas } from '@polar-sh/client' +import { DataTable } from '@polar-sh/ui/components/atoms/DataTable' +import Input from '@polar-sh/ui/components/atoms/Input' +import { useState } from 'react' +import { CustomerMeter } from '../Customer/CustomerMeter' +import DateRangePicker from '../Metrics/DateRangePicker' +import IntervalPicker from '../Metrics/IntervalPicker' + +const mockedMeters = (organizationId: string): schemas['Meter'][] => [ + { + id: '1', + name: 'CPU', + filter: { + conjunction: 'and', + clauses: [], + }, + aggregation: { + func: 'sum', + property: 'value', + }, + metadata: {}, + created_at: '2021-01-01', + modified_at: '2021-01-01', + organization_id: organizationId, + }, + { + id: '2', + name: 'Memory', + filter: { + conjunction: 'and', + clauses: [], + }, + aggregation: { + func: 'sum', + property: 'value', + }, + metadata: {}, + created_at: '2021-01-01', + modified_at: '2021-01-01', + organization_id: organizationId, + }, +] + +const mockedEvents = (organizationId: string): schemas['Event'][] => [ + { + id: '1', + organization_id: organizationId, + timestamp: '2021-01-01', + metadata: { + value: 100, + }, + name: 'CPU', + source: 'system', + customer_id: null, + customer: null, + external_customer_id: null, + }, + { + id: '2', + organization_id: organizationId, + timestamp: '2021-01-02', + metadata: { + value: 200, + }, + name: 'CPU', + source: 'system', + customer_id: null, + customer: null, + external_customer_id: null, + }, + { + id: '3', + organization_id: organizationId, + timestamp: '2021-01-03', + metadata: { value: 130 }, + name: 'CPU', + source: 'system', + customer_id: null, + customer: null, + external_customer_id: null, + }, +] + +export interface CustomerUsageProps { + organizationId: string +} + +export const CustomerUsage = ({ organizationId }: CustomerUsageProps) => { + const [interval, setInterval] = useState< + 'hour' | 'day' | 'week' | 'month' | 'year' + >('week') + const [dateRange, setDateRange] = useState({ + from: new Date(), + to: new Date(), + }) + + return ( +
+
+
+
+ } + placeholder="Search Usage Meter" + /> +
+
+ +
+
+ +
+
+
+ +
+

Overview

+ { + return ( +
+
+
+
+
+ {row.original.name} +
+ ) + }, + }, + { + header: 'Value', + accessorKey: 'value', + cell: ({ row }) => { + return ( + + {computeCumulativeValue( + { + slug: 'quantity', + display_name: 'Quantity', + type: 'scalar', + }, + mockedEvents(organizationId).map((e) => + Number(e.metadata.value), + ) ?? [], + )} + + ) + }, + }, + { + header: 'Included', + accessorKey: 'included', + cell: ({ row }) => { + return ( + + {' '} + {computeCumulativeValue( + { + slug: 'quantity', + display_name: 'Quantity', + type: 'scalar', + }, + mockedEvents(organizationId).map((e) => + Number(e.metadata.value), + ) ?? [], + )}{' '} + / 20K + + ) + }, + }, + { + header: 'Overage', + accessorKey: 'overage', + cell: ({ row }) => { + return $0.00 + }, + }, + ]} + data={mockedMeters(organizationId)} + /> +
+ + {mockedMeters(organizationId).map((meter) => ( + ({ + timestamp: new Date(event.timestamp), + quantity: Number(event.metadata.value), + })), + }} + /> + ))} +
+ ) +} diff --git a/clients/apps/web/src/hooks/queries/meters.ts b/clients/apps/web/src/hooks/queries/meters.ts index 9cc0a5019a..dfc5667e0a 100644 --- a/clients/apps/web/src/hooks/queries/meters.ts +++ b/clients/apps/web/src/hooks/queries/meters.ts @@ -38,7 +38,7 @@ export const useMeter = (id: string, initialData?: schemas['Meter']) => initialData, }) -interface ParsedMeterQuantities { +export interface ParsedMeterQuantities { quantities: { timestamp: Date quantity: number From 76b10d97c2e41b64263dff41227fc78de9f6b224 Mon Sep 17 00:00:00 2001 From: Emil Widlund Date: Wed, 19 Feb 2025 18:23:27 +0100 Subject: [PATCH 04/15] add alerts --- .../[organization]/portal/usage/page.tsx | 47 +-- .../CustomerPortal/CustomerUsage.tsx | 306 ++++++++++++------ 2 files changed, 200 insertions(+), 153 deletions(-) diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/usage/page.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/usage/page.tsx index 78e2c11c13..a498311276 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/usage/page.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/usage/page.tsx @@ -2,7 +2,6 @@ import { CustomerUsage } from '@/components/CustomerPortal/CustomerUsage' import { getServerSideAPI } from '@/utils/client/serverside' import { getOrganizationOrNotFound } from '@/utils/customerPortal' import type { Metadata } from 'next' -import { redirect } from 'next/navigation' const cacheConfig = { cache: 'no-store' as RequestCache, @@ -61,54 +60,10 @@ export default async function Page({ searchParams: { customer_session_token?: string } }) { const api = getServerSideAPI(searchParams.customer_session_token) - const { organization, products } = await getOrganizationOrNotFound( + const { organization } = await getOrganizationOrNotFound( api, params.organization, ) - const { - data: subscriptions, - error: subscriptionsError, - response: subscriptionsResponse, - } = await api.GET('/v1/customer-portal/subscriptions/', { - params: { - query: { - organization_id: organization.id, - active: true, - limit: 100, - }, - }, - ...cacheConfig, - }) - - const { - data: oneTimePurchases, - error: oneTimePurchasesError, - response: oneTimePurchasesResponse, - } = await api.GET('/v1/customer-portal/orders/', { - params: { - query: { - organization_id: organization.id, - limit: 100, - }, - }, - ...cacheConfig, - }) - - if ( - subscriptionsResponse.status === 401 || - oneTimePurchasesResponse.status === 401 - ) { - redirect(`/${organization.slug}/portal/request`) - } - - if (subscriptionsError) { - throw subscriptionsError - } - - if (oneTimePurchasesError) { - throw oneTimePurchasesError - } - return } diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerUsage.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerUsage.tsx index 51e33027e8..bd43741650 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerUsage.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerUsage.tsx @@ -3,8 +3,16 @@ import { computeCumulativeValue } from '@/utils/metrics' import { Search } from '@mui/icons-material' import { schemas } from '@polar-sh/client' +import Button from '@polar-sh/ui/components/atoms/Button' import { DataTable } from '@polar-sh/ui/components/atoms/DataTable' import Input from '@polar-sh/ui/components/atoms/Input' +import { Status } from '@polar-sh/ui/components/atoms/Status' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@polar-sh/ui/components/atoms/Tabs' import { useState } from 'react' import { CustomerMeter } from '../Customer/CustomerMeter' import DateRangePicker from '../Metrics/DateRangePicker' @@ -85,6 +93,21 @@ const mockedEvents = (organizationId: string): schemas['Event'][] => [ }, ] +const mockedAlerts = (organizationId: string) => [ + { + id: '1', + organization_id: organizationId, + meter: mockedMeters(organizationId)[0], + threshold: 100, + }, + { + id: '2', + organization_id: organizationId, + meter: mockedMeters(organizationId)[1], + threshold: 200, + }, +] + export interface CustomerUsageProps { organizationId: string } @@ -99,121 +122,190 @@ export const CustomerUsage = ({ organizationId }: CustomerUsageProps) => { }) return ( -
-
-
-
- } - placeholder="Search Usage Meter" - /> -
-
- +
+ +
+

Usage

+ + Meters + Alerts + +
+ +
+
+
+ } + placeholder="Search Usage Meter" + /> +
+
+ +
+
+ +
+
-
- +

Overview

+ { + return ( +
+
+
+
+
+ {row.original.name} +
+ ) + }, + }, + { + header: 'Value', + accessorKey: 'value', + cell: ({ row }) => { + return ( + + {computeCumulativeValue( + { + slug: 'quantity', + display_name: 'Quantity', + type: 'scalar', + }, + mockedEvents(organizationId).map((e) => + Number(e.metadata.value), + ) ?? [], + )} + + ) + }, + }, + { + header: 'Included', + accessorKey: 'included', + cell: ({ row }) => { + return ( + + {' '} + {computeCumulativeValue( + { + slug: 'quantity', + display_name: 'Quantity', + type: 'scalar', + }, + mockedEvents(organizationId).map((e) => + Number(e.metadata.value), + ) ?? [], + )}{' '} + / 20K + + ) + }, + }, + { + header: 'Overage', + accessorKey: 'overage', + cell: ({ row }) => { + return $0.00 + }, + }, + ]} + data={mockedMeters(organizationId)} />
-
-
-
-

Overview

- { - return ( -
-
-
-
-
- {row.original.name} -
- ) + {mockedMeters(organizationId).map((meter) => ( + ({ + timestamp: new Date(event.timestamp), + quantity: Number(event.metadata.value), + })), + }} + /> + ))} + + + { + return {row.original.meter.name} + }, }, - }, - { - header: 'Value', - accessorKey: 'value', - cell: ({ row }) => { - return ( - - {computeCumulativeValue( - { - slug: 'quantity', - display_name: 'Quantity', - type: 'scalar', - }, - mockedEvents(organizationId).map((e) => - Number(e.metadata.value), - ) ?? [], - )} - - ) + { + header: 'Threshold', + accessorKey: 'threshold', + cell: ({ row }) => { + return {row.original.threshold} + }, }, - }, - { - header: 'Included', - accessorKey: 'included', - cell: ({ row }) => { - return ( - - {' '} - {computeCumulativeValue( - { - slug: 'quantity', - display_name: 'Quantity', - type: 'scalar', - }, - mockedEvents(organizationId).map((e) => - Number(e.metadata.value), - ) ?? [], - )}{' '} - / 20K - - ) + { + header: 'Progress', + accessorKey: 'progress', + cell: ({ row }) => { + return ( +
+
+
+
+
+ {(row.original.threshold / 100) * 100}% +
+ ) + }, }, - }, - { - header: 'Overage', - accessorKey: 'overage', - cell: ({ row }) => { - return $0.00 + { + header: 'Status', + accessorKey: 'status', + cell: ({ row }) => { + return ( + + ) + }, }, - }, - ]} - data={mockedMeters(organizationId)} - /> -
- - {mockedMeters(organizationId).map((meter) => ( - ({ - timestamp: new Date(event.timestamp), - quantity: Number(event.metadata.value), - })), - }} - /> - ))} + ]} + data={mockedAlerts(organizationId)} + /> + + +
) } From 8b46453f635fbedb01e2516d5837eb2777356ccf Mon Sep 17 00:00:00 2001 From: Emil Widlund Date: Thu, 20 Feb 2025 13:45:18 +0100 Subject: [PATCH 05/15] improve customer portal --- .../[organization]/portal/Navigation.tsx | 19 +++++++++++-------- .../CustomerPortal/CustomerUsage.tsx | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx index bc4ba17382..41645312df 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx @@ -5,11 +5,14 @@ import Link from 'next/link' import { usePathname, useSearchParams } from 'next/navigation' import { twMerge } from 'tailwind-merge' -const links = [ - { href: '/', label: 'Overview' }, - { href: '/subscriptions', label: 'Subscriptions' }, - { href: '/usage', label: 'Usage' }, - { href: '/orders', label: 'Orders' }, +const links = (organization: schemas['Organization']) => [ + { href: `/${organization.slug}/portal`, label: 'Overview' }, + { + href: `/${organization.slug}/portal/subscriptions`, + label: 'Subscriptions', + }, + { href: `/${organization.slug}/portal/orders`, label: 'Orders' }, + { href: `/${organization.slug}/portal/usage`, label: 'Usage' }, ] export const Navigation = ({ @@ -22,7 +25,7 @@ export const Navigation = ({ const buildPath = (path: string) => { const url = new URL(window.location.origin + currentPath) - url.pathname = `/${organization.slug}/portal${path}` + url.pathname = path for (const [key, value] of searchParams.entries()) { url.searchParams.set(key, value) @@ -33,13 +36,13 @@ export const Navigation = ({ return (
) } - -const SelectedItemDetails = ({ - item, - products, - api, -}: { - item: schemas['CustomerSubscription'] | schemas['CustomerOrder'] - products: schemas['CustomerProduct'][] - api: Client -}) => { - // Render order details - return 'recurring_interval' in item ? ( - - ) : ( - - ) -} diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx index 9b2e16ae36..ab622be351 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx @@ -1,8 +1,14 @@ import revalidate from '@/app/actions' +import { useCustomerPaymentMethods } from '@/hooks/queries' import { Client, schemas } from '@polar-sh/client' +import Button from '@polar-sh/ui/components/atoms/Button' import { useCallback } from 'react' +import { Modal } from '../Modal' +import { useModal } from '../Modal/useModal' +import { Well, WellContent, WellFooter, WellHeader } from '../Shared/Well' import CustomerSubscriptionDetails from '../Subscriptions/CustomerSubscriptionDetails' -import { CustomerUsage } from './CustomerUsage' +import { AddPaymentMethodModal } from './AddPaymentMethodModal' +import PaymentMethod from './PaymentMethod' interface CustomerPortalOverviewProps { organization: schemas['Organization'] @@ -17,13 +23,39 @@ export const CustomerPortalOverview = ({ products, api, }: CustomerPortalOverviewProps) => { + const { + isShown: isAddPaymentMethodModalOpen, + hide: hideAddPaymentMethodModal, + show: showAddPaymentMethodModal, + } = useModal() + const multipleSubscriptions = organization.subscription_settings.allow_multiple_subscriptions const activeSubscription = subscriptions.find((s) => s.status === 'active') + const { data: paymentMethods } = useCustomerPaymentMethods(api) + return (
+ + +

Payment Method

+

+ The method used for subscriptions & one-time purchases +

+
+ + {paymentMethods?.items.map((pm) => ( + + ))} + + + + +
{multipleSubscriptions ? ( )} - -
-
-
-

Overview

-
- - Current Billing Cycle - - Jan 27, 9:00 - Feb 27, 9:00 -
-
- -
-
-
- Product -
-
- Included -
-
- On-demand -
-
- Charge -
- - {[ - { - name: 'Observability Events', - included: '1M / 1M', - onDemand: '~15M', - charge: '$13.25', - }, - { - name: 'Edge Middleware Invocations', - included: '1M / 1M', - onDemand: '~2.7M', - charge: '$2.60', - }, - { - name: 'Function Invocations', - included: '1M / 1M', - onDemand: '~2.3M', - charge: '$1.80', - }, - { - name: 'Edge Function Execution Units', - included: '125k / 1M', - onDemand: '$0', - charge: '$0', - }, - { - name: 'Edge Requests', - included: '10M / 1M', - onDemand: '$0', - charge: '$0', - }, - ].map((item) => ( -
-
-
- {item.name} -
-
{item.included}
-
{item.onDemand}
-
{item.charge}
-
- ))} -
-
-
-
- - + { + revalidate(`customer_portal`) + hideAddPaymentMethodModal() + }} + hide={hideAddPaymentMethodModal} + /> + } + />
) } @@ -136,8 +106,8 @@ const SingleSubscriptionOverview = ({ }, []) return ( -
-
+
+
{subscription ? ( +
-
•••• {paymentMethod.card.last4}
-
-
- Expires {paymentMethod.card.exp_month}/{paymentMethod.card.exp_year} +
+
•••• {paymentMethod.card.last4}
+ + Expires {paymentMethod.card.exp_month}/{paymentMethod.card.exp_year} + +
) diff --git a/clients/apps/web/src/components/Shared/Well.tsx b/clients/apps/web/src/components/Shared/Well.tsx new file mode 100644 index 0000000000..7391f7c33f --- /dev/null +++ b/clients/apps/web/src/components/Shared/Well.tsx @@ -0,0 +1,58 @@ +import { twMerge } from 'tailwind-merge' + +export interface WellProps { + className?: string + children: React.ReactNode +} + +export const Well = ({ children, className }: WellProps) => { + return ( +
+ {children} +
+ ) +} + +export interface WellHeaderProps { + className?: string + children: React.ReactNode +} + +export const WellHeader = ({ children, className }: WellHeaderProps) => { + return ( +
+ {children} +
+ ) +} + +export interface WellContentProps { + className?: string + children: React.ReactNode +} + +export const WellContent = ({ children, className }: WellContentProps) => { + return ( +
+ {children} +
+ ) +} + +export interface WellFooterProps { + className?: string + children: React.ReactNode +} + +export const WellFooter = ({ children, className }: WellFooterProps) => { + return ( +
+ {children} +
+ ) +} diff --git a/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx b/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx index eeadca5fc0..5a0fe87b34 100644 --- a/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx +++ b/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx @@ -10,6 +10,7 @@ import { import { Client, schemas } from '@polar-sh/client' import Button from '@polar-sh/ui/components/atoms/Button' import ShadowBox from '@polar-sh/ui/components/atoms/ShadowBox' +import Link from 'next/link' import { useMemo, useState } from 'react' import { InlineModal } from '../Modal/InlineModal' import CustomerCancellationModal from './CustomerCancellationModal' @@ -161,16 +162,13 @@ const CustomerSubscriptionDetails = ({ {primaryAction.label} )} - {!isCanceled && ( - - )} + setShowCancelModal(false)} From 7c2793799fb6710f280f5c1feb81011c3cd7c1a2 Mon Sep 17 00:00:00 2001 From: Emil Widlund Date: Wed, 26 Feb 2025 13:33:39 +0100 Subject: [PATCH 08/15] refinements to subscriptions in customer portal --- .../[organization]/portal/Navigation.tsx | 8 +- .../CustomerPortalGrantsModal.tsx | 0 .../CustomerPortal/CustomerPortalOverview.tsx | 116 +++------ .../CustomerPortalSubscription.tsx | 227 ++++++++++++------ .../CustomerPortal/PaymentMethod.tsx | 49 ++-- .../CustomerSubscriptionDetails.tsx | 30 ++- 6 files changed, 234 insertions(+), 196 deletions(-) create mode 100644 clients/apps/web/src/components/CustomerPortal/CustomerPortalGrantsModal.tsx diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx index 524aa17742..e399fa3a39 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx @@ -7,13 +7,13 @@ import { usePathname, useSearchParams } from 'next/navigation' import { twMerge } from 'tailwind-merge' const links = (organization: schemas['Organization']) => [ - { href: `/${organization.slug}/portal`, label: 'Overview' }, + { href: `/${organization.slug}/portal/`, label: 'Overview' }, { - href: `/${organization.slug}/portal/subscriptions`, + href: `/${organization.slug}/portal/subscriptions/`, label: 'Subscriptions', }, - { href: `/${organization.slug}/portal/orders`, label: 'Orders' }, - { href: `/${organization.slug}/portal/usage`, label: 'Usage' }, + { href: `/${organization.slug}/portal/orders/`, label: 'Orders' }, + { href: `/${organization.slug}/portal/usage/`, label: 'Usage' }, ] export const Navigation = ({ diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalGrantsModal.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalGrantsModal.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx index ab622be351..273ab2af3e 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx @@ -2,10 +2,11 @@ import revalidate from '@/app/actions' import { useCustomerPaymentMethods } from '@/hooks/queries' import { Client, schemas } from '@polar-sh/client' import Button from '@polar-sh/ui/components/atoms/Button' +import { Separator } from '@polar-sh/ui/components/ui/separator' import { useCallback } from 'react' import { Modal } from '../Modal' import { useModal } from '../Modal/useModal' -import { Well, WellContent, WellFooter, WellHeader } from '../Shared/Well' +import { Well, WellContent, WellHeader } from '../Shared/Well' import CustomerSubscriptionDetails from '../Subscriptions/CustomerSubscriptionDetails' import { AddPaymentMethodModal } from './AddPaymentMethodModal' import PaymentMethod from './PaymentMethod' @@ -29,48 +30,35 @@ export const CustomerPortalOverview = ({ show: showAddPaymentMethodModal, } = useModal() - const multipleSubscriptions = - organization.subscription_settings.allow_multiple_subscriptions - - const activeSubscription = subscriptions.find((s) => s.status === 'active') - const { data: paymentMethods } = useCustomerPaymentMethods(api) return (
- - -

Payment Method

-

- The method used for subscriptions & one-time purchases -

+ + +
+

Payment Methods

+

+ Methods used for subscriptions & one-time purchases +

+
+
- + + {paymentMethods?.items.map((pm) => ( - + ))} - - -
- {multipleSubscriptions ? ( - - ) : ( - - )} + { - const onSubscriptionUpdate = useCallback(async () => { - await revalidate(`customer_portal`) - }, []) - - return ( -
-
- {subscription ? ( - - ) : ( -
-

- No active subscription -

-
- )} -
-
- ) -} - interface MultipleSubscriptionOverviewProps { organization: schemas['Organization'] subscriptions: schemas['CustomerSubscription'][] @@ -134,7 +84,7 @@ interface MultipleSubscriptionOverviewProps { api: Client } -const MultipleSubscriptionOverview = ({ +const SubscriptionsOverview = ({ organization, subscriptions, products, @@ -146,17 +96,15 @@ const MultipleSubscriptionOverview = ({ return (
-
- {subscriptions.map((s) => ( - - ))} -
+ {subscriptions.map((s) => ( + + ))}
) } diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalSubscription.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSubscription.tsx index 862bce48c9..fbdb7f51a7 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortalSubscription.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSubscription.tsx @@ -13,15 +13,16 @@ import FormattedDateTime from '@polar-sh/ui/components/atoms/FormattedDateTime' import { List, ListItem } from '@polar-sh/ui/components/atoms/List' import { formatCurrencyAndAmount } from '@polar-sh/ui/lib/money' import { useCallback } from 'react' +import AmountLabel from '../Shared/AmountLabel' +import { DetailRow } from '../Shared/DetailRow' +import { SubscriptionStatusLabel } from '../Subscriptions/utils' const CustomerPortalSubscription = ({ api, subscription, - products, }: { api: Client subscription: schemas['CustomerSubscription'] - products: schemas['CustomerProduct'][] }) => { const { data: benefitGrants } = useCustomerBenefitGrants(api, { subscription_id: subscription.id, @@ -47,85 +48,157 @@ const CustomerPortalSubscription = ({ const hasInvoices = orders?.items && orders.items.length > 0 return ( - <> -
-
- {(benefitGrants?.items.length ?? 0) > 0 && ( -
- - {benefitGrants?.items.map((benefitGrant) => ( - - - - ))} - -
- )} -
+
+
+

{subscription.product.name}

+
-
- {hasInvoices && ( -
-

Invoices

- ( - - ), - }, - { - accessorKey: 'product.name', - header: 'Product', - cell: ({ row }) => row.original.product.name, - }, - { - accessorKey: 'amount', - header: 'Amount', - cell: ({ row }) => ( - - {formatCurrencyAndAmount( - row.original.amount, - row.original.currency, - 0, - )} - - ), - }, +
+ + ) : ( + 'Free' + ) + } + /> + } + /> + {subscription.started_at && ( + + {new Date(subscription.started_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + } + /> + )} + {!subscription.ended_at && subscription.current_period_end && ( + + {new Date(subscription.current_period_end).toLocaleDateString( + 'en-US', { - accessorKey: 'id', - header: '', - cell: ({ row }) => ( - - - - ), + year: 'numeric', + month: 'long', + day: 'numeric', }, - ]} - /> -
- )} -
+ )} + + } + /> + )} + {subscription.ended_at && ( + + {new Date(subscription.ended_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + } + /> + )} +
+ +
+

Benefit Grants

+ {(benefitGrants?.items.length ?? 0) > 0 ? ( +
+ + {benefitGrants?.items.map((benefitGrant) => ( + + + + ))} + +
+ ) : ( +
+ + This subscription has no benefit grants + +
+ )} +
+ +
+ {hasInvoices && ( +
+

Invoices

+ ( + + ), + }, + { + accessorKey: 'amount', + header: 'Amount', + cell: ({ row }) => ( + + {formatCurrencyAndAmount( + row.original.amount, + row.original.currency, + 0, + )} + + ), + }, + { + accessorKey: 'id', + header: '', + cell: ({ row }) => ( + + + + ), + }, + ]} + /> +
+ )}
- +
) } diff --git a/clients/apps/web/src/components/CustomerPortal/PaymentMethod.tsx b/clients/apps/web/src/components/CustomerPortal/PaymentMethod.tsx index a1e788d074..97a50e2081 100644 --- a/clients/apps/web/src/components/CustomerPortal/PaymentMethod.tsx +++ b/clients/apps/web/src/components/CustomerPortal/PaymentMethod.tsx @@ -1,6 +1,7 @@ import { useDeleteCustomerPaymentMethod } from '@/hooks/queries' import type { Client, operations, schemas } from '@polar-sh/client' import Button from '@polar-sh/ui/components/atoms/Button' +import { Status } from '@polar-sh/ui/components/atoms/Status' import { X } from 'lucide-react' import CreditCardBrandIcon from '../CreditCardBrandIcon' @@ -22,15 +23,15 @@ const PaymentMethodCard = ({ } = paymentMethod return ( -
-
- -
-
•••• {paymentMethod.card.last4}
- - Expires {paymentMethod.card.exp_month}/{paymentMethod.card.exp_year} - -
+
+ +
+ + {`${paymentMethod.card.brand} •••• ${paymentMethod.card.last4}`} + + + Expires {paymentMethod.card.exp_month}/{paymentMethod.card.exp_year} +
) @@ -56,18 +57,24 @@ const PaymentMethod = ({ ) : (
{paymentMethod.type}
)} - {paymentMethod.default && ( -
Default
- )} - +
+ {paymentMethod.default && ( + + )} + +
) } diff --git a/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx b/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx index 5a0fe87b34..e9aab2259f 100644 --- a/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx +++ b/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx @@ -10,9 +10,10 @@ import { import { Client, schemas } from '@polar-sh/client' import Button from '@polar-sh/ui/components/atoms/Button' import ShadowBox from '@polar-sh/ui/components/atoms/ShadowBox' -import Link from 'next/link' import { useMemo, useState } from 'react' +import CustomerPortalSubscription from '../CustomerPortal/CustomerPortalSubscription' import { InlineModal } from '../Modal/InlineModal' +import { useModal } from '../Modal/useModal' import CustomerCancellationModal from './CustomerCancellationModal' import CustomerChangePlanModal from './CustomerChangePlanModal' @@ -32,6 +33,12 @@ const CustomerSubscriptionDetails = ({ const [showChangePlanModal, setShowChangePlanModal] = useState(false) const [showCancelModal, setShowCancelModal] = useState(false) + const { + isShown: isBenefitGrantsModalOpen, + hide: hideBenefitGrantsModal, + show: showBenefitGrantsModal, + } = useModal() + const cancelSubscription = useCustomerCancelSubscription(api) const isCanceled = @@ -151,24 +158,19 @@ const CustomerSubscriptionDetails = ({ )}
-
+
{primaryAction && ( )} - - - + setShowCancelModal(false)} @@ -191,6 +193,14 @@ const CustomerSubscriptionDetails = ({ /> } /> + + + } + /> ) } From cb059cbc37c166f6050fa98a2c29d208694e6df3 Mon Sep 17 00:00:00 2001 From: Emil Widlund Date: Fri, 28 Feb 2025 09:16:30 +0100 Subject: [PATCH 09/15] add mobile layout to customer portal --- .../[organization]/portal/(topbar)/layout.tsx | 40 ---------- .../[organization]/portal/Navigation.tsx | 69 ++++++++++++------ .../authenticate/ClientPage.tsx | 0 .../{(topbar) => }/authenticate/page.tsx | 0 .../(main)/[organization]/portal/layout.tsx | 39 ++++------ .../{(topbar) => }/orders/[id]/ClientPage.tsx | 0 .../{(topbar) => }/orders/[id]/page.tsx | 0 .../{(topbar) => }/request/ClientPage.tsx | 0 .../portal/{(topbar) => }/request/page.tsx | 0 .../[organization]/portal/settings/page.tsx | 67 +++++++++++++++++ .../subscriptions/[id]/ClientPage.tsx | 11 +-- .../subscriptions/[id]/page.tsx | 4 +- .../CustomerPortal/CustomerPortal.tsx | 2 +- .../CustomerPortal/CustomerPortalOverview.tsx | 55 +------------- .../CustomerPortal/CustomerPortalSettings.tsx | 73 +++++++++++++++++++ 15 files changed, 207 insertions(+), 153 deletions(-) delete mode 100644 clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/layout.tsx rename clients/apps/web/src/app/(main)/[organization]/portal/{(topbar) => }/authenticate/ClientPage.tsx (100%) rename clients/apps/web/src/app/(main)/[organization]/portal/{(topbar) => }/authenticate/page.tsx (100%) rename clients/apps/web/src/app/(main)/[organization]/portal/{(topbar) => }/orders/[id]/ClientPage.tsx (100%) rename clients/apps/web/src/app/(main)/[organization]/portal/{(topbar) => }/orders/[id]/page.tsx (100%) rename clients/apps/web/src/app/(main)/[organization]/portal/{(topbar) => }/request/ClientPage.tsx (100%) rename clients/apps/web/src/app/(main)/[organization]/portal/{(topbar) => }/request/page.tsx (100%) create mode 100644 clients/apps/web/src/app/(main)/[organization]/portal/settings/page.tsx rename clients/apps/web/src/app/(main)/[organization]/portal/{(topbar) => }/subscriptions/[id]/ClientPage.tsx (66%) rename clients/apps/web/src/app/(main)/[organization]/portal/{(topbar) => }/subscriptions/[id]/page.tsx (94%) create mode 100644 clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/layout.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/layout.tsx deleted file mode 100644 index bca537f32d..0000000000 --- a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/layout.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import PublicLayout from '@/components/Layout/PublicLayout' -import { getServerSideAPI } from '@/utils/client/serverside' -import { getOrganizationOrNotFound } from '@/utils/customerPortal' -import Avatar from '@polar-sh/ui/components/atoms/Avatar' -import Link from 'next/link' - -export default async function Layout({ - params, - children, -}: { - params: { organization: string } - children: React.ReactNode -}) { - const api = getServerSideAPI() - const { organization } = await getOrganizationOrNotFound( - api, - params.organization, - ) - - return ( -
- -
- - -

{organization.name}

- -
- {children} -
-
- ) -} diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx index e399fa3a39..afe9f9e522 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx @@ -2,18 +2,21 @@ import { usePostHog } from '@/hooks/posthog' import { schemas } from '@polar-sh/client' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@polar-sh/ui/components/atoms/Select' import Link from 'next/link' import { usePathname, useSearchParams } from 'next/navigation' import { twMerge } from 'tailwind-merge' const links = (organization: schemas['Organization']) => [ { href: `/${organization.slug}/portal/`, label: 'Overview' }, - { - href: `/${organization.slug}/portal/subscriptions/`, - label: 'Subscriptions', - }, - { href: `/${organization.slug}/portal/orders/`, label: 'Orders' }, { href: `/${organization.slug}/portal/usage/`, label: 'Usage' }, + { href: `/${organization.slug}/portal/settings/`, label: 'Settings' }, ] export const Navigation = ({ @@ -37,23 +40,43 @@ export const Navigation = ({ } return ( - + <> + + + ) } diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/authenticate/ClientPage.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/authenticate/ClientPage.tsx similarity index 100% rename from clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/authenticate/ClientPage.tsx rename to clients/apps/web/src/app/(main)/[organization]/portal/authenticate/ClientPage.tsx diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/authenticate/page.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/authenticate/page.tsx similarity index 100% rename from clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/authenticate/page.tsx rename to clients/apps/web/src/app/(main)/[organization]/portal/authenticate/page.tsx diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx index 41c959dd33..ba9042bb77 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/layout.tsx @@ -1,16 +1,8 @@ import { getServerSideAPI } from '@/utils/client/serverside' import { getOrganizationOrNotFound } from '@/utils/customerPortal' import Avatar from '@polar-sh/ui/components/atoms/Avatar' -import { Separator } from '@polar-sh/ui/components/ui/separator' import { Navigation } from './Navigation' -const links = [ - { href: '/', label: 'Overview' }, - { href: '/subscriptions', label: 'Subscriptions' }, - { href: '/usage', label: 'Usage' }, - { href: '/orders', label: 'Orders' }, -] - export default async function Layout({ params, children, @@ -26,25 +18,24 @@ export default async function Layout({ return (
-
-
- -

{organization.name}

-
-
-

Customer Portal

+
+
+
+ +

{organization.name}

+
+
+

Customer Portal

+
- -
+
-
- {children} -
+
{children}
) diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/orders/[id]/ClientPage.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/orders/[id]/ClientPage.tsx similarity index 100% rename from clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/orders/[id]/ClientPage.tsx rename to clients/apps/web/src/app/(main)/[organization]/portal/orders/[id]/ClientPage.tsx diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/orders/[id]/page.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/orders/[id]/page.tsx similarity index 100% rename from clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/orders/[id]/page.tsx rename to clients/apps/web/src/app/(main)/[organization]/portal/orders/[id]/page.tsx diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/request/ClientPage.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/request/ClientPage.tsx similarity index 100% rename from clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/request/ClientPage.tsx rename to clients/apps/web/src/app/(main)/[organization]/portal/request/ClientPage.tsx diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/request/page.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/request/page.tsx similarity index 100% rename from clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/request/page.tsx rename to clients/apps/web/src/app/(main)/[organization]/portal/request/page.tsx diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/settings/page.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/settings/page.tsx new file mode 100644 index 0000000000..bf62288963 --- /dev/null +++ b/clients/apps/web/src/app/(main)/[organization]/portal/settings/page.tsx @@ -0,0 +1,67 @@ +import { CustomerPortalSettings } from '@/components/CustomerPortal/CustomerPortalSettings' +import { getServerSideAPI } from '@/utils/client/serverside' +import { getOrganizationOrNotFound } from '@/utils/customerPortal' +import type { Metadata } from 'next' + +export async function generateMetadata({ + params, +}: { + params: { organization: string } +}): Promise { + const api = getServerSideAPI() + const { organization } = await getOrganizationOrNotFound( + api, + params.organization, + ) + + return { + title: `Customer Portal | ${organization.name}`, // " | Polar is added by the template" + openGraph: { + title: `Customer Portal | ${organization.name} on Polar`, + description: `Customer Portal | ${organization.name} on Polar`, + siteName: 'Polar', + type: 'website', + images: [ + { + url: `https://polar.sh/og?org=${organization.slug}`, + width: 1200, + height: 630, + }, + ], + }, + twitter: { + images: [ + { + url: `https://polar.sh/og?org=${organization.slug}`, + width: 1200, + height: 630, + alt: `${organization.name} on Polar`, + }, + ], + card: 'summary_large_image', + title: `Customer Portal | ${organization.name} on Polar`, + description: `Customer Portal | ${organization.name} on Polar`, + }, + } +} + +export default async function Page({ + params, + searchParams, +}: { + params: { organization: string } + searchParams: { customer_session_token?: string } +}) { + const api = getServerSideAPI(searchParams.customer_session_token) + const { organization } = await getOrganizationOrNotFound( + api, + params.organization, + ) + + return ( + + ) +} diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/subscriptions/[id]/ClientPage.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/subscriptions/[id]/ClientPage.tsx similarity index 66% rename from clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/subscriptions/[id]/ClientPage.tsx rename to clients/apps/web/src/app/(main)/[organization]/portal/subscriptions/[id]/ClientPage.tsx index 5614144930..48654922f9 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/subscriptions/[id]/ClientPage.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/subscriptions/[id]/ClientPage.tsx @@ -6,22 +6,13 @@ import { schemas } from '@polar-sh/client' const ClientPage = ({ subscription, - products, customerSessionToken, }: { - organization: schemas['Organization'] - products: schemas['CustomerProduct'][] subscription: schemas['CustomerSubscription'] customerSessionToken?: string }) => { const api = createClientSideAPI(customerSessionToken) - return ( - - ) + return } export default ClientPage diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/subscriptions/[id]/page.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/subscriptions/[id]/page.tsx similarity index 94% rename from clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/subscriptions/[id]/page.tsx rename to clients/apps/web/src/app/(main)/[organization]/portal/subscriptions/[id]/page.tsx index 801fcd98be..a5ecb703d7 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/(topbar)/subscriptions/[id]/page.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/subscriptions/[id]/page.tsx @@ -54,7 +54,7 @@ export default async function Page({ searchParams: { customer_session_token?: string } }) { const api = getServerSideAPI(searchParams.customer_session_token) - const { organization, products } = await getOrganizationOrNotFound( + const { organization } = await getOrganizationOrNotFound( api, params.organization, ) @@ -85,8 +85,6 @@ export default async function Page({ return ( diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx index 79ad93c407..d5f66e9a87 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx @@ -49,7 +49,7 @@ export const CustomerPortal = ({ {orders.length > 0 && (
-

Orders

+

Orders

{ - const { - isShown: isAddPaymentMethodModalOpen, - hide: hideAddPaymentMethodModal, - show: showAddPaymentMethodModal, - } = useModal() - - const { data: paymentMethods } = useCustomerPaymentMethods(api) - return (
- - -
-

Payment Methods

-

- Methods used for subscriptions & one-time purchases -

-
- -
- - - {paymentMethods?.items.map((pm) => ( - - ))} - -
+

Overview

- { - revalidate(`customer_portal`) - hideAddPaymentMethodModal() - }} - hide={hideAddPaymentMethodModal} - /> - } - />
) } -interface MultipleSubscriptionOverviewProps { +interface SubscriptionsOverviewProps { organization: schemas['Organization'] subscriptions: schemas['CustomerSubscription'][] products: schemas['CustomerProduct'][] @@ -85,11 +37,10 @@ interface MultipleSubscriptionOverviewProps { } const SubscriptionsOverview = ({ - organization, subscriptions, products, api, -}: MultipleSubscriptionOverviewProps) => { +}: SubscriptionsOverviewProps) => { const onSubscriptionUpdate = useCallback(async () => { await revalidate(`customer_portal`) }, []) diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx new file mode 100644 index 0000000000..a28419d9a8 --- /dev/null +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx @@ -0,0 +1,73 @@ +'use client' + +import revalidate from '@/app/actions' +import { useCustomerPaymentMethods } from '@/hooks/queries' +import { createClientSideAPI } from '@/utils/client' +import { schemas } from '@polar-sh/client' +import Button from '@polar-sh/ui/components/atoms/Button' +import { Separator } from '@polar-sh/ui/components/ui/separator' +import { Modal } from '../Modal' +import { useModal } from '../Modal/useModal' +import { Well, WellContent, WellHeader } from '../Shared/Well' +import { AddPaymentMethodModal } from './AddPaymentMethodModal' +import PaymentMethod from './PaymentMethod' + +interface CustomerPortalSettingsProps { + organization: schemas['Organization'] + customerSessionToken?: string +} + +export const CustomerPortalSettings = ({ + organization, + customerSessionToken, +}: CustomerPortalSettingsProps) => { + const api = createClientSideAPI(customerSessionToken) + + const { + isShown: isAddPaymentMethodModalOpen, + hide: hideAddPaymentMethodModal, + show: showAddPaymentMethodModal, + } = useModal() + + const { data: paymentMethods } = useCustomerPaymentMethods(api) + + return ( +
+

Settings

+ + +
+

Payment Methods

+

+ Methods used for subscriptions & one-time purchases +

+
+ +
+ + + {paymentMethods?.items.map((pm) => ( + + ))} + +
+ + { + revalidate(`customer_portal`) + hideAddPaymentMethodModal() + }} + hide={hideAddPaymentMethodModal} + /> + } + /> +
+ ) +} From 96db0c975bbf224ff890c0445f12b42f9f501ea9 Mon Sep 17 00:00:00 2001 From: Emil Widlund Date: Fri, 28 Feb 2025 14:25:45 +0100 Subject: [PATCH 10/15] add billing address --- .../[organization]/portal/ClientPage.tsx | 54 ++++---- .../[organization]/portal/Navigation.tsx | 6 +- .../app/(main)/[organization]/portal/page.tsx | 2 +- .../CustomerPortal/CustomerPortal.tsx | 117 +++++++++--------- .../CustomerPortal/CustomerPortalOverview.tsx | 22 ++-- .../CustomerPortal/CustomerPortalSettings.tsx | 31 ++++- .../CustomerPortal/EditBillingDetails.tsx | 45 ++++--- 7 files changed, 155 insertions(+), 122 deletions(-) diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/ClientPage.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/ClientPage.tsx index e2bfaea783..00fec20539 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/ClientPage.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/ClientPage.tsx @@ -1,33 +1,33 @@ -'use client' +"use client"; -import { CustomerPortal } from '@/components/CustomerPortal/CustomerPortal' -import { schemas } from '@polar-sh/client' -import { NuqsAdapter } from 'nuqs/adapters/next/app' +import { CustomerPortal } from "@/components/CustomerPortal/CustomerPortal"; +import { schemas } from "@polar-sh/client"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; const ClientPage = ({ - organization, - products, - subscriptions, - orders, - customerSessionToken, + organization, + products, + subscriptions, + oneTimePurchases: orders, + customerSessionToken, }: { - organization: schemas['Organization'] - products: schemas['CustomerProduct'][] - subscriptions: schemas['ListResource_CustomerSubscription_'] - orders: schemas['ListResource_CustomerOrder_'] - customerSessionToken?: string + organization: schemas["Organization"]; + products: schemas["CustomerProduct"][]; + subscriptions: schemas["ListResource_CustomerSubscription_"]; + oneTimePurchases: schemas["ListResource_CustomerOrder_"]; + customerSessionToken?: string; }) => { - return ( - - - - ) -} + return ( + + + + ); +}; -export default ClientPage +export default ClientPage; diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx index afe9f9e522..50e8b02fa2 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx @@ -14,9 +14,9 @@ import { usePathname, useSearchParams } from 'next/navigation' import { twMerge } from 'tailwind-merge' const links = (organization: schemas['Organization']) => [ - { href: `/${organization.slug}/portal/`, label: 'Overview' }, - { href: `/${organization.slug}/portal/usage/`, label: 'Usage' }, - { href: `/${organization.slug}/portal/settings/`, label: 'Settings' }, + { href: `/${organization.slug}/portal`, label: 'Overview' }, + { href: `/${organization.slug}/portal/usage`, label: 'Usage' }, + { href: `/${organization.slug}/portal/settings`, label: 'Settings' }, ] export const Navigation = ({ diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/page.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/page.tsx index 8032fe18ba..e6cf25f405 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/page.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/page.tsx @@ -116,7 +116,7 @@ export default async function Page({ organization={organization} products={products} subscriptions={subscriptions} - orders={oneTimePurchases} + oneTimePurchases={oneTimePurchases} customerSessionToken={searchParams.customer_session_token} /> ) diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx index d5f66e9a87..d168158d3c 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx @@ -14,7 +14,7 @@ export interface CustomerPortalProps { organization: schemas['Organization'] products: schemas['CustomerProduct'][] subscriptions: schemas['CustomerSubscription'][] - orders: schemas['CustomerOrder'][] + oneTimePurchases: schemas['CustomerOrder'][] customerSessionToken?: string } @@ -22,7 +22,7 @@ export const CustomerPortal = ({ organization, products, subscriptions, - orders, + oneTimePurchases, customerSessionToken, }: CustomerPortalProps) => { const api = createClientSideAPI(customerSessionToken) @@ -45,65 +45,62 @@ export const CustomerPortal = ({ products={products} subscriptions={subscriptions} /> - - {orders.length > 0 && ( -
-
-

Orders

-
- ( - - ), - }, - { - accessorKey: 'product.name', - header: 'Product', - cell: ({ row }) => row.original.product.name, - }, - { - accessorKey: 'amount', - header: 'Amount', - cell: ({ row }) => ( - - {formatCurrencyAndAmount( - row.original.amount, - row.original.currency, - 0, - )} - - ), - }, - { - accessorKey: 'id', - header: '', - cell: ({ row }) => ( - - - - ), - }, - ]} - /> +
+
+

Product Purchases

- )} + ( + + ), + }, + { + accessorKey: 'product.name', + header: 'Product', + cell: ({ row }) => row.original.product.name, + }, + { + accessorKey: 'amount', + header: 'Amount', + cell: ({ row }) => ( + + {formatCurrencyAndAmount( + row.original.amount, + row.original.currency, + 0, + )} + + ), + }, + { + accessorKey: 'id', + header: '', + cell: ({ row }) => ( + + + + ), + }, + ]} + /> +
) } diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx index 2f31b1a49a..615d978610 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx @@ -18,7 +18,6 @@ export const CustomerPortalOverview = ({ }: CustomerPortalOverviewProps) => { return (
-

Overview

- {subscriptions.map((s) => ( - - ))} +

Subscriptions

+
+ {subscriptions.map((s) => ( + + ))} +
) } diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx index a28419d9a8..b0162f40f6 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx @@ -1,7 +1,7 @@ 'use client' import revalidate from '@/app/actions' -import { useCustomerPaymentMethods } from '@/hooks/queries' +import { useCustomer, useCustomerPaymentMethods } from '@/hooks/queries' import { createClientSideAPI } from '@/utils/client' import { schemas } from '@polar-sh/client' import Button from '@polar-sh/ui/components/atoms/Button' @@ -10,6 +10,7 @@ import { Modal } from '../Modal' import { useModal } from '../Modal/useModal' import { Well, WellContent, WellHeader } from '../Shared/Well' import { AddPaymentMethodModal } from './AddPaymentMethodModal' +import EditBillingDetails from './EditBillingDetails' import PaymentMethod from './PaymentMethod' interface CustomerPortalSettingsProps { @@ -28,14 +29,18 @@ export const CustomerPortalSettings = ({ hide: hideAddPaymentMethodModal, show: showAddPaymentMethodModal, } = useModal() - + const { data: customer } = useCustomer(api) const { data: paymentMethods } = useCustomerPaymentMethods(api) + if (!customer) { + return null + } + return (

Settings

- +

Payment Methods

@@ -53,6 +58,26 @@ export const CustomerPortalSettings = ({ ))} + + +

+

Billing Details

+

+ Update your billing details +

+
+ + + + { + revalidate(`customer_portal`) + }} + /> + + { - const { error } = await updateCustomer.mutateAsync(data) + const { error, data: updatedCustomer } = + await updateCustomer.mutateAsync(data) if (error) { if (error.detail) { setValidationErrors(error.detail, setError) } return } + + reset({ + ...updatedCustomer, + tax_id: updatedCustomer.tax_id ? updatedCustomer.tax_id[0] : null, + }) + onSuccess() }, [updateCustomer, onSuccess, setError], @@ -108,7 +116,7 @@ const EditBillingDetails = ({ )} /> - + Billing address ( - <> +
- +
)} />
@@ -137,7 +145,7 @@ const EditBillingDetails = ({ control={control} name="billing_address.line2" render={({ field }) => ( - <> +
- +
)} /> -
+
( - <> +
- +
)} />
@@ -183,7 +191,7 @@ const EditBillingDetails = ({ required: 'This field is required', }} render={({ field }) => ( - <> +
- +
)} /> @@ -206,14 +214,14 @@ const EditBillingDetails = ({ required: 'This field is required', }} render={({ field }) => ( - <> +
- +
)} /> @@ -226,7 +234,7 @@ const EditBillingDetails = ({ required: 'This field is required', }} render={({ field }) => ( - <> +
- +
)} /> @@ -266,10 +274,11 @@ const EditBillingDetails = ({ /> From fef94dd467d1267c5ae85fdc477cf9aaafd58450 Mon Sep 17 00:00:00 2001 From: Emil Widlund Date: Fri, 28 Feb 2025 15:06:03 +0100 Subject: [PATCH 11/15] finalize new customer portal --- .../[organization]/portal/Navigation.tsx | 10 ++- .../CustomerPortal/BillingAddress.tsx | 17 ---- .../CustomerPortal/CustomerPortal.tsx | 78 +++++++++++-------- .../CustomerPortalGrantsModal.tsx | 0 .../CustomerPortal/CustomerPortalOrder.tsx | 37 +++++---- .../CustomerPortal/CustomerPortalOverview.tsx | 6 ++ .../CustomerPortalSubscription.tsx | 2 +- .../CustomerSubscriptionDetails.tsx | 22 +++++- 8 files changed, 101 insertions(+), 71 deletions(-) delete mode 100644 clients/apps/web/src/components/CustomerPortal/BillingAddress.tsx delete mode 100644 clients/apps/web/src/components/CustomerPortal/CustomerPortalGrantsModal.tsx diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx index 50e8b02fa2..58a679da5c 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx @@ -14,9 +14,9 @@ import { usePathname, useSearchParams } from 'next/navigation' import { twMerge } from 'tailwind-merge' const links = (organization: schemas['Organization']) => [ - { href: `/${organization.slug}/portal`, label: 'Overview' }, - { href: `/${organization.slug}/portal/usage`, label: 'Usage' }, - { href: `/${organization.slug}/portal/settings`, label: 'Settings' }, + { href: `/${organization.slug}/portal/`, label: 'Overview' }, + { href: `/${organization.slug}/portal/usage/`, label: 'Usage' }, + { href: `/${organization.slug}/portal/settings/`, label: 'Settings' }, ] export const Navigation = ({ @@ -29,6 +29,10 @@ export const Navigation = ({ const { isFeatureEnabled } = usePostHog() const buildPath = (path: string) => { + if (typeof window === 'undefined') { + throw new Error('Navigation is not available on the server') + } + const url = new URL(window.location.origin + currentPath) url.pathname = path diff --git a/clients/apps/web/src/components/CustomerPortal/BillingAddress.tsx b/clients/apps/web/src/components/CustomerPortal/BillingAddress.tsx deleted file mode 100644 index c932946e1c..0000000000 --- a/clients/apps/web/src/components/CustomerPortal/BillingAddress.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { schemas } from '@polar-sh/client' - -const BillingAddress = ({ address }: { address: schemas['Address'] }) => { - return ( -
- {address.line1 &&
{address.line1}
} - {address.line2 &&
{address.line2}
} -
- {address.postal_code && {address.postal_code}} - {address.state && {address.state}} - {address.country && {address.country}} -
-
- ) -} - -export default BillingAddress diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx index d168158d3c..16a04feaa0 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortal.tsx @@ -1,13 +1,15 @@ 'use client' -import { useCustomerOrderInvoice } from '@/hooks/queries' import { createClientSideAPI } from '@/utils/client' import { schemas } from '@polar-sh/client' 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 { formatCurrencyAndAmount } from '@polar-sh/ui/lib/money' -import { useCallback } from 'react' +import Link from 'next/link' +import { useState } from 'react' +import { InlineModal } from '../Modal/InlineModal' +import { useModal } from '../Modal/useModal' +import CustomerPortalOrder from './CustomerPortalOrder' import { CustomerPortalOverview } from './CustomerPortalOverview' export interface CustomerPortalProps { @@ -27,15 +29,14 @@ export const CustomerPortal = ({ }: CustomerPortalProps) => { const api = createClientSideAPI(customerSessionToken) - const orderInvoiceMutation = useCustomerOrderInvoice(api) - - const openInvoice = useCallback( - async (order: schemas['CustomerOrder']) => { - const { url } = await orderInvoiceMutation.mutateAsync({ id: order.id }) - window.open(url, '_blank') - }, - [orderInvoiceMutation], - ) + const [selectedOrder, setSelectedOrder] = useState< + schemas['CustomerOrder'] | null + >(null) + const { + isShown: isOrderModalOpen, + hide: hideOrderModal, + show: showOrderModal, + } = useModal() return (
@@ -44,6 +45,7 @@ export const CustomerPortal = ({ organization={organization} products={products} subscriptions={subscriptions} + customerSessionToken={customerSessionToken} />
@@ -53,6 +55,11 @@ export const CustomerPortal = ({ data={oneTimePurchases ?? []} isLoading={false} columns={[ + { + accessorKey: 'product.name', + header: 'Product', + cell: ({ row }) => row.original.product.name, + }, { accessorKey: 'created_at', header: 'Date', @@ -64,42 +71,45 @@ export const CustomerPortal = ({ /> ), }, - { - accessorKey: 'product.name', - header: 'Product', - cell: ({ row }) => row.original.product.name, - }, - { - accessorKey: 'amount', - header: 'Amount', - cell: ({ row }) => ( - - {formatCurrencyAndAmount( - row.original.amount, - row.original.currency, - 0, - )} - - ), - }, { accessorKey: 'id', header: '', cell: ({ row }) => ( + + + ), }, ]} /> + + +
+ ) : ( + <> + ) + } + />
) diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalGrantsModal.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalGrantsModal.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalOrder.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalOrder.tsx index 45f2c6a710..4474034008 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortalOrder.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortalOrder.tsx @@ -38,20 +38,29 @@ const CustomerPortalOrder = ({ <>
- {(benefitGrants?.items.length ?? 0) > 0 && ( -
- - {benefitGrants?.items.map((benefitGrant) => ( - - - - ))} - -
- )} +
+

Benefit Grants

+ {(benefitGrants?.items.length ?? 0) > 0 ? ( +
+ + {benefitGrants?.items.map((benefitGrant) => ( + + + + ))} + +
+ ) : ( +
+ + This product has no benefit grants + +
+ )} +

{order.product.name}

diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx index 615d978610..61c6d8bba0 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortalOverview.tsx @@ -8,6 +8,7 @@ interface CustomerPortalOverviewProps { subscriptions: schemas['CustomerSubscription'][] products: schemas['CustomerProduct'][] api: Client + customerSessionToken?: string } export const CustomerPortalOverview = ({ @@ -15,10 +16,12 @@ export const CustomerPortalOverview = ({ subscriptions, products, api, + customerSessionToken, }: CustomerPortalOverviewProps) => { return (
{ const onSubscriptionUpdate = useCallback(async () => { await revalidate(`customer_portal`) @@ -55,6 +60,7 @@ const SubscriptionsOverview = ({ subscription={s} products={products} onUserSubscriptionUpdate={onSubscriptionUpdate} + customerSessionToken={customerSessionToken} /> ))}
diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalSubscription.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSubscription.tsx index fbdb7f51a7..d2bbaf39d6 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortalSubscription.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSubscription.tsx @@ -48,7 +48,7 @@ const CustomerPortalSubscription = ({ const hasInvoices = orders?.items && orders.items.length > 0 return ( -
+

{subscription.product.name}

diff --git a/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx b/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx index e9aab2259f..13ca7ae8f5 100644 --- a/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx +++ b/clients/apps/web/src/components/Subscriptions/CustomerSubscriptionDetails.tsx @@ -10,6 +10,7 @@ import { import { Client, schemas } from '@polar-sh/client' import Button from '@polar-sh/ui/components/atoms/Button' import ShadowBox from '@polar-sh/ui/components/atoms/ShadowBox' +import Link from 'next/link' import { useMemo, useState } from 'react' import CustomerPortalSubscription from '../CustomerPortal/CustomerPortalSubscription' import { InlineModal } from '../Modal/InlineModal' @@ -22,6 +23,7 @@ const CustomerSubscriptionDetails = ({ products, api, onUserSubscriptionUpdate, + customerSessionToken, }: { subscription: schemas['CustomerSubscription'] products: schemas['CustomerProduct'][] @@ -29,6 +31,7 @@ const CustomerSubscriptionDetails = ({ onUserSubscriptionUpdate: ( subscription: schemas['CustomerSubscription'], ) => void + customerSessionToken?: string }) => { const [showChangePlanModal, setShowChangePlanModal] = useState(false) const [showCancelModal, setShowCancelModal] = useState(false) @@ -168,9 +171,22 @@ const CustomerSubscriptionDetails = ({ {primaryAction.label} )} - + + + setShowCancelModal(false)} @@ -198,7 +214,9 @@ const CustomerSubscriptionDetails = ({ isShown={isBenefitGrantsModalOpen} hide={hideBenefitGrantsModal} modalContent={ - +
+ +
} /> From db95f96e41afba2d1318398d01e741f90d6d9094 Mon Sep 17 00:00:00 2001 From: Emil Widlund Date: Fri, 28 Feb 2025 15:17:42 +0100 Subject: [PATCH 12/15] fix mobile nav --- .../[organization]/portal/Navigation.tsx | 63 +++++++++++-------- .../[organization]/portal/usage/page.tsx | 7 --- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx index 58a679da5c..d660971a0d 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/Navigation.tsx @@ -10,7 +10,7 @@ import { SelectValue, } from '@polar-sh/ui/components/atoms/Select' import Link from 'next/link' -import { usePathname, useSearchParams } from 'next/navigation' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { twMerge } from 'tailwind-merge' const links = (organization: schemas['Organization']) => [ @@ -24,6 +24,7 @@ export const Navigation = ({ }: { organization: schemas['Organization'] }) => { + const router = useRouter() const currentPath = usePathname() const searchParams = useSearchParams() const { isFeatureEnabled } = usePostHog() @@ -43,41 +44,49 @@ export const Navigation = ({ return url.toString() } + const filteredLinks = links(organization).filter(({ label }) => + label === 'Usage' ? isFeatureEnabled('usage_based_billing') : true, + ) + return ( <> - href === currentPath)?.label + } + onValueChange={(value) => { + router.push( + buildPath( + filteredLinks.find(({ label }) => label === value)?.href ?? '', + ), + ) + }} + > - { - links(organization).find(({ href }) => href === currentPath) - ?.label - } + {filteredLinks.find(({ href }) => href === currentPath)?.label} - {links(organization).map((link) => ( - - {link.label} - + {filteredLinks.map((link) => ( + + {link.label} + ))} diff --git a/clients/apps/web/src/app/(main)/[organization]/portal/usage/page.tsx b/clients/apps/web/src/app/(main)/[organization]/portal/usage/page.tsx index a498311276..da4e7ee634 100644 --- a/clients/apps/web/src/app/(main)/[organization]/portal/usage/page.tsx +++ b/clients/apps/web/src/app/(main)/[organization]/portal/usage/page.tsx @@ -3,13 +3,6 @@ import { getServerSideAPI } from '@/utils/client/serverside' import { getOrganizationOrNotFound } from '@/utils/customerPortal' import type { Metadata } from 'next' -const cacheConfig = { - cache: 'no-store' as RequestCache, - next: { - tags: ['customer_portal'], - }, -} - export async function generateMetadata({ params, }: { From 1e4e5093b3c43b4f49abb72d4d0a243122e8c75e Mon Sep 17 00:00:00 2001 From: Emil Widlund Date: Fri, 28 Feb 2025 15:36:55 +0100 Subject: [PATCH 13/15] remove unusd variable --- .../web/src/components/CustomerPortal/CustomerPortalSettings.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx index b0162f40f6..c7d7978c57 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerPortalSettings.tsx @@ -19,7 +19,6 @@ interface CustomerPortalSettingsProps { } export const CustomerPortalSettings = ({ - organization, customerSessionToken, }: CustomerPortalSettingsProps) => { const api = createClientSideAPI(customerSessionToken) From 34c6b1dc21cafa5a1f3e2e01689db2f0f1d95797 Mon Sep 17 00:00:00 2001 From: Emil Widlund Date: Fri, 28 Feb 2025 15:59:29 +0100 Subject: [PATCH 14/15] remove unused var --- .../web/src/components/CustomerPortal/CustomerUsage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/clients/apps/web/src/components/CustomerPortal/CustomerUsage.tsx b/clients/apps/web/src/components/CustomerPortal/CustomerUsage.tsx index a1a5f3214e..928537d68f 100644 --- a/clients/apps/web/src/components/CustomerPortal/CustomerUsage.tsx +++ b/clients/apps/web/src/components/CustomerPortal/CustomerUsage.tsx @@ -200,7 +200,7 @@ export const CustomerUsage = ({ organizationId }: CustomerUsageProps) => { { header: 'Value', accessorKey: 'value', - cell: ({ row }) => { + cell: () => { return ( {computeCumulativeValue( @@ -220,7 +220,7 @@ export const CustomerUsage = ({ organizationId }: CustomerUsageProps) => { { header: 'Included', accessorKey: 'included', - cell: ({ row }) => { + cell: () => { return ( {' '} @@ -242,7 +242,7 @@ export const CustomerUsage = ({ organizationId }: CustomerUsageProps) => { { header: 'Overage', accessorKey: 'overage', - cell: ({ row }) => { + cell: () => { return $0.00 }, }, @@ -307,7 +307,7 @@ export const CustomerUsage = ({ organizationId }: CustomerUsageProps) => { { header: 'Status', accessorKey: 'status', - cell: ({ row }) => { + cell: () => { return ( Date: Fri, 28 Feb 2025 16:05:01 +0100 Subject: [PATCH 15/15] remove portal preview --- .../Customization/CustomizationPage.tsx | 9 ---- .../Customization/CustomizationProvider.tsx | 2 +- .../Portal/PortalCustomization.tsx | 10 ----- .../Customization/Portal/PortalPreview.tsx | 23 ---------- .../Customization/Portal/PortalSidebar.tsx | 43 ------------------- 5 files changed, 1 insertion(+), 86 deletions(-) delete mode 100644 clients/apps/web/src/components/Customization/Portal/PortalCustomization.tsx delete mode 100644 clients/apps/web/src/components/Customization/Portal/PortalPreview.tsx delete mode 100644 clients/apps/web/src/components/Customization/Portal/PortalSidebar.tsx diff --git a/clients/apps/web/src/components/Customization/CustomizationPage.tsx b/clients/apps/web/src/components/Customization/CustomizationPage.tsx index 60def01cea..b280a987f7 100644 --- a/clients/apps/web/src/components/Customization/CustomizationPage.tsx +++ b/clients/apps/web/src/components/Customization/CustomizationPage.tsx @@ -18,7 +18,6 @@ import { useRouter, useSearchParams } from 'next/navigation' import { useContext, useMemo } from 'react' import { useForm } from 'react-hook-form' import { CheckoutCustomization } from './Checkout/CheckoutCustomization' -import { PortalCustomization } from './Portal/PortalCustomization' import { StorefrontCustomization } from './Storefront/StorefrontCustomization' import { StorefrontSidebar } from './Storefront/StorefrontSidebar' @@ -51,8 +50,6 @@ const Customization = () => { switch (customizationMode) { case 'checkout': return isLoading ? null : - case 'portal': - return case 'storefront': default: return @@ -102,12 +99,6 @@ const Customization = () => { > Checkout - - Portal - { - return ( - <> - - {/* */} - - ) -} diff --git a/clients/apps/web/src/components/Customization/Portal/PortalPreview.tsx b/clients/apps/web/src/components/Customization/Portal/PortalPreview.tsx deleted file mode 100644 index b66224ba16..0000000000 --- a/clients/apps/web/src/components/Customization/Portal/PortalPreview.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { CustomerPortal } from '@/components/CustomerPortal/CustomerPortal' -import { MaintainerOrganizationContext } from '@/providers/maintainerOrganization' -import { schemas } from '@polar-sh/client' -import ShadowBox from '@polar-sh/ui/components/atoms/ShadowBox' -import { useContext } from 'react' -import { ORDER_PREVIEW, SUBSCRIPTION_ORDER_PREVIEW } from '../utils' - -export const PortalPreview = () => { - const { organization: org } = useContext(MaintainerOrganizationContext) - - return ( - - - - ) -} diff --git a/clients/apps/web/src/components/Customization/Portal/PortalSidebar.tsx b/clients/apps/web/src/components/Customization/Portal/PortalSidebar.tsx deleted file mode 100644 index e20a7c6667..0000000000 --- a/clients/apps/web/src/components/Customization/Portal/PortalSidebar.tsx +++ /dev/null @@ -1,43 +0,0 @@ -'use client' - -import Button from '@polar-sh/ui/components/atoms/Button' -import ShadowBox from '@polar-sh/ui/components/atoms/ShadowBox' -import { PropsWithChildren } from 'react' -import { twMerge } from 'tailwind-merge' - -const PortalSidebarContentWrapper = ({ - title, - children, -}: PropsWithChildren<{ - title: string -}>) => { - return ( - -
-
-

{title}

-
-
{children}
-
-
- ) -} - -const PortalForm = () => { - return <> -} - -export const PortalSidebar = () => { - return ( - -
{}} className="flex flex-col gap-y-8"> - -
- -
- -
- ) -}