diff --git a/packages/web-shared/components/AddToCalendar/AddToCalendar.js b/packages/web-shared/components/AddToCalendar/AddToCalendar.js new file mode 100644 index 00000000..1cb14aaa --- /dev/null +++ b/packages/web-shared/components/AddToCalendar/AddToCalendar.js @@ -0,0 +1,93 @@ +import React from 'react'; +import { Menu } from '@headlessui/react'; +import { + CalendarPlus, + AppleLogo, + GoogleLogo, + MicrosoftOutlookLogo, + FileArrowDown, +} from '@phosphor-icons/react'; +import { ActionIcon, List, MenuLink } from './AddToCalendar.styles'; +import { addSeconds, parseISO } from 'date-fns'; + +function convertToIcsLink({ start, duration, location, allDay, title }) { + const startDate = parseISO(start); + const endDate = addSeconds(startDate, duration); + const startDateString = startDate.toISOString().replace(/-|:|\.\d+/g, ''); + const endDateString = endDate.toISOString().replace(/-|:|\.\d+/g, ''); + const locationString = (location || '').replace(/<[^>]+>/g, ' '); + return `data:text/calendar;charset=utf8,BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART:${startDateString} +DTEND:${endDateString} +SUMMARY:${title} +LOCATION:${locationString} +END:VEVENT +END:VCALENDAR`; +} + +function convertToGoogleLink({ start, duration, location, allDay, title }) { + const startDate = parseISO(start); + const endDate = addSeconds(startDate, duration); + const startDateString = startDate.toISOString().replace(/-|:|\.\d+/g, ''); + const endDateString = endDate.toISOString().replace(/-|:|\.\d+/g, ''); + const locationString = (location || '').replace(/<[^>]+>/g, ' '); + return `https://www.google.com/calendar/render?action=TEMPLATE&text=${title}&dates=${startDateString}/${endDateString}&location=${locationString}`; +} + +function convertToOutlookLink({ start, duration, location, allDay, title }) { + const startDate = parseISO(start); + const endDate = addSeconds(startDate, duration); + const startDateString = startDate.toISOString().replace(/-|:|\.\d+/g, ''); + const endDateString = endDate.toISOString().replace(/-|:|\.\d+/g, ''); + const locationString = (location || '').replace(/<[^>]+>/g, ' '); + return `https://outlook.live.com/calendar/action/compose&rru=addevent&startdt=${startDateString}&enddt=${endDateString}&subject=${title}&location=${locationString}`; +} + +const AddToCalendar = ({ start, duration, allDay, location, title = 'Event' }) => { + return ( + + + + + + + + +  Apple Calendar + + + + + +  Google Calendar + + + + + +  Microsoft Outlook + + + + + +  Download .ics + + + + + ); +}; + +export default AddToCalendar; diff --git a/packages/web-shared/components/AddToCalendar/AddToCalendar.styles.js b/packages/web-shared/components/AddToCalendar/AddToCalendar.styles.js new file mode 100644 index 00000000..e9de00f0 --- /dev/null +++ b/packages/web-shared/components/AddToCalendar/AddToCalendar.styles.js @@ -0,0 +1,65 @@ +import React from 'react'; +import { Menu } from '@headlessui/react'; +import styled from 'styled-components'; +import { withTheme } from 'styled-components'; +import { rgba } from 'polished'; + +import { themeGet } from '@styled-system/theme-get'; +import { system } from '../../ui-kit/_lib/system'; + +export const ActionIcon = withTheme(styled(Menu.Button)` + // flex items-center justify-center p-2 bg-gray-50 rounded-full + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background-color: ${themeGet('colors.fill.system4')}; + transition: background-color ${themeGet('timing.xl')}; + border-radius: 100%; + cursor: pointer; + border: none; + outline: none; + + &:hover { + background-color: ${themeGet('colors.fill.system2')}; + } + ${system} +`); + +export const List = withTheme(styled(Menu.Items)` + position: absolute; + right: 0; + top: 38px; + min-width: 200px; + color: ${themeGet('colors.text.primary')}; + border-radius: ${themeGet('radii.xl')}; + background-color: ${themeGet('colors.fill.paper')}; + box-shadow: ${themeGet('shadows.medium')}; + overflow: hidden; + padding: ${themeGet('space.xxs')}; + ${system} +`); + +export const MenuContainer = withTheme(styled(Menu)` + position: relative; + ${system} +`); + +export const MenuLink = withTheme(styled.a` + display: flex; + align-items: center; + padding: ${themeGet('space.xxs')} ${themeGet('space.xs')}; + color: ${themeGet('colors.text.primary')}; + margin-bottom: ${themeGet('space.xxs')}; + border-radius: ${themeGet('radii.base')}; + transition: all ${themeGet('timing.l')}; + text-align: left; + cursor: pointer; + + &:hover { + background-color: ${(props) => rgba(themeGet('colors.base.secondary')(props), 0.15)}; + color: ${themeGet('colors.base.secondary')}; + } + ${system} +`); diff --git a/packages/web-shared/components/AddToCalendar/index.js b/packages/web-shared/components/AddToCalendar/index.js new file mode 100644 index 00000000..ad0a8ee8 --- /dev/null +++ b/packages/web-shared/components/AddToCalendar/index.js @@ -0,0 +1,2 @@ +import AddToCalendar from './AddToCalendar'; +export default AddToCalendar; diff --git a/packages/web-shared/components/FeatureFeed/FeatureFeedComponentMap.js b/packages/web-shared/components/FeatureFeed/FeatureFeedComponentMap.js index f2cf935e..ebc18943 100644 --- a/packages/web-shared/components/FeatureFeed/FeatureFeedComponentMap.js +++ b/packages/web-shared/components/FeatureFeed/FeatureFeedComponentMap.js @@ -8,6 +8,7 @@ import { ActionBarFeature, ActionListFeature, ChipListFeature, + EventBlockFeature, PrayerListFeature, ScriptureFeature, } from './Features'; @@ -23,6 +24,7 @@ const FeatureFeedComponentMap = { ActionBarFeature, ActionListFeature, ChipListFeature, + EventBlockFeature, PrayerListFeature, ScriptureFeature, }; diff --git a/packages/web-shared/components/FeatureFeed/Features/EventBlockFeature.js b/packages/web-shared/components/FeatureFeed/Features/EventBlockFeature.js new file mode 100644 index 00000000..1664e303 --- /dev/null +++ b/packages/web-shared/components/FeatureFeed/Features/EventBlockFeature.js @@ -0,0 +1,150 @@ +import React from 'react'; +import { ArrowSquareOut, MapPin, CalendarPlus, Clock } from '@phosphor-icons/react'; +import { + Container, + IconContainer, + LineItem, + SeparatorContainer, + Details, + ActionIcon, +} from './EventBlockFeature.styles'; +import { addSeconds, isSameDay, parseISO } from 'date-fns'; + +import { useTheme } from 'styled-components'; +import AddToCalendar from '../../AddToCalendar'; +import { BodyText } from '../../../ui-kit'; + +function eventTimestampLines({ start, duration, allDay }) { + const startDate = parseISO(start); + const endDate = addSeconds(startDate, duration); + + // native JS .toLocaleDateString() coming in clutch! + + if (allDay) { + // For all-day events + if (isSameDay(startDate, endDate) || duration === 0) { + // If the event ends on the same day or has no duration + // e.g. "Wednesday June 24, 2024" + return [ + startDate.toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }), + ]; + } else { + // For multi-day all-day events + // e.g. "Wednesday June 24 to", "Sunday June 28, 2024" + return [ + `${startDate.toLocaleDateString(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric', + })}`, + `to ${endDate.toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + })}`, + ]; + } + } else { + // For events with specific times + if (isSameDay(startDate, endDate)) { + // If the event starts and ends on the same day + // e.g. "Wednesday June 24, 2024", "9:00am to 4:00pm CST" + return [ + startDate.toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }), + `${startDate.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: 'numeric', + })} to ${endDate.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short', + })}`, + ]; + } else { + // For events that span multiple days + // e.g. "Wednesday June 24, 2024 5:00am to Sunday June 28, 2024 7:00pm CST" + return [ + `${startDate.toLocaleDateString(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + })}`, + `to ${endDate.toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short', + })}`, + ]; + } + } +} + +// convert address html to google map link +function convertAddressToGoogleMapLink(html) { + const address = (html || '').replace(/<[^>]+>/g, ' '); + if (address.startsWith('http')) return address; // handle links + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`; +} + +const EventBlockFeature = ({ feature }) => { + const { allDay, start, duration, location, title } = feature; + const timelines = eventTimestampLines({ start, duration, allDay }); + const theme = useTheme(); + + return ( + + + + + + +
+ {timelines[0]} + {timelines[1] ? {timelines[1]} : undefined} +
+ +
+
+ {location ? ( + + + + + +
+ +
+ + + +
+
+ ) : null} +
+ ); +}; + +export default EventBlockFeature; diff --git a/packages/web-shared/components/FeatureFeed/Features/EventBlockFeature.styles.js b/packages/web-shared/components/FeatureFeed/Features/EventBlockFeature.styles.js new file mode 100644 index 00000000..f1934ac8 --- /dev/null +++ b/packages/web-shared/components/FeatureFeed/Features/EventBlockFeature.styles.js @@ -0,0 +1,83 @@ +import styled from 'styled-components'; +import { withTheme } from 'styled-components'; +import { themeGet } from '@styled-system/theme-get'; +import { rgba } from 'polished'; + +import { system } from '../../../ui-kit/_lib/system'; + +export const Container = withTheme(styled.ul` + display: flex; + flex-direction: column; + margin-bottom: ${themeGet('space.base')}; + color: ${themeGet('colors.text.primary')}; + border-radius: ${themeGet('radii.xl')}; + background-color: ${themeGet('colors.fill.paper')}; + box-shadow: ${themeGet('shadows.medium')}; + ${system} +`); + +export const LineItem = withTheme(styled.li` + // flex flex-wrap items-center justify-center gap-x-3 self-stretch pl-3 + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: ${themeGet('space.s')}; + padding-left: ${themeGet('space.s')}; + ${system} +`); + +export const IconContainer = withTheme(styled.div` + display: flex; + width: 38px; + height: 38px; + align-items: center; + justify-content: center; + border-radius: ${themeGet('radii.l')}; + background-color: ${(props) => rgba(themeGet('colors.base.primary')(props), 0.15)}; + ${system} +`); + +export const SeparatorContainer = withTheme(styled.div` + // flex flex-1 items-center gap-x-3 self-stretch py-3 pr-3 border-b border-gray-100 + display: flex; + flex: 1; + align-items: center; + align-self: stretch; + gap: ${themeGet('space.s')}; + padding: ${themeGet('space.s')} ${themeGet('space.s')} ${themeGet('space.s')} 0; + border-bottom: 1px solid ${themeGet('colors.fill.system2')}; + + ${({ last }) => + last && + ` + border-bottom: none; + `} +`); + +export const Details = withTheme(styled.div` + display: flex; + flex-direction: column; + flex: 1; + flex-grow: 1; + align-self: center; + ${system} +`); + +export const ActionIcon = withTheme(styled.a` + // flex items-center justify-center p-2 bg-gray-50 rounded-full + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background-color: ${themeGet('colors.fill.system4')}; + transition: background-color ${themeGet('timing.xl')}; + border-radius: 100%; + cursor: pointer; + + &:hover { + background-color: ${themeGet('colors.fill.system2')}; + } + ${system} +`); diff --git a/packages/web-shared/components/FeatureFeed/Features/index.js b/packages/web-shared/components/FeatureFeed/Features/index.js index 804e5491..11e9700a 100644 --- a/packages/web-shared/components/FeatureFeed/Features/index.js +++ b/packages/web-shared/components/FeatureFeed/Features/index.js @@ -2,6 +2,7 @@ import ActionBarFeature from './ActionBarFeature'; import ActionListFeature from './ActionListFeature'; import ButtonFeature from './ButtonFeature'; import ChipListFeature from './ChipListFeature'; +import EventBlockFeature from './EventBlockFeature'; import HeroListFeature from './HeroListFeature'; import HorizontalCardListFeature from './HorizontalCardListFeature'; import HorizontalMediaListFeature from './HorizontalMediaListFeature'; @@ -15,6 +16,7 @@ export { ActionListFeature, ButtonFeature, ChipListFeature, + EventBlockFeature, HeroListFeature, HorizontalCardListFeature, HorizontalMediaListFeature, diff --git a/packages/web-shared/hooks/useFeatureFeed.js b/packages/web-shared/hooks/useFeatureFeed.js index 84377377..ed1a4d5c 100644 --- a/packages/web-shared/hooks/useFeatureFeed.js +++ b/packages/web-shared/hooks/useFeatureFeed.js @@ -66,6 +66,16 @@ export const FEED_FEATURES = gql` } } } + + ... on EventBlockFeature { + id + start + duration + allDay + location + title + } + ... on HorizontalMediaListFeature { id title