mirror of https://github.com/portainer/portainer
feat(system/upgrade): add get license dialog [EE-4743] (#8249)
parent
5942f4ff58
commit
406ff8812c
|
@ -83,6 +83,7 @@ overrides:
|
||||||
'newlines-between': 'always',
|
'newlines-between': 'always',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
no-plusplus: off
|
||||||
func-style: [error, 'declaration']
|
func-style: [error, 'declaration']
|
||||||
import/prefer-default-export: off
|
import/prefer-default-export: off
|
||||||
no-use-before-define: ['error', { functions: false }]
|
no-use-before-define: ['error', { functions: false }]
|
||||||
|
|
|
@ -30,11 +30,53 @@ declare module 'axios-progress-bar' {
|
||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HubSpotCreateFormOptions {
|
||||||
|
/** User's portal ID */
|
||||||
|
portalId: string;
|
||||||
|
/** Unique ID of the form you wish to build */
|
||||||
|
formId: string;
|
||||||
|
|
||||||
|
region: string;
|
||||||
|
/**
|
||||||
|
* jQuery style selector specifying an existing element on the page into which the form will be placed once built.
|
||||||
|
*
|
||||||
|
* NOTE: If you're including multiple forms on the page, it is strongly recommended that you include a separate, specific target for each form.
|
||||||
|
*/
|
||||||
|
target: string;
|
||||||
|
/**
|
||||||
|
* Callback that executes after form is validated, just before the data is actually sent.
|
||||||
|
* This is for any logic that needs to execute during the submit.
|
||||||
|
* Any changes will not be validated.
|
||||||
|
* Takes the jQuery form object as the argument: onFormSubmit($form).
|
||||||
|
*
|
||||||
|
* Note: Performing a browser redirect in this callback is not recommended and could prevent the form submission
|
||||||
|
*/
|
||||||
|
onFormSubmit?: (form: JQuery<HTMLFormElement>) => void;
|
||||||
|
/**
|
||||||
|
* Callback when the data is actually sent.
|
||||||
|
* This allows you to perform an action when the submission is fully complete,
|
||||||
|
* such as displaying a confirmation or thank you message.
|
||||||
|
*/
|
||||||
|
onFormSubmitted?: (form: JQuery<HTMLFormElement>) => void;
|
||||||
|
/**
|
||||||
|
* Callback that executes after form is built, placed in the DOM, and validation has been initialized.
|
||||||
|
* This is perfect for any logic that needs to execute when the form is on the page.
|
||||||
|
*
|
||||||
|
* Takes the jQuery form object as the argument: onFormReady($form)
|
||||||
|
*/
|
||||||
|
onFormReady?: (form: JQuery<HTMLFormElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
/**
|
/**
|
||||||
* will be true if portainer is run as a Docker Desktop Extension
|
* will be true if portainer is run as a Docker Desktop Extension
|
||||||
*/
|
*/
|
||||||
ddExtension?: boolean;
|
ddExtension?: boolean;
|
||||||
|
hbspt?: {
|
||||||
|
forms: {
|
||||||
|
create: (options: HubSpotCreateFormOptions) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'process' {
|
declare module 'process' {
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Meta, Story } from '@storybook/react';
|
||||||
|
|
||||||
|
import { Alert } from './Alert';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: Alert,
|
||||||
|
title: 'Components/Alert',
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
color: 'success' | 'error' | 'info';
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Template({ text, color, title }: Args) {
|
||||||
|
return (
|
||||||
|
<Alert color={color} title={title}>
|
||||||
|
{text}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Success: Story<Args> = Template.bind({});
|
||||||
|
Success.args = {
|
||||||
|
color: 'success',
|
||||||
|
title: 'Success',
|
||||||
|
text: 'This is a success alert. Very long text, Very long text,Very long text ,Very long text ,Very long text, Very long text',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Error: Story<Args> = Template.bind({});
|
||||||
|
Error.args = {
|
||||||
|
color: 'error',
|
||||||
|
title: 'Error',
|
||||||
|
text: 'This is an error alert',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Info: Story<Args> = Template.bind({});
|
||||||
|
Info.args = {
|
||||||
|
color: 'info',
|
||||||
|
title: 'Info',
|
||||||
|
text: 'This is an info alert',
|
||||||
|
};
|
|
@ -0,0 +1,83 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { AlertCircle, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
import { PropsWithChildren, ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
|
type AlertType = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
|
const alertSettings: Record<
|
||||||
|
AlertType,
|
||||||
|
{ container: string; header: string; body: string; icon: ReactNode }
|
||||||
|
> = {
|
||||||
|
success: {
|
||||||
|
container:
|
||||||
|
'border-green-4 bg-green-2 th-dark:bg-green-3 th-dark:border-green-5',
|
||||||
|
header: 'text-green-8',
|
||||||
|
body: 'text-green-7',
|
||||||
|
icon: CheckCircle,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
container:
|
||||||
|
'border-error-4 bg-error-2 th-dark:bg-error-3 th-dark:border-error-5',
|
||||||
|
header: 'text-error-8',
|
||||||
|
body: 'text-error-7',
|
||||||
|
icon: XCircle,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
container:
|
||||||
|
'border-blue-4 bg-blue-2 th-dark:bg-blue-3 th-dark:border-blue-5',
|
||||||
|
header: 'text-blue-8',
|
||||||
|
body: 'text-blue-7',
|
||||||
|
icon: AlertCircle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Alert({
|
||||||
|
color,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{ color: AlertType; title: string }>) {
|
||||||
|
const { container, header, body, icon } = alertSettings[color];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertContainer className={container}>
|
||||||
|
<AlertHeader className={header}>
|
||||||
|
<Icon icon={icon} />
|
||||||
|
{title}
|
||||||
|
</AlertHeader>
|
||||||
|
<AlertBody className={body}>{children}</AlertBody>
|
||||||
|
</AlertContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertContainer({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{ className?: string }>) {
|
||||||
|
return (
|
||||||
|
<div className={clsx('border-2 border-solid rounded-md', 'p-3', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertHeader({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{ className?: string }>) {
|
||||||
|
return (
|
||||||
|
<h4
|
||||||
|
className={clsx('text-base', 'flex gap-2 items-center !m-0', className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertBody({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{ className?: string }>) {
|
||||||
|
return <div className={clsx('ml-6 mt-2 text-sm', className)}>{children}</div>;
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { Alert } from './Alert';
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { ReactNode, useRef } from 'react';
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
|
let globalId = 0;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
portalId: HubSpotCreateFormOptions['portalId'];
|
||||||
|
formId: HubSpotCreateFormOptions['formId'];
|
||||||
|
region: HubSpotCreateFormOptions['region'];
|
||||||
|
|
||||||
|
onSubmitted: () => void;
|
||||||
|
|
||||||
|
loading?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HubspotForm({
|
||||||
|
loading,
|
||||||
|
portalId,
|
||||||
|
region,
|
||||||
|
formId,
|
||||||
|
onSubmitted,
|
||||||
|
}: Props) {
|
||||||
|
const elRef = useRef<HTMLDivElement>(null);
|
||||||
|
const id = useRef(`reactHubspotForm${globalId++}`);
|
||||||
|
const { isLoading } = useHubspotForm({
|
||||||
|
elId: id.current,
|
||||||
|
formId,
|
||||||
|
portalId,
|
||||||
|
region,
|
||||||
|
onSubmitted,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={elRef}
|
||||||
|
id={id.current}
|
||||||
|
style={{ display: isLoading ? 'none' : 'block' }}
|
||||||
|
/>
|
||||||
|
{isLoading && loading}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useHubspotForm({
|
||||||
|
elId,
|
||||||
|
formId,
|
||||||
|
portalId,
|
||||||
|
region,
|
||||||
|
onSubmitted,
|
||||||
|
}: {
|
||||||
|
elId: string;
|
||||||
|
portalId: HubSpotCreateFormOptions['portalId'];
|
||||||
|
formId: HubSpotCreateFormOptions['formId'];
|
||||||
|
region: HubSpotCreateFormOptions['region'];
|
||||||
|
|
||||||
|
onSubmitted: () => void;
|
||||||
|
}) {
|
||||||
|
return useQuery(
|
||||||
|
['hubspot', { elId, formId, portalId, region }],
|
||||||
|
async () => {
|
||||||
|
await loadHubspot();
|
||||||
|
await createForm(`#${elId}`, {
|
||||||
|
formId,
|
||||||
|
portalId,
|
||||||
|
region,
|
||||||
|
onFormSubmit: onSubmitted,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHubspot() {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
if (window.hbspt) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement(`script`);
|
||||||
|
|
||||||
|
script.defer = true;
|
||||||
|
script.onload = () => {
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
script.src = `//js.hsforms.net/forms/v2.js`;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createForm(
|
||||||
|
target: string,
|
||||||
|
options: Omit<HubSpotCreateFormOptions, 'target'>
|
||||||
|
) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
if (!window.hbspt) {
|
||||||
|
throw new Error('hbspt object is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.hbspt.forms.create({
|
||||||
|
...options,
|
||||||
|
target,
|
||||||
|
onFormReady(...rest) {
|
||||||
|
options.onFormReady?.(...rest);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -12,7 +12,11 @@ export function CloseButton({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={clsx(styles.close, className, 'absolute top-4 right-5')}
|
className={clsx(
|
||||||
|
styles.close,
|
||||||
|
className,
|
||||||
|
'absolute top-4 right-5 close-button'
|
||||||
|
)}
|
||||||
onClick={() => onClose()}
|
onClick={() => onClose()}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-dialog {
|
.modal-dialog {
|
||||||
width: 450px;
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
|
|
@ -21,6 +21,8 @@ interface Props {
|
||||||
onDismiss?(): void;
|
onDismiss?(): void;
|
||||||
'aria-label'?: string;
|
'aria-label'?: string;
|
||||||
'aria-labelledby'?: string;
|
'aria-labelledby'?: string;
|
||||||
|
size?: 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({
|
export function Modal({
|
||||||
|
@ -28,6 +30,8 @@ export function Modal({
|
||||||
onDismiss,
|
onDismiss,
|
||||||
'aria-label': ariaLabel,
|
'aria-label': ariaLabel,
|
||||||
'aria-labelledby': ariaLabelledBy,
|
'aria-labelledby': ariaLabelledBy,
|
||||||
|
size = 'md',
|
||||||
|
className,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
return (
|
return (
|
||||||
<Context.Provider value>
|
<Context.Provider value>
|
||||||
|
@ -43,9 +47,12 @@ export function Modal({
|
||||||
<DialogContent
|
<DialogContent
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-labelledby={ariaLabelledBy}
|
aria-labelledby={ariaLabelledBy}
|
||||||
className={clsx(styles.modalDialog, 'p-0 bg-transparent')}
|
className={clsx(styles.modalDialog, 'p-0 bg-transparent', {
|
||||||
|
'w-[450px]': size === 'md',
|
||||||
|
'w-[700px]': size === 'lg',
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<div className={clsx(styles.modalContent, 'relative')}>
|
<div className={clsx(styles.modalContent, 'relative', className)}>
|
||||||
{children}
|
{children}
|
||||||
{onDismiss && <CloseButton onClose={onDismiss} />}
|
{onDismiss && <CloseButton onClose={onDismiss} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { HubspotForm } from '@@/HubspotForm';
|
||||||
|
import { Modal } from '@@/modals/Modal';
|
||||||
|
|
||||||
|
export function GetLicenseDialog({
|
||||||
|
onDismiss,
|
||||||
|
goToUploadLicense,
|
||||||
|
}: {
|
||||||
|
onDismiss: () => void;
|
||||||
|
goToUploadLicense: (isSubmitted: boolean) => void;
|
||||||
|
}) {
|
||||||
|
// form is loaded from hubspot, so it won't have the same styling as the rest of the app
|
||||||
|
// since it won't support darkmode, we enforce a white background and black text for the components we use
|
||||||
|
// (Modal, CloseButton, loading text)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
aria-label="Upgrade Portainer to Business Edition"
|
||||||
|
size="lg"
|
||||||
|
className="!bg-white [&>.close-button]:!text-black"
|
||||||
|
>
|
||||||
|
<Modal.Body>
|
||||||
|
<div className="max-h-[80vh] overflow-auto">
|
||||||
|
<HubspotForm
|
||||||
|
region="na1"
|
||||||
|
portalId="4731999"
|
||||||
|
formId="1ef8ea88-3e03-46c5-8aef-c1d9f48fd06b"
|
||||||
|
onSubmitted={() => goToUploadLicense(true)}
|
||||||
|
loading={<div className="text-black">Loading...</div>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -5,13 +5,14 @@ import { useUser } from '@/react/hooks/useUser';
|
||||||
import { UploadLicenseDialog } from './UploadLicenseDialog';
|
import { UploadLicenseDialog } from './UploadLicenseDialog';
|
||||||
import { LoadingDialog } from './LoadingDialog';
|
import { LoadingDialog } from './LoadingDialog';
|
||||||
import { NonAdminUpgradeDialog } from './NonAdminUpgradeDialog';
|
import { NonAdminUpgradeDialog } from './NonAdminUpgradeDialog';
|
||||||
|
import { GetLicenseDialog } from './GetLicenseDialog';
|
||||||
|
|
||||||
type Step = 'uploadLicense' | 'loading' | 'getLicense';
|
type Step = 'uploadLicense' | 'loading' | 'getLicense';
|
||||||
|
|
||||||
export function UpgradeDialog({ onDismiss }: { onDismiss: () => void }) {
|
export function UpgradeDialog({ onDismiss }: { onDismiss: () => void }) {
|
||||||
const { isAdmin } = useUser();
|
const { isAdmin } = useUser();
|
||||||
const [currentStep, setCurrentStep] = useState<Step>('uploadLicense');
|
const [currentStep, setCurrentStep] = useState<Step>('uploadLicense');
|
||||||
|
const [isGetLicenseSubmitted, setIsGetLicenseSubmitted] = useState(false);
|
||||||
const component = getDialog();
|
const component = getDialog();
|
||||||
|
|
||||||
return component;
|
return component;
|
||||||
|
@ -23,13 +24,22 @@ export function UpgradeDialog({ onDismiss }: { onDismiss: () => void }) {
|
||||||
|
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 'getLicense':
|
case 'getLicense':
|
||||||
throw new Error('Not implemented');
|
return (
|
||||||
// return <GetLicense setCurrentStep={setCurrentStep} />;
|
<GetLicenseDialog
|
||||||
|
goToUploadLicense={(isSubmitted) => {
|
||||||
|
setCurrentStep('uploadLicense');
|
||||||
|
setIsGetLicenseSubmitted(isSubmitted);
|
||||||
|
}}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'uploadLicense':
|
case 'uploadLicense':
|
||||||
return (
|
return (
|
||||||
<UploadLicenseDialog
|
<UploadLicenseDialog
|
||||||
goToLoading={() => setCurrentStep('loading')}
|
goToLoading={() => setCurrentStep('loading')}
|
||||||
onDismiss={onDismiss}
|
onDismiss={onDismiss}
|
||||||
|
goToGetLicense={() => setCurrentStep('getLicense')}
|
||||||
|
isGetLicenseSubmitted={isGetLicenseSubmitted}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'loading':
|
case 'loading':
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import { object, SchemaOf, string } from 'yup';
|
import { object, SchemaOf, string } from 'yup';
|
||||||
import { ExternalLink } from 'lucide-react';
|
|
||||||
|
|
||||||
import { useUpgradeEditionMutation } from '@/react/portainer/system/useUpgradeEditionMutation';
|
import { useUpgradeEditionMutation } from '@/react/portainer/system/useUpgradeEditionMutation';
|
||||||
import { notifySuccess } from '@/portainer/services/notifications';
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
@ -9,6 +8,7 @@ import { Button, LoadingButton } from '@@/buttons';
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
import { Input } from '@@/form-components/Input';
|
import { Input } from '@@/form-components/Input';
|
||||||
import { Modal } from '@@/modals/Modal';
|
import { Modal } from '@@/modals/Modal';
|
||||||
|
import { Alert } from '@@/Alert';
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
license: string;
|
license: string;
|
||||||
|
@ -21,9 +21,13 @@ const initialValues: FormValues = {
|
||||||
export function UploadLicenseDialog({
|
export function UploadLicenseDialog({
|
||||||
onDismiss,
|
onDismiss,
|
||||||
goToLoading,
|
goToLoading,
|
||||||
|
goToGetLicense,
|
||||||
|
isGetLicenseSubmitted,
|
||||||
}: {
|
}: {
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
goToLoading: () => void;
|
goToLoading: () => void;
|
||||||
|
goToGetLicense: () => void;
|
||||||
|
isGetLicenseSubmitted: boolean;
|
||||||
}) {
|
}) {
|
||||||
const upgradeMutation = useUpgradeEditionMutation();
|
const upgradeMutation = useUpgradeEditionMutation();
|
||||||
|
|
||||||
|
@ -44,9 +48,19 @@ export function UploadLicenseDialog({
|
||||||
{({ errors }) => (
|
{({ errors }) => (
|
||||||
<Form noValidate>
|
<Form noValidate>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
|
{!isGetLicenseSubmitted ? (
|
||||||
<p className="font-semibold text-gray-7">
|
<p className="font-semibold text-gray-7">
|
||||||
Please enter your Portainer License below
|
Please enter your Portainer License below
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mb-4">
|
||||||
|
<Alert color="success" title="License successfully sent">
|
||||||
|
Please check your email and copy your license into the field
|
||||||
|
below to upgrade Portainer.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormControl
|
<FormControl
|
||||||
label="License"
|
label="License"
|
||||||
errors={errors.license}
|
errors={errors.license}
|
||||||
|
@ -58,21 +72,14 @@ export function UploadLicenseDialog({
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<div className="flex gap-2 [&>*]:w-1/2 w-full">
|
<div className="flex gap-2 [&>*]:w-1/2 w-full">
|
||||||
<a
|
|
||||||
href="https://www.portainer.io/pricing"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="no-link"
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
color="default"
|
color="default"
|
||||||
size="medium"
|
size="medium"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
icon={ExternalLink}
|
onClick={goToGetLicense}
|
||||||
>
|
>
|
||||||
Get a license
|
Get a license
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
color="primary"
|
color="primary"
|
||||||
size="medium"
|
size="medium"
|
||||||
|
|
Loading…
Reference in New Issue