This commit is contained in:
Luke Vella 2024-09-28 20:14:55 +01:00
parent 8ca4b2acf8
commit 211e261c71
No known key found for this signature in database
GPG key ID: 469CAD687F0D784C
28 changed files with 519 additions and 324 deletions

View file

@ -3,11 +3,8 @@
@tailwind utilities;
@layer base {
* {
@apply border-border;
}
body {
@apply text-foreground h-full overflow-y-auto bg-gray-100 font-sans;
@apply text-foreground h-full overflow-y-auto bg-gray-50 font-sans;
}
html {
@apply h-full font-sans text-base;

View file

@ -241,7 +241,6 @@
"verificationCodeSentTo": "We sent a verification code to <b>{email}</b>",
"home": "Home",
"groupPoll": "Group Poll",
"groupPollDescription": "Share your availability with a group of people and find the best time to meet.",
"create": "Create",
"upcoming": "Upcoming",
"past": "Past",
@ -250,7 +249,6 @@
"upcomingEventsEmptyStateDescription": "When you schedule events, they will appear here.",
"pastEventsEmptyStateTitle": "No Past Events",
"pastEventsEmptyStateDescription": "When you schedule events, they will appear here.",
"activePollCount": "{{activePollCount}} Live",
"createPoll": "Create poll",
"yearlyBillingDescription": "per year",
"addToCalendar": "Add to Calendar",
@ -281,5 +279,7 @@
"savePercentage": "Save {percentage}%",
"1month": "1 month",
"subscribe": "Subscribe",
"cancelAnytime": "Cancel anytime from your <a>billing page</a>."
"cancelAnytime": "Cancel anytime from your <a>billing page</a>.",
"copiedToClipboard": "Copied to clipboard",
"viewAll": "View All"
}

View file

@ -1,5 +1,4 @@
import { cn } from "@rallly/ui";
import { BarChart2Icon } from "lucide-react";
import React from "react";
export function AppCard({
@ -25,36 +24,6 @@ export function AppCardContent({ children }: { children?: React.ReactNode }) {
return <div className="">{children}</div>;
}
export function GroupPollIcon({
size = "md",
}: {
size?: "xs" | "sm" | "md" | "lg";
}) {
return (
<div
role="img"
aria-label="Group Poll Icon"
className={cn(
"inline-flex items-center justify-center bg-gradient-to-br from-purple-500 to-violet-500 text-purple-100",
{
"size-6 rounded": size === "xs",
"size-8 rounded-md": size === "sm",
"size-9 rounded-md": size === "md",
"size-10 rounded-lg": size === "lg",
},
)}
>
<BarChart2Icon
className={cn({
"size-4": size === "sm" || size === "xs",
"size-5": size === "md",
"size-6": size === "lg",
})}
/>
</div>
);
}
export function AppCardIcon({
children,
className,

View file

@ -1,75 +1,64 @@
"use client";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { PlusIcon } from "lucide-react";
import Link from "next/link";
import {
AppCard,
AppCardContent,
AppCardDescription,
AppCardFooter,
AppCardIcon,
AppCardName,
GroupPollIcon,
} from "@/app/[locale]/(admin)/app-card";
import { GroupPollCard } from "@/components/group-poll-card";
import { Subheading } from "@/components/heading";
import { Spinner } from "@/components/spinner";
import { Trans } from "@/components/trans";
import { trpc } from "@/utils/trpc/client";
export default function Dashboard() {
const { data } = trpc.dashboard.info.useQuery();
return (
<div className="space-y-6">
<div>
<Button asChild>
<Link href="/new">Create a Poll</Link>
</Button>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Subheading>
<Trans i18nKey="pending" defaults="Pending" />
</Subheading>
<Button asChild>
<Link href="/polls">
<Trans i18nKey="viewAll" defaults="View All" />
</Link>
</Button>
</div>
<PendingPolls />
</div>
</div>
);
}
function PendingPolls() {
const { data } = trpc.dashboard.getPending.useQuery(undefined, {
suspense: true,
});
if (!data) {
return <Spinner />;
}
return (
<div className="space-y-4">
<div className="grid md:flex">
<AppCard className="basis-96">
<AppCardIcon>
<GroupPollIcon size="lg" />
</AppCardIcon>
<AppCardContent>
<div>
<AppCardName>
<Trans i18nKey="groupPoll" defaults="Group Poll" />
</AppCardName>
<AppCardDescription>
<Trans
i18nKey="groupPollDescription"
defaults="Share your availability with a group of people and find the best time to meet."
<div className="grid gap-2 md:grid-cols-3">
{data.map((poll) => {
return (
<GroupPollCard
key={poll.id}
pollId={poll.id}
title={poll.title}
status={poll.status}
inviteLink={poll.inviteLink}
responseCount={poll.responseCount}
dateStart={poll.range.start}
dateEnd={poll.range.end}
/>
</AppCardDescription>
</div>
</AppCardContent>
<AppCardFooter className="flex items-center justify-between gap-4">
<div className="inline-flex items-center gap-1 text-sm">
<Link
className="text-primary font-medium hover:underline"
href="/polls?status=live"
>
<Trans
i18nKey="activePollCount"
defaults="{{activePollCount}} Live"
values={{
activePollCount: data.activePollCount,
}}
/>
</Link>
</div>
<Button asChild>
<Link href="/new">
<Icon>
<PlusIcon />
</Icon>
<Trans i18nKey="create" defaults="Create" />
</Link>
</Button>
</AppCardFooter>
</AppCard>
</div>
);
})}
</div>
);
}

View file

@ -1,10 +1,15 @@
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { PlusIcon, SearchIcon } from "lucide-react";
import Link from "next/link";
import React from "react";
import { MobileNavigation } from "@/app/[locale]/(admin)/mobile-navigation";
import { ProBadge } from "@/app/[locale]/(admin)/pro-badge";
import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
import { LogoLink } from "@/app/components/logo-link";
import { UserDropdown } from "@/components/user-dropdown";
export default async function Layout({
children,
@ -12,10 +17,10 @@ export default async function Layout({
children: React.ReactNode;
}) {
return (
<div className="flex h-screen flex-col pb-16 md:pb-0">
<div className="flex h-screen flex-col bg-gray-50 pb-16 md:pb-0">
<div
className={cn(
"fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-4 overflow-y-auto p-6 py-5 md:flex",
"fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-6 overflow-y-auto p-6 lg:flex",
)}
>
<div className="flex w-full items-center justify-between gap-4">
@ -24,16 +29,33 @@ export default async function Layout({
</div>
<Sidebar />
</div>
<div
className={cn(
"flex flex-1 flex-col pb-2 lg:min-w-0 lg:pl-72 lg:pr-2 lg:pt-2",
)}
>
<div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-sm lg:ring-1 lg:ring-gray-950/5 dark:lg:bg-gray-900 dark:lg:ring-white/10">
<div className={cn("pb-16 lg:min-w-0 lg:pb-0 lg:pl-72")}>
<div className="mx-auto max-w-7xl p-6">
<div className="mb-6 flex justify-between gap-2">
<div>
<Button>
<Icon>
<SearchIcon />
</Icon>
</Button>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" asChild>
<Link href="/new">
<Icon>
<PlusIcon />
</Icon>
Create
</Link>
</Button>
<UserDropdown />
</div>
</div>
{children}
</div>
</div>
<div className="fixed bottom-0 z-20 flex h-16 w-full flex-col justify-center bg-gray-100/90 backdrop-blur-md md:hidden">
<div className="fixed bottom-0 z-20 flex h-16 w-full flex-col justify-center bg-gray-100/90 backdrop-blur-md lg:hidden">
<MobileNavigation />
</div>
</div>

View file

@ -7,7 +7,6 @@ import {
PageContainer,
PageContent,
PageHeader,
PageIcon,
PageTitle,
} from "@/app/components/page-layout";
import { getTranslation } from "@/app/i18n";
@ -18,14 +17,9 @@ export default async function Page({ params }: { params: Params }) {
<div>
<PageContainer>
<PageHeader>
<div className="flex items-center gap-x-3">
<PageIcon>
<HomeIcon />
</PageIcon>
<PageTitle>
<Trans t={t} i18nKey="home" defaults="Home" />
</PageTitle>
</div>
</PageHeader>
<PageContent>
<Dashboard />

View file

@ -1,26 +1,18 @@
"use client";
import { PollStatus } from "@rallly/database";
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { RadioCards, RadioCardsItem } from "@rallly/ui/radio-pills";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { CalendarPlusIcon, CheckIcon, LinkIcon, UserIcon } from "lucide-react";
import Link from "next/link";
import { CalendarPlusIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React from "react";
import useCopyToClipboard from "react-use/lib/useCopyToClipboard";
import { z } from "zod";
import { GroupPollIcon } from "@/app/[locale]/(admin)/app-card";
import {
EmptyState,
EmptyStateDescription,
EmptyStateIcon,
EmptyStateTitle,
} from "@/app/components/empty-state";
import { PollStatusBadge } from "@/components/poll-status";
import { GroupPollCard } from "@/components/group-poll-card";
import { Spinner } from "@/components/spinner";
import { Trans } from "@/components/trans";
import { VisibilityTrigger } from "@/components/visibility-trigger";
@ -31,8 +23,7 @@ function PollCount({ count }: { count?: number }) {
}
function FilteredPolls({ status }: { status: PollStatus }) {
const { data, fetchNextPage, hasNextPage } =
trpc.polls.infiniteList.useInfiniteQuery(
const { data, fetchNextPage, hasNextPage } = trpc.polls.list.useInfiniteQuery(
{
status,
limit: 30,
@ -47,12 +38,41 @@ function FilteredPolls({ status }: { status: PollStatus }) {
return <Spinner />;
}
if (data?.pages[0]?.polls.length === 0) {
return (
<EmptyState className="h-96">
<EmptyStateIcon>
<CalendarPlusIcon />
</EmptyStateIcon>
<EmptyStateTitle>
<Trans i18nKey="noPolls" />
</EmptyStateTitle>
<EmptyStateDescription>
<Trans i18nKey="noPollsDescription" />
</EmptyStateDescription>
</EmptyState>
);
}
return (
<div className="space-y-6">
<ol className="space-y-4">
{data.pages.map((page, i) => (
<li key={i}>
<PollsListView data={page.polls} />
<div className="grid gap-3 sm:gap-4 md:grid-cols-3">
{page.polls.map((poll) => (
<GroupPollCard
key={poll.id}
title={poll.title}
pollId={poll.id}
status={poll.status}
inviteLink={`${window.location.origin}/invite/${poll.id}`}
responseCount={poll.participants.length}
dateStart={poll.createdAt}
dateEnd={poll.createdAt}
/>
))}
</div>
</li>
))}
</ol>
@ -128,122 +148,3 @@ export function UserPolls() {
</div>
);
}
function CopyLinkButton({ pollId }: { pollId: string }) {
const [, copy] = useCopyToClipboard();
const [didCopy, setDidCopy] = React.useState(false);
return (
<Button
type="button"
disabled={didCopy}
onClick={(e) => {
e.stopPropagation();
copy(`${window.location.origin}/invite/${pollId}`);
setDidCopy(true);
setTimeout(() => {
setDidCopy(false);
}, 1000);
}}
className="relative z-20 w-full"
>
{didCopy ? (
<>
<CheckIcon className="size-4" />
<Trans i18nKey="copied" defaults="Copied" />
</>
) : (
<>
<LinkIcon className="size-4" />
<Trans i18nKey="copyLink" defaults="Copy Link" />
</>
)}
</Button>
);
}
function ParticipantCount({ count }: { count: number }) {
return (
<div className="inline-flex items-center gap-x-1 text-sm font-medium">
<Icon>
<UserIcon />
</Icon>
<span>{count}</span>
</div>
);
}
function PollsListView({
data,
}: {
data: {
id: string;
status: PollStatus;
title: string;
createdAt: Date;
userId: string;
participants: {
id: string;
name: string;
}[];
}[];
}) {
const table = useReactTable({
columns: [],
data,
getCoreRowModel: getCoreRowModel(),
});
if (data?.length === 0) {
return (
<EmptyState className="h-96">
<EmptyStateIcon>
<CalendarPlusIcon />
</EmptyStateIcon>
<EmptyStateTitle>
<Trans i18nKey="noPolls" />
</EmptyStateTitle>
<EmptyStateDescription>
<Trans i18nKey="noPollsDescription" />
</EmptyStateDescription>
</EmptyState>
);
}
return (
<div className="grid gap-3 sm:gap-4 md:grid-cols-2">
{table.getRowModel().rows.map((row) => (
<div
className={cn(
"group relative space-y-4 overflow-hidden rounded-lg border bg-white p-4 focus-within:bg-gray-50",
)}
key={row.id}
>
<div className="space-y-4">
<div className="flex items-center gap-3">
<GroupPollIcon size="xs" />
<h2 className="truncate text-base font-medium group-hover:underline">
<Link
href={`/poll/${row.original.id}`}
className="absolute inset-0 z-10"
/>
{row.original.title}
</h2>
</div>
<div className="flex items-center gap-2">
<Badge size="lg">
<PollStatusBadge status={row.original.status} />
</Badge>
<Badge size="lg">
<ParticipantCount count={row.original.participants.length} />
</Badge>
</div>
</div>
<div className="flex items-end justify-between">
<CopyLinkButton pollId={row.original.id} />
</div>
</div>
))}
</div>
);
}

View file

@ -23,7 +23,7 @@ export default async function ProfileLayout({
<PageTitle>{t("settings")}</PageTitle>
</PageHeader>
<PageContent className="space-y-3 sm:space-y-4">
<div className="scrollbar-none -mx-3 overflow-auto bg-gray-100 px-3 sm:mx-0 sm:px-0">
<div className="scrollbar-none -mx-3 overflow-auto px-3 sm:mx-0 sm:px-0">
<SettingsMenu />
</div>
<div>{children}</div>

View file

@ -92,16 +92,6 @@ export function Sidebar() {
</li>
</ul>
</li>
<li className="-mx-2 space-y-1">
<Button variant="primary" className="w-full rounded-full" asChild>
<Link href="/new">
<Icon>
<PlusIcon />
</Icon>
<Trans i18nKey="create" defaults="create" />
</Link>
</Button>
</li>
<li className="mt-auto">
<ul role="list" className="-mx-2 space-y-1">
<IfFreeUser>

View file

@ -1,10 +1,10 @@
import { Trans } from "react-i18next/TransWithoutContext";
import { GroupPollIcon } from "@/app/[locale]/(admin)/app-card";
import { BackButton } from "@/app/[locale]/(admin)/menu/menu-button";
import { Params } from "@/app/[locale]/types";
import { getTranslation } from "@/app/i18n";
import { CreatePoll } from "@/components/create-poll";
import { GroupPollIcon } from "@/components/group-poll-icon";
import { UserDropdown } from "@/components/user-dropdown";
export default async function Page({ params }: { params: Params }) {

View file

@ -2,11 +2,13 @@
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@rallly/ui";
import { Heading } from "@/components/heading";
export function PageContainer({
children,
className,
}: React.PropsWithChildren<{ className?: string }>) {
return <div className={cn(className)}>{children}</div>;
return <div className={cn("space-y-6", className)}>{children}</div>;
}
export function PageIcon({
@ -30,16 +32,7 @@ export function PageTitle({
children?: React.ReactNode;
className?: string;
}) {
return (
<h1
className={cn(
"inline-flex items-center truncate text-xl font-bold tracking-tight text-gray-700",
className,
)}
>
{children}
</h1>
);
return <Heading className={className}>{children}</Heading>;
}
export function PageHeader({
@ -50,7 +43,7 @@ export function PageHeader({
className?: string;
variant?: "default" | "ghost";
}) {
return <div className={cn("mb-6", className)}>{children}</div>;
return <div className={cn(className)}>{children}</div>;
}
export function PageSection({ children }: { children?: React.ReactNode }) {

View file

@ -37,7 +37,7 @@ export function EventCard() {
const { t } = useTranslation();
return (
<>
<Card className="bg-gray-50">
<Card>
<RandomGradientBar seed={poll.id} />
<CardContent className="space-y-4 sm:space-y-6">
<div className="flex flex-col items-start gap-4 lg:flex-row lg:justify-between">

View file

@ -0,0 +1,13 @@
export function GridCardFooter({ children }: React.PropsWithChildren) {
return <div className="relative z-10 mt-6 flex gap-1">{children}</div>;
}
export function GridCardHeader({ children }: React.PropsWithChildren) {
return <div className="mb-4 flex items-center gap-2">{children}</div>;
}
export const GridCard = ({ children }: { children: React.ReactNode }) => {
return (
<div className="relative rounded-lg border bg-white p-4">{children}</div>
);
};

View file

@ -0,0 +1,111 @@
"use client";
import { PollStatus } from "@rallly/database";
import { Button } from "@rallly/ui/button";
import { useToast } from "@rallly/ui/hooks/use-toast";
import { Icon } from "@rallly/ui/icon";
import {
BarChart2Icon,
CalendarIcon,
Link2Icon,
User2Icon,
} from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { useCopyToClipboard } from "react-use";
import {
GridCard,
GridCardFooter,
GridCardHeader,
} from "@/components/grid-card";
import { GroupPollIcon } from "@/components/group-poll-icon";
import { Pill, PillList } from "@/components/pill";
import { PollStatusLabel } from "@/components/poll-status";
import { Trans } from "@/components/trans";
import { useLocalizeTime } from "@/utils/dayjs";
import { getRange } from "@/utils/get-range";
export function GroupPollCard({
status,
pollId,
title,
inviteLink,
responseCount,
dateStart,
dateEnd,
}: {
pollId: string;
title: string;
inviteLink: string;
responseCount: number;
dateStart: Date;
dateEnd: Date;
status: PollStatus;
}) {
const localizeTime = useLocalizeTime();
const { t } = useTranslation("app");
const [, copy] = useCopyToClipboard();
const { toast } = useToast();
return (
<GridCard key={pollId}>
<GridCardHeader>
<div>
<GroupPollIcon size="xs" />
</div>
<h3 className="truncate font-medium">
<Link className="focus:underline" href={`/poll/${pollId}`}>
{title}
</Link>
</h3>
</GridCardHeader>
<PillList>
<Pill>
<PollStatusLabel status={status} />
</Pill>
<Pill>
<Icon>
<CalendarIcon />
</Icon>
{getRange(
localizeTime(dateStart).toDate(),
localizeTime(dateEnd).toDate(),
)}
</Pill>
<Pill>
<Icon>
<User2Icon />
</Icon>
<Trans defaults="{count, number}" values={{ count: responseCount }} />
</Pill>
</PillList>
<GridCardFooter>
<Button
variant="ghost"
onClick={() => {
copy(inviteLink);
toast({
title: t("copiedToClipboard", {
ns: "app",
defaultValue: "Copied to clipboard",
}),
});
}}
>
<Icon>
<Link2Icon />
</Icon>
</Button>
<Button variant="ghost" asChild>
<Link href={`/poll/${pollId}`}>
<Icon>
<BarChart2Icon />
</Icon>
</Link>
</Button>
</GridCardFooter>
</GridCard>
);
}

View file

@ -0,0 +1,32 @@
import { cn } from "@rallly/ui";
import { BarChart2Icon } from "lucide-react";
export function GroupPollIcon({
size = "md",
}: {
size?: "xs" | "sm" | "md" | "lg";
}) {
return (
<div
role="img"
aria-label="Group Poll Icon"
className={cn(
"inline-flex items-center justify-center bg-gradient-to-br from-purple-500 to-violet-500 text-purple-100",
{
"size-6 rounded": size === "xs",
"size-8 rounded-md": size === "sm",
"size-9 rounded-md": size === "md",
"size-10 rounded-lg": size === "lg",
},
)}
>
<BarChart2Icon
className={cn({
"size-4": size === "sm" || size === "xs",
"size-5": size === "md",
"size-6": size === "lg",
})}
/>
</div>
);
}

View file

@ -0,0 +1,37 @@
import { cn } from "@rallly/ui";
import { heading } from "@/fonts/heading";
export function Heading({
children,
className,
}: React.PropsWithChildren<{ className?: string }>) {
return (
<h1
className={cn(
"text-xl font-semibold text-gray-900",
heading.className,
className,
)}
>
{children}
</h1>
);
}
export function Subheading({
children,
className,
}: React.PropsWithChildren<{ className?: string }>) {
return (
<h2
className={cn(
"text-base font-semibold text-gray-900",
heading.className,
className,
)}
>
{children}
</h2>
);
}

View file

@ -13,9 +13,9 @@ import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import React from "react";
import { GroupPollIcon } from "@/app/[locale]/(admin)/app-card";
import Loader from "@/app/[locale]/poll/[urlId]/skeleton";
import { LogoutButton } from "@/app/components/logout-button";
import { GroupPollIcon } from "@/components/group-poll-icon";
import { InviteDialog } from "@/components/invite-dialog";
import { LoginLink } from "@/components/login-link";
import {
@ -49,7 +49,7 @@ const Layout = ({ children }: React.PropsWithChildren) => {
const pollLink = `/poll/${poll.id}`;
const pathname = usePathname();
return (
<div className="bg-gray-100">
<div>
<div className="sticky top-0 z-30 flex flex-col justify-between gap-x-4 gap-y-2.5 border-b bg-gray-100 p-3 sm:flex-row lg:items-center lg:px-5">
<div className="flex min-w-0 items-center gap-x-2.5">
{pathname === pollLink ? (

View file

@ -0,0 +1,11 @@
export function PillList({ children }: React.PropsWithChildren) {
return <ul className="flex flex-wrap gap-1">{children}</ul>;
}
export function Pill({ children }: React.PropsWithChildren) {
return (
<span className="text-muted-foreground inline-flex items-center gap-2 rounded-md border px-1.5 py-0.5 text-sm">
{children}
</span>
);
}

View file

@ -15,7 +15,7 @@ export const PollStatusLabel = ({
return (
<span
className={cn(
"inline-flex items-center gap-x-1.5 text-sm font-medium text-pink-600",
"inline-flex items-center gap-x-1.5 text-sm",
className,
)}
>
@ -27,11 +27,11 @@ export const PollStatusLabel = ({
return (
<span
className={cn(
"inline-flex items-center gap-x-1.5 rounded-full text-sm font-medium text-gray-500",
"inline-flex items-center gap-x-1.5 rounded-full text-sm text-gray-500",
className,
)}
>
<span className="size-1.5 rounded-full bg-gray-600" />
<span className="size-1.5 rounded-full bg-gray-500" />
<Trans i18nKey="pollStatusPaused" defaults="Paused" />
</span>
@ -40,7 +40,7 @@ export const PollStatusLabel = ({
return (
<span
className={cn(
"inline-flex items-center gap-x-1.5 rounded-full text-sm font-medium text-green-600",
"inline-flex items-center gap-x-1.5 rounded-full text-sm",
className,
)}
>

View file

@ -5,7 +5,7 @@ import { useTranslation } from "@/app/i18n/client";
import { I18nNamespaces } from "../../declarations/i18next";
export const Trans = (props: {
i18nKey: keyof I18nNamespaces["app"];
i18nKey?: keyof I18nNamespaces["app"];
defaults?: string;
values?: Record<string, string | number | boolean | undefined>;
children?: React.ReactNode;

View file

@ -0,0 +1,6 @@
import { Archivo } from "next/font/google";
export const heading = Archivo({
subsets: ["latin"],
display: "swap",
});

View file

@ -0,0 +1,6 @@
import { Inter } from "next/font/google";
export const sans = Inter({
subsets: ["latin"],
display: "swap",
});

View file

@ -7,7 +7,7 @@
@apply border-border;
}
body {
@apply text-foreground bg-gray-100;
@apply text-foreground bg-gray-50;
font-feature-settings:
"rlig" 1,
"calt" 1;

View file

@ -1,4 +1,7 @@
import { prisma } from "@rallly/database";
import dayjs from "dayjs";
import { shortUrl } from "@/utils/absolute-url";
import { possiblyPublicProcedure, router } from "../trpc";
@ -14,4 +17,48 @@ export const dashboard = router({
return { activePollCount };
}),
getPending: possiblyPublicProcedure.query(async ({ ctx }) => {
const polls = await prisma.poll.findMany({
where: {
userId: ctx.user.id,
status: "live",
deleted: false,
},
select: {
id: true,
title: true,
createdAt: true,
status: true,
options: {
select: {
startTime: true,
duration: true,
},
},
_count: {
select: {
participants: true,
},
},
},
take: 3,
});
return polls.map((poll) => {
return {
id: poll.id,
title: poll.title,
createdAt: poll.createdAt,
range: {
start: poll.options[0].startTime,
end: dayjs(poll.options[poll.options.length - 1].startTime)
.add(poll.options[poll.options.length - 1].duration, "minute")
.toDate(),
},
status: poll.status,
responseCount: poll._count.participants,
inviteLink: shortUrl(`/invite/${poll.id}`),
};
});
}),
});

View file

@ -60,43 +60,10 @@ export const polls = router({
{} as Record<PollStatus, number>,
);
}),
list: possiblyPublicProcedure
.input(
z.object({
status: z.enum(["all", "live", "paused", "finalized"]),
}),
)
.query(async ({ ctx, input }) => {
return await prisma.poll.findMany({
where: {
userId: ctx.user.id,
status: input.status === "all" ? undefined : input.status,
},
orderBy: [
{
createdAt: "desc",
},
{
title: "asc",
},
],
select: {
id: true,
title: true,
location: true,
timeZone: true,
createdAt: true,
status: true,
userId: true,
participants: {
select: {
id: true,
name: true,
},
},
},
});
}),
/**
* @deprecated
*/
infiniteList: possiblyPublicProcedure
.input(
z.object({
@ -150,6 +117,74 @@ export const polls = router({
};
}),
list: possiblyPublicProcedure
.input(
z.object({
status: z.enum(["all", "live", "paused", "finalized"]),
cursor: z.string().optional(),
limit: z.number(),
}),
)
.query(async ({ ctx, input }) => {
const { cursor, limit, status } = input;
const polls = await prisma.poll.findMany({
where: {
userId: ctx.user.id,
status: status === "all" ? undefined : status,
},
orderBy: [
{
createdAt: "desc",
},
{
title: "asc",
},
],
cursor: cursor ? { id: cursor } : undefined,
take: limit + 1,
select: {
id: true,
title: true,
location: true,
timeZone: true,
createdAt: true,
status: true,
userId: true,
options: {
select: {
startTime: true,
duration: true,
},
},
participants: {
select: {
id: true,
name: true,
},
},
},
});
let nextCursor: typeof cursor | undefined = undefined;
if (polls.length > input.limit) {
const nextItem = polls.pop();
nextCursor = nextItem!.id;
}
return {
polls: polls.map((poll) => ({
...poll,
participantCount: poll.participants.length,
dateRange: {
start: poll.options[0].startTime,
end: dayjs(poll.options[poll.options.length - 1].startTime)
.add(poll.options[poll.options.length - 1].duration, "minute")
.toDate(),
},
})),
nextCursor,
};
}),
// START LEGACY ROUTES
create: possiblyPublicProcedure
.use(rateLimitMiddleware)

View file

@ -195,6 +195,14 @@ export const useDayjs = () => {
return useRequiredContext(DayjsContext);
};
export const useLocalizeTime = () => {
const { timeZone } = useDayjs();
return React.useCallback(
(date: Date) => dayjs(date).tz(timeZone),
[timeZone],
);
};
export const DayjsProvider: React.FunctionComponent<{
children?: React.ReactNode;
config?: {

View file

@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { getRange } from "./get-range";
describe("getRange", () => {
it("should return the start date if the start and end date are the same", () => {
const start = new Date("2021-01-01");
const end = new Date("2021-01-01");
expect(getRange(start, end)).toBe("01 Jan");
});
it("should return the start and end date if the start and end date are different", () => {
const start = new Date("2021-01-01");
const end = new Date("2021-01-02");
expect(getRange(start, end)).toBe("01 Jan - 02 Jan");
});
});

View file

@ -0,0 +1,17 @@
import dayjs from "dayjs";
/**
* Get a range of dates in a human readable format
* If the start and end date are the same, return the start date
* @param start The start date
* @param end The end date
* @returns A human readable range of dates
*/
export function getRange(start: Date, end: Date) {
const startDay = dayjs(start).format("DD MMM");
const endDay = dayjs(end).format("DD MMM");
if (startDay === endDay) {
return startDay;
}
return `${startDay} - ${endDay}`;
}