mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-14 01:16:48 +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";
|
||||
|
||||
export interface ButtonProps {
|
||||
export interface ButtonProps
|
||||
extends Omit<
|
||||
React.DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
>,
|
||||
"type" | "ref"
|
||||
> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
|
|
|
@ -28,7 +28,7 @@ const DateCard: React.VoidFunctionComponent<DateCardProps> = ({
|
|||
) : null}
|
||||
<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-['']">
|
||||
{dow}
|
||||
{dow.substring(0, 3)}
|
||||
</span>
|
||||
</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 * as React from "react";
|
||||
|
||||
import DateCard from "../date-card";
|
||||
import { ScoreSummary } from "../poll/score-summary";
|
||||
import UserAvatar from "../poll/user-avatar";
|
||||
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"
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<DateCard
|
||||
day={format(d, "dd")}
|
||||
dow={format(d, "E")}
|
||||
month={format(d, "MMM")}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
|
||||
<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>
|
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">
|
||||
<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" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
||||
<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>
|
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 * as React from "react";
|
||||
|
||||
|
@ -9,6 +9,7 @@ import { useRequiredContext } from "./use-required-context";
|
|||
const ParticipantsContext =
|
||||
React.createContext<{
|
||||
participants: Array<Participant & { votes: Vote[] }>;
|
||||
getParticipants: (optionId: string, voteType: VoteType) => Participant[];
|
||||
} | null>(null);
|
||||
|
||||
export const useParticipants = () => {
|
||||
|
@ -26,6 +27,20 @@ export const ParticipantsProvider: React.VoidFunctionComponent<{
|
|||
{ 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
|
||||
|
||||
if (!participants) {
|
||||
|
@ -33,7 +48,7 @@ export const ParticipantsProvider: React.VoidFunctionComponent<{
|
|||
}
|
||||
|
||||
return (
|
||||
<ParticipantsContext.Provider value={{ participants }}>
|
||||
<ParticipantsContext.Provider value={{ participants, getParticipants }}>
|
||||
{children}
|
||||
</ParticipantsContext.Provider>
|
||||
);
|
||||
|
|
|
@ -124,7 +124,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
|||
participantsByOptionId[option.id] = (participants ?? []).filter(
|
||||
(participant) =>
|
||||
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}
|
||||
|
||||
<div className="flex items-center space-x-3 px-4 py-2 sm:justify-end">
|
||||
<span className="text-xs font-semibold text-slate-500">
|
||||
Legend:
|
||||
</span>
|
||||
<span className="inline-flex items-center space-x-2">
|
||||
<span className="text-xs font-semibold text-slate-500">Key:</span>
|
||||
<span className="inline-flex items-center space-x-1">
|
||||
<VoteIcon type="yes" />
|
||||
<span className="text-xs text-slate-500">Yes</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center space-x-2">
|
||||
<span className="inline-flex items-center space-x-1">
|
||||
<VoteIcon type="ifNeedBe" />
|
||||
<span className="text-xs text-slate-500">If need be</span>
|
||||
</span>
|
||||
|
||||
<span className="inline-flex items-center space-x-2">
|
||||
<span className="inline-flex items-center space-x-1">
|
||||
<VoteIcon type="no" />
|
||||
<span className="text-xs text-slate-500">No</span>
|
||||
</span>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
import DateCard from "@/components/date-card";
|
||||
import { usePoll } from "@/components/poll-context";
|
||||
|
||||
import { ScoreSummary } from "../score-summary";
|
||||
|
@ -49,11 +48,15 @@ const PollHeader: React.VoidFunctionComponent = () => {
|
|||
onMouseOut={() => setActiveOptionId(null)}
|
||||
>
|
||||
<div>
|
||||
<DateCard
|
||||
day={option.day}
|
||||
dow={option.dow}
|
||||
month={option.month}
|
||||
/>
|
||||
<div className="font-semibold leading-9">
|
||||
<div className="text-sm uppercase text-slate-400">
|
||||
{option.dow}
|
||||
</div>
|
||||
<div className="text-2xl">{option.day}</div>
|
||||
<div className="text-xs font-medium uppercase text-slate-400/75">
|
||||
{option.month}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{option.type === "timeSlot" ? (
|
||||
<TimeRange
|
||||
|
@ -66,7 +69,6 @@ const PollHeader: React.VoidFunctionComponent = () => {
|
|||
<ScoreSummary
|
||||
yesScore={numVotes.yes}
|
||||
ifNeedBeScore={numVotes.ifNeedBe}
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -21,8 +21,7 @@ import NameInput from "../name-input";
|
|||
import { useParticipants } from "../participants-provider";
|
||||
import { isUnclaimed, useSession } from "../session";
|
||||
import TimeZonePicker from "../time-zone-picker";
|
||||
import PollOptions from "./mobile-poll/poll-options";
|
||||
import TimeSlotOptions from "./mobile-poll/time-slot-options";
|
||||
import GroupedOptions from "./mobile-poll/grouped-options";
|
||||
import {
|
||||
normalizeVotes,
|
||||
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">
|
||||
<Listbox
|
||||
value={selectedParticipantId}
|
||||
|
@ -266,28 +265,20 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{(() => {
|
||||
switch (pollContext.pollType) {
|
||||
// we pass poll options as props since we are
|
||||
// discriminating on poll type here
|
||||
case "date":
|
||||
return (
|
||||
<PollOptions
|
||||
<GroupedOptions
|
||||
selectedParticipantId={selectedParticipantId}
|
||||
options={pollContext.options}
|
||||
editable={isEditing}
|
||||
/>
|
||||
);
|
||||
case "timeSlot":
|
||||
return (
|
||||
<TimeSlotOptions
|
||||
selectedParticipantId={selectedParticipantId}
|
||||
options={pollContext.options}
|
||||
editable={isEditing}
|
||||
/>
|
||||
);
|
||||
groupClassName={
|
||||
pollContext.pollType === "timeSlot" ? "top-[151px]" : "top-[108px]"
|
||||
}
|
||||
})()}
|
||||
group={(option) => {
|
||||
if (option.type === "timeSlot") {
|
||||
return `${option.dow} ${option.day} ${option.month}`;
|
||||
}
|
||||
return `${option.month} ${option.year}`;
|
||||
}}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{shouldShowSaveButton && isEditing ? (
|
||||
<motion.button
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
import * as React from "react";
|
||||
|
||||
import DateCard from "@/components/date-card";
|
||||
|
||||
import PollOption, { PollOptionProps } from "./poll-option";
|
||||
|
||||
export interface DateOptionProps extends PollOptionProps {
|
||||
dow: string;
|
||||
day: string;
|
||||
month: string;
|
||||
}
|
||||
|
||||
const DateOption: React.VoidFunctionComponent<DateOptionProps> = ({
|
||||
dow,
|
||||
day,
|
||||
month,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,31 +1,39 @@
|
|||
import clsx from "clsx";
|
||||
import { groupBy } from "lodash";
|
||||
import * as React from "react";
|
||||
|
||||
import { ParsedTimeSlotOption } from "@/utils/date-time-utils";
|
||||
import { ParsedDateTimeOpton } from "@/utils/date-time-utils";
|
||||
|
||||
import PollOptions from "./poll-options";
|
||||
|
||||
export interface TimeSlotOptionsProps {
|
||||
options: ParsedTimeSlotOption[];
|
||||
export interface GroupedOptionsProps {
|
||||
options: ParsedDateTimeOpton[];
|
||||
editable?: boolean;
|
||||
selectedParticipantId?: string;
|
||||
group: (option: ParsedDateTimeOpton) => string;
|
||||
groupClassName?: string;
|
||||
}
|
||||
|
||||
const TimeSlotOptions: React.VoidFunctionComponent<TimeSlotOptionsProps> = ({
|
||||
const GroupedOptions: React.VoidFunctionComponent<GroupedOptionsProps> = ({
|
||||
options,
|
||||
editable,
|
||||
selectedParticipantId,
|
||||
group,
|
||||
groupClassName,
|
||||
}) => {
|
||||
const grouped = groupBy(options, (option) => {
|
||||
return `${option.dow} ${option.day} ${option.month}`;
|
||||
});
|
||||
const grouped = groupBy(options, group);
|
||||
|
||||
return (
|
||||
<div className="select-none divide-y">
|
||||
{Object.entries(grouped).map(([day, options]) => {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
<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 * as React from "react";
|
||||
|
||||
import ChevronDown from "@/components/icons/chevron-down.svg";
|
||||
|
||||
import { useParticipants } from "../../participants-provider";
|
||||
import { ScoreSummary } from "../score-summary";
|
||||
import UserAvatar from "../user-avatar";
|
||||
import VoteIcon from "../vote-icon";
|
||||
|
@ -17,6 +20,7 @@ export interface PollOptionProps {
|
|||
onChange: (vote: VoteType) => void;
|
||||
participants: Participant[];
|
||||
selectedParticipantId?: string;
|
||||
optionId: string;
|
||||
}
|
||||
|
||||
const CollapsibleContainer: React.VoidFunctionComponent<{
|
||||
|
@ -25,8 +29,9 @@ const CollapsibleContainer: React.VoidFunctionComponent<{
|
|||
className?: string;
|
||||
}> = ({ className, children, expanded }) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{expanded ? (
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
variants={{
|
||||
collapsed: {
|
||||
width: 0,
|
||||
|
@ -37,11 +42,15 @@ const CollapsibleContainer: React.VoidFunctionComponent<{
|
|||
width: 58,
|
||||
},
|
||||
}}
|
||||
animate={expanded ? "expanded" : "collapsed"}
|
||||
initial="collapsed"
|
||||
animate="expanded"
|
||||
exit="collapsed"
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</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> = ({
|
||||
children,
|
||||
selectedParticipantId,
|
||||
vote,
|
||||
onChange,
|
||||
participants,
|
||||
editable,
|
||||
editable = false,
|
||||
yesScore,
|
||||
ifNeedBeScore,
|
||||
optionId,
|
||||
}) => {
|
||||
const showVotes = !!(selectedParticipantId || editable);
|
||||
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const selectorRef = React.useRef<HTMLButtonElement>(null);
|
||||
const [active, setActive] = React.useState(false);
|
||||
return (
|
||||
<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"
|
||||
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="grow">{children}</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<ScoreSummary yesScore={yesScore} ifNeedBeScore={ifNeedBeScore} />
|
||||
{participants.length > 0 ? (
|
||||
<div className="mt-1 -mr-1">
|
||||
<div className="-space-x-1">
|
||||
<div className="flex select-none transition duration-75">
|
||||
<div className="flex grow space-x-8">
|
||||
<div>{children}</div>
|
||||
<div className="flex grow items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
.slice(0, participants.length <= 6 ? 6 : 5)
|
||||
.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">
|
||||
+{participants.length - 5}
|
||||
+{participants.length - 7}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</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 (
|
||||
<TimeSlotOption
|
||||
onChange={handleChange}
|
||||
optionId={option.optionId}
|
||||
yesScore={score.yes}
|
||||
ifNeedBeScore={score.ifNeedBe}
|
||||
participants={participants}
|
||||
|
@ -80,13 +81,13 @@ const PollOptions: React.VoidFunctionComponent<PollOptions> = ({
|
|||
return (
|
||||
<DateOption
|
||||
onChange={handleChange}
|
||||
optionId={option.optionId}
|
||||
yesScore={score.yes}
|
||||
ifNeedBeScore={score.ifNeedBe}
|
||||
participants={participants}
|
||||
vote={vote}
|
||||
dow={option.dow}
|
||||
day={option.day}
|
||||
month={option.month}
|
||||
editable={editable}
|
||||
selectedParticipantId={selectedParticipant?.id}
|
||||
/>
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
import clsx from "clsx";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { usePrevious } from "react-use";
|
||||
|
||||
import CheckCircle from "@/components/icons/check-circle.svg";
|
||||
import IfNeedBe from "@/components/icons/if-need-be.svg";
|
||||
import User from "@/components/icons/user-solid.svg";
|
||||
|
||||
export interface PopularityScoreProps {
|
||||
yesScore: number;
|
||||
compact?: boolean;
|
||||
ifNeedBeScore?: number;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
@ -18,9 +15,8 @@ const Score = React.forwardRef<
|
|||
{
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
score: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
>(function Score({ icon: Icon, score, compact }, ref) {
|
||||
>(function Score({ icon: Icon, score }, ref) {
|
||||
const prevScore = usePrevious(score);
|
||||
|
||||
const multiplier = prevScore !== undefined ? score - prevScore : 0;
|
||||
|
@ -28,21 +24,10 @@ const Score = React.forwardRef<
|
|||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
"relative inline-flex items-center font-mono font-semibold text-slate-500",
|
||||
{ "text-sm": !compact, "text-xs": compact },
|
||||
)}
|
||||
className="relative inline-flex items-center text-sm font-bold"
|
||||
>
|
||||
<Icon
|
||||
className={clsx(
|
||||
"mr-1 inline-block text-slate-400/80 transition-opacity",
|
||||
{
|
||||
"h-4": !compact,
|
||||
"h-3": compact,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<span className="relative inline-block">
|
||||
<Icon className="mr-1 inline-block h-4 text-slate-300 transition-opacity" />
|
||||
<span className="relative inline-block text-slate-500">
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.span
|
||||
transition={{
|
||||
|
@ -70,33 +55,14 @@ const Score = React.forwardRef<
|
|||
);
|
||||
});
|
||||
|
||||
const MotionScore = motion(Score);
|
||||
|
||||
export const ScoreSummary: React.VoidFunctionComponent<PopularityScoreProps> =
|
||||
React.memo(function PopularityScore({ yesScore, ifNeedBeScore, compact }) {
|
||||
React.memo(function PopularityScore({ yesScore }) {
|
||||
return (
|
||||
<div
|
||||
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} />
|
||||
<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>
|
||||
<Score icon={User} score={yesScore} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,26 +1,57 @@
|
|||
import { VoteType } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
import CheckCircle from "@/components/icons/check-circle.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<{
|
||||
type?: VoteType;
|
||||
}> = ({ type }) => {
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}> = ({ type, className, size = "md" }) => {
|
||||
switch (type) {
|
||||
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":
|
||||
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":
|
||||
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:
|
||||
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}>
|
||||
<motion.span
|
||||
className="absolute flex items-center justify-center"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, y: -15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 15 }}
|
||||
key={value}
|
||||
>
|
||||
<VoteIcon type={value} />
|
||||
|
|
|
@ -25,7 +25,7 @@ export default function Document() {
|
|||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<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"
|
||||
/>
|
||||
<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() {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_BASE_URL ??
|
||||
getVercelUrl() ??
|
||||
"http://localhost:3000"
|
||||
);
|
||||
return process.env.NEXT_PUBLIC_BASE_URL ?? process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: typeof window !== "undefined"
|
||||
? window.origin
|
||||
: "http://localhost:3000";
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ export interface ParsedDateOption {
|
|||
day: string;
|
||||
dow: string;
|
||||
month: string;
|
||||
year: string;
|
||||
}
|
||||
|
||||
export interface ParsedTimeSlotOption {
|
||||
|
@ -41,6 +42,7 @@ export interface ParsedTimeSlotOption {
|
|||
startTime: string;
|
||||
endTime: string;
|
||||
duration: string;
|
||||
year: string;
|
||||
}
|
||||
|
||||
export type ParsedDateTimeOpton = ParsedDateOption | ParsedTimeSlotOption;
|
||||
|
@ -91,8 +93,9 @@ const parseDateOption = (option: Option): ParsedDateOption => {
|
|||
type: "date",
|
||||
optionId: option.id,
|
||||
day: format(date, "d"),
|
||||
dow: format(date, "E"),
|
||||
dow: format(date, "EEE"),
|
||||
month: format(date, "MMM"),
|
||||
year: format(date, "yyyy"),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -122,9 +125,10 @@ const parseTimeSlotOption = (
|
|||
startTime: localeFormatInTimezone(startDate, targetTimeZone, "p"),
|
||||
endTime: localeFormatInTimezone(endDate, targetTimeZone, "p"),
|
||||
day: localeFormatInTimezone(startDate, targetTimeZone, "d"),
|
||||
dow: localeFormatInTimezone(startDate, targetTimeZone, "E"),
|
||||
dow: localeFormatInTimezone(startDate, targetTimeZone, "EEE"),
|
||||
month: localeFormatInTimezone(startDate, targetTimeZone, "MMM"),
|
||||
duration: getDuration(startDate, endDate),
|
||||
year: localeFormatInTimezone(startDate, targetTimeZone, "yyyy"),
|
||||
};
|
||||
} else {
|
||||
const startDate = new Date(start);
|
||||
|
@ -138,6 +142,7 @@ const parseTimeSlotOption = (
|
|||
dow: format(startDate, "E"),
|
||||
month: format(startDate, "MMM"),
|
||||
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;
|
||||
}
|
||||
.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 {
|
||||
@apply cursor-pointer hover:no-underline;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
|
|
@ -36,7 +36,7 @@ module.exports = {
|
|||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", ...defaultTheme.fontFamily.sans],
|
||||
mono: [...defaultTheme.fontFamily.mono],
|
||||
mono: ["Noto Sans Mono", ...defaultTheme.fontFamily.mono],
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
"in-expo": "cubic-bezier(0.68, -0.6, 0.32, 1.6)",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue