The weeb for the next gen discord boat - Wamellow wamellow.com
bot discord

feat: yearly premium billing period (#26)

authored by shi.gg and committed by

GitHub ece784d9 98b0023b

+208 -142
+1
app/(home)/premium/checkout/api.ts
··· 10 donationQuantity: number; 11 giftId: string | null; 12 referer: string | null; 13 } 14 ) { 15 const response = await fetch(`${process.env.NEXT_PUBLIC_API}/billing/subscriptions`, {
··· 10 donationQuantity: number; 11 giftId: string | null; 12 referer: string | null; 13 + period: "month" | "year"; 14 } 15 ) { 16 const response = await fetch(`${process.env.NEXT_PUBLIC_API}/billing/subscriptions`, {
+2 -1
app/(home)/premium/checkout/route.ts
··· 19 { 20 donationQuantity: Number.parseInt(searchParams.get("donation") || "0", 10), 21 giftId: searchParams.get("gift"), 22 - referer: request.headers.get("referer") 23 } 24 ) 25 .catch((error) => error);
··· 19 { 20 donationQuantity: Number.parseInt(searchParams.get("donation") || "0", 10), 21 giftId: searchParams.get("gift"), 22 + referer: request.headers.get("referer"), 23 + period: searchParams.get("period") === "year" ? "year" : "month" 24 } 25 ) 26 .catch((error) => error);
+3 -1
app/(home)/premium/gfit-banner.tsx
··· 27 const gift = await getGift(giftId); 28 29 if (!gift || "message" in gift) { 30 - return <div>Gift not found</div>; 31 } 32 33 return (
··· 27 const gift = await getGift(giftId); 28 29 if (!gift || "message" in gift) { 30 + return ( 31 + <Notice message={gift?.message || "Gift not found"} /> 32 + ); 33 } 34 35 return (
+2 -2
app/(home)/premium/page.tsx
··· 29 { title: "Price", free: 0, premium: 4, unit: "€/month" }, 30 31 { title: "Your Benefits", icon: <HiUser /> }, 32 - { title: "Text to Speech", free: Infinity, premium: Infinity, unit: "chars /month" }, 33 - { title: "TTS Translations", free: 10_000, premium: 100_000, unit: "chars /month" }, 34 { title: "Bypass voting", free: false, premium: true, tooltip: <OtherBotsTooltip /> }, 35 { title: "Bypass passport", free: false, premium: true }, 36 { title: "Premium role", free: false, premium: true, url: "/support" },
··· 29 { title: "Price", free: 0, premium: 4, unit: "€/month" }, 30 31 { title: "Your Benefits", icon: <HiUser /> }, 32 + { title: "Text to Speech", free: Infinity, premium: Infinity, unit: "chars" }, 33 + { title: "TTS Translations", free: 10_000, premium: 100_000, unit: "chars" }, 34 { title: "Bypass voting", free: false, premium: true, tooltip: <OtherBotsTooltip /> }, 35 { title: "Bypass passport", free: false, premium: true }, 36 { title: "Premium role", free: false, premium: true, url: "/support" },
+62 -8
app/(home)/premium/subscribe.component.tsx
··· 10 import { type HTMLProps, useState } from "react"; 11 import { HiArrowDown, HiArrowUp, HiLightningBolt, HiOutlineInformationCircle } from "react-icons/hi"; 12 13 export function Subscribe({ header }: { header?: boolean; }) { 14 const search = useSearchParams(); 15 16 const premium = userStore((u) => u?.premium || false); 17 const [donation, setDonation] = useState(0); 18 19 if (premium) { 20 return ( ··· 32 </Button> 33 ); 34 } 35 36 return ( 37 - <div className="w-full"> 38 {header && ( 39 - <div className="flex gap-2 justify-center mb-2"> 40 <span className="dark:text-neutral-200 text-neutral-800 font-medium text-sm">Upgrade your experience further!</span> 41 <Badge 42 variant="flat" 43 radius="rounded" 44 > 45 - €{donation + 4} /month 46 </Badge> 47 </div> 48 )} ··· 55 > 56 <Link 57 prefetch={false} 58 - href={`/premium/checkout?${new URLSearchParams({ donation: donation.toString(), gift: search.get("gift") || "" }).toString()}`} 59 > 60 <HiLightningBolt /> 61 Subscribe ··· 63 </Button> 64 </div> 65 66 - <div className="w-full flex justify-center my-2"> 67 <span className="text-muted-foreground font-medium text-xs uppercase">choose what to pay</span> 68 </div> 69 70 <div className="flex gap-1 w-full"> 71 - {[4, 8, 12, 18, 25].map((amount) => ( 72 <Button 73 key={amount} 74 - className={cn("h-7 w-1/5", amount === (donation + 4) && "bg-violet-400/20 hover:bg-violet-400/40")} 75 - onClick={() => setDonation(amount - 4)} 76 > 77 {amount}€ 78 </Button>
··· 10 import { type HTMLProps, useState } from "react"; 11 import { HiArrowDown, HiArrowUp, HiLightningBolt, HiOutlineInformationCircle } from "react-icons/hi"; 12 13 + export const MONTHLY_PRICES = [4, 8, 12, 18, 25] as const; 14 + export const YEARLY_PRICES = [40, 50, 60, 80, 100] as const; 15 + const PERIODS = ["month", "year"] as const; 16 + 17 export function Subscribe({ header }: { header?: boolean; }) { 18 const search = useSearchParams(); 19 20 const premium = userStore((u) => u?.premium || false); 21 const [donation, setDonation] = useState(0); 22 + const [period, setPeriod] = useState<"month" | "year">("month"); 23 24 if (premium) { 25 return ( ··· 37 </Button> 38 ); 39 } 40 + 41 + const basePrice = period === "year" ? YEARLY_PRICES[0] : MONTHLY_PRICES[0]; 42 + const prices = period === "year" ? YEARLY_PRICES : MONTHLY_PRICES; 43 + const currentPrice = basePrice + donation; 44 45 return ( 46 + <div className="w-full space-y-2"> 47 {header && ( 48 + <div className="flex gap-2 justify-center"> 49 <span className="dark:text-neutral-200 text-neutral-800 font-medium text-sm">Upgrade your experience further!</span> 50 <Badge 51 variant="flat" 52 radius="rounded" 53 > 54 + €{currentPrice} /{period} 55 </Badge> 56 </div> 57 )} ··· 64 > 65 <Link 66 prefetch={false} 67 + href={`/premium/checkout?${new URLSearchParams({ 68 + donation: donation.toString(), 69 + gift: search.get("gift") || "", 70 + period 71 + }).toString()}`} 72 > 73 <HiLightningBolt /> 74 Subscribe ··· 76 </Button> 77 </div> 78 79 + <div className="w-full flex justify-center"> 80 <span className="text-muted-foreground font-medium text-xs uppercase">choose what to pay</span> 81 </div> 82 83 <div className="flex gap-1 w-full"> 84 + {PERIODS.map((p) => ( 85 + <Button 86 + key={p} 87 + className={cn("h-7 w-1/2", p === period && "bg-violet-400/20 hover:bg-violet-400/40")} 88 + onClick={() => { 89 + setPeriod(p); 90 + 91 + const currentTotal = basePrice + donation; 92 + const targetPrices = p === "year" ? YEARLY_PRICES : MONTHLY_PRICES; 93 + const targetBase = p === "year" ? YEARLY_PRICES[0] : MONTHLY_PRICES[0]; 94 + 95 + const projectedTotal = p === "year" ? currentTotal * 10 : currentTotal / 10; 96 + 97 + const nearest = targetPrices.reduce((prev, curr) => { 98 + const prevDiff = Math.abs(prev - projectedTotal); 99 + const currDiff = Math.abs(curr - projectedTotal); 100 + 101 + if (currDiff < prevDiff) return curr; 102 + if (currDiff === prevDiff) return curr > prev ? curr : prev; 103 + return prev; 104 + }); 105 + 106 + setDonation(nearest - targetBase); 107 + }} 108 + > 109 + {p.replace(/^\w/, (char) => char.toUpperCase())}ly 110 + {p === "year" && ( 111 + <Badge 112 + variant="flat" 113 + radius="rounded" 114 + size="sm" 115 + className={period === "month" ? "text-green-400 bg-green-400/10" : "text-violet-400 bg-violet-400/10"} 116 + > 117 + Save {Math.round((1 - YEARLY_PRICES[0] / (MONTHLY_PRICES[0] * 12)) * 100)}% 118 + </Badge> 119 + )} 120 + </Button> 121 + ))} 122 + </div> 123 + 124 + <div className="flex gap-1 w-full"> 125 + {prices.map((amount) => ( 126 <Button 127 key={amount} 128 + className={cn("h-7 w-1/5", amount === currentPrice && "bg-violet-400/20 hover:bg-violet-400/40")} 129 + onClick={() => setDonation(amount - basePrice)} 130 > 131 {amount}€ 132 </Button>
+136 -128
app/profile/billing/page.tsx
··· 1 "use client"; 2 3 - import { DonationSelect } from "@/app/(home)/premium/subscribe.component"; 4 import { userStore } from "@/common/user"; 5 import Box from "@/components/box"; 6 import ImageReduceMotion from "@/components/image-reduce-motion"; ··· 17 import type { ApiV1UsersMeBillingGetResponse, ApiV1UsersMeGuildsGetResponse } from "@/typings"; 18 import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile"; 19 import Link from "next/link"; 20 - import { useRef, useState } from "react"; 21 import { GrAmex } from "react-icons/gr"; 22 import { HiCreditCard, HiLightningBolt } from "react-icons/hi"; 23 import { SiDinersclub, SiDiscover, SiJcb, SiMastercard, SiPaypal, SiStripe, SiVisa } from "react-icons/si"; 24 25 - function isActive(status: ApiV1UsersMeBillingGetResponse["status"]): status is "active" | "trialing" | "past_due" { 26 - return status === "active" || status === "trialing" || status === "past_due"; 27 - } 28 - 29 export default function Home() { 30 const user = userStore((u) => u); 31 - const [changeDonationModalOpen, setChangeDonationModalOpen] = useState(false); 32 33 const { data, isLoading, error, edit } = useApi<ApiV1UsersMeBillingGetResponse>("/users/@me/billing"); 34 const [nowInSeconds] = useState(() => Date.now() / 1_000); 35 36 if ((isLoading && !user?.premium) || (!isLoading && !data) || (data && !isActive(data.status))) { 37 - return (<> 38 - {(error && error !== "Not Found") && <Notice message={error} />} 39 40 - <OverviewLink 41 - title="Upgrade to Premium" 42 - message="Get access to premium features, higher limits, and more — such as supporting the project!" 43 - url="/premium" 44 - icon={<HiLightningBolt />} 45 - /> 46 47 - {data?.status && ( 48 - <Button asChild> 49 - <Link 50 - href={data?.portalUrl} 51 - target="_blank" 52 - > 53 - Billing Portal 54 - </Link> 55 - </Button> 56 - )} 57 - </>); 58 } 59 60 - const periodEndsInDays = Math.floor(((data?.currentPeriodEnd || 0) - nowInSeconds) / (60 * 60 * 24)); 61 - const periodEndsInStr = `${periodEndsInDays > 1 ? "in " : ""}${periodEndsInDays === 0 ? "Today" : periodEndsInDays === 1 ? "Tomorrow" : periodEndsInDays} ${periodEndsInDays > 1 ? "days" : ""}`; 62 63 return ( 64 - <div className="space-y-2"> 65 {data?.status === "past_due" && ( 66 - <Notice message={`Your renew is over due! Please check your emails to renew your subscription or contact support. Your subscription will be canceled ${periodEndsInStr}.`} /> 67 )} 68 69 - <Box 70 - className="md:flex justify-between" 71 - small 72 - > 73 <div className="flex flex-col"> 74 <h2 className="font-bold text-3xl bg-linear-to-r bg-clip-text text-transparent from-violet-400/80 to-indigo-400/80"> 75 Wamellow Premium 76 {data?.status === "trialing" && ( 77 - <Badge 78 - className="relative bottom-1 ml-2" 79 - > 80 - trial — Ends {periodEndsInStr} 81 </Badge> 82 )} 83 </h2> 84 - <p className="text-muted-foreground">You have all premium features for <span className="font-semibold text-neutral-300">EUR {(4 + (data?.donationQuantity || 0)).toFixed(2)} / {data?.priceId.startsWith("monthly_") ? "Month" : "Year"}</span>!</p> 85 </div> 86 <div className="flex gap-1 mt-4 md:mt-0"> 87 - {isLoading 88 - ? <Skeleton className="h-10 w-full md:w-20" /> 89 - : <PortalButton data={data!} /> 90 - } 91 </div> 92 </Box> 93 94 - <div className="flex-col lg:flex-row flex gap-4 pt-1"> 95 - <Box 96 - className="lg:w-1/2 text-sm" 97 - small 98 - > 99 - <h2 className="font-semibold text-xl text-neutral-300 mb-2 lg:mb-0 lg:relative lg:bottom-2">Billing Cycle</h2> 100 - {isLoading 101 - ? <Skeleton className="h-12 w-full" /> 102 - : (data?.cancelAtPeriodEnd 103 - ? <p> 104 - Your subscription will expire on <span className="font-semibold text-neutral-300">{new Date(data.currentPeriodEnd * 1_000).toLocaleDateString()}</span> and you will not be charged again. 105 - </p> 106 - : <p> 107 - Your subscription will renew on <span className="font-semibold text-neutral-300">{new Date(data!.currentPeriodEnd * 1_000).toLocaleDateString()}</span>, for a total of <span className="font-semibold text-neutral-300">EUR {(4 + (data!.donationQuantity || 0)).toFixed(2)}</span>. 108 - 109 - You{"'"}re paying <span className="font-semibold text-neutral-300">EUR {(4).toFixed(2)} Premium</span> and <span className="font-semibold text-neutral-300">EUR {(data!.donationQuantity || 0).toFixed(2)} Donation{data!.donationQuantity ? "s" : ""}</span> 110 - {" "} 111 - (<Button 112 - className="text-sm p-0 m-0 h-3 text-violet-400" 113 - onClick={() => setChangeDonationModalOpen(true)} 114 - variant="link" 115 - size="sm" 116 - > 117 - change 118 - </Button>). 119 - </p> 120 - ) 121 - } 122 - </Box> 123 - <Box 124 - className="lg:w-1/2" 125 - small 126 - > 127 - <h2 className="font-semibold text-xl text-neutral-300 mb-2 lg:mb-0 lg:relative lg:bottom-2">Payment Method</h2> 128 - {isLoading 129 - ? <Skeleton className="h-12 w-full" /> 130 - : <div className="flex gap-2 items-center bg-wamellow-100 px-4 py-1 rounded-lg"> 131 - <PaymentMethodIcon method={data!.paymentMethod} /> 132 - {getPaymentMethodInfo(data!.paymentMethod)} 133 - 134 - <Button 135 - asChild 136 - className="ml-auto" 137 variant="link" 138 > 139 - <Link href={data!.portalUrl}> 140 Change 141 </Link> 142 </Button> 143 </div> 144 - } 145 </Box> 146 </div> 147 148 - <div className="pt-4"> 149 <PremiumGuildSelect 150 - isParentLoading={isLoading} 151 guildIds={data?.guildIds || []} 152 /> 153 </div> ··· 156 <ChangeDonationAmountModal 157 open={changeDonationModalOpen} 158 setOpen={setChangeDonationModalOpen} 159 - donationQuantity={data?.donationQuantity || 0} 160 trialing={data.status === "trialing"} 161 edit={edit} 162 /> 163 )} ··· 165 ); 166 } 167 168 function PortalButton({ data }: { data: ApiV1UsersMeBillingGetResponse; }) { 169 const path = getPortalPath(data); 170 171 return ( 172 - <Button 173 - asChild 174 - className="w-full md:w-auto" 175 - > 176 - <Link href={data.portalUrl + "/" + path}> 177 - {path?.split("/").pop()?.replace(/^\w/, (char) => char.toUpperCase()) || "Manage"} 178 </Link> 179 </Button> 180 ); 181 } 182 183 function getPortalPath(data: ApiV1UsersMeBillingGetResponse) { 184 - if (data.cancelAtPeriodEnd) return "subscriptions/" + data.subscriptionId + "/reactivate"; 185 - return "subscriptions/" + data.subscriptionId + "/cancel"; 186 } 187 188 function PaymentMethodIcon({ method }: { method?: ApiV1UsersMeBillingGetResponse["paymentMethod"]; }) { ··· 221 222 if (isLoading || isParentLoading) { 223 return ( 224 - <div className="w-full md:w-1/3 flex flex-col"> 225 - <Skeleton className="w-32 h-5 rounded-lg mt-1.5" /> 226 - <Skeleton className="w-full h-12 mt-1.5" /> 227 - <Skeleton className="w-96 h-5 rounded-lg mt-1.5" /> 228 </div> 229 ); 230 } 231 232 - if (error) { 233 - return <Notice message={error} />; 234 - } 235 236 return ( 237 <MultiSelectMenu ··· 265 setOpen, 266 donationQuantity: defaultDonationQuantity, 267 trialing, 268 edit 269 }: { 270 open: boolean; 271 setOpen: (open: boolean) => void; 272 donationQuantity: number; 273 trialing: boolean; 274 edit: ApiEdit<ApiV1UsersMeBillingGetResponse>; 275 }) { 276 const [donation, setDonation] = useState(defaultDonationQuantity); ··· 306 }} 307 isDisabled={donation === defaultDonationQuantity || !terms} 308 > 309 - <p className="text-sm mb-4"> 310 - Change how much you want to donate on top of the monthly premium subscription. 311 Please do not feel pressured to donate more than you can afford. 312 I appreciate any additional support you can provide 💜 313 </p> ··· 318 setDonation={setDonation} 319 /> 320 321 - <p className="text-sm mt-8 mb-6"> 322 - <Separator className="my-4" /> 323 324 {dueToday > 0 && ( 325 - <div className="flex justify-between items-center mb-4"> 326 <div> 327 <h2 className="text-lg font-medium text-neutral-100">Due Today</h2> 328 <p className="text-sm text-neutral-500"> ··· 332 } 333 </p> 334 </div> 335 - 336 - <span className="text-xl font-medium text-neutral-100">€{trialing ? 0 : dueToday.toFixed(2)}</span> 337 </div> 338 )} 339 340 <div className="flex justify-between items-center"> 341 <div> 342 - <h2 className="text-lg font-medium text-neutral-100">Monthly Total</h2> 343 - <p className="text-sm text-neutral-500">The total amount you will be charged monthly.</p> 344 </div> 345 - 346 - <span className="text-xl font-medium text-neutral-100">€{(donation + 4).toFixed(2)}</span> 347 </div> 348 - </p> 349 350 - <Separator className="my-4" /> 351 352 - <InputSwitch 353 - label="I agree to the terms and conditions" 354 - description="I waive my right of withdrawal." 355 - link="/terms" 356 - defaultState={terms} 357 - onSave={setTerms} 358 - isTickbox 359 - /> 360 361 <Turnstile 362 - className="mt-10" 363 siteKey={process.env.NEXT_PUBLIC_TURNSTILE_KEY!} 364 options={{ 365 size: "flexible",
··· 1 "use client"; 2 3 + import { DonationSelect, MONTHLY_PRICES, YEARLY_PRICES } from "@/app/(home)/premium/subscribe.component"; 4 import { userStore } from "@/common/user"; 5 import Box from "@/components/box"; 6 import ImageReduceMotion from "@/components/image-reduce-motion"; ··· 17 import type { ApiV1UsersMeBillingGetResponse, ApiV1UsersMeGuildsGetResponse } from "@/typings"; 18 import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile"; 19 import Link from "next/link"; 20 + import { useMemo, useRef, useState } from "react"; 21 import { GrAmex } from "react-icons/gr"; 22 import { HiCreditCard, HiLightningBolt } from "react-icons/hi"; 23 import { SiDinersclub, SiDiscover, SiJcb, SiMastercard, SiPaypal, SiStripe, SiVisa } from "react-icons/si"; 24 25 export default function Home() { 26 const user = userStore((u) => u); 27 + const [changeDonationModalOpen, setChangeDonationModalOpen] = useState( 28 + () => typeof window !== "undefined" && window.location.hash === "#donation" 29 + ); 30 31 const { data, isLoading, error, edit } = useApi<ApiV1UsersMeBillingGetResponse>("/users/@me/billing"); 32 const [nowInSeconds] = useState(() => Date.now() / 1_000); 33 34 + const period = useMemo(() => data?.priceId.startsWith("monthly_") ? "month" : "year", [data?.priceId]); 35 + const basePrice = useMemo(() => period === "year" ? YEARLY_PRICES[0] : MONTHLY_PRICES[0], [period]); 36 + 37 if ((isLoading && !user?.premium) || (!isLoading && !data) || (data && !isActive(data.status))) { 38 + return ( 39 + <div className="space-y-4"> 40 + {error && error !== "Not Found" && <Notice message={error} />} 41 42 + <OverviewLink 43 + title="Upgrade to Premium" 44 + message="Get access to premium features, higher limits, and more — such as supporting the project!" 45 + url="/premium" 46 + icon={<HiLightningBolt />} 47 + /> 48 49 + {data?.status && ( 50 + <Button asChild> 51 + <Link href={data.portalUrl} target="_blank"> 52 + Billing Portal 53 + </Link> 54 + </Button> 55 + )} 56 + </div> 57 + ); 58 } 59 60 + const periodEndsIn = data ? getPeriodEndsIn(data.currentPeriodEnd, nowInSeconds) : "..."; 61 + const totalAmount = data ? (basePrice + (data.donationQuantity || 0)).toFixed(2) : "0.00"; 62 63 return ( 64 + <div className="space-y-4"> 65 {data?.status === "past_due" && ( 66 + <Notice message={`Your renewal is overdue! Please check your emails to renew your subscription or contact support. Your subscription will be canceled ${periodEndsIn}.`} /> 67 )} 68 69 + <Box className="md:flex justify-between items-center" small> 70 <div className="flex flex-col"> 71 <h2 className="font-bold text-3xl bg-linear-to-r bg-clip-text text-transparent from-violet-400/80 to-indigo-400/80"> 72 Wamellow Premium 73 {data?.status === "trialing" && ( 74 + <Badge className="relative bottom-1 ml-2"> 75 + Trial — Ends {periodEndsIn} 76 </Badge> 77 )} 78 </h2> 79 + <p className="text-muted-foreground"> 80 + You have all premium features for <span className="font-semibold text-neutral-300">EUR {totalAmount} / {period.replace(/^\w/, (c) => c.toUpperCase())}</span>! 81 + </p> 82 </div> 83 <div className="flex gap-1 mt-4 md:mt-0"> 84 + {isLoading || !data ? ( 85 + <Skeleton className="h-10 w-full md:w-20" /> 86 + ) : ( 87 + <PortalButton data={data} /> 88 + )} 89 </div> 90 </Box> 91 92 + <div className="flex flex-col lg:flex-row gap-4"> 93 + <Box className="lg:w-1/2 text-sm" small> 94 + <h2 className="font-semibold text-xl text-neutral-300 mb-2">Billing Cycle</h2> 95 + {isLoading || !data ? ( 96 + <Skeleton className="h-12 w-full" /> 97 + ) : data.cancelAtPeriodEnd ? ( 98 + <p> 99 + The subscription will expire on <span className="font-semibold text-neutral-300">{formatDate(data.currentPeriodEnd)}</span> and you will not be charged again. 100 + </p> 101 + ) : ( 102 + <p> 103 + The subscription will renew on <span className="font-semibold text-neutral-300">{formatDate(data.currentPeriodEnd)}</span>, for a total of <span className="font-semibold text-neutral-300">EUR {totalAmount}</span>. 104 + <br /> 105 + You{"'"}re paying <span className="font-semibold text-neutral-300">EUR {basePrice} Premium</span> and <span className="font-semibold text-neutral-300">EUR {(data.donationQuantity || 0).toFixed(2)} Donation{(data.donationQuantity || 0) === 1 ? "" : "s"}</span> 106 + {" "} 107 + (<Button 108 + className="text-sm p-0 m-0 h-3 text-violet-400" 109 + onClick={() => setChangeDonationModalOpen(true)} 110 variant="link" 111 + size="sm" 112 > 113 + change 114 + </Button>). 115 + </p> 116 + )} 117 + </Box> 118 + <Box className="lg:w-1/2" small> 119 + <h2 className="font-semibold text-xl text-neutral-300 mb-2">Payment Method</h2> 120 + {isLoading || !data ? ( 121 + <Skeleton className="h-12 w-full" /> 122 + ) : ( 123 + <div className="flex gap-2 items-center bg-wamellow-100 px-4 py-2 rounded-lg"> 124 + <PaymentMethodIcon method={data.paymentMethod} /> 125 + <span className="text-neutral-200">{getPaymentMethodInfo(data.paymentMethod)}</span> 126 + 127 + <Button asChild className="ml-auto" variant="link"> 128 + <Link href={data.portalUrl}> 129 Change 130 </Link> 131 </Button> 132 </div> 133 + )} 134 </Box> 135 </div> 136 137 + <div className="pt-2"> 138 <PremiumGuildSelect 139 + isParentLoading={isLoading || !data} 140 guildIds={data?.guildIds || []} 141 /> 142 </div> ··· 145 <ChangeDonationAmountModal 146 open={changeDonationModalOpen} 147 setOpen={setChangeDonationModalOpen} 148 + donationQuantity={data.donationQuantity || 0} 149 trialing={data.status === "trialing"} 150 + basePrice={basePrice} 151 + period={period} 152 edit={edit} 153 /> 154 )} ··· 156 ); 157 } 158 159 + function formatDate(seconds: number) { 160 + return new Date(seconds * 1_000).toLocaleDateString(); 161 + } 162 + 163 + function getPeriodEndsIn(endsAt: number, nowInSeconds: number) { 164 + const days = Math.floor((endsAt - nowInSeconds) / (60 * 60 * 24)); 165 + if (days <= 0) return "Today"; 166 + if (days === 1) return "Tomorrow"; 167 + return `in ${days} days`; 168 + } 169 + 170 + function isActive(status: ApiV1UsersMeBillingGetResponse["status"]): status is "active" | "trialing" | "past_due" { 171 + return status === "active" || status === "trialing" || status === "past_due"; 172 + } 173 + 174 function PortalButton({ data }: { data: ApiV1UsersMeBillingGetResponse; }) { 175 const path = getPortalPath(data); 176 + const label = path?.split("/").pop()?.replace(/^\w/, (c) => c.toUpperCase()) || "Manage"; 177 178 return ( 179 + <Button asChild className="w-full md:w-auto"> 180 + <Link href={`${data.portalUrl}/${path}`}> 181 + {label} 182 </Link> 183 </Button> 184 ); 185 } 186 187 function getPortalPath(data: ApiV1UsersMeBillingGetResponse) { 188 + if (data.cancelAtPeriodEnd) return `subscriptions/${data.subscriptionId}/reactivate`; 189 + return `subscriptions/${data.subscriptionId}/cancel`; 190 } 191 192 function PaymentMethodIcon({ method }: { method?: ApiV1UsersMeBillingGetResponse["paymentMethod"]; }) { ··· 225 226 if (isLoading || isParentLoading) { 227 return ( 228 + <div className="w-full md:w-1/2 lg:w-1/3 flex flex-col gap-2 mt-2"> 229 + <Skeleton className="w-32 h-5 rounded-lg" /> 230 + <Skeleton className="w-full h-12" /> 231 + <Skeleton className="w-full h-5 rounded-lg" /> 232 </div> 233 ); 234 } 235 236 + if (error) return <Notice message={error} />; 237 238 return ( 239 <MultiSelectMenu ··· 267 setOpen, 268 donationQuantity: defaultDonationQuantity, 269 trialing, 270 + basePrice, 271 + period, 272 edit 273 }: { 274 open: boolean; 275 setOpen: (open: boolean) => void; 276 donationQuantity: number; 277 trialing: boolean; 278 + basePrice: number; 279 + period: "month" | "year"; 280 edit: ApiEdit<ApiV1UsersMeBillingGetResponse>; 281 }) { 282 const [donation, setDonation] = useState(defaultDonationQuantity); ··· 312 }} 313 isDisabled={donation === defaultDonationQuantity || !terms} 314 > 315 + <p className="text-sm mb-6"> 316 + Change how much you want to donate on top of your {period}ly premium subscription. 317 Please do not feel pressured to donate more than you can afford. 318 I appreciate any additional support you can provide 💜 319 </p> ··· 324 setDonation={setDonation} 325 /> 326 327 + <div className="mt-8 space-y-4"> 328 + <Separator /> 329 330 {dueToday > 0 && ( 331 + <div className="flex justify-between items-center"> 332 <div> 333 <h2 className="text-lg font-medium text-neutral-100">Due Today</h2> 334 <p className="text-sm text-neutral-500"> ··· 338 } 339 </p> 340 </div> 341 + <span className="text-xl font-medium text-neutral-100">€{trialing ? "0.00" : dueToday.toFixed(2)}</span> 342 </div> 343 )} 344 345 <div className="flex justify-between items-center"> 346 <div> 347 + <h2 className="text-lg font-medium text-neutral-100"> 348 + {period.replace(/^\w/, (c) => c.toUpperCase())}ly Total 349 + </h2> 350 + <p className="text-sm text-neutral-500">The total amount you will be charged {period}ly.</p> 351 </div> 352 + <span className="text-xl font-medium text-neutral-100">€{(donation + basePrice).toFixed(2)}</span> 353 </div> 354 355 + <Separator /> 356 + </div> 357 358 + <div className="mt-6"> 359 + <InputSwitch 360 + label="I agree to the terms and conditions" 361 + description="I waive my right of withdrawal." 362 + link="/terms" 363 + defaultState={terms} 364 + onSave={setTerms} 365 + isTickbox 366 + /> 367 + </div> 368 369 <Turnstile 370 + className="mt-8" 371 siteKey={process.env.NEXT_PUBLIC_TURNSTILE_KEY!} 372 options={{ 373 size: "flexible",
+1 -1
components/modal.tsx
··· 119 120 <div 121 className={cn( 122 - "scrollbar-hide overflow-y-scroll overflow-x-hidden max-h-[70vh] px-0.5", 123 className 124 )} 125 >
··· 119 120 <div 121 className={cn( 122 + "scrollbar-none overflow-y-scroll overflow-x-hidden max-h-[70vh] px-0.5", 123 className 124 )} 125 >
+1 -1
components/ui/dialog.tsx
··· 83 return ( 84 <div 85 data-slot="dialog-header" 86 - className={cn("flex flex-col gap-2 text-center sm:text-left", className)} 87 {...props} 88 /> 89 );
··· 83 return ( 84 <div 85 data-slot="dialog-header" 86 + className={cn("flex flex-col gap-2 text-left", className)} 87 {...props} 88 /> 89 );