Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/lib/zod/schemas/program-lander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const programLanderEarningsCalculatorBlockSchema =
type: z.literal("earnings-calculator"),
data: z.object({
productPrice: z.number().describe("Average product price in cents"),
billingPeriod: z.enum(["monthly", "yearly"]).optional(),
}),
});

Expand Down
40 changes: 40 additions & 0 deletions apps/web/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,46 @@ input[type="number"]::-webkit-outer-spin-button {
border-radius: 10px;
}

/* Earnings calculator slider */
.earnings-slider {
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 999px;
outline: none;
cursor: pointer;
}

.earnings-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #262626;
cursor: pointer;
margin-top: -4px;
}

.earnings-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #262626;
border: none;
cursor: pointer;
}

.earnings-slider::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
}

.earnings-slider::-moz-range-track {
height: 4px;
border-radius: 999px;
}

/* Override Sonner toast border radius from 8px to 10px (Tailwind rounded-2xl) */
[data-sonner-toast] {
border-radius: 10px !important;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@
import useGroup from "@/lib/swr/use-group";
import useWorkspace from "@/lib/swr/use-workspace";
import { programLanderEarningsCalculatorBlockSchema } from "@/lib/zod/schemas/program-lander";
import { Button, Modal, useMediaQuery, useScrollProgress } from "@dub/ui";
import {
Button,
Modal,
ToggleGroup,
useMediaQuery,
useScrollProgress,
} from "@dub/ui";
import { cn } from "@dub/utils";
import Link from "next/link";
import { Dispatch, SetStateAction, useId, useRef } from "react";
import { Control, useForm, useWatch } from "react-hook-form";
import { Controller, useForm, useWatch } from "react-hook-form";
import type { Control } from "react-hook-form";
import * as z from "zod/v4";
import { EarningsCalculatorBlock } from "../../../../lander/blocks/earnings-calculator-block";
import { useBrandingFormContext } from "../../branding-form";

type EarningsCalculatorBlockData = z.infer<
typeof programLanderEarningsCalculatorBlockSchema
Expand Down Expand Up @@ -56,6 +62,7 @@ function EarningsCalculatorBlockModalInner({
productPrice: defaultValues?.productPrice
? defaultValues.productPrice / 100
: undefined,
billingPeriod: defaultValues?.billingPeriod ?? "monthly",
},
});

Expand Down Expand Up @@ -120,6 +127,32 @@ function EarningsCalculatorBlockModalInner({
</div>
</div>

{/* Earnings time toggle */}
<div>
<label className="text-sm font-medium text-neutral-700">
Earnings time
</label>
<div className="mt-2">
<Controller
control={control}
name="billingPeriod"
render={({ field }) => (
<ToggleGroup
options={[
{ value: "monthly", label: "Monthly" },
{ value: "yearly", label: "Yearly" },
]}
selected={field.value ?? "monthly"}
selectAction={(value) => field.onChange(value)}
className="grid w-full grid-cols-2 rounded-lg border-none bg-neutral-100 p-0.5"
optionClassName="flex h-9 justify-center"
indicatorClassName="rounded-md border-none bg-white shadow-[0px_0px_2px_0px_rgba(0,0,0,0.05),0px_2px_6px_0px_rgba(0,0,0,0.1)]"
/>
)}
/>
</div>
</div>

<div className="flex flex-col gap-2.5">
<div>
<span className="text-content-emphasis text-sm font-medium">
Expand Down Expand Up @@ -173,14 +206,9 @@ function Preview({
control: Control<EarningsCalculatorBlockData>;
}) {
const productPrice = useWatch({ control, name: "productPrice" });
const billingPeriod = useWatch({ control, name: "billingPeriod" });

const { group } = useGroup();
const { control: brandingFormControl } = useBrandingFormContext();

const brandColor = useWatch({
control: brandingFormControl,
name: "brandColor",
});

if (!group) return null;

Expand All @@ -192,9 +220,10 @@ function Preview({
data: {
productPrice:
Math.min(Math.max(productPrice || 0, 0), MAX_PRODUCT_PRICE) * 100,
billingPeriod: billingPeriod ?? "monthly",
},
}}
group={{ ...group, brandColor }}
group={group}
showTitleAndDescription={false}
/>
);
Expand Down
101 changes: 57 additions & 44 deletions apps/web/ui/partners/lander/blocks/earnings-calculator-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@ import * as z from "zod/v4";
import { formatRewardDescription } from "../../format-reward-description";
import { BlockDescription } from "./block-description";
import { BlockTitle } from "./block-title";
import { WavePattern } from "./wave-pattern";

const SLIDER_MIN = 1;
const SLIDER_MAX = 50;

export function EarningsCalculatorBlock({
block,
group,
showTitleAndDescription = true,
}: {
block: z.infer<typeof programLanderEarningsCalculatorBlockSchema>;
group: Pick<GroupProps, "saleReward" | "brandColor">;
group: Pick<GroupProps, "saleReward">;
showTitleAndDescription?: boolean;
}) {
const id = useId();
Expand All @@ -28,6 +32,16 @@ export function EarningsCalculatorBlock({
const rewardAmount = getRewardAmount(group.saleReward);
const revenue = value * ((block.data.productPrice || 30_00) / 100);

const isYearly = block.data.billingPeriod === "yearly";

const monthlyEarnings = Math.floor(
group.saleReward.type === "flat"
? (value * rewardAmount) / 100
: revenue * (rewardAmount / 100),
);

const displayEarnings = isYearly ? monthlyEarnings * 12 : monthlyEarnings;

return (
<div className="space-y-5">
{showTitleAndDescription && (
Expand All @@ -37,54 +51,53 @@ export function EarningsCalculatorBlock({
</div>
)}

<div className="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<div className="p-4 sm:p-8">
<label
htmlFor={`${id}-slider`}
className="text-base font-semibold text-neutral-700"
>
Customer referrals
</label>
<div className="mt-1.5">
<div className="flex flex-col gap-4 rounded-xl border border-neutral-200 bg-white p-6">
{/* Earnings display */}
<div className="relative flex flex-col pt-3">
<span className="absolute left-0 top-0 text-sm font-semibold leading-5 text-neutral-700">
You can earn
</span>
<div className="flex items-baseline">
<NumberFlow
value={value}
className="text-2xl font-medium text-neutral-800"
value={displayEarnings}
className="text-5xl font-medium leading-[48px] tracking-[-0.96px] text-neutral-800"
prefix="$"
/>
</div>
<input
id={`${id}-slider`}
type="range"
min={1}
max={50}
value={value}
onChange={(e) => setValue(Number(e.target.value))}
className="mt-4 w-full"
style={{ accentColor: group.brandColor || "black" }}
/>
<div className="mt-2 flex items-center gap-1">
<InvoiceDollar className="size-3.5 text-neutral-400" />
<p className="text-xs text-neutral-500">
{formatRewardDescription(group.saleReward)}
</p>
<span className="text-base font-semibold leading-6 tracking-[-0.32px] text-neutral-700">
/ {isYearly ? "yr" : "mo"}
</span>
</div>
</div>
<div className="relative border-t border-neutral-200">
<div
className="absolute inset-0 opacity-5"
style={{ backgroundColor: group.brandColor || "black" }}
/>
<div className="flex flex-col items-center justify-center p-4 font-semibold text-neutral-800/60 sm:p-6">
<span>You can earn</span>
<NumberFlow
value={Math.floor(
group.saleReward.type === "flat"
? (value * rewardAmount) / 100
: revenue * (rewardAmount / 100),
)}
className="text-4xl font-medium text-neutral-800"
prefix="$"

{/* Slider section */}
<div className="relative overflow-hidden rounded-[10px] border border-neutral-100 bg-neutral-50 p-5">
<WavePattern />
<div className="relative z-10 flex flex-col gap-5">
<p
id={`${id}-label`}
className="text-base font-medium leading-6 tracking-[-0.32px] text-neutral-500"
>
<NumberFlow value={value} /> customer sales
</p>
<input
id={`${id}-slider`}
type="range"
aria-labelledby={`${id}-label`}
min={SLIDER_MIN}
max={SLIDER_MAX}
value={value}
onChange={(e) => setValue(Number(e.target.value))}
className="earnings-slider w-full"
style={{
background: `linear-gradient(to right, #262626 ${((value - SLIDER_MIN) / (SLIDER_MAX - SLIDER_MIN)) * 100}%, #e5e5e5 ${((value - SLIDER_MIN) / (SLIDER_MAX - SLIDER_MIN)) * 100}%)`,
}}
/>
<span>every month</span>
<div className="flex items-center gap-1">
<InvoiceDollar className="size-3.5 text-neutral-500" />
<p className="text-xs font-normal leading-4 tracking-[-0.24px] text-neutral-500">
{formatRewardDescription(group.saleReward)}
</p>
</div>
</div>
</div>
</div>
Expand Down
Loading
Loading