mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-14 17:36:49 +02:00
Show full list of participants on mobile view (#193)
This commit is contained in:
parent
53fa823857
commit
4ce5a1990e
23 changed files with 340 additions and 209 deletions
|
@ -3,7 +3,14 @@ import * as React from "react";
|
||||||
|
|
||||||
import SpinnerIcon from "@/components/icons/spinner.svg";
|
import SpinnerIcon from "@/components/icons/spinner.svg";
|
||||||
|
|
||||||
export interface ButtonProps {
|
export interface ButtonProps
|
||||||
|
extends Omit<
|
||||||
|
React.DetailedHTMLProps<
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
HTMLButtonElement
|
||||||
|
>,
|
||||||
|
"type" | "ref"
|
||||||
|
> {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
|
@ -28,7 +28,7 @@ const DateCard: React.VoidFunctionComponent<DateCardProps> = ({
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative -mt-2 mb-[-1px] text-xs text-slate-400">
|
<div className="relative -mt-2 mb-[-1px] text-xs text-slate-400">
|
||||||
<span className="relative z-10 inline-block px-1 after:absolute after:left-0 after:top-[7px] after:-z-10 after:inline-block after:w-full after:border-t after:border-white after:content-['']">
|
<span className="relative z-10 inline-block px-1 after:absolute after:left-0 after:top-[7px] after:-z-10 after:inline-block after:w-full after:border-t after:border-white after:content-['']">
|
||||||
{dow}
|
{dow.substring(0, 3)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="-mb-1 text-center text-lg text-rose-500">{day}</div>
|
<div className="-mb-1 text-center text-lg text-rose-500">{day}</div>
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { format } from "date-fns";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import DateCard from "../date-card";
|
|
||||||
import { ScoreSummary } from "../poll/score-summary";
|
import { ScoreSummary } from "../poll/score-summary";
|
||||||
import UserAvatar from "../poll/user-avatar";
|
import UserAvatar from "../poll/user-avatar";
|
||||||
import VoteIcon from "../poll/vote-icon";
|
import VoteIcon from "../poll/vote-icon";
|
||||||
|
@ -64,13 +63,19 @@ const PollDemo: React.VoidFunctionComponent = () => {
|
||||||
className="shrink-0 space-y-3 py-2 pt-3 text-center transition-colors"
|
className="shrink-0 space-y-3 py-2 pt-3 text-center transition-colors"
|
||||||
style={{ width: 100 }}
|
style={{ width: 100 }}
|
||||||
>
|
>
|
||||||
<DateCard
|
|
||||||
day={format(d, "dd")}
|
|
||||||
dow={format(d, "E")}
|
|
||||||
month={format(d, "MMM")}
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<ScoreSummary yesScore={score} compact={true} />
|
<div className="font-semibold leading-9">
|
||||||
|
<div className="text-sm uppercase text-slate-400">
|
||||||
|
{format(d, "E")}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl">{format(d, "dd")}</div>
|
||||||
|
<div className="text-xs font-medium uppercase text-slate-400/75">
|
||||||
|
{format(d, "MMM")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ScoreSummary yesScore={score} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M14.06 3.44a1.5 1.5 0 0 1 0 2.12l-7 7a1.5 1.5 0 0 1-2.12 0l-3-3a1.5 1.5 0 1 1 2.12-2.12L6 9.378l5.94-5.94a1.5 1.5 0 0 1 2.12 0Z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 270 B |
|
@ -1,3 +1,3 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd" d="M3.433 2.099a1 1 0 0 1 .468 1.334C2.986 5.34 2.57 6.704 2.561 8.007c-.008 1.298.387 2.655 1.333 4.546a1 1 0 0 1-1.788.895C1.095 11.428.55 9.742.56 7.994c.012-1.74.575-3.422 1.538-5.427A1 1 0 0 1 3.433 2.1Zm8.274 3.194a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L7 8.586l3.293-3.293a1 1 0 0 1 1.414 0Zm.392-1.86a1 1 0 1 1 1.803-.866c.963 2.005 1.526 3.686 1.537 5.427.011 1.748-.533 3.434-1.545 5.454a1 1 0 0 1-1.788-.896c.947-1.89 1.341-3.247 1.333-4.545-.008-1.303-.424-2.667-1.34-4.574Z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.106-7.553c-.31-.62-.586-1.014-.813-1.24C12.116 9.03 12.02 9.004 12 9c-.02.004-.116.03-.293.207-.226.226-.503.62-.812 1.24-.357.714-.747 1.32-1.188 1.76C9.265 12.65 8.692 13 8 13c-.692 0-1.265-.35-1.707-.793-.44-.44-.83-1.046-1.187-1.76a1 1 0 1 1 1.789-.894c.31.62.586 1.013.812 1.24.177.177.273.204.293.207.02-.004.116-.03.293-.207.226-.226.503-.62.813-1.24.357-.714.747-1.32 1.187-1.76C10.735 7.35 11.308 7 12 7c.692 0 1.265.35 1.707.793.44.44.83 1.046 1.188 1.76a1 1 0 1 1-1.79.894ZM11.996 9H12h-.002Zm.005 0h.003-.003Zm-3.997 2h-.003.003ZM8 11h-.003H8Z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
Before Width: | Height: | Size: 655 B After Width: | Height: | Size: 738 B |
3
src/components/icons/question-mark.svg
Normal file
3
src/components/icons/question-mark.svg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M4 5.313c0-.502.163-1.01.488-1.522.325-.518.8-.946 1.424-1.284C6.536 2.17 7.264 2 8.096 2c.773 0 1.456.143 2.048.428.592.28 1.048.663 1.368 1.15.325.485.488 1.014.488 1.584 0 .45-.093.843-.28 1.181-.181.339-.4.632-.656.88-.25.243-.704.655-1.36 1.237a5.696 5.696 0 0 0-.44.436 1.676 1.676 0 0 0-.368.65c-.027.1-.07.28-.128.539-.101.55-.419.824-.952.824a.992.992 0 0 1-.704-.27c-.187-.18-.28-.446-.28-.8 0-.444.07-.827.208-1.15.139-.327.323-.612.552-.855.23-.249.539-.542.928-.88.341-.296.587-.518.736-.666a2.2 2.2 0 0 0 .384-.507c.107-.185.16-.386.16-.603 0-.422-.16-.78-.48-1.07-.315-.29-.723-.436-1.224-.436-.587 0-1.019.148-1.296.444-.277.29-.512.722-.704 1.292-.181.597-.525.896-1.032.896a1.04 1.04 0 0 1-.76-.31C4.101 5.785 4 5.557 4 5.315ZM7.904 14a1.29 1.29 0 0 1-.856-.31c-.24-.21-.36-.504-.36-.879 0-.333.117-.613.352-.84.235-.227.523-.34.864-.34.336 0 .619.113.848.34.23.227.344.507.344.84 0 .37-.12.66-.36.872-.24.211-.517.317-.832.317Z" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1 KiB |
|
@ -1,4 +1,4 @@
|
||||||
import { Participant, Vote } from "@prisma/client";
|
import { Participant, Vote, VoteType } from "@prisma/client";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ import { useRequiredContext } from "./use-required-context";
|
||||||
const ParticipantsContext =
|
const ParticipantsContext =
|
||||||
React.createContext<{
|
React.createContext<{
|
||||||
participants: Array<Participant & { votes: Vote[] }>;
|
participants: Array<Participant & { votes: Vote[] }>;
|
||||||
|
getParticipants: (optionId: string, voteType: VoteType) => Participant[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
export const useParticipants = () => {
|
export const useParticipants = () => {
|
||||||
|
@ -26,6 +27,20 @@ export const ParticipantsProvider: React.VoidFunctionComponent<{
|
||||||
{ pollId },
|
{ pollId },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const getParticipants = (
|
||||||
|
optionId: string,
|
||||||
|
voteType: VoteType,
|
||||||
|
): Participant[] => {
|
||||||
|
if (!participants) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return participants.filter((participant) => {
|
||||||
|
return participant.votes.some((vote) => {
|
||||||
|
return vote.optionId === optionId && vote.type === voteType;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// TODO (Luke Vella) [2022-05-18]: Add mutations here
|
// TODO (Luke Vella) [2022-05-18]: Add mutations here
|
||||||
|
|
||||||
if (!participants) {
|
if (!participants) {
|
||||||
|
@ -33,7 +48,7 @@ export const ParticipantsProvider: React.VoidFunctionComponent<{
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParticipantsContext.Provider value={{ participants }}>
|
<ParticipantsContext.Provider value={{ participants, getParticipants }}>
|
||||||
{children}
|
{children}
|
||||||
</ParticipantsContext.Provider>
|
</ParticipantsContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -124,7 +124,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
||||||
participantsByOptionId[option.id] = (participants ?? []).filter(
|
participantsByOptionId[option.id] = (participants ?? []).filter(
|
||||||
(participant) =>
|
(participant) =>
|
||||||
participant.votes.some(
|
participant.votes.some(
|
||||||
({ type, optionId }) => optionId === option.id && type !== "no",
|
({ type, optionId }) => optionId === option.id && type === "yes",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -199,19 +199,17 @@ const PollPage: NextPage = () => {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 px-4 py-2 sm:justify-end">
|
<div className="flex items-center space-x-3 px-4 py-2 sm:justify-end">
|
||||||
<span className="text-xs font-semibold text-slate-500">
|
<span className="text-xs font-semibold text-slate-500">Key:</span>
|
||||||
Legend:
|
<span className="inline-flex items-center space-x-1">
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center space-x-2">
|
|
||||||
<VoteIcon type="yes" />
|
<VoteIcon type="yes" />
|
||||||
<span className="text-xs text-slate-500">Yes</span>
|
<span className="text-xs text-slate-500">Yes</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center space-x-2">
|
<span className="inline-flex items-center space-x-1">
|
||||||
<VoteIcon type="ifNeedBe" />
|
<VoteIcon type="ifNeedBe" />
|
||||||
<span className="text-xs text-slate-500">If need be</span>
|
<span className="text-xs text-slate-500">If need be</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="inline-flex items-center space-x-2">
|
<span className="inline-flex items-center space-x-1">
|
||||||
<VoteIcon type="no" />
|
<VoteIcon type="no" />
|
||||||
<span className="text-xs text-slate-500">No</span>
|
<span className="text-xs text-slate-500">No</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import DateCard from "@/components/date-card";
|
|
||||||
import { usePoll } from "@/components/poll-context";
|
import { usePoll } from "@/components/poll-context";
|
||||||
|
|
||||||
import { ScoreSummary } from "../score-summary";
|
import { ScoreSummary } from "../score-summary";
|
||||||
|
@ -49,11 +48,15 @@ const PollHeader: React.VoidFunctionComponent = () => {
|
||||||
onMouseOut={() => setActiveOptionId(null)}
|
onMouseOut={() => setActiveOptionId(null)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<DateCard
|
<div className="font-semibold leading-9">
|
||||||
day={option.day}
|
<div className="text-sm uppercase text-slate-400">
|
||||||
dow={option.dow}
|
{option.dow}
|
||||||
month={option.month}
|
</div>
|
||||||
/>
|
<div className="text-2xl">{option.day}</div>
|
||||||
|
<div className="text-xs font-medium uppercase text-slate-400/75">
|
||||||
|
{option.month}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{option.type === "timeSlot" ? (
|
{option.type === "timeSlot" ? (
|
||||||
<TimeRange
|
<TimeRange
|
||||||
|
@ -66,7 +69,6 @@ const PollHeader: React.VoidFunctionComponent = () => {
|
||||||
<ScoreSummary
|
<ScoreSummary
|
||||||
yesScore={numVotes.yes}
|
yesScore={numVotes.yes}
|
||||||
ifNeedBeScore={numVotes.ifNeedBe}
|
ifNeedBeScore={numVotes.ifNeedBe}
|
||||||
compact={true}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -21,8 +21,7 @@ import NameInput from "../name-input";
|
||||||
import { useParticipants } from "../participants-provider";
|
import { useParticipants } from "../participants-provider";
|
||||||
import { isUnclaimed, useSession } from "../session";
|
import { isUnclaimed, useSession } from "../session";
|
||||||
import TimeZonePicker from "../time-zone-picker";
|
import TimeZonePicker from "../time-zone-picker";
|
||||||
import PollOptions from "./mobile-poll/poll-options";
|
import GroupedOptions from "./mobile-poll/grouped-options";
|
||||||
import TimeSlotOptions from "./mobile-poll/time-slot-options";
|
|
||||||
import {
|
import {
|
||||||
normalizeVotes,
|
normalizeVotes,
|
||||||
useAddParticipantMutation,
|
useAddParticipantMutation,
|
||||||
|
@ -139,7 +138,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="sticky top-12 z-30 flex flex-col space-y-2 border-b bg-gray-50 p-3">
|
<div className="sticky top-[47px] z-30 flex flex-col space-y-2 border-b bg-gray-50 p-3">
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<Listbox
|
<Listbox
|
||||||
value={selectedParticipantId}
|
value={selectedParticipantId}
|
||||||
|
@ -266,28 +265,20 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
<GroupedOptions
|
||||||
switch (pollContext.pollType) {
|
|
||||||
// we pass poll options as props since we are
|
|
||||||
// discriminating on poll type here
|
|
||||||
case "date":
|
|
||||||
return (
|
|
||||||
<PollOptions
|
|
||||||
selectedParticipantId={selectedParticipantId}
|
selectedParticipantId={selectedParticipantId}
|
||||||
options={pollContext.options}
|
options={pollContext.options}
|
||||||
editable={isEditing}
|
editable={isEditing}
|
||||||
/>
|
groupClassName={
|
||||||
);
|
pollContext.pollType === "timeSlot" ? "top-[151px]" : "top-[108px]"
|
||||||
case "timeSlot":
|
|
||||||
return (
|
|
||||||
<TimeSlotOptions
|
|
||||||
selectedParticipantId={selectedParticipantId}
|
|
||||||
options={pollContext.options}
|
|
||||||
editable={isEditing}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
})()}
|
group={(option) => {
|
||||||
|
if (option.type === "timeSlot") {
|
||||||
|
return `${option.dow} ${option.day} ${option.month}`;
|
||||||
|
}
|
||||||
|
return `${option.month} ${option.year}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{shouldShowSaveButton && isEditing ? (
|
{shouldShowSaveButton && isEditing ? (
|
||||||
<motion.button
|
<motion.button
|
||||||
|
|
|
@ -1,24 +1,24 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import DateCard from "@/components/date-card";
|
|
||||||
|
|
||||||
import PollOption, { PollOptionProps } from "./poll-option";
|
import PollOption, { PollOptionProps } from "./poll-option";
|
||||||
|
|
||||||
export interface DateOptionProps extends PollOptionProps {
|
export interface DateOptionProps extends PollOptionProps {
|
||||||
dow: string;
|
dow: string;
|
||||||
day: string;
|
day: string;
|
||||||
month: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DateOption: React.VoidFunctionComponent<DateOptionProps> = ({
|
const DateOption: React.VoidFunctionComponent<DateOptionProps> = ({
|
||||||
dow,
|
dow,
|
||||||
day,
|
day,
|
||||||
month,
|
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<PollOption {...rest}>
|
<PollOption {...rest}>
|
||||||
<DateCard dow={dow} day={day} month={month} />
|
<div className="font-semibold leading-9">
|
||||||
|
<span className="text-2xl">{day}</span>
|
||||||
|
|
||||||
|
<span className="text-sm uppercase text-slate-400">{dow}</span>
|
||||||
|
</div>
|
||||||
</PollOption>
|
</PollOption>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,31 +1,39 @@
|
||||||
|
import clsx from "clsx";
|
||||||
import { groupBy } from "lodash";
|
import { groupBy } from "lodash";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { ParsedTimeSlotOption } from "@/utils/date-time-utils";
|
import { ParsedDateTimeOpton } from "@/utils/date-time-utils";
|
||||||
|
|
||||||
import PollOptions from "./poll-options";
|
import PollOptions from "./poll-options";
|
||||||
|
|
||||||
export interface TimeSlotOptionsProps {
|
export interface GroupedOptionsProps {
|
||||||
options: ParsedTimeSlotOption[];
|
options: ParsedDateTimeOpton[];
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
selectedParticipantId?: string;
|
selectedParticipantId?: string;
|
||||||
|
group: (option: ParsedDateTimeOpton) => string;
|
||||||
|
groupClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimeSlotOptions: React.VoidFunctionComponent<TimeSlotOptionsProps> = ({
|
const GroupedOptions: React.VoidFunctionComponent<GroupedOptionsProps> = ({
|
||||||
options,
|
options,
|
||||||
editable,
|
editable,
|
||||||
selectedParticipantId,
|
selectedParticipantId,
|
||||||
|
group,
|
||||||
|
groupClassName,
|
||||||
}) => {
|
}) => {
|
||||||
const grouped = groupBy(options, (option) => {
|
const grouped = groupBy(options, group);
|
||||||
return `${option.dow} ${option.day} ${option.month}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="select-none divide-y">
|
<div className="select-none divide-y">
|
||||||
{Object.entries(grouped).map(([day, options]) => {
|
{Object.entries(grouped).map(([day, options]) => {
|
||||||
return (
|
return (
|
||||||
<div key={day}>
|
<div key={day}>
|
||||||
<div className="sticky top-[152px] z-10 flex border-b bg-gray-50/80 py-2 px-4 text-sm font-semibold shadow-sm backdrop-blur-md">
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"sticky z-10 flex border-b bg-gray-50/80 py-2 px-4 text-sm font-semibold shadow-sm backdrop-blur-md",
|
||||||
|
groupClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
<PollOptions
|
<PollOptions
|
||||||
|
@ -40,4 +48,4 @@ const TimeSlotOptions: React.VoidFunctionComponent<TimeSlotOptionsProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TimeSlotOptions;
|
export default GroupedOptions;
|
|
@ -3,6 +3,9 @@ import clsx from "clsx";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
import ChevronDown from "@/components/icons/chevron-down.svg";
|
||||||
|
|
||||||
|
import { useParticipants } from "../../participants-provider";
|
||||||
import { ScoreSummary } from "../score-summary";
|
import { ScoreSummary } from "../score-summary";
|
||||||
import UserAvatar from "../user-avatar";
|
import UserAvatar from "../user-avatar";
|
||||||
import VoteIcon from "../vote-icon";
|
import VoteIcon from "../vote-icon";
|
||||||
|
@ -17,6 +20,7 @@ export interface PollOptionProps {
|
||||||
onChange: (vote: VoteType) => void;
|
onChange: (vote: VoteType) => void;
|
||||||
participants: Participant[];
|
participants: Participant[];
|
||||||
selectedParticipantId?: string;
|
selectedParticipantId?: string;
|
||||||
|
optionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CollapsibleContainer: React.VoidFunctionComponent<{
|
const CollapsibleContainer: React.VoidFunctionComponent<{
|
||||||
|
@ -25,8 +29,9 @@ const CollapsibleContainer: React.VoidFunctionComponent<{
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ className, children, expanded }) => {
|
}> = ({ className, children, expanded }) => {
|
||||||
return (
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{expanded ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ width: 0 }}
|
|
||||||
variants={{
|
variants={{
|
||||||
collapsed: {
|
collapsed: {
|
||||||
width: 0,
|
width: 0,
|
||||||
|
@ -37,11 +42,15 @@ const CollapsibleContainer: React.VoidFunctionComponent<{
|
||||||
width: 58,
|
width: 58,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
animate={expanded ? "expanded" : "collapsed"}
|
initial="collapsed"
|
||||||
|
animate="expanded"
|
||||||
|
exit="collapsed"
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -61,41 +70,159 @@ const PopInOut: React.VoidFunctionComponent<{
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PollOptionVoteSummary: React.VoidFunctionComponent<{ optionId: string }> =
|
||||||
|
({ optionId }) => {
|
||||||
|
const { getParticipants } = useParticipants();
|
||||||
|
const participantsWhoVotedYes = getParticipants(optionId, "yes");
|
||||||
|
const participantsWhoVotedIfNeedBe = getParticipants(optionId, "ifNeedBe");
|
||||||
|
const participantsWhoVotedNo = getParticipants(optionId, "no");
|
||||||
|
const noVotes =
|
||||||
|
participantsWhoVotedYes.length + participantsWhoVotedIfNeedBe.length ===
|
||||||
|
0;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
transition={{
|
||||||
|
duration: 0.1,
|
||||||
|
}}
|
||||||
|
initial={{ height: 0, opacity: 0, y: -10 }}
|
||||||
|
animate={{ height: "auto", opacity: 1, y: 0 }}
|
||||||
|
exit={{ height: 0, opacity: 0, y: -10 }}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{noVotes ? (
|
||||||
|
<div className="rounded-lg bg-slate-50 p-2 text-center text-slate-400">
|
||||||
|
No one has vote for this option
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-x-4">
|
||||||
|
<div className="col-span-1 space-y-2">
|
||||||
|
{participantsWhoVotedYes.map(({ name }, i) => (
|
||||||
|
<div key={i} className="flex">
|
||||||
|
<div className="relative mr-2 flex h-5 w-5 items-center justify-center">
|
||||||
|
<UserAvatar name={name} />
|
||||||
|
<VoteIcon
|
||||||
|
type="yes"
|
||||||
|
size="sm"
|
||||||
|
className="absolute -right-1 -top-1 rounded-full bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-500">{name}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{participantsWhoVotedIfNeedBe.map(({ name }, i) => (
|
||||||
|
<div key={i} className="flex">
|
||||||
|
<div className="relative mr-2 flex h-5 w-5 items-center justify-center">
|
||||||
|
<UserAvatar name={name} />
|
||||||
|
<VoteIcon
|
||||||
|
type="ifNeedBe"
|
||||||
|
size="sm"
|
||||||
|
className="absolute -right-1 -top-1 rounded-full bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-500"> {name}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 space-y-2">
|
||||||
|
{participantsWhoVotedNo.map(({ name }, i) => (
|
||||||
|
<div key={i} className="flex">
|
||||||
|
<div className="relative mr-2 flex h-5 w-5 items-center justify-center">
|
||||||
|
<UserAvatar name={name} />
|
||||||
|
<VoteIcon
|
||||||
|
type="no"
|
||||||
|
size="sm"
|
||||||
|
className="absolute -right-1 -top-1 rounded-full bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-500"> {name}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
|
const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
|
||||||
children,
|
children,
|
||||||
selectedParticipantId,
|
selectedParticipantId,
|
||||||
vote,
|
vote,
|
||||||
onChange,
|
onChange,
|
||||||
participants,
|
participants,
|
||||||
editable,
|
editable = false,
|
||||||
yesScore,
|
yesScore,
|
||||||
ifNeedBeScore,
|
optionId,
|
||||||
}) => {
|
}) => {
|
||||||
const showVotes = !!(selectedParticipantId || editable);
|
const showVotes = !!(selectedParticipantId || editable);
|
||||||
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
const selectorRef = React.useRef<HTMLButtonElement>(null);
|
const selectorRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
const [active, setActive] = React.useState(false);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className={clsx("space-y-4 overflow-hidden p-4", {
|
||||||
|
"bg-slate-400/5": editable && active,
|
||||||
|
})}
|
||||||
|
onTouchStart={() => setActive(editable)}
|
||||||
|
onTouchEnd={() => setActive(false)}
|
||||||
data-testid="poll-option"
|
data-testid="poll-option"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selectorRef.current) {
|
selectorRef.current?.click();
|
||||||
selectorRef.current.click();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
|
||||||
"flex select-none items-center space-x-3 px-4 py-3 transition duration-75",
|
|
||||||
{
|
|
||||||
"active:bg-slate-400/5": editable,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex grow items-center">
|
<div className="flex select-none transition duration-75">
|
||||||
<div className="grow">{children}</div>
|
<div className="flex grow space-x-8">
|
||||||
<div className="flex flex-col items-end">
|
<div>{children}</div>
|
||||||
<ScoreSummary yesScore={yesScore} ifNeedBeScore={ifNeedBeScore} />
|
<div className="flex grow items-center justify-end">
|
||||||
{participants.length > 0 ? (
|
<button
|
||||||
<div className="mt-1 -mr-1">
|
type="button"
|
||||||
<div className="-space-x-1">
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
|
className="flex justify-end rounded-lg p-2 active:bg-slate-500/10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpanded((value) => !value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScoreSummary yesScore={yesScore} />
|
||||||
|
<ChevronDown
|
||||||
|
className={clsx("h-5 text-slate-400 transition-transform", {
|
||||||
|
"-rotate-180": expanded,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContainer
|
||||||
|
expanded={showVotes}
|
||||||
|
className="relative flex justify-center"
|
||||||
|
>
|
||||||
|
{editable ? (
|
||||||
|
<div className="flex h-full w-14 items-center justify-center">
|
||||||
|
<VoteSelector
|
||||||
|
ref={selectorRef}
|
||||||
|
value={vote}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
<PopInOut
|
||||||
|
key={vote}
|
||||||
|
className="absolute inset-0 flex h-full items-center justify-center"
|
||||||
|
>
|
||||||
|
<VoteIcon type={vote} />
|
||||||
|
</PopInOut>
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
</CollapsibleContainer>
|
||||||
|
</div>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{expanded ? <PollOptionVoteSummary optionId={optionId} /> : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
{!expanded && participants.length > 0 ? (
|
||||||
|
<div className="flex -space-x-1">
|
||||||
{participants
|
{participants
|
||||||
.slice(0, participants.length <= 6 ? 6 : 5)
|
.slice(0, participants.length <= 6 ? 6 : 5)
|
||||||
.map((participant, i) => {
|
.map((participant, i) => {
|
||||||
|
@ -107,36 +234,14 @@ const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{participants.length > 6 ? (
|
{participants.length > 8 ? (
|
||||||
<span className="inline-flex h-5 items-center justify-center rounded-full bg-slate-100 px-1 text-xs font-medium ring-1 ring-white">
|
<span className="inline-flex h-5 items-center justify-center rounded-full bg-slate-100 px-1 text-xs font-medium ring-1 ring-white">
|
||||||
+{participants.length - 5}
|
+{participants.length - 7}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<CollapsibleContainer
|
|
||||||
expanded={showVotes}
|
|
||||||
className="relative flex h-12 items-center justify-center rounded-lg"
|
|
||||||
>
|
|
||||||
{editable ? (
|
|
||||||
<div className="flex h-full w-14 items-center justify-center">
|
|
||||||
<VoteSelector ref={selectorRef} value={vote} onChange={onChange} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
<PopInOut
|
|
||||||
key={vote}
|
|
||||||
className="absolute inset-0 flex h-full w-full items-center justify-center"
|
|
||||||
>
|
|
||||||
<VoteIcon type={vote} />
|
|
||||||
</PopInOut>
|
|
||||||
</AnimatePresence>
|
|
||||||
)}
|
|
||||||
</CollapsibleContainer>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -65,6 +65,7 @@ const PollOptions: React.VoidFunctionComponent<PollOptions> = ({
|
||||||
return (
|
return (
|
||||||
<TimeSlotOption
|
<TimeSlotOption
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
optionId={option.optionId}
|
||||||
yesScore={score.yes}
|
yesScore={score.yes}
|
||||||
ifNeedBeScore={score.ifNeedBe}
|
ifNeedBeScore={score.ifNeedBe}
|
||||||
participants={participants}
|
participants={participants}
|
||||||
|
@ -80,13 +81,13 @@ const PollOptions: React.VoidFunctionComponent<PollOptions> = ({
|
||||||
return (
|
return (
|
||||||
<DateOption
|
<DateOption
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
optionId={option.optionId}
|
||||||
yesScore={score.yes}
|
yesScore={score.yes}
|
||||||
ifNeedBeScore={score.ifNeedBe}
|
ifNeedBeScore={score.ifNeedBe}
|
||||||
participants={participants}
|
participants={participants}
|
||||||
vote={vote}
|
vote={vote}
|
||||||
dow={option.dow}
|
dow={option.dow}
|
||||||
day={option.day}
|
day={option.day}
|
||||||
month={option.month}
|
|
||||||
editable={editable}
|
editable={editable}
|
||||||
selectedParticipantId={selectedParticipant?.id}
|
selectedParticipantId={selectedParticipant?.id}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
import clsx from "clsx";
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { usePrevious } from "react-use";
|
import { usePrevious } from "react-use";
|
||||||
|
|
||||||
import CheckCircle from "@/components/icons/check-circle.svg";
|
import User from "@/components/icons/user-solid.svg";
|
||||||
import IfNeedBe from "@/components/icons/if-need-be.svg";
|
|
||||||
|
|
||||||
export interface PopularityScoreProps {
|
export interface PopularityScoreProps {
|
||||||
yesScore: number;
|
yesScore: number;
|
||||||
compact?: boolean;
|
|
||||||
ifNeedBeScore?: number;
|
ifNeedBeScore?: number;
|
||||||
highlight?: boolean;
|
highlight?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -18,9 +15,8 @@ const Score = React.forwardRef<
|
||||||
{
|
{
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
score: number;
|
score: number;
|
||||||
compact?: boolean;
|
|
||||||
}
|
}
|
||||||
>(function Score({ icon: Icon, score, compact }, ref) {
|
>(function Score({ icon: Icon, score }, ref) {
|
||||||
const prevScore = usePrevious(score);
|
const prevScore = usePrevious(score);
|
||||||
|
|
||||||
const multiplier = prevScore !== undefined ? score - prevScore : 0;
|
const multiplier = prevScore !== undefined ? score - prevScore : 0;
|
||||||
|
@ -28,21 +24,10 @@ const Score = React.forwardRef<
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={clsx(
|
className="relative inline-flex items-center text-sm font-bold"
|
||||||
"relative inline-flex items-center font-mono font-semibold text-slate-500",
|
|
||||||
{ "text-sm": !compact, "text-xs": compact },
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon className="mr-1 inline-block h-4 text-slate-300 transition-opacity" />
|
||||||
className={clsx(
|
<span className="relative inline-block text-slate-500">
|
||||||
"mr-1 inline-block text-slate-400/80 transition-opacity",
|
|
||||||
{
|
|
||||||
"h-4": !compact,
|
|
||||||
"h-3": compact,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="relative inline-block">
|
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
<motion.span
|
<motion.span
|
||||||
transition={{
|
transition={{
|
||||||
|
@ -70,33 +55,14 @@ const Score = React.forwardRef<
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const MotionScore = motion(Score);
|
|
||||||
|
|
||||||
export const ScoreSummary: React.VoidFunctionComponent<PopularityScoreProps> =
|
export const ScoreSummary: React.VoidFunctionComponent<PopularityScoreProps> =
|
||||||
React.memo(function PopularityScore({ yesScore, ifNeedBeScore, compact }) {
|
React.memo(function PopularityScore({ yesScore }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="popularity-score"
|
data-testid="popularity-score"
|
||||||
className="inline-flex items-center space-x-2"
|
className="relative inline-flex items-center space-x-2"
|
||||||
>
|
>
|
||||||
<Score icon={CheckCircle} compact={compact} score={yesScore} />
|
<Score icon={User} score={yesScore} />
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{ifNeedBeScore ? (
|
|
||||||
<MotionScore
|
|
||||||
initial={{ opacity: 0, width: 0, x: -10 }}
|
|
||||||
animate={{ opacity: 1, width: "auto", x: 0 }}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
width: 0,
|
|
||||||
x: -10,
|
|
||||||
transition: { duration: 0.1 },
|
|
||||||
}}
|
|
||||||
icon={IfNeedBe}
|
|
||||||
compact={compact}
|
|
||||||
score={ifNeedBeScore}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,26 +1,57 @@
|
||||||
import { VoteType } from "@prisma/client";
|
import { VoteType } from "@prisma/client";
|
||||||
|
import clsx from "clsx";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import CheckCircle from "@/components/icons/check-circle.svg";
|
import CheckCircle from "@/components/icons/check-circle.svg";
|
||||||
import IfNeedBe from "@/components/icons/if-need-be.svg";
|
import IfNeedBe from "@/components/icons/if-need-be.svg";
|
||||||
|
import QuestionMark from "@/components/icons/question-mark.svg";
|
||||||
|
import X from "@/components/icons/x-circle.svg";
|
||||||
|
|
||||||
const VoteIcon: React.VoidFunctionComponent<{
|
const VoteIcon: React.VoidFunctionComponent<{
|
||||||
type?: VoteType;
|
type?: VoteType;
|
||||||
}> = ({ type }) => {
|
size?: "sm" | "md";
|
||||||
|
className?: string;
|
||||||
|
}> = ({ type, className, size = "md" }) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "yes":
|
case "yes":
|
||||||
return <CheckCircle className="h-5 w-5 text-green-400" />;
|
return (
|
||||||
|
<CheckCircle
|
||||||
|
className={clsx("text-green-400", className, {
|
||||||
|
"h-5": size === "md",
|
||||||
|
"h-3": size === "sm",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case "ifNeedBe":
|
case "ifNeedBe":
|
||||||
return <IfNeedBe className="h-5 w-5 text-yellow-400" />;
|
return (
|
||||||
|
<IfNeedBe
|
||||||
|
className={clsx("text-amber-300", className, {
|
||||||
|
"h-5": size === "md",
|
||||||
|
"h-3": size === "sm",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case "no":
|
case "no":
|
||||||
return (
|
return (
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-slate-300" />
|
<X
|
||||||
|
className={clsx("text-slate-300", className, {
|
||||||
|
"h-5": size === "md",
|
||||||
|
"h-3": size === "sm",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <span className="inline-block font-bold text-slate-300">?</span>;
|
return (
|
||||||
|
<QuestionMark
|
||||||
|
className={clsx("text-slate-300", className, {
|
||||||
|
"h-5": size === "md",
|
||||||
|
"h-3": size === "sm",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -40,10 +40,10 @@ export const VoteSelector = React.forwardRef<
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
<motion.span
|
<motion.span
|
||||||
className="absolute flex items-center justify-center"
|
className="absolute flex items-center justify-center"
|
||||||
transition={{ duration: 0.1 }}
|
transition={{ duration: 0.15 }}
|
||||||
initial={{ opacity: 0, scale: 0 }}
|
initial={{ opacity: 0, y: -15 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0 }}
|
exit={{ opacity: 0, y: 15 }}
|
||||||
key={value}
|
key={value}
|
||||||
>
|
>
|
||||||
<VoteIcon type={value} />
|
<VoteIcon type={value} />
|
||||||
|
|
|
@ -25,7 +25,7 @@ export default function Document() {
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+Mono&display=optional"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#f9fafb" />
|
<meta name="theme-color" content="#f9fafb" />
|
||||||
|
|
|
@ -1,13 +1,7 @@
|
||||||
const getVercelUrl = () => {
|
|
||||||
return process.env.NEXT_PUBLIC_VERCEL_URL
|
|
||||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
|
||||||
: null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function absoluteUrl() {
|
export function absoluteUrl() {
|
||||||
return (
|
return process.env.NEXT_PUBLIC_BASE_URL ?? process.env.NEXT_PUBLIC_VERCEL_URL
|
||||||
process.env.NEXT_PUBLIC_BASE_URL ??
|
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||||
getVercelUrl() ??
|
: typeof window !== "undefined"
|
||||||
"http://localhost:3000"
|
? window.origin
|
||||||
);
|
: "http://localhost:3000";
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ export interface ParsedDateOption {
|
||||||
day: string;
|
day: string;
|
||||||
dow: string;
|
dow: string;
|
||||||
month: string;
|
month: string;
|
||||||
|
year: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedTimeSlotOption {
|
export interface ParsedTimeSlotOption {
|
||||||
|
@ -41,6 +42,7 @@ export interface ParsedTimeSlotOption {
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
duration: string;
|
duration: string;
|
||||||
|
year: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ParsedDateTimeOpton = ParsedDateOption | ParsedTimeSlotOption;
|
export type ParsedDateTimeOpton = ParsedDateOption | ParsedTimeSlotOption;
|
||||||
|
@ -91,8 +93,9 @@ const parseDateOption = (option: Option): ParsedDateOption => {
|
||||||
type: "date",
|
type: "date",
|
||||||
optionId: option.id,
|
optionId: option.id,
|
||||||
day: format(date, "d"),
|
day: format(date, "d"),
|
||||||
dow: format(date, "E"),
|
dow: format(date, "EEE"),
|
||||||
month: format(date, "MMM"),
|
month: format(date, "MMM"),
|
||||||
|
year: format(date, "yyyy"),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -122,9 +125,10 @@ const parseTimeSlotOption = (
|
||||||
startTime: localeFormatInTimezone(startDate, targetTimeZone, "p"),
|
startTime: localeFormatInTimezone(startDate, targetTimeZone, "p"),
|
||||||
endTime: localeFormatInTimezone(endDate, targetTimeZone, "p"),
|
endTime: localeFormatInTimezone(endDate, targetTimeZone, "p"),
|
||||||
day: localeFormatInTimezone(startDate, targetTimeZone, "d"),
|
day: localeFormatInTimezone(startDate, targetTimeZone, "d"),
|
||||||
dow: localeFormatInTimezone(startDate, targetTimeZone, "E"),
|
dow: localeFormatInTimezone(startDate, targetTimeZone, "EEE"),
|
||||||
month: localeFormatInTimezone(startDate, targetTimeZone, "MMM"),
|
month: localeFormatInTimezone(startDate, targetTimeZone, "MMM"),
|
||||||
duration: getDuration(startDate, endDate),
|
duration: getDuration(startDate, endDate),
|
||||||
|
year: localeFormatInTimezone(startDate, targetTimeZone, "yyyy"),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const startDate = new Date(start);
|
const startDate = new Date(start);
|
||||||
|
@ -138,6 +142,7 @@ const parseTimeSlotOption = (
|
||||||
dow: format(startDate, "E"),
|
dow: format(startDate, "E"),
|
||||||
month: format(startDate, "MMM"),
|
month: format(startDate, "MMM"),
|
||||||
duration: getDuration(startDate, endDate),
|
duration: getDuration(startDate, endDate),
|
||||||
|
year: format(startDate, "yyyy"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -66,14 +66,14 @@
|
||||||
@apply h-4 w-4 rounded border-slate-300 text-indigo-500 shadow-sm focus:ring-indigo-500;
|
@apply h-4 w-4 rounded border-slate-300 text-indigo-500 shadow-sm focus:ring-indigo-500;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex h-9 cursor-default select-none items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all active:scale-95;
|
@apply inline-flex h-9 cursor-default select-none items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all sm:active:scale-95;
|
||||||
}
|
}
|
||||||
a.btn {
|
a.btn {
|
||||||
@apply cursor-pointer hover:no-underline;
|
@apply cursor-pointer hover:no-underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-default {
|
.btn-default {
|
||||||
@apply btn border-slate-300 bg-white text-slate-700 hover:bg-indigo-50/10 active:bg-slate-100;
|
@apply btn bg-white text-slate-700 hover:bg-indigo-50/10 active:bg-slate-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.btn-default {
|
a.btn-default {
|
||||||
|
|
|
@ -36,7 +36,7 @@ module.exports = {
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["Inter", ...defaultTheme.fontFamily.sans],
|
sans: ["Inter", ...defaultTheme.fontFamily.sans],
|
||||||
mono: [...defaultTheme.fontFamily.mono],
|
mono: ["Noto Sans Mono", ...defaultTheme.fontFamily.mono],
|
||||||
},
|
},
|
||||||
transitionTimingFunction: {
|
transitionTimingFunction: {
|
||||||
"in-expo": "cubic-bezier(0.68, -0.6, 0.32, 1.6)",
|
"in-expo": "cubic-bezier(0.68, -0.6, 0.32, 1.6)",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue