Skip to content

Commit

Permalink
Merge pull request #293 from HiEventsDev/feature/checkout-flow-UX-imp…
Browse files Browse the repository at this point in the history
…rovements
  • Loading branch information
daveearley authored Nov 22, 2024
2 parents e4b1d7a + 92de2a7 commit 5d72612
Show file tree
Hide file tree
Showing 21 changed files with 670 additions and 217 deletions.
12 changes: 11 additions & 1 deletion backend/app/Services/Handlers/Order/GetOrderPublicHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\OrganizerDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract;
use HiEvents\DomainObjects\ImageDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Exceptions\UnauthorizedException;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
Expand Down Expand Up @@ -79,6 +82,13 @@ private function getOrderDomainObject(GetOrderPublicDTO $getOrderData): ?OrderDo
nested: [
new Relationship(
domainObject: EventSettingDomainObject::class,
),
new Relationship(
domainObject: OrganizerDomainObject::class,
name: OrganizerDomainObjectAbstract::SINGULAR_NAME,
),
new Relationship(
domainObject: ImageDomainObject::class,
)
],
name: EventDomainObjectAbstract::SINGULAR_NAME
Expand Down
143 changes: 143 additions & 0 deletions frontend/src/components/common/AddEventToCalendarButton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {ActionIcon, Button, Popover, Stack, Text, Tooltip} from '@mantine/core';
import {IconBrandGoogle, IconCalendarPlus, IconDownload} from '@tabler/icons-react';
import {t} from "@lingui/macro";

interface LocationDetails {
venue_name?: string;

[key: string]: any;
}

interface EventSettings {
location_details?: LocationDetails;
}

interface Event {
title: string;
description_preview?: string;
description?: string;
start_date: string;
end_date?: string;
settings?: EventSettings;
}

interface AddToCalendarProps {
event: Event;
}

const eventLocation = (event: Event): string => {
if (event.settings?.location_details) {
const details = event.settings.location_details;
const addressParts = [];

if (details.street_address) addressParts.push(details.street_address);
if (details.street_address_2) addressParts.push(details.street_address_2);
if (details.city) addressParts.push(details.city);
if (details.state) addressParts.push(details.state);
if (details.postal_code) addressParts.push(details.postal_code);
if (details.country) addressParts.push(details.country);

const address = addressParts.join(', ');

if (details.venue_name) {
return `${details.venue_name}, ${address}`;
}

return address;
}

return '';
};

const createICSContent = (event: Event): string => {
const formatDate = (date: string): string => {
return new Date(date).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
};

const stripHtml = (html: string): string => {
const tmp = document.createElement('div');
tmp.innerHTML = html || '';
return tmp.textContent || tmp.innerText || '';
};

return [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Hi.Events//NONSGML Event Calendar//EN',
'CALSCALE:GREGORIAN',
'BEGIN:VEVENT',
`DTSTART:${formatDate(event.start_date)}`,
`DTEND:${formatDate(event.end_date || event.start_date)}`,
`SUMMARY:${event.title.replace(/\n/g, '\\n')}`,
`DESCRIPTION:${stripHtml(event.description || '').replace(/\n/g, '\\n')}`,
`LOCATION:${eventLocation(event)}`,
`DTSTAMP:${formatDate(new Date().toISOString())}`,
`UID:${crypto.randomUUID()}@hi.events`,
'END:VEVENT',
'END:VCALENDAR'
].join('\r\n');
};

const downloadICSFile = (event: Event): void => {
const content = createICSContent(event);
const blob = new Blob([content], {type: 'text/calendar;charset=utf-8'});
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.setAttribute('download', `${event.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.ics`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};

const createGoogleCalendarUrl = (event: Event): string => {
const formatGoogleDate = (date: string): string => {
return new Date(date).toISOString().replace(/-|:|\.\d{3}/g, '');
};

const params = new URLSearchParams({
action: 'TEMPLATE',
text: event.title,
details: event.description_preview || '',
location: eventLocation(event),
dates: `${formatGoogleDate(event.start_date)}/${formatGoogleDate(event.end_date || event.start_date)}`
});

return `https://calendar.google.com/calendar/render?${params.toString()}`;
};

export const AddToEventCalendarButton = ({event}: AddToCalendarProps) => {
return (
<Popover width={200} position="bottom" withArrow shadow="md">
<Popover.Target>
<Tooltip label={t`Add to Calendar`}>
<ActionIcon variant="subtle">
<IconCalendarPlus size={20}/>
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="sm" fw={500}>{t`Add to Calendar`}</Text>
<Button
variant="light"
size="xs"
leftSection={<IconBrandGoogle size={16}/>}
onClick={() => window.open(createGoogleCalendarUrl(event), '_blank')}
fullWidth
>
{t`Google Calendar`}
</Button>
<Button
variant="light"
size="xs"
leftSection={<IconDownload size={16}/>}
onClick={() => downloadICSFile(event)}
fullWidth
>
{t`Download .ics`}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
61 changes: 48 additions & 13 deletions frontend/src/components/common/Countdown/index.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,37 @@
import { useEffect, useState } from 'react';
import {useEffect, useState} from 'react';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { t } from '@lingui/macro';
import {t} from '@lingui/macro';
import classNames from "classnames";

dayjs.extend(utc);

interface CountdownProps {
targetDate: string;
onExpiry?: () => void;
className?: string;
closeToExpiryClassName?: string;
displayType?: 'short' | 'long';
}

export const Countdown = ({ targetDate, onExpiry, className = '' }: CountdownProps) => {
export const Countdown = ({
targetDate,
onExpiry,
className = '',
displayType = 'long',
closeToExpiryClassName = ''
}: CountdownProps) => {
const [timeLeft, setTimeLeft] = useState('');
const [closeToExpiry, setCloseToExpiry] = useState(false);

useEffect(() => {
const interval = setInterval(() => {
const now = dayjs();

const dateInUTC = dayjs.utc(targetDate);

const diff = dateInUTC.diff(now);

if (diff <= 0) {
setTimeLeft(t`0 minutes and 0 seconds`);
setTimeLeft(displayType === 'short' ? '0:00' : t`0 minutes and 0 seconds`);
clearInterval(interval);
onExpiry && onExpiry();
return;
Expand All @@ -34,19 +42,46 @@ export const Countdown = ({ targetDate, onExpiry, className = '' }: CountdownPro
const minutes = Math.floor((diff / 1000 / 60) % 60);
const seconds = Math.floor((diff / 1000) % 60);

if (days > 0) {
setTimeLeft(t`${days} days, ${hours} hours, ${minutes} minutes, and ${seconds} seconds`);
} else if (hours > 0) {
setTimeLeft(t`${hours} hours, ${minutes} minutes, and ${seconds} seconds`);
if (!closeToExpiry) {
setCloseToExpiry(days === 0 && hours === 0 && minutes < 5)
}

if (displayType === 'short') {
const totalHours = days * 24 + hours;
const formattedMinutes = String(minutes).padStart(2, '0');

let display: string;

if (totalHours > 0) {
display = `${totalHours}:${formattedMinutes}:${String(seconds).padStart(2, '0')}`;
} else if (minutes > 0) {
display = seconds > 0
? `${minutes}:${String(seconds).padStart(2, '0')}`
: `${minutes}:00`;
} else {
display = String(seconds);
}

setTimeLeft(display);
} else {
setTimeLeft(t`${minutes} minutes and ${seconds} seconds`);
if (days > 0) {
setTimeLeft(t`${days} days, ${hours} hours, ${minutes} minutes, and ${seconds} seconds`);
} else if (hours > 0) {
setTimeLeft(t`${hours} hours, ${minutes} minutes, and ${seconds} seconds`);
} else {
setTimeLeft(t`${minutes} minutes and ${seconds} seconds`);
}
}
}, 1000);

return () => {
clearInterval(interval);
};
}, [targetDate, onExpiry]);
}, [targetDate, onExpiry, displayType]);

return <span className={className}>{timeLeft === '' ? '...' : timeLeft}</span>;
return (
<span className={classNames(className, closeToExpiry ? closeToExpiryClassName : '')}>
{timeLeft === '' ? '--:--' : timeLeft}
</span>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {EventSettings} from "../../../types.ts";
export const OnlineEventDetails = (props: { eventSettings: EventSettings }) => {
return <>
{(props.eventSettings.is_online_event && props.eventSettings.online_event_connection_details) && (
<div style={{marginTop: "40px"}}>
<div style={{marginTop: "40px", marginBottom: "40px"}}>
<h2>{t`Online Event Details`}</h2>
<Card>
<div
Expand Down
34 changes: 26 additions & 8 deletions frontend/src/components/common/ShareIcon/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {useState} from 'react';
import {ActionIcon, CopyButton, Group, Input, Popover, Button} from '@mantine/core';
import {ActionIcon, Button, CopyButton, Group, Input, Popover} from '@mantine/core';
import {
IconBrandFacebook,
IconBrandLinkedin,
IconBrandTwitter,
IconBrandWhatsapp,
IconCheck,
IconCopy, IconMail,
IconCopy,
IconMail,
IconShare
} from "@tabler/icons-react";
import {t} from "@lingui/macro";
Expand All @@ -16,9 +17,17 @@ interface ShareComponentProps {
text: string;
url: string;
imageUrl?: string;
shareButtonText?: string;
hideShareButtonText?: boolean;
}

export const ShareComponent = ({title, text, url}: ShareComponentProps) => {
export const ShareComponent = ({
title,
text,
url,
shareButtonText = t`Share`,
hideShareButtonText = false,
}: ShareComponentProps) => {
const [opened, setOpened] = useState(false);

let shareText = text;
Expand Down Expand Up @@ -49,12 +58,21 @@ export const ShareComponent = ({title, text, url}: ShareComponentProps) => {
withArrow
>
<Popover.Target>
<Button variant={'transparent'} leftSection={<IconShare size={20}/>} onClick={handleShareClick}>
{t`Share`}
</Button>
<div style={{display: 'flex'}}>
{hideShareButtonText && (
<ActionIcon variant={'transparent'} onClick={handleShareClick}>
<IconShare size={20}/>
</ActionIcon>
)}

{!hideShareButtonText && (
<Button variant={'transparent'} leftSection={<IconShare size={20}/>} onClick={handleShareClick}>
{hideShareButtonText ? '' : shareButtonText}
</Button>)}
</div>
</Popover.Target>

<Popover.Dropdown>
<Popover.Dropdown style={{display: 'flex'}}>
<Group>
<ActionIcon variant={'transparent'} component="a"
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(url)}`}
Expand Down Expand Up @@ -84,7 +102,7 @@ export const ShareComponent = ({title, text, url}: ShareComponentProps) => {
</Group>
<Input rightSectionPointerEvents={'all'} mt={10} value={url} rightSection={(
<CopyButton value={url}>
{({ copied, copy }) => (
{({copied, copy}) => (
<ActionIcon variant={'transparent'} onClick={copy}>
{copied ? <IconCheck/> : <IconCopy/>}
</ActionIcon>
Expand Down
Loading

0 comments on commit 5d72612

Please sign in to comment.