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