diff --git a/apps/web/package.json b/apps/web/package.json
index 02ee405d0..3831a589d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -44,6 +44,7 @@
"@vercel/kv": "^2.0.0",
"accept-language-parser": "^1.5.0",
"autoprefixer": "^10.4.13",
+ "calendar-link": "^2.6.0",
"class-variance-authority": "^0.7.0",
"color-hash": "^2.0.2",
"cookie": "^0.6.0",
diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json
index 889373a7b..fb859a246 100644
--- a/apps/web/public/locales/en/app.json
+++ b/apps/web/public/locales/en/app.json
@@ -263,5 +263,11 @@
"activePollCount": "{{activePollCount}} Live",
"createPoll": "Create poll",
"yearlyDiscount": "Save {amount}",
- "yearlyBillingDescription": "per year"
+ "yearlyBillingDescription": "per year",
+ "addToCalendar": "Add to Calendar",
+ "microsoft365": "Microsoft 365",
+ "outlook": "Outlook",
+ "yahoo": "Yahoo",
+ "downloadICSFile": "Download ICS File",
+ "schedulateDate": "Scheduled Date"
}
diff --git a/apps/web/public/static/google-calendar.svg b/apps/web/public/static/google-calendar.svg
new file mode 100644
index 000000000..c32c0c772
--- /dev/null
+++ b/apps/web/public/static/google-calendar.svg
@@ -0,0 +1,28 @@
+
+
+
diff --git a/apps/web/public/static/microsoft-365.svg b/apps/web/public/static/microsoft-365.svg
new file mode 100644
index 000000000..aa6e0f0e5
--- /dev/null
+++ b/apps/web/public/static/microsoft-365.svg
@@ -0,0 +1,46 @@
+
+
\ No newline at end of file
diff --git a/apps/web/public/static/outlook.svg b/apps/web/public/static/outlook.svg
new file mode 100644
index 000000000..586f39ffb
--- /dev/null
+++ b/apps/web/public/static/outlook.svg
@@ -0,0 +1,35 @@
+
+
+
+
\ No newline at end of file
diff --git a/apps/web/public/static/yahoo.svg b/apps/web/public/static/yahoo.svg
new file mode 100644
index 000000000..c3e68fe21
--- /dev/null
+++ b/apps/web/public/static/yahoo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx b/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx
index 12ffbf49d..04681d726 100644
--- a/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx
+++ b/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx
@@ -7,9 +7,9 @@ import { EventCard } from "@/components/event-card";
import { PollFooter } from "@/components/poll/poll-footer";
import { PollHeader } from "@/components/poll/poll-header";
import { ResponsiveResults } from "@/components/poll/responsive-results";
+import { ScheduledEvent } from "@/components/poll/scheduled-event";
import { useTouchBeacon } from "@/components/poll/use-touch-beacon";
import { VotingForm } from "@/components/poll/voting-form";
-import { ScheduledEvent } from "@/components/scheduled-event";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { usePoll } from "@/contexts/poll";
diff --git a/apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx b/apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx
index 3d4e4711a..52adbdae4 100644
--- a/apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx
+++ b/apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx
@@ -4,9 +4,9 @@ import { EventCard } from "@/components/event-card";
import { PollFooter } from "@/components/poll/poll-footer";
import { PollHeader } from "@/components/poll/poll-header";
import { ResponsiveResults } from "@/components/poll/responsive-results";
+import { ScheduledEvent } from "@/components/poll/scheduled-event";
import { useTouchBeacon } from "@/components/poll/use-touch-beacon";
import { VotingForm } from "@/components/poll/voting-form";
-import { ScheduledEvent } from "@/components/scheduled-event";
import { GuestPollAlert } from "./guest-poll-alert";
diff --git a/apps/web/src/components/add-to-calendar-button.tsx b/apps/web/src/components/add-to-calendar-button.tsx
new file mode 100644
index 000000000..83a5e8e3d
--- /dev/null
+++ b/apps/web/src/components/add-to-calendar-button.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import { Button } from "@rallly/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@rallly/ui/dropdown-menu";
+import { Icon } from "@rallly/ui/icon";
+import {
+ CalendarEvent,
+ google,
+ ics,
+ office365,
+ outlook,
+ yahoo,
+} from "calendar-link";
+import { DownloadIcon, PlusIcon } from "lucide-react";
+import Image from "next/image";
+
+import { Trans } from "@/components/trans";
+
+export function AddToCalendarButton({
+ title,
+ description,
+ location,
+ start,
+ duration,
+ organizer,
+ guests,
+}: {
+ title: string;
+ description?: string;
+ location?: string;
+ start: Date;
+ duration: number;
+ organizer?: {
+ name: string;
+ email: string;
+ };
+ guests?: string[];
+}) {
+ const calendarEvent: CalendarEvent = {
+ title,
+ description,
+ start,
+ allDay: duration === 0,
+ duration: duration > 0 ? [duration, "minutes"] : undefined,
+ location,
+ organizer,
+ guests,
+ busy: true,
+ };
+
+ return (
+
+
+
+
+
+ {
+ const res = google(calendarEvent);
+ window.open(res, "_blank");
+ }}
+ >
+
+ Google Calendar
+
+ {
+ const res = office365(calendarEvent);
+ window.open(res, "_blank");
+ }}
+ >
+
+
+
+ {
+ const res = outlook(calendarEvent);
+ window.open(res, "_blank");
+ }}
+ >
+
+
+
+ {
+ const res = yahoo(calendarEvent);
+ window.open(res, "_blank");
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/poll/scheduled-event.tsx b/apps/web/src/components/poll/scheduled-event.tsx
new file mode 100644
index 000000000..3c42b2aa4
--- /dev/null
+++ b/apps/web/src/components/poll/scheduled-event.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { AddToCalendarButton } from "@/components/add-to-calendar-button";
+import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
+import { useVisibleParticipants } from "@/components/participants-provider";
+import { Trans } from "@/components/trans";
+import { IfParticipantsVisible } from "@/components/visibility";
+import { usePoll } from "@/contexts/poll";
+import { useDayjs } from "@/utils/dayjs";
+
+function FinalDate({ start }: { start: Date }) {
+ const poll = usePoll();
+ const { adjustTimeZone } = useDayjs();
+ return {adjustTimeZone(start, !poll.timeZone).format("LL")};
+}
+
+function DateIcon({ start }: { start: Date }) {
+ const poll = usePoll();
+ const { adjustTimeZone } = useDayjs();
+ const d = adjustTimeZone(start, !poll.timeZone);
+ return (
+
+ );
+}
+
+function FinalTime({ start, duration }: { start: Date; duration: number }) {
+ const poll = usePoll();
+ const { adjustTimeZone, dayjs } = useDayjs();
+ if (duration === 0) {
+ return ;
+ }
+ return (
+ {`${adjustTimeZone(start, !poll.timeZone).format("LT")} - ${adjustTimeZone(dayjs(start).add(duration, "minutes"), !poll.timeZone).format(poll.timeZone ? "LT z" : "LT")}`}
+ );
+}
+
+function useAttendees() {
+ const participants = useVisibleParticipants();
+ const poll = usePoll();
+ return participants.filter((participant) =>
+ participant.votes.some(
+ (vote) =>
+ vote.optionId === poll?.event?.optionId &&
+ (vote.type === "yes" || vote.type === "ifNeedBe"),
+ ),
+ );
+}
+
+function Attendees() {
+ const attendees = useAttendees();
+
+ return ;
+}
+
+export function ScheduledEvent() {
+ const poll = usePoll();
+ const { event } = poll;
+
+ const attendees = useAttendees();
+
+ if (!event) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
!!participant.email)
+ .map((participant) => participant.email!)}
+ />
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/components/scheduled-event.tsx b/apps/web/src/components/scheduled-event.tsx
deleted file mode 100644
index 70ea42c18..000000000
--- a/apps/web/src/components/scheduled-event.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-"use client";
-
-import { Card, CardContent } from "@rallly/ui/card";
-
-import { DateIconInner } from "@/components/date-icon";
-import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
-import { useParticipants } from "@/components/participants-provider";
-import { Trans } from "@/components/trans";
-import { IfParticipantsVisible } from "@/components/visibility";
-import { usePoll } from "@/contexts/poll";
-import { useDayjs } from "@/utils/dayjs";
-
-function FinalDate({ start }: { start: Date }) {
- const poll = usePoll();
- const { adjustTimeZone } = useDayjs();
- return {adjustTimeZone(start, !poll.timeZone).format("LL")};
-}
-
-function DateIcon({ start }: { start: Date }) {
- const poll = usePoll();
- const { adjustTimeZone } = useDayjs();
- const d = adjustTimeZone(start, !poll.timeZone);
- return ;
-}
-
-function FinalTime({ start, duration }: { start: Date; duration: number }) {
- const poll = usePoll();
- const { adjustTimeZone, dayjs } = useDayjs();
- if (duration === 0) {
- return ;
- }
- return (
- {`${adjustTimeZone(start, !poll.timeZone).format("LT")} - ${adjustTimeZone(dayjs(start).add(duration, "minutes"), !poll.timeZone).format(poll.timeZone ? "LT z" : "LT")}`}
- );
-}
-
-function Attendees() {
- const { participants } = useParticipants();
- const poll = usePoll();
- const attendees = participants.filter((participant) =>
- participant.votes.some(
- (vote) =>
- vote.optionId === poll?.event?.optionId &&
- (vote.type === "yes" || vote.type === "ifNeedBe"),
- ),
- );
-
- return ;
-}
-
-export function ScheduledEvent() {
- const poll = usePoll();
- if (!poll.event) {
- return null;
- }
- return (
-
-
-
-
-
- );
-}
diff --git a/packages/ui/src/dropdown-menu.tsx b/packages/ui/src/dropdown-menu.tsx
index ade17a406..1507868bd 100644
--- a/packages/ui/src/dropdown-menu.tsx
+++ b/packages/ui/src/dropdown-menu.tsx
@@ -96,7 +96,7 @@ const DropdownMenuItem = React.forwardRef<
ref={ref}
className={cn(
"flex items-center gap-x-2.5",
- "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded px-2 py-1.5 text-sm font-medium outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
diff --git a/yarn.lock b/yarn.lock
index 5ab018ce6..7e63d592c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6811,6 +6811,14 @@ cac@^6.7.14:
resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959"
integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==
+calendar-link@^2.6.0:
+ version "2.6.0"
+ resolved "https://registry.yarnpkg.com/calendar-link/-/calendar-link-2.6.0.tgz#47cec3d9a2d0c13ac87f732898867e92ea423c98"
+ integrity sha512-ypgYoFBz2w0WkJV1m6LoO31i8F5te3/rsTdJp/ONacVmr+C4Ny7rHhvwmIZS3yrbG2abbcohn2D8DZbcFZvwfA==
+ dependencies:
+ dayjs "^1.9.3"
+ query-string "^6.13.6"
+
call-bind@^1.0.0, call-bind@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz"
@@ -7395,6 +7403,11 @@ dayjs@^1.11.7:
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz"
integrity sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==
+dayjs@^1.9.3:
+ version "1.11.12"
+ resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.12.tgz#5245226cc7f40a15bf52e0b99fd2a04669ccac1d"
+ integrity sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==
+
debounce@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/debounce/-/debounce-2.0.0.tgz#b2f914518a1481466f4edaee0b063e4d473ad549"
@@ -7441,6 +7454,11 @@ decode-named-character-reference@^1.0.0:
dependencies:
character-entities "^2.0.0"
+decode-uri-component@^0.2.0:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9"
+ integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==
+
deep-eql@^4.1.3:
version "4.1.3"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d"
@@ -8535,6 +8553,11 @@ fill-range@^7.1.1:
dependencies:
to-regex-range "^5.0.1"
+filter-obj@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b"
+ integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==
+
find-up@^4.1.0:
version "4.1.0"
resolved "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz"
@@ -12050,6 +12073,16 @@ qs@^6.11.0:
dependencies:
side-channel "^1.0.4"
+query-string@^6.13.6:
+ version "6.14.1"
+ resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.14.1.tgz#7ac2dca46da7f309449ba0f86b1fd28255b0c86a"
+ integrity sha512-XDxAeVmpfu1/6IjyT/gXHOl+S0vQ9owggJ30hhWKdHAsNPOcasn5o9BW0eejZqL2e4vMjhAxoW3jVHcD6mbcYw==
+ dependencies:
+ decode-uri-component "^0.2.0"
+ filter-obj "^1.1.0"
+ split-on-first "^1.0.0"
+ strict-uri-encode "^2.0.0"
+
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
@@ -13052,6 +13085,11 @@ spdx-license-ids@^3.0.0:
resolved "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz"
integrity sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==
+split-on-first@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f"
+ integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==
+
sprintf-js@~1.0.2:
version "1.0.3"
resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz"
@@ -13123,6 +13161,11 @@ streamsearch@^1.1.0:
resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
+strict-uri-encode@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
+ integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==
+
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"