Update admin layout and pages (#976)

This commit is contained in:
Luke Vella 2024-01-13 15:09:48 +07:00 committed by GitHub
parent 0ba7e9ce91
commit a1bac0c986
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
81 changed files with 2053 additions and 1260 deletions

View file

@ -1,6 +1,6 @@
{ {
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll": true "source.fixAll": "explicit"
}, },
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.importModuleSpecifier": "non-relative",

View file

@ -52,13 +52,12 @@ const nextConfig = {
}, },
]; ];
}, },
sentry: {
hideSourceMaps: false,
},
}; };
const sentryWebpackPluginOptions = { const sentryWebpackPluginOptions = {
// Additional config options for the Sentry Webpack plugin. Keep in mind that org: "stack-snap",
project: "rallly",
// Additional config ocptions for the Sentry Webpack plugin. Keep in mind that
// the following options are set automatically, and overriding them is not // the following options are set automatically, and overriding them is not
// recommended: // recommended:
// release, url, org, project, authToken, configFile, stripPrefix, // release, url, org, project, authToken, configFile, stripPrefix,
@ -70,8 +69,9 @@ const sentryWebpackPluginOptions = {
// https://github.com/getsentry/sentry-webpack-plugin#options. // https://github.com/getsentry/sentry-webpack-plugin#options.
}; };
const withBundleAnalyzerConfig = withBundleAnalyzer(nextConfig);
// Make sure adding Sentry options is the last code to run before exporting, to // Make sure adding Sentry options is the last code to run before exporting, to
// ensure that your source maps include changes from all other Webpack plugins // ensure that your source maps include changes from all other Webpack plugins
module.exports = withSentryConfig( module.exports = process.env.SENTRY_AUTH_TOKEN
withBundleAnalyzer(nextConfig, sentryWebpackPluginOptions), ? withSentryConfig(withBundleAnalyzerConfig, sentryWebpackPluginOptions)
); : withBundleAnalyzerConfig;

View file

@ -53,6 +53,7 @@
"iron-session": "^6.3.1", "iron-session": "^6.3.1",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.294.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"nanoid": "^4.0.0", "nanoid": "^4.0.0",
"next-auth": "^4.24.5", "next-auth": "^4.24.5",

View file

@ -112,9 +112,6 @@
"dates": "Dates", "dates": "Dates",
"menu": "Menu", "menu": "Menu",
"useLocaleDefaults": "Use locale defaults", "useLocaleDefaults": "Use locale defaults",
"inviteParticipantsDescription": "Copy and share this invite link to start gathering responses from your participants.",
"inviteLink": "Invite Link",
"inviteParticipantLinkInfo": "Anyone with this link will be able to vote on your poll.",
"support": "Support", "support": "Support",
"billing": "Billing", "billing": "Billing",
"guestPollAlertDescription": "<0>Create an account</0> or <1>login</1> to claim this poll.", "guestPollAlertDescription": "<0>Create an account</0> or <1>login</1> to claim this poll.",
@ -135,7 +132,6 @@
"permissionDenied": "Unauthorized", "permissionDenied": "Unauthorized",
"permissionDeniedDescription": "If you are the poll creator, please login to access your poll", "permissionDeniedDescription": "If you are the poll creator, please login to access your poll",
"loginDifferent": "Switch user", "loginDifferent": "Switch user",
"share": "Share",
"timeShownIn": "Times shown in {timeZone}", "timeShownIn": "Times shown in {timeZone}",
"editDetailsDescription": "Change the details of your event.", "editDetailsDescription": "Change the details of your event.",
"finalizeDescription": "Select a final date for your event.", "finalizeDescription": "Select a final date for your event.",
@ -210,14 +206,7 @@
"earlyAccess": "Get early access to new 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.", "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", "upgradeNowSaveLater": "Upgrade now, save later",
"savePercent": "Save {percent}%",
"priceIncreaseSoon": "Price increase soon.",
"lockPrice": "Upgrade today to keep this price forever.",
"features": "Get access to all current and future Pro features!",
"noAds": "No ads",
"supportProject": "Support this project",
"pricing": "Pricing", "pricing": "Pricing",
"pleaseUpgrade": "Please upgrade to Pro to use this feature",
"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",
"hideParticipantsLabel": "Hide participant list from other participants", "hideParticipantsLabel": "Hide participant list from other participants",
@ -226,8 +215,28 @@
"authErrorDescription": "There was an error logging you in. Please try again.", "authErrorDescription": "There was an error logging you in. Please try again.",
"authErrorCta": "Go to login page", "authErrorCta": "Go to login page",
"continueAs": "Continue as", "continueAs": "Continue as",
"finalizeFeature": "Finalize",
"duplicateFeature": "Duplicate",
"pageMovedDescription": "Redirecting to <a>{newUrl}</a>", "pageMovedDescription": "Redirecting to <a>{newUrl}</a>",
"notRegistered": "Don't have an account? <a>Register</a>" "notRegistered": "Don't have an account? <a>Register</a>",
"comingSoon": "Coming Soon",
"integrations": "Integrations",
"contacts": "Contacts",
"unlockFeatures": "Unlock all Pro features.",
"back": "Back",
"pollStatusAll": "All",
"pollStatusLive": "Live",
"pollStatusFinalized": "Finalized",
"pending": "Pending",
"xMore": "{count} more",
"share": "Share",
"pageXOfY": "Page {currentPage} of {pageCount}",
"noParticipants": "No participants",
"userId": "User ID",
"aboutGuest": "Guest User",
"aboutGuestDescription": "Profile settings are not available for guest users. <0>Sign in</0> to your existing account or <1>create a new account</1> to customize your profile.",
"logoutDescription": "Sign out of your existing session",
"events": "Events",
"registrations": "Registrations",
"inviteParticipantsDescription": "Copy and share the invite link to start gathering responses from your participants.",
"inviteLink": "Invite Link",
"inviteParticipantLinkInfo": "Anyone with this link will be able to vote on your poll."
} }

View file

@ -1,8 +1,13 @@
"use client"; import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import { MenuIcon } from "lucide-react";
import Link from "next/link";
import { signIn, useSession } from "next-auth/react"; import { signIn, useSession } from "next-auth/react";
import React from "react"; import React from "react";
import { StandardLayout } from "@/components/layouts/standard-layout"; import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
import { LogoLink } from "@/app/components/logo-link";
import { CurrentUserAvatar } from "@/components/user";
import { isSelfHosted } from "@/utils/constants"; import { isSelfHosted } from "@/utils/constants";
const Auth = ({ children }: { children: React.ReactNode }) => { const Auth = ({ children }: { children: React.ReactNode }) => {
@ -22,13 +27,57 @@ const Auth = ({ children }: { children: React.ReactNode }) => {
return null; return null;
}; };
export default function Layout({ children }: { children: React.ReactNode }) { function MobileNavigation() {
return (
<div className="lg:hidden shadow-sm bg-gray-100 border-b flex items-center justify-between px-4 py-3">
<LogoLink />
<div className="flex gap-x-2.5 justify-end">
<Link
href="/settings/profile"
className="inline-flex items-center w-7 h-9"
>
<CurrentUserAvatar size="sm" />
</Link>
<Button asChild variant="ghost">
<Link href="/menu">
<MenuIcon className="h-4 w-4 text-muted-foreground" />
</Link>
</Button>
</div>
</div>
);
}
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
function SidebarLayout() {
return (
<div className="lg:flex h-full bg-gray-50">
<MobileNavigation />
<div
className={cn(
"hidden lg:flex lg:w-72 bg-gray-100 shrink-0 flex-col gap-y-5 overflow-y-auto border-r lg:px-6 lg:py-4 px-5 py-4",
)}
>
<div>
<LogoLink />
</div>
<Sidebar />
</div>
<div className={cn("grow overflow-auto bg-gray-50")}>{children}</div>
</div>
);
}
if (isSelfHosted) { if (isSelfHosted) {
return ( return (
<Auth> <Auth>
<StandardLayout>{children}</StandardLayout> <SidebarLayout />
</Auth> </Auth>
); );
} }
return <StandardLayout>{children}</StandardLayout>; return <SidebarLayout />;
} }

View file

@ -0,0 +1,42 @@
"use client";
import { cn } from "@rallly/ui";
import { Link } from "lucide-react";
import { usePathname } from "next/navigation";
import { IconComponent } from "@/types";
export function MenuItem({
href,
children,
icon: Icon,
}: {
href: string;
icon: IconComponent;
children: React.ReactNode;
}) {
const pathname = usePathname();
const isCurrent = pathname === href;
return (
<Link
href={href}
className={cn(
isCurrent
? "bg-gray-200 text-indigo-600"
: "text-gray-700 hover:text-primary",
"group flex items-center gap-x-3 rounded-md py-2 px-3 text-sm leading-6 font-semibold",
)}
>
<Icon
className={cn(
isCurrent
? "text-indigo-600"
: "text-gray-400 group-hover:text-indigo-600",
"h-5 w-5 shrink-0",
)}
aria-hidden="true"
/>
{children}
</Link>
);
}

View file

@ -0,0 +1,3 @@
export default function Loading() {
return null;
}

View file

@ -1,8 +1,37 @@
import { Button } from "@rallly/ui/button";
import Link from "next/link";
import { Trans } from "react-i18next/TransWithoutContext";
import {
PageContainer,
PageContent,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { getTranslation } from "@/app/i18n"; import { getTranslation } from "@/app/i18n";
import { CreatePoll } from "@/components/create-poll"; import { CreatePoll } from "@/components/create-poll";
export default function Page() { export default async function Page({ params }: { params: { locale: string } }) {
return <CreatePoll />; const { t } = await getTranslation(params.locale);
return (
<PageContainer>
<PageHeader>
<div className="flex justify-between items-center gap-x-4">
<PageTitle>
<Trans t={t} i18nKey="polls" />
</PageTitle>
<Button asChild>
<Link href="/polls">
<Trans t={t} i18nKey="cancel" defaults="Cancel" />
</Link>
</Button>
</div>
</PageHeader>
<PageContent>
<CreatePoll />
</PageContent>
</PageContainer>
);
} }
export async function generateMetadata({ export async function generateMetadata({

View file

@ -1,9 +1,44 @@
import { Button } from "@rallly/ui/button";
import { PenBoxIcon } from "lucide-react";
import Link from "next/link";
import { Trans } from "react-i18next/TransWithoutContext";
import {
PageContainer,
PageContent,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { getTranslation } from "@/app/i18n"; import { getTranslation } from "@/app/i18n";
import { PollsPage } from "./polls-page"; import { PollsList } from "./polls-list";
export default function Page() { export default async function Page({ params }: { params: { locale: string } }) {
return <PollsPage />; const { t } = await getTranslation(params.locale);
return (
<PageContainer>
<PageHeader>
<div className="flex justify-between items-center gap-x-4">
<PageTitle>
<Trans t={t} i18nKey="polls" />
</PageTitle>
<Button asChild>
<Link href="/new">
<PenBoxIcon className="w-4 text-muted-foreground h-4" />
<span className="hidden sm:inline">
<Trans t={t} i18nKey="newPoll" />
</span>
</Link>
</Button>
</div>
</PageHeader>
<PageContent>
<div className="space-y-6">
<PollsList />
</div>
</PageContent>
</PageContainer>
);
} }
export async function generateMetadata({ export async function generateMetadata({

View file

@ -0,0 +1,55 @@
"use client";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Trans } from "@/components/trans";
function PollFolder({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const pathname = usePathname() ?? "";
const searchParams = useSearchParams();
const query = searchParams?.has("status")
? `?${searchParams?.toString()}`
: "";
const currentUrl = pathname + query;
const isActive = href === currentUrl;
return (
<Button
asChild
className={cn(
isActive
? "bg-gray-100"
: "shadow-sm text-muted-foreground hover:bg-gray-100 active:bg-gray-200",
)}
>
<Link href={href}>{children}</Link>
</Button>
);
}
export function PollFolders() {
return (
<div className="flex flex-wrap gap-3">
<PollFolder href="/polls">
<Trans i18nKey="pollStatusAll" defaults="All" />
</PollFolder>
<PollFolder href="/polls?status=live">
<Trans i18nKey="pollStatusLive" defaults="Live" />
</PollFolder>
<PollFolder href="/polls?status=paused">
<Trans i18nKey="pollStatusPaused" defaults="Paused" />
</PollFolder>
<PollFolder href="/polls?status=finalized">
<Trans i18nKey="pollStatusFinalized" defaults="Finalized" />
</PollFolder>
</div>
);
}

View file

@ -0,0 +1,211 @@
"use client";
import { PollStatus } from "@rallly/database";
import { Button } from "@rallly/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import { createColumnHelper, PaginationState } from "@tanstack/react-table";
import dayjs from "dayjs";
import { ArrowRightIcon, InboxIcon, PlusIcon, UsersIcon } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React from "react";
import { PollStatusBadge } from "@/components/poll-status";
import { Table } from "@/components/table";
import { Trans } from "@/components/trans";
import { useDayjs } from "@/utils/dayjs";
import { trpc } from "@/utils/trpc/client";
const EmptyState = () => {
return (
<div className="py-24">
<div className="mx-auto max-w-md rounded-md border-2 w-full border-dashed border-gray-300 p-8 text-center">
<div className="mb-4">
<InboxIcon className="inline-block h-10 w-10 text-gray-400" />
</div>
<h3 className="font-semibold">
<Trans i18nKey="noPolls" defaults="No polls" />
</h3>
<p className="text-muted-foreground">
<Trans
i18nKey="noPollsDescription"
defaults="Get started by creating a new poll."
/>
</p>
<div className="mt-6">
<Button variant="primary" asChild={true}>
<Link href="/new">
<PlusIcon className="h-5 w-5" />
<Trans defaults="New Poll" i18nKey="newPoll" />
</Link>
</Button>
</div>
</div>
</div>
);
};
type Column = {
id: string;
status: PollStatus;
title: string;
createdAt: Date;
participants: { name: string }[];
timeZone: string | null;
event: {
start: Date;
duration: number;
} | null;
};
const columnHelper = createColumnHelper<Column>();
export function PollsList() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const pagination = React.useMemo<PaginationState>(
() => ({
pageIndex: (Number(searchParams?.get("page")) || 1) - 1,
pageSize: Number(searchParams?.get("pageSize")) || 10,
}),
[searchParams],
);
const { data } = trpc.polls.paginatedList.useQuery({ pagination });
const { adjustTimeZone } = useDayjs();
const columns = React.useMemo(
() => [
columnHelper.display({
id: "title",
header: () => null,
size: 5000,
cell: ({ row }) => {
return (
<Link className="group block" href={`/poll/${row.original.id}`}>
<div className="flex items-center gap-x-2 mb-1 min-w-0">
<h3 className="font-semibold truncate text-gray-600 group-hover:text-gray-900">
{row.original.title}
</h3>
<ArrowRightIcon className="h-4 w-4 opacity-0 transition-all group-focus:translate-x-2 group-hover:opacity-100" />
</div>
{row.original.event ? (
<p className="text-sm text-muted-foreground">
{row.original.event.duration === 0
? adjustTimeZone(
row.original.event.start,
!row.original.timeZone,
).format("LL")
: `${adjustTimeZone(
row.original.event.start,
!row.original.timeZone,
).format("LL LT")} - ${adjustTimeZone(
dayjs(row.original.event.start).add(
row.original.event.duration,
"minutes",
),
!row.original.timeZone,
).format("LT")}`}
</p>
) : (
<p className="text-sm text-gray-400">
<Trans i18nKey="pending" defaults="Pending" />
</p>
)}
</Link>
);
},
}),
columnHelper.accessor("status", {
header: () => null,
size: 200,
cell: ({ row }) => {
return (
<div>
<PollStatusBadge status={row.getValue("status")} />
</div>
);
},
}),
columnHelper.accessor("createdAt", {
header: () => null,
size: 1000,
cell: ({ row }) => {
const { createdAt } = row.original;
return (
<p className="text-sm whitespace-nowrap text-muted-foreground">
<time dateTime={createdAt.toDateString()}>
<Trans
i18nKey="createdTime"
values={{ relativeTime: dayjs(createdAt).fromNow() }}
/>
</time>
</p>
);
},
}),
columnHelper.accessor("participants", {
header: () => null,
cell: ({ row }) => {
return (
<Tooltip delayDuration={100}>
<TooltipTrigger className="flex items-center text-muted-foreground gap-x-2">
<UsersIcon className="h-4 w-4" />
<span className="text-sm">
{row.original.participants.length}
</span>
</TooltipTrigger>
<TooltipContent>
{row.original.participants.length > 0 ? (
<>
{row.original.participants
.slice(0, 10)
.map((participant, i) => (
<p key={i}>{participant.name}</p>
))}
{row.original.participants.length > 10 ? (
<p>
<Trans
i18nKey="xMore"
defaults="{count} more"
values={{
count: row.original.participants.length - 5,
}}
/>
</p>
) : null}
</>
) : (
<Trans i18nKey="noParticipants" defaults="No participants" />
)}
</TooltipContent>
</Tooltip>
);
},
}),
],
[adjustTimeZone],
);
if (!data) return null;
if (data.total === 0) return <EmptyState />;
return (
<Table
layout="auto"
paginationState={pagination}
data={data.rows as Column[]}
pageCount={Math.ceil(data.total / pagination.pageSize)}
onPaginationChange={(updater) => {
const newPagination =
typeof updater === "function" ? updater(pagination) : updater;
const current = new URLSearchParams(searchParams ?? undefined);
current.set("page", String(newPagination.pageIndex + 1));
// current.set("pageSize", String(newPagination.pageSize));
router.push(`${pathname}?${current.toString()}`);
}}
columns={columns}
/>
);
}

View file

@ -1,174 +0,0 @@
"use client";
import { Button } from "@rallly/ui/button";
import dayjs from "dayjs";
import {
InboxIcon,
PauseCircleIcon,
PlusIcon,
RadioIcon,
VoteIcon,
} from "lucide-react";
import Link from "next/link";
import { Container } from "@/components/container";
import { DateIcon } from "@/components/date-icon";
import {
TopBar,
TopBarTitle,
} from "@/components/layouts/standard-layout/top-bar";
import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
import { PollStatusBadge } from "@/components/poll-status";
import { Skeleton } from "@/components/skeleton";
import { Trans } from "@/components/trans";
import { useDayjs } from "@/utils/dayjs";
import { trpc } from "@/utils/trpc/client";
const EmptyState = () => {
return (
<div className="p-8 lg:p-36">
<div className="mx-auto max-w-lg rounded-md border-2 border-dashed border-gray-300 p-8 text-center text-gray-600">
<div className="mb-4">
<InboxIcon className="inline-block h-10 w-10 text-gray-500" />
</div>
<h3>
<Trans i18nKey="noPolls" defaults="No polls" />
</h3>
<p>
<Trans
i18nKey="noPollsDescription"
defaults="Get started by creating a new poll."
/>
</p>
<div className="mt-6">
<Button variant="primary" asChild={true}>
<Link href="/new">
<PlusIcon className="h-5 w-5" />
<Trans defaults="New Poll" i18nKey="newPoll" />
</Link>
</Button>
</div>
</div>
</div>
);
};
export function PollsPage() {
const { data } = trpc.polls.list.useQuery();
const { adjustTimeZone } = useDayjs();
return (
<div>
<TopBar className="flex items-center justify-between gap-4">
<TopBarTitle title={<Trans i18nKey="polls" />} icon={VoteIcon} />
<div>
<Button variant="primary" asChild={true}>
<Link href="/new">
<PlusIcon className="-ml-0.5 h-5 w-5" />
<Trans defaults="New Poll" i18nKey="newPoll" />
</Link>
</Button>
</div>
</TopBar>
<div>
<Container className="mx-auto p-3 sm:p-8">
{data ? (
data.length > 0 ? (
<div className="mx-auto grid max-w-3xl gap-3 sm:gap-4">
{data.map((poll) => {
const { title, id: pollId, createdAt, status } = poll;
return (
<div
key={poll.id}
className="flex overflow-hidden rounded-md border shadow-sm"
>
<div className="flex grow flex-col-reverse justify-between gap-x-4 gap-y-4 bg-white p-4 sm:flex-row sm:items-start sm:px-6">
<div className="flex gap-x-4">
<div className="sm:-ml-2">
{poll.event ? (
<DateIcon
date={adjustTimeZone(
poll.event.start,
!poll.timeZone,
)}
/>
) : (
<div className="inline-flex h-14 w-14 items-center justify-center rounded-md border bg-gray-50 text-gray-400">
{status === "live" ? (
<RadioIcon className="h-5 w-5" />
) : (
<PauseCircleIcon className="h-5 w-5" />
)}
</div>
)}
</div>
<div>
<div className="text-muted-foreground text-sm">
{poll.event
? poll.event.duration > 0
? `${adjustTimeZone(
poll.event.start,
!poll.timeZone,
).format("LL LT")} - ${adjustTimeZone(
dayjs(poll.event.start).add(
poll.event.duration,
"minutes",
),
!poll.timeZone,
).format("LT")}`
: adjustTimeZone(
poll.event.start,
!poll.timeZone,
).format("LL")
: null}
</div>
<div>
<Link
href={`/poll/${pollId}`}
className="text-lg font-semibold tracking-tight hover:underline"
>
{title}
</Link>
</div>
<div className="text-muted-foreground text-sm">
<Trans
i18nKey="createdTime"
defaults="Created {relativeTime}"
values={{
relativeTime: dayjs(createdAt).fromNow(),
}}
/>
</div>
{poll.participants.length > 0 ? (
<div className="mt-4">
<ParticipantAvatarBar
participants={poll.participants}
max={5}
/>
</div>
) : null}
</div>
</div>
<div>
<PollStatusBadge status={status} />
</div>
</div>
</div>
);
})}
</div>
) : (
<EmptyState />
)
) : (
<div className="mx-auto grid max-w-3xl gap-3 sm:gap-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
)}
</Container>
</div>
</div>
);
}

View file

@ -13,7 +13,6 @@ import { BillingPlans } from "@/components/billing/billing-plans";
import { import {
Settings, Settings,
SettingsContent, SettingsContent,
SettingsHeader,
SettingsSection, SettingsSection,
} from "@/components/settings/settings"; } from "@/components/settings/settings";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
@ -239,9 +238,6 @@ export function BillingPage() {
return ( return (
<Settings> <Settings>
<SettingsHeader>
<Trans i18nKey="billing" />
</SettingsHeader>
<Head> <Head>
<title>{t("billing")}</title> <title>{t("billing")}</title>
</Head> </Head>
@ -257,6 +253,7 @@ export function BillingPage() {
> >
<SubscriptionStatus /> <SubscriptionStatus />
</SettingsSection> </SettingsSection>
<hr />
<SettingsSection <SettingsSection
title={<Trans i18nKey="support" defaults="Support" />} title={<Trans i18nKey="support" defaults="Support" />}
description={ description={

View file

@ -0,0 +1,3 @@
export default function Loading() {
return null;
}

View file

@ -1,10 +1,61 @@
"use client"; import { CreditCardIcon, Settings2Icon, UserIcon } from "lucide-react";
import { ProfileLayout } from "@/components/layouts/profile-layout"; import React from "react";
import { Trans } from "react-i18next/TransWithoutContext";
export default function SettingsLayout({ import {
PageContainer,
PageContent,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { getTranslation } from "@/app/i18n";
import { isSelfHosted } from "@/utils/constants";
import { SettingsMenu } from "./menu-item";
export default async function ProfileLayout({
children, children,
}: { params,
children: React.ReactNode; }: React.PropsWithChildren<{
}) { params: { locale: string };
return <ProfileLayout>{children}</ProfileLayout>; }>) {
const { t } = await getTranslation(params.locale);
const menuItems = [
{
title: t("profile"),
href: "/settings/profile",
icon: UserIcon,
},
{
title: t("preferences"),
href: "/settings/preferences",
icon: Settings2Icon,
},
];
if (!isSelfHosted) {
menuItems.push({
title: t("billing"),
href: "/settings/billing",
icon: CreditCardIcon,
});
}
return (
<PageContainer>
<PageHeader>
<div className="flex items-center justify-between gap-x-4">
<PageTitle>
<Trans t={t} i18nKey="settings" />
</PageTitle>
</div>
</PageHeader>
<PageContent className="space-y-6">
<div>
<SettingsMenu />
</div>
<div className="max-w-4xl">{children}</div>
</PageContent>
</PageContainer>
);
} }

View file

@ -0,0 +1,102 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@rallly/ui/select";
import clsx from "clsx";
import { CreditCardIcon, Settings2Icon, UserIcon } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import React from "react";
import { useTranslation } from "react-i18next";
import { isSelfHosted } from "@/utils/constants";
export function MenuItem(props: { href: string; children: React.ReactNode }) {
const pathname = usePathname();
return (
<Link
className={clsx(
"flex min-w-0 items-center gap-x-2 px-3 py-2 text-sm font-medium",
pathname === props.href
? "bg-gray-200"
: "text-gray-500 hover:text-gray-800",
)}
href={props.href}
>
{props.children}
</Link>
);
}
export function SettingsMenu() {
const { t } = useTranslation();
const pathname = usePathname();
const menuItems = React.useMemo(() => {
const items = [
{
title: t("profile"),
href: "/settings/profile",
icon: UserIcon,
},
{
title: t("preferences"),
href: "/settings/preferences",
icon: Settings2Icon,
},
];
if (!isSelfHosted) {
items.push({
title: t("billing"),
href: "/settings/billing",
icon: CreditCardIcon,
});
}
return items;
}, [t]);
const router = useRouter();
const value = React.useMemo(
() => menuItems.find((item) => item.href === pathname),
[menuItems, pathname],
);
return (
<>
<div className="hidden lg:inline-flex mb-4 border rounded-md p-0.5 gap-x-2">
{menuItems.map((item, i) => (
<MenuItem key={i} href={item.href}>
<item.icon className="h-4 w-4" />
{item.title}
</MenuItem>
))}
</div>
<Select
value={value?.title}
onValueChange={(value) => {
const item = menuItems.find((item) => item.title === value);
if (item) {
router.push(item.href);
}
}}
>
<SelectTrigger className="lg:hidden">
<SelectValue />
</SelectTrigger>
<SelectContent>
{menuItems.map((item, i) => (
<SelectItem key={i} value={item.title}>
<div className="flex items-center gap-x-2.5">
<item.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{item.title}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</>
);
}

View file

@ -0,0 +1,3 @@
export default function Loading() {
return null;
}

View file

@ -7,7 +7,6 @@ import { LanguagePreference } from "@/components/settings/language-preference";
import { import {
Settings, Settings,
SettingsContent, SettingsContent,
SettingsHeader,
SettingsSection, SettingsSection,
} from "@/components/settings/settings"; } from "@/components/settings/settings";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
@ -17,9 +16,6 @@ export function PreferencesPage() {
return ( return (
<Settings> <Settings>
<SettingsHeader>
<Trans i18nKey="preferences" />
</SettingsHeader>
<SettingsContent> <SettingsContent>
<Head> <Head>
<title>{t("settings")}</title> <title>{t("settings")}</title>
@ -35,6 +31,7 @@ export function PreferencesPage() {
> >
<LanguagePreference /> <LanguagePreference />
</SettingsSection> </SettingsSection>
<hr />
<SettingsSection <SettingsSection
title={<Trans i18nKey="dateAndTime" defaults="Date & Time" />} title={<Trans i18nKey="dateAndTime" defaults="Date & Time" />}
description={ description={

View file

@ -0,0 +1,3 @@
export default function Loading() {
return null;
}

View file

@ -1,13 +1,19 @@
"use client"; "use client";
import { Alert, AlertDescription, AlertTitle } from "@rallly/ui/alert";
import { Label } from "@rallly/ui/label";
import { InfoIcon, LogOutIcon, UserXIcon } from "lucide-react";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { LogoutButton } from "@/app/components/logout-button";
import { ProfileSettings } from "@/components/settings/profile-settings"; import { ProfileSettings } from "@/components/settings/profile-settings";
import { import {
Settings, Settings,
SettingsHeader, SettingsContent,
SettingsSection, SettingsSection,
} from "@/components/settings/settings"; } from "@/components/settings/settings";
import { TextInput } from "@/components/text-input";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider"; import { useUser } from "@/components/user-provider";
@ -15,54 +21,78 @@ export const ProfilePage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useUser(); const { user } = useUser();
if (user.isGuest) {
return null;
}
return ( return (
<Settings> <Settings>
<Head> <Head>
<title>{t("profile")}</title> <title>{t("profile")}</title>
</Head> </Head>
<SettingsHeader> {user.isGuest ? (
<Trans i18nKey="profile" /> <SettingsContent>
</SettingsHeader> <SettingsSection
<SettingsSection title={<Trans i18nKey="profile" />}
title={<Trans i18nKey="profile" defaults="Profile" />} description={<Trans i18nKey="profileDescription" />}
description={ >
<Trans <Label className="mb-2.5">
i18nKey="profileDescription" <Trans i18nKey="userId" defaults="User ID" />
defaults="Set your public profile information" </Label>
/> <TextInput
} className="w-full"
> value={user.id.substring(0, 10)}
<ProfileSettings /> readOnly
</SettingsSection> disabled
{/* <SettingsSection />
title={<Trans defaults="Email" i18nKey="settings_profile_email" />} <Alert className="mt-4" icon={InfoIcon}>
description={ <AlertTitle>
<Trans <Trans i18nKey="aboutGuest" defaults="Guest User" />
i18nKey="settings_profile_emailDescription" </AlertTitle>
defaults="Change your email address" <AlertDescription>
/> <Trans
} i18nKey="aboutGuestDescription"
> defaults="Profile settings are not available for guest users. <0>Sign in</0> to your existing account or <1>create a new account</1> to customize your profile."
<ChangeEmailForm /> components={[
</SettingsSection> */} <Link className="text-link" key={0} href="/login" />,
{/* <SettingsSection <Link className="text-link" key={1} href="/register" />,
title={<Trans i18nKey="deleteAccount" defaults="Delete Account" />} ]}
description={ />
<Trans </AlertDescription>
i18nKey="deleteAccountDescription" </Alert>
defaults="Delete your account here. <LogoutButton className="mt-6" variant="destructive">
This action is not reversible. All information related to this <UserXIcon className="h-4 w-4" />
account will be deleted permanently." <Trans i18nKey="forgetMe" />
/> </LogoutButton>
} </SettingsSection>
> </SettingsContent>
<Button htmlType="submit" variant="destructive"> ) : (
<Trans i18nKey="deleteMyAccount" defaults="Yes, delete my account" /> <SettingsContent>
</Button> <SettingsSection
</SettingsSection> */} title={<Trans i18nKey="profile" defaults="Profile" />}
description={
<Trans
i18nKey="profileDescription"
defaults="Set your public profile information"
/>
}
>
<ProfileSettings />
</SettingsSection>
<hr />
<SettingsSection
title={<Trans i18nKey="logout" />}
description={
<Trans
i18nKey="logoutDescription"
defaults="Sign out of your existing session"
/>
}
>
<LogoutButton>
<LogOutIcon className="h-4 w-4" />
<Trans i18nKey="logout" defaults="Logout" />
</LogoutButton>
</SettingsSection>
</SettingsContent>
)}
</Settings> </Settings>
); );
}; };

View file

@ -0,0 +1,161 @@
"use client";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import {
BlocksIcon,
BookMarkedIcon,
CalendarIcon,
ChevronRightIcon,
LogInIcon,
Settings2Icon,
SparklesIcon,
UsersIcon,
VoteIcon,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ProBadge } from "@/components/pro-badge";
import { Trans } from "@/components/trans";
import { CurrentUserAvatar } from "@/components/user";
import { IfGuest, useUser } from "@/components/user-provider";
import { IfFreeUser } from "@/contexts/plan";
import { IconComponent } from "@/types";
function NavItem({
href,
children,
icon: Icon,
current,
}: {
href: string;
icon: IconComponent;
children: React.ReactNode;
current?: boolean;
}) {
return (
<Link
href={href}
className={cn(
current
? "bg-gray-200 text-gray-800"
: "text-gray-700 hover:bg-gray-200 active:bg-gray-300",
"group flex items-center gap-x-3 rounded-md py-2 px-3 text-sm leading-6 font-semibold",
)}
>
<Icon
className={cn(
current ? "text-gray-500" : "text-gray-400 group-hover:text-gray-500",
"h-5 w-5 shrink-0",
)}
aria-hidden="true"
/>
{children}
</Link>
);
}
export function Sidebar() {
const pathname = usePathname();
const { user } = useUser();
return (
<nav className="flex flex-1 flex-col ">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
<li>
<NavItem
current={pathname?.startsWith("/poll")}
href="/polls"
icon={VoteIcon}
>
<Trans i18nKey="polls" defaults="Polls" />
</NavItem>
</li>
</ul>
</li>
<li>
<div className="text-xs font-semibold leading-6 text-gray-400">
<Trans i18nKey="comingSoon" defaults="Coming Soon" />
</div>
<ul role="list" className="-mx-2 mt-2 space-y-1">
<li className="grid gap-1 pointer-events-none opacity-50">
<NavItem href="/events" icon={CalendarIcon}>
<Trans i18nKey="events" defaults="Events" />
</NavItem>
<NavItem href="/b" icon={BookMarkedIcon}>
<Trans i18nKey="registrations" defaults="Registrations" />
</NavItem>
<NavItem href="/contacts" icon={UsersIcon}>
<Trans i18nKey="contacts" defaults="Contacts" />
</NavItem>
<NavItem href="/integrations" icon={BlocksIcon}>
<Trans i18nKey="integrations" defaults="Integrations" />
</NavItem>
</li>
</ul>
</li>
<li className="mt-auto">
<ul role="list" className="-mx-2 space-y-1">
<IfFreeUser>
<li>
<Link
href="/settings/billing"
className="border rounded-md mb-4 px-4 py-3 bg-gray-50 hover:bg-gray-200 active:bg-gray-300 border-gray-200 hover:border-gray-300 grid"
>
<span className="flex mb-2 items-center gap-x-2">
<SparklesIcon className="h-5 text-gray-400 w-5" />
<span className="font-bold text-sm">
<Trans i18nKey="upgrade" />
</span>
<ProBadge />
</span>
<span className="text-gray-500 leading-relaxed text-sm">
<Trans
i18nKey="unlockFeatures"
defaults="Unlock all Pro features."
/>
</span>
</Link>
</li>
</IfFreeUser>
<IfGuest>
<li>
<NavItem href="/login" icon={LogInIcon}>
<Trans i18nKey="login" />
</NavItem>
</li>
</IfGuest>
<li>
<NavItem href="/settings/preferences" icon={Settings2Icon}>
<Trans i18nKey="preferences" />
</NavItem>
</li>
</ul>
<hr className="my-2" />
<ul role="list" className="-mx-2 space-y-1">
<li>
<Button
asChild
variant="ghost"
className="group h-auto py-3 w-full justify-start"
>
<Link href="/settings/profile">
<CurrentUserAvatar />
<span className="grid ml-1 grow">
<span className="font-semibold">{user.name}</span>
<span className="text-muted-foreground text-sm">
{user.email}
</span>
</span>
<ChevronRightIcon className="h-4 w-4 opacity-0 group-hover:opacity-100 text-muted-foreground" />
</Link>
</Button>
</li>
</ul>
</li>
</ul>
</nav>
);
}

View file

@ -3,8 +3,8 @@ import { NextResponse } from "next/server";
import { resetUser } from "@/app/guest"; import { resetUser } from "@/app/guest";
import { absoluteUrl } from "@/utils/absolute-url"; import { absoluteUrl } from "@/utils/absolute-url";
export async function GET() { export async function POST() {
const res = NextResponse.redirect(absoluteUrl()); const res = NextResponse.redirect(absoluteUrl("/login"), 302);
await resetUser(res); await resetUser(res);
return res; return res;
} }

View file

@ -6,34 +6,7 @@ import { getTranslation } from "@/app/i18n";
import { absoluteUrl } from "@/utils/absolute-url"; import { absoluteUrl } from "@/utils/absolute-url";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return <>{children}</>;
<div className="relative">
<svg
className="absolute inset-x-0 top-0 z-10 hidden h-[64rem] w-full stroke-gray-300/75 [mask-image:radial-gradient(800px_800px_at_center,white,transparent)] sm:block"
aria-hidden="true"
>
<defs>
<pattern
id="1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84"
width={240}
height={240}
x="50%"
y={-1}
patternUnits="userSpaceOnUse"
>
<path d="M.5 240V.5H240" fill="none" />
</pattern>
</defs>
<rect
width="100%"
height="100%"
strokeWidth={0}
fill="url(#1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84)"
/>
</svg>
<div className="relative z-20">{children}</div>
</div>
);
} }
export async function generateMetadata({ export async function generateMetadata({

View file

@ -6,6 +6,7 @@ import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import React from "react"; import React from "react";
import { PageHeader } from "@/app/components/page-layout";
import { Poll } from "@/components/poll"; import { Poll } from "@/components/poll";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider"; import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
@ -61,23 +62,25 @@ const GoToApp = () => {
const { user } = useUser(); const { user } = useUser();
return ( return (
<div className="flex items-center justify-between gap-2 p-3"> <PageHeader variant="ghost">
<div> <div className="flex justify-between">
<Button <div>
variant="ghost" <Button
asChild variant="ghost"
className={poll.userId !== user.id ? "hidden" : ""} asChild
> className={poll.userId !== user.id ? "hidden" : ""}
<Link href={`/poll/${poll.id}`}> >
<ArrowUpLeftIcon className="h-4 w-4" /> <Link href={`/poll/${poll.id}`}>
<Trans i18nKey="manage" /> <ArrowUpLeftIcon className="h-4 w-4 text-muted-foreground" />
</Link> <Trans i18nKey="manage" />
</Button> </Link>
</Button>
</div>
<div>
<UserDropdown />
</div>
</div> </div>
<div> </PageHeader>
<UserDropdown />
</div>
</div>
); );
}; };
@ -87,23 +90,10 @@ export default function InvitePage() {
<LegacyPollContextProvider> <LegacyPollContextProvider>
<VisibilityProvider> <VisibilityProvider>
<GoToApp /> <GoToApp />
<div className="mx-auto max-w-4xl space-y-4 px-3 sm:py-8"> <div className="lg:px-6 lg:py-5 p-3">
<Poll /> <div className="max-w-4xl mx-auto">
<div className="mt-4 space-y-4 text-center text-gray-500"> <div className="-mx-1">
<div className="py-8"> <Poll />
<Trans
defaults="Powered by <a>{name}</a>"
i18nKey="poweredByRallly"
values={{ name: "rallly.co" }}
components={{
a: (
<Link
className="hover:text-primary-600 rounded-none border-b border-b-gray-500 font-semibold"
href="https://rallly.co"
/>
),
}}
/>
</div> </div>
</div> </div>
</div> </div>

View file

@ -2,6 +2,7 @@ import "tailwindcss/tailwind.css";
import "../../style.css"; import "../../style.css";
import languages from "@rallly/languages"; import languages from "@rallly/languages";
import { Toaster } from "@rallly/ui/toaster";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import React from "react"; import React from "react";
@ -26,6 +27,7 @@ export default function Root({
return ( return (
<html lang={locale} className={inter.className}> <html lang={locale} className={inter.className}>
<body className="h-screen overflow-y-scroll"> <body className="h-screen overflow-y-scroll">
<Toaster />
<Providers>{children}</Providers> <Providers>{children}</Providers>
</body> </body>
</html> </html>

View file

@ -0,0 +1,19 @@
"use client";
import { Button } from "@rallly/ui/button";
import { XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
export function BackButton() {
const router = useRouter();
return (
<Button
variant="ghost"
onClick={() => {
router.back();
}}
>
<XIcon className="h-4 w-4 text-muted-foreground" />
</Button>
);
}

View file

@ -0,0 +1,30 @@
import Image from "next/image";
import Link from "next/link";
import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
import { BackButton } from "@/app/[locale]/menu/back-button";
export default function Page() {
return (
<div className="bg-gray-100">
<div className="flex items-center justify-between px-4 py-3">
<Link
className="active:translate-y-1 transition-transform inline-block"
href="/"
>
<Image
src="/logo-mark.svg"
alt="Rallly"
width={32}
height={32}
className="shrink-0"
/>
</Link>
<BackButton />
</div>
<div className="px-5 py-5">
<Sidebar />
</div>
</div>
);
}

View file

@ -0,0 +1,3 @@
export default function Loading() {
return null;
}

View file

@ -35,10 +35,10 @@ const GuestPollAlert = () => {
defaults="<0>Create an account</0> or <1>login</1> to claim this poll." defaults="<0>Create an account</0> or <1>login</1> to claim this poll."
components={[ components={[
<RegisterLink <RegisterLink
className="hover:text-primary underline" className="hover:text-gray-800 underline"
key="register" key="register"
/>, />,
<LoginLink className="hover:text-primary underline" key="login" />, <LoginLink className="hover:text-gray-800 underline" key="login" />,
]} ]}
/> />
</AlertDescription> </AlertDescription>
@ -48,9 +48,11 @@ const GuestPollAlert = () => {
export default function Page() { export default function Page() {
return ( return (
<div className={cn("mx-auto w-full max-w-4xl space-y-3 sm:space-y-4")}> <div className={cn("max-w-4xl space-y-4 mx-auto")}>
<GuestPollAlert /> <div className="-mx-1 space-y-3 sm:space-y-6">
<Poll /> <GuestPollAlert />
<Poll />
</div>
</div> </div>
); );
} }

View file

@ -0,0 +1,20 @@
import Image from "next/image";
import Link from "next/link";
export function LogoLink() {
return (
<Link
className="active:translate-y-1 transition-transform inline-block"
href="/"
>
<Image
src="/logo-mark.svg"
alt="Rallly"
width={32}
height={32}
priority={true}
className="shrink-0"
/>
</Link>
);
}

View file

@ -0,0 +1,14 @@
import { Button, ButtonProps } from "@rallly/ui/button";
export function LogoutButton({
children,
...rest
}: React.PropsWithChildren<ButtonProps>) {
return (
<form action="/auth/logout" method="POST">
<Button {...rest} type="submit">
{children}
</Button>
</form>
);
}

View file

@ -0,0 +1,57 @@
"use client";
import { cn } from "@rallly/ui";
export function PageContainer({
children,
className,
}: React.PropsWithChildren<{ className?: string }>) {
return <div className={cn("", className)}>{children}</div>;
}
export function PageTitle({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) {
return (
<h2 className={cn("font-semibold leading-9 truncate", className)}>
{children}
</h2>
);
}
export function PageHeader({
children,
className,
variant = "default",
}: {
children?: React.ReactNode;
className?: string;
variant?: "default" | "ghost";
}) {
return (
<div
className={cn(
"lg:px-6 lg:py-3 px-4 py-3",
{
"border-b bg-gray-50 sticky z-20 top-0": variant === "default",
},
className,
)}
>
{children}
</div>
);
}
export function PageContent({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) {
return <div className={cn("lg:p-6 p-4", className)}>{children}</div>;
}

View file

@ -44,7 +44,7 @@ export const VerifyCode: React.FunctionComponent<{
})} })}
> >
<fieldset> <fieldset>
<h1 className="mb-1">{t("verifyYourEmail")}</h1> <h1 className="mb-1 font-bold text-2xl">{t("verifyYourEmail")}</h1>
<div className="mb-4 text-gray-500"> <div className="mb-4 text-gray-500">
{t("stepSummary", { {t("stepSummary", {
current: 2, current: 2,
@ -60,6 +60,7 @@ export const VerifyCode: React.FunctionComponent<{
b: <strong className="whitespace-nowrap" />, b: <strong className="whitespace-nowrap" />,
a: ( a: (
<button <button
type="button"
role="button" role="button"
className="text-link" className="text-link"
onClick={() => { onClick={() => {

View file

@ -71,9 +71,9 @@ export const BillingPlans = () => {
</BillingPlan> </BillingPlan>
<div className="space-y-4 rounded-md border p-4"> <div className="space-y-4 rounded-md border p-4">
<div> <div>
<h3> <BillingPlanTitle>
<Trans i18nKey="planPro" /> <Trans i18nKey="planPro" />
</h3> </BillingPlanTitle>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
<Trans <Trans
i18nKey="planProDescription" i18nKey="planProDescription"
@ -132,7 +132,7 @@ export const BillingPlans = () => {
<TrendingUpIcon className="text-indigo mr-2 mt-0.5 h-6 w-6 shrink-0" /> <TrendingUpIcon className="text-indigo mr-2 mt-0.5 h-6 w-6 shrink-0" />
</div> </div>
<div className="mb-2 flex items-center gap-x-2"> <div className="mb-2 flex items-center gap-x-2">
<h3 className="text-sm"> <h3 className="text-sm font-semibold">
<Trans <Trans
i18nKey="upgradeNowSaveLater" i18nKey="upgradeNowSaveLater"
defaults="Upgrade now, save later" defaults="Upgrade now, save later"

View file

@ -5,8 +5,6 @@ export const Container = ({
className, className,
}: React.PropsWithChildren<{ className?: string }>) => { }: React.PropsWithChildren<{ className?: string }>) => {
return ( return (
<div className={cn("mx-auto max-w-7xl px-3 sm:px-8", className)}> <div className={cn("mx-auto max-w-7xl px-4", className)}>{children}</div>
{children}
</div>
); );
}; };

View file

@ -16,6 +16,7 @@ import { useUnmount } from "react-use";
import { PollSettingsForm } from "@/components/forms/poll-settings"; import { PollSettingsForm } from "@/components/forms/poll-settings";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { setCookie } from "@/utils/cookies";
import { usePostHog } from "@/utils/posthog"; import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc/client"; import { trpc } from "@/utils/trpc/client";
@ -62,12 +63,16 @@ export const CreatePoll: React.FunctionComponent = () => {
const posthog = usePostHog(); const posthog = usePostHog();
const queryClient = trpc.useUtils(); const queryClient = trpc.useUtils();
const createPoll = trpc.polls.create.useMutation(); const createPoll = trpc.polls.create.useMutation({
networkMode: "always",
onSuccess: () => {
setCookie("new-poll", "1");
},
});
return ( return (
<Form {...form}> <Form {...form}>
<form <form
className="pb-16"
onSubmit={form.handleSubmit(async (formData) => { onSubmit={form.handleSubmit(async (formData) => {
const title = required(formData?.title); const title = required(formData?.title);
@ -100,7 +105,7 @@ export const CreatePoll: React.FunctionComponent = () => {
); );
})} })}
> >
<div className="mx-auto max-w-4xl space-y-4 p-2 sm:p-8"> <div className="mx-auto max-w-4xl space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>

View file

@ -24,7 +24,7 @@ const DateCard: React.FunctionComponent<DateCardProps> = ({
)} )}
> >
{annotation ? ( {annotation ? (
<div className="absolute -right-3 -top-3 z-20">{annotation}</div> <div className="absolute -right-3 -top-3 z-10">{annotation}</div>
) : null} ) : null}
<div> <div>
{dow ? ( {dow ? (

View file

@ -39,102 +39,100 @@ export const EventCard = () => {
} }
return ( return (
<Card fullWidthOnMobile={false}> <Card className="overflow-visible" fullWidthOnMobile={false}>
<div className="divide-y"> <div
<div className="h-2 -mx-px rounded-t-md -mt-px"
className="h-2" style={{ background: generateGradient(poll.id) }}
style={{ background: generateGradient(poll.id) }} />
/> <div className="bg-pattern p-4 sm:flex grid gap-4 sm:justify-between sm:px-6">
<div className="bg-pattern p-4 sm:flex sm:flex-row-reverse sm:justify-between sm:px-6"> <div className="flex items-start justify-between">
<div className="mb-2"> <div className="flex items-start gap-4 sm:gap-6">
<PollStatusBadge status={poll.status} /> {poll.event ? (
</div> <div>
<div className="flex items-start justify-between"> <DateIcon
<div className="flex items-start gap-4 sm:gap-6"> date={adjustTimeZone(poll.event.start, !poll.timeZone)}
/>
</div>
) : null}
<div>
<h1
className="text-xl font-bold tracking-tight mb-1"
data-testid="poll-title"
>
{preventWidows(poll.title)}
</h1>
{poll.event ? ( {poll.event ? (
<div> <div className="text-muted-foreground text-sm">
<DateIcon {poll.event.duration === 0
date={adjustTimeZone(poll.event.start, !poll.timeZone)} ? adjustTimeZone(poll.event.start, !poll.timeZone).format(
/> "LL",
)
: `${adjustTimeZone(
poll.event.start,
!poll.timeZone,
).format("LL LT")} - ${adjustTimeZone(
dayjs(poll.event.start).add(
poll.event.duration,
"minutes",
),
!poll.timeZone,
).format("LT")}`}
</div> </div>
) : null} ) : null}
<div> {!poll.event ? (
{poll.event ? ( <PollSubheader />
) : (
<div className="mt-4 space-y-2">
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
{poll.event.duration === 0 <Trans
? adjustTimeZone(poll.event.start, !poll.timeZone).format( i18nKey="attendeeCount"
"LL", defaults="{count, plural, one {# attendee} other {# attendees}}"
) values={{ count: attendees.length }}
: `${adjustTimeZone( />
poll.event.start,
!poll.timeZone,
).format("LL LT")} - ${adjustTimeZone(
dayjs(poll.event.start).add(
poll.event.duration,
"minutes",
),
!poll.timeZone,
).format("LT")}`}
</div> </div>
) : null} <IfParticipantsVisible>
<h1 <ParticipantAvatarBar participants={attendees} max={10} />
className="text-xl font-bold tracking-tight sm:text-2xl" </IfParticipantsVisible>
data-testid="poll-title" </div>
> )}
{preventWidows(poll.title)}
</h1>
{!poll.event ? (
<PollSubheader />
) : (
<div className="mt-4 space-y-2">
<div className="text-muted-foreground text-sm">
<Trans
i18nKey="attendeeCount"
defaults="{count, plural, one {# attendee} other {# attendees}}"
values={{ count: attendees.length }}
/>
</div>
<IfParticipantsVisible>
<ParticipantAvatarBar participants={attendees} max={10} />
</IfParticipantsVisible>
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-4 p-4 sm:px-6"> <div>
{poll.description ? ( <PollStatusBadge status={poll.status} />
<div className="flex gap-4"> </div>
<TextIcon className="h-4 w-4 shrink-0 translate-y-1" /> </div>
<div className="whitespace-pre-line leading-relaxed"> <div className="space-y-4 p-4 sm:px-6">
<TruncatedLinkify>{poll.description}</TruncatedLinkify> {poll.description ? (
</div>
</div>
) : null}
{poll.location ? (
<div className="flex gap-4">
<MapPinIcon className="h-4 w-4 translate-y-1" />
<TruncatedLinkify>{poll.location}</TruncatedLinkify>
</div>
) : null}
<div className="flex gap-4"> <div className="flex gap-4">
<MousePointerClickIcon className="h-4 w-4 shrink-0 translate-y-0.5" /> <TextIcon className="h-4 w-4 text-muted-foreground shrink-0 translate-y-1" />
<div> <div className="whitespace-pre-line">
<div className="flex gap-2.5"> <TruncatedLinkify>{poll.description}</TruncatedLinkify>
<span className="inline-flex items-center space-x-1"> </div>
<VoteIcon type="yes" /> </div>
<span className="text-sm">{t("yes")}</span> ) : null}
</span> {poll.location ? (
<span className="inline-flex items-center space-x-1"> <div className="flex gap-4">
<VoteIcon type="ifNeedBe" /> <MapPinIcon className="h-4 w-4 translate-y-1 text-muted-foreground" />
<span className="text-sm">{t("ifNeedBe")}</span> <TruncatedLinkify>{poll.location}</TruncatedLinkify>
</span> </div>
<span className="inline-flex items-center space-x-1"> ) : null}
<VoteIcon type="no" /> <div className="flex gap-4">
<span className="text-sm">{t("no")}</span> <MousePointerClickIcon className="h-4 w-4 shrink-0 text-muted-foreground translate-y-0.5" />
</span> <div>
</div> <div className="flex gap-2.5">
<span className="inline-flex items-center space-x-1">
<VoteIcon type="yes" />
<span className="text-sm">{t("yes")}</span>
</span>
<span className="inline-flex items-center space-x-1">
<VoteIcon type="ifNeedBe" />
<span className="text-sm">{t("ifNeedBe")}</span>
</span>
<span className="inline-flex items-center space-x-1">
<VoteIcon type="no" />
<span className="text-sm">{t("no")}</span>
</span>
</div> </div>
</div> </div>
</div> </div>

View file

@ -16,12 +16,10 @@ import { useParticipants } from "@/components/participants-provider";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
export const InviteDialog = () => { export function CopyInviteLinkButton() {
const { participants } = useParticipants(); const [didCopy, setDidCopy] = React.useState(false);
const [isOpen, setIsOpen] = React.useState(participants.length === 0);
const poll = usePoll();
const [state, copyToClipboard] = useCopyToClipboard(); const [state, copyToClipboard] = useCopyToClipboard();
const poll = usePoll();
React.useEffect(() => { React.useEffect(() => {
if (state.error) { if (state.error) {
@ -29,15 +27,36 @@ export const InviteDialog = () => {
} }
}, [state]); }, [state]);
const [didCopy, setDidCopy] = React.useState(false); return (
<Button
className="grow min-w-0"
onClick={() => {
copyToClipboard(poll.inviteLink);
setDidCopy(true);
setTimeout(() => {
setDidCopy(false);
}, 1000);
}}
>
{didCopy ? (
<Trans i18nKey="copied" />
) : (
<span className="truncate min-w-0">{`${window.location.hostname}/invite/${poll.id}`}</span>
)}
</Button>
);
}
export const InviteDialog = () => {
const { participants } = useParticipants();
const [isOpen, setIsOpen] = React.useState(participants.length === 0);
const poll = usePoll();
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild={true}> <DialogTrigger asChild={true}>
<Button variant="primary" icon={Share2Icon}> <Button variant="primary" icon={Share2Icon}>
<span className="hidden sm:block"> <Trans i18nKey="share" defaults="Share" />
<Trans i18nKey="share" defaults="Share" />
</span>
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent <DialogContent
@ -63,22 +82,7 @@ export const InviteDialog = () => {
<Trans i18nKey="inviteLink" defaults="Invite Link" /> <Trans i18nKey="inviteLink" defaults="Invite Link" />
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <CopyInviteLinkButton />
className="w-full min-w-0 bg-gray-50 px-2.5"
onClick={() => {
copyToClipboard(poll.inviteLink);
setDidCopy(true);
setTimeout(() => {
setDidCopy(false);
}, 1000);
}}
>
{didCopy ? (
<Trans i18nKey="copied" />
) : (
<span className="flex truncate">{poll.inviteLink}</span>
)}
</Button>
<div className="shrink-0"> <div className="shrink-0">
<Button asChild> <Button asChild>
<Link target="_blank" href={`/invite/${poll.id}`}> <Link target="_blank" href={`/invite/${poll.id}`}>

View file

@ -11,7 +11,7 @@ import {
ArrowLeftIcon, ArrowLeftIcon,
ArrowUpRight, ArrowUpRight,
ChevronDownIcon, ChevronDownIcon,
FileBarChart, ListIcon,
LogInIcon, LogInIcon,
LogOutIcon, LogOutIcon,
PauseCircleIcon, PauseCircleIcon,
@ -19,18 +19,18 @@ import {
RotateCcw, RotateCcw,
ShieldCloseIcon, ShieldCloseIcon,
} from "lucide-react"; } from "lucide-react";
import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import React from "react"; import React from "react";
import { Container } from "@/components/container"; import { LogoutButton } from "@/app/components/logout-button";
import { InviteDialog } from "@/components/invite-dialog";
import { StandardLayout } from "@/components/layouts/standard-layout";
import { import {
TopBar, PageContainer,
TopBarTitle, PageContent,
} from "@/components/layouts/standard-layout/top-bar"; PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { InviteDialog } from "@/components/invite-dialog";
import { LoginLink } from "@/components/login-link"; import { LoginLink } from "@/components/login-link";
import { import {
PageDialog, PageDialog,
@ -43,14 +43,11 @@ import ManagePoll from "@/components/poll/manage-poll";
import NotificationsToggle from "@/components/poll/notifications-toggle"; import NotificationsToggle from "@/components/poll/notifications-toggle";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider"; import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
import { PollStatusLabel } from "@/components/poll-status"; import { PollStatusLabel } from "@/components/poll-status";
import { Skeleton } from "@/components/skeleton";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider"; import { useUser } from "@/components/user-provider";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
import { trpc } from "@/utils/trpc/client"; import { trpc } from "@/utils/trpc/client";
import { NextPageWithLayout } from "../../types";
const StatusControl = () => { const StatusControl = () => {
const poll = usePoll(); const poll = usePoll();
const queryClient = trpc.useUtils(); const queryClient = trpc.useUtils();
@ -152,41 +149,49 @@ const StatusControl = () => {
}; };
const AdminControls = () => { const AdminControls = () => {
const poll = usePoll();
const pollLink = `/poll/${poll.id}`;
const pathname = usePathname();
return ( return (
<TopBar> <div className="flex items-center gap-x-2">
<div className="flex flex-col items-start justify-between gap-x-4 gap-y-2 sm:flex-row"> <NotificationsToggle />
<div className="flex min-w-0 gap-4"> <StatusControl />
{pathname !== pollLink ? ( <ManagePoll />
<Button asChild> <InviteDialog />
<Link href={pollLink}> </div>
<ArrowLeftIcon className="h-4 w-4" />
</Link>
</Button>
) : null}
<TopBarTitle title={poll?.title} icon={FileBarChart} />
</div>
<div className="flex items-center gap-x-2">
<NotificationsToggle />
<StatusControl />
<ManagePoll />
<InviteDialog />
</div>
</div>
</TopBar>
); );
}; };
const Layout = ({ children }: React.PropsWithChildren) => { const Layout = ({ children }: React.PropsWithChildren) => {
const poll = usePoll();
const pollLink = `/poll/${poll.id}`;
const pathname = usePathname();
return ( return (
<div className="flex min-w-0 grow flex-col"> <PageContainer>
<AdminControls /> <PageHeader className="flex md:flex-row flex-col md:items-center gap-x-4 gap-y-2.5">
<div> <div className="flex min-w-0 md:basis-2/3 items-center gap-x-4">
<Container className="py-3 sm:py-8">{children}</Container> <div className="md:basis-1/2 flex gap-x-4">
</div> {pathname === pollLink ? (
</div> <Button asChild>
<Link href="/polls">
<ListIcon className="h-4 w-4" />
</Link>
</Button>
) : (
<Button asChild>
<Link href={pollLink}>
<ArrowLeftIcon className="h-4 w-4" />
</Link>
</Button>
)}
<PageTitle>{poll.title}</PageTitle>
</div>
</div>
<div className="flex basis-1/3 md:justify-end">
<AdminControls />
</div>
</PageHeader>
<PageContent>{children}</PageContent>
</PageContainer>
); );
}; };
@ -218,21 +223,19 @@ export const PermissionGuard = ({ children }: React.PropsWithChildren) => {
</PageDialogHeader> </PageDialogHeader>
<PageDialogFooter> <PageDialogFooter>
{user.isGuest ? ( {user.isGuest ? (
<Button asChild variant="primary" size="lg"> <Button asChild variant="primary">
<LoginLink> <LoginLink>
<LogInIcon className="-ml-1 h-5 w-5" /> <LogInIcon className="-ml-1 h-4 w-4" />
<Trans i18nKey="login" defaults="Login" /> <Trans i18nKey="login" defaults="Login" />
</LoginLink> </LoginLink>
</Button> </Button>
) : ( ) : (
<Button asChild variant="primary" size="lg"> <LogoutButton>
<Link href="/logout"> <LogOutIcon className="-ml-1 h-4 w-4" />
<LogOutIcon className="-ml-1 h-5 w-5" /> <Trans i18nKey="loginDifferent" defaults="Switch user" />
<Trans i18nKey="loginDifferent" defaults="Switch user" /> </LogoutButton>
</Link>
</Button>
)} )}
<Button asChild size="lg"> <Button asChild>
<Link href={`/invite/${poll.id}`}> <Link href={`/invite/${poll.id}`}>
<Trans i18nKey="goToInvite" defaults="Go to Invite Page" /> <Trans i18nKey="goToInvite" defaults="Go to Invite Page" />
<ArrowUpRight className="h-4 w-4" /> <ArrowUpRight className="h-4 w-4" />
@ -246,15 +249,6 @@ export const PermissionGuard = ({ children }: React.PropsWithChildren) => {
return <>{children}</>; return <>{children}</>;
}; };
const Title = () => {
const poll = usePoll();
return (
<Head>
<title>{poll.title}</title>
</Head>
);
};
const Prefetch = ({ children }: React.PropsWithChildren) => { const Prefetch = ({ children }: React.PropsWithChildren) => {
const params = useParams(); const params = useParams();
@ -265,18 +259,7 @@ const Prefetch = ({ children }: React.PropsWithChildren) => {
const watchers = trpc.polls.getWatchers.useQuery({ pollId: urlId }); const watchers = trpc.polls.getWatchers.useQuery({ pollId: urlId });
if (!poll.data || !watchers.data || !participants.data) { if (!poll.data || !watchers.data || !participants.data) {
return ( return null;
<div>
<TopBar className="flex flex-col items-start justify-between gap-x-4 gap-y-2 sm:flex-row">
<Skeleton className="my-2 h-5 w-48" />
<div className="flex gap-x-2">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-24" />
</div>
</TopBar>
</div>
);
} }
return <>{children}</>; return <>{children}</>;
@ -295,7 +278,6 @@ export const PollLayout = ({ children }: React.PropsWithChildren) => {
return ( return (
<Prefetch> <Prefetch>
<LegacyPollContextProvider> <LegacyPollContextProvider>
<Title />
<PermissionGuard> <PermissionGuard>
<Layout>{children}</Layout> <Layout>{children}</Layout>
</PermissionGuard> </PermissionGuard>
@ -303,12 +285,3 @@ export const PollLayout = ({ children }: React.PropsWithChildren) => {
</Prefetch> </Prefetch>
); );
}; };
export const getPollLayout: NextPageWithLayout["getLayout"] =
function getLayout(page) {
return (
<StandardLayout>
<PollLayout>{page}</PollLayout>
</StandardLayout>
);
};

View file

@ -1,25 +1,21 @@
import { cn } from "@rallly/ui"; "use client";
import { Button } from "@rallly/ui/button";
import { Card } from "@rallly/ui/card";
import clsx from "clsx"; import clsx from "clsx";
import { import { CreditCardIcon, Settings2Icon, UserIcon } from "lucide-react";
CreditCardIcon,
MenuIcon,
Settings2Icon,
UserIcon,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import React from "react"; import React from "react";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { useToggle } from "react-use"; import { useToggle } from "react-use";
import { Container } from "@/components/container"; import {
PageContainer,
PageContent,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { IfCloudHosted } from "@/contexts/environment"; import { IfCloudHosted } from "@/contexts/environment";
import { Plan } from "@/contexts/plan";
import { IconComponent } from "../../types"; import { IconComponent } from "../../types";
import { IfAuthenticated, useUser } from "../user-provider";
const MenuItem = (props: { const MenuItem = (props: {
icon: IconComponent; icon: IconComponent;
@ -30,10 +26,10 @@ const MenuItem = (props: {
return ( return (
<Link <Link
className={clsx( className={clsx(
"flex min-w-0 items-center gap-x-2.5 px-2.5 py-1.5 text-sm font-medium", "flex min-w-0 items-center gap-x-2 px-3 py-2 text-sm font-medium",
pathname === props.href pathname === props.href
? "bg-gray-200" ? "bg-gray-200"
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800", : "text-gray-500 hover:text-gray-800",
)} )}
href={props.href} href={props.href}
> >
@ -44,59 +40,39 @@ const MenuItem = (props: {
}; };
export const ProfileLayout = ({ children }: React.PropsWithChildren) => { export const ProfileLayout = ({ children }: React.PropsWithChildren) => {
const { user } = useUser();
// reset toggle whenever route changes // reset toggle whenever route changes
const pathname = usePathname(); const pathname = usePathname();
const [isMenuOpen, toggle] = useToggle(false); const [, toggle] = useToggle(false);
React.useEffect(() => { React.useEffect(() => {
toggle(false); toggle(false);
}, [pathname, toggle]); }, [pathname, toggle]);
return ( return (
<div> <PageContainer>
<Container className="p-2 sm:py-8"> <PageHeader>
<Card className="mx-auto flex flex-col overflow-hidden md:min-h-[600px]"> <div className="flex items-center justify-between gap-x-4">
<div className="border-b bg-gray-50 p-3 md:hidden"> <PageTitle>
<Button onClick={toggle} icon={MenuIcon} /> <Trans i18nKey="settings" />
</div> </PageTitle>
<div className="relative flex grow md:divide-x"> </div>
<div </PageHeader>
className={cn( <PageContent>
"absolute inset-0 z-10 grow bg-gray-50 md:static md:block md:shrink-0 md:grow-0 md:basis-56 md:px-5 md:py-4", <div className="inline-flex mb-4 border rounded-md p-0.5 gap-x-2">
{ <MenuItem href="/settings/profile" icon={UserIcon}>
hidden: !isMenuOpen, <Trans i18nKey="profile" defaults="Profile" />
}, </MenuItem>
)} <MenuItem href="/settings/preferences" icon={Settings2Icon}>
> <Trans i18nKey="preferences" defaults="Preferences" />
<div className="grid gap-1"> </MenuItem>
<div className="flex items-center justify-between gap-x-2.5 gap-y-2 p-3"> <IfCloudHosted>
<div className="truncate text-sm font-semibold"> <MenuItem href="/settings/billing" icon={CreditCardIcon}>
{user.name} <Trans i18nKey="billing" defaults="Billing" />
</div> </MenuItem>
<Plan /> </IfCloudHosted>
</div> </div>
<IfAuthenticated> <div className="max-w-4xl py-4">{children}</div>
<MenuItem href="/settings/profile" icon={UserIcon}> </PageContent>
<Trans i18nKey="profile" defaults="Profile" /> </PageContainer>
</MenuItem>
</IfAuthenticated>
<MenuItem href="/settings/preferences" icon={Settings2Icon}>
<Trans i18nKey="preferences" defaults="Preferences" />
</MenuItem>
<IfCloudHosted>
<MenuItem href="/settings/billing" icon={CreditCardIcon}>
<Trans i18nKey="billing" defaults="Billing" />
</MenuItem>
</IfCloudHosted>
</div>
</div>
<div className="max-w-2xl grow">{children}</div>
</div>
</Card>
</Container>
</div>
); );
}; };

View file

@ -1,207 +0,0 @@
"use client";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import clsx from "clsx";
import { AnimatePresence, m } from "framer-motion";
import { ListIcon, SparklesIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
import { Toaster } from "react-hot-toast";
import { Clock, ClockPreferences } from "@/components/clock";
import { Container } from "@/components/container";
import {
FeaturebaseChangelog,
FeaturebaseIdentify,
} from "@/components/featurebase";
import FeedbackButton from "@/components/feedback";
import { LoginLink } from "@/components/login-link";
import { Logo } from "@/components/logo";
import { Trans } from "@/components/trans";
import { UserDropdown } from "@/components/user-dropdown";
import { IfCloudHosted } from "@/contexts/environment";
import { useSubscription } from "@/contexts/plan";
import { appVersion, isFeedbackEnabled } from "@/utils/constants";
import { IconComponent, NextPageWithLayout } from "../../types";
import ModalProvider from "../modal/modal-provider";
import { IfGuest } from "../user-provider";
const NavMenuItem = ({
href,
target,
label,
icon: Icon,
className,
}: {
icon: IconComponent;
href: string;
target?: string;
label: React.ReactNode;
className?: string;
}) => {
const pathname = usePathname();
return (
<Button variant="ghost" asChild>
<Link
target={target}
href={href}
className={cn(
pathname === href
? "text-foreground"
: "text-muted-foreground hover:text-foreground active:bg-gray-200/50",
className,
)}
>
<Icon className="h-4 w-4" />
{label}
</Link>
</Button>
);
};
const Upgrade = () => {
return (
<Button
className="hidden sm:inline-flex"
variant="primary"
size="sm"
asChild
>
<Link href="/settings/billing">
<SparklesIcon className="-ml-0.5 h-4 w-4" />
<Trans i18nKey="upgrade" defaults="Upgrade" />
</Link>
</Button>
);
};
const LogoArea = () => {
return (
<div className="relative flex items-center justify-center gap-x-4">
<Link
href="/polls"
className={clsx(
"inline-block transition-transform active:translate-y-1",
)}
>
<Logo size="sm" />
</Link>
</div>
);
};
const Changelog = () => {
return <FeaturebaseChangelog />;
};
const MainNav = () => {
const subscription = useSubscription();
return (
<m.div
variants={{
hidden: { y: -56, opacity: 0, height: 0 },
visible: { y: 0, opacity: 1, height: "auto" },
}}
initial="hidden"
animate="visible"
exit="hidden"
className="border-b bg-gray-50/50"
>
<Container className="flex h-14 items-center justify-between gap-x-2.5">
<div className="flex shrink-0 gap-x-4">
<LogoArea />
<nav className="hidden gap-x-2 sm:flex">
<NavMenuItem
icon={ListIcon}
href="/polls"
label={<Trans i18nKey="polls" defaults="Polls" />}
/>
</nav>
</div>
<div className="flex items-center gap-x-2.5">
<nav className="flex items-center gap-x-1 sm:gap-x-1.5">
<IfCloudHosted>
{subscription?.active === false ? <Upgrade /> : null}
</IfCloudHosted>
<IfGuest>
<Button
size="sm"
variant="ghost"
asChild
className="hidden sm:flex"
>
<LoginLink>
<Trans i18nKey="login" defaults="Login" />
</LoginLink>
</Button>
</IfGuest>
<IfCloudHosted>
<Changelog />
</IfCloudHosted>
<ClockPreferences>
<Button size="sm" variant="ghost">
<Clock />
</Button>
</ClockPreferences>
</nav>
<UserDropdown />
</div>
</Container>
</m.div>
);
};
export const StandardLayout: React.FunctionComponent<{
children?: React.ReactNode;
hideNav?: boolean;
}> = ({ children, hideNav, ...rest }) => {
const key = hideNav ? "no-nav" : "nav";
return (
<ModalProvider>
<Toaster />
<div className="flex min-h-screen flex-col" {...rest}>
<AnimatePresence initial={false}>
{!hideNav ? <MainNav /> : null}
</AnimatePresence>
<AnimatePresence mode="wait" initial={false}>
<m.div
key={key}
variants={{
hidden: { opacity: 0, y: -56 },
visible: { opacity: 1, y: 0 },
}}
initial="hidden"
animate="visible"
exit={{ opacity: 0, y: 56 }}
>
{children}
</m.div>
</AnimatePresence>
{appVersion ? (
<div className="fixed bottom-0 right-0 z-50 rounded-tl-md bg-gray-200/90">
<Link
className="px-2 py-1 text-xs tabular-nums tracking-tight"
target="_blank"
href={`https://github.com/lukevella/rallly/releases/${appVersion}`}
>
{`${appVersion}`}
</Link>
</div>
) : null}
</div>
{isFeedbackEnabled ? (
<>
<FeaturebaseIdentify />
<FeedbackButton />
</>
) : null}
</ModalProvider>
);
};
export const getStandardLayout: NextPageWithLayout["getLayout"] =
function getLayout(page) {
return <StandardLayout>{page}</StandardLayout>;
};

View file

@ -1,40 +0,0 @@
import { cn } from "@rallly/ui";
import { m } from "framer-motion";
import React from "react";
import { Container } from "@/components/container";
import { IconComponent } from "@/types";
export const TopBar = (
props: React.PropsWithChildren<{
title?: React.ReactNode;
className?: string;
}>,
) => {
return (
<m.div
initial={{ y: 0 }}
exit={{ y: -100 }}
className={cn(
"sticky top-0 z-20 border-b bg-gray-50/75 py-3 backdrop-blur-md",
)}
>
<Container className={cn(props.className)}>{props.children}</Container>
</m.div>
);
};
export const TopBarTitle = ({
// icon: Icon,
title,
}: {
icon?: IconComponent;
title: React.ReactNode;
}) => {
return (
<div className="flex h-9 min-w-0 items-center gap-2.5">
{/* <Icon className="-ml-0.5 h-6 w-6 shrink-0" /> */}
<div className="truncate font-medium tracking-tight">{title}</div>
</div>
);
};

View file

@ -26,18 +26,18 @@ export const PageDialogHeader = (props: React.PropsWithChildren) => {
export const PageDialogFooter = (props: React.PropsWithChildren) => { export const PageDialogFooter = (props: React.PropsWithChildren) => {
return ( return (
<div className="mt-6 flex flex-col items-center justify-center gap-x-4 gap-y-4 sm:flex-row"> <div className="mt-6 flex flex-col items-center justify-center gap-2.5 sm:flex-row">
{props.children} {props.children}
</div> </div>
); );
}; };
export const PageDialogTitle = (props: React.PropsWithChildren) => { export const PageDialogTitle = (props: React.PropsWithChildren) => {
return <h1 className="text-3xl">{props.children}</h1>; return <h1 className="text-2xl font-bold">{props.children}</h1>;
}; };
export const PageDialogDescription = (props: React.PropsWithChildren) => { export const PageDialogDescription = (props: React.PropsWithChildren) => {
return ( return (
<p className="max-w-xl text-base leading-relaxed text-gray-600"> <p className="max-w-xl text-sm leading-relaxed text-muted-foreground">
{props.children} {props.children}
</p> </p>
); );

View file

@ -1,60 +1,16 @@
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge"; import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@rallly/ui/tabs";
import { m } from "framer-motion"; import { m } from "framer-motion";
import {
CalendarCheck2Icon,
CopyIcon,
DatabaseIcon,
HeartIcon,
ImageOffIcon,
LockIcon,
Settings2Icon,
TrendingUpIcon,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import React from "react"; import React from "react";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { UpgradeButton } from "@/components/upgrade-button";
import { usePlan } from "@/contexts/plan"; import { usePlan } from "@/contexts/plan";
import { IconComponent } from "@/types";
import { annualPriceUsd, monthlyPriceUsd } from "@/utils/constants";
const Feature = ({
icon: Icon,
children,
className,
upcoming,
}: React.PropsWithChildren<{
icon: IconComponent;
upcoming?: boolean;
className?: string;
}>) => {
return (
<li
className={cn(
"flex translate-y-0 cursor-default items-center justify-center gap-x-2.5 rounded-full border bg-gray-50 p-1 pr-4 shadow-sm transition-all hover:-translate-y-1 hover:bg-white/50",
upcoming ? "bg-transparent` border-dashed shadow-none" : "",
)}
>
<span
className={cn("bg-primary rounded-full p-1 text-gray-50", className)}
>
<Icon className="h-4 w-4" />
</span>
<div className="text-sm font-semibold">{children}</div>
</li>
);
};
const Teaser = () => { const Teaser = () => {
const params = useParams(); const params = useParams();
const [tab, setTab] = React.useState("yearly");
return ( return (
<m.div <m.div
transition={{ transition={{
@ -89,7 +45,7 @@ const Teaser = () => {
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<h2 className="text-center"> <h2 className="text-center font-bold text-xl">
<Trans defaults="Pro Feature" i18nKey="proFeature" /> <Trans defaults="Pro Feature" i18nKey="proFeature" />
</h2> </h2>
<p className="text-muted-foreground mx-auto max-w-xs text-center text-sm leading-relaxed"> <p className="text-muted-foreground mx-auto max-w-xs text-center text-sm leading-relaxed">
@ -99,119 +55,12 @@ const Teaser = () => {
/> />
</p> </p>
</div> </div>
<Tabs
className="flex flex-col items-center gap-4"
value={tab}
onValueChange={setTab}
>
<TabsList>
<TabsTrigger value="monthly">
<Trans i18nKey="billingPeriodMonthly" />
</TabsTrigger>
<TabsTrigger value="yearly">
<Trans i18nKey="billingPeriodYearly" />
</TabsTrigger>
</TabsList>
<TabsContent value="monthly">
<div>
<div className="flex items-start justify-center gap-2.5">
<div className=" text-4xl font-bold">${monthlyPriceUsd}</div>
<div>
<div className="text-xs font-semibold leading-5">USD</div>
</div>
</div>
<div className="text-muted-foreground text-sm">
<Trans i18nKey="monthlyBillingDescription" />
</div>
</div>
</TabsContent>
<TabsContent value="yearly">
<div className="text-center">
<div className="flex items-start justify-center gap-2.5">
<div className="flex items-end gap-2">
<div className="font-bold text-gray-500 line-through">
${monthlyPriceUsd}
</div>
<div className=" text-4xl font-bold">
${(annualPriceUsd / 12).toFixed(2)}
</div>
</div>
<div>
<div className="mt-1 text-xs font-semibold">USD</div>
</div>
</div>
<div className="text-muted-foreground text-sm">
<Trans i18nKey="annualBillingDescription" />
</div>
<p className="mt-2">
<span className="rounded border border-dashed border-green-400 px-1 py-0.5 text-xs text-green-500">
<Trans
i18nKey="savePercent"
defaults="Save {percent}%"
values={{
percent: (annualPriceUsd / 12 / monthlyPriceUsd) * 100,
}}
/>
</span>
</p>
</div>
</TabsContent>
</Tabs>
<div className="space-y-2">
<p className="text-primary text-center text-xs">
<Link
className="text-link"
href="https://rallly.co/blog/july-recap"
target="_blank"
>
<TrendingUpIcon className="mr-2 inline-block h-4 w-4" />
<Trans
i18nKey="priceIncreaseSoon"
defaults="Price increase soon."
/>
</Link>
</p>
<p className="text-center text-xs text-gray-400">
<LockIcon className="mr-2 inline-block h-4 w-4" />
<Trans
i18nKey="lockPrice"
defaults="Upgrade today to keep this price forever."
/>
</p>
</div>
<h3 className="mx-auto max-w-sm text-center">
<Trans
i18nKey="features"
defaults="Get access to all current and future Pro features!"
/>
</h3>
<ul className="flex flex-wrap justify-center gap-2 border-gray-100 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-gray-100 via-transparent">
<Feature className="bg-violet-500" icon={ImageOffIcon}>
<Trans i18nKey="noAds" defaults="No ads" />
</Feature>
<Feature className="bg-rose-500" icon={DatabaseIcon}>
<Trans
i18nKey="plan_extendedPollLife"
defaults="Extend poll life"
/>
</Feature>
<Feature className="bg-green-500" icon={CalendarCheck2Icon}>
<Trans i18nKey="finalizeFeature" defaults="Finalize" />
</Feature>
<Feature className="bg-teal-500" icon={CopyIcon}>
<Trans i18nKey="duplicateFeature" defaults="Duplicate" />
</Feature>
<Feature className="bg-gray-700" icon={Settings2Icon}>
<Trans i18nKey="settings" defaults="Settings" />
</Feature>
<Feature className="bg-pink-600" icon={HeartIcon}>
<Trans i18nKey="supportProject" defaults="Support this project" />
</Feature>
</ul>
<div className="grid gap-2.5"> <div className="grid gap-2.5">
<UpgradeButton annual={tab === "yearly"}> <Button variant="primary" asChild>
<Trans i18nKey="upgrade" defaults="Upgrade" /> <Link href="/settings/billing">
</UpgradeButton> <Trans i18nKey="upgrade" defaults="Upgrade" />
</Link>
</Button>
<Button asChild className="w-full"> <Button asChild className="w-full">
<Link href={`/poll/${params?.urlId as string}`}> <Link href={`/poll/${params?.urlId as string}`}>
<Trans i18nKey="notToday" defaults="Not Today" /> <Trans i18nKey="notToday" defaults="Not Today" />

View file

@ -15,9 +15,9 @@ const LabelWithIcon = ({
className?: string; className?: string;
}) => { }) => {
return ( return (
<span className={cn("inline-flex items-center gap-2", className)}> <span className={cn("inline-flex items-center gap-1.5", className)}>
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4 -ml-0.5" />
<span>{children}</span> <span className="font-medium">{children}</span>
</span> </span>
); );
}; };
@ -54,11 +54,15 @@ export const PollStatusLabel = ({
export const PollStatusBadge = ({ status }: { status: PollStatus }) => { export const PollStatusBadge = ({ status }: { status: PollStatus }) => {
return ( return (
<PollStatusLabel <PollStatusLabel
className={cn("rounded-full border py-0.5 pl-1.5 pr-3 text-sm", { className={cn(
"border-blue-500 text-blue-500": status === "live", "rounded-md font-medium whitespace-nowrap border py-1 px-2 text-xs",
"border-gray-500 text-gray-500": status === "paused", {
"border-green-500 text-green-500": status === "finalized", "border-pink-200 bg-pink-50 text-pink-600": status === "live",
})} "bg-gray-100 border-gray-200 text-gray-500": status === "paused",
"text-indigo-600 bg-indigo-50 border-indigo-200":
status === "finalized",
},
)}
status={status} status={status}
/> />
); );

View file

@ -1,5 +1,7 @@
import { cn } from "@rallly/ui"; import { cn } from "@rallly/ui";
import Link from "next/link";
import React from "react"; import React from "react";
import { Trans } from "react-i18next";
import { Card } from "@/components/card"; import { Card } from "@/components/card";
import Discussion from "@/components/discussion"; import Discussion from "@/components/discussion";
@ -32,7 +34,7 @@ export const Poll = () => {
const PollComponent = isWideScreen ? DesktopPoll : MobilePoll; const PollComponent = isWideScreen ? DesktopPoll : MobilePoll;
return ( return (
<div className={cn("space-y-3 sm:space-y-4")}> <div className={cn("space-y-3 sm:space-y-6")}>
<EventCard /> <EventCard />
<Card fullWidthOnMobile={false}> <Card fullWidthOnMobile={false}>
<VotingForm> <VotingForm>
@ -47,6 +49,23 @@ export const Poll = () => {
</Card> </Card>
</> </>
)} )}
<div className="mt-4 space-y-4 text-center text-gray-500">
<div className="py-8">
<Trans
defaults="Powered by <a>{name}</a>"
i18nKey="poweredByRallly"
values={{ name: "rallly.co" }}
components={{
a: (
<Link
className="hover:text-primary-600 rounded-none border-b border-b-gray-500 font-semibold"
href="https://rallly.co"
/>
),
}}
/>
</div>
</div>
</div> </div>
); );
}; };

View file

@ -8,7 +8,7 @@ const PollSubheader: React.FunctionComponent = () => {
const { poll } = usePoll(); const { poll } = usePoll();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="text-gray-500"> <div className="text-gray-500 text-sm">
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<div> <div>
<Trans <Trans

View file

@ -1,31 +1,15 @@
"use client";
import { Badge } from "@rallly/ui/badge"; import { Badge } from "@rallly/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import Link from "next/link";
import { Trans } from "next-i18next"; import { Trans } from "next-i18next";
import { usePlan } from "@/contexts/plan"; import { IfFreeUser } from "@/contexts/plan";
export const ProBadge = ({ className }: { className?: string }) => { export const ProBadge = ({ className }: { className?: string }) => {
const isPaid = usePlan() === "paid";
if (isPaid) {
return null;
}
return ( return (
<Tooltip> <IfFreeUser>
<TooltipTrigger asChild className="inline-flex" type="button"> <Badge className={className}>
<Link href="/settings/billing"> <Trans i18nKey="planPro" />
<Badge className={className}> </Badge>
<Trans i18nKey="planPro" /> </IfFreeUser>
</Badge>
</Link>
</TooltipTrigger>
<TooltipContent>
<Trans
i18nKey="pleaseUpgrade"
defaults="Please upgrade to Pro to use this feature"
/>
</TooltipContent>
</Tooltip>
); );
}; };

View file

@ -3,19 +3,19 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import { InfoIcon } from "lucide-react"; import { InfoIcon } from "lucide-react";
export const Settings = ({ children }: React.PropsWithChildren) => { export const Settings = ({ children }: React.PropsWithChildren) => {
return <div className="px-4 py-3 md:p-6">{children}</div>; return <div className="">{children}</div>;
}; };
export const SettingsHeader = ({ children }: React.PropsWithChildren) => { export const SettingsHeader = ({ children }: React.PropsWithChildren) => {
return ( return (
<div className="mb-4 md:mb-8"> <div className="mb-4 font-semibold text-lg md:mb-8">
<h2>{children}</h2> <h2>{children}</h2>
</div> </div>
); );
}; };
export const SettingsContent = ({ children }: React.PropsWithChildren) => { export const SettingsContent = ({ children }: React.PropsWithChildren) => {
return <div className="space-y-8">{children}</div>; return <div className="space-y-6">{children}</div>;
}; };
export const SettingsSection = (props: { export const SettingsSection = (props: {
@ -24,12 +24,12 @@ export const SettingsSection = (props: {
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
return ( return (
<div className="grid gap-3 md:gap-4"> <div className="grid grid-cols-1 lg:grid-cols-10 gap-3 md:gap-8">
<div> <div className="col-span-3">
<h2 className="mb-1 text-base font-semibold">{props.title}</h2> <h2 className="mb-1 text-base font-semibold">{props.title}</h2>
<p className="text-muted-foreground text-sm">{props.description}</p> <p className="text-muted-foreground text-sm">{props.description}</p>
</div> </div>
<div>{props.children}</div> <div className="col-span-7">{props.children}</div>
</div> </div>
); );
}; };

View file

@ -1,12 +1,20 @@
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import { import {
ColumnDef, ColumnDef,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getPaginationRowModel,
OnChangeFn,
PaginationState,
useReactTable, useReactTable,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import clsx from "clsx"; import clsx from "clsx";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import React from "react"; import React from "react";
import { Trans } from "@/components/trans";
export const Table = < export const Table = <
T extends Record<string, unknown>, T extends Record<string, unknown>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -15,85 +23,151 @@ export const Table = <
columns: C[]; columns: C[];
data: T[]; data: T[];
footer?: React.ReactNode; footer?: React.ReactNode;
pageCount?: number;
enableTableFooter?: boolean; enableTableFooter?: boolean;
enableTableHeader?: boolean;
layout?: "fixed" | "auto"; layout?: "fixed" | "auto";
onPaginationChange?: OnChangeFn<PaginationState>;
paginationState: PaginationState | undefined;
className?: string; className?: string;
}) => { }) => {
const table = useReactTable<T>({ const table = useReactTable<T>({
data: props.data, data: props.data,
columns: props.columns, columns: props.columns,
pageCount: props.pageCount,
state: {
pagination: props.paginationState,
},
manualPagination: true,
onPaginationChange: props.onPaginationChange,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
}); });
return ( return (
<div className={clsx(props.className, "max-w-full overflow-x-auto")}> <div>
<table <div
className={clsx( className={clsx(
"border-collapse", props.className,
props.layout === "auto" ? "w-full table-auto" : "table-fixed", "max-w-full overflow-x-auto scrollbar-thin",
)} )}
> >
<thead> <table
{table.getHeaderGroups().map((headerGroup) => ( className={clsx(
<tr key={headerGroup.id}> "border-collapse",
{headerGroup.headers.map((header) => ( props.layout === "auto" ? "w-full table-auto" : "table-fixed",
<th )}
key={header.id} >
style={{ {props.enableTableHeader ? (
width: header.getSize(), <thead>
maxWidth: {table.getHeaderGroups().map((headerGroup) => (
props.layout === "auto" ? header.getSize() : undefined, <tr key={headerGroup.id}>
}} {headerGroup.headers.map((header) => (
className="whitespace-nowrap border-b border-gray-100 px-3 py-2.5 text-left align-bottom text-sm font-semibold" <th
> key={header.id}
{header.isPlaceholder style={{
? null width: header.getSize(),
: flexRender( maxWidth:
header.column.columnDef.header, props.layout === "auto"
header.getContext(), ? header.getSize()
)} : undefined,
</th> }}
className="whitespace-nowrap border-b border-gray-100 px-3 py-2.5 text-left align-bottom text-sm font-semibold"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))} ))}
</tr> </thead>
))} ) : null}
</thead> <tbody>
<tbody> {table.getRowModel().rows.map((row, i) => (
{table.getRowModel().rows.map((row, i) => ( <tr key={row.id}>
<tr key={row.id}> {row.getVisibleCells().map((cell) => (
{row.getVisibleCells().map((cell) => ( <td
<td style={{
key={cell.id} width: cell.column.getSize(),
className={clsx( maxWidth:
"overflow-hidden border-gray-100 px-3 py-2.5", props.layout === "auto"
{ ? cell.column.getSize()
"border-b ": table.getRowModel().rows.length !== i + 1, : undefined,
}, }}
)} key={cell.id}
> className={clsx(
{flexRender(cell.column.columnDef.cell, cell.getContext())} "overflow-hidden align-middle border-gray-100 pr-8 py-4",
</td> {
))} "border-b": table.getRowModel().rows.length !== i + 1,
</tr> "pt-0": !props.enableTableHeader && i === 0,
))} },
</tbody> )}
{props.enableTableFooter ? ( >
<tfoot> {flexRender(cell.column.columnDef.cell, cell.getContext())}
{table.getFooterGroups().map((footerGroup) => ( </td>
<tr key={footerGroup.id}>
{footerGroup.headers.map((header) => (
<th className="border-t bg-gray-50" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.footer,
header.getContext(),
)}
</th>
))} ))}
</tr> </tr>
))} ))}
</tfoot> </tbody>
) : null} {props.enableTableFooter ? (
</table> <tfoot>
{table.getFooterGroups().map((footerGroup) => (
<tr key={footerGroup.id} className="relative">
{footerGroup.headers.map((header) => (
<th className="border-t bg-gray-50" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.footer,
header.getContext(),
)}
</th>
))}
</tr>
))}
</tfoot>
) : null}
</table>
</div>
<hr className="my-2" />
<div className="flex items-center justify-between space-x-2 py-4">
<Button
variant="ghost"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeftIcon
className={cn("h-4 w-4", {
"text-gray-400": !table.getCanPreviousPage(),
})}
/>
</Button>
<span className="text-sm text-muted-foreground">
<Trans
i18nKey="pageXOfY"
defaults="Page {currentPage} of {pageCount}"
values={{
currentPage: table.getState().pagination.pageIndex + 1,
pageCount: table.getPageCount(),
}}
/>
</span>
<Button
variant="ghost"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRightIcon
className={cn("h-4 w-4", {
"text-gray-400": !table.getCanNextPage(),
})}
/>
</Button>
</div>
</div> </div>
); );
}; };

View file

@ -20,10 +20,10 @@ export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
ref={ref} ref={ref}
type="text" type="text"
className={clsx( className={clsx(
"appearance-none rounded border text-gray-800 placeholder:text-gray-500", "appearance-none text-sm rounded border text-gray-800 placeholder:text-gray-500",
className, className,
{ {
"px-2 py-1": size === "md", "px-2.5 py-2": size === "md",
"px-3 py-2 text-xl": size === "lg", "px-3 py-2 text-xl": size === "lg",
"input-error": error, "input-error": error,
"bg-gray-50 text-gray-500": forwardProps.disabled, "bg-gray-50 text-gray-500": forwardProps.disabled,

View file

@ -1,3 +1,5 @@
"use client";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@ -33,18 +35,21 @@ import { isFeedbackEnabled } from "@/utils/constants";
import { IfAuthenticated, IfGuest, useUser } from "./user-provider"; import { IfAuthenticated, IfGuest, useUser } from "./user-provider";
export const UserDropdown = () => { export const UserDropdown = ({ className }: { className?: string }) => {
const { user } = useUser(); const { user } = useUser();
return ( return (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger <DropdownMenuTrigger
data-testid="user-dropdown" data-testid="user-dropdown"
asChild asChild
className="group" className={cn("group min-w-0", className)}
> >
<Button variant="ghost" className="rounded-full"> <Button variant="ghost" className="flex justify-between">
<CurrentUserAvatar size="sm" className="-ml-1" /> <span className="flex items-center gap-x-2.5">
<ChevronDown className="h-4 w-4" /> <CurrentUserAvatar size="sm" className="shrink-0 -ml-1 " />
<span className="truncate">{user.name}</span>
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@ -62,7 +67,7 @@ export const UserDropdown = () => {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild={true}> <DropdownMenuItem asChild={true}>
<Link href="/polls" className="flex items-center gap-x-2 sm:hidden"> <Link href="/polls" className="flex items-center gap-x-2 sm:hidden">
<ListIcon className="h-4 w-4" /> <ListIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="polls" defaults="Polls" /> <Trans i18nKey="polls" defaults="Polls" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
@ -72,7 +77,7 @@ export const UserDropdown = () => {
href="/settings/profile" href="/settings/profile"
className="flex items-center gap-x-2" className="flex items-center gap-x-2"
> >
<UserIcon className="h-4 w-4" /> <UserIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="profile" defaults="Profile" /> <Trans i18nKey="profile" defaults="Profile" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
@ -82,7 +87,7 @@ export const UserDropdown = () => {
href="/settings/preferences" href="/settings/preferences"
className="flex items-center gap-x-2" className="flex items-center gap-x-2"
> >
<Settings2Icon className="h-4 w-4" /> <Settings2Icon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="preferences" defaults="Preferences" /> <Trans i18nKey="preferences" defaults="Preferences" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
@ -92,7 +97,7 @@ export const UserDropdown = () => {
href="/settings/billing" href="/settings/billing"
className="flex items-center gap-x-2" className="flex items-center gap-x-2"
> >
<CreditCardIcon className="h-4 w-4" /> <CreditCardIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="Billing" defaults="Billing" /> <Trans i18nKey="Billing" defaults="Billing" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
@ -104,7 +109,7 @@ export const UserDropdown = () => {
href="https://support.rallly.co" href="https://support.rallly.co"
className="flex items-center gap-x-2" className="flex items-center gap-x-2"
> >
<LifeBuoyIcon className="h-4 w-4" /> <LifeBuoyIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="support" defaults="Support" /> <Trans i18nKey="support" defaults="Support" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
@ -115,7 +120,7 @@ export const UserDropdown = () => {
href="https://support.rallly.co/self-hosting/pricing" href="https://support.rallly.co/self-hosting/pricing"
className="flex items-center gap-x-2" className="flex items-center gap-x-2"
> >
<GemIcon className="h-4 w-4" /> <GemIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="pricing" defaults="Pricing" /> <Trans i18nKey="pricing" defaults="Pricing" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
@ -127,7 +132,7 @@ export const UserDropdown = () => {
href="https://feedback.rallly.co" href="https://feedback.rallly.co"
className="flex items-center gap-x-2" className="flex items-center gap-x-2"
> >
<MegaphoneIcon className="h-4 w-4" /> <MegaphoneIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="feedback" defaults="Feedback" /> <Trans i18nKey="feedback" defaults="Feedback" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
@ -136,13 +141,13 @@ export const UserDropdown = () => {
<IfGuest> <IfGuest>
<DropdownMenuItem asChild={true}> <DropdownMenuItem asChild={true}>
<LoginLink className="flex items-center gap-x-2"> <LoginLink className="flex items-center gap-x-2">
<LogInIcon className="h-4 w-4" /> <LogInIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="login" defaults="login" /> <Trans i18nKey="login" defaults="login" />
</LoginLink> </LoginLink>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild={true}> <DropdownMenuItem asChild={true}>
<RegisterLink className="flex items-center gap-x-2"> <RegisterLink className="flex items-center gap-x-2">
<UserPlusIcon className="h-4 w-4" /> <UserPlusIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="createAnAccount" defaults="Register" /> <Trans i18nKey="createAnAccount" defaults="Register" />
</RegisterLink> </RegisterLink>
</DropdownMenuItem> </DropdownMenuItem>
@ -153,7 +158,7 @@ export const UserDropdown = () => {
> >
{/* Don't use signOut() from next-auth. It doesn't work in vercel-production env. I don't know why. */} {/* Don't use signOut() from next-auth. It doesn't work in vercel-production env. I don't know why. */}
<a href="/logout"> <a href="/logout">
<RefreshCcwIcon className="h-4 w-4" /> <RefreshCcwIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="forgetMe" /> <Trans i18nKey="forgetMe" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
@ -162,7 +167,7 @@ export const UserDropdown = () => {
<DropdownMenuItem asChild className="flex items-center gap-x-2"> <DropdownMenuItem asChild className="flex items-center gap-x-2">
{/* Don't use signOut() from next-auth. It doesn't work in vercel-production env. I don't know why. */} {/* Don't use signOut() from next-auth. It doesn't work in vercel-production env. I don't know why. */}
<a href="/logout"> <a href="/logout">
<LogOutIcon className="h-4 w-4" /> <LogOutIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="logout" /> <Trans i18nKey="logout" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -1,3 +1,4 @@
"use client";
import clsx from "clsx"; import clsx from "clsx";
import { UserIcon } from "lucide-react"; import { UserIcon } from "lucide-react";

View file

@ -44,9 +44,9 @@ export const IfSubscribed = ({ children }: React.PropsWithChildren) => {
}; };
export const IfFreeUser = ({ children }: React.PropsWithChildren) => { export const IfFreeUser = ({ children }: React.PropsWithChildren) => {
const plan = usePlan(); const subscription = useSubscription();
return plan === "free" ? <>{children}</> : null; return subscription?.active === false ? <>{children}</> : null;
}; };
export const Plan = () => { export const Plan = () => {

View file

@ -3,7 +3,6 @@ import { useTranslation } from "next-i18next";
import React from "react"; import React from "react";
import ErrorPage from "@/components/error-page"; import ErrorPage from "@/components/error-page";
import { getStandardLayout } from "@/components/layouts/standard-layout";
import { NextPageWithLayout } from "@/types"; import { NextPageWithLayout } from "@/types";
import { getStaticTranslations } from "@/utils/with-page-translations"; import { getStaticTranslations } from "@/utils/with-page-translations";
@ -18,8 +17,6 @@ const Custom404: NextPageWithLayout = () => {
); );
}; };
Custom404.getLayout = getStandardLayout;
export const getStaticProps = getStaticTranslations; export const getStaticProps = getStaticTranslations;
export default Custom404; export default Custom404;

View file

@ -51,7 +51,7 @@ export default async function handler(
}); });
if (!user) { if (!user) {
res.redirect(303, "/logout"); res.status(404).end();
return; return;
} }

View file

@ -38,7 +38,7 @@ export default async function handler(
}); });
if (!user) { if (!user) {
res.status(403).redirect("/logout"); res.status(404).end();
return; return;
} }

View file

@ -7,27 +7,14 @@
@apply border-border; @apply border-border;
} }
body { body {
@apply text-foreground overflow-y-scroll overscroll-none bg-gray-200/50; @apply text-foreground bg-gray-100 overscroll-none;
font-feature-settings: "rlig" 1, "calt" 1; font-feature-settings: "rlig" 1, "calt" 1;
} }
html { html {
@apply h-full font-sans text-base text-gray-700; @apply h-full font-sans text-base overflow-hidden text-gray-700;
} }
body #__next { body #__next {
@apply min-h-screen; @apply h-full;
}
h1,
h2,
h3,
h4,
h5 {
@apply font-sans font-bold tracking-tight;
}
h1 {
@apply text-2xl;
}
h2 {
@apply text-xl;
} }
label { label {
@ -39,7 +26,7 @@
input, input,
select, select,
textarea { textarea {
@apply rounded outline-none focus-visible:ring-2 focus-visible:ring-gray-300; @apply rounded outline-none focus-visible:ring-1 focus:ring-gray-300;
} }
#floating-ui-root { #floating-ui-root {
@ -49,7 +36,7 @@
@layer components { @layer components {
.text-link { .text-link {
@apply text-primary-600 hover:text-primary-600 focus-visible:ring-primary-600 rounded-md font-medium outline-none hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1; @apply hover:text-gray-800 rounded-md outline-none underline focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1;
} }
.formField { .formField {
@apply mb-4; @apply mb-4;

View file

@ -0,0 +1,11 @@
import Cookies from "js-cookie";
export function setCookie(key: string, value: string) {
Cookies.set(key, value);
}
export function popCookie(key: string) {
const value = Cookies.get(key);
Cookies.remove(key);
return value;
}

View file

@ -205,6 +205,15 @@ export const DayjsProvider: React.FunctionComponent<{
return await dayjsLocales[l].import(); return await dayjsLocales[l].import();
}, [l]); }, [l]);
const preferredTimeZone = config?.timeZone ?? getBrowserTimeZone();
const adjustTimeZone = React.useCallback(
(date: dayjs.ConfigType, keepLocalTime = false) => {
return dayjs(date).tz(preferredTimeZone, keepLocalTime);
},
[preferredTimeZone],
);
if (!state.value) { if (!state.value) {
// wait for locale to load before rendering // wait for locale to load before rendering
return null; return null;
@ -234,16 +243,10 @@ export const DayjsProvider: React.FunctionComponent<{
dayjs.locale(dayjsLocale); dayjs.locale(dayjsLocale);
} }
const preferredTimeZone = config?.timeZone ?? getBrowserTimeZone();
return ( return (
<DayjsContext.Provider <DayjsContext.Provider
value={{ value={{
adjustTimeZone: (date, keepLocalTime) => { adjustTimeZone,
return keepLocalTime
? dayjs(date).utc()
: dayjs(date).tz(preferredTimeZone);
},
dayjs, dayjs,
locale: localeConfig, // locale defaults locale: localeConfig, // locale defaults
timeZone: preferredTimeZone, timeZone: preferredTimeZone,

View file

@ -123,8 +123,6 @@ test.describe.serial(() => {
await page.waitForURL("/polls"); await page.waitForURL("/polls");
await page.getByTestId("user-dropdown").click();
await expect(page.getByText("Test User")).toBeVisible(); await expect(page.getByText("Test User")).toBeVisible();
}); });
@ -145,8 +143,6 @@ test.describe.serial(() => {
await page.waitForURL("/polls"); await page.waitForURL("/polls");
await page.getByTestId("user-dropdown").click();
await expect(page.getByText("Test User")).toBeVisible(); await expect(page.getByText("Test User")).toBeVisible();
}); });
@ -167,8 +163,6 @@ test.describe.serial(() => {
await page.waitForURL("/polls"); await page.waitForURL("/polls");
await page.getByTestId("user-dropdown").click();
await expect(page.getByText("Test User")).toBeVisible(); await expect(page.getByText("Test User")).toBeVisible();
}); });
}); });

View file

@ -1,32 +0,0 @@
import test, { expect } from "@playwright/test";
import { sealData } from "iron-session";
/**
* This test checks that a legacy token can be used to sign in.
*/
test("should convert guest legacy token", async ({ browser }) => {
const context = await browser.newContext();
const guestLegacyToken = await sealData(
{
user: {
id: "user-1234",
isGuest: true,
},
},
{
password: process.env.SECRET_PASSWORD,
},
);
await context.addCookies([
{
name: "rallly-session",
value: guestLegacyToken,
httpOnly: true,
url: process.env.NEXT_PUBLIC_BASE_URL,
},
]);
const page = await context.newPage();
await page.goto("/polls");
await page.getByTestId("user-dropdown").click();
await expect(page.locator("text=user-1234")).toBeVisible();
});

View file

@ -35,7 +35,7 @@
"dependencies": { "dependencies": {
"@sentry/nextjs": "^7.77.0", "@sentry/nextjs": "^7.77.0",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"next": "^14.0.1", "next": "^14.0.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",

View file

@ -420,6 +420,77 @@ export const polls = router({
}, },
}); });
}), }),
paginatedList: possiblyPublicProcedure
.input(
z.object({
pagination: z.object({
pageIndex: z.number(),
pageSize: z.number(),
}),
}),
)
.query(async ({ ctx, input }) => {
const [total, rows] = await prisma.$transaction([
prisma.poll.count({
where: {
userId: ctx.user.id,
deleted: false,
},
}),
prisma.poll.findMany({
where: {
userId: ctx.user.id,
deleted: false,
},
select: {
id: true,
title: true,
location: true,
createdAt: true,
timeZone: true,
adminUrlId: true,
participantUrlId: true,
status: true,
event: {
select: {
start: true,
duration: true,
},
},
options: {
select: {
id: true,
start: true,
duration: true,
},
},
closed: true,
participants: {
select: {
id: true,
name: true,
},
orderBy: [
{
createdAt: "desc",
},
{ name: "desc" },
],
},
},
orderBy: [
{
createdAt: "desc",
},
{ title: "asc" },
],
skip: input.pagination.pageIndex * input.pagination.pageSize,
take: input.pagination.pageSize,
}),
]);
return { total, rows };
}),
list: possiblyPublicProcedure.query(async ({ ctx }) => { list: possiblyPublicProcedure.query(async ({ ctx }) => {
const polls = await prisma.poll.findMany({ const polls = await prisma.poll.findMany({
where: { where: {

View file

@ -32,7 +32,9 @@ const Alert = React.forwardRef<
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant }), className)}
{...props} {...props}
> >
{Icon ? <Icon className="mb-2 h-6 w-6" /> : null} {Icon ? (
<Icon className="mb-2 -mt-1 h-6 w-6 text-muted-foreground" />
) : null}
<div>{children}</div> <div>{children}</div>
</div> </div>
)); ));
@ -60,7 +62,10 @@ const AlertDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)} className={cn(
"text-sm text-muted-foreground [&_p]:leading-relaxed",
className,
)}
{...props} {...props}
/> />
)); ));

View file

@ -8,7 +8,7 @@ const badgeVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: "border-transparent bg-primary text-primary-foreground", default: "border-transparent bg-primary text-primary-50",
secondary: "border-transparent bg-secondary text-secondary-foreground", secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground", "border-transparent bg-destructive text-destructive-foreground",

View file

@ -26,7 +26,7 @@ export const BillingPlanHeader = ({
}; };
export const BillingPlanTitle = ({ children }: React.PropsWithChildren) => { export const BillingPlanTitle = ({ children }: React.PropsWithChildren) => {
return <h3>{children}</h3>; return <h3 className="font-semibold">{children}</h3>;
}; };
export const BillingPlanDescription = ({ export const BillingPlanDescription = ({

View file

@ -1,22 +1,22 @@
"use client"; "use client";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { SpinnerIcon } from "@rallly/icons"; import { Loader2Icon } from "lucide-react";
import { cva, VariantProps } from "class-variance-authority"; import { cva, VariantProps } from "class-variance-authority";
import * as React from "react"; import * as React from "react";
import { cn } from "./lib/utils"; import { cn } from "./lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex border font-medium disabled:text-muted-foreground focus:ring-1 focus:ring-gray-200 disabled:bg-muted disabled:pointer-events-none select-none items-center justify-center whitespace-nowrap rounded-md border", "inline-flex border font-medium disabled:text-muted-foreground focus:ring-1 focus:ring-gray-300 disabled:bg-muted disabled:pointer-events-none select-none items-center justify-center whitespace-nowrap rounded-md border",
{ {
variants: { variants: {
variant: { variant: {
primary: primary:
"border-transparent bg-primary text-white shadow-sm hover:bg-primary-500 active:bg-primary-700", "border-transparent bg-primary text-white focus:ring-offset-1 shadow-sm hover:bg-primary-500 active:bg-primary-700",
destructive: destructive:
"bg-destructive text-destructive-foreground active:bg-destructive hover:bg-destructive/90", "bg-destructive text-destructive-foreground focus:ring-offset-1 active:bg-destructive border-destructive hover:bg-destructive/90",
default: default:
"rounded-md px-3.5 py-2.5 data-[state=open]:shadow-none data-[state=open]:bg-gray-100 active:bg-gray-200 hover:bg-gray-100 bg-gray-50", "rounded-md px-3.5 py-2.5 data-[state=open]:shadow-none data-[state=open]:bg-gray-100 active:bg-gray-200 focus:border-gray-300 hover:bg-gray-100 bg-gray-50",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "border-transparent hover:bg-gray-200 active:bg-gray-300", ghost: "border-transparent hover:bg-gray-200 active:bg-gray-300",
@ -77,9 +77,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
) : ( ) : (
<> <>
{loading ? ( {loading ? (
<SpinnerIcon className="inline-block h-4 w-4 animate-spin" /> <Loader2Icon className="h-4 w-4 animate-spin" />
) : Icon ? ( ) : Icon ? (
<Icon className="-ml-0.5 h-4 w-4" /> <Icon className={cn("-ml-0.5 h-4 w-4")} />
) : null} ) : null}
{children} {children}
</> </>

View file

@ -17,6 +17,7 @@
"@radix-ui/react-switch": "^1.0.2", "@radix-ui/react-switch": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.6", "@radix-ui/react-tooltip": "^1.0.6",
"@radix-ui/react-toast": "^1.1.4",
"@rallly/icons": "*", "@rallly/icons": "*",
"@rallly/languages": "*", "@rallly/languages": "*",
"class-variance-authority": "^0.6.0", "class-variance-authority": "^0.6.0",

View file

@ -80,7 +80,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-50 data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex w-full cursor-default select-none items-center rounded-sm py-2 pl-8 pr-2 text-sm outline-none focus:bg-gray-50 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className, className,
)} )}
{...props} {...props}

127
packages/ui/toast.tsx Normal file
View file

@ -0,0 +1,127 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "./lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-4 sm:right-4 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg p-5 shadow-huge transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "bg-background/75 backdrop-blur-lg text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

34
packages/ui/toaster.tsx Normal file
View file

@ -0,0 +1,34 @@
"use client";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "./toast";
import { useToast } from "./use-toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider duration={2000}>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

189
packages/ui/use-toast.ts Normal file
View file

@ -0,0 +1,189 @@
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "./toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

155
yarn.lock
View file

@ -2332,10 +2332,10 @@
dependencies: dependencies:
webpack-bundle-analyzer "4.3.0" webpack-bundle-analyzer "4.3.0"
"@next/env@14.0.1": "@next/env@14.0.4":
version "14.0.1" version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.0.1.tgz#7d03c9042c205a320aef2ea3f83c2d16b6825563" resolved "https://registry.yarnpkg.com/@next/env/-/env-14.0.4.tgz#d5cda0c4a862d70ae760e58c0cd96a8899a2e49a"
integrity sha512-Ms8ZswqY65/YfcjrlcIwMPD7Rg/dVjdLapMcSHG26W6O67EJDF435ShW4H4LXi1xKO1oRc97tLXUpx8jpLe86A== integrity sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==
"@next/eslint-plugin-next@14.0.1": "@next/eslint-plugin-next@14.0.1":
version "14.0.1" version "14.0.1"
@ -2344,50 +2344,50 @@
dependencies: dependencies:
glob "7.1.7" glob "7.1.7"
"@next/swc-darwin-arm64@14.0.1": "@next/swc-darwin-arm64@14.0.4":
version "14.0.1" version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.1.tgz#75a5f872c4077ecd536d7496bc24f3d312d5dcd0" resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.4.tgz#27b1854c2cd04eb1d5e75081a1a792ad91526618"
integrity sha512-JyxnGCS4qT67hdOKQ0CkgFTp+PXub5W1wsGvIq98TNbF3YEIN7iDekYhYsZzc8Ov0pWEsghQt+tANdidITCLaw== integrity sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==
"@next/swc-darwin-x64@14.0.1": "@next/swc-darwin-x64@14.0.4":
version "14.0.1" version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.1.tgz#7d8498fc680cc8b4d5181bee336818c63779bc5e" resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz#9940c449e757d0ee50bb9e792d2600cc08a3eb3b"
integrity sha512-625Z7bb5AyIzswF9hvfZWa+HTwFZw+Jn3lOBNZB87lUS0iuCYDHqk3ujuHCkiyPtSC0xFBtYDLcrZ11mF/ap3w== integrity sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==
"@next/swc-linux-arm64-gnu@14.0.1": "@next/swc-linux-arm64-gnu@14.0.4":
version "14.0.1" version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.1.tgz#184286794e67bed192de7dbb10d7f040c996f965" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz#0eafd27c8587f68ace7b4fa80695711a8434de21"
integrity sha512-iVpn3KG3DprFXzVHM09kvb//4CNNXBQ9NB/pTm8LO+vnnnaObnzFdS5KM+w1okwa32xH0g8EvZIhoB3fI3mS1g== integrity sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==
"@next/swc-linux-arm64-musl@14.0.1": "@next/swc-linux-arm64-musl@14.0.4":
version "14.0.1" version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.1.tgz#e8121b860ebc97a8d2a9113e5a42878430e749d5" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz#2b0072adb213f36dada5394ea67d6e82069ae7dd"
integrity sha512-mVsGyMxTLWZXyD5sen6kGOTYVOO67lZjLApIj/JsTEEohDDt1im2nkspzfV5MvhfS7diDw6Rp/xvAQaWZTv1Ww== integrity sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==
"@next/swc-linux-x64-gnu@14.0.1": "@next/swc-linux-x64-gnu@14.0.4":
version "14.0.1" version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.1.tgz#cdc4276b11a10c892fd1cb7dd31e024064db9c3b" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz#68c67d20ebc8e3f6ced6ff23a4ba2a679dbcec32"
integrity sha512-wMqf90uDWN001NqCM/auRl3+qVVeKfjJdT9XW+RMIOf+rhUzadmYJu++tp2y+hUbb6GTRhT+VjQzcgg/QTD9NQ== integrity sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==
"@next/swc-linux-x64-musl@14.0.1": "@next/swc-linux-x64-musl@14.0.4":
version "14.0.1" version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.1.tgz#4a194a484ceb34fd370e8d1af571493859fb2542" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz#67cd81b42fb2caf313f7992fcf6d978af55a1247"
integrity sha512-ol1X1e24w4j4QwdeNjfX0f+Nza25n+ymY0T2frTyalVczUmzkVD7QGgPTZMHfR1aLrO69hBs0G3QBYaj22J5GQ== integrity sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==
"@next/swc-win32-arm64-msvc@14.0.1": "@next/swc-win32-arm64-msvc@14.0.4":
version "14.0.1" version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.1.tgz#71923debee50f98ef166b28cdb3ad7e7463e6598" resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz#be06585906b195d755ceda28f33c633e1443f1a3"
integrity sha512-WEmTEeWs6yRUEnUlahTgvZteh5RJc4sEjCQIodJlZZ5/VJwVP8p2L7l6VhzQhT4h7KvLx/Ed4UViBdne6zpIsw== integrity sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==
"@next/swc-win32-ia32-msvc@14.0.1": "@next/swc-win32-ia32-msvc@14.0.4":
version "14.0.1" version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.1.tgz#b8f46da899c279fd65db76f0951849290c480ef9" resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz#e76cabefa9f2d891599c3d85928475bd8d3f6600"
integrity sha512-oFpHphN4ygAgZUKjzga7SoH2VGbEJXZa/KL8bHCAwCjDWle6R1SpiGOdUdA8EJ9YsG1TYWpzY6FTbUA+iAJeww== integrity sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==
"@next/swc-win32-x64-msvc@14.0.1": "@next/swc-win32-x64-msvc@14.0.4":
version "14.0.1" version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.1.tgz#be3dd8b3729ec51c99ff04b51e2b235756d02b6e" resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz#e74892f1a9ccf41d3bf5979ad6d3d77c07b9cba1"
integrity sha512-FFp3nOJ/5qSpeWT0BZQ+YE1pSMk4IMpkME/1DwKBwhg4mJLB9L+6EXuJi4JEwaJdl5iN+UUlmUD3IsR1kx5fAg== integrity sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
@ -2802,6 +2802,18 @@
"@radix-ui/react-use-callback-ref" "1.0.1" "@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown" "1.0.3" "@radix-ui/react-use-escape-keydown" "1.0.3"
"@radix-ui/react-dismissable-layer@1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4"
integrity sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown" "1.0.3"
"@radix-ui/react-dropdown-menu@^2.0.4": "@radix-ui/react-dropdown-menu@^2.0.4":
version "2.0.4" version "2.0.4"
resolved "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.4.tgz" resolved "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.4.tgz"
@ -2989,6 +3001,14 @@
"@babel/runtime" "^7.13.10" "@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3" "@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-portal@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz#df4bfd353db3b1e84e639e9c63a5f2565fb00e15"
integrity sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-presence@1.0.0": "@radix-ui/react-presence@1.0.0":
version "1.0.0" version "1.0.0"
resolved "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.0.tgz" resolved "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.0.tgz"
@ -3161,6 +3181,25 @@
"@radix-ui/react-roving-focus" "1.0.4" "@radix-ui/react-roving-focus" "1.0.4"
"@radix-ui/react-use-controllable-state" "1.0.1" "@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-toast@^1.1.4":
version "1.1.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.1.5.tgz#f5788761c0142a5ae9eb97f0051fd3c48106d9e6"
integrity sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-collection" "1.0.3"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-dismissable-layer" "1.0.5"
"@radix-ui/react-portal" "1.0.4"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-visually-hidden" "1.0.3"
"@radix-ui/react-tooltip@^1.0.6": "@radix-ui/react-tooltip@^1.0.6":
version "1.0.6" version "1.0.6"
resolved "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.6.tgz" resolved "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.6.tgz"
@ -6786,7 +6825,7 @@ gopd@^1.0.1:
dependencies: dependencies:
get-intrinsic "^1.1.3" get-intrinsic "^1.1.3"
graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.2.4: graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.2.11, graceful-fs@^4.2.4:
version "4.2.11" version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
@ -8066,6 +8105,11 @@ lucide-react@^0.265.0:
resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.265.0.tgz" resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.265.0.tgz"
integrity sha512-znyvziBEUQ7CKR31GiU4viomQbJrpDLG5ac+FajwiZIavC3YbPFLkzQx3dCXT4JWJx/pB34EwmtiZ0ElGZX0PA== integrity sha512-znyvziBEUQ7CKR31GiU4viomQbJrpDLG5ac+FajwiZIavC3YbPFLkzQx3dCXT4JWJx/pB34EwmtiZ0ElGZX0PA==
lucide-react@^0.294.0:
version "0.294.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.294.0.tgz#dc406e1e7e2f722cf93218fe5b31cf3c95778817"
integrity sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==
luxon@^3.2.1: luxon@^3.2.1:
version "3.2.1" version "3.2.1"
resolved "https://registry.npmjs.org/luxon/-/luxon-3.2.1.tgz" resolved "https://registry.npmjs.org/luxon/-/luxon-3.2.1.tgz"
@ -8586,28 +8630,29 @@ next-seo@^6.1.0:
resolved "https://registry.npmjs.org/next-seo/-/next-seo-6.1.0.tgz" resolved "https://registry.npmjs.org/next-seo/-/next-seo-6.1.0.tgz"
integrity sha512-iMBpFoJsR5zWhguHJvsoBDxDSmdYTHtnVPB1ij+CD0NReQCP78ZxxbdL9qkKIf4oEuZEqZkrjAQLB0bkII7RYA== integrity sha512-iMBpFoJsR5zWhguHJvsoBDxDSmdYTHtnVPB1ij+CD0NReQCP78ZxxbdL9qkKIf4oEuZEqZkrjAQLB0bkII7RYA==
next@^14.0.1: next@^14.0.4:
version "14.0.1" version "14.0.4"
resolved "https://registry.yarnpkg.com/next/-/next-14.0.1.tgz#1375d94c5dc7af730234af48401be270e975cb22" resolved "https://registry.yarnpkg.com/next/-/next-14.0.4.tgz#bf00b6f835b20d10a5057838fa2dfced1d0d84dc"
integrity sha512-s4YaLpE4b0gmb3ggtmpmV+wt+lPRuGtANzojMQ2+gmBpgX9w5fTbjsy6dXByBuENsdCX5pukZH/GxdFgO62+pA== integrity sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==
dependencies: dependencies:
"@next/env" "14.0.1" "@next/env" "14.0.4"
"@swc/helpers" "0.5.2" "@swc/helpers" "0.5.2"
busboy "1.6.0" busboy "1.6.0"
caniuse-lite "^1.0.30001406" caniuse-lite "^1.0.30001406"
graceful-fs "^4.2.11"
postcss "8.4.31" postcss "8.4.31"
styled-jsx "5.1.1" styled-jsx "5.1.1"
watchpack "2.4.0" watchpack "2.4.0"
optionalDependencies: optionalDependencies:
"@next/swc-darwin-arm64" "14.0.1" "@next/swc-darwin-arm64" "14.0.4"
"@next/swc-darwin-x64" "14.0.1" "@next/swc-darwin-x64" "14.0.4"
"@next/swc-linux-arm64-gnu" "14.0.1" "@next/swc-linux-arm64-gnu" "14.0.4"
"@next/swc-linux-arm64-musl" "14.0.1" "@next/swc-linux-arm64-musl" "14.0.4"
"@next/swc-linux-x64-gnu" "14.0.1" "@next/swc-linux-x64-gnu" "14.0.4"
"@next/swc-linux-x64-musl" "14.0.1" "@next/swc-linux-x64-musl" "14.0.4"
"@next/swc-win32-arm64-msvc" "14.0.1" "@next/swc-win32-arm64-msvc" "14.0.4"
"@next/swc-win32-ia32-msvc" "14.0.1" "@next/swc-win32-ia32-msvc" "14.0.4"
"@next/swc-win32-x64-msvc" "14.0.1" "@next/swc-win32-x64-msvc" "14.0.4"
nice-try@^1.0.4: nice-try@^1.0.4:
version "1.0.5" version "1.0.5"