Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/users/components/RegistrationPlanStep.tsx
T

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>
);
}