487 lines
20 KiB
TypeScript
487 lines
20 KiB
TypeScript
import {
|
|
FeatherArrowLeft,
|
|
FeatherBriefcase,
|
|
FeatherCheck,
|
|
FeatherCreditCard,
|
|
FeatherHardHat,
|
|
FeatherLock,
|
|
FeatherMail,
|
|
FeatherMinus,
|
|
FeatherPlus,
|
|
FeatherUsers,
|
|
} from "@subframe/core";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import Captcha from "~/components/captcha/Captcha";
|
|
import { useLocaleFormatters } from "~/locale/format";
|
|
import { Alert } from "~/ui/components/Alert";
|
|
import { BadgeFeedback } from "~/ui/components/BadgeFeedback";
|
|
import { Button } from "~/ui/components/Button";
|
|
import { Card } from "~/ui/components/Card";
|
|
import { Checkbox } from "~/ui/components/Checkbox";
|
|
import { IconButton } from "~/ui/components/IconButton";
|
|
import { IconWithBackground } from "~/ui/components/IconWithBackground";
|
|
import { LinkButton } from "~/ui/components/LinkButton";
|
|
import { RadioCardGroup } from "~/ui/components/RadioCardGroup";
|
|
import { Slider } from "~/ui/components/Slider";
|
|
import { TextInput } from "~/ui/components/TextInput";
|
|
|
|
import type { RegistrationPlanOptionData } from "./RegisterScreen";
|
|
|
|
type CaptchaStatus = "pending" | "solved" | "error";
|
|
|
|
export function RegistrationPlanStep({
|
|
visible,
|
|
screen,
|
|
plans,
|
|
selectedPriceId,
|
|
recommendedPriceId,
|
|
officeSeatQuantity,
|
|
fieldSeatQuantity,
|
|
planAdviceLoading,
|
|
planAdviceFailed,
|
|
isSubmitting,
|
|
acceptTerms,
|
|
acceptPrivacy,
|
|
captchaEnabled,
|
|
captchaConfig,
|
|
captchaResetKey,
|
|
captchaStatus,
|
|
requireEmailVerification,
|
|
error,
|
|
onSelectPlan,
|
|
onOfficeSeatQuantityChange,
|
|
onFieldSeatQuantityChange,
|
|
onAcceptTermsChange,
|
|
onAcceptPrivacyChange,
|
|
onCaptchaStatusChange,
|
|
onRetryPlanAdvice,
|
|
onContinue,
|
|
onBack,
|
|
}: {
|
|
visible: boolean;
|
|
screen: "team" | "plan";
|
|
plans: readonly RegistrationPlanOptionData[];
|
|
selectedPriceId: string;
|
|
recommendedPriceId: string;
|
|
officeSeatQuantity: number;
|
|
fieldSeatQuantity: number;
|
|
planAdviceLoading: boolean;
|
|
planAdviceFailed: boolean;
|
|
isSubmitting: boolean;
|
|
acceptTerms: boolean;
|
|
acceptPrivacy: boolean;
|
|
captchaEnabled: boolean;
|
|
captchaConfig: {
|
|
provider?: "mcaptcha" | "cap";
|
|
instanceUrl?: string;
|
|
loginSiteKey?: string;
|
|
};
|
|
captchaResetKey?: string;
|
|
captchaStatus: CaptchaStatus;
|
|
requireEmailVerification: boolean;
|
|
error?: string;
|
|
onSelectPlan: (priceId: string) => void;
|
|
onOfficeSeatQuantityChange: (quantity: number) => void;
|
|
onFieldSeatQuantityChange: (quantity: number) => void;
|
|
onAcceptTermsChange: (accepted: boolean) => void;
|
|
onAcceptPrivacyChange: (accepted: boolean) => void;
|
|
onCaptchaStatusChange: (status: CaptchaStatus) => void;
|
|
onRetryPlanAdvice: () => void;
|
|
onContinue: () => void;
|
|
onBack: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const formatters = useLocaleFormatters();
|
|
const selectedPlan = plans.find((plan) => plan.priceId === selectedPriceId) ?? plans[0];
|
|
const allConsents = acceptTerms && acceptPrivacy;
|
|
const capProofPending = captchaEnabled && captchaConfig.provider === "cap" && captchaStatus !== "solved";
|
|
|
|
return (
|
|
<div className={visible ? "flex w-full flex-col gap-6" : "hidden"} data-ui-id="ui.auth.register.planStep">
|
|
{screen === "team" ? (
|
|
<div className="flex w-full flex-col gap-6" data-ui-id="ui.auth.register.teamStep">
|
|
<div className="flex w-full items-start gap-3">
|
|
<IconWithBackground variant="information" size="medium" icon={<FeatherUsers />} />
|
|
<div className="flex min-w-0 flex-col gap-1">
|
|
<span className="text-body-default-semibold-0 font-body-default-semibold-0 text-text-neutral-primary">{t("account.register.teamSizeTitle")}</span>
|
|
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">
|
|
{t("account.register.teamSizeHint")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="mobile:grid-cols-1 grid w-full grid-cols-2 gap-4">
|
|
<div className="min-w-0 grow basis-0">
|
|
<SeatQuantityStepper
|
|
name="selectedOfficeSeatQuantity"
|
|
label={t("account.register.officeUsersLabel")}
|
|
description={t("account.register.officeUsersHint")}
|
|
value={officeSeatQuantity}
|
|
min={1}
|
|
sliderMax={50}
|
|
icon={<FeatherBriefcase />}
|
|
disabled={isSubmitting}
|
|
decreaseLabel={t("account.register.decreaseQuantity", { role: t("account.register.officeUsersLabel") })}
|
|
increaseLabel={t("account.register.increaseQuantity", { role: t("account.register.officeUsersLabel") })}
|
|
dataUiId="ui.auth.register.officeSeats"
|
|
onChange={onOfficeSeatQuantityChange}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 grow basis-0">
|
|
<SeatQuantityStepper
|
|
name="selectedFieldSeatQuantity"
|
|
label={t("account.register.fieldUsersLabel")}
|
|
description={t("account.register.fieldUsersHint")}
|
|
value={fieldSeatQuantity}
|
|
min={0}
|
|
sliderMax={100}
|
|
icon={<FeatherHardHat />}
|
|
disabled={isSubmitting}
|
|
decreaseLabel={t("account.register.decreaseQuantity", { role: t("account.register.fieldUsersLabel") })}
|
|
increaseLabel={t("account.register.increaseQuantity", { role: t("account.register.fieldUsersLabel") })}
|
|
dataUiId="ui.auth.register.fieldSeats"
|
|
onChange={onFieldSeatQuantityChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Card variant="neutral" size="small" className="gap-2 p-4">
|
|
<div className="flex w-full items-center gap-2">
|
|
<FeatherCheck className="text-body-default-regular-0 font-body-default-regular-0 text-icon-feedback-positive" />
|
|
<span className="text-body-small-semibold-0 font-body-small-semibold-0 text-text-neutral-primary">
|
|
{t("account.register.recommendationPromise")}
|
|
</span>
|
|
</div>
|
|
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary px-6">
|
|
{t("account.register.recommendationPromiseHint")}
|
|
</span>
|
|
</Card>
|
|
<Button
|
|
type="button"
|
|
size="large"
|
|
className="w-full"
|
|
icon={<FeatherArrowLeft className="rotate-180" />}
|
|
disabled={isSubmitting || planAdviceLoading || planAdviceFailed}
|
|
onClick={onContinue}
|
|
data-ui-id="ui.auth.register.toPlanStep"
|
|
>
|
|
{t("account.register.continueToPlans")}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex w-full flex-col gap-4" data-ui-id="ui.auth.register.planSelection">
|
|
<div className="flex w-full flex-col gap-1">
|
|
<span className="text-body-small-semibold-0 font-body-small-semibold-0 text-text-neutral-primary">{t("account.register.planSelectionLabel")}</span>
|
|
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">
|
|
{t("account.register.planSelectionHint")}
|
|
</span>
|
|
</div>
|
|
{selectedPlan ? (
|
|
<RadioCardGroup
|
|
className="mobile:grid-cols-1 grid w-full grid-cols-3"
|
|
value={selectedPriceId}
|
|
onValueChange={onSelectPlan}
|
|
aria-label={t("account.register.planSelectionLabel")}
|
|
>
|
|
{plans.map((plan) => (
|
|
<RadioCardGroup.RadioCard key={plan.priceId} value={plan.priceId} disabled={isSubmitting || planAdviceLoading}>
|
|
<div className="flex h-full w-full flex-col gap-4">
|
|
<div className="flex w-full flex-col gap-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-heading- font-heading- text-text-neutral-primary">{t(plan.titleKey)}</span>
|
|
{plan.priceId === recommendedPriceId ? (
|
|
<BadgeFeedback farbe="subtle-brand-primary" size="small">
|
|
{t("account.register.recommendedBadge")}
|
|
</BadgeFeedback>
|
|
) : null}
|
|
</div>
|
|
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">
|
|
{t("account.register.teamConfigured", { office: officeSeatQuantity, field: fieldSeatQuantity })}
|
|
</span>
|
|
</div>
|
|
<span className="text-heading-0 font-heading-0 text-text-neutral-primary">
|
|
{t("account.register.planPriceMonthly", {
|
|
price: formatters.currencyFromCents(plan.amountCents, plan.currency),
|
|
})}
|
|
</span>
|
|
<div className="border-border-default flex w-full flex-col gap-2 border-0 border-t border-solid pt-3">
|
|
<span className="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-secondary">
|
|
{t("account.register.planIncluded", {
|
|
office: plan.includedOfficeSeatQuantity,
|
|
field: plan.includedFieldSeatQuantity,
|
|
storage: plan.includedStorageGb,
|
|
})}
|
|
</span>
|
|
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">
|
|
{t("account.register.planStorageGrowth", {
|
|
storage: plan.storagePerAdditionalSeatGb,
|
|
})}
|
|
</span>
|
|
{plan.extraOfficeSeatQuantity > 0 || plan.extraFieldSeatQuantity > 0 ? (
|
|
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">
|
|
{t("account.register.planExtrasIncluded", {
|
|
office: plan.extraOfficeSeatQuantity,
|
|
field: plan.extraFieldSeatQuantity,
|
|
})}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</RadioCardGroup.RadioCard>
|
|
))}
|
|
</RadioCardGroup>
|
|
) : null}
|
|
{planAdviceLoading ? (
|
|
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary" role="status">
|
|
{t("account.register.recommendationLoading")}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{planAdviceFailed ? (
|
|
<div className="flex w-full flex-col items-start gap-3">
|
|
<Alert
|
|
variant="error"
|
|
notDismissible
|
|
title={t("account.register.recommendationErrorTitle")}
|
|
description={t("account.register.recommendationErrorBody")}
|
|
/>
|
|
<Button type="button" variant="secondary" disabled={isSubmitting || planAdviceLoading} onClick={onRetryPlanAdvice}>
|
|
{t("account.register.recommendationRetry")}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{screen === "plan" ? (
|
|
<Card variant="default" size="medium" className="mobile:flex-col w-full flex-row items-start gap-6" data-ui-id="ui.auth.register.confirmation">
|
|
{captchaEnabled ? (
|
|
<div className="mobile:w-full flex w-[360px] flex-none flex-col gap-3">
|
|
<div className="flex w-full items-start gap-3">
|
|
<IconWithBackground variant="neutral" size="medium" icon={<FeatherLock />} />
|
|
<div className="flex min-w-0 flex-col gap-1">
|
|
<span className="text-body-small-semibold-0 font-body-small-semibold-0 text-text-neutral-primary">{t("account.register.securityCheck")}</span>
|
|
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">
|
|
{t("account.register.securityCheckHint")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{captchaResetKey ? (
|
|
<Alert
|
|
variant="neutral"
|
|
notDismissible
|
|
title={t("account.register.captchaReloadedTitle")}
|
|
description={t("account.register.captchaReloadedBody")}
|
|
/>
|
|
) : null}
|
|
<div className="border-border-default bg-surface-neutral-secondary flex w-full items-center justify-center rounded-md border border-solid px-3 py-3">
|
|
<Captcha
|
|
key={captchaResetKey ?? "initial-captcha"}
|
|
enabled
|
|
provider={captchaConfig.provider}
|
|
instanceUrl={captchaConfig.instanceUrl}
|
|
sitekey={captchaConfig.loginSiteKey}
|
|
onStatusChange={onCaptchaStatusChange}
|
|
/>
|
|
</div>
|
|
{captchaStatus === "error" ? (
|
|
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-feedback-negative" role="alert">
|
|
{t("account.register.captchaClientError")}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex min-w-0 grow basis-0 flex-col gap-4">
|
|
<div className="flex w-full flex-col items-start gap-3">
|
|
<ConsentRow
|
|
checked={acceptTerms}
|
|
label={t("account.register.consentTerms")}
|
|
href="/terms-and-conditions"
|
|
linkLabel={t("account.register.consentTermsLink")}
|
|
dataUiId="ui.auth.register.consentTerms"
|
|
onChange={onAcceptTermsChange}
|
|
/>
|
|
<ConsentRow
|
|
checked={acceptPrivacy}
|
|
label={t("account.register.consentPrivacy")}
|
|
href="/privacy-policy"
|
|
linkLabel={t("account.register.privacyPolicy")}
|
|
dataUiId="ui.auth.register.consentPrivacy"
|
|
onChange={onAcceptPrivacyChange}
|
|
/>
|
|
</div>
|
|
|
|
{error ? (
|
|
<p className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative" role="alert">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
|
|
<div className="flex w-full flex-col items-start gap-2">
|
|
<Button
|
|
type="submit"
|
|
size="large"
|
|
className="w-full flex-none"
|
|
icon={<FeatherCreditCard />}
|
|
loading={isSubmitting}
|
|
disabled={isSubmitting || !allConsents || planAdviceLoading || planAdviceFailed || capProofPending || !selectedPlan}
|
|
data-ui-id="ui.auth.register.submit"
|
|
>
|
|
{t("account.register.continueWithPlan", { plan: selectedPlan ? t(selectedPlan.titleKey) : "" })}
|
|
</Button>
|
|
<div className="flex w-full items-start justify-center gap-1.5">
|
|
{requireEmailVerification ? (
|
|
<FeatherMail className="text-body-small-regular-0 font-body-small-regular-0 text-icon-neutral-tertiary mt-0.5" />
|
|
) : (
|
|
<FeatherLock className="text-body-small-regular-0 font-body-small-regular-0 text-icon-neutral-tertiary mt-0.5" />
|
|
)}
|
|
<span className="text-body-extra-small-regular-0 font-body-extra-small-regular-0 text-text-neutral-tertiary text-center">
|
|
{t(requireEmailVerification ? "account.register.noPaymentYet" : "account.register.checkoutNext")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
) : null}
|
|
|
|
<Button
|
|
type="button"
|
|
className="self-start"
|
|
variant="ghost"
|
|
icon={<FeatherArrowLeft />}
|
|
onClick={onBack}
|
|
disabled={isSubmitting}
|
|
data-ui-id={screen === "plan" ? "ui.auth.register.backToTeam" : "ui.auth.register.backToAccount"}
|
|
>
|
|
{t(screen === "plan" ? "account.register.backToTeam" : "account.register.backToAccount")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ConsentRow({
|
|
checked,
|
|
label,
|
|
href,
|
|
linkLabel,
|
|
dataUiId,
|
|
onChange,
|
|
}: {
|
|
checked: boolean;
|
|
label: string;
|
|
href: string;
|
|
linkLabel: string;
|
|
dataUiId: string;
|
|
onChange: (checked: boolean) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="flex w-full items-center gap-2">
|
|
<Checkbox label={label} hideLabel checked={checked} onCheckedChange={onChange} data-ui-id={dataUiId} />
|
|
<div className="flex min-h-5 flex-wrap items-center gap-1">
|
|
<span className="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-secondary">{t("account.register.consentAcceptPrefix")}</span>
|
|
<a href={href} target="_blank" rel="noreferrer" className="no-underline">
|
|
<LinkButton size="small" hideIcon>
|
|
{linkLabel}
|
|
</LinkButton>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SeatQuantityStepper({
|
|
name,
|
|
label,
|
|
description,
|
|
value,
|
|
min,
|
|
max = 10_000,
|
|
sliderMax,
|
|
icon,
|
|
disabled,
|
|
decreaseLabel,
|
|
increaseLabel,
|
|
dataUiId,
|
|
onChange,
|
|
}: {
|
|
name: string;
|
|
label: string;
|
|
description: string;
|
|
value: number;
|
|
min: number;
|
|
max?: number;
|
|
sliderMax: number;
|
|
icon: React.ReactNode;
|
|
disabled: boolean;
|
|
decreaseLabel: string;
|
|
increaseLabel: string;
|
|
dataUiId: string;
|
|
onChange: (value: number) => void;
|
|
}) {
|
|
return (
|
|
<Card size="small" variant="default" className="h-full justify-between gap-3 p-4" data-ui-id={dataUiId}>
|
|
<div className="flex w-full items-start gap-3 px-1">
|
|
<IconWithBackground variant="neutral" size="medium" icon={icon} />
|
|
<div className="flex min-w-0 flex-col gap-1">
|
|
<span className="text-body-small-semibold-0 font-body-small-semibold-0 text-text-neutral-primary">{label}</span>
|
|
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">{description}</span>
|
|
</div>
|
|
</div>
|
|
<Slider
|
|
min={min}
|
|
max={sliderMax}
|
|
step={1}
|
|
value={[Math.min(value, sliderMax)]}
|
|
valueText={value}
|
|
description={value > sliderMax ? `${sliderMax}+` : undefined}
|
|
disabled={disabled}
|
|
onValueChange={(values) => onChange(values[0] ?? min)}
|
|
aria-label={label}
|
|
/>
|
|
<div className="flex w-full items-center justify-between gap-3" role="group" aria-label={label}>
|
|
<IconButton
|
|
type="button"
|
|
variant="tertiary"
|
|
square
|
|
size="medium"
|
|
icon={<FeatherMinus />}
|
|
aria-label={decreaseLabel}
|
|
disabled={disabled || value <= min}
|
|
onClick={() => onChange(Math.max(min, value - 1))}
|
|
data-ui-id={`${dataUiId}.decrease`}
|
|
/>
|
|
<TextInput className="max-w-24" type="number">
|
|
<TextInput.Input
|
|
name={name}
|
|
type="number"
|
|
min={min}
|
|
max={max}
|
|
step={1}
|
|
value={value}
|
|
aria-label={label}
|
|
disabled={disabled}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.valueAsNumber;
|
|
if (Number.isFinite(nextValue)) {
|
|
onChange(Math.min(max, Math.max(min, nextValue)));
|
|
}
|
|
}}
|
|
/>
|
|
</TextInput>
|
|
<IconButton
|
|
type="button"
|
|
variant="tertiary"
|
|
square
|
|
size="medium"
|
|
icon={<FeatherPlus />}
|
|
aria-label={increaseLabel}
|
|
disabled={disabled || value >= max}
|
|
onClick={() => onChange(Math.min(max, value + 1))}
|
|
data-ui-id={`${dataUiId}.increase`}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|