diff --git a/apps/web/src/components/coming-soon/coming-soon-page.tsx b/apps/web/src/components/coming-soon/coming-soon-page.tsx deleted file mode 100644 index c8f9ec8405..0000000000 --- a/apps/web/src/components/coming-soon/coming-soon-page.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Plan, before it exists: what it will do, over a - * sketch of it. The sketch is decoration, so it carries no copy of its own - * and is hidden from assistive tech. - */ - -import type { ComponentType } from "react"; -import { CheckDone01, Target04, TrendUp01 } from "@untitledui/icons"; -import { Badge } from "@decocms/ui/components/badge.tsx"; -import { Page } from "@/components/page"; -import { cn } from "@decocms/ui/lib/utils.ts"; -import { useT } from "@/i18n/use-t.ts"; -import type { TranslationKey } from "@/i18n/use-t.ts"; - -type Kind = "plan"; -type Icon = ComponentType<{ size?: number; className?: string }>; - -const COPY: Record< - Kind, - { - heading: TranslationKey; - description: TranslationKey; - points: { Icon: Icon; labelKey: TranslationKey; tone: string }[]; - } -> = { - plan: { - heading: "home.comingSoon.planHeading", - description: "home.comingSoon.planDescription", - points: [ - { - Icon: Target04, - labelKey: "home.comingSoon.planGoals", - tone: "bg-chart-2/15 text-chart-2", - }, - { - Icon: CheckDone01, - labelKey: "home.comingSoon.planTasks", - tone: "bg-chart-1/15 text-chart-1", - }, - { - Icon: TrendUp01, - labelKey: "home.comingSoon.planProgress", - tone: "bg-chart-4/15 text-chart-4", - }, - ], - }, -}; - -/** A line of text, sketched. */ -function Line({ className }: { className: string }) { - return ; -} - -const GOALS = [ - { title: "w-32", fill: "w-[72%]", tone: "bg-chart-2", value: "72%" }, - { title: "w-24", fill: "w-[45%]", tone: "bg-chart-1", value: "45%" }, - { title: "w-28", fill: "w-[88%]", tone: "bg-chart-4", value: "88%" }, -]; - -function PlanSketch() { - return ( -
- {GOALS.map((goal) => ( -
-
- - - - - - {goal.value} - -
-
-
-
-
- ))} -
- ); -} - -export function ComingSoonPage({ kind }: { kind: Kind }) { - const t = useT(); - const copy = COPY[kind]; - return ( - - -
-
-
-
- -
-
-
- {t("home.comingSoon.badge")} -

- {t(copy.heading)} -

-

- {t(copy.description)} -

-
-
    - {copy.points.map(({ Icon, labelKey, tone }) => ( -
  • - - - - {t(labelKey)} -
  • - ))} -
-
- - - ); -} diff --git a/apps/web/src/components/plan/funnel-view.tsx b/apps/web/src/components/plan/funnel-view.tsx new file mode 100644 index 0000000000..e17a53a029 --- /dev/null +++ b/apps/web/src/components/plan/funnel-view.tsx @@ -0,0 +1,192 @@ +/** + * CRO as the store's funnel: how many sessions reach each step, where the + * biggest share is lost, and which page to open to see why. + */ + +import { AlertTriangle, ArrowDown, ChevronRight } from "@untitledui/icons"; +import { Badge } from "@decocms/ui/components/badge.tsx"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import { usePreferences } from "@/hooks/use-preferences.ts"; +import { useT } from "@/i18n/use-t.ts"; +import type { MapNode } from "./site-map-mock.ts"; + +function percent(rate: number): string { + return `${Math.round(rate * 100)}%`; +} + +/** Stages grouped by step, in funnel order. */ +function stepsOf(stages: MapNode[]): MapNode[][] { + const steps = new Map(); + for (const stage of stages) { + const step = stage.step ?? steps.size + 1; + steps.set(step, [...(steps.get(step) ?? []), stage]); + } + return [...steps.entries()] + .sort(([a], [b]) => a - b) + .map(([, group]) => group); +} + +function StageCell({ + stage, + top, + selected, + onSelect, +}: { + stage: MapNode; + top: number; + selected: boolean; + onSelect: (id: string) => void; +}) { + const t = useT(); + const [{ language }] = usePreferences(); + const value = stage.sessions ?? 0; + const share = top ? value / top : 0; + return ( + + ); +} + +export function FunnelView({ + funnel, + selectedId, + compact, + onSelect, +}: { + funnel: MapNode; + selectedId: string; + /** Narrow, beside an open stage. */ + compact: boolean; + onSelect: (id: string) => void; +}) { + const t = useT(); + const [{ language }] = usePreferences(); + const count = new Intl.NumberFormat(language); + const steps = stepsOf(funnel.children ?? []); + const totals = steps.map((group) => + group.reduce((sum, stage) => sum + (stage.sessions ?? 0), 0), + ); + const top = totals[0] ?? 0; + const last = totals[totals.length - 1] ?? 0; + const orders = funnel.orders ?? 0; + const rates = totals.map((value, index) => + index === 0 ? 1 : value / (totals[index - 1] || 1), + ); + const worst = rates.reduce( + (worstIndex, rate, index) => + index > 0 && rate < (rates[worstIndex] ?? 1) ? index : worstIndex, + 1, + ); + + const transition = (rate: number, isWorst: boolean) => ( +
+ + {isWorst ? ( + + + {t("plan.funnel.biggestLoss", { rate: percent(1 - rate) })} + + ) : ( + t("plan.funnel.advance", { rate: percent(rate) }) + )} +
+ ); + + return ( +
+
+
+ + {t("plan.funnel.conversion")} + + + {new Intl.NumberFormat(language, { + maximumFractionDigits: 1, + }).format((top ? orders / top : 0) * 100)} + % + + + {t("plan.funnel.summary", { + orders: count.format(orders), + sessions: count.format(top), + })} + +
+
    + {steps.map((group, index) => ( +
  1. stage.id).join("+")}> + {index > 0 && transition(rates[index] ?? 1, index === worst)} +
    1 && "flex-col", + )} + > + {group.map((stage) => ( + + ))} +
    +
  2. + ))} +
  3. + {transition(last ? orders / last : 0, false)} +
    + + {t("plan.funnel.orders")} + + + {t("plan.funnel.ordersPeriod", { + count: count.format(orders), + })} + +
    +
  4. +
+
+
+ ); +} diff --git a/apps/web/src/components/plan/goals-view.tsx b/apps/web/src/components/plan/goals-view.tsx new file mode 100644 index 0000000000..2b8c86126e --- /dev/null +++ b/apps/web/src/components/plan/goals-view.tsx @@ -0,0 +1,1545 @@ +/** + * Goals: the store's web revenue by year against its targets, and a timeline + * from today to the end of next year. Each milestone projects the revenue + * from three levers (sessions, conversion, average ticket) the user can move + * from today's values to see what reaches the target. + */ + +import { useState, type FormEvent } from "react"; +import { Plus, RefreshCw01, Trash01 } from "@untitledui/icons"; +import { Badge } from "@decocms/ui/components/badge.tsx"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { Input } from "@decocms/ui/components/input.tsx"; +import { Slider } from "@decocms/ui/components/slider.tsx"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import { usePreferences } from "@/hooks/use-preferences.ts"; +import { useT } from "@/i18n/use-t.ts"; +import type { MapStore } from "./site-map.tsx"; + +/** Web revenue per year, in millions of reais. */ +export interface YearRevenue { + year: number; + actual?: number; + target?: number; +} + +/** Levers today, until the analysis measures them. */ +const CONVERSION = 0.014; +const AVERAGE_TICKET = 450; +const EXAMPLE = [42, 56, 58, 72, 96]; +const MONTH_WIDTH = 96; +const TIMELINE_START = 200; + +/** Past years of actual revenue the form starts with, and how far back it goes. */ +const DEFAULT_HISTORY = 2; +const MAX_HISTORY = 10; + +function useFormats() { + const [{ language }] = usePreferences(); + const money = new Intl.NumberFormat(language, { + style: "currency", + currency: "BRL", + notation: "compact", + maximumFractionDigits: 1, + }); + return { + millions: (value: number) => money.format(value * 1_000_000), + count: new Intl.NumberFormat(language, { + notation: "compact", + maximumFractionDigits: 1, + }), + percent: new Intl.NumberFormat(language, { + style: "percent", + maximumFractionDigits: 1, + }), + ticket: new Intl.NumberFormat(language, { + style: "currency", + currency: "BRL", + maximumFractionDigits: 0, + }), + month: new Intl.DateTimeFormat(language, { month: "short" }), + language, + }; +} + +function parseMillions(value: string): number | undefined { + const number = Number(value.replace(",", ".")); + return value.trim() && Number.isFinite(number) && number >= 0 + ? number + : undefined; +} + +function GoalForm({ + current, + initial, + onSave, +}: { + /** This year: actual so far plus a goal; next year takes only a goal. */ + current: number; + initial: YearRevenue[] | null; + onSave: (goals: YearRevenue[]) => void; +}) { + const t = useT(); + const next = current + 1; + const [history, setHistory] = useState(() => + initial + ? initial.filter((row) => row.year < current).length + : DEFAULT_HISTORY, + ); + const oldest = current - history; + const years = [ + ...Array.from({ length: history }, (_, index) => oldest + index), + current, + next, + ]; + const [values, setValues] = useState>(() => + Object.fromEntries( + (initial ?? []).flatMap((row) => [ + [`${row.year}-actual`, row.actual?.toString() ?? ""], + [`${row.year}-target`, row.target?.toString() ?? ""], + ]), + ), + ); + const read = (year: number, kind: "actual" | "target") => + parseMillions(values[`${year}-${kind}`] ?? ""); + const ready = + read(current, "actual") !== undefined && + read(current, "target") !== undefined && + read(next, "target") !== undefined; + + const fields: { year: number; actual: boolean; target: boolean }[] = + years.map((year) => ({ + year, + actual: year <= current, + target: year >= current, + })); + + const fillExample = () => { + const [a, b, c, d, e] = EXAMPLE; + setHistory(DEFAULT_HISTORY); + setValues({ + [`${current - 2}-actual`]: String(a), + [`${current - 1}-actual`]: String(b), + [`${current}-actual`]: String(c), + [`${current}-target`]: String(d), + [`${next}-target`]: String(e), + }); + }; + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!ready) return; + onSave( + fields.map(({ year, actual, target }) => ({ + year, + actual: actual ? read(year, "actual") : undefined, + target: target ? read(year, "target") : undefined, + })), + ); + }; + + return ( +
+
+

+ {t("plan.goals.formTitle")} +

+

+ {t("plan.goals.formDescription")} +

+
+
+ + + {t("plan.goals.actual")} + + + {t("plan.goals.target")} + + + {history < MAX_HISTORY && ( + + )} + {fields.map(({ year, actual, target }) => ( +
+ + {year} + + {(["actual", "target"] as const).map((kind) => + (kind === "actual" ? actual : target) ? ( + + setValues((prev) => ({ + ...prev, + [`${year}-${kind}`]: event.target.value, + })) + } + /> + ) : ( + + — + + ), + )} + {year === oldest && history > 0 ? ( + + ) : ( + + )} +
+ ))} +
+

+ {t("plan.goals.formHint", { year: current })} +

+
+ + +
+
+ ); +} + +function growthOf(goals: YearRevenue[], index: number) { + const row = goals[index]; + const previous = goals[index - 1]; + if (!row || !previous) return null; + const base = previous.actual ?? previous.target; + if (!base) return null; + // A closed year grew by what it did; an open one is read by its target. + if (row.target === undefined && row.actual !== undefined) { + return { rate: row.actual / base - 1, planned: false }; + } + if (row.target !== undefined) { + return { rate: row.target / base - 1, planned: true }; + } + return null; +} + +function RevenueChart({ goals }: { goals: YearRevenue[] }) { + const t = useT(); + const format = useFormats(); + const max = Math.max( + 1, + ...goals.flatMap((row) => [row.actual ?? 0, row.target ?? 0]), + ); + const height = (value: number | undefined) => + `${((value ?? 0) / max) * 100}%`; + const signed = (rate: number) => + `${rate >= 0 ? "+" : ""}${format.percent.format(rate)}`; + + return ( +
+
+

+ {t("plan.goals.chartTitle")} +

+
+ + + {t("plan.goals.actual")} + + + + {t("plan.goals.target")} + +
+
+
+ + {goals.map((row) => ( +
+
+ {row.target !== undefined && ( +
+ )} + {row.actual !== undefined && ( +
+ )} +
+ + {[ + row.actual !== undefined && + t("plan.goals.tooltipActual", { + value: format.millions(row.actual), + }), + row.target !== undefined && + t("plan.goals.tooltipTarget", { + value: format.millions(row.target), + }), + ] + .filter(Boolean) + .join(" · ")} + +
+ ))} + + {goals.map((row) => ( + + {row.year} + + ))} + {( + [ + ["plan.goals.actual", "actual"], + ["plan.goals.target", "target"], + ] as const + ).map(([label, kind]) => ( +
+ + {t(label)} + + {goals.map((row) => ( + + {row[kind] !== undefined ? format.millions(row[kind]) : "—"} + + ))} +
+ ))} + + {t("plan.goals.growth")} + + {goals.map((row, index) => { + const growth = growthOf(goals, index); + return ( + + {growth + ? growth.planned + ? t("plan.goals.plannedGrowth", { + rate: signed(growth.rate), + }) + : signed(growth.rate) + : "—"} + + ); + })} +
+
+ ); +} + +function StoreBadge({ + store, + className, +}: { + store: MapStore; + className?: string; +}) { + const [failed, setFailed] = useState(false); + return ( + + {store.logo && !failed ? ( + {store.name} setFailed(true)} + className="max-h-full max-w-full object-contain" + /> + ) : ( + store.name + )} + + ); +} + +interface Levers { + sessions: number; + conversion: number; + ticket: number; +} + +type LeverKey = keyof Levers; + +function LeverSlider({ + label, + value, + today, + step, + format, + onChange, +}: { + label: string; + value: number; + today: number; + step: number; + format: (value: number) => string; + onChange: (value: number) => void; +}) { + const min = today * 0.5; + const max = today * 2; + const share = (at: number) => `${((at - min) / (max - min)) * 100}%`; + return ( +
+
+ {label} + + {value === today + ? format(value) + : `${format(today)} → ${format(value)}`} + +
+
+ + { + if (next !== undefined) onChange(next); + }} + /> +
+
+ ); +} + +function MilestoneCard({ + store, + title, + monthName, + target, + yearBanked, + yearTarget, + today, + saved, + onSave, +}: { + store: MapStore; + title: string; + monthName: string; + /** This month's revenue goal, in millions. */ + target: number; + /** The year's revenue before this month: made, or projected from earlier months. */ + yearBanked: number; + /** The year's goal through this month. */ + yearTarget: number; + today: Levers; + /** The goals set for this month, if any. */ + saved: Levers | undefined; + onSave: (levers: Levers) => void; +}) { + const t = useT(); + const format = useFormats(); + const [editing, setEditing] = useState(saved === undefined); + const [levers, setLevers] = useState(saved ?? today); + const shown = editing ? levers : (saved ?? levers); + const projection = (shown.sessions * shown.conversion * shown.ticket) / 1e6; + const changed = (Object.keys(today) as LeverKey[]).some( + (key) => levers[key] !== today[key], + ); + const set = (key: LeverKey) => (value: number) => + setLevers((current) => ({ ...current, [key]: value })); + const gap = target - projection; + + const rows: { + key: LeverKey; + label: string; + step: number; + format: (value: number) => string; + }[] = [ + { + key: "sessions", + label: t("plan.goals.sessions"), + step: 10_000, + format: (value) => format.count.format(value), + }, + { + key: "conversion", + label: t("plan.goals.conversion"), + step: 0.0005, + format: (value) => format.percent.format(value), + }, + { + key: "ticket", + label: t("plan.goals.ticket"), + step: 5, + format: (value) => format.ticket.format(value), + }, + ]; + + return ( +
+
+ + {title} + + + + {t("plan.goals.progress", { + actual: format.millions(projection), + target: format.millions(target), + })} + + + {t("plan.goals.monthGoal", { month: monthName })} + + {gap <= 0 ? ( + {t("plan.goals.onTrack")} + ) : ( + + {t("plan.goals.gap", { value: format.millions(gap) })} + + )} + + {t("plan.goals.yearToDate", { + month: monthName, + actual: format.millions(yearBanked + projection), + target: format.millions(yearTarget), + })} + +
+ {editing ? ( + <> +

+ {t("plan.goals.leversIntro")} +

+
+ {rows.map((row) => ( + + ))} +
+ + ) : ( + <> +

+ {t("plan.goals.yourGoals")} +

+
+ {rows.map((row) => ( +
+
{row.label}
+
+ {shown[row.key] === today[row.key] + ? row.format(shown[row.key]) + : `${row.format(today[row.key])} → ${row.format(shown[row.key])}`} +
+
+ ))} +
+ + )} +
+ {editing ? ( + <> + {changed ? ( + + ) : ( + + )} + + + ) : ( + <> + {t("plan.goals.goalsSet")} + + + )} +
+
+ ); +} + +/** Scroll the timeline sideways so a newly opened month's card is in view. */ +function revealCard(element: HTMLDivElement | null) { + const scroller = element?.closest(".overflow-x-auto"); + if (!element || !scroller) return; + const center = element.offsetLeft + element.offsetWidth / 2; + scroller.scrollTo({ + left: center - scroller.clientWidth / 2, + behavior: "smooth", + }); +} + +/** A month on the timeline, as `YYYY-MM`. */ +function monthKey(month: Date): string { + return `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, "0")}`; +} + +const CARD_WIDTH = 320; + +/** Share of a year's web revenue per month in Brazilian retail: Mother's Day, Father's Day, Black Friday, Christmas. */ +const SEASONALITY = [7, 6.5, 7.5, 7.5, 9, 8, 7.5, 8, 7.5, 8, 12, 11.5]; + +/** A goal spread over months by weight, in millions, to one decimal. */ +function spread( + total: number, + months: Date[], + weight: (month: Date) => number, +): Record { + const weights = months.map(weight); + const sum = weights.reduce((a, b) => a + b, 0) || 1; + return Object.fromEntries( + months.map((month, index) => [ + monthKey(month), + Math.round(((total * (weights[index] ?? 0)) / sum) * 10) / 10, + ]), + ); +} + +/** The months still open in each goal year, from this month to next December. */ +function openMonths(now: Date): Date[] { + return Array.from( + { length: 12 - now.getMonth() + 12 }, + (_, index) => new Date(now.getFullYear(), now.getMonth() + index, 1), + ); +} + +/** What each open month has to make: this year's remainder and next year's goal, by season. */ +function defaultSplit(goals: YearRevenue[], now: Date): Record { + const year = now.getFullYear(); + const thisYear = goals.find((row) => row.year === year); + const nextYear = goals.find((row) => row.year === year + 1); + const months = openMonths(now); + const seasonal = (month: Date) => SEASONALITY[month.getMonth()] ?? 1; + return { + ...spread( + Math.max((thisYear?.target ?? 0) - (thisYear?.actual ?? 0), 0), + months.filter((month) => month.getFullYear() === year), + seasonal, + ), + ...spread( + nextYear?.target ?? 0, + months.filter((month) => month.getFullYear() === year + 1), + seasonal, + ), + }; +} + +const YEAR_SPACING = 420; + +function YearCard({ + store, + year, + target, + banked, + open, + today, + monthGoals, + split, + onPickMonth, + onSave, +}: { + store: MapStore; + year: number; + target: number; + /** Revenue already made this year, in millions. */ + banked: number; + /** The year's months still open, with their index on the timeline. */ + open: { month: Date; index: number }[]; + today: Levers; + monthGoals: Record; + split: Record; + onPickMonth: (index: number) => void; + /** Goals for every open month of the year at once. */ + onSave: (goals: Record) => void; +}) { + const t = useT(); + const format = useFormats(); + // Each month starts from its own goals, or today's pace. + const base = open.map(({ month }) => monthGoals[monthKey(month)] ?? today); + const baseSessions = base.reduce((sum, l) => sum + l.sessions, 0); + const baseOrders = base.reduce( + (sum, l) => sum + l.sessions * l.conversion, + 0, + ); + const baseRevenue = base.reduce( + (sum, l) => sum + l.sessions * l.conversion * l.ticket, + 0, + ); + /** The year's levers: total sessions, and conversion and ticket weighted by month. */ + const aggregate: Levers = { + sessions: baseSessions, + conversion: baseSessions ? baseOrders / baseSessions : 0, + ticket: baseOrders ? baseRevenue / baseOrders : 0, + }; + const allSet = + open.length > 0 && + open.every(({ month }) => monthGoals[monthKey(month)] !== undefined); + const [editing, setEditing] = useState(!allSet); + const [levers, setLevers] = useState(aggregate); + // A year lever moves every month by the same ratio, so the split between months holds. + const ratio = (key: LeverKey) => + editing && aggregate[key] ? levers[key] / aggregate[key] : 1; + const factor = ratio("sessions") * ratio("conversion") * ratio("ticket"); + const monthLevers = base.map((l) => ({ + sessions: l.sessions * ratio("sessions"), + conversion: l.conversion * ratio("conversion"), + ticket: l.ticket * ratio("ticket"), + })); + const shown = editing ? levers : aggregate; + const projection = banked + (baseRevenue * factor) / 1e6; + const changed = (Object.keys(aggregate) as LeverKey[]).some( + (key) => levers[key] !== aggregate[key], + ); + const set = (key: LeverKey) => (value: number) => + setLevers((current) => ({ ...current, [key]: value })); + const rows: { + key: LeverKey; + label: string; + step: number; + format: (value: number) => string; + }[] = [ + { + key: "sessions", + label: t("plan.goals.yearSessions"), + step: 50_000, + format: (value) => format.count.format(value), + }, + { + key: "conversion", + label: t("plan.goals.averageConversion"), + step: 0.0005, + format: (value) => format.percent.format(value), + }, + { + key: "ticket", + label: t("plan.goals.ticket"), + step: 5, + format: (value) => format.ticket.format(value), + }, + ]; + const gap = target - projection; + const withGoals = open.filter( + ({ month }) => monthGoals[monthKey(month)] !== undefined, + ).length; + const bars = open.map(({ month, index }, position) => { + const levers = monthLevers[position] ?? today; + return { + month, + index, + goal: split[monthKey(month)] ?? 0, + projected: (levers.sessions * levers.conversion * levers.ticket) / 1e6, + }; + }); + const max = Math.max( + 0.0001, + ...bars.flatMap((bar) => [bar.goal, bar.projected]), + ); + + return ( +
+
+ + {year} + + + + {t("plan.goals.progress", { + actual: format.millions(projection), + target: format.millions(target), + })} + + + {t("plan.goals.yearGoal", { year })} + + {gap <= 0 ? ( + {t("plan.goals.onTrack")} + ) : ( + + {t("plan.goals.gap", { value: format.millions(gap) })} + + )} + {banked > 0 && ( + + {t("plan.goals.bankedSoFar", { value: format.millions(banked) })} + + )} +
+ {editing ? ( + <> +

+ {t("plan.goals.yearLeversIntro")} +

+
+ {rows.map((row) => ( + + ))} +
+ + ) : ( +
+ {rows.map((row) => ( +
+
{row.label}
+
+ {row.format(shown[row.key])} +
+
+ ))} +
+ )} +
+
+ {t("plan.goals.byMonth")} + + + + {t("plan.goals.projection")} + + + + {t("plan.goals.target")} + + +
+
+ {bars.map((bar) => ( + + ))} +
+ + {t("plan.goals.monthsWithGoals", { + count: withGoals, + total: open.length, + })} + +
+
+ {editing ? ( + <> + {changed ? ( + + ) : ( + + )} + + + ) : ( + <> + {t("plan.goals.goalsSet")} + + + )} +
+
+ ); +} + +function YearTimeline({ + store, + goals, + now, + months, + actual, + today, + monthGoals, + split, + onPickMonth, + onSave, +}: { + store: MapStore; + goals: YearRevenue[]; + now: Date; + months: Date[]; + actual: number; + today: Levers; + monthGoals: Record; + split: Record; + onPickMonth: (index: number) => void; + onSave: (goals: Record) => void; +}) { + const year = now.getFullYear(); + const years = [year, year + 1]; + const x = (index: number) => TIMELINE_START + (index + 0.5) * YEAR_SPACING; + const width = x(years.length - 1) + 240; + return ( +
+
+
+ +
+
+
+ {years.map((item, index) => { + const target = goals.find((row) => row.year === item)?.target; + const open = months + .map((month, monthIndex) => ({ month, index: monthIndex })) + .filter(({ month }) => month.getFullYear() === item); + return ( +
+
+ + + {item} + +
+ {target !== undefined && ( +
+ +
+ )} +
+ ); + })} +
+
+ ); +} + +function Timeline({ + store, + goals, + now, + monthGoals, + split, + onMonthGoals, + onYearGoals, +}: { + store: MapStore; + goals: YearRevenue[]; + now: Date; + monthGoals: Record; + /** Each open month's revenue goal, in millions. */ + split: Record; + onMonthGoals: (key: string, levers: Levers) => void; + onYearGoals: (goals: Record) => void; +}) { + const t = useT(); + const format = useFormats(); + const [{ language }] = usePreferences(); + const year = now.getFullYear(); + const thisYear = goals.find((row) => row.year === year); + const months = openMonths(now); + // The months closed so far set today's monthly pace; every lever starts there. + const closedMonths = Math.max(now.getMonth(), 1); + const actual = thisYear?.actual ?? 0; + const monthly = (actual / closedMonths) * 1e6; + const today: Levers = { + sessions: monthly / (CONVERSION * AVERAGE_TICKET), + conversion: CONVERSION, + ticket: AVERAGE_TICKET, + }; + const yearEnd = months.findIndex( + (month) => month.getFullYear() === year && month.getMonth() === 11, + ); + const nextEnd = months.length - 1; + const [selected, setSelected] = useState(Math.max(yearEnd, 0)); + const [scale, setScale] = useState<"month" | "year">("month"); + + const x = (index: number) => TIMELINE_START + (index + 0.5) * MONTH_WIDTH; + const width = x(nextEnd) + CARD_WIDTH / 2 + 48; + const longMonth = new Intl.DateTimeFormat(language, { + month: "long", + year: "numeric", + }); + const monthName = new Intl.DateTimeFormat(language, { month: "long" }); + + const revenueOf = (levers: Levers) => + (levers.sessions * levers.conversion * levers.ticket) / 1e6; + /** The year up to a month: what is made, plus earlier open months at their goals or today's pace. */ + const yearBefore = (index: number) => { + const month = months[index]; + if (!month) return { banked: 0, target: 0 }; + const isThisYear = month.getFullYear() === year; + const start = isThisYear ? actual : 0; + const earlier = months + .slice(0, index) + .filter((other) => other.getFullYear() === month.getFullYear()); + return { + banked: + start + + earlier.reduce( + (sum, other) => sum + revenueOf(monthGoals[monthKey(other)] ?? today), + 0, + ), + target: + start + + earlier.reduce((sum, other) => sum + (split[monthKey(other)] ?? 0), 0), + }; + }; + const selectedMonth = months[selected]; + const before = yearBefore(selected); + const selectedTarget = selectedMonth + ? split[monthKey(selectedMonth)] + : undefined; + const cardLeft = Math.min( + Math.max(x(selected) - CARD_WIDTH / 2, 0), + width - CARD_WIDTH, + ); + + return ( +
+
+
+

+ {t("plan.goals.timelineTitle")} +

+

+ {t( + scale === "month" + ? "plan.goals.timelineHint" + : "plan.goals.timelineYearHint", + )} +

+
+
+ {(["month", "year"] as const).map((option) => ( + + ))} +
+
+ {scale === "year" ? ( + { + setSelected(index); + setScale("month"); + }} + onSave={onYearGoals} + /> + ) : ( +
+
+
+ +
+
+
+ {months.map((month, index) => { + const milestone = index === yearEnd || index === nextEnd; + const showYear = month.getMonth() === 0 || index === 0; + const isSelected = index === selected; + const hasGoals = monthGoals[monthKey(month)] !== undefined; + return ( + + ); + })} + {selectedMonth && selectedTarget !== undefined && ( +
+ + onMonthGoals(monthKey(selectedMonth), levers) + } + /> +
+ )} +
+
+ )} +
+ ); +} + +function MonthlySplit({ + goals, + now, + split, + onChange, +}: { + goals: YearRevenue[]; + now: Date; + split: Record; + onChange: (values: Record) => void; +}) { + const t = useT(); + const format = useFormats(); + const year = now.getFullYear(); + const [shownYear, setShownYear] = useState(year + 1); + const [drafts, setDrafts] = useState>({}); + const months = openMonths(now).filter( + (month) => month.getFullYear() === shownYear, + ); + const row = goals.find((item) => item.year === shownYear); + // This year only its open months are left to plan: the goal minus what is made. + const required = + shownYear === year + ? Math.max((row?.target ?? 0) - (row?.actual ?? 0), 0) + : (row?.target ?? 0); + const values = months.map((month) => split[monthKey(month)] ?? 0); + const sum = values.reduce((a, b) => a + b, 0); + const max = Math.max(...values, 0.0001); + const diff = sum - required; + const decimal = new Intl.NumberFormat(format.language, { + maximumFractionDigits: 1, + }); + + const commit = (key: string) => { + const value = parseMillions(drafts[key] ?? ""); + if (value !== undefined) onChange({ [key]: value }); + setDrafts((current) => { + const rest = { ...current }; + delete rest[key]; + return rest; + }); + }; + + return ( +
+
+
+

+ {t("plan.goals.splitTitle")} +

+

+ {t("plan.goals.splitDescription")} +

+
+
+ {[year, year + 1].map((option) => ( + + ))} +
+
+
+ {months.map((month, index) => { + const key = monthKey(month); + const value = values[index] ?? 0; + return ( +
+
+
+
+ + {format.month.format(month).replace(".", "")} + + + setDrafts((current) => ({ + ...current, + [key]: event.target.value, + })) + } + onBlur={() => commit(key)} + onKeyDown={(event) => { + if (event.key === "Enter") commit(key); + }} + className="px-1 text-center tabular-nums" + /> +
+ ); + })} +
+
+
+ + {t("plan.goals.splitSum", { + sum: format.millions(sum), + target: format.millions(required), + })} + + {Math.abs(diff) < 0.05 ? ( + {t("plan.goals.splitMatches")} + ) : ( + + {diff < 0 + ? t("plan.goals.splitShort", { + value: format.millions(-diff), + }) + : t("plan.goals.splitOver", { value: format.millions(diff) })} + + )} +
+
+ + +
+
+
+ ); +} + +export function GoalsView({ + store, + goals, + onGoals, +}: { + store: MapStore; + goals: YearRevenue[] | null; + onGoals: (goals: YearRevenue[]) => void; +}) { + const t = useT(); + const [now] = useState(() => new Date()); + const [editing, setEditing] = useState(goals === null); + const [monthGoals, setMonthGoals] = useState>({}); + const [splitOverrides, setSplitOverrides] = useState>( + {}, + ); + + if (editing || !goals) { + return ( +
+ { + onGoals(next); + // New yearly goals re-spread every month. + setSplitOverrides({}); + setEditing(false); + }} + /> +
+ ); + } + + const split = { ...defaultSplit(goals, now), ...splitOverrides }; + return ( +
+
+
+ +
+ + + setSplitOverrides((current) => ({ ...current, ...values })) + } + /> + + setMonthGoals((current) => ({ ...current, [key]: levers })) + } + onYearGoals={(goals) => + setMonthGoals((current) => ({ ...current, ...goals })) + } + /> +
+
+ ); +} diff --git a/apps/web/src/components/plan/plan-page.tsx b/apps/web/src/components/plan/plan-page.tsx new file mode 100644 index 0000000000..613dc7c995 --- /dev/null +++ b/apps/web/src/components/plan/plan-page.tsx @@ -0,0 +1,295 @@ +/** + * Plan opens on the map of the store. Starting a plan offers three ways in, + * from the most specific to the most open; none of them is wired yet. + */ + +import { useState, type ComponentType, type ReactNode } from "react"; +import { + ArrowRight, + BarChartSquare02, + Compass03, + Lightbulb02, + Plus, +} from "@untitledui/icons"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@decocms/ui/components/dialog.tsx"; +import { Textarea } from "@decocms/ui/components/textarea.tsx"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import { Page } from "@/components/page"; +import { useT } from "@/i18n/use-t.ts"; +import { useAnalyticsStatus } from "@/components/monitor/deco-analytics"; +import { useSiteProjects } from "@/components/monitor/monitor-views"; +import { siteOrigin } from "@/layouts/main-panel-tabs/analytics-heatmap-model.ts"; +import { useProjectContext } from "@/sdk"; +import { Panel } from "@/components/panel"; +import { GoalsView, type YearRevenue } from "./goals-view.tsx"; +import { SiteMap, type MapStore } from "./site-map.tsx"; +import type { TranslationKey } from "@/i18n/use-t.ts"; + +type Icon = ComponentType<{ size?: number; className?: string }>; + +const TOPICS: TranslationKey[] = [ + "plan.topic.shipping", + "plan.topic.pdp", + "plan.topic.home", + "plan.topic.search", + "plan.topic.minicart", + "plan.topic.plp", + "plan.topic.checkout", + "plan.topic.payment", + "plan.topic.promotions", + "plan.topic.recommendations", + "plan.topic.reviews", + "plan.topic.performance", + "plan.topic.seo", + "plan.topic.mobile", + "plan.topic.account", + "plan.topic.accessibility", +]; + +const REPORT_POINTS: TranslationKey[] = [ + "plan.full.pointFunnel", + "plan.full.pointPerformance", + "plan.full.pointSeo", + "plan.full.pointPriorities", +]; + +function Column({ + Icon, + tone, + title, + description, + action, + children, +}: { + Icon: Icon; + tone: string; + title: string; + description: string; + action: ReactNode; + children: ReactNode; +}) { + return ( +
+
+ + + +
+

{title}

+

+ {description} +

+
+
+
{children}
+ {action} +
+ ); +} + +/** + * The report, framed against the current project's site, or the org's first + * site when the plan is opened from the org. The domain is the one the heatmap + * frames. + */ +function PlanReport({ + store, + leading, +}: { + store: MapStore; + leading: ReactNode; +}) { + const { org, project } = useProjectContext(); + const projects = useSiteProjects(); + const site = projects.find((item) => item.id === project.id) ?? projects[0]; + const status = useAnalyticsStatus( + org.slug, + { analyticsSite: site?.analyticsSite ?? "" }, + site !== undefined, + ); + const origin = siteOrigin( + status.data?.config?.domains?.[0] ?? status.data?.host, + ); + return ; +} + +/** The three ways to start a plan; none of them is wired yet. */ +function PlanOptions({ onDone }: { onDone: () => void }) { + const t = useT(); + const [selected, setSelected] = useState>( + () => new Set(), + ); + const toggle = (topic: TranslationKey) => + setSelected((current) => { + const next = new Set(current); + if (!next.delete(topic)) next.add(topic); + return next; + }); + + return ( +
+ + {t("plan.idea.action")} + + + } + > +