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": {
"source.fixAll": true
"source.fixAll": "explicit"
},
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.preferences.importModuleSpecifier": "non-relative",

View file

@ -52,13 +52,12 @@ const nextConfig = {
},
];
},
sentry: {
hideSourceMaps: false,
},
};
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
// recommended:
// release, url, org, project, authToken, configFile, stripPrefix,
@ -70,8 +69,9 @@ const sentryWebpackPluginOptions = {
// 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
// ensure that your source maps include changes from all other Webpack plugins
module.exports = withSentryConfig(
withBundleAnalyzer(nextConfig, sentryWebpackPluginOptions),
);
module.exports = process.env.SENTRY_AUTH_TOKEN
? withSentryConfig(withBundleAnalyzerConfig, sentryWebpackPluginOptions)
: withBundleAnalyzerConfig;

View file

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

View file

@ -112,9 +112,6 @@
"dates": "Dates",
"menu": "Menu",
"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",
"billing": "Billing",
"guestPollAlertDescription": "<0>Create an account</0> or <1>login</1> to claim this poll.",
@ -135,7 +132,6 @@
"permissionDenied": "Unauthorized",
"permissionDeniedDescription": "If you are the poll creator, please login to access your poll",
"loginDifferent": "Switch user",
"share": "Share",
"timeShownIn": "Times shown in {timeZone}",
"editDetailsDescription": "Change the details of your event.",
"finalizeDescription": "Select a final date for your event.",
@ -210,14 +206,7 @@
"earlyAccess": "Get early access to new features",
"earlyAdopterDescription": "As an early adopter, you'll lock in your subscription rate and won't be affected by future price increases.",
"upgradeNowSaveLater": "Upgrade now, save later",
"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",
"pleaseUpgrade": "Please upgrade to Pro to use this feature",
"pollSettingsDescription": "Customize the behaviour of your poll",
"requireParticipantEmailLabel": "Make email address required for participants",
"hideParticipantsLabel": "Hide participant list from other participants",
@ -226,8 +215,28 @@
"authErrorDescription": "There was an error logging you in. Please try again.",
"authErrorCta": "Go to login page",
"continueAs": "Continue as",
"finalizeFeature": "Finalize",
"duplicateFeature": "Duplicate",
"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 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";
const Auth = ({ children }: { children: React.ReactNode }) => {
@ -22,13 +27,57 @@ const Auth = ({ children }: { children: React.ReactNode }) => {
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) {
return (
<Auth>
<StandardLayout>{children}</StandardLayout>
<SidebarLayout />
</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 { CreatePoll } from "@/components/create-poll";
export default function Page() {
return <CreatePoll />;
export default async function Page({ params }: { params: { locale: string } }) {
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({

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 { PollsPage } from "./polls-page";
import { PollsList } from "./polls-list";
export default function Page() {
return <PollsPage />;
export default async function Page({ params }: { params: { locale: string } }) {
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({

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

View file

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

View file

@ -1,10 +1,61 @@
"use client";
import { ProfileLayout } from "@/components/layouts/profile-layout";
import { CreditCardIcon, Settings2Icon, UserIcon } from "lucide-react";
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: React.ReactNode;
}) {
return <ProfileLayout>{children}</ProfileLayout>;
params,
}: React.PropsWithChildren<{
params: { locale: string };
}>) {
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 {
Settings,
SettingsContent,
SettingsHeader,
SettingsSection,
} from "@/components/settings/settings";
import { Trans } from "@/components/trans";
@ -17,9 +16,6 @@ export function PreferencesPage() {
return (
<Settings>
<SettingsHeader>
<Trans i18nKey="preferences" />
</SettingsHeader>
<SettingsContent>
<Head>
<title>{t("settings")}</title>
@ -35,6 +31,7 @@ export function PreferencesPage() {
>
<LanguagePreference />
</SettingsSection>
<hr />
<SettingsSection
title={<Trans i18nKey="dateAndTime" defaults="Date & Time" />}
description={

View file

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

View file

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

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 { absoluteUrl } from "@/utils/absolute-url";
export async function GET() {
const res = NextResponse.redirect(absoluteUrl());
export async function POST() {
const res = NextResponse.redirect(absoluteUrl("/login"), 302);
await resetUser(res);
return res;
}

View file

@ -6,34 +6,7 @@ import { getTranslation } from "@/app/i18n";
import { absoluteUrl } from "@/utils/absolute-url";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<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>
);
return <>{children}</>;
}
export async function generateMetadata({

View file

@ -6,6 +6,7 @@ import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
import React from "react";
import { PageHeader } from "@/app/components/page-layout";
import { Poll } from "@/components/poll";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
import { Trans } from "@/components/trans";
@ -61,23 +62,25 @@ const GoToApp = () => {
const { user } = useUser();
return (
<div className="flex items-center justify-between gap-2 p-3">
<div>
<Button
variant="ghost"
asChild
className={poll.userId !== user.id ? "hidden" : ""}
>
<Link href={`/poll/${poll.id}`}>
<ArrowUpLeftIcon className="h-4 w-4" />
<Trans i18nKey="manage" />
</Link>
</Button>
<PageHeader variant="ghost">
<div className="flex justify-between">
<div>
<Button
variant="ghost"
asChild
className={poll.userId !== user.id ? "hidden" : ""}
>
<Link href={`/poll/${poll.id}`}>
<ArrowUpLeftIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="manage" />
</Link>
</Button>
</div>
<div>
<UserDropdown />
</div>
</div>
<div>
<UserDropdown />
</div>
</div>
</PageHeader>
);
};
@ -87,23 +90,10 @@ export default function InvitePage() {
<LegacyPollContextProvider>
<VisibilityProvider>
<GoToApp />
<div className="mx-auto max-w-4xl space-y-4 px-3 sm:py-8">
<Poll />
<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 className="lg:px-6 lg:py-5 p-3">
<div className="max-w-4xl mx-auto">
<div className="-mx-1">
<Poll />
</div>
</div>
</div>

View file

@ -2,6 +2,7 @@ import "tailwindcss/tailwind.css";
import "../../style.css";
import languages from "@rallly/languages";
import { Toaster } from "@rallly/ui/toaster";
import { Inter } from "next/font/google";
import React from "react";
@ -26,6 +27,7 @@ export default function Root({
return (
<html lang={locale} className={inter.className}>
<body className="h-screen overflow-y-scroll">
<Toaster />
<Providers>{children}</Providers>
</body>
</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."
components={[
<RegisterLink
className="hover:text-primary underline"
className="hover:text-gray-800 underline"
key="register"
/>,
<LoginLink className="hover:text-primary underline" key="login" />,
<LoginLink className="hover:text-gray-800 underline" key="login" />,
]}
/>
</AlertDescription>
@ -48,9 +48,11 @@ const GuestPollAlert = () => {
export default function Page() {
return (
<div className={cn("mx-auto w-full max-w-4xl space-y-3 sm:space-y-4")}>
<GuestPollAlert />
<Poll />
<div className={cn("max-w-4xl space-y-4 mx-auto")}>
<div className="-mx-1 space-y-3 sm:space-y-6">
<GuestPollAlert />
<Poll />
</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>
<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">
{t("stepSummary", {
current: 2,
@ -60,6 +60,7 @@ export const VerifyCode: React.FunctionComponent<{
b: <strong className="whitespace-nowrap" />,
a: (
<button
type="button"
role="button"
className="text-link"
onClick={() => {

View file

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

View file

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

View file

@ -16,6 +16,7 @@ import { useUnmount } from "react-use";
import { PollSettingsForm } from "@/components/forms/poll-settings";
import { Trans } from "@/components/trans";
import { setCookie } from "@/utils/cookies";
import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc/client";
@ -62,12 +63,16 @@ export const CreatePoll: React.FunctionComponent = () => {
const posthog = usePostHog();
const queryClient = trpc.useUtils();
const createPoll = trpc.polls.create.useMutation();
const createPoll = trpc.polls.create.useMutation({
networkMode: "always",
onSuccess: () => {
setCookie("new-poll", "1");
},
});
return (
<Form {...form}>
<form
className="pb-16"
onSubmit={form.handleSubmit(async (formData) => {
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>
<CardHeader>
<CardTitle>

View file

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

View file

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

View file

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

View file

@ -11,7 +11,7 @@ import {
ArrowLeftIcon,
ArrowUpRight,
ChevronDownIcon,
FileBarChart,
ListIcon,
LogInIcon,
LogOutIcon,
PauseCircleIcon,
@ -19,18 +19,18 @@ import {
RotateCcw,
ShieldCloseIcon,
} from "lucide-react";
import Head from "next/head";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import React from "react";
import { Container } from "@/components/container";
import { InviteDialog } from "@/components/invite-dialog";
import { StandardLayout } from "@/components/layouts/standard-layout";
import { LogoutButton } from "@/app/components/logout-button";
import {
TopBar,
TopBarTitle,
} from "@/components/layouts/standard-layout/top-bar";
PageContainer,
PageContent,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { InviteDialog } from "@/components/invite-dialog";
import { LoginLink } from "@/components/login-link";
import {
PageDialog,
@ -43,14 +43,11 @@ import ManagePoll from "@/components/poll/manage-poll";
import NotificationsToggle from "@/components/poll/notifications-toggle";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
import { PollStatusLabel } from "@/components/poll-status";
import { Skeleton } from "@/components/skeleton";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { usePoll } from "@/contexts/poll";
import { trpc } from "@/utils/trpc/client";
import { NextPageWithLayout } from "../../types";
const StatusControl = () => {
const poll = usePoll();
const queryClient = trpc.useUtils();
@ -152,41 +149,49 @@ const StatusControl = () => {
};
const AdminControls = () => {
const poll = usePoll();
const pollLink = `/poll/${poll.id}`;
const pathname = usePathname();
return (
<TopBar>
<div className="flex flex-col items-start justify-between gap-x-4 gap-y-2 sm:flex-row">
<div className="flex min-w-0 gap-4">
{pathname !== pollLink ? (
<Button asChild>
<Link href={pollLink}>
<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>
<div className="flex items-center gap-x-2">
<NotificationsToggle />
<StatusControl />
<ManagePoll />
<InviteDialog />
</div>
);
};
const Layout = ({ children }: React.PropsWithChildren) => {
const poll = usePoll();
const pollLink = `/poll/${poll.id}`;
const pathname = usePathname();
return (
<div className="flex min-w-0 grow flex-col">
<AdminControls />
<div>
<Container className="py-3 sm:py-8">{children}</Container>
</div>
</div>
<PageContainer>
<PageHeader className="flex md:flex-row flex-col md:items-center gap-x-4 gap-y-2.5">
<div className="flex min-w-0 md:basis-2/3 items-center gap-x-4">
<div className="md:basis-1/2 flex gap-x-4">
{pathname === pollLink ? (
<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>
<PageDialogFooter>
{user.isGuest ? (
<Button asChild variant="primary" size="lg">
<Button asChild variant="primary">
<LoginLink>
<LogInIcon className="-ml-1 h-5 w-5" />
<LogInIcon className="-ml-1 h-4 w-4" />
<Trans i18nKey="login" defaults="Login" />
</LoginLink>
</Button>
) : (
<Button asChild variant="primary" size="lg">
<Link href="/logout">
<LogOutIcon className="-ml-1 h-5 w-5" />
<Trans i18nKey="loginDifferent" defaults="Switch user" />
</Link>
</Button>
<LogoutButton>
<LogOutIcon className="-ml-1 h-4 w-4" />
<Trans i18nKey="loginDifferent" defaults="Switch user" />
</LogoutButton>
)}
<Button asChild size="lg">
<Button asChild>
<Link href={`/invite/${poll.id}`}>
<Trans i18nKey="goToInvite" defaults="Go to Invite Page" />
<ArrowUpRight className="h-4 w-4" />
@ -246,15 +249,6 @@ export const PermissionGuard = ({ children }: React.PropsWithChildren) => {
return <>{children}</>;
};
const Title = () => {
const poll = usePoll();
return (
<Head>
<title>{poll.title}</title>
</Head>
);
};
const Prefetch = ({ children }: React.PropsWithChildren) => {
const params = useParams();
@ -265,18 +259,7 @@ const Prefetch = ({ children }: React.PropsWithChildren) => {
const watchers = trpc.polls.getWatchers.useQuery({ pollId: urlId });
if (!poll.data || !watchers.data || !participants.data) {
return (
<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 null;
}
return <>{children}</>;
@ -295,7 +278,6 @@ export const PollLayout = ({ children }: React.PropsWithChildren) => {
return (
<Prefetch>
<LegacyPollContextProvider>
<Title />
<PermissionGuard>
<Layout>{children}</Layout>
</PermissionGuard>
@ -303,12 +285,3 @@ export const PollLayout = ({ children }: React.PropsWithChildren) => {
</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";
import { Button } from "@rallly/ui/button";
import { Card } from "@rallly/ui/card";
"use client";
import clsx from "clsx";
import {
CreditCardIcon,
MenuIcon,
Settings2Icon,
UserIcon,
} from "lucide-react";
import { CreditCardIcon, Settings2Icon, UserIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
import { Trans } from "react-i18next";
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 { Plan } from "@/contexts/plan";
import { IconComponent } from "../../types";
import { IfAuthenticated, useUser } from "../user-provider";
const MenuItem = (props: {
icon: IconComponent;
@ -30,10 +26,10 @@ const MenuItem = (props: {
return (
<Link
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
? "bg-gray-200"
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800",
: "text-gray-500 hover:text-gray-800",
)}
href={props.href}
>
@ -44,59 +40,39 @@ const MenuItem = (props: {
};
export const ProfileLayout = ({ children }: React.PropsWithChildren) => {
const { user } = useUser();
// reset toggle whenever route changes
const pathname = usePathname();
const [isMenuOpen, toggle] = useToggle(false);
const [, toggle] = useToggle(false);
React.useEffect(() => {
toggle(false);
}, [pathname, toggle]);
return (
<div>
<Container className="p-2 sm:py-8">
<Card className="mx-auto flex flex-col overflow-hidden md:min-h-[600px]">
<div className="border-b bg-gray-50 p-3 md:hidden">
<Button onClick={toggle} icon={MenuIcon} />
</div>
<div className="relative flex grow md:divide-x">
<div
className={cn(
"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",
{
hidden: !isMenuOpen,
},
)}
>
<div className="grid gap-1">
<div className="flex items-center justify-between gap-x-2.5 gap-y-2 p-3">
<div className="truncate text-sm font-semibold">
{user.name}
</div>
<Plan />
</div>
<IfAuthenticated>
<MenuItem href="/settings/profile" icon={UserIcon}>
<Trans i18nKey="profile" defaults="Profile" />
</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>
<PageContainer>
<PageHeader>
<div className="flex items-center justify-between gap-x-4">
<PageTitle>
<Trans i18nKey="settings" />
</PageTitle>
</div>
</PageHeader>
<PageContent>
<div className="inline-flex mb-4 border rounded-md p-0.5 gap-x-2">
<MenuItem href="/settings/profile" icon={UserIcon}>
<Trans i18nKey="profile" defaults="Profile" />
</MenuItem>
<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 className="max-w-4xl py-4">{children}</div>
</PageContent>
</PageContainer>
);
};

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) => {
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}
</div>
);
};
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) => {
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}
</p>
);

View file

@ -1,60 +1,16 @@
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@rallly/ui/tabs";
import { m } from "framer-motion";
import {
CalendarCheck2Icon,
CopyIcon,
DatabaseIcon,
HeartIcon,
ImageOffIcon,
LockIcon,
Settings2Icon,
TrendingUpIcon,
} from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import React from "react";
import { Trans } from "@/components/trans";
import { UpgradeButton } from "@/components/upgrade-button";
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 params = useParams();
const [tab, setTab] = React.useState("yearly");
return (
<m.div
transition={{
@ -89,7 +45,7 @@ const Teaser = () => {
<div className="space-y-6">
<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" />
</h2>
<p className="text-muted-foreground mx-auto max-w-xs text-center text-sm leading-relaxed">
@ -99,119 +55,12 @@ const Teaser = () => {
/>
</p>
</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">
<UpgradeButton annual={tab === "yearly"}>
<Trans i18nKey="upgrade" defaults="Upgrade" />
</UpgradeButton>
<Button variant="primary" asChild>
<Link href="/settings/billing">
<Trans i18nKey="upgrade" defaults="Upgrade" />
</Link>
</Button>
<Button asChild className="w-full">
<Link href={`/poll/${params?.urlId as string}`}>
<Trans i18nKey="notToday" defaults="Not Today" />

View file

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

View file

@ -1,5 +1,7 @@
import { cn } from "@rallly/ui";
import Link from "next/link";
import React from "react";
import { Trans } from "react-i18next";
import { Card } from "@/components/card";
import Discussion from "@/components/discussion";
@ -32,7 +34,7 @@ export const Poll = () => {
const PollComponent = isWideScreen ? DesktopPoll : MobilePoll;
return (
<div className={cn("space-y-3 sm:space-y-4")}>
<div className={cn("space-y-3 sm:space-y-6")}>
<EventCard />
<Card fullWidthOnMobile={false}>
<VotingForm>
@ -47,6 +49,23 @@ export const Poll = () => {
</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>
);
};

View file

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

View file

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

View file

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

View file

@ -1,12 +1,20 @@
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
OnChangeFn,
PaginationState,
useReactTable,
} from "@tanstack/react-table";
import clsx from "clsx";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import React from "react";
import { Trans } from "@/components/trans";
export const Table = <
T extends Record<string, unknown>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -15,85 +23,151 @@ export const Table = <
columns: C[];
data: T[];
footer?: React.ReactNode;
pageCount?: number;
enableTableFooter?: boolean;
enableTableHeader?: boolean;
layout?: "fixed" | "auto";
onPaginationChange?: OnChangeFn<PaginationState>;
paginationState: PaginationState | undefined;
className?: string;
}) => {
const table = useReactTable<T>({
data: props.data,
columns: props.columns,
pageCount: props.pageCount,
state: {
pagination: props.paginationState,
},
manualPagination: true,
onPaginationChange: props.onPaginationChange,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className={clsx(props.className, "max-w-full overflow-x-auto")}>
<table
<div>
<div
className={clsx(
"border-collapse",
props.layout === "auto" ? "w-full table-auto" : "table-fixed",
props.className,
"max-w-full overflow-x-auto scrollbar-thin",
)}
>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
style={{
width: header.getSize(),
maxWidth:
props.layout === "auto" ? header.getSize() : undefined,
}}
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>
<table
className={clsx(
"border-collapse",
props.layout === "auto" ? "w-full table-auto" : "table-fixed",
)}
>
{props.enableTableHeader ? (
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
style={{
width: header.getSize(),
maxWidth:
props.layout === "auto"
? header.getSize()
: undefined,
}}
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>
<tbody>
{table.getRowModel().rows.map((row, i) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={clsx(
"overflow-hidden border-gray-100 px-3 py-2.5",
{
"border-b ": table.getRowModel().rows.length !== i + 1,
},
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
{props.enableTableFooter ? (
<tfoot>
{table.getFooterGroups().map((footerGroup) => (
<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>
</thead>
) : null}
<tbody>
{table.getRowModel().rows.map((row, i) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td
style={{
width: cell.column.getSize(),
maxWidth:
props.layout === "auto"
? cell.column.getSize()
: undefined,
}}
key={cell.id}
className={clsx(
"overflow-hidden align-middle border-gray-100 pr-8 py-4",
{
"border-b": table.getRowModel().rows.length !== i + 1,
"pt-0": !props.enableTableHeader && i === 0,
},
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tfoot>
) : null}
</table>
</tbody>
{props.enableTableFooter ? (
<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>
);
};

View file

@ -20,10 +20,10 @@ export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
ref={ref}
type="text"
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,
{
"px-2 py-1": size === "md",
"px-2.5 py-2": size === "md",
"px-3 py-2 text-xl": size === "lg",
"input-error": error,
"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 {
DropdownMenu,
@ -33,18 +35,21 @@ import { isFeedbackEnabled } from "@/utils/constants";
import { IfAuthenticated, IfGuest, useUser } from "./user-provider";
export const UserDropdown = () => {
export const UserDropdown = ({ className }: { className?: string }) => {
const { user } = useUser();
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
data-testid="user-dropdown"
asChild
className="group"
className={cn("group min-w-0", className)}
>
<Button variant="ghost" className="rounded-full">
<CurrentUserAvatar size="sm" className="-ml-1" />
<ChevronDown className="h-4 w-4" />
<Button variant="ghost" className="flex justify-between">
<span className="flex items-center gap-x-2.5">
<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>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@ -62,7 +67,7 @@ export const UserDropdown = () => {
<DropdownMenuSeparator />
<DropdownMenuItem asChild={true}>
<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" />
</Link>
</DropdownMenuItem>
@ -72,7 +77,7 @@ export const UserDropdown = () => {
href="/settings/profile"
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" />
</Link>
</DropdownMenuItem>
@ -82,7 +87,7 @@ export const UserDropdown = () => {
href="/settings/preferences"
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" />
</Link>
</DropdownMenuItem>
@ -92,7 +97,7 @@ export const UserDropdown = () => {
href="/settings/billing"
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" />
</Link>
</DropdownMenuItem>
@ -104,7 +109,7 @@ export const UserDropdown = () => {
href="https://support.rallly.co"
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" />
</Link>
</DropdownMenuItem>
@ -115,7 +120,7 @@ export const UserDropdown = () => {
href="https://support.rallly.co/self-hosting/pricing"
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" />
</Link>
</DropdownMenuItem>
@ -127,7 +132,7 @@ export const UserDropdown = () => {
href="https://feedback.rallly.co"
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" />
</Link>
</DropdownMenuItem>
@ -136,13 +141,13 @@ export const UserDropdown = () => {
<IfGuest>
<DropdownMenuItem asChild={true}>
<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" />
</LoginLink>
</DropdownMenuItem>
<DropdownMenuItem asChild={true}>
<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" />
</RegisterLink>
</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. */}
<a href="/logout">
<RefreshCcwIcon className="h-4 w-4" />
<RefreshCcwIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="forgetMe" />
</a>
</DropdownMenuItem>
@ -162,7 +167,7 @@ export const UserDropdown = () => {
<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. */}
<a href="/logout">
<LogOutIcon className="h-4 w-4" />
<LogOutIcon className="h-4 w-4 text-muted-foreground" />
<Trans i18nKey="logout" />
</a>
</DropdownMenuItem>

View file

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

View file

@ -44,9 +44,9 @@ export const IfSubscribed = ({ 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 = () => {

View file

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

View file

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

View file

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

View file

@ -7,27 +7,14 @@
@apply border-border;
}
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;
}
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 {
@apply min-h-screen;
}
h1,
h2,
h3,
h4,
h5 {
@apply font-sans font-bold tracking-tight;
}
h1 {
@apply text-2xl;
}
h2 {
@apply text-xl;
@apply h-full;
}
label {
@ -39,7 +26,7 @@
input,
select,
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 {
@ -49,7 +36,7 @@
@layer components {
.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 {
@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();
}, [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) {
// wait for locale to load before rendering
return null;
@ -234,16 +243,10 @@ export const DayjsProvider: React.FunctionComponent<{
dayjs.locale(dayjsLocale);
}
const preferredTimeZone = config?.timeZone ?? getBrowserTimeZone();
return (
<DayjsContext.Provider
value={{
adjustTimeZone: (date, keepLocalTime) => {
return keepLocalTime
? dayjs(date).utc()
: dayjs(date).tz(preferredTimeZone);
},
adjustTimeZone,
dayjs,
locale: localeConfig, // locale defaults
timeZone: preferredTimeZone,

View file

@ -123,8 +123,6 @@ test.describe.serial(() => {
await page.waitForURL("/polls");
await page.getByTestId("user-dropdown").click();
await expect(page.getByText("Test User")).toBeVisible();
});
@ -145,8 +143,6 @@ test.describe.serial(() => {
await page.waitForURL("/polls");
await page.getByTestId("user-dropdown").click();
await expect(page.getByText("Test User")).toBeVisible();
});
@ -167,8 +163,6 @@ test.describe.serial(() => {
await page.waitForURL("/polls");
await page.getByTestId("user-dropdown").click();
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": {
"@sentry/nextjs": "^7.77.0",
"framer-motion": "^10.16.4",
"next": "^14.0.1",
"next": "^14.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"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 }) => {
const polls = await prisma.poll.findMany({
where: {

View file

@ -32,7 +32,9 @@ const Alert = React.forwardRef<
className={cn(alertVariants({ variant }), className)}
{...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>
));
@ -60,7 +62,10 @@ const AlertDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
className={cn(
"text-sm text-muted-foreground [&_p]:leading-relaxed",
className,
)}
{...props}
/>
));

View file

@ -8,7 +8,7 @@ const badgeVariants = cva(
{
variants: {
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",
destructive:
"border-transparent bg-destructive text-destructive-foreground",

View file

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

View file

@ -1,22 +1,22 @@
"use client";
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 * as React from "react";
import { cn } from "./lib/utils";
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: {
variant: {
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:
"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:
"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:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "border-transparent hover:bg-gray-200 active:bg-gray-300",
@ -77,9 +77,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
) : (
<>
{loading ? (
<SpinnerIcon className="inline-block h-4 w-4 animate-spin" />
<Loader2Icon className="h-4 w-4 animate-spin" />
) : Icon ? (
<Icon className="-ml-0.5 h-4 w-4" />
<Icon className={cn("-ml-0.5 h-4 w-4")} />
) : null}
{children}
</>

View file

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

View file

@ -80,7 +80,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
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,
)}
{...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:
webpack-bundle-analyzer "4.3.0"
"@next/env@14.0.1":
version "14.0.1"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.0.1.tgz#7d03c9042c205a320aef2ea3f83c2d16b6825563"
integrity sha512-Ms8ZswqY65/YfcjrlcIwMPD7Rg/dVjdLapMcSHG26W6O67EJDF435ShW4H4LXi1xKO1oRc97tLXUpx8jpLe86A==
"@next/env@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.0.4.tgz#d5cda0c4a862d70ae760e58c0cd96a8899a2e49a"
integrity sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==
"@next/eslint-plugin-next@14.0.1":
version "14.0.1"
@ -2344,50 +2344,50 @@
dependencies:
glob "7.1.7"
"@next/swc-darwin-arm64@14.0.1":
version "14.0.1"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.1.tgz#75a5f872c4077ecd536d7496bc24f3d312d5dcd0"
integrity sha512-JyxnGCS4qT67hdOKQ0CkgFTp+PXub5W1wsGvIq98TNbF3YEIN7iDekYhYsZzc8Ov0pWEsghQt+tANdidITCLaw==
"@next/swc-darwin-arm64@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.4.tgz#27b1854c2cd04eb1d5e75081a1a792ad91526618"
integrity sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==
"@next/swc-darwin-x64@14.0.1":
version "14.0.1"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.1.tgz#7d8498fc680cc8b4d5181bee336818c63779bc5e"
integrity sha512-625Z7bb5AyIzswF9hvfZWa+HTwFZw+Jn3lOBNZB87lUS0iuCYDHqk3ujuHCkiyPtSC0xFBtYDLcrZ11mF/ap3w==
"@next/swc-darwin-x64@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz#9940c449e757d0ee50bb9e792d2600cc08a3eb3b"
integrity sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==
"@next/swc-linux-arm64-gnu@14.0.1":
version "14.0.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.1.tgz#184286794e67bed192de7dbb10d7f040c996f965"
integrity sha512-iVpn3KG3DprFXzVHM09kvb//4CNNXBQ9NB/pTm8LO+vnnnaObnzFdS5KM+w1okwa32xH0g8EvZIhoB3fI3mS1g==
"@next/swc-linux-arm64-gnu@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz#0eafd27c8587f68ace7b4fa80695711a8434de21"
integrity sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==
"@next/swc-linux-arm64-musl@14.0.1":
version "14.0.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.1.tgz#e8121b860ebc97a8d2a9113e5a42878430e749d5"
integrity sha512-mVsGyMxTLWZXyD5sen6kGOTYVOO67lZjLApIj/JsTEEohDDt1im2nkspzfV5MvhfS7diDw6Rp/xvAQaWZTv1Ww==
"@next/swc-linux-arm64-musl@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz#2b0072adb213f36dada5394ea67d6e82069ae7dd"
integrity sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==
"@next/swc-linux-x64-gnu@14.0.1":
version "14.0.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.1.tgz#cdc4276b11a10c892fd1cb7dd31e024064db9c3b"
integrity sha512-wMqf90uDWN001NqCM/auRl3+qVVeKfjJdT9XW+RMIOf+rhUzadmYJu++tp2y+hUbb6GTRhT+VjQzcgg/QTD9NQ==
"@next/swc-linux-x64-gnu@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz#68c67d20ebc8e3f6ced6ff23a4ba2a679dbcec32"
integrity sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==
"@next/swc-linux-x64-musl@14.0.1":
version "14.0.1"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.1.tgz#4a194a484ceb34fd370e8d1af571493859fb2542"
integrity sha512-ol1X1e24w4j4QwdeNjfX0f+Nza25n+ymY0T2frTyalVczUmzkVD7QGgPTZMHfR1aLrO69hBs0G3QBYaj22J5GQ==
"@next/swc-linux-x64-musl@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz#67cd81b42fb2caf313f7992fcf6d978af55a1247"
integrity sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==
"@next/swc-win32-arm64-msvc@14.0.1":
version "14.0.1"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.1.tgz#71923debee50f98ef166b28cdb3ad7e7463e6598"
integrity sha512-WEmTEeWs6yRUEnUlahTgvZteh5RJc4sEjCQIodJlZZ5/VJwVP8p2L7l6VhzQhT4h7KvLx/Ed4UViBdne6zpIsw==
"@next/swc-win32-arm64-msvc@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz#be06585906b195d755ceda28f33c633e1443f1a3"
integrity sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==
"@next/swc-win32-ia32-msvc@14.0.1":
version "14.0.1"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.1.tgz#b8f46da899c279fd65db76f0951849290c480ef9"
integrity sha512-oFpHphN4ygAgZUKjzga7SoH2VGbEJXZa/KL8bHCAwCjDWle6R1SpiGOdUdA8EJ9YsG1TYWpzY6FTbUA+iAJeww==
"@next/swc-win32-ia32-msvc@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz#e76cabefa9f2d891599c3d85928475bd8d3f6600"
integrity sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==
"@next/swc-win32-x64-msvc@14.0.1":
version "14.0.1"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.1.tgz#be3dd8b3729ec51c99ff04b51e2b235756d02b6e"
integrity sha512-FFp3nOJ/5qSpeWT0BZQ+YE1pSMk4IMpkME/1DwKBwhg4mJLB9L+6EXuJi4JEwaJdl5iN+UUlmUD3IsR1kx5fAg==
"@next/swc-win32-x64-msvc@14.0.4":
version "14.0.4"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz#e74892f1a9ccf41d3bf5979ad6d3d77c07b9cba1"
integrity sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
@ -2802,6 +2802,18 @@
"@radix-ui/react-use-callback-ref" "1.0.1"
"@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":
version "2.0.4"
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"
"@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":
version "1.0.0"
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-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":
version "1.0.6"
resolved "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.6.tgz"
@ -6786,7 +6825,7 @@ gopd@^1.0.1:
dependencies:
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"
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==
@ -8066,6 +8105,11 @@ lucide-react@^0.265.0:
resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.265.0.tgz"
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:
version "3.2.1"
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"
integrity sha512-iMBpFoJsR5zWhguHJvsoBDxDSmdYTHtnVPB1ij+CD0NReQCP78ZxxbdL9qkKIf4oEuZEqZkrjAQLB0bkII7RYA==
next@^14.0.1:
version "14.0.1"
resolved "https://registry.yarnpkg.com/next/-/next-14.0.1.tgz#1375d94c5dc7af730234af48401be270e975cb22"
integrity sha512-s4YaLpE4b0gmb3ggtmpmV+wt+lPRuGtANzojMQ2+gmBpgX9w5fTbjsy6dXByBuENsdCX5pukZH/GxdFgO62+pA==
next@^14.0.4:
version "14.0.4"
resolved "https://registry.yarnpkg.com/next/-/next-14.0.4.tgz#bf00b6f835b20d10a5057838fa2dfced1d0d84dc"
integrity sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==
dependencies:
"@next/env" "14.0.1"
"@next/env" "14.0.4"
"@swc/helpers" "0.5.2"
busboy "1.6.0"
caniuse-lite "^1.0.30001406"
graceful-fs "^4.2.11"
postcss "8.4.31"
styled-jsx "5.1.1"
watchpack "2.4.0"
optionalDependencies:
"@next/swc-darwin-arm64" "14.0.1"
"@next/swc-darwin-x64" "14.0.1"
"@next/swc-linux-arm64-gnu" "14.0.1"
"@next/swc-linux-arm64-musl" "14.0.1"
"@next/swc-linux-x64-gnu" "14.0.1"
"@next/swc-linux-x64-musl" "14.0.1"
"@next/swc-win32-arm64-msvc" "14.0.1"
"@next/swc-win32-ia32-msvc" "14.0.1"
"@next/swc-win32-x64-msvc" "14.0.1"
"@next/swc-darwin-arm64" "14.0.4"
"@next/swc-darwin-x64" "14.0.4"
"@next/swc-linux-arm64-gnu" "14.0.4"
"@next/swc-linux-arm64-musl" "14.0.4"
"@next/swc-linux-x64-gnu" "14.0.4"
"@next/swc-linux-x64-musl" "14.0.4"
"@next/swc-win32-arm64-msvc" "14.0.4"
"@next/swc-win32-ia32-msvc" "14.0.4"
"@next/swc-win32-x64-msvc" "14.0.4"
nice-try@^1.0.4:
version "1.0.5"