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 (
+ onSelect(stage.id)}
+ aria-pressed={selected}
+ className={cn(
+ "group flex min-w-0 flex-1 flex-col gap-3 surface surface-interactive p-4 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ selected && "ring-2 ring-foreground",
+ )}
+ >
+
+
+
+ {stage.label}
+
+
+ {t("plan.funnel.sessionsPeriod", {
+ count: new Intl.NumberFormat(language).format(value),
+ })}
+
+
+
+
+
+
+
+ {t("plan.funnel.ofTotal", { rate: percent(share) })}
+
+
+
+ );
+}
+
+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) => (
+ stage.id).join("+")}>
+ {index > 0 && transition(rates[index] ?? 1, index === worst)}
+ 1 && "flex-col",
+ )}
+ >
+ {group.map((stage) => (
+
+ ))}
+
+
+ ))}
+
+ {transition(last ? orders / last : 0, false)}
+
+
+ {t("plan.funnel.orders")}
+
+
+ {t("plan.funnel.ordersPeriod", {
+ count: count.format(orders),
+ })}
+
+
+
+
+
+
+ );
+}
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 (
+
+ );
+}
+
+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 ? (
+ 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 ? (
+ setLevers(today)}
+ >
+
+ {t("plan.goals.reset")}
+
+ ) : (
+
+ )}
+ {
+ onSave(levers);
+ setEditing(false);
+ }}
+ >
+ {t("plan.goals.setGoals")}
+
+ >
+ ) : (
+ <>
+ {t("plan.goals.goalsSet")}
+ setEditing(true)}
+ >
+ {t("plan.goals.editGoals")}
+
+ >
+ )}
+
+
+ );
+}
+
+/** 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) => (
+ onPickMonth(bar.index)}
+ title={t("plan.goals.monthBar", {
+ month: format.month.format(bar.month),
+ projected: format.millions(bar.projected),
+ goal: format.millions(bar.goal),
+ })}
+ className="group flex min-w-0 flex-1 flex-col items-center gap-1 rounded-md pt-1 outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
+ >
+
+
+
+
+
+ {format.month.format(bar.month).replace(".", "").slice(0, 3)}
+
+ {monthGoals[monthKey(bar.month)] !== undefined && (
+
+ )}
+
+ ))}
+
+
+ {t("plan.goals.monthsWithGoals", {
+ count: withGoals,
+ total: open.length,
+ })}
+
+
+
+ {editing ? (
+ <>
+ {changed ? (
+ setLevers(aggregate)}
+ >
+
+ {t("plan.goals.reset")}
+
+ ) : (
+
+ )}
+ {
+ onSave(
+ Object.fromEntries(
+ open.map(({ month }, position) => [
+ monthKey(month),
+ monthLevers[position] ?? today,
+ ]),
+ ),
+ );
+ setEditing(false);
+ }}
+ >
+ {t("plan.goals.setGoals")}
+
+ >
+ ) : (
+ <>
+ {t("plan.goals.goalsSet")}
+ {
+ setLevers(aggregate);
+ setEditing(true);
+ }}
+ >
+ {t("plan.goals.editGoals")}
+
+ >
+ )}
+
+
+ );
+}
+
+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) => (
+ setScale(option)}
+ >
+ {t(
+ option === "month"
+ ? "plan.goals.scaleMonth"
+ : "plan.goals.scaleYear",
+ )}
+
+ ))}
+
+
+ {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 (
+
setSelected(index)}
+ aria-pressed={isSelected}
+ aria-label={longMonth.format(month)}
+ className="group absolute top-[5.5rem] flex w-20 -translate-x-1/2 flex-col items-center rounded-lg pb-1.5 outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ style={{ left: x(index) }}
+ >
+
+
+ {format.month.format(month).replace(".", "")}
+ {showYear || milestone ? ` ${month.getFullYear()}` : ""}
+
+ {index === 0 && (
+
+ {t("plan.goals.today", {
+ conversion: format.percent.format(CONVERSION),
+ })}
+
+ )}
+ {hasGoals && (
+
+ )}
+
+ );
+ })}
+ {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) => (
+ setShownYear(option)}
+ >
+ {option === year
+ ? t("plan.goals.splitRest", { year: option })
+ : 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) })}
+
+ )}
+
+
+
+ onChange(
+ spread(
+ required,
+ months,
+ (month) => SEASONALITY[month.getMonth()] ?? 1,
+ ),
+ )
+ }
+ >
+ {t("plan.goals.splitSeasonal")}
+
+ onChange(spread(required, months, () => 1))}
+ >
+ {t("plan.goals.splitEven")}
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+ setEditing(true)}>
+ {t("plan.goals.edit")}
+
+
+
+
+ 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")}
+
+
+ }
+ >
+
+
+
+ {t("plan.direction.action")}
+
+
+ }
+ >
+
+ {TOPICS.map((topic) => (
+ toggle(topic)}
+ >
+ {t(topic)}
+
+ ))}
+
+
+
+ {t("plan.full.action")}
+
+
+ }
+ >
+
+ {REPORT_POINTS.map((point) => (
+
+
+ {t(point)}
+
+ ))}
+
+
+
+ );
+}
+
+type PlanView = "map" | "goals";
+
+/**
+ * An org's file through the Studio that is open: the stored URL names the
+ * deployment that uploaded it, which a local or preview Studio can't read
+ * with its own session.
+ */
+function orgFileUrl(url: string | null, orgSlug: string): string | null {
+ if (!url) return null;
+ try {
+ const parsed = new URL(url, window.location.origin);
+ return parsed.pathname.startsWith(`/api/${orgSlug}/files/`)
+ ? `${parsed.pathname}${parsed.search}`
+ : url;
+ } catch {
+ return url;
+ }
+}
+
+/** Map and Goals, in the panel's toolbar like the Monitor's views. */
+function PlanTabs({
+ view,
+ onView,
+}: {
+ view: PlanView;
+ onView: (view: PlanView) => void;
+}) {
+ const t = useT();
+ const tabs = (
+
+ onView("map")}>
+ {t("plan.view.map")}
+
+ onView("goals")}>
+ {t("plan.view.goals")}
+
+
+ );
+ return (
+
+ {tabs}
+
+ );
+}
+
+export function PlanPage() {
+ const t = useT();
+ const { org } = useProjectContext();
+ const [view, setView] = useState("map");
+ const [goals, setGoals] = useState(null);
+ const [starting, setStarting] = useState(false);
+ const store = { name: org.name, logo: orgFileUrl(org.logo, org.slug) };
+ return (
+
+
+ {view === "map" ? (
+ setStarting(true)}>
+
+ {t("plan.start")}
+
+ }
+ />
+ ) : (
+
+ )}
+
+
+
+ {t("plan.heading")}
+ {t("plan.description")}
+
+ setStarting(false)} />
+
+
+
+ );
+}
diff --git a/apps/web/src/components/plan/site-map-mock.ts b/apps/web/src/components/plan/site-map-mock.ts
new file mode 100644
index 0000000000..0365be7b6a
--- /dev/null
+++ b/apps/web/src/components/plan/site-map-mock.ts
@@ -0,0 +1,363 @@
+/**
+ * Placeholder report until the full analysis exists. The labels stand in for
+ * data the analysis will return, so they are not UI copy.
+ */
+
+export type CheckStatus = "missing" | "partial" | "done";
+
+export interface MapNode {
+ id: string;
+ label: string;
+ status?: CheckStatus;
+ /** An area the agents do not cover yet. */
+ soon?: boolean;
+ note?: string;
+ /** The outcome this area moves, shown instead of a checklist score. */
+ metric?: { value: string; label: string };
+ /** Sessions that reached this funnel stage. */
+ sessions?: number;
+ /** Stages sharing a step are alternative paths, like a category page or a search. */
+ step?: number;
+ /** A page on the store that shows this stage. */
+ path?: string;
+ /** Orders placed at the end of the funnel. */
+ orders?: number;
+ /** What the agents found on a funnel stage's page. */
+ opportunities?: MapNode[];
+ children?: MapNode[];
+}
+
+function leaf(
+ id: string,
+ label: string,
+ status: CheckStatus,
+ note?: string,
+): MapNode {
+ return { id, label, status, note };
+}
+
+export const MOCK_SITE_MAP: MapNode = {
+ id: "root",
+ label: "Minha loja",
+ children: [
+ {
+ id: "inbound",
+ label: "Inbound",
+ metric: { value: "182 mil", label: "sessões orgânicas em 30 dias" },
+ children: [
+ {
+ id: "seo",
+ label: "SEO",
+ metric: { value: "48 mil", label: "cliques no Google em 30 dias" },
+ children: [
+ leaf("sitemap", "Sitemap", "done"),
+ leaf("robots", "Robots.txt", "done"),
+ leaf(
+ "canonical",
+ "Canonical",
+ "partial",
+ "Páginas de categoria com filtros apontam o canonical para a própria URL filtrada.",
+ ),
+ leaf(
+ "meta-tags",
+ "Meta tags",
+ "partial",
+ "Títulos duplicados em parte das páginas de produto.",
+ ),
+ leaf(
+ "structured-data",
+ "Dados estruturados",
+ "missing",
+ "Páginas de produto sem schema Product e Offer.",
+ ),
+ leaf(
+ "broken-links",
+ "Links quebrados",
+ "missing",
+ "Links internos retornando 404 no menu e no rodapé.",
+ ),
+ leaf(
+ "hreflang",
+ "Hreflang",
+ "missing",
+ "Versões em outros idiomas sem hreflang.",
+ ),
+ ],
+ },
+ {
+ id: "geo",
+ label: "GEO",
+ metric: {
+ value: "1,2 mil",
+ label: "cliques vindos de IA em 30 dias",
+ },
+ children: [
+ leaf(
+ "llms-txt",
+ "llms.txt",
+ "missing",
+ "Nenhum llms.txt publicado para orientar assistentes de IA.",
+ ),
+ leaf("ai-bots", "Bots de IA liberados", "done"),
+ leaf(
+ "ai-data",
+ "Dados para IA",
+ "partial",
+ "Preço e disponibilidade só aparecem após o carregamento do JavaScript.",
+ ),
+ leaf(
+ "faq",
+ "FAQ nas PDPs",
+ "missing",
+ "Sem perguntas frequentes nas páginas de produto.",
+ ),
+ ],
+ },
+ {
+ id: "content",
+ label: "Conteúdo",
+ metric: { value: "12 mil", label: "sessões no blog em 30 dias" },
+ children: [
+ leaf(
+ "blog",
+ "Blog",
+ "partial",
+ "Último post publicado há 4 meses.",
+ ),
+ leaf("size-guide", "Guia de medidas", "done"),
+ leaf(
+ "category-copy",
+ "Textos de categoria",
+ "missing",
+ "Categorias principais sem texto descritivo.",
+ ),
+ ],
+ },
+ ],
+ },
+ {
+ id: "cro",
+ label: "CRO",
+ metric: { value: "1,4%", label: "de conversão em 30 dias" },
+ orders: 5_900,
+ children: [
+ {
+ id: "stage-home",
+ label: "Home",
+ step: 1,
+ sessions: 420_000,
+ path: "/",
+ opportunities: [
+ leaf(
+ "hero-banner",
+ "Banner principal",
+ "partial",
+ "O banner principal leva para uma coleção esgotada.",
+ ),
+ leaf(
+ "personal-shelves",
+ "Vitrines personalizadas",
+ "missing",
+ "Todos os clientes veem as mesmas vitrines.",
+ ),
+ leaf("visible-search", "Busca visível", "done"),
+ ],
+ },
+ {
+ id: "stage-plp",
+ label: "PLP",
+ step: 2,
+ sessions: 190_000,
+ path: "/feminino",
+ opportunities: [
+ leaf(
+ "filters",
+ "Filtros",
+ "partial",
+ "Filtros de tamanho e cor não combinam entre si.",
+ ),
+ leaf(
+ "plp-sort",
+ "Ordenação",
+ "missing",
+ "A ordenação padrão mostra primeiro produtos sem estoque.",
+ ),
+ leaf("plp-pagination", "Carregar mais", "done"),
+ ],
+ },
+ {
+ id: "stage-search",
+ label: "Busca",
+ step: 2,
+ sessions: 70_000,
+ path: "/s?q=vestido",
+ opportunities: [
+ leaf("autocomplete", "Autocomplete", "done"),
+ leaf(
+ "no-results",
+ "Sem resultados",
+ "missing",
+ "A página sem resultados não sugere alternativas.",
+ ),
+ leaf("synonyms", "Sinônimos", "done"),
+ ],
+ },
+ {
+ id: "stage-product",
+ label: "Produto",
+ step: 3,
+ sessions: 180_000,
+ path: "/vestido-midi-floral/p",
+ opportunities: [
+ leaf("gallery", "Galeria", "done"),
+ leaf(
+ "delivery-estimate",
+ "Prazo de entrega",
+ "missing",
+ "O prazo só aparece no checkout.",
+ ),
+ leaf(
+ "reviews",
+ "Avaliações",
+ "partial",
+ "Avaliações carregam abaixo da dobra e sem nota média no topo.",
+ ),
+ leaf(
+ "buy-together",
+ "Compre junto",
+ "partial",
+ "Sem sugestão de produtos complementares.",
+ ),
+ leaf("size-table", "Tabela de medidas", "done"),
+ ],
+ },
+ {
+ id: "stage-cart",
+ label: "Carrinho",
+ step: 4,
+ sessions: 21_600,
+ path: "/checkout/#/cart",
+ opportunities: [
+ leaf(
+ "free-shipping-bar",
+ "Barra de frete grátis",
+ "missing",
+ "O cliente não vê quanto falta para o frete grátis.",
+ ),
+ leaf("cart-coupon", "Cupom no carrinho", "done"),
+ leaf(
+ "cart-recommendations",
+ "Recomendações",
+ "partial",
+ "Recomendações genéricas, sem relação com os itens do carrinho.",
+ ),
+ ],
+ },
+ {
+ id: "stage-checkout",
+ label: "Checkout",
+ step: 5,
+ sessions: 11_900,
+ path: "/checkout",
+ opportunities: [
+ leaf(
+ "steps",
+ "Etapas",
+ "partial",
+ "Cadastro obrigatório antes do pagamento.",
+ ),
+ leaf("pix", "Pix", "done"),
+ leaf(
+ "social-login",
+ "Login social",
+ "missing",
+ "Sem login com Google ou Apple.",
+ ),
+ ],
+ },
+ ],
+ },
+ { id: "tracking", label: "Tracking", soon: true },
+ { id: "promotions", label: "Promoções e cupons", soon: true },
+ { id: "outbound", label: "Outbound", soon: true },
+ {
+ id: "technical",
+ label: "Saúde técnica",
+ children: [
+ {
+ id: "speed",
+ label: "Velocidade",
+ children: [
+ leaf("lcp", "LCP", "done"),
+ leaf("inp", "INP", "done"),
+ leaf(
+ "cls",
+ "CLS",
+ "missing",
+ "Banners sem altura reservada empurram o conteúdo.",
+ ),
+ leaf("images", "Imagens", "done"),
+ leaf(
+ "third-party",
+ "Scripts de terceiros",
+ "missing",
+ "Oito scripts de terceiros bloqueiam a renderização.",
+ ),
+ leaf("cache", "Cache", "done"),
+ ],
+ },
+ {
+ id: "accessibility",
+ label: "Acessibilidade",
+ children: [
+ leaf(
+ "contrast",
+ "Contraste",
+ "partial",
+ "Preços promocionais em cinza claro sobre fundo branco.",
+ ),
+ leaf(
+ "alt-text",
+ "Texto alternativo",
+ "missing",
+ "Imagens de produto sem texto alternativo.",
+ ),
+ leaf(
+ "keyboard",
+ "Navegação por teclado",
+ "partial",
+ "O menu não abre pelo teclado.",
+ ),
+ leaf(
+ "form-labels",
+ "Rótulos de formulário",
+ "missing",
+ "Campos do newsletter e do CEP sem rótulo.",
+ ),
+ leaf("touch-targets", "Áreas de toque", "done"),
+ ],
+ },
+ {
+ id: "best-practices",
+ label: "Boas práticas",
+ children: [
+ leaf("https", "HTTPS", "done"),
+ leaf(
+ "console-errors",
+ "Erros no console",
+ "partial",
+ "Erros de JavaScript em páginas de produto.",
+ ),
+ leaf(
+ "outdated-libs",
+ "Bibliotecas desatualizadas",
+ "missing",
+ "Bibliotecas com vulnerabilidades conhecidas.",
+ ),
+ leaf("image-ratio", "Proporção das imagens", "done"),
+ ],
+ },
+ ],
+ },
+ ],
+};
diff --git a/apps/web/src/components/plan/site-map.tsx b/apps/web/src/components/plan/site-map.tsx
new file mode 100644
index 0000000000..812b99f1bb
--- /dev/null
+++ b/apps/web/src/components/plan/site-map.tsx
@@ -0,0 +1,889 @@
+/**
+ * The full analysis as a radial map of the store: drag to pan, pinch or
+ * Ctrl + scroll to zoom, click a topic to move into it.
+ */
+
+import {
+ useRef,
+ useState,
+ type PointerEvent as ReactPointerEvent,
+ type ReactNode,
+ type WheelEvent as ReactWheelEvent,
+} from "react";
+import {
+ ChevronRight,
+ Expand06,
+ Minus,
+ Plus,
+ RefreshCw01,
+} from "@untitledui/icons";
+import { Badge } from "@decocms/ui/components/badge.tsx";
+import { Button } from "@decocms/ui/components/button.tsx";
+import { cn } from "@decocms/ui/lib/utils.ts";
+import { usePreferences } from "@/hooks/use-preferences.ts";
+import { useT } from "@/i18n/use-t.ts";
+import {
+ MOCK_SITE_MAP,
+ type CheckStatus,
+ type MapNode,
+} from "./site-map-mock.ts";
+import {
+ STATUS_BADGE,
+ STATUS_DOT,
+ STATUS_LABEL,
+ STATUS_RING,
+ STATUSES,
+ StatusDot,
+} from "./status.tsx";
+import { FunnelView } from "./funnel-view.tsx";
+import { StageCard } from "./stage-card.tsx";
+
+/** Distance from the center, per depth, in world pixels. */
+const RADIUS = [0, 400, 680, 900];
+const BRANCH_SIZE = [240, 136, 124];
+const LEAF_SIZE = 30;
+/** Room under a leaf for its label. */
+const LEAF_LABEL = 44;
+/** The first area sits up and to the left, as in a hand-drawn map. */
+const FIRST_ANGLE = -110;
+const PANEL_WIDTH = 320;
+const MIN_ZOOM = 0.15;
+const MAX_ZOOM = 2.5;
+
+interface Placed {
+ node: MapNode;
+ depth: number;
+ x: number;
+ y: number;
+ size: number;
+ parent: Placed | null;
+}
+
+interface Camera {
+ x: number;
+ y: number;
+ k: number;
+}
+
+type Counts = Record;
+
+function weight(node: MapNode): number {
+ if (node.soon || node.opportunities) return 3;
+ if (!node.children?.length) return 1;
+ return node.children.reduce((sum, child) => sum + weight(child), 0);
+}
+
+/** Type sizes per depth, matching the classes in `MapNodeView`. */
+const BRANCH_TYPE = [
+ { label: 30, value: 48, caption: 12, captionWidth: 150 },
+ { label: 24, value: 18, caption: 12, captionWidth: 150 },
+ { label: 13, value: 14, caption: 10, captionWidth: 96 },
+];
+/** Ring plus inner padding, on each side. */
+const BRANCH_INSET = 18;
+
+function textWidth(text: string, fontSize: number): number {
+ return text.length * fontSize * 0.56;
+}
+
+/** A circle wide enough to hold its label and result: the content box's diagonal. */
+function branchSize(node: MapNode, depth: number): number {
+ const base = BRANCH_SIZE[depth] ?? LEAF_SIZE;
+ const type = BRANCH_TYPE[depth];
+ if (!type || node.soon || depth === 0) return base;
+ let width = textWidth(node.label, type.label);
+ let height = type.label * 1.25;
+ if (node.metric) {
+ const caption = textWidth(node.metric.label, type.caption);
+ const lines = Math.ceil(caption / type.captionWidth);
+ width = Math.max(
+ width,
+ textWidth(node.metric.value, type.value),
+ Math.min(caption, type.captionWidth),
+ );
+ height += 4 + type.value * 1.25 + lines * type.caption * 1.25;
+ } else {
+ height += 4 + type.value * 1.25;
+ }
+ return Math.max(
+ base,
+ Math.ceil(Math.hypot(width, height) + BRANCH_INSET * 2),
+ );
+}
+
+function layout(root: MapNode): Placed[] {
+ const placed: Placed[] = [];
+ const visit = (
+ node: MapNode,
+ depth: number,
+ from: number,
+ to: number,
+ parent: Placed | null,
+ ) => {
+ const angle = ((from + to) / 2) * (Math.PI / 180);
+ const radius = RADIUS[depth] ?? 0;
+ const isLeaf = node.status !== undefined;
+ const item: Placed = {
+ node,
+ depth,
+ x: Math.cos(angle) * radius,
+ y: Math.sin(angle) * radius,
+ size: isLeaf ? LEAF_SIZE : branchSize(node, depth),
+ parent,
+ };
+ placed.push(item);
+ const children = node.children ?? [];
+ const total = children.reduce((sum, child) => sum + weight(child), 0);
+ let start = from;
+ for (const child of children) {
+ const span = ((to - from) * weight(child)) / total;
+ visit(child, depth + 1, start, start + span, item);
+ start += span;
+ }
+ };
+ const first = root.children?.[0];
+ const firstSpan = first ? (360 * weight(first)) / weight(root) : 0;
+ const from = FIRST_ANGLE - firstSpan / 2;
+ visit(root, 0, from, from + 360, null);
+ return placed;
+}
+
+const PLACED = layout(MOCK_SITE_MAP);
+const BY_ID = new Map(PLACED.map((item) => [item.node.id, item]));
+
+function countLeaves(node: MapNode): Counts {
+ const counts: Counts = { missing: 0, partial: 0, done: 0 };
+ const visit = (current: MapNode) => {
+ if (current.status) counts[current.status] += 1;
+ for (const child of current.children ?? []) visit(child);
+ for (const opportunity of current.opportunities ?? []) visit(opportunity);
+ };
+ visit(node);
+ return counts;
+}
+
+/** Done counts fully, partial counts half. */
+function score(counts: Counts): number | null {
+ const total = counts.missing + counts.partial + counts.done;
+ if (total === 0) return null;
+ return Math.round(((counts.done + counts.partial / 2) / total) * 100);
+}
+
+/** The status mix as a ring: green, then yellow, then red. */
+function ringGradient(counts: Counts): string {
+ const total = counts.missing + counts.partial + counts.done;
+ if (total === 0) return "var(--border)";
+ const done = (counts.done / total) * 100;
+ const partial = done + (counts.partial / total) * 100;
+ return `conic-gradient(var(--success) 0 ${done}%, var(--warning) ${done}% ${partial}%, var(--destructive) ${partial}% 100%)`;
+}
+
+function isDescendant(item: Placed, ancestor: Placed): boolean {
+ for (let current: Placed | null = item; current; current = current.parent) {
+ if (current === ancestor) return true;
+ }
+ return false;
+}
+
+function pathTo(item: Placed): Placed[] {
+ const path: Placed[] = [];
+ for (let current: Placed | null = item; current; current = current.parent) {
+ path.unshift(current);
+ }
+ return path;
+}
+
+const TECHNICAL = BY_ID.get("technical");
+const FUNNEL = BY_ID.get("cro");
+
+/** Only the technical checklist gets a grade; the other areas are read by their results. */
+function isGraded(item: Placed): boolean {
+ return TECHNICAL ? isDescendant(item, TECHNICAL) : false;
+}
+
+function isLeaf(item: Placed): boolean {
+ return item.node.status !== undefined;
+}
+
+/** Fit a topic and everything under it; a single check is shown with its siblings. */
+/** A check shows once its topic is open, so the overview stays at the topics. */
+function isShown(item: Placed, focus: Placed): boolean {
+ if (!isLeaf(item)) return true;
+ return (
+ item.parent === focus || (isLeaf(focus) && item.parent === focus.parent)
+ );
+}
+
+function fitCamera(
+ focus: Placed,
+ width: number,
+ height: number,
+ panelOpen: boolean,
+): Camera {
+ const target = isLeaf(focus) && focus.parent ? focus.parent : focus;
+ let minX = Number.POSITIVE_INFINITY;
+ let minY = Number.POSITIVE_INFINITY;
+ let maxX = Number.NEGATIVE_INFINITY;
+ let maxY = Number.NEGATIVE_INFINITY;
+ for (const item of PLACED) {
+ if (!isDescendant(item, target) || !isShown(item, focus)) continue;
+ const half = item.size / 2;
+ const below = isLeaf(item) ? LEAF_LABEL : 0;
+ minX = Math.min(minX, item.x - half - 40);
+ maxX = Math.max(maxX, item.x + half + 40);
+ minY = Math.min(minY, item.y - half);
+ maxY = Math.max(maxY, item.y + half + below);
+ }
+ const usable = panelOpen ? Math.max(width - PANEL_WIDTH, width / 2) : width;
+ const k = clamp(
+ Math.min(usable / (maxX - minX), height / (maxY - minY)) * 0.88,
+ );
+ return {
+ k,
+ x: usable / 2 - ((minX + maxX) / 2) * k,
+ y: height / 2 - ((minY + maxY) / 2) * k,
+ };
+}
+
+function clamp(k: number): number {
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k));
+}
+
+function zoomAround(camera: Camera, factor: number, px: number, py: number) {
+ const k = clamp(camera.k * factor);
+ const ratio = k / camera.k;
+ return {
+ k,
+ x: px - (px - camera.x) * ratio,
+ y: py - (py - camera.y) * ratio,
+ };
+}
+
+/** A mouse click must not focus the topic: focusing scrolls it into view and shifts the canvas. */
+function keepCanvasStill(event: { preventDefault: () => void }) {
+ event.preventDefault();
+}
+
+function MapNodeView({
+ item,
+ store,
+ zoom,
+ shown,
+ focused,
+ onSelect,
+}: {
+ item: Placed;
+ store: MapStore;
+ zoom: number;
+ /** False hides a check until its topic is open. */
+ shown: boolean;
+ focused: boolean;
+ onSelect: (id: string) => void;
+}) {
+ const t = useT();
+ // A logo that does not load gives way to the name.
+ const [logoFailed, setLogoFailed] = useState(false);
+ const { node, size, depth } = item;
+ const position = {
+ left: item.x - size / 2,
+ top: item.y - size / 2,
+ width: size,
+ height: size,
+ };
+
+ if (isLeaf(item) && node.status) {
+ return (
+ onSelect(node.id)}
+ onMouseDown={keepCanvasStill}
+ style={position}
+ tabIndex={shown ? undefined : -1}
+ aria-hidden={shown ? undefined : true}
+ className={cn(
+ "group absolute flex flex-col items-center outline-none transition-opacity duration-300",
+ !shown && "pointer-events-none opacity-0",
+ )}
+ >
+
+
+ {node.label}
+
+
+ );
+ }
+
+ if (node.soon) {
+ return (
+ onSelect(node.id)}
+ onMouseDown={keepCanvasStill}
+ style={position}
+ className={cn(
+ "absolute flex flex-col items-center justify-center gap-2 rounded-full border-2 border-dashed border-border bg-muted/60 p-4 text-center outline-none transition-colors hover:bg-muted",
+ focused && "outline-3 outline-offset-6 outline-foreground",
+ )}
+ >
+
+ {node.label}
+
+
+ {t("plan.map.soon")}
+
+
+ );
+ }
+
+ const counts = countLeaves(node);
+ const graded = isGraded(item);
+ const value = graded ? score(counts) : null;
+ const metric = graded ? undefined : node.metric;
+ const logo = depth === 0 && !logoFailed ? store.logo : null;
+ return (
+ onSelect(node.id)}
+ onMouseDown={keepCanvasStill}
+ style={{
+ ...position,
+ background: graded ? ringGradient(counts) : "var(--border)",
+ }}
+ className={cn(
+ "absolute rounded-full shadow-edge outline-none transition-transform hover:scale-[1.03]",
+ depth === 0 ? "p-2" : "p-1.5",
+ focused && "outline-3 outline-offset-6 outline-foreground",
+ )}
+ >
+
+ {logo ? (
+ setLogoFailed(true)}
+ className="max-h-20 max-w-[62%] object-contain"
+ />
+ ) : (
+
+ {labelOf(node, store)}
+
+ )}
+ {value !== null && (
+
+ {value}%
+
+ )}
+ {metric && (
+
+
+ {metric.value}
+
+
+ {metric.label}
+
+
+ )}
+
+
+ );
+}
+
+function DetailPanel({
+ item,
+ store,
+ onSelect,
+}: {
+ item: Placed;
+ store: MapStore;
+ onSelect: (id: string) => void;
+}) {
+ const t = useT();
+ const { node } = item;
+ const [{ language }] = usePreferences();
+ const counts = countLeaves(node);
+ const value = isGraded(item) ? score(counts) : null;
+ const metric = isGraded(item) ? undefined : node.metric;
+ const children = item.node.children ?? [];
+ const summaryOf = (child: MapNode): string | null => {
+ const placed = BY_ID.get(child.id);
+ if (child.soon) return t("plan.map.soon");
+ if (placed && isGraded(placed) && !child.status) {
+ return `${score(countLeaves(child)) ?? 0}%`;
+ }
+ return null;
+ };
+ /** A number always travels with what it counts and over which period. */
+ const resultOf = (child: MapNode): string | null => {
+ if (child.metric) return `${child.metric.value} ${child.metric.label}`;
+ if (child.sessions !== undefined) {
+ return t("plan.funnel.sessionsPeriod", {
+ count: new Intl.NumberFormat(language).format(child.sessions),
+ });
+ }
+ return null;
+ };
+ return (
+ event.stopPropagation()}
+ onWheel={(event) => event.stopPropagation()}
+ >
+
+
+
+ {labelOf(node, store)}
+
+ {node.status && (
+
+ {t(STATUS_LABEL[node.status])}
+
+ )}
+ {!node.status && value !== null && (
+
+ {value}%
+
+ )}
+
+ {metric && (
+
+
+ {metric.value}
+ {" "}
+ {metric.label}
+
+ )}
+ {node.soon && (
+
+ {t("plan.map.soonDescription")}
+
+ )}
+ {node.note && (
+
+ {node.note}
+
+ )}
+ {node.status === "done" && !node.note && (
+
+ {t("plan.map.doneDescription")}
+
+ )}
+
+ {node.status && node.status !== "done" && (
+
+ {t("plan.map.createTask")}
+
+ )}
+ {node.status === "done" && (
+
+
+ {t("plan.map.revalidate")}
+
+ )}
+ {children.length > 0 && (
+
+ {children.map((child) => {
+ const childCounts = countLeaves(child);
+ const childPlaced = BY_ID.get(child.id);
+ const childGraded = childPlaced ? isGraded(childPlaced) : false;
+ return (
+
+ onSelect(child.id)}
+ >
+ {child.status ? (
+
+ ) : (
+
+ )}
+
+ {child.label}
+ {resultOf(child) && (
+
+ {resultOf(child)}
+
+ )}
+
+
+ {summaryOf(child)}
+
+ {!child.status && !child.soon && (
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+/** The store at the center of the map: the organization. */
+export interface MapStore {
+ name: string;
+ logo: string | null;
+}
+
+function labelOf(node: MapNode, store: MapStore): string {
+ return node.id === MOCK_SITE_MAP.id ? store.name : node.label;
+}
+
+interface Drag {
+ x: number;
+ y: number;
+ camera: Camera;
+ moved: boolean;
+}
+
+export function SiteMap({
+ store,
+ origin,
+ leading,
+}: {
+ store: MapStore;
+ /** The store's live origin, for framing a funnel stage's page. */
+ origin: string | null;
+ /** Controls before the breadcrumb, over the map's top-left corner. */
+ leading?: ReactNode;
+}) {
+ const t = useT();
+ const [size, setSize] = useState({ width: 0, height: 0 });
+ const [focusId, setFocusId] = useState("root");
+ const [manual, setManual] = useState(null);
+ const [smooth, setSmooth] = useState(true);
+ const drag = useRef(null);
+
+ const focus = BY_ID.get(focusId) ?? PLACED[0]!;
+ const panelOpen = size.width >= 900;
+ const camera = manual ?? fitCamera(focus, size.width, size.height, panelOpen);
+
+ const viewport = (element: HTMLDivElement | null) => {
+ if (!element) return;
+ const observer = new ResizeObserver(([entry]) => {
+ if (!entry) return;
+ setSize({
+ width: entry.contentRect.width,
+ height: entry.contentRect.height,
+ });
+ });
+ observer.observe(element);
+ return () => observer.disconnect();
+ };
+
+ const canvas = (element: HTMLDivElement | null) => {
+ if (!element) return;
+ // React's wheel listener is passive; without this, Ctrl + scroll zooms the browser.
+ const block = (event: WheelEvent) => event.preventDefault();
+ element.addEventListener("wheel", block, { passive: false });
+ return () => element.removeEventListener("wheel", block);
+ };
+
+ const select = (id: string) => {
+ setSmooth(true);
+ setManual(null);
+ setFocusId(id);
+ };
+
+ const zoomBy = (factor: number) => {
+ setSmooth(true);
+ setManual((current) =>
+ zoomAround(current ?? camera, factor, size.width / 2, size.height / 2),
+ );
+ };
+
+ const onWheel = (event: ReactWheelEvent) => {
+ const bounds = event.currentTarget.getBoundingClientRect();
+ setSmooth(false);
+ if (event.ctrlKey || event.metaKey) {
+ const factor = Math.exp(-event.deltaY * 0.01);
+ const px = event.clientX - bounds.left;
+ const py = event.clientY - bounds.top;
+ setManual((current) => zoomAround(current ?? camera, factor, px, py));
+ return;
+ }
+ setManual((current) => {
+ const base = current ?? camera;
+ return { ...base, x: base.x - event.deltaX, y: base.y - event.deltaY };
+ });
+ };
+
+ const onPointerDown = (event: ReactPointerEvent) => {
+ if (event.button !== 0) return;
+ drag.current = {
+ x: event.clientX,
+ y: event.clientY,
+ camera,
+ moved: false,
+ };
+ };
+
+ const onPointerMove = (event: ReactPointerEvent) => {
+ const current = drag.current;
+ if (!current) return;
+ const dx = event.clientX - current.x;
+ const dy = event.clientY - current.y;
+ if (!current.moved) {
+ if (Math.hypot(dx, dy) < 4) return;
+ current.moved = true;
+ // Captured only once it is a drag, so a plain click still reaches the topic.
+ event.currentTarget.setPointerCapture(event.pointerId);
+ setSmooth(false);
+ }
+ setManual({
+ ...current.camera,
+ x: current.camera.x + dx,
+ y: current.camera.y + dy,
+ });
+ };
+
+ const endDrag = () => {
+ drag.current = null;
+ };
+
+ const path = pathTo(focus);
+ const inFunnel = FUNNEL ? isDescendant(focus, FUNNEL) : false;
+ /** A funnel stage opens as a card with its page instead of the side panel. */
+ const stage = focus.node.opportunities ? focus.node : null;
+ const ready = size.width > 0;
+
+ return (
+
+
+ {leading}
+
+ {path.map((item, index) => {
+ const last = index === path.length - 1;
+ return (
+
+ {index > 0 && (
+
+ )}
+ select(item.node.id)}
+ aria-current={last ? "page" : undefined}
+ className={cn(
+ "rounded-md px-1.5 py-0.5 hover:bg-accent",
+ last
+ ? "font-medium text-foreground"
+ : "text-muted-foreground",
+ )}
+ >
+ {labelOf(item.node, store)}
+
+
+ );
+ })}
+
+ {!inFunnel && (
+
+ {t("plan.map.hint")}
+
+ )}
+
+
+ {inFunnel && FUNNEL && stage ? (
+
+
+
+ select(FUNNEL.node.id)}
+ />
+
+
+ ) : inFunnel && FUNNEL ? (
+
+
+
+ ) : (
+
+ {ready && (
+
+
+ {PLACED.filter((item) => item.parent).map((item) => (
+
+ ))}
+
+ {PLACED.map((item) => (
+ 0}
+ onSelect={select}
+ />
+ ))}
+
+ )}
+
event.stopPropagation()}
+ >
+ {STATUSES.map((status) => (
+
+
+ {t(STATUS_LABEL[status])}
+
+ ))}
+
+
+ {t("plan.map.soon")}
+
+
+
event.stopPropagation()}
+ >
+
zoomBy(1 / 1.25)}
+ >
+
+
+
+ {Math.round(camera.k * 100)}%
+
+
zoomBy(1.25)}
+ >
+
+
+
select("root")}
+ >
+
+
+
+
+ )}
+ {panelOpen && !stage && (
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/plan/stage-card.tsx b/apps/web/src/components/plan/stage-card.tsx
new file mode 100644
index 0000000000..16e6e19e26
--- /dev/null
+++ b/apps/web/src/components/plan/stage-card.tsx
@@ -0,0 +1,204 @@
+/**
+ * One funnel stage opened: the store's live page for it, framed the same way
+ * the heatmap frames it, beside what the agents found on that page.
+ */
+
+import { useState, type FormEvent } from "react";
+import { LinkExternal01, RefreshCw01, XClose } 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 { Spinner } from "@decocms/ui/components/spinner.tsx";
+import { cn } from "@decocms/ui/lib/utils.ts";
+import { usePreferences } from "@/hooks/use-preferences.ts";
+import { useT } from "@/i18n/use-t.ts";
+import { frameUrl } from "@/layouts/main-panel-tabs/analytics-heatmap-model.ts";
+import type { MapNode } from "./site-map-mock.ts";
+import { STATUS_BADGE, STATUS_LABEL, StatusDot } from "./status.tsx";
+
+/** The desktop width the heatmap renders at when it has no measured viewport. */
+const FRAME_WIDTH = 1440;
+
+function PagePreview({ origin, path }: { origin: string; path: string }) {
+ const t = useT();
+ const [box, setBox] = useState({ width: 0, height: 0 });
+ const [loaded, setLoaded] = useState(false);
+ const src = frameUrl(origin, path);
+
+ const measure = (element: HTMLDivElement | null) => {
+ if (!element) return;
+ const observer = new ResizeObserver(([entry]) => {
+ if (!entry) return;
+ setBox({
+ width: entry.contentRect.width,
+ height: entry.contentRect.height,
+ });
+ });
+ observer.observe(element);
+ return () => observer.disconnect();
+ };
+
+ const scale = box.width ? box.width / FRAME_WIDTH : 0;
+ return (
+
+ {src && scale > 0 && (
+
+ );
+}
+
+export function StageCard({
+ stage,
+ origin,
+ onClose,
+}: {
+ stage: MapNode;
+ /** The store's registered origin; null when the site has none. */
+ origin: string | null;
+ onClose: () => void;
+}) {
+ const t = useT();
+ const [{ language }] = usePreferences();
+ const [path, setPath] = useState(stage.path ?? "/");
+ const [draft, setDraft] = useState(path);
+ const opportunities = stage.opportunities ?? [];
+ const open = (event: FormEvent) => {
+ event.preventDefault();
+ const next = draft.trim();
+ setPath(next.startsWith("/") ? next : `/${next}`);
+ };
+ const live = origin ? new URL(path, origin).href : null;
+
+ return (
+
+
+
+
+ {stage.label}
+
+
+ {t("plan.funnel.sessionsPeriod", {
+ count: new Intl.NumberFormat(language).format(
+ stage.sessions ?? 0,
+ ),
+ })}
+
+
+ {origin && (
+
+ )}
+
+
+
+
+
+
+ {origin ? (
+
+ ) : (
+
+ {t("plan.stage.noDomain")}
+
+ )}
+
+
+
+ {t("plan.stage.opportunities", { count: opportunities.length })}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/plan/status.tsx b/apps/web/src/components/plan/status.tsx
new file mode 100644
index 0000000000..541798bf2b
--- /dev/null
+++ b/apps/web/src/components/plan/status.tsx
@@ -0,0 +1,46 @@
+import { cn } from "@decocms/ui/lib/utils.ts";
+import type { TranslationKey } from "@/i18n/use-t.ts";
+import type { CheckStatus } from "./site-map-mock.ts";
+
+export const STATUS_DOT: Record = {
+ missing: "bg-destructive",
+ partial: "bg-warning",
+ done: "bg-success",
+};
+export const STATUS_RING: Record = {
+ missing: "ring-destructive/20",
+ partial: "ring-warning/20",
+ done: "ring-success/20",
+};
+export const STATUS_BADGE: Record<
+ CheckStatus,
+ "destructive" | "warning" | "success"
+> = {
+ missing: "destructive",
+ partial: "warning",
+ done: "success",
+};
+export const STATUS_LABEL: Record = {
+ missing: "plan.map.status.missing",
+ partial: "plan.map.status.partial",
+ done: "plan.map.status.done",
+};
+export const STATUSES: CheckStatus[] = ["missing", "partial", "done"];
+
+export function StatusDot({
+ status,
+ className,
+}: {
+ status: CheckStatus;
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/i18n/en/home.ts b/apps/web/src/i18n/en/home.ts
index a9949c79e5..0a09f340f8 100644
--- a/apps/web/src/i18n/en/home.ts
+++ b/apps/web/src/i18n/en/home.ts
@@ -23,13 +23,6 @@ export const home = {
"home.needsYou.reasonAnswer": "Waiting on an answer",
"home.needsYou.reasonReview": "Needs review",
"home.needsYou.reasonAssigned": "Assigned to you",
- "home.comingSoon.badge": "Coming soon",
- "home.comingSoon.planHeading": "Goals your agents work toward",
- "home.comingSoon.planDescription":
- "Set an outcome and how it is measured. Agents turn it into tasks and report progress against it.",
- "home.comingSoon.planGoals": "Goals with a metric and a target",
- "home.comingSoon.planTasks": "Tasks proposed from each goal",
- "home.comingSoon.planProgress": "Progress you can read at a glance",
"home.brief.changeOne": "1 change",
"home.brief.changeMany": "{count} changes",
"home.brief.waitingOne": "1 thing needs you.",
diff --git a/apps/web/src/i18n/en/index.ts b/apps/web/src/i18n/en/index.ts
index cce77e9f55..1c5c09a3e5 100644
--- a/apps/web/src/i18n/en/index.ts
+++ b/apps/web/src/i18n/en/index.ts
@@ -50,6 +50,7 @@ import { sandbox } from "./sandbox.ts";
import { settings } from "./settings.ts";
import { announcements } from "./announcements.ts";
import { assets } from "./assets.ts";
+import { plan } from "./plan.ts";
// English is the source of truth: every domain file is spread here and
// TranslationKey is derived from the result. pt-BR mirrors this structure
@@ -105,6 +106,7 @@ export const en = {
...settings,
...announcements,
...assets,
+ ...plan,
...projects,
...page,
} as const;
diff --git a/apps/web/src/i18n/en/plan.ts b/apps/web/src/i18n/en/plan.ts
new file mode 100644
index 0000000000..1b55992809
--- /dev/null
+++ b/apps/web/src/i18n/en/plan.ts
@@ -0,0 +1,136 @@
+export const plan = {
+ "plan.start": "Start a plan",
+ "plan.heading": "Where should the agents look?",
+ "plan.description":
+ "Start from an idea, point at an area of the store, or let the agents analyze everything.",
+ "plan.idea.title": "I already have an idea",
+ "plan.idea.description":
+ "Describe what you want to change and the agents turn it into a plan.",
+ "plan.idea.placeholder":
+ "E.g. Show the delivery estimate on the product page before the shopper adds to cart.",
+ "plan.idea.action": "Create the plan",
+ "plan.direction.title": "I have a direction",
+ "plan.direction.description":
+ "Pick the areas of the store you care about and the agents look for opportunities there.",
+ "plan.direction.action": "Find opportunities",
+ "plan.full.title": "Full analysis",
+ "plan.full.description":
+ "The agents go through the whole store and deliver a complete report.",
+ "plan.full.pointFunnel": "Conversion funnel, from home to checkout",
+ "plan.full.pointPerformance": "Performance and Core Web Vitals",
+ "plan.full.pointSeo": "SEO and content",
+ "plan.full.pointPriorities": "Opportunities ranked by impact",
+ "plan.full.action": "Generate full report",
+ "plan.topic.shipping": "Shipping",
+ "plan.topic.pdp": "PDP",
+ "plan.topic.home": "Home",
+ "plan.topic.search": "Search",
+ "plan.topic.minicart": "Minicart",
+ "plan.topic.plp": "PLP",
+ "plan.topic.checkout": "Checkout",
+ "plan.topic.payment": "Payment",
+ "plan.topic.promotions": "Promotions",
+ "plan.topic.recommendations": "Recommendations",
+ "plan.topic.reviews": "Reviews",
+ "plan.topic.performance": "Performance",
+ "plan.topic.seo": "SEO",
+ "plan.topic.mobile": "Mobile",
+ "plan.topic.account": "Login and account",
+ "plan.topic.accessibility": "Accessibility",
+ "plan.map.breadcrumb": "Map location",
+ "plan.map.hint":
+ "Drag to move · Ctrl + scroll to zoom · Click a topic to open it",
+ "plan.map.status.missing": "Not done",
+ "plan.map.status.partial": "Partial",
+ "plan.map.status.done": "Correct",
+ "plan.map.soon": "Coming soon",
+ "plan.map.soonDescription": "The agents do not analyze this area yet.",
+ "plan.map.doneDescription": "Nothing to fix here.",
+ "plan.map.createTask": "Create task",
+ "plan.map.revalidate": "Validate again",
+ "plan.map.zoomIn": "Zoom in",
+ "plan.map.zoomOut": "Zoom out",
+ "plan.map.fit": "Show the whole map",
+ "plan.funnel.conversion": "Conversion rate · last 30 days",
+ "plan.funnel.summary": "{orders} orders from {sessions} sessions",
+ "plan.funnel.sessionsPeriod": "{count} sessions in 30 days",
+ "plan.funnel.ofTotal": "{rate} of the total",
+ "plan.funnel.advance": "{rate} move on",
+ "plan.funnel.biggestLoss": "Biggest loss: {rate} drop off",
+ "plan.funnel.orders": "Orders",
+ "plan.funnel.ordersPeriod": "{count} orders in 30 days",
+ "plan.stage.frameTitle": "Live page of this stage",
+ "plan.stage.loading": "Loading the page…",
+ "plan.stage.invalidPath":
+ "This path cannot be opened. Use a page of the store, starting with /.",
+ "plan.stage.path": "Page path",
+ "plan.stage.openLive": "Open the page in a new tab",
+ "plan.stage.close": "Close",
+ "plan.stage.noDomain":
+ "The site has no domain registered in Deco Analytics, so its page cannot be shown here.",
+ "plan.stage.opportunities": "Opportunities on this page ({count})",
+ "plan.view.map": "Map",
+ "plan.view.goals": "Goals",
+ "plan.goals.formTitle": "What is your goal?",
+ "plan.goals.formDescription":
+ "Fill in your web revenue for the last years and how much you want to make this year and next. The agents prioritize the plan from it.",
+ "plan.goals.actual": "Actual",
+ "plan.goals.target": "Goal",
+ "plan.goals.placeholder": "R$ millions",
+ "plan.goals.actualFor": "Actual revenue in {year}",
+ "plan.goals.targetFor": "Goal for {year}",
+ "plan.goals.formHint":
+ "Values in millions of reais. For {year}, actual is the total so far.",
+ "plan.goals.example": "Use example values",
+ "plan.goals.save": "See the timeline",
+ "plan.goals.chartTitle": "Web revenue growth",
+ "plan.goals.tooltipActual": "Actual {value}",
+ "plan.goals.tooltipTarget": "Goal {value}",
+ "plan.goals.growth": "Growth",
+ "plan.goals.plannedGrowth": "{rate} (goal)",
+ "plan.goals.sessions": "Sessions per month",
+ "plan.goals.conversion": "Conversion rate",
+ "plan.goals.ticket": "Average ticket",
+ "plan.goals.timelineTitle": "Timeline",
+ "plan.goals.today": "Today · conversion {conversion}",
+ "plan.goals.progress": "{actual} of {target}",
+ "plan.goals.edit": "Edit goals",
+ "plan.goals.onTrack": "On track for the goal",
+ "plan.goals.gap": "{value} short",
+ "plan.goals.reset": "Back to today's values",
+ "plan.goals.leversIntro": "Move the levers to simulate",
+ "plan.goals.setGoals": "Set goals",
+ "plan.goals.editGoals": "Edit goals",
+ "plan.goals.goalsSet": "Goals set",
+ "plan.goals.yourGoals": "Your goals for this month",
+ "plan.goals.timelineHint":
+ "Click a month to simulate the levers and set goals for it.",
+ "plan.goals.addYear": "Add {year}",
+ "plan.goals.removeYear": "Remove {year}",
+ "plan.goals.monthGoal": "Goal for {month}",
+ "plan.goals.yearToDate": "Year through {month}: {actual} of {target}",
+ "plan.goals.splitTitle": "Goal per month",
+ "plan.goals.splitDescription":
+ "Split the year's goal across the months. Sales are not even: November sells more because of Black Friday.",
+ "plan.goals.splitRest": "{year} (rest of year)",
+ "plan.goals.splitSum": "Split {sum} of {target}",
+ "plan.goals.splitMatches": "Matches the goal",
+ "plan.goals.splitShort": "{value} short of the goal",
+ "plan.goals.splitOver": "{value} over the goal",
+ "plan.goals.splitSeasonal": "Typical seasonality",
+ "plan.goals.splitEven": "Same every month",
+ "plan.goals.scaleMonth": "Month",
+ "plan.goals.scaleYear": "Year",
+ "plan.goals.timelineYearHint":
+ "Simulate the whole year with the levers, or click a month to adjust it.",
+ "plan.goals.yearGoal": "Goal for {year}",
+ "plan.goals.bankedSoFar": "{value} already made this year",
+ "plan.goals.yearSessions": "Sessions in the period",
+ "plan.goals.averageConversion": "Average conversion",
+ "plan.goals.byMonth": "By month",
+ "plan.goals.projection": "Projection",
+ "plan.goals.monthBar": "{month}: {projected} projected, goal {goal}",
+ "plan.goals.monthsWithGoals": "{count} of {total} months with goals set",
+ "plan.goals.yearLeversIntro":
+ "Move the year's levers: every month moves by the same ratio",
+} as const;
diff --git a/apps/web/src/i18n/pt-br/home.ts b/apps/web/src/i18n/pt-br/home.ts
index 280d2faf5e..a0f91e8970 100644
--- a/apps/web/src/i18n/pt-br/home.ts
+++ b/apps/web/src/i18n/pt-br/home.ts
@@ -25,13 +25,6 @@ export const home = {
"home.needsYou.reasonAnswer": "Esperando resposta",
"home.needsYou.reasonReview": "Precisa de revis\u00e3o",
"home.needsYou.reasonAssigned": "Atribu\u00edda a voc\u00ea",
- "home.comingSoon.badge": "Em breve",
- "home.comingSoon.planHeading": "Metas para os seus agentes perseguirem",
- "home.comingSoon.planDescription":
- "Defina um resultado e como ele é medido. Os agentes transformam isso em tarefas e reportam o progresso.",
- "home.comingSoon.planGoals": "Metas com métrica e alvo",
- "home.comingSoon.planTasks": "Tarefas propostas a partir de cada meta",
- "home.comingSoon.planProgress": "Progresso que se lê de relance",
"home.brief.changeOne": "1 mudança",
"home.brief.changeMany": "{count} mudanças",
"home.brief.waitingOne": "1 coisa espera por você.",
diff --git a/apps/web/src/i18n/pt-br/index.ts b/apps/web/src/i18n/pt-br/index.ts
index 64da9ab887..d4775afab0 100644
--- a/apps/web/src/i18n/pt-br/index.ts
+++ b/apps/web/src/i18n/pt-br/index.ts
@@ -50,6 +50,7 @@ import { sandbox } from "./sandbox.ts";
import { settings } from "./settings.ts";
import { announcements } from "./announcements.ts";
import { assets } from "./assets.ts";
+import { plan } from "./plan.ts";
import type { TranslationKey } from "../en/index.ts";
export const ptBR = {
@@ -103,6 +104,7 @@ export const ptBR = {
...settings,
...announcements,
...assets,
+ ...plan,
...projects,
...page,
} satisfies Record;
diff --git a/apps/web/src/i18n/pt-br/plan.ts b/apps/web/src/i18n/pt-br/plan.ts
new file mode 100644
index 0000000000..ce5a4e4627
--- /dev/null
+++ b/apps/web/src/i18n/pt-br/plan.ts
@@ -0,0 +1,138 @@
+import type { plan as enPlan } from "../en/plan.ts";
+
+export const plan = {
+ "plan.start": "Iniciar um plano",
+ "plan.heading": "Onde os agentes devem olhar?",
+ "plan.description":
+ "Comece por uma ideia, aponte uma área da loja ou deixe os agentes analisarem tudo.",
+ "plan.idea.title": "Já tenho uma ideia",
+ "plan.idea.description":
+ "Descreva o que você quer mudar e os agentes transformam isso em um plano.",
+ "plan.idea.placeholder":
+ "Ex.: Mostrar o prazo de entrega na página de produto antes de adicionar ao carrinho.",
+ "plan.idea.action": "Criar o plano",
+ "plan.direction.title": "Tenho uma direção",
+ "plan.direction.description":
+ "Escolha as áreas da loja que importam e os agentes procuram oportunidades nelas.",
+ "plan.direction.action": "Procurar oportunidades",
+ "plan.full.title": "Análise completa",
+ "plan.full.description":
+ "Os agentes percorrem a loja inteira e entregam um report completo.",
+ "plan.full.pointFunnel": "Funil de conversão, da home ao checkout",
+ "plan.full.pointPerformance": "Performance e Core Web Vitals",
+ "plan.full.pointSeo": "SEO e conteúdo",
+ "plan.full.pointPriorities": "Oportunidades priorizadas por impacto",
+ "plan.full.action": "Gerar report completo",
+ "plan.topic.shipping": "Frete",
+ "plan.topic.pdp": "PDP",
+ "plan.topic.home": "Home",
+ "plan.topic.search": "Busca",
+ "plan.topic.minicart": "Minicart",
+ "plan.topic.plp": "PLP",
+ "plan.topic.checkout": "Checkout",
+ "plan.topic.payment": "Pagamento",
+ "plan.topic.promotions": "Promoções",
+ "plan.topic.recommendations": "Recomendações",
+ "plan.topic.reviews": "Avaliações",
+ "plan.topic.performance": "Performance",
+ "plan.topic.seo": "SEO",
+ "plan.topic.mobile": "Mobile",
+ "plan.topic.account": "Login e conta",
+ "plan.topic.accessibility": "Acessibilidade",
+ "plan.map.breadcrumb": "Posição no mapa",
+ "plan.map.hint":
+ "Arraste para mover · Ctrl + scroll para zoom · Clique num tópico para entrar",
+ "plan.map.status.missing": "Não feito",
+ "plan.map.status.partial": "Parcial",
+ "plan.map.status.done": "Correto",
+ "plan.map.soon": "Em breve",
+ "plan.map.soonDescription": "Os agentes ainda não analisam esta área.",
+ "plan.map.doneDescription": "Nada a corrigir aqui.",
+ "plan.map.createTask": "Criar tarefa",
+ "plan.map.revalidate": "Validar novamente",
+ "plan.map.zoomIn": "Aumentar zoom",
+ "plan.map.zoomOut": "Diminuir zoom",
+ "plan.map.fit": "Ver o mapa inteiro",
+ "plan.funnel.conversion": "Taxa de conversão · últimos 30 dias",
+ "plan.funnel.summary": "{orders} pedidos de {sessions} sessões",
+ "plan.funnel.sessionsPeriod": "{count} sessões em 30 dias",
+ "plan.funnel.ofTotal": "{rate} do total",
+ "plan.funnel.advance": "{rate} avançam",
+ "plan.funnel.biggestLoss": "Maior perda: {rate} saem",
+ "plan.funnel.orders": "Pedidos",
+ "plan.funnel.ordersPeriod": "{count} pedidos em 30 dias",
+ "plan.stage.frameTitle": "Página ao vivo desta etapa",
+ "plan.stage.loading": "Carregando a página…",
+ "plan.stage.invalidPath":
+ "Não dá para abrir este caminho. Use uma página da loja, começando com /.",
+ "plan.stage.path": "Caminho da página",
+ "plan.stage.openLive": "Abrir a página em nova aba",
+ "plan.stage.close": "Fechar",
+ "plan.stage.noDomain":
+ "O site não tem domínio registrado no Deco Analytics, então a página não pode ser mostrada aqui.",
+ "plan.stage.opportunities": "Oportunidades nesta página ({count})",
+ "plan.view.map": "Mapa",
+ "plan.view.goals": "Metas",
+ "plan.goals.formTitle": "Qual é a sua meta?",
+ "plan.goals.formDescription":
+ "Preencha o faturamento web dos últimos anos e quanto você quer faturar neste ano e no próximo. Os agentes priorizam o plano a partir disso.",
+ "plan.goals.actual": "Realizado",
+ "plan.goals.target": "Meta",
+ "plan.goals.placeholder": "R$ milhões",
+ "plan.goals.actualFor": "Faturamento realizado em {year}",
+ "plan.goals.targetFor": "Meta para {year}",
+ "plan.goals.formHint":
+ "Valores em milhões de reais. Em {year}, o realizado é o acumulado até agora.",
+ "plan.goals.example": "Usar valores de exemplo",
+ "plan.goals.save": "Ver linha do tempo",
+ "plan.goals.chartTitle": "Crescimento do faturamento web",
+ "plan.goals.tooltipActual": "Realizado {value}",
+ "plan.goals.tooltipTarget": "Meta {value}",
+ "plan.goals.growth": "Crescimento",
+ "plan.goals.plannedGrowth": "{rate} (meta)",
+ "plan.goals.sessions": "Sessões por mês",
+ "plan.goals.conversion": "Taxa de conversão",
+ "plan.goals.ticket": "Ticket médio",
+ "plan.goals.timelineTitle": "Linha do tempo",
+ "plan.goals.today": "Hoje · conversão {conversion}",
+ "plan.goals.progress": "{actual} de {target}",
+ "plan.goals.edit": "Editar metas",
+ "plan.goals.onTrack": "Bate a meta",
+ "plan.goals.gap": "Faltam {value}",
+ "plan.goals.reset": "Voltar aos valores de hoje",
+ "plan.goals.leversIntro": "Mexa nas alavancas para simular",
+ "plan.goals.setGoals": "Definir metas",
+ "plan.goals.editGoals": "Editar metas",
+ "plan.goals.goalsSet": "Metas definidas",
+ "plan.goals.yourGoals": "Suas metas para este mês",
+ "plan.goals.timelineHint":
+ "Clique num mês para simular as alavancas e definir metas para ele.",
+ "plan.goals.addYear": "Adicionar {year}",
+ "plan.goals.removeYear": "Remover {year}",
+ "plan.goals.monthGoal": "Meta de {month}",
+ "plan.goals.yearToDate": "No ano até {month}: {actual} de {target}",
+ "plan.goals.splitTitle": "Meta por mês",
+ "plan.goals.splitDescription":
+ "Distribua a meta do ano entre os meses. A venda não é igual: novembro vende mais por causa da Black Friday.",
+ "plan.goals.splitRest": "{year} (restante)",
+ "plan.goals.splitSum": "Distribuído {sum} de {target}",
+ "plan.goals.splitMatches": "Fecha com a meta",
+ "plan.goals.splitShort": "Faltam {value} para a meta",
+ "plan.goals.splitOver": "Passa {value} da meta",
+ "plan.goals.splitSeasonal": "Sazonalidade típica",
+ "plan.goals.splitEven": "Igual todo mês",
+ "plan.goals.scaleMonth": "Mês",
+ "plan.goals.scaleYear": "Ano",
+ "plan.goals.timelineYearHint":
+ "Simule o ano inteiro nas alavancas ou clique num mês para ajustá-lo.",
+ "plan.goals.yearGoal": "Meta de {year}",
+ "plan.goals.bankedSoFar": "{value} já faturados no ano",
+ "plan.goals.yearSessions": "Sessões no período",
+ "plan.goals.averageConversion": "Conversão média",
+ "plan.goals.byMonth": "Por mês",
+ "plan.goals.projection": "Projeção",
+ "plan.goals.monthBar": "{month}: {projected} projetado, meta {goal}",
+ "plan.goals.monthsWithGoals": "{count} de {total} meses com metas definidas",
+ "plan.goals.yearLeversIntro":
+ "Mexa nas alavancas do ano: todos os meses mudam na mesma proporção",
+} satisfies Record;
diff --git a/apps/web/src/routes/workspace/plan.tsx b/apps/web/src/routes/workspace/plan.tsx
index a3bb0a78f6..4c47e88d46 100644
--- a/apps/web/src/routes/workspace/plan.tsx
+++ b/apps/web/src/routes/workspace/plan.tsx
@@ -1,10 +1,10 @@
import { ChatLayout } from "@/components/chat-layout";
-import { ComingSoonPage } from "@/components/coming-soon/coming-soon-page";
+import { PlanPage } from "@/components/plan/plan-page";
export default function PlanRoute() {
return (
-
+
);
}