From 1bbad181309886108b7aa6f55234f37f7f28a14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Comerci?= <45410089+ncomerci@users.noreply.github.com> Date: Mon, 28 Aug 2023 14:25:33 -0300 Subject: [PATCH] feat: Add calendar alert (#1204) * feat: Calendar alert * minor fix * requested changes --- .../BidRequest/BidRequestFundingSection.tsx | 2 +- .../GrantRequest/AddBudgetBreakdownModal.tsx | 2 +- .../GrantRequestFundingSection.tsx | 2 +- src/components/Icon/CalendarAdd.tsx | 26 +++++ src/components/Modal/CalendarAlertModal.css | 7 ++ src/components/Modal/CalendarAlertModal.tsx | 96 +++++++++++++++++++ .../ProjectRequest/NumberSelector.css | 21 +++- .../ProjectRequest/NumberSelector.tsx | 46 ++++++++- src/components/Proposal/ProposalSidebar.tsx | 19 +++- .../Proposal/View/CalendarAlertButton.tsx | 24 +++++ .../Proposal/View/SectionButton.css | 5 +- .../Proposal/View/SidebarButton.tsx | 31 ++++++ .../Proposal/View/SubscribeButton.tsx | 26 ++--- src/intl/en.json | 20 +++- src/utils/projects.ts | 2 +- 15 files changed, 294 insertions(+), 35 deletions(-) create mode 100644 src/components/Icon/CalendarAdd.tsx create mode 100644 src/components/Modal/CalendarAlertModal.css create mode 100644 src/components/Modal/CalendarAlertModal.tsx create mode 100644 src/components/Proposal/View/CalendarAlertButton.tsx create mode 100644 src/components/Proposal/View/SidebarButton.tsx diff --git a/src/components/BidRequest/BidRequestFundingSection.tsx b/src/components/BidRequest/BidRequestFundingSection.tsx index 34f0a50fc..e5b48438f 100644 --- a/src/components/BidRequest/BidRequestFundingSection.tsx +++ b/src/components/BidRequest/BidRequestFundingSection.tsx @@ -92,7 +92,7 @@ export default function BidRequestFundingSection({ onValidation, isFormDisabled, max={schema.projectDuration.maximum} onChange={(value) => setValue('projectDuration', Number(value))} label={t('page.submit_bid.funding_section.project_duration_label')} - unitLabel={t('page.submit_bid.funding_section.project_duration_unit')} + unit="months" /> diff --git a/src/components/GrantRequest/AddBudgetBreakdownModal.tsx b/src/components/GrantRequest/AddBudgetBreakdownModal.tsx index f59c0a8f5..b09c23d0b 100644 --- a/src/components/GrantRequest/AddBudgetBreakdownModal.tsx +++ b/src/components/GrantRequest/AddBudgetBreakdownModal.tsx @@ -159,8 +159,8 @@ export default function AddBudgetBreakdownModal({ max={projectDuration} onChange={(value) => setValue('duration', Number(value))} label={t('page.submit_grant.due_diligence.budget_breakdown_modal.duration_label')} - unitLabel={t('page.submit_grant.due_diligence.budget_breakdown_modal.duration_unit_label')} subtitle={t('page.submit_grant.due_diligence.budget_breakdown_modal.duration_subtitle')} + unit="months" /> diff --git a/src/components/GrantRequest/GrantRequestFundingSection.tsx b/src/components/GrantRequest/GrantRequestFundingSection.tsx index 51606564c..b05dab3ba 100644 --- a/src/components/GrantRequest/GrantRequestFundingSection.tsx +++ b/src/components/GrantRequest/GrantRequestFundingSection.tsx @@ -210,7 +210,7 @@ export default function GrantRequestFundingSection({ max={availableDurations[availableDurations.length - 1]} onChange={(value) => setValue('projectDuration', Number(value))} label={t('page.submit_grant.funding_section.project_duration_title')} - unitLabel={t('page.submit_grant.funding_section.project_duration_unit')} + unit="months" /> diff --git a/src/components/Icon/CalendarAdd.tsx b/src/components/Icon/CalendarAdd.tsx new file mode 100644 index 000000000..ab36716bf --- /dev/null +++ b/src/components/Icon/CalendarAdd.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +interface Props { + size?: string + className?: string +} + +function CalendarAdd({ size = '24', className }: Props) { + return ( + + + + ) +} + +export default CalendarAdd diff --git a/src/components/Modal/CalendarAlertModal.css b/src/components/Modal/CalendarAlertModal.css new file mode 100644 index 000000000..0d3480c82 --- /dev/null +++ b/src/components/Modal/CalendarAlertModal.css @@ -0,0 +1,7 @@ +.CalendarAlertModal__SelectorContainer { + margin-bottom: 25px; +} + +.ui.button.basic.CalendarAlertModal__CancelButton { + color: var(--black-600) !important; +} diff --git a/src/components/Modal/CalendarAlertModal.tsx b/src/components/Modal/CalendarAlertModal.tsx new file mode 100644 index 000000000..95cece021 --- /dev/null +++ b/src/components/Modal/CalendarAlertModal.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useMemo, useState } from 'react' + +import { UnitTypeLongPlural } from 'dayjs' +import { Button } from 'decentraland-ui/dist/components/Button/Button' +import { Close } from 'decentraland-ui/dist/components/Close/Close' +import { Header } from 'decentraland-ui/dist/components/Header/Header' +import { Modal, ModalProps } from 'decentraland-ui/dist/components/Modal/Modal' + +import { ProposalAttributes } from '../../entities/Proposal/types' +import { proposalUrl } from '../../entities/Proposal/utils' +import useFormatMessage from '../../hooks/useFormatMessage' +import Time from '../../utils/date/Time' +import { getGoogleCalendarUrl } from '../../utils/projects' +import NumberSelector from '../ProjectRequest/NumberSelector' + +import './CalendarAlertModal.css' + +type CalendarAlertModalProps = Omit & { + proposal: Pick +} + +const UNITS: UnitTypeLongPlural[] = ['seconds', 'minutes', 'hours', 'days'] +const MAX_TIME_VALUE = 300 + +function getAlertDate(finishAt: ProposalAttributes['finish_at'], value: number, unit: UnitTypeLongPlural): Date | null { + const proposalFinishDate = Time.from(finishAt) + const currentDate = Time() + const alertDate = proposalFinishDate.subtract(value, unit) + if (alertDate.isBefore(currentDate)) { + return null + } + return alertDate.toDate() +} + +function CalendarAlertModal({ proposal, onClose, ...props }: CalendarAlertModalProps) { + const t = useFormatMessage() + const { finish_at, title, id } = proposal + const [timeValue, setTimeValue] = useState(0) + const [unit, setUnit] = useState() + const [isDisabled, setIsDisabled] = useState(false) + + const handleClose = () => { + onClose() + setTimeValue(0) + setUnit(undefined) + } + + const alertDate = useMemo( + () => (unit ? getAlertDate(finish_at, timeValue, unit) : null), + [finish_at, unit, timeValue] + ) + + useEffect(() => { + setIsDisabled(!alertDate) + }, [alertDate]) + + const alertUrl = getGoogleCalendarUrl({ + title: t(`modal.calendar_alert.calendar_title`, { title }), + details: t(`modal.calendar_alert.calendar_details`, { + finish_date: Time.from(finish_at).format('MMM DD, YYYY HH:mm'), + url: proposalUrl(id), + }), + startAt: alertDate!, + }) + + return ( + }> + +
+
{t('modal.calendar_alert.title')}
+
+
+ +
+
+ + +
+
+
+ ) +} + +export default CalendarAlertModal diff --git a/src/components/ProjectRequest/NumberSelector.css b/src/components/ProjectRequest/NumberSelector.css index bc3df2cb7..6224ac8f3 100644 --- a/src/components/ProjectRequest/NumberSelector.css +++ b/src/components/ProjectRequest/NumberSelector.css @@ -1,5 +1,5 @@ -.NumberSelector { - max-width: 210px; +.NumberSelector__Container { + max-width: fit-content; } .NumberSelector__InputContainer { @@ -23,6 +23,23 @@ border-left: 1px solid var(--alpha-black-400); } +.ui.dropdown.NumberSelector__Dropdown { + min-width: unset; + border: unset; + border-left: 1px solid var(--alpha-black-400); +} + +.ui.dropdown.NumberSelector__Dropdown .text, +.ui.dropdown.active.NumberSelector__Dropdown .text { + text-transform: unset; + font-weight: var(--weight-normal); + color: var(--black-600) !important; +} + +.ui.dropdown.NumberSelector__Dropdown > .dropdown.icon { + color: var(--black-600); +} + .NumberSelector__Input { display: flex; align-items: center; diff --git a/src/components/ProjectRequest/NumberSelector.tsx b/src/components/ProjectRequest/NumberSelector.tsx index bf3ca82ab..3242afa98 100644 --- a/src/components/ProjectRequest/NumberSelector.tsx +++ b/src/components/ProjectRequest/NumberSelector.tsx @@ -1,5 +1,9 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useEffect } from 'react' +import { UnitTypeLongPlural } from 'dayjs' +import { Dropdown } from 'decentraland-ui/dist/components/Dropdown/Dropdown' + +import useFormatMessage from '../../hooks/useFormatMessage' import Label from '../Common/Typography/Label' import Markdown from '../Common/Typography/Markdown' import Add from '../Icon/Add' @@ -13,11 +17,13 @@ interface Props { min: number max: number label: string - unitLabel: string + unit: UnitTypeLongPlural | UnitTypeLongPlural[] subtitle?: string + onUnitChange?: (unit: UnitTypeLongPlural) => void } -const NumberSelector = ({ value, onChange, min, max, label, unitLabel, subtitle }: Props) => { +const NumberSelector = ({ value, onChange, min, max, label, unit, onUnitChange, subtitle }: Props) => { + const t = useFormatMessage() const handleAddClick = useCallback(() => { if (value === max) { return @@ -34,10 +40,30 @@ const NumberSelector = ({ value, onChange, min, max, label, unitLabel, subtitle onChange(value - 1) }, [onChange, min, value]) + const handleUnitChange = useCallback( + (unit: UnitTypeLongPlural) => { + if (onUnitChange) { + onUnitChange(unit) + } + }, + [onUnitChange] + ) + const isUnitArray = Array.isArray(unit) + + useEffect(() => { + if (isUnitArray && unit.length > 0) { + handleUnitChange(unit[0]) + } + }, [handleUnitChange, isUnitArray, unit]) + + const getUnitLabel = (unit: UnitTypeLongPlural) => t(`general.time_units.${unit}`) + const getUnitOptions = (units: UnitTypeLongPlural[]) => + units.map((unit) => ({ key: unit, value: unit, text: getUnitLabel(unit) })) + return (
-
+
-
{unitLabel}
+ {!isUnitArray ? ( +
{getUnitLabel(unit)}
+ ) : ( + handleUnitChange(value as UnitTypeLongPlural)} + /> + )}
{subtitle && ( diff --git a/src/components/Proposal/ProposalSidebar.tsx b/src/components/Proposal/ProposalSidebar.tsx index 39ac80592..8499cfdb2 100644 --- a/src/components/Proposal/ProposalSidebar.tsx +++ b/src/components/Proposal/ProposalSidebar.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React, { useMemo, useState } from 'react' import useAuthContext from 'decentraland-gatsby/dist/context/Auth/useAuthContext' @@ -10,7 +10,9 @@ import { isProposalStatusWithUpdates } from '../../entities/Updates/utils' import { SelectedVoteChoice, Vote } from '../../entities/Votes/types' import { calculateResult } from '../../entities/Votes/utils' import { ProposalPageState } from '../../pages/proposal' +import CalendarAlertModal from '../Modal/CalendarAlertModal' +import CalendarAlertButton from './View/CalendarAlertButton' import ForumButton from './View/ForumButton' import ProposalCoAuthorStatus from './View/ProposalCoAuthorStatus' import ProposalDetailSection from './View/ProposalDetailSection' @@ -80,6 +82,8 @@ export default function ProposalSidebar({ const choices: string[] = proposal?.snapshot_proposal?.choices || EMPTY_VOTE_CHOICES const partialResults = useMemo(() => calculateResult(choices, highQualityVotes || {}), [choices, highQualityVotes]) + const [isCalendarModalOpen, setIsCalendarModalOpen] = useState(false) + const handleVoteClick = (selectedChoice: SelectedVoteChoice) => { if (voteWithSurvey) { updatePageState({ @@ -107,6 +111,7 @@ export default function ProposalSidebar({ ) const showVestingContract = proposal?.vesting_addresses && proposal?.vesting_addresses.length > 0 + const isCalendarButtonDisabled = !proposal || proposal.status !== ProposalStatus.Active return ( <> @@ -145,8 +150,20 @@ export default function ProposalSidebar({ subscribed={subscribed} onClick={() => subscribe(!subscribed)} /> + setIsCalendarModalOpen(true)} + /> {proposal && } {proposal && } + {proposal && ( + setIsCalendarModalOpen(false)} + /> + )}
) diff --git a/src/components/Proposal/View/CalendarAlertButton.tsx b/src/components/Proposal/View/CalendarAlertButton.tsx new file mode 100644 index 000000000..f84da1f2f --- /dev/null +++ b/src/components/Proposal/View/CalendarAlertButton.tsx @@ -0,0 +1,24 @@ +import React from 'react' + +import useFormatMessage from '../../../hooks/useFormatMessage' +import CalendarAdd from '../../Icon/CalendarAdd' + +import SidebarButton from './SidebarButton' + +interface Props { + loading?: boolean + disabled?: boolean + onClick: () => void +} + +function CalendarAlertButton({ loading, disabled, onClick }: Props) { + const t = useFormatMessage() + return ( + + + {t('page.proposal_detail.calendar_button')} + + ) +} + +export default CalendarAlertButton diff --git a/src/components/Proposal/View/SectionButton.css b/src/components/Proposal/View/SectionButton.css index 822010388..85f60cf79 100644 --- a/src/components/Proposal/View/SectionButton.css +++ b/src/components/Proposal/View/SectionButton.css @@ -19,13 +19,14 @@ .SectionButton.SectionButton--disabled span, .SectionButton.SectionButton--disabled img, +.SectionButton.SectionButton--disabled svg, .SectionButton.SectionButton--loading span, +.SectionButton.SectionButton--loading svg, .SectionButton.SectionButton--loading img { opacity: 0.5; } -.SectionButton.SectionButton--disabled span, -.SectionButton.SectionButton--disabled img { +.SectionButton.SectionButton--disabled { pointer-events: none; } diff --git a/src/components/Proposal/View/SidebarButton.tsx b/src/components/Proposal/View/SidebarButton.tsx new file mode 100644 index 000000000..cca255f3e --- /dev/null +++ b/src/components/Proposal/View/SidebarButton.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +import classNames from 'classnames' + +import './DetailsSection.css' +import './SectionButton.css' + +interface Props { + loading?: boolean + disabled?: boolean + onClick?: () => void + children?: React.ReactNode +} + +function SidebarButton({ loading, disabled, onClick, children }: Props) { + return ( + + ) +} + +export default SidebarButton diff --git a/src/components/Proposal/View/SubscribeButton.tsx b/src/components/Proposal/View/SubscribeButton.tsx index 984062afa..06f085d31 100644 --- a/src/components/Proposal/View/SubscribeButton.tsx +++ b/src/components/Proposal/View/SubscribeButton.tsx @@ -1,14 +1,12 @@ import React from 'react' -import classNames from 'classnames' import { Loader } from 'decentraland-ui/dist/components/Loader/Loader' import useFormatMessage from '../../../hooks/useFormatMessage' import Subscribe from '../../Icon/Subscribe' import Subscribed from '../../Icon/Subscribed' -import './DetailsSection.css' -import './SectionButton.css' +import SidebarButton from './SidebarButton' interface Props { loading: boolean @@ -21,22 +19,10 @@ export default function SubscribeButton({ loading, disabled, subscribed, onClick const t = useFormatMessage() return ( - + + + {subscribed ? : } + {t(subscribed ? 'page.proposal_detail.subscribed_button' : 'page.proposal_detail.subscribe_button')} + ) } diff --git a/src/intl/en.json b/src/intl/en.json index 7b9e4c422..dd84fada5 100644 --- a/src/intl/en.json +++ b/src/intl/en.json @@ -5,7 +5,16 @@ "count_votes": "{count} {count, plural, one {vote} other {votes}}", "sign_in": "sign in to vote", "jump_in": "jump in", - "cancel": "cancel" + "cancel": "cancel", + "time_units": { + "milliseconds": "Milliseconds", + "seconds": "Seconds", + "minutes": "Minutes", + "hours": "Hours", + "days": "Days", + "months": "Months", + "years": "Years" + } }, "navigation": { "home": "DAO Home", @@ -445,6 +454,14 @@ "accept": "Delete", "reject": "Cancel" }, + "calendar_alert": { + "title": "Setting a calendar alert", + "label": "Notify me this long before voting ends", + "add_to_calendar": "Add to calendar", + "cancel": "Cancel", + "calendar_title": "Proposal \"{title}\" is about to end", + "calendar_details": "The voting period will finish on {finish_date}.\n\nCheck the current status at {url}" + }, "update_status_proposal": { "title": "Confirm Proposal Enactment", "description": "Mark this proposal as **{status}**, add vesting contracts, or edit enactment data using this tool.", @@ -940,6 +957,7 @@ "forum_button": "Discuss in the forum", "subscribe_button": "Add to my Watchlist", "subscribed_button": "Remove from my Watchlist", + "calendar_button": "Set calendar alert", "leading_option_label": "Leading: ", "finished_result_label": "Result: ", "your_vote_label": "Your vote: ", diff --git a/src/utils/projects.ts b/src/utils/projects.ts index 889e7277f..db6c83faa 100644 --- a/src/utils/projects.ts +++ b/src/utils/projects.ts @@ -21,7 +21,7 @@ export function getGoogleCalendarUrl({ const startAtDate = Time.from(startAt, { utc: true }) const dates = [ startAtDate.format(Time.Formats.GoogleCalendar), - Time.from(startAt, { utc: true }).add(1, 'hour').format(Time.Formats.GoogleCalendar), + Time.from(startAt, { utc: true }).add(15, 'minutes').format(Time.Formats.GoogleCalendar), ] params.set('dates', dates.join('/'))