feat: implement calendar scheduling (#7574)
* wip * feat: add calendar scheduling * fix: update names * UI Changes for calendar scheduling --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>pull/7704/head
parent
e4c863bbf4
commit
2a6c1bfce8
15 changed files with 614 additions and 50 deletions
@ -0,0 +1,37 @@ |
||||
import type { SVGProps } from 'react'; |
||||
|
||||
export function AppleCalendarIcon(props: SVGProps<SVGSVGElement>) { |
||||
return ( |
||||
<svg |
||||
width="1736" |
||||
height="1693" |
||||
viewBox="0 0 1736 1693" |
||||
fill="none" |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
{...props} |
||||
> |
||||
<rect width="1736" height="1693" fill="#ECEFF1" /> |
||||
<rect x="1" width="1734" height="526" fill="#FF3D00" /> |
||||
<path |
||||
d="M724.689 300.13L750.665 128H805.4L756.691 401.947H701.224L669.269 240.501L637.587 401.947H581.892L533 128H588.101L613.894 299.947L646.032 128H692.505L724.689 300.13Z" |
||||
fill="white" |
||||
/> |
||||
<path |
||||
d="M976.776 283.419H890.632V356.061H992.617V401.947H835.303V128H992.206V174.069H890.632V238.812H976.776V283.419Z" |
||||
fill="white" |
||||
/> |
||||
<path |
||||
d="M1024.39 401.947V128H1096.84C1128.79 128 1154.31 138.182 1173.3 158.454C1192.29 178.771 1201.97 206.623 1202.34 242.008V286.433C1202.34 322.411 1192.84 350.673 1173.8 371.219C1154.86 391.674 1128.66 401.947 1095.28 401.947H1024.39ZM1079.72 174.069V356.015H1096.29C1114.73 356.015 1127.7 351.175 1135.23 341.45C1142.76 331.725 1146.73 314.969 1147.1 291.135V243.514C1147.1 217.946 1143.49 200.094 1136.37 189.958C1129.2 179.867 1117.06 174.571 1099.85 174.069H1079.72Z" |
||||
fill="white" |
||||
/> |
||||
<path |
||||
d="M831.353 1451.15H380.138V1345.95L587.348 1082.46C613.643 1045.98 632.999 1013.97 645.462 986.442C657.925 958.91 664.133 932.52 664.133 907.271C664.133 873.256 658.29 846.592 646.512 827.324C634.78 808.056 617.843 798.423 595.748 798.423C571.553 798.423 552.379 809.654 538.182 832.072C523.984 854.536 516.863 886.086 516.863 926.767H367.492C367.492 879.785 377.216 836.821 396.663 797.875C416.111 758.929 443.456 728.703 478.698 707.153C513.941 685.556 553.84 674.781 598.35 674.781C666.735 674.781 719.736 693.638 757.444 731.351C795.152 769.065 814.006 822.621 814.006 892.067C814.006 935.168 803.552 978.954 782.735 1023.29C761.872 1067.67 724.073 1122.27 669.383 1187.11L571.051 1327.55H831.353V1451.15Z" |
||||
fill="#424242" |
||||
/> |
||||
<path |
||||
d="M1354.1 888.871C1354.1 926.036 1346.21 959.001 1330.41 987.766C1314.62 1016.53 1292.89 1039.5 1265.22 1056.66C1296.77 1074.56 1321.69 1099.17 1339.91 1130.58C1358.12 1161.95 1367.25 1198.89 1367.25 1241.3C1367.25 1309.33 1347.62 1363.07 1308.36 1402.52C1269.1 1441.97 1215.6 1461.69 1147.94 1461.69C1080.29 1461.69 1026.47 1441.97 986.475 1402.52C946.53 1363.07 926.535 1309.33 926.535 1241.3C926.535 1198.89 935.62 1161.9 953.88 1130.35C972.095 1098.81 997.203 1074.24 1029.11 1056.71C1001.04 1039.54 979.171 1016.58 963.376 987.811C947.58 959.047 939.683 926.128 939.683 888.916C939.683 821.936 958.445 769.521 995.971 731.625C1033.45 693.729 1083.8 674.781 1146.89 674.781C1210.71 674.781 1261.2 693.912 1298.36 732.127C1335.52 770.343 1354.1 822.576 1354.1 888.871ZM1147.94 1338.05C1170.36 1338.05 1187.66 1328.46 1199.76 1309.38C1211.85 1290.29 1217.88 1263.72 1217.88 1229.71C1217.88 1195.69 1211.58 1169.07 1198.94 1149.76C1186.29 1130.45 1168.94 1120.81 1146.89 1120.81C1124.8 1120.81 1107.36 1130.45 1094.58 1149.76C1081.79 1169.07 1075.36 1195.69 1075.36 1229.71C1075.36 1263.72 1081.75 1290.29 1094.58 1309.38C1107.36 1328.51 1125.16 1338.05 1147.94 1338.05ZM1205.78 896.724C1205.78 866.909 1200.94 843.076 1191.31 825.224C1181.68 807.326 1166.89 798.377 1146.89 798.377C1127.95 798.377 1113.57 807.052 1103.8 824.402C1093.98 841.752 1089.05 865.859 1089.05 896.724C1089.05 926.904 1093.98 951.148 1103.8 969.594C1113.61 987.994 1128.31 997.217 1147.94 997.217C1167.57 997.217 1182.14 987.994 1191.59 969.594C1201.04 951.194 1205.78 926.904 1205.78 896.724Z" |
||||
fill="#424242" |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
@ -0,0 +1,22 @@ |
||||
import type { SVGProps } from 'react'; |
||||
|
||||
export function FileIcon(props: SVGProps<SVGSVGElement>) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
x="0px" |
||||
y="0px" |
||||
width={100} |
||||
height={100} |
||||
viewBox="0 0 48 48" |
||||
{...props} |
||||
> |
||||
<path fill="#90CAF9" d="M40 45L8 45 8 3 30 3 40 13z" /> |
||||
<path fill="#E1F5FE" d="M38.5 14L29 14 29 4.5z" /> |
||||
<path |
||||
fill="#1976D2" |
||||
d="M16 21H33V23H16zM16 25H29V27H16zM16 29H33V31H16zM16 33H29V35H16z" |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
@ -0,0 +1,40 @@ |
||||
import type { SVGProps } from 'react'; |
||||
|
||||
export function GoogleCalendarIcon(props: SVGProps<SVGSVGElement>) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
x="0px" |
||||
y="0px" |
||||
width="100" |
||||
height="100" |
||||
viewBox="0 0 48 48" |
||||
{...props} |
||||
> |
||||
<rect width="22" height="22" x="13" y="13" fill="#fff"></rect> |
||||
<polygon |
||||
fill="#1e88e5" |
||||
points="25.68,20.92 26.688,22.36 28.272,21.208 28.272,29.56 30,29.56 30,18.616 28.56,18.616" |
||||
></polygon> |
||||
<path |
||||
fill="#1e88e5" |
||||
d="M22.943,23.745c0.625-0.574,1.013-1.37,1.013-2.249c0-1.747-1.533-3.168-3.417-3.168 c-1.602,0-2.972,1.009-3.33,2.453l1.657,0.421c0.165-0.664,0.868-1.146,1.673-1.146c0.942,0,1.709,0.646,1.709,1.44 c0,0.794-0.767,1.44-1.709,1.44h-0.997v1.728h0.997c1.081,0,1.993,0.751,1.993,1.64c0,0.904-0.866,1.64-1.931,1.64 c-0.962,0-1.784-0.61-1.914-1.418L17,26.802c0.262,1.636,1.81,2.87,3.6,2.87c2.007,0,3.64-1.511,3.64-3.368 C24.24,25.281,23.736,24.363,22.943,23.745z" |
||||
></path> |
||||
<polygon |
||||
fill="#fbc02d" |
||||
points="34,42 14,42 13,38 14,34 34,34 35,38" |
||||
></polygon> |
||||
<polygon |
||||
fill="#4caf50" |
||||
points="38,35 42,34 42,14 38,13 34,14 34,34" |
||||
></polygon> |
||||
<path |
||||
fill="#1e88e5" |
||||
d="M34,14l1-4l-1-4H9C7.343,6,6,7.343,6,9v25l4,1l4-1V14H34z" |
||||
></path> |
||||
<polygon fill="#e53935" points="34,34 34,42 42,34"></polygon> |
||||
<path fill="#1565c0" d="M39,6h-5v8h8V9C42,7.343,40.657,6,39,6z"></path> |
||||
<path fill="#1565c0" d="M9,42h5v-8H6v5C6,40.657,7.343,42,9,42z"></path> |
||||
</svg> |
||||
); |
||||
} |
@ -0,0 +1,40 @@ |
||||
import type { SVGProps } from 'react'; |
||||
|
||||
export function OutlookCalendarIcon(props: SVGProps<SVGSVGElement>) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
x="0px" |
||||
y="0px" |
||||
width={100} |
||||
height={100} |
||||
viewBox="0 0 48 48" |
||||
{...props} |
||||
> |
||||
<path |
||||
fill="#1976d2" |
||||
d="M28,13h14.533C43.343,13,44,13.657,44,14.467v19.066C44,34.343,43.343,35,42.533,35H28V13z" |
||||
/> |
||||
<rect width={14} height="15.542" x={28} y="17.958" fill="#fff" /> |
||||
<polygon fill="#1976d2" points="27,44 4,39.5 4,8.5 27,4" /> |
||||
<path |
||||
fill="#fff" |
||||
d="M15.25,16.5c-3.176,0-5.75,3.358-5.75,7.5s2.574,7.5,5.75,7.5S21,28.142,21,24 S18.426,16.5,15.25,16.5z M15,28.5c-1.657,0-3-2.015-3-4.5s1.343-4.5,3-4.5s3,2.015,3,4.5S16.657,28.5,15,28.5z" |
||||
/> |
||||
<rect width="2.7" height="2.9" x="28.047" y="29.737" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="31.448" y="29.737" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="34.849" y="29.737" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="28.047" y="26.159" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="31.448" y="26.159" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="34.849" y="26.159" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="38.25" y="26.159" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="28.047" y="22.706" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="31.448" y="22.706" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="34.849" y="22.706" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="38.25" y="22.706" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="31.448" y="19.112" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="34.849" y="19.112" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="38.25" y="19.112" fill="#1976d2" /> |
||||
</svg> |
||||
); |
||||
} |
@ -0,0 +1,40 @@ |
||||
import { Calendar } from 'lucide-react'; |
||||
import { cn } from '../../lib/classname'; |
||||
import type { ResourceType } from '../../lib/resource-progress'; |
||||
import { ScheduleEventModal } from './ScheduleEventModal'; |
||||
import { useState } from 'react'; |
||||
|
||||
type ScheduleButtonProps = { |
||||
resourceId: string; |
||||
resourceType: ResourceType; |
||||
resourceTitle: string; |
||||
}; |
||||
|
||||
export function ScheduleButton(props: ScheduleButtonProps) { |
||||
const { resourceId, resourceType, resourceTitle } = props; |
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false); |
||||
|
||||
return ( |
||||
<> |
||||
{isModalOpen && ( |
||||
<ScheduleEventModal |
||||
onClose={() => { |
||||
setIsModalOpen(false); |
||||
}} |
||||
roadmapId={resourceId} |
||||
/> |
||||
)} |
||||
|
||||
<button |
||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 px-3 py-1.5 text-xs font-medium hover:bg-gray-300 sm:text-sm" |
||||
onClick={() => { |
||||
setIsModalOpen(true); |
||||
}} |
||||
> |
||||
<Calendar className="h-4 w-4 flex-shrink-0" /> |
||||
<span className="hidden sm:inline">Schedule Learning Time</span> |
||||
</button> |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,325 @@ |
||||
import { DateTime } from 'luxon'; |
||||
import { Modal } from '../Modal'; |
||||
import { ChevronRight, type LucideIcon, X } from 'lucide-react'; |
||||
import { useState, type ReactNode, type SVGProps } from 'react'; |
||||
import { GoogleCalendarIcon } from '../ReactIcons/GoogleCalendarIcon'; |
||||
import { OutlookCalendarIcon } from '../ReactIcons/OutlookCalendarIcon'; |
||||
import { AppleCalendarIcon } from '../ReactIcons/AppleCalendarIcon'; |
||||
import { FileIcon } from '../ReactIcons/FileIcon'; |
||||
|
||||
function generateRoadmapIcsFile( |
||||
title: string, |
||||
details: string, |
||||
location: string, |
||||
startDate: Date, |
||||
endDate: Date, |
||||
) { |
||||
const ics = ` |
||||
BEGIN:VCALENDAR |
||||
VERSION:2.0 |
||||
BEGIN:VEVENT |
||||
SUMMARY:${title} |
||||
DESCRIPTION:${details} |
||||
LOCATION:${location} |
||||
DTSTART:${startDate.toISOString().replace(/-|:|\.\d+/g, '')} |
||||
DTEND:${endDate.toISOString().replace(/-|:|\.\d+/g, '')} |
||||
RRULE:FREQ=DAILY |
||||
|
||||
BEGIN:VALARM |
||||
TRIGGER:-PT30M |
||||
ACTION:DISPLAY |
||||
DESCRIPTION:Reminder: ${title} starts in 30 minutes |
||||
END:VALARM |
||||
|
||||
BEGIN:VALARM |
||||
TRIGGER:-PT15M |
||||
ACTION:DISPLAY |
||||
DESCRIPTION:Reminder: ${title} starts in 15 minutes |
||||
END:VALARM |
||||
|
||||
END:VEVENT |
||||
END:VCALENDAR |
||||
`.trim();
|
||||
|
||||
return new Blob([ics], { type: 'text/calendar' }); |
||||
} |
||||
|
||||
type ScheduleEventModalProps = { |
||||
roadmapId: string; |
||||
onClose: () => void; |
||||
}; |
||||
|
||||
export function ScheduleEventModal(props: ScheduleEventModalProps) { |
||||
const { onClose, roadmapId } = props; |
||||
|
||||
let roadmapTitle = ''; |
||||
|
||||
if (roadmapId === 'devops') { |
||||
roadmapTitle = 'DevOps'; |
||||
} else if (roadmapId === 'ios') { |
||||
roadmapTitle = 'iOS'; |
||||
} else if (roadmapId === 'postgresql-dba') { |
||||
roadmapTitle = 'PostgreSQL'; |
||||
} else if (roadmapId === 'devrel') { |
||||
roadmapTitle = 'DevRel'; |
||||
} else if (roadmapId === 'qa') { |
||||
roadmapTitle = 'QA'; |
||||
} else if (roadmapId === 'api-design') { |
||||
roadmapTitle = 'API Design'; |
||||
} else if (roadmapId === 'ai-data-scientist') { |
||||
roadmapTitle = 'AI/Data Scientist'; |
||||
} else if (roadmapId === 'technical-writer') { |
||||
} else if (roadmapId === 'software-architect') { |
||||
roadmapTitle = 'Software Architecture'; |
||||
} else if (roadmapId === 'ai-engineer') { |
||||
roadmapTitle = 'AI Engineer'; |
||||
} else { |
||||
roadmapTitle = roadmapId |
||||
.split('-') |
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1)) |
||||
.join(' '); |
||||
} |
||||
|
||||
const [selectedCalendar, setSelectedCalendar] = useState< |
||||
'apple' | 'outlook' | null |
||||
>(null); |
||||
const [isLoading, setIsLoading] = useState(false); |
||||
|
||||
const location = `https://roadmap.sh/${roadmapId}`; |
||||
const title = `Learn from ${roadmapTitle} Roadmap - roadmap.sh`; |
||||
const details = ` |
||||
Learn from the ${roadmapTitle} roadmap on roadmap.sh |
||||
|
||||
Visit the roadmap at https://roadmap.sh/${roadmapId}
|
||||
`.trim();
|
||||
|
||||
const handleDownloadICS = () => { |
||||
setIsLoading(true); |
||||
|
||||
const startDate = DateTime.now().minus({ |
||||
minutes: DateTime.now().minute % 30, |
||||
}); |
||||
const endDate = startDate.plus({ hours: 1 }); |
||||
const blob = generateRoadmapIcsFile( |
||||
title, |
||||
details, |
||||
location, |
||||
startDate.toJSDate(), |
||||
endDate.toJSDate(), |
||||
); |
||||
|
||||
const url = URL.createObjectURL(blob); |
||||
|
||||
const a = document.createElement('a'); |
||||
a.href = url; |
||||
a.download = `${roadmapTitle}.ics`; |
||||
a.click(); |
||||
|
||||
setIsLoading(false); |
||||
URL.revokeObjectURL(url); |
||||
}; |
||||
|
||||
const handleGoogleCalendar = () => { |
||||
setIsLoading(true); |
||||
const baseURL = |
||||
'https://calendar.google.com/calendar/render?action=TEMPLATE'; |
||||
|
||||
const startDate = DateTime.now().minus({ |
||||
minutes: DateTime.now().minute % 30, |
||||
}); |
||||
const endDate = startDate.plus({ hours: 1 }); |
||||
|
||||
const eventDetails = new URLSearchParams({ |
||||
text: title, |
||||
dates: `${startDate.toISO().replace(/-|:|\.\d+/g, '')}/${endDate.toISO().replace(/-|:|\.\d+/g, '')}`, |
||||
details, |
||||
location, |
||||
recur: 'RRULE:FREQ=DAILY', |
||||
}).toString(); |
||||
|
||||
setIsLoading(false); |
||||
window.open(`${baseURL}&${eventDetails}`, '_blank'); |
||||
}; |
||||
|
||||
const stepDetails = { |
||||
apple: { |
||||
title: 'Add to Apple Calendar', |
||||
steps: [ |
||||
'Download the iCS File', |
||||
'Open the downloaded file, and it will automatically open your default calendar app.', |
||||
<> |
||||
If Apple Calendar is not your default calendar app, open Apple |
||||
Calendar, go to <strong>File > Import</strong>, and choose the |
||||
downloaded file. |
||||
</>, |
||||
], |
||||
}, |
||||
outlook: { |
||||
title: 'Add to Outlook Calendar', |
||||
steps: [ |
||||
'Download the iCS File', |
||||
<> |
||||
Open Outlook and go to{' '} |
||||
<strong>File > Open & Export > Import/Export</strong>. |
||||
</>, |
||||
<> |
||||
In the Import and Export Wizard select{' '} |
||||
<strong>Import an iCalendar (.ics) or vCalendar file (.vcs)</strong>. |
||||
You can then choose to keep it a separate calendar or make it a new |
||||
calendar. |
||||
</>, |
||||
], |
||||
}, |
||||
}; |
||||
|
||||
return ( |
||||
<Modal |
||||
onClose={onClose} |
||||
bodyClassName="bg-transparent shadow-none" |
||||
wrapperClassName="h-auto max-w-lg" |
||||
overlayClassName="items-start md:items-center" |
||||
> |
||||
<div className="rounded-xl bg-white px-3"> |
||||
<button |
||||
className="absolute right-4 top-4 text-gray-400 hover:text-black" |
||||
onClick={onClose} |
||||
> |
||||
<X className="h-4 w-4 stroke-[2.5]" /> |
||||
</button> |
||||
|
||||
<div className="flex flex-col items-center p-4 py-6 text-center"> |
||||
{selectedCalendar && ( |
||||
<CalendarSteps |
||||
title={stepDetails[selectedCalendar].title} |
||||
steps={stepDetails[selectedCalendar].steps} |
||||
onDownloadICS={handleDownloadICS} |
||||
onCancel={() => { |
||||
setSelectedCalendar(null); |
||||
}} |
||||
isLoading={isLoading} |
||||
/> |
||||
)} |
||||
|
||||
{!selectedCalendar && ( |
||||
<> |
||||
<h2 className="text-3xl font-semibold">Schedule Learning Time</h2> |
||||
<p className="mt-1.5 text-balance text-base text-gray-600"> |
||||
Block some time on your calendar to stay consistent |
||||
</p> |
||||
|
||||
<div className="mt-6 flex w-full flex-col gap-1"> |
||||
<CalendarButton |
||||
icon={GoogleCalendarIcon} |
||||
label="Google Calendar" |
||||
onClick={handleGoogleCalendar} |
||||
isLoading={isLoading} |
||||
/> |
||||
<CalendarButton |
||||
icon={AppleCalendarIcon} |
||||
label="Apple Calendar" |
||||
onClick={() => { |
||||
setSelectedCalendar('apple'); |
||||
}} |
||||
/> |
||||
<CalendarButton |
||||
icon={OutlookCalendarIcon} |
||||
label="Outlook Calendar" |
||||
onClick={() => { |
||||
setSelectedCalendar('outlook'); |
||||
}} |
||||
/> |
||||
|
||||
<div className="mx-auto my-4 text-base text-gray-600"> |
||||
or download the iCS file and import it to your calendar app |
||||
</div> |
||||
|
||||
<CalendarButton |
||||
icon={FileIcon} |
||||
label="Download File (.ics)" |
||||
onClick={handleDownloadICS} |
||||
/> |
||||
</div> |
||||
</> |
||||
)} |
||||
</div> |
||||
</div> |
||||
</Modal> |
||||
); |
||||
} |
||||
|
||||
type SVGIcon = (props: SVGProps<SVGSVGElement>) => ReactNode; |
||||
|
||||
type CalendarButtonProps = { |
||||
icon: LucideIcon | SVGIcon; |
||||
label: string; |
||||
isLoading?: boolean; |
||||
onClick: () => void; |
||||
}; |
||||
|
||||
function CalendarButton(props: CalendarButtonProps) { |
||||
const { icon: Icon, label, isLoading, onClick } = props; |
||||
|
||||
return ( |
||||
<button |
||||
className="flex w-full items-center justify-between gap-2 rounded-lg border px-3 py-3 leading-none hover:bg-gray-100 disabled:opacity-60 data-[loading='true']:cursor-progress" |
||||
data-loading={isLoading} |
||||
disabled={isLoading} |
||||
onClick={onClick} |
||||
> |
||||
<div className="flex items-center gap-2"> |
||||
<Icon className="h-5 w-5 shrink-0 stroke-[2.5]" /> |
||||
{label} |
||||
</div> |
||||
<ChevronRight className="h-4 w-4 stroke-[2.5]" /> |
||||
</button> |
||||
); |
||||
} |
||||
|
||||
type CalendarStepsProps = { |
||||
title: string; |
||||
steps: (string | ReactNode)[]; |
||||
onDownloadICS: () => void; |
||||
isLoading?: boolean; |
||||
onCancel: () => void; |
||||
}; |
||||
|
||||
export function CalendarSteps(props: CalendarStepsProps) { |
||||
const { steps, onDownloadICS, onCancel, title, isLoading } = props; |
||||
|
||||
return ( |
||||
<div className="flex flex-col"> |
||||
<h2 className="text-3xl font-semibold">{title}</h2> |
||||
|
||||
<div className="mt-6 flex flex-col gap-2"> |
||||
{steps.map((step, index) => ( |
||||
<div key={index} className="flex items-baseline gap-3"> |
||||
<div className="flex h-6 w-6 relative top-px text-sm shrink-0 items-center justify-center rounded-full bg-gray-200 text-gray-600"> |
||||
{index + 1} |
||||
</div> |
||||
<div className="flex flex-col gap-1"> |
||||
<p className="text-left text-base text-gray-800">{step}</p> |
||||
</div> |
||||
</div> |
||||
))} |
||||
</div> |
||||
|
||||
<div className="mt-6 flex gap-2"> |
||||
<button |
||||
className="flex-1 rounded-md border hover:bg-gray-100 border-gray-300 py-2 text-sm text-gray-600 disabled:opacity-60 data-[loading='true']:cursor-progress" |
||||
onClick={onCancel} |
||||
disabled={isLoading} |
||||
> |
||||
Go back |
||||
</button> |
||||
<button |
||||
className="flex-1 rounded-md bg-black py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-60 data-[loading='true']:cursor-progress" |
||||
onClick={onDownloadICS} |
||||
disabled={isLoading} |
||||
data-loading={isLoading} |
||||
> |
||||
Download |
||||
</button> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
Loading…
Reference in new issue