mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-20 09:47:21 +02:00
✨ Update billing page (#1578)
This commit is contained in:
parent
34f5555791
commit
b3aafb5af6
12 changed files with 766 additions and 493 deletions
|
@ -129,23 +129,6 @@
|
||||||
"goToInvite": "Go to Invite Page",
|
"goToInvite": "Go to Invite Page",
|
||||||
"planPro": "Pro",
|
"planPro": "Pro",
|
||||||
"Billing": "Billing",
|
"Billing": "Billing",
|
||||||
"subscriptionUpdatePayment": "Update Payment Details",
|
|
||||||
"subscriptionCancel": "Cancel Subscription",
|
|
||||||
"billingStatus": "Billing Status",
|
|
||||||
"billingStatusDescription": "Manage your subscription and billing details",
|
|
||||||
"freeForever": "free forever",
|
|
||||||
"billingStatusState": "Status",
|
|
||||||
"billingStatusActive": "Active",
|
|
||||||
"billingStatusPaused": "Paused",
|
|
||||||
"billingStatusDeleted": "Cancelled",
|
|
||||||
"endDate": "End date",
|
|
||||||
"dueDate": "Next payment due",
|
|
||||||
"billingStatusPlan": "Plan",
|
|
||||||
"billingPeriod": "Period",
|
|
||||||
"billingPeriodMonthly": "Monthly",
|
|
||||||
"billingPeriodYearly": "Yearly",
|
|
||||||
"monthlyBillingDescription": "per month",
|
|
||||||
"plan_extendedPollLife": "Keep polls indefinitely",
|
|
||||||
"becomeATranslator": "Help translate",
|
"becomeATranslator": "Help translate",
|
||||||
"noPolls": "No polls",
|
"noPolls": "No polls",
|
||||||
"noPollsDescription": "Get started by creating a new poll.",
|
"noPollsDescription": "Get started by creating a new poll.",
|
||||||
|
@ -173,20 +156,10 @@
|
||||||
"scrollRight": "Scroll Right",
|
"scrollRight": "Scroll Right",
|
||||||
"shrink": "Shrink",
|
"shrink": "Shrink",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"activeSubscription": "Thank you for subscribing to Rallly Pro. You can manage your subscription and billing details from the billing portal.",
|
|
||||||
"billingPortal": "Billing Portal",
|
"billingPortal": "Billing Portal",
|
||||||
"supportDescription": "Need help with anything?",
|
"supportDescription": "Need help with anything?",
|
||||||
"supportBilling": "Please reach out if you need any assistance.",
|
"supportBilling": "Please reach out if you need any assistance.",
|
||||||
"contactSupport": "Contact Support",
|
"contactSupport": "Contact Support",
|
||||||
"planFreeDescription": "For casual users",
|
|
||||||
"currentPlan": "Current Plan",
|
|
||||||
"limitedAccess": "Access to core features",
|
|
||||||
"pollsDeleted": "Polls are automatically deleted once they become inactive",
|
|
||||||
"planProDescription": "For power users and professionals",
|
|
||||||
"accessAllFeatures": "Access all features",
|
|
||||||
"earlyAccess": "Get early access to new features",
|
|
||||||
"earlyAdopterDescription": "As an early adopter, you'll lock in your subscription rate and won't be affected by future price increases.",
|
|
||||||
"upgradeNowSaveLater": "Upgrade now, save later",
|
|
||||||
"pricing": "Pricing",
|
"pricing": "Pricing",
|
||||||
"pollSettingsDescription": "Customize the behaviour of your poll",
|
"pollSettingsDescription": "Customize the behaviour of your poll",
|
||||||
"requireParticipantEmailLabel": "Make email address required for participants",
|
"requireParticipantEmailLabel": "Make email address required for participants",
|
||||||
|
@ -236,7 +209,6 @@
|
||||||
"pastEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
"pastEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
||||||
"activePollCount": "{{activePollCount}} Live",
|
"activePollCount": "{{activePollCount}} Live",
|
||||||
"createPoll": "Create poll",
|
"createPoll": "Create poll",
|
||||||
"yearlyBillingDescription": "per year",
|
|
||||||
"addToCalendar": "Add to Calendar",
|
"addToCalendar": "Add to Calendar",
|
||||||
"microsoft365": "Microsoft 365",
|
"microsoft365": "Microsoft 365",
|
||||||
"outlook": "Outlook",
|
"outlook": "Outlook",
|
||||||
|
@ -247,7 +219,6 @@
|
||||||
"timeZoneChangeDetectorMessage": "Your timezone has changed to <b>{currentTimeZone}</b>. Do you want to update your preferences?",
|
"timeZoneChangeDetectorMessage": "Your timezone has changed to <b>{currentTimeZone}</b>. Do you want to update your preferences?",
|
||||||
"yesUpdateTimezone": "Yes, update my timezone",
|
"yesUpdateTimezone": "Yes, update my timezone",
|
||||||
"noKeepCurrentTimezone": "No, keep the current timezone",
|
"noKeepCurrentTimezone": "No, keep the current timezone",
|
||||||
"annualBenefit": "{count} months free",
|
|
||||||
"removeAvatar": "Remove",
|
"removeAvatar": "Remove",
|
||||||
"uploadProfilePicture": "Upload",
|
"uploadProfilePicture": "Upload",
|
||||||
"profilePictureDescription": "Up to 2MB, JPG or PNG",
|
"profilePictureDescription": "Up to 2MB, JPG or PNG",
|
||||||
|
@ -304,5 +275,33 @@
|
||||||
"createAccount": "Create Account",
|
"createAccount": "Create Account",
|
||||||
"tooManyRequests": "Too many requests",
|
"tooManyRequests": "Too many requests",
|
||||||
"tooManyRequestsDescription": "Please try again later.",
|
"tooManyRequestsDescription": "Please try again later.",
|
||||||
"loginMagicLinkError": "This link is invalid or expired. Please request a new link."
|
"loginMagicLinkError": "This link is invalid or expired. Please request a new link.",
|
||||||
|
"subscriptionPriceMonthly": "{{price}} per month",
|
||||||
|
"subscriptionPriceYearly": "{{price}} per year",
|
||||||
|
"billingSubscriptionTitle": "Subscription",
|
||||||
|
"billingSubscriptionDescription": "View and manage your current subscription plan",
|
||||||
|
"billingSubscriptionNotActive": "You are not currently subscribed to a plan.",
|
||||||
|
"billingSubscriptionUpgradeToProDescription": "Upgrade to Pro to get access to all features and benefits.",
|
||||||
|
"billingSubscriptionUpgradeToPro": "Upgrade to Pro",
|
||||||
|
"subscriptionStatusActive": "Active",
|
||||||
|
"subscriptionStatusTrialing": "Trialing",
|
||||||
|
"subscriptionStatusPastDue": "Past due",
|
||||||
|
"subscriptionStatusCanceled": "Canceled",
|
||||||
|
"subscriptionStatusUnpaid": "Unpaid",
|
||||||
|
"subscriptionStatusUnknown": "Unknown",
|
||||||
|
"paymentMethodUnknown": "Unknown",
|
||||||
|
"subscriptionCancelOn": "Cancels {date}",
|
||||||
|
"subscriptionStatusPaused": "Paused",
|
||||||
|
"subscriptionStatusIncomplete": "Incomplete",
|
||||||
|
"subscriptionStatusIncompleteExpired": "Incomplete expired",
|
||||||
|
"billingSubscriptionPlan": "Plan",
|
||||||
|
"billingSubscriptionPrice": "Price",
|
||||||
|
"billingSubscriptionNextPaymentDue": "Next Payment Due",
|
||||||
|
"billingPaymentMethod": "Payment Method",
|
||||||
|
"noPaymentMethodSet": "No Payment Method Set",
|
||||||
|
"addPaymentMethodDescription": "Please add a payment method to ensure uninterrupted service for your subscription.",
|
||||||
|
"addPaymentMethod": "Add Payment Method",
|
||||||
|
"needToMakeChanges": "Need to make changes?",
|
||||||
|
"billingPortalDescription": "Visit the billing portal to manage your subscription, update payment methods, or view billing history.",
|
||||||
|
"priceFree": "Free"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,273 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { Button } from "@rallly/ui/button";
|
|
||||||
import { Card } from "@rallly/ui/card";
|
|
||||||
import { Label } from "@rallly/ui/label";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import { ArrowUpRight, CreditCardIcon, SendIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import Script from "next/script";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Settings,
|
|
||||||
SettingsContent,
|
|
||||||
SettingsSection,
|
|
||||||
} from "@/components/settings/settings";
|
|
||||||
import { Trans } from "@/components/trans";
|
|
||||||
import { useSubscription } from "@/contexts/plan";
|
|
||||||
import { trpc } from "@/trpc/client";
|
|
||||||
|
|
||||||
import type { PricingData } from "./billing-plans";
|
|
||||||
import { BillingPlans } from "./billing-plans";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
Paddle: any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const BillingPortal = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm">
|
|
||||||
<Trans
|
|
||||||
i18nKey="activeSubscription"
|
|
||||||
defaults="Thank you for subscribing to Rallly Pro. You can manage your subscription and billing details from the billing portal."
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<div className="mt-6">
|
|
||||||
<Button asChild>
|
|
||||||
<Link
|
|
||||||
href={`/api/stripe/portal?return_path=${encodeURIComponent(
|
|
||||||
window.location.pathname,
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
<CreditCardIcon className="size-4" />
|
|
||||||
<span>
|
|
||||||
<Trans i18nKey="billingPortal" defaults="Billing Portal" />
|
|
||||||
</span>
|
|
||||||
<ArrowUpRight className="size-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const proPlanIdMonthly = process.env.NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY as string;
|
|
||||||
|
|
||||||
const SubscriptionStatus = ({ pricingData }: { pricingData: PricingData }) => {
|
|
||||||
const data = useSubscription();
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{!data.active ? (
|
|
||||||
<BillingPlans pricingData={pricingData} />
|
|
||||||
) : data.legacy ? (
|
|
||||||
<LegacyBilling />
|
|
||||||
) : (
|
|
||||||
<SettingsSection
|
|
||||||
title={<Trans i18nKey="billingStatus" defaults="Billing Status" />}
|
|
||||||
description={
|
|
||||||
<Trans
|
|
||||||
i18nKey="billingStatusDescription"
|
|
||||||
defaults="Manage your subscription and billing details."
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<BillingPortal />
|
|
||||||
</SettingsSection>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LegacyBilling = () => {
|
|
||||||
const { data: userPaymentData } = trpc.user.getBilling.useQuery();
|
|
||||||
|
|
||||||
if (!userPaymentData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { status, endDate, planId } = userPaymentData;
|
|
||||||
|
|
||||||
if (status === "trialing" || status === "past_due") {
|
|
||||||
return <p>Invalid billing status</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{process.env.NEXT_PUBLIC_PADDLE_VENDOR_ID ? (
|
|
||||||
<Script
|
|
||||||
src="https://cdn.paddle.com/paddle/paddle.js"
|
|
||||||
onLoad={() => {
|
|
||||||
if (process.env.NEXT_PUBLIC_PADDLE_SANDBOX === "true") {
|
|
||||||
window.Paddle.Environment.set("sandbox");
|
|
||||||
}
|
|
||||||
window.Paddle.Setup({
|
|
||||||
vendor: Number(process.env.NEXT_PUBLIC_PADDLE_VENDOR_ID),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Card>
|
|
||||||
<div className="grid gap-4 p-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<Label>
|
|
||||||
<Trans i18nKey="billingStatusState" defaults="Status" />
|
|
||||||
</Label>
|
|
||||||
<div>
|
|
||||||
{(() => {
|
|
||||||
switch (status) {
|
|
||||||
case "active":
|
|
||||||
return (
|
|
||||||
<Trans i18nKey="billingStatusActive" defaults="Active" />
|
|
||||||
);
|
|
||||||
case "paused":
|
|
||||||
return (
|
|
||||||
<Trans i18nKey="billingStatusPaused" defaults="Paused" />
|
|
||||||
);
|
|
||||||
case "deleted":
|
|
||||||
return (
|
|
||||||
<Trans
|
|
||||||
i18nKey="billingStatusDeleted"
|
|
||||||
defaults="Cancelled"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{status === "deleted" ? (
|
|
||||||
<Label>
|
|
||||||
<Trans i18nKey="endDate" defaults="End date" />
|
|
||||||
</Label>
|
|
||||||
) : (
|
|
||||||
<Label>
|
|
||||||
<Trans i18nKey="dueDate" defaults="Due date" />
|
|
||||||
</Label>
|
|
||||||
)}
|
|
||||||
<div>{dayjs(endDate).format("LL")}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>
|
|
||||||
<Trans i18nKey="billingStatusPlan" defaults="Plan" />
|
|
||||||
</Label>
|
|
||||||
<div>
|
|
||||||
<Trans i18nKey="planPro" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>
|
|
||||||
<Trans i18nKey="billingPeriod" defaults="Period" />
|
|
||||||
</Label>
|
|
||||||
<div>
|
|
||||||
{planId === proPlanIdMonthly ? (
|
|
||||||
<Trans i18nKey="billingPeriodMonthly" defaults="Monthly" />
|
|
||||||
) : (
|
|
||||||
<Trans i18nKey="billingPeriodYearly" defaults="Yearly" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status === "active" || status === "paused" ? (
|
|
||||||
<div className="flex items-center gap-x-2 border-t bg-gray-50 p-3">
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
window.Paddle.Checkout.open({
|
|
||||||
override: userPaymentData.updateUrl,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link href={userPaymentData.updateUrl}>
|
|
||||||
<CreditCardIcon className="size-4" />
|
|
||||||
<Trans
|
|
||||||
i18nKey="subscriptionUpdatePayment"
|
|
||||||
defaults="Update Payment Details"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
window.Paddle.Checkout.open({
|
|
||||||
override: userPaymentData.cancelUrl,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link href={userPaymentData.cancelUrl}>
|
|
||||||
<Trans i18nKey="subscriptionCancel" defaults="Cancel" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
<SettingsSection
|
|
||||||
title={<Trans i18nKey="support" defaults="Support" />}
|
|
||||||
description={
|
|
||||||
<Trans i18nKey="supportDescription" defaults="Need help with anything?" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<p className="text-sm">
|
|
||||||
<Trans
|
|
||||||
i18nKey="supportBilling"
|
|
||||||
defaults="Please reach out if you need any assistance."
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="mailto:support@rallly.co">
|
|
||||||
<SendIcon className="size-4" />
|
|
||||||
<Trans i18nKey="contactSupport" defaults="Contact Support" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SettingsSection>;
|
|
||||||
|
|
||||||
export function BillingPage({ pricingData }: { pricingData: PricingData }) {
|
|
||||||
return (
|
|
||||||
<Settings>
|
|
||||||
<SettingsContent>
|
|
||||||
<SubscriptionStatus pricingData={pricingData} />
|
|
||||||
<hr />
|
|
||||||
<SettingsSection
|
|
||||||
title={<Trans i18nKey="support" defaults="Support" />}
|
|
||||||
description={
|
|
||||||
<Trans
|
|
||||||
i18nKey="supportDescription"
|
|
||||||
defaults="Need help with anything?"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<p className="text-sm">
|
|
||||||
<Trans
|
|
||||||
i18nKey="supportBilling"
|
|
||||||
defaults="Please reach out if you need any assistance."
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="mailto:support@rallly.co">
|
|
||||||
<SendIcon className="size-4" />
|
|
||||||
<Trans i18nKey="contactSupport" defaults="Contact Support" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsContent>
|
|
||||||
</Settings>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,176 +0,0 @@
|
||||||
import { Badge } from "@rallly/ui/badge";
|
|
||||||
import {
|
|
||||||
BillingPlan,
|
|
||||||
BillingPlanDescription,
|
|
||||||
BillingPlanHeader,
|
|
||||||
BillingPlanPeriod,
|
|
||||||
BillingPlanPerk,
|
|
||||||
BillingPlanPerks,
|
|
||||||
BillingPlanPrice,
|
|
||||||
BillingPlanTitle,
|
|
||||||
} from "@rallly/ui/billing-plan";
|
|
||||||
import { Button } from "@rallly/ui/button";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@rallly/ui/tabs";
|
|
||||||
import { TrendingUpIcon } from "lucide-react";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import { Trans } from "@/components/trans";
|
|
||||||
import { UpgradeButton } from "@/components/upgrade-button";
|
|
||||||
|
|
||||||
export type PricingData = {
|
|
||||||
monthly: {
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
yearly: {
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BillingPlans = ({ pricingData }: { pricingData: PricingData }) => {
|
|
||||||
const [tab, setTab] = React.useState("yearly");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs value={tab} onValueChange={setTab}>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="monthly">
|
|
||||||
<Trans i18nKey="billingPeriodMonthly" />
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="yearly" className="inline-flex gap-x-2.5">
|
|
||||||
<Trans i18nKey="billingPeriodYearly" />
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 rounded-md md:grid-cols-2">
|
|
||||||
<BillingPlan>
|
|
||||||
<BillingPlanHeader>
|
|
||||||
<BillingPlanTitle>
|
|
||||||
<Trans i18nKey="planFree" />
|
|
||||||
</BillingPlanTitle>
|
|
||||||
<BillingPlanDescription>
|
|
||||||
<Trans
|
|
||||||
i18nKey="planFreeDescription"
|
|
||||||
defaults="For casual users"
|
|
||||||
/>
|
|
||||||
</BillingPlanDescription>
|
|
||||||
</BillingPlanHeader>
|
|
||||||
<div>
|
|
||||||
<BillingPlanPrice>$0</BillingPlanPrice>
|
|
||||||
<BillingPlanPeriod>
|
|
||||||
<Trans i18nKey="freeForever" />
|
|
||||||
</BillingPlanPeriod>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<Button disabled className="w-full">
|
|
||||||
<Trans i18nKey="currentPlan" defaults="Current Plan" />
|
|
||||||
</Button>
|
|
||||||
<BillingPlanPerks>
|
|
||||||
<BillingPlanPerk>
|
|
||||||
<Trans
|
|
||||||
i18nKey="limitedAccess"
|
|
||||||
defaults="Access to core features"
|
|
||||||
/>
|
|
||||||
</BillingPlanPerk>
|
|
||||||
<BillingPlanPerk>
|
|
||||||
<Trans
|
|
||||||
i18nKey="pollsDeleted"
|
|
||||||
defaults="Polls are automatically deleted once they become inactive"
|
|
||||||
/>
|
|
||||||
</BillingPlanPerk>
|
|
||||||
</BillingPlanPerks>
|
|
||||||
</BillingPlan>
|
|
||||||
<div className="space-y-4 rounded-md border bg-white p-4 shadow-sm">
|
|
||||||
<div>
|
|
||||||
<BillingPlanTitle>
|
|
||||||
<Trans i18nKey="planPro" />
|
|
||||||
</BillingPlanTitle>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<Trans
|
|
||||||
i18nKey="planProDescription"
|
|
||||||
defaults="For power users and professionals"
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<TabsContent value="yearly">
|
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
<BillingPlanPrice>
|
|
||||||
${pricingData.yearly.amount / 100}
|
|
||||||
</BillingPlanPrice>
|
|
||||||
<Badge variant="green">
|
|
||||||
<Trans
|
|
||||||
i18nKey="annualBenefit"
|
|
||||||
defaults="{count} months free"
|
|
||||||
values={{
|
|
||||||
count: 4,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BillingPlanPeriod>
|
|
||||||
<Trans
|
|
||||||
i18nKey="yearlyBillingDescription"
|
|
||||||
defaults="per year"
|
|
||||||
/>
|
|
||||||
</BillingPlanPeriod>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="monthly">
|
|
||||||
<BillingPlanPrice>
|
|
||||||
${pricingData.monthly.amount / 100}
|
|
||||||
</BillingPlanPrice>
|
|
||||||
<BillingPlanPeriod>
|
|
||||||
<Trans
|
|
||||||
i18nKey="monthlyBillingDescription"
|
|
||||||
defaults="per month"
|
|
||||||
/>
|
|
||||||
</BillingPlanPeriod>
|
|
||||||
</TabsContent>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<UpgradeButton annual={tab === "yearly"} />
|
|
||||||
<BillingPlanPerks>
|
|
||||||
<BillingPlanPerk pro={true}>
|
|
||||||
<Trans
|
|
||||||
i18nKey="accessAllFeatures"
|
|
||||||
defaults="Access all features"
|
|
||||||
/>
|
|
||||||
</BillingPlanPerk>
|
|
||||||
<BillingPlanPerk pro={true}>
|
|
||||||
<Trans i18nKey="plan_extendedPollLife" />
|
|
||||||
</BillingPlanPerk>
|
|
||||||
<BillingPlanPerk pro={true}>
|
|
||||||
<Trans
|
|
||||||
i18nKey="earlyAccess"
|
|
||||||
defaults="Get early access to new features"
|
|
||||||
/>
|
|
||||||
</BillingPlanPerk>
|
|
||||||
</BillingPlanPerks>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-cyan-800/10 bg-cyan-50 px-4 py-3 text-cyan-800 shadow-sm">
|
|
||||||
<div className="mb-2">
|
|
||||||
<TrendingUpIcon className="text-indigo mr-2 mt-0.5 size-6 shrink-0" />
|
|
||||||
</div>
|
|
||||||
<div className="mb-2 flex items-center gap-x-2">
|
|
||||||
<h3 className="text-sm font-semibold">
|
|
||||||
<Trans
|
|
||||||
i18nKey="upgradeNowSaveLater"
|
|
||||||
defaults="Upgrade now, save later"
|
|
||||||
/>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm">
|
|
||||||
<Trans
|
|
||||||
i18nKey="earlyAdopterDescription"
|
|
||||||
defaults="As an early adopter, you'll lock in your subscription rate and won't be affected by future price increases."
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@rallly/ui/badge";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
|
||||||
|
const brandLabels = {
|
||||||
|
visa: "Visa",
|
||||||
|
mastercard: "Mastercard",
|
||||||
|
amex: "American Express",
|
||||||
|
discover: "Discover",
|
||||||
|
unionpay: "UnionPay",
|
||||||
|
eftpos_au: "Eftpos (AU)",
|
||||||
|
jcb: "JCB",
|
||||||
|
link: "Link",
|
||||||
|
diners: "Diners Club",
|
||||||
|
unknown: "Unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
type CardBrands = keyof typeof brandLabels;
|
||||||
|
|
||||||
|
function CardDetails({ brand, last4 }: { brand: CardBrands; last4: string }) {
|
||||||
|
const brandLabel = brandLabels[brand];
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Badge>{brandLabel}</Badge>
|
||||||
|
<span>**** {last4}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardDataSchema = z.object({
|
||||||
|
brand: z
|
||||||
|
.enum([
|
||||||
|
"visa",
|
||||||
|
"mastercard",
|
||||||
|
"amex",
|
||||||
|
"discover",
|
||||||
|
"unionpay",
|
||||||
|
"eftpos_au",
|
||||||
|
"jcb",
|
||||||
|
"diners",
|
||||||
|
"link",
|
||||||
|
"unknown",
|
||||||
|
])
|
||||||
|
.catch("unknown"),
|
||||||
|
last4: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function PaymentMethod({ type, data }: { type: string; data: unknown }) {
|
||||||
|
switch (type) {
|
||||||
|
case "card": {
|
||||||
|
const cardData = cardDataSchema.parse(data);
|
||||||
|
return <CardDetails brand={cardData.brand} last4={cardData.last4} />;
|
||||||
|
}
|
||||||
|
case "link":
|
||||||
|
return "Link";
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Badge>
|
||||||
|
<Trans i18nKey="paymentMethodUnknown" defaults="Unknown" />
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the formatted price for the given amount, interval and currency.
|
||||||
|
* eg. $7 per month, £40 per year, etc…
|
||||||
|
*/
|
||||||
|
export function SubscriptionPrice({
|
||||||
|
amount,
|
||||||
|
interval,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
amount: number;
|
||||||
|
interval: string;
|
||||||
|
currency: string;
|
||||||
|
}) {
|
||||||
|
const formattedAmount = new Intl.NumberFormat("en", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount / 100); // Divide by 100 since amount is in cents
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{interval === "month" ? (
|
||||||
|
<Trans
|
||||||
|
i18nKey="subscriptionPriceMonthly"
|
||||||
|
defaults="{{price}} per month"
|
||||||
|
values={{ price: formattedAmount }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Trans
|
||||||
|
i18nKey="subscriptionPriceYearly"
|
||||||
|
defaults="{{price}} per year"
|
||||||
|
values={{ price: formattedAmount }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { SubscriptionStatus as SubscriptionStatusType } from "@prisma/client";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { useTranslation } from "@/i18n/client";
|
||||||
|
|
||||||
|
interface SubscriptionStatusProps {
|
||||||
|
status: SubscriptionStatusType;
|
||||||
|
cancelAtPeriodEnd: boolean;
|
||||||
|
periodEnd: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubscriptionStatus = ({
|
||||||
|
status,
|
||||||
|
cancelAtPeriodEnd,
|
||||||
|
periodEnd,
|
||||||
|
}: SubscriptionStatusProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const statusConfig: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
active: {
|
||||||
|
label: t("subscriptionStatusActive", { defaultValue: "Active" }),
|
||||||
|
},
|
||||||
|
paused: {
|
||||||
|
label: t("subscriptionStatusPaused", { defaultValue: "Paused" }),
|
||||||
|
},
|
||||||
|
trialing: {
|
||||||
|
label: t("subscriptionStatusTrialing", { defaultValue: "Trialing" }),
|
||||||
|
},
|
||||||
|
past_due: {
|
||||||
|
label: t("subscriptionStatusPastDue", { defaultValue: "Past due" }),
|
||||||
|
},
|
||||||
|
canceled: {
|
||||||
|
label: t("subscriptionStatusCanceled", { defaultValue: "Canceled" }),
|
||||||
|
},
|
||||||
|
unpaid: {
|
||||||
|
label: t("subscriptionStatusUnpaid", { defaultValue: "Unpaid" }),
|
||||||
|
},
|
||||||
|
incomplete: {
|
||||||
|
label: t("subscriptionStatusIncomplete", { defaultValue: "Incomplete" }),
|
||||||
|
},
|
||||||
|
incomplete_expired: {
|
||||||
|
label: t("subscriptionStatusIncompleteExpired", {
|
||||||
|
defaultValue: "Incomplete expired",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
label: t("subscriptionStatusUnknown", { defaultValue: "Unknown" }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[status] || statusConfig.unknown;
|
||||||
|
|
||||||
|
if (status === "active" && cancelAtPeriodEnd) {
|
||||||
|
return (
|
||||||
|
<Trans
|
||||||
|
i18nKey="subscriptionCancelOn"
|
||||||
|
defaults="Cancels {date}"
|
||||||
|
values={{ date: dayjs(periodEnd).format("MMM D") }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <>{config.label}</>;
|
||||||
|
};
|
|
@ -1,21 +1,307 @@
|
||||||
import { pricingData } from "@rallly/billing/pricing";
|
import { prisma } from "@rallly/database";
|
||||||
|
import { Badge } from "@rallly/ui/badge";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import { DialogTrigger } from "@rallly/ui/dialog";
|
||||||
|
import { Icon } from "@rallly/ui/icon";
|
||||||
|
import {
|
||||||
|
ArrowUpRightIcon,
|
||||||
|
CircleAlertIcon,
|
||||||
|
CreditCardIcon,
|
||||||
|
GemIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SendIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
import { BillingPage } from "@/app/[locale]/(admin)/settings/billing/billing-page";
|
import {
|
||||||
import type { Params } from "@/app/[locale]/types";
|
DescriptionDetails,
|
||||||
import { env } from "@/env";
|
DescriptionList,
|
||||||
import { getTranslation } from "@/i18n/server";
|
DescriptionTerm,
|
||||||
|
} from "@/components/description-list";
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
EmptyStateDescription,
|
||||||
|
EmptyStateFooter,
|
||||||
|
EmptyStateIcon,
|
||||||
|
EmptyStateTitle,
|
||||||
|
} from "@/components/empty-state";
|
||||||
|
import { FormattedDate } from "@/components/formatted-date";
|
||||||
|
import { PayWallDialog } from "@/components/pay-wall-dialog";
|
||||||
|
import { Settings, SettingsSection } from "@/components/settings/settings";
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { requireUser } from "@/next-auth";
|
||||||
|
import { isSelfHosted } from "@/utils/constants";
|
||||||
|
|
||||||
|
import { PaymentMethod } from "./components/payment-method";
|
||||||
|
import { SubscriptionPrice } from "./components/subscription-price";
|
||||||
|
import { SubscriptionStatus } from "./components/subscription-status";
|
||||||
|
|
||||||
|
async function getData() {
|
||||||
|
const user = await requireUser();
|
||||||
|
|
||||||
|
const data = await prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: {
|
||||||
|
customerId: true,
|
||||||
|
subscription: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
active: true,
|
||||||
|
currency: true,
|
||||||
|
amount: true,
|
||||||
|
priceId: true,
|
||||||
|
periodStart: true,
|
||||||
|
periodEnd: true,
|
||||||
|
interval: true,
|
||||||
|
cancelAtPeriodEnd: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paymentMethods: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
data: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
if (env.NEXT_PUBLIC_SELF_HOSTED === "true") {
|
if (isSelfHosted) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
return <BillingPage pricingData={pricingData} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Params }) {
|
const data = await getData();
|
||||||
const { t } = await getTranslation(params.locale);
|
|
||||||
return {
|
const { subscription } = data;
|
||||||
title: t("billing"),
|
|
||||||
};
|
return (
|
||||||
|
<Settings>
|
||||||
|
<SettingsSection
|
||||||
|
title={
|
||||||
|
<Trans i18nKey="billingSubscriptionTitle" defaults="Subscription" />
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<Trans
|
||||||
|
i18nKey="billingSubscriptionDescription"
|
||||||
|
defaults="View and manage your current subscription plan"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{subscription ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<DescriptionList>
|
||||||
|
<DescriptionTerm>
|
||||||
|
<Trans i18nKey="billingSubscriptionPlan" defaults="Plan" />
|
||||||
|
</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
{subscription ? (
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<span className="font-bold">Pro</span>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
subscription.status === "active" ? "green" : "default"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SubscriptionStatus
|
||||||
|
status={subscription.status}
|
||||||
|
cancelAtPeriodEnd={subscription.cancelAtPeriodEnd}
|
||||||
|
periodEnd={subscription.periodEnd}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Hobby"
|
||||||
|
)}
|
||||||
|
</DescriptionDetails>
|
||||||
|
|
||||||
|
<DescriptionTerm>
|
||||||
|
<Trans i18nKey="billingSubscriptionPrice" defaults="Price" />
|
||||||
|
</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
{subscription ? (
|
||||||
|
<SubscriptionPrice
|
||||||
|
amount={subscription.amount}
|
||||||
|
currency={subscription.currency}
|
||||||
|
interval={subscription.interval}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Trans i18nKey="priceFree" defaults="Free" />
|
||||||
|
)}
|
||||||
|
</DescriptionDetails>
|
||||||
|
|
||||||
|
<DescriptionTerm>
|
||||||
|
<Trans
|
||||||
|
i18nKey="billingSubscriptionNextPaymentDue"
|
||||||
|
defaults="Next Payment Due"
|
||||||
|
/>
|
||||||
|
</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
{subscription.cancelAtPeriodEnd ? (
|
||||||
|
"-"
|
||||||
|
) : (
|
||||||
|
<FormattedDate date={subscription.periodEnd} format="short" />
|
||||||
|
)}
|
||||||
|
</DescriptionDetails>
|
||||||
|
|
||||||
|
<DescriptionTerm>
|
||||||
|
<Trans
|
||||||
|
i18nKey="billingPaymentMethod"
|
||||||
|
defaults="Payment Method"
|
||||||
|
/>
|
||||||
|
</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
{data.paymentMethods.length === 0 ? (
|
||||||
|
<div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-destructive flex items-center gap-x-2">
|
||||||
|
<CircleAlertIcon className="size-4" />
|
||||||
|
<span className="font-medium">
|
||||||
|
<Trans
|
||||||
|
i18nKey="noPaymentMethodSet"
|
||||||
|
defaults="No Payment Method Set"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<Trans
|
||||||
|
i18nKey="addPaymentMethodDescription"
|
||||||
|
defaults="Please add a payment method to ensure uninterrupted service for your subscription."
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button variant="default" asChild>
|
||||||
|
<Link
|
||||||
|
prefetch={false}
|
||||||
|
href="/api/stripe/portal/payment-methods"
|
||||||
|
>
|
||||||
|
<Icon>
|
||||||
|
<PlusIcon />
|
||||||
|
</Icon>
|
||||||
|
<Trans
|
||||||
|
i18nKey="addPaymentMethod"
|
||||||
|
defaults="Add Payment Method"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{data.paymentMethods.map((method) => (
|
||||||
|
<li key={method.id}>
|
||||||
|
<PaymentMethod type={method.type} data={method.data} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</DescriptionDetails>
|
||||||
|
</DescriptionList>
|
||||||
|
{data.customerId ? (
|
||||||
|
<div className="flex flex-col items-start justify-between gap-6 gap-x-4 rounded-lg border bg-gray-50 p-4 sm:flex-row">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
<Trans
|
||||||
|
i18nKey="needToMakeChanges"
|
||||||
|
defaults="Need to make changes?"
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<Trans
|
||||||
|
i18nKey="billingPortalDescription"
|
||||||
|
defaults="Visit the billing portal to manage your subscription, update payment methods, or view billing history."
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="default">
|
||||||
|
<Link
|
||||||
|
href="/api/stripe/portal"
|
||||||
|
prefetch={false}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Icon>
|
||||||
|
<CreditCardIcon />
|
||||||
|
</Icon>
|
||||||
|
<Trans i18nKey="billingPortal" defaults="Billing Portal" />
|
||||||
|
<Icon>
|
||||||
|
<ArrowUpRightIcon />
|
||||||
|
</Icon>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState className="py-8">
|
||||||
|
<EmptyStateIcon>
|
||||||
|
<GemIcon />
|
||||||
|
</EmptyStateIcon>
|
||||||
|
<EmptyStateTitle>
|
||||||
|
<Trans
|
||||||
|
i18nKey="billingSubscriptionNotActive"
|
||||||
|
defaults="You are not currently subscribed to a plan."
|
||||||
|
/>
|
||||||
|
</EmptyStateTitle>
|
||||||
|
<EmptyStateDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey="billingSubscriptionUpgradeToProDescription"
|
||||||
|
defaults="Upgrade to Pro to get access to all features and benefits."
|
||||||
|
/>
|
||||||
|
</EmptyStateDescription>
|
||||||
|
<EmptyStateFooter>
|
||||||
|
<PayWallDialog>
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button>
|
||||||
|
<Icon>
|
||||||
|
<SparklesIcon />
|
||||||
|
</Icon>
|
||||||
|
<Trans
|
||||||
|
i18nKey="billingSubscriptionUpgradeToPro"
|
||||||
|
defaults="Upgrade to Pro"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</PayWallDialog>
|
||||||
|
</EmptyStateFooter>
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
<hr />
|
||||||
|
<SettingsSection
|
||||||
|
title={<Trans i18nKey="support" defaults="Support" />}
|
||||||
|
description={
|
||||||
|
<Trans
|
||||||
|
i18nKey="supportDescription"
|
||||||
|
defaults="Need help with anything?"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="text-sm">
|
||||||
|
<Trans
|
||||||
|
i18nKey="supportBilling"
|
||||||
|
defaults="Please reach out if you need any assistance."
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="mailto:support@rallly.co">
|
||||||
|
<SendIcon className="size-4" />
|
||||||
|
<Trans i18nKey="contactSupport" defaults="Contact Support" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
</Settings>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
46
apps/web/src/components/description-list.tsx
Normal file
46
apps/web/src/components/description-list.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { cn } from "@rallly/ui";
|
||||||
|
|
||||||
|
export function DescriptionList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<"dl">) {
|
||||||
|
return (
|
||||||
|
<dl
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
className,
|
||||||
|
"grid grid-cols-1 text-sm/6 sm:grid-cols-[min(50%,320px)_auto]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DescriptionTerm({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<"dt">) {
|
||||||
|
return (
|
||||||
|
<dt
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
className,
|
||||||
|
"col-start-1 border-t border-gray-800/5 pt-3 text-gray-500 first:border-none sm:border-t sm:border-gray-800/5 sm:py-3 dark:border-white/5 dark:text-gray-400 sm:dark:border-white/5",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DescriptionDetails({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<"dd">) {
|
||||||
|
return (
|
||||||
|
<dd
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
className,
|
||||||
|
"pb-3 pt-1 text-gray-800 sm:border-t sm:border-gray-800/5 sm:py-3 dark:text-white dark:sm:border-white/5 sm:[&:nth-child(2)]:border-none",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
19
apps/web/src/components/formatted-date.tsx
Normal file
19
apps/web/src/components/formatted-date.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
const formatMap = {
|
||||||
|
short: "D MMM YYYY",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Format = keyof typeof formatMap;
|
||||||
|
|
||||||
|
export function FormattedDate({
|
||||||
|
date,
|
||||||
|
format,
|
||||||
|
}: {
|
||||||
|
date: Date;
|
||||||
|
format: Format;
|
||||||
|
}) {
|
||||||
|
return <>{dayjs(date).format(formatMap[format])}</>;
|
||||||
|
}
|
183
apps/web/src/components/table.tsx
Normal file
183
apps/web/src/components/table.tsx
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@rallly/ui";
|
||||||
|
import Link from "next/link";
|
||||||
|
import React, { createContext, useContext, useState } from "react";
|
||||||
|
|
||||||
|
const TableContext = createContext<{
|
||||||
|
bleed: boolean;
|
||||||
|
dense: boolean;
|
||||||
|
grid: boolean;
|
||||||
|
striped: boolean;
|
||||||
|
}>({
|
||||||
|
bleed: false,
|
||||||
|
dense: false,
|
||||||
|
grid: false,
|
||||||
|
striped: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Table({
|
||||||
|
bleed = false,
|
||||||
|
dense = false,
|
||||||
|
grid = false,
|
||||||
|
striped = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
bleed?: boolean;
|
||||||
|
dense?: boolean;
|
||||||
|
grid?: boolean;
|
||||||
|
striped?: boolean;
|
||||||
|
} & React.ComponentPropsWithoutRef<"div">) {
|
||||||
|
return (
|
||||||
|
<TableContext.Provider
|
||||||
|
value={
|
||||||
|
{ bleed, dense, grid, striped } as React.ContextType<
|
||||||
|
typeof TableContext
|
||||||
|
>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flow-root">
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
className,
|
||||||
|
"-mx-(--gutter) overflow-x-auto whitespace-nowrap",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-block min-w-full align-middle",
|
||||||
|
!bleed && "sm:px-(--gutter)",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<table className="min-w-full text-left text-sm/6 text-zinc-950 dark:text-white">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHead({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
{...props}
|
||||||
|
className={cn(className, "text-zinc-500 dark:text-zinc-400")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableBody(props: React.ComponentPropsWithoutRef<"tbody">) {
|
||||||
|
return <tbody {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableRowContext = createContext<{
|
||||||
|
href?: string;
|
||||||
|
target?: string;
|
||||||
|
title?: string;
|
||||||
|
}>({
|
||||||
|
href: undefined,
|
||||||
|
target: undefined,
|
||||||
|
title: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function TableRow({
|
||||||
|
href,
|
||||||
|
target,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
href?: string;
|
||||||
|
target?: string;
|
||||||
|
title?: string;
|
||||||
|
} & React.ComponentPropsWithoutRef<"tr">) {
|
||||||
|
const { striped } = useContext(TableContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRowContext.Provider
|
||||||
|
value={
|
||||||
|
{ href, target, title } as React.ContextType<typeof TableRowContext>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<tr
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
className,
|
||||||
|
href &&
|
||||||
|
"has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-blue-500 dark:focus-within:bg-white/[2.5%]",
|
||||||
|
striped && "even:bg-zinc-950/[2.5%] dark:even:bg-white/[2.5%]",
|
||||||
|
href && striped && "hover:bg-zinc-950/5 dark:hover:bg-white/5",
|
||||||
|
href &&
|
||||||
|
!striped &&
|
||||||
|
"hover:bg-zinc-950/[2.5%] dark:hover:bg-white/[2.5%]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TableRowContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<"th">) {
|
||||||
|
const { bleed, grid } = useContext(TableContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
className,
|
||||||
|
"first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2)) border-b border-b-zinc-950/10 px-4 py-2 font-medium dark:border-b-white/10",
|
||||||
|
grid &&
|
||||||
|
"border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5",
|
||||||
|
!bleed && "sm:first:pl-1 sm:last:pr-1",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableCell({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<"td">) {
|
||||||
|
const { bleed, dense, grid, striped } = useContext(TableContext);
|
||||||
|
const { href, target, title } = useContext(TableRowContext);
|
||||||
|
const [cellRef, setCellRef] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
ref={href ? setCellRef : undefined}
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
className,
|
||||||
|
"first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2)) relative px-4",
|
||||||
|
!striped && "border-b border-zinc-950/5 dark:border-white/5",
|
||||||
|
grid &&
|
||||||
|
"border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5",
|
||||||
|
dense ? "py-2.5" : "py-4",
|
||||||
|
!bleed && "sm:first:pl-1 sm:last:pr-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{href && (
|
||||||
|
<Link
|
||||||
|
data-row-link
|
||||||
|
href={href}
|
||||||
|
target={target}
|
||||||
|
aria-label={title}
|
||||||
|
tabIndex={cellRef?.previousElementSibling === null ? 0 : -1}
|
||||||
|
className="focus:outline-hidden absolute inset-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
"use client";
|
||||||
import { Trans as BaseTrans, useTranslation } from "react-i18next";
|
import { Trans as BaseTrans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { TxKeyPath } from "../i18n/types";
|
import type { TxKeyPath } from "../i18n/types";
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { prisma } from "@rallly/database";
|
import { prisma } from "@rallly/database";
|
||||||
import { posthog } from "@rallly/posthog/server";
|
import { posthog } from "@rallly/posthog/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import type { Provider } from "next-auth/providers";
|
import type { Provider } from "next-auth/providers";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
@ -185,4 +186,12 @@ const auth = async () => {
|
||||||
return getLegacySession();
|
return getLegacySession();
|
||||||
};
|
};
|
||||||
|
|
||||||
export { auth, handlers, signIn, signOut };
|
const requireUser = async () => {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
return session?.user;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { auth, handlers, requireUser, signIn, signOut };
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue