diff --git a/apps/web/lib/ai/review-reward-tooltip-schema.ts b/apps/web/lib/ai/review-reward-tooltip-schema.ts new file mode 100644 index 00000000000..a2031878e81 --- /dev/null +++ b/apps/web/lib/ai/review-reward-tooltip-schema.ts @@ -0,0 +1,159 @@ +import { AI_REWARD_EVENTS } from "@/lib/ai/ai-reward-schema"; +import { CONDITION_OPERATORS } from "@/lib/zod/schemas/rewards"; +import * as z from "zod/v4"; + +export const TOOLTIP_SUGGESTION_CONFIDENCE_FLOOR = 0.5; + +const tooltipSuggestionValueSchema = z.union([ + z.string(), + z.number(), + z.array(z.string()), + z.array(z.number()), +]); + +const tooltipSuggestionPatchSchema = z.object({ + operator: z + .enum(CONDITION_OPERATORS) + .optional() + .describe("Replacement operator. Omit to keep the current operator."), + value: tooltipSuggestionValueSchema + .optional() + .describe("Replacement value. Omit to keep the current value."), +}); + +const tooltipSuggestionSchema = z.object({ + modifierIndex: z + .number() + .int() + .nonnegative() + .describe("Index of the existing modifier group (0-based)."), + conditionIndex: z + .number() + .int() + .nonnegative() + .describe("Index of the existing condition within that group (0-based)."), + confidence: z + .number() + .min(0) + .max(1) + .describe( + "0–1 confidence that this condition contradicts the tooltip. Use below 0.5 when the reading is ambiguous.", + ), + reason: z + .string() + .min(1) + .max(200) + .describe( + "One short sentence for a non-technical user. Quote the tooltip phrase and the conflicting condition wording. Never use raw operator IDs.", + ), + suggested: tooltipSuggestionPatchSchema.describe( + "Operator and/or value to write back. Never change entity or attribute.", + ), +}); + +const payoutFixSchema = z.object({ + scope: z + .enum(["default", "group"]) + .describe( + "default is the payout when no condition matches. group is one condition group's payout.", + ), + modifierIndex: z + .number() + .int() + .nonnegative() + .nullish() + .describe( + "Required when scope is group. The condition group's modifierIndex.", + ), + amount: z + .number() + .nonnegative() + .nullish() + .describe( + "New amount in dollars for a flat payout, or percent for a percentage payout. Omit to keep the current amount.", + ), + maxDuration: z + .number() + .int() + .min(0) + .max(600) + .nullable() + .optional() + .describe( + "0 = one time, null = for the customer's lifetime, a positive integer = that many months. Omit to keep the current duration.", + ), + confidence: z + .number() + .min(0) + .max(1) + .describe( + "0–1 confidence that the copy states this amount or duration. Use below 0.5 when the reading is ambiguous.", + ), + reason: z + .string() + .min(1) + .max(300) + .describe( + "One short sentence quoting the copy and the payout it disagrees with. Never use raw field names.", + ), +}); + +export const reviewRewardTooltipOutputSchema = z.object({ + suggestions: z + .array(tooltipSuggestionSchema) + .describe( + "Condition operator or value fixes. Empty when no existing condition should change.", + ), + payoutFixes: z + .array(payoutFixSchema) + .describe( + "Amount or duration changes so a payout matches the partner copy. One entry per payout that disagrees. Empty when every payout already matches, or when you cannot name the replacement amount or duration.", + ), + note: z + .string() + .max(300) + .nullish() + .describe( + "One sentence when the copy disagrees with a payout and you cannot name the replacement amount or duration. Null when suggestions or payoutFixes is non-empty.", + ), +}); + +const reviewRewardTooltipConditionSchema = z.object({ + entity: z.string(), + attribute: z.string(), + operator: z.enum(CONDITION_OPERATORS), + value: tooltipSuggestionValueSchema, + label: z.string().nullish(), + metadataField: z.string().optional(), +}); + +const rewardPayoutSchema = z.object({ + type: z.enum(["flat", "percentage"]), + amount: z.number().nullish(), + maxDuration: z.number().int().nonnegative().nullish(), +}); + +const reviewRewardTooltipModifierSchema = z.object({ + operator: z.enum(["AND", "OR"]).default("AND"), + conditions: z.array(reviewRewardTooltipConditionSchema).min(1), + payout: rewardPayoutSchema, +}); + +export const reviewRewardTooltipInputSchema = z.object({ + workspaceId: z.string(), + event: z.enum(AI_REWARD_EVENTS), + tooltip: z.string().min(1).max(4000), + description: z.string().nullish(), + basePayout: rewardPayoutSchema, + modifiers: z.array(reviewRewardTooltipModifierSchema).min(1), +}); + +export type TooltipSuggestion = z.infer; +export type TooltipSuggestionPatch = z.infer< + typeof tooltipSuggestionPatchSchema +>; +export type PayoutFix = z.infer; +export type ReviewRewardTooltipModifier = z.infer< + typeof reviewRewardTooltipModifierSchema +>; +export type RewardPayout = z.infer; diff --git a/apps/web/lib/ai/review-reward-tooltip.ts b/apps/web/lib/ai/review-reward-tooltip.ts new file mode 100644 index 00000000000..bf3b5effcf5 --- /dev/null +++ b/apps/web/lib/ai/review-reward-tooltip.ts @@ -0,0 +1,423 @@ +"use server"; + +import { normalizeWorkspaceId } from "@/lib/api/workspaces/workspace-id"; +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { + filterValidatedTooltipSuggestions, + getRewardConditionAttribute, + stripRewardTooltipMarkdown, +} from "@/lib/rewards/validate-tooltip-suggestion"; +import { assertRateLimit } from "@/lib/upstash/assert-rate-limit"; +import { RATELIMIT_POLICIES } from "@/lib/upstash/ratelimit-policies"; +import { + CONDITION_OPERATOR_LABELS, + REWARD_CONDITIONS, +} from "@/lib/zod/schemas/rewards"; +import { anthropic } from "@ai-sdk/anthropic"; +import { type EventType } from "@prisma/client"; +import { experimental_evaluate as evaluate, generateText, Output } from "ai"; +import { throwIfNoPermission } from "../actions/throw-if-no-permission"; +import { + reviewRewardTooltipInputSchema, + reviewRewardTooltipOutputSchema, + TOOLTIP_SUGGESTION_CONFIDENCE_FLOOR, + type PayoutFix, + type ReviewRewardTooltipModifier, + type RewardPayout, + type TooltipSuggestion, +} from "./review-reward-tooltip-schema"; + +export async function screenRewardTooltipContradiction( + input: unknown, +): Promise<{ flagged: boolean | null }> { + try { + const authorized = await authorizeRewardTooltipReview(input); + if (!authorized) { + return { flagged: false }; + } + + const tooltip = stripRewardTooltipMarkdown(authorized.data.tooltip); + if (!tooltip) { + return { flagged: false }; + } + + await assertRateLimit({ + policy: RATELIMIT_POLICIES.aiRewardTooltipScreen, + identifier: [authorized.userId, authorized.workspaceId], + }); + + const reward = describeReward({ + ...authorized.data, + tooltip, + }); + const result = await evaluate({ + model: "typesafe-ai/jev", + state: reward, + questions: { + contradicts: { + type: "boolean", + instructions: + "Would a partner who read the shown-as text or the tooltip be surprised by this reward?", + criteria: { + true: { + meaning: + "Yes when the partner copy states an amount, a duration, or an eligibility rule that the payout or a condition does not match. One strong mismatch is enough. Use a low probability when unsure.", + matches_any: [ + "The copy says every month, every N months, monthly, recurring, lifetime, or one time, and that is not the payout duration.", + "The copy states a dollar or percent amount that is not what the default payout or the matching condition group pays.", + "The copy says minimum, at least, or that a threshold qualifies, but the condition uses a strict greater-than or less-than that excludes that boundary.", + "The copy says more than, over, or above, but the condition includes the boundary.", + "The copy names a different operator or threshold than an existing condition.", + ], + }, + false: { + meaning: + "No when the copy agrees with the payout and the conditions, or when it does not clearly state a conflicting amount, duration, operator, or threshold.", + even_if: [ + "The copy is shorter than the config or omits extra filters such as country, product, or metadata.", + "The copy never mentions a condition that still exists, and it also does not state a conflicting amount or duration.", + "The wording is informal and does not clearly name a different amount, duration, operator, or threshold.", + ], + }, + }, + }, + }, + providerOptions: { + gateway: { + zeroDataRetention: true, + }, + }, + }); + + const answer = result.answers.contradicts; + if (answer?.type !== "boolean") { + console.log("[screenRewardTooltipContradiction] jev result", { + reward, + answer, + flagged: null, + }); + return { flagged: null }; + } + + const flagged = + Number.isFinite(answer.probability) && + answer.probability > TOOLTIP_SUGGESTION_CONFIDENCE_FLOOR; + + console.log("[screenRewardTooltipContradiction] jev result", { + reward, + probability: answer.probability, + flagged, + }); + + return { flagged }; + } catch (error) { + console.error("[screenRewardTooltipContradiction]", error); + return { flagged: null }; + } +} + +export async function reviewRewardTooltipConsistency(input: unknown): Promise<{ + suggestions: TooltipSuggestion[]; + payoutFixes: PayoutFix[]; + note: string | null; +}> { + const empty = { suggestions: [], payoutFixes: [], note: null }; + + try { + const authorized = await authorizeRewardTooltipReview(input); + if (!authorized) { + return empty; + } + + await assertRateLimit({ + policy: RATELIMIT_POLICIES.aiRewardTooltipReview, + identifier: [authorized.userId, authorized.workspaceId], + }); + + const tooltip = stripRewardTooltipMarkdown(authorized.data.tooltip); + if (!tooltip) { + return empty; + } + + const { event, modifiers, basePayout } = authorized.data; + const reward = describeReward({ + ...authorized.data, + tooltip, + }); + + const { output } = await generateText({ + model: anthropic("claude-haiku-4-5"), + output: Output.object({ + schema: reviewRewardTooltipOutputSchema, + }), + system: buildSystemPrompt(event), + prompt: `Partner copy and the reward it must match: +${JSON.stringify(reward, null, 2)} + +Use modifierIndex and conditionIndex from conditionGroups when a condition should change. Write the reason for a non-technical user.`, + temperature: 0, + maxOutputTokens: 900, + }); + + const parsedOutput = reviewRewardTooltipOutputSchema.safeParse(output); + if (!parsedOutput.success) { + return empty; + } + + const suggestions = filterValidatedTooltipSuggestions({ + event, + modifiers, + suggestions: parsedOutput.data.suggestions, + }); + const payoutFixes = filterPayoutFixes({ + basePayout, + modifiers, + fixes: parsedOutput.data.payoutFixes, + }); + + return { + suggestions, + payoutFixes, + note: + suggestions.length || payoutFixes.length + ? null + : parsedOutput.data.note?.trim() || null, + }; + } catch (error) { + console.error("[reviewRewardTooltipConsistency]", error); + return empty; + } +} + +async function authorizeRewardTooltipReview(input: unknown) { + const parsedInput = reviewRewardTooltipInputSchema.safeParse(input); + if (!parsedInput.success) { + return null; + } + + const session = await getSession(); + if (!session?.user.id) { + return null; + } + + const workspaceId = normalizeWorkspaceId(parsedInput.data.workspaceId); + const workspace = await prisma.project.findUnique({ + where: { id: workspaceId }, + include: { + users: { + where: { userId: session.user.id }, + select: { role: true }, + }, + }, + }); + + if (!workspace?.users?.length) { + return null; + } + + throwIfNoPermission({ + role: workspace.users[0].role, + requiredRoles: ["owner", "member"], + }); + + return { + userId: session.user.id, + workspaceId, + data: parsedInput.data, + }; +} + +function buildSystemPrompt(event: string) { + return `You review whether partner-facing reward copy contradicts the structured reward. + +Event type: ${event} + +The reward has partner copy (shown-as text and tooltip), a default payout, and condition groups. Each group pays its own amount and duration when its conditions match. The default payout applies when no group matches. Amounts are dollars for flat payouts and percents for percentage payouts. Duration is "one time", "for N months", or "for the customer's lifetime". + +Rules: +- Compare meaning, not wording. +- Only flag an EXISTING condition that a partner who read the copy would find surprising. +- Do not flag extra conditions the copy never mentioned. +- Do not invent new condition rows. Do not change entity or attribute. +- A suggestion may change operator and/or value only. +- When the copy states an amount or duration that a payout does not match, return a payoutFix for each payout that disagrees. Set amount and/or maxDuration to what the copy says. Omit a field that already matches. +- "no money", "$0", "free", or "earn nothing" means amount 0. +- "no month", "one time", or "not monthly" means maxDuration 0. +- "every month" or "monthly" means maxDuration 1. "every N months" means maxDuration N. "lifetime" means maxDuration null. +- If you return a condition suggestion or a payoutFix, set note to null. +- Use note only when the copy disagrees and you cannot name the replacement amount, duration, operator, or threshold. +- If you are not sure, return suggestions: [], payoutFixes: [], and note: null. Never guess. +- confidence is 0–1. Use below 0.5 when the reading is ambiguous or only loosely implied. +- reason and note must be one short sentence a program manager can understand. +- Use the human operator labels (e.g. "is greater than"), never raw IDs like greater_than or field paths like sale.amount. +- Wrap the copy phrase and the conflicting wording in double quotes. +- Do not explain inclusive/exclusive in parentheses. Say what a partner would expect vs what the reward does. + +Good reason: "Minimum $20 deposit" implies $20 qualifies, but "is greater than" excludes it. +Bad reason: Tooltip says "min $20" (at least $20, inclusive) but the condition uses greater_than 20 (excludes $20). + +Condition contradictions: +- Copy says "minimum $20" / "at least $20" but the condition is "is greater than" $20 (excludes $20). +- Copy says "more than $20" / "over $20" but the condition is "is greater than or equal to" $20. + +Payout fixes, not a note: +- Copy says "earn no money" and "no month", but the default payout is $10 one time and a condition group pays $30 one time. Return two payoutFixes with amount 0. Omit maxDuration because it is already one time. +- Copy says "every 3 months" but the payout duration is "one time". Return maxDuration 3 and omit amount if it already matches. +- Copy says "earn $300" but the payout is a flat $10. Return amount 300 and omit maxDuration if it already matches. + +Not contradictions: +- Copy is shorter than the config or omits extra filters (country, product, metadata). +- Informal wording that does not clearly specify a different amount, duration, operator, or threshold.`; +} + +function describeReward({ + event, + tooltip, + description, + basePayout, + modifiers, +}: { + event: EventType; + tooltip: string; + description?: string | null; + basePayout: RewardPayout; + modifiers: ReviewRewardTooltipModifier[]; +}) { + const shownAs = stripRewardTooltipMarkdown(description ?? ""); + + return { + event, + partnerCopy: { + shownAs: shownAs || null, + tooltip, + }, + defaultPayout: { + pays: formatPayout(basePayout), + appliesWhen: "no condition group matches", + }, + conditionGroups: modifiers.map((modifier, modifierIndex) => ({ + modifierIndex, + match: + modifier.operator === "OR" + ? "any condition (OR)" + : "every condition (AND)", + when: modifier.conditions.map((condition, conditionIndex) => ({ + conditionIndex, + rule: formatCondition(event, condition), + })), + thenPays: formatPayout(modifier.payout), + })), + }; +} + +function formatPayout(payout: RewardPayout) { + const amount = + payout.type === "percentage" + ? `${payout.amount ?? 0}% of the sale` + : `$${payout.amount ?? 0} flat`; + const duration = + payout.maxDuration == null + ? "for the customer's lifetime" + : payout.maxDuration === 0 + ? "one time" + : `for ${payout.maxDuration} month${payout.maxDuration === 1 ? "" : "s"}`; + + return `${amount}, ${duration}`; +} + +function filterPayoutFixes({ + basePayout, + modifiers, + fixes, +}: { + basePayout: RewardPayout; + modifiers: ReviewRewardTooltipModifier[]; + fixes: PayoutFix[]; +}): PayoutFix[] { + const byTarget = new Map(); + + for (const fix of fixes) { + if ( + typeof fix.confidence !== "number" || + fix.confidence < TOOLTIP_SUGGESTION_CONFIDENCE_FLOOR || + !fix.reason?.trim() + ) { + continue; + } + + const current = + fix.scope === "default" + ? basePayout + : modifiers[fix.modifierIndex ?? -1]?.payout; + + if (!current) continue; + if (fix.scope === "group" && typeof fix.modifierIndex !== "number") { + continue; + } + + const nextDuration = + fix.maxDuration === undefined ? current.maxDuration : fix.maxDuration; + const amountChanges = + typeof fix.amount === "number" && fix.amount !== current.amount; + const durationChanges = + fix.maxDuration !== undefined && nextDuration !== current.maxDuration; + + if (!amountChanges && !durationChanges) continue; + + const key = + fix.scope === "default" ? "default" : `group:${fix.modifierIndex}`; + const existing = byTarget.get(key); + + if (!existing || fix.confidence > existing.confidence) { + byTarget.set(key, { + ...fix, + reason: fix.reason.trim(), + amount: amountChanges ? fix.amount : null, + maxDuration: durationChanges ? nextDuration : undefined, + }); + } + } + + return [...byTarget.values()]; +} + +function formatCondition( + event: EventType, + condition: ReviewRewardTooltipModifier["conditions"][number], +) { + const attribute = getRewardConditionAttribute({ + event, + entity: condition.entity, + attribute: condition.attribute, + }); + const entity = REWARD_CONDITIONS[event].entities.find( + (entry) => entry.id === condition.entity, + ); + const operator = + CONDITION_OPERATOR_LABELS[condition.operator] ?? condition.operator; + const field = condition.metadataField + ? `${attribute?.label ?? condition.attribute} (${condition.metadataField})` + : attribute?.label ?? condition.attribute; + + return `${entity?.label ?? condition.entity} ${field} ${operator} ${formatConditionValue(attribute?.type, condition.value)}`; +} + +function formatConditionValue( + type: string | undefined, + value: unknown, +): string { + if (Array.isArray(value)) { + return value.map((item) => formatConditionValue(type, item)).join(", "); + } + + if ( + type === "currency" && + typeof value === "number" && + Number.isFinite(value) + ) { + return `$${value}`; + } + + return String(value); +} diff --git a/apps/web/lib/rewards/validate-tooltip-suggestion.ts b/apps/web/lib/rewards/validate-tooltip-suggestion.ts new file mode 100644 index 00000000000..13eb7a36913 --- /dev/null +++ b/apps/web/lib/rewards/validate-tooltip-suggestion.ts @@ -0,0 +1,357 @@ +import { + TOOLTIP_SUGGESTION_CONFIDENCE_FLOOR, + type ReviewRewardTooltipModifier, + type TooltipSuggestion, + type TooltipSuggestionPatch, +} from "@/lib/ai/review-reward-tooltip-schema"; +import { + CONDITION_OPERATORS, + DATE_CONDITION_OPERATORS, + ENUM_CONDITION_OPERATORS, + METADATA_CONDITION_OPERATORS, + METADATA_NUMBER_CONDITION_OPERATORS, + NUMBER_CONDITION_OPERATORS, + REWARD_CONDITIONS, + STRING_CONDITION_OPERATORS, + type RewardConditionEntityAttribute, +} from "@/lib/zod/schemas/rewards"; +import { EventType } from "@prisma/client"; + +type ConditionOperator = (typeof CONDITION_OPERATORS)[number]; + +export function getRewardConditionAttribute({ + event, + entity, + attribute, +}: { + event: EventType; + entity?: string; + attribute?: string; +}): RewardConditionEntityAttribute | undefined { + if (!entity || !attribute) return undefined; + + return REWARD_CONDITIONS[event].entities + .find((entry) => entry.id === entity) + ?.attributes.find((entry) => entry.id === attribute); +} + +export function isRewardConditionComplete({ + event, + condition, +}: { + event: EventType; + condition?: { + entity?: string; + attribute?: string; + operator?: string; + value?: unknown; + metadataField?: string; + } | null; +}): boolean { + if ( + !condition?.entity || + !condition.attribute || + !condition.operator || + !getRewardConditionAttribute({ + event, + entity: condition.entity, + attribute: condition.attribute, + }) + ) { + return false; + } + + if (condition.attribute === "metadata" && !condition.metadataField?.trim()) { + return false; + } + + return isConditionValueFilled(condition.value); +} + +export function applyTooltipSuggestion< + T extends { operator?: ConditionOperator; value?: unknown }, +>(condition: T, suggested: TooltipSuggestionPatch): T { + return { + ...condition, + ...(suggested.operator ? { operator: suggested.operator } : {}), + ...(suggested.value !== undefined ? { value: suggested.value } : {}), + }; +} + +export type TooltipSuggestionField = "operator" | "value"; + +export type TooltipSuggestionPage = { + suggestion: TooltipSuggestion; + field: TooltipSuggestionField; +}; + +export function getTooltipSuggestionPages({ + suggestions, + modifiers, +}: { + suggestions: TooltipSuggestion[]; + modifiers: Array<{ + conditions: Array<{ operator?: ConditionOperator; value?: unknown }>; + }>; +}): TooltipSuggestionPage[] { + const pages: TooltipSuggestionPage[] = []; + + for (const suggestion of suggestions) { + const current = + modifiers[suggestion.modifierIndex]?.conditions[ + suggestion.conditionIndex + ]; + + if (!current) continue; + + if ( + suggestionTouchesField({ + field: "operator", + current, + suggested: suggestion.suggested, + }) + ) { + pages.push({ suggestion, field: "operator" }); + } + + if ( + suggestionTouchesField({ + field: "value", + current, + suggested: suggestion.suggested, + }) + ) { + pages.push({ suggestion, field: "value" }); + } + } + + return pages; +} + +export function suggestionTouchesField({ + field, + current, + suggested, +}: { + field: TooltipSuggestionField; + current: { operator?: ConditionOperator; value?: unknown }; + suggested: TooltipSuggestionPatch; +}): boolean { + if (field === "operator") { + return ( + suggested.operator != null && suggested.operator !== current.operator + ); + } + + return ( + suggested.value !== undefined && + !valuesEqual(suggested.value, current.value) + ); +} + +export function filterValidatedTooltipSuggestions({ + event, + modifiers, + suggestions, +}: { + event: EventType; + modifiers: ReviewRewardTooltipModifier[]; + suggestions: TooltipSuggestion[]; +}): TooltipSuggestion[] { + const byCondition = new Map(); + + for (const suggestion of suggestions) { + if (!isValidTooltipSuggestion({ event, modifiers, suggestion })) { + continue; + } + + const key = `${suggestion.modifierIndex}:${suggestion.conditionIndex}`; + const existing = byCondition.get(key); + + if (!existing || suggestion.confidence > existing.confidence) { + byCondition.set(key, suggestion); + } + } + + return [...byCondition.values()]; +} + +function isValidTooltipSuggestion({ + event, + modifiers, + suggestion, +}: { + event: EventType; + modifiers: ReviewRewardTooltipModifier[]; + suggestion: TooltipSuggestion; +}): boolean { + if ( + typeof suggestion.confidence !== "number" || + Number.isNaN(suggestion.confidence) || + suggestion.confidence < TOOLTIP_SUGGESTION_CONFIDENCE_FLOOR + ) { + return false; + } + + if (!suggestion.reason?.trim()) { + return false; + } + + if ( + suggestion.suggested.operator == null && + suggestion.suggested.value === undefined + ) { + return false; + } + + const current = + modifiers[suggestion.modifierIndex]?.conditions[suggestion.conditionIndex]; + + if (!current) { + return false; + } + + const nextOperator = suggestion.suggested.operator ?? current.operator; + const allowedOperators = getAllowedOperators({ + event, + entity: current.entity, + attribute: current.attribute, + }); + + if (!allowedOperators.includes(nextOperator)) { + return false; + } + + const nextValue = + suggestion.suggested.value !== undefined + ? suggestion.suggested.value + : current.value; + + if ( + !isValueValidForOperator({ + event, + entity: current.entity, + attribute: current.attribute, + operator: nextOperator, + value: nextValue, + }) + ) { + return false; + } + + return ( + nextOperator !== current.operator || !valuesEqual(nextValue, current.value) + ); +} + +function getAllowedOperators({ + event, + entity, + attribute, +}: { + event: EventType; + entity?: string; + attribute?: string; +}): ConditionOperator[] { + if ( + (entity === "customer" && attribute === "source") || + (entity === "sale" && attribute === "type") + ) { + return ["equals_to"]; + } + + const attributeType = + getRewardConditionAttribute({ event, entity, attribute })?.type ?? "string"; + + if (attributeType === "metadata") { + return [...METADATA_CONDITION_OPERATORS]; + } + + if (attributeType === "number" || attributeType === "currency") { + return [...NUMBER_CONDITION_OPERATORS]; + } + + if (attributeType === "enum") { + return [...ENUM_CONDITION_OPERATORS]; + } + + if (attributeType === "date") { + return [...DATE_CONDITION_OPERATORS]; + } + + return [...STRING_CONDITION_OPERATORS]; +} + +function isConditionValueFilled(value: unknown): boolean { + if (value == null || value === "") return false; + if (Array.isArray(value)) return value.filter(Boolean).length > 0; + if (typeof value === "number") return !Number.isNaN(value); + return true; +} + +function isValueValidForOperator({ + event, + entity, + attribute, + operator, + value, +}: { + event: EventType; + entity: string; + attribute: string; + operator: ConditionOperator; + value: unknown; +}): boolean { + if (!isConditionValueFilled(value)) { + return false; + } + + if (operator === "in" || operator === "not_in") { + return Array.isArray(value); + } + + if (Array.isArray(value)) { + return false; + } + + const attributeType = + getRewardConditionAttribute({ event, entity, attribute })?.type ?? "string"; + + const numeric = + attributeType === "number" || + attributeType === "currency" || + attributeType === "date" || + (attributeType === "metadata" && + METADATA_NUMBER_CONDITION_OPERATORS.includes(operator)); + + if (numeric) { + return typeof value === "number" && !Number.isNaN(value); + } + + return typeof value === "string"; +} + +function valuesEqual(left: unknown, right: unknown): boolean { + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((item, index) => item === right[index]) + ); + } + + return left === right; +} + +export function stripRewardTooltipMarkdown(markdown: string): string { + return markdown + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .replace(/_([^_]+)_/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/#+\s+/g, "") + .replace(/\n+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} diff --git a/apps/web/lib/upstash/ratelimit-policies.ts b/apps/web/lib/upstash/ratelimit-policies.ts index f0693d3374c..1a5a7c52fc0 100644 --- a/apps/web/lib/upstash/ratelimit-policies.ts +++ b/apps/web/lib/upstash/ratelimit-policies.ts @@ -115,6 +115,18 @@ export const RATELIMIT_POLICIES = { keyPrefix: "rl:ai:reward:generate", }, + aiRewardTooltipReview: { + attempts: 20, + window: "1 m", + keyPrefix: "rl:ai:reward:tooltip-review", + }, + + aiRewardTooltipScreen: { + attempts: 60, + window: "1 m", + keyPrefix: "rl:ai:reward:tooltip-screen", + }, + // Keyed on workspace + user so one actor cannot exhaust the workspace quota forwardDnsInstructions: { attempts: 10, diff --git a/apps/web/tests/rewards/validate-tooltip-suggestion.test.ts b/apps/web/tests/rewards/validate-tooltip-suggestion.test.ts new file mode 100644 index 00000000000..19bef35255b --- /dev/null +++ b/apps/web/tests/rewards/validate-tooltip-suggestion.test.ts @@ -0,0 +1,283 @@ +import type { + ReviewRewardTooltipModifier, + TooltipSuggestion, +} from "@/lib/ai/review-reward-tooltip-schema"; +import { + applyTooltipSuggestion, + filterValidatedTooltipSuggestions, + getTooltipSuggestionPages, + isRewardConditionComplete, + suggestionTouchesField, +} from "@/lib/rewards/validate-tooltip-suggestion"; +import { describe, expect, test } from "vitest"; + +const saleAmountModifiers: ReviewRewardTooltipModifier[] = [ + { + operator: "AND", + payout: { type: "flat", amount: 10, maxDuration: 0 }, + conditions: [ + { + entity: "sale", + attribute: "amount", + operator: "greater_than", + value: 20, + }, + ], + }, +]; + +const minTwentySuggestion: TooltipSuggestion = { + modifierIndex: 0, + conditionIndex: 0, + confidence: 0.9, + reason: + '"Minimum $20 deposit" implies $20 qualifies, but "is greater than" excludes it.', + suggested: { + operator: "greater_than_or_equal" as const, + }, +}; + +function accepted(suggestion: TooltipSuggestion = minTwentySuggestion) { + return filterValidatedTooltipSuggestions({ + event: "sale", + modifiers: saleAmountModifiers, + suggestions: [suggestion], + }); +} + +describe("isRewardConditionComplete", () => { + test("requires entity, attribute, operator, and value", () => { + expect( + isRewardConditionComplete({ + event: "sale", + condition: { + entity: "sale", + attribute: "amount", + operator: "greater_than", + }, + }), + ).toBe(false); + + expect( + isRewardConditionComplete({ + event: "sale", + condition: { + entity: "sale", + attribute: "amount", + operator: "greater_than", + value: 20, + }, + }), + ).toBe(true); + }); + + test("requires a metadata field name", () => { + expect( + isRewardConditionComplete({ + event: "sale", + condition: { + entity: "sale", + attribute: "metadata", + operator: "equals_to", + value: "enterprise", + }, + }), + ).toBe(false); + + expect( + isRewardConditionComplete({ + event: "sale", + condition: { + entity: "sale", + attribute: "metadata", + operator: "equals_to", + value: "enterprise", + metadataField: "plan", + }, + }), + ).toBe(true); + }); + + test("rejects empty arrays and NaN", () => { + expect( + isRewardConditionComplete({ + event: "sale", + condition: { + entity: "customer", + attribute: "country", + operator: "in", + value: [], + }, + }), + ).toBe(false); + + expect( + isRewardConditionComplete({ + event: "sale", + condition: { + entity: "sale", + attribute: "amount", + operator: "greater_than", + value: Number.NaN, + }, + }), + ).toBe(false); + }); +}); + +describe("filterValidatedTooltipSuggestions", () => { + test("accepts a high-confidence operator fix on an existing condition", () => { + expect(accepted()).toEqual([minTwentySuggestion]); + }); + + test("rejects confidence below the 0.5 floor", () => { + expect(accepted({ ...minTwentySuggestion, confidence: 0.49 })).toEqual([]); + }); + + test("rejects unknown condition indexes", () => { + expect(accepted({ ...minTwentySuggestion, conditionIndex: 1 })).toEqual([]); + }); + + test("rejects operators that are not allowed for the attribute", () => { + expect( + accepted({ + ...minTwentySuggestion, + suggested: { operator: "contains" }, + }), + ).toEqual([]); + }); + + test("rejects a no-op that matches the current condition", () => { + expect( + accepted({ + ...minTwentySuggestion, + suggested: { operator: "greater_than" }, + }), + ).toEqual([]); + }); + + test("rejects a value whose shape does not match the operator", () => { + expect( + accepted({ + ...minTwentySuggestion, + suggested: { operator: "in", value: 20 }, + }), + ).toEqual([]); + }); + + test("rejects customer.source operator changes", () => { + expect( + filterValidatedTooltipSuggestions({ + event: "sale", + modifiers: [ + { + operator: "AND", + payout: { type: "flat", amount: 10, maxDuration: 0 }, + conditions: [ + { + entity: "customer", + attribute: "source", + operator: "equals_to", + value: "tracked", + }, + ], + }, + ], + suggestions: [ + { + ...minTwentySuggestion, + suggested: { operator: "not_equals" }, + }, + ], + }), + ).toEqual([]); + }); + + test("keeps the higher-confidence suggestion per condition", () => { + const result = filterValidatedTooltipSuggestions({ + event: "sale", + modifiers: saleAmountModifiers, + suggestions: [ + { ...minTwentySuggestion, confidence: 0.6 }, + { + ...minTwentySuggestion, + confidence: 0.95, + suggested: { operator: "greater_than_or_equal", value: 20 }, + }, + { ...minTwentySuggestion, confidence: 0.2 }, + ], + }); + + expect(result).toHaveLength(1); + expect(result[0].confidence).toBe(0.95); + expect(result[0].suggested.value).toBe(20); + }); +}); + +describe("applyTooltipSuggestion", () => { + test("writes only the patched fields", () => { + expect( + applyTooltipSuggestion(saleAmountModifiers[0].conditions[0], { + operator: "greater_than_or_equal", + }), + ).toEqual({ + entity: "sale", + attribute: "amount", + operator: "greater_than_or_equal", + value: 20, + }); + }); +}); + +describe("getTooltipSuggestionPages", () => { + test("splits one suggestion that changes operator and value into two pages", () => { + const pages = getTooltipSuggestionPages({ + modifiers: saleAmountModifiers, + suggestions: [ + { + ...minTwentySuggestion, + suggested: { operator: "greater_than_or_equal", value: 25 }, + }, + ], + }); + + expect(pages.map((page) => page.field)).toEqual(["operator", "value"]); + }); + + test("keeps one page when only the operator changes", () => { + expect( + getTooltipSuggestionPages({ + modifiers: saleAmountModifiers, + suggestions: [minTwentySuggestion], + }), + ).toHaveLength(1); + }); +}); + +describe("suggestionTouchesField", () => { + test("detects operator vs value changes", () => { + const current = saleAmountModifiers[0].conditions[0]; + + expect( + suggestionTouchesField({ + field: "operator", + current, + suggested: { operator: "greater_than_or_equal" }, + }), + ).toBe(true); + expect( + suggestionTouchesField({ + field: "value", + current, + suggested: { operator: "greater_than_or_equal" }, + }), + ).toBe(false); + expect( + suggestionTouchesField({ + field: "value", + current, + suggested: { value: 25 }, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx b/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx index 22898682995..a0a298047d4 100644 --- a/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx +++ b/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx @@ -9,6 +9,7 @@ import { handleMoneyInputChange, handleMoneyKeyDown } from "@/lib/form-utils"; import { ReferralRewardConfig } from "@/lib/partner-referrals/types"; import { getPlanCapabilities } from "@/lib/plan-capabilities"; import { PARTNER_LEVEL_REWARDS_PLAN_ERROR } from "@/lib/rewards/constants"; +import { applyTooltipSuggestion } from "@/lib/rewards/validate-tooltip-suggestion"; import useGroup from "@/lib/swr/use-group"; import useProgram from "@/lib/swr/use-program"; import { useRewards } from "@/lib/swr/use-rewards"; @@ -76,6 +77,11 @@ import { PartnerReferralRewardBuilder } from "./partner-referral-reward-builder" import { RewardIconSquare } from "./reward-icon-square"; import { RewardPreviewCard } from "./reward-preview-card"; import { REWARD_TYPES, RewardsLogic } from "./rewards-logic"; +import { ReviewingSuggestedFixBadge } from "./suggested-fix-popover"; +import { + RewardTooltipConsistencyContext, + useRewardTooltipConsistency, +} from "./use-reward-tooltip-consistency"; interface RewardSheetProps { setIsOpen: Dispatch>; @@ -378,6 +384,79 @@ function RewardSheetContent({ const { canCreateReferralReward, canUseAdvancedRewardLogic } = getPlanCapabilities(plan); + const consistency = useRewardTooltipConsistency({ + workspaceId, + event: selectedEvent, + tooltipDescription, + description, + baseReward: { + type, + amount, + maxDuration, + }, + modifiers, + onApply: (suggestion) => { + const conditionKey = + `modifiers.${suggestion.modifierIndex}.conditions.${suggestion.conditionIndex}` as const; + const current = getValues(conditionKey); + + if (!current) return; + + setValue( + conditionKey, + applyTooltipSuggestion(current, suggestion.suggested), + { shouldDirty: true }, + ); + }, + onApplyPayout: (fixes) => { + for (const fix of fixes) { + if (fix.scope === "default") { + if (typeof fix.amount === "number") { + setValue( + type === "percentage" ? "amountInPercentage" : "amountInCents", + fix.amount, + { shouldDirty: true }, + ); + } + + if (fix.maxDuration !== undefined) { + setValue( + "maxDuration", + fix.maxDuration === null ? Infinity : fix.maxDuration, + { shouldDirty: true }, + ); + } + + continue; + } + + if (typeof fix.modifierIndex !== "number") continue; + + const modifierKey = `modifiers.${fix.modifierIndex}` as const; + const modifier = getValues(modifierKey); + const payoutType = modifier?.type || type; + + if (typeof fix.amount === "number") { + setValue( + payoutType === "percentage" + ? `${modifierKey}.amountInPercentage` + : `${modifierKey}.amountInCents`, + fix.amount, + { shouldDirty: true }, + ); + } + + if (fix.maxDuration !== undefined) { + setValue( + `${modifierKey}.maxDuration`, + fix.maxDuration === null ? Infinity : fix.maxDuration, + { shouldDirty: true }, + ); + } + } + }, + }); + const isAiRewardEvent = selectedEvent !== "referral" && selectedEvent !== "custom"; const aiEvent = isAiRewardEvent ? selectedEvent : "sale"; @@ -575,289 +654,303 @@ function RewardSheetContent({ )} {isAiRewardEvent ? ( - -
-
- - - Pay{" "} - {selectedEvent === "sale" && ( - <> - a{" "} - - - setValue( - "type", - value as RewardStructure, - { - shouldDirty: true, - }, - ) - } - items={REWARD_TYPES} - /> - {" "} - {type === "percentage" && "of "} - - )} - - - {" "} - per {selectedEvent} - {selectedEvent === "sale" && ( - <> - {" "} - - - setValue("maxDuration", value, { - shouldDirty: true, - }) - } - presetDurations={RECURRING_MAX_DURATIONS.filter( - (v) => v !== 0 && v !== 1, - )} - /> - - - )} - {modifiers?.length ? ( - <> for all other {selectedEvent}s - ) : null} - {flags?.rewardSpendLimit ? ( - <> - {", "}with{" "} - - { - if (value === "none") { - setValue("spendLimitAmount", null, { - shouldDirty: true, - }); - setValue("spendLimitInterval", null, { - shouldDirty: true, - }); - } else { + + +
+
+ + + Pay{" "} + {selectedEvent === "sale" && ( + <> + a{" "} + + setValue( - "spendLimitInterval", - spendLimitInterval ?? "allTime", + "type", + value as RewardStructure, { shouldDirty: true, }, - ); + ) } - }} - items={[ - { - text: "no spend limit", - value: "none", - }, - { - text: "a spend limit of", - value: "limit", - }, - ]} - /> - {" "} - {spendLimitEnabled ? ( - <> - + {" "} + {type === "percentage" && "of "} + + )} + + + {" "} + per {selectedEvent} + {selectedEvent === "sale" && ( + <> + {" "} + + + setValue("maxDuration", value, { + shouldDirty: true, + }) } - > - - {" "} - v !== 0 && v !== 1, + )} + /> + + + )} + {modifiers?.length ? ( + <> for all other {selectedEvent}s + ) : null} + {flags?.rewardSpendLimit ? ( + <> + {", "}with{" "} + + - + onSelect={(value) => { + if (value === "none") { + setValue("spendLimitAmount", null, { + shouldDirty: true, + }); + setValue("spendLimitInterval", null, { + shouldDirty: true, + }); + } else { setValue( "spendLimitInterval", - value as any, + spendLimitInterval ?? "allTime", { shouldDirty: true, }, - ) + ); } - items={[ - { - text: "all-time", - value: "allTime", - }, - { text: "per day", value: "day" }, - { text: "per week", value: "week" }, - { - text: "per month", - value: "month", - }, - ]} - /> - - - ) : null} - - ) : null} - + }} + items={[ + { + text: "no spend limit", + value: "none", + }, + { + text: "a spend limit of", + value: "limit", + }, + ]} + /> + {" "} + {spendLimitEnabled ? ( + <> + + + {" "} + + + setValue( + "spendLimitInterval", + value as any, + { + shouldDirty: true, + }, + ) + } + items={[ + { + text: "all-time", + value: "allTime", + }, + { text: "per day", value: "day" }, + { text: "per week", value: "week" }, + { + text: "per month", + value: "month", + }, + ]} + /> + + + ) : null} + + ) : null} + +
+ +
+
+
- -
-
- +
- -
-
- - - Shown as{" "} - - - setValue( - "description", - (e.target as HTMLInputElement).value, - { - shouldDirty: true, - }, - ) - } - className="sm:w-80" - maxLength={REWARD_DESCRIPTION_MAX_LENGTH} - /> - {" "} - with the tooltip{" "} - - - setValue("tooltipDescription", value, { - shouldDirty: true, - }) - } - className="sm:w-80" - maxLength={ - REWARD_TOOLTIP_DESCRIPTION_MAX_LENGTH - } - /> - - -
-
-
- - } - content={} - /> + } + content={} + /> +
) : ( - {modifierFields.map((field, index) => ( - removeModifier(index)} - /> - ))} - + ); +} + +export function ReviewingSuggestedFixBadge({ text }: { text: string }) { + const consistency = useRewardTooltipConsistencyContext(); + const { isMobile } = useMediaQuery(); + const closeTimerRef = useRef(undefined); + const [open, setOpen] = useState(false); + + const reviewing = consistency?.status === "reviewing"; + const payoutFixes = reviewing ? [] : consistency?.payoutFixes ?? []; + const note = reviewing || payoutFixes.length ? null : consistency?.note; + + if (!consistency || (!reviewing && !note && !payoutFixes.length)) return null; + + const cancelHide = () => { + if (closeTimerRef.current) { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = undefined; + } + }; + + const scheduleHide = () => { + cancelHide(); + closeTimerRef.current = window.setTimeout(() => { + setOpen(false); + }, 300); + }; + + return ( + event.preventDefault()} + popoverContentClassName={POPOVER_CONTENT_CLASS_NAME} + content={ +
+
+ + Suggested fix +
+ {reviewing ? ( +
+
+
+
+
+ ) : ( + <> +
+ {payoutFixes.length ? ( + + ) : ( +

+ {note} +

+ )} +
+
+
+ + )} +
+ } + > + + + ); +} + +function PayoutFixPreview({ fixes }: { fixes: PayoutFix[] }) { + const { control } = useAddEditRewardForm(); + const [ + event, + type, + amountInCents, + amountInPercentage, + maxDuration, + modifiers, + ] = useWatch({ + control, + name: [ + "event", + "type", + "amountInCents", + "amountInPercentage", + "maxDuration", + "modifiers", + ], + }); + const reason = fixes[0]?.reason; + + return ( + <> +
+ {fixes.map((fix) => { + const group = + fix.scope === "group" && typeof fix.modifierIndex === "number"; + const modifier = group ? modifiers?.[fix.modifierIndex!] : null; + const payoutType = + (group ? modifier?.type || type : type) === "percentage" + ? "percentage" + : "flat"; + const currentAmount = + payoutType === "percentage" + ? group + ? modifier?.amountInPercentage + : amountInPercentage + : group + ? modifier?.amountInCents + : amountInCents; + const currentDuration = group + ? modifier?.maxDuration === undefined + ? maxDuration + : modifier?.maxDuration + : maxDuration; + const nextAmount = + typeof fix.amount === "number" ? fix.amount : currentAmount; + const nextDuration = + fix.maxDuration === undefined ? currentDuration : fix.maxDuration; + const amountLabel = + nextAmount == null || Number.isNaN(nextAmount) + ? "amount" + : payoutType === "percentage" + ? `${nextAmount}%` + : `$${nextAmount}`; + const durationLabel = + nextDuration == null || !Number.isFinite(nextDuration) + ? "for the customer's lifetime" + : nextDuration === 0 + ? "one time" + : `for ${nextDuration} month${nextDuration === 1 ? "" : "s"}`; + + return ( +

+ {group ? "Then pay a" : "Pay a"}{" "} + + {amountLabel} + {" "} + per {event}{" "} + + {durationLabel} + +

+ ); + })} +
+ {reason && } + + ); +} + +function SuggestedFixContent({ + suggestion, + field, + index, + total, + onNext, + onDiscard, + onAccept, +}: { + suggestion: TooltipSuggestion; + field: TooltipSuggestionField; + index: number; + total: number; + onNext: () => void; + onDiscard: () => void; + onAccept: () => void; +}) { + const { control } = useAddEditRewardForm(); + const [event, modifiers] = useWatch({ + control, + name: ["event", "modifiers"], + }); + + const current = + modifiers?.[suggestion.modifierIndex]?.conditions?.[ + suggestion.conditionIndex + ]; + + if (!current?.entity || !current.attribute || !current.operator) { + return null; + } + + const patched = applyTooltipSuggestion( + current, + field === "operator" + ? { operator: suggestion.suggested.operator } + : { value: suggestion.suggested.value }, + ); + const entity = patched.entity ?? current.entity; + const attribute = patched.attribute ?? current.attribute; + const operator = patched.operator ?? current.operator; + const attributeType = getRewardConditionAttribute({ + event, + entity, + attribute, + })?.type; + + const { entityLabel, attributeLabel, operatorLabel, valueLabel } = + formatRewardConditionParts({ + event, + condition: { + entity, + attribute, + operator, + value: + attributeType === "currency" && typeof patched.value === "number" + ? patched.value * 100 + : patched.value, + label: patched.label, + metadataField: patched.metadataField, + } as RewardCondition, + }); + + const isMultiple = total > 1; + + return ( +
+
+
+ + Suggested fix +
+ {isMultiple && ( + + {index + 1} of {total} + + )} +
+ +
+
+
+ +
+

+ If {entityLabel}{" "} + {attributeLabel}{" "} + + {operatorLabel} + {" "} + {valueLabel} +

+
+ + +
+ +
+ {isMultiple && ( + + )} +
+
+
+
+ ); +} + +function SuggestedFixReason({ reason }: { reason: string }) { + const nodes: ReactNode[] = []; + const quoteRe = /"([^"]+)"|“([^”]+)”/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + let key = 0; + + while ((match = quoteRe.exec(reason)) !== null) { + if (match.index > lastIndex) { + nodes.push(reason.slice(lastIndex, match.index)); + } + + nodes.push( + + {match[0]} + , + ); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < reason.length) { + nodes.push(reason.slice(lastIndex)); + } + + return ( +

