Show full list of participants on mobile view (#193)

This commit is contained in:
Luke Vella 2022-06-01 09:43:34 +01:00 committed by GitHub
parent 53fa823857
commit 4ce5a1990e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 340 additions and 209 deletions

View file

@ -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;

View file

@ -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>

View file

@ -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>
);

View file

@ -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

Before After
Before After

View file

@ -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

Before After
Before After

View 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

View file

@ -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>
);

View file

@ -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",
),
);
});

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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>
&nbsp;
<span className="text-sm uppercase text-slate-400">{dow}</span>
</div>
</PollOption>
);
};

View file

@ -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;

View file

@ -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>
);
};

View file

@ -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}
/>

View file

@ -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>
);
});

View file

@ -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",
})}
/>
);
}
};

View file

@ -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} />

View file

@ -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" />

View file

@ -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";
}

View file

@ -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"),
};
}
};

View file

@ -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 {

View file

@ -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)",