Skip to content

Commit 93ec75d

Browse files
authored
Merge pull request dubinc#2825 from dubinc/custom-reward-descriptions
Custom reward description configuration
2 parents 052077e + 85c6521 commit 93ec75d

File tree

5 files changed

+215
-109
lines changed

5 files changed

+215
-109
lines changed

apps/web/lib/actions/partners/create-reward.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,15 @@ export const createRewardAction = authActionClient
1818
.schema(createRewardSchema)
1919
.action(async ({ parsedInput, ctx }) => {
2020
const { workspace, user } = ctx;
21-
const { event, amount, type, maxDuration, modifiers, groupId } =
22-
parsedInput;
21+
const {
22+
event,
23+
amount,
24+
type,
25+
maxDuration,
26+
description,
27+
modifiers,
28+
groupId,
29+
} = parsedInput;
2330

2431
const programId = getDefaultProgramIdOrThrow(workspace);
2532
const { canUseAdvancedRewardLogic } = getPlanCapabilities(workspace.plan);
@@ -52,6 +59,7 @@ export const createRewardAction = authActionClient
5259
type,
5360
amount,
5461
maxDuration,
62+
description: description || null,
5563
modifiers: modifiers || Prisma.DbNull,
5664
},
5765
});

apps/web/lib/actions/partners/update-reward.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export const updateRewardAction = authActionClient
1515
.schema(updateRewardSchema)
1616
.action(async ({ parsedInput, ctx }) => {
1717
const { workspace, user } = ctx;
18-
const { rewardId, amount, maxDuration, type, modifiers } = parsedInput;
18+
const { rewardId, amount, maxDuration, type, description, modifiers } =
19+
parsedInput;
1920

2021
const programId = getDefaultProgramIdOrThrow(workspace);
2122

@@ -40,6 +41,7 @@ export const updateRewardAction = authActionClient
4041
type,
4142
amount,
4243
maxDuration,
44+
description: description || null,
4345
modifiers: modifiers === null ? Prisma.DbNull : modifiers,
4446
},
4547
include: {

apps/web/lib/zod/schemas/rewards.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export const createOrUpdateRewardSchema = z.object({
150150
amount: z.number().min(0),
151151
maxDuration: maxDurationSchema,
152152
modifiers: rewardConditionsArraySchema.nullish(),
153+
description: z.string().max(100).nullish(),
153154
groupId: z.string(),
154155
});
155156

apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx

Lines changed: 160 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,16 @@ import { X } from "@/ui/shared/icons";
2222
import { EventType, RewardStructure } from "@dub/prisma/client";
2323
import {
2424
Button,
25+
Gift,
2526
MoneyBills2,
27+
Pen2,
2628
Sheet,
29+
Tooltip,
2730
TooltipContent,
2831
useRouterStuff,
2932
} from "@dub/ui";
3033
import { capitalize, cn, pluralize } from "@dub/utils";
34+
import { motion } from "framer-motion";
3135
import { useAction } from "next-safe-action/hooks";
3236
import {
3337
Dispatch,
@@ -46,6 +50,7 @@ import { z } from "zod";
4650
import {
4751
InlineBadgePopover,
4852
InlineBadgePopoverContext,
53+
InlineBadgePopoverInput,
4954
InlineBadgePopoverMenu,
5055
} from "../../shared/inline-badge-popover";
5156
import { usePartnersUpgradeModal } from "../partners-upgrade-modal";
@@ -103,6 +108,7 @@ function RewardSheetContent({
103108
defaultValuesSource?.type === "flat"
104109
? defaultValuesSource.amount / 100
105110
: defaultValuesSource?.amount,
111+
description: defaultValuesSource?.description ?? null,
106112
modifiers: defaultValuesSource?.modifiers?.map((m) => {
107113
const type = m.type === undefined ? defaultValuesSource?.type : m.type;
108114
const maxDuration =
@@ -131,13 +137,15 @@ function RewardSheetContent({
131137

132138
const { handleSubmit, watch, setValue, setError } = form;
133139

134-
const [selectedEvent, amount, type, maxDuration, modifiers] = watch([
135-
"event",
136-
"amount",
137-
"type",
138-
"maxDuration",
139-
"modifiers",
140-
]);
140+
const [selectedEvent, amount, type, maxDuration, description, modifiers] =
141+
watch([
142+
"event",
143+
"amount",
144+
"type",
145+
"maxDuration",
146+
"description",
147+
"modifiers",
148+
]);
141149

142150
const { executeAsync: createReward, isPending: isCreating } = useAction(
143151
createRewardAction,
@@ -188,7 +196,7 @@ function RewardSheetContent({
188196

189197
useEffect(() => {
190198
if (
191-
modifiers?.length > 0 &&
199+
modifiers?.length &&
192200
!getPlanCapabilities(plan).canUseAdvancedRewardLogic
193201
) {
194202
setShowAdvancedUpsell(true);
@@ -308,83 +316,154 @@ function RewardSheetContent({
308316
<div className="flex flex-1 flex-col overflow-y-auto p-6">
309317
<RewardSheetCard
310318
title={
311-
<>
312-
<RewardIconSquare icon={MoneyBills2} />
313-
<span className="leading-relaxed">
314-
Pay{" "}
315-
{selectedEvent === "sale" && (
316-
<>
317-
a{" "}
318-
<InlineBadgePopover text={capitalize(type)}>
319-
<InlineBadgePopoverMenu
320-
selectedValue={type}
321-
onSelect={(value) =>
322-
setValue("type", value as RewardStructure, {
323-
shouldDirty: true,
324-
})
325-
}
326-
items={REWARD_TYPES}
327-
/>
328-
</InlineBadgePopover>{" "}
329-
{type === "percentage" && "of "}
330-
</>
331-
)}
332-
<InlineBadgePopover
333-
text={
334-
!isNaN(amount)
335-
? constructRewardAmount({
336-
amount: type === "flat" ? amount * 100 : amount,
337-
type,
338-
maxDuration,
339-
})
340-
: "amount"
341-
}
342-
invalid={isNaN(amount)}
343-
>
344-
<AmountInput />
345-
</InlineBadgePopover>{" "}
346-
per {selectedEvent}
347-
{selectedEvent === "sale" && (
348-
<>
349-
{" "}
319+
<div className="w-full">
320+
<div className="flex min-w-0 items-center justify-between">
321+
<div className="flex min-w-0 items-center gap-2.5">
322+
<RewardIconSquare icon={MoneyBills2} />
323+
<span className="leading-relaxed">
324+
Pay{" "}
325+
{selectedEvent === "sale" && (
326+
<>
327+
a{" "}
328+
<InlineBadgePopover text={capitalize(type)}>
329+
<InlineBadgePopoverMenu
330+
selectedValue={type}
331+
onSelect={(value) =>
332+
setValue("type", value as RewardStructure, {
333+
shouldDirty: true,
334+
})
335+
}
336+
items={REWARD_TYPES}
337+
/>
338+
</InlineBadgePopover>{" "}
339+
{type === "percentage" && "of "}
340+
</>
341+
)}
350342
<InlineBadgePopover
351343
text={
352-
maxDuration === 0
353-
? "one time"
354-
: maxDuration === Infinity
355-
? "for the customer's lifetime"
356-
: `for ${maxDuration} ${pluralize("month", Number(maxDuration))}`
344+
!isNaN(amount)
345+
? constructRewardAmount({
346+
amount: type === "flat" ? amount * 100 : amount,
347+
type,
348+
maxDuration,
349+
})
350+
: "amount"
357351
}
352+
invalid={isNaN(amount)}
358353
>
359-
<InlineBadgePopoverMenu
360-
selectedValue={maxDuration?.toString()}
361-
onSelect={(value) =>
362-
setValue("maxDuration", Number(value), {
363-
shouldDirty: true,
364-
})
365-
}
366-
items={[
367-
{
368-
text: "one time",
369-
value: "0",
370-
},
371-
...RECURRING_MAX_DURATIONS.filter(
372-
(v) => v !== 0 && v !== 1, // filter out one-time and 1-month intervals (we only use 1-month for discounts)
373-
).map((v) => ({
374-
text: `for ${v} ${pluralize("month", Number(v))}`,
375-
value: v.toString(),
376-
})),
377-
{
378-
text: "for the customer's lifetime",
379-
value: "Infinity",
380-
},
381-
]}
382-
/>
383-
</InlineBadgePopover>
384-
</>
385-
)}
386-
</span>
387-
</>
354+
<AmountInput />
355+
</InlineBadgePopover>{" "}
356+
per {selectedEvent}
357+
{selectedEvent === "sale" && (
358+
<>
359+
{" "}
360+
<InlineBadgePopover
361+
text={
362+
maxDuration === 0
363+
? "one time"
364+
: maxDuration === Infinity
365+
? "for the customer's lifetime"
366+
: `for ${maxDuration} ${pluralize("month", Number(maxDuration))}`
367+
}
368+
>
369+
<InlineBadgePopoverMenu
370+
selectedValue={maxDuration?.toString()}
371+
onSelect={(value) =>
372+
setValue("maxDuration", Number(value), {
373+
shouldDirty: true,
374+
})
375+
}
376+
items={[
377+
{
378+
text: "one time",
379+
value: "0",
380+
},
381+
...RECURRING_MAX_DURATIONS.filter(
382+
(v) => v !== 0 && v !== 1, // filter out one-time and 1-month intervals (we only use 1-month for discounts)
383+
).map((v) => ({
384+
text: `for ${v} ${pluralize("month", Number(v))}`,
385+
value: v.toString(),
386+
})),
387+
{
388+
text: "for the customer's lifetime",
389+
value: "Infinity",
390+
},
391+
]}
392+
/>
393+
</InlineBadgePopover>
394+
</>
395+
)}
396+
</span>
397+
</div>
398+
<Tooltip
399+
content={"Add a custom reward description"}
400+
disabled={description !== null}
401+
>
402+
<div className="shrink-0">
403+
<Button
404+
variant="secondary"
405+
className={cn(
406+
"size-7 p-0",
407+
description !== null && "text-blue-600",
408+
)}
409+
icon={<Pen2 className="size-3.5" />}
410+
onClick={() =>
411+
setValue(
412+
"description",
413+
description === null ? "" : null,
414+
{ shouldDirty: true },
415+
)
416+
}
417+
/>
418+
</div>
419+
</Tooltip>
420+
</div>
421+
<motion.div
422+
initial={false}
423+
transition={{ ease: "easeInOut", duration: 0.2 }}
424+
animate={{
425+
height: description !== null ? "auto" : 0,
426+
opacity: description !== null ? 1 : 0,
427+
}}
428+
className="-mx-2.5 overflow-hidden"
429+
>
430+
<div className="pt-2.5">
431+
<div className="border-border-subtle flex min-w-0 items-center gap-2.5 border-t px-2.5 pt-2.5">
432+
<RewardIconSquare icon={Gift} />
433+
<span className="grow leading-relaxed">
434+
Shown as{" "}
435+
<InlineBadgePopover
436+
text={description || "Reward description"}
437+
invalid={!description}
438+
>
439+
<InlineBadgePopoverInput
440+
value={description ?? ""}
441+
onChange={(e) =>
442+
setValue(
443+
"description",
444+
(e.target as HTMLInputElement).value,
445+
{
446+
shouldDirty: true,
447+
},
448+
)
449+
}
450+
className="sm:w-80"
451+
maxLength={100}
452+
/>
453+
</InlineBadgePopover>
454+
</span>
455+
<Button
456+
variant="outline"
457+
className="size-6 shrink-0 p-0"
458+
icon={<X className="size-3" strokeWidth={2} />}
459+
onClick={() =>
460+
setValue("description", null, { shouldDirty: true })
461+
}
462+
/>
463+
</div>
464+
</div>
465+
</motion.div>
466+
</div>
388467
}
389468
content={
390469
selectedEvent === "click" ? undefined : (

0 commit comments

Comments
 (0)