mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-18 03:16:21 +02:00
✨ Update admin layout and pages (#976)
This commit is contained in:
parent
0ba7e9ce91
commit
a1bac0c986
81 changed files with 2053 additions and 1260 deletions
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": true
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
|
42
apps/web/src/app/[locale]/(admin)/menu-item.tsx
Normal file
42
apps/web/src/app/[locale]/(admin)/menu-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
3
apps/web/src/app/[locale]/(admin)/new/loading.tsx
Normal file
3
apps/web/src/app/[locale]/(admin)/new/loading.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default function Loading() {
|
||||
return null;
|
||||
}
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
55
apps/web/src/app/[locale]/(admin)/polls/polls-folders.tsx
Normal file
55
apps/web/src/app/[locale]/(admin)/polls/polls-folders.tsx
Normal 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>
|
||||
);
|
||||
}
|
211
apps/web/src/app/[locale]/(admin)/polls/polls-list.tsx
Normal file
211
apps/web/src/app/[locale]/(admin)/polls/polls-list.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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={
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export default function Loading() {
|
||||
return null;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
102
apps/web/src/app/[locale]/(admin)/settings/menu-item.tsx
Normal file
102
apps/web/src/app/[locale]/(admin)/settings/menu-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Loading() {
|
||||
return null;
|
||||
}
|
|
@ -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={
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export default function Loading() {
|
||||
return null;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
161
apps/web/src/app/[locale]/(admin)/sidebar.tsx
Normal file
161
apps/web/src/app/[locale]/(admin)/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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({
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
19
apps/web/src/app/[locale]/menu/back-button.tsx
Normal file
19
apps/web/src/app/[locale]/menu/back-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
30
apps/web/src/app/[locale]/menu/page.tsx
Normal file
30
apps/web/src/app/[locale]/menu/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
3
apps/web/src/app/[locale]/poll/[urlId]/loading.tsx
Normal file
3
apps/web/src/app/[locale]/poll/[urlId]/loading.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default function Loading() {
|
||||
return null;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
20
apps/web/src/app/components/logo-link.tsx
Normal file
20
apps/web/src/app/components/logo-link.tsx
Normal 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>
|
||||
);
|
||||
}
|
14
apps/web/src/app/components/logout-button.tsx
Normal file
14
apps/web/src/app/components/logout-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
57
apps/web/src/app/components/page-layout.tsx
Normal file
57
apps/web/src/app/components/page-layout.tsx
Normal 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>;
|
||||
}
|
|
@ -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={() => {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}`}>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
"use client";
|
||||
import clsx from "clsx";
|
||||
import { UserIcon } from "lucide-react";
|
||||
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -51,7 +51,7 @@ export default async function handler(
|
|||
});
|
||||
|
||||
if (!user) {
|
||||
res.redirect(303, "/logout");
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ export default async function handler(
|
|||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(403).redirect("/logout");
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
11
apps/web/src/utils/cookies.ts
Normal file
11
apps/web/src/utils/cookies.ts
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
|
@ -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",
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 = ({
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
127
packages/ui/toast.tsx
Normal 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
34
packages/ui/toaster.tsx
Normal 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
189
packages/ui/use-toast.ts
Normal 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
155
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue