Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Checkout flow UI improvements #293

Merged
merged 1 commit into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading