Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(console): Seat removal #2687

Merged
merged 3 commits into from
Sep 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions apps/console/app/components/Billing/seating.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Listbox, Transition } from '@headlessui/react'
import { Button } from '@proofzero/design-system'
import { Text } from '@proofzero/design-system/src/atoms/text/Text'
import { ToastWithLink } from '@proofzero/design-system/src/atoms/toast/ToastWithLink'
Expand All @@ -9,6 +10,9 @@ import classnames from 'classnames'
import { useState } from 'react'
import { FaTrash } from 'react-icons/fa'
import {
HiArrowNarrowRight,
HiChevronDown,
HiChevronUp,
HiMinus,
HiOutlineShoppingCart,
HiOutlineX,
Expand Down Expand Up @@ -183,20 +187,219 @@ export const PurchaseGroupSeatingModal = ({
)
}

export const RemoveGroupSeatingModal = ({
isOpen,
setIsOpen,
removalFn,
seatsUsed,
totalSeats,
paymentIsSetup,
}: {
isOpen: boolean
setIsOpen: (open: boolean) => void
removalFn: (quantity: number) => void
seatsUsed: number
totalSeats: number
paymentIsSetup: boolean
}) => {
const [seatsNew, setSeatsNew] = useState(seatsUsed)
return (
<Modal isOpen={isOpen} handleClose={() => setIsOpen(false)}>
<div className="max-sm:w-screen sm:min-w-[640px] lg:min-w-[764px] w-fit">
<div className="pb-2 pt-5 px-5 w-full flex flex-row items-center justify-between">
<Text size="lg" weight="semibold" className="text-left text-gray-800">
Remove Additional User Seat(s)
</Text>
<div
className={`bg-white p-2 rounded-lg text-xl cursor-pointer
hover:bg-[#F3F4F6]`}
onClick={() => {
setIsOpen(false)
}}
>
<HiOutlineX />
</div>
</div>
<section className="p-5 pt-auto w-full">
<div className="w-full border rounded-lg overflow-auto thin-scrollbar">
<div className="p-6">
<Text
size="lg"
weight="semibold"
className="text-gray-900 text-left"
>
Additional User Seats
</Text>
<ul className="pl-4">
<li className="list-disc text-sm font-medium text-gray-500 text-left">
You are currently using {seatsUsed}/{totalSeats} Additional
User Seats
</li>
<li className="list-disc text-sm font-medium text-gray-500 text-left">
You can remove some Members of your Group if you'd like to pay
for fewer Seats.
</li>
</ul>
</div>
<div className="border-b border-gray-200"></div>
<div className="p-6 flex justify-between items-center">
<div>
<Text
size="sm"
weight="medium"
className="text-gray-800 text-left"
>
Number of Additional Seats
</Text>
<Text
size="sm"
weight="medium"
className="text-gray-500 text-left"
>{`${totalSeats} x ${seatingCost}/month`}</Text>
</div>

<div className="flex flex-row text-gray-500 space-x-4">
<div className="flex flex-row items-center space-x-2">
<Text size="sm">{totalSeats} Seat(s)</Text>
<HiArrowNarrowRight />
</div>

<div className="flex flex-row">
<Listbox
value={seatsNew}
onChange={setSeatsNew}
disabled={seatsUsed === totalSeats}
as="div"
>
{({ open }) => {
return (
<div>
<Listbox.Button
className="relative w-full cursor-default border
py-1.5 px-4 text-left sm:text-sm rounded-lg
focus:border-indigo-500 focus:outline-none focus:ring-1
flex flex-row space-x-3 items-center"
>
<Text size="sm">{seatsNew}</Text>
{open ? (
<HiChevronUp className="text-right" />
) : (
<HiChevronDown className="text-right" />
)}
</Listbox.Button>
<Transition
show={open}
as="div"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="bg-gray-800"
>
<Listbox.Options
className="absolute no-scrollbar w-full bg-white
rounded-lg border max-h-[150px] max-w-[66.1833px] overflow-auto"
>
{Array.apply(null, Array(totalSeats + 1)).map(
(_, i) => {
return i >= seatsUsed ? (
<Listbox.Option
key={i}
value={i}
className="flex items-center
cursor-pointer hover:bg-gray-100
rounded-lg m-1"
>
{({ selected }) => {
return (
<div
className={`w-full h-full px-4 py-1.5
rounded-lg ${
selected ? 'bg-gray-100 font-medium' : ''
}`}
>
{i}
</div>
)
}}
</Listbox.Option>
) : null
}
)}
</Listbox.Options>
</Transition>
</div>
)
}}
</Listbox>
</div>
</div>
</div>
<div className="border-b border-gray-200"></div>

<div className="p-6 flex justify-between items-center">
<Text
size="sm"
weight="medium"
className="text-gray-800 text-left"
>
Changes to your subscription
</Text>

<div className="flex flex-row gap-2 items-center">
<Text size="lg" weight="semibold" className="text-gray-900">{`${
seatingCost * (totalSeats - seatsNew) !== 0 ? '-' : ''
}$${seatingCost * (totalSeats - seatsNew)}`}</Text>
<Text size="sm" weight="medium" className="text-gray-500">
per month
</Text>
</div>
</div>
</div>
</section>
<section className="flex flex-row-reverse gap-4 mt-auto m-5">
<Button
btnType="dangerous-alt"
disabled={
!paymentIsSetup ||
seatsUsed === totalSeats ||
seatsNew < seatsUsed ||
seatsNew === totalSeats
}
onClick={() => {
setIsOpen(false)
setSeatsNew(seatsNew)

removalFn(seatsNew)
}}
>
Remove Seat(s)
</Button>
<Button btnType="secondary-alt" onClick={() => setIsOpen(false)}>
Cancel
</Button>
</section>
</div>
</Modal>
)
}

export const GroupSeatingCard = ({
groupID,
seatsTotal,
seatsUsed,
paymentData,
purchaseFn,
removalFn,
}: {
groupID: string
seatsTotal: number
seatsUsed: number
paymentData?: PaymentData
purchaseFn: (quantity: number) => void
removalFn: (quantity: number) => void
}) => {
const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false)
const [isRemovalModalOpen, setIsRemovalModalOpen] = useState(false)

return (
<>
Expand All @@ -208,6 +411,15 @@ export const GroupSeatingCard = ({
purchaseFn={purchaseFn}
/>

<RemoveGroupSeatingModal
isOpen={isRemovalModalOpen}
setIsOpen={setIsRemovalModalOpen}
removalFn={removalFn}
seatsUsed={seatsUsed}
totalSeats={seatsTotal}
paymentIsSetup={Boolean(paymentData?.paymentMethodID)}
/>

<article className="bg-white rounded-lg border">
<header className="flex flex-col lg:flex-row justify-between lg:items-center p-4 relative">
<div>
Expand Down Expand Up @@ -308,6 +520,7 @@ export const GroupSeatingCard = ({
<button
type="button"
className="flex flex-row items-center gap-3.5 text-indigo-500 cursor-pointer rounded-b-lg disabled:text-indigo-300"
onClick={() => setIsRemovalModalOpen(true)}
>
<FaTrash className="w-3.5 h-3.5" />
<Text size="sm" weight="medium">
Expand Down
17 changes: 16 additions & 1 deletion apps/console/app/routes/__layout/billing/groups/$groupID.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,22 @@ export default () => {
quantity: groupSeats.total + quantity,
customerID: paymentData?.customerID,
txType: 'buy',
txTarget: TxProduct.Seats,
txProduct: TxProduct.Seats,
}),
},
{
method: 'post',
}
)
}}
removalFn={(quantity) => {
submit(
{
payload: JSON.stringify({
quantity: quantity,
customerID: paymentData?.customerID,
txType: 'remove',
txProduct: TxProduct.Seats,
}),
},
{
Expand Down
12 changes: 6 additions & 6 deletions apps/console/app/routes/__layout/billing/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,13 +252,13 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(
})

const fd = await request.formData()
const { customerID, quantity, txType, txTarget } = JSON.parse(
const { customerID, quantity, txType, txProduct } = JSON.parse(
fd.get('payload') as string
) as {
customerID: string
quantity: number
txType: 'buy' | 'remove'
txTarget?: TxProduct
txProduct?: TxProduct
}

if ((quantity < 1 && txType === 'buy') || quantity < 0) {
Expand All @@ -268,7 +268,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(
}

let sub
if (!txTarget || txTarget === TxProduct.Entitlements) {
if (!txProduct || txProduct === TxProduct.Entitlements) {
if (IdentityURNSpace.is(targetURN)) {
const apps = await coreClient.starbase.listApps.query()
const assignedEntitlementCount = apps.filter(
Expand All @@ -295,7 +295,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(
subscriptionID: entitlements.subscriptionID,
URN: targetURN,
})
} else if (txTarget === TxProduct.Seats) {
} else if (txProduct === TxProduct.Seats) {
const seats = await coreClient.billing.getIdentityGroupSeats.query({
URN: groupURN as IdentityGroupURN,
})
Expand Down Expand Up @@ -338,7 +338,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(
setPurchaseToastNotification({
sub,
flashSession,
txTarget,
txProduct,
})
}
if (txType === 'remove') {
Expand All @@ -347,7 +347,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(
JSON.stringify({
type: ToastType.Success,
message: `${
!txTarget || txTarget === TxProduct.Entitlements
!txProduct || txProduct === TxProduct.Entitlements
? 'Entitlement(s)'
: 'Seat(s)'
} successfully removed`,
Expand Down
2 changes: 1 addition & 1 deletion apps/console/app/routes/__layout/groups/$groupID/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ export default () => {
quantity: groupSeats.total + quantity,
customerID: paymentData?.customerID,
txType: 'buy',
txTarget: TxProduct.Seats,
txProduct: TxProduct.Seats,
}),
},
{
Expand Down
2 changes: 1 addition & 1 deletion apps/console/app/services/billing/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ export const reconcileAppSubscriptions = async (
priceID: si.price.id,
quantity: si.quantity,
}))
.filter((pq) => Boolean(pq.quantity))
.filter((pq) => pq.quantity != null)

const priceIdToPlanTypeDict = {
[env.SECRET_STRIPE_PRO_PLAN_ID]: ServicePlanType.PRO,
Expand Down
6 changes: 3 additions & 3 deletions apps/console/app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ export function useMatchesData(
export const setPurchaseToastNotification = ({
sub,
flashSession,
txTarget,
txProduct,
}: {
sub: Stripe.Subscription
flashSession: any
txTarget?: TxProduct
txProduct?: TxProduct
}) => {
const status = (sub.latest_invoice as Stripe.Invoice).status

Expand All @@ -77,7 +77,7 @@ export const setPurchaseToastNotification = ({
JSON.stringify({
type: ToastType.Success,
message: `${
!txTarget || txTarget === TxProduct.Entitlements
!txProduct || txProduct === TxProduct.Entitlements
? 'Entitlement(s)'
: 'Seat(s)'
} successfully bought`,
Expand Down
Loading