From 980a3cae36c9ebc7223421485cfc9182885d8fd2 Mon Sep 17 00:00:00 2001
From: Conrad VanLandingham <conrad@differential.com>
Date: Thu, 29 Feb 2024 16:00:10 -0600
Subject: [PATCH] Add Event Block

---
 .../components/AddToCalendar/AddToCalendar.js |  93 +++++++++++
 .../AddToCalendar/AddToCalendar.styles.js     |  65 ++++++++
 .../components/AddToCalendar/index.js         |   2 +
 .../FeatureFeed/FeatureFeedComponentMap.js    |   2 +
 .../FeatureFeed/Features/EventBlockFeature.js | 150 ++++++++++++++++++
 .../Features/EventBlockFeature.styles.js      |  83 ++++++++++
 .../components/FeatureFeed/Features/index.js  |   2 +
 packages/web-shared/hooks/useFeatureFeed.js   |  10 ++
 8 files changed, 407 insertions(+)
 create mode 100644 packages/web-shared/components/AddToCalendar/AddToCalendar.js
 create mode 100644 packages/web-shared/components/AddToCalendar/AddToCalendar.styles.js
 create mode 100644 packages/web-shared/components/AddToCalendar/index.js
 create mode 100644 packages/web-shared/components/FeatureFeed/Features/EventBlockFeature.js
 create mode 100644 packages/web-shared/components/FeatureFeed/Features/EventBlockFeature.styles.js

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 (
+    <Menu as="div" style={{ position: 'relative' }}>
+      <ActionIcon>
+        <CalendarPlus size={16} weight="bold" />
+      </ActionIcon>
+      <List>
+        <Menu.Item>
+          <MenuLink href={convertToIcsLink({ start, duration, allDay, location, title })}>
+            <AppleLogo size={14} weight="fill" />
+            &nbsp;Apple Calendar
+          </MenuLink>
+        </Menu.Item>
+        <Menu.Item>
+          <MenuLink
+            href={convertToGoogleLink({ start, duration, allDay, location, title })}
+            target="blank"
+          >
+            <GoogleLogo size={14} weight="fill" />
+            &nbsp;Google Calendar
+          </MenuLink>
+        </Menu.Item>
+        <Menu.Item>
+          <MenuLink
+            href={convertToOutlookLink({ start, duration, allDay, location, title })}
+            target="blank"
+          >
+            <MicrosoftOutlookLogo size={14} weight="fill" />
+            &nbsp;Microsoft Outlook
+          </MenuLink>
+        </Menu.Item>
+        <Menu.Item>
+          <MenuLink
+            href={convertToIcsLink({ start, duration, allDay, location, title })}
+            target="blank"
+          >
+            <FileArrowDown size={14} weight="fill" />
+            &nbsp;Download .ics
+          </MenuLink>
+        </Menu.Item>
+      </List>
+    </Menu>
+  );
+};
+
+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 (
+    <Container>
+      <LineItem>
+        <IconContainer>
+          <Clock size={24} fill={theme.colors.base.primary} weight="fill" />
+        </IconContainer>
+        <SeparatorContainer last={!location}>
+          <Details>
+            <BodyText>{timelines[0]}</BodyText>
+            {timelines[1] ? <BodyText color="text.secondary">{timelines[1]}</BodyText> : undefined}
+          </Details>
+          <AddToCalendar
+            allDay={allDay}
+            start={start}
+            duration={duration}
+            location={location}
+            title={title}
+          />
+        </SeparatorContainer>
+      </LineItem>
+      {location ? (
+        <LineItem>
+          <IconContainer>
+            <MapPin size={24} fill={theme.colors.base.primary} weight="fill" />
+          </IconContainer>
+          <SeparatorContainer last>
+            <Details>
+              <BodyText dangerouslySetInnerHTML={{ __html: location }} />
+            </Details>
+            <ActionIcon href={convertAddressToGoogleMapLink(location)} target="blank">
+              <ArrowSquareOut className="h-5 w-5" weight="bold" />
+            </ActionIcon>
+          </SeparatorContainer>
+        </LineItem>
+      ) : null}
+    </Container>
+  );
+};
+
+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