+ {nodes} +

+ ); +} + +function PreviewChip({ + children, + changed, +}: { + children: ReactNode; + changed?: boolean; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/ui/partners/rewards/use-reward-tooltip-consistency.ts b/apps/web/ui/partners/rewards/use-reward-tooltip-consistency.ts new file mode 100644 index 00000000000..e58cb37a245 --- /dev/null +++ b/apps/web/ui/partners/rewards/use-reward-tooltip-consistency.ts @@ -0,0 +1,710 @@ +"use client"; + +import { + AI_REWARD_EVENTS, + type AIRewardEvent, +} from "@/lib/ai/ai-reward-schema"; +import { + reviewRewardTooltipConsistency, + screenRewardTooltipContradiction, +} from "@/lib/ai/review-reward-tooltip"; +import type { + PayoutFix, + ReviewRewardTooltipModifier, + RewardPayout, + TooltipSuggestion, +} from "@/lib/ai/review-reward-tooltip-schema"; +import { + getRewardConditionAttribute, + getTooltipSuggestionPages, + isRewardConditionComplete, + stripRewardTooltipMarkdown, + type TooltipSuggestionField, + type TooltipSuggestionPage, +} from "@/lib/rewards/validate-tooltip-suggestion"; +import { EventType } from "@prisma/client"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type MutableRefObject, +} from "react"; + +const DEBOUNCE_MS = 200; +const HIDE_MS = 300; +const REVIEW_CACHE_VERSION = 2; + +type TooltipReviewStatus = "idle" | "reviewing"; + +type ReviewCacheEntry = { + v: typeof REVIEW_CACHE_VERSION; + flagged: boolean; + suggestions: TooltipSuggestion[]; + payoutFixes: PayoutFix[]; + note: string | null; +}; + +const reviewCache = new Map(); + +export function tooltipSuggestionPageKey({ + modifierIndex, + conditionIndex, + field, +}: { + modifierIndex: number; + conditionIndex: number; + field: TooltipSuggestionField; +}) { + return `${modifierIndex}:${conditionIndex}:${field}`; +} + +type RewardTooltipConsistencyValue = { + status: TooltipReviewStatus; + suggestions: TooltipSuggestion[]; + pages: TooltipSuggestionPage[]; + activeIndex: number; + open: boolean; + activeAnchorRef: MutableRefObject; + registerBadge: (key: string, element: HTMLElement | null) => void; + showPage: (index: number) => void; + scheduleHide: () => void; + cancelHide: () => void; + hide: () => void; + getSuggestion: ( + modifierIndex: number, + conditionIndex: number, + ) => TooltipSuggestion | undefined; + getPageIndex: ( + modifierIndex: number, + conditionIndex: number, + field: TooltipSuggestionField, + ) => number; + accept: (suggestion: TooltipSuggestion) => void; + acceptAll: () => void; + dismiss: (suggestion: TooltipSuggestion) => void; + dismissAll: () => void; + note: string | null; + payoutFixes: PayoutFix[]; + acceptPayouts: () => void; + dismissNote: () => void; +}; + +export const RewardTooltipConsistencyContext = + createContext(null); + +export function useRewardTooltipConsistencyContext() { + return useContext(RewardTooltipConsistencyContext); +} + +export function useRewardTooltipConsistency({ + workspaceId, + event, + tooltipDescription, + description, + baseReward, + modifiers, + onApply, + onApplyPayout, +}: { + workspaceId?: string; + event: EventType; + tooltipDescription?: string | null; + description?: string | null; + baseReward?: { + type?: string | null; + amount?: number | null; + maxDuration?: number | null; + } | null; + modifiers?: Array<{ + operator?: "AND" | "OR"; + type?: string | null; + amountInCents?: number | null; + amountInPercentage?: number | null; + maxDuration?: number | null; + conditions?: Array<{ + entity?: string; + attribute?: string; + operator?: string; + value?: unknown; + label?: string | null; + metadataField?: string; + } | null>; + } | null> | null; + onApply?: (suggestion: TooltipSuggestion) => void; + onApplyPayout?: (fixes: PayoutFix[]) => void; +}): RewardTooltipConsistencyValue { + const [suggestions, setSuggestions] = useState([]); + const [payoutFixes, setPayoutFixes] = useState([]); + const [note, setNote] = useState(null); + const [status, setStatus] = useState("idle"); + const [activeIndex, setActiveIndex] = useState(0); + const [open, setOpen] = useState(false); + const [dismissedKeys, setDismissedKeys] = useState([]); + const requestIdRef = useRef(0); + const closeTimerRef = useRef(undefined); + const badgeRefs = useRef(new Map()); + const activeAnchorRef = useRef(null); + + const tooltip = stripRewardTooltipMarkdown(tooltipDescription ?? ""); + const serializedReward = useMemo( + () => + serializeRewardForReview({ + event, + description, + baseReward, + modifiers, + }), + [baseReward, description, event, modifiers], + ); + const serializedModifiers = serializedReward?.modifiers ?? null; + + const cacheKey = useMemo(() => { + if ( + !workspaceId || + !isAiRewardEvent(event) || + !tooltip || + !serializedReward + ) { + return null; + } + + return JSON.stringify({ + event, + tooltip, + reward: serializedReward, + }); + }, [event, serializedReward, tooltip, workspaceId]); + + const serializedRewardRef = useRef(serializedReward); + serializedRewardRef.current = serializedReward; + + useEffect(() => { + const requestId = ++requestIdRef.current; + + const reward = serializedRewardRef.current; + + if (!cacheKey || !workspaceId || !isAiRewardEvent(event) || !reward) { + setSuggestions([]); + setPayoutFixes([]); + setNote(null); + setStatus("idle"); + setActiveIndex(0); + return; + } + + const cached = reviewCache.get(cacheKey); + if (cached?.v === REVIEW_CACHE_VERSION) { + const cachedNote = noteForFlaggedReward({ + flagged: cached.flagged, + suggestions: cached.suggestions, + payoutFixes: cached.payoutFixes, + note: cached.note, + }); + setSuggestions(cached.suggestions); + setPayoutFixes(cached.payoutFixes); + setNote(cachedNote); + setStatus("idle"); + setActiveIndex(0); + return; + } + + setSuggestions([]); + setPayoutFixes([]); + setNote(null); + setStatus("idle"); + setActiveIndex(0); + const timeout = window.setTimeout(async () => { + let flagged: boolean | null = null; + + try { + const screen = await screenRewardTooltipContradiction({ + workspaceId, + event, + tooltip, + description: reward.description, + basePayout: reward.basePayout, + modifiers: reward.modifiers, + }); + + if (requestId !== requestIdRef.current) { + return; + } + + flagged = screen.flagged; + } catch { + if (requestId !== requestIdRef.current) { + return; + } + + flagged = null; + } + + if (flagged === false) { + reviewCache.set(cacheKey, { + v: REVIEW_CACHE_VERSION, + flagged: false, + suggestions: [], + payoutFixes: [], + note: null, + }); + setSuggestions([]); + setPayoutFixes([]); + setNote(null); + setStatus("idle"); + setActiveIndex(0); + return; + } + + if (flagged === true) { + setStatus("reviewing"); + } + + try { + const result = await reviewRewardTooltipConsistency({ + workspaceId, + event, + tooltip, + description: reward.description, + basePayout: reward.basePayout, + modifiers: reward.modifiers, + }); + + if (requestId !== requestIdRef.current) { + return; + } + + const next = result.suggestions ?? []; + const nextPayoutFixes = result.payoutFixes ?? []; + const nextNote = noteForFlaggedReward({ + flagged, + suggestions: next, + payoutFixes: nextPayoutFixes, + note: result.note, + }); + reviewCache.set(cacheKey, { + v: REVIEW_CACHE_VERSION, + flagged: true, + suggestions: next, + payoutFixes: nextPayoutFixes, + note: nextNote, + }); + setSuggestions(next); + setPayoutFixes(nextPayoutFixes); + setNote(nextNote); + setStatus("idle"); + setActiveIndex(0); + } catch { + if (requestId !== requestIdRef.current) { + return; + } + + const nextNote = noteForFlaggedReward({ + flagged, + suggestions: [], + payoutFixes: [], + note: null, + }); + if (nextNote) { + reviewCache.set(cacheKey, { + v: REVIEW_CACHE_VERSION, + flagged: true, + suggestions: [], + payoutFixes: [], + note: nextNote, + }); + } + setSuggestions([]); + setPayoutFixes([]); + setNote(nextNote); + setStatus("idle"); + setActiveIndex(0); + } + }, DEBOUNCE_MS); + + return () => { + window.clearTimeout(timeout); + }; + }, [cacheKey, event, tooltip, workspaceId]); + + const visibleSuggestions = useMemo(() => { + if (!serializedModifiers?.length) return []; + + return suggestions.filter((suggestion) => { + const current = + serializedModifiers[suggestion.modifierIndex]?.conditions[ + suggestion.conditionIndex + ]; + + if (!current) return false; + + return !dismissedKeys.includes(dismissalKey(suggestion, current)); + }); + }, [dismissedKeys, serializedModifiers, suggestions]); + + const pages = useMemo( + () => + serializedModifiers + ? getTooltipSuggestionPages({ + suggestions: visibleSuggestions, + modifiers: serializedModifiers, + }) + : [], + [serializedModifiers, visibleSuggestions], + ); + useEffect(() => { + if (activeIndex >= pages.length) { + setActiveIndex(0); + } + + if (!pages.length && open) { + setOpen(false); + } + }, [activeIndex, open, pages.length]); + + const cancelHide = useCallback(() => { + if (closeTimerRef.current) { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = undefined; + } + }, []); + + const registerBadge = useCallback( + (key: string, element: HTMLElement | null) => { + if (element) { + badgeRefs.current.set(key, element); + } else { + badgeRefs.current.delete(key); + } + }, + [], + ); + + const showPage = useCallback( + (index: number) => { + cancelHide(); + + const page = pages[index]; + if (page) { + const element = badgeRefs.current.get( + tooltipSuggestionPageKey({ + modifierIndex: page.suggestion.modifierIndex, + conditionIndex: page.suggestion.conditionIndex, + field: page.field, + }), + ); + + if (element) { + activeAnchorRef.current = element; + } + } + + setActiveIndex(index); + setOpen(true); + }, + [cancelHide, pages], + ); + + const scheduleHide = useCallback(() => { + cancelHide(); + closeTimerRef.current = window.setTimeout(() => { + setOpen(false); + }, HIDE_MS); + }, [cancelHide]); + + const hide = useCallback(() => { + cancelHide(); + setOpen(false); + }, [cancelHide]); + + const getSuggestion = useCallback( + (modifierIndex: number, conditionIndex: number) => + visibleSuggestions.find( + (suggestion) => + suggestion.modifierIndex === modifierIndex && + suggestion.conditionIndex === conditionIndex, + ), + [visibleSuggestions], + ); + + const getPageIndex = useCallback( + ( + modifierIndex: number, + conditionIndex: number, + field: TooltipSuggestionField, + ) => + pages.findIndex( + (page) => + page.field === field && + page.suggestion.modifierIndex === modifierIndex && + page.suggestion.conditionIndex === conditionIndex, + ), + [pages], + ); + + const dismiss = useCallback( + (suggestion: TooltipSuggestion) => { + const current = + serializedModifiers?.[suggestion.modifierIndex]?.conditions[ + suggestion.conditionIndex + ]; + + if (!current) return; + + const key = dismissalKey(suggestion, current); + setDismissedKeys((keys) => (keys.includes(key) ? keys : [...keys, key])); + }, + [serializedModifiers], + ); + + const dismissAll = useCallback(() => { + if (!serializedModifiers) return; + + setDismissedKeys((keys) => { + const next = new Set(keys); + + for (const suggestion of suggestions) { + const current = + serializedModifiers[suggestion.modifierIndex]?.conditions[ + suggestion.conditionIndex + ]; + + if (current) { + next.add(dismissalKey(suggestion, current)); + } + } + + return [...next]; + }); + }, [serializedModifiers, suggestions]); + + const accept = useCallback( + (suggestion: TooltipSuggestion) => { + onApply?.(suggestion); + dismiss(suggestion); + hide(); + }, + [dismiss, hide, onApply], + ); + + const acceptAll = useCallback(() => { + visibleSuggestions.forEach((suggestion) => onApply?.(suggestion)); + dismissAll(); + hide(); + }, [dismissAll, hide, onApply, visibleSuggestions]); + + const dismissAndHide = useCallback( + (suggestion: TooltipSuggestion) => { + dismiss(suggestion); + hide(); + }, + [dismiss, hide], + ); + + const dismissAllAndHide = useCallback(() => { + dismissAll(); + hide(); + }, [dismissAll, hide]); + + const dismissNote = useCallback(() => { + setNote(null); + setPayoutFixes([]); + + if (!cacheKey) return; + + const cached = reviewCache.get(cacheKey); + if (cached) { + reviewCache.set(cacheKey, { + ...cached, + note: null, + payoutFixes: [], + }); + } + }, [cacheKey]); + + const acceptPayouts = useCallback(() => { + onApplyPayout?.(payoutFixes); + dismissNote(); + }, [dismissNote, onApplyPayout, payoutFixes]); + + return { + status, + suggestions: visibleSuggestions, + pages, + activeIndex, + open, + activeAnchorRef, + registerBadge, + showPage, + scheduleHide, + cancelHide, + hide, + getSuggestion, + getPageIndex, + accept, + acceptAll, + dismiss: dismissAndHide, + dismissAll: dismissAllAndHide, + note, + payoutFixes, + acceptPayouts, + dismissNote, + }; +} + +function isAiRewardEvent(event: EventType): event is AIRewardEvent { + return (AI_REWARD_EVENTS as readonly string[]).includes(event); +} + +function noteForFlaggedReward({ + flagged, + suggestions, + payoutFixes, + note, +}: { + flagged: boolean | null; + suggestions: TooltipSuggestion[]; + payoutFixes: PayoutFix[]; + note?: string | null; +}) { + if (suggestions.length || payoutFixes.length) return null; + + const trimmed = note?.trim(); + if (trimmed) return trimmed; + if (flagged === true) return "This copy doesn't match the reward payout."; + + return null; +} + +function dismissalKey( + suggestion: TooltipSuggestion, + current: { operator: string; value: unknown }, +) { + return [ + suggestion.modifierIndex, + suggestion.conditionIndex, + current.operator, + JSON.stringify(current.value), + suggestion.suggested.operator ?? "", + JSON.stringify(suggestion.suggested.value ?? null), + ].join(":"); +} + +function serializeRewardForReview({ + event, + description, + baseReward, + modifiers, +}: { + event: EventType; + description?: string | null; + baseReward?: { + type?: string | null; + amount?: number | null; + maxDuration?: number | null; + } | null; + modifiers?: Array<{ + operator?: "AND" | "OR"; + type?: string | null; + amountInCents?: number | null; + amountInPercentage?: number | null; + maxDuration?: number | null; + conditions?: Array<{ + entity?: string; + attribute?: string; + operator?: string; + value?: unknown; + label?: string | null; + metadataField?: string; + } | null>; + } | null> | null; +}): { + description: string | null; + basePayout: RewardPayout; + modifiers: ReviewRewardTooltipModifier[]; +} | null { + if (baseReward?.type !== "flat" && baseReward?.type !== "percentage") { + return null; + } + if (!isAiRewardEvent(event) || !modifiers?.length) return null; + if ( + !modifiers.every( + (modifier) => + !!modifier?.conditions?.length && + modifier.conditions.every((condition) => + isRewardConditionComplete({ event, condition }), + ), + ) + ) { + return null; + } + + const basePayout: RewardPayout = { + type: baseReward.type, + amount: baseReward.amount ?? null, + maxDuration: normalizeDuration(baseReward.maxDuration), + }; + const shownAs = stripRewardTooltipMarkdown(description ?? ""); + + return { + description: shownAs || null, + basePayout, + modifiers: modifiers.map((modifier) => { + const type = + modifier?.type === "flat" || modifier?.type === "percentage" + ? modifier.type + : basePayout.type; + const amount = + type === "percentage" + ? modifier?.amountInPercentage ?? basePayout.amount + : modifier?.amountInCents ?? basePayout.amount; + + return { + operator: modifier?.operator ?? "AND", + payout: { + type, + amount: amount ?? null, + maxDuration: + modifier?.maxDuration === undefined + ? basePayout.maxDuration + : normalizeDuration(modifier.maxDuration), + }, + conditions: (modifier?.conditions ?? []).map((condition) => { + const attribute = getRewardConditionAttribute({ + event, + entity: condition?.entity, + attribute: condition?.attribute, + }); + + let value = condition?.value; + if ( + attribute && + ["number", "currency", "date"].includes(attribute.type) && + !Array.isArray(value) + ) { + value = Number(value); + } + + return { + entity: condition!.entity!, + attribute: condition!.attribute!, + operator: condition! + .operator as ReviewRewardTooltipModifier["conditions"][number]["operator"], + value: + value as ReviewRewardTooltipModifier["conditions"][number]["value"], + label: condition?.label, + metadataField: condition?.metadataField, + }; + }), + }; + }), + }; +} + +function normalizeDuration(maxDuration: number | null | undefined) { + if (maxDuration == null || !Number.isFinite(maxDuration)) return null; + return maxDuration; +} diff --git a/packages/ui/src/popover.tsx b/packages/ui/src/popover.tsx index 4fb0b7349cc..f3de9f0eb3b 100644 --- a/packages/ui/src/popover.tsx +++ b/packages/ui/src/popover.tsx @@ -2,7 +2,12 @@ import { cn } from "@dub/utils"; import * as PopoverPrimitive from "@radix-ui/react-popover"; -import { PropsWithChildren, ReactNode, WheelEventHandler } from "react"; +import { + PropsWithChildren, + ReactNode, + RefObject, + WheelEventHandler, +} from "react"; import { createPortal } from "react-dom"; import { Drawer } from "vaul"; import { useMediaQuery } from "./hooks"; @@ -25,6 +30,7 @@ export type PopoverProps = PropsWithChildren<{ sideOffset?: number; collisionPadding?: PopoverPrimitive.PopoverContentProps["collisionPadding"]; anchor?: ReactNode; + virtualAnchorRef?: RefObject; }>; export function Popover({ @@ -46,6 +52,7 @@ export function Popover({ sideOffset = 8, collisionPadding, anchor, + virtualAnchorRef, }: PopoverProps) { const { isMobile } = useMediaQuery(); @@ -91,6 +98,15 @@ export function Popover({ {anchor}, document.body, )} + {virtualAnchorRef && ( + + } + /> + )} {children}