From a94f98195ba9d5573e6833ec543cc0ddba8ba596 Mon Sep 17 00:00:00 2001 From: MananTank Date: Fri, 4 Oct 2024 00:24:25 +0000 Subject: [PATCH] Show active coupon on billing page (#4917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem solved Short description of the bug fixed or feature added --- ## PR-Codex overview This PR focuses on refactoring and enhancing coupon-related components in the `Billing` section of the dashboard. It involves renaming components, improving props handling, and adding new functionalities for coupon application and management. ### Detailed summary - Renamed `CouponCard` to `CouponSection` in `Billing/index.tsx`. - Updated `SettingsCard` to use dynamic class names for buttons. - Added new stories for `CouponDetailsCardUI` and `ApplyCouponCardUI`. - Implemented optimistic updates for coupon application and deletion. - Enhanced `CouponCard` to `ApplyCouponCard` with new props for coupon application. - Introduced `CouponDetailsCardUI` for displaying active coupon details. - Improved error handling and loading states in coupon-related components. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .../@/components/blocks/DangerSettingCard.tsx | 27 +- .../src/@/components/blocks/SettingsCard.tsx | 16 +- ...tories.tsx => ApplyCouponCard.stories.tsx} | 53 +++- .../settings/Account/Billing/CouponCard.tsx | 276 ++++++++++++++---- .../Account/Billing/CouponDetails.stories.tsx | 60 ++++ .../settings/Account/Billing/index.tsx | 5 +- 6 files changed, 363 insertions(+), 74 deletions(-) rename apps/dashboard/src/components/settings/Account/Billing/{CouponCard.stories.tsx => ApplyCouponCard.stories.tsx} (50%) create mode 100644 apps/dashboard/src/components/settings/Account/Billing/CouponDetails.stories.tsx diff --git a/apps/dashboard/src/@/components/blocks/DangerSettingCard.tsx b/apps/dashboard/src/@/components/blocks/DangerSettingCard.tsx index 3a6f1cf8ac7..d0ebd69dcbd 100644 --- a/apps/dashboard/src/@/components/blocks/DangerSettingCard.tsx +++ b/apps/dashboard/src/@/components/blocks/DangerSettingCard.tsx @@ -11,9 +11,12 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { cn } from "../../lib/utils"; export function DangerSettingCard(props: { title: string; + className?: string; + footerClassName?: string; description: string; buttonLabel: string; buttonOnClick: () => void; @@ -22,18 +25,31 @@ export function DangerSettingCard(props: { title: string; description: string; }; + children?: React.ReactNode; }) { return ( -
-
+
+

{props.title}

{props.description}

+ + {props.children}
-
+
)}
diff --git a/apps/dashboard/src/components/settings/Account/Billing/CouponCard.stories.tsx b/apps/dashboard/src/components/settings/Account/Billing/ApplyCouponCard.stories.tsx similarity index 50% rename from apps/dashboard/src/components/settings/Account/Billing/CouponCard.stories.tsx rename to apps/dashboard/src/components/settings/Account/Billing/ApplyCouponCard.stories.tsx index 09323219145..26eb55f1627 100644 --- a/apps/dashboard/src/components/settings/Account/Billing/CouponCard.stories.tsx +++ b/apps/dashboard/src/components/settings/Account/Billing/ApplyCouponCard.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Toaster } from "sonner"; import { BadgeContainer, mobileViewport } from "../../../../stories/utils"; -import { CouponCardUI } from "./CouponCard"; +import { type ActiveCouponResponse, ApplyCouponCardUI } from "./CouponCard"; const meta = { - title: "billing/CouponCard", + title: "billing/Coupons/ApplyCoupon", component: Story, parameters: { nextjs: { @@ -30,7 +30,24 @@ export const Mobile: Story = { function statusStub(status: number) { return async () => { await new Promise((resolve) => setTimeout(resolve, 1000)); - return status; + const data: ActiveCouponResponse | null = + status === 200 + ? { + id: "xyz", + start: 1727992716, + end: 1759528716, + coupon: { + id: "XYZTEST", + name: "TEST COUPON", + duration: "repeating", + duration_in_months: 12, + }, + } + : null; + return { + status, + data, + }; }; } @@ -38,27 +55,45 @@ function Story() { return (
- + - + - + - + - + - +
diff --git a/apps/dashboard/src/components/settings/Account/Billing/CouponCard.tsx b/apps/dashboard/src/components/settings/Account/Billing/CouponCard.tsx index 78f56031d15..a096d4d6719 100644 --- a/apps/dashboard/src/components/settings/Account/Billing/CouponCard.tsx +++ b/apps/dashboard/src/components/settings/Account/Billing/CouponCard.tsx @@ -1,27 +1,45 @@ "use client"; -import { Spinner } from "@/components/ui/Spinner/Spinner"; -import { Button } from "@/components/ui/button"; +import { DangerSettingCard } from "@/components/blocks/DangerSettingCard"; +import { SettingsCard } from "@/components/blocks/SettingsCard"; import { Form, FormControl, FormField, FormItem, FormLabel, - FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser"; +import { Spinner } from "@chakra-ui/react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { format, fromUnixTime } from "date-fns"; +import { TagIcon } from "lucide-react"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; -export function CouponCard(props: { +export type ActiveCouponResponse = { + id: string; + start: number; + end: number | null; + coupon: { + id: string; + name: string | null; + duration: "forever" | "once" | "repeating"; + duration_in_months: number | null; + }; +}; + +function ApplyCouponCard(props: { teamId: string | undefined; + onCouponApplied: (data: ActiveCouponResponse) => void; }) { return ( - { const res = await fetch("/api/server-proxy/api/v1/coupons/redeem", { method: "POST", @@ -34,7 +52,18 @@ export function CouponCard(props: { }), }); - return res.status; + if (res.ok) { + const json = await res.json(); + return { + status: 200, + data: json.data as ActiveCouponResponse, + }; + } + + return { + status: res.status, + data: null, + }; }} /> ); @@ -44,8 +73,12 @@ const couponFormSchema = z.object({ promoCode: z.string().min(1, "Coupon code is required"), }); -export function CouponCardUI(props: { - submit: (promoCode: string) => Promise; +export function ApplyCouponCardUI(props: { + submit: (promoCode: string) => Promise<{ + status: number; + data: null | ActiveCouponResponse; + }>; + onCouponApplied: ((data: ActiveCouponResponse) => void) | undefined; }) { const form = useForm>({ resolver: zodResolver(couponFormSchema), @@ -60,10 +93,13 @@ export function CouponCardUI(props: { async function onSubmit(values: z.infer) { try { - const status = await applyCoupon.mutateAsync(values.promoCode); - switch (status) { + const res = await applyCoupon.mutateAsync(values.promoCode); + switch (res.status) { case 200: { toast.success("Coupon applied successfully"); + if (res.data) { + props.onCouponApplied?.(res.data); + } break; } case 400: { @@ -93,54 +129,184 @@ export function CouponCardUI(props: { } catch { toast.error("Failed to apply coupon"); } - - form.reset(); } return ( -
- {/* header */} -
-

- Apply Coupon -

-

- Enter your coupon code to apply discounts or free trials on thirdweb - products -

+
+ + + ( + + Coupon Code + + + + + )} + /> + +
+ + ); +} + +export function CouponDetailsCardUI(props: { + activeCoupon: ActiveCouponResponse; + deleteCoupon: { + mutateAsync: () => Promise; + isPending: boolean; + }; +}) { + const { activeCoupon, deleteCoupon } = props; + return ( + { + const promise = deleteCoupon.mutateAsync(); + toast.promise(promise, { + success: "Coupon Removed Successfully", + error: "Failed To Remove Coupon", + }); + }} + confirmationDialog={{ + title: `Delete Coupon "${activeCoupon.coupon.name}" ?`, + description: "Offers added by this coupon will no longer be available.", + }} + isPending={deleteCoupon.isPending} + > +
+ +
+

+ {activeCoupon.coupon.name || `Coupon #${activeCoupon.coupon.id}`} +

+

+ Valid from {format(fromUnixTime(activeCoupon.start), "MMM d, yyyy")}{" "} + {activeCoupon.end && ( + <>to {format(fromUnixTime(activeCoupon.end), "MMMM d, yyyy")} + )} +

+
+
+ ); +} -
- -
- - {/* Body */} -
- ( - - Coupon Code - - - - - - )} - /> -
-
- - {/* Footer */} -
- -
- - -
+export function CouponSection(props: { teamId: string | undefined }) { + const loggedInUser = useLoggedInUser(); + const [optimisticCouponData, setOptimisticCouponData] = useState< + | { + type: "added"; + data: ActiveCouponResponse; + } + | { + type: "deleted"; + data: null; + } + >(); + + const activeCoupon = useQuery({ + queryKey: ["active-coupon", loggedInUser.user?.address, props.teamId], + queryFn: async () => { + const res = await fetch( + `/api/server-proxy/api/v1/active-coupon${ + props.teamId ? `?teamId=${props.teamId}` : "" + }`, + ); + if (!res.ok) { + return null; + } + const json = await res.json(); + return json.data as ActiveCouponResponse; + }, + }); + + const deleteActiveCoupon = useMutation({ + mutationFn: async () => { + const res = await fetch( + `/api/server-proxy/api/v1/active-coupon${ + props.teamId ? `?teamId=${props.teamId}` : "" + }`, + { + method: "DELETE", + }, + ); + if (!res.ok) { + throw new Error("Failed to delete coupon"); + } + }, + onSuccess: () => { + setOptimisticCouponData({ + type: "deleted", + data: null, + }); + activeCoupon.refetch().then(() => { + setOptimisticCouponData(undefined); + }); + }, + }); + + if (activeCoupon.isPending) { + return ( +
+ +
+ ); + } + + const couponData = optimisticCouponData + ? optimisticCouponData.data + : activeCoupon.data; + + if (couponData) { + return ( + + ); + } + + return ( + { + setOptimisticCouponData({ + type: "added", + data: coupon, + }); + activeCoupon.refetch().then(() => { + setOptimisticCouponData(undefined); + }); + }} + /> ); } diff --git a/apps/dashboard/src/components/settings/Account/Billing/CouponDetails.stories.tsx b/apps/dashboard/src/components/settings/Account/Billing/CouponDetails.stories.tsx new file mode 100644 index 00000000000..4cdfeeab61c --- /dev/null +++ b/apps/dashboard/src/components/settings/Account/Billing/CouponDetails.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { Toaster } from "sonner"; +import { mobileViewport } from "../../../../stories/utils"; +import { CouponDetailsCardUI } from "./CouponCard"; + +const meta = { + title: "billing/Coupons/CouponDetails", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + args: {}, +}; + +export const Mobile: Story = { + args: {}, + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +function Story() { + const [isPending, setIsPending] = useState(false); + return ( +
+ { + setIsPending(true); + await new Promise((resolve) => setTimeout(resolve, 2000)); + setIsPending(false); + }, + isPending: isPending, + }} + /> + + +
+ ); +} diff --git a/apps/dashboard/src/components/settings/Account/Billing/index.tsx b/apps/dashboard/src/components/settings/Account/Billing/index.tsx index 4b202559d44..1344382f357 100644 --- a/apps/dashboard/src/components/settings/Account/Billing/index.tsx +++ b/apps/dashboard/src/components/settings/Account/Billing/index.tsx @@ -1,5 +1,4 @@ "use client"; - import { type Account, AccountPlan, @@ -18,7 +17,7 @@ import { FiExternalLink } from "react-icons/fi"; import { Button, Heading, Text, TrackedLink } from "tw-components"; import { PLANS } from "utils/pricing"; import { LazyOnboardingBilling } from "../../../onboarding/LazyOnboardingBilling"; -import { CouponCard } from "./CouponCard"; +import { CouponSection } from "./CouponCard"; import { BillingDowngradeDialog } from "./DowngradeDialog"; import { BillingHeader } from "./Header"; import { BillingPlanCard } from "./PlanCard"; @@ -304,7 +303,7 @@ export const Billing: React.FC = ({ account, teamId }) => { /> )} - + ); };