diff --git a/.env.development b/.env.development index 0bdb018f4..a2ada3c7e 100644 --- a/.env.development +++ b/.env.development @@ -5,8 +5,11 @@ SECRET_PASSWORD=abcdef1234567890abcdef1234567890 # Example: https://example.com NEXT_PUBLIC_BASE_URL=http://localhost:3000 +# NEXTAUTH_URL should be the same as NEXT_PUBLIC_BASE_URL +NEXTAUTH_URL=http://localhost:3000 + # A connection string to your Postgres database -DATABASE_URL="postgres://postgres:postgres@rallly_db:5450/rallly" +DATABASE_URL="postgres://postgres:postgres@localhost:5450/rallly" # Required to be able to send emails SUPPORT_EMAIL=support@rallly.co diff --git a/.gitignore b/.gitignore index a7b4e7411..4a6e49685 100644 --- a/.gitignore +++ b/.gitignore @@ -26,10 +26,7 @@ yarn-error.log* # local env files .env -.env.local -.env.development.local -.env.test.local -.env.production.local +.env*.local # ts tsconfig.tsbuildinfo diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 1c9288d57..4f2cb73a0 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -46,11 +46,6 @@ const nextConfig = { destination: "/settings/profile", permanent: true, }, - { - source: "/", - destination: "/polls", - permanent: false, - }, ]; }, }; diff --git a/apps/web/package.json b/apps/web/package.json index b57070904..a903aa954 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,7 +12,7 @@ "lint:tsc": "tsc --noEmit", "i18n:scan": "i18next-scanner --config i18next-scanner.config.js", "prettier": "prettier --write ./src", - "test:integration": "playwright test", + "test:integration": "NODE_ENV=test playwright test", "test:unit": "vitest run", "test": "yarn test:unit && yarn test:e2e", "test:codegen": "playwright codegen http://localhost:3000", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 0219183cf..66a69c5fb 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,12 +1,11 @@ +import { loadEnvConfig } from "@next/env"; import { devices, PlaywrightTestConfig } from "@playwright/test"; -import dotenv from "dotenv"; -import path from "path"; const ci = process.env.CI === "true"; -dotenv.config({ path: path.resolve(__dirname, ".env.test") }); +loadEnvConfig(process.cwd()); -const port = process.env.PORT || 3000; +const port = process.env.PORT || 3002; // Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port const baseURL = `http://localhost:${port}`; diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index 886d145d3..1ef1f4117 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -15,7 +15,6 @@ "copied": "Copied", "createAnAccount": "Create an account", "createdBy": "by {name}", - "createPoll": "Create poll", "delete": "Delete", "deleteDate": "Delete date", "deletedPoll": "Deleted poll", @@ -209,9 +208,6 @@ "continueAs": "Continue as", "pageMovedDescription": "Redirecting to {newUrl}", "notRegistered": "Don't have an account? Register", - "comingSoon": "Coming Soon", - "integrations": "Integrations", - "contacts": "Contacts", "unlockFeatures": "Unlock all Pro features.", "pollStatusFinalized": "Finalized", "share": "Share", @@ -222,7 +218,6 @@ "aboutGuestDescription": "Profile settings are not available for guest users. <0>Sign in to your existing account or <1>create a new account 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.", @@ -233,14 +228,8 @@ "autoTimeZoneHelp": "Enable this setting to automatically adjust event times to each participant's local time zone.", "commentsDisabled": "Comments have been disabled", "allParticipants": "All Participants", - "host": "Host", - "created": "Created", - "pollStatus": "Status", "pollsListAll": "All", - "pollsListMine": "Mine", - "pollsListOther": "Other", "noParticipantsDescription": "Click Share to invite participants", - "back": "Back", "timeShownIn": "Times shown in {timeZone}", "pollStatusPausedDescription": "Votes cannot be submitted or edited at this time", "eventHostTitle": "Manage Access", @@ -261,5 +250,18 @@ "advancedSettingsDescription": "Hide participants, hide scores, require participant email address.", "keepPollsIndefinitely": "Keep Polls Indefinitely", "keepPollsIndefinitelyDescription": "Inactive polls will not be auto-deleted.", - "verificationCodeSentTo": "We sent a verification code to {{ email }}" + "verificationCodeSentTo": "We sent a verification code to {{ email }}", + "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", + "copyLink": "Copy Link", + "upcomingEventsEmptyStateTitle": "No Upcoming Events", + "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" } diff --git a/apps/web/src/app/[locale]/(admin)/app-card.tsx b/apps/web/src/app/[locale]/(admin)/app-card.tsx new file mode 100644 index 000000000..7e6b58290 --- /dev/null +++ b/apps/web/src/app/[locale]/(admin)/app-card.tsx @@ -0,0 +1,115 @@ +import { cn } from "@rallly/ui"; +import { BarChart2Icon } from "lucide-react"; +import React from "react"; + +import { Squircle } from "@/app/components/squircle"; + +export function AppCard({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function AppCardContent({ children }: { children?: React.ReactNode }) { + return
{children}
; +} + +export function GroupPollIcon({ + size = "md", +}: { + size?: "xs" | "sm" | "md" | "lg"; +}) { + return ( + + + + ); +} + +export function AppCardIcon({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function AppCardName({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) { + return

{children}

; +} + +export function AppCardDescription({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) { + return ( +

+ {children} +

+ ); +} + +export function AppCardFooter({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) { + return
{children}
; +} diff --git a/apps/web/src/app/[locale]/(admin)/dashboard.tsx b/apps/web/src/app/[locale]/(admin)/dashboard.tsx new file mode 100644 index 000000000..94432c595 --- /dev/null +++ b/apps/web/src/app/[locale]/(admin)/dashboard.tsx @@ -0,0 +1,75 @@ +"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 { 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(); + + if (!data) { + return ; + } + + return ( +
+
+ + + + + +
+ + + + + + +
+
+ +
+ + + +
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(admin)/events/event-list.tsx b/apps/web/src/app/[locale]/(admin)/events/event-list.tsx new file mode 100644 index 000000000..fdc35632c --- /dev/null +++ b/apps/web/src/app/[locale]/(admin)/events/event-list.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Card, CardContent } from "@rallly/ui/card"; +import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import dayjs from "dayjs"; + +import { ScheduledEvent } from "@/app/[locale]/(admin)/events/types"; +import { Trans } from "@/components/trans"; +import { generateGradient } from "@/utils/color-hash"; +import { useDayjs } from "@/utils/dayjs"; + +export function EventList({ data }: { data: ScheduledEvent[] }) { + const table = useReactTable({ + data, + columns: [], + getCoreRowModel: getCoreRowModel(), + }); + + const { adjustTimeZone } = useDayjs(); + return ( + + + + ); +} diff --git a/apps/web/src/app/[locale]/(admin)/events/layout.tsx b/apps/web/src/app/[locale]/(admin)/events/layout.tsx new file mode 100644 index 000000000..626ea6e8e --- /dev/null +++ b/apps/web/src/app/[locale]/(admin)/events/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }: { children?: React.ReactNode }) { + return
{children}
; +} diff --git a/apps/web/src/app/[locale]/(admin)/new/page.tsx b/apps/web/src/app/[locale]/(admin)/events/page.tsx similarity index 52% rename from apps/web/src/app/[locale]/(admin)/new/page.tsx rename to apps/web/src/app/[locale]/(admin)/events/page.tsx index f37b7f5fe..901e0cbd0 100644 --- a/apps/web/src/app/[locale]/(admin)/new/page.tsx +++ b/apps/web/src/app/[locale]/(admin)/events/page.tsx @@ -1,34 +1,28 @@ -import { Button } from "@rallly/ui/button"; -import { Icon } from "@rallly/ui/icon"; -import { ArrowLeftIcon } from "lucide-react"; -import Link from "next/link"; -import { Trans } from "react-i18next/TransWithoutContext"; - +import { UserScheduledEvents } from "@/app/[locale]/(admin)/events/user-scheduled-events"; import { Params } from "@/app/[locale]/types"; import { PageContainer, PageContent, PageHeader, + PageTitle, } from "@/app/components/page-layout"; import { getTranslation } from "@/app/i18n"; -import { CreatePoll } from "@/components/create-poll"; export default async function Page({ params }: { params: Params }) { const { t } = await getTranslation(params.locale); return ( - +
+ + {t("events", { + defaultValue: "Events", + })} + +
- +
); @@ -41,6 +35,8 @@ export async function generateMetadata({ }) { const { t } = await getTranslation(params.locale); return { - title: t("newPoll"), + title: t("events", { + defaultValue: "Events", + }), }; } diff --git a/apps/web/src/app/[locale]/(admin)/events/past-events.tsx b/apps/web/src/app/[locale]/(admin)/events/past-events.tsx new file mode 100644 index 000000000..7bbac0727 --- /dev/null +++ b/apps/web/src/app/[locale]/(admin)/events/past-events.tsx @@ -0,0 +1,47 @@ +"use client"; +import { CalendarPlusIcon } from "lucide-react"; + +import { EventList } from "@/app/[locale]/(admin)/events/event-list"; +import { + EmptyState, + EmptyStateDescription, + EmptyStateIcon, + EmptyStateTitle, +} from "@/app/components/empty-state"; +import { Spinner } from "@/components/spinner"; +import { Trans } from "@/components/trans"; +import { trpc } from "@/utils/trpc/client"; + +export function PastEvents() { + const { data } = trpc.scheduledEvents.list.useQuery({ + period: "past", + }); + + if (!data) { + return ; + } + + if (data.length === 0) { + return ( + + + + + + + + + + + + ); + } + + return ; +} diff --git a/apps/web/src/app/[locale]/(admin)/events/types.ts b/apps/web/src/app/[locale]/(admin)/events/types.ts new file mode 100644 index 000000000..92f1411b7 --- /dev/null +++ b/apps/web/src/app/[locale]/(admin)/events/types.ts @@ -0,0 +1,8 @@ +export type ScheduledEvent = { + id: string; + title: string; + start: Date; + duration: number; + timeZone: string | null; + participants: { name: string }[]; +}; diff --git a/apps/web/src/app/[locale]/(admin)/events/upcoming-events.tsx b/apps/web/src/app/[locale]/(admin)/events/upcoming-events.tsx new file mode 100644 index 000000000..437212b1c --- /dev/null +++ b/apps/web/src/app/[locale]/(admin)/events/upcoming-events.tsx @@ -0,0 +1,45 @@ +"use client"; +import { CalendarPlusIcon } from "lucide-react"; + +import { EventList } from "@/app/[locale]/(admin)/events/event-list"; +import { + EmptyState, + EmptyStateDescription, + EmptyStateIcon, + EmptyStateTitle, +} from "@/app/components/empty-state"; +import { Spinner } from "@/components/spinner"; +import { Trans } from "@/components/trans"; +import { trpc } from "@/utils/trpc/client"; + +export function UpcomingEvents() { + const { data } = trpc.scheduledEvents.list.useQuery({ period: "upcoming" }); + + if (!data) { + return ; + } + + if (data.length === 0) { + return ( + + + + + + + + + + + + ); + } + + return ; +} diff --git a/apps/web/src/app/[locale]/(admin)/events/user-scheduled-events.tsx b/apps/web/src/app/[locale]/(admin)/events/user-scheduled-events.tsx new file mode 100644 index 000000000..87ed31642 --- /dev/null +++ b/apps/web/src/app/[locale]/(admin)/events/user-scheduled-events.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { RadioCards, RadioCardsItem } from "@rallly/ui/radio-pills"; +import { useSearchParams } from "next/navigation"; +import { z } from "zod"; + +import { PastEvents } from "@/app/[locale]/(admin)/events/past-events"; +import { Trans } from "@/components/trans"; + +import { UpcomingEvents } from "./upcoming-events"; + +const eventPeriodSchema = z.enum(["upcoming", "past"]).catch("upcoming"); + +export function UserScheduledEvents() { + const searchParams = useSearchParams(); + const period = eventPeriodSchema.parse(searchParams?.get("period")); + + return ( +
+
+ { + const newParams = new URLSearchParams(searchParams?.toString()); + newParams.set("period", value); + window.history.pushState(null, "", `?${newParams.toString()}`); + }} + > + + + + + + + +
+
+ {period === "upcoming" && } + {period === "past" && } +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(admin)/layout.tsx b/apps/web/src/app/[locale]/(admin)/layout.tsx index b68bd73f8..cb36d708a 100644 --- a/apps/web/src/app/[locale]/(admin)/layout.tsx +++ b/apps/web/src/app/[locale]/(admin)/layout.tsx @@ -11,11 +11,10 @@ export default async function Layout({ children: React.ReactNode; }) { return ( -
- +
-
+
{children}
+
+ +
); } diff --git a/apps/web/src/app/[locale]/(admin)/menu/page.tsx b/apps/web/src/app/[locale]/(admin)/menu/page.tsx index c1f69cc82..7623c0f99 100644 --- a/apps/web/src/app/[locale]/(admin)/menu/page.tsx +++ b/apps/web/src/app/[locale]/(admin)/menu/page.tsx @@ -1,9 +1,27 @@ -import { Sidebar } from "@/app/[locale]/(admin)/sidebar"; +import { Trans } from "react-i18next/TransWithoutContext"; -export default function Page() { +import { Sidebar } from "@/app/[locale]/(admin)/sidebar"; +import { Params } from "@/app/[locale]/types"; +import { + PageContainer, + PageContent, + PageHeader, + PageTitle, +} from "@/app/components/page-layout"; +import { getTranslation } from "@/app/i18n"; + +export default async function Page({ params }: { params: Params }) { + const { t } = await getTranslation(params.locale); return ( -
- -
+ + + + + + + + + + ); } diff --git a/apps/web/src/app/[locale]/(admin)/mobile-navigation.tsx b/apps/web/src/app/[locale]/(admin)/mobile-navigation.tsx index a2890c7ff..14d98faad 100644 --- a/apps/web/src/app/[locale]/(admin)/mobile-navigation.tsx +++ b/apps/web/src/app/[locale]/(admin)/mobile-navigation.tsx @@ -1,27 +1,77 @@ "use client"; +import { Slot } from "@radix-ui/react-slot"; +import { cn } from "@rallly/ui"; import { Button } from "@rallly/ui/button"; +import { + BarChart2Icon, + CalendarIcon, + HomeIcon, + MenuIcon, + PlusIcon, +} from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { MobileMenuButton } from "@/app/[locale]/(admin)/menu/menu-button"; -import { CurrentUserAvatar } from "@/components/user"; +function MobileNavigationIcon({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function MobileNavigationItem({ + children, + href, +}: { + href: string; + children?: React.ReactNode; +}) { + const pathname = usePathname(); + return ( + + {children} + + ); +} export function MobileNavigation() { - const pathname = usePathname(); - - const isOpen = pathname === "/menu"; - return ( -
- -
- -
+
+ + + + + + + + + + + + + + + + + + + + +
); } diff --git a/apps/web/src/app/[locale]/(admin)/page.tsx b/apps/web/src/app/[locale]/(admin)/page.tsx new file mode 100644 index 000000000..fd7aab034 --- /dev/null +++ b/apps/web/src/app/[locale]/(admin)/page.tsx @@ -0,0 +1,49 @@ +import { HomeIcon } from "lucide-react"; +import { Trans } from "react-i18next/TransWithoutContext"; + +import Dashboard from "@/app/[locale]/(admin)/dashboard"; +import { Params } from "@/app/[locale]/types"; +import { + PageContainer, + PageContent, + PageHeader, + PageIcon, + PageTitle, +} from "@/app/components/page-layout"; +import { getTranslation } from "@/app/i18n"; + +export default async function Page({ params }: { params: Params }) { + const { t } = await getTranslation(params.locale); + return ( +
+ + +
+ + + + + + +
+
+ + + +
+
+ ); +} + +export async function generateMetadata({ + params, +}: { + params: { locale: string }; +}) { + const { t } = await getTranslation(params.locale); + return { + title: t("home", { + defaultValue: "Home", + }), + }; +} diff --git a/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/columns.tsx b/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/columns.tsx deleted file mode 100644 index 87e6e4f5e..000000000 --- a/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/columns.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { PollStatus } from "@rallly/database"; -import { Button } from "@rallly/ui/button"; -import { Icon } from "@rallly/ui/icon"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip"; -import { createColumnHelper } from "@tanstack/react-table"; -import dayjs from "dayjs"; -import { BarChart2Icon } from "lucide-react"; -import Link from "next/link"; -import React from "react"; -import { useTranslation } from "react-i18next"; - -import { PollStatusBadge } from "@/components/poll-status"; -import { Trans } from "@/components/trans"; -import { UserAvatar } from "@/components/user"; -import { useUser } from "@/components/user-provider"; -import { useDayjs } from "@/utils/dayjs"; - -export type PollData = { - id: string; - status: PollStatus; - title: string; - createdAt: Date; - participants: { name: string }[]; - timeZone: string | null; - userId: string; - user: { - name: string; - id: string; - } | null; - event: { - start: Date; - duration: number; - } | null; -}; - -const columnHelper = createColumnHelper(); - -export const usePollColumns = () => { - const { t } = useTranslation(); - const { adjustTimeZone } = useDayjs(); - const { user } = useUser(); - return React.useMemo( - () => [ - columnHelper.accessor("title", { - id: "title", - header: t("title"), - size: 400, - cell: ({ row }) => { - return ( - - - - - {row.original.title} - - ); - }, - }), - columnHelper.accessor("user", { - header: () => ( -
- {t("host", { defaultValue: "Host" })} -
- ), - size: 75, - cell: ({ getValue }) => { - const isYou = getValue()?.id === user.id; - return ( -
- - - - - - {isYou ? t("you") : getValue()?.name ?? t("guest")} - - -
- ); - }, - }), - columnHelper.accessor("createdAt", { - header: () => , - cell: ({ row }) => { - const { createdAt } = row.original; - return ( -

- -

- ); - }, - }), - columnHelper.accessor("status", { - header: t("pollStatus", { defaultValue: "Status" }), - cell: ({ row }) => { - return ( -
- {row.original.event ? ( - - - - - - {adjustTimeZone( - row.original.event.start, - !row.original.timeZone, - ).format(row.original.event.duration === 0 ? "LL" : "LLLL")} - - - ) : ( - - )} -
- ); - }, - }), - - columnHelper.accessor("participants", { - header: () => null, - cell: ({ row }) => { - if (row.original.userId !== user.id) { - return null; - } - - return ( - - ); - }, - }), - ], - [adjustTimeZone, t, user.id], - ); -}; diff --git a/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/loading.tsx b/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/loading.tsx deleted file mode 100644 index 351081a7f..000000000 --- a/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/loading.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Loader() { - return null; -} diff --git a/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/page.tsx b/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/page.tsx deleted file mode 100644 index fc3dd94a5..000000000 --- a/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { PollsList } from "@/app/[locale]/(admin)/polls/[[...list]]/polls-list"; -import { Params } from "@/app/[locale]/types"; -import { getTranslation } from "@/app/i18n"; - -interface PageParams extends Params { - list?: string; -} - -export default async function Page({ params }: { params: PageParams }) { - const list = params.list ? params.list[0] : "all"; - return ; -} - -export async function generateMetadata({ - params, -}: { - params: { locale: string }; -}) { - const { t } = await getTranslation(params.locale); - return { - title: t("polls"), - }; -} diff --git a/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/polls-folders.tsx b/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/polls-folders.tsx deleted file mode 100644 index e19e95bdb..000000000 --- a/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/polls-folders.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import { - ResponsiveMenu, - ResponsiveMenuItem, -} from "@/app/components/responsive-menu"; -import { Trans } from "@/components/trans"; - -export function PollFolders() { - return ( - - - - - - - - - - - - ); -} diff --git a/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/polls-list.tsx b/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/polls-list.tsx deleted file mode 100644 index aef979858..000000000 --- a/apps/web/src/app/[locale]/(admin)/polls/[[...list]]/polls-list.tsx +++ /dev/null @@ -1,137 +0,0 @@ -"use client"; -import { Button } from "@rallly/ui/button"; -import { Card } from "@rallly/ui/card"; -import { Icon } from "@rallly/ui/icon"; -import { PaginationState } from "@tanstack/react-table"; -import { BarChart2Icon, PlusIcon } from "lucide-react"; -import Link from "next/link"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import React from "react"; -import { useTranslation } from "react-i18next"; - -import { - EmptyState, - EmptyStateDescription, - EmptyStateFooter, - EmptyStateIcon, - EmptyStateTitle, -} from "@/app/components/empty-state"; -import { Spinner } from "@/components/spinner"; -import { Table } from "@/components/table"; -import { Trans } from "@/components/trans"; -import { trpc } from "@/utils/trpc/client"; - -import { PollData, usePollColumns } from "./columns"; - -function PollsEmptyState() { - const { t } = useTranslation(); - return ( - - - - - - {t("noPolls", { defaultValue: "No Polls" })} - - {t("noPollsDescription")} - - - - - ); -} - -export function PollsList({ list }: { list?: string }) { - const searchParams = useSearchParams(); - const pathname = usePathname(); - - const router = useRouter(); - const pagination = React.useMemo( - () => ({ - pageIndex: (Number(searchParams?.get("page")) || 1) - 1, - pageSize: Number(searchParams?.get("pageSize")) || 10, - }), - [searchParams], - ); - - // const sorting = React.useMemo(() => { - // const id = searchParams?.get("sort"); - // const desc = searchParams?.get("desc"); - // if (!id) { - // return [{ id: "createdAt", desc: true }]; - // } - // return [{ id, desc: desc === "desc" }]; - // }, [searchParams]); - - const { data, isFetching } = trpc.polls.paginatedList.useQuery( - { list, pagination }, - { - staleTime: Infinity, - cacheTime: Infinity, - keepPreviousData: true, - }, - ); - const columns = usePollColumns(); - - if (!data) { - // return a table using components - return ( -
- -
- ); - } - - return ( -
- {data.total ? ( - - { - // const newSorting = - // typeof updater === "function" ? updater(sorting) : updater; - - // const current = new URLSearchParams(searchParams ?? undefined); - // const sortColumn = newSorting[0]; - // if (sortColumn === undefined) { - // current.delete("sort"); - // current.delete("desc"); - // } else { - // current.set("sort", sortColumn.id); - // current.set("desc", sortColumn.desc ? "desc" : "asc"); - // } - // // current.set("pageSize", String(newPagination.pageSize)); - // router.replace(`${pathname}?${current.toString()}`); - // }} - 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.replace(`${pathname}?${current.toString()}`); - }} - columns={columns} - /> - - ) : ( - - )} - - ); -} diff --git a/apps/web/src/app/[locale]/(admin)/polls/layout.tsx b/apps/web/src/app/[locale]/(admin)/polls/layout.tsx deleted file mode 100644 index 245d57936..000000000 --- a/apps/web/src/app/[locale]/(admin)/polls/layout.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Button } from "@rallly/ui/button"; -import { Icon } from "@rallly/ui/icon"; -import { PlusIcon } from "lucide-react"; -import Link from "next/link"; - -import { PollFolders } from "@/app/[locale]/(admin)/polls/[[...list]]/polls-folders"; -import { Params } from "@/app/[locale]/types"; -import { - PageContainer, - PageContent, - PageHeader, - PageTitle, -} from "@/app/components/page-layout"; -import { getTranslation } from "@/app/i18n"; - -interface PageParams extends Params { - list?: string; -} - -export default async function Layout({ - children, - params, -}: { - children?: React.ReactNode; - params: PageParams; -}) { - const { t } = await getTranslation(params.locale); - return ( - - -
- {t("polls")} - -
-
- - - {children} - -
- ); -} diff --git a/apps/web/src/app/[locale]/(admin)/polls/page.tsx b/apps/web/src/app/[locale]/(admin)/polls/page.tsx new file mode 100644 index 000000000..497bcd49a --- /dev/null +++ b/apps/web/src/app/[locale]/(admin)/polls/page.tsx @@ -0,0 +1,53 @@ +import { BarChart2Icon } from "lucide-react"; + +import { UserPolls } from "@/app/[locale]/(admin)/polls/user-polls"; +import { Params } from "@/app/[locale]/types"; +import { + PageContainer, + PageContent, + PageHeader, + PageIcon, + PageTitle, +} from "@/app/components/page-layout"; +import { getTranslation } from "@/app/i18n"; + +export default async function Page({ + params, +}: { + params: Params; + children?: React.ReactNode; +}) { + const { t } = await getTranslation(params.locale); + return ( + + +
+ + + + + {t("polls", { + defaultValue: "Polls", + })} + +
+
+ + + +
+ ); +} + +export async function generateMetadata({ + params, +}: { + params: { locale: string }; +}) { + const { t } = await getTranslation(params.locale); + return { + title: t("polls", { + defaultValue: "Polls", + }), + }; +} diff --git a/apps/web/src/app/[locale]/(admin)/polls/user-polls.tsx b/apps/web/src/app/[locale]/(admin)/polls/user-polls.tsx new file mode 100644 index 000000000..795c53f9a --- /dev/null +++ b/apps/web/src/app/[locale]/(admin)/polls/user-polls.tsx @@ -0,0 +1,237 @@ +"use client"; +import { PollStatus } from "@rallly/database"; +import { cn } from "@rallly/ui"; +import { Icon } from "@rallly/ui/icon"; +import { RadioCards, RadioCardsItem } from "@rallly/ui/radio-pills"; +import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import dayjs from "dayjs"; +import { CalendarPlusIcon, CheckIcon, LinkIcon, UserIcon } from "lucide-react"; +import Link from "next/link"; +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 { Spinner } from "@/components/spinner"; +import { Trans } from "@/components/trans"; +import { trpc } from "@/utils/trpc/client"; + +function PollCount({ count }: { count?: number }) { + return {count || 0}; +} + +function FilteredPolls({ status }: { status: PollStatus }) { + const { data, isFetching } = trpc.polls.list.useQuery( + { + status, + }, + { + keepPreviousData: true, + }, + ); + + if (!data) { + return ; + } + + return ( +
+ +
+ ); +} + +function PollStatusMenu({ + status, + onStatusChange, +}: { + status?: PollStatus; + onStatusChange?: (status: PollStatus) => void; +}) { + const { data: countByStatus, isFetching } = + trpc.polls.getCountByStatus.useQuery(); + + if (!countByStatus) { + return null; + } + + return ( + + + + + + + + + + + + + + {isFetching && } + + ); +} + +function useQueryParam(name: string) { + const searchParams = useSearchParams(); + return [ + searchParams?.get(name), + function (value: string) { + const newParams = new URLSearchParams(searchParams?.toString()); + newParams.set(name, value); + window.history.replaceState(null, "", `?${newParams.toString()}`); + }, + ] as const; +} + +const pollStatusSchema = z.enum(["live", "paused", "finalized"]).catch("live"); + +const pollStatusQueryKey = "status"; + +export function UserPolls() { + const [pollStatus, setPollStatus] = useQueryParam(pollStatusQueryKey); + const parsedPollStatus = pollStatusSchema.parse(pollStatus); + + return ( +
+ + +
+ ); +} + +function CopyLinkButton({ pollId }: { pollId: string }) { + const [, copy] = useCopyToClipboard(); + const [didCopy, setDidCopy] = React.useState(false); + + if (didCopy) { + return ( +
+ + +
+ ); + } + + return ( + + ); +} + +function ParticipantCount({ count }: { count: number }) { + return ( +
+ + + + {count} +
+ ); +} + +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 ( + + + + + + + + + + + + ); + } + + return ( +
+ {table.getRowModel().rows.map((row) => ( +
+
+
+ + +
+
+

+ + {row.original.title} +

+ +
+
+
+ +

+ +

+
+
+ ))} +
+ ); +} diff --git a/apps/web/src/app/[locale]/(admin)/settings/layout.tsx b/apps/web/src/app/[locale]/(admin)/settings/layout.tsx index 9fe77296d..3d621b6ae 100644 --- a/apps/web/src/app/[locale]/(admin)/settings/layout.tsx +++ b/apps/web/src/app/[locale]/(admin)/settings/layout.tsx @@ -22,8 +22,8 @@ export default async function ProfileLayout({ {t("settings")} - -
+ +
{children}
diff --git a/apps/web/src/app/[locale]/(admin)/settings/settings-menu.tsx b/apps/web/src/app/[locale]/(admin)/settings/settings-menu.tsx index 17edc69e4..a49dd8a10 100644 --- a/apps/web/src/app/[locale]/(admin)/settings/settings-menu.tsx +++ b/apps/web/src/app/[locale]/(admin)/settings/settings-menu.tsx @@ -4,35 +4,32 @@ import { Icon } from "@rallly/ui/icon"; import { CreditCardIcon, Settings2Icon, UserIcon } from "lucide-react"; import { Trans } from "react-i18next"; -import { - ResponsiveMenu, - ResponsiveMenuItem, -} from "@/app/components/responsive-menu"; +import { TabMenu, TabMenuItem } from "@/app/components/tab-menu"; import { IfCloudHosted } from "@/contexts/environment"; export function SettingsMenu() { return ( - - + + - - + + - + - + - + - + ); } diff --git a/apps/web/src/app/[locale]/(admin)/sidebar.tsx b/apps/web/src/app/[locale]/(admin)/sidebar.tsx index 99c52af75..056362422 100644 --- a/apps/web/src/app/[locale]/(admin)/sidebar.tsx +++ b/apps/web/src/app/[locale]/(admin)/sidebar.tsx @@ -6,16 +6,14 @@ import { Icon } from "@rallly/ui/icon"; import { ArrowUpRightIcon, BarChart2Icon, - BlocksIcon, - BookMarkedIcon, CalendarIcon, ChevronRightIcon, + HomeIcon, LifeBuoyIcon, LogInIcon, PlusIcon, Settings2Icon, SparklesIcon, - UsersIcon, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -46,18 +44,12 @@ function NavItem({ target={target} 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-2.5 rounded-md px-3 py-2 text-sm font-semibold leading-6", + ? "text-foreground bg-gray-200" + : "text-muted-foreground border-transparent hover:bg-gray-200 focus:bg-gray-300", + "group flex items-center gap-x-3 rounded-md px-3 py-2 text-sm font-semibold leading-6", )} > -