chore: add advanced settings popover
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import { FreeTrialDuration, type ProductItem } from "@autumn/shared";
|
||||
import {
|
||||
FreeTrialDuration,
|
||||
type PlanTiming,
|
||||
type ProductItem,
|
||||
} from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const AttachFormSchema = z.object({
|
||||
@@ -9,6 +13,7 @@ export const AttachFormSchema = z.object({
|
||||
trialLength: z.number().positive().nullable(),
|
||||
trialDuration: z.enum(FreeTrialDuration),
|
||||
trialEnabled: z.boolean(),
|
||||
planSchedule: z.custom<PlanTiming>().nullable(),
|
||||
});
|
||||
|
||||
export type AttachForm = z.infer<typeof AttachFormSchema>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TimerIcon } from "@phosphor-icons/react";
|
||||
import { GearIcon, TimerIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
STAGGER_CONTAINER,
|
||||
@@ -17,21 +17,31 @@ export function AttachPlanSkeleton() {
|
||||
animate="visible"
|
||||
variants={STAGGER_CONTAINER}
|
||||
>
|
||||
{/* Section title - static content with disabled button */}
|
||||
{/* Section title - static content with disabled buttons */}
|
||||
<motion.div variants={STAGGER_ITEM}>
|
||||
<h3 className="text-sub select-none w-full">
|
||||
<span className="flex items-center justify-between w-full gap-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
Plan Configuration
|
||||
</span>
|
||||
<IconButton
|
||||
icon={<TimerIcon size={14} />}
|
||||
variant="secondary"
|
||||
className="h-7 whitespace-nowrap"
|
||||
disabled
|
||||
>
|
||||
Free Trial
|
||||
</IconButton>
|
||||
<span className="flex items-center gap-2">
|
||||
<IconButton
|
||||
icon={<GearIcon size={14} />}
|
||||
variant="secondary"
|
||||
className="h-7 whitespace-nowrap"
|
||||
disabled
|
||||
>
|
||||
Settings
|
||||
</IconButton>
|
||||
<IconButton
|
||||
icon={<TimerIcon size={14} />}
|
||||
variant="secondary"
|
||||
className="h-7 whitespace-nowrap"
|
||||
disabled
|
||||
>
|
||||
Free Trial
|
||||
</IconButton>
|
||||
</span>
|
||||
</span>
|
||||
</h3>
|
||||
</motion.div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/components/v2/tooltips/Tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAttachFormContext } from "../context/AttachFormProvider";
|
||||
import { AttachSettingsPopover } from "./AttachSettingsPopover";
|
||||
|
||||
export function AttachSectionTitle() {
|
||||
const { hasCustomizations, form, formValues } = useAttachFormContext();
|
||||
@@ -35,6 +36,7 @@ export function AttachSectionTitle() {
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<AttachSettingsPopover />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconButton
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { PlanTiming } from "@autumn/shared";
|
||||
import { CalendarIcon, GearIcon, LightningIcon } from "@phosphor-icons/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import { IconCheckbox } from "@/components/v2/checkboxes/IconCheckbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAttachFormContext } from "../context/AttachFormProvider";
|
||||
|
||||
export function AttachSettingsPopover() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { form, formValues, previewQuery } = useAttachFormContext();
|
||||
const { planSchedule } = formValues;
|
||||
const previewData = previewQuery.data;
|
||||
|
||||
// Compute the default planSchedule based on upgrade vs downgrade
|
||||
const defaultPlanSchedule = useMemo((): PlanTiming => {
|
||||
if (!previewData) return "immediate";
|
||||
|
||||
const hasOutgoing = previewData.outgoing.length > 0;
|
||||
if (!hasOutgoing) return "immediate";
|
||||
|
||||
// Compare prices to determine upgrade vs downgrade
|
||||
const incomingPrice = previewData.incoming[0]?.plan.price?.amount ?? 0;
|
||||
const outgoingPrice = previewData.outgoing[0]?.plan.price?.amount ?? 0;
|
||||
const isUpgrade = incomingPrice > outgoingPrice;
|
||||
|
||||
return isUpgrade ? "immediate" : "end_of_cycle";
|
||||
}, [previewData]);
|
||||
|
||||
// Effective value: user's choice or computed default
|
||||
const effectivePlanSchedule = planSchedule ?? defaultPlanSchedule;
|
||||
|
||||
const handleScheduleChange = (value: PlanTiming) => {
|
||||
form.setFieldValue("planSchedule", value);
|
||||
};
|
||||
|
||||
const isImmediateSelected = effectivePlanSchedule === "immediate";
|
||||
const isEndOfCycleSelected = effectivePlanSchedule === "end_of_cycle";
|
||||
|
||||
// Show blue highlight when user has overridden the default
|
||||
const hasCustomSchedule =
|
||||
planSchedule !== null && planSchedule !== defaultPlanSchedule;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<IconButton
|
||||
icon={
|
||||
<GearIcon
|
||||
size={14}
|
||||
weight={hasCustomSchedule ? "fill" : "regular"}
|
||||
/>
|
||||
}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-7 whitespace-nowrap",
|
||||
hasCustomSchedule &&
|
||||
"text-blue-400! border-blue-500/50 bg-blue-500/10",
|
||||
)}
|
||||
>
|
||||
Settings
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="p-3 w-[380px] z-101 bg-muted"
|
||||
sideOffset={4}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-t2 font-medium text-base">
|
||||
Advanced Configuration
|
||||
</p>
|
||||
<p className="text-t3 text-xs">Override default billing behavior</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-t1 text-sm">Plan Schedule</span>
|
||||
<div className="flex">
|
||||
<IconCheckbox
|
||||
icon={<LightningIcon />}
|
||||
iconOrientation="left"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
checked={isImmediateSelected}
|
||||
onCheckedChange={() => handleScheduleChange("immediate")}
|
||||
className={cn(
|
||||
"rounded-r-none",
|
||||
!isImmediateSelected && "border-r-0",
|
||||
)}
|
||||
>
|
||||
Immediately
|
||||
</IconCheckbox>
|
||||
<IconCheckbox
|
||||
icon={<CalendarIcon />}
|
||||
iconOrientation="left"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
checked={isEndOfCycleSelected}
|
||||
onCheckedChange={() => handleScheduleChange("end_of_cycle")}
|
||||
className={cn(
|
||||
"rounded-l-none",
|
||||
!isEndOfCycleSelected && "border-l-0",
|
||||
)}
|
||||
>
|
||||
End of cycle
|
||||
</IconCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,33 @@
|
||||
import { MinusCircleIcon, PlusCircleIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
STAGGER_CONTAINER,
|
||||
STAGGER_ITEM,
|
||||
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
|
||||
import { useAttachFormContext } from "../context/AttachFormProvider";
|
||||
|
||||
function AttachUpdatesSkeleton() {
|
||||
return (
|
||||
<SheetSection withSeparator>
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={STAGGER_CONTAINER}
|
||||
>
|
||||
<motion.div variants={STAGGER_ITEM}>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<Skeleton className="h-4 w-4 rounded-full shrink-0" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function AttachUpdatesSection() {
|
||||
const { previewQuery, formValues, product } = useAttachFormContext();
|
||||
|
||||
@@ -10,7 +35,15 @@ export function AttachUpdatesSection() {
|
||||
const { data: previewData, isPending } = previewQuery;
|
||||
const outgoing = previewData?.outgoing ?? [];
|
||||
|
||||
if (!hasProductSelected || isPending || outgoing.length === 0 || !product) {
|
||||
if (!hasProductSelected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return <AttachUpdatesSkeleton />;
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -38,15 +71,23 @@ export function AttachUpdatesSection() {
|
||||
|
||||
return (
|
||||
<SheetSection withSeparator>
|
||||
<InfoBox variant="note">
|
||||
Attaching{" "}
|
||||
<PlusCircleIcon
|
||||
weight="fill"
|
||||
className="text-green-500 size-3.5 inline align-[-2px] mr-1"
|
||||
/>
|
||||
<span className="text-foreground font-medium">{product.name}</span> and
|
||||
removing {renderOutgoingPlans()}
|
||||
</InfoBox>
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={STAGGER_CONTAINER}
|
||||
>
|
||||
<motion.div variants={STAGGER_ITEM}>
|
||||
<InfoBox variant="note">
|
||||
Attaching{" "}
|
||||
<PlusCircleIcon
|
||||
weight="fill"
|
||||
className="text-green-500 size-3.5 inline align-[-2px] mr-1"
|
||||
/>
|
||||
<span className="text-foreground font-medium">{product.name}</span>
|
||||
{outgoing.length > 0 && <> and removing {renderOutgoingPlans()}</>}
|
||||
</InfoBox>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export function AttachFormProvider({
|
||||
trialLength,
|
||||
trialDuration,
|
||||
trialEnabled,
|
||||
planSchedule,
|
||||
} = formValues;
|
||||
|
||||
const product = useMemo(
|
||||
@@ -167,6 +168,7 @@ export function AttachFormProvider({
|
||||
trialLength,
|
||||
trialDuration,
|
||||
trialEnabled,
|
||||
planSchedule,
|
||||
});
|
||||
|
||||
const previewQuery = useAttachPreview({ requestBody });
|
||||
|
||||
@@ -18,6 +18,7 @@ export function useAttachForm({
|
||||
trialLength: null,
|
||||
trialDuration: FreeTrialDuration.Day,
|
||||
trialEnabled: false,
|
||||
planSchedule: null,
|
||||
} as AttachForm,
|
||||
validators: {
|
||||
onChange: AttachFormSchema,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type AttachParamsV0Input,
|
||||
type FeatureOptions,
|
||||
type FreeTrialDuration,
|
||||
type PlanTiming,
|
||||
type ProductItem,
|
||||
ProductItemInterval,
|
||||
type ProductV2,
|
||||
@@ -22,6 +23,7 @@ interface UseAttachRequestBodyParams {
|
||||
trialLength: number | null;
|
||||
trialDuration: FreeTrialDuration;
|
||||
trialEnabled: boolean;
|
||||
planSchedule: PlanTiming | null;
|
||||
}
|
||||
|
||||
function convertPrepaidOptionsToFeatureOptions({
|
||||
@@ -72,6 +74,7 @@ export function useAttachRequestBody({
|
||||
trialLength,
|
||||
trialDuration,
|
||||
trialEnabled,
|
||||
planSchedule,
|
||||
}: UseAttachRequestBodyParams) {
|
||||
const requestBody = useMemo((): AttachParamsV0 | null => {
|
||||
if (!customerId || !product) {
|
||||
@@ -118,6 +121,10 @@ export function useAttachRequestBody({
|
||||
body.free_trial = freeTrial;
|
||||
}
|
||||
|
||||
if (planSchedule) {
|
||||
body.plan_schedule = planSchedule;
|
||||
}
|
||||
|
||||
return body;
|
||||
}, [
|
||||
customerId,
|
||||
@@ -129,6 +136,7 @@ export function useAttachRequestBody({
|
||||
trialLength,
|
||||
trialDuration,
|
||||
trialEnabled,
|
||||
planSchedule,
|
||||
]);
|
||||
|
||||
const buildRequestBody = useMemo(
|
||||
|
||||
Reference in New Issue
Block a user